@swarmclawai/swarmclaw 0.4.0 → 0.5.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 (209) hide show
  1. package/README.md +21 -4
  2. package/bin/server-cmd.js +28 -19
  3. package/next.config.ts +13 -0
  4. package/package.json +3 -1
  5. package/src/app/api/agents/[id]/route.ts +39 -22
  6. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  7. package/src/app/api/agents/route.ts +3 -2
  8. package/src/app/api/agents/trash/route.ts +44 -0
  9. package/src/app/api/clawhub/install/route.ts +2 -2
  10. package/src/app/api/connectors/[id]/route.ts +17 -7
  11. package/src/app/api/connectors/[id]/webhook/route.ts +103 -0
  12. package/src/app/api/connectors/route.ts +6 -3
  13. package/src/app/api/credentials/[id]/route.ts +2 -1
  14. package/src/app/api/credentials/route.ts +2 -2
  15. package/src/app/api/documents/route.ts +2 -2
  16. package/src/app/api/files/serve/route.ts +8 -0
  17. package/src/app/api/knowledge/[id]/route.ts +5 -4
  18. package/src/app/api/knowledge/upload/route.ts +2 -2
  19. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  20. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  21. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  22. package/src/app/api/mcp-servers/route.ts +2 -2
  23. package/src/app/api/memory/[id]/route.ts +9 -8
  24. package/src/app/api/memory/route.ts +2 -2
  25. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  26. package/src/app/api/openclaw/agent-files/route.ts +57 -0
  27. package/src/app/api/openclaw/approvals/route.ts +46 -0
  28. package/src/app/api/openclaw/config-sync/route.ts +33 -0
  29. package/src/app/api/openclaw/cron/route.ts +52 -0
  30. package/src/app/api/openclaw/directory/route.ts +27 -0
  31. package/src/app/api/openclaw/discover/route.ts +62 -0
  32. package/src/app/api/openclaw/dotenv-keys/route.ts +18 -0
  33. package/src/app/api/openclaw/exec-config/route.ts +41 -0
  34. package/src/app/api/openclaw/gateway/route.ts +72 -0
  35. package/src/app/api/openclaw/history/route.ts +109 -0
  36. package/src/app/api/openclaw/media/route.ts +53 -0
  37. package/src/app/api/openclaw/models/route.ts +12 -0
  38. package/src/app/api/openclaw/permissions/route.ts +39 -0
  39. package/src/app/api/openclaw/sandbox-env/route.ts +69 -0
  40. package/src/app/api/openclaw/skills/install/route.ts +32 -0
  41. package/src/app/api/openclaw/skills/remove/route.ts +24 -0
  42. package/src/app/api/openclaw/skills/route.ts +82 -0
  43. package/src/app/api/openclaw/sync/route.ts +31 -0
  44. package/src/app/api/orchestrator/run/route.ts +2 -2
  45. package/src/app/api/projects/[id]/route.ts +55 -0
  46. package/src/app/api/projects/route.ts +27 -0
  47. package/src/app/api/providers/[id]/models/route.ts +2 -1
  48. package/src/app/api/providers/[id]/route.ts +13 -15
  49. package/src/app/api/providers/route.ts +2 -2
  50. package/src/app/api/schedules/[id]/route.ts +16 -18
  51. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  52. package/src/app/api/schedules/route.ts +2 -2
  53. package/src/app/api/secrets/[id]/route.ts +16 -17
  54. package/src/app/api/secrets/route.ts +2 -2
  55. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  56. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  57. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  58. package/src/app/api/sessions/[id]/edit-resend/route.ts +22 -0
  59. package/src/app/api/sessions/[id]/fork/route.ts +44 -0
  60. package/src/app/api/sessions/[id]/messages/route.ts +20 -2
  61. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  62. package/src/app/api/sessions/[id]/route.ts +14 -4
  63. package/src/app/api/sessions/route.ts +8 -4
  64. package/src/app/api/skills/[id]/route.ts +23 -21
  65. package/src/app/api/skills/import/route.ts +2 -2
  66. package/src/app/api/skills/route.ts +2 -2
  67. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  68. package/src/app/api/tasks/[id]/route.ts +6 -5
  69. package/src/app/api/tasks/route.ts +2 -2
  70. package/src/app/api/tts/stream/route.ts +48 -0
  71. package/src/app/api/upload/route.ts +2 -2
  72. package/src/app/api/uploads/[filename]/route.ts +4 -1
  73. package/src/app/api/webhooks/[id]/route.ts +29 -31
  74. package/src/app/api/webhooks/route.ts +2 -2
  75. package/src/app/globals.css +14 -0
  76. package/src/app/layout.tsx +5 -20
  77. package/src/app/page.tsx +3 -24
  78. package/src/cli/index.js +60 -0
  79. package/src/cli/index.ts +1 -1
  80. package/src/cli/spec.js +42 -0
  81. package/src/components/agents/agent-avatar.tsx +45 -0
  82. package/src/components/agents/agent-card.tsx +19 -5
  83. package/src/components/agents/agent-chat-list.tsx +31 -24
  84. package/src/components/agents/agent-files-editor.tsx +185 -0
  85. package/src/components/agents/agent-list.tsx +84 -3
  86. package/src/components/agents/agent-sheet.tsx +147 -14
  87. package/src/components/agents/cron-job-form.tsx +137 -0
  88. package/src/components/agents/exec-config-panel.tsx +147 -0
  89. package/src/components/agents/inspector-panel.tsx +310 -0
  90. package/src/components/agents/openclaw-skills-panel.tsx +230 -0
  91. package/src/components/agents/permission-preset-selector.tsx +79 -0
  92. package/src/components/agents/personality-builder.tsx +111 -0
  93. package/src/components/agents/sandbox-env-panel.tsx +72 -0
  94. package/src/components/agents/skill-install-dialog.tsx +102 -0
  95. package/src/components/agents/trash-list.tsx +109 -0
  96. package/src/components/chat/chat-area.tsx +41 -6
  97. package/src/components/chat/chat-header.tsx +305 -29
  98. package/src/components/chat/chat-preview-panel.tsx +113 -0
  99. package/src/components/chat/exec-approval-card.tsx +89 -0
  100. package/src/components/chat/message-bubble.tsx +218 -36
  101. package/src/components/chat/message-list.tsx +135 -31
  102. package/src/components/chat/streaming-bubble.tsx +59 -10
  103. package/src/components/chat/suggestions-bar.tsx +74 -0
  104. package/src/components/chat/thinking-indicator.tsx +20 -6
  105. package/src/components/chat/tool-call-bubble.tsx +98 -19
  106. package/src/components/chat/tool-request-banner.tsx +20 -2
  107. package/src/components/chat/trace-block.tsx +103 -0
  108. package/src/components/chat/voice-overlay.tsx +80 -0
  109. package/src/components/connectors/connector-list.tsx +6 -2
  110. package/src/components/connectors/connector-sheet.tsx +31 -7
  111. package/src/components/layout/app-layout.tsx +47 -25
  112. package/src/components/projects/project-list.tsx +123 -0
  113. package/src/components/projects/project-sheet.tsx +135 -0
  114. package/src/components/schedules/schedule-list.tsx +3 -1
  115. package/src/components/sessions/new-session-sheet.tsx +6 -6
  116. package/src/components/sessions/session-card.tsx +1 -1
  117. package/src/components/sessions/session-list.tsx +7 -7
  118. package/src/components/settings/gateway-connection-panel.tsx +278 -0
  119. package/src/components/shared/avatar.tsx +13 -2
  120. package/src/components/shared/connector-platform-icon.tsx +4 -0
  121. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  122. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  123. package/src/components/shared/settings/section-web-search.tsx +56 -0
  124. package/src/components/shared/settings/settings-page.tsx +74 -0
  125. package/src/components/skills/skill-list.tsx +2 -1
  126. package/src/components/tasks/task-board.tsx +1 -1
  127. package/src/components/tasks/task-list.tsx +5 -2
  128. package/src/components/tasks/task-sheet.tsx +12 -12
  129. package/src/hooks/use-continuous-speech.ts +181 -0
  130. package/src/hooks/use-openclaw-gateway.ts +63 -0
  131. package/src/hooks/use-view-router.ts +52 -0
  132. package/src/hooks/use-voice-conversation.ts +80 -0
  133. package/src/lib/id.ts +6 -0
  134. package/src/lib/notification-sounds.ts +58 -0
  135. package/src/lib/personality-parser.ts +97 -0
  136. package/src/lib/projects.ts +13 -0
  137. package/src/lib/provider-sets.ts +5 -0
  138. package/src/lib/providers/anthropic.ts +14 -1
  139. package/src/lib/providers/index.ts +6 -0
  140. package/src/lib/providers/ollama.ts +9 -1
  141. package/src/lib/providers/openai.ts +9 -1
  142. package/src/lib/providers/openclaw.ts +28 -2
  143. package/src/lib/runtime-loop.ts +2 -2
  144. package/src/lib/server/api-routes.test.ts +5 -6
  145. package/src/lib/server/build-llm.ts +17 -4
  146. package/src/lib/server/chat-execution.ts +82 -6
  147. package/src/lib/server/collection-helpers.ts +54 -0
  148. package/src/lib/server/connectors/bluebubbles.test.ts +217 -0
  149. package/src/lib/server/connectors/bluebubbles.ts +360 -0
  150. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  151. package/src/lib/server/connectors/googlechat.ts +51 -8
  152. package/src/lib/server/connectors/manager.ts +424 -13
  153. package/src/lib/server/connectors/media.ts +2 -2
  154. package/src/lib/server/connectors/openclaw.ts +65 -0
  155. package/src/lib/server/connectors/pairing.test.ts +99 -0
  156. package/src/lib/server/connectors/pairing.ts +256 -0
  157. package/src/lib/server/connectors/signal.ts +1 -0
  158. package/src/lib/server/connectors/teams.ts +5 -5
  159. package/src/lib/server/connectors/types.ts +10 -0
  160. package/src/lib/server/daemon-state.ts +11 -0
  161. package/src/lib/server/execution-log.ts +3 -3
  162. package/src/lib/server/heartbeat-service.ts +1 -1
  163. package/src/lib/server/knowledge-db.test.ts +2 -33
  164. package/src/lib/server/main-agent-loop.ts +8 -9
  165. package/src/lib/server/main-session.ts +21 -0
  166. package/src/lib/server/memory-db.ts +6 -6
  167. package/src/lib/server/openclaw-approvals.ts +105 -0
  168. package/src/lib/server/openclaw-config-sync.ts +107 -0
  169. package/src/lib/server/openclaw-exec-config.ts +52 -0
  170. package/src/lib/server/openclaw-gateway.ts +291 -0
  171. package/src/lib/server/openclaw-history-merge.ts +36 -0
  172. package/src/lib/server/openclaw-models.ts +56 -0
  173. package/src/lib/server/openclaw-permission-presets.ts +64 -0
  174. package/src/lib/server/openclaw-sync.ts +497 -0
  175. package/src/lib/server/orchestrator-lg.ts +30 -9
  176. package/src/lib/server/orchestrator.ts +4 -4
  177. package/src/lib/server/process-manager.ts +2 -2
  178. package/src/lib/server/queue.ts +24 -11
  179. package/src/lib/server/scheduler.ts +2 -2
  180. package/src/lib/server/session-mailbox.ts +2 -2
  181. package/src/lib/server/session-run-manager.ts +2 -2
  182. package/src/lib/server/session-tools/connector.ts +53 -6
  183. package/src/lib/server/session-tools/crud.ts +3 -3
  184. package/src/lib/server/session-tools/delegate.ts +22 -6
  185. package/src/lib/server/session-tools/file.ts +192 -19
  186. package/src/lib/server/session-tools/index.ts +4 -2
  187. package/src/lib/server/session-tools/memory.ts +2 -2
  188. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  189. package/src/lib/server/session-tools/sandbox.ts +33 -0
  190. package/src/lib/server/session-tools/search-providers.ts +277 -0
  191. package/src/lib/server/session-tools/session-info.ts +2 -2
  192. package/src/lib/server/session-tools/session-tools-wiring.test.ts +2 -2
  193. package/src/lib/server/session-tools/shell.ts +1 -1
  194. package/src/lib/server/session-tools/web.ts +53 -72
  195. package/src/lib/server/storage.ts +74 -11
  196. package/src/lib/server/stream-agent-chat.ts +53 -4
  197. package/src/lib/server/suggestions.ts +20 -0
  198. package/src/lib/server/task-result.test.ts +44 -0
  199. package/src/lib/server/task-result.ts +14 -0
  200. package/src/lib/server/ws-hub.ts +14 -0
  201. package/src/lib/tool-definitions.ts +5 -3
  202. package/src/lib/tts-stream.ts +130 -0
  203. package/src/lib/view-routes.ts +28 -0
  204. package/src/proxy.ts +3 -0
  205. package/src/stores/use-app-store.ts +80 -1
  206. package/src/stores/use-approval-store.ts +78 -0
  207. package/src/stores/use-chat-store.ts +162 -6
  208. package/src/types/index.ts +154 -3
  209. package/tsconfig.json +13 -4
