hermesBotRelay.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
  1. import type Koa from 'koa'
  2. import { Readable } from 'node:stream'
  3. import type { LocalAuthStore } from './localAuth.js'
  4. import type { CsrfProtection } from './security.js'
  5. import { WorkspaceGateway, type WorkspaceNodes, type WorkspaceNode } from './workspaceGateway.js'
  6. import { HttpError } from './errors.js'
  7. /** Native Hermes transport, scoped to a primary account's paired child node.
  8. * Never imports a Profile or creates a workspace Agent/group. */
  9. export function hermesBotRelay(nodes: WorkspaceNodes, auth: LocalAuthStore, csrf: CsrfProtection): Koa.Middleware {
  10. return async (ctx, next) => {
  11. const native = /^(?:\/api\/app\/hermes-bot\/local|\/node\/([0-9a-f-]{36})\/api\/hermes-bot)(\/api\/.*)$/.exec(ctx.path)
  12. if (native) {
  13. const path = native[2]!
  14. const allowed = path.startsWith('/api/realtime/') || (ctx.method === 'GET' &&
  15. /^\/api\/(?:profiles(?:\/sessions)?|sessions(?:\/[^/]+(?:\/messages)?)?|files\/download)$/.test(path))
  16. if (!allowed) throw new HttpError(403, '不支持的原生机器人操作', 'hermes_bot_path_forbidden')
  17. if (!native[1]) auth.require(ctx)
  18. ctx.state.hermesBotNative = true
  19. ctx.path = native[1] ? `/node/${native[1]}${path}` : path
  20. return next()
  21. }
  22. if (ctx.path === '/api/app/hermes-bot/roster' && ctx.method === 'GET') {
  23. const owner = auth.require(ctx).id
  24. const nodesList = nodes.store.list<WorkspaceNode>(owner, 'node').filter(n => n.transport === 'paired-web')
  25. const roster = await Promise.all(nodesList.map(async node => {
  26. const gateway = new WorkspaceGateway(nodes.target(owner, node.id))
  27. try {
  28. await gateway.connect()
  29. const snapshot = await gateway.rpc('profiles.list', {include_sessions: true})
  30. return {id: node.id, name: node.name, remoteNodeId: node.remoteNodeId, fingerprint: node.fingerprint, snapshot}
  31. } catch { return {id: node.id, name: node.name, remoteNodeId: node.remoteNodeId, error: '节点不可用,请检查连接或扫码授权'} }
  32. finally { gateway.close() }
  33. }))
  34. ctx.set('Cache-Control', 'no-store'); ctx.body = {nodes: roster}; return
  35. }
  36. const match = /^\/api\/app\/hermes-bot\/nodes\/([0-9a-f-]{36})(\/native)?(\/api\/.*)$/.exec(ctx.path)
  37. if (!match) return next()
  38. const owner = auth.require(ctx).id, nodeID = match[1]!, path = match[3]!
  39. const node = nodes.store.require<WorkspaceNode>(owner, 'node', nodeID)
  40. if (node.transport !== 'paired-web') throw new HttpError(409, '请重新扫码授权 15300 子节点', 'paired_node_required')
  41. const realtime = /^\/api\/realtime\/(?:capabilities|channels(?:\/[0-9a-f-]{36}(?:\/(?:commands|events))?)?|commands\/[A-Za-z0-9:_-]{1,200})$/.test(path)
  42. const read = /^\/api\/(?:profiles(?:\/sessions)?|sessions(?:\/[^/]+(?:\/messages)?)?|files\/download)$/.test(path)
  43. if (!realtime && !(read && ctx.method === 'GET')) throw new HttpError(403, '不支持的 Hermes Bot 操作', 'hermes_bot_path_forbidden')
  44. const target = nodes.target(owner, nodeID)
  45. if (!target.pairedToken) throw new HttpError(409, '请重新扫码授权子节点', 'paired_node_required')
  46. let body: Buffer | undefined
  47. if (!['GET', 'HEAD', 'DELETE'].includes(ctx.method)) {
  48. if (!ctx.is('application/json')) throw new HttpError(415, 'JSON body required', 'invalid_content_type')
  49. const chunks: Buffer[] = []; let length = 0
  50. for await (const chunk of ctx.req) {
  51. length += Buffer.byteLength(chunk)
  52. if (length > 15 * 1024 * 1024) throw new HttpError(413, '请求过大', 'body_too_large')
  53. chunks.push(Buffer.from(chunk))
  54. }
  55. body = Buffer.concat(chunks)
  56. }
  57. const controller = new AbortController()
  58. const timeout = setTimeout(() => controller.abort(), 30_000)
  59. ctx.res.once('close', () => controller.abort())
  60. let response: Response
  61. try {
  62. response = await target.client.fetchImpl(new URL(`${target.url.href.replace(/\/$/, '')}${match[2] ? '/api/hermes-bot' : ''}${path}${ctx.search}`), {
  63. method: ctx.method, redirect: 'error', signal: controller.signal,
  64. headers: { Authorization: `Bearer ${target.pairedToken}`, 'Content-Type': 'application/json',
  65. 'X-Hermes-Profile': ctx.get('x-hermes-profile'),
  66. 'Last-Event-ID': ctx.get('last-event-id'), 'Idempotency-Key': ctx.get('idempotency-key') },
  67. ...(body ? { body: new Uint8Array(body) } : {}),
  68. })
  69. } finally { clearTimeout(timeout) }
  70. ctx.status = response.status
  71. ctx.set('Cache-Control', 'no-store')
  72. for (const name of ['content-type', 'content-disposition']) {
  73. const value = response.headers.get(name); if (value) ctx.set(name, value)
  74. }
  75. if (response.headers.get('content-type')?.startsWith('text/event-stream') && response.body) {
  76. const timer = setInterval(() => {
  77. try {
  78. if (auth.require(ctx).id !== owner || nodes.store.get<WorkspaceNode>(owner, 'node', nodeID)?.secret !== node.secret) controller.abort()
  79. } catch { controller.abort() }
  80. }, 5_000); timer.unref()
  81. const stream = Readable.fromWeb(response.body as any)
  82. const clear = () => clearInterval(timer)
  83. stream.once('close', clear); stream.once('error', clear); ctx.res.once('close', clear)
  84. ctx.body = stream
  85. } else if (path === '/api/realtime/capabilities' && response.ok) {
  86. ctx.body = { ...await response.json() as object, csrfToken: csrf.issue(ctx) }
  87. } else if (response.status !== 204) ctx.body = Buffer.from(await response.arrayBuffer())
  88. }
  89. }