@swarmclawai/swarmclaw 0.4.0 → 0.4.5

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 (144) hide show
  1. package/README.md +13 -2
  2. package/next.config.ts +8 -0
  3. package/package.json +2 -1
  4. package/src/app/api/agents/[id]/route.ts +20 -21
  5. package/src/app/api/agents/[id]/thread/route.ts +2 -2
  6. package/src/app/api/agents/route.ts +3 -2
  7. package/src/app/api/clawhub/install/route.ts +2 -2
  8. package/src/app/api/connectors/[id]/route.ts +10 -3
  9. package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
  10. package/src/app/api/connectors/route.ts +6 -3
  11. package/src/app/api/credentials/[id]/route.ts +2 -1
  12. package/src/app/api/credentials/route.ts +2 -2
  13. package/src/app/api/documents/route.ts +2 -2
  14. package/src/app/api/files/serve/route.ts +8 -0
  15. package/src/app/api/knowledge/[id]/route.ts +5 -4
  16. package/src/app/api/knowledge/upload/route.ts +2 -2
  17. package/src/app/api/mcp-servers/[id]/route.ts +11 -14
  18. package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
  19. package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
  20. package/src/app/api/mcp-servers/route.ts +2 -2
  21. package/src/app/api/memory/[id]/route.ts +9 -8
  22. package/src/app/api/memory/route.ts +2 -2
  23. package/src/app/api/memory-images/[filename]/route.ts +2 -1
  24. package/src/app/api/openclaw/directory/route.ts +26 -0
  25. package/src/app/api/openclaw/discover/route.ts +61 -0
  26. package/src/app/api/openclaw/sync/route.ts +30 -0
  27. package/src/app/api/orchestrator/run/route.ts +2 -2
  28. package/src/app/api/projects/[id]/route.ts +55 -0
  29. package/src/app/api/projects/route.ts +27 -0
  30. package/src/app/api/providers/[id]/models/route.ts +2 -1
  31. package/src/app/api/providers/[id]/route.ts +13 -15
  32. package/src/app/api/providers/route.ts +2 -2
  33. package/src/app/api/schedules/[id]/route.ts +16 -18
  34. package/src/app/api/schedules/[id]/run/route.ts +4 -3
  35. package/src/app/api/schedules/route.ts +2 -2
  36. package/src/app/api/secrets/[id]/route.ts +16 -17
  37. package/src/app/api/secrets/route.ts +2 -2
  38. package/src/app/api/sessions/[id]/clear/route.ts +2 -1
  39. package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
  40. package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
  41. package/src/app/api/sessions/[id]/messages/route.ts +2 -1
  42. package/src/app/api/sessions/[id]/retry/route.ts +2 -1
  43. package/src/app/api/sessions/[id]/route.ts +2 -1
  44. package/src/app/api/sessions/route.ts +2 -2
  45. package/src/app/api/skills/[id]/route.ts +23 -21
  46. package/src/app/api/skills/import/route.ts +2 -2
  47. package/src/app/api/skills/route.ts +2 -2
  48. package/src/app/api/tasks/[id]/approve/route.ts +2 -1
  49. package/src/app/api/tasks/[id]/route.ts +6 -5
  50. package/src/app/api/tasks/route.ts +2 -2
  51. package/src/app/api/tts/stream/route.ts +48 -0
  52. package/src/app/api/upload/route.ts +2 -2
  53. package/src/app/api/uploads/[filename]/route.ts +4 -1
  54. package/src/app/api/webhooks/[id]/route.ts +29 -31
  55. package/src/app/api/webhooks/route.ts +2 -2
  56. package/src/app/page.tsx +3 -24
  57. package/src/cli/index.js +28 -0
  58. package/src/cli/index.ts +1 -1
  59. package/src/cli/spec.js +2 -0
  60. package/src/components/agents/agent-list.tsx +3 -1
  61. package/src/components/agents/agent-sheet.tsx +116 -14
  62. package/src/components/chat/chat-area.tsx +27 -4
  63. package/src/components/chat/chat-header.tsx +141 -29
  64. package/src/components/chat/tool-call-bubble.tsx +9 -3
  65. package/src/components/chat/voice-overlay.tsx +80 -0
  66. package/src/components/connectors/connector-list.tsx +6 -2
  67. package/src/components/connectors/connector-sheet.tsx +31 -7
  68. package/src/components/layout/app-layout.tsx +47 -25
  69. package/src/components/projects/project-list.tsx +122 -0
  70. package/src/components/projects/project-sheet.tsx +135 -0
  71. package/src/components/schedules/schedule-list.tsx +3 -1
  72. package/src/components/sessions/new-session-sheet.tsx +6 -6
  73. package/src/components/sessions/session-card.tsx +1 -1
  74. package/src/components/sessions/session-list.tsx +7 -7
  75. package/src/components/shared/connector-platform-icon.tsx +4 -0
  76. package/src/components/shared/settings/section-heartbeat.tsx +1 -1
  77. package/src/components/shared/settings/section-orchestrator.tsx +1 -2
  78. package/src/components/shared/settings/section-web-search.tsx +56 -0
  79. package/src/components/shared/settings/settings-page.tsx +73 -0
  80. package/src/components/skills/skill-list.tsx +2 -1
  81. package/src/components/tasks/task-list.tsx +5 -2
  82. package/src/hooks/use-continuous-speech.ts +144 -0
  83. package/src/hooks/use-view-router.ts +52 -0
  84. package/src/hooks/use-voice-conversation.ts +80 -0
  85. package/src/lib/id.ts +6 -0
  86. package/src/lib/projects.ts +13 -0
  87. package/src/lib/provider-sets.ts +5 -0
  88. package/src/lib/providers/anthropic.ts +14 -1
  89. package/src/lib/providers/index.ts +6 -0
  90. package/src/lib/providers/ollama.ts +9 -1
  91. package/src/lib/providers/openai.ts +9 -1
  92. package/src/lib/providers/openclaw.ts +11 -0
  93. package/src/lib/server/api-routes.test.ts +5 -6
  94. package/src/lib/server/build-llm.ts +17 -4
  95. package/src/lib/server/chat-execution.ts +38 -4
  96. package/src/lib/server/collection-helpers.ts +54 -0
  97. package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
  98. package/src/lib/server/connectors/bluebubbles.ts +357 -0
  99. package/src/lib/server/connectors/connector-routing.test.ts +1 -1
  100. package/src/lib/server/connectors/googlechat.ts +46 -7
  101. package/src/lib/server/connectors/manager.ts +392 -3
  102. package/src/lib/server/connectors/media.ts +2 -2
  103. package/src/lib/server/connectors/openclaw.ts +64 -0
  104. package/src/lib/server/connectors/pairing.test.ts +99 -0
  105. package/src/lib/server/connectors/pairing.ts +256 -0
  106. package/src/lib/server/connectors/signal.ts +1 -0
  107. package/src/lib/server/connectors/teams.ts +5 -5
  108. package/src/lib/server/connectors/types.ts +10 -0
  109. package/src/lib/server/execution-log.ts +3 -3
  110. package/src/lib/server/heartbeat-service.ts +1 -1
  111. package/src/lib/server/knowledge-db.test.ts +2 -33
  112. package/src/lib/server/main-agent-loop.ts +6 -6
  113. package/src/lib/server/memory-db.ts +6 -6
  114. package/src/lib/server/openclaw-approvals.ts +105 -0
  115. package/src/lib/server/openclaw-sync.ts +496 -0
  116. package/src/lib/server/orchestrator-lg.ts +30 -9
  117. package/src/lib/server/orchestrator.ts +4 -4
  118. package/src/lib/server/process-manager.ts +2 -2
  119. package/src/lib/server/queue.ts +22 -10
  120. package/src/lib/server/scheduler.ts +2 -2
  121. package/src/lib/server/session-mailbox.ts +2 -2
  122. package/src/lib/server/session-run-manager.ts +2 -2
  123. package/src/lib/server/session-tools/connector.ts +51 -4
  124. package/src/lib/server/session-tools/crud.ts +3 -3
  125. package/src/lib/server/session-tools/delegate.ts +3 -3
  126. package/src/lib/server/session-tools/file.ts +176 -3
  127. package/src/lib/server/session-tools/index.ts +2 -0
  128. package/src/lib/server/session-tools/memory.ts +2 -2
  129. package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
  130. package/src/lib/server/session-tools/sandbox.ts +33 -0
  131. package/src/lib/server/session-tools/search-providers.ts +270 -0
  132. package/src/lib/server/session-tools/session-info.ts +2 -2
  133. package/src/lib/server/session-tools/web.ts +47 -66
  134. package/src/lib/server/storage.ts +12 -0
  135. package/src/lib/server/stream-agent-chat.ts +29 -0
  136. package/src/lib/server/task-result.test.ts +44 -0
  137. package/src/lib/server/task-result.ts +14 -0
  138. package/src/lib/tool-definitions.ts +5 -3
  139. package/src/lib/tts-stream.ts +130 -0
  140. package/src/lib/view-routes.ts +28 -0
  141. package/src/proxy.ts +3 -0
  142. package/src/stores/use-app-store.ts +28 -1
  143. package/src/stores/use-chat-store.ts +9 -1
  144. package/src/types/index.ts +27 -2