@@ -0,0 +1,360 @@
1
+ import crypto from 'node:crypto'
2
+ import type { PlatformConnector, ConnectorInstance, InboundMessage, InboundMedia } from './types'
3
+ import { isNoMessage } from './manager'
4
+
5
+ const DEFAULT_TIMEOUT_MS = 10_000
6
+ const DEFAULT_WEBHOOK_PATH = '/api/connectors/{id}/webhook'
7
+
8
+ function getErrorMessage(err: unknown): string {
9
+ if (err instanceof Error && err.message) return err.message
10
+ return String(err)
11
+ }
12
+
13
+ function asRecord(value: unknown): Record<string, unknown> | null {
14
+ return value && typeof value === 'object' && !Array.isArray(value)
15
+ ? value as Record<string, unknown>
16
+ : null
17
+ }
18
+
19
+ function readString(record: Record<string, unknown> | null, key: string): string | undefined {
20
+ if (!record) return undefined
21
+ const value = record[key]
22
+ return typeof value === 'string' ? value : undefined
23
+ }
24
+
25
+ function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
26
+ if (!record) return undefined
27
+ const value = record[key]
28
+ if (typeof value === 'number' && Number.isFinite(value)) return value
29
+ if (typeof value === 'string' && value.trim()) {
30
+ const parsed = Number.parseFloat(value)
31
+ if (Number.isFinite(parsed)) return parsed
32
+ }
33
+ return undefined
34
+ }
35
+
36
+ function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
37
+ if (!record) return undefined
38
+ const value = record[key]
39
+ return typeof value === 'boolean' ? value : undefined
40
+ }
41
+
42
+ function extractPayloadMessage(payload: Record<string, unknown>): Record<string, unknown> | null {
43
+ const parseRecord = (value: unknown): Record<string, unknown> | null => {
44
+ const record = asRecord(value)
45
+ if (record) return record
46
+ if (Array.isArray(value)) {
47
+ for (const item of value) {
48
+ const nested = parseRecord(item)
49
+ if (nested) return nested
50
+ }
51
+ return null
52
+ }
53
+ if (typeof value !== 'string') return null
54
+ const trimmed = value.trim()
55
+ if (!trimmed) return null
56
+ try {
57
+ return parseRecord(JSON.parse(trimmed))
58
+ } catch {
59
+ return null
60
+ }
61
+ }
62
+
63
+ const dataRaw = payload.data ?? payload.payload ?? payload.event
64
+ const data = parseRecord(dataRaw)
65
+ const messageRaw = payload.message ?? data?.message ?? data
66
+ return parseRecord(messageRaw)
67
+ }
68
+
69
+ function normalizeHandle(value: string): string {
70
+ return value.trim().toLowerCase()
71
+ }
72
+
73
+ function extractHandleFromChatGuid(chatGuid: string): string | null {
74
+ const parts = chatGuid.split(';')
75
+ if (parts.length < 3) return null
76
+ const handle = parts[2]?.trim()
77
+ return handle || null
78
+ }
79
+
80
+ function resolveGroupFlagFromChatGuid(chatGuid?: string): boolean | undefined {
81
+ const guid = chatGuid?.trim()
82
+ if (!guid) return undefined
83
+ const parts = guid.split(';')
84
+ if (parts.length >= 3) {
85
+ if (parts[1] === '+') return true
86
+ if (parts[1] === '-') return false
87
+ }
88
+ if (guid.includes(';+;')) return true
89
+ if (guid.includes(';-;')) return false
90
+ return undefined
91
+ }
92
+
93
+ function normalizeAttachmentType(mimeType?: string): InboundMedia['type'] {
94
+ const mime = (mimeType || '').trim().toLowerCase()
95
+ if (mime.startsWith('image/')) return 'image'
96
+ if (mime.startsWith('video/')) return 'video'
97
+ if (mime.startsWith('audio/')) return 'audio'
98
+ if (mime.startsWith('application/')) return 'document'
99
+ return 'file'
100
+ }
101
+
102
+ function normalizeAttachments(message: Record<string, unknown>): InboundMedia[] {
103
+ const raw = message.attachments
104
+ if (!Array.isArray(raw)) return []
105
+
106
+ const output: InboundMedia[] = []
107
+ for (const item of raw) {
108
+ const record = asRecord(item)
109
+ if (!record) continue
110
+
111
+ const mimeType = readString(record, 'mimeType') || readString(record, 'mime_type')
112
+ const fileName = readString(record, 'transferName') || readString(record, 'transfer_name')
113
+ const sizeBytes = readNumberLike(record, 'totalBytes') || readNumberLike(record, 'total_bytes')
114
+
115
+ output.push({
116
+ type: normalizeAttachmentType(mimeType),
117
+ mimeType,
118
+ fileName,
119
+ sizeBytes,
120
+ })
121
+ }
122
+
123
+ return output
124
+ }
125
+
126
+ function parseInboundMessage(payload: Record<string, unknown>): InboundMessage | null {
127
+ const eventType = readString(payload, 'type')?.trim().toLowerCase() || ''
128
+ if (eventType && !['new-message', 'created-message', 'message'].includes(eventType)) {
129
+ return null
130
+ }
131
+
132
+ const message = extractPayloadMessage(payload)
133
+ if (!message) return null
134
+
135
+ const fromMe = readBoolean(message, 'isFromMe') ?? readBoolean(message, 'is_from_me') ?? false
136
+ if (fromMe) return null
137
+
138
+ const text = (
139
+ readString(message, 'text')
140
+ || readString(message, 'body')
141
+ || readString(message, 'subject')
142
+ || ''
143
+ ).trim()
144
+
145
+ const handle = asRecord(message.handle) || asRecord(message.sender)
146
+ const rawSenderId = (
147
+ readString(handle, 'address')
148
+ || readString(handle, 'handle')
149
+ || readString(handle, 'id')
150
+ || readString(message, 'senderId')
151
+ || readString(message, 'sender')
152
+ || readString(message, 'from')
153
+ || ''
154
+ ).trim()
155
+
156
+ const chatGuid = (
157
+ readString(message, 'chatGuid')
158
+ || readString(message, 'chat_guid')
159
+ || ''
160
+ ).trim()
161
+
162
+ const inferredSender = !rawSenderId && chatGuid ? (extractHandleFromChatGuid(chatGuid) || '') : ''
163
+ const senderId = normalizeHandle(rawSenderId || inferredSender)
164
+ if (!senderId) return null
165
+
166
+ const chatIdentifier = (
167
+ readString(message, 'chatIdentifier')
168
+ || readString(message, 'chat_identifier')
169
+ || ''
170
+ ).trim()
171
+ const chatIdNum = readNumberLike(message, 'chatId') || readNumberLike(message, 'chat_id')
172
+ const chatId = chatGuid || chatIdentifier || (Number.isFinite(chatIdNum) ? String(chatIdNum) : senderId)
173
+ const channelName = (
174
+ readString(message, 'chatName')
175
+ || readString(message, 'displayName')
176
+ || chatId
177
+ ).trim()
178
+
179
+ const senderName = (
180
+ readString(handle, 'displayName')
181
+ || readString(handle, 'name')
182
+ || readString(message, 'senderName')
183
+ || senderId
184
+ ).trim()
185
+
186
+ const media = normalizeAttachments(message)
187
+ const fallbackText = media.length > 0 ? '<media:attachment>' : ''
188
+
189
+ const groupFlag = (
190
+ readBoolean(message, 'isGroup')
191
+ ?? readBoolean(message, 'is_group')
192
+ ?? resolveGroupFlagFromChatGuid(chatGuid)
193
+ ?? false
194
+ )
195
+
196
+ return {
197
+ platform: 'bluebubbles',
198
+ channelId: chatId,
199
+ channelName,
200
+ senderId,
201
+ senderName,
202
+ text: text || fallbackText,
203
+ media,
204
+ isGroup: groupFlag,
205
+ }
206
+ }
207
+
208
+ function resolveRequestUrl(baseUrl: string, path: string, password: string): string {
209
+ const base = new URL(baseUrl)
210
+ const url = new URL(path, base)
211
+ url.searchParams.set('password', password)
212
+ return url.toString()
213
+ }
214
+
215
+ async function fetchJsonWithTimeout(
216
+ input: string,
217
+ init: RequestInit,
218
+ timeoutMs: number,
219
+ ): Promise<Response> {
220
+ const controller = new AbortController()
221
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
222
+ try {
223
+ return await fetch(input, { ...init, signal: controller.signal })
224
+ } finally {
225
+ clearTimeout(timer)
226
+ }
227
+ }
228
+
229
+ function parseCsvList(raw: string | undefined): string[] {
230
+ if (!raw) return []
231
+ return raw.split(',').map((value) => value.trim()).filter(Boolean)
232
+ }
233
+
234
+ const bluebubbles: PlatformConnector = {
235
+ async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
236
+ const serverUrl = connector.config.serverUrl?.trim()
237
+ const password = (botToken || connector.config.password || '').trim()
238
+
239
+ if (!serverUrl) throw new Error('Missing serverUrl in connector config')
240
+ if (!password) throw new Error('Missing BlueBubbles password (credential/token)')
241
+
242
+ const timeoutMsRaw = Number.parseInt(connector.config.timeoutMs || '', 10)
243
+ const timeoutMs = Number.isFinite(timeoutMsRaw)
244
+ ? Math.max(1_000, Math.min(60_000, timeoutMsRaw))
245
+ : DEFAULT_TIMEOUT_MS
246
+
247
+ const allowedChats = new Set(parseCsvList(connector.config.chatIds))
248
+
249
+ let stopped = false
250
+
251
+ const processWebhookEvent = async (payload: Record<string, unknown>) => {
252
+ if (stopped) throw new Error('Connector is stopped')
253
+ const inbound = parseInboundMessage(payload)
254
+ if (!inbound) return {}
255
+
256
+ if (allowedChats.size > 0) {
257
+ const id = inbound.channelId
258
+ const name = inbound.channelName || ''
259
+ const allowed = Array.from(allowedChats).some((needle) => id.includes(needle) || name.includes(needle))
260
+ if (!allowed) return {}
261
+ }
262
+
263
+ const response = await onMessage(inbound)
264
+ if (!response || isNoMessage(response)) return {}
265
+
266
+ await sendBlueBubblesText({
267
+ serverUrl,
268
+ password,
269
+ channelId: inbound.channelId,
270
+ text: response,
271
+ timeoutMs,
272
+ })
273
+ return {}
274
+ }
275
+
276
+ const handlerKey = `__swarmclaw_bluebubbles_handler_${connector.id}__`
277
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
278
+ ;(globalThis as any)[handlerKey] = processWebhookEvent
279
+
280
+ const pingUrl = resolveRequestUrl(serverUrl, '/api/v1/ping', password)
281
+ const pingRes = await fetchJsonWithTimeout(pingUrl, { method: 'GET' }, timeoutMs)
282
+ if (!pingRes.ok) {
283
+ throw new Error(`BlueBubbles ping failed (${pingRes.status})`)
284
+ }
285
+
286
+ console.log(`[bluebubbles] Connected to ${serverUrl}`)
287
+ console.log(`[bluebubbles] Inbound webhook endpoint: ${DEFAULT_WEBHOOK_PATH.replace('{id}', connector.id)}`)
288
+
289
+ return {
290
+ connector,
291
+ async sendMessage(channelId, text) {
292
+ if (stopped) throw new Error('Connector is stopped')
293
+ return await sendBlueBubblesText({
294
+ serverUrl,
295
+ password,
296
+ channelId,
297
+ text,
298
+ timeoutMs,
299
+ })
300
+ },
301
+ async stop() {
302
+ stopped = true
303
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
304
+ delete (globalThis as any)[handlerKey]
305
+ console.log(`[bluebubbles] Connector stopped`)
306
+ },
307
+ }
308
+ },
309
+ }
310
+
311
+ async function sendBlueBubblesText(params: {
312
+ serverUrl: string
313
+ password: string
314
+ channelId: string
315
+ text: string
316
+ timeoutMs: number
317
+ }): Promise<{ messageId?: string }> {
318
+ const message = params.text.trim()
319
+ if (!message) return {}
320
+
321
+ const channel = params.channelId.trim()
322
+ if (!channel) throw new Error('BlueBubbles send requires channelId')
323
+
324
+ const payload: Record<string, unknown> = {
325
+ message,
326
+ tempGuid: crypto.randomUUID(),
327
+ }
328
+
329
+ // For inbound-driven replies we store chat GUID in channelId. If callers pass a phone/email,
330
+ // BlueBubbles can still attempt routing via chatGuid field when it already matches.
331
+ payload.chatGuid = channel
332
+
333
+ const url = resolveRequestUrl(params.serverUrl, '/api/v1/message/text', params.password)
334
+ const res = await fetchJsonWithTimeout(url, {
335
+ method: 'POST',
336
+ headers: { 'Content-Type': 'application/json' },
337
+ body: JSON.stringify(payload),
338
+ }, params.timeoutMs)
339
+
340
+ if (!res.ok) {
341
+ const errBody = await res.text().catch(() => '')
342
+ throw new Error(`BlueBubbles send failed (${res.status}): ${errBody || 'unknown'}`)
343
+ }
344
+
345
+ try {
346
+ const body = await res.json() as Record<string, unknown>
347
+ const data = body?.data && typeof body.data === 'object' ? body.data as Record<string, unknown> : null
348
+ const id = data?.guid || body?.guid || data?.id || body?.id
349
+ return { messageId: typeof id === 'string' ? id : undefined }
350
+ } catch (err) {
351
+ // BlueBubbles may return empty body on success in some setups.
352
+ const message = getErrorMessage(err)
353
+ if (!message.toLowerCase().includes('json')) {
354
+ console.warn(`[bluebubbles] Unable to parse send response body: ${message}`)
355
+ }
356
+ return {}
357
+ }
358
+ }
359
+
360
+ export default bluebubbles
@@ -9,7 +9,7 @@ import type { InboundMessage, InboundMedia } from './types.ts'
9
9
  // 1. Connector module resolution (getPlatform)
