@swarmclawai/swarmclaw 0.7.7 → 0.8.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 (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -39,6 +39,23 @@ function normalizeSenderId(value: string): string {
39
39
  return value.trim().toLowerCase()
40
40
  }
41
41
 
42
+ function senderIdVariants(value: string): string[] {
43
+ const normalized = normalizeSenderId(value)
44
+ if (!normalized) return []
45
+
46
+ const variants = new Set<string>([normalized])
47
+ const jidUser = normalized.split('@')[0]?.split(':')[0]?.trim()
48
+ if (jidUser) variants.add(jidUser)
49
+
50
+ const digits = normalized.replace(/[^\d]/g, '')
51
+ if (digits) {
52
+ variants.add(digits)
53
+ variants.add(`${digits}@s.whatsapp.net`)
54
+ }
55
+
56
+ return [...variants]
57
+ }
58
+
42
59
  function dedupe(items: string[]): string[] {
43
60
  const seen = new Set<string>()
44
61
  const out: string[] = []
@@ -237,15 +254,19 @@ export function isSenderAllowed(params: {
237
254
  senderId: string
238
255
  configAllowFrom?: string[]
239
256
  }): boolean {
240
- const normalized = normalizeSenderId(params.senderId)
241
- if (!normalized) return false
257
+ const senderVariants = new Set(senderIdVariants(params.senderId))
258
+ if (senderVariants.size === 0) return false
242
259
 
243
- const configSet = new Set((params.configAllowFrom || []).map((item) => normalizeSenderId(item)).filter(Boolean))
244
- if (configSet.has(normalized)) return true
260
+ const configMatches = (params.configAllowFrom || []).some((item) =>
261
+ senderIdVariants(item).some((variant) => senderVariants.has(variant)),
262
+ )
263
+ if (configMatches) return true
245
264
 
246
265
  const store = loadStore()
247
266
  const state = ensureConnectorState(store, params.connectorId)
248
- return state.allowedSenderIds.includes(normalized)
267
+ return state.allowedSenderIds.some((item) =>
268
+ senderIdVariants(item).some((variant) => senderVariants.has(variant)),
269
+ )
249
270
  }
250
271
 
251
272
  export function clearConnectorPairingState(connectorId: string): void {
@@ -24,8 +24,10 @@ export interface InboundMedia {
24
24
  export interface InboundMessage {
25
25
  platform: string
26
26
  channelId: string // platform-specific channel/chat ID
27
+ channelIdAlt?: string
27
28
  channelName?: string // human-readable name
28
29
  senderId: string // platform-specific user ID
30
+ senderIdAlt?: string
29
31
  senderName: string // display name
30
32
  text: string
31
33
  isGroup?: boolean
@@ -0,0 +1,134 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import {
4
+ buildWhatsAppTextPayloads,
5
+ buildWhatsAppInboundMessage,
6
+ isWhatsAppInboundAllowed,
7
+ normalizeWhatsAppAudioForSend,
8
+ normalizeWhatsAppIdentifier,
9
+ } from './whatsapp'
10
+
11
+ test('buildWhatsAppTextPayloads disables link previews for text sends', () => {
12
+ const payloads = buildWhatsAppTextPayloads('See https://example.com for details')
13
+
14
+ assert.deepEqual(payloads, [
15
+ { text: 'See https://example.com for details', linkPreview: null },
16
+ ])
17
+ })
18
+
19
+ test('buildWhatsAppTextPayloads chunks long messages and disables previews for each chunk', () => {
20
+ const payloads = buildWhatsAppTextPayloads('x'.repeat(4500))
21
+
22
+ assert.equal(payloads.length, 2)
23
+ assert.equal(payloads[0].text.length, 4000)
24
+ assert.equal(payloads[1].text.length, 500)
25
+ assert.equal(payloads[0].linkPreview, null)
26
+ assert.equal(payloads[1].linkPreview, null)
27
+ })
28
+
29
+ test('normalizeWhatsAppIdentifier strips jid wrappers and device suffixes', () => {
30
+ assert.equal(normalizeWhatsAppIdentifier('+1 (555) 000-1111@s.whatsapp.net'), '15550001111')
31
+ assert.equal(normalizeWhatsAppIdentifier('15550001111:7@s.whatsapp.net'), '15550001111')
32
+ assert.equal(normalizeWhatsAppIdentifier('199900000001@lid'), '199900000001')
33
+ })
34
+
35
+ test('isWhatsAppInboundAllowed matches allow-list entries against alt phone JIDs', () => {
36
+ const allowed = ['15550001111']
37
+ const msg = {
38
+ key: {
39
+ remoteJid: '199900000001@lid',
40
+ remoteJidAlt: '15550001111@s.whatsapp.net',
41
+ },
42
+ } as any
43
+
44
+ assert.equal(isWhatsAppInboundAllowed({ allowedJids: allowed, msg }), true)
45
+ assert.equal(isWhatsAppInboundAllowed({ allowedJids: ['15559990000'], msg }), false)
46
+ })
47
+
48
+ test('buildWhatsAppInboundMessage includes modern WhatsApp metadata', () => {
49
+ const inbound = buildWhatsAppInboundMessage({
50
+ msg: {
51
+ key: {
52
+ remoteJid: '199900000001@lid',
53
+ remoteJidAlt: '15550001111@s.whatsapp.net',
54
+ id: 'wamid-1',
55
+ },
56
+ pushName: 'Alice',
57
+ message: {
58
+ extendedTextMessage: {
59
+ text: 'Hey there',
60
+ contextInfo: {
61
+ stanzaId: 'quoted-1',
62
+ mentionedJid: ['bot@s.whatsapp.net'],
63
+ },
64
+ },
65
+ },
66
+ } as any,
67
+ selfJids: ['bot@s.whatsapp.net'],
68
+ })
69
+
70
+ assert.ok(inbound)
71
+ assert.equal(inbound?.channelId, '199900000001@lid')
72
+ assert.equal(inbound?.channelIdAlt, '15550001111@s.whatsapp.net')
73
+ assert.equal(inbound?.senderId, '199900000001@lid')
74
+ assert.equal(inbound?.senderIdAlt, '15550001111@s.whatsapp.net')
75
+ assert.equal(inbound?.messageId, 'wamid-1')
76
+ assert.equal(inbound?.replyToMessageId, 'quoted-1')
77
+ assert.equal(inbound?.mentionsBot, true)
78
+ assert.equal(inbound?.isGroup, false)
79
+ assert.equal(inbound?.text, 'Hey there')
80
+ })
81
+
82
+ test('normalizeWhatsAppAudioForSend transcodes mp3 voice notes to Android-safe opus/ogg', () => {
83
+ let transcodeCalls = 0
84
+ const converted = normalizeWhatsAppAudioForSend({
85
+ buffer: Buffer.from('mp3-audio'),
86
+ mimeType: 'audio/mpeg',
87
+ fileName: 'voice-note.mp3',
88
+ ptt: true,
89
+ transcode: ({ buffer, mimeType, fileName }) => {
90
+ transcodeCalls += 1
91
+ assert.equal(buffer.toString(), 'mp3-audio')
92
+ assert.equal(mimeType, 'audio/mpeg')
93
+ assert.equal(fileName, 'voice-note.mp3')
94
+ return {
95
+ buffer: Buffer.from('ogg-opus-audio'),
96
+ mimeType: 'audio/ogg; codecs=opus',
97
+ }
98
+ },
99
+ })
100
+
101
+ assert.equal(transcodeCalls, 1)
102
+ assert.equal(converted.buffer.toString(), 'ogg-opus-audio')
103
+ assert.equal(converted.mimeType, 'audio/ogg; codecs=opus')
104
+ })
105
+
106
+ test('normalizeWhatsAppAudioForSend keeps existing ogg voice notes unchanged', () => {
107
+ const converted = normalizeWhatsAppAudioForSend({
108
+ buffer: Buffer.from('already-ogg'),
109
+ mimeType: 'audio/ogg',
110
+ fileName: 'voice-note.ogg',
111
+ ptt: true,
112
+ transcode: () => {
113
+ throw new Error('transcode should not be called')
114
+ },
115
+ })
116
+
117
+ assert.equal(converted.buffer.toString(), 'already-ogg')
118
+ assert.equal(converted.mimeType, 'audio/ogg; codecs=opus')
119
+ })
120
+
121
+ test('normalizeWhatsAppAudioForSend leaves normal audio attachments alone when ptt is disabled', () => {
122
+ const converted = normalizeWhatsAppAudioForSend({
123
+ buffer: Buffer.from('music'),
124
+ mimeType: 'audio/mpeg',
125
+ fileName: 'music.mp3',
126
+ ptt: false,
127
+ transcode: () => {
128
+ throw new Error('transcode should not be called')
129
+ },
130
+ })
131
+
132
+ assert.equal(converted.buffer.toString(), 'music')
133
+ assert.equal(converted.mimeType, 'audio/mpeg')
134
+ })
@@ -4,31 +4,264 @@ import makeWASocket, {
4
4
  fetchLatestBaileysVersion,
5
5
  normalizeMessageContent,
6
6
  downloadMediaMessage,
7
+ type WAMessage,
7
8
  } from '@whiskeysockets/baileys'
8
9
  import QRCode from 'qrcode'
9
10
  import path from 'path'
10
11
  import fs from 'fs'
12
+ import os from 'os'
13
+ import { spawnSync } from 'child_process'
11
14
  import type { Connector } from '@/types'
12
15
  import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
13
16
  import { saveInboundMediaBuffer, mimeFromPath, isImageMime, isAudioMime } from './media'
14
- import { isNoMessage } from './manager'
17
+ import { isNoMessage, recordConnectorOutboundDelivery } from './manager'
15
18
  import { formatTextForWhatsApp } from './whatsapp-text'
16
19
 
17
20
  import { DATA_DIR } from '../data-dir'
21
+ import { loadConnectors } from '../storage'
18
22
 
19
23
  const AUTH_DIR = path.join(DATA_DIR, 'whatsapp-auth')
20
24
  const INBOUND_DEDUPE_TTL_MS = 2 * 60 * 1000
25
+ const WHATSAPP_SINGLE_MESSAGE_MAX = 4096
26
+ const WHATSAPP_TEXT_CHUNK_MAX = 4000
27
+ const WHATSAPP_VOICE_NOTE_MIME = 'audio/ogg; codecs=opus'
28
+ const WHATSAPP_VOICE_NOTE_EXTS = new Set(['.ogg', '.opus'])
29
+
30
+ let cachedFfmpegBinary: string | null | undefined
31
+
32
+ export function buildWhatsAppTextPayloads(text: string): Array<{ text: string; linkPreview: null }> {
33
+ const chunks = text.length <= WHATSAPP_SINGLE_MESSAGE_MAX
34
+ ? [text]
35
+ : (text.match(new RegExp(`[\\s\\S]{1,${WHATSAPP_TEXT_CHUNK_MAX}}`, 'g')) || [text])
36
+ return chunks.map((chunk) => ({ text: chunk, linkPreview: null }))
37
+ }
38
+
39
+ function normalizeMimeType(mimeType?: string): string {
40
+ return String(mimeType || '').toLowerCase().split(';')[0].trim()
41
+ }
42
+
43
+ function looksLikeWhatsAppVoiceNote(params: { mimeType?: string; fileName?: string }): boolean {
44
+ const mime = normalizeMimeType(params.mimeType)
45
+ if (mime === 'audio/ogg' || mime === 'audio/opus') return true
46
+ const ext = path.extname(String(params.fileName || '')).toLowerCase()
47
+ return WHATSAPP_VOICE_NOTE_EXTS.has(ext)
48
+ }
49
+
50
+ function resolveAudioExt(params: { mimeType?: string; fileName?: string }): string {
51
+ const ext = path.extname(String(params.fileName || '')).toLowerCase()
52
+ if (ext) return ext
53
+ const mime = normalizeMimeType(params.mimeType)
54
+ if (mime === 'audio/mpeg' || mime === 'audio/mp3') return '.mp3'
55
+ if (mime === 'audio/wav' || mime === 'audio/x-wav') return '.wav'
56
+ if (mime === 'audio/mp4' || mime === 'audio/m4a' || mime === 'audio/x-m4a') return '.m4a'
57
+ if (mime === 'audio/ogg' || mime === 'audio/opus') return '.ogg'
58
+ return '.bin'
59
+ }
60
+
61
+ function resolveFfmpegBinary(): string | null {
62
+ if (cachedFfmpegBinary !== undefined) return cachedFfmpegBinary
63
+ const candidates = ['ffmpeg', '/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
64
+ for (const candidate of candidates) {
65
+ const probe = spawnSync(candidate, ['-version'], { encoding: 'utf-8', timeout: 2_000 })
66
+ if ((probe.status ?? 1) === 0) {
67
+ cachedFfmpegBinary = candidate
68
+ return candidate
69
+ }
70
+ }
71
+ cachedFfmpegBinary = null
72
+ return null
73
+ }
74
+
75
+ function transcodeToWhatsAppVoiceNote(params: {
76
+ buffer: Buffer
77
+ mimeType?: string
78
+ fileName?: string
79
+ }): { buffer: Buffer; mimeType: string } | null {
80
+ const ffmpeg = resolveFfmpegBinary()
81
+ if (!ffmpeg) return null
82
+
83
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-wa-voice-'))
84
+ const inputPath = path.join(tempDir, `input${resolveAudioExt(params)}`)
85
+ const outputPath = path.join(tempDir, 'voice-note.ogg')
86
+
87
+ try {
88
+ fs.writeFileSync(inputPath, params.buffer)
89
+ const result = spawnSync(ffmpeg, [
90
+ '-y',
91
+ '-i', inputPath,
92
+ '-vn',
93
+ '-ac', '1',
94
+ '-ar', '48000',
95
+ '-c:a', 'libopus',
96
+ '-b:a', '32k',
97
+ '-vbr', 'on',
98
+ '-compression_level', '10',
99
+ '-application', 'voip',
100
+ '-f', 'ogg',
101
+ outputPath,
102
+ ], {
103
+ encoding: 'utf-8',
104
+ timeout: 20_000,
105
+ })
106
+ if ((result.status ?? 1) !== 0 || !fs.existsSync(outputPath)) {
107
+ const stderr = (result.stderr || '').trim()
108
+ console.warn(`[whatsapp] Failed to transcode voice note to opus/ogg${stderr ? `: ${stderr}` : ''}`)
109
+ return null
110
+ }
111
+ return {
112
+ buffer: fs.readFileSync(outputPath),
113
+ mimeType: WHATSAPP_VOICE_NOTE_MIME,
114
+ }
115
+ } finally {
116
+ fs.rmSync(tempDir, { recursive: true, force: true })
117
+ }
118
+ }
119
+
120
+ export function normalizeWhatsAppAudioForSend(params: {
121
+ buffer: Buffer
122
+ mimeType?: string
123
+ fileName?: string
124
+ ptt?: boolean
125
+ transcode?: (params: { buffer: Buffer; mimeType?: string; fileName?: string }) => { buffer: Buffer; mimeType: string } | null
126
+ }): { buffer: Buffer; mimeType: string } {
127
+ const mimeType = params.mimeType || 'application/octet-stream'
128
+ if (params.ptt === false) return { buffer: params.buffer, mimeType }
129
+ if (looksLikeWhatsAppVoiceNote(params)) {
130
+ return {
131
+ buffer: params.buffer,
132
+ mimeType: normalizeMimeType(mimeType) === 'audio/ogg' ? WHATSAPP_VOICE_NOTE_MIME : mimeType,
133
+ }
134
+ }
135
+ const transcode = params.transcode || transcodeToWhatsAppVoiceNote
136
+ const converted = transcode({
137
+ buffer: params.buffer,
138
+ mimeType: params.mimeType,
139
+ fileName: params.fileName,
140
+ })
141
+ return converted || { buffer: params.buffer, mimeType }
142
+ }
143
+
144
+ function jidUserPart(raw: string): string {
145
+ const trimmed = String(raw || '').trim().toLowerCase()
146
+ if (!trimmed) return ''
147
+ const withoutServer = trimmed.includes('@') ? trimmed.split('@')[0] : trimmed
148
+ return withoutServer.split(':')[0]
149
+ }
21
150
 
22
- /** Normalize a phone number for JID matching strip leading 0 or + */
23
- function normalizeNumber(num: string): string {
24
- let n = num.replace(/[\s\-()]/g, '')
151
+ /** Normalize a phone number or JID user part for inbound matching */
152
+ export function normalizeWhatsAppIdentifier(raw: string): string {
153
+ let n = jidUserPart(raw).replace(/[\s\-()]/g, '')
25
154
  // UK local: 07xxx → 447xxx
26
155
  if (n.startsWith('0') && n.length >= 10) {
27
156
  n = '44' + n.slice(1)
28
157
  }
29
158
  // Strip leading +
30
159
  if (n.startsWith('+')) n = n.slice(1)
31
- return n
160
+ return n.replace(/[^a-z0-9]/g, '')
161
+ }
162
+
163
+ function parseAllowedIdentifiers(raw: unknown): string[] | null {
164
+ if (typeof raw !== 'string') return null
165
+ const out = raw
166
+ .split(',')
167
+ .map((entry) => normalizeWhatsAppIdentifier(entry))
168
+ .filter(Boolean)
169
+ return out.length ? out : null
170
+ }
171
+
172
+ function messageContextInfo(content: any): any {
173
+ return content?.extendedTextMessage?.contextInfo
174
+ || content?.imageMessage?.contextInfo
175
+ || content?.videoMessage?.contextInfo
176
+ || content?.documentMessage?.contextInfo
177
+ || content?.audioMessage?.contextInfo
178
+ || content?.stickerMessage?.contextInfo
179
+ || null
180
+ }
181
+
182
+ export function collectWhatsAppAddressCandidates(msg: Pick<WAMessage, 'key'>): string[] {
183
+ const key = msg?.key || {}
184
+ const raw = [
185
+ key.remoteJid,
186
+ key.remoteJidAlt,
187
+ key.participant,
188
+ key.participantAlt,
189
+ ]
190
+ const normalized = raw
191
+ .map((entry) => normalizeWhatsAppIdentifier(String(entry || '')))
192
+ .filter(Boolean)
193
+ return Array.from(new Set(normalized))
194
+ }
195
+
196
+ export function isWhatsAppInboundAllowed(params: {
197
+ allowedJids: string[] | null
198
+ msg: Pick<WAMessage, 'key'>
199
+ isSelfChat?: boolean
200
+ }): boolean {
201
+ if (!params.allowedJids?.length || params.isSelfChat) return true
202
+ const candidates = collectWhatsAppAddressCandidates(params.msg)
203
+ return candidates.some((candidate) =>
204
+ params.allowedJids!.some((allowed) => candidate.includes(allowed) || allowed.includes(candidate)),
205
+ )
206
+ }
207
+
208
+ export function buildWhatsAppInboundMessage(params: {
209
+ msg: WAMessage
210
+ media?: NonNullable<InboundMessage['media']>
211
+ selfJids?: string[]
212
+ }): InboundMessage | null {
213
+ const { msg } = params
214
+ const media = Array.isArray(params.media) ? params.media : []
215
+ const jid = msg.key.remoteJid || ''
216
+ if (!jid) return null
217
+
218
+ const content: any = normalizeMessageContent(msg.message as any) || msg.message || {}
219
+ const text = content?.conversation
220
+ || content?.extendedTextMessage?.text
221
+ || content?.imageMessage?.caption
222
+ || content?.videoMessage?.caption
223
+ || content?.documentMessage?.caption
224
+ || ''
225
+ if (!text && media.length === 0) return null
226
+
227
+ const isGroup = jid.endsWith('@g.us')
228
+ const channelIdAlt = typeof msg.key.remoteJidAlt === 'string' && msg.key.remoteJidAlt.trim()
229
+ ? msg.key.remoteJidAlt.trim()
230
+ : undefined
231
+ const senderId = isGroup
232
+ ? (msg.key.participant || jid)
233
+ : jid
234
+ const senderIdAlt = isGroup
235
+ ? (msg.key.participantAlt || undefined)
236
+ : channelIdAlt
237
+ const senderName = msg.pushName || jidUserPart(senderIdAlt || senderId) || jidUserPart(jid)
238
+ const contextInfo = messageContextInfo(content)
239
+ const mentionedJids = Array.isArray(contextInfo?.mentionedJid)
240
+ ? contextInfo.mentionedJid.map((entry: unknown) => String(entry || '')).filter(Boolean)
241
+ : []
242
+ const selfIds = Array.isArray(params.selfJids) ? params.selfJids.map((entry: unknown) => normalizeWhatsAppIdentifier(String(entry || ''))).filter(Boolean) : []
243
+ const mentionsBot = selfIds.length > 0
244
+ ? mentionedJids.some((entry: string) => selfIds.includes(normalizeWhatsAppIdentifier(entry)))
245
+ : false
246
+
247
+ return {
248
+ platform: 'whatsapp',
249
+ channelId: jid,
250
+ channelIdAlt,
251
+ channelName: isGroup ? (channelIdAlt || jid) : `DM:${senderName}`,
252
+ senderId,
253
+ senderIdAlt,
254
+ senderName,
255
+ text: text || '(media message)',
256
+ isGroup,
257
+ messageId: msg.key.id || undefined,
258
+ imageUrl: media.find((item) => item.type === 'image')?.url,
259
+ media,
260
+ replyToMessageId: typeof contextInfo?.stanzaId === 'string' && contextInfo.stanzaId.trim()
261
+ ? contextInfo.stanzaId.trim()
262
+ : undefined,
263
+ mentionsBot,
264
+ }
32
265
  }
33
266
 
34
267
  /** Check if auth directory has saved credentials */
@@ -96,7 +329,17 @@ const whatsapp: PlatformConnector = {
96
329
  sent = await sock.sendMessage(channelId, { document: buf, fileName: fName, mimetype: mime, caption })
97
330
  }
98
331
  } else if (isAudioMime(mime)) {
99
- sent = await sock.sendMessage(channelId, { audio: buf, mimetype: mime, ptt: options.ptt !== false })
332
+ const normalizedAudio = normalizeWhatsAppAudioForSend({
333
+ buffer: buf,
334
+ mimeType: mime,
335
+ fileName: fName,
336
+ ptt: options.ptt !== false,
337
+ })
338
+ sent = await sock.sendMessage(channelId, {
339
+ audio: normalizedAudio.buffer,
340
+ mimetype: normalizedAudio.mimeType,
341
+ ptt: options.ptt !== false,
342
+ })
100
343
  } else {
101
344
  sent = await sock.sendMessage(channelId, { document: buf, fileName: fName, mimetype: mime, caption })
102
345
  }
@@ -123,10 +366,9 @@ const whatsapp: PlatformConnector = {
123
366
  }
124
367
 
125
368
  const payload = normalizedText || normalizedCaption || ''
126
- const chunks = payload.length <= 4096 ? [payload] : (payload.match(/[\s\S]{1,4000}/g) || [payload])
127
369
  let lastMessageId: string | undefined
128
- for (const chunk of chunks) {
129
- const sent = await sock.sendMessage(channelId, { text: chunk })
370
+ for (const chunk of buildWhatsAppTextPayloads(payload)) {
371
+ const sent = await sock.sendMessage(channelId, chunk)
130
372
  if (sent?.key?.id) {
131
373
  lastMessageId = sent.key.id
132
374
  sentMessageIds.add(sent.key.id)
@@ -142,18 +384,9 @@ const whatsapp: PlatformConnector = {
142
384
  },
143
385
  }
144
386
 
145
- // Normalize allowed JIDs for matching
146
- const allowedJids = connector.config.allowedJids
147
- ? connector.config.allowedJids.split(',').map((s) => normalizeNumber(s.trim())).filter(Boolean)
148
- : null
149
-
150
387
  // Track message IDs sent by the bot to avoid infinite loops in self-chat
151
388
  const sentMessageIds = new Set<string>()
152
389
 
153
- if (allowedJids) {
154
- console.log(`[whatsapp] Allowed JIDs (normalized): ${allowedJids.join(', ')}`)
155
- }
156
-
157
390
  const startSocket = () => {
158
391
  // Close previous socket to prevent stale event handlers
159
392
  if (sock) {
@@ -284,28 +517,22 @@ const whatsapp: PlatformConnector = {
284
517
  if (msg.key.fromMe && !isSelfChat) continue
285
518
 
286
519
  const jid = msg.key.remoteJid || ''
520
+ const latestConnector = (loadConnectors()[connector.id] as Connector | undefined) || connector
521
+ const allowedJids = parseAllowedIdentifiers(latestConnector.config?.allowedJids)
287
522
 
288
523
  // Match allowed JIDs using normalized numbers
289
524
  // Self-chat always passes the filter (it's the bot's own account)
290
- if (allowedJids && !isSelfChat) {
291
- const jidNumber = jid.split('@')[0]
292
- const matched = allowedJids.some((n) => jidNumber.includes(n) || n.includes(jidNumber))
293
- console.log(`[whatsapp] JID filter: jidNumber=${jidNumber}, allowedJids=${allowedJids.join(',')}, matched=${matched}`)
525
+ if (allowedJids?.length && !isSelfChat) {
526
+ const matched = isWhatsAppInboundAllowed({ allowedJids, msg, isSelfChat })
527
+ console.log(`[whatsapp] JID filter: candidates=${collectWhatsAppAddressCandidates(msg).join(',')}, allowedJids=${allowedJids.join(',')}, matched=${matched}`)
294
528
  if (!matched) {
295
529
  console.log(`[whatsapp] Skipping message from non-allowed JID: ${jid}`)
296
530
  continue
297
531
  }
298
532
  }
299
533
 
300
- const content: any = normalizeMessageContent(msg.message as any) || msg.message || {}
301
- const text = content?.conversation
302
- || content?.extendedTextMessage?.text
303
- || content?.imageMessage?.caption
304
- || content?.videoMessage?.caption
305
- || content?.documentMessage?.caption
306
- || ''
307
-
308
534
  const media: NonNullable<InboundMessage['media']> = []
535
+ const content: any = normalizeMessageContent(msg.message as any) || msg.message || {}
309
536
  const mediaCandidate:
310
537
  | { kind: 'image' | 'video' | 'audio' | 'document' | 'file'; payload: any }
311
538
  | null =
@@ -342,23 +569,14 @@ const whatsapp: PlatformConnector = {
342
569
  }
343
570
  }
344
571
 
345
- if (!text && media.length === 0) continue
346
-
347
- const senderName = msg.pushName || jid.split('@')[0]
348
- const isGroup = jid.endsWith('@g.us')
349
-
350
- console.log(`[whatsapp] Message from ${senderName} (${jid}): ${text.slice(0, 80)}`)
572
+ const selfJids = [
573
+ sock?.user?.id || '',
574
+ sock?.user?.lid || '',
575
+ ].filter(Boolean)
576
+ const inbound = buildWhatsAppInboundMessage({ msg, media, selfJids })
577
+ if (!inbound) continue
351
578
 
352
- const inbound: InboundMessage = {
353
- platform: 'whatsapp',
354
- channelId: jid,
355
- channelName: isGroup ? jid : `DM:${senderName}`,
356
- senderId: msg.key.participant || jid,
357
- senderName,
358
- text: text || '(media message)',
359
- imageUrl: media.find((m) => m.type === 'image')?.url,
360
- media,
361
- }
579
+ console.log(`[whatsapp] Message from ${inbound.senderName} (${jid}): ${inbound.text.slice(0, 80)}`)
362
580
 
363
581
  try {
364
582
  await sock!.sendPresenceUpdate('composing', jid)
@@ -366,7 +584,13 @@ const whatsapp: PlatformConnector = {
366
584
  await sock!.sendPresenceUpdate('paused', jid)
367
585
 
368
586
  if (!isNoMessage(response)) {
369
- await instance.sendMessage?.(jid, response)
587
+ const sent = await instance.sendMessage?.(jid, response)
588
+ await recordConnectorOutboundDelivery({
589
+ connectorId: connector.id,
590
+ inbound,
591
+ messageId: sent?.messageId,
592
+ state: 'sent',
593
+ })
370
594
  }
371
595
  } catch (err: any) {
372
596
  console.error(`[whatsapp] Error handling message:`, err.message)
@@ -204,7 +204,7 @@ export function consolidateToMemory(
204
204
  if (hasDecision || hasKeyFact || hasResult) {
205
205
  // Create a concise summary (first 500 chars)
206
206
  const summary = text.length > 500 ? text.slice(0, 500) + '...' : text
207
- const category = hasDecision ? 'decision' : hasResult ? 'result' : 'note'
207
+ const category = 'working/scratch'
208
208
  const title = `[auto-consolidated] ${text.slice(0, 60).replace(/\n/g, ' ')}`
209
209
 
210
210
  db.add({
@@ -213,6 +213,11 @@ export function consolidateToMemory(
213
213
  category,
214
214
  title,
215
215
  content: summary,
216
+ metadata: {
217
+ origin: 'auto-consolidated',
218
+ kind: hasDecision ? 'decision' : hasResult ? 'result' : 'note',
219
+ tier: 'working',
220
+ },
216
221
  })
217
222
  stored++
218
223
  }