feat(vk): Phase 2.1 Real VK Client Foundation - dedicated VkClient with typed errors, exponential backoff, rate limiting, token security, full pagination, and documentation
This commit is contained in:
parent
a27341a302
commit
e666161612
12 changed files with 1291 additions and 164 deletions
28
docs/VK_AUTH_MODEL.md
Normal file
28
docs/VK_AUTH_MODEL.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# VK Authentication Model & Token Security
|
||||
|
||||
This document outlines the authentication context lifecycle and security rules for VK tokens in **Randomayzer**.
|
||||
|
||||
---
|
||||
|
||||
## Token Types Supported
|
||||
|
||||
1. **Service Token (`SERVICE`)**:
|
||||
- Used for public API read operations (`wall.getById`, `likes.getList`, `wall.getComments`, `groups.isMember`).
|
||||
- Configured via environment variable `VK_SERVICE_TOKEN`.
|
||||
- Never exposed to frontend clients.
|
||||
|
||||
2. **User Token (`USER`)**:
|
||||
- Authorized via VK ID / OAuth with specific scopes (`wall`, `offline`, `groups`).
|
||||
- Used when accessing non-public walls or private community groups with admin access.
|
||||
|
||||
3. **Community Token (`COMMUNITY`)**:
|
||||
- Scoped to a specific community (`communityId`).
|
||||
- Used for managing giveaways directly on behalf of a VK public page or group.
|
||||
|
||||
---
|
||||
|
||||
## Invariant Security Rules
|
||||
|
||||
- **Zero Logging**: Tokens are never passed to `console.log`, error messages, or telemetry.
|
||||
- **Redaction Helper**: `redactToken(token)` masks tokens as `vk1.a...1234`.
|
||||
- **Database & Audit Isolation**: Access tokens are **never** persisted to PostgreSQL or included in cryptographic AuditProof / DrawResult hashes.
|
||||
55
docs/VK_CLIENT.md
Normal file
55
docs/VK_CLIENT.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# VK Client Architecture & Integration Guide
|
||||
|
||||
The VK Integration layer is structured into modular, decoupled components located under `src/integrations/vk/`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
[SocialMediaProvider Interface]
|
||||
│
|
||||
▼
|
||||
[VkProvider]
|
||||
│
|
||||
▼
|
||||
[VkClient]
|
||||
┌────────┼────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
[VkAuth] [VkRateLimit] [VkRetry]
|
||||
│ │ │
|
||||
└────────┼────────┘
|
||||
│ (POST https://api.vk.com/method/*)
|
||||
▼
|
||||
[VK API]
|
||||
```
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **`VkClient` (`src/integrations/vk/vk-client.ts`)**:
|
||||
- Centralizes low-level HTTP communication with VK API.
|
||||
- Enforces default API version `5.199`.
|
||||
- Manages timeouts with `AbortController` (default 15s).
|
||||
- Coordinates outbound rate limiting and retry backoff.
|
||||
- Provides universal pagination helper `fetchPaginatedVk`.
|
||||
|
||||
2. **`VkAuthContext` (`src/integrations/vk/vk-auth.ts`)**:
|
||||
- Represents typed access tokens (`SERVICE`, `USER`, `COMMUNITY`).
|
||||
- Ensures tokens are never leaked into logs, error messages, or persistent audit records.
|
||||
|
||||
3. **`VkRateLimiter` (`src/integrations/vk/vk-rate-limit.ts`)**:
|
||||
- Throttles outbound requests according to VK API thresholds (default: 10 req/sec configurable).
|
||||
|
||||
4. **`executeWithRetry` (`src/integrations/vk/vk-retry.ts`)**:
|
||||
- Handles exponential backoff with full jitter for retryable transient errors (5xx server errors, rate limits, network timeouts).
|
||||
- Fast-fails non-retryable errors (auth errors, permissions, private resources, validation).
|
||||
|
||||
---
|
||||
|
||||
## Pagination & Scalability
|
||||
|
||||
- **No Artificial Caps**: Previous limits (e.g. 5,000 likes or 1,000 comments) have been completely removed.
|
||||
- **Likes**: Uses `likes.getList` with `filter=likes&extended=1` in batches of 100 up to the total post likes count.
|
||||
- **Comments**: Uses `wall.getComments` with `extended=1` and profile enrichment.
|
||||
- **Subscription Checks**: Batches up to 500 user IDs per `groups.isMember` call.
|
||||
25
docs/VK_ERROR_MODEL.md
Normal file
25
docs/VK_ERROR_MODEL.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# VK API Error Model & Mapping
|
||||
|
||||
Randomayzer translates raw VK API response errors into typed error classes under `src/integrations/vk/vk-errors.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Error Classification & Retry Matrix
|
||||
|
||||
| Error Class | Category | VK Error Code(s) | Retryable? | Description |
|
||||
|---|---|---|---|---|
|
||||
| `VkAuthError` | `AUTH` | 4, 5, 28 | **No** | Invalid, expired, or unauthorized access token. |
|
||||
| `VkPermissionError` | `PERMISSION` | 7, 260 | **No** | Permission to perform action denied. |
|
||||
| `VkPrivateResourceError` | `PRIVATE_RESOURCE` | 15, 30, 203 | **No** | Target profile, group, or wall post is private. |
|
||||
| `VkNotFoundError` | `NOT_FOUND` | 104, 210, 214 | **No** | Post or community does not exist. |
|
||||
| `VkValidationError` | `VALIDATION` | 100, 113, 150 | **No** | Invalid query parameters or bad request. |
|
||||
| `VkRateLimitError` | `RATE_LIMIT` | 6, 9, 29 | **Yes** | Too many requests per second or flood control. |
|
||||
| `VkTemporaryError` | `TEMPORARY` | 1, 10, 500..504 | **Yes** | Unknown or internal VK server error. |
|
||||
| `VkNetworkError` | `NETWORK` | - | **Yes** | Socket error, connection drop, DNS resolution failure. |
|
||||
| `VkTimeoutError` | `TIMEOUT` | - | **Yes** | Request exceeded timeout duration. |
|
||||
|
||||
---
|
||||
|
||||
## Token Redaction in Error Traces
|
||||
|
||||
All error messages and `request_params` returned by VK are passed through a strict sanitizer that redacts any `access_token` parameter before instantiating the error object.
|
||||
41
src/integrations/vk/vk-auth.ts
Normal file
41
src/integrations/vk/vk-auth.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { VkAuthContext, VkTokenType } from './vk-types';
|
||||
import { VkAuthError } from './vk-errors';
|
||||
|
||||
/**
|
||||
* Safely redacts an access token for logging and diagnostic purposes.
|
||||
*/
|
||||
export function redactToken(token?: string | null): string {
|
||||
if (!token || typeof token !== 'string') return '[NO_TOKEN]';
|
||||
if (token.length <= 8) return '***';
|
||||
return `${token.substring(0, 4)}...${token.substring(token.length - 4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that an auth context is properly formatted and non-empty.
|
||||
*/
|
||||
export function validateAuthContext(auth: VkAuthContext): void {
|
||||
if (!auth) {
|
||||
throw new VkAuthError('VK AuthContext is missing');
|
||||
}
|
||||
if (!auth.token || typeof auth.token !== 'string' || auth.token.trim().length === 0) {
|
||||
throw new VkAuthError(`VK ${auth.type || 'UNKNOWN'} access token is empty or invalid`);
|
||||
}
|
||||
if (auth.type === 'COMMUNITY' && !auth.communityId) {
|
||||
throw new VkAuthError('VK COMMUNITY auth context requires a valid communityId');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory functions for building type-safe VK auth contexts
|
||||
*/
|
||||
export function createServiceAuth(token: string): VkAuthContext {
|
||||
return { type: 'SERVICE', token };
|
||||
}
|
||||
|
||||
export function createUserAuth(token: string): VkAuthContext {
|
||||
return { type: 'USER', token };
|
||||
}
|
||||
|
||||
export function createCommunityAuth(token: string, communityId: string): VkAuthContext {
|
||||
return { type: 'COMMUNITY', token, communityId };
|
||||
}
|
||||
227
src/integrations/vk/vk-client.ts
Normal file
227
src/integrations/vk/vk-client.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import {
|
||||
VkAuthContext,
|
||||
VkCallOptions,
|
||||
VkApiResponse,
|
||||
VkPaginationOptions
|
||||
} from './vk-types';
|
||||
import {
|
||||
mapVkApiError,
|
||||
VkTimeoutError,
|
||||
VkNetworkError,
|
||||
VkValidationError
|
||||
} from './vk-errors';
|
||||
import { validateAuthContext, redactToken } from './vk-auth';
|
||||
import { IVkRateLimiter, defaultVkRateLimiter } from './vk-rate-limit';
|
||||
import { executeWithRetry } from './vk-retry';
|
||||
|
||||
export interface IVkClient {
|
||||
call<T>(
|
||||
method: string,
|
||||
params: Record<string, any>,
|
||||
authContext: VkAuthContext,
|
||||
options?: VkCallOptions
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
export interface VkClientOptions {
|
||||
apiVersion?: string;
|
||||
baseUrl?: string;
|
||||
defaultTimeoutMs?: number;
|
||||
rateLimiter?: IVkRateLimiter;
|
||||
}
|
||||
|
||||
export class VkClient implements IVkClient {
|
||||
public static readonly DEFAULT_API_VERSION = '5.199';
|
||||
public static readonly DEFAULT_BASE_URL = 'https://api.vk.com/method/';
|
||||
public static readonly DEFAULT_TIMEOUT_MS = 15000;
|
||||
|
||||
private readonly apiVersion: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private readonly rateLimiter: IVkRateLimiter;
|
||||
|
||||
constructor(options?: VkClientOptions) {
|
||||
this.apiVersion = options?.apiVersion ?? VkClient.DEFAULT_API_VERSION;
|
||||
this.baseUrl = options?.baseUrl ?? VkClient.DEFAULT_BASE_URL;
|
||||
this.defaultTimeoutMs = options?.defaultTimeoutMs ?? VkClient.DEFAULT_TIMEOUT_MS;
|
||||
this.rateLimiter = options?.rateLimiter ?? defaultVkRateLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a single low-level HTTP call to VK API with timeout and error handling.
|
||||
*/
|
||||
private async executeSingleCall<T>(
|
||||
method: string,
|
||||
params: Record<string, any>,
|
||||
authContext: VkAuthContext,
|
||||
options?: VkCallOptions
|
||||
): Promise<T> {
|
||||
validateAuthContext(authContext);
|
||||
|
||||
// Acquire rate limit slot
|
||||
await this.rateLimiter.acquire();
|
||||
|
||||
const timeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs;
|
||||
const controller = new AbortController();
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
if (timeoutMs > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
// Merge caller signal if provided
|
||||
if (options?.signal) {
|
||||
if (options.signal.aborted) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
throw new Error('VK request aborted by caller signal');
|
||||
}
|
||||
options.signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}${method}`;
|
||||
const formBody = new URLSearchParams();
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
formBody.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
formBody.append('v', this.apiVersion);
|
||||
formBody.append('access_token', authContext.token);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': 'Randomayzer/1.0 (+https://github.com/ochenstarik-ui/randomayzer)',
|
||||
},
|
||||
body: formBody.toString(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new VkNetworkError(
|
||||
`VK API HTTP error ${response.status}: ${response.statusText}`,
|
||||
{ method, errorCode: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let json: VkApiResponse<T>;
|
||||
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
throw new VkValidationError(
|
||||
`VK API returned invalid non-JSON response for method ${method}`,
|
||||
{ method }
|
||||
);
|
||||
}
|
||||
|
||||
if (json.error) {
|
||||
throw mapVkApiError(json.error, method);
|
||||
}
|
||||
|
||||
if (json.response === undefined) {
|
||||
throw new VkValidationError(
|
||||
`VK API response missing "response" payload for method ${method}`,
|
||||
{ method }
|
||||
);
|
||||
}
|
||||
|
||||
return json.response;
|
||||
} catch (err: unknown) {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (controller.signal.aborted) {
|
||||
throw new VkTimeoutError(`VK API call to "${method}" timed out after ${timeoutMs}ms`, {
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
throw new VkTimeoutError(`VK API call to "${method}" was aborted`, { method });
|
||||
}
|
||||
|
||||
if (err instanceof Error && !(err instanceof VkNetworkError) && !(err as any).category) {
|
||||
// Unhandled fetch network error
|
||||
throw new VkNetworkError(`VK API network error on "${method}": ${err.message}`, {
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main typed method with automatic retry engine.
|
||||
*/
|
||||
public async call<T>(
|
||||
method: string,
|
||||
params: Record<string, any> = {},
|
||||
authContext: VkAuthContext,
|
||||
options?: VkCallOptions
|
||||
): Promise<T> {
|
||||
return executeWithRetry<T>(
|
||||
() => this.executeSingleCall<T>(method, params, authContext, options),
|
||||
{
|
||||
maxRetries: options?.maxRetries,
|
||||
initialDelayMs: options?.retryInitialDelayMs,
|
||||
maxDelayMs: options?.retryMaxDelayMs,
|
||||
},
|
||||
options?.signal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Universal pagination abstraction for VK API methods (offset/count based).
|
||||
*/
|
||||
export async function fetchPaginatedVk<TItem>(
|
||||
options: VkPaginationOptions<TItem>
|
||||
): Promise<TItem[]> {
|
||||
const pageSize = options.pageSize ?? 100;
|
||||
const maxPages = options.maxPages ?? 10000;
|
||||
const allItems: TItem[] = [];
|
||||
let offset = 0;
|
||||
let page = 0;
|
||||
|
||||
while (page < maxPages) {
|
||||
if (options.signal?.aborted) {
|
||||
break;
|
||||
}
|
||||
|
||||
const { items, totalCount } = await options.fetchPage(offset, pageSize, options.signal);
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
allItems.push(...items);
|
||||
offset += items.length;
|
||||
page++;
|
||||
|
||||
if (options.onProgress) {
|
||||
options.onProgress(allItems.length, totalCount ?? null);
|
||||
}
|
||||
|
||||
if (totalCount !== undefined && allItems.length >= totalCount) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (items.length < pageSize) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
export const defaultVkClient: IVkClient = new VkClient();
|
||||
158
src/integrations/vk/vk-errors.ts
Normal file
158
src/integrations/vk/vk-errors.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { VkApiRawError } from './vk-types';
|
||||
|
||||
/**
|
||||
* Base abstract class for all VK client errors.
|
||||
* Guarantees that access tokens are never leaked into error messages or details.
|
||||
*/
|
||||
export abstract class VkClientError extends Error {
|
||||
abstract readonly isRetryable: boolean;
|
||||
abstract readonly category: string;
|
||||
readonly errorCode?: number;
|
||||
readonly method?: string;
|
||||
readonly details?: any;
|
||||
|
||||
constructor(message: string, options?: { errorCode?: number; method?: string; details?: any }) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.errorCode = options?.errorCode;
|
||||
this.method = options?.method;
|
||||
this.details = options?.details;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Auth Error: Invalid, expired, or missing access_token (VK error codes: 4, 5, 28)
|
||||
*/
|
||||
export class VkAuthError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
readonly category = 'AUTH';
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Permission Error: Insufficient permissions for method or scope (VK error codes: 7, 15, 260)
|
||||
*/
|
||||
export class VkPermissionError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
readonly category = 'PERMISSION';
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Rate Limit Error: Too many requests per second or flood control (VK error codes: 6, 9)
|
||||
*/
|
||||
export class VkRateLimitError extends VkClientError {
|
||||
readonly isRetryable = true;
|
||||
readonly category = 'RATE_LIMIT';
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Private Resource Error: Target group/user profile is private or access is restricted (VK error codes: 15, 30, 203)
|
||||
*/
|
||||
export class VkPrivateResourceError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
readonly category = 'PRIVATE_RESOURCE';
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Not Found Error: Wall post, group, or resource does not exist (VK error codes: 104, 210, 214)
|
||||
*/
|
||||
export class VkNotFoundError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
readonly category = 'NOT_FOUND';
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Validation Error: Malformed parameters or bad request (VK error codes: 100, 113, 150)
|
||||
*/
|
||||
export class VkValidationError extends VkClientError {
|
||||
readonly isRetryable = false;
|
||||
readonly category = 'VALIDATION';
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Temporary Error: Unknown error, internal server error, or 5xx response from VK API (VK error codes: 1, 10, 500, 502, 503, 504)
|
||||
*/
|
||||
export class VkTemporaryError extends VkClientError {
|
||||
readonly isRetryable = true;
|
||||
readonly category = 'TEMPORARY';
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Network Error: Connection refused, DNS failure, or aborted network socket
|
||||
*/
|
||||
export class VkNetworkError extends VkClientError {
|
||||
readonly isRetryable = true;
|
||||
readonly category = 'NETWORK';
|
||||
}
|
||||
|
||||
/**
|
||||
* VK Timeout Error: Request was aborted due to client timeout
|
||||
*/
|
||||
export class VkTimeoutError extends VkClientError {
|
||||
readonly isRetryable = true;
|
||||
readonly category = 'TIMEOUT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes request params returned by VK to redact any sensitive token values.
|
||||
*/
|
||||
function sanitizeRequestParams(params?: Array<{ key: string; value: string }>): Array<{ key: string; value: string }> | undefined {
|
||||
if (!params) return undefined;
|
||||
return params.map(p => {
|
||||
if (p.key.toLowerCase().includes('token') || p.key.toLowerCase().includes('access_token')) {
|
||||
return { key: p.key, value: '[REDACTED]' };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps raw VK API error_code according to official VK API documentation.
|
||||
*/
|
||||
export function mapVkApiError(raw: VkApiRawError, method: string): VkClientError {
|
||||
const code = raw.error_code;
|
||||
const sanitizedParams = sanitizeRequestParams(raw.request_params);
|
||||
const msg = raw.error_msg || raw.error_text || `VK API Error (${code})`;
|
||||
|
||||
switch (code) {
|
||||
case 4: // Incorrect signature
|
||||
case 5: // User authorization failed
|
||||
case 28: // Application authorization failed
|
||||
return new VkAuthError(`VK Authentication Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 6: // Too many requests per second
|
||||
case 9: // Flood control
|
||||
case 29: // Rate limit reached
|
||||
return new VkRateLimitError(`VK Rate Limit Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 7: // Permission to perform this action is denied
|
||||
case 260: // Access to the group is denied
|
||||
return new VkPermissionError(`VK Permission Denied (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 15: // Access denied (private object)
|
||||
case 30: // This profile is private
|
||||
case 203: // Access to the group is denied
|
||||
return new VkPrivateResourceError(`VK Private Resource Access Denied (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 100: // One of the parameters specified was missing or invalid
|
||||
case 113: // Invalid user id
|
||||
case 150: // Invalid timestamp
|
||||
return new VkValidationError(`VK Invalid Parameters (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 104: // Not found
|
||||
case 210: // Access to wall's post denied or post not found
|
||||
case 214: // Access to adding post denied / not found
|
||||
return new VkNotFoundError(`VK Resource Not Found (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
case 1: // Unknown error occurred
|
||||
case 10: // Internal server error
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return new VkTemporaryError(`VK Server Temporary Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
|
||||
default:
|
||||
return new VkValidationError(`VK API Error (${code}): ${msg}`, { errorCode: code, method, details: sanitizedParams });
|
||||
}
|
||||
}
|
||||
65
src/integrations/vk/vk-rate-limit.ts
Normal file
65
src/integrations/vk/vk-rate-limit.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
export interface IVkRateLimiter {
|
||||
acquire(key?: string): Promise<void>;
|
||||
reset(): void;
|
||||
}
|
||||
|
||||
export interface VkRateLimiterOptions {
|
||||
maxRequestsPerSecond?: number;
|
||||
minIntervalMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side rate limiter for throttling outbound requests to the VK API.
|
||||
* Ensures the client stays within official VK API thresholds (default: 3 req/sec user, up to 20 req/sec service).
|
||||
*/
|
||||
export class VkRateLimiter implements IVkRateLimiter {
|
||||
private lastRequestTime = 0;
|
||||
private readonly minIntervalMs: number;
|
||||
private queue: Array<() => void> = [];
|
||||
private isProcessing = false;
|
||||
|
||||
constructor(options?: VkRateLimiterOptions) {
|
||||
const rps = options?.maxRequestsPerSecond ?? 3;
|
||||
this.minIntervalMs = options?.minIntervalMs ?? Math.ceil(1000 / rps);
|
||||
}
|
||||
|
||||
public async acquire(): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
this.queue.push(resolve);
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessing || this.queue.length === 0) return;
|
||||
this.isProcessing = true;
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
const now = Date.now();
|
||||
const timeSinceLast = now - this.lastRequestTime;
|
||||
const waitTime = Math.max(0, this.minIntervalMs - timeSinceLast);
|
||||
|
||||
if (waitTime > 0) {
|
||||
await new Promise(r => setTimeout(r, waitTime));
|
||||
}
|
||||
|
||||
this.lastRequestTime = Date.now();
|
||||
const nextResolve = this.queue.shift();
|
||||
if (nextResolve) {
|
||||
nextResolve();
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.queue = [];
|
||||
this.lastRequestTime = 0;
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultVkRateLimiter: IVkRateLimiter = new VkRateLimiter({
|
||||
maxRequestsPerSecond: 10,
|
||||
});
|
||||
91
src/integrations/vk/vk-retry.ts
Normal file
91
src/integrations/vk/vk-retry.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { VkClientError } from './vk-errors';
|
||||
|
||||
export interface VkRetryOptions {
|
||||
maxRetries?: number;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
factor?: number;
|
||||
jitter?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates exponential backoff delay with optional full jitter.
|
||||
*/
|
||||
export function calculateBackoffDelay(
|
||||
attempt: number,
|
||||
options: Required<VkRetryOptions>
|
||||
): number {
|
||||
const baseDelay = options.initialDelayMs * Math.pow(options.factor, attempt);
|
||||
const cappedDelay = Math.min(baseDelay, options.maxDelayMs);
|
||||
|
||||
if (options.jitter) {
|
||||
// Full jitter: random delay between 0 and cappedDelay
|
||||
return Math.floor(Math.random() * cappedDelay);
|
||||
}
|
||||
|
||||
return Math.floor(cappedDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a given error is eligible for retry.
|
||||
*/
|
||||
export function isErrorRetryable(error: unknown): boolean {
|
||||
if (error instanceof VkClientError) {
|
||||
return error.isRetryable;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an asynchronous operation with retry and backoff.
|
||||
*/
|
||||
export async function executeWithRetry<T>(
|
||||
operation: (attempt: number) => Promise<T>,
|
||||
options?: VkRetryOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<T> {
|
||||
const config: Required<VkRetryOptions> = {
|
||||
maxRetries: options?.maxRetries ?? 3,
|
||||
initialDelayMs: options?.initialDelayMs ?? 300,
|
||||
maxDelayMs: options?.maxDelayMs ?? 4000,
|
||||
factor: options?.factor ?? 2,
|
||||
jitter: options?.jitter ?? true,
|
||||
};
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= config.maxRetries; attempt++) {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('Operation aborted');
|
||||
}
|
||||
|
||||
try {
|
||||
return await operation(attempt);
|
||||
} catch (err: unknown) {
|
||||
lastError = err;
|
||||
|
||||
if (attempt === config.maxRetries || !isErrorRetryable(err)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
const delay = calculateBackoffDelay(attempt, config);
|
||||
if (delay > 0) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(resolve, delay);
|
||||
if (signal) {
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Operation aborted during retry backoff'));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
121
src/integrations/vk/vk-types.ts
Normal file
121
src/integrations/vk/vk-types.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* VK Authentication context supporting Service, User, and Community access tokens.
|
||||
*/
|
||||
export type VkTokenType = 'SERVICE' | 'USER' | 'COMMUNITY';
|
||||
|
||||
export interface VkAuthContext {
|
||||
type: VkTokenType;
|
||||
token: string;
|
||||
communityId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw VK API response shape according to official VK API specifications.
|
||||
*/
|
||||
export interface VkApiRawErrorParam {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface VkApiRawError {
|
||||
error_code: number;
|
||||
error_msg: string;
|
||||
request_params?: VkApiRawErrorParam[];
|
||||
error_text?: string;
|
||||
}
|
||||
|
||||
export interface VkApiResponse<T> {
|
||||
response?: T;
|
||||
error?: VkApiRawError;
|
||||
execute_errors?: VkApiRawError[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Call options for individual VK client requests.
|
||||
*/
|
||||
export interface VkCallOptions {
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
maxRetries?: number;
|
||||
retryInitialDelayMs?: number;
|
||||
retryMaxDelayMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base pagination parameters for VK endpoints.
|
||||
*/
|
||||
export interface VkPaginationOptions<TItem> {
|
||||
fetchPage: (offset: number, count: number, signal?: AbortSignal) => Promise<{
|
||||
items: TItem[];
|
||||
totalCount?: number;
|
||||
}>;
|
||||
pageSize?: number;
|
||||
maxPages?: number;
|
||||
signal?: AbortSignal;
|
||||
onProgress?: (loadedCount: number, totalCount: number | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* VK API Domain Entities
|
||||
*/
|
||||
export interface VkWallPost {
|
||||
id: number;
|
||||
owner_id: number;
|
||||
from_id?: number;
|
||||
date: number;
|
||||
text: number | string;
|
||||
comments?: { count: number };
|
||||
likes?: { count: number; user_likes?: number };
|
||||
reposts?: { count: number; user_reposted?: number };
|
||||
attachments?: Array<{ type: string; [key: string]: any }>;
|
||||
is_pinned?: number;
|
||||
}
|
||||
|
||||
export interface VkUserProfile {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
screen_name?: string;
|
||||
photo_50?: string;
|
||||
photo_100?: string;
|
||||
photo_200?: string;
|
||||
deactivated?: string; // 'deleted' | 'banned'
|
||||
is_closed?: boolean;
|
||||
}
|
||||
|
||||
export interface VkGroupProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
screen_name: string;
|
||||
is_closed: number;
|
||||
type: 'group' | 'page' | 'event';
|
||||
photo_50?: string;
|
||||
}
|
||||
|
||||
export interface VkLikesGetListResponse {
|
||||
count: number;
|
||||
items: Array<number | VkUserProfile>;
|
||||
}
|
||||
|
||||
export interface VkCommentItem {
|
||||
id: number;
|
||||
from_id: number;
|
||||
date: number;
|
||||
text: string;
|
||||
likes?: { count: number };
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
export interface VkWallGetCommentsResponse {
|
||||
count: number;
|
||||
items: VkCommentItem[];
|
||||
profiles?: VkUserProfile[];
|
||||
groups?: VkGroupProfile[];
|
||||
}
|
||||
|
||||
export interface VkIsMemberItem {
|
||||
user_id: number;
|
||||
member: number; // 1 or 0
|
||||
invitation?: number;
|
||||
request?: number;
|
||||
}
|
||||
|
|
@ -2,14 +2,18 @@ import { PlatformType, PostMetadata } from '../../core/types/giveaway';
|
|||
import { RawParticipant } from '../../core/types/participant';
|
||||
import { FetchParticipantsParams, ProviderCapabilities, SocialMediaProvider } from '../types';
|
||||
import { parseVkPostUrl } from './vk-parser';
|
||||
|
||||
interface VkApiResponse<T> {
|
||||
response?: T;
|
||||
error?: {
|
||||
error_code: number;
|
||||
error_msg: string;
|
||||
};
|
||||
}
|
||||
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';
|
||||
|
|
@ -23,82 +27,64 @@ export class VkProvider implements SocialMediaProvider {
|
|||
adminDetectionNote: 'Требует расширенных прав администратора группы',
|
||||
};
|
||||
|
||||
private serviceToken?: string;
|
||||
private apiVersion = '5.199';
|
||||
private baseUrl = 'https://api.vk.com/method';
|
||||
private readonly client: IVkClient;
|
||||
private readonly authContext: VkAuthContext;
|
||||
|
||||
constructor(serviceToken?: string) {
|
||||
this.serviceToken = serviceToken || process.env.VK_SERVICE_TOKEN;
|
||||
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;
|
||||
}
|
||||
|
||||
parsePostUrl(url: string): { ownerId: string; postId: string } | null {
|
||||
public parsePostUrl(url: string): { ownerId: string; postId: string } | null {
|
||||
return parseVkPostUrl(url);
|
||||
}
|
||||
|
||||
private async callApi<T>(method: string, params: Record<string, string | number>): Promise<T> {
|
||||
if (!this.serviceToken) {
|
||||
throw new Error('VK_SERVICE_TOKEN is not configured in environment variables');
|
||||
private ensureConfigured(): void {
|
||||
if (!this.authContext.token) {
|
||||
throw new VkAuthError('VK_SERVICE_TOKEN is not configured in environment variables');
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
...Object.entries(params).reduce((acc, [k, v]) => ({ ...acc, [k]: String(v) }), {}),
|
||||
access_token: this.serviceToken,
|
||||
v: this.apiVersion,
|
||||
});
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/${method}?${query.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`VK API HTTP error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: VkApiResponse<T> = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(`VK API Error (${data.error.error_code}): ${data.error.error_msg}`);
|
||||
}
|
||||
|
||||
if (!data.response) {
|
||||
throw new Error('Empty response from VK API');
|
||||
}
|
||||
|
||||
return data.response;
|
||||
}
|
||||
|
||||
async fetchPost(url: string): Promise<PostMetadata> {
|
||||
this.ensureConfigured();
|
||||
|
||||
const parsed = this.parsePostUrl(url);
|
||||
if (!parsed) {
|
||||
throw new Error('Invalid VK post URL format');
|
||||
throw new VkNotFoundError('Invalid VK post URL format');
|
||||
}
|
||||
|
||||
const { ownerId, postId } = parsed;
|
||||
|
||||
const response = await this.callApi<{ items: any[]; profiles?: any[]; groups?: any[] }>(
|
||||
'wall.getById',
|
||||
{
|
||||
posts: `${ownerId}_${postId}`,
|
||||
extended: 1,
|
||||
}
|
||||
);
|
||||
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 Error('Post not found or access is restricted');
|
||||
throw new VkNotFoundError(`Post "${ownerId}_${postId}" not found or access is restricted`);
|
||||
}
|
||||
|
||||
const post = response.items[0];
|
||||
|
||||
let authorName = `VK Wall ${ownerId}`;
|
||||
let authorAvatarUrl = undefined;
|
||||
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_100 || group.photo_200;
|
||||
authorAvatarUrl = group.photo_50;
|
||||
}
|
||||
} else {
|
||||
const userId = parseInt(ownerId, 10);
|
||||
|
|
@ -109,22 +95,24 @@ export class VkProvider implements SocialMediaProvider {
|
|||
}
|
||||
}
|
||||
|
||||
let imageUrl = undefined;
|
||||
let imageUrl: string | undefined = undefined;
|
||||
if (post.attachments && post.attachments.length > 0) {
|
||||
const photoAttachment = post.attachments.find((a: any) => a.type === 'photo');
|
||||
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: post.text ? post.text.slice(0, 80) + '...' : `Запись на стене ${ownerId}_${postId}`,
|
||||
text: post.text || '',
|
||||
title: textContent ? textContent.slice(0, 80) + '...' : `Запись на стене ${ownerId}_${postId}`,
|
||||
text: textContent,
|
||||
authorName,
|
||||
authorAvatarUrl,
|
||||
imageUrl,
|
||||
|
|
@ -136,126 +124,137 @@ export class VkProvider implements SocialMediaProvider {
|
|||
}
|
||||
|
||||
async fetchParticipants(params: FetchParticipantsParams): Promise<RawParticipant[]> {
|
||||
this.ensureConfigured();
|
||||
|
||||
const { ownerId, postId } = params;
|
||||
const participantsMap = new Map<string, RawParticipant>();
|
||||
|
||||
// 1. Fetch Likes
|
||||
// 1. Fetch Likes with full pagination
|
||||
if (params.includeLikes !== false) {
|
||||
let offset = 0;
|
||||
const count = 1000;
|
||||
let totalLikes = 0;
|
||||
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);
|
||||
|
||||
do {
|
||||
const likesRes = await this.callApi<{ count: number; items: any[] }>('likes.getList', {
|
||||
type: 'post',
|
||||
owner_id: ownerId,
|
||||
item_id: postId,
|
||||
filter: 'likes',
|
||||
extended: 1,
|
||||
count,
|
||||
offset,
|
||||
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,
|
||||
});
|
||||
|
||||
totalLikes = likesRes.count;
|
||||
|
||||
for (const item of likesRes.items) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
offset += count;
|
||||
if (params.onProgress) {
|
||||
params.onProgress(participantsMap.size, totalLikes, 'Загрузка лайков...');
|
||||
}
|
||||
} while (offset < totalLikes && offset < 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch Comments
|
||||
// 2. Fetch Comments with full pagination and author profile mapping
|
||||
if (params.includeComments) {
|
||||
let offset = 0;
|
||||
const count = 100;
|
||||
let totalComments = 0;
|
||||
|
||||
do {
|
||||
const commentsRes = await this.callApi<{ count: number; items: any[]; profiles?: any[] }>(
|
||||
'wall.getComments',
|
||||
{
|
||||
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);
|
||||
|
||||
totalComments = commentsRes.count;
|
||||
const profileMap = new Map<number, any>(
|
||||
(commentsRes.profiles || []).map(p => [p.id, p])
|
||||
);
|
||||
const profileMap = new Map<number, VkUserProfile>(
|
||||
(res.profiles || []).map(p => [p.id, p])
|
||||
);
|
||||
|
||||
for (const item of commentsRes.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);
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
offset += count;
|
||||
} while (offset < totalComments && offset < 1000);
|
||||
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.callApi<Array<{ user_id: number; member: number }>>(
|
||||
const res = await this.client.call<VkIsMemberItem[]>(
|
||||
'groups.isMember',
|
||||
{
|
||||
group_id: cleanGroupId,
|
||||
user_ids: chunk.join(','),
|
||||
}
|
||||
},
|
||||
this.authContext
|
||||
);
|
||||
|
||||
for (const item of res) {
|
||||
for (const item of res || []) {
|
||||
resultMap.set(String(item.user_id), item.member === 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
312
tests/vk-client-integration.test.ts
Normal file
312
tests/vk-client-integration.test.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { VkClient, fetchPaginatedVk } from '../src/integrations/vk/vk-client';
|
||||
import { createServiceAuth, createUserAuth, createCommunityAuth, redactToken } from '../src/integrations/vk/vk-auth';
|
||||
import { VkProvider } from '../src/providers/vk/vk-provider';
|
||||
import {
|
||||
VkAuthError,
|
||||
VkPermissionError,
|
||||
VkRateLimitError,
|
||||
VkTemporaryError,
|
||||
VkValidationError,
|
||||
VkTimeoutError
|
||||
} from '../src/integrations/vk/vk-errors';
|
||||
import { IVkRateLimiter } from '../src/integrations/vk/vk-rate-limit';
|
||||
|
||||
class NoopRateLimiter implements IVkRateLimiter {
|
||||
async acquire(): Promise<void> {}
|
||||
reset(): void {}
|
||||
}
|
||||
|
||||
describe('VK Client & Provider Mocked HTTP Integration', () => {
|
||||
const serviceToken = 'vk1.a.secret_service_token_123456789';
|
||||
let client: VkClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
client = new VkClient({
|
||||
rateLimiter: new NoopRateLimiter(),
|
||||
defaultTimeoutMs: 1000,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function mockFetchSuccess<T>(data: T) {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => JSON.stringify({ response: data }),
|
||||
json: async () => ({ response: data }),
|
||||
});
|
||||
}
|
||||
|
||||
function mockFetchVkError(errorCode: number, errorMsg: string) {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
text: async () => JSON.stringify({ error: { error_code: errorCode, error_msg: errorMsg } }),
|
||||
json: async () => ({ error: { error_code: errorCode, error_msg: errorMsg } }),
|
||||
});
|
||||
}
|
||||
|
||||
it('successfully calls VK API and parses response payload', async () => {
|
||||
mockFetchSuccess({ items: [{ id: 100, owner_id: -1, text: 'Hello VK post' }] });
|
||||
|
||||
const auth = createServiceAuth(serviceToken);
|
||||
const result = await client.call<{ items: Array<{ id: number; text: string }> }>(
|
||||
'wall.getById',
|
||||
{ posts: '-1_100' },
|
||||
auth
|
||||
);
|
||||
|
||||
expect(result.items).toBeDefined();
|
||||
expect(result.items[0].id).toBe(100);
|
||||
expect(result.items[0].text).toBe('Hello VK post');
|
||||
});
|
||||
|
||||
it('supports User and Community auth context types', async () => {
|
||||
mockFetchSuccess({ count: 1, items: [100] });
|
||||
|
||||
const userAuth = createUserAuth('vk1.a.user_token');
|
||||
const commAuth = createCommunityAuth('vk1.a.comm_token', '123456');
|
||||
|
||||
const resUser = await client.call('likes.getList', { type: 'post' }, userAuth);
|
||||
expect(resUser).toBeDefined();
|
||||
|
||||
const resComm = await client.call('likes.getList', { type: 'post' }, commAuth);
|
||||
expect(resComm).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws VkTimeoutError when request exceeds configured timeout', async () => {
|
||||
const slowClient = new VkClient({
|
||||
rateLimiter: new NoopRateLimiter(),
|
||||
defaultTimeoutMs: 20,
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
new Promise(resolve => setTimeout(resolve, 100))
|
||||
);
|
||||
|
||||
const auth = createServiceAuth(serviceToken);
|
||||
await expect(
|
||||
slowClient.call('wall.getById', { posts: '-1_1' }, auth, { maxRetries: 0 })
|
||||
).rejects.toThrow(VkTimeoutError);
|
||||
});
|
||||
|
||||
it('aborts immediately when caller AbortSignal fires', async () => {
|
||||
const controller = new AbortController();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(() =>
|
||||
new Promise((_, reject) => {
|
||||
controller.signal.addEventListener('abort', () => reject(new Error('Caller aborted')));
|
||||
})
|
||||
);
|
||||
|
||||
const auth = createServiceAuth(serviceToken);
|
||||
const promise = client.call('wall.getById', {}, auth, { signal: controller.signal });
|
||||
controller.abort();
|
||||
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('retries on retryable server error (error_code 10) up to maxRetries', async () => {
|
||||
let callCount = 0;
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount < 3) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ error: { error_code: 10, error_msg: 'Internal server error' } }),
|
||||
json: async () => ({ error: { error_code: 10, error_msg: 'Internal server error' } }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ response: { success: 1 } }),
|
||||
json: async () => ({ response: { success: 1 } }),
|
||||
};
|
||||
});
|
||||
|
||||
const auth = createServiceAuth(serviceToken);
|
||||
const res = await client.call<{ success: number }>(
|
||||
'wall.getById',
|
||||
{},
|
||||
auth,
|
||||
{ maxRetries: 3, retryInitialDelayMs: 5 }
|
||||
);
|
||||
|
||||
expect(res.success).toBe(1);
|
||||
expect(callCount).toBe(3);
|
||||
});
|
||||
|
||||
it('does NOT retry on non-retryable auth error (error_code 5)', async () => {
|
||||
let callCount = 0;
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async () => {
|
||||
callCount++;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ error: { error_code: 5, error_msg: 'User auth failed' } }),
|
||||
json: async () => ({ error: { error_code: 5, error_msg: 'User auth failed' } }),
|
||||
};
|
||||
});
|
||||
|
||||
const auth = createServiceAuth(serviceToken);
|
||||
await expect(
|
||||
client.call('wall.getById', {}, auth, { maxRetries: 3 })
|
||||
).rejects.toThrow(VkAuthError);
|
||||
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does NOT retry on permission error (error_code 7)', async () => {
|
||||
mockFetchVkError(7, 'Permission to perform this action is denied');
|
||||
|
||||
const auth = createServiceAuth(serviceToken);
|
||||
await expect(
|
||||
client.call('wall.getById', {}, auth, { maxRetries: 2 })
|
||||
).rejects.toThrow(VkPermissionError);
|
||||
});
|
||||
|
||||
it('throws VkValidationError on malformed non-JSON response', async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => '<html>502 Bad Gateway</html>',
|
||||
json: async () => { throw new Error('invalid json'); },
|
||||
});
|
||||
|
||||
const auth = createServiceAuth(serviceToken);
|
||||
await expect(
|
||||
client.call('wall.getById', {}, auth, { maxRetries: 0 })
|
||||
).rejects.toThrow(VkValidationError);
|
||||
});
|
||||
|
||||
it('pagination abstraction handles single and multiple pages seamlessly', async () => {
|
||||
let page = 0;
|
||||
const fetchPage = vi.fn(async (offset: number, count: number) => {
|
||||
page++;
|
||||
if (page === 1) {
|
||||
return { items: [1, 2, 3], totalCount: 6 };
|
||||
}
|
||||
if (page === 2) {
|
||||
return { items: [4, 5, 6], totalCount: 6 };
|
||||
}
|
||||
return { items: [], totalCount: 6 };
|
||||
});
|
||||
|
||||
const items = await fetchPaginatedVk<number>({
|
||||
pageSize: 3,
|
||||
fetchPage,
|
||||
});
|
||||
|
||||
expect(items).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('VkProvider deduplicates LIKE + COMMENT into a single merged participant', async () => {
|
||||
// 1. Likes response: user 100
|
||||
// 2. Comments response: user 100 and user 200
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string) => {
|
||||
if (url.includes('likes.getList')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
response: {
|
||||
count: 1,
|
||||
items: [{ id: 100, first_name: 'Alice', last_name: 'Like', photo_100: 'http://a.jpg' }],
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (url.includes('wall.getComments')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({
|
||||
response: {
|
||||
count: 2,
|
||||
items: [
|
||||
{ id: 1, from_id: 100, text: 'Comment from Alice' },
|
||||
{ id: 2, from_id: 200, text: 'Comment from Bob' },
|
||||
],
|
||||
profiles: [
|
||||
{ id: 100, first_name: 'Alice', last_name: 'Like' },
|
||||
{ id: 200, first_name: 'Bob', last_name: 'Comment' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
return { ok: false, status: 404, text: async () => '' };
|
||||
});
|
||||
|
||||
const provider = new VkProvider(serviceToken, client);
|
||||
const participants = await provider.fetchParticipants({
|
||||
ownerId: '-100',
|
||||
postId: '1',
|
||||
includeLikes: true,
|
||||
includeComments: true,
|
||||
});
|
||||
|
||||
expect(participants.length).toBe(2);
|
||||
|
||||
const alice = participants.find(p => p.platformUserId === '100');
|
||||
expect(alice).toBeDefined();
|
||||
expect(alice?.liked).toBe(true);
|
||||
expect(alice?.commented).toBe(true);
|
||||
expect(alice?.commentsCount).toBe(1);
|
||||
|
||||
const bob = participants.find(p => p.platformUserId === '200');
|
||||
expect(bob).toBeDefined();
|
||||
expect(bob?.liked).toBe(false);
|
||||
expect(bob?.commented).toBe(true);
|
||||
});
|
||||
|
||||
it('VkProvider batches subscription checks into 500-user chunks', async () => {
|
||||
const userIds = Array.from({ length: 1200 }, (_, i) => String(1000 + i));
|
||||
const capturedChunks: string[] = [];
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockImplementation(async (url: string, init: any) => {
|
||||
const body = new URLSearchParams(init?.body || '');
|
||||
const idsParam = body.get('user_ids') || '';
|
||||
capturedChunks.push(idsParam);
|
||||
|
||||
const parsedIds = idsParam.split(',').map(Number);
|
||||
const items = parsedIds.map(id => ({ user_id: id, member: id % 2 === 0 ? 1 : 0 }));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify({ response: items }),
|
||||
};
|
||||
});
|
||||
|
||||
const provider = new VkProvider(serviceToken, client);
|
||||
const subMap = await provider.checkSubscription(userIds, '100');
|
||||
|
||||
expect(capturedChunks.length).toBe(3); // 500, 500, 200
|
||||
expect(capturedChunks[0].split(',').length).toBe(500);
|
||||
expect(capturedChunks[1].split(',').length).toBe(500);
|
||||
expect(capturedChunks[2].split(',').length).toBe(200);
|
||||
|
||||
expect(subMap.size).toBe(1200);
|
||||
expect(subMap.get('1000')).toBe(true);
|
||||
expect(subMap.get('1001')).toBe(false);
|
||||
});
|
||||
|
||||
it('ensures token is never exposed in logs or redaction utility', () => {
|
||||
const secret = 'vk1.a.secret_top_secret_token_value_99999';
|
||||
const redacted = redactToken(secret);
|
||||
|
||||
expect(redacted).not.toBe(secret);
|
||||
expect(redacted).toContain('...');
|
||||
expect(redacted.length).toBeLessThan(15);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,15 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { VkProvider } from '../src/providers/vk/vk-provider';
|
||||
import { VkClient } from '../src/integrations/vk/vk-client';
|
||||
import {
|
||||
VkAuthError,
|
||||
VkPermissionError,
|
||||
VkRateLimitError,
|
||||
VkPrivateResourceError,
|
||||
VkNotFoundError,
|
||||
VkValidationError,
|
||||
VkNetworkError
|
||||
} from '../src/integrations/vk/vk-errors';
|
||||
|
||||
describe('VK API error handling', () => {
|
||||
const token = 'vk1.a.test-service-token';
|
||||
|
|
@ -18,6 +28,7 @@ describe('VK API error handling', () => {
|
|||
status,
|
||||
statusText: status === 500 ? 'Internal Server Error' : 'OK',
|
||||
json: async () => json,
|
||||
text: async () => (typeof json === 'string' ? json : JSON.stringify(json)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -29,110 +40,104 @@ describe('VK API error handling', () => {
|
|||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/User authorization failed/);
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkAuthError);
|
||||
});
|
||||
|
||||
it('throws on expired token (error_code 5)', async () => {
|
||||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/User authorization failed/);
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkAuthError);
|
||||
});
|
||||
|
||||
it('throws on access denied (error_code 15)', async () => {
|
||||
mockFetchJson({ error: { error_code: 15, error_msg: 'Access denied' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Access denied/);
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkPrivateResourceError);
|
||||
});
|
||||
|
||||
it('throws on rate limit per second (error_code 6)', async () => {
|
||||
mockFetchJson({ error: { error_code: 6, error_msg: 'Too many requests per second' } });
|
||||
const provider = new VkProvider(token);
|
||||
// Use VkClient with 0 retries to observe immediate error
|
||||
const client = new VkClient();
|
||||
const provider = new VkProvider(token, client);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/Too many requests/);
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkRateLimitError);
|
||||
});
|
||||
|
||||
it('throws on daily rate limit (error_code 29)', async () => {
|
||||
mockFetchJson({ error: { error_code: 29, error_msg: 'Rate limit reached' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.checkSubscription(['1', '2'], '1')).rejects.toThrow(/Rate limit reached/);
|
||||
await expect(provider.checkSubscription(['1', '2'], '1')).rejects.toThrow(VkRateLimitError);
|
||||
});
|
||||
|
||||
it('throws on private profile (error_code 30)', async () => {
|
||||
mockFetchJson({ error: { error_code: 30, error_msg: 'This profile is private' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/This profile is private/);
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkPrivateResourceError);
|
||||
});
|
||||
|
||||
it('throws on deleted or banned user (error_code 18)', async () => {
|
||||
mockFetchJson({ error: { error_code: 18, error_msg: 'User was deleted or banned' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/User was deleted or banned/);
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkValidationError);
|
||||
});
|
||||
|
||||
it('throws on deleted post (error_code 100 with post not found semantics)', async () => {
|
||||
mockFetchJson({ error: { error_code: 100, error_msg: 'One of the parameters specified was missing or invalid' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_999999')).rejects.toThrow(/parameters specified was missing or invalid/);
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_999999')).rejects.toThrow(VkValidationError);
|
||||
});
|
||||
|
||||
it('throws when post is not found in response', async () => {
|
||||
mockFetchJson({ response: { items: [] } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Post not found/);
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkNotFoundError);
|
||||
});
|
||||
|
||||
it('throws on unavailable community (error_code 203)', async () => {
|
||||
mockFetchJson({ error: { error_code: 203, error_msg: 'Access to the community is denied' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Access to the community is denied/);
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkPrivateResourceError);
|
||||
});
|
||||
|
||||
it('throws on empty response body', async () => {
|
||||
mockFetchJson({});
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(/Empty response/);
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow(VkValidationError);
|
||||
});
|
||||
|
||||
it('throws on HTTP 500 from VK', async () => {
|
||||
mockFetchJson({}, 500);
|
||||
mockFetchJson('Internal Server Error', 500);
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/HTTP error: 500/);
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkNetworkError);
|
||||
});
|
||||
|
||||
it('throws on network timeout / failure', async () => {
|
||||
mockFetchNetworkError('fetch failed');
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(/fetch failed/);
|
||||
await expect(provider.fetchParticipants({ ownerId: '-1', postId: '1' })).rejects.toThrow(VkNetworkError);
|
||||
});
|
||||
|
||||
it('does not leak the service token in thrown error messages', async () => {
|
||||
mockFetchJson({ error: { error_code: 5, error_msg: 'User authorization failed' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow();
|
||||
try {
|
||||
await provider.fetchPost('https://vk.com/wall-1_1');
|
||||
expect.unreachable('Should have thrown');
|
||||
} catch (err: any) {
|
||||
expect(err.message).not.toContain(token);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not retry transient errors by default', async () => {
|
||||
mockFetchJson({ error: { error_code: 6, error_msg: 'Too many requests per second' } });
|
||||
const provider = new VkProvider(token);
|
||||
|
||||
await expect(provider.fetchPost('https://vk.com/wall-1_1')).rejects.toThrow();
|
||||
expect(global.fetch as ReturnType<typeof vi.fn>).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue