diff --git a/README.md b/README.md index 23f72b9..da36752 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ # teleton-plugins [![GitHub stars](https://img.shields.io/github/stars/TONresistor/teleton-plugins?style=flat&logo=github)](https://github.com/TONresistor/teleton-plugins/stargazers) -[![Plugins](https://img.shields.io/badge/plugins-33-8B5CF6.svg)](#available-plugins) -[![Tools](https://img.shields.io/badge/tools-366-E040FB.svg)](#available-plugins) +[![Plugins](https://img.shields.io/badge/plugins-34-8B5CF6.svg)](#available-plugins) +[![Tools](https://img.shields.io/badge/tools-400-E040FB.svg)](#available-plugins) [![SDK](https://img.shields.io/badge/SDK-v1.0.0-00C896.svg)](#plugin-sdk) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) @@ -76,7 +76,7 @@ No build step. Just copy and go. Plugins with npm dependencies are auto-installe ## Available Plugins -> **33 plugins** · **366 tools** · [Browse the registry](registry.json) +> **34 plugins** · **400 tools** · [Browse the registry](registry.json) ### DeFi & Trading @@ -107,6 +107,7 @@ No build step. Just copy and go. Plugins with npm dependencies are auto-installe | Plugin | Description | Tools | Author | |--------|-------------|:-----:|--------| | [twitter](plugins/twitter/) | X/Twitter API v2 — search, post, like, retweet, follow | 24 | teleton | +| [vk-full-admin](plugins/vk-full-admin/) | VK personal account and community administration — publishing, moderation, members, settings, analytics, group dialogs | 34 | xlabtg | | [pic](plugins/pic/) | Image search via @pic inline bot | 1 | teleton | | [vid](plugins/vid/) | YouTube search via @vid inline bot | 1 | teleton | | [deezer](plugins/deezer/) | Music search via @DeezerMusicBot | 1 | teleton | diff --git a/plugins/vk-full-admin/.gitignore b/plugins/vk-full-admin/.gitignore new file mode 100644 index 0000000..d81596c --- /dev/null +++ b/plugins/vk-full-admin/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +*.db +*.secrets.json diff --git a/plugins/vk-full-admin/README.md b/plugins/vk-full-admin/README.md new file mode 100644 index 0000000..41c7419 --- /dev/null +++ b/plugins/vk-full-admin/README.md @@ -0,0 +1,210 @@ +# VK Full Admin + +VK Full Admin gives Teleton tools for managing a VK personal account and VK communities where the token owner is a moderator, editor, administrator, or creator. + +The plugin uses `vk-io` for all VK API and upload calls. Raw tokens are read only through `sdk.secrets`; the runtime cache stores token fingerprints and admin-check metadata, not token values. + +## Setup + +Install the plugin folder and dependencies: + +```bash +cp -r plugins/vk-full-admin ~/.teleton/plugins/ +cd ~/.teleton/plugins/vk-full-admin +npm ci --ignore-scripts +``` + +### Create VK Access Tokens + +Log in to VK with the account that owns or manages the target communities. + +1. Create or open a VK developer app at `https://vk.com/dev/standalone` or `https://vk.com/apps?act=manage` and copy its **Application ID**. For a manual local flow, use `https://oauth.vk.com/blank.html` as the redirect URI. +2. Create the user token for `vk_user_token`. In Teleton, run `vk_auth_user_url` with the copied `client_id`, open the returned URL, approve the requested scopes, and copy the `access_token` value from the redirected URL fragment. +3. Create a community token for each managed community. In VK, open the community, go to **Manage** -> **Settings** -> **API usage** -> **Access tokens**, click **Create token**, allow the permissions needed by the plugin, confirm, and copy the generated token. +4. If you prefer OAuth for community tokens, run `vk_auth_group_url` with the copied `client_id` and `group_ids`, open the returned URL, approve access, and copy each `access_token_` value from the redirected URL fragment. + +Recommended user token scopes: + +```text +offline, wall, messages, friends, photos, groups, stats, notifications +``` + +Recommended community token scopes: + +```text +manage, messages, photos, docs +``` + +The helper tools only build VK OAuth URLs. They do not store tokens. + +If the helper tools are not available yet during first install, use these OAuth URL templates directly: + +```text +https://oauth.vk.com/authorize?client_id=&display=page&redirect_uri=https://oauth.vk.com/blank.html&scope=offline,wall,messages,friends,photos,groups,stats,notifications&response_type=token&v=5.199 +https://oauth.vk.com/authorize?client_id=&display=page&redirect_uri=https://oauth.vk.com/blank.html&scope=manage,messages,photos,docs&response_type=token&v=5.199&group_ids=123456,789012 +``` + +### Link Tokens To Teleton + +Configure secrets in **Teleton WebUI** -> **Plugins** -> **VK Full Admin** -> **Keys**: + +| Secret | Required | Value | +| --- | --- | --- | +| `vk_user_token` | Yes | User `access_token` copied from the OAuth redirect URL | +| `vk_community_tokens` | For community write tools | JSON object keyed by community ID without the minus sign | + +Example `vk_community_tokens` value: + +```json +{ + "123456": "vk1.a.group-token", + "789012": "vk1.a.other-token" +} +``` + +The same secrets can be set through the Teleton chat CLI: + +```bash +/secret set vk_full_admin vk_user_token "vk1.a...." +/secret set vk_full_admin vk_community_tokens '{"123456":"vk1.a.group-token","789012":"vk1.a.other-token"}' +``` + +Container and CI deployments can set the matching environment variables: + +```bash +export VK_FULL_ADMIN_VK_USER_TOKEN="vk1.a...." +export VK_FULL_ADMIN_VK_COMMUNITY_TOKENS='{"123456":"vk1.a.group-token"}' +``` + +`vk_user_token` is required for personal account tools and for validating that the user has community manager rights. `vk_community_tokens` is a JSON object keyed by community ID without the minus sign. Community write tools require both a valid user token and a matching community token. + +### Verify Installation + +Ask the agent to run `vk_auth_status` with `validate: true`, then run `vk_group_admin_check` for each configured community ID. If validation fails with VK error `5`, recreate and relink the expired or revoked token. + +## Tools + +### Authorization + +| Tool | Purpose | +| --- | --- | +| `vk_auth_status` | Check configured user and community tokens | +| `vk_auth_user_url` | Build a user-token OAuth URL | +| `vk_auth_group_url` | Build a community-token OAuth URL | +| `vk_group_admin_check` | Validate the token owner's community role | + +### Personal Account + +| Tool | VK API | +| --- | --- | +| `vk_user_info` | `users.get` | +| `vk_user_messages_send` | `messages.send` | +| `vk_user_wall_post` | `wall.post` | +| `vk_user_friends_list` | `friends.get` | + +### Community Content + +| Tool | VK API | +| --- | --- | +| `vk_group_wall_post` | `wall.post` | +| `vk_group_wall_edit` | `wall.edit` | +| `vk_group_wall_delete` | `wall.delete` | +| `vk_group_pin_post` | `wall.pin` / `wall.unpin` | +| `vk_group_upload_photo` | `vk.upload.wallPhoto` / `vk.upload.photoAlbum` | +| `vk_group_create_poll` | `polls.create` | + +### Moderation + +| Tool | VK API | +| --- | --- | +| `vk_group_comment_delete` | `wall.deleteComment` | +| `vk_group_comment_hide_spam` | `wall.reportComment` | +| `vk_group_ban_user` | `groups.ban` | +| `vk_group_unban_user` | `groups.unban` | +| `vk_group_blacklist_list` | `groups.getBanned` | +| `vk_group_clean_wall` | `wall.get` plus guarded `wall.delete` | + +### Members And Roles + +| Tool | VK API | +| --- | --- | +| `vk_group_members_list` | `groups.getMembers` | +| `vk_group_invite` | `groups.invite` | +| `vk_group_remove` | `groups.removeUser` | +| `vk_group_set_role` | `groups.editManager` | + +### Settings And Analytics + +| Tool | VK API | +| --- | --- | +| `vk_group_info` | `groups.getById` | +| `vk_group_update_settings` | `groups.edit` | +| `vk_group_update_cover` | `vk.upload.groupCover` | +| `vk_group_update_avatar` | `vk.upload.ownerPhoto` | +| `vk_group_stats` | `stats.get` | +| `vk_group_post_reach` | `wall.getPostReach` | +| `vk_group_audience` | `stats.get` visitors group | + +### Community Dialogs + +| Tool | VK API | +| --- | --- | +| `vk_group_msg_send` | `messages.send` | +| `vk_group_msg_history` | `messages.getHistory` | +| `vk_group_msg_set_typing` | `messages.setActivity` | + +## Safety Model + +- Community tools call `groups.isMember` and fall back to `groups.getById` before taking action. +- Roles are enforced by tool impact: moderators for moderation, editors for content and analytics, administrators for settings and manager roles. +- VK API error codes `5`, `7`, `15`, and `260` are normalized for the LLM. Error text is capped at 500 characters and token-like values are redacted. +- A per-token rate gate defaults to 3 requests per second, matching VK limits. API error `260` is retried once. +- `vk_group_clean_wall` defaults to `dry_run: true` and returns the matching posts before deletion. +- Action audit rows are written to the plugin database when `sdk.db` is available. + +Automation may violate VK terms or community policies if used carelessly. Use these tools only for communities you are authorized to manage and review destructive actions before running them. + +## Examples + +Publish a community post: + +```json +{ + "owner_id": -123456, + "message": "New collection is live", + "attachments": "photo123_456", + "close_comments": false +} +``` + +Dry-run wall cleanup: + +```json +{ + "owner_id": -123456, + "filter": "contains", + "query": "test", + "count": 20, + "dry_run": true +} +``` + +Send a community dialog response: + +```json +{ + "group_id": 123456, + "peer_id": 2000000001, + "message": "Thanks for contacting support." +} +``` + +## Local Verification + +```bash +node -e "import('./plugins/vk-full-admin/index.js').then(m => console.log(m.tools({secrets:{get:()=>null},pluginConfig:{},log:{debug(){},warn(){},info(){},error(){}}}).length))" +node --test plugins/vk-full-admin/tests/index.test.js +npm run validate +npm run lint +npm test +``` diff --git a/plugins/vk-full-admin/index.js b/plugins/vk-full-admin/index.js new file mode 100644 index 0000000..1c85686 --- /dev/null +++ b/plugins/vk-full-admin/index.js @@ -0,0 +1,1762 @@ +/** + * vk-full-admin - VK personal account and managed community administration. + * + * All VK API calls go through vk-io. Tokens are read from sdk.secrets only; + * storage keeps fingerprints and admin-check metadata, never raw tokens. + */ + +import { createHash, randomInt } from "node:crypto"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { VK } = require("vk-io"); +const { groupScopes, userScopes } = require("@vk-io/authorization"); + +const PLUGIN_ID = "vk-full-admin"; +const DEFAULT_API_VERSION = "5.199"; +const TOKEN_CACHE_TTL_MS = 50 * 60 * 1000; +const DEFAULT_TIMEOUT_MS = 15_000; +const DEFAULT_RATE_LIMIT_PER_SECOND = 3; +const MAX_ERROR_LENGTH = 500; +const DEFAULT_USER_SCOPES = [ + "offline", + "wall", + "messages", + "friends", + "photos", + "groups", + "stats", + "notifications", +]; +const DEFAULT_GROUP_SCOPES = ["manage", "messages", "photos", "docs"]; +const ADMIN_ROLES = new Set(["moderator", "editor", "admin", "administrator", "creator"]); +const ROLE_RANK = { + none: 0, + member: 0, + moderator: 1, + editor: 2, + administrator: 3, + admin: 3, + creator: 4, +}; + +export const manifest = { + name: PLUGIN_ID, + version: "1.0.0", + sdkVersion: ">=1.0.0", + description: + "VK full administration tools for user profiles and managed communities", + defaultConfig: { + api_version: DEFAULT_API_VERSION, + api_timeout_ms: DEFAULT_TIMEOUT_MS, + rate_limit_per_second: DEFAULT_RATE_LIMIT_PER_SECOND, + admin_cache_ttl_ms: TOKEN_CACHE_TTL_MS, + language: "ru", + }, + secrets: { + vk_user_token: { + required: true, + env: "VK_FULL_ADMIN_VK_USER_TOKEN", + description: + "VK user access token from OAuth implicit flow with wall, messages, friends, photos, groups, stats, notifications, and offline scopes", + }, + vk_community_tokens: { + required: false, + env: "VK_FULL_ADMIN_VK_COMMUNITY_TOKENS", + description: + "JSON object mapping community IDs without minus signs to community tokens from VK community API access-token settings", + }, + }, +}; + +export function migrate(db) { + db.exec(` + CREATE TABLE IF NOT EXISTS vk_group_cache ( + group_id INTEGER PRIMARY KEY, + token_hash TEXT, + admin_checked_at INTEGER, + last_activity INTEGER + ); + + CREATE TABLE IF NOT EXISTS vk_action_audit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + group_id INTEGER, + actor_id INTEGER, + success INTEGER NOT NULL, + details TEXT, + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + `); +} + +export const tools = (sdk) => createVkFullAdminTools(sdk); + +export function createVkFullAdminTools(sdk, options = {}) { + const VKClass = options.VKClass ?? VK; + const now = options.now ?? (() => Date.now()); + const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); + const clientCache = new Map(); + const rateBuckets = new Map(); + + const config = { + apiVersion: sdk?.pluginConfig?.api_version ?? DEFAULT_API_VERSION, + apiTimeoutMs: Number(sdk?.pluginConfig?.api_timeout_ms ?? DEFAULT_TIMEOUT_MS), + rateLimitPerSecond: Math.max( + 1, + Number(sdk?.pluginConfig?.rate_limit_per_second ?? DEFAULT_RATE_LIMIT_PER_SECOND) + ), + adminCacheTtlMs: Number(sdk?.pluginConfig?.admin_cache_ttl_ms ?? TOKEN_CACHE_TTL_MS), + language: sdk?.pluginConfig?.language ?? "ru", + }; + + async function storageGet(key) { + try { + return await sdk?.storage?.get?.(key); + } catch (err) { + sdk?.log?.debug?.(`vk storage get failed for ${key}: ${String(err.message || err)}`); + return undefined; + } + } + + async function storageSet(key, value, opts) { + try { + await sdk?.storage?.set?.(key, value, opts); + } catch (err) { + sdk?.log?.debug?.(`vk storage set failed for ${key}: ${String(err.message || err)}`); + } + } + + async function storageDelete(key) { + try { + await sdk?.storage?.delete?.(key); + } catch (err) { + sdk?.log?.debug?.(`vk storage delete failed for ${key}: ${String(err.message || err)}`); + } + } + + async function getSecret(name, { required = true } = {}) { + const secrets = sdk?.secrets; + let value; + if (required && typeof secrets?.require === "function") { + value = await secrets.require(name); + } else if (typeof secrets?.get === "function") { + value = await secrets.get(name); + } + + if (required && !value) { + throw new Error(`${name} is required. Add it in Teleton plugin secrets.`); + } + return value ?? null; + } + + async function loadUserToken() { + return String(await getSecret("vk_user_token")); + } + + async function loadCommunityTokens({ required = true } = {}) { + const raw = await getSecret("vk_community_tokens", { required }); + if (!raw) return {}; + if (typeof raw === "object") return normalizeCommunityTokenMap(raw); + try { + return normalizeCommunityTokenMap(JSON.parse(String(raw))); + } catch { + throw new Error("vk_community_tokens must be a JSON object like {\"123456\":\"token\"}."); + } + } + + function normalizeCommunityTokenMap(raw) { + if (!raw || Array.isArray(raw) || typeof raw !== "object") { + throw new Error("vk_community_tokens must be a JSON object keyed by community ID."); + } + + const normalized = {}; + for (const [key, value] of Object.entries(raw)) { + if (!value) continue; + const groupId = Math.abs(Number(key)); + if (!Number.isSafeInteger(groupId) || groupId <= 0) continue; + normalized[String(groupId)] = String(value); + } + return normalized; + } + + async function loadCommunityToken(groupId) { + const tokens = await loadCommunityTokens(); + const token = tokens[String(groupId)]; + if (!token) { + throw new Error(`No VK community token configured for group ${groupId}.`); + } + return token; + } + + async function getVkSession(kind, id, token) { + const tokenHash = hashToken(token); + const cacheKey = `${kind}:${id}:${tokenHash}`; + const cached = clientCache.get(cacheKey); + if (cached && cached.expiresAt > now()) return cached.session; + + const client = new VKClass({ + token, + apiVersion: config.apiVersion, + apiLimit: config.rateLimitPerSecond, + apiTimeout: config.apiTimeoutMs, + language: config.language, + }); + + const session = { client, tokenHash, token }; + clientCache.set(cacheKey, { + session, + expiresAt: now() + TOKEN_CACHE_TTL_MS, + }); + + await storageSet( + `vk:token:${kind}:${id}`, + { token_hash: tokenHash, cached_at: now() }, + { ttl: TOKEN_CACHE_TTL_MS } + ); + return session; + } + + async function getUserSession() { + return getVkSession("user", "self", await loadUserToken()); + } + + async function getCommunitySession(groupId) { + return getVkSession("community", groupId, await loadCommunityToken(groupId)); + } + + async function getCurrentUserId(userSession) { + const cacheKey = `vk:user:self:${userSession.tokenHash}`; + const cached = await storageGet(cacheKey); + if (cached?.id) return cached.id; + + const users = await callVk(userSession, "users.get", {}); + const id = Array.isArray(users) ? users[0]?.id : users?.id; + if (!Number.isSafeInteger(Number(id))) { + throw new Error("Could not determine VK user ID from vk_user_token."); + } + const userId = Number(id); + await storageSet(cacheKey, { id: userId }, { ttl: TOKEN_CACHE_TTL_MS }); + return userId; + } + + async function waitRateLimit(tokenHash) { + const limit = config.rateLimitPerSecond; + const key = `rate:${tokenHash}`; + + while (true) { + const current = now(); + const bucket = (rateBuckets.get(key) ?? []).filter((ts) => current - ts < 1000); + if (bucket.length < limit) { + bucket.push(current); + rateBuckets.set(key, bucket); + return; + } + + const waitMs = Math.max(1, 1000 - (current - bucket[0]) + 5); + rateBuckets.set(key, bucket); + await sleep(waitMs); + } + } + + async function callVk(session, method, params = {}, { retryRateLimit = true } = {}) { + await waitRateLimit(session.tokenHash); + try { + return await session.client.api.call(method, cleanParams(params)); + } catch (err) { + if (getVkErrorCode(err) === 260 && retryRateLimit) { + sdk?.log?.warn?.(`VK rate limit for ${method}; retrying once.`); + await sleep(1000); + return callVk(session, method, params, { retryRateLimit: false }); + } + if (getVkErrorCode(err) === 5) { + clearTokenHash(session.tokenHash); + } + throw err; + } + } + + async function uploadVk(session, uploadMethod, params = {}) { + await waitRateLimit(session.tokenHash); + try { + const upload = session.client.upload?.[uploadMethod]; + if (typeof upload !== "function") { + throw new Error(`vk-io upload method ${uploadMethod} is not available.`); + } + return await upload.call(session.client.upload, cleanParams(params)); + } catch (err) { + if (getVkErrorCode(err) === 5) clearTokenHash(session.tokenHash); + throw err; + } + } + + function clearTokenHash(tokenHash) { + for (const [key, value] of clientCache.entries()) { + if (value.session.tokenHash === tokenHash) clientCache.delete(key); + } + void storageDelete(`vk:invalid:${tokenHash}`); + } + + async function assertGroupAdmin(groupId, { minRole = "moderator" } = {}) { + const userSession = await getUserSession(); + const userId = await getCurrentUserId(userSession); + const cacheKey = `vk:admin:${userId}:${groupId}`; + const cached = await storageGet(cacheKey); + if (cached?.role && isRoleAllowed(cached.role, minRole)) return cached; + + const member = await callVk(userSession, "groups.isMember", { + group_id: groupId, + user_id: userId, + extended: 1, + }); + let role = roleFromAdminPayload(member); + + if (!isRoleAllowed(role, minRole)) { + const groupInfo = await callVk(userSession, "groups.getById", { + group_ids: String(groupId), + fields: "is_admin,admin_level,manager_role", + }); + role = roleFromAdminPayload(Array.isArray(groupInfo) ? groupInfo[0] : groupInfo?.groups?.[0]); + } + + if (!isRoleAllowed(role, minRole)) { + throw new Error( + `Insufficient rights in community ${groupId}. Required role: ${minRole}; detected role: ${role}.` + ); + } + + const result = { group_id: groupId, user_id: userId, role, checked_at: now() }; + await storageSet(cacheKey, result, { ttl: config.adminCacheTtlMs }); + cacheGroupAdminCheck(groupId, userSession.tokenHash); + return result; + } + + function cacheGroupAdminCheck(groupId, tokenHash) { + try { + sdk?.db + ?.prepare( + `INSERT INTO vk_group_cache (group_id, token_hash, admin_checked_at, last_activity) + VALUES (?, ?, unixepoch(), unixepoch()) + ON CONFLICT(group_id) DO UPDATE SET + token_hash = excluded.token_hash, + admin_checked_at = excluded.admin_checked_at, + last_activity = excluded.last_activity` + ) + .run(groupId, tokenHash); + } catch (err) { + sdk?.log?.debug?.(`vk group cache write failed: ${String(err.message || err)}`); + } + } + + async function communityAction(groupId, { minRole = "moderator" } = {}) { + const admin = await assertGroupAdmin(groupId, { minRole }); + const communitySession = await getCommunitySession(groupId); + return { admin, communitySession }; + } + + async function executeTool(action, handler, { groupId = null, actorId = null } = {}) { + try { + const data = await handler(); + auditAction(action, { groupId, actorId, success: true }); + return { success: true, data }; + } catch (err) { + auditAction(action, { + groupId, + actorId, + success: false, + details: sanitizeError(err), + }); + sdk?.log?.warn?.(`${action} failed: ${sanitizeError(err)}`); + return { success: false, error: formatVkError(err) }; + } + } + + function auditAction(action, { groupId = null, actorId = null, success, details = null } = {}) { + try { + sdk?.db + ?.prepare( + `INSERT INTO vk_action_audit (action, group_id, actor_id, success, details) + VALUES (?, ?, ?, ?, ?)` + ) + .run(action, groupId, actorId, success ? 1 : 0, details); + } catch (err) { + sdk?.log?.debug?.(`vk audit write failed: ${String(err.message || err)}`); + } + } + + return [ + { + name: "vk_auth_status", + description: + "Check whether VK user and community tokens are configured. Optionally validates the user token through VK users.get.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + validate: { + type: "boolean", + description: "When true, call VK users.get to validate vk_user_token.", + default: false, + }, + }, + }, + execute: async (params) => executeTool("vk_auth_status", async () => { + const userToken = await getSecret("vk_user_token", { required: false }); + const communityTokens = await loadCommunityTokens({ required: false }); + const data = { + user_token_configured: Boolean(userToken), + community_token_group_ids: Object.keys(communityTokens).map(Number).sort((a, b) => a - b), + community_token_count: Object.keys(communityTokens).length, + default_user_scopes: DEFAULT_USER_SCOPES, + default_group_scopes: DEFAULT_GROUP_SCOPES, + }; + + if (params.validate && userToken) { + const session = await getVkSession("user", "self", String(userToken)); + const users = await callVk(session, "users.get", {}); + data.user = Array.isArray(users) ? users[0] : users; + } + return data; + }), + }, + { + name: "vk_auth_user_url", + description: + "Build a VK OAuth implicit-flow URL for obtaining vk_user_token. No network request is made and no token is stored.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + client_id: { type: "string", description: "VK application client ID." }, + redirect_uri: { + type: "string", + description: "OAuth redirect URI registered in VK app settings.", + default: "https://oauth.vk.com/blank.html", + }, + scopes: { + type: "array", + description: "VK user scope names. Defaults cover this plugin.", + items: { type: "string" }, + }, + revoke: { + type: "boolean", + description: "Force VK to ask for permissions again.", + default: false, + }, + }, + required: ["client_id"], + }, + execute: async (params) => executeTool("vk_auth_user_url", async () => { + const scopes = params.scopes?.length ? params.scopes : DEFAULT_USER_SCOPES; + return { + url: buildOAuthUrl({ + clientId: params.client_id, + redirectUri: params.redirect_uri, + scope: scopeMask(scopes, userScopes), + revoke: params.revoke, + }), + scopes, + flow: "implicit_user", + store_as_secret: "vk_user_token", + }; + }), + }, + { + name: "vk_auth_group_url", + description: + "Build a VK OAuth URL for obtaining community tokens for selected groups. No network request is made and no token is stored.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + client_id: { type: "string", description: "VK application client ID." }, + group_ids: { + type: "array", + description: "Community IDs without minus sign.", + items: { type: "integer" }, + }, + redirect_uri: { + type: "string", + description: "OAuth redirect URI registered in VK app settings.", + default: "https://oauth.vk.com/blank.html", + }, + scopes: { + type: "array", + description: "VK community scope names. Defaults cover group administration.", + items: { type: "string" }, + }, + revoke: { type: "boolean", description: "Force permission confirmation.", default: false }, + }, + required: ["client_id", "group_ids"], + }, + execute: async (params) => executeTool("vk_auth_group_url", async () => { + const scopes = params.scopes?.length ? params.scopes : DEFAULT_GROUP_SCOPES; + return { + url: buildOAuthUrl({ + clientId: params.client_id, + redirectUri: params.redirect_uri, + scope: scopeMask(scopes, groupScopes), + groupIds: params.group_ids, + revoke: params.revoke, + }), + scopes, + flow: "implicit_group", + store_as_secret: "vk_community_tokens", + }; + }), + }, + { + name: "vk_group_admin_check", + description: + "Validate that vk_user_token belongs to a moderator, editor, administrator, or creator of a VK community.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + min_role: { + type: "string", + enum: ["moderator", "editor", "admin"], + description: "Minimum role required for the check.", + default: "moderator", + }, + }, + required: ["group_id"], + }, + execute: async (params) => executeTool("vk_group_admin_check", async () => { + const admin = await assertGroupAdmin(Number(params.group_id), { + minRole: normalizeRequestedRole(params.min_role ?? "moderator"), + }); + const communityTokens = await loadCommunityTokens({ required: false }); + return { + ...admin, + community_token_configured: Boolean(communityTokens[String(Math.abs(Number(params.group_id)))]), + }; + }, { groupId: Number(params.group_id) }), + }, + buildUserInfoTool(), + buildUserMessagesSendTool(), + buildUserWallPostTool(), + buildUserFriendsListTool(), + buildGroupWallPostTool(), + buildGroupWallEditTool(), + buildGroupWallDeleteTool(), + buildGroupPinPostTool(), + buildGroupUploadPhotoTool(), + buildGroupCreatePollTool(), + buildGroupCommentDeleteTool(), + buildGroupCommentHideSpamTool(), + buildGroupBanUserTool(), + buildGroupUnbanUserTool(), + buildGroupBlacklistListTool(), + buildGroupCleanWallTool(), + buildGroupMembersListTool(), + buildGroupInviteTool(), + buildGroupRemoveTool(), + buildGroupSetRoleTool(), + buildGroupInfoTool(), + buildGroupUpdateSettingsTool(), + buildGroupUpdateCoverTool(), + buildGroupUpdateAvatarTool(), + buildGroupStatsTool(), + buildGroupPostReachTool(), + buildGroupAudienceTool(), + buildGroupMsgSendTool(), + buildGroupMsgHistoryTool(), + buildGroupMsgSetTypingTool(), + ]; + + function buildUserInfoTool() { + return { + name: "vk_user_info", + description: + "Get VK user profile data including status, online state, screen name, and optional fields.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + user_ids: { + type: "array", + description: "VK user IDs or screen names. Omit for the token owner.", + items: { type: "string" }, + }, + fields: { + type: "array", + description: "VK users.get fields.", + items: { type: "string" }, + }, + }, + }, + execute: async (params) => executeTool("vk_user_info", async () => { + const session = await getUserSession(); + const data = await callVk(session, "users.get", { + user_ids: normalizeList(params.user_ids).join(",") || undefined, + fields: normalizeList(params.fields, [ + "photo_200", + "screen_name", + "online", + "status", + "city", + "country", + "bdate", + ]).join(","), + }); + return { users: data }; + }), + }; + } + + function buildUserMessagesSendTool() { + return { + name: "vk_user_messages_send", + description: + "Send a VK direct message from the user account to user_id or peer_id. Uses random_id for idempotency.", + scope: "dm-only", + category: "action", + parameters: { + type: "object", + properties: { + user_id: { type: "integer", description: "Recipient user ID." }, + peer_id: { type: "integer", description: "Recipient peer ID for chats or users." }, + message: { type: "string", description: "Message text." }, + attachment: { type: "string", description: "Optional VK attachment string." }, + random_id: { type: "integer", description: "Optional idempotency key." }, + }, + required: ["message"], + }, + execute: async (params) => executeTool("vk_user_messages_send", async () => { + const session = await getUserSession(); + const response = await callVk(session, "messages.send", { + user_id: params.user_id, + peer_id: params.peer_id, + message: params.message, + attachment: params.attachment, + random_id: params.random_id ?? randomId(), + }); + sdk?.log?.info?.("VK user message sent."); + return { response }; + }), + }; + } + + function buildUserWallPostTool() { + return { + name: "vk_user_wall_post", + description: + "Publish a post on the VK user's wall or a user-managed owner_id using vk_user_token.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Optional wall owner ID. Omit for own wall." }, + message: { type: "string", description: "Post text." }, + attachments: { type: "string", description: "Optional VK attachment string." }, + signed: { type: "boolean", description: "Sign the post when supported.", default: false }, + }, + required: ["message"], + }, + execute: async (params) => executeTool("vk_user_wall_post", async () => { + const session = await getUserSession(); + const response = await callVk(session, "wall.post", { + owner_id: params.owner_id, + message: params.message, + attachments: params.attachments, + signed: boolFlag(params.signed), + }); + sdk?.log?.info?.("VK user wall post created."); + return { response }; + }), + }; + } + + function buildUserFriendsListTool() { + return { + name: "vk_user_friends_list", + description: "List VK friends for the token owner or another visible user.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + user_id: { type: "integer", description: "Optional VK user ID." }, + fields: { type: "array", items: { type: "string" }, description: "Friend fields." }, + order: { type: "string", description: "VK friends.get order, for example name or hints." }, + count: { type: "integer", description: "Maximum number of friends to return.", minimum: 1, maximum: 5000 }, + offset: { type: "integer", description: "Pagination offset.", minimum: 0 }, + }, + }, + execute: async (params) => executeTool("vk_user_friends_list", async () => { + const session = await getUserSession(); + const response = await callVk(session, "friends.get", { + user_id: params.user_id, + fields: normalizeList(params.fields).join(",") || undefined, + order: params.order, + count: params.count, + offset: params.offset, + }); + return { friends: response }; + }), + }; + } + + function buildGroupWallPostTool() { + return { + name: "vk_group_wall_post", + description: + "Publish a post on a managed VK community wall. Requires Editor/Admin rights and a community token.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Negative community owner ID, for example -123456." }, + group_id: { type: "integer", description: "Alternative community ID without minus sign." }, + message: { type: "string", description: "Post text up to VK limits." }, + attachments: { type: "string", description: "Optional VK attachment string." }, + publish_date: { type: "integer", description: "Unix timestamp for scheduled publication." }, + close_comments: { type: "boolean", description: "Disable comments when VK allows it.", default: false }, + signed: { type: "boolean", description: "Sign post with administrator name.", default: false }, + }, + required: ["message"], + }, + execute: async (params) => { + const { groupId, ownerId } = normalizeGroupOwner(params); + return executeTool("vk_group_wall_post", async () => { + const { communitySession, admin } = await communityAction(groupId, { minRole: "editor" }); + const response = await callVk(communitySession, "wall.post", { + owner_id: ownerId, + from_group: 1, + message: params.message, + attachments: params.attachments, + publish_date: params.publish_date, + close_comments: boolFlag(params.close_comments), + signed: boolFlag(params.signed), + }); + sdk?.log?.info?.(`VK group ${groupId} wall post created.`); + return { group_id: groupId, owner_id: ownerId, admin_role: admin.role, response }; + }, { groupId }); + }, + }; + } + + function buildGroupWallEditTool() { + return { + name: "vk_group_wall_edit", + description: "Edit a post on a managed VK community wall. Requires Editor/Admin rights.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Negative community owner ID." }, + group_id: { type: "integer", description: "Alternative community ID without minus sign." }, + post_id: { type: "integer", description: "Post ID." }, + message: { type: "string", description: "Updated post text." }, + attachments: { type: "string", description: "Updated VK attachment string." }, + }, + required: ["post_id"], + }, + execute: async (params) => { + const { groupId, ownerId } = normalizeGroupOwner(params); + return executeTool("vk_group_wall_edit", async () => { + const { communitySession } = await communityAction(groupId, { minRole: "editor" }); + const response = await callVk(communitySession, "wall.edit", { + owner_id: ownerId, + post_id: params.post_id, + message: params.message, + attachments: params.attachments, + }); + return { group_id: groupId, owner_id: ownerId, post_id: params.post_id, response }; + }, { groupId }); + }, + }; + } + + function buildGroupWallDeleteTool() { + return { + name: "vk_group_wall_delete", + description: "Delete a post or repost from a managed VK community wall.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Negative community owner ID." }, + group_id: { type: "integer", description: "Alternative community ID without minus sign." }, + post_id: { type: "integer", description: "Post ID to delete." }, + }, + required: ["post_id"], + }, + execute: async (params) => { + const { groupId, ownerId } = normalizeGroupOwner(params); + return executeTool("vk_group_wall_delete", async () => { + const { communitySession } = await communityAction(groupId, { minRole: "editor" }); + const response = await callVk(communitySession, "wall.delete", { + owner_id: ownerId, + post_id: params.post_id, + }); + return { group_id: groupId, owner_id: ownerId, post_id: params.post_id, response }; + }, { groupId }); + }, + }; + } + + function buildGroupPinPostTool() { + return { + name: "vk_group_pin_post", + description: "Pin or unpin a post on a managed VK community wall.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Negative community owner ID." }, + group_id: { type: "integer", description: "Alternative community ID without minus sign." }, + post_id: { type: "integer", description: "Post ID." }, + pin: { type: "boolean", description: "true to pin, false to unpin.", default: true }, + }, + required: ["post_id", "pin"], + }, + execute: async (params) => { + const { groupId, ownerId } = normalizeGroupOwner(params); + return executeTool("vk_group_pin_post", async () => { + const { communitySession } = await communityAction(groupId, { minRole: "editor" }); + const method = params.pin ? "wall.pin" : "wall.unpin"; + const response = await callVk(communitySession, method, { + owner_id: ownerId, + post_id: params.post_id, + }); + return { group_id: groupId, owner_id: ownerId, post_id: params.post_id, pinned: Boolean(params.pin), response }; + }, { groupId }); + }, + }; + } + + function buildGroupUploadPhotoTool() { + return { + name: "vk_group_upload_photo", + description: "Upload a photo to a VK community wall or album using vk-io upload helpers.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Negative community owner ID." }, + group_id: { type: "integer", description: "Alternative community ID without minus sign." }, + file_path: { type: "string", description: "Local image path accessible to Teleton." }, + album_id: { type: "integer", description: "Optional album ID. Omit to upload wall photo." }, + caption: { type: "string", description: "Optional photo caption." }, + }, + required: ["file_path"], + }, + execute: async (params) => { + const { groupId } = normalizeGroupOwner(params); + return executeTool("vk_group_upload_photo", async () => { + const { communitySession } = await communityAction(groupId, { minRole: "editor" }); + const source = { value: params.file_path }; + const result = params.album_id + ? await uploadVk(communitySession, "photoAlbum", { + group_id: groupId, + album_id: params.album_id, + caption: params.caption, + source, + }) + : await uploadVk(communitySession, "wallPhoto", { + group_id: groupId, + caption: params.caption, + source, + }); + return { + group_id: groupId, + attachment: Array.isArray(result) ? result.map(attachmentToString) : attachmentToString(result), + raw: result, + }; + }, { groupId }); + }, + }; + } + + function buildGroupCreatePollTool() { + return { + name: "vk_group_create_poll", + description: "Create a VK poll owned by a managed community.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Negative community owner ID." }, + group_id: { type: "integer", description: "Alternative community ID without minus sign." }, + question: { type: "string", description: "Poll question." }, + options: { + type: "array", + description: "Poll answer options.", + items: { type: "string" }, + minItems: 2, + }, + anonymous: { type: "boolean", description: "Create an anonymous poll.", default: true }, + is_multiple: { type: "boolean", description: "Allow multiple choices.", default: false }, + end_date: { type: "integer", description: "Unix timestamp when poll closes." }, + }, + required: ["question", "options"], + }, + execute: async (params) => { + const { groupId, ownerId } = normalizeGroupOwner(params); + return executeTool("vk_group_create_poll", async () => { + const { communitySession } = await communityAction(groupId, { minRole: "editor" }); + const response = await callVk(communitySession, "polls.create", { + owner_id: ownerId, + question: params.question, + add_answers: JSON.stringify(params.options ?? []), + is_anonymous: params.anonymous === false ? 0 : 1, + is_multiple: boolFlag(params.is_multiple), + end_date: params.end_date, + }); + return { group_id: groupId, owner_id: ownerId, response }; + }, { groupId }); + }, + }; + } + + function buildGroupCommentDeleteTool() { + return { + name: "vk_group_comment_delete", + description: "Delete a comment from a managed VK community wall.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Negative community owner ID." }, + group_id: { type: "integer", description: "Alternative community ID without minus sign." }, + comment_id: { type: "integer", description: "Comment ID to delete." }, + }, + required: ["comment_id"], + }, + execute: async (params) => { + const { groupId, ownerId } = normalizeGroupOwner(params); + return executeTool("vk_group_comment_delete", async () => { + const { communitySession } = await communityAction(groupId); + const response = await callVk(communitySession, "wall.deleteComment", { + owner_id: ownerId, + comment_id: params.comment_id, + }); + return { group_id: groupId, owner_id: ownerId, comment_id: params.comment_id, response }; + }, { groupId }); + }, + }; + } + + function buildGroupCommentHideSpamTool() { + return { + name: "vk_group_comment_hide_spam", + description: "Report a VK community wall comment as spam or another moderation reason.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Negative community owner ID." }, + group_id: { type: "integer", description: "Alternative community ID without minus sign." }, + comment_id: { type: "integer", description: "Comment ID." }, + reason: { + type: "integer", + description: "VK report reason code. 0 is spam.", + default: 0, + }, + }, + required: ["comment_id"], + }, + execute: async (params) => { + const { groupId, ownerId } = normalizeGroupOwner(params); + return executeTool("vk_group_comment_hide_spam", async () => { + const { communitySession } = await communityAction(groupId); + const response = await callVk(communitySession, "wall.reportComment", { + owner_id: ownerId, + comment_id: params.comment_id, + reason: params.reason ?? 0, + }); + return { group_id: groupId, owner_id: ownerId, comment_id: params.comment_id, response }; + }, { groupId }); + }, + }; + } + + function buildGroupBanUserTool() { + return { + name: "vk_group_ban_user", + description: "Ban a user or guest from a managed VK community.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + user_id: { type: "integer", description: "User ID to ban." }, + comment: { type: "string", description: "Optional private moderation comment." }, + end_date: { type: "integer", description: "Unix timestamp for temporary ban end." }, + reason: { type: "integer", description: "VK ban reason code." }, + comment_visible: { type: "boolean", description: "Show comment to banned user.", default: false }, + }, + required: ["group_id", "user_id"], + }, + execute: async (params) => executeTool("vk_group_ban_user", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId); + const response = await callVk(communitySession, "groups.ban", { + group_id: groupId, + owner_id: params.user_id, + comment: params.comment, + end_date: params.end_date, + reason: params.reason, + comment_visible: boolFlag(params.comment_visible), + }); + return { group_id: groupId, user_id: params.user_id, response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupUnbanUserTool() { + return { + name: "vk_group_unban_user", + description: "Remove a user or guest from a VK community blacklist.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + user_id: { type: "integer", description: "User ID to unban." }, + }, + required: ["group_id", "user_id"], + }, + execute: async (params) => executeTool("vk_group_unban_user", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId); + const response = await callVk(communitySession, "groups.unban", { + group_id: groupId, + owner_id: params.user_id, + }); + return { group_id: groupId, user_id: params.user_id, response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupBlacklistListTool() { + return { + name: "vk_group_blacklist_list", + description: "List banned users for a managed VK community.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + offset: { type: "integer", description: "Pagination offset.", minimum: 0 }, + count: { type: "integer", description: "Number of banned entries.", minimum: 1, maximum: 200 }, + }, + required: ["group_id"], + }, + execute: async (params) => executeTool("vk_group_blacklist_list", async () => { + const groupId = Math.abs(Number(params.group_id)); + await assertGroupAdmin(groupId); + const session = await getUserSession(); + const response = await callVk(session, "groups.getBanned", { + group_id: groupId, + offset: params.offset, + count: params.count ?? 20, + }); + return { group_id: groupId, banned: response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupCleanWallTool() { + return { + name: "vk_group_clean_wall", + description: + "Find and optionally delete multiple VK community wall posts. Defaults to dry_run=true for safety.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Negative community owner ID." }, + group_id: { type: "integer", description: "Alternative community ID without minus sign." }, + filter: { + type: "string", + description: "all, owner, others, or contains. contains uses query.", + enum: ["all", "owner", "others", "contains"], + default: "all", + }, + query: { type: "string", description: "Text substring for filter=contains." }, + count: { type: "integer", description: "Maximum posts to inspect/delete.", minimum: 1, maximum: 100 }, + dry_run: { type: "boolean", description: "When true, only return matching posts.", default: true }, + }, + }, + execute: async (params) => { + const { groupId, ownerId } = normalizeGroupOwner(params); + return executeTool("vk_group_clean_wall", async () => { + const { communitySession } = await communityAction(groupId, { minRole: "editor" }); + const requestedCount = clamp(Number(params.count ?? 20), 1, 100); + const wall = await callVk(communitySession, "wall.get", { + owner_id: ownerId, + count: requestedCount, + }); + const posts = Array.isArray(wall?.items) ? wall.items : []; + const matches = posts + .filter((post) => matchesCleanFilter(post, ownerId, params.filter ?? "all", params.query)) + .slice(0, requestedCount); + + if (params.dry_run !== false) { + return { + group_id: groupId, + owner_id: ownerId, + dry_run: true, + matched_count: matches.length, + posts: matches.map(compactPost), + }; + } + + const deleted = []; + for (const post of matches) { + const response = await callVk(communitySession, "wall.delete", { + owner_id: ownerId, + post_id: post.id, + }); + deleted.push({ post_id: post.id, response }); + } + return { group_id: groupId, owner_id: ownerId, dry_run: false, deleted }; + }, { groupId }); + }, + }; + } + + function buildGroupMembersListTool() { + return { + name: "vk_group_members_list", + description: "List members or subscribers of a managed VK community.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + sort: { type: "string", description: "VK sorting mode." }, + count: { type: "integer", description: "Number of members.", minimum: 1, maximum: 1000 }, + offset: { type: "integer", description: "Pagination offset.", minimum: 0 }, + fields: { type: "array", items: { type: "string" }, description: "Member fields." }, + }, + required: ["group_id"], + }, + execute: async (params) => executeTool("vk_group_members_list", async () => { + const groupId = Math.abs(Number(params.group_id)); + await assertGroupAdmin(groupId); + const session = await getUserSession(); + const response = await callVk(session, "groups.getMembers", { + group_id: groupId, + sort: params.sort, + count: params.count ?? 100, + offset: params.offset, + fields: normalizeList(params.fields).join(",") || undefined, + }); + return { group_id: groupId, members: response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupInviteTool() { + return { + name: "vk_group_invite", + description: "Invite a VK user to a managed community.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + user_id: { type: "integer", description: "User ID to invite." }, + text: { type: "string", description: "Optional note returned in tool output." }, + }, + required: ["group_id", "user_id"], + }, + execute: async (params) => executeTool("vk_group_invite", async () => { + const groupId = Math.abs(Number(params.group_id)); + await assertGroupAdmin(groupId, { minRole: "editor" }); + const session = await getUserSession(); + const response = await callVk(session, "groups.invite", { + group_id: groupId, + user_id: params.user_id, + }); + return { group_id: groupId, user_id: params.user_id, note: params.text ?? null, response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupRemoveTool() { + return { + name: "vk_group_remove", + description: "Remove a user from a managed VK community.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + user_id: { type: "integer", description: "User ID to remove." }, + message: { type: "string", description: "Optional note returned in tool output." }, + }, + required: ["group_id", "user_id"], + }, + execute: async (params) => executeTool("vk_group_remove", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId); + const response = await callVk(communitySession, "groups.removeUser", { + group_id: groupId, + user_id: params.user_id, + }); + return { group_id: groupId, user_id: params.user_id, note: params.message ?? null, response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupSetRoleTool() { + return { + name: "vk_group_set_role", + description: "Assign or remove a community manager role. Requires administrator rights.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + user_id: { type: "integer", description: "User ID whose role will change." }, + role: { + type: "string", + enum: ["admin", "editor", "moderator", "none"], + description: "Target role. admin maps to VK administrator.", + }, + }, + required: ["group_id", "user_id", "role"], + }, + execute: async (params) => executeTool("vk_group_set_role", async () => { + const groupId = Math.abs(Number(params.group_id)); + await assertGroupAdmin(groupId, { minRole: "admin" }); + const session = await getUserSession(); + const response = await callVk(session, "groups.editManager", { + group_id: groupId, + user_id: params.user_id, + role: vkManagerRole(params.role), + }); + return { group_id: groupId, user_id: params.user_id, role: params.role, response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupInfoTool() { + return { + name: "vk_group_info", + description: "Get information for one or more VK communities after validating admin rights.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + group_ids: { + type: "array", + description: "Community IDs without minus sign. First ID is used for admin validation.", + items: { type: "integer" }, + }, + fields: { + type: "array", + description: "VK groups.getById fields.", + items: { type: "string" }, + }, + }, + required: ["group_ids"], + }, + execute: async (params) => executeTool("vk_group_info", async () => { + const groupIds = normalizeList(params.group_ids).map((id) => Math.abs(Number(id))).filter(Boolean); + if (!groupIds.length) throw new Error("group_ids must contain at least one community ID."); + await assertGroupAdmin(groupIds[0]); + const session = await getUserSession(); + const response = await callVk(session, "groups.getById", { + group_ids: groupIds.join(","), + fields: normalizeList(params.fields, [ + "description", + "members_count", + "activity", + "status", + "site", + "is_admin", + "admin_level", + ]).join(","), + }); + return { groups: response }; + }, { groupId: Number(normalizeList(params.group_ids)[0]) }), + }; + } + + function buildGroupUpdateSettingsTool() { + return { + name: "vk_group_update_settings", + description: "Update managed VK community settings such as title, description, type, or access.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + title: { type: "string", description: "New community title." }, + description: { type: "string", description: "New community description." }, + type: { type: "string", enum: ["group", "event", "public"], description: "Community type." }, + access: { type: "integer", description: "VK access mode." }, + website: { type: "string", description: "Community website URL." }, + }, + required: ["group_id"], + }, + execute: async (params) => executeTool("vk_group_update_settings", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId, { minRole: "admin" }); + const response = await callVk(communitySession, "groups.edit", { + group_id: groupId, + title: params.title, + description: params.description, + type: params.type, + access: params.access, + website: params.website, + }); + return { group_id: groupId, response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupUpdateCoverTool() { + return { + name: "vk_group_update_cover", + description: "Upload and set the cover image for a managed VK community.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + file_path: { type: "string", description: "Local cover image path." }, + crop_x: { type: "integer", description: "Optional crop start X." }, + crop_y: { type: "integer", description: "Optional crop start Y." }, + crop_x2: { type: "integer", description: "Optional crop end X." }, + crop_y2: { type: "integer", description: "Optional crop end Y." }, + }, + required: ["group_id", "file_path"], + }, + execute: async (params) => executeTool("vk_group_update_cover", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId, { minRole: "admin" }); + const response = await uploadVk(communitySession, "groupCover", { + group_id: groupId, + source: { value: params.file_path }, + crop_x: params.crop_x, + crop_y: params.crop_y, + crop_x2: params.crop_x2, + crop_y2: params.crop_y2, + }); + return { group_id: groupId, response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupUpdateAvatarTool() { + return { + name: "vk_group_update_avatar", + description: "Upload and set the main avatar photo for a managed VK community.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + file_path: { type: "string", description: "Local image path." }, + }, + required: ["group_id", "file_path"], + }, + execute: async (params) => executeTool("vk_group_update_avatar", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId, { minRole: "admin" }); + const response = await uploadVk(communitySession, "ownerPhoto", { + owner_id: -groupId, + source: { value: params.file_path }, + }); + return { group_id: groupId, response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupStatsTool() { + return { + name: "vk_group_stats", + description: "Get VK community statistics for a period.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + interval: { type: "string", enum: ["day", "week", "month"], description: "VK stats interval." }, + stats_groups: { type: "array", items: { type: "string" }, description: "Stats groups such as visitors or reach." }, + date_from: { type: "string", description: "Start date YYYY-MM-DD or Unix timestamp." }, + date_to: { type: "string", description: "End date YYYY-MM-DD or Unix timestamp." }, + }, + required: ["group_id"], + }, + execute: async (params) => executeTool("vk_group_stats", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId, { minRole: "editor" }); + const response = await callVk(communitySession, "stats.get", { + group_id: groupId, + interval: params.interval ?? "day", + stats_groups: normalizeList(params.stats_groups).join(",") || undefined, + timestamp_from: parseDateOrTimestamp(params.date_from), + timestamp_to: parseDateOrTimestamp(params.date_to), + }); + return { group_id: groupId, stats: response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupPostReachTool() { + return { + name: "vk_group_post_reach", + description: "Get reach and engagement statistics for VK community posts.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + owner_id: { type: "integer", description: "Negative community owner ID." }, + group_id: { type: "integer", description: "Alternative community ID without minus sign." }, + post_ids: { + type: "array", + description: "Post IDs to inspect.", + items: { type: "integer" }, + }, + }, + required: ["post_ids"], + }, + execute: async (params) => { + const { groupId, ownerId } = normalizeGroupOwner(params); + return executeTool("vk_group_post_reach", async () => { + const { communitySession } = await communityAction(groupId, { minRole: "editor" }); + const response = await callVk(communitySession, "wall.getPostReach", { + owner_id: ownerId, + post_ids: normalizeList(params.post_ids).join(","), + }); + return { group_id: groupId, owner_id: ownerId, reach: response }; + }, { groupId }); + }, + }; + } + + function buildGroupAudienceTool() { + return { + name: "vk_group_audience", + description: "Get VK community audience visitor demographics for a period.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + date_from: { type: "string", description: "Start date YYYY-MM-DD or Unix timestamp." }, + date_to: { type: "string", description: "End date YYYY-MM-DD or Unix timestamp." }, + }, + required: ["group_id"], + }, + execute: async (params) => executeTool("vk_group_audience", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId, { minRole: "editor" }); + const response = await callVk(communitySession, "stats.get", { + group_id: groupId, + interval: "day", + stats_groups: "visitors", + timestamp_from: parseDateOrTimestamp(params.date_from), + timestamp_to: parseDateOrTimestamp(params.date_to), + }); + return { group_id: groupId, audience: response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupMsgSendTool() { + return { + name: "vk_group_msg_send", + description: "Send a VK message from a managed community to a peer.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + peer_id: { type: "integer", description: "VK peer ID." }, + message: { type: "string", description: "Message text." }, + random_id: { type: "integer", description: "Optional idempotency key." }, + attachment: { type: "string", description: "Optional VK attachment string." }, + keyboard: { type: "string", description: "Optional VK keyboard JSON string." }, + }, + required: ["group_id", "peer_id", "message"], + }, + execute: async (params) => executeTool("vk_group_msg_send", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId); + const response = await callVk(communitySession, "messages.send", { + group_id: groupId, + peer_id: params.peer_id, + message: params.message, + random_id: params.random_id ?? randomId(), + attachment: params.attachment, + keyboard: params.keyboard, + }); + return { group_id: groupId, peer_id: params.peer_id, response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupMsgHistoryTool() { + return { + name: "vk_group_msg_history", + description: "Read message history for a VK community dialog.", + scope: "admin-only", + category: "data-bearing", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + peer_id: { type: "integer", description: "VK peer ID." }, + count: { type: "integer", description: "Number of messages.", minimum: 1, maximum: 200 }, + offset: { type: "integer", description: "Pagination offset.", minimum: 0 }, + }, + required: ["group_id", "peer_id"], + }, + execute: async (params) => executeTool("vk_group_msg_history", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId); + const response = await callVk(communitySession, "messages.getHistory", { + group_id: groupId, + peer_id: params.peer_id, + count: params.count ?? 20, + offset: params.offset, + }); + return { group_id: groupId, peer_id: params.peer_id, history: response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } + + function buildGroupMsgSetTypingTool() { + return { + name: "vk_group_msg_set_typing", + description: "Set typing or videocall activity in a VK community dialog.", + scope: "admin-only", + category: "action", + parameters: { + type: "object", + properties: { + group_id: { type: "integer", description: "Community ID without minus sign." }, + peer_id: { type: "integer", description: "VK peer ID." }, + type: { + type: "string", + enum: ["typing", "videocall"], + description: "Activity type.", + default: "typing", + }, + }, + required: ["group_id", "peer_id"], + }, + execute: async (params) => executeTool("vk_group_msg_set_typing", async () => { + const groupId = Math.abs(Number(params.group_id)); + const { communitySession } = await communityAction(groupId); + const response = await callVk(communitySession, "messages.setActivity", { + group_id: groupId, + peer_id: params.peer_id, + type: params.type ?? "typing", + }); + return { group_id: groupId, peer_id: params.peer_id, type: params.type ?? "typing", response }; + }, { groupId: Math.abs(Number(params.group_id)) }), + }; + } +} + +function hashToken(token) { + return createHash("sha256").update(String(token)).digest("hex").slice(0, 16); +} + +function cleanParams(params) { + const cleaned = {}; + for (const [key, value] of Object.entries(params ?? {})) { + if (value !== undefined && value !== null && value !== "") cleaned[key] = value; + } + return cleaned; +} + +function getVkErrorCode(err) { + return Number(err?.code ?? err?.error_code ?? err?.error?.error_code ?? 0); +} + +function sanitizeError(err) { + let message = String(err?.message ?? err?.error_msg ?? err?.error?.error_msg ?? err); + message = message.replace(/(access_token=)[^\s&]+/gi, "$1[redacted]"); + message = message.replace(/("access_token"\s*:\s*")[^"]+(")/gi, "$1[redacted]$2"); + message = message.replace(/(token=)[^\s&]+/gi, "$1[redacted]"); + return message.slice(0, MAX_ERROR_LENGTH); +} + +function formatVkError(err) { + const code = getVkErrorCode(err); + const message = sanitizeError(err); + if (code === 5) { + return `VK API error 5: invalid or expired access token. Update vk_user_token or vk_community_tokens. ${message}`; + } + if (code === 7 || code === 15) { + return `VK API error ${code}: access denied or insufficient permissions. ${message}`; + } + if (code === 260) { + return `VK API error 260: rate limit exceeded. ${message}`; + } + return message; +} + +function normalizeGroupOwner(params) { + const rawGroup = params.group_id ?? params.owner_id; + if (rawGroup === undefined || rawGroup === null) { + throw new Error("group_id or owner_id is required for community tools."); + } + const groupId = Math.abs(Number(rawGroup)); + if (!Number.isSafeInteger(groupId) || groupId <= 0) { + throw new Error("group_id/owner_id must resolve to a positive community ID."); + } + return { groupId, ownerId: -groupId }; +} + +function roleFromAdminPayload(payload) { + const item = Array.isArray(payload) ? payload[0] : payload; + if (!item || item.member === 0) return "none"; + + const managerRole = normalizeRoleName(item.manager_role ?? item.role); + if (ADMIN_ROLES.has(managerRole)) return managerRole; + + const adminLevel = Number(item.admin_level ?? item.level ?? 0); + if (adminLevel >= 3) return "admin"; + if (adminLevel === 2) return "editor"; + if (adminLevel === 1) return "moderator"; + if (item.is_admin === 1 || item.is_admin === true) return "admin"; + return item.member === 1 || item.member === true ? "member" : "none"; +} + +function normalizeRoleName(role) { + const value = String(role ?? "none").toLowerCase(); + if (value === "administrator") return "admin"; + return value; +} + +function normalizeRequestedRole(role) { + const normalized = normalizeRoleName(role); + return normalized === "administrator" ? "admin" : normalized; +} + +function isRoleAllowed(actualRole, requiredRole) { + return (ROLE_RANK[normalizeRoleName(actualRole)] ?? 0) >= (ROLE_RANK[normalizeRequestedRole(requiredRole)] ?? 0); +} + +function normalizeList(value, fallback = []) { + if (value === undefined || value === null || value === "") return fallback; + if (Array.isArray(value)) return value.filter((item) => item !== undefined && item !== null && item !== ""); + return [value]; +} + +function boolFlag(value) { + return value ? 1 : undefined; +} + +function randomId() { + return randomInt(1, 2_147_483_647); +} + +function scopeMask(scopes, scopeMap) { + let mask = 0; + for (const scope of normalizeList(scopes)) { + const value = scopeMap.get(String(scope)); + if (!value) throw new Error(`Unknown VK scope: ${scope}`); + mask |= value; + } + return String(mask); +} + +function buildOAuthUrl({ clientId, redirectUri, scope, groupIds, revoke = false }) { + const url = new URL("https://oauth.vk.com/authorize"); + url.searchParams.set("client_id", String(clientId)); + url.searchParams.set("display", "page"); + url.searchParams.set("redirect_uri", redirectUri ?? "https://oauth.vk.com/blank.html"); + url.searchParams.set("scope", String(scope)); + url.searchParams.set("response_type", "token"); + url.searchParams.set("v", DEFAULT_API_VERSION); + if (groupIds?.length) { + url.searchParams.set("group_ids", normalizeList(groupIds).map((id) => Math.abs(Number(id))).join(",")); + } + if (revoke) url.searchParams.set("revoke", "1"); + return url.toString(); +} + +function attachmentToString(attachment) { + if (!attachment) return null; + if (typeof attachment === "string") return attachment; + if (typeof attachment.toString === "function") return attachment.toString(); + const ownerId = attachment.owner_id ?? attachment.ownerId; + const id = attachment.id; + const accessKey = attachment.access_key ?? attachment.accessKey; + if (ownerId && id) return `photo${ownerId}_${id}${accessKey ? `_${accessKey}` : ""}`; + return attachment; +} + +function matchesCleanFilter(post, ownerId, filter, query) { + if (filter === "all") return true; + if (filter === "owner") return Number(post.from_id) === ownerId; + if (filter === "others") return Number(post.from_id) !== ownerId; + if (filter === "contains") { + const needle = String(query ?? "").toLowerCase(); + if (!needle) return false; + return String(post.text ?? "").toLowerCase().includes(needle); + } + return false; +} + +function compactPost(post) { + return { + id: post.id, + from_id: post.from_id, + date: post.date, + text: String(post.text ?? "").slice(0, 300), + }; +} + +function clamp(value, min, max) { + if (!Number.isFinite(value)) return min; + return Math.min(max, Math.max(min, value)); +} + +function parseDateOrTimestamp(value) { + if (value === undefined || value === null || value === "") return undefined; + if (/^\d+$/.test(String(value))) return Number(value); + const timestamp = Date.parse(`${value}T00:00:00Z`); + if (Number.isNaN(timestamp)) throw new Error(`Invalid date: ${value}`); + return Math.floor(timestamp / 1000); +} + +function vkManagerRole(role) { + if (role === "none") return undefined; + if (role === "admin") return "administrator"; + return role; +} diff --git a/plugins/vk-full-admin/manifest.json b/plugins/vk-full-admin/manifest.json new file mode 100644 index 0000000..533819a --- /dev/null +++ b/plugins/vk-full-admin/manifest.json @@ -0,0 +1,64 @@ +{ + "id": "vk-full-admin", + "name": "VK Full Admin", + "version": "1.0.0", + "description": "VK personal account and community administration with publishing, moderation, members, settings, analytics, and group dialogs", + "author": { + "name": "xlabtg", + "url": "https://github.com/xlabtg" + }, + "license": "MIT", + "entry": "index.js", + "teleton": ">=1.0.0", + "sdkVersion": ">=1.0.0", + "secrets": { + "vk_user_token": { + "required": true, + "description": "VK user access token created through VK OAuth implicit flow; use the plugin vk_auth_user_url helper or https://oauth.vk.com/blank.html redirect with offline, wall, messages, friends, photos, groups, stats, and notifications scopes" + }, + "vk_community_tokens": { + "required": false, + "description": "JSON object mapping community IDs without minus signs to community tokens created in VK community Manage -> Settings -> API usage -> Access tokens, for example {\"123456\":\"token\"}" + } + }, + "tools": [ + { "name": "vk_auth_status", "description": "Check VK token configuration and optionally validate the user token" }, + { "name": "vk_auth_user_url", "description": "Build a VK OAuth implicit-flow URL for a user token" }, + { "name": "vk_auth_group_url", "description": "Build a VK OAuth URL for community tokens" }, + { "name": "vk_group_admin_check", "description": "Validate admin rights in a VK community" }, + { "name": "vk_user_info", "description": "Get VK user profile information" }, + { "name": "vk_user_messages_send", "description": "Send a VK direct message from the user account" }, + { "name": "vk_user_wall_post", "description": "Publish a VK user wall post" }, + { "name": "vk_user_friends_list", "description": "List VK friends" }, + { "name": "vk_group_wall_post", "description": "Publish a post on a VK community wall" }, + { "name": "vk_group_wall_edit", "description": "Edit a VK community wall post" }, + { "name": "vk_group_wall_delete", "description": "Delete a VK community wall post" }, + { "name": "vk_group_pin_post", "description": "Pin or unpin a VK community wall post" }, + { "name": "vk_group_upload_photo", "description": "Upload a photo to a VK community" }, + { "name": "vk_group_create_poll", "description": "Create a VK community poll" }, + { "name": "vk_group_comment_delete", "description": "Delete a VK community wall comment" }, + { "name": "vk_group_comment_hide_spam", "description": "Report a VK community wall comment" }, + { "name": "vk_group_ban_user", "description": "Ban a VK user from a community" }, + { "name": "vk_group_unban_user", "description": "Unban a VK user from a community" }, + { "name": "vk_group_blacklist_list", "description": "List VK community blacklist entries" }, + { "name": "vk_group_clean_wall", "description": "Dry-run or delete matching VK community wall posts" }, + { "name": "vk_group_members_list", "description": "List VK community members" }, + { "name": "vk_group_invite", "description": "Invite a user to a VK community" }, + { "name": "vk_group_remove", "description": "Remove a user from a VK community" }, + { "name": "vk_group_set_role", "description": "Set a VK community manager role" }, + { "name": "vk_group_info", "description": "Get VK community information" }, + { "name": "vk_group_update_settings", "description": "Update VK community settings" }, + { "name": "vk_group_update_cover", "description": "Update a VK community cover image" }, + { "name": "vk_group_update_avatar", "description": "Update a VK community avatar" }, + { "name": "vk_group_stats", "description": "Get VK community statistics" }, + { "name": "vk_group_post_reach", "description": "Get VK community post reach" }, + { "name": "vk_group_audience", "description": "Get VK community audience demographics" }, + { "name": "vk_group_msg_send", "description": "Send a VK message from a community" }, + { "name": "vk_group_msg_history", "description": "Read VK community dialog history" }, + { "name": "vk_group_msg_set_typing", "description": "Set VK community dialog activity" } + ], + "permissions": [], + "tags": ["vk", "social", "admin", "community", "moderation", "analytics", "messages"], + "repository": "https://github.com/TONresistor/teleton-plugins", + "funding": null +} diff --git a/plugins/vk-full-admin/package-lock.json b/plugins/vk-full-admin/package-lock.json new file mode 100644 index 0000000..7ad2edc --- /dev/null +++ b/plugins/vk-full-admin/package-lock.json @@ -0,0 +1,607 @@ +{ + "name": "teleton-plugin-vk-full-admin", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "teleton-plugin-vk-full-admin", + "version": "1.0.0", + "dependencies": { + "@vk-io/authorization": "^1.4.0", + "vk-io": "^4.8.0" + } + }, + "node_modules/@vk-io/authorization": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@vk-io/authorization/-/authorization-1.4.1.tgz", + "integrity": "sha512-9QFU7dIWf67vKoi6YXjHzB329BuX3mYmIlMeqlHOwcbc5OUoTtdJsy/lmzJZAu9XGhMMPtJ8JJ3BgrL/FJLv3Q==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "cheerio": "^1.0.0-rc.12", + "debug": "^4.3.4", + "inspectable": "^2.0.0", + "node-fetch": "^3.3.1", + "tough-cookie": "^4.1.2" + }, + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "vk-io": "^4.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/form-data-encoder": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.9.0.tgz", + "integrity": "sha512-rahaRMkN8P8d/tgK/BLPX+WBVM27NbvdXBxqQujBtkDAIFspaRqN7Od7lfdGQA6KAD+f82fYCLBq1ipvcu8qLw==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inspectable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/inspectable/-/inspectable-2.1.1.tgz", + "integrity": "sha512-emgKBW9bb33IjcVozbsLsgmx/PD8pEemmAEDi+5ruSOuKJ9x/KneVJaOr/7Z93jXlpfNaW2qoqfhKB2qtqcGow==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "reflect-metadata": "^0.1.13" + }, + "peerDependenciesMeta": { + "reflect-metadata": { + "optional": true + } + } + }, + "node_modules/middleware-io": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/middleware-io/-/middleware-io-2.8.1.tgz", + "integrity": "sha512-H0XftkexHKxxQsoCsItMzM7WU3S/rIFzL3T4guU8tWLKr7e5cVkdaZ+JQeeL+TB3OaHpqFi/ozYqQl69z2X6bg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/vk-io": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/vk-io/-/vk-io-4.10.1.tgz", + "integrity": "sha512-B+jPX/7UxD7mViwF8roCw3cNZ9xB5hPLULr3KYSLXVcVmSuHhA7y9310qViGgYQsTf1AA/1ngdDtjE3PwgrC1g==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "form-data-encoder": "^1.7.2", + "formdata-node": "^4.4.1", + "inspectable": "^2.1.0", + "middleware-io": "^2.8.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/plugins/vk-full-admin/package.json b/plugins/vk-full-admin/package.json new file mode 100644 index 0000000..f4e2433 --- /dev/null +++ b/plugins/vk-full-admin/package.json @@ -0,0 +1,11 @@ +{ + "name": "teleton-plugin-vk-full-admin", + "version": "1.0.0", + "type": "module", + "private": true, + "description": "Teleton plugin for VK user and community administration", + "dependencies": { + "@vk-io/authorization": "^1.4.0", + "vk-io": "^4.8.0" + } +} diff --git a/plugins/vk-full-admin/tests/index.test.js b/plugins/vk-full-admin/tests/index.test.js new file mode 100644 index 0000000..ebaa5f7 --- /dev/null +++ b/plugins/vk-full-admin/tests/index.test.js @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { createVkFullAdminTools } from "../index.js"; + +function makeStorage() { + const values = new Map(); + return { + get: (key) => values.get(key), + set: (key, value) => { + values.set(key, value); + }, + delete: (key) => values.delete(key), + has: (key) => values.has(key), + }; +} + +function makeSdk({ userToken = "user-token", communityTokens = { 123: "group-token" }, pluginConfig = {} } = {}) { + return { + pluginConfig, + storage: makeStorage(), + secrets: { + get: async (name) => { + if (name === "vk_user_token") return userToken; + if (name === "vk_community_tokens") return JSON.stringify(communityTokens); + return null; + }, + require: async (name) => { + if (name === "vk_user_token" && userToken) return userToken; + if (name === "vk_community_tokens" && communityTokens) return JSON.stringify(communityTokens); + throw new Error(`${name} missing`); + }, + }, + log: { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + }, + }; +} + +function makeMockVK({ calls, onCall } = {}) { + return class MockVK { + constructor(options) { + this.options = options; + this.api = { + call: async (method, params) => { + calls.push({ token: options.token, method, params }); + if (onCall) return onCall(method, params, options.token); + if (method === "users.get") return [{ id: 42, first_name: "Ada" }]; + if (method === "groups.isMember") { + return { member: 1, user_id: 42, is_admin: 1, manager_role: "administrator" }; + } + if (method === "wall.post") return { post_id: 77 }; + return { ok: 1 }; + }, + }; + this.upload = { + wallPhoto: async (params) => { + calls.push({ token: options.token, method: "upload.wallPhoto", params }); + return { toString: () => "photo-123_1" }; + }, + }; + } + }; +} + +function getTool(tools, name) { + const tool = tools.find((item) => item.name === name); + assert.ok(tool, `expected tool ${name}`); + return tool; +} + +describe("vk-full-admin", () => { + it("registers the expected VK administration tools with unique names", () => { + const calls = []; + const tools = createVkFullAdminTools(makeSdk(), { VKClass: makeMockVK({ calls }) }); + const names = tools.map((tool) => tool.name); + + assert.equal(names.length, new Set(names).size); + assert.ok(names.includes("vk_group_wall_post")); + assert.ok(names.includes("vk_group_ban_user")); + assert.ok(names.includes("vk_group_msg_send")); + assert.ok(names.includes("vk_auth_user_url")); + assert.equal(names.length, 34); + }); + + it("checks admin rights before posting to a community with the community token", async () => { + const calls = []; + const tools = createVkFullAdminTools(makeSdk(), { VKClass: makeMockVK({ calls }) }); + const result = await getTool(tools, "vk_group_wall_post").execute({ + owner_id: -123, + message: "Launch post", + close_comments: true, + }); + + assert.equal(result.success, true); + assert.deepEqual( + calls.map((call) => call.method), + ["users.get", "groups.isMember", "wall.post"] + ); + assert.equal(calls[2].token, "group-token"); + assert.equal(calls[2].params.owner_id, -123); + assert.equal(calls[2].params.from_group, 1); + assert.equal(calls[2].params.close_comments, 1); + }); + + it("returns a clear rights error when the VK user is not a community manager", async () => { + const calls = []; + const VKClass = makeMockVK({ + calls, + onCall: (method) => { + if (method === "users.get") return [{ id: 42 }]; + if (method === "groups.isMember") return { member: 1, user_id: 42 }; + if (method === "groups.getById") return [{ id: 123, is_admin: 0 }]; + return { ok: 1 }; + }, + }); + const tools = createVkFullAdminTools(makeSdk(), { VKClass }); + const result = await getTool(tools, "vk_group_wall_post").execute({ + owner_id: -123, + message: "Should not post", + }); + + assert.equal(result.success, false); + assert.match(result.error, /Insufficient rights/); + assert.equal(calls.some((call) => call.method === "wall.post"), false); + }); + + it("omits the manager role parameter when removing community manager rights", async () => { + const calls = []; + const tools = createVkFullAdminTools(makeSdk(), { VKClass: makeMockVK({ calls }) }); + const result = await getTool(tools, "vk_group_set_role").execute({ + group_id: 123, + user_id: 99, + role: "none", + }); + + assert.equal(result.success, true); + const editCall = calls.find((call) => call.method === "groups.editManager"); + assert.ok(editCall); + assert.equal(Object.hasOwn(editCall.params, "role"), false); + }); + + it("does not expose access tokens in formatted VK errors", async () => { + const calls = []; + const VKClass = makeMockVK({ + calls, + onCall: () => { + const err = new Error("request failed access_token=user-token&token=group-token"); + err.code = 5; + throw err; + }, + }); + const tools = createVkFullAdminTools(makeSdk(), { VKClass }); + const result = await getTool(tools, "vk_user_info").execute({}); + + assert.equal(result.success, false); + assert.doesNotMatch(result.error, /user-token/); + assert.doesNotMatch(result.error, /group-token/); + assert.match(result.error, /\[redacted\]/); + }); + + it("applies the per-token rate gate before API calls", async () => { + const calls = []; + let currentTime = 0; + let slept = 0; + const tools = createVkFullAdminTools( + makeSdk({ pluginConfig: { rate_limit_per_second: 1 } }), + { + VKClass: makeMockVK({ calls }), + now: () => currentTime, + sleep: async (ms) => { + slept += ms; + currentTime += ms; + }, + } + ); + await getTool(tools, "vk_user_info").execute({}); + await getTool(tools, "vk_user_info").execute({}); + + assert.ok(slept >= 1000); + assert.equal(calls.filter((call) => call.method === "users.get").length, 2); + }); +}); diff --git a/registry.json b/registry.json index 29b2e94..50ff521 100644 --- a/registry.json +++ b/registry.json @@ -177,6 +177,14 @@ "tags": ["social", "twitter", "x", "search", "trends"], "path": "plugins/twitter" }, + { + "id": "vk-full-admin", + "name": "VK Full Admin", + "description": "VK personal account and community administration with publishing, moderation, members, settings, analytics, and group dialogs", + "author": "xlabtg", + "tags": ["vk", "social", "admin", "community", "moderation", "analytics", "messages"], + "path": "plugins/vk-full-admin" + }, { "id": "casino", "name": "Teleton Casino",