@@ -0,0 +1,208 @@
1
+ import assert from 'node:assert/strict'
2
+ import { test } from 'node:test'
3
+ import bluebubbles from './bluebubbles.ts'
4
+
5
+ type FetchCall = {
6
+ url: string
7
+ init?: RequestInit
8
+ }
9
+
10
+ type MockResponse = {
11
+ ok: boolean
12
+ status: number
13
+ json: () => Promise<any>
14
+ text: () => Promise<string>
15
+ }
16
+
17
+ function jsonResponse(status: number, body: unknown): MockResponse {
18
+ return {
19
+ ok: status >= 200 && status < 300,
20
+ status,
21
+ json: async () => body,
22
+ text: async () => JSON.stringify(body),
23
+ }
24
+ }
25
+
26
+ function textResponse(status: number, text: string): MockResponse {
27
+ return {
28
+ ok: status >= 200 && status < 300,
29
+ status,
30
+ json: async () => {
31
+ throw new Error('not json')
32
+ },
33
+ text: async () => text,
34
+ }
35
+ }
36
+
37
+ const originalFetch = globalThis.fetch
38
+
39
+ test.afterEach(() => {
40
+ ;(globalThis as any).fetch = originalFetch
41
+ })
42
+
43
+ test('bluebubbles connector processes inbound webhook payloads and sends replies', async () => {
44
+ const calls: FetchCall[] = []
45
+ const queue: MockResponse[] = [
46
+ jsonResponse(200, { ok: true }), // ping
47
+ jsonResponse(200, { data: { guid: 'msg-1' } }), // send reply
48
+ ]
49
+
50
+ ;(globalThis as any).fetch = async (url: string, init?: RequestInit) => {
51
+ calls.push({ url: String(url), init })
52
+ const next = queue.shift()
53
+ assert.ok(next, 'unexpected fetch call')
54
+ return next as any
55
+ }
56
+
57
+ const received: any[] = []
58
+ const connector = {
59
+ id: 'bb-1',
60
+ name: 'BlueBubbles Test',
61
+ platform: 'bluebubbles',
62
+ agentId: 'agent-1',
63
+ credentialId: null,
64
+ config: {
65
+ serverUrl: 'http://127.0.0.1:1234',
66
+ },
67
+ isEnabled: true,
68
+ status: 'running',
69
+ createdAt: Date.now(),
70
+ updatedAt: Date.now(),
71
+ } as any
72
+
73
+ const instance = await bluebubbles.start(connector, 'pw-test', async (msg) => {
74
+ received.push(msg)
75
+ return 'pong'
76
+ })
77
+
78
+ try {
79
+ const handler = (globalThis as any).__swarmclaw_bluebubbles_handler_bb_1__
80
+ assert.equal(typeof handler, 'undefined', 'sanity: wrong handler key should be undefined')
81
+ const validHandler = (globalThis as any)[`__swarmclaw_bluebubbles_handler_${connector.id}__`]
82
+ assert.equal(typeof validHandler, 'function')
83
+
84
+ await validHandler({
85
+ type: 'new-message',
86
+ data: {
87
+ guid: 'm-123',
88
+ text: 'hello there',
89
+ isFromMe: false,
90
+ isGroup: false,
91
+ handle: { address: '+15551234567', displayName: 'Alice' },
92
+ chatGuid: 'iMessage;-;+15551234567',
93
+ },
94
+ })
95
+
96
+ assert.equal(received.length, 1)
97
+ assert.equal(received[0].text, 'hello there')
98
+ assert.equal(received[0].senderId, '+15551234567')
99
+ assert.equal(received[0].channelId, 'iMessage;-;+15551234567')
100
+
101
+ assert.equal(calls.length, 2)
102
+ assert.ok(calls[0].url.includes('/api/v1/ping'))
103
+ assert.ok(calls[1].url.includes('/api/v1/message/text'))
104
+
105
+ const body = JSON.parse(String(calls[1].init?.body || '{}'))
106
+ assert.equal(body.chatGuid, 'iMessage;-;+15551234567')
107
+ assert.equal(body.message, 'pong')
108
+ } finally {
109
+ await instance.stop()
110
+ }
111
+ })
112
+
113
+ test('bluebubbles connector supports array-wrapped webhook payload and NO_MESSAGE suppression', async () => {
114
+ const calls: FetchCall[] = []
115
+ const queue: MockResponse[] = [
116
+ jsonResponse(200, { ok: true }), // ping
117
+ ]
118
+
119
+ ;(globalThis as any).fetch = async (url: string, init?: RequestInit) => {
120
+ calls.push({ url: String(url), init })
121
+ const next = queue.shift()
122
+ assert.ok(next, 'unexpected fetch call')
123
+ return next as any
124
+ }
125
+
126
+ const connector = {
127
+ id: 'bb-2',
128
+ name: 'BlueBubbles Test 2',
129
+ platform: 'bluebubbles',
130
+ agentId: 'agent-2',
131
+ credentialId: null,
132
+ config: {
133
+ serverUrl: 'http://127.0.0.1:1234',
134
+ },
135
+ isEnabled: true,
136
+ status: 'running',
137
+ createdAt: Date.now(),
138
+ updatedAt: Date.now(),
139
+ } as any
140
+
141
+ const instance = await bluebubbles.start(connector, 'pw-test', async () => 'NO_MESSAGE')
142
+
143
+ try {
144
+ const handler = (globalThis as any)[`__swarmclaw_bluebubbles_handler_${connector.id}__`]
145
+ assert.equal(typeof handler, 'function')
146
+
147
+ await handler({
148
+ type: 'new-message',
149
+ data: [
150
+ {
151
+ guid: 'm-124',
152
+ text: '',
153
+ isFromMe: false,
154
+ handle: { address: 'test@example.com', displayName: 'Taylor' },
155
+ chatGuid: 'iMessage;-;test@example.com',
156
+ attachments: [{ mimeType: 'image/png', transferName: 'a.png', totalBytes: 128 }],
157
+ },
158
+ ],
159
+ })
160
+
161
+ assert.equal(calls.length, 1, 'should not call send endpoint when NO_MESSAGE is returned')
162
+ } finally {
163
+ await instance.stop()
164
+ }
165
+ })
166
+
167
+ test('bluebubbles sendMessage posts to message/text endpoint', async () => {
168
+ const calls: FetchCall[] = []
169
+ const queue: MockResponse[] = [
170
+ jsonResponse(200, { ok: true }), // ping
171
+ textResponse(200, ''), // send
172
+ ]
173
+
174
+ ;(globalThis as any).fetch = async (url: string, init?: RequestInit) => {
175
+ calls.push({ url: String(url), init })
176
+ const next = queue.shift()
177
+ assert.ok(next, 'unexpected fetch call')
178
+ return next as any
179
+ }
180
+
181
+ const connector = {
182
+ id: 'bb-3',
183
+ name: 'BlueBubbles Test 3',
184
+ platform: 'bluebubbles',
185
+ agentId: 'agent-3',
186
+ credentialId: null,
187
+ config: {
188
+ serverUrl: 'http://127.0.0.1:1234',
189
+ },
190
+ isEnabled: true,
191
+ status: 'running',
192
+ createdAt: Date.now(),
193
+ updatedAt: Date.now(),
194
+ } as any
195
+
196
+ const instance = await bluebubbles.start(connector, 'pw-test', async () => 'ok')
197
+
198
+ try {
199
+ await instance.sendMessage?.('iMessage;-;+15550001111', 'hello outbound')
200
+ assert.equal(calls.length, 2)
201
+ assert.ok(calls[1].url.includes('/api/v1/message/text'))
202
+ const body = JSON.parse(String(calls[1].init?.body || '{}'))
203
+ assert.equal(body.chatGuid, 'iMessage;-;+15550001111')
204
+ assert.equal(body.message, 'hello outbound')
205
+ } finally {
206
+ await instance.stop()
207
+ }
208
+ })
@@ -0,0 +1,357 @@
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
+ ;(globalThis as any)[handlerKey] = processWebhookEvent
278
+
279
+ const pingUrl = resolveRequestUrl(serverUrl, '/api/v1/ping', password)
280
+ const pingRes = await fetchJsonWithTimeout(pingUrl, { method: 'GET' }, timeoutMs)
281
+ if (!pingRes.ok) {
282
+ throw new Error(`BlueBubbles ping failed (${pingRes.status})`)
283
+ }
284
+
285
+ console.log(`[bluebubbles] Connected to ${serverUrl}`)
286
+ console.log(`[bluebubbles] Inbound webhook endpoint: ${DEFAULT_WEBHOOK_PATH.replace('{id}', connector.id)}`)
287
+
288
+ return {
289
+ connector,
290
+ async sendMessage(channelId, text) {
291
+ if (stopped) throw new Error('Connector is stopped')
292
+ return await sendBlueBubblesText({
293
+ serverUrl,
294
+ password,
295
+ channelId,
296
+ text,
297
+ timeoutMs,
298
+ })
299
+ },
300
+ async stop() {
301
+ stopped = true
302
+ delete (globalThis as any)[handlerKey]
303
+ console.log(`[bluebubbles] Connector stopped`)
304
+ },
305
+ }
306
+ },
307
+ }
308
+
309
+ async function sendBlueBubblesText(params: {
310
+ serverUrl: string
311
+ password: string
312
+ channelId: string
313
+ text: string
314
+ timeoutMs: number
315
+ }): Promise<{ messageId?: string }> {
316
+ const message = params.text.trim()
317
+ if (!message) return {}
318
+
319
+ const channel = params.channelId.trim()
320
+ if (!channel) throw new Error('BlueBubbles send requires channelId')
321
+
322
+ const payload: Record<string, unknown> = {
323
+ message,
324
+ tempGuid: crypto.randomUUID(),
325
+ }
326
+
327
+ // For inbound-driven replies we store chat GUID in channelId. If callers pass a phone/email,
328
+ // BlueBubbles can still attempt routing via chatGuid field when it already matches.
329
+ payload.chatGuid = channel
330
+
331
+ const url = resolveRequestUrl(params.serverUrl, '/api/v1/message/text', params.password)
332
+ const res = await fetchJsonWithTimeout(url, {
333
+ method: 'POST',
334
+ headers: { 'Content-Type': 'application/json' },
335
+ body: JSON.stringify(payload),
336
+ }, params.timeoutMs)
337
+
338
+ if (!res.ok) {
339
+ const errBody = await res.text().catch(() => '')
340
+ throw new Error(`BlueBubbles send failed (${res.status}): ${errBody || 'unknown'}`)
341
+ }
342
+
343
+ try {
344
+ const body = await res.json() as any
345
+ const id = body?.data?.guid || body?.guid || body?.data?.id || body?.id
346
+ return { messageId: typeof id === 'string' ? id : undefined }
347
+ } catch (err) {
348
+ // BlueBubbles may return empty body on success in some setups.
349
+ const message = getErrorMessage(err)
350
+ if (!message.toLowerCase().includes('json')) {
351
+ console.warn(`[bluebubbles] Unable to parse send response body: ${message}`)
352
+ }
353
+ return {}
354
+ }
355
+ }
356
+
357
+ 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,7 +1,8 @@
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
 
