feat: QR login, AcFun settings panel, and 动态 (moment) tools
- 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
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
import { AcfunProvider } from '../lib/provider.js'
|
||||
import { resolveConfig } from '../lib/config.js'
|
||||
import { createAcfunTools } from '../lib/tools.js'
|
||||
import { AcfunQrFlow } from '../lib/qr-flow.js'
|
||||
import { validateValue } from './schema-check.mjs'
|
||||
|
||||
const provider = new AcfunProvider({ requestTimeoutMs: 30_000 })
|
||||
@@ -79,12 +80,41 @@ await run('acfun_comments', { acId, page: 1 })
|
||||
await run('acfun_danmaku', { acId, limit: 50 })
|
||||
await run('acfun_rank', { period: 'day', limit: 5 })
|
||||
await run('acfun_playurl', { acId })
|
||||
await run('acfun_account', {})
|
||||
await run('acfun_moment', { momentId: process.env.ACFUN_MOMENT_ID ?? '5093831' })
|
||||
await run('acfun_moment_comments', { momentId: process.env.ACFUN_MOMENT_ID ?? '5093831', page: 1 })
|
||||
await run('acfun_topic', { tagId: process.env.ACFUN_TOPIC_ID ?? '488' })
|
||||
if (video !== undefined && video.title.length === 0) failures.push('acfun_video: empty title')
|
||||
|
||||
// Interactions must fail closed without allowWriteActions + cookie.
|
||||
await run('acfun_interact', { action: 'like', acId }, { expectError: 'WRITE_DISABLED' })
|
||||
await run('acfun_interact', { action: 'moment_like', momentId: process.env.ACFUN_MOMENT_ID ?? '5093831' }, { expectError: 'WRITE_DISABLED' })
|
||||
await run('acfun_post_comment', { acId, content: 'live-check should not post this' }, { expectError: 'WRITE_DISABLED' })
|
||||
|
||||
// QR login: ask AcFun for a real ticket and check the waiting state. No phone
|
||||
// scans it, so this proves issuance + the long-poll waiting path only.
|
||||
console.log('\n=== QR login (live, never scanned)')
|
||||
try {
|
||||
const flow = new AcfunQrFlow({ longPollTimeoutMs: 8_000 })
|
||||
const started = await flow.start()
|
||||
console.log(` ok issued ticket ${String(started.ticket).slice(0, 12)}… with QR ${started.qr.kind} (${started.qr.value.length} chars), ttl ${Math.round((started.expiresAt - Date.now()) / 1000)}s`)
|
||||
const aborter = new AbortController()
|
||||
const scan = await flow.waitForScan({ signal: aborter.signal })
|
||||
console.log(` ok unscanned ticket reports state=${scan.state}`)
|
||||
if (scan.state === 'waiting') {
|
||||
const pending = flow.waitForScan({ signal: aborter.signal })
|
||||
setTimeout(() => aborter.abort(), 400)
|
||||
try {
|
||||
await pending
|
||||
failures.push('QR wait did not abort promptly')
|
||||
} catch (error) {
|
||||
console.log(` ok abort cancels the long poll (${error?.name})`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
failures.push(`QR login live check failed: ${error?.name}: ${error?.message}`)
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`\n${failures.length} live check(s) failed`)
|
||||
for (const failure of failures) console.error(`- ${failure}`)
|
||||
|
||||
+177
-15
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
/**
|
||||
* Offline pre-install check: load the bundle exactly as the Loader would
|
||||
* (`apply()` on a mock context), verify every tool definition, render each one
|
||||
* from a schema-shaped sample, and prove the write guard fails closed.
|
||||
* (`apply()` on a mock context), verify every tool definition, exercise the QR
|
||||
* login state machine with a fake flow, and serve the panel routes on an
|
||||
* ephemeral local HTTP server.
|
||||
*
|
||||
* Run with: node scripts/self-check.mjs
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
|
||||
import { apply, inject, name } from '../index.js'
|
||||
import { resolveConfig } from '../lib/config.js'
|
||||
import { createAcfunTools, ACFUN_GUIDANCE } from '../lib/tools.js'
|
||||
import { AcfunProvider } from '../lib/provider.js'
|
||||
import { AcfunLoginManager, createCredentialSessionStore, SESSION_RECORD_KEY } from '../lib/login.js'
|
||||
import { createPanelRoutes, PANEL_PATH } from '../lib/panel.js'
|
||||
import { AcfunQrFlow, QR_PATHS } from '../lib/qr-flow.js'
|
||||
import { assertSchemaSupported, sampleValue, validateValue } from './schema-check.mjs'
|
||||
|
||||
const failures = []
|
||||
@@ -26,16 +32,21 @@ console.log('plugin exports')
|
||||
check('name is dsh-plugin-acfun', name === 'dsh-plugin-acfun', String(name))
|
||||
check('inject lists tools and systemPrompt', inject.includes('tools') && inject.includes('systemPrompt'), JSON.stringify(inject))
|
||||
check('guidance is non-empty prose', typeof ACFUN_GUIDANCE === 'string' && ACFUN_GUIDANCE.length > 80, String(ACFUN_GUIDANCE.length))
|
||||
check('QR endpoints are documented constants', QR_PATHS.start.startsWith('/rest/pc-direct/qr/') && QR_PATHS.scanResult.includes('scanResult'))
|
||||
|
||||
console.log('\nmock activation')
|
||||
const registered = []
|
||||
const sections = []
|
||||
const effects = []
|
||||
const routes = []
|
||||
const mockCtx = {
|
||||
effect(factory) {
|
||||
effects.push(factory)
|
||||
factory()
|
||||
},
|
||||
get(service) {
|
||||
if (service === 'credentials') return undefined
|
||||
if (service === 'webServer') return { register: (route) => { routes.push(route); return () => {} } }
|
||||
return undefined
|
||||
},
|
||||
tools: {
|
||||
register(definition) {
|
||||
registered.push(definition)
|
||||
@@ -53,23 +64,23 @@ apply(mockCtx, {})
|
||||
|
||||
check('one system-prompt section registered', sections.length === 1, JSON.stringify(sections))
|
||||
check('section name is tool:acfun', sections[0]?.name === 'tool:acfun', String(sections[0]?.name))
|
||||
check('eight tools registered', registered.length === 8, String(registered.length))
|
||||
check('twelve tools registered', registered.length === 12, String(registered.length))
|
||||
check(
|
||||
'tool names are unique acfun_* names',
|
||||
new Set(registered.map((tool) => tool.name)).size === 8 && registered.every((tool) => tool.name.startsWith('acfun_')),
|
||||
new Set(registered.map((tool) => tool.name)).size === 12 && registered.every((tool) => tool.name.startsWith('acfun_')),
|
||||
registered.map((tool) => tool.name).join(','),
|
||||
)
|
||||
check('panel routes registered', routes.length === 1 && routes[0].kind === 'prefix' && routes[0].path === PANEL_PATH, JSON.stringify(routes.map((route) => route.path)))
|
||||
|
||||
console.log('\ntool definitions')
|
||||
for (const tool of registered) {
|
||||
const params = assertSchemaSupported(tool.parameters, `${tool.name}.parameters`)
|
||||
const output = assertSchemaSupported(tool.output.schema, `${tool.name}.output`)
|
||||
check(`${tool.name}: has a description`, typeof tool.description === 'string' && tool.description.length > 40)
|
||||
check(`${tool.name}: parameters stay in the schema subset`, assertSchemaSupported(tool.parameters, `${tool.name}.parameters`).length === 0, assertSchemaSupported(tool.parameters, `${tool.name}.parameters`).join('; '))
|
||||
check(`${tool.name}: parameters stay in the schema subset`, params.length === 0, params.join('; '))
|
||||
check(`${tool.name}: parameters are an object root`, tool.parameters.type === 'object' && tool.parameters.additionalProperties === false)
|
||||
const outputErrors = assertSchemaSupported(tool.output.schema, `${tool.name}.output`)
|
||||
check(`${tool.name}: output stays in the schema subset`, outputErrors.length === 0, outputErrors.join('; '))
|
||||
check(`${tool.name}: output stays in the schema subset`, output.length === 0, output.join('; '))
|
||||
check(`${tool.name}: output is an object root`, tool.output.schema.type === 'object')
|
||||
check(`${tool.name}: execute is a function`, typeof tool.execute === 'function')
|
||||
|
||||
const sample = sampleValue(tool.output.schema)
|
||||
const valueErrors = validateValue(sample, tool.output.schema, `${tool.name}.sample`)
|
||||
check(`${tool.name}: schema sample validates`, valueErrors.length === 0, valueErrors.join('; '))
|
||||
@@ -96,7 +107,7 @@ const exec = { signal: new AbortController().signal }
|
||||
|
||||
for (const [tool, args] of [
|
||||
[interact, { action: 'like', acId: '42588725' }],
|
||||
[postComment, { action: 'post', acId: '42588725', content: 'hello' }],
|
||||
[postComment, { acId: '42588725', content: 'hello' }],
|
||||
]) {
|
||||
try {
|
||||
await tool.execute(args, exec)
|
||||
@@ -120,10 +131,161 @@ const withEnv = resolveConfig({}, { ACFUN_COOKIE: 'auth_key=abc; _did=xyz' })
|
||||
check('env cookie is read through cookieEnv', withEnv.cookie === 'auth_key=abc; _did=xyz' && withEnv.cookieSource === 'env')
|
||||
const literal = resolveConfig({ cookie: 'auth_key=literal', cookieEnv: 'OTHER' }, { OTHER: 'ignored' })
|
||||
check('literal cookie wins over env', literal.cookie === 'auth_key=literal' && literal.cookieSource === 'config')
|
||||
const defaults = resolveConfig(undefined)
|
||||
check('defaults keep writes disabled', defaults.allowWriteActions === false && defaults.cookie === '')
|
||||
check('defaults keep writes disabled', resolveConfig(undefined).allowWriteActions === false && resolveConfig(undefined).cookie === '')
|
||||
check('bad config values fall back with warnings', resolveConfig(null).warnings.length > 0)
|
||||
|
||||
console.log('\nQR login state machine (fake flow, no network)')
|
||||
{
|
||||
const fakeProvider = new AcfunProvider({})
|
||||
const writes = []
|
||||
const store = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async write(cookie) {
|
||||
writes.push(cookie)
|
||||
},
|
||||
async clear() {
|
||||
writes.push('cleared')
|
||||
},
|
||||
}
|
||||
let step = 0
|
||||
const fakeFlow = {
|
||||
async start() {
|
||||
return { ticket: 'T-1', qr: { kind: 'data-url', value: 'data:image/png;base64,AAA' }, expiresAt: Date.now() + 60_000 }
|
||||
},
|
||||
async waitForScan() {
|
||||
step += 1
|
||||
return step === 1 ? { state: 'waiting', message: '' } : { state: 'scanned', message: '' }
|
||||
},
|
||||
async accept() {
|
||||
return { state: 'confirmed', message: 'ok', cookie: 'auth_key=abc; _did=xyz' }
|
||||
},
|
||||
}
|
||||
const manager = new AcfunLoginManager({ provider: fakeProvider, store, flow: fakeFlow })
|
||||
check('login is available with a flow', manager.available === true)
|
||||
const started = await manager.start()
|
||||
check('start returns a waiting ticket with a QR image', started.state === 'waiting' && started.ticket === 'T-1' && started.qr?.kind === 'data-url')
|
||||
const deadline = Date.now() + 6000
|
||||
while (manager.snapshot().state !== 'confirmed' && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
}
|
||||
check('background loop reaches confirmed', manager.snapshot().state === 'confirmed', manager.snapshot().state)
|
||||
check('confirmed session is persisted', writes.includes('auth_key=abc; _did=xyz'), JSON.stringify(writes))
|
||||
check('provider receives the session cookie', fakeProvider.loggedIn && fakeProvider.describeSession().hasAuthKey)
|
||||
check('session source is qr', manager.authSource === 'qr')
|
||||
const cleared = await manager.logout()
|
||||
check('logout clears state and cookie', cleared.state === 'idle' && !fakeProvider.loggedIn && writes.includes('cleared'))
|
||||
check('QR image data is not exposed in snapshot after login', manager.snapshot().qr === null)
|
||||
}
|
||||
|
||||
{
|
||||
const store = { async read() { return undefined }, async write() {}, async clear() {} }
|
||||
const manager = new AcfunLoginManager({
|
||||
provider: new AcfunProvider({}),
|
||||
store,
|
||||
flow: {
|
||||
async start() {
|
||||
return { ticket: 'T-2', qr: { kind: 'data-url', value: 'data:image/png;base64,BBB' }, expiresAt: Date.now() + 200 }
|
||||
},
|
||||
async waitForScan() {
|
||||
return { state: 'waiting', message: '' }
|
||||
},
|
||||
},
|
||||
})
|
||||
await manager.start()
|
||||
await new Promise((resolve) => setTimeout(resolve, 2600))
|
||||
check('expired tickets stop polling and report expired', manager.snapshot().state === 'expired', manager.snapshot().state)
|
||||
await manager.cancel()
|
||||
}
|
||||
|
||||
console.log('\ncredential store')
|
||||
{
|
||||
const calls = []
|
||||
const fakeCredentials = {
|
||||
async readRecord(key) {
|
||||
calls.push(['read', key])
|
||||
return { kind: 'grant', payload: { cookie: 'auth_key=stored', savedAt: 1 } }
|
||||
},
|
||||
async modifyRecord(key, mutate) {
|
||||
calls.push(['modify', key])
|
||||
await mutate(undefined)
|
||||
},
|
||||
async deleteRecord(key) {
|
||||
calls.push(['delete', key])
|
||||
},
|
||||
}
|
||||
const store = createCredentialSessionStore(fakeCredentials)
|
||||
const loaded = await store.read()
|
||||
check('credential store reads a grant record', loaded?.cookie === 'auth_key=stored' && calls.some(([op, key]) => op === 'read' && key === SESSION_RECORD_KEY))
|
||||
await store.write('auth_key=next')
|
||||
check('credential store writes through modifyRecord', calls.some(([op]) => op === 'modify'))
|
||||
await store.clear()
|
||||
check('credential store clears the record', calls.some(([op]) => op === 'delete'))
|
||||
}
|
||||
|
||||
console.log('\npanel routes over local HTTP')
|
||||
{
|
||||
const panelProvider = new AcfunProvider({})
|
||||
const store = { async read() { return undefined }, async write() {}, async clear() {} }
|
||||
const login = new AcfunLoginManager({
|
||||
provider: panelProvider,
|
||||
store,
|
||||
flow: {
|
||||
async start() {
|
||||
return { ticket: 'P-1', qr: { kind: 'data-url', value: 'data:image/png;base64,CCC' }, expiresAt: Date.now() + 60_000 }
|
||||
},
|
||||
async waitForScan() {
|
||||
return { state: 'waiting', message: '' }
|
||||
},
|
||||
},
|
||||
})
|
||||
const panelTools = createAcfunTools(panelProvider, resolveConfig({}))
|
||||
const [route] = createPanelRoutes({
|
||||
provider: panelProvider,
|
||||
login,
|
||||
version: '0.0.0-test',
|
||||
allowWriteActions: false,
|
||||
cookieSource: () => 'none',
|
||||
tools: panelTools,
|
||||
})
|
||||
const server = http.createServer(route.handler)
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}${PANEL_PATH}`
|
||||
try {
|
||||
const state = await (await fetch(`${base}/state`)).json()
|
||||
check('panel state reports the plugin and tools', state.ok === true && state.plugin.name === 'dsh-plugin-acfun' && state.tools.length === 12)
|
||||
check('panel state does not leak a cookie', state.auth.configured === false && state.auth.hasAuthKey === false)
|
||||
|
||||
const started = await (await fetch(`${base}/login/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json()
|
||||
check('panel start returns a QR data URL', started.ok === true && started.login.state === 'waiting' && started.login.qr.kind === 'data-url')
|
||||
|
||||
const polled = await (await fetch(`${base}/login/poll?ticket=${encodeURIComponent(started.login.ticket)}`)).json()
|
||||
check('panel poll returns the in-memory state', polled.ok === true && polled.login.state === 'waiting')
|
||||
|
||||
const crossed = await fetch(`${base}/state`, { headers: { Origin: 'https://evil.example' } })
|
||||
check('panel rejects cross-origin browser requests', crossed.status === 403, String(crossed.status))
|
||||
|
||||
const unknown = await fetch(`${base}/nope`)
|
||||
check('panel returns 404 for unknown routes', unknown.status === 404, String(unknown.status))
|
||||
|
||||
const loggedOut = await (await fetch(`${base}/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' })).json()
|
||||
check('panel logout returns to idle', loggedOut.ok === true && loggedOut.login.state === 'idle')
|
||||
} finally {
|
||||
await login.cancel()
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nqr flow module')
|
||||
{
|
||||
const flow = new AcfunQrFlow({ serviceUrl: 'https://scan.acfun.cn', longPollTimeoutMs: 5000 })
|
||||
check('flow exposes documented paths', flow.serviceUrl === 'https://scan.acfun.cn')
|
||||
check('start is a function', typeof flow.start === 'function')
|
||||
check('waitForScan is a function', typeof flow.waitForScan === 'function')
|
||||
check('accept is a function', typeof flow.accept === 'function')
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`\n${failures.length} check(s) failed`)
|
||||
process.exitCode = 1
|
||||
|
||||
Reference in New Issue
Block a user