/** * Model-facing AcFun tools and their formatters. * * Deliberately dependency-free: every tool is a plain `ToolDefinition` object * (`name`, `description`, `parameters` as raw JSON Schema, `output.schema` as * raw JSON Schema, and `render`), so the bundle needs no `@deepseek-ai/*` * import and `node scripts/live-check.mjs` can execute it outside the harness. */ import { AcfunError, acfunVideoUrl, DANMAKU_MAX_COUNT, normalizeAcId, RANK_PERIODS, SEARCH_SORTS } from './provider.js' /** The system-prompt guidance registered by the plugin. */ export const ACFUN_GUIDANCE = [ 'AcFun (A站) tools: use acfun_search to find videos, acfun_video for one result\'s full metadata,', 'acfun_comments for its comment thread, acfun_danmaku for the 弹幕 list, acfun_rank for the day/week', 'ranking, and acfun_playurl for an m3u8 play URL. acfun_interact (like / unlike / banana /', 'favorite / unfavorite / follow / unfollow) and acfun_post_comment are disabled unless the plugin', 'config sets allowWriteActions: true and a login cookie; when a write tool reports WRITE_DISABLED or', 'LOGIN_REQUIRED, tell the user which config key is missing instead of retrying. Cite the video URL', 'as a markdown link when you use its content, and never repeat a mutating call just because the', 'result was unclear.', ].join(' ') /** One text content block. */ const text = (value) => [{ type: 'text', text: value }] /** * Compile an author-friendly property map into a raw JSON Schema object node. * A property marked `required: true` contributes to the object's `required` * array; the marker itself is removed from the emitted schema. */ function objectNode(properties) { const required = [] const cleaned = {} for (const [key, value] of Object.entries(properties)) { const { required: isRequired, ...schema } = value if (isRequired === true) required.push(key) cleaned[key] = schema } const node = { type: 'object', additionalProperties: false, properties: cleaned } if (required.length > 0) node.required = required return node } /** A required scalar property. */ const required = (schema) => ({ ...schema, required: true }) const string = (description, extra = {}) => ({ type: 'string', description, ...extra }) const number = (description, extra = {}) => ({ type: 'number', description, ...extra }) const boolean = (description, extra = {}) => ({ type: 'boolean', description, ...extra }) const arrayOf = (items, description) => ({ type: 'array', description, items }) /** A compact one-line list entry: `- [title](url) — meta`. */ function bullet(title, url, meta) { return meta.length > 0 ? `- [${title}](${url}) — ${meta}` : `- [${title}](${url})` } /** Join non-empty metadata fragments with ` · `. */ function meta(...fragments) { return fragments.filter((fragment) => typeof fragment === 'string' && fragment.length > 0).join(' · ') } /** Format the search result for the model. */ export function formatSearch(value) { const sortNote = value.sort === 'views' ? 'sorted by most-viewed' : 'sorted by relevance' const header = `Found ${value.total} AcFun videos for "${value.keyword}" (${sortNote}); showing ${value.returned}. AcFun's search API ignores paging parameters and returns one fixed first page.` if (value.items.length === 0) return `${header}\n\nNo videos matched this keyword.` const lines = value.items.map((item) => bullet( item.title, item.url, meta( item.authorName.length > 0 ? `UP ${item.authorName}` : '', `${item.viewCount} plays`, item.danmakuCount > 0 ? `${item.danmakuCount} danmaku` : '', item.commentCount > 0 ? `${item.commentCount} comments` : '', item.duration, item.publishedAt.slice(0, 10), `acId ${item.acId}`, ), ), ) return `${header}\n\n${lines.join('\n')}\n\nUse acfun_video with one of those acId values for full metadata.` } /** Format one video detail. */ export function formatVideo(value) { const lines = [`# ${value.title}`, `URL: ${value.url}`, `acId: ${value.acId}`] const author = meta( `UP ${value.authorName || 'unknown'}${value.authorId ? ` (id ${value.authorId})` : ''}`, value.authorFollowers > 0 ? `${value.authorFollowers} followers` : '', value.authorSignature.length > 0 ? value.authorSignature : '', ) lines.push(`Author: ${author}`) lines.push( `Channel: ${meta(value.channelParent, value.channel, `duration ${value.duration}`, `published ${value.publishedAt.slice(0, 10)}`)}`, ) lines.push( `Stats: ${meta( `${value.viewCount} plays`, `${value.likeCount} likes`, `${value.favoriteCount} favorites`, `${value.bananaCount} bananas`, `${value.commentCount} comments`, `${value.danmakuCount} danmaku`, )}`, ) if (value.tags.length > 0) lines.push(`Tags: ${value.tags.map((tag) => tag.name).join(', ')}`) lines.push( `Interaction state: ${meta( value.loggedIn ? 'logged in' : 'anonymous (no cookie)', `liked ${value.liked ? 'yes' : 'no'}`, `favorited ${value.favorited ? 'yes' : 'no'}`, `banana given ${value.bananaGiven ? 'yes' : 'no'}`, )}`, ) if (value.description.length > 0) lines.push(`\nDescription:\n${value.description}`) return lines.join('\n') } /** Format one comment page. */ export function formatComments(value) { const header = `Comments on ${value.url} — page ${value.page}/${value.totalPages}, ${value.totalCount} comments total, ${value.returned} on this page.` if (value.items.length === 0) return `${header}\n\nNo comments on this page.` const lines = value.items.map((item) => { const head = meta( `#${item.floor}`, item.userName.length > 0 ? `${item.userName} (id ${item.userId})` : `user ${item.userId}`, item.postedAt, item.isUp ? 'UP主' : '', `cid ${item.commentId}`, ) const body = item.isDeleted ? '[deleted]' : item.content.length > 0 ? item.content : '[empty]' return `- ${head}\n ${body.replace(/\n/g, '\n ')}` }) const footer = value.hasMore ? `More comments follow: call acfun_comments again with page: ${value.page + 1}.` : 'This is the last comment page.' return `${header}\n\n${lines.join('\n')}\n\n${footer}` } /** Format one danmaku page. */ export function formatDanmaku(value) { const header = `Danmaku on ${acfunVideoUrl(value.acId)} — ${value.totalCount} total, ${value.returned} in this batch, ordered by in-video position.` if (value.items.length === 0) return `${header}\n\nNo danmaku in this batch.` const lines = value.items.map((item) => { const metaText = meta( item.time, item.mode > 0 ? `mode ${item.mode}` : '', item.color.length > 0 ? item.color : '', item.userId.length > 0 ? `user ${item.userId}` : '', item.likeCount > 0 ? `${item.likeCount} likes` : '', ) return `- ${metaText}\n ${item.body.replace(/\n/g, '\n ')}` }) const footer = value.nextCursor.length > 0 ? `More danmaku follow: call acfun_danmaku again with cursor: "${value.nextCursor}".` : 'This is the last danmaku batch.' return `${header}\n\n${lines.join('\n')}\n\n${footer}` } /** Format one ranking page. */ export function formatRank(value) { const scope = value.channelId === 0 ? 'all channels' : `channel ${value.channelId}` const header = `AcFun ${value.period} ranking (${scope}) — ${value.returned} entries.` if (value.items.length === 0) return `${header}\n\nNo entries for this period/channel.` const lines = value.items.map((item, index) => { const suffix = meta( item.authorName.length > 0 ? `UP ${item.authorName}` : '', item.channelName, item.duration, item.danmakuCount > 0 ? `${item.danmakuCount} danmaku` : '', item.likeCountText.length > 0 ? `${item.likeCountText} likes` : '', item.commentCountText.length > 0 ? `${item.commentCountText} comments` : '', `acId ${item.acId}`, ) return `${index + 1}. [${item.title}](${item.url})${suffix.length > 0 ? ` — ${suffix}` : ''}` }) return `${header}\n\n${lines.join('\n')}` } /** Format the playable quality ladder. */ export function formatPlayUrls(value) { const header = `Playable streams for ${value.title} (${value.duration}) — ${acfunVideoUrl(value.acId)}` const lines = value.qualities.map((quality) => `- ${quality.label} (${quality.width}x${quality.height}): ${quality.url}`, ) return `${header}\n\n${lines.join('\n')}\n\n${value.chosenUrl.length > 0 ? `Selected ${value.chosenLabel}: ${value.chosenUrl}` : 'No quality was requested; the first (highest) entry is the default.'}\n\nThese are time-limited m3u8 URLs; re-run acfun_playurl if one expires.` } /** Format one interaction outcome. */ export function formatInteract(value) { return `${value.message}\n\nAction: ${value.action} · acId: ${value.acId || '-'} · userId: ${value.targetUserId || '-'}` } /** Format one posted comment. */ export function formatPostComment(value) { return `${value.message}\n\nComment id: ${value.commentId || '(not returned)'} · video: ${value.url}` } /** Resolve a requested quality label/height against the available ladder. */ function chooseQuality(qualities, requested) { if (qualities.length === 0) return undefined const wanted = typeof requested === 'string' ? requested.trim().toLowerCase() : '' if (wanted.length === 0) return qualities[0] const exact = qualities.find((quality) => quality.label.toLowerCase() === wanted) if (exact !== undefined) return exact const partial = qualities.find((quality) => quality.label.toLowerCase().includes(wanted)) if (partial !== undefined) return partial const height = /^(\d{3,4})p?$/.exec(wanted) if (height !== null) { const target = Number(height[1]) const ranked = [...qualities].sort((left, right) => Math.abs(left.height - target) - Math.abs(right.height - target)) if (ranked[0] !== undefined) return ranked[0] } throw new AcfunError( `No AcFun stream matches quality "${requested}". Available: ${qualities.map((quality) => quality.label).join(', ')}.`, { code: 'INVALID_ARGUMENT' }, ) } /** Assert one required non-empty string argument; return its trimmed value. */ function requireText(args, name, description) { const value = args?.[name] if (typeof value !== 'string' || value.trim().length === 0) { throw new AcfunError(`${name} must be a non-empty string${description ? ` (${description})` : ''}.`, { code: 'INVALID_ARGUMENT', }) } return value.trim() } /** Assert one positive integer argument with a bound. */ function requireInteger(args, name, { min = 1, max = Number.MAX_SAFE_INTEGER, fallback } = {}) { const raw = args?.[name] const value = raw === undefined || raw === null ? fallback : raw if (!Number.isInteger(value) || value < min || value > max) { throw new AcfunError(`${name} must be an integer between ${min} and ${max}.`, { code: 'INVALID_ARGUMENT' }) } return value } /** Map one provider banana result to the interaction output shape. */ function interactOutput(action, message, acId = '', targetUserId = '') { return { action, ok: true, message, acId, targetUserId } } /** * Build the seven AcFun tool definitions for one provider instance. * * @param {import('./provider.js').AcfunProvider} provider - bound API client. * @param {{timeoutMs: number, allowWriteActions: boolean}} config - resolved plugin config. * @returns {object[]} registry-ready ToolDefinition objects. */ export function createAcfunTools(provider, config) { const readTimeout = config.timeoutMs const writeTimeout = Math.max(config.timeoutMs, 60_000) const searchTool = { name: 'acfun_search', description: 'Search acfun.cn (A站) for videos by keyword. Returns matching videos with acId, title, uploader, play/danmaku/comment counts, duration, and publish date (at most the first 30 results; AcFun ignores paging). sort "views" orders by play count, "relevance" keeps AcFun\'s default order. Follow up with acfun_video for full metadata, acfun_comments/acfun_danmaku for the thread and 弹幕, or acfun_playurl for a stream URL.', parameters: objectNode({ keyword: required(string('The search keyword, for example "原神" or "动画".')), sort: string('Result order: "relevance" (default) or "views" (most-viewed first).', { enum: [...SEARCH_SORTS], }), }), output: { schema: objectNode({ keyword: required(string('The normalized search keyword.')), sort: required(string('The requested result order.')), total: required(number('How many results AcFun reports in total.')), returned: required(number('How many items are in this response.')), items: required( arrayOf( objectNode({ acId: required(string('AcFun video id used by the other acfun tools.')), title: required(string('Video title.')), authorId: required(string('Uploader user id.')), authorName: required(string('Uploader display name.')), viewCount: required(number('Play count.')), danmakuCount: required(number('Danmaku count.')), commentCount: required(number('Comment count.')), duration: required(string('Duration as m:ss or h:mm:ss.')), publishedAt: required(string('ISO-8601 publish time.')), coverUrl: required(string('Cover image URL.')), url: required(string('Canonical acfun.cn video URL.')), }), 'Matching videos.', ), ), }), render: (_args, value) => text(formatSearch(value)), }, timeoutMs: readTimeout, isConcurrencySafe: () => true, async execute(args, exec) { const keyword = requireText(args, 'keyword', 'the search phrase') const sort = typeof args?.sort === 'string' ? args.sort.trim() : 'relevance' const result = await provider.search(keyword, { sort, signal: exec?.signal }) return result }, } const videoTool = { name: 'acfun_video', description: 'Read full metadata for one acfun.cn video: title, description, uploader, channel, tags, duration, publish time, play/like/favorite/banana/comment/danmaku counts, cover, and — when a login cookie is configured — whether you already liked/favorited/tipped it. Accepts a numeric acId (42588725), the ac-prefixed form (ac42588725), or a /v/ac... URL.', parameters: objectNode({ acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')), }), output: { schema: objectNode({ acId: required(string('Bare numeric AcFun video id.')), videoId: required(number('Internal video (currentVideoId) id.')), title: required(string('Video title.')), description: required(string('Video description, empty when absent.')), authorId: required(string('Uploader user id.')), authorName: required(string('Uploader display name.')), authorAvatar: required(string('Uploader avatar URL.')), authorSignature: required(string('Uploader signature, empty when absent.')), authorFollowers: required(number('Uploader follower count.')), channel: required(string('AcFun channel name, for example 动画综合.')), channelParent: required(string('Parent channel name, for example 动画.')), channelId: required(number('AcFun channel id.')), tags: required( arrayOf( objectNode({ name: required(string('Tag name.')), id: required(string('Tag id.')), }), 'Video tags.', ), ), durationMillis: required(number('Duration in milliseconds.')), duration: required(string('Duration as m:ss or h:mm:ss.')), publishedAt: required(string('ISO-8601 publish time.')), viewCount: required(number('Play count.')), danmakuCount: required(number('Danmaku count.')), commentCount: required(number('Comment count.')), likeCount: required(number('Like count.')), favoriteCount: required(number('Favorite count.')), bananaCount: required(number('Banana (投蕉) count.')), coverUrl: required(string('Cover image URL.')), url: required(string('Canonical acfun.cn video URL.')), loggedIn: required(boolean('Whether a login cookie is configured.')), liked: required(boolean('Whether the configured account liked this video; false when anonymous.')), favorited: required(boolean('Whether the configured account favorited this video; false when anonymous.')), bananaGiven: required(boolean('Whether the configured account tipped this video; false when anonymous.')), }), render: (_args, value) => text(formatVideo(value)), }, timeoutMs: readTimeout, isConcurrencySafe: () => true, async execute(args, exec) { const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL') return provider.video(acId, { signal: exec?.signal }) }, } const commentsTool = { name: 'acfun_comments', description: 'Read one page of the comment thread (楼) of an acfun.cn video, in floor order. Returns comment ids, authors, floor numbers, post times, UP-main flags, and plain-text content. Use the returned commentId with acfun_post_comment replies.', parameters: objectNode({ acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')), page: number('1-based comment page. Defaults to 1.'), }), output: { schema: objectNode({ acId: required(string('Bare numeric AcFun video id.')), page: required(number('Current comment page.')), totalPages: required(number('Total number of comment pages.')), totalCount: required(number('Total comments on the video.')), pageSize: required(number('Server page size for this response.')), returned: required(number('Comments returned on this page.')), hasMore: required(boolean('Whether a later page exists.')), url: required(string('Canonical acfun.cn video URL.')), items: required( arrayOf( objectNode({ commentId: required(string('Numeric comment id usable as replyToCommentId.')), userId: required(string('Commenter user id.')), userName: required(string('Commenter display name.')), content: required(string('Plain-text comment content with UBB markup stripped.')), floor: required(number('Floor number.')), postedAt: required(string('Server-formatted post time.')), timestamp: required(number('Post time as epoch milliseconds.')), isUp: required(boolean('Whether the commenter is the video uploader.')), isDeleted: required(boolean('Whether AcFun reports the comment as deleted.')), }), 'Comments in floor order.', ), ), }), render: (_args, value) => text(formatComments(value)), }, timeoutMs: readTimeout, isConcurrencySafe: () => true, async execute(args, exec) { const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL') const page = requireInteger(args, 'page', { min: 1, max: 500, fallback: 1 }) return provider.comments(acId, { page, signal: exec?.signal }) }, } const danmakuTool = { name: 'acfun_danmaku', description: 'Read one batch of the danmaku (弹幕) list of an acfun.cn video, ordered by in-video position. Returns each danmaku body, position (ms), mode, color, author and like count. Pass the returned nextCursor to continue with the next batch.', parameters: objectNode({ acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')), limit: number(`How many danmaku to request, 1-${DANMAKU_MAX_COUNT}. Defaults to ${DANMAKU_MAX_COUNT}.`), cursor: string('Opaque continuation cursor from a previous call\'s nextCursor. Omit for the first batch.'), }), output: { schema: objectNode({ acId: required(string('Bare numeric AcFun video id.')), videoId: required(number('The part videoId AcFun addresses danmaku by.')), title: required(string('Video title.')), totalCount: required(number('Total danmaku AcFun reports for the video.')), returned: required(number('Danmaku in this batch.')), nextCursor: required(string('Cursor for the next batch, empty when there is no more.')), items: required( arrayOf( objectNode({ danmakuId: required(string('Danmaku id.')), body: required(string('Danmaku text.')), position: required(number('In-video position in milliseconds.')), time: required(string('In-video position as m:ss or h:mm:ss.')), color: required(string('Text color as #rrggbb, empty when absent.')), mode: required(number('AcFun danmaku mode: 1 scroll, 4 bottom, 5 top, and so on.')), userId: required(string('Author user id.')), createdAt: required(string('ISO-8601 creation time, empty when absent.')), likeCount: required(number('Like count.')), }), 'Danmaku ordered by in-video position.', ), ), }), render: (_args, value) => text(formatDanmaku(value)), }, timeoutMs: readTimeout, isConcurrencySafe: () => true, async execute(args, exec) { const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL') const limit = requireInteger(args, 'limit', { min: 1, max: DANMAKU_MAX_COUNT, fallback: DANMAKU_MAX_COUNT }) const cursor = typeof args?.cursor === 'string' ? args.cursor : '' return provider.danmaku(acId, { limit, cursor, signal: exec?.signal }) }, } const rankTool = { name: 'acfun_rank', description: 'Read the AcFun video ranking (排行榜) for one period: day or week. channelId 0 means all channels; pass a channel id to narrow it. Returns ranked videos with title, uploader, channel, duration, and like/comment/danmaku hints.', parameters: objectNode({ period: string('Ranking period: "day" (default) or "week".', { enum: [...RANK_PERIODS] }), channelId: number('AcFun channel id, 0 (default) for all channels.'), limit: number('How many entries to return, 1-50. Defaults to 10.'), }), output: { schema: objectNode({ period: required(string('The requested ranking period.')), channelId: required(number('The requested channel id.')), returned: required(number('How many entries are in this response.')), items: required( arrayOf( objectNode({ acId: required(string('AcFun video id.')), title: required(string('Video title.')), description: required(string('Short description, empty when absent.')), authorId: required(string('Uploader user id.')), authorName: required(string('Uploader display name.')), channelName: required(string('AcFun channel name.')), coverUrl: required(string('Cover image URL.')), duration: required(string('Duration as m:ss or h:mm:ss.')), danmakuCount: required(number('Danmaku count.')), likeCountText: required(string('Server-formatted like count, for example "253" or "1.2万".')), commentCountText: required(string('Server-formatted comment count.')), publishedAt: required(string('ISO-8601 publish time.')), url: required(string('Canonical acfun.cn video URL.')), }), 'Ranked videos in rank order.', ), ), }), render: (_args, value) => text(formatRank(value)), }, timeoutMs: readTimeout, isConcurrencySafe: () => true, async execute(args, exec) { const period = args?.period === undefined ? 'day' : String(args.period).trim() const channelId = requireInteger(args, 'channelId', { min: 0, max: 10_000, fallback: 0 }) const limit = requireInteger(args, 'limit', { min: 1, max: 50, fallback: 10 }) return provider.rank({ period, channelId, limit, signal: exec?.signal }) }, } const playUrlTool = { name: 'acfun_playurl', description: 'Return the playable m3u8 stream URLs of one acfun.cn video across its quality ladder (for example 1080P+, 1080P, 720P, 540P, 360P). Optionally name a quality; otherwise the highest is selected. The URLs are time-limited, so re-run when one expires.', parameters: objectNode({ acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')), quality: string('Optional quality label such as "720P" or "1080P+". Defaults to the highest available.'), }), output: { schema: objectNode({ acId: required(string('Bare numeric AcFun video id.')), title: required(string('Video title.')), duration: required(string('Duration as m:ss or h:mm:ss.')), qualities: required( arrayOf( objectNode({ label: required(string('Quality label.')), width: required(number('Video width in pixels.')), height: required(number('Video height in pixels.')), url: required(string('Time-limited m3u8 stream URL.')), }), 'Playable qualities, highest first.', ), ), chosenLabel: required(string('Label of the selected quality, empty when none applied.')), chosenUrl: required(string('URL of the selected quality, empty when none applied.')), }), render: (_args, value) => text(formatPlayUrls(value)), }, timeoutMs: readTimeout, isConcurrencySafe: () => true, async execute(args, exec) { const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL') const requested = typeof args?.quality === 'string' ? args.quality : '' const result = await provider.playUrls(acId, { signal: exec?.signal }) const chosen = chooseQuality(result.qualities, requested) return { acId: result.acId, title: result.title, duration: result.duration, qualities: result.qualities, chosenLabel: chosen?.label ?? '', chosenUrl: chosen?.url ?? '', } }, } const interactTool = { name: 'acfun_interact', description: 'Perform one AcFun interaction on a video or user: like, unlike, banana (投蕉), favorite, unfavorite, follow, or unfollow. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error instead of acting. Do not repeat a call whose result was ambiguous.', parameters: objectNode({ action: required( string('Interaction to perform.', { enum: ['like', 'unlike', 'banana', 'favorite', 'unfavorite', 'follow', 'unfollow'], }), ), acId: string('Video identifier for like/unlike/banana/favorite/unfavorite.'), userId: string('Target user id for follow/unfollow.'), count: number('Bananas to tip for action "banana", 1-10. Defaults to 1.'), folderId: string('Optional favorite folder id for action "favorite".'), }), output: { schema: objectNode({ action: required(string('The interaction that ran.')), ok: required(boolean('Whether AcFun accepted the interaction.')), message: required(string('Human-readable outcome.')), acId: required(string('Video id the action targeted, empty for follow/unfollow.')), targetUserId: required(string('User id the action targeted, empty for video actions.')), }), render: (_args, value) => text(formatInteract(value)), }, timeoutMs: writeTimeout, isConcurrencySafe: () => false, async execute(args, exec) { const action = requireText(args, 'action') const signal = exec?.signal switch (action) { case 'like': case 'unlike': { const acId = requireText(args, 'acId', 'required for like/unlike') const outcome = await provider.setLike(acId, action === 'like', { signal }) return interactOutput(action, outcome.message, acId) } case 'banana': { const acId = requireText(args, 'acId', 'required for banana') const count = requireInteger(args, 'count', { min: 1, max: 10, fallback: 1 }) const outcome = await provider.throwBanana(acId, count, { signal }) return interactOutput(action, outcome.message, acId) } case 'favorite': case 'unfavorite': { const acId = requireText(args, 'acId', 'required for favorite/unfavorite') const folderId = typeof args?.folderId === 'string' ? args.folderId : undefined const outcome = await provider.setFavorite(acId, action === 'favorite', { folderId, signal }) return interactOutput(action, outcome.message, acId) } case 'follow': case 'unfollow': { const userId = requireText(args, 'userId', 'required for follow/unfollow') const outcome = await provider.setFollow(userId, action === 'follow', { signal }) return interactOutput(action, outcome.message, '', userId) } default: throw new AcfunError(`Unknown acfun_interact action: ${action}.`, { code: 'INVALID_ARGUMENT' }) } }, } const commentTool = { name: 'acfun_post_comment', description: 'Post a comment (or a reply) on one acfun.cn video. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error. Keep content short and on-topic, and do not retry after an unclear result — fetch acfun_comments to check whether it appeared.', parameters: objectNode({ acId: required(string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.')), content: required(string('Comment text, 1-2000 characters. AcFun UBB markup is allowed.')), replyToCommentId: string('Optional numeric comment id to reply to; omit for a top-level comment.'), }), output: { schema: objectNode({ ok: required(boolean('Whether AcFun accepted the comment.')), acId: required(string('Bare numeric AcFun video id.')), commentId: required(string('New comment id when AcFun returns one, otherwise empty.')), message: required(string('Human-readable outcome.')), url: required(string('Canonical acfun.cn video URL.')), }), render: (_args, value) => text(formatPostComment(value)), }, timeoutMs: writeTimeout, isConcurrencySafe: () => false, async execute(args, exec) { const acId = requireText(args, 'acId', 'numeric id, ac-prefixed id, or video URL') const content = requireText(args, 'content', 'the comment text') const replyToCommentId = typeof args?.replyToCommentId === 'string' ? args.replyToCommentId : undefined const outcome = await provider.postComment(acId, content, { replyToCommentId, signal: exec?.signal }) const normalized = normalizeAcId(acId) return { ok: outcome.ok, acId: normalized, commentId: outcome.commentId, message: outcome.message, url: acfunVideoUrl(normalized), } }, } return [searchTool, videoTool, commentsTool, danmakuTool, rankTool, playUrlTool, interactTool, commentTool] }