@swarmclawai/swarmclaw 0.7.2 → 0.7.4
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 +116 -50
- package/bin/package-manager.js +157 -0
- package/bin/package-manager.test.js +90 -0
- package/bin/server-cmd.js +38 -7
- package/bin/swarmclaw.js +54 -4
- package/bin/update-cmd.js +48 -10
- package/bin/update-cmd.test.js +55 -0
- package/package.json +8 -3
- package/scripts/postinstall.mjs +26 -0
- package/src/app/api/agents/[id]/route.ts +43 -0
- package/src/app/api/agents/[id]/thread/route.ts +39 -8
- package/src/app/api/agents/route.ts +35 -2
- package/src/app/api/auth/route.ts +77 -8
- package/src/app/api/chatrooms/[id]/chat/route.ts +22 -6
- package/src/app/api/chatrooms/[id]/pins/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/reactions/route.ts +2 -1
- package/src/app/api/chatrooms/[id]/route.ts +6 -0
- package/src/app/api/chats/[id]/browser/route.ts +5 -1
- package/src/app/api/chats/[id]/chat/route.ts +7 -3
- package/src/app/api/chats/[id]/messages/route.ts +19 -13
- package/src/app/api/chats/[id]/route.ts +30 -0
- package/src/app/api/chats/[id]/stop/route.ts +6 -1
- package/src/app/api/chats/heartbeat/route.ts +2 -1
- package/src/app/api/chats/route.ts +23 -1
- 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/external-agents/[id]/heartbeat/route.ts +33 -0
- package/src/app/api/external-agents/[id]/route.ts +31 -0
- package/src/app/api/external-agents/register/route.ts +3 -0
- package/src/app/api/external-agents/route.ts +66 -0
- package/src/app/api/files/open/route.ts +16 -14
- package/src/app/api/gateways/[id]/health/route.ts +28 -0
- package/src/app/api/gateways/[id]/route.ts +79 -0
- package/src/app/api/gateways/route.ts +57 -0
- 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/gateway/route.ts +10 -7
- package/src/app/api/openclaw/skills/route.ts +12 -4
- 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 +3 -26
- package/src/app/api/plugins/settings/route.ts +17 -12
- package/src/app/api/plugins/ui/route.ts +1 -0
- package/src/app/api/providers/[id]/discover-models/route.ts +27 -0
- package/src/app/api/schedules/[id]/route.ts +38 -9
- package/src/app/api/schedules/route.ts +51 -28
- package/src/app/api/settings/route.ts +55 -17
- package/src/app/api/setup/doctor/route.ts +6 -4
- package/src/app/api/tasks/[id]/route.ts +16 -6
- package/src/app/api/tasks/bulk/route.ts +3 -3
- package/src/app/api/tasks/route.ts +9 -4
- package/src/app/api/webhooks/[id]/route.ts +8 -1
- package/src/app/page.tsx +135 -17
- package/src/cli/binary.test.js +142 -0
- package/src/cli/index.js +38 -11
- package/src/cli/index.test.js +195 -0
- package/src/cli/index.ts +21 -12
- package/src/cli/server-cmd.test.js +59 -0
- package/src/cli/spec.js +20 -2
- package/src/components/agents/agent-card.tsx +15 -12
- package/src/components/agents/agent-chat-list.tsx +101 -1
- package/src/components/agents/agent-list.tsx +46 -9
- package/src/components/agents/agent-sheet.tsx +456 -23
- package/src/components/agents/inspector-panel.tsx +110 -49
- package/src/components/agents/sandbox-env-panel.tsx +4 -1
- package/src/components/auth/access-key-gate.tsx +36 -97
- package/src/components/auth/setup-wizard.tsx +970 -275
- package/src/components/chat/chat-area.tsx +70 -27
- package/src/components/chat/chat-card.tsx +6 -21
- package/src/components/chat/chat-header.tsx +263 -366
- package/src/components/chat/chat-list.tsx +62 -26
- package/src/components/chat/checkpoint-timeline.tsx +1 -1
- package/src/components/chat/message-list.tsx +145 -19
- package/src/components/chatrooms/chatroom-input.tsx +96 -33
- package/src/components/chatrooms/chatroom-list.tsx +141 -72
- package/src/components/chatrooms/chatroom-message.tsx +7 -6
- package/src/components/chatrooms/chatroom-sheet.tsx +13 -1
- package/src/components/chatrooms/chatroom-tool-request-banner.tsx +5 -2
- package/src/components/chatrooms/chatroom-view.tsx +422 -209
- package/src/components/chatrooms/reaction-picker.tsx +38 -33
- package/src/components/connectors/connector-list.tsx +265 -127
- package/src/components/connectors/connector-sheet.tsx +217 -0
- package/src/components/gateways/gateway-sheet.tsx +567 -0
- package/src/components/home/home-view.tsx +128 -4
- package/src/components/input/chat-input.tsx +135 -86
- package/src/components/layout/app-layout.tsx +385 -194
- package/src/components/layout/mobile-header.tsx +26 -8
- package/src/components/memory/memory-browser.tsx +71 -6
- package/src/components/memory/memory-card.tsx +18 -0
- package/src/components/memory/memory-detail.tsx +58 -31
- package/src/components/memory/memory-sheet.tsx +32 -4
- package/src/components/plugins/plugin-list.tsx +15 -3
- package/src/components/plugins/plugin-sheet.tsx +118 -9
- package/src/components/projects/project-detail.tsx +189 -1
- package/src/components/providers/provider-list.tsx +158 -2
- package/src/components/providers/provider-sheet.tsx +81 -70
- package/src/components/shared/agent-picker-list.tsx +2 -2
- package/src/components/shared/bottom-sheet.tsx +31 -15
- package/src/components/shared/command-palette.tsx +111 -24
- package/src/components/shared/confirm-dialog.tsx +45 -30
- package/src/components/shared/model-combobox.tsx +90 -8
- 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 +88 -6
- package/src/components/shared/settings/section-orchestrator.tsx +6 -3
- 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 +248 -47
- package/src/components/tasks/approvals-panel.tsx +211 -18
- package/src/components/tasks/task-board.tsx +242 -46
- package/src/components/ui/dialog.tsx +2 -2
- package/src/components/usage/metrics-dashboard.tsx +74 -1
- package/src/components/wallets/wallet-approval-dialog.tsx +59 -54
- package/src/components/wallets/wallet-panel.tsx +17 -5
- package/src/components/webhooks/webhook-sheet.tsx +7 -7
- 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/heartbeat-defaults.ts +48 -0
- package/src/lib/memory-presentation.ts +59 -0
- package/src/lib/openclaw-agent-id.test.ts +14 -0
- package/src/lib/openclaw-agent-id.ts +31 -0
- package/src/lib/provider-model-discovery-client.ts +29 -0
- package/src/lib/providers/index.ts +12 -5
- package/src/lib/runtime-loop.ts +105 -3
- package/src/lib/safe-storage.ts +6 -1
- package/src/lib/server/agent-assignment.test.ts +112 -0
- package/src/lib/server/agent-assignment.ts +169 -0
- package/src/lib/server/agent-runtime-config.test.ts +141 -0
- package/src/lib/server/agent-runtime-config.ts +277 -0
- package/src/lib/server/approval-connector-notify.test.ts +253 -0
- package/src/lib/server/approvals-auto-approve.test.ts +264 -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 +44 -0
- package/src/lib/server/build-llm.ts +11 -4
- package/src/lib/server/builtin-plugins.ts +34 -0
- package/src/lib/server/chat-execution-heartbeat.test.ts +40 -0
- package/src/lib/server/chat-execution-tool-events.test.ts +219 -0
- package/src/lib/server/chat-execution.ts +402 -125
- 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 +74 -2
- package/src/lib/server/chatroom-helpers.ts +144 -11
- package/src/lib/server/chatroom-session-persistence.test.ts +87 -0
- 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 +994 -130
- 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 +189 -10
- 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/daemon-state.ts +62 -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/eval/agent-regression.test.ts +47 -0
- package/src/lib/server/eval/agent-regression.ts +1742 -0
- package/src/lib/server/eval/runner.ts +11 -1
- package/src/lib/server/eval/store.ts +2 -1
- package/src/lib/server/heartbeat-service.ts +23 -43
- 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 +31 -964
- 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 +6 -5
- package/src/lib/server/openclaw-gateway.ts +123 -36
- 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 +18 -8
- package/src/lib/server/orchestrator.ts +5 -4
- package/src/lib/server/playwright-proxy.mjs +27 -3
- package/src/lib/server/plugins.test.ts +215 -0
- package/src/lib/server/plugins.ts +832 -69
- package/src/lib/server/provider-health.ts +33 -3
- package/src/lib/server/provider-model-discovery.ts +481 -0
- package/src/lib/server/queue.ts +4 -21
- package/src/lib/server/runtime-settings.test.ts +119 -0
- package/src/lib/server/runtime-settings.ts +12 -92
- package/src/lib/server/schedule-normalization.ts +187 -0
- 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 -80
- package/src/lib/server/session-tools/autonomy-tools.test.ts +128 -0
- package/src/lib/server/session-tools/calendar.ts +2 -12
- package/src/lib/server/session-tools/connector.ts +109 -8
- package/src/lib/server/session-tools/context.ts +14 -2
- package/src/lib/server/session-tools/crawl.ts +447 -0
- package/src/lib/server/session-tools/crud.ts +96 -34
- package/src/lib/server/session-tools/delegate-fallback.test.ts +219 -0
- package/src/lib/server/session-tools/delegate.ts +406 -20
- package/src/lib/server/session-tools/discovery-approvals.test.ts +170 -0
- package/src/lib/server/session-tools/discovery.ts +40 -12
- package/src/lib/server/session-tools/document.ts +283 -0
- package/src/lib/server/session-tools/email.ts +1 -3
- package/src/lib/server/session-tools/extract.ts +137 -0
- package/src/lib/server/session-tools/file-normalize.test.ts +98 -0
- package/src/lib/server/session-tools/file-send.test.ts +84 -1
- package/src/lib/server/session-tools/file.ts +243 -24
- package/src/lib/server/session-tools/http.ts +9 -3
- package/src/lib/server/session-tools/human-loop.ts +227 -0
- package/src/lib/server/session-tools/image-gen.ts +1 -3
- package/src/lib/server/session-tools/index.ts +87 -2
- package/src/lib/server/session-tools/mailbox.ts +276 -0
- package/src/lib/server/session-tools/manage-schedules.test.ts +137 -0
- package/src/lib/server/session-tools/memory.ts +35 -3
- package/src/lib/server/session-tools/monitor.ts +162 -12
- package/src/lib/server/session-tools/normalize-tool-args.ts +17 -14
- package/src/lib/server/session-tools/openclaw-nodes.test.ts +111 -0
- package/src/lib/server/session-tools/openclaw-nodes.ts +86 -20
- package/src/lib/server/session-tools/platform-normalize.test.ts +142 -0
- package/src/lib/server/session-tools/platform.ts +142 -4
- package/src/lib/server/session-tools/plugin-creator.ts +95 -25
- package/src/lib/server/session-tools/primitive-tools.test.ts +257 -0
- package/src/lib/server/session-tools/replicate.ts +1 -3
- package/src/lib/server/session-tools/sandbox.ts +51 -92
- package/src/lib/server/session-tools/schedule.ts +20 -10
- package/src/lib/server/session-tools/session-info.ts +58 -4
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +54 -17
- package/src/lib/server/session-tools/shell.ts +2 -2
- package/src/lib/server/session-tools/subagent.ts +195 -27
- package/src/lib/server/session-tools/table.ts +587 -0
- package/src/lib/server/session-tools/wallet.ts +13 -10
- package/src/lib/server/session-tools/web-browser-config.test.ts +39 -0
- package/src/lib/server/session-tools/web.ts +947 -108
- package/src/lib/server/storage.ts +255 -10
- package/src/lib/server/stream-agent-chat.test.ts +61 -0
- package/src/lib/server/stream-agent-chat.ts +185 -25
- 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 -11
- package/src/lib/server/tool-aliases.ts +80 -12
- package/src/lib/server/tool-capability-policy.ts +7 -1
- 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/setup-defaults.ts +352 -11
- package/src/lib/tool-definitions.ts +3 -4
- package/src/lib/validation/schemas.test.ts +26 -0
- package/src/lib/validation/schemas.ts +62 -1
- package/src/lib/ws-client.ts +14 -12
- package/src/proxy.ts +5 -5
- package/src/stores/use-app-store.ts +43 -7
- package/src/stores/use-chat-store.ts +31 -2
- package/src/stores/use-chatroom-store.ts +153 -26
- package/src/types/index.ts +470 -44
- package/src/app/api/chats/[id]/main-loop/route.ts +0 -94
- package/src/components/chat/new-chat-sheet.tsx +0 -253
- package/src/lib/server/main-session.ts +0 -17
- package/src/lib/server/session-run-manager.test.ts +0 -26
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import test from 'node:test'
|
|
3
|
+
import type { Connector, Session } from '@/types'
|
|
4
|
+
import type { InboundMessage } from './types'
|
|
5
|
+
import {
|
|
6
|
+
buildConnectorConversationKey,
|
|
7
|
+
buildConnectorDoctorWarnings,
|
|
8
|
+
buildInboundDedupeKey,
|
|
9
|
+
getConnectorSessionStaleness,
|
|
10
|
+
isReplyToLastOutbound,
|
|
11
|
+
mergeInboundMessages,
|
|
12
|
+
normalizeConnectorGroupPolicy,
|
|
13
|
+
normalizeConnectorReplyMode,
|
|
14
|
+
normalizeConnectorSessionScope,
|
|
15
|
+
normalizeConnectorThreadBinding,
|
|
16
|
+
resetConnectorSessionRuntime,
|
|
17
|
+
resolveConnectorSessionPolicy,
|
|
18
|
+
shouldReplyToInboundMessage,
|
|
19
|
+
textMentionsAlias,
|
|
20
|
+
} from './policy'
|
|
21
|
+
|
|
22
|
+
function makeConnector(config: Record<string, string> = {}): Connector {
|
|
23
|
+
return {
|
|
24
|
+
id: 'connector-1',
|
|
25
|
+
name: 'Test Connector',
|
|
26
|
+
platform: 'slack',
|
|
27
|
+
agentId: 'agent-1',
|
|
28
|
+
chatroomId: null,
|
|
29
|
+
credentialId: 'cred-1',
|
|
30
|
+
config,
|
|
31
|
+
isEnabled: true,
|
|
32
|
+
status: 'running',
|
|
33
|
+
createdAt: 1,
|
|
34
|
+
updatedAt: 1,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeInbound(overrides: Partial<InboundMessage> = {}): InboundMessage {
|
|
39
|
+
return {
|
|
40
|
+
platform: 'slack',
|
|
41
|
+
channelId: 'C123',
|
|
42
|
+
channelName: 'general',
|
|
43
|
+
senderId: 'U123',
|
|
44
|
+
senderName: 'Alice',
|
|
45
|
+
text: 'hello',
|
|
46
|
+
isGroup: false,
|
|
47
|
+
...overrides,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test('normalizers fall back safely', () => {
|
|
52
|
+
assert.equal(normalizeConnectorSessionScope('THREAD', 'channel'), 'thread')
|
|
53
|
+
assert.equal(normalizeConnectorSessionScope('unknown', 'channel'), 'channel')
|
|
54
|
+
assert.equal(normalizeConnectorReplyMode('ALL'), 'all')
|
|
55
|
+
assert.equal(normalizeConnectorReplyMode('weird'), 'first')
|
|
56
|
+
assert.equal(normalizeConnectorThreadBinding('STRICT'), 'strict')
|
|
57
|
+
assert.equal(normalizeConnectorThreadBinding('weird'), 'prefer')
|
|
58
|
+
assert.equal(normalizeConnectorGroupPolicy('MENTION'), 'mention')
|
|
59
|
+
assert.equal(normalizeConnectorGroupPolicy('nope'), 'reply-or-mention')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test('policy resolves DM and group defaults', () => {
|
|
63
|
+
const dmPolicy = resolveConnectorSessionPolicy(makeConnector(), makeInbound())
|
|
64
|
+
assert.equal(dmPolicy.scope, 'channel-peer')
|
|
65
|
+
assert.equal(dmPolicy.groupPolicy, 'reply-or-mention')
|
|
66
|
+
assert.equal(dmPolicy.typingIndicators, true)
|
|
67
|
+
|
|
68
|
+
const groupPolicy = resolveConnectorSessionPolicy(makeConnector(), makeInbound({ isGroup: true }))
|
|
69
|
+
assert.equal(groupPolicy.scope, 'channel')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('policy resolves connector runtime defaults', () => {
|
|
73
|
+
const policy = resolveConnectorSessionPolicy(
|
|
74
|
+
makeConnector({
|
|
75
|
+
thinkingLevel: 'high',
|
|
76
|
+
providerOverride: 'openai',
|
|
77
|
+
modelOverride: 'gpt-4.1-mini',
|
|
78
|
+
typingIndicators: 'false',
|
|
79
|
+
}),
|
|
80
|
+
makeInbound(),
|
|
81
|
+
)
|
|
82
|
+
assert.equal(policy.thinkingLevel, 'high')
|
|
83
|
+
assert.equal(policy.providerOverride, 'openai')
|
|
84
|
+
assert.equal(policy.modelOverride, 'gpt-4.1-mini')
|
|
85
|
+
assert.equal(policy.typingIndicators, false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('conversation key uses thread scope when configured', () => {
|
|
89
|
+
const connector = makeConnector({ sessionScope: 'thread', threadBinding: 'strict' })
|
|
90
|
+
const msg = makeInbound({ isGroup: true, channelId: 'C999', threadId: 'T321' })
|
|
91
|
+
const policy = resolveConnectorSessionPolicy(connector, msg)
|
|
92
|
+
const key = buildConnectorConversationKey({ connector, msg, agentId: 'agent-1', policy })
|
|
93
|
+
assert.equal(key, 'connector:connector-1:agent:agent-1:channel:C999:thread:T321')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('staleness detects idle and max age expiry', () => {
|
|
97
|
+
const connector = makeConnector({ idleTimeoutSec: '10', maxAgeSec: '20' })
|
|
98
|
+
const msg = makeInbound()
|
|
99
|
+
const policy = resolveConnectorSessionPolicy(connector, msg)
|
|
100
|
+
const session = {
|
|
101
|
+
id: 's1',
|
|
102
|
+
createdAt: 0,
|
|
103
|
+
lastActiveAt: 0,
|
|
104
|
+
messages: [{ role: 'user', text: 'hi', time: 0 }],
|
|
105
|
+
} as Partial<Session>
|
|
106
|
+
assert.deepEqual(getConnectorSessionStaleness(session, policy, 11_000), { stale: true, reason: 'idle_timeout:10' })
|
|
107
|
+
assert.deepEqual(getConnectorSessionStaleness(session, policy, 25_000), { stale: true, reason: 'idle_timeout:10' })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('connector staleness supports daily reset mode', () => {
|
|
111
|
+
const connector = makeConnector({ sessionResetMode: 'daily', sessionDailyResetAt: '04:00', idleTimeoutSec: '0', maxAgeSec: '999999' })
|
|
112
|
+
const msg = makeInbound()
|
|
113
|
+
const policy = resolveConnectorSessionPolicy(connector, msg)
|
|
114
|
+
const session = {
|
|
115
|
+
id: 's2',
|
|
116
|
+
createdAt: Date.parse('2026-03-04T00:00:00.000Z'),
|
|
117
|
+
lastActiveAt: Date.parse('2026-03-05T03:30:00.000Z'),
|
|
118
|
+
messages: [{ role: 'user', text: 'hi', time: 0 }],
|
|
119
|
+
} as Partial<Session>
|
|
120
|
+
assert.deepEqual(
|
|
121
|
+
getConnectorSessionStaleness(session, { ...policy, resetTimezone: 'UTC' }, Date.parse('2026-03-05T10:00:00.000Z')),
|
|
122
|
+
{ stale: true, reason: 'daily_reset:04:00' },
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('resetConnectorSessionRuntime clears conversation state', () => {
|
|
127
|
+
const session = {
|
|
128
|
+
id: 's1',
|
|
129
|
+
name: 'test',
|
|
130
|
+
cwd: '/',
|
|
131
|
+
user: 'u',
|
|
132
|
+
provider: 'openai',
|
|
133
|
+
model: 'gpt-4.1',
|
|
134
|
+
claudeSessionId: 'claude',
|
|
135
|
+
codexThreadId: 'codex',
|
|
136
|
+
opencodeSessionId: 'open',
|
|
137
|
+
delegateResumeIds: { claudeCode: 'x', codex: 'y', opencode: 'z', gemini: 'g' },
|
|
138
|
+
messages: [{ role: 'user', text: 'hi', time: 0 }],
|
|
139
|
+
createdAt: 0,
|
|
140
|
+
lastActiveAt: 0,
|
|
141
|
+
connectorContext: { lastOutboundMessageId: 'm1' },
|
|
142
|
+
} as Session
|
|
143
|
+
const cleared = resetConnectorSessionRuntime(session, 'idle_timeout:10')
|
|
144
|
+
assert.equal(cleared, 1)
|
|
145
|
+
assert.deepEqual(session.messages, [])
|
|
146
|
+
assert.equal(session.claudeSessionId, null)
|
|
147
|
+
assert.equal(session.connectorContext?.lastOutboundMessageId, null)
|
|
148
|
+
assert.equal(session.connectorContext?.lastResetReason, 'idle_timeout:10')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
test('reply policy uses first reply once per session', () => {
|
|
152
|
+
const connector = makeConnector({ replyMode: 'first' })
|
|
153
|
+
const msg = makeInbound({ messageId: 'in-1' })
|
|
154
|
+
const policy = resolveConnectorSessionPolicy(connector, msg)
|
|
155
|
+
assert.deepEqual(shouldReplyToInboundMessage({ msg, policy }), { replyToMessageId: 'in-1', threadId: undefined })
|
|
156
|
+
assert.deepEqual(shouldReplyToInboundMessage({
|
|
157
|
+
msg,
|
|
158
|
+
policy,
|
|
159
|
+
session: { connectorContext: { lastOutboundMessageId: 'out-1' } } as Partial<Session>,
|
|
160
|
+
}), { replyToMessageId: undefined, threadId: undefined })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('reply detection matches last outbound', () => {
|
|
164
|
+
const session = { connectorContext: { lastOutboundMessageId: 'out-1' } } as Partial<Session>
|
|
165
|
+
assert.equal(isReplyToLastOutbound(makeInbound({ replyToMessageId: 'out-1' }), session), true)
|
|
166
|
+
assert.equal(isReplyToLastOutbound(makeInbound({ replyToMessageId: 'out-2' }), session), false)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('mergeInboundMessages combines text and media', () => {
|
|
170
|
+
const merged = mergeInboundMessages([
|
|
171
|
+
makeInbound({ text: 'first' }),
|
|
172
|
+
makeInbound({ text: 'second', media: [{ type: 'image', url: 'https://example.com/a.png' }] }),
|
|
173
|
+
])
|
|
174
|
+
assert.equal(merged.text, 'first\nsecond')
|
|
175
|
+
assert.equal(merged.media?.length, 1)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('textMentionsAlias catches plain and @ mentions', () => {
|
|
179
|
+
assert.equal(textMentionsAlias('hey swarmy can you help?', ['Swarmy']), true)
|
|
180
|
+
assert.equal(textMentionsAlias('@swarmy help', ['Swarmy']), true)
|
|
181
|
+
assert.equal(textMentionsAlias('hello team', ['Swarmy']), false)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('dedupe key prefers explicit message ids', () => {
|
|
185
|
+
const connector = makeConnector()
|
|
186
|
+
assert.equal(buildInboundDedupeKey(connector, makeInbound({ messageId: 'm123' })), 'msg:connector-1:C123:m123')
|
|
187
|
+
assert.match(buildInboundDedupeKey(connector, makeInbound({ text: 'Hello there' })), /^text:connector-1:C123:U123:none:none:hello there$/)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('doctor warnings flag unsafe defaults', () => {
|
|
191
|
+
const warnings = buildConnectorDoctorWarnings({
|
|
192
|
+
connector: makeConnector({
|
|
193
|
+
sessionScope: 'main',
|
|
194
|
+
groupPolicy: 'open',
|
|
195
|
+
replyMode: 'off',
|
|
196
|
+
threadBinding: 'off',
|
|
197
|
+
idleTimeoutSec: '0',
|
|
198
|
+
maxAgeSec: '0',
|
|
199
|
+
inboundDebounceMs: '0',
|
|
200
|
+
}),
|
|
201
|
+
msg: makeInbound({ isGroup: true }),
|
|
202
|
+
})
|
|
203
|
+
assert.ok(warnings.some((item) => item.includes('blend unrelated connector conversations')))
|
|
204
|
+
assert.ok(warnings.some((item) => item.includes('may speak in group chats without being mentioned')))
|
|
205
|
+
assert.ok(warnings.some((item) => item.includes('Inbound debounce is disabled')))
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('doctor warnings flag daily reset timezone and chatroom overrides', () => {
|
|
209
|
+
const connector = makeConnector({
|
|
210
|
+
sessionResetMode: 'daily',
|
|
211
|
+
sessionDailyResetAt: '04:00',
|
|
212
|
+
providerOverride: 'openai',
|
|
213
|
+
modelOverride: 'gpt-4.1-mini',
|
|
214
|
+
})
|
|
215
|
+
connector.chatroomId = 'chatroom-1'
|
|
216
|
+
const warnings = buildConnectorDoctorWarnings({
|
|
217
|
+
connector,
|
|
218
|
+
msg: makeInbound(),
|
|
219
|
+
})
|
|
220
|
+
assert.ok(warnings.some((item) => item.includes('server timezone')))
|
|
221
|
+
assert.ok(warnings.some((item) => item.includes('routes to a chatroom')))
|
|
222
|
+
})
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import type { Connector, Session, SessionResetMode, SessionResetType } from '@/types'
|
|
2
|
+
import { getProvider } from '@/lib/providers'
|
|
3
|
+
import type { InboundMessage } from './types'
|
|
4
|
+
import { evaluateSessionFreshness, inferSessionResetType, resetSessionRuntime, resolveSessionResetPolicy } from '../session-reset-policy'
|
|
5
|
+
import { listStoredAllowedSenders, parseAllowFromCsv, parsePairingPolicy } from './pairing'
|
|
6
|
+
import { loadAgents, loadChatrooms, loadCredentials } from '../storage'
|
|
7
|
+
|
|
8
|
+
export type ConnectorSessionScope = 'main' | 'channel' | 'peer' | 'channel-peer' | 'thread'
|
|
9
|
+
export type ConnectorReplyMode = 'off' | 'first' | 'all'
|
|
10
|
+
export type ConnectorThreadBinding = 'off' | 'prefer' | 'strict'
|
|
11
|
+
export type ConnectorGroupPolicy = 'open' | 'mention' | 'reply-or-mention' | 'disabled'
|
|
12
|
+
export type ConnectorThinkingLevel = 'minimal' | 'low' | 'medium' | 'high'
|
|
13
|
+
|
|
14
|
+
export interface ResolvedConnectorSessionPolicy {
|
|
15
|
+
scope: ConnectorSessionScope
|
|
16
|
+
replyMode: ConnectorReplyMode
|
|
17
|
+
threadBinding: ConnectorThreadBinding
|
|
18
|
+
groupPolicy: ConnectorGroupPolicy
|
|
19
|
+
thinkingLevel: ConnectorThinkingLevel | null
|
|
20
|
+
providerOverride: string | null
|
|
21
|
+
modelOverride: string | null
|
|
22
|
+
resetType: SessionResetType
|
|
23
|
+
resetMode: SessionResetMode
|
|
24
|
+
idleTimeoutSec: number | null
|
|
25
|
+
maxAgeSec: number | null
|
|
26
|
+
dailyResetAt: string | null
|
|
27
|
+
resetTimezone: string | null
|
|
28
|
+
inboundDebounceMs: number
|
|
29
|
+
statusReactions: boolean
|
|
30
|
+
typingIndicators: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ConnectorSessionStaleness {
|
|
34
|
+
stale: boolean
|
|
35
|
+
reason?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_DM_SCOPE: ConnectorSessionScope = 'channel-peer'
|
|
39
|
+
const DEFAULT_GROUP_SCOPE: ConnectorSessionScope = 'channel'
|
|
40
|
+
const DEFAULT_REPLY_MODE: ConnectorReplyMode = 'first'
|
|
41
|
+
const DEFAULT_THREAD_BINDING: ConnectorThreadBinding = 'prefer'
|
|
42
|
+
const DEFAULT_GROUP_POLICY: ConnectorGroupPolicy = 'reply-or-mention'
|
|
43
|
+
const DEFAULT_IDLE_TIMEOUT_SEC = 12 * 60 * 60
|
|
44
|
+
const DEFAULT_MAX_AGE_SEC = 7 * 24 * 60 * 60
|
|
45
|
+
const DEFAULT_INBOUND_DEBOUNCE_MS = 700
|
|
46
|
+
|
|
47
|
+
function parseIntBounded(raw: unknown, min: number, max: number): number | null {
|
|
48
|
+
if (raw === null || raw === undefined) return null
|
|
49
|
+
const parsed = typeof raw === 'number'
|
|
50
|
+
? raw
|
|
51
|
+
: typeof raw === 'string'
|
|
52
|
+
? Number.parseInt(raw.trim(), 10)
|
|
53
|
+
: Number.NaN
|
|
54
|
+
if (!Number.isFinite(parsed)) return null
|
|
55
|
+
return Math.max(min, Math.min(max, Math.trunc(parsed)))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseBool(raw: unknown, fallback: boolean): boolean {
|
|
59
|
+
if (typeof raw === 'boolean') return raw
|
|
60
|
+
if (typeof raw !== 'string') return fallback
|
|
61
|
+
const normalized = raw.trim().toLowerCase()
|
|
62
|
+
if (['true', '1', 'yes', 'on'].includes(normalized)) return true
|
|
63
|
+
if (['false', '0', 'no', 'off'].includes(normalized)) return false
|
|
64
|
+
return fallback
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeEnum<T extends string>(raw: unknown, allowed: readonly T[], fallback: T): T {
|
|
68
|
+
const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
|
|
69
|
+
return (allowed as readonly string[]).includes(normalized) ? normalized as T : fallback
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function normalizeConnectorSessionScope(raw: unknown, fallback: ConnectorSessionScope): ConnectorSessionScope {
|
|
73
|
+
return normalizeEnum(raw, ['main', 'channel', 'peer', 'channel-peer', 'thread'] as const, fallback)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeConnectorReplyMode(raw: unknown, fallback: ConnectorReplyMode = DEFAULT_REPLY_MODE): ConnectorReplyMode {
|
|
77
|
+
return normalizeEnum(raw, ['off', 'first', 'all'] as const, fallback)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeConnectorThreadBinding(raw: unknown, fallback: ConnectorThreadBinding = DEFAULT_THREAD_BINDING): ConnectorThreadBinding {
|
|
81
|
+
return normalizeEnum(raw, ['off', 'prefer', 'strict'] as const, fallback)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeConnectorGroupPolicy(raw: unknown, fallback: ConnectorGroupPolicy = DEFAULT_GROUP_POLICY): ConnectorGroupPolicy {
|
|
85
|
+
return normalizeEnum(raw, ['open', 'mention', 'reply-or-mention', 'disabled'] as const, fallback)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function normalizeConnectorThinkingLevel(raw: unknown, fallback: ConnectorThinkingLevel | null = null): ConnectorThinkingLevel | null {
|
|
89
|
+
if (raw === null || raw === undefined) return fallback
|
|
90
|
+
const normalized = typeof raw === 'string' ? raw.trim().toLowerCase() : ''
|
|
91
|
+
if (normalized === 'minimal' || normalized === 'low' || normalized === 'medium' || normalized === 'high') {
|
|
92
|
+
return normalized
|
|
93
|
+
}
|
|
94
|
+
return fallback
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeSessionResetMode(raw: unknown, fallback: SessionResetMode): SessionResetMode {
|
|
98
|
+
return normalizeEnum(raw, ['idle', 'daily'] as const, fallback)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function normalizeTimeHHMM(raw: unknown, fallback: string | null): string | null {
|
|
102
|
+
if (typeof raw !== 'string') return fallback
|
|
103
|
+
const trimmed = raw.trim()
|
|
104
|
+
const match = trimmed.match(/^(\d{1,2}):(\d{2})$/)
|
|
105
|
+
if (!match) return fallback
|
|
106
|
+
const hours = Number.parseInt(match[1], 10)
|
|
107
|
+
const minutes = Number.parseInt(match[2], 10)
|
|
108
|
+
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return fallback
|
|
109
|
+
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return fallback
|
|
110
|
+
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function normalizeTimezone(raw: unknown, fallback: string | null): string | null {
|
|
114
|
+
if (typeof raw !== 'string') return fallback
|
|
115
|
+
const trimmed = raw.trim()
|
|
116
|
+
return trimmed || fallback
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeNonEmptyText(raw: unknown): string | null {
|
|
120
|
+
if (typeof raw !== 'string') return null
|
|
121
|
+
const trimmed = raw.trim()
|
|
122
|
+
return trimmed || null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function normalizeProviderOverride(raw: unknown): string | null {
|
|
126
|
+
const trimmed = normalizeNonEmptyText(raw)
|
|
127
|
+
if (!trimmed) return null
|
|
128
|
+
return getProvider(trimmed) ? trimmed : null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function textMentionsAlias(text: string, aliases: string[]): boolean {
|
|
132
|
+
const normalized = text.trim()
|
|
133
|
+
if (!normalized) return false
|
|
134
|
+
for (const alias of aliases) {
|
|
135
|
+
const trimmed = alias.trim()
|
|
136
|
+
if (!trimmed) continue
|
|
137
|
+
const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
138
|
+
const pattern = new RegExp(`(^|\\s)(@?${escaped})(?=$|[\\s:,.!?])`, 'i')
|
|
139
|
+
if (pattern.test(normalized)) return true
|
|
140
|
+
}
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function resolveConnectorSessionPolicy(
|
|
145
|
+
connector: Connector,
|
|
146
|
+
msg: InboundMessage,
|
|
147
|
+
session?: Partial<Session> | null,
|
|
148
|
+
): ResolvedConnectorSessionPolicy {
|
|
149
|
+
const fallbackScope = msg.isGroup ? DEFAULT_GROUP_SCOPE : DEFAULT_DM_SCOPE
|
|
150
|
+
const scope = normalizeConnectorSessionScope(
|
|
151
|
+
session?.connectorSessionScope ?? connector.config?.sessionScope,
|
|
152
|
+
fallbackScope,
|
|
153
|
+
)
|
|
154
|
+
const resetType = inferSessionResetType(session, {
|
|
155
|
+
isGroup: msg.isGroup,
|
|
156
|
+
threadId: msg.threadId || null,
|
|
157
|
+
})
|
|
158
|
+
const baseReset = resolveSessionResetPolicy({ session, resetType })
|
|
159
|
+
return {
|
|
160
|
+
scope,
|
|
161
|
+
replyMode: normalizeConnectorReplyMode(session?.connectorReplyMode ?? connector.config?.replyMode),
|
|
162
|
+
threadBinding: normalizeConnectorThreadBinding(session?.connectorThreadBinding ?? connector.config?.threadBinding),
|
|
163
|
+
groupPolicy: normalizeConnectorGroupPolicy(session?.connectorGroupPolicy ?? connector.config?.groupPolicy),
|
|
164
|
+
thinkingLevel: normalizeConnectorThinkingLevel(session?.connectorThinkLevel ?? connector.config?.thinkingLevel, null),
|
|
165
|
+
providerOverride: normalizeProviderOverride(connector.config?.providerOverride),
|
|
166
|
+
modelOverride: normalizeNonEmptyText(connector.config?.modelOverride),
|
|
167
|
+
resetType,
|
|
168
|
+
resetMode: normalizeSessionResetMode(
|
|
169
|
+
session?.sessionResetMode ?? connector.config?.sessionResetMode,
|
|
170
|
+
baseReset.mode,
|
|
171
|
+
),
|
|
172
|
+
idleTimeoutSec: parseIntBounded(
|
|
173
|
+
session?.connectorIdleTimeoutSec ?? connector.config?.idleTimeoutSec,
|
|
174
|
+
0,
|
|
175
|
+
30 * 24 * 60 * 60,
|
|
176
|
+
) ?? baseReset.idleTimeoutSec ?? DEFAULT_IDLE_TIMEOUT_SEC,
|
|
177
|
+
maxAgeSec: parseIntBounded(
|
|
178
|
+
session?.connectorMaxAgeSec ?? connector.config?.maxAgeSec,
|
|
179
|
+
0,
|
|
180
|
+
90 * 24 * 60 * 60,
|
|
181
|
+
) ?? baseReset.maxAgeSec ?? DEFAULT_MAX_AGE_SEC,
|
|
182
|
+
dailyResetAt: normalizeTimeHHMM(
|
|
183
|
+
session?.sessionDailyResetAt ?? connector.config?.sessionDailyResetAt,
|
|
184
|
+
baseReset.dailyResetAt,
|
|
185
|
+
),
|
|
186
|
+
resetTimezone: normalizeTimezone(
|
|
187
|
+
session?.sessionResetTimezone ?? connector.config?.sessionResetTimezone,
|
|
188
|
+
baseReset.timezone,
|
|
189
|
+
),
|
|
190
|
+
inboundDebounceMs: parseIntBounded(
|
|
191
|
+
connector.config?.inboundDebounceMs,
|
|
192
|
+
0,
|
|
193
|
+
60_000,
|
|
194
|
+
) ?? DEFAULT_INBOUND_DEBOUNCE_MS,
|
|
195
|
+
statusReactions: parseBool(connector.config?.statusReactions, true),
|
|
196
|
+
typingIndicators: parseBool(connector.config?.typingIndicators, true),
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function normalizeKeyPart(raw: string | null | undefined, fallback = 'none'): string {
|
|
201
|
+
const normalized = (raw || '').trim()
|
|
202
|
+
return normalized || fallback
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function buildConnectorConversationKey(params: {
|
|
206
|
+
connector: Connector
|
|
207
|
+
msg: InboundMessage
|
|
208
|
+
agentId: string
|
|
209
|
+
policy: ResolvedConnectorSessionPolicy
|
|
210
|
+
}): string {
|
|
211
|
+
const { connector, msg, agentId, policy } = params
|
|
212
|
+
let scope = policy.scope
|
|
213
|
+
if (scope === 'thread' && !msg.threadId) {
|
|
214
|
+
scope = msg.isGroup ? 'channel' : 'channel-peer'
|
|
215
|
+
}
|
|
216
|
+
if (policy.threadBinding === 'strict' && msg.threadId) {
|
|
217
|
+
scope = 'thread'
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const parts = [`connector:${connector.id}`, `agent:${normalizeKeyPart(agentId)}`]
|
|
221
|
+
switch (scope) {
|
|
222
|
+
case 'main':
|
|
223
|
+
parts.push('main')
|
|
224
|
+
break
|
|
225
|
+
case 'channel':
|
|
226
|
+
parts.push(`channel:${normalizeKeyPart(msg.channelId)}`)
|
|
227
|
+
break
|
|
228
|
+
case 'peer':
|
|
229
|
+
parts.push(`peer:${normalizeKeyPart(msg.senderId)}`)
|
|
230
|
+
break
|
|
231
|
+
case 'channel-peer':
|
|
232
|
+
parts.push(`channel:${normalizeKeyPart(msg.channelId)}`, `peer:${normalizeKeyPart(msg.senderId)}`)
|
|
233
|
+
break
|
|
234
|
+
case 'thread':
|
|
235
|
+
parts.push(
|
|
236
|
+
`channel:${normalizeKeyPart(msg.channelId)}`,
|
|
237
|
+
`thread:${normalizeKeyPart(msg.threadId || msg.replyToMessageId || msg.messageId)}`,
|
|
238
|
+
)
|
|
239
|
+
break
|
|
240
|
+
}
|
|
241
|
+
return parts.join(':')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function buildInboundDedupeKey(connector: Connector, msg: InboundMessage): string {
|
|
245
|
+
if (msg.messageId) return `msg:${connector.id}:${normalizeKeyPart(msg.channelId)}:${normalizeKeyPart(msg.messageId)}`
|
|
246
|
+
const rawText = msg.text.trim().replace(/\s+/g, ' ').toLowerCase()
|
|
247
|
+
const textKey = rawText.slice(0, 240) || '(empty)'
|
|
248
|
+
return [
|
|
249
|
+
'text',
|
|
250
|
+
connector.id,
|
|
251
|
+
normalizeKeyPart(msg.channelId),
|
|
252
|
+
normalizeKeyPart(msg.senderId),
|
|
253
|
+
normalizeKeyPart(msg.threadId),
|
|
254
|
+
normalizeKeyPart(msg.replyToMessageId),
|
|
255
|
+
textKey,
|
|
256
|
+
].join(':')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function buildInboundDebounceKey(connector: Connector, msg: InboundMessage): string {
|
|
260
|
+
return [
|
|
261
|
+
connector.id,
|
|
262
|
+
normalizeKeyPart(msg.channelId),
|
|
263
|
+
normalizeKeyPart(msg.senderId),
|
|
264
|
+
normalizeKeyPart(msg.threadId),
|
|
265
|
+
].join(':')
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function mergeInboundMessages(messages: InboundMessage[]): InboundMessage {
|
|
269
|
+
if (!messages.length) {
|
|
270
|
+
throw new Error('Cannot merge zero inbound messages')
|
|
271
|
+
}
|
|
272
|
+
if (messages.length === 1) return messages[0]
|
|
273
|
+
|
|
274
|
+
const last = messages[messages.length - 1]
|
|
275
|
+
const sameSender = messages.every((msg) => msg.senderId === last.senderId)
|
|
276
|
+
const text = messages
|
|
277
|
+
.map((msg) => {
|
|
278
|
+
const content = msg.text.trim()
|
|
279
|
+
if (!content) return ''
|
|
280
|
+
return sameSender ? content : `[${msg.senderName}] ${content}`
|
|
281
|
+
})
|
|
282
|
+
.filter(Boolean)
|
|
283
|
+
.join('\n')
|
|
284
|
+
const media = messages.flatMap((msg) => msg.media || [])
|
|
285
|
+
const imageUrl = messages.map((msg) => msg.imageUrl).find(Boolean)
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
...last,
|
|
289
|
+
text,
|
|
290
|
+
media: media.length ? media : undefined,
|
|
291
|
+
imageUrl,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function getConnectorSessionStaleness(
|
|
296
|
+
session: Partial<Session> | null | undefined,
|
|
297
|
+
policy: ResolvedConnectorSessionPolicy,
|
|
298
|
+
now = Date.now(),
|
|
299
|
+
): ConnectorSessionStaleness {
|
|
300
|
+
const freshness = evaluateSessionFreshness({
|
|
301
|
+
session,
|
|
302
|
+
now,
|
|
303
|
+
policy: {
|
|
304
|
+
type: policy.resetType,
|
|
305
|
+
mode: policy.resetMode,
|
|
306
|
+
idleTimeoutSec: policy.idleTimeoutSec,
|
|
307
|
+
maxAgeSec: policy.maxAgeSec,
|
|
308
|
+
dailyResetAt: policy.dailyResetAt,
|
|
309
|
+
timezone: policy.resetTimezone,
|
|
310
|
+
},
|
|
311
|
+
})
|
|
312
|
+
return freshness.fresh ? { stale: false } : { stale: true, reason: freshness.reason }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function resetConnectorSessionRuntime(session: Session, reason: string): number {
|
|
316
|
+
return resetSessionRuntime(session, reason)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function shouldReplyToInboundMessage(params: {
|
|
320
|
+
msg: InboundMessage
|
|
321
|
+
session?: Partial<Session> | null
|
|
322
|
+
policy: ResolvedConnectorSessionPolicy
|
|
323
|
+
}): { replyToMessageId?: string; threadId?: string } {
|
|
324
|
+
const { msg, session, policy } = params
|
|
325
|
+
const replyToMessageId = (() => {
|
|
326
|
+
if (!msg.messageId) return undefined
|
|
327
|
+
if (policy.replyMode === 'off') return undefined
|
|
328
|
+
if (policy.replyMode === 'all') return msg.messageId
|
|
329
|
+
const priorOutbound = session?.connectorContext?.lastOutboundMessageId
|
|
330
|
+
return priorOutbound ? undefined : msg.messageId
|
|
331
|
+
})()
|
|
332
|
+
const threadId = policy.threadBinding !== 'off'
|
|
333
|
+
? (msg.threadId || session?.connectorContext?.threadId || undefined)
|
|
334
|
+
: undefined
|
|
335
|
+
return { replyToMessageId, threadId }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function isReplyToLastOutbound(msg: InboundMessage, session?: Partial<Session> | null): boolean {
|
|
339
|
+
if (!msg.replyToMessageId) return false
|
|
340
|
+
return msg.replyToMessageId === session?.connectorContext?.lastOutboundMessageId
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function buildConnectorDoctorWarnings(params: {
|
|
344
|
+
connector: Connector
|
|
345
|
+
msg?: InboundMessage | null
|
|
346
|
+
session?: Partial<Session> | null
|
|
347
|
+
}): string[] {
|
|
348
|
+
const { connector, msg, session } = params
|
|
349
|
+
const sampleMsg = msg || {
|
|
350
|
+
platform: connector.platform,
|
|
351
|
+
channelId: 'sample-channel',
|
|
352
|
+
senderId: 'sample-user',
|
|
353
|
+
senderName: 'Sample User',
|
|
354
|
+
text: 'sample',
|
|
355
|
+
isGroup: false,
|
|
356
|
+
} as InboundMessage
|
|
357
|
+
const policy = resolveConnectorSessionPolicy(connector, sampleMsg, session)
|
|
358
|
+
const warnings: string[] = []
|
|
359
|
+
const agents = loadAgents()
|
|
360
|
+
const chatrooms = loadChatrooms()
|
|
361
|
+
|
|
362
|
+
if (!connector.agentId && !connector.chatroomId) {
|
|
363
|
+
warnings.push('No agent or chatroom is assigned, so inbound messages cannot be handled.')
|
|
364
|
+
}
|
|
365
|
+
if (connector.agentId && connector.chatroomId) {
|
|
366
|
+
warnings.push('Both agentId and chatroomId are set; chatroom routing will win and the direct agent assignment is ignored.')
|
|
367
|
+
}
|
|
368
|
+
if (connector.agentId && !agents[connector.agentId]) {
|
|
369
|
+
warnings.push(`Assigned agent "${connector.agentId}" was not found, so direct connector routing will fail.`)
|
|
370
|
+
}
|
|
371
|
+
if (connector.chatroomId) {
|
|
372
|
+
const chatroom = chatrooms[connector.chatroomId]
|
|
373
|
+
if (!chatroom) {
|
|
374
|
+
warnings.push(`Assigned chatroom "${connector.chatroomId}" was not found, so inbound messages cannot be routed.`)
|
|
375
|
+
} else if (!Array.isArray(chatroom.agentIds) || chatroom.agentIds.length === 0) {
|
|
376
|
+
warnings.push(`Assigned chatroom "${chatroom.name || connector.chatroomId}" has no agents, so inbound messages will not get a response.`)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const dmPolicy = parsePairingPolicy(connector.config?.dmPolicy, 'open')
|
|
380
|
+
const configuredAllowFrom = parseAllowFromCsv(connector.config?.allowFrom)
|
|
381
|
+
const storedAllowFrom = listStoredAllowedSenders(connector.id)
|
|
382
|
+
if (parseBool(connector.config?.statusReactions, true) && connector.platform === 'telegram') {
|
|
383
|
+
warnings.push('Status reactions are enabled, but Telegram support is partial and may no-op depending on bot permissions.')
|
|
384
|
+
}
|
|
385
|
+
if (parseBool(connector.config?.typingIndicators, true) && connector.platform === 'slack') {
|
|
386
|
+
warnings.push('Typing indicators are enabled, but Slack support is unavailable over the current connector transport.')
|
|
387
|
+
}
|
|
388
|
+
if (policy.scope === 'main') {
|
|
389
|
+
warnings.push('Session scope is "main", which can blend unrelated connector conversations into one session.')
|
|
390
|
+
}
|
|
391
|
+
if (policy.groupPolicy === 'open') {
|
|
392
|
+
warnings.push('Group policy is "open", so the agent may speak in group chats without being mentioned or replied to.')
|
|
393
|
+
}
|
|
394
|
+
if (policy.replyMode === 'off') {
|
|
395
|
+
warnings.push('Reply mode is "off", so outbound messages will not stay attached to the originating inbound message.')
|
|
396
|
+
}
|
|
397
|
+
if (policy.threadBinding === 'off') {
|
|
398
|
+
warnings.push('Thread binding is disabled, so threaded conversations may collapse into the parent channel session.')
|
|
399
|
+
}
|
|
400
|
+
if ((policy.idleTimeoutSec ?? 0) === 0 || (policy.maxAgeSec ?? 0) === 0) {
|
|
401
|
+
warnings.push('Session freshness reset is disabled, so stale connector context can accumulate indefinitely.')
|
|
402
|
+
}
|
|
403
|
+
if (policy.resetMode === 'daily' && !policy.dailyResetAt) {
|
|
404
|
+
warnings.push('Daily reset mode is enabled without a valid reset time, so freshness falls back to max-age or idle checks only.')
|
|
405
|
+
}
|
|
406
|
+
if (policy.resetMode === 'daily' && !policy.resetTimezone) {
|
|
407
|
+
warnings.push('Daily reset mode uses the server timezone. Set sessionResetTimezone explicitly when the connector follows a different local day boundary.')
|
|
408
|
+
}
|
|
409
|
+
if (policy.inboundDebounceMs === 0) {
|
|
410
|
+
warnings.push('Inbound debounce is disabled, so rapid message bursts can trigger duplicate or fragmented autonomous runs.')
|
|
411
|
+
}
|
|
412
|
+
if (!sampleMsg.isGroup && dmPolicy === 'open') {
|
|
413
|
+
warnings.push('DM policy is "open", so any direct sender can start a connector session without approval.')
|
|
414
|
+
}
|
|
415
|
+
if (dmPolicy === 'allowlist' && configuredAllowFrom.length === 0 && storedAllowFrom.length === 0) {
|
|
416
|
+
warnings.push('DM policy is "allowlist", but no approved sender IDs are configured or paired yet.')
|
|
417
|
+
}
|
|
418
|
+
if (dmPolicy === 'pairing' && configuredAllowFrom.length === 0 && storedAllowFrom.length === 0) {
|
|
419
|
+
warnings.push('DM policy is "pairing" with no approved senders, so the first pairing approval will bootstrap trust from any DM.')
|
|
420
|
+
}
|
|
421
|
+
if (connector.config?.providerOverride && !policy.providerOverride) {
|
|
422
|
+
warnings.push(`Provider override "${connector.config.providerOverride}" is invalid, so connector runs fall back to the agent provider.`)
|
|
423
|
+
}
|
|
424
|
+
if ((policy.providerOverride || policy.modelOverride) && connector.chatroomId) {
|
|
425
|
+
warnings.push('Provider/model overrides are configured, but this connector routes to a chatroom. Those overrides only apply to direct agent connector sessions.')
|
|
426
|
+
}
|
|
427
|
+
if (policy.providerOverride && policy.modelOverride) {
|
|
428
|
+
const provider = getProvider(policy.providerOverride)
|
|
429
|
+
if (provider && provider.models.length > 0 && !provider.models.includes(policy.modelOverride)) {
|
|
430
|
+
warnings.push(`Model override "${policy.modelOverride}" is not in the advertised model list for provider "${policy.providerOverride}".`)
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (policy.providerOverride && connector.agentId) {
|
|
434
|
+
const credentials = loadCredentials()
|
|
435
|
+
const agent = agents[connector.agentId]
|
|
436
|
+
if (agent) {
|
|
437
|
+
const provider = getProvider(policy.providerOverride)
|
|
438
|
+
const candidateIds = [agent.credentialId, ...(agent.fallbackCredentialIds || [])].filter(Boolean) as string[]
|
|
439
|
+
const hasMatchingCredential = candidateIds.some((credentialId) => credentials[credentialId]?.provider === policy.providerOverride)
|
|
440
|
+
if (provider?.requiresApiKey && !hasMatchingCredential) {
|
|
441
|
+
warnings.push(`Provider override "${policy.providerOverride}" requires matching API credentials, but the assigned agent has no primary/fallback credential for that provider.`)
|
|
442
|
+
}
|
|
443
|
+
if (provider?.requiresEndpoint && !(agent.apiEndpoint || provider.defaultEndpoint)) {
|
|
444
|
+
warnings.push(`Provider override "${policy.providerOverride}" requires an endpoint, but the assigned agent does not provide one.`)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!connector.credentialId && connector.platform !== 'whatsapp' && connector.platform !== 'openclaw' && connector.platform !== 'signal' && connector.platform !== 'email') {
|
|
449
|
+
warnings.push('This connector does not have stored credentials, so startup depends on inline config or will fail.')
|
|
450
|
+
}
|
|
451
|
+
return warnings
|
|
452
|
+
}
|