@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,1132 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { DATA_DIR } from '../data-dir'
5
+ import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
6
+ import {
7
+ createGatewayRequestFrame,
8
+ parseGatewayFrame,
9
+ serializeGatewayFrame,
10
+ type GatewayFrame,
11
+ type GatewayResponseFrame,
12
+ } from '../gateway/protocol'
13
+
14
+ /**
15
+ * OpenClaw gateway connector using the current WS protocol:
16
+ * - server emits `event: connect.challenge`
17
+ * - client sends `req(connect, params)`
18
+ * - gateway responds via `res` payload `hello-ok`
19
+ * - chat traffic is event `chat` and RPC method `chat.send`
20
+ */
21
+
22
+ const PROTOCOL_VERSION = 3
23
+ const RECONNECT_BASE_MS = 2_000
24
+ const RECONNECT_MAX_MS = 30_000
25
+ const RPC_TIMEOUT_MS = 25_000
26
+ const CONNECT_DELAY_FALLBACK_MS = 750
27
+ const CONNECT_HELLO_TIMEOUT_MS = 20_000
28
+ const DEFAULT_WS_URL = 'ws://localhost:18789'
29
+ const DEFAULT_SESSION_KEY = 'main'
30
+ const DEFAULT_TICK_INTERVAL_MS = 30_000
31
+ const MIN_TICK_WATCHDOG_POLL_MS = 750
32
+ const MAX_TICK_WATCHDOG_POLL_MS = 5_000
33
+ const TICK_MISS_TOLERANCE_MULTIPLIER = 2
34
+ const DEFAULT_CHAT_HISTORY_POLL_MS = 2_500
35
+ const MIN_CHAT_HISTORY_POLL_MS = 500
36
+ const MAX_CHAT_HISTORY_POLL_MS = 60_000
37
+ const DEFAULT_CHAT_HISTORY_LIMIT = 40
38
+ const MIN_CHAT_HISTORY_LIMIT = 5
39
+ const MAX_CHAT_HISTORY_LIMIT = 200
40
+ const MAX_INLINE_ATTACHMENT_BYTES = 5_000_000
41
+ const NO_MESSAGE_SENTINEL = 'NO_MESSAGE'
42
+ const MAX_SEEN_HISTORY_MESSAGES = 4_096
43
+ const RECENT_HISTORY_DUPLICATE_WINDOW_MS = 20_000
44
+ const HISTORY_ERROR_LOG_INTERVAL_MS = 30_000
45
+
46
+ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
47
+ const MAX_SEEN_CHAT_EVENTS = 2048
48
+
49
+ type StoredIdentity = {
50
+ version: 1
51
+ deviceId: string
52
+ publicKeyPem: string
53
+ privateKeyPem: string
54
+ createdAtMs: number
55
+ deviceToken?: string
56
+ }
57
+
58
+ type DeviceIdentity = {
59
+ deviceId: string
60
+ publicKeyPem: string
61
+ privateKeyPem: string
62
+ deviceToken?: string
63
+ }
64
+
65
+ type PendingRequest = {
66
+ method: string
67
+ resolve: (value: unknown) => void
68
+ reject: (reason?: unknown) => void
69
+ timer: ReturnType<typeof setTimeout>
70
+ }
71
+
72
+ type OutboundSendOptions = Parameters<NonNullable<ConnectorInstance['sendMessage']>>[2]
73
+
74
+ type OutboundAttachment = {
75
+ type: 'image' | 'file'
76
+ mimeType: string
77
+ fileName?: string
78
+ content: string
79
+ }
80
+
81
+ type ChatEventPayload = {
82
+ runId?: string
83
+ seq?: number
84
+ state?: string
85
+ sessionKey?: string
86
+ message?: {
87
+ role?: string
88
+ sender?: string
89
+ senderId?: string
90
+ senderName?: string
91
+ text?: string
92
+ content?: unknown
93
+ }
94
+ sender?: string
95
+ senderId?: string
96
+ senderName?: string
97
+ text?: string
98
+ }
99
+
100
+ type ChatHistoryMessage = {
101
+ role?: unknown
102
+ sender?: unknown
103
+ senderId?: unknown
104
+ senderName?: unknown
105
+ content?: unknown
106
+ timestamp?: unknown
107
+ }
108
+
109
+ type ChatHistoryPayload = {
110
+ sessionKey?: unknown
111
+ messages?: unknown
112
+ }
113
+
114
+ function isSecureWsUrl(url: string): boolean {
115
+ let parsed: URL
116
+ try {
117
+ parsed = new URL(url)
118
+ } catch {
119
+ return false
120
+ }
121
+ if (parsed.protocol === 'wss:') return true
122
+ if (parsed.protocol !== 'ws:') return false
123
+ const host = parsed.hostname.trim().toLowerCase()
124
+ if (host === 'localhost' || host === '::1') return true
125
+ if (host.startsWith('127.')) return true
126
+ return false
127
+ }
128
+
129
+ function isNoMessage(text: string): boolean {
130
+ return text.trim().toUpperCase() === NO_MESSAGE_SENTINEL
131
+ }
132
+
133
+ function base64UrlEncode(buf: Buffer): string {
134
+ return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
135
+ }
136
+
137
+ function derivePublicKeyRaw(publicKeyPem: string): Buffer {
138
+ const key = crypto.createPublicKey(publicKeyPem)
139
+ const spki = key.export({ type: 'spki', format: 'der' }) as Buffer
140
+ if (
141
+ spki.length === ED25519_SPKI_PREFIX.length + 32
142
+ && spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
143
+ ) {
144
+ return spki.subarray(ED25519_SPKI_PREFIX.length)
145
+ }
146
+ return spki
147
+ }
148
+
149
+ function fingerprintPublicKey(publicKeyPem: string): string {
150
+ const raw = derivePublicKeyRaw(publicKeyPem)
151
+ return crypto.createHash('sha256').update(raw).digest('hex')
152
+ }
153
+
154
+ function buildDeviceAuthPayload(params: {
155
+ deviceId: string
156
+ clientId: string
157
+ clientMode: string
158
+ role: string
159
+ scopes: string[]
160
+ signedAtMs: number
161
+ token?: string | null
162
+ nonce?: string | null
163
+ }): string {
164
+ const version = params.nonce ? 'v2' : 'v1'
165
+ const scopes = params.scopes.join(',')
166
+ const token = params.token ?? ''
167
+ const base = [
168
+ version,
169
+ params.deviceId,
170
+ params.clientId,
171
+ params.clientMode,
172
+ params.role,
173
+ scopes,
174
+ String(params.signedAtMs),
175
+ token,
176
+ ]
177
+ if (version === 'v2') base.push(params.nonce ?? '')
178
+ return base.join('|')
179
+ }
180
+
181
+ function signDevicePayload(privateKeyPem: string, payload: string): string {
182
+ const key = crypto.createPrivateKey(privateKeyPem)
183
+ const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), key)
184
+ return base64UrlEncode(sig)
185
+ }
186
+
187
+ function resolveIdentityPath(connectorId: string): string {
188
+ return path.join(DATA_DIR, 'openclaw', `${connectorId}-device.json`)
189
+ }
190
+
191
+ function persistIdentity(filePath: string, identity: DeviceIdentity) {
192
+ const doc: StoredIdentity = {
193
+ version: 1,
194
+ deviceId: identity.deviceId,
195
+ publicKeyPem: identity.publicKeyPem,
196
+ privateKeyPem: identity.privateKeyPem,
197
+ createdAtMs: Date.now(),
198
+ deviceToken: identity.deviceToken,
199
+ }
200
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
201
+ fs.writeFileSync(filePath, `${JSON.stringify(doc, null, 2)}\n`, { mode: 0o600 })
202
+ try { fs.chmodSync(filePath, 0o600) } catch { /* best effort */ }
203
+ }
204
+
205
+ function loadOrCreateIdentity(filePath: string): DeviceIdentity {
206
+ try {
207
+ if (fs.existsSync(filePath)) {
208
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as StoredIdentity
209
+ if (
210
+ parsed?.version === 1
211
+ && typeof parsed.deviceId === 'string'
212
+ && typeof parsed.publicKeyPem === 'string'
213
+ && typeof parsed.privateKeyPem === 'string'
214
+ ) {
215
+ const derivedDeviceId = fingerprintPublicKey(parsed.publicKeyPem)
216
+ const identity: DeviceIdentity = {
217
+ deviceId: derivedDeviceId || parsed.deviceId,
218
+ publicKeyPem: parsed.publicKeyPem,
219
+ privateKeyPem: parsed.privateKeyPem,
220
+ deviceToken: typeof parsed.deviceToken === 'string' && parsed.deviceToken.trim()
221
+ ? parsed.deviceToken.trim()
222
+ : undefined,
223
+ }
224
+ if (identity.deviceId !== parsed.deviceId) persistIdentity(filePath, identity)
225
+ return identity
226
+ }
227
+ }
228
+ } catch {
229
+ // fall through and regenerate
230
+ }
231
+
232
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
233
+ const identity: DeviceIdentity = {
234
+ deviceId: fingerprintPublicKey(publicKey.export({ type: 'spki', format: 'pem' }).toString()),
235
+ publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
236
+ privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
237
+ }
238
+ persistIdentity(filePath, identity)
239
+ return identity
240
+ }
241
+
242
+ function contentToText(content: unknown): string {
243
+ if (typeof content === 'string') return content
244
+ if (!Array.isArray(content)) return ''
245
+ const parts: string[] = []
246
+ for (const part of content) {
247
+ if (!part || typeof part !== 'object') continue
248
+ const obj = part as { text?: unknown; input_text?: unknown }
249
+ if (typeof obj.text === 'string') parts.push(obj.text)
250
+ else if (typeof obj.input_text === 'string') parts.push(obj.input_text)
251
+ }
252
+ return parts.join('\n').trim()
253
+ }
254
+
255
+ function getErrorMessage(err: unknown): string {
256
+ if (err instanceof Error && err.message) return err.message
257
+ return String(err)
258
+ }
259
+
260
+ function normalizeMimeType(value?: string | null): string | undefined {
261
+ if (!value) return undefined
262
+ const cleaned = value.split(';')[0]?.trim().toLowerCase()
263
+ return cleaned || undefined
264
+ }
265
+
266
+ function clampNumber(value: number, min: number, max: number): number {
267
+ return Math.max(min, Math.min(max, value))
268
+ }
269
+
270
+ function parseBooleanLike(value: unknown, fallback: boolean): boolean {
271
+ if (typeof value === 'boolean') return value
272
+ if (typeof value === 'string') {
273
+ const normalized = value.trim().toLowerCase()
274
+ if (!normalized) return fallback
275
+ if (['false', '0', 'off', 'no'].includes(normalized)) return false
276
+ if (['true', '1', 'on', 'yes'].includes(normalized)) return true
277
+ }
278
+ return fallback
279
+ }
280
+
281
+ function matchesSessionKey(filter: string, actual: string): boolean {
282
+ const configured = filter.trim()
283
+ const incoming = actual.trim()
284
+ if (!configured) return true
285
+ if (!incoming) return false
286
+ if (configured === incoming) return true
287
+
288
+ // Support legacy short filters like "main" when OpenClaw uses keys like "agent:main:main".
289
+ if (!configured.includes(':') && incoming.endsWith(`:${configured}`)) return true
290
+ if (!incoming.includes(':') && configured.endsWith(`:${incoming}`)) return true
291
+ return false
292
+ }
293
+
294
+ function resolveHistorySessionCandidates(rawKeys: string[]): string[] {
295
+ const out: string[] = []
296
+ const seen = new Set<string>()
297
+
298
+ const push = (value: string) => {
299
+ const key = value.trim()
300
+ if (!key || seen.has(key)) return
301
+ seen.add(key)
302
+ out.push(key)
303
+ }
304
+
305
+ for (const raw of rawKeys) {
306
+ const key = raw.trim()
307
+ if (!key) continue
308
+ if (!key.includes(':')) {
309
+ // Prefer canonical agent-session keys first when users configure short aliases like "main".
310
+ push(`agent:main:${key}`)
311
+ push(key)
312
+ continue
313
+ }
314
+ push(key)
315
+ }
316
+ return out
317
+ }
318
+
319
+ function canonicalSessionForDuplicateKey(sessionKey: string): string {
320
+ const normalized = sessionKey.trim()
321
+ if (!normalized) return ''
322
+ if (!normalized.startsWith('agent:')) return normalized
323
+ const parts = normalized.split(':')
324
+ const tail = parts[parts.length - 1]
325
+ return tail || normalized
326
+ }
327
+
328
+ function inferMimeFromFileName(fileName?: string): string | undefined {
329
+ if (!fileName) return undefined
330
+ const ext = path.extname(fileName).toLowerCase()
331
+ switch (ext) {
332
+ case '.png': return 'image/png'
333
+ case '.jpg':
334
+ case '.jpeg': return 'image/jpeg'
335
+ case '.gif': return 'image/gif'
336
+ case '.webp': return 'image/webp'
337
+ case '.bmp': return 'image/bmp'
338
+ case '.svg': return 'image/svg+xml'
339
+ case '.txt': return 'text/plain'
340
+ case '.json': return 'application/json'
341
+ case '.pdf': return 'application/pdf'
342
+ case '.zip': return 'application/zip'
343
+ case '.mp3': return 'audio/mpeg'
344
+ case '.wav': return 'audio/wav'
345
+ case '.ogg': return 'audio/ogg'
346
+ case '.mp4': return 'video/mp4'
347
+ case '.mov': return 'video/quicktime'
348
+ case '.webm': return 'video/webm'
349
+ default: return undefined
350
+ }
351
+ }
352
+
353
+ function parseDataUrl(value: string): { mimeType?: string; base64: string } | null {
354
+ const match = /^data:([^;,]+)?;base64,([A-Za-z0-9+/=\s]+)$/i.exec(value.trim())
355
+ if (!match) return null
356
+ const mimeType = normalizeMimeType(match[1])
357
+ const base64 = match[2].replace(/\s+/g, '')
358
+ if (!base64) return null
359
+ return { mimeType, base64 }
360
+ }
361
+
362
+ function isHttpUrl(value: string): boolean {
363
+ try {
364
+ const parsed = new URL(value)
365
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:'
366
+ } catch {
367
+ return false
368
+ }
369
+ }
370
+
371
+ function deriveFileNameFromUrl(value: string): string | undefined {
372
+ try {
373
+ const parsed = new URL(value)
374
+ const fileName = path.basename(parsed.pathname || '')
375
+ return fileName && fileName !== '/' ? fileName : undefined
376
+ } catch {
377
+ return undefined
378
+ }
379
+ }
380
+
381
+ function buildAttachmentFromBuffer(buffer: Buffer, opts: {
382
+ mimeType?: string
383
+ fileName?: string
384
+ }): OutboundAttachment {
385
+ if (buffer.byteLength > MAX_INLINE_ATTACHMENT_BYTES) {
386
+ throw new Error(
387
+ `OpenClaw attachment exceeds size limit (${buffer.byteLength} > ${MAX_INLINE_ATTACHMENT_BYTES} bytes)`,
388
+ )
389
+ }
390
+
391
+ const fileName = opts.fileName?.trim() || undefined
392
+ const mimeType = (
393
+ normalizeMimeType(opts.mimeType)
394
+ || inferMimeFromFileName(fileName)
395
+ || 'application/octet-stream'
396
+ )
397
+ const type: OutboundAttachment['type'] = mimeType.startsWith('image/') ? 'image' : 'file'
398
+ return {
399
+ type,
400
+ mimeType,
401
+ fileName,
402
+ content: buffer.toString('base64'),
403
+ }
404
+ }
405
+
406
+ async function buildOutboundAttachments(options?: OutboundSendOptions): Promise<{
407
+ attachments: OutboundAttachment[]
408
+ fallbackUrl: string | null
409
+ }> {
410
+ if (!options) return { attachments: [], fallbackUrl: null }
411
+
412
+ // Explicit local file path gets first priority.
413
+ if (options.mediaPath) {
414
+ const filePath = options.mediaPath
415
+ if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`)
416
+ const content = fs.readFileSync(filePath)
417
+ const attachment = buildAttachmentFromBuffer(content, {
418
+ mimeType: options.mimeType,
419
+ fileName: options.fileName || path.basename(filePath),
420
+ })
421
+ return { attachments: [attachment], fallbackUrl: null }
422
+ }
423
+
424
+ const mediaUrl = options.imageUrl || options.fileUrl
425
+ if (!mediaUrl) return { attachments: [], fallbackUrl: null }
426
+
427
+ // Data URL can be sent as a true attachment.
428
+ const data = parseDataUrl(mediaUrl)
429
+ if (data) {
430
+ const attachment = buildAttachmentFromBuffer(Buffer.from(data.base64, 'base64'), {
431
+ mimeType: options.mimeType || data.mimeType,
432
+ fileName: options.fileName,
433
+ })
434
+ return { attachments: [attachment], fallbackUrl: null }
435
+ }
436
+
437
+ // For regular URLs, attempt inline fetch so OpenClaw receives real attachment bytes.
438
+ if (isHttpUrl(mediaUrl)) {
439
+ try {
440
+ const response = await fetch(mediaUrl)
441
+ if (!response.ok) throw new Error(`HTTP ${response.status}`)
442
+ const arrayBuffer = await response.arrayBuffer()
443
+ const attachment = buildAttachmentFromBuffer(Buffer.from(arrayBuffer), {
444
+ mimeType: options.mimeType || response.headers.get('content-type') || undefined,
445
+ fileName: options.fileName || deriveFileNameFromUrl(mediaUrl),
446
+ })
447
+ return { attachments: [attachment], fallbackUrl: null }
448
+ } catch (err) {
449
+ console.warn(`[openclaw] Failed to inline media URL, falling back to link send: ${getErrorMessage(err)}`)
450
+ return { attachments: [], fallbackUrl: mediaUrl }
451
+ }
452
+ }
453
+
454
+ return { attachments: [], fallbackUrl: null }
455
+ }
456
+
457
+ function extractInbound(payload: ChatEventPayload): InboundMessage | null {
458
+ if (!payload || typeof payload !== 'object') return null
459
+ if (payload.state && payload.state !== 'final') return null
460
+
461
+ const message = payload.message || {}
462
+ const roleRaw = typeof message.role === 'string' ? message.role.toLowerCase() : ''
463
+ const text = (
464
+ (typeof message.text === 'string' ? message.text : '')
465
+ || contentToText(message.content)
466
+ || (typeof payload.text === 'string' ? payload.text : '')
467
+ ).trim()
468
+
469
+ if (!text) return null
470
+ if (roleRaw && roleRaw !== 'user') return null
471
+
472
+ const sessionKey = (typeof payload.sessionKey === 'string' && payload.sessionKey.trim())
473
+ ? payload.sessionKey.trim()
474
+ : DEFAULT_SESSION_KEY
475
+
476
+ const senderId = (
477
+ message.senderId
478
+ || message.sender
479
+ || payload.senderId
480
+ || payload.sender
481
+ || 'unknown'
482
+ ).toString()
483
+
484
+ const senderName = (
485
+ message.senderName
486
+ || message.sender
487
+ || payload.senderName
488
+ || payload.sender
489
+ || 'User'
490
+ ).toString()
491
+
492
+ return {
493
+ platform: 'openclaw',
494
+ channelId: sessionKey,
495
+ channelName: sessionKey,
496
+ senderId,
497
+ senderName,
498
+ text,
499
+ }
500
+ }
501
+
502
+ function extractInboundFromHistory(
503
+ sessionKey: string,
504
+ message: ChatHistoryMessage,
505
+ ): { inbound: InboundMessage; dedupeKey: string } | null {
506
+ const roleRaw = typeof message.role === 'string' ? message.role.trim().toLowerCase() : ''
507
+ if (roleRaw !== 'user') return null
508
+
509
+ const text = contentToText(message.content).trim()
510
+ if (!text) return null
511
+
512
+ const senderId = (
513
+ (typeof message.senderId === 'string' && message.senderId.trim())
514
+ || (typeof message.sender === 'string' && message.sender.trim())
515
+ || 'unknown'
516
+ ).toString()
517
+
518
+ const senderName = (
519
+ (typeof message.senderName === 'string' && message.senderName.trim())
520
+ || (typeof message.sender === 'string' && message.sender.trim())
521
+ || 'User'
522
+ ).toString()
523
+
524
+ const rawTs = Number(message.timestamp)
525
+ const timestamp = Number.isFinite(rawTs) && rawTs > 0 ? Math.round(rawTs) : 0
526
+ const textHash = crypto.createHash('sha1').update(text).digest('hex').slice(0, 16)
527
+ const dedupeKey = `${sessionKey}:${timestamp}:${textHash}`
528
+
529
+ return {
530
+ inbound: {
531
+ platform: 'openclaw',
532
+ channelId: sessionKey,
533
+ channelName: sessionKey,
534
+ senderId,
535
+ senderName,
536
+ text,
537
+ },
538
+ dedupeKey,
539
+ }
540
+ }
541
+
542
+ function rememberSeenEntry(set: Set<string>, key: string, maxEntries: number): boolean {
543
+ if (!key.trim()) return false
544
+ if (set.has(key)) return false
545
+ set.add(key)
546
+ if (set.size > maxEntries) {
547
+ const first = set.values().next().value
548
+ if (first) set.delete(first)
549
+ }
550
+ return true
551
+ }
552
+
553
+ const openclaw: PlatformConnector = {
554
+ async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
555
+ const rawUrl = (connector.config.wsUrl || DEFAULT_WS_URL || '').trim()
556
+ const wsUrl = rawUrl || DEFAULT_WS_URL
557
+ if (!isSecureWsUrl(wsUrl)) {
558
+ throw new Error(
559
+ `Insecure OpenClaw WebSocket URL: "${wsUrl}". Use wss:// for remote hosts, or ws:// only on localhost/127.x/::1.`,
560
+ )
561
+ }
562
+
563
+ const defaultSessionKey = (
564
+ typeof connector.config.sessionKey === 'string' && connector.config.sessionKey.trim()
565
+ ? connector.config.sessionKey.trim()
566
+ : DEFAULT_SESSION_KEY
567
+ )
568
+ const configuredSessionFilter = typeof connector.config.sessionKey === 'string'
569
+ ? connector.config.sessionKey.trim()
570
+ : ''
571
+ const historyPollEnabled = parseBooleanLike(
572
+ (connector.config as Record<string, unknown>).historyPoll,
573
+ true,
574
+ )
575
+ const rawHistoryPollMs = Number((connector.config as Record<string, unknown>).historyPollMs)
576
+ const historyPollMs = Number.isFinite(rawHistoryPollMs) && rawHistoryPollMs > 0
577
+ ? clampNumber(Math.round(rawHistoryPollMs), MIN_CHAT_HISTORY_POLL_MS, MAX_CHAT_HISTORY_POLL_MS)
578
+ : DEFAULT_CHAT_HISTORY_POLL_MS
579
+ const rawHistoryLimit = Number((connector.config as Record<string, unknown>).historyLimit)
580
+ const historyLimit = Number.isFinite(rawHistoryLimit) && rawHistoryLimit > 0
581
+ ? clampNumber(Math.round(rawHistoryLimit), MIN_CHAT_HISTORY_LIMIT, MAX_CHAT_HISTORY_LIMIT)
582
+ : DEFAULT_CHAT_HISTORY_LIMIT
583
+ const configuredHistorySessionKey = typeof (connector.config as Record<string, unknown>).historySessionKey === 'string'
584
+ ? ((connector.config as Record<string, unknown>).historySessionKey as string).trim()
585
+ : ''
586
+ const historySessionCandidates = resolveHistorySessionCandidates([
587
+ configuredHistorySessionKey,
588
+ configuredSessionFilter,
589
+ defaultSessionKey,
590
+ ])
591
+
592
+ const clientId = 'gateway-client'
593
+ const clientMode = 'backend'
594
+ const clientDisplayName = (
595
+ typeof connector.config.clientDisplayName === 'string' && connector.config.clientDisplayName.trim()
596
+ ? connector.config.clientDisplayName.trim()
597
+ : typeof connector.config.nodeId === 'string' && connector.config.nodeId.trim()
598
+ ? connector.config.nodeId.trim()
599
+ : connector.name
600
+ )
601
+
602
+ const rawScopes: unknown = (connector.config as Record<string, unknown>).scopes
603
+ const configuredScopes = typeof rawScopes === 'string'
604
+ ? rawScopes.split(',').map((s: string) => s.trim()).filter(Boolean)
605
+ : Array.isArray(rawScopes)
606
+ ? rawScopes.map((s: unknown) => String(s).trim()).filter(Boolean)
607
+ : []
608
+ const scopes = configuredScopes.length > 0 ? configuredScopes : ['operator.read', 'operator.write']
609
+ const rawTickInterval = Number((connector.config as Record<string, unknown>).tickIntervalMs)
610
+ const configuredTickIntervalMs = Number.isFinite(rawTickInterval) && rawTickInterval > 0
611
+ ? Math.round(rawTickInterval)
612
+ : null
613
+ const rawTickWatchdog = String(
614
+ (connector.config as Record<string, unknown>).tickWatchdog ?? 'true',
615
+ ).trim().toLowerCase()
616
+ const tickWatchdogEnabled = rawTickWatchdog !== 'false' && rawTickWatchdog !== '0' && rawTickWatchdog !== 'off'
617
+
618
+ const configuredRole = typeof connector.config.role === 'string'
619
+ ? connector.config.role.trim()
620
+ : ''
621
+ const role = configuredRole || 'operator'
622
+ const identityPath = resolveIdentityPath(connector.id)
623
+ let identity = loadOrCreateIdentity(identityPath)
624
+
625
+ let ws: WebSocket | null = null
626
+ let stopped = false
627
+ let reconnectAttempt = 0
628
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
629
+ let connectTimer: ReturnType<typeof setTimeout> | null = null
630
+ let connectHelloTimer: ReturnType<typeof setTimeout> | null = null
631
+ let tickWatchdogTimer: ReturnType<typeof setInterval> | null = null
632
+ let historyPollTimer: ReturnType<typeof setInterval> | null = null
633
+ let historyPollInFlight = false
634
+ let historyPollingUnsupported = false
635
+ let connectNonce: string | null = null
636
+ let connectSent = false
637
+ let connected = false
638
+ let lastTickAtMs = 0
639
+ let tickIntervalMs = configuredTickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS
640
+
641
+ const pending = new Map<string, PendingRequest>()
642
+ const seenInbound = new Set<string>()
643
+ const seenHistoryMessages = new Set<string>()
644
+ const historyWarmSessions = new Set<string>()
645
+ const recentInboundByText = new Map<string, number>()
646
+ const historyErrorLogBySession = new Map<string, number>()
647
+
648
+ function clearPending(reason: string) {
649
+ for (const [id, p] of pending) {
650
+ clearTimeout(p.timer)
651
+ p.reject(new Error(reason))
652
+ pending.delete(id)
653
+ }
654
+ }
655
+
656
+ function clearReconnectTimer() {
657
+ if (reconnectTimer) {
658
+ clearTimeout(reconnectTimer)
659
+ reconnectTimer = null
660
+ }
661
+ }
662
+
663
+ function clearConnectTimer() {
664
+ if (connectTimer) {
665
+ clearTimeout(connectTimer)
666
+ connectTimer = null
667
+ }
668
+ }
669
+
670
+ function clearConnectHelloTimer() {
671
+ if (connectHelloTimer) {
672
+ clearTimeout(connectHelloTimer)
673
+ connectHelloTimer = null
674
+ }
675
+ }
676
+
677
+ function clearTickWatchdogTimer() {
678
+ if (tickWatchdogTimer) {
679
+ clearInterval(tickWatchdogTimer)
680
+ tickWatchdogTimer = null
681
+ }
682
+ }
683
+
684
+ function clearHistoryPollTimer() {
685
+ if (historyPollTimer) {
686
+ clearInterval(historyPollTimer)
687
+ historyPollTimer = null
688
+ }
689
+ historyPollInFlight = false
690
+ }
691
+
692
+ function startTickWatchdog() {
693
+ clearTickWatchdogTimer()
694
+ if (!tickWatchdogEnabled || tickIntervalMs <= 0) return
695
+
696
+ const toleranceMs = Math.max(
697
+ 3_000,
698
+ Math.round(tickIntervalMs * TICK_MISS_TOLERANCE_MULTIPLIER),
699
+ )
700
+ const pollMs = Math.max(
701
+ MIN_TICK_WATCHDOG_POLL_MS,
702
+ Math.min(MAX_TICK_WATCHDOG_POLL_MS, Math.round(toleranceMs / 3)),
703
+ )
704
+ lastTickAtMs = Date.now()
705
+ tickWatchdogTimer = setInterval(() => {
706
+ if (stopped || !connected) return
707
+ if (!ws || ws.readyState !== WebSocket.OPEN) return
708
+ if (lastTickAtMs <= 0) return
709
+ const delta = Date.now() - lastTickAtMs
710
+ if (delta <= toleranceMs) return
711
+ console.error(
712
+ `[openclaw] Tick missed (${delta}ms > ${toleranceMs}ms), forcing reconnect`,
713
+ )
714
+ try { ws.close(4000, 'tick missed') } catch { /* ignore */ }
715
+ }, pollMs)
716
+ // Do not keep the process alive solely for health checks.
717
+ tickWatchdogTimer.unref?.()
718
+ }
719
+
720
+ function pruneRecentInbound(now: number) {
721
+ for (const [key, ts] of recentInboundByText) {
722
+ if (now - ts > RECENT_HISTORY_DUPLICATE_WINDOW_MS) {
723
+ recentInboundByText.delete(key)
724
+ }
725
+ }
726
+ }
727
+
728
+ function markRecentInbound(inbound: InboundMessage, now: number) {
729
+ pruneRecentInbound(now)
730
+ const canonicalSession = canonicalSessionForDuplicateKey(inbound.channelId)
731
+ recentInboundByText.set(`${canonicalSession}:${inbound.text}`, now)
732
+ }
733
+
734
+ function hasRecentInboundDuplicate(inbound: InboundMessage, now: number): boolean {
735
+ pruneRecentInbound(now)
736
+ const canonicalSession = canonicalSessionForDuplicateKey(inbound.channelId)
737
+ const ts = recentInboundByText.get(`${canonicalSession}:${inbound.text}`)
738
+ return typeof ts === 'number' && now - ts <= RECENT_HISTORY_DUPLICATE_WINDOW_MS
739
+ }
740
+
741
+ function maybeLogHistoryError(sessionKey: string, message: string) {
742
+ const now = Date.now()
743
+ const previous = historyErrorLogBySession.get(sessionKey) || 0
744
+ if (now - previous < HISTORY_ERROR_LOG_INTERVAL_MS) return
745
+ historyErrorLogBySession.set(sessionKey, now)
746
+ console.warn(`[openclaw] chat.history poll failed for "${sessionKey}": ${message}`)
747
+ }
748
+
749
+ function cleanupSocket() {
750
+ clearConnectTimer()
751
+ clearConnectHelloTimer()
752
+ clearReconnectTimer()
753
+ clearTickWatchdogTimer()
754
+ clearHistoryPollTimer()
755
+ clearPending('openclaw socket closed')
756
+ if (ws) {
757
+ try { ws.close() } catch { /* ignore */ }
758
+ ws = null
759
+ }
760
+ connectSent = false
761
+ connected = false
762
+ connectNonce = null
763
+ lastTickAtMs = 0
764
+ }
765
+
766
+ function scheduleReconnect() {
767
+ if (stopped) return
768
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS)
769
+ reconnectAttempt++
770
+ console.log(`[openclaw] Reconnecting in ${delay}ms (attempt ${reconnectAttempt})`)
771
+ clearReconnectTimer()
772
+ reconnectTimer = setTimeout(() => connect(), delay)
773
+ }
774
+
775
+ function sendRaw(frame: GatewayFrame): boolean {
776
+ if (!ws || ws.readyState !== WebSocket.OPEN) return false
777
+ try {
778
+ ws.send(serializeGatewayFrame(frame))
779
+ return true
780
+ } catch {
781
+ return false
782
+ }
783
+ }
784
+
785
+ function rpcRequest(method: string, params?: Record<string, unknown>): Promise<unknown> {
786
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
787
+ return Promise.reject(new Error('openclaw not connected'))
788
+ }
789
+ const id = crypto.randomUUID()
790
+ const frame = createGatewayRequestFrame(id, method, params)
791
+ if (!sendRaw(frame)) {
792
+ return Promise.reject(new Error(`failed to send request: ${method}`))
793
+ }
794
+ return new Promise((resolve, reject) => {
795
+ const timer = setTimeout(() => {
796
+ pending.delete(id)
797
+ reject(new Error(`openclaw rpc timeout: ${method}`))
798
+ }, RPC_TIMEOUT_MS)
799
+ pending.set(id, { method, resolve, reject, timer })
800
+ })
801
+ }
802
+
803
+ async function sendChat(sessionKey: string, text: string, options?: OutboundSendOptions): Promise<void> {
804
+ const key = (sessionKey || '').trim() || defaultSessionKey
805
+ const outgoing = text.trim()
806
+ const caption = options?.caption?.trim() || ''
807
+ const { attachments, fallbackUrl } = await buildOutboundAttachments(options)
808
+
809
+ let message = outgoing || caption
810
+ if (!message && attachments.length > 0) message = 'See attached.'
811
+ if (fallbackUrl) {
812
+ message = message ? `${message}\n${fallbackUrl}` : fallbackUrl
813
+ }
814
+ if (!message && attachments.length === 0) return
815
+
816
+ const params: Record<string, unknown> = {
817
+ sessionKey: key,
818
+ message,
819
+ idempotencyKey: crypto.randomUUID(),
820
+ }
821
+ if (attachments.length > 0) params.attachments = attachments
822
+ await rpcRequest('chat.send', params)
823
+ }
824
+
825
+ function persistIdentityToken(token?: string) {
826
+ const normalized = typeof token === 'string' && token.trim() ? token.trim() : undefined
827
+ if (identity.deviceToken === normalized) return
828
+ identity = { ...identity, deviceToken: normalized }
829
+ persistIdentity(identityPath, identity)
830
+ }
831
+
832
+ function clearStaleTokenIfNeeded(reason?: string) {
833
+ const lowerReason = (reason || '').toLowerCase()
834
+ if (!lowerReason.includes('device token mismatch')) return
835
+ if (!identity.deviceToken) return
836
+ console.warn('[openclaw] Clearing stale stored device token after mismatch')
837
+ persistIdentityToken(undefined)
838
+ }
839
+
840
+ function sendConnect() {
841
+ if (!ws || ws.readyState !== WebSocket.OPEN) return
842
+ if (connectSent) return
843
+ connectSent = true
844
+ clearConnectTimer()
845
+
846
+ const configuredToken = (botToken || connector.config.token || '').trim()
847
+ const authToken = configuredToken || identity.deviceToken || undefined
848
+ const auth = authToken ? { token: authToken } : undefined
849
+ const signedAt = Date.now()
850
+ const nonce = connectNonce || undefined
851
+
852
+ const payload = buildDeviceAuthPayload({
853
+ deviceId: identity.deviceId,
854
+ clientId,
855
+ clientMode,
856
+ role,
857
+ scopes,
858
+ signedAtMs: signedAt,
859
+ token: authToken ?? null,
860
+ nonce,
861
+ })
862
+
863
+ const connectParams = {
864
+ minProtocol: PROTOCOL_VERSION,
865
+ maxProtocol: PROTOCOL_VERSION,
866
+ client: {
867
+ id: clientId,
868
+ displayName: clientDisplayName,
869
+ version: 'swarmclaw',
870
+ platform: process.platform,
871
+ mode: clientMode,
872
+ instanceId: connector.id,
873
+ },
874
+ role,
875
+ scopes,
876
+ caps: [],
877
+ commands: [],
878
+ permissions: {},
879
+ auth,
880
+ locale: 'en-US',
881
+ userAgent: 'swarmclaw-openclaw-connector/1.0',
882
+ device: {
883
+ id: identity.deviceId,
884
+ publicKey: base64UrlEncode(derivePublicKeyRaw(identity.publicKeyPem)),
885
+ signature: signDevicePayload(identity.privateKeyPem, payload),
886
+ signedAt,
887
+ nonce,
888
+ },
889
+ }
890
+
891
+ void rpcRequest('connect', connectParams)
892
+ .then((hello) => {
893
+ clearConnectHelloTimer()
894
+ connected = true
895
+ reconnectAttempt = 0
896
+ const helloObj = hello && typeof hello === 'object'
897
+ ? (hello as {
898
+ auth?: { deviceToken?: unknown }
899
+ policy?: { tickIntervalMs?: unknown }
900
+ })
901
+ : null
902
+ const deviceToken = helloObj?.auth?.deviceToken
903
+ if (typeof deviceToken === 'string' && deviceToken.trim()) {
904
+ persistIdentityToken(deviceToken)
905
+ }
906
+ const policyTick = Number(helloObj?.policy?.tickIntervalMs)
907
+ if (Number.isFinite(policyTick) && policyTick > 0) {
908
+ tickIntervalMs = Math.round(policyTick)
909
+ } else if (configuredTickIntervalMs) {
910
+ tickIntervalMs = configuredTickIntervalMs
911
+ } else {
912
+ tickIntervalMs = DEFAULT_TICK_INTERVAL_MS
913
+ }
914
+ if (tickWatchdogEnabled) startTickWatchdog()
915
+ startHistoryPoller()
916
+ console.log(`[openclaw] Connected + authenticated (${wsUrl})`)
917
+ })
918
+ .catch((err: unknown) => {
919
+ clearConnectHelloTimer()
920
+ console.error(`[openclaw] Connect handshake failed: ${getErrorMessage(err)}`)
921
+ try { ws?.close(1008, 'connect failed') } catch { /* ignore */ }
922
+ })
923
+ }
924
+
925
+ async function routeInbound(
926
+ inbound: InboundMessage,
927
+ dedupeKey: string,
928
+ source: 'event' | 'history',
929
+ ) {
930
+ if (!matchesSessionKey(configuredSessionFilter, inbound.channelId)) return
931
+ if (!rememberSeenEntry(seenInbound, dedupeKey, MAX_SEEN_CHAT_EVENTS)) return
932
+
933
+ const now = Date.now()
934
+ if (source === 'history' && hasRecentInboundDuplicate(inbound, now)) return
935
+ markRecentInbound(inbound, now)
936
+
937
+ try {
938
+ const response = await onMessage(inbound)
939
+ if (!isNoMessage(response)) await sendChat(inbound.channelId, response)
940
+ } catch (err: unknown) {
941
+ const message = getErrorMessage(err)
942
+ console.error('[openclaw] Error routing inbound chat event:', message)
943
+ await sendChat(inbound.channelId, `[Error] ${message}`)
944
+ }
945
+ }
946
+
947
+ async function pollHistorySession(sessionKey: string) {
948
+ const raw = await rpcRequest('chat.history', {
949
+ sessionKey,
950
+ limit: historyLimit,
951
+ })
952
+ const payload = raw && typeof raw === 'object'
953
+ ? (raw as ChatHistoryPayload)
954
+ : null
955
+ const rawMessages = Array.isArray(payload?.messages)
956
+ ? payload.messages as unknown[]
957
+ : []
958
+
959
+ const extracted: Array<{ inbound: InboundMessage; dedupeKey: string }> = []
960
+ for (const rawMessage of rawMessages) {
961
+ if (!rawMessage || typeof rawMessage !== 'object') continue
962
+ const parsed = extractInboundFromHistory(sessionKey, rawMessage as ChatHistoryMessage)
963
+ if (!parsed) continue
964
+ extracted.push(parsed)
965
+ }
966
+
967
+ if (!historyWarmSessions.has(sessionKey)) {
968
+ historyWarmSessions.add(sessionKey)
969
+ for (const item of extracted) {
970
+ rememberSeenEntry(seenHistoryMessages, item.dedupeKey, MAX_SEEN_HISTORY_MESSAGES)
971
+ }
972
+ return
973
+ }
974
+
975
+ for (const item of extracted) {
976
+ if (!rememberSeenEntry(seenHistoryMessages, item.dedupeKey, MAX_SEEN_HISTORY_MESSAGES)) {
977
+ continue
978
+ }
979
+ await routeInbound(item.inbound, `history:${item.dedupeKey}`, 'history')
980
+ }
981
+ }
982
+
983
+ async function pollChatHistory() {
984
+ if (stopped || !connected) return
985
+ if (historyPollingUnsupported) return
986
+ if (!historyPollEnabled) return
987
+ if (historySessionCandidates.length === 0) return
988
+ if (historyPollInFlight) return
989
+
990
+ historyPollInFlight = true
991
+ try {
992
+ for (const sessionKey of historySessionCandidates) {
993
+ try {
994
+ await pollHistorySession(sessionKey)
995
+ } catch (err: unknown) {
996
+ const message = getErrorMessage(err)
997
+ const lowered = message.toLowerCase()
998
+ if (
999
+ lowered.includes('unknown method')
1000
+ || lowered.includes('method not found')
1001
+ || lowered.includes('not implemented')
1002
+ ) {
1003
+ historyPollingUnsupported = true
1004
+ clearHistoryPollTimer()
1005
+ console.warn('[openclaw] chat.history is unavailable; disabling history polling fallback')
1006
+ return
1007
+ }
1008
+ maybeLogHistoryError(sessionKey, message)
1009
+ }
1010
+ }
1011
+ } finally {
1012
+ historyPollInFlight = false
1013
+ }
1014
+ }
1015
+
1016
+ function startHistoryPoller() {
1017
+ clearHistoryPollTimer()
1018
+ if (!historyPollEnabled) return
1019
+ if (historySessionCandidates.length === 0) return
1020
+ if (historyPollingUnsupported) return
1021
+ void pollChatHistory()
1022
+ historyPollTimer = setInterval(() => {
1023
+ void pollChatHistory()
1024
+ }, historyPollMs)
1025
+ historyPollTimer.unref?.()
1026
+ }
1027
+
1028
+ async function handleChatEvent(payload: ChatEventPayload) {
1029
+ const inbound = extractInbound(payload)
1030
+ if (!inbound) return
1031
+
1032
+ const dedupeKey = `event:${payload.runId || ''}:${payload.seq || ''}:${inbound.channelId}:${inbound.text}`
1033
+ await routeInbound(inbound, dedupeKey, 'event')
1034
+ }
1035
+
1036
+ function connect() {
1037
+ if (stopped) return
1038
+ cleanupSocket()
1039
+ console.log(`[openclaw] Connecting to ${wsUrl}`)
1040
+ ws = new WebSocket(wsUrl)
1041
+
1042
+ ws.onopen = () => {
1043
+ console.log(`[openclaw] Socket open: ${wsUrl}`)
1044
+ connectSent = false
1045
+ connected = false
1046
+ lastTickAtMs = 0
1047
+ clearConnectHelloTimer()
1048
+ connectHelloTimer = setTimeout(() => {
1049
+ if (stopped || connected) return
1050
+ console.warn(`[openclaw] Connect handshake timed out after ${CONNECT_HELLO_TIMEOUT_MS}ms`)
1051
+ try { ws?.close(4001, 'connect timeout') } catch { /* ignore */ }
1052
+ }, CONNECT_HELLO_TIMEOUT_MS)
1053
+ connectHelloTimer.unref?.()
1054
+ connectTimer = setTimeout(() => sendConnect(), CONNECT_DELAY_FALLBACK_MS)
1055
+ }
1056
+
1057
+ ws.onmessage = (event) => {
1058
+ const frame = parseGatewayFrame(event.data)
1059
+ if (!frame) {
1060
+ console.warn('[openclaw] Ignoring malformed gateway frame')
1061
+ return
1062
+ }
1063
+
1064
+ if (frame.type === 'event') {
1065
+ if (frame.event === 'connect.challenge') {
1066
+ const payload = frame.payload && typeof frame.payload === 'object'
1067
+ ? (frame.payload as { nonce?: unknown })
1068
+ : null
1069
+ const nonce = payload?.nonce
1070
+ if (typeof nonce === 'string' && nonce.trim()) connectNonce = nonce
1071
+ sendConnect()
1072
+ return
1073
+ }
1074
+ if (frame.event === 'chat') {
1075
+ void handleChatEvent((frame.payload || {}) as ChatEventPayload)
1076
+ return
1077
+ }
1078
+ if (frame.event === 'tick') {
1079
+ lastTickAtMs = Date.now()
1080
+ return
1081
+ }
1082
+ return
1083
+ }
1084
+
1085
+ if (frame.type === 'res') {
1086
+ const responseFrame = frame as GatewayResponseFrame
1087
+ const req = pending.get(responseFrame.id)
1088
+ if (!req) return
1089
+ pending.delete(responseFrame.id)
1090
+ clearTimeout(req.timer)
1091
+ if (responseFrame.ok === true) req.resolve(responseFrame.payload)
1092
+ else {
1093
+ const errorMessage = typeof responseFrame.error?.message === 'string'
1094
+ ? responseFrame.error.message
1095
+ : `${req.method} failed`
1096
+ req.reject(new Error(errorMessage))
1097
+ }
1098
+ return
1099
+ }
1100
+ }
1101
+
1102
+ ws.onclose = (event) => {
1103
+ const reason = event.reason || 'none'
1104
+ console.log(`[openclaw] Disconnected (code=${event.code}, reason=${reason})`)
1105
+ clearStaleTokenIfNeeded(reason)
1106
+ cleanupSocket()
1107
+ if (!stopped) scheduleReconnect()
1108
+ }
1109
+
1110
+ ws.onerror = () => {
1111
+ console.error('[openclaw] WebSocket error')
1112
+ }
1113
+ }
1114
+
1115
+ connect()
1116
+
1117
+ return {
1118
+ connector,
1119
+ async sendMessage(channelId, text, options) {
1120
+ if (!connected) throw new Error('openclaw connector is not connected')
1121
+ await sendChat(channelId || defaultSessionKey, text, options)
1122
+ },
1123
+ async stop() {
1124
+ stopped = true
1125
+ cleanupSocket()
1126
+ console.log('[openclaw] Connector stopped')
1127
+ },
1128
+ }
1129
+ },
1130
+ }
1131
+
1132
+ export default openclaw