@swarmclawai/swarmclaw 0.6.2 → 0.6.3

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.
@@ -3,49 +3,303 @@ import { tool, type StructuredToolInterface } from '@langchain/core/tools'
3
3
  import path from 'path'
4
4
  import fs from 'fs'
5
5
  import { loadConnectors, loadSettings, UPLOAD_DIR } from '../storage'
6
+ import { genId } from '@/lib/id'
7
+ import { synthesizeElevenLabsMp3 } from '../elevenlabs'
6
8
  import type { ToolBuildContext } from './context'
7
9
 
10
+ const CONNECTOR_ACTION_DEDUPE_TTL_MS = 30_000
11
+ const CONNECTOR_TURN_SEND_TTL_MS = 180_000
12
+ const AUTONOMOUS_OUTREACH_COOLDOWN_MS = 2 * 60 * 60 * 1000
13
+ const recentConnectorActionCache = new Map<string, { at: number; result: string }>()
14
+ const connectorTurnSendBudget = new Map<string, { count: number; at: number; lastResult?: string }>()
15
+ const autonomousOutreachBudget = new Map<string, { at: number; result?: string }>()
16
+
17
+ function pruneOldConnectorToolState(now: number): void {
18
+ for (const [key, entry] of recentConnectorActionCache.entries()) {
19
+ if (now - entry.at > CONNECTOR_ACTION_DEDUPE_TTL_MS) recentConnectorActionCache.delete(key)
20
+ }
21
+ for (const [key, entry] of connectorTurnSendBudget.entries()) {
22
+ if (now - entry.at > CONNECTOR_TURN_SEND_TTL_MS) connectorTurnSendBudget.delete(key)
23
+ }
24
+ for (const [key, entry] of autonomousOutreachBudget.entries()) {
25
+ if (now - entry.at > AUTONOMOUS_OUTREACH_COOLDOWN_MS) autonomousOutreachBudget.delete(key)
26
+ }
27
+ }
28
+
29
+ function parseLatestUserTurn(
30
+ session: { messages?: Array<Record<string, unknown>> } | null | undefined,
31
+ ): { text: string; time: number } {
32
+ const msgs = Array.isArray(session?.messages) ? session.messages : []
33
+ for (let i = msgs.length - 1; i >= 0; i -= 1) {
34
+ const msg = msgs[i]
35
+ if (String(msg?.role || '') !== 'user') continue
36
+ const text = typeof msg.text === 'string' ? msg.text.trim() : ''
37
+ const time = typeof msg.time === 'number' ? msg.time : 0
38
+ return { text, time }
39
+ }
40
+ return { text: '', time: 0 }
41
+ }
42
+
43
+ function userExplicitlyWantsMultipleOutbound(userText: string): boolean {
44
+ if (!userText) return false
45
+ const text = userText.toLowerCase()
46
+ return /\b(both|multiple|all of them|all numbers|two messages|three messages|each number|every number|and also|plus also|send again|resend)\b/.test(text)
47
+ }
48
+
49
+ function userExplicitlyRequestedFollowup(userText: string): boolean {
50
+ if (!userText) return false
51
+ const text = userText.toLowerCase()
52
+ if (/connector_message_tool/.test(text) && /(schedule_followup|followupmessage|followup|delaysec|follow.?up)/.test(text)) return true
53
+ return /\b(follow[ -]?up|check[ -]?in|remind(?: me)?|later|tomorrow|in \d+\s*(sec|secs|second|seconds|min|mins|minute|minutes|hour|hours|day|days))\b/.test(text)
54
+ }
55
+
56
+ function isAutonomousSystemTurn(userText: string): boolean {
57
+ if (!userText) return false
58
+ const text = userText.toUpperCase()
59
+ return text.includes('AGENT_HEARTBEAT_WAKE')
60
+ || text.includes('SWARM_MAIN_MISSION_TICK')
61
+ || text.includes('SWARM_MAIN_AUTO_FOLLOWUP')
62
+ || text.includes('SWARM_HEARTBEAT_CHECK')
63
+ }
64
+
65
+ function isSignificantOutreachText(raw: string): boolean {
66
+ const text = (raw || '').trim().toLowerCase()
67
+ if (text.length < 12) return false
68
+ if (/\b(just checking in|checking in|touching base|quick check-in|hope you'?re well|any updates\??)\b/.test(text)) {
69
+ return false
70
+ }
71
+ return /\b(completed|complete|done|finished|failed|failure|error|blocked|urgent|important|deadline|overdue|incident|warning|reminder|birthday|anniversary|milestone|congrats|congratulations|celebrate|payment|invoice|appointment|meeting)\b/.test(text)
72
+ }
73
+
74
+ function isUrgentOutreachText(raw: string): boolean {
75
+ const text = (raw || '').toLowerCase()
76
+ return /\b(urgent|immediately|asap|critical|incident|outage|failed|failure|blocked|overdue|deadline)\b/.test(text)
77
+ }
78
+
79
+ function buildConnectorActionKey(parts: Array<string | number | boolean | null | undefined>): string {
80
+ return parts.map((part) => String(part ?? '')).join('|')
81
+ }
82
+
83
+ function normalizeDedupedReplayResult(raw: string, fallback: { connectorId: string; platform: string; to: string }): string {
84
+ try {
85
+ const parsed = JSON.parse(raw)
86
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('invalid')
87
+ const record = parsed as Record<string, unknown>
88
+ if (String(record.status || '') === 'deduped') {
89
+ return JSON.stringify({
90
+ status: 'sent',
91
+ connectorId: String(record.connectorId || fallback.connectorId),
92
+ platform: String(record.platform || fallback.platform),
93
+ to: String(record.to || fallback.to),
94
+ deduped: true,
95
+ })
96
+ }
97
+ return raw
98
+ } catch {
99
+ return JSON.stringify({
100
+ status: 'sent',
101
+ connectorId: fallback.connectorId,
102
+ platform: fallback.platform,
103
+ to: fallback.to,
104
+ deduped: true,
105
+ })
106
+ }
107
+ }
108
+
8
109
  /** Resolve /api/uploads/filename URLs to actual disk paths */
9
110
  function resolveUploadUrl(url: string | undefined): { mediaPath: string; mimeType?: string } | null {
10
111
  if (!url) return null
11
112
  const match = url.match(/^\/api\/uploads\/([^?#]+)/)
12
113
  if (!match) return null
13
- const safeName = match[1].replace(/[^a-zA-Z0-9._-]/g, '')
114
+ // Decode URL-encoded filenames (e.g. from encodeURIComponent) before sanitizing
115
+ let decoded: string
116
+ try { decoded = decodeURIComponent(match[1]) } catch { decoded = match[1] }
117
+ const safeName = decoded.replace(/[^a-zA-Z0-9._-]/g, '')
14
118
  const filePath = path.join(UPLOAD_DIR, safeName)
15
119
  if (!fs.existsSync(filePath)) return null
16
120
  return { mediaPath: filePath }
17
121
  }
18
122
 
123
+ function normalizeWhatsAppTarget(input: string): string {
124
+ const raw = input.trim()
125
+ if (!raw) return raw
126
+ if (raw.includes('@')) return raw
127
+ let cleaned = raw.replace(/[^\d+]/g, '')
128
+ if (cleaned.startsWith('+')) cleaned = cleaned.slice(1)
129
+ if (cleaned.startsWith('0') && cleaned.length >= 10) {
130
+ cleaned = `44${cleaned.slice(1)}`
131
+ }
132
+ cleaned = cleaned.replace(/[^\d]/g, '')
133
+ return cleaned ? `${cleaned}@s.whatsapp.net` : raw
134
+ }
135
+
136
+ function parseCsv(raw: string | undefined): string[] {
137
+ if (!raw) return []
138
+ return raw.split(',').map((s) => s.trim()).filter(Boolean)
139
+ }
140
+
141
+ function pickChannelTarget(params: {
142
+ connector: { config?: Record<string, string> }
143
+ to?: string
144
+ recentChannelId: string | null
145
+ }): { channelId: string; error?: string } {
146
+ let channelId = params.to?.trim() || ''
147
+ const connector = params.connector
148
+
149
+ if (!channelId) {
150
+ const outbound = connector.config?.outboundJid?.trim()
151
+ if (outbound) channelId = outbound
152
+ }
153
+ if (!channelId) {
154
+ const outbound = connector.config?.outboundTarget?.trim()
155
+ if (outbound) channelId = outbound
156
+ }
157
+ if (!channelId && params.recentChannelId) {
158
+ channelId = params.recentChannelId
159
+ }
160
+ if (!channelId) {
161
+ const allowed = parseCsv(connector.config?.allowedJids)
162
+ if (allowed.length) channelId = allowed[0]
163
+ }
164
+ if (!channelId) {
165
+ const allowed = parseCsv(connector.config?.allowFrom)
166
+ if (allowed.length) channelId = allowed[0]
167
+ }
168
+ if (!channelId) {
169
+ const knownTargets = [
170
+ connector.config?.outboundJid?.trim(),
171
+ connector.config?.outboundTarget?.trim(),
172
+ ...parseCsv(connector.config?.allowedJids),
173
+ ...parseCsv(connector.config?.allowFrom),
174
+ ].filter(Boolean) as string[]
175
+ const unique = [...new Set(knownTargets)]
176
+ if (unique.length) {
177
+ return {
178
+ channelId: '',
179
+ error: `Error: no default outbound target is set, but the connector has ${unique.length} configured number(s)/target(s): ${JSON.stringify(unique)}. Ask the user which one to send to, then re-call with the "to" parameter set to their choice.`,
180
+ }
181
+ }
182
+ return {
183
+ channelId: '',
184
+ error: 'Error: no target recipient configured and no known contacts on this connector. Ask the user for the recipient number/ID, then re-call with the "to" parameter. They can also configure "allowedJids" or "outboundJid" in the connector settings.',
185
+ }
186
+ }
187
+ return { channelId }
188
+ }
189
+
190
+ function resolveConnectorMediaInput(params: {
191
+ cwd: string
192
+ mediaPath?: string
193
+ imageUrl?: string
194
+ fileUrl?: string
195
+ }): { mediaPath?: string; imageUrl?: string; fileUrl?: string; error?: string } {
196
+ let resolvedMediaPath = params.mediaPath?.trim() || undefined
197
+ let resolvedImageUrl = params.imageUrl?.trim() || undefined
198
+ let resolvedFileUrl = params.fileUrl?.trim() || undefined
199
+
200
+ if (resolvedMediaPath && !path.isAbsolute(resolvedMediaPath) && !resolvedMediaPath.startsWith('/api/uploads/')) {
201
+ const candidatePaths = [
202
+ path.resolve(params.cwd, resolvedMediaPath),
203
+ path.resolve(params.cwd, 'uploads', resolvedMediaPath),
204
+ path.join(UPLOAD_DIR, resolvedMediaPath),
205
+ path.join(UPLOAD_DIR, path.basename(resolvedMediaPath)),
206
+ ]
207
+ const found = candidatePaths.find((p) => fs.existsSync(p))
208
+ if (found) {
209
+ resolvedMediaPath = found
210
+ } else {
211
+ return {
212
+ error: `Error: File not found. Tried: ${candidatePaths.join(', ')}. Use an absolute path or ensure the file exists in the session workspace.`,
213
+ }
214
+ }
215
+ }
216
+
217
+ if (!resolvedMediaPath) {
218
+ const fromImage = resolveUploadUrl(resolvedImageUrl)
219
+ if (fromImage) {
220
+ resolvedMediaPath = fromImage.mediaPath
221
+ resolvedImageUrl = undefined
222
+ }
223
+ const fromFile = resolveUploadUrl(resolvedFileUrl)
224
+ if (fromFile) {
225
+ resolvedMediaPath = fromFile.mediaPath
226
+ resolvedFileUrl = undefined
227
+ }
228
+ }
229
+
230
+ return {
231
+ mediaPath: resolvedMediaPath,
232
+ imageUrl: resolvedImageUrl,
233
+ fileUrl: resolvedFileUrl,
234
+ }
235
+ }
236
+
19
237
  export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInterface[] {
20
238
  const tools: StructuredToolInterface[] = []
21
- const { ctx, hasTool } = bctx
239
+ const { hasTool } = bctx
22
240
 
23
241
  if (hasTool('manage_connectors')) {
242
+ const settings = loadSettings()
243
+ const hasElevenLabsKey = !!String(settings.elevenLabsApiKey || process.env.ELEVENLABS_API_KEY || '').trim()
244
+ const voiceNoteToolEnabled = settings.elevenLabsEnabled === true && hasElevenLabsKey
245
+ const actionSchema = voiceNoteToolEnabled
246
+ ? z.enum([
247
+ 'list_running',
248
+ 'list_targets',
249
+ 'start',
250
+ 'stop',
251
+ 'send',
252
+ 'send_voice_note',
253
+ 'schedule_followup',
254
+ 'message_react',
255
+ 'message_edit',
256
+ 'message_delete',
257
+ 'message_pin',
258
+ ] as const)
259
+ : z.enum([
260
+ 'list_running',
261
+ 'list_targets',
262
+ 'start',
263
+ 'stop',
264
+ 'send',
265
+ 'schedule_followup',
266
+ 'message_react',
267
+ 'message_edit',
268
+ 'message_delete',
269
+ 'message_pin',
270
+ ] as const)
24
271
  tools.push(
25
272
  tool(
26
- async ({ action, connectorId, platform, to, message, imageUrl, fileUrl, mediaPath, mimeType, fileName, caption, approved }) => {
273
+ async ({
274
+ action,
275
+ connectorId,
276
+ platform,
277
+ to,
278
+ message,
279
+ voiceText,
280
+ voiceId,
281
+ imageUrl,
282
+ fileUrl,
283
+ mediaPath,
284
+ mimeType,
285
+ fileName,
286
+ caption,
287
+ delaySec,
288
+ followUpMessage,
289
+ followUpDelaySec,
290
+ approved,
291
+ ptt,
292
+ }) => {
27
293
  try {
28
- const normalizeWhatsAppTarget = (input: string): string => {
29
- const raw = input.trim()
30
- if (!raw) return raw
31
- if (raw.includes('@')) return raw
32
- let cleaned = raw.replace(/[^\d+]/g, '')
33
- if (cleaned.startsWith('+')) cleaned = cleaned.slice(1)
34
- if (cleaned.startsWith('0') && cleaned.length >= 10) {
35
- cleaned = '44' + cleaned.slice(1)
36
- }
37
- cleaned = cleaned.replace(/[^\d]/g, '')
38
- return cleaned ? `${cleaned}@s.whatsapp.net` : raw
39
- }
40
-
41
- const { listRunningConnectors, sendConnectorMessage, getConnectorRecentChannelId } = await import('../connectors/manager')
294
+ const actionName = String(action)
295
+ const { listRunningConnectors, sendConnectorMessage, getConnectorRecentChannelId, scheduleConnectorFollowUp } = await import('../connectors/manager')
42
296
  const running = listRunningConnectors(platform || undefined)
43
297
 
44
- if (action === 'list_running' || action === 'list_targets') {
298
+ if (actionName === 'list_running' || actionName === 'list_targets') {
45
299
  return JSON.stringify(running)
46
300
  }
47
301
 
48
- if (action === 'start') {
302
+ if (actionName === 'start') {
49
303
  if (!connectorId) {
50
304
  // If no ID given, list available connectors to start
51
305
  const allConnectors = loadConnectors()
@@ -61,121 +315,305 @@ export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInter
61
315
  return JSON.stringify({ status: 'started', connectorId })
62
316
  }
63
317
 
64
- if (action === 'stop') {
318
+ if (actionName === 'stop') {
65
319
  if (!connectorId) return 'Error: connectorId is required for stop action.'
66
320
  const { stopConnector: doStop } = await import('../connectors/manager')
67
321
  await doStop(connectorId)
68
322
  return JSON.stringify({ status: 'stopped', connectorId })
69
323
  }
70
324
 
71
- if (action === 'send') {
72
- const settings = loadSettings()
73
- if (settings.safetyRequireApprovalForOutbound === true && approved !== true) {
74
- return 'Error: outbound connector sends require explicit approval. Re-run with approved=true after user confirmation.'
75
- }
76
- const hasText = !!message?.trim()
77
- const hasMedia = !!imageUrl?.trim() || !!fileUrl?.trim()
78
- if (!hasText && !hasMedia) return 'Error: message or media URL is required for send action.'
325
+ const resolveSelectedConnector = () => {
79
326
  if (!running.length) {
80
- // Check for configured-but-not-running connectors to give actionable feedback
81
327
  const allConnectors = loadConnectors()
82
328
  const configured = Object.values(allConnectors)
83
329
  .filter((c) => !platform || c.platform === platform)
84
330
  .map((c) => ({ id: c.id, name: c.name, platform: c.platform, agentId: c.agentId || null }))
85
331
  if (configured.length) {
86
- return `Error: no running connectors${platform ? ` for platform "${platform}"` : ''}, but ${configured.length} configured connector(s) found: ${JSON.stringify(configured)}. These connectors exist but are not currently started. Ask the user if they'd like you to start one (use action "start" with the connectorId), then retry the send.`
332
+ return {
333
+ error: `Error: no running connectors${platform ? ` for platform "${platform}"` : ''}, but ${configured.length} configured connector(s) found: ${JSON.stringify(configured)}. These connectors exist but are not currently started. Ask the user if they'd like you to start one (use action "start" with the connectorId), then retry the send.`,
334
+ }
335
+ }
336
+ return {
337
+ error: `Error: no running connectors${platform ? ` for platform "${platform}"` : ''}. No connectors are configured for this platform either — the user needs to set one up in the Connectors panel first.`,
87
338
  }
88
- return `Error: no running connectors${platform ? ` for platform "${platform}"` : ''}. No connectors are configured for this platform either — the user needs to set one up in the Connectors panel first.`
89
339
  }
90
-
91
340
  const selected = connectorId
92
341
  ? running.find((c) => c.id === connectorId)
93
342
  : running[0]
94
- if (!selected) return `Error: running connector not found: ${connectorId}`
95
-
343
+ if (!selected) return { error: `Error: running connector not found: ${connectorId}` }
96
344
  const connectors = loadConnectors()
97
345
  const connector = connectors[selected.id]
98
- if (!connector) return `Error: connector not found: ${selected.id}`
346
+ if (!connector) return { error: `Error: connector not found: ${selected.id}` }
347
+ return { selected, connector }
348
+ }
99
349
 
100
- let channelId = to?.trim() || ''
101
- if (!channelId) {
102
- const outbound = connector.config?.outboundJid?.trim()
103
- if (outbound) channelId = outbound
104
- }
105
- if (!channelId) {
106
- const outbound = connector.config?.outboundTarget?.trim()
107
- if (outbound) channelId = outbound
350
+ if (actionName === 'send' || actionName === 'send_voice_note' || actionName === 'schedule_followup') {
351
+ const settings = loadSettings()
352
+ if (settings.safetyRequireApprovalForOutbound === true && approved !== true) {
353
+ return 'Error: outbound connector sends require explicit approval. Re-run with approved=true after user confirmation.'
108
354
  }
109
- if (!channelId) {
110
- const recentChannelId = getConnectorRecentChannelId(selected.id)
111
- if (recentChannelId) channelId = recentChannelId
355
+ const now = Date.now()
356
+ pruneOldConnectorToolState(now)
357
+ const resolved = resolveSelectedConnector()
358
+ if ('error' in resolved) return resolved.error
359
+ const { selected, connector } = resolved
360
+
361
+ const target = pickChannelTarget({
362
+ connector,
363
+ to,
364
+ recentChannelId: getConnectorRecentChannelId(selected.id),
365
+ })
366
+ if (target.error) return target.error
367
+
368
+ let channelId = target.channelId
369
+ if (connector.platform === 'whatsapp') channelId = normalizeWhatsAppTarget(channelId)
370
+
371
+ const currentSession = bctx.resolveCurrentSession()
372
+ const latestUserTurn = parseLatestUserTurn(currentSession)
373
+ const sessionId = bctx.ctx?.sessionId || currentSession?.id || 'unknown-session'
374
+ const turnKey = buildConnectorActionKey([sessionId, latestUserTurn.time || 'no-user-turn'])
375
+ const multiOutboundAllowed = userExplicitlyWantsMultipleOutbound(latestUserTurn.text)
376
+ const followupExplicitlyRequested = userExplicitlyRequestedFollowup(latestUserTurn.text)
377
+ const autonomousTurn = isAutonomousSystemTurn(latestUserTurn.text)
378
+ const existingBudget = connectorTurnSendBudget.get(turnKey)
379
+ if (
380
+ !multiOutboundAllowed
381
+ && existingBudget
382
+ && now - existingBudget.at <= CONNECTOR_TURN_SEND_TTL_MS
383
+ && existingBudget.count >= 1
384
+ ) {
385
+ if (existingBudget.lastResult) {
386
+ return normalizeDedupedReplayResult(existingBudget.lastResult, {
387
+ connectorId: selected.id,
388
+ platform: selected.platform,
389
+ to: channelId,
390
+ })
391
+ }
392
+ return JSON.stringify({
393
+ status: 'sent',
394
+ connectorId: selected.id,
395
+ platform: selected.platform,
396
+ to: channelId,
397
+ deduped: true,
398
+ })
112
399
  }
113
- if (!channelId) {
114
- const allowed = connector.config?.allowedJids?.split(',').map((s: string) => s.trim()).filter(Boolean) || []
115
- if (allowed.length) channelId = allowed[0]
400
+
401
+ if (actionName === 'send_voice_note') {
402
+ if (!voiceNoteToolEnabled) {
403
+ return 'Error: send_voice_note is unavailable. Enable ElevenLabs in Settings > Voice and set a valid API key.'
404
+ }
405
+ const ttsText = (voiceText || message || '').trim()
406
+ if (!ttsText) return 'Error: voiceText or message is required for send_voice_note action.'
407
+ const voiceActionKey = buildConnectorActionKey([
408
+ sessionId,
409
+ actionName,
410
+ selected.id,
411
+ channelId,
412
+ ttsText,
413
+ voiceId?.trim() || '',
414
+ fileName?.trim() || '',
415
+ caption?.trim() || '',
416
+ ptt ?? true,
417
+ ])
418
+ const cachedVoice = recentConnectorActionCache.get(voiceActionKey)
419
+ if (cachedVoice && now - cachedVoice.at <= CONNECTOR_ACTION_DEDUPE_TTL_MS) {
420
+ return cachedVoice.result
421
+ }
422
+ const audioBuffer = await synthesizeElevenLabsMp3({ text: ttsText, voiceId: voiceId?.trim() || undefined })
423
+ const voiceFileName = `${Date.now()}-${genId()}-voicenote.mp3`
424
+ const voicePath = path.join(UPLOAD_DIR, voiceFileName)
425
+ fs.writeFileSync(voicePath, audioBuffer)
426
+
427
+ const sent = await sendConnectorMessage({
428
+ connectorId: selected.id,
429
+ channelId,
430
+ text: '',
431
+ mediaPath: voicePath,
432
+ mimeType: 'audio/mpeg',
433
+ fileName: fileName?.trim() || 'voicenote.mp3',
434
+ caption: caption?.trim() || undefined,
435
+ ptt: ptt ?? true,
436
+ })
437
+ const result = JSON.stringify({
438
+ status: 'voice_sent',
439
+ connectorId: sent.connectorId,
440
+ platform: sent.platform,
441
+ to: sent.channelId,
442
+ messageId: sent.messageId || null,
443
+ voiceFile: voicePath,
444
+ })
445
+ connectorTurnSendBudget.set(turnKey, {
446
+ count: (existingBudget?.count || 0) + 1,
447
+ at: now,
448
+ lastResult: result,
449
+ })
450
+ recentConnectorActionCache.set(voiceActionKey, { at: now, result })
451
+ return result
116
452
  }
117
- if (!channelId) {
118
- const allowed = connector.config?.allowFrom?.split(',').map((s: string) => s.trim()).filter(Boolean) || []
119
- if (allowed.length) channelId = allowed[0]
453
+
454
+ const media = resolveConnectorMediaInput({
455
+ cwd: bctx.cwd,
456
+ mediaPath,
457
+ imageUrl,
458
+ fileUrl,
459
+ })
460
+ if (media.error) return media.error
461
+
462
+ const hasText = !!message?.trim()
463
+ const hasMedia = !!media.mediaPath || !!media.imageUrl || !!media.fileUrl
464
+ if (actionName === 'send' && !hasText && !hasMedia) {
465
+ return 'Error: message, media URL, or mediaPath is required for send action.'
120
466
  }
121
- if (!channelId) {
122
- // Collect any known numbers/targets from config to help the agent suggest them
123
- const knownTargets: string[] = []
124
- const jids = connector.config?.allowedJids?.split(',').map((s: string) => s.trim()).filter(Boolean) || []
125
- const from = connector.config?.allowFrom?.split(',').map((s: string) => s.trim()).filter(Boolean) || []
126
- const outJid = connector.config?.outboundJid?.trim()
127
- const outTarget = connector.config?.outboundTarget?.trim()
128
- if (outJid) knownTargets.push(outJid)
129
- if (outTarget) knownTargets.push(outTarget)
130
- knownTargets.push(...jids, ...from)
131
- const unique = [...new Set(knownTargets)]
132
- if (unique.length) {
133
- return `Error: no default outbound target is set, but the connector has ${unique.length} configured number(s)/target(s): ${JSON.stringify(unique)}. Ask the user which one to send to, then re-call with the "to" parameter set to their choice.`
467
+
468
+ let followUpText = followUpMessage?.trim() || ''
469
+ const followDelaySec = Number.isFinite(followUpDelaySec) ? Number(followUpDelaySec) : 300
470
+
471
+ const proactivePayload = followUpText || message?.trim() || ''
472
+ const significantAutonomousOutreach = autonomousTurn && isSignificantOutreachText(proactivePayload)
473
+ const urgentAutonomousOutreach = autonomousTurn && isUrgentOutreachText(proactivePayload)
474
+ const outreachBudgetKey = buildConnectorActionKey([selected.id, channelId])
475
+ const priorAutonomousOutreach = autonomousOutreachBudget.get(outreachBudgetKey)
476
+ if (
477
+ autonomousTurn
478
+ && significantAutonomousOutreach
479
+ && priorAutonomousOutreach
480
+ && !urgentAutonomousOutreach
481
+ && now - priorAutonomousOutreach.at <= AUTONOMOUS_OUTREACH_COOLDOWN_MS
482
+ ) {
483
+ if (priorAutonomousOutreach.result) {
484
+ return normalizeDedupedReplayResult(priorAutonomousOutreach.result, {
485
+ connectorId: selected.id,
486
+ platform: selected.platform,
487
+ to: channelId,
488
+ })
134
489
  }
135
- return `Error: no target recipient configured and no known contacts on this connector. Ask the user for the recipient number/ID, then re-call with the "to" parameter. They can also configure "allowedJids" or "outboundJid" in the connector settings.`
490
+ return JSON.stringify({
491
+ status: 'sent',
492
+ connectorId: selected.id,
493
+ platform: selected.platform,
494
+ to: channelId,
495
+ deduped: true,
496
+ })
136
497
  }
137
- if (connector.platform === 'whatsapp') {
138
- channelId = normalizeWhatsAppTarget(channelId)
498
+
499
+ if (followUpText && !followupExplicitlyRequested && !significantAutonomousOutreach) {
500
+ followUpText = ''
139
501
  }
140
502
 
141
- // Resolve /api/uploads/ URLs to actual disk paths so connectors can read the files
142
- let resolvedMediaPath = mediaPath?.trim() || undefined
143
- let resolvedImageUrl = imageUrl?.trim() || undefined
144
- let resolvedFileUrl = fileUrl?.trim() || undefined
145
- if (!resolvedMediaPath) {
146
- const fromImage = resolveUploadUrl(resolvedImageUrl)
147
- if (fromImage) {
148
- resolvedMediaPath = fromImage.mediaPath
149
- resolvedImageUrl = undefined
503
+ if (actionName === 'schedule_followup') {
504
+ if (!followupExplicitlyRequested && !significantAutonomousOutreach) {
505
+ return 'Error: schedule_followup requires either an explicit user request or a significant autonomous event.'
506
+ }
507
+ const payload = followUpText || message?.trim() || ''
508
+ if (!payload) return 'Error: followUpMessage or message is required for schedule_followup action.'
509
+ const scheduleActionKey = buildConnectorActionKey([
510
+ sessionId,
511
+ actionName,
512
+ selected.id,
513
+ channelId,
514
+ payload,
515
+ Number.isFinite(delaySec) ? Number(delaySec) : followDelaySec,
516
+ ])
517
+ const cachedSchedule = recentConnectorActionCache.get(scheduleActionKey)
518
+ if (cachedSchedule && now - cachedSchedule.at <= CONNECTOR_ACTION_DEDUPE_TTL_MS) {
519
+ return cachedSchedule.result
150
520
  }
151
- const fromFile = resolveUploadUrl(resolvedFileUrl)
152
- if (fromFile) {
153
- resolvedMediaPath = fromFile.mediaPath
154
- resolvedFileUrl = undefined
521
+ const scheduled = scheduleConnectorFollowUp({
522
+ connectorId: selected.id,
523
+ channelId,
524
+ text: payload,
525
+ delaySec: Number.isFinite(delaySec) ? Number(delaySec) : followDelaySec,
526
+ })
527
+ const result = JSON.stringify({
528
+ status: 'followup_scheduled',
529
+ connectorId: selected.id,
530
+ platform: selected.platform,
531
+ to: channelId,
532
+ followUpId: scheduled.followUpId,
533
+ sendAt: scheduled.sendAt,
534
+ })
535
+ connectorTurnSendBudget.set(turnKey, {
536
+ count: (existingBudget?.count || 0) + 1,
537
+ at: now,
538
+ lastResult: result,
539
+ })
540
+ if (autonomousTurn && significantAutonomousOutreach) {
541
+ autonomousOutreachBudget.set(outreachBudgetKey, { at: now, result })
155
542
  }
543
+ recentConnectorActionCache.set(scheduleActionKey, { at: now, result })
544
+ return result
545
+ }
546
+
547
+ const sendActionKey = buildConnectorActionKey([
548
+ sessionId,
549
+ actionName,
550
+ selected.id,
551
+ channelId,
552
+ message?.trim() || '',
553
+ media.mediaPath || '',
554
+ media.imageUrl || '',
555
+ media.fileUrl || '',
556
+ mimeType?.trim() || '',
557
+ fileName?.trim() || '',
558
+ caption?.trim() || '',
559
+ ptt ?? '',
560
+ followUpText,
561
+ followDelaySec,
562
+ ])
563
+ const cachedSend = recentConnectorActionCache.get(sendActionKey)
564
+ if (cachedSend && now - cachedSend.at <= CONNECTOR_ACTION_DEDUPE_TTL_MS) {
565
+ return cachedSend.result
156
566
  }
157
567
 
158
568
  const sent = await sendConnectorMessage({
159
569
  connectorId: selected.id,
160
570
  channelId,
161
571
  text: message?.trim() || '',
162
- imageUrl: resolvedImageUrl,
163
- fileUrl: resolvedFileUrl,
164
- mediaPath: resolvedMediaPath,
572
+ imageUrl: media.imageUrl,
573
+ fileUrl: media.fileUrl,
574
+ mediaPath: media.mediaPath,
165
575
  mimeType: mimeType?.trim() || undefined,
166
576
  fileName: fileName?.trim() || undefined,
167
577
  caption: caption?.trim() || undefined,
578
+ ptt: ptt ?? undefined,
168
579
  })
169
- return JSON.stringify({
580
+
581
+ let followup: { followUpId: string; sendAt: number } | null = null
582
+ if (followUpText) {
583
+ followup = scheduleConnectorFollowUp({
584
+ connectorId: selected.id,
585
+ channelId,
586
+ text: followUpText,
587
+ delaySec: followDelaySec,
588
+ })
589
+ }
590
+
591
+ const result = JSON.stringify({
170
592
  status: 'sent',
171
593
  connectorId: sent.connectorId,
172
594
  platform: sent.platform,
173
595
  to: sent.channelId,
174
596
  messageId: sent.messageId || null,
597
+ ...(followup
598
+ ? {
599
+ followUpId: followup.followUpId,
600
+ followUpSendAt: followup.sendAt,
601
+ }
602
+ : {}),
175
603
  })
604
+ connectorTurnSendBudget.set(turnKey, {
605
+ count: (existingBudget?.count || 0) + 1,
606
+ at: now,
607
+ lastResult: result,
608
+ })
609
+ if (autonomousTurn && significantAutonomousOutreach) {
610
+ autonomousOutreachBudget.set(outreachBudgetKey, { at: now, result })
611
+ }
612
+ recentConnectorActionCache.set(sendActionKey, { at: now, result })
613
+ return result
176
614
  }
177
615
 
178
- if (action === 'message_react' || action === 'message_edit' || action === 'message_pin' || action === 'message_delete') {
616
+ if (actionName === 'message_react' || actionName === 'message_edit' || actionName === 'message_pin' || actionName === 'message_delete') {
179
617
  if (!connectorId) return 'Error: connectorId is required for rich messaging actions.'
180
618
  const { getRunningInstance } = await import('../connectors/manager')
181
619
  const inst = getRunningInstance(connectorId)
@@ -186,25 +624,25 @@ export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInter
186
624
  if (!targetMessageId) return 'Error: message parameter (used as messageId) is required for rich messaging actions.'
187
625
 
188
626
  try {
189
- if (action === 'message_react') {
627
+ if (actionName === 'message_react') {
190
628
  if (!inst.sendReaction) return 'Error: this connector does not support reactions.'
191
629
  const emoji = caption?.trim() || '👍'
192
630
  await inst.sendReaction(targetChannel, targetMessageId, emoji)
193
631
  return JSON.stringify({ status: 'reacted', connectorId, messageId: targetMessageId, emoji })
194
632
  }
195
- if (action === 'message_edit') {
633
+ if (actionName === 'message_edit') {
196
634
  if (!inst.editMessage) return 'Error: this connector does not support message editing.'
197
635
  const newText = caption?.trim() || ''
198
636
  if (!newText) return 'Error: caption (new text) is required for message_edit.'
199
637
  await inst.editMessage(targetChannel, targetMessageId, newText)
200
638
  return JSON.stringify({ status: 'edited', connectorId, messageId: targetMessageId })
201
639
  }
202
- if (action === 'message_delete') {
640
+ if (actionName === 'message_delete') {
203
641
  if (!inst.deleteMessage) return 'Error: this connector does not support message deletion.'
204
642
  await inst.deleteMessage(targetChannel, targetMessageId)
205
643
  return JSON.stringify({ status: 'deleted', connectorId, messageId: targetMessageId })
206
644
  }
207
- if (action === 'message_pin') {
645
+ if (actionName === 'message_pin') {
208
646
  if (!inst.pinMessage) return 'Error: this connector does not support message pinning.'
209
647
  await inst.pinMessage(targetChannel, targetMessageId)
210
648
  return JSON.stringify({ status: 'pinned', connectorId, messageId: targetMessageId })
@@ -214,26 +652,34 @@ export function buildConnectorTools(bctx: ToolBuildContext): StructuredToolInter
214
652
  }
215
653
  }
216
654
 
217
- return 'Unknown action. Use list_running, list_targets, start, stop, or send.'
655
+ return 'Unknown action. Use list_running, list_targets, start, stop, send, send_voice_note, schedule_followup, or message_* actions.'
218
656
  } catch (err: unknown) {
219
657
  return `Error: ${err instanceof Error ? err.message : String(err)}`
220
658
  }
221
659
  },
222
660
  {
223
661
  name: 'connector_message_tool',
224
- description: 'Manage and send messages through chat platform connectors (WhatsApp, Telegram, Slack, Discord, etc.). Use "start"/"stop" to manage connector lifecycle, "list_running"/"list_targets" to discover available connectors and recipients, "send" to deliver messages, and rich actions (react, edit, delete, pin) for message management. When a send fails because no connector is running, check if one is configured and offer to start it. When no target is set, list available configured numbers and ask the user which to send to.',
662
+ description: voiceNoteToolEnabled
663
+ ? 'Manage and send messages through chat platform connectors (WhatsApp, Telegram, Slack, Discord, etc.). Use "start"/"stop" to manage connector lifecycle, "list_running"/"list_targets" to discover available connectors and recipients, "send" to deliver text/media, "send_voice_note" to synthesize and send audio via ElevenLabs, "schedule_followup" for delayed check-ins, and rich actions (react, edit, delete, pin) for message management. When a send fails because no connector is running, check if one is configured and offer to start it. When no target is set, list available configured numbers and ask the user which to send to.'
664
+ : 'Manage and send messages through chat platform connectors (WhatsApp, Telegram, Slack, Discord, etc.). Use "start"/"stop" to manage connector lifecycle, "list_running"/"list_targets" to discover available connectors and recipients, "send" to deliver text/media, "schedule_followup" for delayed check-ins, and rich actions (react, edit, delete, pin) for message management. Voice-note sending appears only when ElevenLabs is enabled with an API key in Settings > Voice. When a send fails because no connector is running, check if one is configured and offer to start it. When no target is set, list available configured numbers and ask the user which to send to.',
225
665
  schema: z.object({
226
- action: z.enum(['list_running', 'list_targets', 'start', 'stop', 'send', 'message_react', 'message_edit', 'message_delete', 'message_pin']).describe('connector messaging action'),
666
+ action: actionSchema.describe('connector messaging action'),
227
667
  connectorId: z.string().optional().describe('Optional connector id. Defaults to the first running connector (or first for selected platform).'),
228
668
  platform: z.string().optional().describe('Optional platform filter (whatsapp, telegram, slack, discord, bluebubbles, etc.).'),
229
669
  to: z.string().optional().describe('Target channel id / recipient. For WhatsApp, phone number or full JID.'),
230
- message: z.string().optional().describe('Message text to send (required for send action).'),
670
+ message: z.string().optional().describe('Message text to send. Required for send unless media is provided. Used as fallback for send_voice_note/schedule_followup when voiceText/followUpMessage are omitted.'),
671
+ voiceText: z.string().optional().describe('Text to synthesize for send_voice_note. Uses message when omitted.'),
672
+ voiceId: z.string().optional().describe('Optional ElevenLabs voice override for send_voice_note.'),
231
673
  imageUrl: z.string().optional().describe('Optional public image URL to attach/send where platform supports media.'),
232
674
  fileUrl: z.string().optional().describe('Optional public file URL to attach/send where platform supports documents.'),
233
675
  mediaPath: z.string().optional().describe('Absolute local file path to send (e.g. a screenshot). Auto-detects mime type from extension. Takes priority over imageUrl/fileUrl.'),
234
676
  mimeType: z.string().optional().describe('Optional MIME type for mediaPath or fileUrl.'),
235
677
  fileName: z.string().optional().describe('Optional display file name for mediaPath or fileUrl.'),
236
678
  caption: z.string().optional().describe('Optional caption used with image/file sends.'),
679
+ delaySec: z.number().optional().describe('Delay in seconds for schedule_followup.'),
680
+ followUpMessage: z.string().optional().describe('Optional delayed follow-up text (for send) or primary text for schedule_followup.'),
681
+ followUpDelaySec: z.number().optional().describe('Delay in seconds for followUpMessage when action=send. Default 300 seconds.'),
682
+ ptt: z.boolean().optional().describe('Send audio as a WhatsApp voice note (push-to-talk). Defaults to true for audio files.'),
237
683
  approved: z.boolean().optional().describe('Set true to explicitly confirm outbound send when safetyRequireApprovalForOutbound is enabled.'),
238
684
  }),
239
685
  },