@@ -25,17 +26,54 @@ 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: any): Promise<Record<string, unknown>> {
45
+ if (stopped) throw new Error('Connector is stopped')
46
+
47
+ const msg = event?.message
48
+ if (!msg) return {}
49
+
50
+ const spaceName: string = msg?.space?.name || event?.space?.name || ''
51
+ if (allowedSpaces && !allowedSpaces.some((s) => spaceName.includes(s))) {
52
+ return {}
53
+ }
54
+
55
+ const rawText = msg?.argumentText || msg?.text || ''
56
+ const text = cleanInboundText(rawText)
57
+ if (!text) return {}
58
+
59
+ const sender = msg?.sender || event?.user || {}
60
+ const senderName = sender?.displayName || sender?.name || 'Google Chat User'
61
+ const senderId = sender?.name || ''
62
+ const inbound: InboundMessage = {
63
+ platform: 'googlechat',
64
+ channelId: spaceName || (msg?.thread?.name || 'space:unknown'),
65
+ channelName: msg?.space?.displayName || spaceName || 'Google Chat',
66
+ senderId,
67
+ senderName,
68
+ text,
69
+ }
70
+
71
+ const response = await onMessage(inbound)
72
+ if (!response || isNoMessage(response)) return {}
73
+ return { text: response }
74
+ }
75
+
76
+ ;(globalThis as any)[handlerKey] = processWebhookEvent
39
77
 
40
78
  return {
41
79
  connector,
@@ -57,6 +95,7 @@ const googlechat: PlatformConnector = {
57
95
  },
58
96
  async stop() {
59
97
  stopped = true
98
+ delete (globalThis as any)[handlerKey]
60
99
  console.log(`[googlechat] Bot disconnected`)
61
100
  },
62
101
  }