- scan.acfun.cn QR flow (start -> long-poll scanResult -> acceptResult) with rotated-signature handling, background poller, durable session in the Host credential service, and cookie handoff to the provider - Client half: Settings -> AcFun page (status, QR code, poll, logout) over a same-origin /acfun-panel JSON API with cross-origin rejection - acfun_moment / acfun_moment_comments / acfun_topic read tools, moment_like / moment_unlike / moment_banana actions, and comment posting on moments - acfun_account status tool; docs for the QR contract, the 广场/动态 research verdict, and the client-composition restart requirement
963 lines
47 KiB
JavaScript
963 lines
47 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, normalizeMomentId, 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. For 动态 (moments) use acfun_moment,',
|
|
'acfun_moment_comments and acfun_topic: AcFun exposes no public 广场 feed, so moment ids come only',
|
|
'from a topic page (acfun_topic) or a share link. acfun_interact (like / unlike / banana /',
|
|
'favorite / unfavorite / follow / unfollow, plus moment_like / moment_unlike / moment_banana) 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. Publishing a moment is app-only and unavailable. Cite the share URL',
|
|
'as a markdown link when you use content, and never repeat a mutating call 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) {
|
|
const target = value.momentId.length > 0 ? `moment ${value.momentId}` : value.acId.length > 0 ? `ac${value.acId}` : value.targetUserId.length > 0 ? `user ${value.targetUserId}` : '-'
|
|
return `${value.message}\n\nAction: ${value.action} · target: ${target}`
|
|
}
|
|
|
|
/** Format one posted comment. */
|
|
export function formatPostComment(value) {
|
|
const target = value.momentId.length > 0 ? `moment ${value.momentId}` : value.acId.length > 0 ? `ac${value.acId}` : 'unknown'
|
|
return `${value.message}\n\nComment id: ${value.commentId || '(not returned)'} · target: ${target} · ${value.url}`
|
|
}
|
|
|
|
/** Format the account/session summary. */
|
|
export function formatAccount(value) {
|
|
return [
|
|
`AcFun account: ${value.loggedIn ? 'signed in' : 'anonymous'} (source: ${value.authSource}${value.hasAuthKey ? ', auth_key present' : ''}).`,
|
|
`Interaction tools: ${value.writesEnabled ? 'enabled' : 'disabled'}${value.writesEnabled ? '' : ' (allowWriteActions is false)'}.`,
|
|
`QR login available in this Host: ${value.qrLoginAvailable ? 'yes' : 'no'}; login state: ${value.loginState}.`,
|
|
value.hint,
|
|
].join('\n')
|
|
}
|
|
|
|
/** Format one moment (动态). */
|
|
export function formatMoment(value) {
|
|
const lines = [
|
|
`# AcFun 动态 ${value.momentId}`,
|
|
`URL: ${value.shareUrl}`,
|
|
`Author: ${value.authorName || 'unknown'}${value.authorId ? ` (id ${value.authorId})` : ''}${value.authorFollowers > 0 ? ` · ${value.authorFollowers} followers` : ''}`,
|
|
`Published: ${value.createTime || '(unknown)'} · type ${value.momentType}`,
|
|
`Stats: ${value.likeCount} likes · ${value.commentCount} comments · ${value.bananaCount} bananas · ${value.shareCount} shares`,
|
|
`Interaction state: ${value.isLike ? 'liked' : 'not liked'} · ${value.isThrowBanana ? 'banana given' : 'no banana'}${value.visibleForFans ? ' · followers-only' : ''}`,
|
|
]
|
|
if (value.tags.length > 0) lines.push(`Topics: ${value.tags.map((tag) => `#${tag.name}#(${tag.id})`).join(' ')}`)
|
|
if (value.text.length > 0) lines.push(`\nText:\n${value.text}`)
|
|
if (value.images.length > 0) {
|
|
lines.push(`\nImages (${value.images.length}):`)
|
|
for (const image of value.images) lines.push(`- ${image.url}${image.width > 0 ? ` (${image.width}x${image.height})` : ''}`)
|
|
}
|
|
if (value.repostedFrom.length > 0) lines.push(`\nRepost of: ${value.repostedFrom}`)
|
|
return lines.join('\n')
|
|
}
|
|
|
|
/** Format one moment comment page. */
|
|
export function formatMomentComments(value) {
|
|
const header = `Comments on moment ${value.momentId} (${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,
|
|
`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_moment_comments with page: ${value.page + 1}.`
|
|
: 'This is the last comment page.'
|
|
return `${header}\n\n${lines.join('\n')}\n\n${footer}`
|
|
}
|
|
|
|
/** Format one topic page. */
|
|
export function formatTopic(value) {
|
|
const header = `AcFun topic ${value.tagName} (tagId ${value.tagId}) — ${meta(`${value.followersText || '?'} followers`, `${value.contentsText || '?'} posts`)}; showing ${value.returned}. ${value.note}`
|
|
if (value.items.length === 0) return `${header}\n\nNo items could be parsed from this topic page.`
|
|
const lines = value.items.map((item, index) => {
|
|
const label = item.momentId.length > 0 ? `moment ${item.momentId}` : `resource ${item.resourceId} (type ${item.resourceType})`
|
|
const title = item.title.length > 0 ? item.title : item.body.slice(0, 80)
|
|
return `${index + 1}. [${title || label}](${item.url}) — ${meta(item.authorName, item.publishedAt, label, item.tags.join(' '))}`
|
|
})
|
|
return `${header}\n\n${lines.join('\n')}\n\nUse acfun_moment with a momentId above to read a full 动态.`
|
|
}
|
|
|
|
/** 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 result to the interaction output shape. */
|
|
function interactOutput(action, message, acId = '', targetUserId = '', momentId = '') {
|
|
return { action, ok: true, message, acId, targetUserId, momentId }
|
|
}
|
|
|
|
/**
|
|
* Build the 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.
|
|
* @param {{sessionSource?: () => string, login?: {available: boolean, snapshot: () => object}}} [context] - runtime session/UI wiring.
|
|
* @returns {object[]} registry-ready ToolDefinition objects.
|
|
*/
|
|
export function createAcfunTools(provider, config, context = {}) {
|
|
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 momentTool = {
|
|
name: 'acfun_moment',
|
|
description:
|
|
'Read one AcFun 动态 (moment) by id: text, topics, images, author, like/comment/banana/share counts, and the canonical share URL. AcFun has no public 广场 feed API, so ids come from a share link or from acfun_topic. Works anonymously.',
|
|
parameters: objectNode({
|
|
momentId: required(string('Numeric moment id (for example 5093831) or a https://m.acfun.cn/communityCircle/moment/... URL.')),
|
|
}),
|
|
output: {
|
|
schema: objectNode({
|
|
momentId: required(string('The moment id.')),
|
|
momentType: required(number('1 text-only, 2 image/text, 3 vote/other.')),
|
|
text: required(string('Moment text, including #topic# markers.')),
|
|
createTime: required(string('Publish date as YYYY-MM-DD (day precision).')),
|
|
authorId: required(string('Author user id.')),
|
|
authorName: required(string('Author display name.')),
|
|
authorAvatar: required(string('Author avatar URL.')),
|
|
authorSignature: required(string('Author signature, empty when absent.')),
|
|
authorFollowers: required(number('Author follower count.')),
|
|
images: required(
|
|
arrayOf(
|
|
objectNode({
|
|
url: required(string('Original image URL.')),
|
|
width: required(number('Image width, 0 when unknown.')),
|
|
height: required(number('Image height, 0 when unknown.')),
|
|
}),
|
|
'Attached images.',
|
|
),
|
|
),
|
|
tags: required(
|
|
arrayOf(
|
|
objectNode({
|
|
id: required(string('Topic (tag) id usable with acfun_topic.')),
|
|
name: required(string('Topic name.')),
|
|
}),
|
|
'Topics attached to the moment.',
|
|
),
|
|
),
|
|
likeCount: required(number('Like count.')),
|
|
commentCount: required(number('Comment count.')),
|
|
bananaCount: required(number('Banana (投蕉) count.')),
|
|
shareCount: required(number('Share count.')),
|
|
isLike: required(boolean('Whether the configured account liked it; false when anonymous.')),
|
|
isThrowBanana: required(boolean('Whether the configured account tipped it; false when anonymous.')),
|
|
visibleForFans: required(boolean('Whether the moment is followers-only.')),
|
|
repostedFrom: required(string('Text excerpt of the reposted moment, empty when not a repost.')),
|
|
shareUrl: required(string('Canonical share URL.')),
|
|
}),
|
|
render: (_args, value) => text(formatMoment(value)),
|
|
},
|
|
timeoutMs: readTimeout,
|
|
isConcurrencySafe: () => true,
|
|
async execute(args, exec) {
|
|
const momentId = requireText(args, 'momentId', 'numeric id or communityCircle/moment URL')
|
|
return provider.moment(momentId, { signal: exec?.signal })
|
|
},
|
|
}
|
|
|
|
const momentCommentsTool = {
|
|
name: 'acfun_moment_comments',
|
|
description:
|
|
'Read one page of the comment thread of an AcFun 动态 (moment), in floor order. Moments use their own comment enum, so this is separate from acfun_comments.',
|
|
parameters: objectNode({
|
|
momentId: required(string('Numeric moment id or a communityCircle/moment URL.')),
|
|
page: number('1-based comment page. Defaults to 1.'),
|
|
}),
|
|
output: {
|
|
schema: objectNode({
|
|
momentId: required(string('The moment id.')),
|
|
page: required(number('Current comment page.')),
|
|
totalPages: required(number('Total number of comment pages.')),
|
|
totalCount: required(number('Total comments on the moment.')),
|
|
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 share URL of the moment.')),
|
|
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.')),
|
|
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 moment author.')),
|
|
isDeleted: required(boolean('Whether AcFun reports the comment as deleted.')),
|
|
}),
|
|
'Comments in floor order.',
|
|
),
|
|
),
|
|
}),
|
|
render: (_args, value) => text(formatMomentComments(value)),
|
|
},
|
|
timeoutMs: readTimeout,
|
|
isConcurrencySafe: () => true,
|
|
async execute(args, exec) {
|
|
const momentId = requireText(args, 'momentId', 'numeric id or communityCircle/moment URL')
|
|
const page = requireInteger(args, 'page', { min: 1, max: 500, fallback: 1 })
|
|
return provider.momentComments(momentId, { page, signal: exec?.signal })
|
|
},
|
|
}
|
|
|
|
const topicTool = {
|
|
name: 'acfun_topic',
|
|
description:
|
|
'Read one AcFun 社区圈 topic page (best effort): the tag header and up to 15 newest items, including the moment ids of resourceType 3 items. This is the only public way to discover current moment ids, because AcFun exposes no square/recommend moment list API. No pagination is available.',
|
|
parameters: objectNode({
|
|
tagId: required(string('Numeric topic (tag) id, for example 488 for 教育. A moment\'s tagList carries these ids.')),
|
|
}),
|
|
output: {
|
|
schema: objectNode({
|
|
tagId: required(string('The requested topic id.')),
|
|
tagName: required(string('Topic display name.')),
|
|
followersText: required(string('Follower count as shown, empty when not found.')),
|
|
contentsText: required(string('Post count as shown, empty when not found.')),
|
|
returned: required(number('How many items were parsed.')),
|
|
note: required(string('Parsing caveat for this surface.')),
|
|
items: required(
|
|
arrayOf(
|
|
objectNode({
|
|
momentId: required(string('Moment id, empty unless resourceType is 3.')),
|
|
resourceType: required(number('1 article-style, 2 video-style, 3 moment.')),
|
|
resourceId: required(string('Resource id for non-moment items.')),
|
|
title: required(string('Item title, empty when absent.')),
|
|
body: required(string('Item body excerpt.')),
|
|
authorName: required(string('Author display name.')),
|
|
publishedAt: required(string('Server-formatted date.')),
|
|
tags: required(arrayOf(string('Topic label including # marks.'), 'Topic labels.')),
|
|
coverUrl: required(string('First image URL on the item, empty when absent.')),
|
|
url: required(string('Public URL of the item.')),
|
|
statTexts: required(arrayOf(string('Numeric stat labels shown on the item.'), 'Stat texts.')),
|
|
}),
|
|
'Topic items in page order.',
|
|
),
|
|
),
|
|
}),
|
|
render: (_args, value) => text(formatTopic(value)),
|
|
},
|
|
timeoutMs: readTimeout,
|
|
isConcurrencySafe: () => true,
|
|
async execute(args, exec) {
|
|
const tagId = requireText(args, 'tagId', 'numeric topic id such as 488')
|
|
return provider.topicMoments(tagId, { 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: like, unlike, banana (投蕉), favorite, unfavorite, follow, unfollow on a video/user, or moment_like / moment_unlike / moment_banana on a 动态. Requires allowWriteActions: true and a login cookie in the plugin config; otherwise it returns a clear configuration error instead of acting. Moment writes are less verified than video writes — never repeat a call whose result was ambiguous.',
|
|
parameters: objectNode({
|
|
action: required(
|
|
string('Interaction to perform.', {
|
|
enum: [
|
|
'like',
|
|
'unlike',
|
|
'banana',
|
|
'favorite',
|
|
'unfavorite',
|
|
'follow',
|
|
'unfollow',
|
|
'moment_like',
|
|
'moment_unlike',
|
|
'moment_banana',
|
|
],
|
|
}),
|
|
),
|
|
acId: string('Video identifier for like/unlike/banana/favorite/unfavorite.'),
|
|
momentId: string('Moment identifier for moment_like / moment_unlike / moment_banana.'),
|
|
userId: string('Target user id for follow/unfollow.'),
|
|
count: number('Bananas to tip for action "banana" or "moment_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 other targets.')),
|
|
targetUserId: required(string('User id the action targeted, empty for content actions.')),
|
|
momentId: required(string('Moment id the action targeted, empty for non-moment 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)
|
|
}
|
|
case 'moment_like':
|
|
case 'moment_unlike': {
|
|
const momentId = requireText(args, 'momentId', 'required for moment_like/moment_unlike')
|
|
const outcome = await provider.setMomentLike(momentId, action === 'moment_like', { signal })
|
|
return interactOutput(action, outcome.message, '', '', momentId)
|
|
}
|
|
case 'moment_banana': {
|
|
const momentId = requireText(args, 'momentId', 'required for moment_banana')
|
|
const count = requireInteger(args, 'count', { min: 1, max: 10, fallback: 1 })
|
|
const outcome = await provider.momentBanana(momentId, count, { signal })
|
|
return interactOutput(action, outcome.message, '', '', momentId)
|
|
}
|
|
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 or 动态. Pass acId for a video or momentId for a moment. 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 — re-read the thread to check whether it appeared.',
|
|
parameters: objectNode({
|
|
acId: string('Video identifier: numeric acId, ac-prefixed id, or acfun.cn /v/ac... URL.'),
|
|
momentId: string('Moment identifier: numeric id or communityCircle/moment URL. Use instead of acId.'),
|
|
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 video id, empty when commenting on a moment.')),
|
|
momentId: required(string('Bare numeric moment id, empty when commenting on a video.')),
|
|
commentId: required(string('New comment id when AcFun returns one, otherwise empty.')),
|
|
message: required(string('Human-readable outcome.')),
|
|
url: required(string('Canonical video or moment URL.')),
|
|
}),
|
|
render: (_args, value) => text(formatPostComment(value)),
|
|
},
|
|
timeoutMs: writeTimeout,
|
|
isConcurrencySafe: () => false,
|
|
async execute(args, exec) {
|
|
const content = requireText(args, 'content', 'the comment text')
|
|
const replyToCommentId = typeof args?.replyToCommentId === 'string' ? args.replyToCommentId : undefined
|
|
const momentArg = typeof args?.momentId === 'string' && args.momentId.trim().length > 0 ? args.momentId.trim() : ''
|
|
if (momentArg.length > 0) {
|
|
const outcome = await provider.postMomentComment(momentArg, content, { replyToCommentId, signal: exec?.signal })
|
|
return {
|
|
ok: outcome.ok,
|
|
acId: '',
|
|
momentId: normalizeMomentId(momentArg),
|
|
commentId: outcome.commentId,
|
|
message: outcome.message,
|
|
url: outcome.url,
|
|
}
|
|
}
|
|
const acId = requireText(args, 'acId', 'or pass momentId')
|
|
const outcome = await provider.postComment(acId, content, { replyToCommentId, signal: exec?.signal })
|
|
const normalized = normalizeAcId(acId)
|
|
return {
|
|
ok: outcome.ok,
|
|
acId: normalized,
|
|
momentId: '',
|
|
commentId: outcome.commentId,
|
|
message: outcome.message,
|
|
url: acfunVideoUrl(normalized),
|
|
}
|
|
},
|
|
}
|
|
|
|
const accountTool = {
|
|
name: 'acfun_account',
|
|
description:
|
|
'Report the configured AcFun account state: whether a login cookie is configured, where it came from (plugin config, environment variable, or QR login), whether interaction tools are enabled, and how to sign in. QR login itself happens in the Harness Web UI (Settings → AcFun), not in this tool.',
|
|
parameters: objectNode({}),
|
|
output: {
|
|
schema: objectNode({
|
|
loggedIn: required(boolean('Whether any AcFun session cookie is configured.')),
|
|
cookieConfigured: required(boolean('Whether the provider currently holds a cookie.')),
|
|
hasAuthKey: required(boolean('Whether the cookie contains an auth_key entry.')),
|
|
authSource: required(string('Where the session came from: config, env, qr, or none.')),
|
|
writesEnabled: required(boolean('Whether interaction tools are allowed by config.')),
|
|
qrLoginAvailable: required(boolean('Whether this Host exposes QR login.')),
|
|
loginState: required(string('Current QR login state: idle, waiting, scanned, confirmed, expired, or error.')),
|
|
hint: required(string('Next step for the user, in one sentence.')),
|
|
}),
|
|
render: (_args, value) => text(formatAccount(value)),
|
|
},
|
|
timeoutMs: readTimeout,
|
|
isConcurrencySafe: () => true,
|
|
async execute() {
|
|
const described = provider.describeSession()
|
|
const login = context.login ?? null
|
|
const loginState = login !== null && typeof login.snapshot === 'function' ? String(login.snapshot().state ?? 'idle') : 'idle'
|
|
const qrAvailable = login !== null && login.available === true
|
|
const source = typeof context.sessionSource === 'function' ? context.sessionSource() : described.configured ? 'config' : 'none'
|
|
const hint = described.configured
|
|
? config.allowWriteActions
|
|
? 'AcFun reads and interactions are ready.'
|
|
: 'Add "allowWriteActions: true" to the acfun plugin config to enable interactions.'
|
|
: qrAvailable
|
|
? 'Open Settings → AcFun in this app and scan the QR code, or set a cookie in the plugin config.'
|
|
: 'Set "cookie" (or the ACFUN_COOKIE environment variable) in the acfun plugin config.'
|
|
return {
|
|
loggedIn: described.configured,
|
|
cookieConfigured: described.configured,
|
|
hasAuthKey: described.hasAuthKey,
|
|
authSource: source,
|
|
writesEnabled: config.allowWriteActions === true,
|
|
qrLoginAvailable: qrAvailable,
|
|
loginState,
|
|
hint,
|
|
}
|
|
},
|
|
}
|
|
|
|
return [
|
|
searchTool,
|
|
videoTool,
|
|
commentsTool,
|
|
momentTool,
|
|
momentCommentsTool,
|
|
topicTool,
|
|
danmakuTool,
|
|
rankTool,
|
|
playUrlTool,
|
|
interactTool,
|
|
commentTool,
|
|
accountTool,
|
|
]
|
|
}
|