@swarmclawai/swarmclaw 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/README.md +577 -0
  2. package/bin/server-cmd.js +359 -0
  3. package/bin/swarmclaw.js +29 -0
  4. package/bin/swarmclaw.mjs +1504 -0
  5. package/next.config.ts +33 -0
  6. package/package.json +112 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/branding/swarmclaw-org-avatar.png +0 -0
  9. package/public/branding/swarmclaw-org-avatar.svg +58 -0
  10. package/public/file.svg +1 -0
  11. package/public/globe.svg +1 -0
  12. package/public/next.svg +1 -0
  13. package/public/screenshots/agents.png +0 -0
  14. package/public/screenshots/connectors.png +0 -0
  15. package/public/screenshots/dashboard.png +0 -0
  16. package/public/screenshots/new-session-openclaw.png +0 -0
  17. package/public/screenshots/providers.png +0 -0
  18. package/public/screenshots/schedules.png +0 -0
  19. package/public/screenshots/tasks.png +0 -0
  20. package/public/vercel.svg +1 -0
  21. package/public/window.svg +1 -0
  22. package/src/app/api/agents/[id]/route.ts +30 -0
  23. package/src/app/api/agents/[id]/thread/route.ts +66 -0
  24. package/src/app/api/agents/generate/route.ts +42 -0
  25. package/src/app/api/agents/route.ts +33 -0
  26. package/src/app/api/auth/route.ts +25 -0
  27. package/src/app/api/claude-skills/route.ts +42 -0
  28. package/src/app/api/clawhub/install/route.ts +39 -0
  29. package/src/app/api/clawhub/search/route.ts +11 -0
  30. package/src/app/api/connectors/[id]/route.ts +79 -0
  31. package/src/app/api/connectors/route.ts +60 -0
  32. package/src/app/api/credentials/[id]/route.ts +14 -0
  33. package/src/app/api/credentials/route.ts +31 -0
  34. package/src/app/api/daemon/health-check/route.ts +11 -0
  35. package/src/app/api/daemon/route.ts +22 -0
  36. package/src/app/api/dirs/pick/route.ts +60 -0
  37. package/src/app/api/dirs/route.ts +29 -0
  38. package/src/app/api/documents/[id]/route.ts +47 -0
  39. package/src/app/api/documents/route.ts +93 -0
  40. package/src/app/api/files/serve/route.ts +69 -0
  41. package/src/app/api/generate/info/route.ts +12 -0
  42. package/src/app/api/generate/route.ts +106 -0
  43. package/src/app/api/ip/route.ts +6 -0
  44. package/src/app/api/knowledge/[id]/route.ts +61 -0
  45. package/src/app/api/knowledge/route.ts +48 -0
  46. package/src/app/api/knowledge/upload/route.ts +86 -0
  47. package/src/app/api/logs/route.ts +65 -0
  48. package/src/app/api/mcp-servers/[id]/route.ts +32 -0
  49. package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
  50. package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
  51. package/src/app/api/mcp-servers/route.ts +27 -0
  52. package/src/app/api/memory/[id]/route.ts +126 -0
  53. package/src/app/api/memory/maintenance/route.ts +63 -0
  54. package/src/app/api/memory/route.ts +111 -0
  55. package/src/app/api/memory-images/[filename]/route.ts +36 -0
  56. package/src/app/api/orchestrator/run/route.ts +43 -0
  57. package/src/app/api/plugins/install/route.ts +58 -0
  58. package/src/app/api/plugins/marketplace/route.ts +33 -0
  59. package/src/app/api/plugins/route.ts +21 -0
  60. package/src/app/api/preview-server/route.ts +339 -0
  61. package/src/app/api/providers/[id]/models/route.ts +29 -0
  62. package/src/app/api/providers/[id]/route.ts +34 -0
  63. package/src/app/api/providers/configs/route.ts +7 -0
  64. package/src/app/api/providers/ollama/route.ts +30 -0
  65. package/src/app/api/providers/openclaw/health/route.ts +23 -0
  66. package/src/app/api/providers/route.ts +28 -0
  67. package/src/app/api/runs/[id]/route.ts +9 -0
  68. package/src/app/api/runs/route.ts +13 -0
  69. package/src/app/api/schedules/[id]/route.ts +28 -0
  70. package/src/app/api/schedules/[id]/run/route.ts +104 -0
  71. package/src/app/api/schedules/route.ts +78 -0
  72. package/src/app/api/secrets/[id]/route.ts +29 -0
  73. package/src/app/api/secrets/route.ts +42 -0
  74. package/src/app/api/sessions/[id]/browser/route.ts +13 -0
  75. package/src/app/api/sessions/[id]/chat/route.ts +96 -0
  76. package/src/app/api/sessions/[id]/clear/route.ts +19 -0
  77. package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
  78. package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
  79. package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
  80. package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
  81. package/src/app/api/sessions/[id]/messages/route.ts +9 -0
  82. package/src/app/api/sessions/[id]/retry/route.ts +28 -0
  83. package/src/app/api/sessions/[id]/route.ts +103 -0
  84. package/src/app/api/sessions/[id]/stop/route.ts +13 -0
  85. package/src/app/api/sessions/heartbeat/route.ts +26 -0
  86. package/src/app/api/sessions/route.ts +85 -0
  87. package/src/app/api/settings/route.ts +58 -0
  88. package/src/app/api/setup/check-provider/route.ts +326 -0
  89. package/src/app/api/setup/doctor/route.ts +250 -0
  90. package/src/app/api/skills/[id]/route.ts +40 -0
  91. package/src/app/api/skills/import/route.ts +69 -0
  92. package/src/app/api/skills/route.ts +28 -0
  93. package/src/app/api/tasks/[id]/route.ts +102 -0
  94. package/src/app/api/tasks/route.ts +115 -0
  95. package/src/app/api/tts/route.ts +40 -0
  96. package/src/app/api/upload/route.ts +18 -0
  97. package/src/app/api/uploads/[filename]/route.ts +59 -0
  98. package/src/app/api/usage/route.ts +35 -0
  99. package/src/app/api/version/route.ts +81 -0
  100. package/src/app/api/version/update/route.ts +95 -0
  101. package/src/app/api/webhooks/[id]/history/route.ts +13 -0
  102. package/src/app/api/webhooks/[id]/route.ts +204 -0
  103. package/src/app/api/webhooks/route.ts +37 -0
  104. package/src/app/favicon.ico +0 -0
  105. package/src/app/globals.css +370 -0
  106. package/src/app/layout.tsx +52 -0
  107. package/src/app/page.tsx +172 -0
  108. package/src/cli/index.js +1232 -0
  109. package/src/cli/index.test.js +281 -0
  110. package/src/cli/index.ts +1158 -0
  111. package/src/cli/spec.js +284 -0
  112. package/src/components/agents/agent-card.tsx +219 -0
  113. package/src/components/agents/agent-chat-list.tsx +165 -0
  114. package/src/components/agents/agent-list.tsx +110 -0
  115. package/src/components/agents/agent-sheet.tsx +1220 -0
  116. package/src/components/auth/access-key-gate.tsx +248 -0
  117. package/src/components/auth/setup-wizard.tsx +940 -0
  118. package/src/components/auth/user-picker.tsx +88 -0
  119. package/src/components/chat/chat-area.tsx +406 -0
  120. package/src/components/chat/chat-header.tsx +491 -0
  121. package/src/components/chat/chat-tool-toggles.tsx +161 -0
  122. package/src/components/chat/code-block.tsx +146 -0
  123. package/src/components/chat/dev-server-bar.tsx +39 -0
  124. package/src/components/chat/message-bubble.tsx +486 -0
  125. package/src/components/chat/message-list.tsx +299 -0
  126. package/src/components/chat/session-debug-panel.tsx +196 -0
  127. package/src/components/chat/streaming-bubble.tsx +85 -0
  128. package/src/components/chat/thinking-indicator.tsx +26 -0
  129. package/src/components/chat/tool-call-bubble.tsx +438 -0
  130. package/src/components/chat/tool-request-banner.tsx +103 -0
  131. package/src/components/connectors/connector-list.tsx +196 -0
  132. package/src/components/connectors/connector-sheet.tsx +804 -0
  133. package/src/components/input/chat-input.tsx +235 -0
  134. package/src/components/knowledge/knowledge-list.tsx +206 -0
  135. package/src/components/knowledge/knowledge-sheet.tsx +316 -0
  136. package/src/components/layout/app-layout.tsx +1016 -0
  137. package/src/components/layout/daemon-indicator.tsx +56 -0
  138. package/src/components/layout/mobile-header.tsx +31 -0
  139. package/src/components/layout/network-banner.tsx +17 -0
  140. package/src/components/layout/update-banner.tsx +130 -0
  141. package/src/components/logs/log-list.tsx +358 -0
  142. package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
  143. package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
  144. package/src/components/memory/memory-card.tsx +63 -0
  145. package/src/components/memory/memory-detail.tsx +339 -0
  146. package/src/components/memory/memory-list.tsx +198 -0
  147. package/src/components/memory/memory-sheet.tsx +70 -0
  148. package/src/components/plugins/plugin-list.tsx +60 -0
  149. package/src/components/plugins/plugin-sheet.tsx +311 -0
  150. package/src/components/providers/provider-list.tsx +96 -0
  151. package/src/components/providers/provider-sheet.tsx +542 -0
  152. package/src/components/runs/run-list.tsx +231 -0
  153. package/src/components/schedules/schedule-card.tsx +63 -0
  154. package/src/components/schedules/schedule-list.tsx +76 -0
  155. package/src/components/schedules/schedule-sheet.tsx +336 -0
  156. package/src/components/secrets/secret-sheet.tsx +180 -0
  157. package/src/components/secrets/secrets-list.tsx +91 -0
  158. package/src/components/sessions/new-session-sheet.tsx +478 -0
  159. package/src/components/sessions/session-card.tsx +144 -0
  160. package/src/components/sessions/session-list.tsx +202 -0
  161. package/src/components/shared/ai-gen-block.tsx +77 -0
  162. package/src/components/shared/avatar.tsx +48 -0
  163. package/src/components/shared/bottom-sheet.tsx +30 -0
  164. package/src/components/shared/confirm-dialog.tsx +47 -0
  165. package/src/components/shared/connector-platform-icon.tsx +113 -0
  166. package/src/components/shared/dir-browser.tsx +285 -0
  167. package/src/components/shared/dropdown.tsx +55 -0
  168. package/src/components/shared/icon-button.tsx +25 -0
  169. package/src/components/shared/settings/plugin-manager.tsx +207 -0
  170. package/src/components/shared/settings/section-capability-policy.tsx +93 -0
  171. package/src/components/shared/settings/section-embedding.tsx +99 -0
  172. package/src/components/shared/settings/section-heartbeat.tsx +168 -0
  173. package/src/components/shared/settings/section-memory.tsx +77 -0
  174. package/src/components/shared/settings/section-orchestrator.tsx +108 -0
  175. package/src/components/shared/settings/section-providers.tsx +181 -0
  176. package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
  177. package/src/components/shared/settings/section-secrets.tsx +132 -0
  178. package/src/components/shared/settings/section-user-preferences.tsx +24 -0
  179. package/src/components/shared/settings/section-voice.tsx +53 -0
  180. package/src/components/shared/settings/settings-sheet.tsx +88 -0
  181. package/src/components/shared/settings/types.ts +7 -0
  182. package/src/components/shared/settings/utils.ts +13 -0
  183. package/src/components/shared/settings-sheet.tsx +1 -0
  184. package/src/components/shared/skeleton.tsx +19 -0
  185. package/src/components/shared/usage-badge.tsx +28 -0
  186. package/src/components/skills/clawhub-browser.tsx +225 -0
  187. package/src/components/skills/skill-list.tsx +70 -0
  188. package/src/components/skills/skill-sheet.tsx +254 -0
  189. package/src/components/tasks/task-board.tsx +96 -0
  190. package/src/components/tasks/task-card.tsx +179 -0
  191. package/src/components/tasks/task-column.tsx +73 -0
  192. package/src/components/tasks/task-list.tsx +118 -0
  193. package/src/components/tasks/task-sheet.tsx +415 -0
  194. package/src/components/ui/avatar.tsx +109 -0
  195. package/src/components/ui/badge.tsx +48 -0
  196. package/src/components/ui/button.tsx +64 -0
  197. package/src/components/ui/card.tsx +92 -0
  198. package/src/components/ui/dialog.tsx +158 -0
  199. package/src/components/ui/dropdown-menu.tsx +257 -0
  200. package/src/components/ui/input.tsx +21 -0
  201. package/src/components/ui/scroll-area.tsx +58 -0
  202. package/src/components/ui/select.tsx +190 -0
  203. package/src/components/ui/separator.tsx +28 -0
  204. package/src/components/ui/sheet.tsx +143 -0
  205. package/src/components/ui/sonner.tsx +22 -0
  206. package/src/components/ui/textarea.tsx +18 -0
  207. package/src/components/ui/tooltip.tsx +56 -0
  208. package/src/components/usage/usage-list.tsx +105 -0
  209. package/src/components/webhooks/webhook-list.tsx +166 -0
  210. package/src/components/webhooks/webhook-sheet.tsx +402 -0
  211. package/src/hooks/use-auto-resize.ts +20 -0
  212. package/src/hooks/use-media-query.ts +21 -0
  213. package/src/hooks/use-speech-recognition.ts +83 -0
  214. package/src/instrumentation.ts +8 -0
  215. package/src/lib/agents.ts +13 -0
  216. package/src/lib/api-client.ts +100 -0
  217. package/src/lib/chat.ts +60 -0
  218. package/src/lib/memory.ts +42 -0
  219. package/src/lib/openclaw-endpoint.test.ts +48 -0
  220. package/src/lib/openclaw-endpoint.ts +67 -0
  221. package/src/lib/provider-config.ts +13 -0
  222. package/src/lib/providers/anthropic.ts +135 -0
  223. package/src/lib/providers/claude-cli.ts +202 -0
  224. package/src/lib/providers/codex-cli.ts +260 -0
  225. package/src/lib/providers/index.ts +351 -0
  226. package/src/lib/providers/ollama.ts +131 -0
  227. package/src/lib/providers/openai.ts +164 -0
  228. package/src/lib/providers/openclaw.ts +330 -0
  229. package/src/lib/providers/opencode-cli.ts +164 -0
  230. package/src/lib/runtime-loop.ts +15 -0
  231. package/src/lib/schedule-dedupe.test.ts +84 -0
  232. package/src/lib/schedule-dedupe.ts +174 -0
  233. package/src/lib/schedule-name.ts +62 -0
  234. package/src/lib/schedules.ts +16 -0
  235. package/src/lib/server/agent-registry.ts +70 -0
  236. package/src/lib/server/api-routes.test.ts +362 -0
  237. package/src/lib/server/autonomy-contract.ts +200 -0
  238. package/src/lib/server/build-llm.ts +155 -0
  239. package/src/lib/server/capability-router.test.ts +21 -0
  240. package/src/lib/server/capability-router.ts +172 -0
  241. package/src/lib/server/chat-execution.ts +894 -0
  242. package/src/lib/server/clawhub-client.test.ts +161 -0
  243. package/src/lib/server/clawhub-client.ts +26 -0
  244. package/src/lib/server/connectors/connector-routing.test.ts +243 -0
  245. package/src/lib/server/connectors/discord.ts +116 -0
  246. package/src/lib/server/connectors/googlechat.ts +66 -0
  247. package/src/lib/server/connectors/manager.ts +559 -0
  248. package/src/lib/server/connectors/matrix.ts +78 -0
  249. package/src/lib/server/connectors/media.ts +149 -0
  250. package/src/lib/server/connectors/openclaw.test.ts +375 -0
  251. package/src/lib/server/connectors/openclaw.ts +1132 -0
  252. package/src/lib/server/connectors/signal.ts +183 -0
  253. package/src/lib/server/connectors/slack.ts +258 -0
  254. package/src/lib/server/connectors/teams.ts +94 -0
  255. package/src/lib/server/connectors/telegram.ts +221 -0
  256. package/src/lib/server/connectors/types.ts +62 -0
  257. package/src/lib/server/connectors/whatsapp.ts +349 -0
  258. package/src/lib/server/context-manager.ts +232 -0
  259. package/src/lib/server/cost.ts +31 -0
  260. package/src/lib/server/daemon-state.ts +354 -0
  261. package/src/lib/server/data-dir.ts +3 -0
  262. package/src/lib/server/embeddings.ts +111 -0
  263. package/src/lib/server/execution-log.ts +257 -0
  264. package/src/lib/server/gateway/protocol.test.ts +54 -0
  265. package/src/lib/server/gateway/protocol.ts +114 -0
  266. package/src/lib/server/heartbeat-service.ts +366 -0
  267. package/src/lib/server/knowledge-db.test.ts +441 -0
  268. package/src/lib/server/logger.ts +47 -0
  269. package/src/lib/server/main-agent-loop.ts +1017 -0
  270. package/src/lib/server/mcp-client.test.ts +342 -0
  271. package/src/lib/server/mcp-client.ts +130 -0
  272. package/src/lib/server/memory-db.ts +1078 -0
  273. package/src/lib/server/memory-graph.test.ts +153 -0
  274. package/src/lib/server/memory-graph.ts +138 -0
  275. package/src/lib/server/openclaw-health.ts +245 -0
  276. package/src/lib/server/orchestrator-lg.ts +431 -0
  277. package/src/lib/server/orchestrator.ts +364 -0
  278. package/src/lib/server/playwright-proxy.mjs +70 -0
  279. package/src/lib/server/plugins.ts +229 -0
  280. package/src/lib/server/process-manager.ts +327 -0
  281. package/src/lib/server/provider-health.ts +113 -0
  282. package/src/lib/server/queue.ts +859 -0
  283. package/src/lib/server/runtime-settings.ts +119 -0
  284. package/src/lib/server/scheduler.ts +196 -0
  285. package/src/lib/server/session-mailbox.ts +129 -0
  286. package/src/lib/server/session-run-manager.ts +512 -0
  287. package/src/lib/server/session-tools/connector.ts +124 -0
  288. package/src/lib/server/session-tools/context-mgmt.ts +103 -0
  289. package/src/lib/server/session-tools/context.ts +114 -0
  290. package/src/lib/server/session-tools/crud.ts +673 -0
  291. package/src/lib/server/session-tools/delegate.ts +708 -0
  292. package/src/lib/server/session-tools/file.ts +264 -0
  293. package/src/lib/server/session-tools/index.ts +164 -0
  294. package/src/lib/server/session-tools/memory.ts +230 -0
  295. package/src/lib/server/session-tools/session-info.ts +422 -0
  296. package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
  297. package/src/lib/server/session-tools/shell.ts +171 -0
  298. package/src/lib/server/session-tools/web.ts +408 -0
  299. package/src/lib/server/session-tools.ts +9 -0
  300. package/src/lib/server/skills-normalize.ts +130 -0
  301. package/src/lib/server/storage-mcp.test.ts +161 -0
  302. package/src/lib/server/storage.ts +670 -0
  303. package/src/lib/server/stream-agent-chat.ts +571 -0
  304. package/src/lib/server/task-reports.ts +122 -0
  305. package/src/lib/server/task-result.ts +161 -0
  306. package/src/lib/server/task-validation.test.ts +27 -0
  307. package/src/lib/server/task-validation.ts +90 -0
  308. package/src/lib/server/tool-capability-policy.test.ts +58 -0
  309. package/src/lib/server/tool-capability-policy.ts +262 -0
  310. package/src/lib/sessions.ts +68 -0
  311. package/src/lib/tasks.ts +20 -0
  312. package/src/lib/tts.ts +42 -0
  313. package/src/lib/upload.ts +10 -0
  314. package/src/lib/utils.ts +6 -0
  315. package/src/proxy.ts +43 -0
  316. package/src/stores/use-app-store.ts +468 -0
  317. package/src/stores/use-chat-store.ts +323 -0
  318. package/src/types/index.ts +621 -0
  319. package/tsconfig.json +34 -0
