bot-plugins.ts 4.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
  1. import type { IncomingMessage, ServerResponse } from 'node:http'
  2. import { randomUUID } from 'node:crypto'
  3. /** Fake Composio plus an actual loopback MCP endpoint and native Hermes bridge.
  4. * Enabled only by WORKSPACE_FIXTURE_PLUGINS=1 in the disposable browser server. */
  5. export class FixtureBotPlugins {
  6. private sessions = new Map<string, string>()
  7. private accounts: any[] = []
  8. private bindings = new Map<string, any>()
  9. readonly fetch: typeof fetch = async (input, init) => {
  10. const url = new URL(String(input))
  11. if (!url.hostname.endsWith('composio.dev')) return fetch(input, init)
  12. const body = JSON.parse(String(init?.body ?? '{}'))
  13. const send = (value: unknown) => new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json' } })
  14. if (url.pathname.endsWith('/tool_router/session')) {
  15. const id = 'trs_' + randomUUID(); this.sessions.set(id, body.user_id)
  16. if (!this.accounts.some(a => a.user_id === body.user_id)) this.accounts.push({ id: 'ca_' + randomUUID(), user_id: body.user_id, alias: '隔离测试账号', toolkit: { slug: 'gmail' }, status: 'ACTIVE' })
  17. return send({ session_id: id, config: { user_id: body.user_id }, mcp: { url: `https://backend.composio.dev/tool_router/${id}/mcp` } })
  18. }
  19. if (url.pathname.endsWith('/toolkits')) return send({ items: [{ slug: 'gmail', name: 'Gmail', description: '读取邮件与管理草稿' }, { slug: 'github', name: 'GitHub', description: '代码仓库与议题' }, { slug: 'notion', name: 'Notion', description: '文档与知识库' }] })
  20. if (url.pathname.endsWith('/connected_accounts')) return send({ items: this.accounts.filter(a => a.user_id === url.searchParams.get('user_ids')) })
  21. if (url.pathname.includes('/connected_accounts/') && init?.method === 'DELETE') { this.accounts = this.accounts.filter(a => a.id !== url.pathname.split('/').at(-1)); return send({ ok: true }) }
  22. if (url.pathname.endsWith('/link')) return send({ redirect_url: 'https://connect.composio.dev/fixture-not-a-real-oauth-link' })
  23. if (body.method === 'notifications/initialized') return new Response(null, { status: 202 })
  24. return send(this.mcp(body))
  25. }
  26. private mcp(frame: any) {
  27. const result = frame.method === 'initialize' ? { protocolVersion: '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'isolated-plugin-fixture', version: '1' } }
  28. : frame.method === 'tools/list' ? { tools: [{ name: 'fixture_echo', description: '回传验收文字,不访问外部服务', inputSchema: { type: 'object', properties: { value: { type: 'string' } }, required: ['value'] } }] }
  29. : { content: [{ type: 'text', text: String(frame.params?.arguments?.value ?? '') }], structuredContent: { verified: true } }
  30. return { jsonrpc: '2.0', id: frame.id, result }
  31. }
  32. handle(req: IncomingMessage, res: ServerResponse, pathname: string): boolean {
  33. if (pathname !== '/__fixture/mcp' && !pathname.startsWith('/api/plugins/yaoyao-bot-bridge/')) return false
  34. if (pathname.endsWith('/capabilities')) { res.end(JSON.stringify({ version: 1, ready: true, native_tools: true, in_process: true })); return true }
  35. let raw = ''
  36. req.on('data', chunk => { raw += String(chunk) })
  37. req.on('end', () => {
  38. const frame = JSON.parse(raw || '{}')
  39. if (pathname.endsWith('/bind')) this.bindings.set(frame.session_id, frame)
  40. if (pathname.endsWith('/unbind')) this.bindings.delete(frame.session_id)
  41. if (pathname === '/__fixture/mcp') {
  42. if (!frame.id) { res.statusCode = 202; res.end(); return }
  43. res.end(JSON.stringify(this.mcp(frame))); return
  44. }
  45. res.end(JSON.stringify({ ok: true, native_tools: true }))
  46. })
  47. return true
  48. }
  49. async exercise(sessionId: string) {
  50. const binding = this.bindings.get(sessionId)
  51. if (!binding) throw new Error('本轮没有原生插件绑定')
  52. const call = async (path: string, body: unknown) => {
  53. const response = await fetch(binding.bridge_url + path, { method: 'POST', headers: { Authorization: 'Bearer ' + binding.token, 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
  54. if (!response.ok) throw new Error('插件桥拒绝了测试请求')
  55. return response.json() as Promise<any>
  56. }
  57. const catalog = await call('/tools/list', {})
  58. const tool = catalog.tools.find((t: any) => t.description.includes('fixture_echo'))
  59. if (!tool) throw new Error('本轮没有已授权的插件工具')
  60. const result = await call('/tools/call', { toolId: tool.id, arguments: { value: 'Bot 插件原生调用成功' }, callId: randomUUID() })
  61. return { tool, result }
  62. }
  63. }