| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158 |
- // 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<InteractionResponse> = 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<IdParams>) => 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<IdParams>('/teams/:id', async req => app.teams.detail(teamFor(req)))
- f.patch<IdParams>('/teams/:id', async req => app.teams.detail(app.teams.patch(req.params.id, source(req), req.body)))
- f.delete<IdParams>('/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<IdParams>(`/teams/:id/${action}`, async req => app.teams.detail(app.teams.setStatus(req.params.id, source(req), action)))
- }
- f.post<IdParams>('/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<BotParams>('/teams/:id/bots/:botId', async req => app.teams.detail(app.teams.removeBot(req.params.id, source(req), req.params.botId)))
- f.post<BotParams>('/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<IdParams>('/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<IdParams & { Querystring: { after?: string; limit?: string } }>('/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<IdParams & { Querystring: { after?: string; limit?: string } }>('/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<IdParams & { Querystring: { after?: string } }>('/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<IdParams & { Params: { iid: string } }>('/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<IdParams>('/teams/:id/jobs', async req => app.teams.jobList(req.params.id, source(req)))
- f.post<JobParams>('/teams/:id/jobs/:jobId/cancel', async req => {
- app.teams.cancelJob(req.params.id, source(req), req.params.jobId)
- return { ok: true }
- })
- f.post<JobParams>('/teams/:id/jobs/:jobId/continue', async req => {
- app.teams.continueJob(req.params.id, source(req), req.params.jobId)
- return { ok: true }
- })
- f.get<IdParams>('/teams/:id/usage', async req => app.teams.usage(req.params.id, source(req)))
- // ---- 调试(仅 admin)----
- f.post<IdParams & { Params: { taskId: string } }>('/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<IdParams & { Params: { stepId: string } }>('/teams/:id/steps/:stepId/continue', async req => {
- app.teams.continueStep(req.params.id, source(req), req.params.stepId)
- return { ok: true }
- })
- // ---- 文件 ----
- f.post<IdParams>('/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<IdParams>('/teams/:id/files', async req => app.teams.listFiles(req.params.id, source(req)))
- f.get<IdParams & { Querystring: { inline?: string } }>('/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))
- })
- }
|