@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.
- package/README.md +45 -44
- package/package.json +1 -1
- package/src/app/api/tts/route.ts +16 -36
- package/src/app/api/tts/stream/route.ts +14 -43
- package/src/components/chat/chat-area.tsx +30 -2
- package/src/components/chat/chat-header.tsx +70 -3
- package/src/components/chat/message-list.tsx +3 -71
- package/src/components/connectors/connector-sheet.tsx +16 -1
- package/src/lib/server/chat-execution.ts +74 -3
- package/src/lib/server/connectors/connector-routing.test.ts +118 -1
- package/src/lib/server/connectors/discord.ts +31 -8
- package/src/lib/server/connectors/manager.ts +398 -31
- package/src/lib/server/connectors/media.ts +5 -0
- package/src/lib/server/connectors/telegram.ts +12 -2
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp.ts +28 -2
- package/src/lib/server/elevenlabs.test.ts +60 -0
- package/src/lib/server/elevenlabs.ts +103 -0
- package/src/lib/server/queue.ts +130 -1
- package/src/lib/server/session-tools/connector.ts +540 -94
- package/src/lib/server/session-tools/file.ts +26 -7
- package/src/lib/server/session-tools/web.ts +3 -4
- package/src/lib/server/stream-agent-chat.ts +7 -0
|
@@ -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
|
-
|
|
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 {
|
|
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 ({
|
|
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
|
|
29
|
-
|
|
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 (
|
|
298
|
+
if (actionName === 'list_running' || actionName === 'list_targets') {
|
|
45
299
|
return JSON.stringify(running)
|
|
46
300
|
}
|
|
47
301
|
|
|
48
|
-
if (
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
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
|
-
|
|
138
|
-
|
|
498
|
+
|
|
499
|
+
if (followUpText && !followupExplicitlyRequested && !significantAutonomousOutreach) {
|
|
500
|
+
followUpText = ''
|
|
139
501
|
}
|
|
140
502
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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:
|
|
163
|
-
fileUrl:
|
|
164
|
-
mediaPath:
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
},
|