@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.
- package/README.md +13 -2
- package/next.config.ts +8 -0
- package/package.json +2 -1
- package/src/app/api/agents/[id]/route.ts +20 -21
- package/src/app/api/agents/[id]/thread/route.ts +2 -2
- package/src/app/api/agents/route.ts +3 -2
- package/src/app/api/clawhub/install/route.ts +2 -2
- package/src/app/api/connectors/[id]/route.ts +10 -3
- package/src/app/api/connectors/[id]/webhook/route.ts +99 -0
- package/src/app/api/connectors/route.ts +6 -3
- package/src/app/api/credentials/[id]/route.ts +2 -1
- package/src/app/api/credentials/route.ts +2 -2
- package/src/app/api/documents/route.ts +2 -2
- package/src/app/api/files/serve/route.ts +8 -0
- package/src/app/api/knowledge/[id]/route.ts +5 -4
- package/src/app/api/knowledge/upload/route.ts +2 -2
- package/src/app/api/mcp-servers/[id]/route.ts +11 -14
- package/src/app/api/mcp-servers/[id]/test/route.ts +2 -1
- package/src/app/api/mcp-servers/[id]/tools/route.ts +2 -1
- package/src/app/api/mcp-servers/route.ts +2 -2
- package/src/app/api/memory/[id]/route.ts +9 -8
- package/src/app/api/memory/route.ts +2 -2
- package/src/app/api/memory-images/[filename]/route.ts +2 -1
- package/src/app/api/openclaw/directory/route.ts +26 -0
- package/src/app/api/openclaw/discover/route.ts +61 -0
- package/src/app/api/openclaw/sync/route.ts +30 -0
- package/src/app/api/orchestrator/run/route.ts +2 -2
- package/src/app/api/projects/[id]/route.ts +55 -0
- package/src/app/api/projects/route.ts +27 -0
- package/src/app/api/providers/[id]/models/route.ts +2 -1
- package/src/app/api/providers/[id]/route.ts +13 -15
- package/src/app/api/providers/route.ts +2 -2
- package/src/app/api/schedules/[id]/route.ts +16 -18
- package/src/app/api/schedules/[id]/run/route.ts +4 -3
- package/src/app/api/schedules/route.ts +2 -2
- package/src/app/api/secrets/[id]/route.ts +16 -17
- package/src/app/api/secrets/route.ts +2 -2
- package/src/app/api/sessions/[id]/clear/route.ts +2 -1
- package/src/app/api/sessions/[id]/deploy/route.ts +2 -1
- package/src/app/api/sessions/[id]/devserver/route.ts +2 -1
- package/src/app/api/sessions/[id]/messages/route.ts +2 -1
- package/src/app/api/sessions/[id]/retry/route.ts +2 -1
- package/src/app/api/sessions/[id]/route.ts +2 -1
- package/src/app/api/sessions/route.ts +2 -2
- package/src/app/api/skills/[id]/route.ts +23 -21
- package/src/app/api/skills/import/route.ts +2 -2
- package/src/app/api/skills/route.ts +2 -2
- package/src/app/api/tasks/[id]/approve/route.ts +2 -1
- package/src/app/api/tasks/[id]/route.ts +6 -5
- package/src/app/api/tasks/route.ts +2 -2
- package/src/app/api/tts/stream/route.ts +48 -0
- package/src/app/api/upload/route.ts +2 -2
- package/src/app/api/uploads/[filename]/route.ts +4 -1
- package/src/app/api/webhooks/[id]/route.ts +29 -31
- package/src/app/api/webhooks/route.ts +2 -2
- package/src/app/page.tsx +3 -24
- package/src/cli/index.js +28 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/spec.js +2 -0
- package/src/components/agents/agent-list.tsx +3 -1
- package/src/components/agents/agent-sheet.tsx +116 -14
- package/src/components/chat/chat-area.tsx +27 -4
- package/src/components/chat/chat-header.tsx +141 -29
- package/src/components/chat/tool-call-bubble.tsx +9 -3
- package/src/components/chat/voice-overlay.tsx +80 -0
- package/src/components/connectors/connector-list.tsx +6 -2
- package/src/components/connectors/connector-sheet.tsx +31 -7
- package/src/components/layout/app-layout.tsx +47 -25
- package/src/components/projects/project-list.tsx +122 -0
- package/src/components/projects/project-sheet.tsx +135 -0
- package/src/components/schedules/schedule-list.tsx +3 -1
- package/src/components/sessions/new-session-sheet.tsx +6 -6
- package/src/components/sessions/session-card.tsx +1 -1
- package/src/components/sessions/session-list.tsx +7 -7
- package/src/components/shared/connector-platform-icon.tsx +4 -0
- package/src/components/shared/settings/section-heartbeat.tsx +1 -1
- package/src/components/shared/settings/section-orchestrator.tsx +1 -2
- package/src/components/shared/settings/section-web-search.tsx +56 -0
- package/src/components/shared/settings/settings-page.tsx +73 -0
- package/src/components/skills/skill-list.tsx +2 -1
- package/src/components/tasks/task-list.tsx +5 -2
- package/src/hooks/use-continuous-speech.ts +144 -0
- package/src/hooks/use-view-router.ts +52 -0
- package/src/hooks/use-voice-conversation.ts +80 -0
- package/src/lib/id.ts +6 -0
- package/src/lib/projects.ts +13 -0
- package/src/lib/provider-sets.ts +5 -0
- package/src/lib/providers/anthropic.ts +14 -1
- package/src/lib/providers/index.ts +6 -0
- package/src/lib/providers/ollama.ts +9 -1
- package/src/lib/providers/openai.ts +9 -1
- package/src/lib/providers/openclaw.ts +11 -0
- package/src/lib/server/api-routes.test.ts +5 -6
- package/src/lib/server/build-llm.ts +17 -4
- package/src/lib/server/chat-execution.ts +38 -4
- package/src/lib/server/collection-helpers.ts +54 -0
- package/src/lib/server/connectors/bluebubbles.test.ts +208 -0
- package/src/lib/server/connectors/bluebubbles.ts +357 -0
- package/src/lib/server/connectors/connector-routing.test.ts +1 -1
- package/src/lib/server/connectors/googlechat.ts +46 -7
- package/src/lib/server/connectors/manager.ts +392 -3
- package/src/lib/server/connectors/media.ts +2 -2
- package/src/lib/server/connectors/openclaw.ts +64 -0
- package/src/lib/server/connectors/pairing.test.ts +99 -0
- package/src/lib/server/connectors/pairing.ts +256 -0
- package/src/lib/server/connectors/signal.ts +1 -0
- package/src/lib/server/connectors/teams.ts +5 -5
- package/src/lib/server/connectors/types.ts +10 -0
- package/src/lib/server/execution-log.ts +3 -3
- package/src/lib/server/heartbeat-service.ts +1 -1
- package/src/lib/server/knowledge-db.test.ts +2 -33
- package/src/lib/server/main-agent-loop.ts +6 -6
- package/src/lib/server/memory-db.ts +6 -6
- package/src/lib/server/openclaw-approvals.ts +105 -0
- package/src/lib/server/openclaw-sync.ts +496 -0
- package/src/lib/server/orchestrator-lg.ts +30 -9
- package/src/lib/server/orchestrator.ts +4 -4
- package/src/lib/server/process-manager.ts +2 -2
- package/src/lib/server/queue.ts +22 -10
- package/src/lib/server/scheduler.ts +2 -2
- package/src/lib/server/session-mailbox.ts +2 -2
- package/src/lib/server/session-run-manager.ts +2 -2
- package/src/lib/server/session-tools/connector.ts +51 -4
- package/src/lib/server/session-tools/crud.ts +3 -3
- package/src/lib/server/session-tools/delegate.ts +3 -3
- package/src/lib/server/session-tools/file.ts +176 -3
- package/src/lib/server/session-tools/index.ts +2 -0
- package/src/lib/server/session-tools/memory.ts +2 -2
- package/src/lib/server/session-tools/openclaw-nodes.ts +112 -0
- package/src/lib/server/session-tools/sandbox.ts +33 -0
- package/src/lib/server/session-tools/search-providers.ts +270 -0
- package/src/lib/server/session-tools/session-info.ts +2 -2
- package/src/lib/server/session-tools/web.ts +47 -66
- package/src/lib/server/storage.ts +12 -0
- package/src/lib/server/stream-agent-chat.ts +29 -0
- package/src/lib/server/task-result.test.ts +44 -0
- package/src/lib/server/task-result.ts +14 -0
- package/src/lib/tool-definitions.ts +5 -3
- package/src/lib/tts-stream.ts +130 -0
- package/src/lib/view-routes.ts +28 -0
- package/src/proxy.ts +3 -0
- package/src/stores/use-app-store.ts +28 -1
- package/src/stores/use-chat-store.ts +9 -1
- 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,
|
|
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
|
-
|
|
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]
|
|
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
|
}
|