routes.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. // Bot API(/bot/v1,docs/design/13-bot-teams.md §9、ADR-0024)
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import type { FastifyInstance, FastifyRequest } from 'fastify'
  5. import { z } from 'zod'
  6. import type { CreateBrowserTokenBody, CreateTeamBody, InteractionResponse, SendTeamMessageBody, StubTaskBody, TeamStatus } from '@agent-server/shared'
  7. import type { App } from '../app'
  8. import { AppError, badRequest, notFound } from '../lib/errors'
  9. import { BROWSER_TOKEN_MAX_TTL } from '../services/browser-tokens'
  10. import { parse } from '../services/catalog'
  11. import { principal, requireBotClient } from '../http/auth'
  12. import { streamEvents } from '../http/sse'
  13. type IdParams = { Params: { id: string } }
  14. type BotParams = { Params: { id: string; botId: string } }
  15. type JobParams = { Params: { id: string; jobId: string } }
  16. const interactionResponseSchema: z.ZodType<InteractionResponse> = z.union([
  17. z.object({ decision: z.enum(['allow_once', 'allow_session']) }),
  18. z.object({ decision: z.literal('deny'), reason: z.string().max(2000).optional() }),
  19. z.object({ answers: z.record(z.string(), z.union([z.string(), z.array(z.string())])) }),
  20. ])
  21. /** 已注册的 /bot/v1 路由(方法 + 路径),用于核对 API 协议文档是否完整(test/api-reference.test.ts) */
  22. export const registeredBotRoutes: { method: string; path: string }[] = []
  23. export default async function botRoutes(f: FastifyInstance, { app }: { app: App }) {
  24. f.addHook('onRoute', r => {
  25. for (const m of [r.method].flat()) if (m !== 'HEAD' && m !== 'OPTIONS') registeredBotRoutes.push({ method: m, path: r.url })
  26. })
  27. f.addHook('preHandler', requireBotClient(app))
  28. const source = (req: FastifyRequest) => principal(req).source
  29. const teamFor = (req: FastifyRequest<IdParams>) => app.teams.getRow(req.params.id, source(req))
  30. f.post('/browser-tokens', async (req, reply) => {
  31. const p = principal(req)
  32. if (p.kind !== 'botkey' || p.browser) throw new AppError(403, 'forbidden', '只能用 Bot Key 签发浏览器令牌')
  33. const body = (req.body ?? {}) as CreateBrowserTokenBody
  34. const ttl = body.ttlSeconds === undefined ? undefined : Number(body.ttlSeconds)
  35. if (ttl !== undefined && !(ttl >= 60 && ttl <= BROWSER_TOKEN_MAX_TTL)) throw badRequest(`ttlSeconds 应在 60 ~ ${BROWSER_TOKEN_MAX_TTL} 之间`)
  36. reply.code(201)
  37. return app.browserTokens.issue(p.source, ttl, Date.now(), 'bot')
  38. })
  39. // 这个 Key 可以使用的机器人(后台不受限)
  40. f.get('/bots', async req => {
  41. const allowed = app.botKeys.allowedBots(source(req))
  42. return app.teamCards.cards(app.bots.list().filter(b => b.enabled && (!allowed || allowed.has(b.id))))
  43. })
  44. // ---- 工作组 ----
  45. f.post('/teams', async (req, reply) => {
  46. const row = await app.teams.create(source(req), (req.body ?? {}) as CreateTeamBody)
  47. reply.code(201)
  48. return app.teams.detail(row)
  49. })
  50. f.get<{ Querystring: { scope?: string; status?: TeamStatus; limit?: string } }>('/teams', async req =>
  51. app.teams.list(source(req), { all: req.query.scope === 'all', status: req.query.status, limit: req.query.limit ? Number(req.query.limit) : undefined }))
  52. f.get<IdParams>('/teams/:id', async req => app.teams.detail(teamFor(req)))
  53. f.patch<IdParams>('/teams/:id', async req => app.teams.detail(app.teams.patch(req.params.id, source(req), req.body)))
  54. f.delete<IdParams>('/teams/:id', async (req, reply) => {
  55. await app.teams.delete(req.params.id, source(req))
  56. reply.code(204)
  57. })
  58. for (const action of ['pause', 'resume', 'close', 'reopen'] as const) {
  59. f.post<IdParams>(`/teams/:id/${action}`, async req => app.teams.detail(app.teams.setStatus(req.params.id, source(req), action)))
  60. }
  61. f.post<IdParams>('/teams/:id/bots', async req => {
  62. const { botId } = parse(z.object({ botId: z.string().min(1) }), req.body)
  63. return app.teams.detail(app.teams.addBot(req.params.id, source(req), botId))
  64. })
  65. f.delete<BotParams>('/teams/:id/bots/:botId', async req => app.teams.detail(app.teams.removeBot(req.params.id, source(req), req.params.botId)))
  66. f.post<BotParams>('/teams/:id/bots/:botId/interrupt', async (req, reply) => {
  67. app.teams.interrupt(req.params.id, source(req), req.params.botId)
  68. reply.code(202)
  69. return { ok: true }
  70. })
  71. // ---- 消息与事件 ----
  72. f.post<IdParams>('/teams/:id/messages', async (req, reply) => {
  73. const message = app.teams.postMessage(req.params.id, source(req), (req.body ?? {}) as SendTeamMessageBody)
  74. reply.code(201)
  75. return message
  76. })
  77. f.get<IdParams & { Querystring: { after?: string; limit?: string } }>('/teams/:id/messages', async req => {
  78. const row = teamFor(req)
  79. const items = app.teams.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 500), 5000), ['message.created'])
  80. return { items, lastSeq: row.lastSeq }
  81. })
  82. f.get<IdParams & { Querystring: { after?: string; limit?: string } }>('/teams/:id/events', async req => {
  83. const row = teamFor(req)
  84. return { items: app.teams.events.list(row.id, Number(req.query.after ?? 0), Math.min(Number(req.query.limit ?? 5000), 5000)), lastSeq: row.lastSeq }
  85. })
  86. f.get<IdParams & { Querystring: { after?: string } }>('/teams/:id/stream', async (req, reply) => {
  87. const row = teamFor(req)
  88. const lastId = Number(req.headers['last-event-id'] ?? req.query.after ?? 0) || 0
  89. streamEvents(req, reply, lastId, after => app.teams.events.list(row.id, after), fn => app.teams.events.subscribe(row.id, fn))
  90. })
  91. f.post<IdParams & { Params: { iid: string } }>('/teams/:id/interactions/:iid', async req => {
  92. app.teams.respond(req.params.id, source(req), req.params.iid, parse(interactionResponseSchema, req.body))
  93. return { ok: true }
  94. })
  95. // ---- 任务、子任务与用量 ----
  96. f.get<IdParams>('/teams/:id/jobs', async req => app.teams.jobList(req.params.id, source(req)))
  97. f.post<JobParams>('/teams/:id/jobs/:jobId/cancel', async req => {
  98. app.teams.cancelJob(req.params.id, source(req), req.params.jobId)
  99. return { ok: true }
  100. })
  101. f.post<JobParams>('/teams/:id/jobs/:jobId/continue', async req => {
  102. app.teams.continueJob(req.params.id, source(req), req.params.jobId)
  103. return { ok: true }
  104. })
  105. f.get<IdParams>('/teams/:id/usage', async req => app.teams.usage(req.params.id, source(req)))
  106. // ---- 调试(仅 admin)----
  107. f.post<IdParams & { Params: { taskId: string } }>('/teams/:id/tasks/:taskId/stub', async req => {
  108. await app.teams.stubResult(req.params.id, source(req), req.params.taskId, (req.body ?? {}) as StubTaskBody)
  109. return { ok: true }
  110. })
  111. f.post<IdParams & { Params: { stepId: string } }>('/teams/:id/steps/:stepId/continue', async req => {
  112. app.teams.continueStep(req.params.id, source(req), req.params.stepId)
  113. return { ok: true }
  114. })
  115. // ---- 文件 ----
  116. f.post<IdParams>('/teams/:id/files', async (req, reply) => {
  117. const limitBytes = app.settings.get().limits.uploadMaxMB * 1048576
  118. const saved = await app.teams.upload(req.params.id, source(req), req.files({ limits: { fileSize: limitBytes, files: 20 } }))
  119. reply.code(201)
  120. return saved
  121. })
  122. f.get<IdParams>('/teams/:id/files', async req => app.teams.listFiles(req.params.id, source(req)))
  123. f.get<IdParams & { Querystring: { inline?: string } }>('/files/:id', async (req, reply) => {
  124. const file = app.files.get(req.params.id)
  125. if (!file.sessionId.startsWith('team_')) throw notFound('文件')
  126. const team = app.teams.getRow(file.sessionId, source(req))
  127. const abs = path.join(team.workspaceDir, file.relPath)
  128. if (!fs.existsSync(abs)) throw notFound('文件')
  129. reply.header('Content-Type', file.mime)
  130. reply.header('Content-Disposition', `${req.query.inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(path.basename(file.relPath))}`)
  131. reply.header('X-Content-Type-Options', 'nosniff')
  132. // 预览 HTML/SVG 时禁止执行脚本
  133. reply.header('Content-Security-Policy', "sandbox; default-src 'none'; img-src data: 'self'; style-src 'unsafe-inline'")
  134. return reply.send(fs.createReadStream(abs))
  135. })
  136. }