@swarmclawai/swarmclaw 0.7.1 → 0.7.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 +155 -150
- package/package.json +1 -1
- package/src/app/api/agents/[id]/route.ts +26 -0
- package/src/app/api/agents/[id]/thread/route.ts +37 -9
- package/src/app/api/agents/route.ts +13 -2
- package/src/app/api/auth/route.ts +76 -7
- package/src/app/api/chatrooms/[id]/chat/route.ts +7 -2
- package/src/app/api/{sessions → chats}/[id]/browser/route.ts +5 -1
- package/src/app/api/{sessions → chats}/[id]/chat/route.ts +7 -3
- package/src/app/api/{sessions → chats}/[id]/checkpoints/route.ts +1 -1
- package/src/app/api/chats/[id]/main-loop/route.ts +13 -0
- package/src/app/api/{sessions → chats}/[id]/messages/route.ts +19 -13
- package/src/app/api/{sessions → chats}/[id]/restore/route.ts +1 -1
- package/src/app/api/{sessions → chats}/[id]/route.ts +22 -52
- package/src/app/api/{sessions → chats}/[id]/stop/route.ts +6 -1
- package/src/app/api/{sessions → chats}/route.ts +21 -7
- package/src/app/api/connectors/[id]/doctor/route.ts +26 -0
- package/src/app/api/connectors/doctor/route.ts +13 -0
- package/src/app/api/files/open/route.ts +16 -14
- package/src/app/api/memory/maintenance/route.ts +11 -1
- package/src/app/api/openclaw/agent-files/route.ts +27 -4
- package/src/app/api/openclaw/skills/route.ts +11 -3
- package/src/app/api/plugins/dependencies/route.ts +24 -0
- package/src/app/api/plugins/install/route.ts +15 -92
- package/src/app/api/plugins/route.ts +6 -26
- package/src/app/api/plugins/settings/route.ts +40 -0
- package/src/app/api/plugins/ui/route.ts +1 -0
- package/src/app/api/settings/route.ts +49 -7
- package/src/app/api/tasks/[id]/route.ts +15 -6
- package/src/app/api/tasks/bulk/route.ts +2 -2
- package/src/app/api/tasks/route.ts +9 -4
- package/src/app/api/usage/route.ts +30 -0
- package/src/app/api/webhooks/[id]/route.ts +8 -1
- package/src/app/page.tsx +9 -2
- package/src/cli/index.js +39 -33
- package/src/cli/index.ts +43 -49
- package/src/cli/spec.js +29 -27
- package/src/components/agents/agent-card.tsx +16 -13
- package/src/components/agents/agent-chat-list.tsx +104 -4
- package/src/components/agents/agent-list.tsx +54 -22
- package/src/components/agents/agent-sheet.tsx +209 -18
- package/src/components/agents/cron-job-form.tsx +3 -3
- package/src/components/agents/inspector-panel.tsx +110 -50
- package/src/components/auth/access-key-gate.tsx +36 -97
- package/src/components/auth/setup-wizard.tsx +5 -38
- package/src/components/chat/chat-area.tsx +39 -27
- package/src/components/{sessions/session-card.tsx → chat/chat-card.tsx} +7 -23
- package/src/components/chat/chat-header.tsx +299 -314
- package/src/components/{sessions/session-list.tsx → chat/chat-list.tsx} +11 -14
- package/src/components/chat/chat-tool-toggles.tsx +26 -17
- package/src/components/chat/checkpoint-timeline.tsx +4 -4
- package/src/components/chat/message-bubble.tsx +4 -1
- package/src/components/chat/message-list.tsx +5 -3
- package/src/components/chat/session-debug-panel.tsx +1 -1
- package/src/components/chat/tool-request-banner.tsx +3 -3
- package/src/components/chatrooms/agent-hover-card.tsx +3 -3
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +2 -2
- package/src/components/chatrooms/chatroom-view.tsx +347 -205
- package/src/components/connectors/connector-list.tsx +265 -127
- package/src/components/connectors/connector-sheet.tsx +218 -1
- package/src/components/home/home-view.tsx +129 -5
- package/src/components/layout/app-layout.tsx +392 -182
- package/src/components/layout/mobile-header.tsx +26 -8
- package/src/components/plugins/plugin-list.tsx +487 -254
- package/src/components/plugins/plugin-sheet.tsx +236 -13
- package/src/components/projects/project-detail.tsx +183 -0
- package/src/components/settings/gateway-connection-panel.tsx +1 -1
- package/src/components/shared/agent-picker-list.tsx +2 -2
- package/src/components/shared/command-palette.tsx +111 -25
- package/src/components/shared/settings/plugin-manager.tsx +20 -4
- package/src/components/shared/settings/section-capability-policy.tsx +105 -0
- package/src/components/shared/settings/section-heartbeat.tsx +78 -1
- package/src/components/shared/settings/section-orchestrator.tsx +3 -3
- package/src/components/shared/settings/section-providers.tsx +1 -1
- package/src/components/shared/settings/section-runtime-loop.tsx +5 -5
- package/src/components/shared/settings/section-secrets.tsx +6 -6
- package/src/components/shared/settings/section-user-preferences.tsx +1 -1
- package/src/components/shared/settings/section-voice.tsx +5 -1
- package/src/components/shared/settings/section-web-search.tsx +10 -2
- package/src/components/shared/settings/settings-page.tsx +244 -56
- package/src/components/tasks/approvals-panel.tsx +205 -18
- package/src/components/tasks/task-board.tsx +242 -46
- package/src/components/usage/metrics-dashboard.tsx +147 -1
- package/src/components/wallets/wallet-panel.tsx +17 -5
- package/src/components/webhooks/webhook-sheet.tsx +8 -8
- package/src/lib/auth.ts +17 -0
- package/src/lib/chat-streaming-state.test.ts +108 -0
- package/src/lib/chat-streaming-state.ts +108 -0
- package/src/lib/chat.ts +1 -1
- package/src/lib/{sessions.ts → chats.ts} +28 -18
- package/src/lib/openclaw-agent-id.test.ts +14 -0
- package/src/lib/openclaw-agent-id.ts +31 -0
- package/src/lib/providers/claude-cli.ts +1 -1
- package/src/lib/server/agent-assignment.test.ts +112 -0
- package/src/lib/server/agent-assignment.ts +169 -0
- package/src/lib/server/approval-connector-notify.test.ts +253 -0
- package/src/lib/server/approvals-auto-approve.test.ts +205 -0
- package/src/lib/server/approvals.ts +483 -75
- package/src/lib/server/autonomy-runtime.test.ts +341 -0
- package/src/lib/server/browser-state.test.ts +118 -0
- package/src/lib/server/browser-state.ts +123 -0
- package/src/lib/server/build-llm.test.ts +36 -0
- package/src/lib/server/build-llm.ts +11 -4
- package/src/lib/server/builtin-plugins.ts +34 -0
- package/src/lib/server/capability-router.ts +10 -8
- package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +134 -0
- package/src/lib/server/chat-execution.ts +285 -165
- package/src/lib/server/chatroom-health.test.ts +26 -0
- package/src/lib/server/chatroom-health.ts +2 -3
- package/src/lib/server/chatroom-helpers.test.ts +67 -2
- package/src/lib/server/chatroom-helpers.ts +48 -8
- package/src/lib/server/connectors/discord.ts +175 -11
- package/src/lib/server/connectors/doctor.test.ts +80 -0
- package/src/lib/server/connectors/doctor.ts +116 -0
- package/src/lib/server/connectors/manager.ts +948 -112
- package/src/lib/server/connectors/policy.test.ts +222 -0
- package/src/lib/server/connectors/policy.ts +452 -0
- package/src/lib/server/connectors/slack.ts +188 -9
- package/src/lib/server/connectors/telegram.ts +65 -15
- package/src/lib/server/connectors/thread-context.test.ts +44 -0
- package/src/lib/server/connectors/thread-context.ts +72 -0
- package/src/lib/server/connectors/types.ts +41 -11
- package/src/lib/server/cost.ts +34 -1
- package/src/lib/server/daemon-state.ts +61 -3
- package/src/lib/server/data-dir.ts +13 -0
- package/src/lib/server/delegation-jobs.test.ts +140 -0
- package/src/lib/server/delegation-jobs.ts +248 -0
- package/src/lib/server/document-utils.test.ts +47 -0
- package/src/lib/server/document-utils.ts +397 -0
- package/src/lib/server/heartbeat-service.ts +14 -40
- package/src/lib/server/heartbeat-source.test.ts +22 -0
- package/src/lib/server/heartbeat-source.ts +7 -0
- package/src/lib/server/identity-continuity.test.ts +77 -0
- package/src/lib/server/identity-continuity.ts +127 -0
- package/src/lib/server/mailbox-utils.ts +347 -0
- package/src/lib/server/main-agent-loop.ts +28 -1103
- package/src/lib/server/memory-db.ts +4 -6
- package/src/lib/server/memory-tiers.ts +40 -0
- package/src/lib/server/openclaw-agent-resolver.test.ts +70 -0
- package/src/lib/server/openclaw-agent-resolver.ts +128 -0
- package/src/lib/server/openclaw-exec-config.ts +5 -6
- package/src/lib/server/openclaw-skills-normalize.test.ts +56 -0
- package/src/lib/server/openclaw-skills-normalize.ts +136 -0
- package/src/lib/server/openclaw-sync.ts +3 -2
- package/src/lib/server/orchestrator-lg.ts +20 -9
- package/src/lib/server/orchestrator.ts +7 -7
- package/src/lib/server/playwright-proxy.mjs +27 -3
- package/src/lib/server/plugins.test.ts +207 -0
- package/src/lib/server/plugins.ts +927 -66
- package/src/lib/server/provider-health.ts +38 -6
- package/src/lib/server/queue.ts +13 -28
- package/src/lib/server/scheduler.ts +2 -0
- package/src/lib/server/session-archive-memory.test.ts +85 -0
- package/src/lib/server/session-archive-memory.ts +230 -0
- package/src/lib/server/session-mailbox.ts +8 -18
- package/src/lib/server/session-reset-policy.test.ts +99 -0
- package/src/lib/server/session-reset-policy.ts +311 -0
- package/src/lib/server/session-run-manager.ts +33 -82
- package/src/lib/server/session-tools/autonomy-tools.test.ts +105 -0
- package/src/lib/server/session-tools/calendar.ts +366 -0
- package/src/lib/server/session-tools/canvas.ts +1 -1
- package/src/lib/server/session-tools/chatroom.ts +4 -2
- package/src/lib/server/session-tools/connector.ts +114 -10
- package/src/lib/server/session-tools/context.ts +21 -5
- package/src/lib/server/session-tools/crawl.ts +447 -0
- package/src/lib/server/session-tools/crud.ts +74 -28
- package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
- package/src/lib/server/session-tools/delegate.ts +497 -24
- package/src/lib/server/session-tools/discovery.ts +24 -6
- package/src/lib/server/session-tools/document.ts +283 -0
- package/src/lib/server/session-tools/edit_file.ts +4 -2
- package/src/lib/server/session-tools/email.ts +320 -0
- package/src/lib/server/session-tools/extract.ts +137 -0
- package/src/lib/server/session-tools/file-normalize.test.ts +93 -0
- package/src/lib/server/session-tools/file-send.test.ts +84 -1
- package/src/lib/server/session-tools/file.ts +241 -25
- package/src/lib/server/session-tools/git.ts +1 -1
- package/src/lib/server/session-tools/http.ts +1 -1
- package/src/lib/server/session-tools/human-loop.ts +227 -0
- package/src/lib/server/session-tools/image-gen.ts +380 -0
- package/src/lib/server/session-tools/index.ts +130 -50
- package/src/lib/server/session-tools/mailbox.ts +276 -0
- package/src/lib/server/session-tools/memory.ts +172 -3
- package/src/lib/server/session-tools/monitor.ts +151 -8
- package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
- package/src/lib/server/session-tools/openclaw-nodes.ts +1 -1
- package/src/lib/server/session-tools/openclaw-workspace.ts +1 -1
- package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
- package/src/lib/server/session-tools/platform.ts +148 -7
- package/src/lib/server/session-tools/plugin-creator.ts +89 -26
- package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
- package/src/lib/server/session-tools/replicate.ts +301 -0
- package/src/lib/server/session-tools/sample-ui.ts +1 -1
- package/src/lib/server/session-tools/sandbox.ts +4 -2
- package/src/lib/server/session-tools/schedule.ts +24 -12
- package/src/lib/server/session-tools/session-info.ts +43 -7
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +31 -17
- package/src/lib/server/session-tools/shell.ts +5 -2
- package/src/lib/server/session-tools/subagent.ts +194 -28
- package/src/lib/server/session-tools/table.ts +587 -0
- package/src/lib/server/session-tools/wallet.ts +42 -12
- package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
- package/src/lib/server/session-tools/web.ts +926 -91
- package/src/lib/server/storage.ts +255 -16
- package/src/lib/server/stream-agent-chat.ts +116 -268
- package/src/lib/server/structured-extract.test.ts +72 -0
- package/src/lib/server/structured-extract.ts +373 -0
- package/src/lib/server/task-mention.test.ts +16 -2
- package/src/lib/server/task-mention.ts +61 -10
- package/src/lib/server/tool-aliases.ts +66 -18
- package/src/lib/server/tool-capability-policy.test.ts +9 -9
- package/src/lib/server/tool-capability-policy.ts +38 -27
- package/src/lib/server/tool-retry.ts +2 -0
- package/src/lib/server/watch-jobs.test.ts +173 -0
- package/src/lib/server/watch-jobs.ts +532 -0
- package/src/lib/server/ws-hub.ts +5 -3
- package/src/lib/tool-definitions.ts +4 -0
- package/src/lib/validation/schemas.test.ts +26 -0
- package/src/lib/validation/schemas.ts +10 -1
- package/src/lib/ws-client.ts +14 -12
- package/src/proxy.ts +5 -5
- package/src/stores/use-app-store.ts +5 -11
- package/src/stores/use-chat-store.ts +38 -9
- package/src/types/index.ts +352 -47
- package/src/app/api/sessions/[id]/main-loop/route.ts +0 -94
- package/src/components/sessions/new-session-sheet.tsx +0 -253
- package/src/lib/server/main-session.ts +0 -24
- package/src/lib/server/session-run-manager.test.ts +0 -23
- /package/src/app/api/{sessions → chats}/[id]/clear/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/deploy/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/devserver/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/edit-resend/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/fork/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/mailbox/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/[id]/retry/route.ts +0 -0
- /package/src/app/api/{sessions → chats}/heartbeat/route.ts +0 -0
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
import { filterHealthyChatroomAgents } from '../chatroom-health'
|
|
29
29
|
import { evaluateRoutingRules } from '../chatroom-routing'
|
|
30
30
|
import { markProviderFailure, markProviderSuccess } from '../provider-health'
|
|
31
|
+
import { syncSessionArchiveMemory } from '../session-archive-memory'
|
|
32
|
+
import { buildIdentityContinuityContext } from '../identity-continuity'
|
|
31
33
|
import { getProvider } from '@/lib/providers'
|
|
32
|
-
import type { Connector, MessageSource, Chatroom, ChatroomMessage } from '@/types'
|
|
34
|
+
import type { Connector, MessageSource, Chatroom, ChatroomMessage, Session } from '@/types'
|
|
33
35
|
import type { ConnectorInstance, InboundMessage, InboundMedia } from './types'
|
|
34
36
|
import {
|
|
35
37
|
addAllowedSender,
|
|
@@ -43,6 +45,20 @@ import {
|
|
|
43
45
|
type PairingPolicy,
|
|
44
46
|
} from './pairing'
|
|
45
47
|
import { enrichInboundMessageWithAudioTranscript } from './inbound-audio-transcription'
|
|
48
|
+
import {
|
|
49
|
+
buildConnectorConversationKey,
|
|
50
|
+
buildConnectorDoctorWarnings,
|
|
51
|
+
buildInboundDebounceKey,
|
|
52
|
+
buildInboundDedupeKey,
|
|
53
|
+
getConnectorSessionStaleness,
|
|
54
|
+
isReplyToLastOutbound,
|
|
55
|
+
mergeInboundMessages,
|
|
56
|
+
resetConnectorSessionRuntime,
|
|
57
|
+
resolveConnectorSessionPolicy,
|
|
58
|
+
shouldReplyToInboundMessage,
|
|
59
|
+
textMentionsAlias,
|
|
60
|
+
} from './policy'
|
|
61
|
+
import { buildConnectorThreadContextBlock, resolveThreadPersonaLabel } from './thread-context'
|
|
46
62
|
|
|
47
63
|
function resolveUploadPathFromUrl(rawUrl: string): string | null {
|
|
48
64
|
if (!rawUrl) return null
|
|
@@ -80,7 +96,7 @@ function parseSseDataEvents(raw: string): Array<Record<string, unknown>> {
|
|
|
80
96
|
return events
|
|
81
97
|
}
|
|
82
98
|
|
|
83
|
-
function parseConnectorToolResult(toolOutput: string): { status?: string; to?: string; followUpId?: string } | null {
|
|
99
|
+
function parseConnectorToolResult(toolOutput: string): { status?: string; to?: string; followUpId?: string; messageId?: string } | null {
|
|
84
100
|
const raw = toolOutput.trim()
|
|
85
101
|
if (!raw) return null
|
|
86
102
|
try {
|
|
@@ -90,7 +106,8 @@ function parseConnectorToolResult(toolOutput: string): { status?: string; to?: s
|
|
|
90
106
|
const status = typeof record.status === 'string' ? String(record.status) : undefined
|
|
91
107
|
const to = typeof record.to === 'string' ? String(record.to) : undefined
|
|
92
108
|
const followUpId = typeof record.followUpId === 'string' ? String(record.followUpId) : undefined
|
|
93
|
-
|
|
109
|
+
const messageId = typeof record.messageId === 'string' ? String(record.messageId) : undefined
|
|
110
|
+
return { status, to, followUpId, messageId }
|
|
94
111
|
} catch {
|
|
95
112
|
return null
|
|
96
113
|
}
|
|
@@ -279,6 +296,24 @@ const followupKey = '__swarmclaw_connector_followups__' as const
|
|
|
279
296
|
const scheduledFollowups: Map<string, ScheduledConnectorFollowup> =
|
|
280
297
|
g[followupKey] ?? (g[followupKey] = new Map<string, ScheduledConnectorFollowup>())
|
|
281
298
|
|
|
299
|
+
const inboundDedupeKey = '__swarmclaw_connector_inbound_dedupe__' as const
|
|
300
|
+
const recentInboundByKey: Map<string, number> =
|
|
301
|
+
g[inboundDedupeKey] ?? (g[inboundDedupeKey] = new Map<string, number>())
|
|
302
|
+
|
|
303
|
+
type DebouncedInboundEntry = {
|
|
304
|
+
connector: Connector
|
|
305
|
+
messages: InboundMessage[]
|
|
306
|
+
timer: ReturnType<typeof setTimeout>
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const inboundDebounceKey = '__swarmclaw_connector_inbound_debounce__' as const
|
|
310
|
+
const pendingInboundDebounce: Map<string, DebouncedInboundEntry> =
|
|
311
|
+
g[inboundDebounceKey] ?? (g[inboundDebounceKey] = new Map<string, DebouncedInboundEntry>())
|
|
312
|
+
|
|
313
|
+
const followupDedupeKey = '__swarmclaw_connector_followup_dedupe__' as const
|
|
314
|
+
const scheduledFollowupByDedupe: Map<string, { id: string; sendAt: number }> =
|
|
315
|
+
g[followupDedupeKey] ?? (g[followupDedupeKey] = new Map<string, { id: string; sendAt: number }>())
|
|
316
|
+
|
|
282
317
|
/** Reconnect state per connector — tracks backoff and retry attempts for crash recovery */
|
|
283
318
|
export interface ConnectorReconnectState {
|
|
284
319
|
attempts: number
|
|
@@ -308,11 +343,157 @@ function recordHealthEvent(connectorId: string, event: ConnectorHealthEventType,
|
|
|
308
343
|
})
|
|
309
344
|
}
|
|
310
345
|
|
|
346
|
+
function statusReactionForPlatform(platform: string, state: 'processing' | 'sent' | 'silent'): string {
|
|
347
|
+
if (platform === 'slack') {
|
|
348
|
+
if (state === 'processing') return 'eyes'
|
|
349
|
+
if (state === 'sent') return 'white_check_mark'
|
|
350
|
+
return 'zipper_mouth_face'
|
|
351
|
+
}
|
|
352
|
+
if (state === 'processing') return '👀'
|
|
353
|
+
if (state === 'sent') return '✅'
|
|
354
|
+
return '🤐'
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function pruneTransientConnectorState(now = Date.now()): void {
|
|
358
|
+
for (const [key, seenAt] of recentInboundByKey.entries()) {
|
|
359
|
+
if (now - seenAt > 120_000) recentInboundByKey.delete(key)
|
|
360
|
+
}
|
|
361
|
+
for (const [key, entry] of scheduledFollowupByDedupe.entries()) {
|
|
362
|
+
if (entry.sendAt <= now) scheduledFollowupByDedupe.delete(key)
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function rememberRecentInbound(key: string, now = Date.now(), ttlMs = 120_000): boolean {
|
|
367
|
+
pruneTransientConnectorState(now)
|
|
368
|
+
const previous = recentInboundByKey.get(key) || 0
|
|
369
|
+
if (previous && now - previous < ttlMs) return false
|
|
370
|
+
recentInboundByKey.set(key, now)
|
|
371
|
+
return true
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
375
|
+
function findDirectSessionForInbound(connector: Connector, msg: InboundMessage): Record<string, any> | null {
|
|
376
|
+
if (connector.chatroomId) return null
|
|
377
|
+
const effectiveAgentId = msg.agentIdOverride || connector.agentId
|
|
378
|
+
const sessions = Object.values(loadSessions()) as Array<Record<string, any>>
|
|
379
|
+
const candidates = sessions.filter((session) =>
|
|
380
|
+
session?.agentId === effectiveAgentId
|
|
381
|
+
&& session?.connectorContext?.connectorId === connector.id
|
|
382
|
+
&& session?.connectorContext?.channelId === msg.channelId,
|
|
383
|
+
)
|
|
384
|
+
if (msg.threadId) {
|
|
385
|
+
const threadExact = candidates.find((session) => session?.connectorContext?.threadId === msg.threadId)
|
|
386
|
+
if (threadExact) return threadExact
|
|
387
|
+
}
|
|
388
|
+
const senderExact = candidates.find((session) => session?.connectorContext?.senderId === msg.senderId)
|
|
389
|
+
if (senderExact) return senderExact
|
|
390
|
+
return candidates[0] || null
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function maybeSendStatusReaction(
|
|
394
|
+
connector: Connector,
|
|
395
|
+
msg: InboundMessage,
|
|
396
|
+
state: 'processing' | 'sent' | 'silent',
|
|
397
|
+
): Promise<void> {
|
|
398
|
+
if (!msg.messageId) return
|
|
399
|
+
const session = findDirectSessionForInbound(connector, msg)
|
|
400
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
401
|
+
if (!policy.statusReactions) return
|
|
402
|
+
const instance = running.get(connector.id)
|
|
403
|
+
if (!instance?.sendReaction) return
|
|
404
|
+
try {
|
|
405
|
+
await instance.sendReaction(msg.channelId, msg.messageId, statusReactionForPlatform(connector.platform, state))
|
|
406
|
+
} catch {
|
|
407
|
+
// Ignore reaction failures — connectors vary widely here.
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function startConnectorTypingLoop(connector: Connector, msg: InboundMessage): (() => void) | null {
|
|
412
|
+
const session = findDirectSessionForInbound(connector, msg)
|
|
413
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
414
|
+
if (!policy.typingIndicators) return null
|
|
415
|
+
const instance = running.get(connector.id)
|
|
416
|
+
if (!instance?.sendTyping) return null
|
|
417
|
+
const replyOptions = shouldReplyToInboundMessage({ msg, session, policy })
|
|
418
|
+
|
|
419
|
+
const sendTyping = () => {
|
|
420
|
+
void instance.sendTyping?.(msg.channelId, { threadId: replyOptions.threadId }).catch(() => {
|
|
421
|
+
// Best effort only.
|
|
422
|
+
})
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
sendTyping()
|
|
426
|
+
const timer = setInterval(sendTyping, 4_000)
|
|
427
|
+
timer.unref?.()
|
|
428
|
+
return () => clearInterval(timer)
|
|
429
|
+
}
|
|
430
|
+
|
|
311
431
|
type RouteMessageHandler = (connector: Connector, msg: InboundMessage) => Promise<string>
|
|
312
432
|
const routeHandlerKey = '__swarmclaw_connector_route_handler__' as const
|
|
313
433
|
const routeMessageHandlerRef: { current: RouteMessageHandler } =
|
|
314
434
|
g[routeHandlerKey] ?? (g[routeHandlerKey] = { current: async () => '[Error] Connector router unavailable.' })
|
|
315
435
|
|
|
436
|
+
async function flushDebouncedInbound(key: string): Promise<void> {
|
|
437
|
+
const entry = pendingInboundDebounce.get(key)
|
|
438
|
+
if (!entry) return
|
|
439
|
+
pendingInboundDebounce.delete(key)
|
|
440
|
+
clearTimeout(entry.timer)
|
|
441
|
+
const merged = mergeInboundMessages(entry.messages)
|
|
442
|
+
const response = await routeMessageHandlerRef.current(entry.connector, merged)
|
|
443
|
+
if (isNoMessage(response)) {
|
|
444
|
+
return
|
|
445
|
+
}
|
|
446
|
+
const replyOptions = getConnectorReplySendOptions({ connectorId: entry.connector.id, inbound: merged })
|
|
447
|
+
const session = findDirectSessionForInbound(entry.connector, merged)
|
|
448
|
+
await sendConnectorMessage({
|
|
449
|
+
connectorId: entry.connector.id,
|
|
450
|
+
channelId: merged.channelId,
|
|
451
|
+
text: response,
|
|
452
|
+
sessionId: session?.id,
|
|
453
|
+
replyToMessageId: replyOptions.replyToMessageId,
|
|
454
|
+
threadId: replyOptions.threadId,
|
|
455
|
+
})
|
|
456
|
+
await maybeSendStatusReaction(entry.connector, merged, 'sent')
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function routeOrDebounceInbound(connector: Connector, msg: InboundMessage): Promise<string> {
|
|
460
|
+
const dedupeKey = buildInboundDedupeKey(connector, msg)
|
|
461
|
+
const dedupeTtlMs = dedupeKey.startsWith('msg:') ? 120_000 : 15_000
|
|
462
|
+
if (!rememberRecentInbound(dedupeKey, Date.now(), dedupeTtlMs)) return NO_MESSAGE_SENTINEL
|
|
463
|
+
|
|
464
|
+
const session = findDirectSessionForInbound(connector, msg)
|
|
465
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
466
|
+
if (policy.inboundDebounceMs <= 0) {
|
|
467
|
+
return routeMessageHandlerRef.current(connector, msg)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const debounceKey = buildInboundDebounceKey(connector, msg)
|
|
471
|
+
const pending = pendingInboundDebounce.get(debounceKey)
|
|
472
|
+
if (pending) {
|
|
473
|
+
pending.messages.push(msg)
|
|
474
|
+
clearTimeout(pending.timer)
|
|
475
|
+
pending.timer = setTimeout(() => {
|
|
476
|
+
void flushDebouncedInbound(debounceKey).catch((err: unknown) => {
|
|
477
|
+
console.warn(`[connector] Debounced inbound flush failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
478
|
+
})
|
|
479
|
+
}, policy.inboundDebounceMs)
|
|
480
|
+
pending.timer.unref?.()
|
|
481
|
+
} else {
|
|
482
|
+
const timer = setTimeout(() => {
|
|
483
|
+
void flushDebouncedInbound(debounceKey).catch((err: unknown) => {
|
|
484
|
+
console.warn(`[connector] Debounced inbound flush failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
485
|
+
})
|
|
486
|
+
}, policy.inboundDebounceMs)
|
|
487
|
+
timer.unref?.()
|
|
488
|
+
pendingInboundDebounce.set(debounceKey, {
|
|
489
|
+
connector,
|
|
490
|
+
messages: [msg],
|
|
491
|
+
timer,
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
return NO_MESSAGE_SENTINEL
|
|
495
|
+
}
|
|
496
|
+
|
|
316
497
|
function dispatchInboundConnectorMessage(
|
|
317
498
|
connectorId: string,
|
|
318
499
|
fallbackConnector: Connector,
|
|
@@ -320,7 +501,7 @@ function dispatchInboundConnectorMessage(
|
|
|
320
501
|
): Promise<string> {
|
|
321
502
|
const connectors = loadConnectors()
|
|
322
503
|
const currentConnector = connectors[connectorId] as Connector | undefined
|
|
323
|
-
return
|
|
504
|
+
return routeOrDebounceInbound(currentConnector ?? fallbackConnector, msg)
|
|
324
505
|
}
|
|
325
506
|
|
|
326
507
|
/** Get the current generation number for a connector (0 if never started) */
|
|
@@ -404,7 +585,17 @@ export function formatInboundUserText(msg: InboundMessage): string {
|
|
|
404
585
|
return lines.join('\n').trim()
|
|
405
586
|
}
|
|
406
587
|
|
|
407
|
-
type ConnectorCommandName =
|
|
588
|
+
type ConnectorCommandName =
|
|
589
|
+
| 'help'
|
|
590
|
+
| 'status'
|
|
591
|
+
| 'new'
|
|
592
|
+
| 'reset'
|
|
593
|
+
| 'compact'
|
|
594
|
+
| 'think'
|
|
595
|
+
| 'pair'
|
|
596
|
+
| 'session'
|
|
597
|
+
| 'focus'
|
|
598
|
+
| 'doctor'
|
|
408
599
|
|
|
409
600
|
interface ParsedConnectorCommand {
|
|
410
601
|
name: ConnectorCommandName
|
|
@@ -425,12 +616,301 @@ function parseConnectorCommand(text: string): ParsedConnectorCommand | null {
|
|
|
425
616
|
case 'compact':
|
|
426
617
|
case 'think':
|
|
427
618
|
case 'pair':
|
|
619
|
+
case 'session':
|
|
620
|
+
case 'focus':
|
|
621
|
+
case 'doctor':
|
|
428
622
|
return { name, args } as ParsedConnectorCommand
|
|
429
623
|
default:
|
|
430
624
|
return null
|
|
431
625
|
}
|
|
432
626
|
}
|
|
433
627
|
|
|
628
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
629
|
+
function persistSessionRecord(session: Record<string, any>): void {
|
|
630
|
+
const sessions = loadSessions()
|
|
631
|
+
sessions[session.id] = session
|
|
632
|
+
saveSessions(sessions)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
636
|
+
function updateSessionConnectorContext(session: Record<string, any>, connector: Connector, msg: InboundMessage, sessionKey: string): void {
|
|
637
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
638
|
+
session.connectorContext = {
|
|
639
|
+
...(session.connectorContext || {}),
|
|
640
|
+
connectorId: connector.id,
|
|
641
|
+
platform: connector.platform,
|
|
642
|
+
channelId: msg.channelId,
|
|
643
|
+
senderId: msg.senderId,
|
|
644
|
+
senderName: msg.senderName,
|
|
645
|
+
sessionKey,
|
|
646
|
+
peerKey: msg.senderId,
|
|
647
|
+
scope: policy.scope,
|
|
648
|
+
replyMode: policy.replyMode,
|
|
649
|
+
threadBinding: policy.threadBinding,
|
|
650
|
+
groupPolicy: policy.groupPolicy,
|
|
651
|
+
threadId: msg.threadId || session.connectorContext?.threadId || null,
|
|
652
|
+
threadTitle: msg.threadTitle || session.connectorContext?.threadTitle || null,
|
|
653
|
+
threadPersonaLabel: resolveThreadPersonaLabel(msg) || session.connectorContext?.threadPersonaLabel || null,
|
|
654
|
+
threadParentChannelId: msg.threadParentChannelId || session.connectorContext?.threadParentChannelId || null,
|
|
655
|
+
threadParentChannelName: msg.threadParentChannelName || session.connectorContext?.threadParentChannelName || null,
|
|
656
|
+
isGroup: !!msg.isGroup,
|
|
657
|
+
lastInboundAt: Date.now(),
|
|
658
|
+
lastInboundMessageId: msg.messageId || null,
|
|
659
|
+
lastInboundReplyToMessageId: msg.replyToMessageId || null,
|
|
660
|
+
lastInboundThreadId: msg.threadId || null,
|
|
661
|
+
lastOutboundAt: session.connectorContext?.lastOutboundAt ?? null,
|
|
662
|
+
lastOutboundMessageId: session.connectorContext?.lastOutboundMessageId ?? null,
|
|
663
|
+
lastResetAt: session.connectorContext?.lastResetAt ?? null,
|
|
664
|
+
lastResetReason: session.connectorContext?.lastResetReason ?? null,
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function describeSessionControls(session: Record<string, any>, connector: Connector, msg: InboundMessage): string {
|
|
669
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
670
|
+
const context = session.connectorContext || {}
|
|
671
|
+
const sessionAgeSec = Math.max(0, Math.round((Date.now() - (session.createdAt || Date.now())) / 1000))
|
|
672
|
+
const idleSec = Math.max(0, Math.round((Date.now() - (session.lastActiveAt || Date.now())) / 1000))
|
|
673
|
+
return [
|
|
674
|
+
`Session controls for ${connector.platform}/${connector.name}:`,
|
|
675
|
+
`- Session: ${session.id}`,
|
|
676
|
+
`- Scope: ${policy.scope}`,
|
|
677
|
+
`- Reply mode: ${policy.replyMode}`,
|
|
678
|
+
`- Thread binding: ${policy.threadBinding}`,
|
|
679
|
+
`- Group policy: ${policy.groupPolicy}`,
|
|
680
|
+
`- Reset mode: ${policy.resetMode}`,
|
|
681
|
+
`- Idle timeout: ${policy.idleTimeoutSec ?? 0}s`,
|
|
682
|
+
`- Max age: ${policy.maxAgeSec ?? 0}s`,
|
|
683
|
+
`- Daily reset: ${policy.dailyResetAt || 'off'}`,
|
|
684
|
+
`- Reset timezone: ${policy.resetTimezone || 'local'}`,
|
|
685
|
+
`- Debounce: ${policy.inboundDebounceMs}ms`,
|
|
686
|
+
`- Typing indicators: ${policy.typingIndicators ? 'on' : 'off'}`,
|
|
687
|
+
`- Thinking: ${policy.thinkingLevel || session.thinkingLevel || 'inherit'}`,
|
|
688
|
+
`- Model: ${session.provider}/${session.model}`,
|
|
689
|
+
`- Last outbound message: ${context.lastOutboundMessageId || 'none'}`,
|
|
690
|
+
`- Thread: ${context.threadId || 'none'}`,
|
|
691
|
+
`- Thread title: ${context.threadTitle || 'none'}`,
|
|
692
|
+
`- Thread persona: ${context.threadPersonaLabel || 'none'}`,
|
|
693
|
+
`- Session age: ${sessionAgeSec}s`,
|
|
694
|
+
`- Idle for: ${idleSec}s`,
|
|
695
|
+
].join('\n')
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function normalizeSessionSettingKey(raw: string): string {
|
|
699
|
+
return raw.trim().toLowerCase().replace(/[_-]+/g, '')
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function applySessionSetting(session: Record<string, any>, keyRaw: string, valueRaw: string, msg: InboundMessage): string {
|
|
703
|
+
const key = normalizeSessionSettingKey(keyRaw)
|
|
704
|
+
const value = valueRaw.trim()
|
|
705
|
+
const asInt = () => {
|
|
706
|
+
const parsed = Number.parseInt(value, 10)
|
|
707
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
708
|
+
throw new Error(`Invalid numeric value for ${keyRaw}: ${valueRaw}`)
|
|
709
|
+
}
|
|
710
|
+
return parsed
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
switch (key) {
|
|
714
|
+
case 'think':
|
|
715
|
+
case 'thinkinglevel':
|
|
716
|
+
session.connectorThinkLevel = value || null
|
|
717
|
+
return `Connector thinking level set to ${session.connectorThinkLevel || 'inherit'}.`
|
|
718
|
+
case 'reply':
|
|
719
|
+
case 'replymode':
|
|
720
|
+
session.connectorReplyMode = value || null
|
|
721
|
+
return `Reply mode set to ${session.connectorReplyMode || 'inherit'}.`
|
|
722
|
+
case 'scope':
|
|
723
|
+
case 'sessionscope':
|
|
724
|
+
session.connectorSessionScope = value || null
|
|
725
|
+
return `Session scope set to ${session.connectorSessionScope || 'inherit'}.`
|
|
726
|
+
case 'thread':
|
|
727
|
+
case 'threadbinding':
|
|
728
|
+
session.connectorThreadBinding = value || null
|
|
729
|
+
if (!value) {
|
|
730
|
+
session.connectorContext = { ...(session.connectorContext || {}), threadId: null }
|
|
731
|
+
} else if (value === 'strict' && msg.threadId) {
|
|
732
|
+
session.connectorContext = { ...(session.connectorContext || {}), threadId: msg.threadId }
|
|
733
|
+
}
|
|
734
|
+
return `Thread binding set to ${session.connectorThreadBinding || 'inherit'}.`
|
|
735
|
+
case 'group':
|
|
736
|
+
case 'grouppolicy':
|
|
737
|
+
session.connectorGroupPolicy = value || null
|
|
738
|
+
return `Group policy set to ${session.connectorGroupPolicy || 'inherit'}.`
|
|
739
|
+
case 'idle':
|
|
740
|
+
case 'idletimeout':
|
|
741
|
+
session.connectorIdleTimeoutSec = asInt()
|
|
742
|
+
return `Idle timeout set to ${session.connectorIdleTimeoutSec}s.`
|
|
743
|
+
case 'maxage':
|
|
744
|
+
session.connectorMaxAgeSec = asInt()
|
|
745
|
+
return `Max age set to ${session.connectorMaxAgeSec}s.`
|
|
746
|
+
case 'reset':
|
|
747
|
+
case 'resetmode': {
|
|
748
|
+
const normalized = value.toLowerCase()
|
|
749
|
+
if (!value) {
|
|
750
|
+
session.sessionResetMode = null
|
|
751
|
+
return 'Reset mode set to inherit.'
|
|
752
|
+
}
|
|
753
|
+
if (normalized !== 'idle' && normalized !== 'daily') {
|
|
754
|
+
throw new Error('Reset mode must be "idle" or "daily".')
|
|
755
|
+
}
|
|
756
|
+
session.sessionResetMode = normalized
|
|
757
|
+
return `Reset mode set to ${session.sessionResetMode}.`
|
|
758
|
+
}
|
|
759
|
+
case 'daily':
|
|
760
|
+
case 'dailyreset':
|
|
761
|
+
case 'dailyresetat':
|
|
762
|
+
if (!value) {
|
|
763
|
+
session.sessionDailyResetAt = null
|
|
764
|
+
return 'Daily reset time cleared.'
|
|
765
|
+
}
|
|
766
|
+
if (!/^\d{1,2}:\d{2}$/.test(value)) {
|
|
767
|
+
throw new Error('Daily reset time must be in HH:MM format.')
|
|
768
|
+
}
|
|
769
|
+
session.sessionDailyResetAt = value
|
|
770
|
+
return `Daily reset time set to ${session.sessionDailyResetAt}.`
|
|
771
|
+
case 'timezone':
|
|
772
|
+
case 'resettimezone':
|
|
773
|
+
session.sessionResetTimezone = value || null
|
|
774
|
+
return `Reset timezone set to ${session.sessionResetTimezone || 'inherit/local'}.`
|
|
775
|
+
case 'model':
|
|
776
|
+
session.model = value
|
|
777
|
+
return `Model set to ${session.model}.`
|
|
778
|
+
case 'provider':
|
|
779
|
+
session.provider = value
|
|
780
|
+
session.apiEndpoint = getProvider(value)?.defaultEndpoint || session.apiEndpoint || null
|
|
781
|
+
return `Provider set to ${session.provider}.`
|
|
782
|
+
default:
|
|
783
|
+
throw new Error(`Unknown session setting "${keyRaw}".`)
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function evaluateGroupPolicy(params: {
|
|
788
|
+
connector: Connector
|
|
789
|
+
msg: InboundMessage
|
|
790
|
+
session?: Record<string, any> | null
|
|
791
|
+
aliases: string[]
|
|
792
|
+
}): { allowed: boolean; reason: string } {
|
|
793
|
+
const { connector, msg, session, aliases } = params
|
|
794
|
+
if (!msg.isGroup) return { allowed: true, reason: 'dm' }
|
|
795
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
796
|
+
if (policy.groupPolicy === 'open') return { allowed: true, reason: 'open' }
|
|
797
|
+
if (policy.groupPolicy === 'disabled') return { allowed: false, reason: 'disabled' }
|
|
798
|
+
const mentioned = !!msg.mentionsBot || textMentionsAlias(msg.text || '', aliases)
|
|
799
|
+
const replied = isReplyToLastOutbound(msg, session)
|
|
800
|
+
if (policy.groupPolicy === 'mention') {
|
|
801
|
+
return { allowed: mentioned, reason: mentioned ? 'mentioned' : 'mention_required' }
|
|
802
|
+
}
|
|
803
|
+
const allowed = mentioned || replied
|
|
804
|
+
return { allowed, reason: allowed ? (mentioned ? 'mentioned' : 'reply') : 'reply_or_mention_required' }
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function applyConnectorRuntimeDefaults(session: Record<string, any>, defaults: {
|
|
808
|
+
provider: string
|
|
809
|
+
model: string
|
|
810
|
+
apiEndpoint: string | null
|
|
811
|
+
thinkingLevel: string | null
|
|
812
|
+
}): void {
|
|
813
|
+
session.provider = defaults.provider
|
|
814
|
+
session.model = defaults.model
|
|
815
|
+
session.apiEndpoint = defaults.apiEndpoint
|
|
816
|
+
session.connectorThinkLevel = defaults.thinkingLevel
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function resolveDirectSession(params: {
|
|
820
|
+
connector: Connector
|
|
821
|
+
msg: InboundMessage
|
|
822
|
+
agent: Record<string, any>
|
|
823
|
+
}): { session: Record<string, any>; sessionKey: string; wasCreated: boolean; staleReason?: string | null; clearedMessages?: number } {
|
|
824
|
+
const { connector, msg, agent } = params
|
|
825
|
+
const policySeed = resolveConnectorSessionPolicy(connector, msg)
|
|
826
|
+
const providerInfo = policySeed.providerOverride ? getProvider(policySeed.providerOverride) : null
|
|
827
|
+
const defaultProvider = policySeed.providerOverride || (agent.provider === 'claude-cli' ? 'anthropic' : agent.provider)
|
|
828
|
+
const defaultModel = policySeed.modelOverride || agent.model
|
|
829
|
+
const defaultApiEndpoint = agent.apiEndpoint || providerInfo?.defaultEndpoint || null
|
|
830
|
+
const runtimeDefaults = {
|
|
831
|
+
provider: defaultProvider,
|
|
832
|
+
model: defaultModel,
|
|
833
|
+
apiEndpoint: defaultApiEndpoint,
|
|
834
|
+
thinkingLevel: policySeed.thinkingLevel || null,
|
|
835
|
+
}
|
|
836
|
+
const sessionKey = buildConnectorConversationKey({
|
|
837
|
+
connector,
|
|
838
|
+
msg,
|
|
839
|
+
agentId: agent.id,
|
|
840
|
+
policy: policySeed,
|
|
841
|
+
})
|
|
842
|
+
const sessions = loadSessions()
|
|
843
|
+
let session = Object.values(sessions).find((item: any) => item?.name === sessionKey) as Record<string, any> | undefined
|
|
844
|
+
let wasCreated = false
|
|
845
|
+
if (!session) {
|
|
846
|
+
const id = genId()
|
|
847
|
+
session = {
|
|
848
|
+
id,
|
|
849
|
+
name: sessionKey,
|
|
850
|
+
cwd: WORKSPACE_DIR,
|
|
851
|
+
user: 'connector',
|
|
852
|
+
provider: defaultProvider,
|
|
853
|
+
model: defaultModel,
|
|
854
|
+
credentialId: agent.credentialId || null,
|
|
855
|
+
fallbackCredentialIds: Array.isArray(agent.fallbackCredentialIds) ? [...agent.fallbackCredentialIds] : [],
|
|
856
|
+
apiEndpoint: defaultApiEndpoint,
|
|
857
|
+
claudeSessionId: null,
|
|
858
|
+
codexThreadId: null,
|
|
859
|
+
opencodeSessionId: null,
|
|
860
|
+
delegateResumeIds: {
|
|
861
|
+
claudeCode: null,
|
|
862
|
+
codex: null,
|
|
863
|
+
opencode: null,
|
|
864
|
+
gemini: null,
|
|
865
|
+
},
|
|
866
|
+
messages: [],
|
|
867
|
+
createdAt: Date.now(),
|
|
868
|
+
lastActiveAt: Date.now(),
|
|
869
|
+
sessionType: 'human' as const,
|
|
870
|
+
agentId: agent.id,
|
|
871
|
+
plugins: agent.plugins || agent.tools || [],
|
|
872
|
+
thinkingLevel: agent.thinkingLevel || null,
|
|
873
|
+
connectorThinkLevel: policySeed.thinkingLevel || null,
|
|
874
|
+
}
|
|
875
|
+
wasCreated = true
|
|
876
|
+
}
|
|
877
|
+
session.name = sessionKey
|
|
878
|
+
session.agentId = agent.id
|
|
879
|
+
session.plugins = Array.isArray(session.plugins) ? session.plugins : (agent.plugins || agent.tools || [])
|
|
880
|
+
if (!session.provider) session.provider = defaultProvider
|
|
881
|
+
if (!session.model) session.model = defaultModel
|
|
882
|
+
if (session.credentialId === undefined) session.credentialId = agent.credentialId || null
|
|
883
|
+
if (!Array.isArray(session.fallbackCredentialIds) && Array.isArray(agent.fallbackCredentialIds)) {
|
|
884
|
+
session.fallbackCredentialIds = [...agent.fallbackCredentialIds]
|
|
885
|
+
}
|
|
886
|
+
if (session.apiEndpoint === undefined || session.apiEndpoint === null) session.apiEndpoint = defaultApiEndpoint
|
|
887
|
+
if ((session.connectorThinkLevel === undefined || session.connectorThinkLevel === null) && policySeed.thinkingLevel) {
|
|
888
|
+
session.connectorThinkLevel = policySeed.thinkingLevel
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
892
|
+
const staleness = getConnectorSessionStaleness(session, policy)
|
|
893
|
+
let clearedMessages = 0
|
|
894
|
+
if (staleness.stale) {
|
|
895
|
+
try { syncSessionArchiveMemory(session as any, { agent }) } catch { /* archive sync is best-effort */ }
|
|
896
|
+
clearedMessages = resetConnectorSessionRuntime(session as any, staleness.reason || 'session_refresh')
|
|
897
|
+
applyConnectorRuntimeDefaults(session, {
|
|
898
|
+
...runtimeDefaults,
|
|
899
|
+
thinkingLevel: policySeed.thinkingLevel || session.connectorThinkLevel || null,
|
|
900
|
+
})
|
|
901
|
+
}
|
|
902
|
+
updateSessionConnectorContext(session, connector, msg, sessionKey)
|
|
903
|
+
sessions[session.id] = session
|
|
904
|
+
saveSessions(sessions)
|
|
905
|
+
return {
|
|
906
|
+
session,
|
|
907
|
+
sessionKey,
|
|
908
|
+
wasCreated,
|
|
909
|
+
staleReason: staleness.reason || null,
|
|
910
|
+
clearedMessages,
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
434
914
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
435
915
|
function pushSessionMessage(session: Record<string, any>, role: 'user' | 'assistant', text: string): void {
|
|
436
916
|
if (!text.trim()) return
|
|
@@ -609,6 +1089,10 @@ async function handleConnectorCommand(params: {
|
|
|
609
1089
|
'/new or /reset — Clear this connector conversation thread',
|
|
610
1090
|
'/compact [keepLastN] — Summarize older history and keep recent messages (default 10)',
|
|
611
1091
|
'/think <minimal|low|medium|high> — Set connector thread reasoning guidance',
|
|
1092
|
+
'/session — Show session controls',
|
|
1093
|
+
'/session set <scope|reply|thread|group|idle|maxAge|resetMode|dailyResetAt|timezone|think|model|provider> <value> — Patch this connector session',
|
|
1094
|
+
'/focus here|clear — Bind or clear focus on the current thread/topic',
|
|
1095
|
+
'/doctor — Show autonomy and safety warnings for this connector/session',
|
|
612
1096
|
'/pair — Pairing/access controls (status, request, list, approve, allow)',
|
|
613
1097
|
'/help — Show this list',
|
|
614
1098
|
].join('\n')
|
|
@@ -619,10 +1103,11 @@ async function handleConnectorCommand(params: {
|
|
|
619
1103
|
}
|
|
620
1104
|
|
|
621
1105
|
if (command.name === 'status') {
|
|
1106
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
622
1107
|
const all = Array.isArray(session.messages) ? session.messages : []
|
|
623
1108
|
const userCount = all.filter((m: { role?: string }) => m?.role === 'user').length
|
|
624
1109
|
const assistantCount = all.filter((m: { role?: string }) => m?.role === 'assistant').length
|
|
625
|
-
const toolsCount = Array.isArray(session.
|
|
1110
|
+
const toolsCount = Array.isArray(session.plugins) ? session.plugins.length : 0
|
|
626
1111
|
const statusText = [
|
|
627
1112
|
`Status for ${connector.platform} / ${connector.name}:`,
|
|
628
1113
|
`- Agent: ${agentName}`,
|
|
@@ -632,6 +1117,9 @@ async function handleConnectorCommand(params: {
|
|
|
632
1117
|
`- Tools enabled: ${toolsCount}`,
|
|
633
1118
|
`- Channel: ${msg.channelName || msg.channelId}`,
|
|
634
1119
|
`- Last active: ${new Date(session.lastActiveAt || session.createdAt || Date.now()).toLocaleString()}`,
|
|
1120
|
+
`- Reset mode: ${policy.resetMode}`,
|
|
1121
|
+
`- Reply mode: ${policy.replyMode}`,
|
|
1122
|
+
`- Scope: ${policy.scope}`,
|
|
635
1123
|
].join('\n')
|
|
636
1124
|
pushSessionMessage(session, 'user', inboundText)
|
|
637
1125
|
pushSessionMessage(session, 'assistant', statusText)
|
|
@@ -640,13 +1128,17 @@ async function handleConnectorCommand(params: {
|
|
|
640
1128
|
}
|
|
641
1129
|
|
|
642
1130
|
if (command.name === 'new' || command.name === 'reset') {
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
session
|
|
648
|
-
|
|
649
|
-
|
|
1131
|
+
try { syncSessionArchiveMemory(session as any, { agent: loadAgents()[session.agentId] }) } catch { /* best effort */ }
|
|
1132
|
+
const cleared = resetConnectorSessionRuntime(session as any, 'manual_reset')
|
|
1133
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
1134
|
+
const providerInfo = policy.providerOverride ? getProvider(policy.providerOverride) : null
|
|
1135
|
+
applyConnectorRuntimeDefaults(session, {
|
|
1136
|
+
provider: policy.providerOverride || session.provider,
|
|
1137
|
+
model: policy.modelOverride || session.model,
|
|
1138
|
+
apiEndpoint: providerInfo?.defaultEndpoint || session.apiEndpoint || null,
|
|
1139
|
+
thinkingLevel: policy.thinkingLevel || session.connectorThinkLevel || null,
|
|
1140
|
+
})
|
|
1141
|
+
updateSessionConnectorContext(session, connector, msg, session.name || session.id)
|
|
650
1142
|
persistSession(session)
|
|
651
1143
|
return `Reset complete for ${connector.platform} channel thread. Cleared ${cleared} message(s).`
|
|
652
1144
|
}
|
|
@@ -683,8 +1175,9 @@ async function handleConnectorCommand(params: {
|
|
|
683
1175
|
const requested = command.args.trim().toLowerCase()
|
|
684
1176
|
const allowed = new Set(['minimal', 'low', 'medium', 'high'])
|
|
685
1177
|
if (!requested) {
|
|
686
|
-
const
|
|
687
|
-
|
|
1178
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
1179
|
+
const current = typeof policy.thinkingLevel === 'string' && allowed.has(policy.thinkingLevel)
|
|
1180
|
+
? policy.thinkingLevel
|
|
688
1181
|
: 'medium'
|
|
689
1182
|
const text = `Current /think level: ${current}. Usage: /think <minimal|low|medium|high>.`
|
|
690
1183
|
pushSessionMessage(session, 'user', inboundText)
|
|
@@ -708,6 +1201,77 @@ async function handleConnectorCommand(params: {
|
|
|
708
1201
|
return text
|
|
709
1202
|
}
|
|
710
1203
|
|
|
1204
|
+
if (command.name === 'doctor') {
|
|
1205
|
+
const warnings = buildConnectorDoctorWarnings({ connector, msg, session })
|
|
1206
|
+
const text = warnings.length
|
|
1207
|
+
? ['Connector doctor:', ...warnings.map((item) => `- ${item}`)].join('\n')
|
|
1208
|
+
: 'Connector doctor: no obvious autonomy or safety issues detected.'
|
|
1209
|
+
pushSessionMessage(session, 'user', inboundText)
|
|
1210
|
+
pushSessionMessage(session, 'assistant', text)
|
|
1211
|
+
persistSession(session)
|
|
1212
|
+
return text
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
if (command.name === 'session') {
|
|
1216
|
+
const parts = command.args.split(/\s+/).map((item) => item.trim()).filter(Boolean)
|
|
1217
|
+
if (!parts.length || parts[0].toLowerCase() === 'show' || parts[0].toLowerCase() === 'status') {
|
|
1218
|
+
const text = describeSessionControls(session, connector, msg)
|
|
1219
|
+
pushSessionMessage(session, 'user', inboundText)
|
|
1220
|
+
pushSessionMessage(session, 'assistant', text)
|
|
1221
|
+
persistSession(session)
|
|
1222
|
+
return text
|
|
1223
|
+
}
|
|
1224
|
+
if (parts[0].toLowerCase() === 'reset') {
|
|
1225
|
+
try { syncSessionArchiveMemory(session as any, { agent: loadAgents()[session.agentId] }) } catch { /* best effort */ }
|
|
1226
|
+
const cleared = resetConnectorSessionRuntime(session as any, 'manual_reset')
|
|
1227
|
+
const policy = resolveConnectorSessionPolicy(connector, msg, session)
|
|
1228
|
+
const providerInfo = policy.providerOverride ? getProvider(policy.providerOverride) : null
|
|
1229
|
+
applyConnectorRuntimeDefaults(session, {
|
|
1230
|
+
provider: policy.providerOverride || session.provider,
|
|
1231
|
+
model: policy.modelOverride || session.model,
|
|
1232
|
+
apiEndpoint: providerInfo?.defaultEndpoint || session.apiEndpoint || null,
|
|
1233
|
+
thinkingLevel: policy.thinkingLevel || session.connectorThinkLevel || null,
|
|
1234
|
+
})
|
|
1235
|
+
updateSessionConnectorContext(session, connector, msg, session.name || session.id)
|
|
1236
|
+
persistSession(session)
|
|
1237
|
+
return `Connector session reset. Cleared ${cleared} message(s).`
|
|
1238
|
+
}
|
|
1239
|
+
if (parts[0].toLowerCase() === 'set') {
|
|
1240
|
+
const key = parts[1] || ''
|
|
1241
|
+
const value = parts.slice(2).join(' ').trim()
|
|
1242
|
+
if (!key) return 'Usage: /session set <scope|reply|thread|group|idle|maxAge|resetMode|dailyResetAt|timezone|think|model|provider> <value>'
|
|
1243
|
+
try {
|
|
1244
|
+
const text = applySessionSetting(session, key, value, msg)
|
|
1245
|
+
updateSessionConnectorContext(session, connector, msg, session.name || session.id)
|
|
1246
|
+
persistSession(session)
|
|
1247
|
+
return text
|
|
1248
|
+
} catch (err: unknown) {
|
|
1249
|
+
return err instanceof Error ? err.message : String(err)
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
return 'Usage: /session, /session show, /session set <key> <value>, /session reset'
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
if (command.name === 'focus') {
|
|
1256
|
+
const subcommand = command.args.trim().toLowerCase()
|
|
1257
|
+
if (subcommand === 'clear') {
|
|
1258
|
+
session.connectorThreadBinding = null
|
|
1259
|
+
session.connectorSessionScope = null
|
|
1260
|
+
session.connectorContext = { ...(session.connectorContext || {}), threadId: null }
|
|
1261
|
+
persistSession(session)
|
|
1262
|
+
return 'Cleared connector thread focus.'
|
|
1263
|
+
}
|
|
1264
|
+
if (!msg.threadId) {
|
|
1265
|
+
return 'Focus can only be set from a threaded or topic-bound message.'
|
|
1266
|
+
}
|
|
1267
|
+
session.connectorThreadBinding = 'strict'
|
|
1268
|
+
session.connectorSessionScope = 'thread'
|
|
1269
|
+
session.connectorReplyMode = session.connectorReplyMode || 'all'
|
|
1270
|
+
session.connectorContext = { ...(session.connectorContext || {}), threadId: msg.threadId }
|
|
1271
|
+
persistSession(session)
|
|
1272
|
+
return `Focused this connector session on thread ${msg.threadId}.`
|
|
1273
|
+
}
|
|
1274
|
+
|
|
711
1275
|
return 'Unknown command.'
|
|
712
1276
|
}
|
|
713
1277
|
|
|
@@ -721,6 +1285,9 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
721
1285
|
if (!chatroom) return '[Error] Chatroom not found.'
|
|
722
1286
|
|
|
723
1287
|
const agents = loadAgents()
|
|
1288
|
+
const chatroomAgentAliases = chatroom.agentIds
|
|
1289
|
+
.map((agentId) => agents[agentId]?.name)
|
|
1290
|
+
.filter((name): name is string => typeof name === 'string' && !!name.trim())
|
|
724
1291
|
const preferredCredentialId = (() => {
|
|
725
1292
|
if (connector.agentId && agents[connector.agentId]?.credentialId) {
|
|
726
1293
|
return agents[connector.agentId].credentialId as string
|
|
@@ -735,6 +1302,16 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
735
1302
|
msg,
|
|
736
1303
|
preferredCredentialId,
|
|
737
1304
|
})
|
|
1305
|
+
const groupGate = evaluateGroupPolicy({
|
|
1306
|
+
connector,
|
|
1307
|
+
msg,
|
|
1308
|
+
aliases: [connector.name, ...chatroomAgentAliases],
|
|
1309
|
+
})
|
|
1310
|
+
if (!groupGate.allowed) return NO_MESSAGE_SENTINEL
|
|
1311
|
+
|
|
1312
|
+
await maybeSendStatusReaction(connector, msg, 'processing')
|
|
1313
|
+
const stopTyping = startConnectorTypingLoop(connector, msg)
|
|
1314
|
+
try {
|
|
738
1315
|
|
|
739
1316
|
const source: MessageSource = {
|
|
740
1317
|
platform: connector.platform,
|
|
@@ -743,10 +1320,14 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
743
1320
|
channelId: msg.channelId,
|
|
744
1321
|
senderId: msg.senderId,
|
|
745
1322
|
senderName: msg.senderName,
|
|
1323
|
+
messageId: msg.messageId,
|
|
1324
|
+
replyToMessageId: msg.replyToMessageId,
|
|
1325
|
+
threadId: msg.threadId,
|
|
746
1326
|
}
|
|
747
1327
|
const inboundText = formatInboundUserText(msg)
|
|
748
1328
|
const inboundAttachmentPaths = buildInboundAttachmentPaths(msg)
|
|
749
1329
|
const firstImagePath = msg.media?.find((m) => m.type === 'image')?.localPath
|
|
1330
|
+
const threadContextBlock = buildConnectorThreadContextBlock(msg)
|
|
750
1331
|
|
|
751
1332
|
// Parse mentions from the message text
|
|
752
1333
|
let mentions = parseMentions(msg.text || '', agents, chatroom.agentIds)
|
|
@@ -824,7 +1405,7 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
824
1405
|
const syntheticSession = buildSyntheticSession(agent, chatroomId)
|
|
825
1406
|
const agentSystemPrompt = buildAgentSystemPromptForChatroom(agent)
|
|
826
1407
|
const chatroomContext = buildChatroomSystemPrompt(freshChatroom, agents, agent.id)
|
|
827
|
-
const fullSystemPrompt = [agentSystemPrompt, chatroomContext].filter(Boolean).join('\n\n')
|
|
1408
|
+
const fullSystemPrompt = [agentSystemPrompt, chatroomContext, threadContextBlock].filter(Boolean).join('\n\n')
|
|
828
1409
|
const history = buildHistoryForAgent(freshChatroom, agent.id)
|
|
829
1410
|
|
|
830
1411
|
try {
|
|
@@ -882,7 +1463,10 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
882
1463
|
}
|
|
883
1464
|
}
|
|
884
1465
|
|
|
885
|
-
if (responses.length === 0)
|
|
1466
|
+
if (responses.length === 0) {
|
|
1467
|
+
await maybeSendStatusReaction(connector, msg, 'silent')
|
|
1468
|
+
return NO_MESSAGE_SENTINEL
|
|
1469
|
+
}
|
|
886
1470
|
|
|
887
1471
|
const joined = responses.join('\n\n')
|
|
888
1472
|
// Extract embedded media from agent responses and send them via connector
|
|
@@ -891,9 +1475,18 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
891
1475
|
if (filesToSend.length > 0) {
|
|
892
1476
|
const inst = running.get(connector.id)
|
|
893
1477
|
if (inst?.sendMessage) {
|
|
1478
|
+
const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound: msg })
|
|
894
1479
|
for (const file of filesToSend) {
|
|
895
1480
|
try {
|
|
896
|
-
await
|
|
1481
|
+
await sendConnectorMessage({
|
|
1482
|
+
connectorId: connector.id,
|
|
1483
|
+
channelId: msg.channelId,
|
|
1484
|
+
text: '',
|
|
1485
|
+
mediaPath: file.path,
|
|
1486
|
+
caption: file.alt || undefined,
|
|
1487
|
+
replyToMessageId: replyOptions.replyToMessageId,
|
|
1488
|
+
threadId: replyOptions.threadId,
|
|
1489
|
+
})
|
|
897
1490
|
console.log(`[connector] Sent chatroom media to ${msg.platform}: ${path.basename(file.path)}`)
|
|
898
1491
|
} catch (err: unknown) {
|
|
899
1492
|
console.error(`[connector] Failed to send chatroom media ${path.basename(file.path)}:`, err instanceof Error ? err.message : String(err))
|
|
@@ -903,6 +1496,9 @@ async function routeMessageToChatroom(connector: Connector, msg: InboundMessage)
|
|
|
903
1496
|
return extracted.cleanText || '(no response)'
|
|
904
1497
|
}
|
|
905
1498
|
return joined
|
|
1499
|
+
} finally {
|
|
1500
|
+
stopTyping?.()
|
|
1501
|
+
}
|
|
906
1502
|
}
|
|
907
1503
|
|
|
908
1504
|
/** Route an inbound message through the assigned agent and return the response */
|
|
@@ -927,84 +1523,11 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
927
1523
|
preferredCredentialId: agent.credentialId || null,
|
|
928
1524
|
})
|
|
929
1525
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
'connector-message',
|
|
936
|
-
)
|
|
937
|
-
requestHeartbeatNow({ agentId: effectiveAgentId, reason: 'connector-message' })
|
|
938
|
-
|
|
939
|
-
// Log connector trigger
|
|
940
|
-
const triggerSessionKey = `connector:${connector.id}:${msg.channelId}`
|
|
941
|
-
const allSessions = loadSessions()
|
|
942
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
943
|
-
const existingSession = Object.values(allSessions).find((s: any) => s.name === triggerSessionKey)
|
|
944
|
-
if (existingSession) {
|
|
945
|
-
logExecution(existingSession.id, 'trigger', `${msg.platform} message from ${msg.senderName}`, {
|
|
946
|
-
agentId: agent.id,
|
|
947
|
-
detail: {
|
|
948
|
-
source: 'connector',
|
|
949
|
-
platform: msg.platform,
|
|
950
|
-
connectorId: connector.id,
|
|
951
|
-
channelId: msg.channelId,
|
|
952
|
-
senderName: msg.senderName,
|
|
953
|
-
messagePreview: (msg.text || '').slice(0, 200),
|
|
954
|
-
hasMedia: !!(msg.media?.length || msg.imageUrl),
|
|
955
|
-
},
|
|
956
|
-
})
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
// Resolve API key for the agent's provider
|
|
960
|
-
let apiKey: string | null = null
|
|
961
|
-
if (agent.credentialId) {
|
|
962
|
-
const creds = loadCredentials()
|
|
963
|
-
const cred = creds[agent.credentialId]
|
|
964
|
-
if (cred?.encryptedKey) {
|
|
965
|
-
try { apiKey = decryptKey(cred.encryptedKey) } catch { /* ignore */ }
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
// Find a session for this connector message.
|
|
970
|
-
// Prefer the agent's thread session (visible in the agent chat UI) so connector
|
|
971
|
-
// messages appear inline alongside web UI messages.
|
|
972
|
-
// Fall back to a connector-keyed session if the agent has no thread session.
|
|
973
|
-
const sessionKey = `connector:${connector.id}:${msg.channelId}`
|
|
974
|
-
const sessions = loadSessions()
|
|
975
|
-
let session = (agent.threadSessionId && sessions[agent.threadSessionId])
|
|
976
|
-
? sessions[agent.threadSessionId]
|
|
977
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
978
|
-
: Object.values(sessions).find((s: any) => s.name === sessionKey)
|
|
979
|
-
if (!session) {
|
|
980
|
-
const id = genId()
|
|
981
|
-
session = {
|
|
982
|
-
id,
|
|
983
|
-
name: sessionKey,
|
|
984
|
-
cwd: WORKSPACE_DIR,
|
|
985
|
-
user: 'connector',
|
|
986
|
-
provider: agent.provider === 'claude-cli' ? 'anthropic' : agent.provider,
|
|
987
|
-
model: agent.model,
|
|
988
|
-
credentialId: agent.credentialId || null,
|
|
989
|
-
apiEndpoint: agent.apiEndpoint || null,
|
|
990
|
-
claudeSessionId: null,
|
|
991
|
-
codexThreadId: null,
|
|
992
|
-
opencodeSessionId: null,
|
|
993
|
-
delegateResumeIds: {
|
|
994
|
-
claudeCode: null,
|
|
995
|
-
codex: null,
|
|
996
|
-
opencode: null,
|
|
997
|
-
},
|
|
998
|
-
messages: [],
|
|
999
|
-
createdAt: Date.now(),
|
|
1000
|
-
lastActiveAt: Date.now(),
|
|
1001
|
-
sessionType: 'human' as const,
|
|
1002
|
-
agentId: agent.id,
|
|
1003
|
-
tools: agent.tools || [],
|
|
1004
|
-
}
|
|
1005
|
-
sessions[id] = session
|
|
1006
|
-
saveSessions(sessions)
|
|
1007
|
-
}
|
|
1526
|
+
const { session, sessionKey, wasCreated, staleReason, clearedMessages } = resolveDirectSession({
|
|
1527
|
+
connector,
|
|
1528
|
+
msg,
|
|
1529
|
+
agent,
|
|
1530
|
+
})
|
|
1008
1531
|
|
|
1009
1532
|
const parsedCommand = parseConnectorCommand(msg.text || '')
|
|
1010
1533
|
if (parsedCommand?.name === 'pair') {
|
|
@@ -1039,6 +1562,26 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
1039
1562
|
return accessPolicyResult
|
|
1040
1563
|
}
|
|
1041
1564
|
|
|
1565
|
+
const groupGate = evaluateGroupPolicy({
|
|
1566
|
+
connector,
|
|
1567
|
+
msg,
|
|
1568
|
+
session,
|
|
1569
|
+
aliases: [agent.name, connector.name],
|
|
1570
|
+
})
|
|
1571
|
+
if (!groupGate.allowed) {
|
|
1572
|
+
logExecution(session.id, 'decision', 'Connector inbound blocked by group policy', {
|
|
1573
|
+
agentId: agent.id,
|
|
1574
|
+
detail: {
|
|
1575
|
+
platform: msg.platform,
|
|
1576
|
+
channelId: msg.channelId,
|
|
1577
|
+
senderId: msg.senderId,
|
|
1578
|
+
groupPolicy: resolveConnectorSessionPolicy(connector, msg, session).groupPolicy,
|
|
1579
|
+
reason: groupGate.reason,
|
|
1580
|
+
},
|
|
1581
|
+
})
|
|
1582
|
+
return NO_MESSAGE_SENTINEL
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1042
1585
|
if (parsedCommand) {
|
|
1043
1586
|
const commandResult = await handleConnectorCommand({
|
|
1044
1587
|
command: parsedCommand,
|
|
@@ -1059,6 +1602,58 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
1059
1602
|
return commandResult
|
|
1060
1603
|
}
|
|
1061
1604
|
|
|
1605
|
+
await maybeSendStatusReaction(connector, msg, 'processing')
|
|
1606
|
+
const stopTyping = startConnectorTypingLoop(connector, msg)
|
|
1607
|
+
try {
|
|
1608
|
+
// Enqueue system event + heartbeat wake for the agent only after access/gating checks pass.
|
|
1609
|
+
const preview = (msg.text || '').slice(0, 80)
|
|
1610
|
+
enqueueSystemEvent(
|
|
1611
|
+
sessionKey,
|
|
1612
|
+
`Inbound message from ${msg.platform}: ${preview}`,
|
|
1613
|
+
'connector-message',
|
|
1614
|
+
)
|
|
1615
|
+
requestHeartbeatNow({ agentId: effectiveAgentId, reason: 'connector-message' })
|
|
1616
|
+
|
|
1617
|
+
logExecution(session.id, 'trigger', `${msg.platform} message from ${msg.senderName}`, {
|
|
1618
|
+
agentId: agent.id,
|
|
1619
|
+
detail: {
|
|
1620
|
+
source: 'connector',
|
|
1621
|
+
platform: msg.platform,
|
|
1622
|
+
connectorId: connector.id,
|
|
1623
|
+
channelId: msg.channelId,
|
|
1624
|
+
senderName: msg.senderName,
|
|
1625
|
+
sessionKey,
|
|
1626
|
+
messagePreview: (msg.text || '').slice(0, 200),
|
|
1627
|
+
hasMedia: !!(msg.media?.length || msg.imageUrl),
|
|
1628
|
+
staleReason: staleReason || null,
|
|
1629
|
+
clearedMessages: clearedMessages || 0,
|
|
1630
|
+
},
|
|
1631
|
+
})
|
|
1632
|
+
|
|
1633
|
+
// Resolve API key for the effective session provider, preferring matching fallback credentials.
|
|
1634
|
+
let apiKey: string | null = null
|
|
1635
|
+
const sessionCredentialIds = [
|
|
1636
|
+
session.credentialId,
|
|
1637
|
+
...(Array.isArray(session.fallbackCredentialIds) ? session.fallbackCredentialIds : []),
|
|
1638
|
+
].filter(Boolean) as string[]
|
|
1639
|
+
if (sessionCredentialIds.length > 0) {
|
|
1640
|
+
const creds = loadCredentials()
|
|
1641
|
+
const matching = sessionCredentialIds.find((credentialId) => creds[credentialId]?.provider === session.provider)
|
|
1642
|
+
const ordered = matching
|
|
1643
|
+
? [matching, ...sessionCredentialIds.filter((credentialId) => credentialId !== matching)]
|
|
1644
|
+
: sessionCredentialIds
|
|
1645
|
+
for (const credentialId of ordered) {
|
|
1646
|
+
const cred = creds[credentialId]
|
|
1647
|
+
if (!cred?.encryptedKey) continue
|
|
1648
|
+
try {
|
|
1649
|
+
apiKey = decryptKey(cred.encryptedKey)
|
|
1650
|
+
break
|
|
1651
|
+
} catch {
|
|
1652
|
+
// Try the next candidate.
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1062
1657
|
// Build system prompt: [identity] \n\n [userPrompt] \n\n [soul] \n\n [systemPrompt]
|
|
1063
1658
|
const settings = loadSettings()
|
|
1064
1659
|
const promptParts: string[] = []
|
|
@@ -1067,6 +1662,8 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
1067
1662
|
if (agent.description) identityLines.push(agent.description)
|
|
1068
1663
|
identityLines.push('I should always refer to myself by this name. I am not "Assistant" — I have my own name and identity.')
|
|
1069
1664
|
promptParts.push(identityLines.join(' '))
|
|
1665
|
+
const continuityBlock = buildIdentityContinuityContext(session as Session, agent)
|
|
1666
|
+
if (continuityBlock) promptParts.push(continuityBlock)
|
|
1070
1667
|
if (settings.userPrompt) promptParts.push(settings.userPrompt)
|
|
1071
1668
|
promptParts.push(buildCurrentDateTimePromptContext())
|
|
1072
1669
|
if (agent.soul) promptParts.push(agent.soul)
|
|
@@ -1078,12 +1675,12 @@ async function routeMessage(connector: Connector, msg: InboundMessage): Promise<
|
|
|
1078
1675
|
if (skill?.content) promptParts.push(`## Skill: ${skill.name}\n${skill.content}`)
|
|
1079
1676
|
}
|
|
1080
1677
|
}
|
|
1081
|
-
const thinkLevel =
|
|
1082
|
-
? session.connectorThinkLevel.trim().toLowerCase()
|
|
1083
|
-
: ''
|
|
1678
|
+
const thinkLevel = resolveConnectorSessionPolicy(connector, msg, session).thinkingLevel || ''
|
|
1084
1679
|
if (thinkLevel) {
|
|
1085
1680
|
promptParts.push(`Connector thinking guidance: ${thinkLevel}. Keep responses concise and useful for chat.`)
|
|
1086
1681
|
}
|
|
1682
|
+
const threadContextBlock = buildConnectorThreadContextBlock(msg, { isFirstThreadTurn: wasCreated })
|
|
1683
|
+
if (threadContextBlock) promptParts.push(threadContextBlock)
|
|
1087
1684
|
// Add connector context
|
|
1088
1685
|
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.
|
|
1089
1686
|
|
|
@@ -1128,6 +1725,9 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1128
1725
|
channelId: msg.channelId,
|
|
1129
1726
|
senderId: msg.senderId,
|
|
1130
1727
|
senderName: msg.senderName,
|
|
1728
|
+
messageId: msg.messageId,
|
|
1729
|
+
replyToMessageId: msg.replyToMessageId,
|
|
1730
|
+
threadId: msg.threadId,
|
|
1131
1731
|
}
|
|
1132
1732
|
session.messages.push({
|
|
1133
1733
|
role: 'user',
|
|
@@ -1139,23 +1739,23 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1139
1739
|
source: messageSource,
|
|
1140
1740
|
})
|
|
1141
1741
|
session.lastActiveAt = Date.now()
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
saveSessions(s1)
|
|
1742
|
+
updateSessionConnectorContext(session, connector, msg, sessionKey)
|
|
1743
|
+
persistSessionRecord(session)
|
|
1145
1744
|
notify(`messages:${session.id}`)
|
|
1146
1745
|
|
|
1147
1746
|
// Stream the response
|
|
1148
1747
|
let fullText = ''
|
|
1149
1748
|
let mediaExtractionText = ''
|
|
1150
1749
|
let connectorToolDeliveredCurrentChannel = false
|
|
1151
|
-
|
|
1152
|
-
|
|
1750
|
+
let connectorToolDeliveredMessageId: string | undefined
|
|
1751
|
+
const hasTools = session.plugins?.length && session.provider !== 'claude-cli'
|
|
1752
|
+
console.log(`[connector] Routing message to agent "${agent.name}" (${session.provider}/${session.model}), hasTools=${!!hasTools}`)
|
|
1153
1753
|
|
|
1154
1754
|
if (hasTools) {
|
|
1155
1755
|
try {
|
|
1156
1756
|
const toolMediaOutputs: string[] = []
|
|
1157
1757
|
const result = await streamAgentChat({
|
|
1158
|
-
session,
|
|
1758
|
+
session: session as Session,
|
|
1159
1759
|
message: modelInputText,
|
|
1160
1760
|
imagePath: firstImagePath,
|
|
1161
1761
|
attachedFiles: inboundAttachmentPaths.length ? inboundAttachmentPaths : undefined,
|
|
@@ -1180,6 +1780,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1180
1780
|
: parsed.to
|
|
1181
1781
|
if (inboundTarget && outboundTarget && inboundTarget === outboundTarget) {
|
|
1182
1782
|
connectorToolDeliveredCurrentChannel = true
|
|
1783
|
+
if (parsed.messageId) connectorToolDeliveredMessageId = parsed.messageId
|
|
1183
1784
|
}
|
|
1184
1785
|
}
|
|
1185
1786
|
}
|
|
@@ -1202,7 +1803,7 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1202
1803
|
if (!provider) return '[Error] Provider not found.'
|
|
1203
1804
|
|
|
1204
1805
|
await provider.handler.streamChat({
|
|
1205
|
-
session,
|
|
1806
|
+
session: session as Session,
|
|
1206
1807
|
message: modelInputText,
|
|
1207
1808
|
imagePath: firstImagePath,
|
|
1208
1809
|
apiKey,
|
|
@@ -1225,6 +1826,17 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1225
1826
|
// If the agent chose NO_MESSAGE, skip saving it to history — the user's message
|
|
1226
1827
|
// is already recorded, and saving the sentinel would pollute the LLM's context
|
|
1227
1828
|
if (isNoMessage(fullText)) {
|
|
1829
|
+
if (connectorToolDeliveredCurrentChannel) {
|
|
1830
|
+
session.connectorContext = {
|
|
1831
|
+
...(session.connectorContext || {}),
|
|
1832
|
+
lastOutboundAt: Date.now(),
|
|
1833
|
+
lastOutboundMessageId: connectorToolDeliveredMessageId || session.connectorContext?.lastOutboundMessageId || null,
|
|
1834
|
+
}
|
|
1835
|
+
persistSessionRecord(session)
|
|
1836
|
+
await maybeSendStatusReaction(connector, msg, 'sent')
|
|
1837
|
+
} else {
|
|
1838
|
+
await maybeSendStatusReaction(connector, msg, 'silent')
|
|
1839
|
+
}
|
|
1228
1840
|
console.log(`[connector] Agent returned NO_MESSAGE — suppressing outbound reply`)
|
|
1229
1841
|
logExecution(session.id, 'decision', 'Agent suppressed outbound (NO_MESSAGE)', {
|
|
1230
1842
|
agentId: agent.id,
|
|
@@ -1251,13 +1863,13 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1251
1863
|
connectorId: connector.id,
|
|
1252
1864
|
connectorName: connector.name,
|
|
1253
1865
|
channelId: msg.channelId,
|
|
1866
|
+
replyToMessageId: msg.messageId,
|
|
1867
|
+
threadId: msg.threadId,
|
|
1254
1868
|
}
|
|
1255
1869
|
if (fullText.trim()) {
|
|
1256
1870
|
session.messages.push({ role: 'assistant', text: fullText.trim(), time: Date.now(), source: assistantSource })
|
|
1257
1871
|
session.lastActiveAt = Date.now()
|
|
1258
|
-
|
|
1259
|
-
s2[session.id] = session
|
|
1260
|
-
saveSessions(s2)
|
|
1872
|
+
persistSessionRecord(session)
|
|
1261
1873
|
notify(`messages:${session.id}`)
|
|
1262
1874
|
}
|
|
1263
1875
|
|
|
@@ -1275,9 +1887,19 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1275
1887
|
if (filesToSend.length > 0) {
|
|
1276
1888
|
const inst = running.get(connector.id)
|
|
1277
1889
|
if (inst?.sendMessage) {
|
|
1890
|
+
const replyOptions = getConnectorReplySendOptions({ connectorId: connector.id, inbound: msg })
|
|
1278
1891
|
for (const file of filesToSend) {
|
|
1279
1892
|
try {
|
|
1280
|
-
await
|
|
1893
|
+
await sendConnectorMessage({
|
|
1894
|
+
connectorId: connector.id,
|
|
1895
|
+
channelId: msg.channelId,
|
|
1896
|
+
text: '',
|
|
1897
|
+
sessionId: session.id,
|
|
1898
|
+
mediaPath: file.path,
|
|
1899
|
+
caption: file.alt || undefined,
|
|
1900
|
+
replyToMessageId: replyOptions.replyToMessageId,
|
|
1901
|
+
threadId: replyOptions.threadId,
|
|
1902
|
+
})
|
|
1281
1903
|
console.log(`[connector] Sent media to ${msg.platform}: ${path.basename(file.path)}`)
|
|
1282
1904
|
logExecution(session.id, 'outbound', 'Connector media sent', {
|
|
1283
1905
|
agentId: agent.id,
|
|
@@ -1317,8 +1939,11 @@ If media sending fails, report the exact error and retry with a corrected path/t
|
|
|
1317
1939
|
return extractedFromReply.cleanText || '(no response)'
|
|
1318
1940
|
}
|
|
1319
1941
|
|
|
1320
|
-
|
|
1321
|
-
|
|
1942
|
+
if (connectorToolDeliveredCurrentChannel) return NO_MESSAGE_SENTINEL
|
|
1943
|
+
return fullText || '(no response)'
|
|
1944
|
+
} finally {
|
|
1945
|
+
stopTyping?.()
|
|
1946
|
+
}
|
|
1322
1947
|
}
|
|
1323
1948
|
|
|
1324
1949
|
routeMessageHandlerRef.current = routeMessage
|
|
@@ -1427,11 +2052,22 @@ export async function stopConnector(connectorId: string): Promise<void> {
|
|
|
1427
2052
|
running.delete(connectorId)
|
|
1428
2053
|
}
|
|
1429
2054
|
|
|
2055
|
+
for (const [debounceKey, entry] of pendingInboundDebounce.entries()) {
|
|
2056
|
+
if (entry.connector.id !== connectorId) continue
|
|
2057
|
+
clearTimeout(entry.timer)
|
|
2058
|
+
pendingInboundDebounce.delete(debounceKey)
|
|
2059
|
+
}
|
|
2060
|
+
|
|
1430
2061
|
for (const [followupId, followup] of scheduledFollowups.entries()) {
|
|
1431
2062
|
if (followup.connectorId !== connectorId) continue
|
|
1432
2063
|
clearTimeout(followup.timer)
|
|
1433
2064
|
scheduledFollowups.delete(followupId)
|
|
1434
2065
|
}
|
|
2066
|
+
for (const [key, entry] of scheduledFollowupByDedupe.entries()) {
|
|
2067
|
+
if (!scheduledFollowups.has(entry.id)) {
|
|
2068
|
+
scheduledFollowupByDedupe.delete(key)
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
1435
2071
|
|
|
1436
2072
|
const connectors = loadConnectors()
|
|
1437
2073
|
const connector = connectors[connectorId]
|
|
@@ -1582,6 +2218,136 @@ export function getRunningInstance(connectorId: string): ConnectorInstance | und
|
|
|
1582
2218
|
return running.get(connectorId)
|
|
1583
2219
|
}
|
|
1584
2220
|
|
|
2221
|
+
export function getConnectorReplySendOptions(params: {
|
|
2222
|
+
connectorId: string
|
|
2223
|
+
inbound: InboundMessage
|
|
2224
|
+
}): { replyToMessageId?: string; threadId?: string } {
|
|
2225
|
+
const connectors = loadConnectors()
|
|
2226
|
+
const connector = connectors[params.connectorId] as Connector | undefined
|
|
2227
|
+
if (!connector) return {}
|
|
2228
|
+
const session = findDirectSessionForInbound(connector, params.inbound)
|
|
2229
|
+
const policy = resolveConnectorSessionPolicy(connector, params.inbound, session)
|
|
2230
|
+
return shouldReplyToInboundMessage({
|
|
2231
|
+
msg: params.inbound,
|
|
2232
|
+
session,
|
|
2233
|
+
policy,
|
|
2234
|
+
})
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
export async function recordConnectorOutboundDelivery(params: {
|
|
2238
|
+
connectorId: string
|
|
2239
|
+
inbound: InboundMessage
|
|
2240
|
+
messageId?: string
|
|
2241
|
+
state?: 'sent' | 'silent'
|
|
2242
|
+
}): Promise<void> {
|
|
2243
|
+
const connectors = loadConnectors()
|
|
2244
|
+
const connector = connectors[params.connectorId] as Connector | undefined
|
|
2245
|
+
if (!connector) return
|
|
2246
|
+
const session = findDirectSessionForInbound(connector, params.inbound)
|
|
2247
|
+
if (session) {
|
|
2248
|
+
session.connectorContext = {
|
|
2249
|
+
...(session.connectorContext || {}),
|
|
2250
|
+
lastOutboundAt: Date.now(),
|
|
2251
|
+
lastOutboundMessageId: params.messageId || session.connectorContext?.lastOutboundMessageId || null,
|
|
2252
|
+
threadId: params.inbound.threadId || session.connectorContext?.threadId || null,
|
|
2253
|
+
}
|
|
2254
|
+
const history = Array.isArray(session.messages) ? session.messages : []
|
|
2255
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
2256
|
+
const entry = history[i]
|
|
2257
|
+
if (entry?.role !== 'assistant') continue
|
|
2258
|
+
const source = entry?.source || {}
|
|
2259
|
+
if (source.connectorId !== connector.id) continue
|
|
2260
|
+
if (source.channelId !== params.inbound.channelId) continue
|
|
2261
|
+
if (!source.messageId && params.messageId) {
|
|
2262
|
+
entry.source = {
|
|
2263
|
+
...source,
|
|
2264
|
+
messageId: params.messageId,
|
|
2265
|
+
replyToMessageId: source.replyToMessageId || params.inbound.messageId,
|
|
2266
|
+
threadId: source.threadId || params.inbound.threadId,
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
break
|
|
2270
|
+
}
|
|
2271
|
+
persistSessionRecord(session)
|
|
2272
|
+
notify(`messages:${session.id}`)
|
|
2273
|
+
}
|
|
2274
|
+
if (params.state) {
|
|
2275
|
+
await maybeSendStatusReaction(connector, params.inbound, params.state)
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
|
|
2279
|
+
export async function performConnectorMessageAction(params: {
|
|
2280
|
+
connectorId?: string
|
|
2281
|
+
platform?: string
|
|
2282
|
+
channelId: string
|
|
2283
|
+
action: 'react' | 'edit' | 'delete' | 'pin'
|
|
2284
|
+
messageId?: string
|
|
2285
|
+
emoji?: string
|
|
2286
|
+
text?: string
|
|
2287
|
+
sessionId?: string | null
|
|
2288
|
+
targetMessage?: 'last_inbound' | 'last_outbound'
|
|
2289
|
+
}): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
|
|
2290
|
+
const connectors = loadConnectors()
|
|
2291
|
+
const requestedId = params.connectorId?.trim()
|
|
2292
|
+
let connector: Connector | undefined
|
|
2293
|
+
let connectorId: string | undefined
|
|
2294
|
+
|
|
2295
|
+
if (requestedId) {
|
|
2296
|
+
connector = connectors[requestedId] as Connector | undefined
|
|
2297
|
+
connectorId = requestedId
|
|
2298
|
+
if (!connector) throw new Error(`Connector not found: ${requestedId}`)
|
|
2299
|
+
} else {
|
|
2300
|
+
const candidates = Object.values(connectors) as Connector[]
|
|
2301
|
+
const filtered = candidates.filter((item) => (!params.platform || item.platform === params.platform) && running.has(item.id))
|
|
2302
|
+
if (!filtered.length) throw new Error(`No running connector found${params.platform ? ` for platform "${params.platform}"` : ''}.`)
|
|
2303
|
+
connector = filtered[0]
|
|
2304
|
+
connectorId = connector.id
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
if (!connector || !connectorId) throw new Error('Connector resolution failed.')
|
|
2308
|
+
const instance = running.get(connectorId)
|
|
2309
|
+
if (!instance) throw new Error(`Connector "${connectorId}" is not running.`)
|
|
2310
|
+
|
|
2311
|
+
const targetMessageId = (() => {
|
|
2312
|
+
if (params.messageId?.trim()) return params.messageId.trim()
|
|
2313
|
+
if (!params.sessionId) return ''
|
|
2314
|
+
const session = loadSessions()[params.sessionId]
|
|
2315
|
+
if (!session) return ''
|
|
2316
|
+
if (params.targetMessage === 'last_inbound') return session.connectorContext?.lastInboundMessageId || ''
|
|
2317
|
+
if (params.targetMessage === 'last_outbound' || !params.targetMessage) return session.connectorContext?.lastOutboundMessageId || ''
|
|
2318
|
+
return ''
|
|
2319
|
+
})()
|
|
2320
|
+
if (!targetMessageId) throw new Error('messageId is required for connector message actions.')
|
|
2321
|
+
|
|
2322
|
+
switch (params.action) {
|
|
2323
|
+
case 'react':
|
|
2324
|
+
if (!instance.sendReaction) throw new Error(`Connector "${connector.name}" does not support reactions.`)
|
|
2325
|
+
if (!params.emoji?.trim()) throw new Error('emoji is required for react action.')
|
|
2326
|
+
await instance.sendReaction(params.channelId, targetMessageId, params.emoji.trim())
|
|
2327
|
+
break
|
|
2328
|
+
case 'edit':
|
|
2329
|
+
if (!instance.editMessage) throw new Error(`Connector "${connector.name}" does not support edits.`)
|
|
2330
|
+
if (!params.text?.trim()) throw new Error('text is required for edit action.')
|
|
2331
|
+
await instance.editMessage(params.channelId, targetMessageId, params.text.trim())
|
|
2332
|
+
break
|
|
2333
|
+
case 'delete':
|
|
2334
|
+
if (!instance.deleteMessage) throw new Error(`Connector "${connector.name}" does not support deletes.`)
|
|
2335
|
+
await instance.deleteMessage(params.channelId, targetMessageId)
|
|
2336
|
+
break
|
|
2337
|
+
case 'pin':
|
|
2338
|
+
if (!instance.pinMessage) throw new Error(`Connector "${connector.name}" does not support pinning.`)
|
|
2339
|
+
await instance.pinMessage(params.channelId, targetMessageId)
|
|
2340
|
+
break
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
return {
|
|
2344
|
+
connectorId,
|
|
2345
|
+
platform: connector.platform,
|
|
2346
|
+
channelId: params.channelId,
|
|
2347
|
+
messageId: targetMessageId,
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
1585
2351
|
/**
|
|
1586
2352
|
* Send an outbound message through a running connector.
|
|
1587
2353
|
* Intended for proactive agent notifications (e.g. WhatsApp updates).
|
|
@@ -1591,12 +2357,15 @@ export async function sendConnectorMessage(params: {
|
|
|
1591
2357
|
platform?: string
|
|
1592
2358
|
channelId: string
|
|
1593
2359
|
text: string
|
|
2360
|
+
sessionId?: string | null
|
|
1594
2361
|
imageUrl?: string
|
|
1595
2362
|
fileUrl?: string
|
|
1596
2363
|
mediaPath?: string
|
|
1597
2364
|
mimeType?: string
|
|
1598
2365
|
fileName?: string
|
|
1599
2366
|
caption?: string
|
|
2367
|
+
replyToMessageId?: string
|
|
2368
|
+
threadId?: string
|
|
1600
2369
|
ptt?: boolean
|
|
1601
2370
|
}): Promise<{ connectorId: string; platform: string; channelId: string; messageId?: string }> {
|
|
1602
2371
|
const connectors = loadConnectors()
|
|
@@ -1650,6 +2419,8 @@ export async function sendConnectorMessage(params: {
|
|
|
1650
2419
|
mimeType: params.mimeType,
|
|
1651
2420
|
fileName: params.fileName,
|
|
1652
2421
|
caption: params.caption,
|
|
2422
|
+
replyToMessageId: params.replyToMessageId,
|
|
2423
|
+
threadId: params.threadId,
|
|
1653
2424
|
ptt: params.ptt,
|
|
1654
2425
|
}
|
|
1655
2426
|
|
|
@@ -1668,6 +2439,41 @@ export async function sendConnectorMessage(params: {
|
|
|
1668
2439
|
}
|
|
1669
2440
|
|
|
1670
2441
|
const result = await instance.sendMessage(channelId, outboundText, outboundOptions)
|
|
2442
|
+
if (params.sessionId) {
|
|
2443
|
+
const sessions = loadSessions()
|
|
2444
|
+
const session = sessions[params.sessionId]
|
|
2445
|
+
if (session) {
|
|
2446
|
+
session.connectorContext = {
|
|
2447
|
+
...(session.connectorContext || {}),
|
|
2448
|
+
connectorId,
|
|
2449
|
+
platform: connector.platform,
|
|
2450
|
+
channelId,
|
|
2451
|
+
threadId: params.threadId || session.connectorContext?.threadId || null,
|
|
2452
|
+
lastOutboundAt: Date.now(),
|
|
2453
|
+
lastOutboundMessageId: result?.messageId || session.connectorContext?.lastOutboundMessageId || null,
|
|
2454
|
+
}
|
|
2455
|
+
const history = Array.isArray(session.messages) ? session.messages : []
|
|
2456
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
2457
|
+
const entry = history[i]
|
|
2458
|
+
if (entry?.role !== 'assistant') continue
|
|
2459
|
+
const source = entry?.source || {}
|
|
2460
|
+
if (source.connectorId !== connectorId) continue
|
|
2461
|
+
if (source.channelId !== channelId) continue
|
|
2462
|
+
if (!source.messageId && result?.messageId) {
|
|
2463
|
+
entry.source = {
|
|
2464
|
+
...source,
|
|
2465
|
+
messageId: result.messageId,
|
|
2466
|
+
threadId: source.threadId || params.threadId,
|
|
2467
|
+
replyToMessageId: source.replyToMessageId || params.replyToMessageId,
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
break
|
|
2471
|
+
}
|
|
2472
|
+
sessions[session.id] = session
|
|
2473
|
+
saveSessions(sessions)
|
|
2474
|
+
notify(`messages:${session.id}`)
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
1671
2477
|
return {
|
|
1672
2478
|
connectorId,
|
|
1673
2479
|
platform: connector.platform,
|
|
@@ -1682,16 +2488,39 @@ export function scheduleConnectorFollowUp(params: {
|
|
|
1682
2488
|
channelId: string
|
|
1683
2489
|
text: string
|
|
1684
2490
|
delaySec?: number
|
|
2491
|
+
dedupeKey?: string
|
|
2492
|
+
replaceExisting?: boolean
|
|
2493
|
+
sessionId?: string | null
|
|
1685
2494
|
imageUrl?: string
|
|
1686
2495
|
fileUrl?: string
|
|
1687
2496
|
mediaPath?: string
|
|
1688
2497
|
mimeType?: string
|
|
1689
2498
|
fileName?: string
|
|
1690
2499
|
caption?: string
|
|
2500
|
+
replyToMessageId?: string
|
|
2501
|
+
threadId?: string
|
|
1691
2502
|
ptt?: boolean
|
|
1692
2503
|
}): { followUpId: string; sendAt: number } {
|
|
1693
2504
|
const delaySecRaw = Number.isFinite(params.delaySec) ? Number(params.delaySec) : 300
|
|
1694
2505
|
const delayMs = Math.max(1_000, Math.min(86_400_000, Math.round(delaySecRaw * 1000)))
|
|
2506
|
+
const dedupeKey = params.dedupeKey || [
|
|
2507
|
+
params.connectorId || params.platform || '',
|
|
2508
|
+
params.channelId,
|
|
2509
|
+
params.threadId || '',
|
|
2510
|
+
(params.text || '').trim().slice(0, 160),
|
|
2511
|
+
].join('|')
|
|
2512
|
+
const existing = scheduledFollowupByDedupe.get(dedupeKey)
|
|
2513
|
+
if (existing && existing.sendAt > Date.now() && !params.replaceExisting) {
|
|
2514
|
+
return { followUpId: existing.id, sendAt: existing.sendAt }
|
|
2515
|
+
}
|
|
2516
|
+
if (existing && params.replaceExisting) {
|
|
2517
|
+
const scheduled = scheduledFollowups.get(existing.id)
|
|
2518
|
+
if (scheduled) {
|
|
2519
|
+
clearTimeout(scheduled.timer)
|
|
2520
|
+
scheduledFollowups.delete(existing.id)
|
|
2521
|
+
}
|
|
2522
|
+
scheduledFollowupByDedupe.delete(dedupeKey)
|
|
2523
|
+
}
|
|
1695
2524
|
const followUpId = genId()
|
|
1696
2525
|
const sendAt = Date.now() + delayMs
|
|
1697
2526
|
|
|
@@ -1701,18 +2530,24 @@ export function scheduleConnectorFollowUp(params: {
|
|
|
1701
2530
|
platform: params.platform,
|
|
1702
2531
|
channelId: params.channelId,
|
|
1703
2532
|
text: params.text,
|
|
2533
|
+
sessionId: params.sessionId,
|
|
1704
2534
|
imageUrl: params.imageUrl,
|
|
1705
2535
|
fileUrl: params.fileUrl,
|
|
1706
2536
|
mediaPath: params.mediaPath,
|
|
1707
2537
|
mimeType: params.mimeType,
|
|
1708
2538
|
fileName: params.fileName,
|
|
1709
2539
|
caption: params.caption,
|
|
2540
|
+
replyToMessageId: params.replyToMessageId,
|
|
2541
|
+
threadId: params.threadId,
|
|
1710
2542
|
ptt: params.ptt,
|
|
1711
2543
|
}).catch((err: unknown) => {
|
|
1712
2544
|
const msg = err instanceof Error ? err.message : String(err)
|
|
1713
2545
|
console.warn(`[connector] Scheduled follow-up ${followUpId} failed: ${msg}`)
|
|
1714
2546
|
}).finally(() => {
|
|
1715
2547
|
scheduledFollowups.delete(followUpId)
|
|
2548
|
+
if (scheduledFollowupByDedupe.get(dedupeKey)?.id === followUpId) {
|
|
2549
|
+
scheduledFollowupByDedupe.delete(dedupeKey)
|
|
2550
|
+
}
|
|
1716
2551
|
})
|
|
1717
2552
|
}, delayMs)
|
|
1718
2553
|
|
|
@@ -1724,6 +2559,7 @@ export function scheduleConnectorFollowUp(params: {
|
|
|
1724
2559
|
sendAt,
|
|
1725
2560
|
timer,
|
|
1726
2561
|
})
|
|
2562
|
+
scheduledFollowupByDedupe.set(dedupeKey, { id: followUpId, sendAt })
|
|
1727
2563
|
|
|
1728
2564
|
return { followUpId, sendAt }
|
|
1729
2565
|
}
|