randomayzer/src/providers/vk/vk-provider.ts

264 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { PlatformType, PostMetadata } from '../../core/types/giveaway';
import { RawParticipant } from '../../core/types/participant';
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
import { parseVkPostUrl } from './vk-parser';
import { IVkClient, defaultVkClient, fetchPaginatedVk } from '@/integrations/vk/vk-client';
import { VkAuthContext } from '@/integrations/vk/vk-types';
import { createServiceAuth } from '@/integrations/vk/vk-auth';
import {
VkWallPost,
VkUserProfile,
VkGroupProfile,
VkLikesGetListResponse,
VkWallGetCommentsResponse,
VkIsMemberItem
} from '@/integrations/vk/vk-types';
import { VkNotFoundError, VkAuthError } from '@/integrations/vk/vk-errors';
export class VkProvider implements SocialMediaProvider {
readonly platform: PlatformType = 'VK';
readonly capabilities: ProviderCapabilities = {
likes: true,
comments: true,
reposts: false,
repostsNote: 'Сбор репостов ограничен политикой приватности VK для закрытых профилей',
subscriptions: true,
adminDetection: false,
adminDetectionNote: 'Требует расширенных прав администратора группы',
};
private readonly client: IVkClient;
private readonly authContext: VkAuthContext;
constructor(serviceToken?: string, client?: IVkClient) {
const token = serviceToken || process.env.VK_SERVICE_TOKEN;
if (!token) {
// In tests or unconfigured environments, create a dummy context that will be validated on call
this.authContext = createServiceAuth('');
} else {
this.authContext = createServiceAuth(token);
}
this.client = client || defaultVkClient;
}
public parsePostUrl(url: string): { ownerId: string; postId: string } | null {
return parseVkPostUrl(url);
}
private ensureConfigured(): void {
if (!this.authContext.token) {
throw new VkAuthError('VK_SERVICE_TOKEN is not configured in environment variables');
}
}
async fetchPost(url: string): Promise<PostMetadata> {
this.ensureConfigured();
const parsed = this.parsePostUrl(url);
if (!parsed) {
throw new VkNotFoundError('Invalid VK post URL format');
}
const { ownerId, postId } = parsed;
const response = await this.client.call<{
items: VkWallPost[];
profiles?: VkUserProfile[];
groups?: VkGroupProfile[];
}>('wall.getById', {
posts: `${ownerId}_${postId}`,
extended: 1,
}, this.authContext);
if (!response.items || response.items.length === 0) {
throw new VkNotFoundError(`Post "${ownerId}_${postId}" not found or access is restricted`);
}
const post = response.items[0];
let authorName = `VK Wall ${ownerId}`;
let authorAvatarUrl: string | undefined = undefined;
if (ownerId.startsWith('-')) {
const groupId = Math.abs(parseInt(ownerId, 10));
const group = (response.groups || []).find(g => g.id === groupId);
if (group) {
authorName = group.name;
authorAvatarUrl = group.photo_50;
}
} else {
const userId = parseInt(ownerId, 10);
const profile = (response.profiles || []).find(p => p.id === userId);
if (profile) {
authorName = `${profile.first_name} ${profile.last_name}`;
authorAvatarUrl = profile.photo_100 || profile.photo_200;
}
}
let imageUrl: string | undefined = undefined;
if (post.attachments && post.attachments.length > 0) {
const photoAttachment = post.attachments.find(a => a.type === 'photo');
if (photoAttachment && photoAttachment.photo && photoAttachment.photo.sizes) {
const sizes = photoAttachment.photo.sizes;
imageUrl = sizes[sizes.length - 1]?.url;
}
}
const textContent = String(post.text || '');
return {
platform: 'VK',
ownerId,
postId,
sourceUrl: url,
title: textContent ? textContent.slice(0, 80) + '...' : `Запись на стене ${ownerId}_${postId}`,
text: textContent,
authorName,
authorAvatarUrl,
imageUrl,
likesCount: post.likes?.count || 0,
commentsCount: post.comments?.count || 0,
repostsCount: post.reposts?.count || 0,
publishedAt: post.date ? new Date(post.date * 1000) : undefined,
};
}
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
this.ensureConfigured();
const { ownerId, postId } = params;
const participantsMap = new Map<string, RawParticipant>();
// 1. Fetch Likes with full pagination
if (params.includeLikes !== false) {
const likeProfiles = await fetchPaginatedVk<VkUserProfile>({
pageSize: 100,
fetchPage: async (offset, count) => {
const res = await this.client.call<VkLikesGetListResponse>('likes.getList', {
type: 'post',
owner_id: ownerId,
item_id: postId,
filter: 'likes',
extended: 1,
count,
offset,
}, this.authContext);
return {
items: (res.items || []) as VkUserProfile[],
totalCount: res.count,
};
},
onProgress: (loaded, total) => {
if (params.onProgress) {
params.onProgress(loaded, total ?? loaded, 'Загрузка лайков...');
}
},
});
for (const item of likeProfiles) {
const userId = String(item.id);
participantsMap.set(userId, {
platformUserId: userId,
firstName: item.first_name || '',
lastName: item.last_name || '',
username: item.screen_name || undefined,
avatarUrl: item.photo_100 || item.photo_200,
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: false,
});
}
}
// 2. Fetch Comments with full pagination and author profile mapping
if (params.includeComments) {
await fetchPaginatedVk<{ from_id: number }>({
pageSize: 100,
fetchPage: async (offset, count) => {
const res = await this.client.call<VkWallGetCommentsResponse>('wall.getComments', {
owner_id: ownerId,
post_id: postId,
extended: 1,
count,
offset,
fields: 'photo_100,photo_200,screen_name',
}, this.authContext);
const profileMap = new Map<number, VkUserProfile>(
(res.profiles || []).map(p => [p.id, p])
);
for (const item of res.items || []) {
if (item.from_id && item.from_id > 0) {
const userId = String(item.from_id);
const prof = profileMap.get(item.from_id);
const existing = participantsMap.get(userId);
if (existing) {
existing.commented = true;
existing.commentsCount = (existing.commentsCount || 0) + 1;
} else {
participantsMap.set(userId, {
platformUserId: userId,
firstName: prof?.first_name || 'Участник',
lastName: prof?.last_name || userId,
username: prof?.screen_name,
avatarUrl: prof?.photo_100,
source: 'COMMENTS',
liked: false,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: false,
});
}
}
}
return {
items: res.items || [],
totalCount: res.count,
};
},
onProgress: () => {
if (params.onProgress) {
params.onProgress(participantsMap.size, participantsMap.size, 'Загрузка комментариев...');
}
},
});
}
return Array.from(participantsMap.values());
}
async checkSubscription(userIds: string[], groupId: string): Promise<Map<string, boolean>> {
this.ensureConfigured();
const cleanGroupId = groupId.replace(/^-/, '');
const resultMap = new Map<string, boolean>();
// VK API groups.isMember allows up to 500 user_ids per batch call
const chunkSize = 500;
for (let i = 0; i < userIds.length; i += chunkSize) {
const chunk = userIds.slice(i, i + chunkSize);
const res = await this.client.call<VkIsMemberItem[]>(
'groups.isMember',
{
group_id: cleanGroupId,
user_ids: chunk.join(','),
},
this.authContext
);
for (const item of res || []) {
resultMap.set(String(item.user_id), item.member === 1);
}
}
return resultMap;
}
}