@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,161 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ let originalFetch: typeof globalThis.fetch
5
+ let originalEnv: string | undefined
6
+
7
+ beforeEach(() => {
8
+ originalFetch = globalThis.fetch
9
+ originalEnv = process.env.CLAWHUB_API_URL
10
+ delete process.env.CLAWHUB_API_URL
11
+ })
12
+
13
+ afterEach(() => {
14
+ globalThis.fetch = originalFetch
15
+ if (originalEnv !== undefined) {
16
+ process.env.CLAWHUB_API_URL = originalEnv
17
+ } else {
18
+ delete process.env.CLAWHUB_API_URL
19
+ }
20
+ })
21
+
22
+ describe('searchClawHub', () => {
23
+ // Module caches CLAWHUB_BASE_URL at import time, so we need dynamic import
24
+ // after setting env. However the default is baked in at module load.
25
+ // We'll test the default URL by importing once.
26
+
27
+ it('constructs correct URL with query params and returns parsed JSON', async () => {
28
+ const mockData = { skills: [{ id: 's1', name: 'test-skill' }], total: 1, page: 1 }
29
+ let capturedUrl = ''
30
+
31
+ globalThis.fetch = async (input: RequestInfo | URL) => {
32
+ capturedUrl = String(input)
33
+ return new Response(JSON.stringify(mockData), { status: 200 })
34
+ }
35
+
36
+ const { searchClawHub } = await import('./clawhub-client.ts')
37
+ const result = await searchClawHub('hello world', 2, 10)
38
+
39
+ assert.ok(capturedUrl.includes('/skills?q=hello%20world&page=2&limit=10'))
40
+ assert.deepStrictEqual(result.skills, mockData.skills)
41
+ assert.equal(result.total, 1)
42
+ assert.equal(result.page, 1)
43
+ })
44
+
45
+ it('uses default page=1 and limit=20', async () => {
46
+ let capturedUrl = ''
47
+
48
+ globalThis.fetch = async (input: RequestInfo | URL) => {
49
+ capturedUrl = String(input)
50
+ return new Response(JSON.stringify({ skills: [], total: 0, page: 1 }), { status: 200 })
51
+ }
52
+
53
+ const { searchClawHub } = await import('./clawhub-client.ts')
54
+ await searchClawHub('test')
55
+
56
+ assert.ok(capturedUrl.includes('page=1'))
57
+ assert.ok(capturedUrl.includes('limit=20'))
58
+ })
59
+
60
+ it('uses default base URL when CLAWHUB_API_URL is not set', async () => {
61
+ let capturedUrl = ''
62
+
63
+ globalThis.fetch = async (input: RequestInfo | URL) => {
64
+ capturedUrl = String(input)
65
+ return new Response(JSON.stringify({ skills: [], total: 0, page: 1 }), { status: 200 })
66
+ }
67
+
68
+ const { searchClawHub } = await import('./clawhub-client.ts')
69
+ await searchClawHub('q')
70
+
71
+ assert.ok(capturedUrl.startsWith('https://clawhub.openclaw.dev/api/skills'))
72
+ })
73
+
74
+ it('returns empty results on non-200 response', async () => {
75
+ globalThis.fetch = async () => {
76
+ return new Response('Not Found', { status: 404 })
77
+ }
78
+
79
+ const { searchClawHub } = await import('./clawhub-client.ts')
80
+ const result = await searchClawHub('fail', 3)
81
+
82
+ assert.deepStrictEqual(result.skills, [])
83
+ assert.equal(result.total, 0)
84
+ assert.equal(result.page, 3)
85
+ })
86
+
87
+ it('returns empty results on fetch network error', async () => {
88
+ globalThis.fetch = async () => {
89
+ throw new Error('network failure')
90
+ }
91
+
92
+ const { searchClawHub } = await import('./clawhub-client.ts')
93
+ const result = await searchClawHub('err', 5)
94
+
95
+ assert.deepStrictEqual(result.skills, [])
96
+ assert.equal(result.total, 0)
97
+ assert.equal(result.page, 5)
98
+ })
99
+
100
+ it('encodes special characters in query', async () => {
101
+ let capturedUrl = ''
102
+
103
+ globalThis.fetch = async (input: RequestInfo | URL) => {
104
+ capturedUrl = String(input)
105
+ return new Response(JSON.stringify({ skills: [], total: 0, page: 1 }), { status: 200 })
106
+ }
107
+
108
+ const { searchClawHub } = await import('./clawhub-client.ts')
109
+ await searchClawHub('a&b=c')
110
+
111
+ assert.ok(capturedUrl.includes('q=a%26b%3Dc'))
112
+ })
113
+ })
114
+
115
+ describe('fetchSkillContent', () => {
116
+ it('fetches raw URL and returns text content', async () => {
117
+ const content = '# Skill README\nHello world'
118
+
119
+ globalThis.fetch = async (input: RequestInfo | URL) => {
120
+ assert.equal(String(input), 'https://example.com/raw/skill.md')
121
+ return new Response(content, { status: 200 })
122
+ }
123
+
124
+ const { fetchSkillContent } = await import('./clawhub-client.ts')
125
+ const result = await fetchSkillContent('https://example.com/raw/skill.md')
126
+
127
+ assert.equal(result, content)
128
+ })
129
+
130
+ it('throws on non-200 response', async () => {
131
+ globalThis.fetch = async () => {
132
+ return new Response('Server Error', { status: 500 })
133
+ }
134
+
135
+ const { fetchSkillContent } = await import('./clawhub-client.ts')
136
+
137
+ await assert.rejects(
138
+ () => fetchSkillContent('https://example.com/raw/fail.md'),
139
+ (err: Error) => {
140
+ assert.ok(err.message.includes('500'))
141
+ return true
142
+ }
143
+ )
144
+ })
145
+
146
+ it('throws on network error', async () => {
147
+ globalThis.fetch = async () => {
148
+ throw new TypeError('fetch failed')
149
+ }
150
+
151
+ const { fetchSkillContent } = await import('./clawhub-client.ts')
152
+
153
+ await assert.rejects(
154
+ () => fetchSkillContent('https://down.example.com/skill.md'),
155
+ (err: Error) => {
156
+ assert.ok(err.message.includes('fetch failed'))
157
+ return true
158
+ }
159
+ )
160
+ })
161
+ })
@@ -0,0 +1,26 @@
1
+ import type { ClawHubSkill } from '@/types'
2
+
3
+ export interface ClawHubSearchResult {
4
+ skills: ClawHubSkill[]
5
+ total: number
6
+ page: number
7
+ }
8
+
9
+ const CLAWHUB_BASE_URL = process.env.CLAWHUB_API_URL || 'https://clawhub.openclaw.dev/api'
10
+
11
+ export async function searchClawHub(query: string, page = 1, limit = 20): Promise<ClawHubSearchResult> {
12
+ try {
13
+ const url = `${CLAWHUB_BASE_URL}/skills?q=${encodeURIComponent(query)}&page=${page}&limit=${limit}`
14
+ const res = await fetch(url)
15
+ if (!res.ok) throw new Error(`ClawHub responded with ${res.status}`)
16
+ return await res.json()
17
+ } catch {
18
+ return { skills: [], total: 0, page }
19
+ }
20
+ }
21
+
22
+ export async function fetchSkillContent(rawUrl: string): Promise<string> {
23
+ const res = await fetch(rawUrl)
24
+ if (!res.ok) throw new Error(`Failed to fetch skill content: ${res.status}`)
25
+ return res.text()
26
+ }
@@ -0,0 +1,243 @@
1
+ import { describe, it } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { getPlatform, isNoMessage, formatMediaLine, formatInboundUserText } from './manager.ts'
4
+ import { handleSignalEvent } from './signal.ts'
5
+ import type { PlatformConnector } from './types.ts'
6
+ import type { InboundMessage, InboundMedia } from './types.ts'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // 1. Connector module resolution (getPlatform)
10
+ // ---------------------------------------------------------------------------
11
+ describe('getPlatform — connector module resolution', () => {
12
+ const newPlatforms = ['matrix', 'googlechat', 'teams', 'signal'] as const
13
+
14
+ for (const name of newPlatforms) {
15
+ it(`returns a valid module for "${name}"`, async () => {
16
+ const mod = await getPlatform(name)
17
+ assert.ok(mod, `getPlatform("${name}") should return a module`)
18
+ })
19
+
20
+ it(`"${name}" module has a start function (PlatformConnector)`, async () => {
21
+ const mod: PlatformConnector = await getPlatform(name)
22
+ assert.equal(typeof mod.start, 'function')
23
+ })
24
+ }
25
+
26
+ // Legacy platforms still resolve
27
+ for (const name of ['discord', 'telegram', 'slack', 'whatsapp', 'openclaw'] as const) {
28
+ it(`resolves legacy platform "${name}"`, async () => {
29
+ const mod = await getPlatform(name)
30
+ assert.equal(typeof mod.start, 'function')
31
+ })
32
+ }
33
+
34
+ it('throws on unknown platform', async () => {
35
+ await assert.rejects(() => getPlatform('nonexistent'), {
36
+ message: 'Unknown platform: nonexistent',
37
+ })
38
+ })
39
+ })
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // 2. Signal — handleSignalEvent message parsing
43
+ // ---------------------------------------------------------------------------
44
+ describe('handleSignalEvent — Signal stdio message parsing', () => {
45
+ function makeFakeConnector() {
46
+ return {
47
+ id: 'sig-test',
48
+ name: 'Signal Test',
49
+ platform: 'signal',
50
+ agentId: 'agent-1',
51
+ credentialId: null,
52
+ config: { phoneNumber: '+15551234567', signalCliPath: 'signal-cli', signalCliMode: 'http' },
53
+ isEnabled: true,
54
+ status: 'running' as const,
55
+ createdAt: Date.now(),
56
+ updatedAt: Date.now(),
57
+ } as any
58
+ }
59
+
60
+ it('parses envelope with dataMessage.message field', async () => {
61
+ const received: InboundMessage[] = []
62
+ const connector = makeFakeConnector()
63
+ const event = {
64
+ envelope: {
65
+ source: '+15559876543',
66
+ sourceName: 'Alice',
67
+ dataMessage: { message: 'Hello from Signal' },
68
+ },
69
+ }
70
+ await handleSignalEvent(event, connector, async (msg) => {
71
+ received.push(msg)
72
+ return 'NO_MESSAGE'
73
+ })
74
+ assert.equal(received.length, 1)
75
+ assert.equal(received[0].text, 'Hello from Signal')
76
+ assert.equal(received[0].senderId, '+15559876543')
77
+ assert.equal(received[0].senderName, 'Alice')
78
+ assert.equal(received[0].platform, 'signal')
79
+ assert.equal(received[0].channelId, '+15559876543') // DM uses sender as channel
80
+ })
81
+
82
+ it('parses envelope with dataMessage.body field', async () => {
83
+ const received: InboundMessage[] = []
84
+ const connector = makeFakeConnector()
85
+ const event = {
86
+ envelope: {
87
+ source: '+15550001111',
88
+ sourceName: 'Bob',
89
+ dataMessage: { body: 'Body variant' },
90
+ },
91
+ }
92
+ await handleSignalEvent(event, connector, async (msg) => {
93
+ received.push(msg)
94
+ return 'NO_MESSAGE'
95
+ })
96
+ assert.equal(received.length, 1)
97
+ assert.equal(received[0].text, 'Body variant')
98
+ })
99
+
100
+ it('uses groupId as channelId when present', async () => {
101
+ const received: InboundMessage[] = []
102
+ const connector = makeFakeConnector()
103
+ const event = {
104
+ envelope: {
105
+ source: '+15550001111',
106
+ sourceName: 'Carol',
107
+ dataMessage: {
108
+ message: 'Group msg',
109
+ groupInfo: { groupId: 'grp-abc123' },
110
+ },
111
+ },
112
+ }
113
+ await handleSignalEvent(event, connector, async (msg) => {
114
+ received.push(msg)
115
+ return 'NO_MESSAGE'
116
+ })
117
+ assert.equal(received[0].channelId, 'grp-abc123')
118
+ assert.equal(received[0].channelName, 'group:grp-abc123')
119
+ })
120
+
121
+ it('handles flat event without .envelope wrapper', async () => {
122
+ const received: InboundMessage[] = []
123
+ const connector = makeFakeConnector()
124
+ // signal-cli can emit flat objects (envelope IS the top-level)
125
+ const event = {
126
+ source: '+15552222222',
127
+ sourceName: 'Dave',
128
+ dataMessage: { message: 'Flat format' },
129
+ }
130
+ await handleSignalEvent(event, connector, async (msg) => {
131
+ received.push(msg)
132
+ return 'NO_MESSAGE'
133
+ })
134
+ assert.equal(received.length, 1)
135
+ assert.equal(received[0].text, 'Flat format')
136
+ assert.equal(received[0].senderId, '+15552222222')
137
+ })
138
+
139
+ it('ignores events without dataMessage', async () => {
140
+ const received: InboundMessage[] = []
141
+ const connector = makeFakeConnector()
142
+ // Typing indicator — no dataMessage
143
+ const event = { envelope: { source: '+15551111111', typingMessage: { action: 'STARTED' } } }
144
+ await handleSignalEvent(event, connector, async (msg) => {
145
+ received.push(msg)
146
+ return 'ok'
147
+ })
148
+ assert.equal(received.length, 0)
149
+ })
150
+
151
+ it('ignores events where dataMessage has no message or body', async () => {
152
+ const received: InboundMessage[] = []
153
+ const connector = makeFakeConnector()
154
+ // Receipt with empty dataMessage
155
+ const event = { envelope: { source: '+15551111111', dataMessage: {} } }
156
+ await handleSignalEvent(event, connector, async (msg) => {
157
+ received.push(msg)
158
+ return 'ok'
159
+ })
160
+ assert.equal(received.length, 0)
161
+ })
162
+ })
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // 3. isNoMessage helper
166
+ // ---------------------------------------------------------------------------
167
+ describe('isNoMessage', () => {
168
+ it('matches exact sentinel', () => {
169
+ assert.ok(isNoMessage('NO_MESSAGE'))
170
+ })
171
+ it('matches case-insensitive', () => {
172
+ assert.ok(isNoMessage('no_message'))
173
+ assert.ok(isNoMessage('No_Message'))
174
+ })
175
+ it('trims whitespace', () => {
176
+ assert.ok(isNoMessage(' NO_MESSAGE \n'))
177
+ })
178
+ it('rejects non-sentinel text', () => {
179
+ assert.ok(!isNoMessage('hello'))
180
+ assert.ok(!isNoMessage('NO_MESSAGE extra'))
181
+ })
182
+ })
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // 4. formatMediaLine
186
+ // ---------------------------------------------------------------------------
187
+ describe('formatMediaLine', () => {
188
+ it('formats media with URL', () => {
189
+ const media: InboundMedia = { type: 'image', fileName: 'photo.jpg', sizeBytes: 2048, url: '/uploads/photo.jpg' }
190
+ const line = formatMediaLine(media)
191
+ assert.equal(line, '- IMAGE: photo.jpg (2 KB) -> /uploads/photo.jpg')
192
+ })
193
+
194
+ it('formats media without URL', () => {
195
+ const media: InboundMedia = { type: 'document', mimeType: 'application/pdf', sizeBytes: 512 }
196
+ const line = formatMediaLine(media)
197
+ assert.equal(line, '- DOCUMENT: application/pdf (1 KB)')
198
+ })
199
+
200
+ it('falls back to "attachment" when no fileName or mimeType', () => {
201
+ const media: InboundMedia = { type: 'file' }
202
+ const line = formatMediaLine(media)
203
+ assert.equal(line, '- FILE: attachment')
204
+ })
205
+ })
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // 5. formatInboundUserText
209
+ // ---------------------------------------------------------------------------
210
+ describe('formatInboundUserText', () => {
211
+ it('formats basic text message', () => {
212
+ const msg = { platform: 'signal', channelId: 'ch1', senderId: 's1', senderName: 'Alice', text: 'Hello' } as InboundMessage
213
+ assert.equal(formatInboundUserText(msg), '[Alice] Hello')
214
+ })
215
+
216
+ it('handles empty text with just sender name', () => {
217
+ const msg = { platform: 'signal', channelId: 'ch1', senderId: 's1', senderName: 'Bob', text: '' } as InboundMessage
218
+ assert.equal(formatInboundUserText(msg), '[Bob]')
219
+ })
220
+
221
+ it('appends media lines', () => {
222
+ const msg: InboundMessage = {
223
+ platform: 'signal', channelId: 'ch1', senderId: 's1', senderName: 'Eve', text: 'Check this',
224
+ media: [{ type: 'image', fileName: 'cat.png', url: '/cat.png' }],
225
+ }
226
+ const result = formatInboundUserText(msg)
227
+ assert.ok(result.includes('[Eve] Check this'))
228
+ assert.ok(result.includes('Media received:'))
229
+ assert.ok(result.includes('- IMAGE: cat.png'))
230
+ })
231
+
232
+ it('truncates media list at 6 with overflow note', () => {
233
+ const media: InboundMedia[] = Array.from({ length: 8 }, (_, i) => ({
234
+ type: 'file' as const, fileName: `f${i}.txt`,
235
+ }))
236
+ const msg: InboundMessage = {
237
+ platform: 'signal', channelId: 'ch1', senderId: 's1', senderName: 'Fran', text: 'files',
238
+ media,
239
+ }
240
+ const result = formatInboundUserText(msg)
241
+ assert.ok(result.includes('...and 2 more attachment(s)'))
242
+ })
243
+ })
@@ -0,0 +1,116 @@
1
+ import { Client, GatewayIntentBits, Events, Partials, AttachmentBuilder } from 'discord.js'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import type { Connector } from '@/types'
5
+ import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
6
+ import { inferInboundMediaType, mimeFromPath, isImageMime } from './media'
7
+ import { isNoMessage } from './manager'
8
+
9
+ const discord: PlatformConnector = {
10
+ async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
11
+ const client = new Client({
12
+ intents: [
13
+ GatewayIntentBits.Guilds,
14
+ GatewayIntentBits.GuildMessages,
15
+ GatewayIntentBits.MessageContent,
16
+ GatewayIntentBits.DirectMessages,
17
+ ],
18
+ partials: [Partials.Channel], // Required to receive DM events
19
+ })
20
+
21
+ // Optional: restrict to specific channels
22
+ const allowedChannels = connector.config.channelIds
23
+ ? connector.config.channelIds.split(',').map((s) => s.trim()).filter(Boolean)
24
+ : null
25
+
26
+ client.on(Events.MessageCreate, async (message) => {
27
+ console.log(`[discord] Message from ${message.author.username} in ${message.channel.type === 1 ? 'DM' : '#' + ('name' in message.channel ? (message.channel as any).name : message.channelId)}: ${message.content.slice(0, 80)}`)
28
+ // Ignore bot messages
29
+ if (message.author.bot) return
30
+
31
+ // Filter by allowed channels if configured
32
+ if (allowedChannels && !allowedChannels.includes(message.channelId)) return
33
+
34
+ const attachmentList = Array.from(message.attachments.values())
35
+ const media = attachmentList.map((a) => ({
36
+ type: inferInboundMediaType(a.contentType || undefined, a.name || undefined),
37
+ fileName: a.name || undefined,
38
+ mimeType: a.contentType || undefined,
39
+ sizeBytes: a.size || undefined,
40
+ url: a.url || undefined,
41
+ }))
42
+ const firstImage = media.find((m) => m.type === 'image' && m.url)
43
+
44
+ const inbound: InboundMessage = {
45
+ platform: 'discord',
46
+ channelId: message.channelId,
47
+ channelName: 'name' in message.channel ? (message.channel as any).name : 'DM',
48
+ senderId: message.author.id,
49
+ senderName: message.author.displayName || message.author.username,
50
+ text: message.content || (media.length > 0 ? '(media message)' : ''),
51
+ imageUrl: firstImage?.url,
52
+ media,
53
+ }
54
+
55
+ try {
56
+ // Show typing indicator
57
+ await message.channel.sendTyping()
58
+ const response = await onMessage(inbound)
59
+
60
+ if (isNoMessage(response)) return
61
+
62
+ // Discord has a 2000 char limit per message
63
+ if (response.length <= 2000) {
64
+ await message.channel.send(response)
65
+ } else {
66
+ // Split into chunks
67
+ const chunks = response.match(/[\s\S]{1,1990}/g) || [response]
68
+ for (const chunk of chunks) {
69
+ await message.channel.send(chunk)
70
+ }
71
+ }
72
+ } catch (err: any) {
73
+ console.error(`[discord] Error handling message:`, err.message)
74
+ try {
75
+ await message.reply('Sorry, I encountered an error processing your message.')
76
+ } catch { /* ignore */ }
77
+ }
78
+ })
79
+
80
+ await client.login(botToken)
81
+ console.log(`[discord] Bot logged in as ${client.user?.tag}`)
82
+
83
+ return {
84
+ connector,
85
+ async sendMessage(channelId, text, options) {
86
+ const channel = await client.channels.fetch(channelId)
87
+ if (!channel || !('send' in channel) || typeof (channel as any).send !== 'function') {
88
+ throw new Error(`Cannot send to channel ${channelId}`)
89
+ }
90
+
91
+ const files: AttachmentBuilder[] = []
92
+ if (options?.mediaPath) {
93
+ if (!fs.existsSync(options.mediaPath)) throw new Error(`File not found: ${options.mediaPath}`)
94
+ files.push(new AttachmentBuilder(options.mediaPath, { name: options.fileName || path.basename(options.mediaPath) }))
95
+ } else if (options?.imageUrl) {
96
+ files.push(new AttachmentBuilder(options.imageUrl, { name: options.fileName || 'image.png' }))
97
+ } else if (options?.fileUrl) {
98
+ files.push(new AttachmentBuilder(options.fileUrl, { name: options.fileName || 'attachment' }))
99
+ }
100
+
101
+ const content = options?.caption || text || undefined
102
+ const msg = await (channel as any).send({
103
+ content: content || (files.length ? undefined : '(empty)'),
104
+ files: files.length ? files : undefined,
105
+ })
106
+ return { messageId: msg.id }
107
+ },
108
+ async stop() {
109
+ client.destroy()
110
+ console.log(`[discord] Bot disconnected`)
111
+ },
112
+ }
113
+ },
114
+ }
115
+
116
+ export default discord
@@ -0,0 +1,66 @@
1
+ import type { PlatformConnector, ConnectorInstance } from './types'
2
+
3
+ const googlechat: PlatformConnector = {
4
+ async start(connector, botToken, _onMessage): Promise<ConnectorInstance> {
5
+ const pkg = 'googleapis'
6
+ const { google } = await import(/* webpackIgnore: true */ pkg)
7
+
8
+ // Parse service account credentials from botToken
9
+ let credentials: any
10
+ try {
11
+ credentials = JSON.parse(botToken)
12
+ } catch {
13
+ throw new Error('botToken must be a valid JSON service account key')
14
+ }
15
+
16
+ const auth = new google.auth.GoogleAuth({
17
+ credentials,
18
+ scopes: ['https://www.googleapis.com/auth/chat.bot'],
19
+ })
20
+
21
+ const chat = google.chat({ version: 'v1', auth })
22
+
23
+ // Optional: restrict to specific spaces
24
+ const allowedSpaces = connector.config.spaceIds
25
+ ? connector.config.spaceIds.split(',').map((s: string) => s.trim()).filter(Boolean)
26
+ : null
27
+
28
+ // Google Chat requires a webhook or Pub/Sub for real-time inbound messages.
29
+ // This connector supports outbound messaging. For inbound messages, configure
30
+ // a webhook endpoint at /api/connectors/[id]/webhook that POSTs events here.
31
+ // Polling is not supported by the Google Chat API for bot messages.
32
+ let stopped = false
33
+
34
+ console.log(`[googlechat] Bot authenticated via service account`)
35
+ if (allowedSpaces) {
36
+ console.log(`[googlechat] Filtering to spaces: ${allowedSpaces.join(', ')}`)
37
+ }
38
+ console.log(`[googlechat] Note: Inbound messages require a webhook or Pub/Sub subscription. This connector supports outbound sends.`)
39
+
40
+ return {
41
+ connector,
42
+ async sendMessage(channelId, text) {
43
+ if (stopped) throw new Error('Connector is stopped')
44
+
45
+ // channelId should be a space name like "spaces/AAAA"
46
+ const parent = channelId.startsWith('spaces/') ? channelId : `spaces/${channelId}`
47
+
48
+ if (allowedSpaces && !allowedSpaces.some((s) => parent.includes(s))) {
49
+ throw new Error(`Space ${parent} not in allowed spaceIds`)
50
+ }
51
+
52
+ const res = await chat.spaces.messages.create({
53
+ parent,
54
+ requestBody: { text },
55
+ })
56
+ return { messageId: res.data.name || undefined }
57
+ },
58
+ async stop() {
59
+ stopped = true
60
+ console.log(`[googlechat] Bot disconnected`)
61
+ },
62
+ }
63
+ },
64
+ }
65
+
66
+ export default googlechat