@@ -0,0 +1,149 @@
1
+ import crypto from 'crypto'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { UPLOAD_DIR } from '../storage'
5
+ import type { InboundMedia, InboundMediaType } from './types'
6
+
7
+ const MIME_EXT_MAP: Record<string, string> = {
8
+ 'image/jpeg': '.jpg',
9
+ 'image/jpg': '.jpg',
10
+ 'image/png': '.png',
11
+ 'image/webp': '.webp',
12
+ 'image/gif': '.gif',
13
+ 'video/mp4': '.mp4',
14
+ 'video/quicktime': '.mov',
15
+ 'video/webm': '.webm',
16
+ 'audio/mpeg': '.mp3',
17
+ 'audio/mp3': '.mp3',
18
+ 'audio/ogg': '.ogg',
19
+ 'audio/wav': '.wav',
20
+ 'audio/x-wav': '.wav',
21
+ 'application/pdf': '.pdf',
22
+ }
23
+
24
+ function normalizeExt(ext: string): string {
25
+ const trimmed = ext.trim().toLowerCase().replace(/[^a-z0-9.]/g, '')
26
+ if (!trimmed) return ''
27
+ return trimmed.startsWith('.') ? trimmed : `.${trimmed}`
28
+ }
29
+
30
+ function extFromName(fileName?: string): string {
31
+ if (!fileName) return ''
32
+ return normalizeExt(path.extname(fileName))
33
+ }
34
+
35
+ function extFromMime(mimeType?: string): string {
36
+ if (!mimeType) return ''
37
+ const key = mimeType.toLowerCase().split(';')[0].trim()
38
+ return MIME_EXT_MAP[key] || ''
39
+ }
40
+
41
+ function safeBaseName(fileName?: string): string {
42
+ if (!fileName) return 'attachment'
43
+ const base = path.basename(fileName, path.extname(fileName))
44
+ const cleaned = base.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^_+|_+$/g, '')
45
+ return cleaned || 'attachment'
46
+ }
47
+
48
+ function ensureUploadDir() {
49
+ if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true })
50
+ }
51
+
52
+ const EXT_MIME_MAP: Record<string, string> = Object.fromEntries(
53
+ Object.entries(MIME_EXT_MAP).map(([m, e]) => [e, m])
54
+ )
55
+ // Add extras not covered by reverse map
56
+ Object.assign(EXT_MIME_MAP, {
57
+ '.jpg': 'image/jpeg',
58
+ '.jpeg': 'image/jpeg',
59
+ '.svg': 'image/svg+xml',
60
+ '.txt': 'text/plain',
61
+ '.json': 'application/json',
62
+ '.csv': 'text/csv',
63
+ '.zip': 'application/zip',
64
+ })
65
+
66
+ /** Guess MIME type from file extension */
67
+ export function mimeFromPath(filePath: string): string {
68
+ const ext = normalizeExt(path.extname(filePath))
69
+ return EXT_MIME_MAP[ext] || 'application/octet-stream'
70
+ }
71
+
72
+ /** Check if a MIME type is an image */
73
+ export function isImageMime(mime: string): boolean {
74
+ return mime.startsWith('image/')
75
+ }
76
+
77
+ export function inferInboundMediaType(mimeType?: string, fileName?: string, fallback: InboundMediaType = 'file'): InboundMediaType {
78
+ const probe = `${mimeType || ''} ${fileName || ''}`.toLowerCase()
79
+ if (probe.includes('image')) return 'image'
80
+ if (probe.includes('video')) return 'video'
81
+ if (probe.includes('audio') || probe.includes('voice')) return 'audio'
82
+ if (probe.includes('pdf') || probe.includes('doc') || probe.includes('sheet') || probe.includes('ppt') || probe.includes('text')) return 'document'
83
+ return fallback
84
+ }
85
+
86
+ export function saveInboundMediaBuffer(params: {
87
+ connectorId: string
88
+ buffer: Buffer
89
+ mediaType: InboundMediaType
90
+ mimeType?: string
91
+ fileName?: string
92
+ }): InboundMedia {
93
+ ensureUploadDir()
94
+
95
+ const ext = extFromName(params.fileName) || extFromMime(params.mimeType) || '.bin'
96
+ const base = safeBaseName(params.fileName)
97
+ const unique = crypto.randomBytes(4).toString('hex')
98
+ const filename = `${params.connectorId}-${Date.now()}-${base}-${unique}${ext}`
99
+ const localPath = path.join(UPLOAD_DIR, filename)
100
+ fs.writeFileSync(localPath, params.buffer)
101
+
102
+ return {
103
+ type: params.mediaType,
104
+ fileName: params.fileName || filename,
105
+ mimeType: params.mimeType,
106
+ sizeBytes: params.buffer.length,
107
+ localPath,
108
+ url: `/api/uploads/${filename}`,
109
+ }
110
+ }
111
+
112
+ export async function downloadInboundMediaToUpload(params: {
113
+ connectorId: string
114
+ mediaType: InboundMediaType
115
+ url: string
116
+ headers?: Record<string, string>
117
+ fileName?: string
118
+ mimeType?: string
119
+ maxBytes?: number
120
+ }): Promise<InboundMedia | null> {
121
+ const res = await fetch(params.url, {
122
+ headers: params.headers,
123
+ redirect: 'follow',
124
+ })
125
+ if (!res.ok) {
126
+ throw new Error(`Media download failed (${res.status})`)
127
+ }
128
+
129
+ const arrayBuffer = await res.arrayBuffer()
130
+ const buf = Buffer.from(arrayBuffer)
131
+ const maxBytes = params.maxBytes ?? 30 * 1024 * 1024
132
+ if (buf.length > maxBytes) {
133
+ return {
134
+ type: params.mediaType,
135
+ fileName: params.fileName,
136
+ mimeType: params.mimeType || res.headers.get('content-type') || undefined,
137
+ sizeBytes: buf.length,
138
+ url: params.url,
139
+ }
140
+ }
141
+
142
+ return saveInboundMediaBuffer({
143
+ connectorId: params.connectorId,
144
+ buffer: buf,
145
+ mediaType: params.mediaType,
146
+ mimeType: params.mimeType || res.headers.get('content-type') || undefined,
147
+ fileName: params.fileName,
148
+ })
149
+ }
@@ -0,0 +1,375 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { test } from 'node:test'
6
+ import openclaw from './openclaw.ts'
7
+
8
+ type WsFrame = Record<string, unknown>
9
+
10
+ type MockEventHandler<T> = ((event: T) => void) | null
11
+
12
+ class MockWebSocket {
13
+ static CONNECTING = 0
14
+ static OPEN = 1
15
+ static CLOSING = 2
16
+ static CLOSED = 3
17
+
18
+ static instances: MockWebSocket[] = []
19
+
20
+ static reset() {
21
+ this.instances = []
22
+ }
23
+
24
+ readonly url: string
25
+ readyState = MockWebSocket.CONNECTING
26
+ sent: WsFrame[] = []
27
+
28
+ onopen: (() => void) | null = null
29
+ onmessage: MockEventHandler<{ data: string }> = null
30
+ onclose: MockEventHandler<{ code: number; reason: string }> = null
31
+ onerror: (() => void) | null = null
32
+
33
+ constructor(url: string) {
34
+ this.url = url
35
+ MockWebSocket.instances.push(this)
36
+ setTimeout(() => {
37
+ if (this.readyState === MockWebSocket.CONNECTING) {
38
+ this.readyState = MockWebSocket.OPEN
39
+ this.onopen?.()
40
+ }
41
+ }, 0)
42
+ }
43
+
44
+ send(data: string) {
45
+ this.sent.push(JSON.parse(data) as WsFrame)
46
+ }
47
+
48
+ close(code = 1000, reason = '') {
49
+ if (this.readyState === MockWebSocket.CLOSED) return
50
+ this.readyState = MockWebSocket.CLOSED
51
+ this.onclose?.({ code, reason })
52
+ }
53
+
54
+ emit(frame: WsFrame) {
55
+ if (this.readyState !== MockWebSocket.OPEN) return
56
+ this.onmessage?.({ data: JSON.stringify(frame) })
57
+ }
58
+ }
59
+
60
+ function findReq(ws: MockWebSocket, method: string): WsFrame | undefined {
61
+ return ws.sent.find((frame) => frame?.type === 'req' && frame?.method === method)
62
+ }
63
+
64
+ function findReqAt(ws: MockWebSocket, method: string, index: number): WsFrame | undefined {
65
+ const matches = ws.sent.filter((frame) => frame?.type === 'req' && frame?.method === method)
66
+ return matches[index]
67
+ }
68
+
69
+ async function waitFor<T>(
70
+ getValue: () => T | null | undefined,
71
+ timeoutMs = 2_000,
72
+ pollMs = 10,
73
+ ): Promise<T> {
74
+ const started = Date.now()
75
+ while (Date.now() - started <= timeoutMs) {
76
+ const value = getValue()
77
+ if (value) return value
78
+ await new Promise((resolve) => setTimeout(resolve, pollMs))
79
+ }
80
+ throw new Error(`waitFor timed out after ${timeoutMs}ms`)
81
+ }
82
+
83
+ async function bootstrapConnector(params?: {
84
+ onMessage?: (msg: any) => Promise<string>
85
+ connectorId?: string
86
+ wsUrl?: string
87
+ config?: Record<string, unknown>
88
+ }) {
89
+ const connectorId = params?.connectorId || `test-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
90
+ const connector = {
91
+ id: connectorId,
92
+ name: 'OpenClaw Test',
93
+ platform: 'openclaw',
94
+ agentId: 'agent-test',
95
+ credentialId: null,
96
+ config: {
97
+ wsUrl: params?.wsUrl || 'ws://localhost:18789',
98
+ historyPoll: false,
99
+ ...(params?.config || {}),
100
+ },
101
+ isEnabled: true,
102
+ status: 'running',
103
+ createdAt: Date.now(),
104
+ updatedAt: Date.now(),
105
+ } as any
106
+
107
+ const onMessage = params?.onMessage || (async () => 'ok')
108
+ const instance = await openclaw.start(connector, '', onMessage)
109
+ const ws = await waitFor(() => MockWebSocket.instances[0], 1_500)
110
+ const identityPath = path.join(process.cwd(), 'data', 'openclaw', `${connectorId}-device.json`)
111
+ return { instance, ws, identityPath }
112
+ }
113
+
114
+ async function performHandshake(ws: MockWebSocket, helloPayload?: WsFrame) {
115
+ ws.emit({ type: 'event', event: 'connect.challenge', payload: { nonce: 'test-nonce' } })
116
+ const connectReq = await waitFor(() => findReq(ws, 'connect'), 1_500)
117
+ ws.emit({
118
+ type: 'res',
119
+ id: connectReq.id as string,
120
+ ok: true,
121
+ payload: helloPayload || {
122
+ type: 'hello-ok',
123
+ protocol: 3,
124
+ auth: { deviceToken: 'device-token-test' },
125
+ policy: { tickIntervalMs: 15_000 },
126
+ },
127
+ })
128
+ }
129
+
130
+ const originalWebSocket = (globalThis as any).WebSocket
131
+
132
+ test.beforeEach(() => {
133
+ MockWebSocket.reset()
134
+ ;(globalThis as any).WebSocket = MockWebSocket
135
+ })
136
+
137
+ test.afterEach(() => {
138
+ ;(globalThis as any).WebSocket = originalWebSocket
139
+ })
140
+
141
+ test('openclaw connector performs connect handshake and routes inbound chat', async () => {
142
+ const received: any[] = []
143
+ const { instance, ws, identityPath } = await bootstrapConnector({
144
+ onMessage: async (msg) => {
145
+ received.push(msg)
146
+ return 'pong'
147
+ },
148
+ })
149
+
150
+ try {
151
+ await performHandshake(ws)
152
+ ws.emit({
153
+ type: 'event',
154
+ event: 'chat',
155
+ payload: {
156
+ state: 'final',
157
+ sessionKey: 'main',
158
+ message: { role: 'user', text: 'Hello there', sender: 'Wayde' },
159
+ },
160
+ })
161
+
162
+ const chatReq = await waitFor(() => findReq(ws, 'chat.send'), 2_000)
163
+ assert.equal(chatReq.params && (chatReq.params as any).message, 'pong')
164
+ assert.equal(chatReq.params && (chatReq.params as any).sessionKey, 'main')
165
+ assert.equal(received.length, 1)
166
+ assert.equal(received[0].text, 'Hello there')
167
+
168
+ ws.emit({ type: 'res', id: chatReq.id as string, ok: true, payload: { runId: 'run-1' } })
169
+ } finally {
170
+ await instance.stop()
171
+ fs.rmSync(identityPath, { force: true })
172
+ }
173
+ })
174
+
175
+ test('openclaw connector suppresses outbound send when NO_MESSAGE is returned', async () => {
176
+ const { instance, ws, identityPath } = await bootstrapConnector({
177
+ onMessage: async () => 'NO_MESSAGE',
178
+ })
179
+
180
+ try {
181
+ await performHandshake(ws)
182
+ ws.emit({
183
+ type: 'event',
184
+ event: 'chat',
185
+ payload: {
186
+ state: 'final',
187
+ sessionKey: 'main',
188
+ message: { role: 'user', text: 'ack', sender: 'Wayde' },
189
+ },
190
+ })
191
+
192
+ await new Promise((resolve) => setTimeout(resolve, 80))
193
+ const sends = ws.sent.filter((frame) => frame.type === 'req' && frame.method === 'chat.send')
194
+ assert.equal(sends.length, 0)
195
+ } finally {
196
+ await instance.stop()
197
+ fs.rmSync(identityPath, { force: true })
198
+ }
199
+ })
200
+
201
+ test('openclaw connector accepts short session filter for full agent session keys', async () => {
202
+ const received: any[] = []
203
+ const { instance, ws, identityPath } = await bootstrapConnector({
204
+ onMessage: async (msg) => {
205
+ received.push(msg)
206
+ return 'alias-ok'
207
+ },
208
+ config: {
209
+ sessionKey: 'main',
210
+ },
211
+ })
212
+
213
+ try {
214
+ await performHandshake(ws)
215
+ ws.emit({
216
+ type: 'event',
217
+ event: 'chat',
218
+ payload: {
219
+ state: 'final',
220
+ sessionKey: 'agent:main:main',
221
+ message: { role: 'user', text: 'Hello alias', sender: 'Wayde' },
222
+ },
223
+ })
224
+
225
+ const chatReq = await waitFor(() => findReq(ws, 'chat.send'), 2_000)
226
+ assert.equal((chatReq.params as any)?.sessionKey, 'agent:main:main')
227
+ assert.equal((chatReq.params as any)?.message, 'alias-ok')
228
+ assert.equal(received.length, 1)
229
+ assert.equal(received[0]?.text, 'Hello alias')
230
+
231
+ ws.emit({
232
+ type: 'res',
233
+ id: chatReq.id as string,
234
+ ok: true,
235
+ payload: { runId: 'alias-run' },
236
+ })
237
+ } finally {
238
+ await instance.stop()
239
+ fs.rmSync(identityPath, { force: true })
240
+ }
241
+ })
242
+
243
+ test('openclaw connector sendMessage attaches local media payloads', async () => {
244
+ const { instance, ws, identityPath } = await bootstrapConnector()
245
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openclaw-connector-test-'))
246
+ const localPath = path.join(tmpDir, 'note.txt')
247
+ fs.writeFileSync(localPath, 'hello attachment', 'utf8')
248
+
249
+ try {
250
+ await performHandshake(ws)
251
+ const sendPromise = instance.sendMessage?.('main', '', {
252
+ mediaPath: localPath,
253
+ fileName: 'note.txt',
254
+ mimeType: 'text/plain',
255
+ })
256
+
257
+ const chatReq = await waitFor(() => findReq(ws, 'chat.send'), 2_000)
258
+ const params = (chatReq.params || {}) as any
259
+ assert.equal(params.message, 'See attached.')
260
+ assert.ok(Array.isArray(params.attachments))
261
+ assert.equal(params.attachments.length, 1)
262
+ assert.equal(params.attachments[0].mimeType, 'text/plain')
263
+ assert.equal(params.attachments[0].type, 'file')
264
+ assert.equal(params.attachments[0].content, Buffer.from('hello attachment').toString('base64'))
265
+
266
+ ws.emit({ type: 'res', id: chatReq.id as string, ok: true, payload: { runId: 'run-media' } })
267
+ await sendPromise
268
+ } finally {
269
+ await instance.stop()
270
+ fs.rmSync(tmpDir, { recursive: true, force: true })
271
+ fs.rmSync(identityPath, { force: true })
272
+ }
273
+ })
274
+
275
+ test('openclaw connector polls chat.history fallback for inbound user messages', async () => {
276
+ const received: any[] = []
277
+ const { instance, ws, identityPath } = await bootstrapConnector({
278
+ onMessage: async (msg) => {
279
+ received.push(msg)
280
+ return 'history-pong'
281
+ },
282
+ config: {
283
+ sessionKey: 'agent:main:main',
284
+ historyPoll: true,
285
+ historyPollMs: 500,
286
+ },
287
+ })
288
+
289
+ try {
290
+ await performHandshake(ws)
291
+
292
+ const firstHistoryReq = await waitFor(
293
+ () => findReqAt(ws, 'chat.history', 0),
294
+ 2_500,
295
+ )
296
+ assert.equal((firstHistoryReq.params as any)?.sessionKey, 'agent:main:main')
297
+ ws.emit({
298
+ type: 'res',
299
+ id: firstHistoryReq.id as string,
300
+ ok: true,
301
+ payload: {
302
+ sessionKey: 'agent:main:main',
303
+ messages: [
304
+ {
305
+ role: 'user',
306
+ timestamp: 1,
307
+ content: [{ type: 'text', text: 'old message' }],
308
+ },
309
+ ],
310
+ },
311
+ })
312
+
313
+ await new Promise((resolve) => setTimeout(resolve, 120))
314
+ assert.equal(received.length, 0)
315
+
316
+ const secondHistoryReq = await waitFor(
317
+ () => findReqAt(ws, 'chat.history', 1),
318
+ 3_000,
319
+ )
320
+ ws.emit({
321
+ type: 'res',
322
+ id: secondHistoryReq.id as string,
323
+ ok: true,
324
+ payload: {
325
+ sessionKey: 'agent:main:main',
326
+ messages: [
327
+ {
328
+ role: 'user',
329
+ timestamp: 1,
330
+ content: [{ type: 'text', text: 'old message' }],
331
+ },
332
+ {
333
+ role: 'user',
334
+ timestamp: 2,
335
+ content: [{ type: 'text', text: 'new message' }],
336
+ },
337
+ ],
338
+ },
339
+ })
340
+
341
+ const chatReq = await waitFor(() => findReq(ws, 'chat.send'), 2_000)
342
+ assert.equal((chatReq.params as any)?.sessionKey, 'agent:main:main')
343
+ assert.equal((chatReq.params as any)?.message, 'history-pong')
344
+ assert.equal(received.length, 1)
345
+ assert.equal(received[0]?.text, 'new message')
346
+
347
+ ws.emit({
348
+ type: 'res',
349
+ id: chatReq.id as string,
350
+ ok: true,
351
+ payload: { runId: 'history-run' },
352
+ })
353
+ } finally {
354
+ await instance.stop()
355
+ fs.rmSync(identityPath, { force: true })
356
+ }
357
+ })
358
+
359
+ test('openclaw connector reconnects when tick watchdog detects stale connection', async () => {
360
+ const { instance, ws, identityPath } = await bootstrapConnector()
361
+
362
+ try {
363
+ await performHandshake(ws, {
364
+ type: 'hello-ok',
365
+ protocol: 3,
366
+ policy: { tickIntervalMs: 200 },
367
+ })
368
+
369
+ await waitFor(() => MockWebSocket.instances.length >= 2 ? MockWebSocket.instances[1] : null, 7_000, 25)
370
+ assert.ok(MockWebSocket.instances.length >= 2)
371
+ } finally {
372
+ await instance.stop()
373
+ fs.rmSync(identityPath, { force: true })
374
+ }
375
+ })