@swarmclawai/swarmclaw 0.6.0 → 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 +56 -42
- package/bin/server-cmd.js +1 -0
- package/package.json +2 -1
- package/src/app/api/canvas/[sessionId]/route.ts +31 -0
- package/src/app/api/chatrooms/[id]/chat/route.ts +10 -136
- package/src/app/api/connectors/[id]/route.ts +1 -0
- package/src/app/api/connectors/route.ts +2 -1
- package/src/app/api/files/open/route.ts +43 -0
- package/src/app/api/search/route.ts +9 -7
- package/src/app/api/sessions/[id]/messages/route.ts +70 -2
- package/src/app/api/sessions/[id]/route.ts +4 -0
- package/src/app/api/tasks/metrics/route.ts +101 -0
- package/src/app/api/tasks/route.ts +17 -2
- package/src/app/api/tts/route.ts +16 -35
- package/src/app/api/tts/stream/route.ts +14 -42
- package/src/app/api/uploads/[filename]/route.ts +19 -34
- package/src/app/api/uploads/route.ts +94 -0
- package/src/app/globals.css +5 -0
- package/src/cli/index.js +16 -1
- package/src/cli/spec.js +26 -0
- package/src/components/agents/agent-card.tsx +3 -3
- package/src/components/agents/agent-chat-list.tsx +29 -6
- package/src/components/agents/agent-sheet.tsx +66 -4
- package/src/components/agents/inspector-panel.tsx +81 -6
- package/src/components/agents/openclaw-skills-panel.tsx +32 -3
- package/src/components/agents/personality-builder.tsx +42 -14
- package/src/components/agents/soul-library-picker.tsx +89 -0
- package/src/components/canvas/canvas-panel.tsx +96 -0
- package/src/components/chat/activity-moment.tsx +8 -4
- package/src/components/chat/chat-area.tsx +76 -24
- package/src/components/chat/chat-header.tsx +522 -286
- package/src/components/chat/chat-preview-panel.tsx +1 -2
- package/src/components/chat/delegation-banner.tsx +371 -0
- package/src/components/chat/file-path-chip.tsx +23 -2
- package/src/components/chat/heartbeat-history-panel.tsx +269 -0
- package/src/components/chat/message-bubble.tsx +315 -25
- package/src/components/chat/message-list.tsx +113 -8
- package/src/components/chat/streaming-bubble.tsx +68 -1
- package/src/components/chat/tool-call-bubble.tsx +45 -3
- package/src/components/chat/transfer-agent-picker.tsx +1 -1
- package/src/components/chatrooms/chatroom-list.tsx +8 -1
- package/src/components/chatrooms/chatroom-message.tsx +8 -3
- package/src/components/chatrooms/chatroom-view.tsx +3 -3
- package/src/components/connectors/connector-list.tsx +168 -90
- package/src/components/connectors/connector-sheet.tsx +84 -17
- package/src/components/home/home-view.tsx +1 -1
- package/src/components/input/chat-input.tsx +28 -2
- package/src/components/layout/app-layout.tsx +19 -2
- package/src/components/projects/project-detail.tsx +1 -1
- package/src/components/schedules/schedule-sheet.tsx +260 -127
- package/src/components/settings/gateway-disconnect-overlay.tsx +80 -0
- package/src/components/shared/agent-switch-dialog.tsx +1 -1
- package/src/components/shared/chatroom-picker-list.tsx +61 -0
- package/src/components/shared/connector-platform-icon.tsx +51 -4
- package/src/components/shared/icon-button.tsx +16 -2
- package/src/components/shared/keyboard-shortcuts-dialog.tsx +1 -1
- package/src/components/shared/search-dialog.tsx +17 -10
- package/src/components/shared/settings/section-embedding.tsx +48 -13
- package/src/components/shared/settings/section-orchestrator.tsx +46 -15
- package/src/components/shared/settings/section-storage.tsx +206 -0
- package/src/components/shared/settings/section-user-preferences.tsx +18 -0
- package/src/components/shared/settings/section-voice.tsx +42 -21
- package/src/components/shared/settings/section-web-search.tsx +30 -6
- package/src/components/shared/settings/settings-page.tsx +3 -1
- package/src/components/shared/settings/storage-browser.tsx +259 -0
- package/src/components/tasks/task-card.tsx +14 -1
- package/src/components/tasks/task-sheet.tsx +328 -3
- package/src/components/usage/metrics-dashboard.tsx +90 -6
- package/src/hooks/use-continuous-speech.ts +10 -4
- package/src/hooks/use-voice-conversation.ts +53 -10
- package/src/hooks/use-ws.ts +4 -2
- package/src/lib/providers/anthropic.ts +13 -7
- package/src/lib/providers/index.ts +1 -0
- package/src/lib/providers/openai.ts +13 -7
- package/src/lib/server/chat-execution.ts +125 -14
- package/src/lib/server/chatroom-helpers.ts +146 -0
- 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 +594 -16
- 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/heartbeat-service.ts +8 -1
- package/src/lib/server/main-agent-loop.ts +1 -1
- package/src/lib/server/memory-consolidation.ts +15 -2
- package/src/lib/server/memory-db.ts +134 -6
- package/src/lib/server/mime.ts +51 -0
- package/src/lib/server/openclaw-gateway.ts +2 -2
- package/src/lib/server/orchestrator-lg.ts +2 -0
- package/src/lib/server/orchestrator.ts +5 -2
- package/src/lib/server/playwright-proxy.mjs +2 -3
- package/src/lib/server/prompt-runtime-context.ts +53 -0
- package/src/lib/server/queue.ts +182 -8
- package/src/lib/server/session-tools/canvas.ts +67 -0
- package/src/lib/server/session-tools/connector.ts +583 -63
- package/src/lib/server/session-tools/crud.ts +21 -0
- package/src/lib/server/session-tools/delegate.ts +68 -4
- package/src/lib/server/session-tools/file.ts +26 -7
- package/src/lib/server/session-tools/git.ts +71 -0
- package/src/lib/server/session-tools/http.ts +57 -0
- package/src/lib/server/session-tools/index.ts +8 -0
- package/src/lib/server/session-tools/memory.ts +1 -0
- package/src/lib/server/session-tools/search-providers.ts +16 -8
- package/src/lib/server/session-tools/subagent.ts +106 -0
- package/src/lib/server/session-tools/web.ts +118 -8
- package/src/lib/server/stream-agent-chat.ts +39 -10
- package/src/lib/server/task-mention.ts +41 -0
- package/src/lib/sessions.ts +10 -0
- package/src/lib/soul-library.ts +103 -0
- package/src/lib/task-dedupe.ts +26 -0
- package/src/lib/tool-definitions.ts +2 -0
- package/src/lib/tts.ts +2 -2
- package/src/stores/use-app-store.ts +5 -1
- package/src/stores/use-chat-store.ts +65 -2
- package/src/types/index.ts +32 -2
|
@@ -2,14 +2,27 @@ import { genId } from '@/lib/id'
|
|
|
2
2
|
import {
|
|
3
3
|
loadConnectors, saveConnectors, loadSessions, saveSessions,
|
|
4
4
|
loadAgents, loadCredentials, decryptKey, loadSettings, loadSkills,
|
|
5
|
+
loadChatrooms, saveChatrooms,
|
|
5
6
|
} from '../storage'
|
|
6
7
|
import { WORKSPACE_DIR } from '../data-dir'
|
|
8
|
+
import { UPLOAD_DIR } from '../storage'
|
|
9
|
+
import fs from 'fs'
|
|
10
|
+
import path from 'path'
|
|
7
11
|
import { streamAgentChat } from '../stream-agent-chat'
|
|
8
12
|
import { notify } from '../ws-hub'
|
|
9
13
|
import { logExecution } from '../execution-log'
|
|
10
14
|
import { enqueueSystemEvent } from '../system-events'
|
|
11
15
|
import { requestHeartbeatNow } from '../heartbeat-wake'
|
|
12
|
-
import
|
|
16
|
+
import { buildCurrentDateTimePromptContext } from '../prompt-runtime-context'
|
|
17
|
+
import {
|
|
18
|
+
parseMentions,
|
|
19
|
+
buildChatroomSystemPrompt,
|
|
20
|
+
buildSyntheticSession,
|
|
21
|
+
buildAgentSystemPromptForChatroom,
|
|
22
|
+
buildHistoryForAgent,
|
|
23
|
+
resolveApiKey as resolveApiKeyHelper,
|
|
24
|
+
} from '../chatroom-helpers'
|
|
25
|
+
import type { Connector, MessageSource, Chatroom, ChatroomMessage } from '@/types'
|
|
13
26
|
import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
|
|
14
27
|
import {
|
|
15
28
|
addAllowedSender,
|
|
@@ -23,6 +36,191 @@ import {
|
|
|
23
36
|
type PairingPolicy,
|
|
24
37
|
} from './pairing'
|
|
25
38
|
|
|
39
|
+
function resolveUploadPathFromUrl(rawUrl: string): string | null {
|
|
40
|
+
if (!rawUrl) return null
|
|
41
|
+
const normalized = rawUrl.trim()
|
|
42
|
+
const match = normalized.match(/\/api\/uploads\/([^?#)\s]+)/)
|
|
43
|
+
if (!match) return null
|
|
44
|
+
let decoded: string
|
|
45
|
+
try { decoded = decodeURIComponent(match[1]) } catch { decoded = match[1] }
|
|
46
|
+
const safeName = decoded.replace(/[^a-zA-Z0-9._-]/g, '')
|
|
47
|
+
if (!safeName) return null
|
|
48
|
+
const filePath = path.join(UPLOAD_DIR, safeName)
|
|
49
|
+
return fs.existsSync(filePath) ? filePath : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function uploadApiUrlFromPath(filePath: string): string | null {
|
|
53
|
+
const rel = path.relative(UPLOAD_DIR, filePath)
|
|
54
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null
|
|
55
|
+
const fileName = path.basename(rel)
|
|
56
|
+
return `/api/uploads/${encodeURIComponent(fileName)}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseSseDataEvents(raw: string): Array<Record<string, unknown>> {
|
|
60
|
+
if (!raw) return []
|
|
61
|
+
const events: Array<Record<string, unknown>> = []
|
|
62
|
+
const lines = raw.split('\n')
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
if (!line.startsWith('data: ')) continue
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(line.slice(6).trim())
|
|
67
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
68
|
+
events.push(parsed as Record<string, unknown>)
|
|
69
|
+
}
|
|
70
|
+
} catch { /* ignore malformed event lines */ }
|
|
71
|
+
}
|
|
72
|
+
return events
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseConnectorToolResult(toolOutput: string): { status?: string; to?: string; followUpId?: string } | null {
|
|
76
|
+
const raw = toolOutput.trim()
|
|
77
|
+
if (!raw) return null
|
|
78
|
+
try {
|
|
79
|
+
const parsed = JSON.parse(raw)
|
|
80
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
81
|
+
const record = parsed as Record<string, unknown>
|
|
82
|
+
const status = typeof record.status === 'string' ? String(record.status) : undefined
|
|
83
|
+
const to = typeof record.to === 'string' ? String(record.to) : undefined
|
|
84
|
+
const followUpId = typeof record.followUpId === 'string' ? String(record.followUpId) : undefined
|
|
85
|
+
return { status, to, followUpId }
|
|
86
|
+
} catch {
|
|
87
|
+
return null
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function canonicalUploadMediaKey(filePath: string): string {
|
|
92
|
+
const base = path.basename(filePath)
|
|
93
|
+
const ext = path.extname(base).toLowerCase()
|
|
94
|
+
const normalized = base
|
|
95
|
+
.replace(/^\d{10,16}-/, '')
|
|
96
|
+
.replace(/^(?:browser|screenshot)-\d{10,16}(?:-\d+)?\./, `playwright-capture.`)
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
return normalized || `unknown${ext}`
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function shouldAllowMultipleMediaSends(userText: string): boolean {
|
|
102
|
+
const text = (userText || '').toLowerCase()
|
|
103
|
+
return /\b(all|both|multiple|several|many|every|each|two|three|4|four|screenshots|images|photos|files|documents)\b/.test(text)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function preferSingleBestMediaFile(files: Array<{ path: string; alt: string }>): Array<{ path: string; alt: string }> {
|
|
107
|
+
if (files.length <= 1) return files
|
|
108
|
+
const ranked = [...files].sort((a, b) => {
|
|
109
|
+
const score = (entry: { path: string }) => {
|
|
110
|
+
const base = path.basename(entry.path).toLowerCase()
|
|
111
|
+
let value = 0
|
|
112
|
+
if (/^\d{10,16}-/.test(base)) value += 20
|
|
113
|
+
if (!base.startsWith('browser-') && !base.startsWith('screenshot-')) value += 10
|
|
114
|
+
if (base.endsWith('.pdf')) value += 8
|
|
115
|
+
if (base.endsWith('.png') || base.endsWith('.jpg') || base.endsWith('.jpeg') || base.endsWith('.webp')) value += 6
|
|
116
|
+
try {
|
|
117
|
+
const stat = fs.statSync(entry.path)
|
|
118
|
+
value += Math.min(5, Math.round((stat.mtimeMs % 10_000) / 2_000))
|
|
119
|
+
} catch { /* ignore stat errors */ }
|
|
120
|
+
return value
|
|
121
|
+
}
|
|
122
|
+
return score(b) - score(a)
|
|
123
|
+
})
|
|
124
|
+
return [ranked[0]]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function selectOutboundMediaFiles(
|
|
128
|
+
files: Array<{ path: string; alt: string }>,
|
|
129
|
+
userText: string,
|
|
130
|
+
): Array<{ path: string; alt: string }> {
|
|
131
|
+
if (files.length === 0) return []
|
|
132
|
+
const mergedFiles: Array<{ path: string; alt: string }> = []
|
|
133
|
+
const seenMediaKeys = new Set<string>()
|
|
134
|
+
for (const candidate of files) {
|
|
135
|
+
const mediaKey = canonicalUploadMediaKey(candidate.path)
|
|
136
|
+
if (seenMediaKeys.has(mediaKey)) continue
|
|
137
|
+
seenMediaKeys.add(mediaKey)
|
|
138
|
+
mergedFiles.push(candidate)
|
|
139
|
+
}
|
|
140
|
+
return shouldAllowMultipleMediaSends(userText || '')
|
|
141
|
+
? mergedFiles
|
|
142
|
+
: preferSingleBestMediaFile(mergedFiles)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Extract embedded media references from agent response text.
|
|
147
|
+
* Supports markdown images/links and bare upload URLs.
|
|
148
|
+
*/
|
|
149
|
+
export function extractEmbeddedMedia(text: string): { cleanText: string; files: Array<{ path: string; alt: string }> } {
|
|
150
|
+
const files: Array<{ path: string; alt: string }> = []
|
|
151
|
+
const seen = new Set<string>()
|
|
152
|
+
let cleanText = text
|
|
153
|
+
|
|
154
|
+
const pushFile = (filePath: string, alt: string) => {
|
|
155
|
+
if (!filePath || seen.has(filePath)) return
|
|
156
|
+
seen.add(filePath)
|
|
157
|
+
files.push({ path: filePath, alt: alt.trim() })
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
|
|
161
|
+
cleanText = cleanText.replace(imageRegex, (full, altRaw, urlRaw) => {
|
|
162
|
+
const filePath = resolveUploadPathFromUrl(String(urlRaw || ''))
|
|
163
|
+
if (!filePath) return full
|
|
164
|
+
pushFile(filePath, String(altRaw || ''))
|
|
165
|
+
return ''
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const linkRegex = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g
|
|
169
|
+
cleanText = cleanText.replace(linkRegex, (full, altRaw, urlRaw) => {
|
|
170
|
+
const filePath = resolveUploadPathFromUrl(String(urlRaw || ''))
|
|
171
|
+
if (!filePath) return full
|
|
172
|
+
pushFile(filePath, String(altRaw || ''))
|
|
173
|
+
return ''
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const bareUploadUrlRegex = /(?:https?:\/\/[^\s)]+)?\/api\/uploads\/[^\s)\]]+/g
|
|
177
|
+
cleanText = cleanText.replace(bareUploadUrlRegex, (full) => {
|
|
178
|
+
const filePath = resolveUploadPathFromUrl(full)
|
|
179
|
+
if (!filePath) return full
|
|
180
|
+
pushFile(filePath, '')
|
|
181
|
+
return ''
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
if (files.length === 0) return { cleanText: text, files }
|
|
185
|
+
cleanText = cleanText.replace(/\n{3,}/g, '\n\n').trim()
|
|
186
|
+
return { cleanText, files }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function buildInboundAttachmentPaths(msg: InboundMessage): string[] {
|
|
190
|
+
if (!Array.isArray(msg.media) || msg.media.length === 0) return []
|
|
191
|
+
const paths: string[] = []
|
|
192
|
+
const seen = new Set<string>()
|
|
193
|
+
for (const media of msg.media) {
|
|
194
|
+
const localPath = typeof media.localPath === 'string' ? media.localPath.trim() : ''
|
|
195
|
+
if (!localPath || seen.has(localPath)) continue
|
|
196
|
+
if (!fs.existsSync(localPath)) continue
|
|
197
|
+
seen.add(localPath)
|
|
198
|
+
paths.push(localPath)
|
|
199
|
+
}
|
|
200
|
+
return paths
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeWhatsappTarget(raw: string): string {
|
|
204
|
+
const trimmed = raw.trim()
|
|
205
|
+
if (!trimmed) return trimmed
|
|
206
|
+
if (trimmed.includes('@')) return trimmed
|
|
207
|
+
let cleaned = trimmed.replace(/[^\d+]/g, '')
|
|
208
|
+
if (cleaned.startsWith('+')) cleaned = cleaned.slice(1)
|
|
209
|
+
if (cleaned.startsWith('0') && cleaned.length >= 10) {
|
|
210
|
+
cleaned = `44${cleaned.slice(1)}`
|
|
211
|
+
}
|
|
212
|
+
cleaned = cleaned.replace(/[^\d]/g, '')
|
|
213
|
+
return cleaned ? `${cleaned}@s.whatsapp.net` : trimmed
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function connectorSupportsBinaryMedia(platform: string): boolean {
|
|
217
|
+
return platform === 'whatsapp'
|
|
218
|
+
|| platform === 'telegram'
|
|
219
|
+
|| platform === 'slack'
|
|
220
|
+
|| platform === 'discord'
|
|
221
|
+
|| platform === 'openclaw'
|
|
222
|
+
}
|
|
223
|
+
|
|
26
224
|
/** Sentinel value agents return when no outbound reply should be sent */
|
|
27
225
|
export const NO_MESSAGE_SENTINEL = 'NO_MESSAGE'
|
|
28
226
|
|
|
@@ -60,6 +258,34 @@ const genCounterKey = '__swarmclaw_connector_gen__' as const
|
|
|
60
258
|
const generationCounter: Map<string, number> =
|
|
61
259
|
g[genCounterKey] ?? (g[genCounterKey] = new Map<string, number>())
|
|
62
260
|
|
|
261
|
+
type ScheduledConnectorFollowup = {
|
|
262
|
+
id: string
|
|
263
|
+
connectorId?: string
|
|
264
|
+
platform?: string
|
|
265
|
+
channelId: string
|
|
266
|
+
sendAt: number
|
|
267
|
+
timer: ReturnType<typeof setTimeout>
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const followupKey = '__swarmclaw_connector_followups__' as const
|
|
271
|
+
const scheduledFollowups: Map<string, ScheduledConnectorFollowup> =
|
|
272
|
+
g[followupKey] ?? (g[followupKey] = new Map<string, ScheduledConnectorFollowup>())
|
|
273
|
+
|
|
274
|
+
type RouteMessageHandler = (connector: Connector, msg: InboundMessage) => Promise<string>
|
|
275
|
+
const routeHandlerKey = '__swarmclaw_connector_route_handler__' as const
|
|
276
|
+
const routeMessageHandlerRef: { current: RouteMessageHandler } =
|
|
277
|
+
g[routeHandlerKey] ?? (g[routeHandlerKey] = { current: async () => '[Error] Connector router unavailable.' })
|
|
278
|
+
|
|
279
|
+
function dispatchInboundConnectorMessage(
|
|
280
|
+
connectorId: string,
|
|
281
|
+
fallbackConnector: Connector,
|
|
282
|
+
msg: InboundMessage,
|
|
283
|
+
): Promise<string> {
|
|
284
|
+
const connectors = loadConnectors()
|
|
285
|
+
const currentConnector = connectors[connectorId] as Connector | undefined
|
|
286
|
+
return routeMessageHandlerRef.current(currentConnector ?? fallbackConnector, msg)
|
|
287
|
+
}
|
|
288
|
+
|
|
63
289
|
/** Get the current generation number for a connector (0 if never started) */
|
|
64
290
|
export function getConnectorGeneration(connectorId: string): number {
|
|
65
291
|
return generationCounter.get(connectorId) ?? 0
|
|
@@ -421,6 +647,140 @@ async function handleConnectorCommand(params: {
|
|
|
421
647
|
return 'Unknown command.'
|
|
422
648
|
}
|
|
423
649
|
|
|
650
|
+
/** Route an inbound message to a chatroom — process mentioned agents and return concatenated responses */
|
|
651
|
+
async function routeMessageToChatroom(connector: Connector, msg: InboundMessage): Promise<string> {
|
|
652
|
+
const chatroomId = connector.chatroomId
|
|
653
|
+
if (!chatroomId) return '[Error] No chatroom configured.'
|
|
654
|
+
|
|
655
|
+
const chatrooms = loadChatrooms()
|
|
656
|
+
const chatroom = chatrooms[chatroomId] as Chatroom | undefined
|
|
657
|
+
if (!chatroom) return '[Error] Chatroom not found.'
|
|
658
|
+
|
|
659
|
+
const agents = loadAgents()
|
|
660
|
+
const source: MessageSource = {
|
|
661
|
+
platform: connector.platform,
|
|
662
|
+
connectorId: connector.id,
|
|
663
|
+
connectorName: connector.name,
|
|
664
|
+
senderName: msg.senderName,
|
|
665
|
+
}
|
|
666
|
+
const inboundText = formatInboundUserText(msg)
|
|
667
|
+
const inboundAttachmentPaths = buildInboundAttachmentPaths(msg)
|
|
668
|
+
const firstImagePath = msg.media?.find((m) => m.type === 'image')?.localPath
|
|
669
|
+
|
|
670
|
+
// Parse mentions from the message text
|
|
671
|
+
let mentions = parseMentions(msg.text || '', agents, chatroom.agentIds)
|
|
672
|
+
// Auto-address: if enabled and no explicit mentions, address all agents
|
|
673
|
+
if (chatroom.autoAddress && mentions.length === 0) {
|
|
674
|
+
mentions = [...chatroom.agentIds]
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// Create and persist the user message in the chatroom
|
|
678
|
+
const userMessage: ChatroomMessage = {
|
|
679
|
+
id: genId(),
|
|
680
|
+
senderId: 'user',
|
|
681
|
+
senderName: msg.senderName || 'User',
|
|
682
|
+
role: 'user',
|
|
683
|
+
text: msg.text || '',
|
|
684
|
+
mentions,
|
|
685
|
+
reactions: [],
|
|
686
|
+
time: Date.now(),
|
|
687
|
+
...(firstImagePath ? { imagePath: firstImagePath } : {}),
|
|
688
|
+
...(inboundAttachmentPaths.length ? { attachedFiles: inboundAttachmentPaths } : {}),
|
|
689
|
+
source,
|
|
690
|
+
}
|
|
691
|
+
chatroom.messages.push(userMessage)
|
|
692
|
+
chatroom.updatedAt = Date.now()
|
|
693
|
+
chatrooms[chatroomId] = chatroom
|
|
694
|
+
saveChatrooms(chatrooms)
|
|
695
|
+
notify('chatrooms')
|
|
696
|
+
notify(`chatroom:${chatroomId}`)
|
|
697
|
+
|
|
698
|
+
// Process mentioned agents sequentially and collect responses
|
|
699
|
+
const responses: string[] = []
|
|
700
|
+
for (const agentId of mentions) {
|
|
701
|
+
const agent = agents[agentId]
|
|
702
|
+
if (!agent) continue
|
|
703
|
+
|
|
704
|
+
const apiKey = resolveApiKeyHelper(agent.credentialId)
|
|
705
|
+
const freshChatrooms = loadChatrooms()
|
|
706
|
+
const freshChatroom = freshChatrooms[chatroomId] as Chatroom
|
|
707
|
+
|
|
708
|
+
const syntheticSession = buildSyntheticSession(agent, chatroomId)
|
|
709
|
+
const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
|
|
710
|
+
const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
|
|
711
|
+
const fullSystemPrompt = [agentSystemPrompt, chatroomContext].filter(Boolean).join('\n\n')
|
|
712
|
+
const history = buildHistoryForAgent(freshChatroom, agent.id)
|
|
713
|
+
|
|
714
|
+
try {
|
|
715
|
+
const result = await streamAgentChat({
|
|
716
|
+
session: syntheticSession,
|
|
717
|
+
message: inboundText,
|
|
718
|
+
imagePath: firstImagePath || undefined,
|
|
719
|
+
attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
|
|
720
|
+
apiKey,
|
|
721
|
+
systemPrompt: fullSystemPrompt,
|
|
722
|
+
write: () => {},
|
|
723
|
+
history,
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
const responseText = result.finalResponse || result.fullText
|
|
727
|
+
if (responseText.trim() && !isNoMessage(responseText)) {
|
|
728
|
+
// Persist agent response to chatroom
|
|
729
|
+
const agentSource: MessageSource = {
|
|
730
|
+
platform: connector.platform,
|
|
731
|
+
connectorId: connector.id,
|
|
732
|
+
connectorName: connector.name,
|
|
733
|
+
}
|
|
734
|
+
const agentMessage: ChatroomMessage = {
|
|
735
|
+
id: genId(),
|
|
736
|
+
senderId: agent.id,
|
|
737
|
+
senderName: agent.name,
|
|
738
|
+
role: 'assistant',
|
|
739
|
+
text: responseText,
|
|
740
|
+
mentions: parseMentions(responseText, agents, freshChatroom.agentIds),
|
|
741
|
+
reactions: [],
|
|
742
|
+
time: Date.now(),
|
|
743
|
+
source: agentSource,
|
|
744
|
+
}
|
|
745
|
+
const latestChatrooms = loadChatrooms()
|
|
746
|
+
const latestChatroom = latestChatrooms[chatroomId] as Chatroom
|
|
747
|
+
latestChatroom.messages.push(agentMessage)
|
|
748
|
+
latestChatroom.updatedAt = Date.now()
|
|
749
|
+
latestChatrooms[chatroomId] = latestChatroom
|
|
750
|
+
saveChatrooms(latestChatrooms)
|
|
751
|
+
notify(`chatroom:${chatroomId}`)
|
|
752
|
+
|
|
753
|
+
responses.push(`[${agent.name}] ${responseText}`)
|
|
754
|
+
}
|
|
755
|
+
} catch (err: unknown) {
|
|
756
|
+
const errMsg = err instanceof Error ? err.message : String(err)
|
|
757
|
+
console.error(`[connector] Chatroom agent ${agent.name} error:`, errMsg)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (responses.length === 0) return NO_MESSAGE_SENTINEL
|
|
762
|
+
|
|
763
|
+
const joined = responses.join('\n\n')
|
|
764
|
+
// Extract embedded media from agent responses and send them via connector
|
|
765
|
+
const extracted = extractEmbeddedMedia(joined)
|
|
766
|
+
const filesToSend = selectOutboundMediaFiles(extracted.files, msg.text || '')
|
|
767
|
+
if (filesToSend.length > 0) {
|
|
768
|
+
const inst = running.get(connector.id)
|
|
769
|
+
if (inst?.sendMessage) {
|
|
770
|
+
for (const file of filesToSend) {
|
|
771
|
+
try {
|
|
772
|
+
await inst.sendMessage(msg.channelId, '', { mediaPath: file.path, caption: file.alt || undefined })
|
|
773
|
+
console.log(`[connector] Sent chatroom media to ${msg.platform}: ${path.basename(file.path)}`)
|
|
774
|
+
} catch (err: unknown) {
|
|
775
|
+
console.error(`[connector] Failed to send chatroom media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return extracted.cleanText || '(no response)'
|
|
780
|
+
}
|
|
781
|
+
return joined
|
|
782
|
+
}
|
|
783
|
+
|
|
424
784
|
/** Route an inbound message through the assigned agent and return the response */
|
|
425
785
|
async function routeMessage(connector: Connector, msg: InboundMessage): Promise<string> {
|
|
426
786
|
if (msg?.channelId) {
|
|
@@ -428,8 +788,14 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
428
788
|
}
|
|
429
789
|
lastInboundTimeByConnector.set(connector.id, Date.now())
|
|
430
790
|
|
|
791
|
+
// Route to chatroom if configured
|
|
792
|
+
if (connector.chatroomId) {
|
|
793
|
+
return routeMessageToChatroom(connector, msg)
|
|
794
|
+
}
|
|
795
|
+
|
|
431
796
|
const agents = loadAgents()
|
|
432
797
|
const effectiveAgentId = msg.agentIdOverride || connector.agentId
|
|
798
|
+
if (!effectiveAgentId) return '[Error] Connector has no agent configured.'
|
|
433
799
|
const agent = agents[effectiveAgentId]
|
|
434
800
|
if (!agent) return '[Error] Connector agent not found.'
|
|
435
801
|
|
|
@@ -472,11 +838,16 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
472
838
|
}
|
|
473
839
|
}
|
|
474
840
|
|
|
475
|
-
// Find
|
|
841
|
+
// Find a session for this connector message.
|
|
842
|
+
// Prefer the agent's thread session (visible in the agent chat UI) so connector
|
|
843
|
+
// messages appear inline alongside web UI messages.
|
|
844
|
+
// Fall back to a connector-keyed session if the agent has no thread session.
|
|
476
845
|
const sessionKey = `connector:${connector.id}:${msg.channelId}`
|
|
477
846
|
const sessions = loadSessions()
|
|
478
|
-
|
|
479
|
-
|
|
847
|
+
let session = (agent.threadSessionId && sessions[agent.threadSessionId])
|
|
848
|
+
? sessions[agent.threadSessionId]
|
|
849
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
850
|
+
: Object.values(sessions).find((s: any) => s.name === sessionKey)
|
|
480
851
|
if (!session) {
|
|
481
852
|
const id = genId()
|
|
482
853
|
session = {
|
|
@@ -564,6 +935,7 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
564
935
|
const settings = loadSettings()
|
|
565
936
|
const promptParts: string[] = []
|
|
566
937
|
if (settings.userPrompt) promptParts.push(settings.userPrompt)
|
|
938
|
+
promptParts.push(buildCurrentDateTimePromptContext())
|
|
567
939
|
if (agent.soul) promptParts.push(agent.soul)
|
|
568
940
|
if (agent.systemPrompt) promptParts.push(agent.systemPrompt)
|
|
569
941
|
if (agent.skillIds?.length) {
|
|
@@ -582,24 +954,49 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
582
954
|
// Add connector context
|
|
583
955
|
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.
|
|
584
956
|
|
|
957
|
+
## Response Style
|
|
958
|
+
Be action-first and autonomous: when the user gives an instruction, execute it instead of asking routine follow-up questions.
|
|
959
|
+
Do not end every reply with a question.
|
|
960
|
+
Only ask a question when a specific missing detail blocks progress.
|
|
961
|
+
When a task is complete, state the result plainly and stop.
|
|
962
|
+
|
|
585
963
|
## Knowing When Not to Reply
|
|
586
964
|
Real conversations have natural pauses — not every message needs a response. Reply with exactly "NO_MESSAGE" (nothing else) to stay silent when replying would feel unnatural or forced.
|
|
587
965
|
Stay silent for simple acknowledgments ("okay", "alright", "cool", "got it", "sounds good"), conversation closers ("thanks", "bye", "night", "ttyl"), reactions (emoji, "haha", "lol"), and forwarded content with no question attached.
|
|
588
966
|
Always reply when there's a question, task, instruction, emotional sharing, or something genuinely useful to add.
|
|
589
|
-
The test: would a thoughtful friend feel compelled to type something back? If not, NO_MESSAGE
|
|
967
|
+
The test: would a thoughtful friend feel compelled to type something back? If not, NO_MESSAGE.
|
|
968
|
+
|
|
969
|
+
## Media Delivery Rules
|
|
970
|
+
When the user asks to send media (image, screenshot, PDF, file, or voice note), actually call tools to send it.
|
|
971
|
+
Do not claim "sent" unless a tool call succeeded.
|
|
972
|
+
If voice note is requested, prefer connector_message_tool action=send_voice_note when available.
|
|
973
|
+
If media sending fails, report the exact error and retry with a corrected path/target.`)
|
|
590
974
|
const systemPrompt = promptParts.join('\n\n')
|
|
591
975
|
|
|
592
976
|
// Add message to session
|
|
593
977
|
const firstImage = msg.media?.find((m) => m.type === 'image')
|
|
594
978
|
const firstImageUrl = msg.imageUrl || (firstImage?.url) || undefined
|
|
595
979
|
const firstImagePath = firstImage?.localPath || undefined
|
|
980
|
+
const inboundAttachmentPaths = buildInboundAttachmentPaths(msg)
|
|
596
981
|
const inboundText = formatInboundUserText(msg)
|
|
982
|
+
const modelInputText = inboundText
|
|
983
|
+
// Store the raw user text for display (source.senderName handles attribution).
|
|
984
|
+
// The formatted text with [SenderName] prefix is only used for LLM history context.
|
|
985
|
+
const rawText = (msg.text || '').trim()
|
|
986
|
+
const messageSource: MessageSource = {
|
|
987
|
+
platform: connector.platform,
|
|
988
|
+
connectorId: connector.id,
|
|
989
|
+
connectorName: connector.name,
|
|
990
|
+
senderName: msg.senderName,
|
|
991
|
+
}
|
|
597
992
|
session.messages.push({
|
|
598
993
|
role: 'user',
|
|
599
|
-
text: inboundText,
|
|
994
|
+
text: rawText || inboundText,
|
|
600
995
|
time: Date.now(),
|
|
601
996
|
imageUrl: firstImageUrl,
|
|
602
997
|
imagePath: firstImagePath,
|
|
998
|
+
attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
|
|
999
|
+
source: messageSource,
|
|
603
1000
|
})
|
|
604
1001
|
session.lastActiveAt = Date.now()
|
|
605
1002
|
const s1 = loadSessions()
|
|
@@ -608,22 +1005,49 @@ The test: would a thoughtful friend feel compelled to type something back? If no
|
|
|
608
1005
|
|
|
609
1006
|
// Stream the response
|
|
610
1007
|
let fullText = ''
|
|
1008
|
+
let mediaExtractionText = ''
|
|
1009
|
+
let connectorToolDeliveredCurrentChannel = false
|
|
611
1010
|
const hasTools = session.tools?.length && session.provider !== 'claude-cli'
|
|
612
1011
|
console.log(`[connector] Routing message to agent "${agent.name}" (${agent.provider}/${agent.model}), hasTools=${!!hasTools}`)
|
|
613
1012
|
|
|
614
1013
|
if (hasTools) {
|
|
615
1014
|
try {
|
|
1015
|
+
const toolMediaOutputs: string[] = []
|
|
616
1016
|
const result = await streamAgentChat({
|
|
617
1017
|
session,
|
|
618
|
-
message:
|
|
1018
|
+
message: modelInputText,
|
|
619
1019
|
imagePath: firstImagePath,
|
|
1020
|
+
attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
|
|
620
1021
|
apiKey,
|
|
621
1022
|
systemPrompt,
|
|
622
|
-
write: () => {
|
|
1023
|
+
write: (raw) => {
|
|
1024
|
+
for (const event of parseSseDataEvents(raw)) {
|
|
1025
|
+
if (event.t !== 'tool_result') continue
|
|
1026
|
+
const toolOutput = typeof event.toolOutput === 'string' ? event.toolOutput : ''
|
|
1027
|
+
if (!toolOutput) continue
|
|
1028
|
+
toolMediaOutputs.push(toolOutput)
|
|
1029
|
+
if (event.toolName === 'connector_message_tool') {
|
|
1030
|
+
const parsed = parseConnectorToolResult(toolOutput)
|
|
1031
|
+
if (!parsed?.status || !parsed.to) continue
|
|
1032
|
+
const sentLikeStatus = parsed.status === 'sent' || parsed.status === 'voice_sent'
|
|
1033
|
+
if (!sentLikeStatus) continue
|
|
1034
|
+
const inboundTarget = connector.platform === 'whatsapp'
|
|
1035
|
+
? normalizeWhatsappTarget(msg.channelId)
|
|
1036
|
+
: msg.channelId
|
|
1037
|
+
const outboundTarget = connector.platform === 'whatsapp'
|
|
1038
|
+
? normalizeWhatsappTarget(parsed.to)
|
|
1039
|
+
: parsed.to
|
|
1040
|
+
if (inboundTarget && outboundTarget && inboundTarget === outboundTarget) {
|
|
1041
|
+
connectorToolDeliveredCurrentChannel = true
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
},
|
|
623
1046
|
history: session.messages.slice(-20),
|
|
624
1047
|
})
|
|
625
1048
|
// Use finalResponse for connectors — strips intermediate planning/tool-use text
|
|
626
|
-
fullText = result.finalResponse
|
|
1049
|
+
fullText = result.finalResponse || result.fullText
|
|
1050
|
+
mediaExtractionText = [result.fullText || '', ...toolMediaOutputs].filter(Boolean).join('\n\n')
|
|
627
1051
|
console.log(`[connector] streamAgentChat returned ${result.fullText.length} chars total, ${fullText.length} chars final`)
|
|
628
1052
|
} catch (err: unknown) {
|
|
629
1053
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -638,7 +1062,7 @@ The test: would a thoughtful friend feel compelled to type something back? If no
|
|
|
638
1062
|
|
|
639
1063
|
await provider.handler.streamChat({
|
|
640
1064
|
session,
|
|
641
|
-
message:
|
|
1065
|
+
message: modelInputText,
|
|
642
1066
|
imagePath: firstImagePath,
|
|
643
1067
|
apiKey,
|
|
644
1068
|
systemPrompt,
|
|
@@ -654,6 +1078,7 @@ The test: would a thoughtful friend feel compelled to type something back? If no
|
|
|
654
1078
|
active: new Map(),
|
|
655
1079
|
loadHistory: () => session.messages.slice(-20),
|
|
656
1080
|
})
|
|
1081
|
+
mediaExtractionText = fullText
|
|
657
1082
|
}
|
|
658
1083
|
|
|
659
1084
|
// If the agent chose NO_MESSAGE, skip saving it to history — the user's message
|
|
@@ -679,9 +1104,14 @@ The test: would a thoughtful friend feel compelled to type something back? If no
|
|
|
679
1104
|
},
|
|
680
1105
|
})
|
|
681
1106
|
|
|
682
|
-
// Save assistant response to session
|
|
1107
|
+
// Save assistant response to session (full text with image markdown for web UI rendering)
|
|
1108
|
+
const assistantSource: MessageSource = {
|
|
1109
|
+
platform: connector.platform,
|
|
1110
|
+
connectorId: connector.id,
|
|
1111
|
+
connectorName: connector.name,
|
|
1112
|
+
}
|
|
683
1113
|
if (fullText.trim()) {
|
|
684
|
-
session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now() })
|
|
1114
|
+
session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now(), source: assistantSource })
|
|
685
1115
|
session.lastActiveAt = Date.now()
|
|
686
1116
|
const s2 = loadSessions()
|
|
687
1117
|
s2[session.id] = session
|
|
@@ -689,9 +1119,68 @@ The test: would a thoughtful friend feel compelled to type something back? If no
|
|
|
689
1119
|
notify(`messages:${session.id}`)
|
|
690
1120
|
}
|
|
691
1121
|
|
|
1122
|
+
// Extract embedded media (screenshots, uploaded files) and send them as separate
|
|
1123
|
+
// media messages via the connector, then return the cleaned text
|
|
1124
|
+
const extractedFromReply = extractEmbeddedMedia(fullText)
|
|
1125
|
+
const extractedFromTools = mediaExtractionText && mediaExtractionText !== fullText
|
|
1126
|
+
? extractEmbeddedMedia(mediaExtractionText)
|
|
1127
|
+
: { cleanText: mediaExtractionText || fullText, files: [] as Array<{ path: string; alt: string }> }
|
|
1128
|
+
const filesToSend = selectOutboundMediaFiles(
|
|
1129
|
+
[...extractedFromReply.files, ...extractedFromTools.files],
|
|
1130
|
+
msg.text || '',
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
if (filesToSend.length > 0) {
|
|
1134
|
+
const inst = running.get(connector.id)
|
|
1135
|
+
if (inst?.sendMessage) {
|
|
1136
|
+
for (const file of filesToSend) {
|
|
1137
|
+
try {
|
|
1138
|
+
await inst.sendMessage(msg.channelId, '', { mediaPath: file.path, caption: file.alt || undefined })
|
|
1139
|
+
console.log(`[connector] Sent media to ${msg.platform}: ${path.basename(file.path)}`)
|
|
1140
|
+
logExecution(session.id, 'outbound', 'Connector media sent', {
|
|
1141
|
+
agentId: agent.id,
|
|
1142
|
+
detail: {
|
|
1143
|
+
platform: msg.platform,
|
|
1144
|
+
channelId: msg.channelId,
|
|
1145
|
+
filePath: file.path,
|
|
1146
|
+
fileName: path.basename(file.path),
|
|
1147
|
+
},
|
|
1148
|
+
})
|
|
1149
|
+
} catch (err: unknown) {
|
|
1150
|
+
console.error(`[connector] Failed to send media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
|
|
1151
|
+
logExecution(session.id, 'error', 'Connector media send failed', {
|
|
1152
|
+
agentId: agent.id,
|
|
1153
|
+
detail: {
|
|
1154
|
+
platform: msg.platform,
|
|
1155
|
+
channelId: msg.channelId,
|
|
1156
|
+
filePath: file.path,
|
|
1157
|
+
fileName: path.basename(file.path),
|
|
1158
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1159
|
+
},
|
|
1160
|
+
})
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
} else {
|
|
1164
|
+
logExecution(session.id, 'error', 'Connector media skipped: sendMessage unavailable', {
|
|
1165
|
+
agentId: agent.id,
|
|
1166
|
+
detail: {
|
|
1167
|
+
platform: msg.platform,
|
|
1168
|
+
channelId: msg.channelId,
|
|
1169
|
+
fileCount: filesToSend.length,
|
|
1170
|
+
connectorId: connector.id,
|
|
1171
|
+
},
|
|
1172
|
+
})
|
|
1173
|
+
}
|
|
1174
|
+
if (connectorToolDeliveredCurrentChannel) return NO_MESSAGE_SENTINEL
|
|
1175
|
+
return extractedFromReply.cleanText || '(no response)'
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
if (connectorToolDeliveredCurrentChannel) return NO_MESSAGE_SENTINEL
|
|
692
1179
|
return fullText || '(no response)'
|
|
693
1180
|
}
|
|
694
1181
|
|
|
1182
|
+
routeMessageHandlerRef.current = routeMessage
|
|
1183
|
+
|
|
695
1184
|
/** Start a connector (serialized per ID to prevent concurrent start/stop races) */
|
|
696
1185
|
export async function startConnector(connectorId: string): Promise<void> {
|
|
697
1186
|
// Wait for any pending operation on this connector to finish (with timeout)
|
|
@@ -756,7 +1245,11 @@ async function _startConnectorImpl(connectorId: string): Promise<void> {
|
|
|
756
1245
|
generationCounter.set(connectorId, (generationCounter.get(connectorId) ?? 0) + 1)
|
|
757
1246
|
|
|
758
1247
|
try {
|
|
759
|
-
const instance = await platform.start(
|
|
1248
|
+
const instance = await platform.start(
|
|
1249
|
+
connector,
|
|
1250
|
+
botToken,
|
|
1251
|
+
(msg) => dispatchInboundConnectorMessage(connectorId, connector, msg),
|
|
1252
|
+
)
|
|
760
1253
|
running.set(connectorId, instance)
|
|
761
1254
|
|
|
762
1255
|
// Update status in storage
|
|
@@ -789,6 +1282,12 @@ export async function stopConnector(connectorId: string): Promise<void> {
|
|
|
789
1282
|
running.delete(connectorId)
|
|
790
1283
|
}
|
|
791
1284
|
|
|
1285
|
+
for (const [followupId, followup] of scheduledFollowups.entries()) {
|
|
1286
|
+
if (followup.connectorId !== connectorId) continue
|
|
1287
|
+
clearTimeout(followup.timer)
|
|
1288
|
+
scheduledFollowups.delete(followupId)
|
|
1289
|
+
}
|
|
1290
|
+
|
|
792
1291
|
const connectors = loadConnectors()
|
|
793
1292
|
const connector = connectors[connectorId]
|
|
794
1293
|
if (connector) {
|
|
@@ -873,6 +1372,7 @@ export function listRunningConnectors(platform?: string): Array<{
|
|
|
873
1372
|
id: string
|
|
874
1373
|
name: string
|
|
875
1374
|
platform: string
|
|
1375
|
+
agentId: string | null
|
|
876
1376
|
supportsSend: boolean
|
|
877
1377
|
configuredTargets: string[]
|
|
878
1378
|
recentChannelId: string | null
|
|
@@ -882,6 +1382,7 @@ export function listRunningConnectors(platform?: string): Array<{
|
|
|
882
1382
|
id: string
|
|
883
1383
|
name: string
|
|
884
1384
|
platform: string
|
|
1385
|
+
agentId: string | null
|
|
885
1386
|
supportsSend: boolean
|
|
886
1387
|
configuredTargets: string[]
|
|
887
1388
|
recentChannelId: string | null
|
|
@@ -907,6 +1408,7 @@ export function listRunningConnectors(platform?: string): Array<{
|
|
|
907
1408
|
id,
|
|
908
1409
|
name: connector.name,
|
|
909
1410
|
platform: connector.platform,
|
|
1411
|
+
agentId: connector.agentId || null,
|
|
910
1412
|
supportsSend: typeof instance.sendMessage === 'function',
|
|
911
1413
|
configuredTargets: Array.from(new Set(configuredTargets)),
|
|
912
1414
|
recentChannelId: lastInboundChannelByConnector.get(id) || null,
|
|
@@ -949,6 +1451,7 @@ export async function sendConnectorMessage(params: {
|
|
|
949
1451
|
mimeType?: string
|
|
950
1452
|
fileName?: string
|
|
951
1453
|
caption?: string
|
|
1454
|
+
ptt?: boolean
|
|
952
1455
|
}): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
|
|
953
1456
|
const connectors = loadConnectors()
|
|
954
1457
|
const requestedId = params.connectorId?.trim()
|
|
@@ -988,18 +1491,93 @@ export async function sendConnectorMessage(params: {
|
|
|
988
1491
|
return { connectorId, platform: connector.platform, channelId: params.channelId }
|
|
989
1492
|
}
|
|
990
1493
|
|
|
991
|
-
const
|
|
1494
|
+
const hasMedia = !!(params.imageUrl || params.fileUrl || params.mediaPath)
|
|
1495
|
+
const channelId = connector.platform === 'whatsapp'
|
|
1496
|
+
? normalizeWhatsappTarget(params.channelId)
|
|
1497
|
+
: params.channelId
|
|
1498
|
+
|
|
1499
|
+
let outboundText = params.text || ''
|
|
1500
|
+
let outboundOptions: Parameters<NonNullable<ConnectorInstance['sendMessage']>>[2] | undefined = {
|
|
992
1501
|
imageUrl: params.imageUrl,
|
|
993
1502
|
fileUrl: params.fileUrl,
|
|
994
1503
|
mediaPath: params.mediaPath,
|
|
995
1504
|
mimeType: params.mimeType,
|
|
996
1505
|
fileName: params.fileName,
|
|
997
1506
|
caption: params.caption,
|
|
998
|
-
|
|
1507
|
+
ptt: params.ptt,
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (hasMedia && !connectorSupportsBinaryMedia(connector.platform)) {
|
|
1511
|
+
const mediaLink = params.imageUrl
|
|
1512
|
+
|| params.fileUrl
|
|
1513
|
+
|| (params.mediaPath ? uploadApiUrlFromPath(params.mediaPath) : null)
|
|
1514
|
+
const fallbackParts = [
|
|
1515
|
+
(params.text || '').trim(),
|
|
1516
|
+
(params.caption || '').trim(),
|
|
1517
|
+
mediaLink ? `Attachment: ${mediaLink}` : '',
|
|
1518
|
+
!mediaLink && params.mediaPath ? `Attachment: ${path.basename(params.mediaPath)}` : '',
|
|
1519
|
+
].filter(Boolean)
|
|
1520
|
+
outboundText = fallbackParts.join('\n')
|
|
1521
|
+
outboundOptions = undefined
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
const result = await instance.sendMessage(channelId, outboundText, outboundOptions)
|
|
999
1525
|
return {
|
|
1000
1526
|
connectorId,
|
|
1001
1527
|
platform: connector.platform,
|
|
1002
|
-
channelId
|
|
1528
|
+
channelId,
|
|
1003
1529
|
messageId: result?.messageId,
|
|
1004
1530
|
}
|
|
1005
1531
|
}
|
|
1532
|
+
|
|
1533
|
+
export function scheduleConnectorFollowUp(params: {
|
|
1534
|
+
connectorId?: string
|
|
1535
|
+
platform?: string
|
|
1536
|
+
channelId: string
|
|
1537
|
+
text: string
|
|
1538
|
+
delaySec?: number
|
|
1539
|
+
imageUrl?: string
|
|
1540
|
+
fileUrl?: string
|
|
1541
|
+
mediaPath?: string
|
|
1542
|
+
mimeType?: string
|
|
1543
|
+
fileName?: string
|
|
1544
|
+
caption?: string
|
|
1545
|
+
ptt?: boolean
|
|
1546
|
+
}): { followUpId: string; sendAt: number } {
|
|
1547
|
+
const delaySecRaw = Number.isFinite(params.delaySec) ? Number(params.delaySec) : 300
|
|
1548
|
+
const delayMs = Math.max(1_000, Math.min(86_400_000, Math.round(delaySecRaw * 1000)))
|
|
1549
|
+
const followUpId = genId()
|
|
1550
|
+
const sendAt = Date.now() + delayMs
|
|
1551
|
+
|
|
1552
|
+
const timer = setTimeout(() => {
|
|
1553
|
+
void sendConnectorMessage({
|
|
1554
|
+
connectorId: params.connectorId,
|
|
1555
|
+
platform: params.platform,
|
|
1556
|
+
channelId: params.channelId,
|
|
1557
|
+
text: params.text,
|
|
1558
|
+
imageUrl: params.imageUrl,
|
|
1559
|
+
fileUrl: params.fileUrl,
|
|
1560
|
+
mediaPath: params.mediaPath,
|
|
1561
|
+
mimeType: params.mimeType,
|
|
1562
|
+
fileName: params.fileName,
|
|
1563
|
+
caption: params.caption,
|
|
1564
|
+
ptt: params.ptt,
|
|
1565
|
+
}).catch((err: unknown) => {
|
|
1566
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
1567
|
+
console.warn(`[connector] Scheduled follow-up ${followUpId} failed: ${msg}`)
|
|
1568
|
+
}).finally(() => {
|
|
1569
|
+
scheduledFollowups.delete(followUpId)
|
|
1570
|
+
})
|
|
1571
|
+
}, delayMs)
|
|
1572
|
+
|
|
1573
|
+
scheduledFollowups.set(followUpId, {
|
|
1574
|
+
id: followUpId,
|
|
1575
|
+
connectorId: params.connectorId,
|
|
1576
|
+
platform: params.platform,
|
|
1577
|
+
channelId: params.channelId,
|
|
1578
|
+
sendAt,
|
|
1579
|
+
timer,
|
|
1580
|
+
})
|
|
1581
|
+
|
|
1582
|
+
return { followUpId, sendAt }
|
|
1583
|
+
}
|