Files
dsh-acfun/lib/tools.js
T
tamakyi f9bedde19e feat: AcFun tools plugin for DeepSeek Harness
Eight tools (search, video, comments, danmaku, rank, playurl, interact,
post_comment) over acfun.cn's public web API, a model-facing skill, a
bundle patch with a skill-filesystem row, and offline/live checks.

Reads are anonymous; writes require allowWriteActions plus a login cookie.
2026-10-27 11:26:51 +08:00

650 lines
31 KiB
JavaScript

/**
* 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]
}