10
10
  // ---------------------------------------------------------------------------
11
11
  describe('getPlatform — connector module resolution', () => {
12
- const newPlatforms = ['matrix', 'googlechat', 'teams', 'signal'] as const
12
+ const newPlatforms = ['matrix', 'googlechat', 'teams', 'signal', 'bluebubbles'] as const
13
13
 
14
14
  for (const name of newPlatforms) {
15
15
  it(`returns a valid module for "${name}"`, async () => {
@@ -1,12 +1,13 @@
1
- import type { PlatformConnector, ConnectorInstance } from './types'
1
+ import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
2
+ import { isNoMessage } from './manager'
2
3
 
3
4
  const googlechat: PlatformConnector = {
4
- async start(connector, botToken, _onMessage): Promise<ConnectorInstance> {
5
+ async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
5
6
  const pkg = 'googleapis'
6
7
  const { google } = await import(/* webpackIgnore: true */ pkg)
7
8
 
8
9
  // Parse service account credentials from botToken
9
- let credentials: any
10
+ let credentials: Record<string, unknown>
10
11
  try {
11
12
  credentials = JSON.parse(botToken)
12
13
  } catch {
@@ -25,17 +26,57 @@ const googlechat: PlatformConnector = {
25
26
  ? connector.config.spaceIds.split(',').map((s: string) => s.trim()).filter(Boolean)
26
27
  : null
27
28
 
28
- // Google Chat requires a webhook or Pub/Sub for real-time inbound messages.
29
- // This connector supports outbound messaging. For inbound messages, configure
30
- // a webhook endpoint at /api/connectors/[id]/webhook that POSTs events here.
31
- // Polling is not supported by the Google Chat API for bot messages.
29
+ const handlerKey = `__swarmclaw_googlechat_handler_${connector.id}__`
32
30
  let stopped = false
33
31
 
34
32
  console.log(`[googlechat] Bot authenticated via service account`)
35
33
  if (allowedSpaces) {
36
34
  console.log(`[googlechat] Filtering to spaces: ${allowedSpaces.join(', ')}`)
37
35
  }
38
- console.log(`[googlechat] Note: Inbound messages require a webhook or Pub/Sub subscription. This connector supports outbound sends.`)
36
+ console.log(`[googlechat] Inbound webhook endpoint: /api/connectors/${connector.id}/webhook`)
37
+
38
+ function cleanInboundText(raw: unknown): string {
39
+ const txt = typeof raw === 'string' ? raw : ''
40
+ // Google Chat mentions often look like <users/123456789>
41
+ return txt.replace(/<users\/[^>]+>/g, '').trim()
42
+ }
43
+
44
+ async function processWebhookEvent(event: Record<string, unknown>): Promise<Record<string, unknown>> {
45
+ if (stopped) throw new Error('Connector is stopped')
46
+
47
+ const msg = event?.message as Record<string, unknown> | undefined
48
+ if (!msg) return {}
49
+
50
+ const msgSpace = msg?.space as Record<string, unknown> | undefined
51
+ const eventSpace = event?.space as Record<string, unknown> | undefined
52
+ const spaceName: string = (msgSpace?.name as string) || (eventSpace?.name as string) || ''
53
+ if (allowedSpaces && !allowedSpaces.some((s) => spaceName.includes(s))) {
54
+ return {}
55
+ }
56
+
57
+ const rawText = (msg?.argumentText as string) || (msg?.text as string) || ''
58
+ const text = cleanInboundText(rawText)
59
+ if (!text) return {}
60
+
61
+ const sender = (msg?.sender || event?.user || {}) as Record<string, unknown>
62
+ const senderName = (sender?.displayName as string) || (sender?.name as string) || 'Google Chat User'
63
+ const senderId = (sender?.name as string) || ''
64
+ const inbound: InboundMessage = {
65
+ platform: 'googlechat',
66
+ channelId: spaceName || ((msg?.thread as Record<string, unknown>)?.name as string) || 'space:unknown',
67
+ channelName: (msgSpace?.displayName as string) || spaceName || 'Google Chat',
68
+ senderId,
69
+ senderName,
70
+ text,
71
+ }
72
+
73
+ const response = await onMessage(inbound)
74
+ if (!response || isNoMessage(response)) return {}
75
+ return { text: response }
76
+ }
77
+
78
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
79
+ ;(globalThis as any)[handlerKey] = processWebhookEvent
39
80
 
40
81
  return {
41
82
  connector,
@@ -57,6 +98,8 @@ const googlechat: PlatformConnector = {
57
98
  },
58
99
  async stop() {
59
100
  stopped = true
101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
+ delete (globalThis as any)[handlerKey]
60
103
  console.log(`[googlechat] Bot disconnected`)
61
104
  },
62
105
  }