// Bot API(/bot/v1,docs/design/13-bot-teams.md §9、ADR-0024) import fs from 'node:fs' import path from 'node:path' import type { FastifyInstance, FastifyRequest } from 'fastify' import { z } from 'zod' import type { CreateBrowserTokenBody, CreateTeamBody, InteractionResponse, SendTeamMessageBody, StubTaskBody, TeamStatus } from '@agent-server/shared' import type { App } from '../app' import { AppError, badRequest, notFound } from '../lib/errors' import { BROWSER_TOKEN_MAX_TTL } from '../services/browser-tokens' import { parse } from '../services/catalog' import { principal, requireBotClient } from '../http/auth' import { streamEvents } from '../http/sse' type IdParams = { Params: { id: string } } type BotParams = { Params: { id: string; botId: string } } type JobParams = { Params: { id: string; jobId: string } } const interactionResponseSchema: z.ZodType = z.union([ z.object({ decision: z.enum(['allow_once', 'allow_session']) }), z.object({ decision: z.literal('deny'), reason: z.string().max(2000).optional() }), z.object({ answers: z.record(z.string(), z.union([z.string(), z.array(z.string())])) }), ]) /** 已注册的 /bot/v1 路由(方法 + 路径),用于核对 API 协议文档是否完整(test/api-reference.test.ts) */ export const registeredBotRoutes: { method: string; path: string }[] = [] export default async function botRoutes(f: FastifyInstance, { app }: { app: App }) { f.addHook('onRoute', r => { for (const m of [r.method].flat()) if (m !== 'HEAD' && m !== 'OPTIONS') registeredBotRoutes.push({ method: m, path: r.url }) }) f.addHook('preHandler', requireBotClient(app)) const source = (req: FastifyRequest) => principal(req).source const teamFor = (req: FastifyRequest) => app.teams.getRow(req.params.id, source(req)) f.post('/browser-tokens', async (req, reply) => { const p = principal(req) if (p.kind !== 'botkey' || p.browser) throw new AppError(403, 'forbidden', '只能用 Bot Key 签发浏览器令牌') const body = (req.body ?? {}) as CreateBrowserTokenBody const ttl = body.ttlSeconds === undefined ? undefined : Number(body.ttlSeconds) if (ttl !== undefined && !(ttl >= 60 && ttl <= BROWSER_TOKEN_MAX_TTL)) throw badRequest(`ttlSeconds 应在 60 ~ ${BROWSER_TOKEN_MAX_TTL} 之间`) reply.code(201) return app.browserTokens.issue(p.source, ttl, Date.now(), 'bot') }) // 这个 Key 可以使用的机器人(后台不受限) f.get('/bots', async req => { const allowed = app.botKeys.allowedBots(source(req)) return app.teamCards.cards(app.bots.list().filter(b => b.enabled && (!allowed || allowed.has(b.id)))) }) // ---- 工作组 ---- f.post('/teams', async (req, reply) => { const row = await app.teams.create(source(req), (req.body ?? {}) as CreateTeamBody) reply.code(201) return app.teams.detail(row) }) f.get<{ Querystring: { scope?: string; status?: TeamStatus; limit?: string } }>('/teams', async req => app.teams.list(source(req), { all: req.query.scope === 'all', status: req.query.status, limit: req.query.limit ? Number(req.query.limit) : undefined })) f.get('/teams/:id', async req => app.teams.detail(teamFor(req))) f.patch('/teams/:id', async req => app.teams.detail(app.teams.patch(req.params.id, source(req), req.body))) f.delete('/teams/:id', async (req, reply) => { await app.teams.delete(req.params.id, source(req)) reply.code(204) }) for (const action of ['pause', 'resume', 'close', 'reopen'] as const) { f.post(`/teams/:id/${action}`, async req => app.teams.detail(app.teams.setStatus(req.params.id, source(req), action))) } f.post('/teams/:id/bots', async req => { const { botId } = parse(z.object({ botId: z.string().min(1) }), req.body) return app.teams.detail(app.teams.addBot(req.params.id, source(req), botId)) }) f.delete('/teams/:id/bots/:botId', async req => app.teams.detail(app.teams.removeBot(req.params.id, source(req), req.params.botId))) f.post('/teams/:id/bots/:botId/interrupt', async (req, reply) => { app.teams.interrupt(req.params.id, source(req), req.params.botId) reply.code(202) return { ok: true } }) // ---- 消息与事件 ---- f.post('/teams/:id/messages', async (req, reply) => { const message = app.teams.postMessage(req.params.id, source(req), (req.body ?? {}) as SendTeamMessageBody) reply.code(201) return message }) f.get('/teams/:id/messages', async req => { const row = teamFor(req) const items = app.teams.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 500), 5000), ['message.created']) return { items, lastSeq: row.lastSeq } }) f.get('/teams/:id/events', async req => { const row = teamFor(req) return { items: app.teams.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 5000), 5000)), lastSeq: row.lastSeq } }) f.get('/teams/:id/stream', async (req, reply) => { const row = teamFor(req) const lastId = Number(req.headers['last-event-id'] ?? req.query.after ?? 0) || 0 streamEvents(req, reply, lastId, after => app.teams.events.list(row.id, after), fn => app.teams.events.subscribe(row.id, fn)) }) f.post('/teams/:id/interactions/:iid', async req => { app.teams.respond(req.params.id, source(req), req.params.iid, parse(interactionResponseSchema, req.body)) return { ok: true } }) // ---- 任务、子任务与用量 ---- f.get('/teams/:id/jobs', async req => app.teams.jobList(req.params.id, source(req))) f.post('/teams/:id/jobs/:jobId/cancel', async req => { app.teams.cancelJob(req.params.id, source(req), req.params.jobId) return { ok: true } }) f.post('/teams/:id/jobs/:jobId/continue', async req => { app.teams.continueJob(req.params.id, source(req), req.params.jobId) return { ok: true } }) f.get('/teams/:id/usage', async req => app.teams.usage(req.params.id, source(req))) // ---- 调试(仅 admin)---- f.post('/teams/:id/tasks/:taskId/stub', async req => { await app.teams.stubResult(req.params.id, source(req), req.params.taskId, (req.body ?? {}) as StubTaskBody) return { ok: true } }) f.post('/teams/:id/steps/:stepId/continue', async req => { app.teams.continueStep(req.params.id, source(req), req.params.stepId) return { ok: true } }) // ---- 文件 ---- f.post('/teams/:id/files', async (req, reply) => { const limitBytes = app.settings.get().limits.uploadMaxMB * 1048576 const saved = await app.teams.upload(req.params.id, source(req), req.files({ limits: { fileSize: limitBytes, files: 20 } })) reply.code(201) return saved }) f.get('/teams/:id/files', async req => app.teams.listFiles(req.params.id, source(req))) f.get('/files/:id', async (req, reply) => { const file = app.files.get(req.params.id) if (!file.sessionId.startsWith('team_')) throw notFound('文件') const team = app.teams.getRow(file.sessionId, source(req)) const abs = path.join(team.workspaceDir, file.relPath) if (!fs.existsSync(abs)) throw notFound('文件') reply.header('Content-Type', file.mime) reply.header('Content-Disposition', `${req.query.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(path.basename(file.relPath))}`) reply.header('X-Content-Type-Options', 'nosniff') // 预览 HTML/SVG 时禁止执行脚本 reply.header('Content-Security-Policy', "sandbox; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'") return reply.send(fs.createReadStream(abs)) }) }