@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
@@ -1,4 +1,4 @@
1
- import crypto from 'crypto'
1
+ import { genId } from '@/lib/id'
2
2
  import {
3
3
  loadConnectors, saveConnectors, loadSessions, saveSessions,
4
4
  loadAgents, loadCredentials, decryptKey, loadSettings, loadSkills,
@@ -9,6 +9,17 @@ import { notify } from '../ws-hub'
9
9
  import { logExecution } from '../execution-log'
10
10
  import type { Connector } from '@/types'
11
11
  import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
12
+ import {
13
+ addAllowedSender,
14
+ approvePairingCode,
15
+ createOrTouchPairingRequest,
16
+ isSenderAllowed,
17
+ listPendingPairingRequests,
18
+ listStoredAllowedSenders,
19
+ parseAllowFromCsv,
20
+ parsePairingPolicy,
21
+ type PairingPolicy,
22
+ } from './pairing'
12
23
 
13
24
  /** Sentinel value agents return when no outbound reply should be sent */
14
25
  export const NO_MESSAGE_SENTINEL = 'NO_MESSAGE'
@@ -43,6 +54,7 @@ export async function getPlatform(platform: string) {
43
54
  case 'slack': return (await import('./slack')).default
44
55
  case 'whatsapp': return (await import('./whatsapp')).default
45
56
  case 'openclaw': return (await import('./openclaw')).default
57
+ case 'bluebubbles': return (await import('./bluebubbles')).default
46
58
  case 'signal': return (await import('./signal')).default
47
59
  case 'teams': return (await import('./teams')).default
48
60
  case 'googlechat': return (await import('./googlechat')).default
@@ -78,6 +90,310 @@ export function formatInboundUserText(msg: InboundMessage): string {
78
90
  return lines.join('\n').trim()
79
91
  }
80
92
 
93
+ type ConnectorCommandName = 'help' | 'status' | 'new' | 'reset' | 'compact' | 'think' | 'pair'
94
+
95
+ interface ParsedConnectorCommand {
96
+ name: ConnectorCommandName
97
+ args: string
98
+ }
99
+
100
+ function parseConnectorCommand(text: string): ParsedConnectorCommand | null {
101
+ const trimmed = text.trim()
102
+ if (!trimmed.startsWith('/')) return null
103
+ const [head, ...rest] = trimmed.split(/\s+/)
104
+ const name = head.slice(1).toLowerCase()
105
+ const args = rest.join(' ').trim()
106
+ switch (name) {
107
+ case 'help':
108
+ case 'status':
109
+ case 'new':
110
+ case 'reset':
111
+ case 'compact':
112
+ case 'think':
113
+ case 'pair':
114
+ return { name, args } as ParsedConnectorCommand
115
+ default:
116
+ return null
117
+ }
118
+ }
119
+
120
+ function pushSessionMessage(session: any, role: 'user' | 'assistant', text: string): void {
121
+ if (!text.trim()) return
122
+ if (!Array.isArray(session.messages)) session.messages = []
123
+ session.messages.push({ role, text: text.trim(), time: Date.now() })
124
+ session.lastActiveAt = Date.now()
125
+ }
126
+
127
+ function persistSession(session: any): void {
128
+ const sessions = loadSessions()
129
+ sessions[session.id] = session
130
+ saveSessions(sessions)
131
+ notify(`messages:${session.id}`)
132
+ }
133
+
134
+ function summarizeForCompaction(messages: Array<{ role?: string; text?: string }>): string {
135
+ const preview = messages
136
+ .slice(-8)
137
+ .map((m, i) => {
138
+ const role = (m.role || 'unknown').toUpperCase()
139
+ const body = (m.text || '').replace(/\s+/g, ' ').trim()
140
+ const clipped = body.length > 180 ? `${body.slice(0, 177)}...` : body
141
+ return `${i + 1}. [${role}] ${clipped || '(no text)'}`
142
+ })
143
+ if (!preview.length) return 'No earlier messages to summarize.'
144
+ return preview.join('\n')
145
+ }
146
+
147
+ function resolvePairingAccess(connector: Connector, msg: InboundMessage): {
148
+ policy: PairingPolicy
149
+ configAllowFrom: string[]
150
+ isAllowed: boolean
151
+ hasAnyApprover: boolean
152
+ } {
153
+ const policy = parsePairingPolicy(connector.config?.dmPolicy, 'open')
154
+ const configAllowFrom = parseAllowFromCsv(connector.config?.allowFrom)
155
+ const stored = listStoredAllowedSenders(connector.id)
156
+ const isAllowed = isSenderAllowed({
157
+ connectorId: connector.id,
158
+ senderId: msg.senderId,
159
+ configAllowFrom,
160
+ })
161
+ return {
162
+ policy,
163
+ configAllowFrom,
164
+ isAllowed,
165
+ hasAnyApprover: (configAllowFrom.length + stored.length) > 0,
166
+ }
167
+ }
168
+
169
+ async function handlePairCommand(params: {
170
+ connector: Connector
171
+ msg: InboundMessage
172
+ args: string
173
+ }): Promise<string> {
174
+ const { connector, msg, args } = params
175
+ const access = resolvePairingAccess(connector, msg)
176
+ const parts = args.split(/\s+/).map((item) => item.trim()).filter(Boolean)
177
+ const subcommand = (parts[0] || 'status').toLowerCase()
178
+
179
+ if (subcommand === 'request') {
180
+ const request = createOrTouchPairingRequest({
181
+ connectorId: connector.id,
182
+ senderId: msg.senderId,
183
+ senderName: msg.senderName,
184
+ channelId: msg.channelId,
185
+ })
186
+ return request.created
187
+ ? `Pairing request created. Share this code with an approved user: ${request.code}`
188
+ : `Pairing request is already pending. Your code is: ${request.code}`
189
+ }
190
+
191
+ if (subcommand === 'list') {
192
+ if (access.hasAnyApprover && !access.isAllowed) {
193
+ return 'Pairing list is restricted to approved senders.'
194
+ }
195
+ const pending = listPendingPairingRequests(connector.id)
196
+ if (!pending.length) return 'No pending pairing requests.'
197
+ const lines = pending.slice(0, 20).map((entry) => {
198
+ const ageMin = Math.max(1, Math.round((Date.now() - entry.updatedAt) / 60_000))
199
+ const sender = entry.senderName ? `${entry.senderName} (${entry.senderId})` : entry.senderId
200
+ return `- ${entry.code} -> ${sender} (${ageMin}m ago)`
201
+ })
202
+ return `Pending pairing requests (${pending.length}):\n${lines.join('\n')}`
203
+ }
204
+
205
+ if (subcommand === 'approve') {
206
+ const code = (parts[1] || '').trim()
207
+ if (!code) return 'Usage: /pair approve <code>'
208
+ if (access.hasAnyApprover && !access.isAllowed) {
209
+ return 'Pairing approvals are restricted to approved senders.'
210
+ }
211
+ const approved = approvePairingCode(connector.id, code)
212
+ if (!approved.ok) return approved.reason || 'Pairing approval failed.'
213
+ const sender = approved.senderName ? `${approved.senderName} (${approved.senderId})` : approved.senderId
214
+ return `Pairing approved: ${sender}`
215
+ }
216
+
217
+ if (subcommand === 'allow') {
218
+ const senderId = (parts[1] || '').trim()
219
+ if (!senderId) return 'Usage: /pair allow <senderId>'
220
+ if (access.hasAnyApprover && !access.isAllowed) {
221
+ return 'Allowlist updates are restricted to approved senders.'
222
+ }
223
+ const result = addAllowedSender(connector.id, senderId)
224
+ if (!result.normalized) return 'Could not parse senderId.'
225
+ return result.added
226
+ ? `Allowed sender: ${result.normalized}`
227
+ : `Sender is already allowed: ${result.normalized}`
228
+ }
229
+
230
+ const pending = listPendingPairingRequests(connector.id)
231
+ const stored = listStoredAllowedSenders(connector.id)
232
+ const policyLine = `Policy: ${access.policy}`
233
+ const approvedLine = `You are ${access.isAllowed ? 'approved' : 'not approved'} as ${msg.senderId}`
234
+ return [
235
+ 'Pairing controls:',
236
+ policyLine,
237
+ approvedLine,
238
+ `- Stored approvals: ${stored.length}`,
239
+ `- Pending requests: ${pending.length}`,
240
+ '- Commands: /pair request, /pair list, /pair approve <code>, /pair allow <senderId>',
241
+ ].join('\n')
242
+ }
243
+
244
+ function enforceInboundAccessPolicy(connector: Connector, msg: InboundMessage): string | null {
245
+ if (msg.isGroup) return null
246
+ const { policy, configAllowFrom, isAllowed } = resolvePairingAccess(connector, msg)
247
+ const storedAllowFrom = listStoredAllowedSenders(connector.id)
248
+ if (policy === 'open') return null
249
+
250
+ if (policy === 'disabled') return NO_MESSAGE_SENTINEL
251
+ if (isAllowed) return null
252
+
253
+ if (policy === 'allowlist') {
254
+ if (!configAllowFrom.length && !storedAllowFrom.length) {
255
+ return 'This connector is set to allowlist mode, but no allowFrom entries are configured.'
256
+ }
257
+ return 'You are not authorized for this connector. Ask an approved user to add your sender ID via /pair allow <senderId>.'
258
+ }
259
+
260
+ if (policy === 'pairing') {
261
+ const request = createOrTouchPairingRequest({
262
+ connectorId: connector.id,
263
+ senderId: msg.senderId,
264
+ senderName: msg.senderName,
265
+ channelId: msg.channelId,
266
+ })
267
+ return [
268
+ 'Pairing is required before this connector will respond.',
269
+ `Your pairing code: ${request.code}`,
270
+ 'Ask an approved sender to run /pair approve <code>.',
271
+ 'Tip: if this is first-time setup with no approvals yet, run /pair approve <code> from this chat to bootstrap.',
272
+ ].join('\n')
273
+ }
274
+
275
+ return null
276
+ }
277
+
278
+ async function handleConnectorCommand(params: {
279
+ command: ParsedConnectorCommand
280
+ connector: Connector
281
+ session: any
282
+ msg: InboundMessage
283
+ agentName: string
284
+ }): Promise<string> {
285
+ const { command, connector, session, msg, agentName } = params
286
+ const inboundText = formatInboundUserText(msg)
287
+
288
+ if (command.name === 'help') {
289
+ const text = [
290
+ 'Connector commands:',
291
+ '/status — Show active session status',
292
+ '/new or /reset — Clear this connector conversation thread',
293
+ '/compact [keepLastN] — Summarize older history and keep recent messages (default 10)',
294
+ '/think <minimal|low|medium|high> — Set connector thread reasoning guidance',
295
+ '/pair — Pairing/access controls (status, request, list, approve, allow)',
296
+ '/help — Show this list',
297
+ ].join('\n')
298
+ pushSessionMessage(session, 'user', inboundText)
299
+ pushSessionMessage(session, 'assistant', text)
300
+ persistSession(session)
301
+ return text
302
+ }
303
+
304
+ if (command.name === 'status') {
305
+ const all = Array.isArray(session.messages) ? session.messages : []
306
+ const userCount = all.filter((m: any) => m?.role === 'user').length
307
+ const assistantCount = all.filter((m: any) => m?.role === 'assistant').length
308
+ const toolsCount = Array.isArray(session.tools) ? session.tools.length : 0
309
+ const statusText = [
310
+ `Status for ${connector.platform} / ${connector.name}:`,
311
+ `- Agent: ${agentName}`,
312
+ `- Session: ${session.id}`,
313
+ `- Model: ${session.provider}/${session.model}`,
314
+ `- Messages: ${all.length} (${userCount} user, ${assistantCount} assistant)`,
315
+ `- Tools enabled: ${toolsCount}`,
316
+ `- Channel: ${msg.channelName || msg.channelId}`,
317
+ `- Last active: ${new Date(session.lastActiveAt || session.createdAt || Date.now()).toLocaleString()}`,
318
+ ].join('\n')
319
+ pushSessionMessage(session, 'user', inboundText)
320
+ pushSessionMessage(session, 'assistant', statusText)
321
+ persistSession(session)
322
+ return statusText
323
+ }
324
+
325
+ if (command.name === 'new' || command.name === 'reset') {
326
+ const cleared = Array.isArray(session.messages) ? session.messages.length : 0
327
+ session.messages = []
328
+ session.claudeSessionId = null
329
+ session.codexThreadId = null
330
+ session.opencodeSessionId = null
331
+ session.delegateResumeIds = { claudeCode: null, codex: null, opencode: null }
332
+ session.lastActiveAt = Date.now()
333
+ persistSession(session)
334
+ return `Reset complete for ${connector.platform} channel thread. Cleared ${cleared} message(s).`
335
+ }
336
+
337
+ if (command.name === 'compact') {
338
+ const keepParsed = Number.parseInt(command.args, 10)
339
+ const keepLastN = Number.isFinite(keepParsed) ? Math.max(4, Math.min(50, keepParsed)) : 10
340
+ const history = Array.isArray(session.messages) ? session.messages : []
341
+ if (history.length <= keepLastN) {
342
+ const text = `Nothing to compact. Current history has ${history.length} message(s), keepLastN=${keepLastN}.`
343
+ pushSessionMessage(session, 'user', inboundText)
344
+ pushSessionMessage(session, 'assistant', text)
345
+ persistSession(session)
346
+ return text
347
+ }
348
+ const oldMessages = history.slice(0, -keepLastN)
349
+ const recentMessages = history.slice(-keepLastN)
350
+ const summary = summarizeForCompaction(oldMessages)
351
+ const summaryMessage = {
352
+ role: 'assistant' as const,
353
+ text: `[Context summary: compacted ${oldMessages.length} message(s)]\n${summary}`,
354
+ time: Date.now(),
355
+ kind: 'system' as const,
356
+ }
357
+ session.messages = [summaryMessage, ...recentMessages]
358
+ session.lastActiveAt = Date.now()
359
+ const text = `Compacted ${oldMessages.length} message(s). Kept ${recentMessages.length} recent message(s) plus a summary.`
360
+ pushSessionMessage(session, 'assistant', text)
361
+ persistSession(session)
362
+ return text
363
+ }
364
+
365
+ if (command.name === 'think') {
366
+ const requested = command.args.trim().toLowerCase()
367
+ const allowed = new Set(['minimal', 'low', 'medium', 'high'])
368
+ if (!requested) {
369
+ const current = typeof session.connectorThinkLevel === 'string' && allowed.has(session.connectorThinkLevel)
370
+ ? session.connectorThinkLevel
371
+ : 'medium'
372
+ const text = `Current /think level: ${current}. Usage: /think <minimal|low|medium|high>.`
373
+ pushSessionMessage(session, 'user', inboundText)
374
+ pushSessionMessage(session, 'assistant', text)
375
+ persistSession(session)
376
+ return text
377
+ }
378
+ if (!allowed.has(requested)) {
379
+ const text = 'Invalid /think level. Use one of: minimal, low, medium, high.'
380
+ pushSessionMessage(session, 'user', inboundText)
381
+ pushSessionMessage(session, 'assistant', text)
382
+ persistSession(session)
383
+ return text
384
+ }
385
+ session.connectorThinkLevel = requested
386
+ session.lastActiveAt = Date.now()
387
+ const text = `Set /think level to ${requested} for this connector thread.`
388
+ pushSessionMessage(session, 'user', inboundText)
389
+ pushSessionMessage(session, 'assistant', text)
390
+ persistSession(session)
391
+ return text
392
+ }
393
+
394
+ return 'Unknown command.'
395
+ }
396
+
81
397
  /** Route an inbound message through the assigned agent and return the response */
82
398
  async function routeMessage(connector: Connector, msg: InboundMessage): Promise<string> {
83
399
  if (msg?.channelId) {
@@ -85,7 +401,8 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
85
401
  }
86
402
 
87
403
  const agents = loadAgents()
88
- const agent = agents[connector.agentId]
404
+ const effectiveAgentId = msg.agentIdOverride || connector.agentId
405
+ const agent = agents[effectiveAgentId]
89
406
  if (!agent) return '[Error] Connector agent not found.'
90
407
 
91
408
  // Log connector trigger
@@ -122,7 +439,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
122
439
  const sessions = loadSessions()
123
440
  let session = Object.values(sessions).find((s: any) => s.name === sessionKey)
124
441
  if (!session) {
125
- const id = crypto.randomBytes(4).toString('hex')
442
+ const id = genId()
126
443
  session = {
127
444
  id,
128
445
  name: sessionKey,
@@ -151,6 +468,59 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
151
468
  saveSessions(sessions)
152
469
  }
153
470
 
471
+ const parsedCommand = parseConnectorCommand(msg.text || '')
472
+ if (parsedCommand?.name === 'pair') {
473
+ const commandResult = await handlePairCommand({
474
+ connector,
475
+ msg,
476
+ args: parsedCommand.args,
477
+ })
478
+ logExecution(session.id, 'decision', 'Connector pair command handled', {
479
+ agentId: agent.id,
480
+ detail: {
481
+ platform: msg.platform,
482
+ channelId: msg.channelId,
483
+ command: 'pair',
484
+ args: parsedCommand.args || null,
485
+ },
486
+ })
487
+ return commandResult
488
+ }
489
+
490
+ const accessPolicyResult = enforceInboundAccessPolicy(connector, msg)
491
+ if (accessPolicyResult) {
492
+ logExecution(session.id, 'decision', 'Connector inbound blocked by access policy', {
493
+ agentId: agent.id,
494
+ detail: {
495
+ platform: msg.platform,
496
+ channelId: msg.channelId,
497
+ senderId: msg.senderId,
498
+ policy: parsePairingPolicy(connector.config?.dmPolicy, 'open'),
499
+ },
500
+ })
501
+ return accessPolicyResult
502
+ }
503
+
504
+ if (parsedCommand) {
505
+ const commandResult = await handleConnectorCommand({
506
+ command: parsedCommand,
507
+ connector,
508
+ session,
509
+ msg,
510
+ agentName: agent.name,
511
+ })
512
+ logExecution(session.id, 'decision', `Connector command handled: /${parsedCommand.name}`, {
513
+ agentId: agent.id,
514
+ detail: {
515
+ platform: msg.platform,
516
+ channelId: msg.channelId,
517
+ command: parsedCommand.name,
518
+ args: parsedCommand.args || null,
519
+ },
520
+ })
521
+ return commandResult
522
+ }
523
+
154
524
  // Build system prompt: [userPrompt] \n\n [soul] \n\n [systemPrompt]
155
525
  const settings = loadSettings()
156
526
  const promptParts: string[] = []
@@ -164,6 +534,12 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
164
534
  if (skill?.content) promptParts.push(`## Skill: ${skill.name}\n${skill.content}`)
165
535
  }
166
536
  }
537
+ const thinkLevel = typeof session.connectorThinkLevel === 'string'
538
+ ? session.connectorThinkLevel.trim().toLowerCase()
539
+ : ''
540
+ if (thinkLevel) {
541
+ promptParts.push(`Connector thinking guidance: ${thinkLevel}. Keep responses concise and useful for chat.`)
542
+ }
167
543
  // Add connector context
168
544
  promptParts.push(`\nYou are receiving messages via ${msg.platform}. The user "${msg.senderName}" is messaging from channel "${msg.channelName || msg.channelId}". Respond naturally and conversationally.
169
545
 
@@ -326,6 +702,9 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
326
702
  if (!botToken && connector.config.botToken) {
327
703
  botToken = connector.config.botToken
328
704
  }
705
+ if (!botToken && connector.platform === 'bluebubbles' && connector.config.password) {
706
+ botToken = connector.config.password
707
+ }
329
708
 
330
709
  if (!botToken && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal') {
331
710
  throw new Error('No bot token configured')
@@ -475,6 +854,11 @@ export function listRunningConnectors(platform?: string): Array<{
475
854
  if (outboundJid) configuredTargets.push(outboundJid)
476
855
  const allowed = connector.config?.allowedJids?.split(',').map((s) => s.trim()).filter(Boolean) || []
477
856
  configuredTargets.push(...allowed)
857
+ } else if (connector.platform === 'bluebubbles') {
858
+ const outbound = connector.config?.outboundTarget?.trim()
859
+ if (outbound) configuredTargets.push(outbound)
860
+ const allowed = connector.config?.allowFrom?.split(',').map((s) => s.trim()).filter(Boolean) || []
861
+ configuredTargets.push(...allowed)
478
862
  }
479
863
  out.push({
480
864
  id,
@@ -494,6 +878,11 @@ export function getConnectorRecentChannelId(connectorId: string): string | null
494
878
  return lastInboundChannelByConnector.get(connectorId) || null
495
879
  }
496
880
 
881
+ /** Get a running connector instance (internal use for rich messaging). */
882
+ export function getRunningInstance(connectorId: string): ConnectorInstance | undefined {
883
+ return running.get(connectorId)
884
+ }
885
+
497
886
  /**
498
887
  * Send an outbound message through a running connector.
499
888
  * Intended for proactive agent notifications (e.g. WhatsApp updates).
@@ -1,6 +1,6 @@
1
- import crypto from 'crypto'
2
1
  import fs from 'fs'
3
2
  import path from 'path'
3
+ import { genId } from '@/lib/id'
4
4
  import { UPLOAD_DIR } from '../storage'
5
5
  import type { InboundMedia, InboundMediaType } from './types'
6
6
 
@@ -94,7 +94,7 @@ export function saveInboundMediaBuffer(params: {
94
94
 
95
95
  const ext = extFromName(params.fileName) || extFromMime(params.mimeType) || '.bin'
96
96
  const base = safeBaseName(params.fileName)
97
- const unique = crypto.randomBytes(4).toString('hex')
97
+ const unique = genId()
98
98
  const filename = `${params.connectorId}-${Date.now()}-${base}-${unique}${ext}`
99
99
  const localPath = path.join(UPLOAD_DIR, filename)
100
100
  fs.writeFileSync(localPath, params.buffer)
@@ -827,6 +827,13 @@ const openclaw: PlatformConnector = {
827
827
  if (identity.deviceToken === normalized) return
828
828
  identity = { ...identity, deviceToken: normalized }
829
829
  persistIdentity(identityPath, identity)
830
+ // Cross-sync device token for provider identity resolution
831
+ if (normalized) {
832
+ try {
833
+ const { setSharedDeviceToken } = require('../openclaw-sync')
834
+ setSharedDeviceToken(normalized)
835
+ } catch { /* openclaw-sync not available */ }
836
+ }
830
837
  }
831
838
 
832
839
  function clearStaleTokenIfNeeded(reason?: string) {
@@ -928,6 +935,23 @@ const openclaw: PlatformConnector = {
928
935
  source: 'event' | 'history',
929
936
  ) {
930
937
  if (!matchesSessionKey(configuredSessionFilter, inbound.channelId)) return
938
+
939
+ // Multi-agent routing: match sessionKey against agentRouting config
940
+ const agentRouting = connector.config.agentRouting
941
+ if (agentRouting) {
942
+ try {
943
+ const routingMap: Record<string, string> = typeof agentRouting === 'string'
944
+ ? JSON.parse(agentRouting)
945
+ : agentRouting as unknown as Record<string, string>
946
+ for (const [pattern, agentId] of Object.entries(routingMap)) {
947
+ if (matchesSessionKey(pattern, inbound.channelId)) {
948
+ inbound.agentIdOverride = agentId
949
+ break
950
+ }
951
+ }
952
+ } catch { /* ignore malformed routing config */ }
953
+ }
954
+
931
955
  if (!rememberSeenEntry(seenInbound, dedupeKey, MAX_SEEN_CHAT_EVENTS)) return
932
956
 
933
957
  const now = Date.now()
@@ -1120,6 +1144,46 @@ const openclaw: PlatformConnector = {
1120
1144
  if (!connected) throw new Error('openclaw connector is not connected')
1121
1145
  await sendChat(channelId || defaultSessionKey, text, options)
1122
1146
  },
1147
+ async sendReaction(channelId, messageId, emoji) {
1148
+ if (!connected) throw new Error('openclaw connector is not connected')
1149
+ try {
1150
+ await rpcRequest('chat.react', { sessionKey: channelId || defaultSessionKey, messageId, emoji })
1151
+ } catch (err: unknown) {
1152
+ const msg = getErrorMessage(err)
1153
+ if (msg.toLowerCase().includes('unknown method')) return // graceful degrade
1154
+ throw err
1155
+ }
1156
+ },
1157
+ async editMessage(channelId, messageId, newText) {
1158
+ if (!connected) throw new Error('openclaw connector is not connected')
1159
+ try {
1160
+ await rpcRequest('chat.edit', { sessionKey: channelId || defaultSessionKey, messageId, text: newText })
1161
+ } catch (err: unknown) {
1162
+ const msg = getErrorMessage(err)
1163
+ if (msg.toLowerCase().includes('unknown method')) return
1164
+ throw err
1165
+ }
1166
+ },
1167
+ async deleteMessage(channelId, messageId) {
1168
+ if (!connected) throw new Error('openclaw connector is not connected')
1169
+ try {
1170
+ await rpcRequest('chat.delete', { sessionKey: channelId || defaultSessionKey, messageId })
1171
+ } catch (err: unknown) {
1172
+ const msg = getErrorMessage(err)
1173
+ if (msg.toLowerCase().includes('unknown method')) return
1174
+ throw err
1175
+ }
1176
+ },
1177
+ async pinMessage(channelId, messageId) {
1178
+ if (!connected) throw new Error('openclaw connector is not connected')
1179
+ try {
1180
+ await rpcRequest('chat.pin', { sessionKey: channelId || defaultSessionKey, messageId })
1181
+ } catch (err: unknown) {
1182
+ const msg = getErrorMessage(err)
1183
+ if (msg.toLowerCase().includes('unknown method')) return
1184
+ throw err
1185
+ }
1186
+ },
1123
1187
  async stop() {
1124
1188
  stopped = true
1125
1189
  cleanupSocket()
@@ -0,0 +1,99 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { test } from 'node:test'
6
+ import {
7
+ addAllowedSender,
8
+ approvePairingCode,
9
+ clearConnectorPairingState,
10
+ createOrTouchPairingRequest,
11
+ isSenderAllowed,
12
+ listPendingPairingRequests,
13
+ listStoredAllowedSenders,
14
+ parseAllowFromCsv,
15
+ parsePairingPolicy,
16
+ } from './pairing.ts'
17
+
18
+ function withTempDataDir<T>(fn: (dir: string) => T): T {
19
+ const original = process.env.DATA_DIR
20
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'swarmclaw-pairing-test-'))
21
+ process.env.DATA_DIR = tempDir
22
+ try {
23
+ return fn(tempDir)
24
+ } finally {
25
+ if (typeof original === 'string') process.env.DATA_DIR = original
26
+ else delete process.env.DATA_DIR
27
+ fs.rmSync(tempDir, { recursive: true, force: true })
28
+ }
29
+ }
30
+
31
+ test('pairing store creates request, approves code, and persists allowlist', () => {
32
+ withTempDataDir(() => {
33
+ const connectorId = 'pair-test-1'
34
+
35
+ const first = createOrTouchPairingRequest({
36
+ connectorId,
37
+ senderId: '+15551234567',
38
+ senderName: 'Alice',
39
+ channelId: 'chat:1',
40
+ })
41
+ assert.equal(first.created, true)
42
+ assert.equal(first.code.length, 8)
43
+
44
+ const second = createOrTouchPairingRequest({
45
+ connectorId,
46
+ senderId: '+15551234567',
47
+ senderName: 'Alice',
48
+ channelId: 'chat:1',
49
+ })
50
+ assert.equal(second.created, false)
51
+ assert.equal(second.code, first.code)
52
+
53
+ const pendingBefore = listPendingPairingRequests(connectorId)
54
+ assert.equal(pendingBefore.length, 1)
55
+ assert.equal(pendingBefore[0].senderId, '+15551234567')
56
+
57
+ const bad = approvePairingCode(connectorId, 'INVALID')
58
+ assert.equal(bad.ok, false)
59
+
60
+ const approved = approvePairingCode(connectorId, first.code)
61
+ assert.equal(approved.ok, true)
62
+ assert.equal(approved.senderId, '+15551234567')
63
+
64
+ const pendingAfter = listPendingPairingRequests(connectorId)
65
+ assert.equal(pendingAfter.length, 0)
66
+
67
+ const stored = listStoredAllowedSenders(connectorId)
68
+ assert.deepEqual(stored, ['+15551234567'])
69
+
70
+ assert.equal(isSenderAllowed({ connectorId, senderId: '+15551234567' }), true)
71
+ assert.equal(isSenderAllowed({ connectorId, senderId: '+16667778888' }), false)
72
+
73
+ clearConnectorPairingState(connectorId)
74
+ assert.deepEqual(listStoredAllowedSenders(connectorId), [])
75
+ })
76
+ })
77
+
78
+ test('pairing helpers normalize policy and allowFrom csv entries', () => {
79
+ assert.equal(parsePairingPolicy('PAIRING'), 'pairing')
80
+ assert.equal(parsePairingPolicy('allowlist'), 'allowlist')
81
+ assert.equal(parsePairingPolicy('unknown', 'open'), 'open')
82
+
83
+ const list = parseAllowFromCsv(' +1555,TEST@example.com,+1555 , ')
84
+ assert.deepEqual(list, ['+1555', 'test@example.com'])
85
+ })
86
+
87
+ test('addAllowedSender deduplicates and normalizes sender ids', () => {
88
+ withTempDataDir(() => {
89
+ const connectorId = 'pair-test-2'
90
+ const first = addAllowedSender(connectorId, ' TEST@Example.com ')
91
+ assert.equal(first.added, true)
92
+ assert.equal(first.normalized, 'test@example.com')
93
+
94
+ const second = addAllowedSender(connectorId, 'test@example.com')
95
+ assert.equal(second.added, false)
96
+
97
+ assert.deepEqual(listStoredAllowedSenders(connectorId), ['test@example.com'])
98
+ })
99
+ })