From 69968036abd66ca07a08f65c0b99836b10a54682 Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 10 Oct 2026 15:18:25 +0800 Subject: [PATCH] feat: W4 第二部分——Webhook、每日对账 job、任务详情 / 流水 / 求助收件箱 / 设置页 --- apps/voko-server/.env.example | 2 ++ apps/voko-server/package.json | 1 + apps/voko-server/src/assembly/runtime.js | 9 +++++---- apps/voko-server/src/env.js | 3 +++ apps/voko-server/src/routes/owner.js | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------- apps/voko-server/src/routes/v1.js | 14 +++++++++----- apps/voko-server/src/voko-worker-main.js | 4 +++- apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs | 3 ++- apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs | 35 ++--------------------------------- apps/voko-server/tools/scenarios/voko-alpha/webhook.mjs | 32 ++++++++++++++++++++++++++++++++ apps/voko-web/src/i18n/messages.js | 78 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-web/src/main.js | 8 ++++++++ apps/voko-web/src/pages/AgentsPage.vue | 9 +++++++-- apps/voko-web/src/pages/BoardPage.vue | 2 +- apps/voko-web/src/pages/HelpPage.vue | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-web/src/pages/LedgerPage.vue | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-web/src/pages/SettingsPage.vue | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ apps/voko-web/src/pages/TaskPage.vue | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ docs/runbooks/verify-alpha.md | 21 +++++++++++++++------ packages/voko-access/src/index.js | 1 + packages/voko-access/src/webhooks.js | 170 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ packages/voko-access/test/webhooks.test.js | 28 ++++++++++++++++++++++++++++ packages/voko-clearing/src/conservation.js | 36 ++++++++++++++++++++++++++++++++++++ packages/voko-clearing/src/index.js | 3 ++- packages/voko-exchange/src/events.js | 4 +++- packages/voko-exchange/src/jobs.js | 25 +++++++++++++++++++++++-- packages/voko-exchange/src/tasks.js | 8 ++++++-- tools/scripts/verify-local.sh | 2 +- 28 files changed, 752 insertions(+), 101 deletions(-) create mode 100644 apps/voko-server/tools/scenarios/voko-alpha/webhook.mjs create mode 100644 apps/voko-web/src/pages/HelpPage.vue create mode 100644 apps/voko-web/src/pages/LedgerPage.vue create mode 100644 apps/voko-web/src/pages/SettingsPage.vue create mode 100644 apps/voko-web/src/pages/TaskPage.vue create mode 100644 packages/voko-access/src/webhooks.js create mode 100644 packages/voko-access/test/webhooks.test.js create mode 100644 packages/voko-clearing/src/conservation.js diff --git a/apps/voko-server/.env.example b/apps/voko-server/.env.example index 74d623f..cef1b9a 100644 --- a/apps/voko-server/.env.example +++ b/apps/voko-server/.env.example @@ -6,6 +6,8 @@ LOG_LEVEL=info DATABASE_URL= # 生产必须设置,开发缺省使用固定开发值 AUTH_TOKEN_SECRET= +# 加密存储各 Agent 的 Webhook 签名 secret。生产必须设置,换掉后已登记的 Webhook 需重新登记 +WEBHOOK_SECRET_KEY= # 前端地址,用于邮件里的链接 PUBLIC_URL=http://localhost:5201 # 发信(验证码、申请通过通知)。缺任何一项服务拒绝启动。 diff --git a/apps/voko-server/package.json b/apps/voko-server/package.json index fa3ec92..7323788 100644 --- a/apps/voko-server/package.json +++ b/apps/voko-server/package.json @@ -18,6 +18,7 @@ "verify:f2": "node tools/scenarios/voko-alpha/f2-execute.mjs", "verify:f3": "node tools/scenarios/voko-alpha/f3-review-settle.mjs", "verify:e2e": "node tools/scenarios/voko-alpha/e2e-loop.mjs", + "verify:webhook": "node tools/scenarios/voko-alpha/webhook.mjs", "verify:timers": "node tools/scenarios/voko-alpha/timers.mjs", "verify:concurrency": "node tools/scenarios/voko-alpha/concurrency.mjs", "verify:conservation": "node tools/scenarios/voko-alpha/verify-conservation.mjs" diff --git a/apps/voko-server/src/assembly/runtime.js b/apps/voko-server/src/assembly/runtime.js index 09a81df..59e2856 100644 --- a/apps/voko-server/src/assembly/runtime.js +++ b/apps/voko-server/src/assembly/runtime.js @@ -9,9 +9,9 @@ import { createBillingRepo, createLedger } from '@drama/billing' import { createDb } from '@drama/db' import { createLLM, createStaticKeyProvider } from '@drama/llm' import { createLogger } from '@drama/observability' -import { createAccessRequests, createAgentAccess, createOwnerSettings, nextAllowedSendTime } from '@voko/access' +import { createAccessRequests, createAgentAccess, createOwnerSettings, createWebhooks, nextAllowedSendTime } from '@voko/access' import { createMemoryStore, createRateLimiter } from '@drama/ratelimit' -import { createEscrow, createTips } from '@voko/clearing' +import { createEscrow, createTips, verifyConservation } from '@voko/clearing' import { createAgents, createEvents, createGuilds, createHelp, createJobs, createJudge, createTasks } from '@voko/exchange' import { readEnv } from '../env.js' @@ -68,7 +68,8 @@ export async function createRuntime({ service }) { }, }) const tips = createTips({ db }) - const jobs = createJobs({ db, tasks, help, logger }) + const webhooks = createWebhooks({ db, secret_key: env.webhookSecretKey }) + const jobs = createJobs({ db, tasks, help, conservationProblems: () => verifyConservation(db), logger }) /** * 给已结算任务的赢家打赏;收款事件与动账同事务。 @@ -134,7 +135,7 @@ export async function createRuntime({ service }) { return { env, logger, - services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, + services: { db, auth, userRepo, access, agents, guilds, agentAccess, escrow, tasks, events, jobs, help, ownerSettings, webhooks, tipTask, rateLimiter, register, oauthEnabled: env.oauth.googleClientId !== null }, async close() { await db.destroy() }, diff --git a/apps/voko-server/src/env.js b/apps/voko-server/src/env.js index f5511e6..11ea196 100644 --- a/apps/voko-server/src/env.js +++ b/apps/voko-server/src/env.js @@ -11,6 +11,7 @@ import { devMysqlUrl } from '@drama/db' * databaseUrl: string, * logLevel: 'debug'|'info'|'warn'|'error', * authTokenSecret: string, + * webhookSecretKey: string, * publicUrl: string, * email: { host: string, port: number, secure: boolean, user: string, pass: string, from: string }, * oauth: { googleClientId: string|null, googleClientSecret: string|null }, @@ -28,6 +29,7 @@ export function readEnv(rawEnv) { const scheme = new URL(databaseUrl).protocol if (scheme !== 'mysql:' && scheme !== 'mysql2:') throw new Error(`voko-server: DATABASE_URL 必须是 MySQL,收到 ${scheme}`) if (nodeEnv === 'production' && !rawEnv.AUTH_TOKEN_SECRET) throw new Error('voko-server: 生产环境必须设置 AUTH_TOKEN_SECRET') + if (nodeEnv === 'production' && !rawEnv.WEBHOOK_SECRET_KEY) throw new Error('voko-server: 生产环境必须设置 WEBHOOK_SECRET_KEY') const email = { host: rawEnv.SMTP_HOST, @@ -52,6 +54,7 @@ export function readEnv(rawEnv) { databaseUrl, logLevel: parseLogLevel(rawEnv.LOG_LEVEL), authTokenSecret: rawEnv.AUTH_TOKEN_SECRET || 'voko-dev-token-secret', + webhookSecretKey: rawEnv.WEBHOOK_SECRET_KEY || 'voko-dev-webhook-secret-key', publicUrl: (rawEnv.PUBLIC_URL || 'http://localhost:5201').replace(/\/$/, ''), email: /** @type {VokoEnv['email']} */ (email), llm: /** @type {VokoEnv['llm']} */ (llm), diff --git a/apps/voko-server/src/routes/owner.js b/apps/voko-server/src/routes/owner.js index 2b0f70b..49fb2ba 100644 --- a/apps/voko-server/src/routes/owner.js +++ b/apps/voko-server/src/routes/owner.js @@ -5,7 +5,7 @@ import { agentView, IdempotencyKey, ledgerView, PublishBody, REFUSAL_STATUS, tas const AgentParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const LimitBody = v.object({ daily_spend_limit: v.nullable(v.pipe(v.number(), v.safeInteger(), v.minValue(0))) }) -const TaskIdParams = v.object({ task_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) +const TaskIdParams = v.object({ id: v.optional(v.string()), task_id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) const ApprovalBody = v.object({ decision: v.picklist(['approve', 'reject']) }) const ResolveBody = v.object({ outcome: v.picklist(['settled', 'refunded']), winner_agent_id: v.optional(v.pipe(v.string(), v.minLength(1), v.maxLength(64))) }) const HelpParams = v.object({ id: v.pipe(v.string(), v.minLength(1), v.maxLength(64)) }) @@ -30,6 +30,58 @@ export function registerOwnerRoutes(app, services) { const { agents, agentAccess, guilds, escrow, tasks, help, ownerSettings, db } = services /** + * 业务拒绝 → 网页接口的错误形状。 + * @param {import('fastify').FastifyReply} reply + * @param {{ code: keyof typeof REFUSAL_STATUS, hint: string }} refusal + */ + const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } }) + + // 任务详情:以自己某个 Agent 的身份查看(可见范围与该 Agent 调公开 API 相同)。 + app.get('/api/voko/agents/:id/tasks/:task_id', async (request, reply) => { + const { agent } = await ownAgent(request) + const params = v.parse(TaskIdParams, request.params) + const result = await tasks.get(request.ctx, { agent, task_id: params.task_id }) + if (!result.ok) return refused(reply, result) + const comments = await tasks.listComments(request.ctx, { agent, task_id: params.task_id }) + if (!comments.ok) return refused(reply, comments) + const iso = (/** @type {{ created_at: Date }} */ row) => ({ ...row, created_at: row.created_at.toISOString() }) + return { task: taskView(result.task), acceptance: result.acceptance, entry_count: result.entry_count, checkpoints: result.checkpoints.map(iso), submissions: result.submissions.map(iso), comments: comments.comments.map(iso) } + }) + + app.get('/api/voko/agents/:id/ledger', async (request) => { + const { agent } = await ownAgent(request) + return { ledger: (await escrow.ledgerOf(request.ctx, { wallet_user_id: agent.wallet_user_id, limit: 100 })).map(ledgerView) } + }) + + // 求助收件箱:主人回复自己 Agent 的求助。 + app.get('/api/voko/help', async (request) => { + const user = await requireUser(request, services) + const rows = await help.listForOwner(request.ctx, { user_id: user.id }) + return { help_requests: rows.map((row) => ({ ...row, created_at: row.created_at.toISOString(), answered_at: row.answered_at === null ? null : row.answered_at.toISOString() })) } + }) + + app.post('/api/voko/help/:id/answer', async (request, reply) => { + const user = await requireUser(request, services) + const params = v.parse(HelpParams, request.params) + const body = v.parse(AnswerBody, request.body) + const result = await help.answer(request.ctx, { user_id: user.id, help_id: params.id, answer: body.answer }) + return result.ok ? { ok: true } : refused(reply, result) + }) + + // 设置:时区、安静时段、邮件开关。 + app.get('/api/voko/me/settings', async (request) => { + const user = await requireUser(request, services) + return { settings: await ownerSettings.get(request.ctx, { user_id: user.id }) } + }) + + app.put('/api/voko/me/settings', async (request) => { + const user = await requireUser(request, services) + const settings = v.parse(SettingsBody, request.body) + await ownerSettings.save(request.ctx, { user_id: user.id, settings }) + return { settings } + }) + + /** * 取当前用户名下的一个 Agent;不是自己的按不存在处理。 * @param {import('fastify').FastifyRequest} request */ @@ -94,46 +146,6 @@ export function registerOwnerRoutes(app, services) { return { task: taskView(result.task) } }) - /** - * 业务拒绝 → 网页接口的错误形状。 - * @param {import('fastify').FastifyReply} reply - * @param {{ code: keyof typeof REFUSAL_STATUS, hint: string }} refusal - */ - const refused = (reply, refusal) => reply.code(REFUSAL_STATUS[refusal.code]).send({ error: { code: refusal.code, message: refusal.hint, retryable: false } }) - - app.get('/api/voko/agents/:id/ledger', async (request) => { - const { agent } = await ownAgent(request) - return { ledger: (await escrow.ledgerOf(request.ctx, { wallet_user_id: agent.wallet_user_id, limit: 100 })).map(ledgerView) } - }) - - // 求助收件箱:主人回复自己 Agent 的求助。 - app.get('/api/voko/help', async (request) => { - const user = await requireUser(request, services) - const rows = await help.listForOwner(request.ctx, { user_id: user.id }) - return { help_requests: rows.map((row) => ({ ...row, created_at: row.created_at.toISOString(), answered_at: row.answered_at === null ? null : row.answered_at.toISOString() })) } - }) - - app.post('/api/voko/help/:id/answer', async (request, reply) => { - const user = await requireUser(request, services) - const params = v.parse(HelpParams, request.params) - const body = v.parse(AnswerBody, request.body) - const result = await help.answer(request.ctx, { user_id: user.id, help_id: params.id, answer: body.answer }) - return result.ok ? { ok: true } : refused(reply, result) - }) - - // 设置:时区、安静时段、邮件开关。 - app.get('/api/voko/me/settings', async (request) => { - const user = await requireUser(request, services) - return { settings: await ownerSettings.get(request.ctx, { user_id: user.id }) } - }) - - app.put('/api/voko/me/settings', async (request) => { - const user = await requireUser(request, services) - const settings = v.parse(SettingsBody, request.body) - await ownerSettings.save(request.ctx, { user_id: user.id, settings }) - return { settings } - }) - // 负责人:待审核任务(公司 Agent 发布、公会开启发布前审核时)。 app.get('/api/voko/approvals', async (request) => { const user = await requireUser(request, services) diff --git a/apps/voko-server/src/routes/v1.js b/apps/voko-server/src/routes/v1.js index c217a22..aed7f8b 100644 --- a/apps/voko-server/src/routes/v1.js +++ b/apps/voko-server/src/routes/v1.js @@ -62,7 +62,7 @@ export const IdempotencyKey = v.pipe(v.string('Idempotency-Key header is require export const REFUSAL_STATUS = Object.freeze({ not_found: 404, forbidden: 403, state_conflict: 409, rule_violation: 422 }) const SKILL_VERSION = '0.1.0' -const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills) }) +const UpdateMeBody = v.object({ description: v.optional(Description), skills: v.optional(Skills), webhook_url: v.optional(v.nullable(v.pipe(v.string(), v.url(), v.maxLength(2048)))) }) /** * 公开 API `/v1`:Agent 用 api_key 调用。统一返回 `{ok:true,data}` / `{ok:false,error:{code,message,hint}}`。 @@ -70,7 +70,7 @@ const UpdateMeBody = v.object({ description: v.optional(Description), skills: v. * @param {Record} services */ export function registerV1Routes(app, services) { - const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, rateLimiter } = services + const { agentAccess, agents, db, tasks, events, escrow, help, tipTask, webhooks, rateLimiter } = services app.register(async (v1) => { v1.setErrorHandler((/** @type {any} */ err, request, reply) => { @@ -125,10 +125,14 @@ export function registerV1Routes(app, services) { v1.patch('/agents/me', async (request, reply) => { const body = v.parse(UpdateMeBody, request.body) - if (body.description === undefined && body.skills === undefined) return fail(reply, 400, 'invalid_request', 'Nothing to update', 'Send description and/or skills.') + if (body.description === undefined && body.skills === undefined && body.webhook_url === undefined) return fail(reply, 400, 'invalid_request', 'Nothing to update', 'Send description, skills and/or webhook_url.') const agentId = /** @type {any} */ (request).agent.id - await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills }) - return ok(agentView(await agents.getById(request.ctx, db, { agent_id: agentId }))) + // 登记 Webhook 时返回签名 secret,仅此一次。 + const hook = body.webhook_url === undefined ? null : await webhooks.register(request.ctx, { agent_id: agentId, webhook_url: body.webhook_url }) + if (hook !== null && !hook.ok) return fail(reply, 400, 'invalid_request', 'webhook_url is not allowed', hook.hint) + if (body.description !== undefined || body.skills !== undefined) await agents.updateProfile(request.ctx, db, { agent_id: agentId, description: body.description, skills: body.skills }) + const view = agentView(await agents.getById(request.ctx, db, { agent_id: agentId })) + return ok(hook === null || hook.webhook_secret === null ? view : { ...view, webhook_secret: hook.webhook_secret }) }) v1.get('/home', async (request) => { diff --git a/apps/voko-server/src/voko-worker-main.js b/apps/voko-server/src/voko-worker-main.js index bd6bf85..23ecf9c 100644 --- a/apps/voko-server/src/voko-worker-main.js +++ b/apps/voko-server/src/voko-worker-main.js @@ -1,5 +1,5 @@ /** - * `voko-worker` 进程入口:每 2 秒领取并执行到期的 vk_jobs(见 SDD「定时任务与事件」)。 + * `voko-worker` 进程入口:每 2 秒领取并执行到期的 vk_jobs,并投递到期的 Webhook(见 SDD「定时任务与事件」)。 */ import { createRuntime } from './assembly/runtime.js' @@ -13,7 +13,9 @@ let stopping = false async function tick() { await runtime.services.jobs.runDue({}) + await runtime.services.webhooks.deliverDue({}) ticks += 1 + if (ticks % HEARTBEAT_EVERY_TICKS === 1) await runtime.services.jobs.ensureDaily({}) if (ticks % HEARTBEAT_EVERY_TICKS === 0) runtime.logger.info('worker 心跳', { ticks }) } diff --git a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs index bd6bf54..b7c26d6 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/_lib.mjs @@ -7,9 +7,9 @@ import { randomUUID } from 'node:crypto' import { createBillingRepo, createLedger } from '@drama/billing' import { createDb } from '@drama/db' +import { verifyConservation } from '@voko/clearing' import { databaseUrlFromArgs } from '../../../src/db/env-file.js' -import { verifyConservation } from './verify-conservation.mjs' const api = (process.env.VOKO_API || 'http://localhost:9201').replace(/\/$/, '') @@ -162,6 +162,7 @@ function createWorld(name, db) { await db('vk_tasks').whereIn('id', taskIds).del() } if (guildIds.length > 0) { + await db('vk_webhook_deliveries').whereIn('event_id', db('vk_events').whereIn('guild_id', guildIds).select('id')).del() await db('vk_events').whereIn('guild_id', guildIds).del() await db('vk_guild_members').whereIn('guild_id', guildIds).del() await db('vk_guilds').whereIn('id', guildIds).del() diff --git a/apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs b/apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs index 56d4a64..28f47b5 100644 --- a/apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs +++ b/apps/voko-server/tools/scenarios/voko-alpha/verify-conservation.mjs @@ -2,45 +2,14 @@ /** * 对账(SDD「积分清算」):① 已关闭托管的发放明细之和 = 托管金额;② held 托管都对应未结束的任务, * 未结束的任务都有 held 托管;③ 每笔 voko 流水的 ref 都能找到业务记录。 - * 任一不满足即失败并打印数据 id。可单独运行,也在每个场景脚本末尾运行。 + * 任一不满足即失败并打印数据 id。可单独运行;每个场景脚本末尾与 worker 的每日 job 调用同一个函数。 */ import { fileURLToPath } from 'node:url' import { createDb } from '@drama/db' +import { verifyConservation } from '@voko/clearing' import { databaseUrlFromArgs } from '../../../src/db/env-file.js' -const TERMINAL = ['settled', 'cancelled', 'refunded'] - -/** - * @param {import('knex').Knex} db - * @returns {Promise>} - */ -export async function verifyConservation(db) { - const problems = [] - const closed = await db('vk_escrows as e') - .leftJoin('vk_escrow_payouts as p', 'p.escrow_id', 'e.id') - .whereNot('e.status', 'held') - .groupBy('e.id', 'e.amount') - .havingRaw('COALESCE(SUM(p.amount), 0) <> e.amount') - .select('e.id', 'e.amount', db.raw('COALESCE(SUM(p.amount), 0) as paid')) - for (const row of closed) problems.push({ rule: 'payouts_sum', id: row.id, detail: `amount=${row.amount}, payouts=${row.paid}` }) - - const heldOnEnded = await db('vk_escrows as e').join('vk_tasks as t', 't.id', 'e.task_id').where('e.status', 'held').whereIn('t.status', TERMINAL).select('e.id', 't.status') - for (const row of heldOnEnded) problems.push({ rule: 'held_on_ended_task', id: row.id, detail: `task status=${row.status}` }) - const openWithoutHeld = await db('vk_tasks as t') - .leftJoin('vk_escrows as e', (join) => join.on('e.task_id', 't.id').andOnVal('e.status', 'held')) - .whereNotIn('t.status', TERMINAL) - .whereNull('e.id') - .select('t.id', 't.status') - for (const row of openWithoutHeld) problems.push({ rule: 'active_task_without_held_escrow', id: row.id, detail: `task status=${row.status}` }) - - const orphanTaskRefs = await db('billing_ledger as l').leftJoin('vk_tasks as t', 't.id', 'l.ref_id').where({ 'l.source': 'voko', 'l.ref_type': 'vk_task' }).whereNull('t.id').select('l.id', 'l.ref_id') - const orphanAgentRefs = await db('billing_ledger as l').leftJoin('vk_agents as a', 'a.id', 'l.ref_id').where({ 'l.source': 'voko', 'l.ref_type': 'vk_agent' }).whereNull('a.id').select('l.id', 'l.ref_id') - const orphanTipRefs = await db('billing_ledger as l').leftJoin('vk_tips as p', 'p.id', 'l.ref_id').where({ 'l.source': 'voko', 'l.ref_type': 'vk_tip' }).whereNull('p.id').select('l.id', 'l.ref_id') - for (const row of [...orphanTaskRefs, ...orphanAgentRefs, ...orphanTipRefs]) problems.push({ rule: 'ledger_ref_missing', id: row.id, detail: `ref_id=${row.ref_id}` }) - return problems -} - if (process.argv[1] === fileURLToPath(import.meta.url)) { const db = createDb({ url: databaseUrlFromArgs(process.argv.slice(2)), pool: { min: 1, max: 1 } }) try { diff --git a/apps/voko-server/tools/scenarios/voko-alpha/webhook.mjs b/apps/voko-server/tools/scenarios/voko-alpha/webhook.mjs new file mode 100644 index 0000000..43018b5 --- /dev/null +++ b/apps/voko-server/tools/scenarios/voko-alpha/webhook.mjs @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * Webhook 的真实链路脚本。目前覆盖 WH-03(非 https、回环、内网、元数据地址一律拒绝)与登记 / 取消。 + * WH-01、WH-02 需要一个公网可达的 HTTPS 接收端(平台拒绝向回环与内网地址投递,本机起的接收服务收不到), + * 在 stage 环境验收;签名算法由 packages/voko-access/test/webhooks.test.js 覆盖。 + */ +import { runScenario } from './_lib.mjs' + +await runScenario('wh', async ({ db, check, call, createOwner, createGuild, createAgent }) => { + const alice = await createOwner('alice') + const guild = await createGuild('main', alice.user_id) + const agent = await createAgent('hooked', alice, guild) + const register = (webhook_url) => call('PATCH', '/v1/agents/me', { token: agent.key, body: { webhook_url } }) + + for (const url of ['http://example.com/hook', 'https://127.0.0.1/hook', 'https://localhost/hook', 'https://10.0.0.5/hook', 'https://192.168.1.10/hook', 'https://169.254.169.254/latest/meta-data', 'https://[::1]/hook', 'https://user:pass@example.com/hook', 'https://no-such-host.invalid/hook']) { + const refused = await register(url) + check(`WH-03 ${url} → 400`, refused.status === 400 && refused.json.error.code === 'invalid_request' && typeof refused.json.error.hint === 'string', refused) + } + const stored = await db('vk_agents').where({ id: agent.id }).first('webhook_url', 'webhook_secret_enc') + check('被拒绝的地址没有落库', stored.webhook_url === null && stored.webhook_secret_enc === null, stored) + + // 登记一个公网 https 地址后立刻取消,期间不产生任何事件,所以不会真的向它发请求。 + // 用 IP 字面量:开发机若开着 fake-ip 代理,域名会被解析到 198.18.0.0/15 保留段而被(正确地)拒绝。 + const ok = await register('https://1.1.1.1/voko-hook') + const row = await db('vk_agents').where({ id: agent.id }).first('webhook_url', 'webhook_secret_enc') + check('公网 https 地址登记成功,secret 只返回一次且加密存储', ok.status === 200 && /^[0-9a-f]{64}$/.test(ok.json.data.webhook_secret) && row.webhook_url === 'https://1.1.1.1/voko-hook' && !row.webhook_secret_enc.includes(ok.json.data.webhook_secret), ok) + const me = await call('GET', '/v1/agents/me', { token: agent.key }) + check('之后的接口不再返回 secret', !JSON.stringify(me.json).includes(ok.json.data.webhook_secret), me) + const cleared = await register(null) + const after = await db('vk_agents').where({ id: agent.id }).first('webhook_url', 'webhook_secret_enc') + check('取消登记', cleared.status === 200 && after.webhook_url === null && after.webhook_secret_enc === null, cleared) +}) diff --git a/apps/voko-web/src/i18n/messages.js b/apps/voko-web/src/i18n/messages.js index 8551934..e0d203c 100644 --- a/apps/voko-web/src/i18n/messages.js +++ b/apps/voko-web/src/i18n/messages.js @@ -69,6 +69,45 @@ export const messages = { approve: 'Approve', reject: 'Reject and refund', }, + help: { + title: 'Help inbox', + empty: 'None of your agents has asked for help.', + task: 'Task: {title}', + answer: 'Your reply', + send: 'Send reply', + yourAnswer: 'Your reply:', + status: { open: 'Waiting for you', answered: 'Answered' }, + }, + settings: { + title: 'Settings', + timezone: 'Time zone (IANA name, e.g. Asia/Shanghai)', + useBrowserZone: 'Use this browser\'s time zone', + quietHint: 'During quiet hours, help emails wait until quiet hours end. Set both to the same hour for no quiet hours.', + quietStart: 'Quiet hours start', + quietEnd: 'Quiet hours end', + emailHelp: 'Email me when an agent asks for help', + emailDigest: 'Email me a daily digest', + save: 'Save settings', + }, + ledger: { + title: 'Points ledger', + empty: 'No entries yet.', + when: 'Time', + what: 'Entry', + amount: 'Points', + task: 'View task', + reason: { vk_starter: 'Starter points', vk_escrow_hold: 'Held in escrow', vk_task_payout: 'Task reward', vk_relay_share: 'Relay share', vk_escrow_refund: 'Escrow refund', vk_tip: 'Tip', admin_adjustment: 'Operator adjustment' }, + }, + task: { + back: 'Back to the board', + acceptance: 'Acceptance checklist', + checkpoints: 'Checkpoints', + submissions: 'Submissions', + noSubmissions: 'Nothing visible yet. Submissions are shown to the publisher and the submitter; contest entries stay sealed until the deadline.', + comments: 'Comments', + round: 'Round {round}', + none: 'None yet.', + }, board: { title: 'Guild board', back: 'Back to my agents', @@ -160,6 +199,45 @@ export const messages = { approve: '通过', reject: '驳回并退款', }, + help: { + title: '求助收件箱', + empty: '你的 Agent 还没有求助过。', + task: '任务:{title}', + answer: '你的回复', + send: '发送回复', + yourAnswer: '你的回复:', + status: { open: '等你回复', answered: '已回复' }, + }, + settings: { + title: '设置', + timezone: '时区(IANA 名称,如 Asia/Shanghai)', + useBrowserZone: '使用当前浏览器的时区', + quietHint: '安静时段内的求助邮件会推迟到安静时段结束再发。起止设为同一小时表示不设安静时段。', + quietStart: '安静时段开始', + quietEnd: '安静时段结束', + emailHelp: 'Agent 求助时给我发邮件', + emailDigest: '每天给我发日报', + save: '保存设置', + }, + ledger: { + title: '积分流水', + empty: '还没有流水。', + when: '时间', + what: '事项', + amount: '积分', + task: '查看任务', + reason: { vk_starter: '启动积分', vk_escrow_hold: '冻结进托管', vk_task_payout: '任务收入', vk_relay_share: '接力分成', vk_escrow_refund: '托管退回', vk_tip: '打赏', admin_adjustment: '运营调账' }, + }, + task: { + back: '返回看板', + acceptance: '验收清单', + checkpoints: '检查点', + submissions: '交付', + noSubmissions: '暂无可见的交付。交付只对发布方和提交者本人可见;擂台赛的提交在截止前封存。', + comments: '评论', + round: '第 {round} 轮', + none: '暂无。', + }, board: { title: '公会看板', back: '返回我的 Agent', diff --git a/apps/voko-web/src/main.js b/apps/voko-web/src/main.js index 01be2f5..b55bd92 100644 --- a/apps/voko-web/src/main.js +++ b/apps/voko-web/src/main.js @@ -10,8 +10,12 @@ import AgentsPage from './pages/AgentsPage.vue' import ApprovalsPage from './pages/ApprovalsPage.vue' import BoardPage from './pages/BoardPage.vue' import ClaimPage from './pages/ClaimPage.vue' +import HelpPage from './pages/HelpPage.vue' import HomePage from './pages/HomePage.vue' +import LedgerPage from './pages/LedgerPage.vue' import LoginPage from './pages/LoginPage.vue' +import SettingsPage from './pages/SettingsPage.vue' +import TaskPage from './pages/TaskPage.vue' const router = createRouter({ history: createWebHistory(), @@ -22,6 +26,10 @@ const router = createRouter({ { path: '/claim/:token', component: ClaimPage }, { path: '/agents', component: AgentsPage }, { path: '/approvals', component: ApprovalsPage }, + { path: '/help', component: HelpPage }, + { path: '/settings', component: SettingsPage }, + { path: '/agents/:id/ledger', component: LedgerPage }, + { path: '/agents/:id/tasks/:taskId', component: TaskPage }, { path: '/agents/:id/board', component: BoardPage }, ], }) diff --git a/apps/voko-web/src/pages/AgentsPage.vue b/apps/voko-web/src/pages/AgentsPage.vue index 5b35aa1..d05d18f 100644 --- a/apps/voko-web/src/pages/AgentsPage.vue +++ b/apps/voko-web/src/pages/AgentsPage.vue @@ -66,13 +66,13 @@ onMounted(async () => {