@swarmclawai/swarmclaw 0.2.0
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 +577 -0
- package/bin/server-cmd.js +359 -0
- package/bin/swarmclaw.js +29 -0
- package/bin/swarmclaw.mjs +1504 -0
- package/next.config.ts +33 -0
- package/package.json +112 -0
- package/postcss.config.mjs +7 -0
- package/public/branding/swarmclaw-org-avatar.png +0 -0
- package/public/branding/swarmclaw-org-avatar.svg +58 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/connectors.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/new-session-openclaw.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/schedules.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/agents/[id]/route.ts +30 -0
- package/src/app/api/agents/[id]/thread/route.ts +66 -0
- package/src/app/api/agents/generate/route.ts +42 -0
- package/src/app/api/agents/route.ts +33 -0
- package/src/app/api/auth/route.ts +25 -0
- package/src/app/api/claude-skills/route.ts +42 -0
- package/src/app/api/clawhub/install/route.ts +39 -0
- package/src/app/api/clawhub/search/route.ts +11 -0
- package/src/app/api/connectors/[id]/route.ts +79 -0
- package/src/app/api/connectors/route.ts +60 -0
- package/src/app/api/credentials/[id]/route.ts +14 -0
- package/src/app/api/credentials/route.ts +31 -0
- package/src/app/api/daemon/health-check/route.ts +11 -0
- package/src/app/api/daemon/route.ts +22 -0
- package/src/app/api/dirs/pick/route.ts +60 -0
- package/src/app/api/dirs/route.ts +29 -0
- package/src/app/api/documents/[id]/route.ts +47 -0
- package/src/app/api/documents/route.ts +93 -0
- package/src/app/api/files/serve/route.ts +69 -0
- package/src/app/api/generate/info/route.ts +12 -0
- package/src/app/api/generate/route.ts +106 -0
- package/src/app/api/ip/route.ts +6 -0
- package/src/app/api/knowledge/[id]/route.ts +61 -0
- package/src/app/api/knowledge/route.ts +48 -0
- package/src/app/api/knowledge/upload/route.ts +86 -0
- package/src/app/api/logs/route.ts +65 -0
- package/src/app/api/mcp-servers/[id]/route.ts +32 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
- package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
- package/src/app/api/mcp-servers/route.ts +27 -0
- package/src/app/api/memory/[id]/route.ts +126 -0
- package/src/app/api/memory/maintenance/route.ts +63 -0
- package/src/app/api/memory/route.ts +111 -0
- package/src/app/api/memory-images/[filename]/route.ts +36 -0
- package/src/app/api/orchestrator/run/route.ts +43 -0
- package/src/app/api/plugins/install/route.ts +58 -0
- package/src/app/api/plugins/marketplace/route.ts +33 -0
- package/src/app/api/plugins/route.ts +21 -0
- package/src/app/api/preview-server/route.ts +339 -0
- package/src/app/api/providers/[id]/models/route.ts +29 -0
- package/src/app/api/providers/[id]/route.ts +34 -0
- package/src/app/api/providers/configs/route.ts +7 -0
- package/src/app/api/providers/ollama/route.ts +30 -0
- package/src/app/api/providers/openclaw/health/route.ts +23 -0
- package/src/app/api/providers/route.ts +28 -0
- package/src/app/api/runs/[id]/route.ts +9 -0
- package/src/app/api/runs/route.ts +13 -0
- package/src/app/api/schedules/[id]/route.ts +28 -0
- package/src/app/api/schedules/[id]/run/route.ts +104 -0
- package/src/app/api/schedules/route.ts +78 -0
- package/src/app/api/secrets/[id]/route.ts +29 -0
- package/src/app/api/secrets/route.ts +42 -0
- package/src/app/api/sessions/[id]/browser/route.ts +13 -0
- package/src/app/api/sessions/[id]/chat/route.ts +96 -0
- package/src/app/api/sessions/[id]/clear/route.ts +19 -0
- package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
- package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
- package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
- package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
- package/src/app/api/sessions/[id]/messages/route.ts +9 -0
- package/src/app/api/sessions/[id]/retry/route.ts +28 -0
- package/src/app/api/sessions/[id]/route.ts +103 -0
- package/src/app/api/sessions/[id]/stop/route.ts +13 -0
- package/src/app/api/sessions/heartbeat/route.ts +26 -0
- package/src/app/api/sessions/route.ts +85 -0
- package/src/app/api/settings/route.ts +58 -0
- package/src/app/api/setup/check-provider/route.ts +326 -0
- package/src/app/api/setup/doctor/route.ts +250 -0
- package/src/app/api/skills/[id]/route.ts +40 -0
- package/src/app/api/skills/import/route.ts +69 -0
- package/src/app/api/skills/route.ts +28 -0
- package/src/app/api/tasks/[id]/route.ts +102 -0
- package/src/app/api/tasks/route.ts +115 -0
- package/src/app/api/tts/route.ts +40 -0
- package/src/app/api/upload/route.ts +18 -0
- package/src/app/api/uploads/[filename]/route.ts +59 -0
- package/src/app/api/usage/route.ts +35 -0
- package/src/app/api/version/route.ts +81 -0
- package/src/app/api/version/update/route.ts +95 -0
- package/src/app/api/webhooks/[id]/history/route.ts +13 -0
- package/src/app/api/webhooks/[id]/route.ts +204 -0
- package/src/app/api/webhooks/route.ts +37 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +370 -0
- package/src/app/layout.tsx +52 -0
- package/src/app/page.tsx +172 -0
- package/src/cli/index.js +1232 -0
- package/src/cli/index.test.js +281 -0
- package/src/cli/index.ts +1158 -0
- package/src/cli/spec.js +284 -0
- package/src/components/agents/agent-card.tsx +219 -0
- package/src/components/agents/agent-chat-list.tsx +165 -0
- package/src/components/agents/agent-list.tsx +110 -0
- package/src/components/agents/agent-sheet.tsx +1220 -0
- package/src/components/auth/access-key-gate.tsx +248 -0
- package/src/components/auth/setup-wizard.tsx +940 -0
- package/src/components/auth/user-picker.tsx +88 -0
- package/src/components/chat/chat-area.tsx +406 -0
- package/src/components/chat/chat-header.tsx +491 -0
- package/src/components/chat/chat-tool-toggles.tsx +161 -0
- package/src/components/chat/code-block.tsx +146 -0
- package/src/components/chat/dev-server-bar.tsx +39 -0
- package/src/components/chat/message-bubble.tsx +486 -0
- package/src/components/chat/message-list.tsx +299 -0
- package/src/components/chat/session-debug-panel.tsx +196 -0
- package/src/components/chat/streaming-bubble.tsx +85 -0
- package/src/components/chat/thinking-indicator.tsx +26 -0
- package/src/components/chat/tool-call-bubble.tsx +438 -0
- package/src/components/chat/tool-request-banner.tsx +103 -0
- package/src/components/connectors/connector-list.tsx +196 -0
- package/src/components/connectors/connector-sheet.tsx +804 -0
- package/src/components/input/chat-input.tsx +235 -0
- package/src/components/knowledge/knowledge-list.tsx +206 -0
- package/src/components/knowledge/knowledge-sheet.tsx +316 -0
- package/src/components/layout/app-layout.tsx +1016 -0
- package/src/components/layout/daemon-indicator.tsx +56 -0
- package/src/components/layout/mobile-header.tsx +31 -0
- package/src/components/layout/network-banner.tsx +17 -0
- package/src/components/layout/update-banner.tsx +130 -0
- package/src/components/logs/log-list.tsx +358 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
- package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
- package/src/components/memory/memory-card.tsx +63 -0
- package/src/components/memory/memory-detail.tsx +339 -0
- package/src/components/memory/memory-list.tsx +198 -0
- package/src/components/memory/memory-sheet.tsx +70 -0
- package/src/components/plugins/plugin-list.tsx +60 -0
- package/src/components/plugins/plugin-sheet.tsx +311 -0
- package/src/components/providers/provider-list.tsx +96 -0
- package/src/components/providers/provider-sheet.tsx +542 -0
- package/src/components/runs/run-list.tsx +231 -0
- package/src/components/schedules/schedule-card.tsx +63 -0
- package/src/components/schedules/schedule-list.tsx +76 -0
- package/src/components/schedules/schedule-sheet.tsx +336 -0
- package/src/components/secrets/secret-sheet.tsx +180 -0
- package/src/components/secrets/secrets-list.tsx +91 -0
- package/src/components/sessions/new-session-sheet.tsx +478 -0
- package/src/components/sessions/session-card.tsx +144 -0
- package/src/components/sessions/session-list.tsx +202 -0
- package/src/components/shared/ai-gen-block.tsx +77 -0
- package/src/components/shared/avatar.tsx +48 -0
- package/src/components/shared/bottom-sheet.tsx +30 -0
- package/src/components/shared/confirm-dialog.tsx +47 -0
- package/src/components/shared/connector-platform-icon.tsx +113 -0
- package/src/components/shared/dir-browser.tsx +285 -0
- package/src/components/shared/dropdown.tsx +55 -0
- package/src/components/shared/icon-button.tsx +25 -0
- package/src/components/shared/settings/plugin-manager.tsx +207 -0
- package/src/components/shared/settings/section-capability-policy.tsx +93 -0
- package/src/components/shared/settings/section-embedding.tsx +99 -0
- package/src/components/shared/settings/section-heartbeat.tsx +168 -0
- package/src/components/shared/settings/section-memory.tsx +77 -0
- package/src/components/shared/settings/section-orchestrator.tsx +108 -0
- package/src/components/shared/settings/section-providers.tsx +181 -0
- package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
- package/src/components/shared/settings/section-secrets.tsx +132 -0
- package/src/components/shared/settings/section-user-preferences.tsx +24 -0
- package/src/components/shared/settings/section-voice.tsx +53 -0
- package/src/components/shared/settings/settings-sheet.tsx +88 -0
- package/src/components/shared/settings/types.ts +7 -0
- package/src/components/shared/settings/utils.ts +13 -0
- package/src/components/shared/settings-sheet.tsx +1 -0
- package/src/components/shared/skeleton.tsx +19 -0
- package/src/components/shared/usage-badge.tsx +28 -0
- package/src/components/skills/clawhub-browser.tsx +225 -0
- package/src/components/skills/skill-list.tsx +70 -0
- package/src/components/skills/skill-sheet.tsx +254 -0
- package/src/components/tasks/task-board.tsx +96 -0
- package/src/components/tasks/task-card.tsx +179 -0
- package/src/components/tasks/task-column.tsx +73 -0
- package/src/components/tasks/task-list.tsx +118 -0
- package/src/components/tasks/task-sheet.tsx +415 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/sonner.tsx +22 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +56 -0
- package/src/components/usage/usage-list.tsx +105 -0
- package/src/components/webhooks/webhook-list.tsx +166 -0
- package/src/components/webhooks/webhook-sheet.tsx +402 -0
- package/src/hooks/use-auto-resize.ts +20 -0
- package/src/hooks/use-media-query.ts +21 -0
- package/src/hooks/use-speech-recognition.ts +83 -0
- package/src/instrumentation.ts +8 -0
- package/src/lib/agents.ts +13 -0
- package/src/lib/api-client.ts +100 -0
- package/src/lib/chat.ts +60 -0
- package/src/lib/memory.ts +42 -0
- package/src/lib/openclaw-endpoint.test.ts +48 -0
- package/src/lib/openclaw-endpoint.ts +67 -0
- package/src/lib/provider-config.ts +13 -0
- package/src/lib/providers/anthropic.ts +135 -0
- package/src/lib/providers/claude-cli.ts +202 -0
- package/src/lib/providers/codex-cli.ts +260 -0
- package/src/lib/providers/index.ts +351 -0
- package/src/lib/providers/ollama.ts +131 -0
- package/src/lib/providers/openai.ts +164 -0
- package/src/lib/providers/openclaw.ts +330 -0
- package/src/lib/providers/opencode-cli.ts +164 -0
- package/src/lib/runtime-loop.ts +15 -0
- package/src/lib/schedule-dedupe.test.ts +84 -0
- package/src/lib/schedule-dedupe.ts +174 -0
- package/src/lib/schedule-name.ts +62 -0
- package/src/lib/schedules.ts +16 -0
- package/src/lib/server/agent-registry.ts +70 -0
- package/src/lib/server/api-routes.test.ts +362 -0
- package/src/lib/server/autonomy-contract.ts +200 -0
- package/src/lib/server/build-llm.ts +155 -0
- package/src/lib/server/capability-router.test.ts +21 -0
- package/src/lib/server/capability-router.ts +172 -0
- package/src/lib/server/chat-execution.ts +894 -0
- package/src/lib/server/clawhub-client.test.ts +161 -0
- package/src/lib/server/clawhub-client.ts +26 -0
- package/src/lib/server/connectors/connector-routing.test.ts +243 -0
- package/src/lib/server/connectors/discord.ts +116 -0
- package/src/lib/server/connectors/googlechat.ts +66 -0
- package/src/lib/server/connectors/manager.ts +559 -0
- package/src/lib/server/connectors/matrix.ts +78 -0
- package/src/lib/server/connectors/media.ts +149 -0
- package/src/lib/server/connectors/openclaw.test.ts +375 -0
- package/src/lib/server/connectors/openclaw.ts +1132 -0
- package/src/lib/server/connectors/signal.ts +183 -0
- package/src/lib/server/connectors/slack.ts +258 -0
- package/src/lib/server/connectors/teams.ts +94 -0
- package/src/lib/server/connectors/telegram.ts +221 -0
- package/src/lib/server/connectors/types.ts +62 -0
- package/src/lib/server/connectors/whatsapp.ts +349 -0
- package/src/lib/server/context-manager.ts +232 -0
- package/src/lib/server/cost.ts +31 -0
- package/src/lib/server/daemon-state.ts +354 -0
- package/src/lib/server/data-dir.ts +3 -0
- package/src/lib/server/embeddings.ts +111 -0
- package/src/lib/server/execution-log.ts +257 -0
- package/src/lib/server/gateway/protocol.test.ts +54 -0
- package/src/lib/server/gateway/protocol.ts +114 -0
- package/src/lib/server/heartbeat-service.ts +366 -0
- package/src/lib/server/knowledge-db.test.ts +441 -0
- package/src/lib/server/logger.ts +47 -0
- package/src/lib/server/main-agent-loop.ts +1017 -0
- package/src/lib/server/mcp-client.test.ts +342 -0
- package/src/lib/server/mcp-client.ts +130 -0
- package/src/lib/server/memory-db.ts +1078 -0
- package/src/lib/server/memory-graph.test.ts +153 -0
- package/src/lib/server/memory-graph.ts +138 -0
- package/src/lib/server/openclaw-health.ts +245 -0
- package/src/lib/server/orchestrator-lg.ts +431 -0
- package/src/lib/server/orchestrator.ts +364 -0
- package/src/lib/server/playwright-proxy.mjs +70 -0
- package/src/lib/server/plugins.ts +229 -0
- package/src/lib/server/process-manager.ts +327 -0
- package/src/lib/server/provider-health.ts +113 -0
- package/src/lib/server/queue.ts +859 -0
- package/src/lib/server/runtime-settings.ts +119 -0
- package/src/lib/server/scheduler.ts +196 -0
- package/src/lib/server/session-mailbox.ts +129 -0
- package/src/lib/server/session-run-manager.ts +512 -0
- package/src/lib/server/session-tools/connector.ts +124 -0
- package/src/lib/server/session-tools/context-mgmt.ts +103 -0
- package/src/lib/server/session-tools/context.ts +114 -0
- package/src/lib/server/session-tools/crud.ts +673 -0
- package/src/lib/server/session-tools/delegate.ts +708 -0
- package/src/lib/server/session-tools/file.ts +264 -0
- package/src/lib/server/session-tools/index.ts +164 -0
- package/src/lib/server/session-tools/memory.ts +230 -0
- package/src/lib/server/session-tools/session-info.ts +422 -0
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
- package/src/lib/server/session-tools/shell.ts +171 -0
- package/src/lib/server/session-tools/web.ts +408 -0
- package/src/lib/server/session-tools.ts +9 -0
- package/src/lib/server/skills-normalize.ts +130 -0
- package/src/lib/server/storage-mcp.test.ts +161 -0
- package/src/lib/server/storage.ts +670 -0
- package/src/lib/server/stream-agent-chat.ts +571 -0
- package/src/lib/server/task-reports.ts +122 -0
- package/src/lib/server/task-result.ts +161 -0
- package/src/lib/server/task-validation.test.ts +27 -0
- package/src/lib/server/task-validation.ts +90 -0
- package/src/lib/server/tool-capability-policy.test.ts +58 -0
- package/src/lib/server/tool-capability-policy.ts +262 -0
- package/src/lib/sessions.ts +68 -0
- package/src/lib/tasks.ts +20 -0
- package/src/lib/tts.ts +42 -0
- package/src/lib/upload.ts +10 -0
- package/src/lib/utils.ts +6 -0
- package/src/proxy.ts +43 -0
- package/src/stores/use-app-store.ts +468 -0
- package/src/stores/use-chat-store.ts +323 -0
- package/src/types/index.ts +621 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { DATA_DIR } from '../data-dir'
|
|
5
|
+
import type { PlatformConnector, ConnectorInstance, InboundMessage } from './types'
|
|
6
|
+
import {
|
|
7
|
+
createGatewayRequestFrame,
|
|
8
|
+
parseGatewayFrame,
|
|
9
|
+
serializeGatewayFrame,
|
|
10
|
+
type GatewayFrame,
|
|
11
|
+
type GatewayResponseFrame,
|
|
12
|
+
} from '../gateway/protocol'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* OpenClaw gateway connector using the current WS protocol:
|
|
16
|
+
* - server emits `event: connect.challenge`
|
|
17
|
+
* - client sends `req(connect, params)`
|
|
18
|
+
* - gateway responds via `res` payload `hello-ok`
|
|
19
|
+
* - chat traffic is event `chat` and RPC method `chat.send`
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const PROTOCOL_VERSION = 3
|
|
23
|
+
const RECONNECT_BASE_MS = 2_000
|
|
24
|
+
const RECONNECT_MAX_MS = 30_000
|
|
25
|
+
const RPC_TIMEOUT_MS = 25_000
|
|
26
|
+
const CONNECT_DELAY_FALLBACK_MS = 750
|
|
27
|
+
const CONNECT_HELLO_TIMEOUT_MS = 20_000
|
|
28
|
+
const DEFAULT_WS_URL = 'ws://localhost:18789'
|
|
29
|
+
const DEFAULT_SESSION_KEY = 'main'
|
|
30
|
+
const DEFAULT_TICK_INTERVAL_MS = 30_000
|
|
31
|
+
const MIN_TICK_WATCHDOG_POLL_MS = 750
|
|
32
|
+
const MAX_TICK_WATCHDOG_POLL_MS = 5_000
|
|
33
|
+
const TICK_MISS_TOLERANCE_MULTIPLIER = 2
|
|
34
|
+
const DEFAULT_CHAT_HISTORY_POLL_MS = 2_500
|
|
35
|
+
const MIN_CHAT_HISTORY_POLL_MS = 500
|
|
36
|
+
const MAX_CHAT_HISTORY_POLL_MS = 60_000
|
|
37
|
+
const DEFAULT_CHAT_HISTORY_LIMIT = 40
|
|
38
|
+
const MIN_CHAT_HISTORY_LIMIT = 5
|
|
39
|
+
const MAX_CHAT_HISTORY_LIMIT = 200
|
|
40
|
+
const MAX_INLINE_ATTACHMENT_BYTES = 5_000_000
|
|
41
|
+
const NO_MESSAGE_SENTINEL = 'NO_MESSAGE'
|
|
42
|
+
const MAX_SEEN_HISTORY_MESSAGES = 4_096
|
|
43
|
+
const RECENT_HISTORY_DUPLICATE_WINDOW_MS = 20_000
|
|
44
|
+
const HISTORY_ERROR_LOG_INTERVAL_MS = 30_000
|
|
45
|
+
|
|
46
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
|
47
|
+
const MAX_SEEN_CHAT_EVENTS = 2048
|
|
48
|
+
|
|
49
|
+
type StoredIdentity = {
|
|
50
|
+
version: 1
|
|
51
|
+
deviceId: string
|
|
52
|
+
publicKeyPem: string
|
|
53
|
+
privateKeyPem: string
|
|
54
|
+
createdAtMs: number
|
|
55
|
+
deviceToken?: string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type DeviceIdentity = {
|
|
59
|
+
deviceId: string
|
|
60
|
+
publicKeyPem: string
|
|
61
|
+
privateKeyPem: string
|
|
62
|
+
deviceToken?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type PendingRequest = {
|
|
66
|
+
method: string
|
|
67
|
+
resolve: (value: unknown) => void
|
|
68
|
+
reject: (reason?: unknown) => void
|
|
69
|
+
timer: ReturnType<typeof setTimeout>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type OutboundSendOptions = Parameters<NonNullable<ConnectorInstance['sendMessage']>>[2]
|
|
73
|
+
|
|
74
|
+
type OutboundAttachment = {
|
|
75
|
+
type: 'image' | 'file'
|
|
76
|
+
mimeType: string
|
|
77
|
+
fileName?: string
|
|
78
|
+
content: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type ChatEventPayload = {
|
|
82
|
+
runId?: string
|
|
83
|
+
seq?: number
|
|
84
|
+
state?: string
|
|
85
|
+
sessionKey?: string
|
|
86
|
+
message?: {
|
|
87
|
+
role?: string
|
|
88
|
+
sender?: string
|
|
89
|
+
senderId?: string
|
|
90
|
+
senderName?: string
|
|
91
|
+
text?: string
|
|
92
|
+
content?: unknown
|
|
93
|
+
}
|
|
94
|
+
sender?: string
|
|
95
|
+
senderId?: string
|
|
96
|
+
senderName?: string
|
|
97
|
+
text?: string
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type ChatHistoryMessage = {
|
|
101
|
+
role?: unknown
|
|
102
|
+
sender?: unknown
|
|
103
|
+
senderId?: unknown
|
|
104
|
+
senderName?: unknown
|
|
105
|
+
content?: unknown
|
|
106
|
+
timestamp?: unknown
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
type ChatHistoryPayload = {
|
|
110
|
+
sessionKey?: unknown
|
|
111
|
+
messages?: unknown
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isSecureWsUrl(url: string): boolean {
|
|
115
|
+
let parsed: URL
|
|
116
|
+
try {
|
|
117
|
+
parsed = new URL(url)
|
|
118
|
+
} catch {
|
|
119
|
+
return false
|
|
120
|
+
}
|
|
121
|
+
if (parsed.protocol === 'wss:') return true
|
|
122
|
+
if (parsed.protocol !== 'ws:') return false
|
|
123
|
+
const host = parsed.hostname.trim().toLowerCase()
|
|
124
|
+
if (host === 'localhost' || host === '::1') return true
|
|
125
|
+
if (host.startsWith('127.')) return true
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isNoMessage(text: string): boolean {
|
|
130
|
+
return text.trim().toUpperCase() === NO_MESSAGE_SENTINEL
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function base64UrlEncode(buf: Buffer): string {
|
|
134
|
+
return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '')
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function derivePublicKeyRaw(publicKeyPem: string): Buffer {
|
|
138
|
+
const key = crypto.createPublicKey(publicKeyPem)
|
|
139
|
+
const spki = key.export({ type: 'spki', format: 'der' }) as Buffer
|
|
140
|
+
if (
|
|
141
|
+
spki.length === ED25519_SPKI_PREFIX.length + 32
|
|
142
|
+
&& spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)
|
|
143
|
+
) {
|
|
144
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length)
|
|
145
|
+
}
|
|
146
|
+
return spki
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fingerprintPublicKey(publicKeyPem: string): string {
|
|
150
|
+
const raw = derivePublicKeyRaw(publicKeyPem)
|
|
151
|
+
return crypto.createHash('sha256').update(raw).digest('hex')
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function buildDeviceAuthPayload(params: {
|
|
155
|
+
deviceId: string
|
|
156
|
+
clientId: string
|
|
157
|
+
clientMode: string
|
|
158
|
+
role: string
|
|
159
|
+
scopes: string[]
|
|
160
|
+
signedAtMs: number
|
|
161
|
+
token?: string | null
|
|
162
|
+
nonce?: string | null
|
|
163
|
+
}): string {
|
|
164
|
+
const version = params.nonce ? 'v2' : 'v1'
|
|
165
|
+
const scopes = params.scopes.join(',')
|
|
166
|
+
const token = params.token ?? ''
|
|
167
|
+
const base = [
|
|
168
|
+
version,
|
|
169
|
+
params.deviceId,
|
|
170
|
+
params.clientId,
|
|
171
|
+
params.clientMode,
|
|
172
|
+
params.role,
|
|
173
|
+
scopes,
|
|
174
|
+
String(params.signedAtMs),
|
|
175
|
+
token,
|
|
176
|
+
]
|
|
177
|
+
if (version === 'v2') base.push(params.nonce ?? '')
|
|
178
|
+
return base.join('|')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function signDevicePayload(privateKeyPem: string, payload: string): string {
|
|
182
|
+
const key = crypto.createPrivateKey(privateKeyPem)
|
|
183
|
+
const sig = crypto.sign(null, Buffer.from(payload, 'utf8'), key)
|
|
184
|
+
return base64UrlEncode(sig)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveIdentityPath(connectorId: string): string {
|
|
188
|
+
return path.join(DATA_DIR, 'openclaw', `${connectorId}-device.json`)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function persistIdentity(filePath: string, identity: DeviceIdentity) {
|
|
192
|
+
const doc: StoredIdentity = {
|
|
193
|
+
version: 1,
|
|
194
|
+
deviceId: identity.deviceId,
|
|
195
|
+
publicKeyPem: identity.publicKeyPem,
|
|
196
|
+
privateKeyPem: identity.privateKeyPem,
|
|
197
|
+
createdAtMs: Date.now(),
|
|
198
|
+
deviceToken: identity.deviceToken,
|
|
199
|
+
}
|
|
200
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
201
|
+
fs.writeFileSync(filePath, `${JSON.stringify(doc, null, 2)}\n`, { mode: 0o600 })
|
|
202
|
+
try { fs.chmodSync(filePath, 0o600) } catch { /* best effort */ }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function loadOrCreateIdentity(filePath: string): DeviceIdentity {
|
|
206
|
+
try {
|
|
207
|
+
if (fs.existsSync(filePath)) {
|
|
208
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as StoredIdentity
|
|
209
|
+
if (
|
|
210
|
+
parsed?.version === 1
|
|
211
|
+
&& typeof parsed.deviceId === 'string'
|
|
212
|
+
&& typeof parsed.publicKeyPem === 'string'
|
|
213
|
+
&& typeof parsed.privateKeyPem === 'string'
|
|
214
|
+
) {
|
|
215
|
+
const derivedDeviceId = fingerprintPublicKey(parsed.publicKeyPem)
|
|
216
|
+
const identity: DeviceIdentity = {
|
|
217
|
+
deviceId: derivedDeviceId || parsed.deviceId,
|
|
218
|
+
publicKeyPem: parsed.publicKeyPem,
|
|
219
|
+
privateKeyPem: parsed.privateKeyPem,
|
|
220
|
+
deviceToken: typeof parsed.deviceToken === 'string' && parsed.deviceToken.trim()
|
|
221
|
+
? parsed.deviceToken.trim()
|
|
222
|
+
: undefined,
|
|
223
|
+
}
|
|
224
|
+
if (identity.deviceId !== parsed.deviceId) persistIdentity(filePath, identity)
|
|
225
|
+
return identity
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// fall through and regenerate
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
|
233
|
+
const identity: DeviceIdentity = {
|
|
234
|
+
deviceId: fingerprintPublicKey(publicKey.export({ type: 'spki', format: 'pem' }).toString()),
|
|
235
|
+
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
236
|
+
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
|
|
237
|
+
}
|
|
238
|
+
persistIdentity(filePath, identity)
|
|
239
|
+
return identity
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function contentToText(content: unknown): string {
|
|
243
|
+
if (typeof content === 'string') return content
|
|
244
|
+
if (!Array.isArray(content)) return ''
|
|
245
|
+
const parts: string[] = []
|
|
246
|
+
for (const part of content) {
|
|
247
|
+
if (!part || typeof part !== 'object') continue
|
|
248
|
+
const obj = part as { text?: unknown; input_text?: unknown }
|
|
249
|
+
if (typeof obj.text === 'string') parts.push(obj.text)
|
|
250
|
+
else if (typeof obj.input_text === 'string') parts.push(obj.input_text)
|
|
251
|
+
}
|
|
252
|
+
return parts.join('\n').trim()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function getErrorMessage(err: unknown): string {
|
|
256
|
+
if (err instanceof Error && err.message) return err.message
|
|
257
|
+
return String(err)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function normalizeMimeType(value?: string | null): string | undefined {
|
|
261
|
+
if (!value) return undefined
|
|
262
|
+
const cleaned = value.split(';')[0]?.trim().toLowerCase()
|
|
263
|
+
return cleaned || undefined
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function clampNumber(value: number, min: number, max: number): number {
|
|
267
|
+
return Math.max(min, Math.min(max, value))
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function parseBooleanLike(value: unknown, fallback: boolean): boolean {
|
|
271
|
+
if (typeof value === 'boolean') return value
|
|
272
|
+
if (typeof value === 'string') {
|
|
273
|
+
const normalized = value.trim().toLowerCase()
|
|
274
|
+
if (!normalized) return fallback
|
|
275
|
+
if (['false', '0', 'off', 'no'].includes(normalized)) return false
|
|
276
|
+
if (['true', '1', 'on', 'yes'].includes(normalized)) return true
|
|
277
|
+
}
|
|
278
|
+
return fallback
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function matchesSessionKey(filter: string, actual: string): boolean {
|
|
282
|
+
const configured = filter.trim()
|
|
283
|
+
const incoming = actual.trim()
|
|
284
|
+
if (!configured) return true
|
|
285
|
+
if (!incoming) return false
|
|
286
|
+
if (configured === incoming) return true
|
|
287
|
+
|
|
288
|
+
// Support legacy short filters like "main" when OpenClaw uses keys like "agent:main:main".
|
|
289
|
+
if (!configured.includes(':') && incoming.endsWith(`:${configured}`)) return true
|
|
290
|
+
if (!incoming.includes(':') && configured.endsWith(`:${incoming}`)) return true
|
|
291
|
+
return false
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function resolveHistorySessionCandidates(rawKeys: string[]): string[] {
|
|
295
|
+
const out: string[] = []
|
|
296
|
+
const seen = new Set<string>()
|
|
297
|
+
|
|
298
|
+
const push = (value: string) => {
|
|
299
|
+
const key = value.trim()
|
|
300
|
+
if (!key || seen.has(key)) return
|
|
301
|
+
seen.add(key)
|
|
302
|
+
out.push(key)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const raw of rawKeys) {
|
|
306
|
+
const key = raw.trim()
|
|
307
|
+
if (!key) continue
|
|
308
|
+
if (!key.includes(':')) {
|
|
309
|
+
// Prefer canonical agent-session keys first when users configure short aliases like "main".
|
|
310
|
+
push(`agent:main:${key}`)
|
|
311
|
+
push(key)
|
|
312
|
+
continue
|
|
313
|
+
}
|
|
314
|
+
push(key)
|
|
315
|
+
}
|
|
316
|
+
return out
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function canonicalSessionForDuplicateKey(sessionKey: string): string {
|
|
320
|
+
const normalized = sessionKey.trim()
|
|
321
|
+
if (!normalized) return ''
|
|
322
|
+
if (!normalized.startsWith('agent:')) return normalized
|
|
323
|
+
const parts = normalized.split(':')
|
|
324
|
+
const tail = parts[parts.length - 1]
|
|
325
|
+
return tail || normalized
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function inferMimeFromFileName(fileName?: string): string | undefined {
|
|
329
|
+
if (!fileName) return undefined
|
|
330
|
+
const ext = path.extname(fileName).toLowerCase()
|
|
331
|
+
switch (ext) {
|
|
332
|
+
case '.png': return 'image/png'
|
|
333
|
+
case '.jpg':
|
|
334
|
+
case '.jpeg': return 'image/jpeg'
|
|
335
|
+
case '.gif': return 'image/gif'
|
|
336
|
+
case '.webp': return 'image/webp'
|
|
337
|
+
case '.bmp': return 'image/bmp'
|
|
338
|
+
case '.svg': return 'image/svg+xml'
|
|
339
|
+
case '.txt': return 'text/plain'
|
|
340
|
+
case '.json': return 'application/json'
|
|
341
|
+
case '.pdf': return 'application/pdf'
|
|
342
|
+
case '.zip': return 'application/zip'
|
|
343
|
+
case '.mp3': return 'audio/mpeg'
|
|
344
|
+
case '.wav': return 'audio/wav'
|
|
345
|
+
case '.ogg': return 'audio/ogg'
|
|
346
|
+
case '.mp4': return 'video/mp4'
|
|
347
|
+
case '.mov': return 'video/quicktime'
|
|
348
|
+
case '.webm': return 'video/webm'
|
|
349
|
+
default: return undefined
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function parseDataUrl(value: string): { mimeType?: string; base64: string } | null {
|
|
354
|
+
const match = /^data:([^;,]+)?;base64,([A-Za-z0-9+/=\s]+)$/i.exec(value.trim())
|
|
355
|
+
if (!match) return null
|
|
356
|
+
const mimeType = normalizeMimeType(match[1])
|
|
357
|
+
const base64 = match[2].replace(/\s+/g, '')
|
|
358
|
+
if (!base64) return null
|
|
359
|
+
return { mimeType, base64 }
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function isHttpUrl(value: string): boolean {
|
|
363
|
+
try {
|
|
364
|
+
const parsed = new URL(value)
|
|
365
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
|
366
|
+
} catch {
|
|
367
|
+
return false
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function deriveFileNameFromUrl(value: string): string | undefined {
|
|
372
|
+
try {
|
|
373
|
+
const parsed = new URL(value)
|
|
374
|
+
const fileName = path.basename(parsed.pathname || '')
|
|
375
|
+
return fileName && fileName !== '/' ? fileName : undefined
|
|
376
|
+
} catch {
|
|
377
|
+
return undefined
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function buildAttachmentFromBuffer(buffer: Buffer, opts: {
|
|
382
|
+
mimeType?: string
|
|
383
|
+
fileName?: string
|
|
384
|
+
}): OutboundAttachment {
|
|
385
|
+
if (buffer.byteLength > MAX_INLINE_ATTACHMENT_BYTES) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
`OpenClaw attachment exceeds size limit (${buffer.byteLength} > ${MAX_INLINE_ATTACHMENT_BYTES} bytes)`,
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const fileName = opts.fileName?.trim() || undefined
|
|
392
|
+
const mimeType = (
|
|
393
|
+
normalizeMimeType(opts.mimeType)
|
|
394
|
+
|| inferMimeFromFileName(fileName)
|
|
395
|
+
|| 'application/octet-stream'
|
|
396
|
+
)
|
|
397
|
+
const type: OutboundAttachment['type'] = mimeType.startsWith('image/') ? 'image' : 'file'
|
|
398
|
+
return {
|
|
399
|
+
type,
|
|
400
|
+
mimeType,
|
|
401
|
+
fileName,
|
|
402
|
+
content: buffer.toString('base64'),
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async function buildOutboundAttachments(options?: OutboundSendOptions): Promise<{
|
|
407
|
+
attachments: OutboundAttachment[]
|
|
408
|
+
fallbackUrl: string | null
|
|
409
|
+
}> {
|
|
410
|
+
if (!options) return { attachments: [], fallbackUrl: null }
|
|
411
|
+
|
|
412
|
+
// Explicit local file path gets first priority.
|
|
413
|
+
if (options.mediaPath) {
|
|
414
|
+
const filePath = options.mediaPath
|
|
415
|
+
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`)
|
|
416
|
+
const content = fs.readFileSync(filePath)
|
|
417
|
+
const attachment = buildAttachmentFromBuffer(content, {
|
|
418
|
+
mimeType: options.mimeType,
|
|
419
|
+
fileName: options.fileName || path.basename(filePath),
|
|
420
|
+
})
|
|
421
|
+
return { attachments: [attachment], fallbackUrl: null }
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const mediaUrl = options.imageUrl || options.fileUrl
|
|
425
|
+
if (!mediaUrl) return { attachments: [], fallbackUrl: null }
|
|
426
|
+
|
|
427
|
+
// Data URL can be sent as a true attachment.
|
|
428
|
+
const data = parseDataUrl(mediaUrl)
|
|
429
|
+
if (data) {
|
|
430
|
+
const attachment = buildAttachmentFromBuffer(Buffer.from(data.base64, 'base64'), {
|
|
431
|
+
mimeType: options.mimeType || data.mimeType,
|
|
432
|
+
fileName: options.fileName,
|
|
433
|
+
})
|
|
434
|
+
return { attachments: [attachment], fallbackUrl: null }
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// For regular URLs, attempt inline fetch so OpenClaw receives real attachment bytes.
|
|
438
|
+
if (isHttpUrl(mediaUrl)) {
|
|
439
|
+
try {
|
|
440
|
+
const response = await fetch(mediaUrl)
|
|
441
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
442
|
+
const arrayBuffer = await response.arrayBuffer()
|
|
443
|
+
const attachment = buildAttachmentFromBuffer(Buffer.from(arrayBuffer), {
|
|
444
|
+
mimeType: options.mimeType || response.headers.get('content-type') || undefined,
|
|
445
|
+
fileName: options.fileName || deriveFileNameFromUrl(mediaUrl),
|
|
446
|
+
})
|
|
447
|
+
return { attachments: [attachment], fallbackUrl: null }
|
|
448
|
+
} catch (err) {
|
|
449
|
+
console.warn(`[openclaw] Failed to inline media URL, falling back to link send: ${getErrorMessage(err)}`)
|
|
450
|
+
return { attachments: [], fallbackUrl: mediaUrl }
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return { attachments: [], fallbackUrl: null }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function extractInbound(payload: ChatEventPayload): InboundMessage | null {
|
|
458
|
+
if (!payload || typeof payload !== 'object') return null
|
|
459
|
+
if (payload.state && payload.state !== 'final') return null
|
|
460
|
+
|
|
461
|
+
const message = payload.message || {}
|
|
462
|
+
const roleRaw = typeof message.role === 'string' ? message.role.toLowerCase() : ''
|
|
463
|
+
const text = (
|
|
464
|
+
(typeof message.text === 'string' ? message.text : '')
|
|
465
|
+
|| contentToText(message.content)
|
|
466
|
+
|| (typeof payload.text === 'string' ? payload.text : '')
|
|
467
|
+
).trim()
|
|
468
|
+
|
|
469
|
+
if (!text) return null
|
|
470
|
+
if (roleRaw && roleRaw !== 'user') return null
|
|
471
|
+
|
|
472
|
+
const sessionKey = (typeof payload.sessionKey === 'string' && payload.sessionKey.trim())
|
|
473
|
+
? payload.sessionKey.trim()
|
|
474
|
+
: DEFAULT_SESSION_KEY
|
|
475
|
+
|
|
476
|
+
const senderId = (
|
|
477
|
+
message.senderId
|
|
478
|
+
|| message.sender
|
|
479
|
+
|| payload.senderId
|
|
480
|
+
|| payload.sender
|
|
481
|
+
|| 'unknown'
|
|
482
|
+
).toString()
|
|
483
|
+
|
|
484
|
+
const senderName = (
|
|
485
|
+
message.senderName
|
|
486
|
+
|| message.sender
|
|
487
|
+
|| payload.senderName
|
|
488
|
+
|| payload.sender
|
|
489
|
+
|| 'User'
|
|
490
|
+
).toString()
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
platform: 'openclaw',
|
|
494
|
+
channelId: sessionKey,
|
|
495
|
+
channelName: sessionKey,
|
|
496
|
+
senderId,
|
|
497
|
+
senderName,
|
|
498
|
+
text,
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function extractInboundFromHistory(
|
|
503
|
+
sessionKey: string,
|
|
504
|
+
message: ChatHistoryMessage,
|
|
505
|
+
): { inbound: InboundMessage; dedupeKey: string } | null {
|
|
506
|
+
const roleRaw = typeof message.role === 'string' ? message.role.trim().toLowerCase() : ''
|
|
507
|
+
if (roleRaw !== 'user') return null
|
|
508
|
+
|
|
509
|
+
const text = contentToText(message.content).trim()
|
|
510
|
+
if (!text) return null
|
|
511
|
+
|
|
512
|
+
const senderId = (
|
|
513
|
+
(typeof message.senderId === 'string' && message.senderId.trim())
|
|
514
|
+
|| (typeof message.sender === 'string' && message.sender.trim())
|
|
515
|
+
|| 'unknown'
|
|
516
|
+
).toString()
|
|
517
|
+
|
|
518
|
+
const senderName = (
|
|
519
|
+
(typeof message.senderName === 'string' && message.senderName.trim())
|
|
520
|
+
|| (typeof message.sender === 'string' && message.sender.trim())
|
|
521
|
+
|| 'User'
|
|
522
|
+
).toString()
|
|
523
|
+
|
|
524
|
+
const rawTs = Number(message.timestamp)
|
|
525
|
+
const timestamp = Number.isFinite(rawTs) && rawTs > 0 ? Math.round(rawTs) : 0
|
|
526
|
+
const textHash = crypto.createHash('sha1').update(text).digest('hex').slice(0, 16)
|
|
527
|
+
const dedupeKey = `${sessionKey}:${timestamp}:${textHash}`
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
inbound: {
|
|
531
|
+
platform: 'openclaw',
|
|
532
|
+
channelId: sessionKey,
|
|
533
|
+
channelName: sessionKey,
|
|
534
|
+
senderId,
|
|
535
|
+
senderName,
|
|
536
|
+
text,
|
|
537
|
+
},
|
|
538
|
+
dedupeKey,
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function rememberSeenEntry(set: Set<string>, key: string, maxEntries: number): boolean {
|
|
543
|
+
if (!key.trim()) return false
|
|
544
|
+
if (set.has(key)) return false
|
|
545
|
+
set.add(key)
|
|
546
|
+
if (set.size > maxEntries) {
|
|
547
|
+
const first = set.values().next().value
|
|
548
|
+
if (first) set.delete(first)
|
|
549
|
+
}
|
|
550
|
+
return true
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const openclaw: PlatformConnector = {
|
|
554
|
+
async start(connector, botToken, onMessage): Promise<ConnectorInstance> {
|
|
555
|
+
const rawUrl = (connector.config.wsUrl || DEFAULT_WS_URL || '').trim()
|
|
556
|
+
const wsUrl = rawUrl || DEFAULT_WS_URL
|
|
557
|
+
if (!isSecureWsUrl(wsUrl)) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
`Insecure OpenClaw WebSocket URL: "${wsUrl}". Use wss:// for remote hosts, or ws:// only on localhost/127.x/::1.`,
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const defaultSessionKey = (
|
|
564
|
+
typeof connector.config.sessionKey === 'string' && connector.config.sessionKey.trim()
|
|
565
|
+
? connector.config.sessionKey.trim()
|
|
566
|
+
: DEFAULT_SESSION_KEY
|
|
567
|
+
)
|
|
568
|
+
const configuredSessionFilter = typeof connector.config.sessionKey === 'string'
|
|
569
|
+
? connector.config.sessionKey.trim()
|
|
570
|
+
: ''
|
|
571
|
+
const historyPollEnabled = parseBooleanLike(
|
|
572
|
+
(connector.config as Record<string, unknown>).historyPoll,
|
|
573
|
+
true,
|
|
574
|
+
)
|
|
575
|
+
const rawHistoryPollMs = Number((connector.config as Record<string, unknown>).historyPollMs)
|
|
576
|
+
const historyPollMs = Number.isFinite(rawHistoryPollMs) && rawHistoryPollMs > 0
|
|
577
|
+
? clampNumber(Math.round(rawHistoryPollMs), MIN_CHAT_HISTORY_POLL_MS, MAX_CHAT_HISTORY_POLL_MS)
|
|
578
|
+
: DEFAULT_CHAT_HISTORY_POLL_MS
|
|
579
|
+
const rawHistoryLimit = Number((connector.config as Record<string, unknown>).historyLimit)
|
|
580
|
+
const historyLimit = Number.isFinite(rawHistoryLimit) && rawHistoryLimit > 0
|
|
581
|
+
? clampNumber(Math.round(rawHistoryLimit), MIN_CHAT_HISTORY_LIMIT, MAX_CHAT_HISTORY_LIMIT)
|
|
582
|
+
: DEFAULT_CHAT_HISTORY_LIMIT
|
|
583
|
+
const configuredHistorySessionKey = typeof (connector.config as Record<string, unknown>).historySessionKey === 'string'
|
|
584
|
+
? ((connector.config as Record<string, unknown>).historySessionKey as string).trim()
|
|
585
|
+
: ''
|
|
586
|
+
const historySessionCandidates = resolveHistorySessionCandidates([
|
|
587
|
+
configuredHistorySessionKey,
|
|
588
|
+
configuredSessionFilter,
|
|
589
|
+
defaultSessionKey,
|
|
590
|
+
])
|
|
591
|
+
|
|
592
|
+
const clientId = 'gateway-client'
|
|
593
|
+
const clientMode = 'backend'
|
|
594
|
+
const clientDisplayName = (
|
|
595
|
+
typeof connector.config.clientDisplayName === 'string' && connector.config.clientDisplayName.trim()
|
|
596
|
+
? connector.config.clientDisplayName.trim()
|
|
597
|
+
: typeof connector.config.nodeId === 'string' && connector.config.nodeId.trim()
|
|
598
|
+
? connector.config.nodeId.trim()
|
|
599
|
+
: connector.name
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
const rawScopes: unknown = (connector.config as Record<string, unknown>).scopes
|
|
603
|
+
const configuredScopes = typeof rawScopes === 'string'
|
|
604
|
+
? rawScopes.split(',').map((s: string) => s.trim()).filter(Boolean)
|
|
605
|
+
: Array.isArray(rawScopes)
|
|
606
|
+
? rawScopes.map((s: unknown) => String(s).trim()).filter(Boolean)
|
|
607
|
+
: []
|
|
608
|
+
const scopes = configuredScopes.length > 0 ? configuredScopes : ['operator.read', 'operator.write']
|
|
609
|
+
const rawTickInterval = Number((connector.config as Record<string, unknown>).tickIntervalMs)
|
|
610
|
+
const configuredTickIntervalMs = Number.isFinite(rawTickInterval) && rawTickInterval > 0
|
|
611
|
+
? Math.round(rawTickInterval)
|
|
612
|
+
: null
|
|
613
|
+
const rawTickWatchdog = String(
|
|
614
|
+
(connector.config as Record<string, unknown>).tickWatchdog ?? 'true',
|
|
615
|
+
).trim().toLowerCase()
|
|
616
|
+
const tickWatchdogEnabled = rawTickWatchdog !== 'false' && rawTickWatchdog !== '0' && rawTickWatchdog !== 'off'
|
|
617
|
+
|
|
618
|
+
const configuredRole = typeof connector.config.role === 'string'
|
|
619
|
+
? connector.config.role.trim()
|
|
620
|
+
: ''
|
|
621
|
+
const role = configuredRole || 'operator'
|
|
622
|
+
const identityPath = resolveIdentityPath(connector.id)
|
|
623
|
+
let identity = loadOrCreateIdentity(identityPath)
|
|
624
|
+
|
|
625
|
+
let ws: WebSocket | null = null
|
|
626
|
+
let stopped = false
|
|
627
|
+
let reconnectAttempt = 0
|
|
628
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
629
|
+
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
|
630
|
+
let connectHelloTimer: ReturnType<typeof setTimeout> | null = null
|
|
631
|
+
let tickWatchdogTimer: ReturnType<typeof setInterval> | null = null
|
|
632
|
+
let historyPollTimer: ReturnType<typeof setInterval> | null = null
|
|
633
|
+
let historyPollInFlight = false
|
|
634
|
+
let historyPollingUnsupported = false
|
|
635
|
+
let connectNonce: string | null = null
|
|
636
|
+
let connectSent = false
|
|
637
|
+
let connected = false
|
|
638
|
+
let lastTickAtMs = 0
|
|
639
|
+
let tickIntervalMs = configuredTickIntervalMs ?? DEFAULT_TICK_INTERVAL_MS
|
|
640
|
+
|
|
641
|
+
const pending = new Map<string, PendingRequest>()
|
|
642
|
+
const seenInbound = new Set<string>()
|
|
643
|
+
const seenHistoryMessages = new Set<string>()
|
|
644
|
+
const historyWarmSessions = new Set<string>()
|
|
645
|
+
const recentInboundByText = new Map<string, number>()
|
|
646
|
+
const historyErrorLogBySession = new Map<string, number>()
|
|
647
|
+
|
|
648
|
+
function clearPending(reason: string) {
|
|
649
|
+
for (const [id, p] of pending) {
|
|
650
|
+
clearTimeout(p.timer)
|
|
651
|
+
p.reject(new Error(reason))
|
|
652
|
+
pending.delete(id)
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function clearReconnectTimer() {
|
|
657
|
+
if (reconnectTimer) {
|
|
658
|
+
clearTimeout(reconnectTimer)
|
|
659
|
+
reconnectTimer = null
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function clearConnectTimer() {
|
|
664
|
+
if (connectTimer) {
|
|
665
|
+
clearTimeout(connectTimer)
|
|
666
|
+
connectTimer = null
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function clearConnectHelloTimer() {
|
|
671
|
+
if (connectHelloTimer) {
|
|
672
|
+
clearTimeout(connectHelloTimer)
|
|
673
|
+
connectHelloTimer = null
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function clearTickWatchdogTimer() {
|
|
678
|
+
if (tickWatchdogTimer) {
|
|
679
|
+
clearInterval(tickWatchdogTimer)
|
|
680
|
+
tickWatchdogTimer = null
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function clearHistoryPollTimer() {
|
|
685
|
+
if (historyPollTimer) {
|
|
686
|
+
clearInterval(historyPollTimer)
|
|
687
|
+
historyPollTimer = null
|
|
688
|
+
}
|
|
689
|
+
historyPollInFlight = false
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function startTickWatchdog() {
|
|
693
|
+
clearTickWatchdogTimer()
|
|
694
|
+
if (!tickWatchdogEnabled || tickIntervalMs <= 0) return
|
|
695
|
+
|
|
696
|
+
const toleranceMs = Math.max(
|
|
697
|
+
3_000,
|
|
698
|
+
Math.round(tickIntervalMs * TICK_MISS_TOLERANCE_MULTIPLIER),
|
|
699
|
+
)
|
|
700
|
+
const pollMs = Math.max(
|
|
701
|
+
MIN_TICK_WATCHDOG_POLL_MS,
|
|
702
|
+
Math.min(MAX_TICK_WATCHDOG_POLL_MS, Math.round(toleranceMs / 3)),
|
|
703
|
+
)
|
|
704
|
+
lastTickAtMs = Date.now()
|
|
705
|
+
tickWatchdogTimer = setInterval(() => {
|
|
706
|
+
if (stopped || !connected) return
|
|
707
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
708
|
+
if (lastTickAtMs <= 0) return
|
|
709
|
+
const delta = Date.now() - lastTickAtMs
|
|
710
|
+
if (delta <= toleranceMs) return
|
|
711
|
+
console.error(
|
|
712
|
+
`[openclaw] Tick missed (${delta}ms > ${toleranceMs}ms), forcing reconnect`,
|
|
713
|
+
)
|
|
714
|
+
try { ws.close(4000, 'tick missed') } catch { /* ignore */ }
|
|
715
|
+
}, pollMs)
|
|
716
|
+
// Do not keep the process alive solely for health checks.
|
|
717
|
+
tickWatchdogTimer.unref?.()
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function pruneRecentInbound(now: number) {
|
|
721
|
+
for (const [key, ts] of recentInboundByText) {
|
|
722
|
+
if (now - ts > RECENT_HISTORY_DUPLICATE_WINDOW_MS) {
|
|
723
|
+
recentInboundByText.delete(key)
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function markRecentInbound(inbound: InboundMessage, now: number) {
|
|
729
|
+
pruneRecentInbound(now)
|
|
730
|
+
const canonicalSession = canonicalSessionForDuplicateKey(inbound.channelId)
|
|
731
|
+
recentInboundByText.set(`${canonicalSession}:${inbound.text}`, now)
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function hasRecentInboundDuplicate(inbound: InboundMessage, now: number): boolean {
|
|
735
|
+
pruneRecentInbound(now)
|
|
736
|
+
const canonicalSession = canonicalSessionForDuplicateKey(inbound.channelId)
|
|
737
|
+
const ts = recentInboundByText.get(`${canonicalSession}:${inbound.text}`)
|
|
738
|
+
return typeof ts === 'number' && now - ts <= RECENT_HISTORY_DUPLICATE_WINDOW_MS
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function maybeLogHistoryError(sessionKey: string, message: string) {
|
|
742
|
+
const now = Date.now()
|
|
743
|
+
const previous = historyErrorLogBySession.get(sessionKey) || 0
|
|
744
|
+
if (now - previous < HISTORY_ERROR_LOG_INTERVAL_MS) return
|
|
745
|
+
historyErrorLogBySession.set(sessionKey, now)
|
|
746
|
+
console.warn(`[openclaw] chat.history poll failed for "${sessionKey}": ${message}`)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function cleanupSocket() {
|
|
750
|
+
clearConnectTimer()
|
|
751
|
+
clearConnectHelloTimer()
|
|
752
|
+
clearReconnectTimer()
|
|
753
|
+
clearTickWatchdogTimer()
|
|
754
|
+
clearHistoryPollTimer()
|
|
755
|
+
clearPending('openclaw socket closed')
|
|
756
|
+
if (ws) {
|
|
757
|
+
try { ws.close() } catch { /* ignore */ }
|
|
758
|
+
ws = null
|
|
759
|
+
}
|
|
760
|
+
connectSent = false
|
|
761
|
+
connected = false
|
|
762
|
+
connectNonce = null
|
|
763
|
+
lastTickAtMs = 0
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function scheduleReconnect() {
|
|
767
|
+
if (stopped) return
|
|
768
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS)
|
|
769
|
+
reconnectAttempt++
|
|
770
|
+
console.log(`[openclaw] Reconnecting in ${delay}ms (attempt ${reconnectAttempt})`)
|
|
771
|
+
clearReconnectTimer()
|
|
772
|
+
reconnectTimer = setTimeout(() => connect(), delay)
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function sendRaw(frame: GatewayFrame): boolean {
|
|
776
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return false
|
|
777
|
+
try {
|
|
778
|
+
ws.send(serializeGatewayFrame(frame))
|
|
779
|
+
return true
|
|
780
|
+
} catch {
|
|
781
|
+
return false
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function rpcRequest(method: string, params?: Record<string, unknown>): Promise<unknown> {
|
|
786
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
787
|
+
return Promise.reject(new Error('openclaw not connected'))
|
|
788
|
+
}
|
|
789
|
+
const id = crypto.randomUUID()
|
|
790
|
+
const frame = createGatewayRequestFrame(id, method, params)
|
|
791
|
+
if (!sendRaw(frame)) {
|
|
792
|
+
return Promise.reject(new Error(`failed to send request: ${method}`))
|
|
793
|
+
}
|
|
794
|
+
return new Promise((resolve, reject) => {
|
|
795
|
+
const timer = setTimeout(() => {
|
|
796
|
+
pending.delete(id)
|
|
797
|
+
reject(new Error(`openclaw rpc timeout: ${method}`))
|
|
798
|
+
}, RPC_TIMEOUT_MS)
|
|
799
|
+
pending.set(id, { method, resolve, reject, timer })
|
|
800
|
+
})
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function sendChat(sessionKey: string, text: string, options?: OutboundSendOptions): Promise<void> {
|
|
804
|
+
const key = (sessionKey || '').trim() || defaultSessionKey
|
|
805
|
+
const outgoing = text.trim()
|
|
806
|
+
const caption = options?.caption?.trim() || ''
|
|
807
|
+
const { attachments, fallbackUrl } = await buildOutboundAttachments(options)
|
|
808
|
+
|
|
809
|
+
let message = outgoing || caption
|
|
810
|
+
if (!message && attachments.length > 0) message = 'See attached.'
|
|
811
|
+
if (fallbackUrl) {
|
|
812
|
+
message = message ? `${message}\n${fallbackUrl}` : fallbackUrl
|
|
813
|
+
}
|
|
814
|
+
if (!message && attachments.length === 0) return
|
|
815
|
+
|
|
816
|
+
const params: Record<string, unknown> = {
|
|
817
|
+
sessionKey: key,
|
|
818
|
+
message,
|
|
819
|
+
idempotencyKey: crypto.randomUUID(),
|
|
820
|
+
}
|
|
821
|
+
if (attachments.length > 0) params.attachments = attachments
|
|
822
|
+
await rpcRequest('chat.send', params)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function persistIdentityToken(token?: string) {
|
|
826
|
+
const normalized = typeof token === 'string' && token.trim() ? token.trim() : undefined
|
|
827
|
+
if (identity.deviceToken === normalized) return
|
|
828
|
+
identity = { ...identity, deviceToken: normalized }
|
|
829
|
+
persistIdentity(identityPath, identity)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function clearStaleTokenIfNeeded(reason?: string) {
|
|
833
|
+
const lowerReason = (reason || '').toLowerCase()
|
|
834
|
+
if (!lowerReason.includes('device token mismatch')) return
|
|
835
|
+
if (!identity.deviceToken) return
|
|
836
|
+
console.warn('[openclaw] Clearing stale stored device token after mismatch')
|
|
837
|
+
persistIdentityToken(undefined)
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function sendConnect() {
|
|
841
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
|
842
|
+
if (connectSent) return
|
|
843
|
+
connectSent = true
|
|
844
|
+
clearConnectTimer()
|
|
845
|
+
|
|
846
|
+
const configuredToken = (botToken || connector.config.token || '').trim()
|
|
847
|
+
const authToken = configuredToken || identity.deviceToken || undefined
|
|
848
|
+
const auth = authToken ? { token: authToken } : undefined
|
|
849
|
+
const signedAt = Date.now()
|
|
850
|
+
const nonce = connectNonce || undefined
|
|
851
|
+
|
|
852
|
+
const payload = buildDeviceAuthPayload({
|
|
853
|
+
deviceId: identity.deviceId,
|
|
854
|
+
clientId,
|
|
855
|
+
clientMode,
|
|
856
|
+
role,
|
|
857
|
+
scopes,
|
|
858
|
+
signedAtMs: signedAt,
|
|
859
|
+
token: authToken ?? null,
|
|
860
|
+
nonce,
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
const connectParams = {
|
|
864
|
+
minProtocol: PROTOCOL_VERSION,
|
|
865
|
+
maxProtocol: PROTOCOL_VERSION,
|
|
866
|
+
client: {
|
|
867
|
+
id: clientId,
|
|
868
|
+
displayName: clientDisplayName,
|
|
869
|
+
version: 'swarmclaw',
|
|
870
|
+
platform: process.platform,
|
|
871
|
+
mode: clientMode,
|
|
872
|
+
instanceId: connector.id,
|
|
873
|
+
},
|
|
874
|
+
role,
|
|
875
|
+
scopes,
|
|
876
|
+
caps: [],
|
|
877
|
+
commands: [],
|
|
878
|
+
permissions: {},
|
|
879
|
+
auth,
|
|
880
|
+
locale: 'en-US',
|
|
881
|
+
userAgent: 'swarmclaw-openclaw-connector/1.0',
|
|
882
|
+
device: {
|
|
883
|
+
id: identity.deviceId,
|
|
884
|
+
publicKey: base64UrlEncode(derivePublicKeyRaw(identity.publicKeyPem)),
|
|
885
|
+
signature: signDevicePayload(identity.privateKeyPem, payload),
|
|
886
|
+
signedAt,
|
|
887
|
+
nonce,
|
|
888
|
+
},
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
void rpcRequest('connect', connectParams)
|
|
892
|
+
.then((hello) => {
|
|
893
|
+
clearConnectHelloTimer()
|
|
894
|
+
connected = true
|
|
895
|
+
reconnectAttempt = 0
|
|
896
|
+
const helloObj = hello && typeof hello === 'object'
|
|
897
|
+
? (hello as {
|
|
898
|
+
auth?: { deviceToken?: unknown }
|
|
899
|
+
policy?: { tickIntervalMs?: unknown }
|
|
900
|
+
})
|
|
901
|
+
: null
|
|
902
|
+
const deviceToken = helloObj?.auth?.deviceToken
|
|
903
|
+
if (typeof deviceToken === 'string' && deviceToken.trim()) {
|
|
904
|
+
persistIdentityToken(deviceToken)
|
|
905
|
+
}
|
|
906
|
+
const policyTick = Number(helloObj?.policy?.tickIntervalMs)
|
|
907
|
+
if (Number.isFinite(policyTick) && policyTick > 0) {
|
|
908
|
+
tickIntervalMs = Math.round(policyTick)
|
|
909
|
+
} else if (configuredTickIntervalMs) {
|
|
910
|
+
tickIntervalMs = configuredTickIntervalMs
|
|
911
|
+
} else {
|
|
912
|
+
tickIntervalMs = DEFAULT_TICK_INTERVAL_MS
|
|
913
|
+
}
|
|
914
|
+
if (tickWatchdogEnabled) startTickWatchdog()
|
|
915
|
+
startHistoryPoller()
|
|
916
|
+
console.log(`[openclaw] Connected + authenticated (${wsUrl})`)
|
|
917
|
+
})
|
|
918
|
+
.catch((err: unknown) => {
|
|
919
|
+
clearConnectHelloTimer()
|
|
920
|
+
console.error(`[openclaw] Connect handshake failed: ${getErrorMessage(err)}`)
|
|
921
|
+
try { ws?.close(1008, 'connect failed') } catch { /* ignore */ }
|
|
922
|
+
})
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
async function routeInbound(
|
|
926
|
+
inbound: InboundMessage,
|
|
927
|
+
dedupeKey: string,
|
|
928
|
+
source: 'event' | 'history',
|
|
929
|
+
) {
|
|
930
|
+
if (!matchesSessionKey(configuredSessionFilter, inbound.channelId)) return
|
|
931
|
+
if (!rememberSeenEntry(seenInbound, dedupeKey, MAX_SEEN_CHAT_EVENTS)) return
|
|
932
|
+
|
|
933
|
+
const now = Date.now()
|
|
934
|
+
if (source === 'history' && hasRecentInboundDuplicate(inbound, now)) return
|
|
935
|
+
markRecentInbound(inbound, now)
|
|
936
|
+
|
|
937
|
+
try {
|
|
938
|
+
const response = await onMessage(inbound)
|
|
939
|
+
if (!isNoMessage(response)) await sendChat(inbound.channelId, response)
|
|
940
|
+
} catch (err: unknown) {
|
|
941
|
+
const message = getErrorMessage(err)
|
|
942
|
+
console.error('[openclaw] Error routing inbound chat event:', message)
|
|
943
|
+
await sendChat(inbound.channelId, `[Error] ${message}`)
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function pollHistorySession(sessionKey: string) {
|
|
948
|
+
const raw = await rpcRequest('chat.history', {
|
|
949
|
+
sessionKey,
|
|
950
|
+
limit: historyLimit,
|
|
951
|
+
})
|
|
952
|
+
const payload = raw && typeof raw === 'object'
|
|
953
|
+
? (raw as ChatHistoryPayload)
|
|
954
|
+
: null
|
|
955
|
+
const rawMessages = Array.isArray(payload?.messages)
|
|
956
|
+
? payload.messages as unknown[]
|
|
957
|
+
: []
|
|
958
|
+
|
|
959
|
+
const extracted: Array<{ inbound: InboundMessage; dedupeKey: string }> = []
|
|
960
|
+
for (const rawMessage of rawMessages) {
|
|
961
|
+
if (!rawMessage || typeof rawMessage !== 'object') continue
|
|
962
|
+
const parsed = extractInboundFromHistory(sessionKey, rawMessage as ChatHistoryMessage)
|
|
963
|
+
if (!parsed) continue
|
|
964
|
+
extracted.push(parsed)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (!historyWarmSessions.has(sessionKey)) {
|
|
968
|
+
historyWarmSessions.add(sessionKey)
|
|
969
|
+
for (const item of extracted) {
|
|
970
|
+
rememberSeenEntry(seenHistoryMessages, item.dedupeKey, MAX_SEEN_HISTORY_MESSAGES)
|
|
971
|
+
}
|
|
972
|
+
return
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
for (const item of extracted) {
|
|
976
|
+
if (!rememberSeenEntry(seenHistoryMessages, item.dedupeKey, MAX_SEEN_HISTORY_MESSAGES)) {
|
|
977
|
+
continue
|
|
978
|
+
}
|
|
979
|
+
await routeInbound(item.inbound, `history:${item.dedupeKey}`, 'history')
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function pollChatHistory() {
|
|
984
|
+
if (stopped || !connected) return
|
|
985
|
+
if (historyPollingUnsupported) return
|
|
986
|
+
if (!historyPollEnabled) return
|
|
987
|
+
if (historySessionCandidates.length === 0) return
|
|
988
|
+
if (historyPollInFlight) return
|
|
989
|
+
|
|
990
|
+
historyPollInFlight = true
|
|
991
|
+
try {
|
|
992
|
+
for (const sessionKey of historySessionCandidates) {
|
|
993
|
+
try {
|
|
994
|
+
await pollHistorySession(sessionKey)
|
|
995
|
+
} catch (err: unknown) {
|
|
996
|
+
const message = getErrorMessage(err)
|
|
997
|
+
const lowered = message.toLowerCase()
|
|
998
|
+
if (
|
|
999
|
+
lowered.includes('unknown method')
|
|
1000
|
+
|| lowered.includes('method not found')
|
|
1001
|
+
|| lowered.includes('not implemented')
|
|
1002
|
+
) {
|
|
1003
|
+
historyPollingUnsupported = true
|
|
1004
|
+
clearHistoryPollTimer()
|
|
1005
|
+
console.warn('[openclaw] chat.history is unavailable; disabling history polling fallback')
|
|
1006
|
+
return
|
|
1007
|
+
}
|
|
1008
|
+
maybeLogHistoryError(sessionKey, message)
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
} finally {
|
|
1012
|
+
historyPollInFlight = false
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function startHistoryPoller() {
|
|
1017
|
+
clearHistoryPollTimer()
|
|
1018
|
+
if (!historyPollEnabled) return
|
|
1019
|
+
if (historySessionCandidates.length === 0) return
|
|
1020
|
+
if (historyPollingUnsupported) return
|
|
1021
|
+
void pollChatHistory()
|
|
1022
|
+
historyPollTimer = setInterval(() => {
|
|
1023
|
+
void pollChatHistory()
|
|
1024
|
+
}, historyPollMs)
|
|
1025
|
+
historyPollTimer.unref?.()
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
async function handleChatEvent(payload: ChatEventPayload) {
|
|
1029
|
+
const inbound = extractInbound(payload)
|
|
1030
|
+
if (!inbound) return
|
|
1031
|
+
|
|
1032
|
+
const dedupeKey = `event:${payload.runId || ''}:${payload.seq || ''}:${inbound.channelId}:${inbound.text}`
|
|
1033
|
+
await routeInbound(inbound, dedupeKey, 'event')
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function connect() {
|
|
1037
|
+
if (stopped) return
|
|
1038
|
+
cleanupSocket()
|
|
1039
|
+
console.log(`[openclaw] Connecting to ${wsUrl}`)
|
|
1040
|
+
ws = new WebSocket(wsUrl)
|
|
1041
|
+
|
|
1042
|
+
ws.onopen = () => {
|
|
1043
|
+
console.log(`[openclaw] Socket open: ${wsUrl}`)
|
|
1044
|
+
connectSent = false
|
|
1045
|
+
connected = false
|
|
1046
|
+
lastTickAtMs = 0
|
|
1047
|
+
clearConnectHelloTimer()
|
|
1048
|
+
connectHelloTimer = setTimeout(() => {
|
|
1049
|
+
if (stopped || connected) return
|
|
1050
|
+
console.warn(`[openclaw] Connect handshake timed out after ${CONNECT_HELLO_TIMEOUT_MS}ms`)
|
|
1051
|
+
try { ws?.close(4001, 'connect timeout') } catch { /* ignore */ }
|
|
1052
|
+
}, CONNECT_HELLO_TIMEOUT_MS)
|
|
1053
|
+
connectHelloTimer.unref?.()
|
|
1054
|
+
connectTimer = setTimeout(() => sendConnect(), CONNECT_DELAY_FALLBACK_MS)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
ws.onmessage = (event) => {
|
|
1058
|
+
const frame = parseGatewayFrame(event.data)
|
|
1059
|
+
if (!frame) {
|
|
1060
|
+
console.warn('[openclaw] Ignoring malformed gateway frame')
|
|
1061
|
+
return
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
if (frame.type === 'event') {
|
|
1065
|
+
if (frame.event === 'connect.challenge') {
|
|
1066
|
+
const payload = frame.payload && typeof frame.payload === 'object'
|
|
1067
|
+
? (frame.payload as { nonce?: unknown })
|
|
1068
|
+
: null
|
|
1069
|
+
const nonce = payload?.nonce
|
|
1070
|
+
if (typeof nonce === 'string' && nonce.trim()) connectNonce = nonce
|
|
1071
|
+
sendConnect()
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
if (frame.event === 'chat') {
|
|
1075
|
+
void handleChatEvent((frame.payload || {}) as ChatEventPayload)
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
1078
|
+
if (frame.event === 'tick') {
|
|
1079
|
+
lastTickAtMs = Date.now()
|
|
1080
|
+
return
|
|
1081
|
+
}
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (frame.type === 'res') {
|
|
1086
|
+
const responseFrame = frame as GatewayResponseFrame
|
|
1087
|
+
const req = pending.get(responseFrame.id)
|
|
1088
|
+
if (!req) return
|
|
1089
|
+
pending.delete(responseFrame.id)
|
|
1090
|
+
clearTimeout(req.timer)
|
|
1091
|
+
if (responseFrame.ok === true) req.resolve(responseFrame.payload)
|
|
1092
|
+
else {
|
|
1093
|
+
const errorMessage = typeof responseFrame.error?.message === 'string'
|
|
1094
|
+
? responseFrame.error.message
|
|
1095
|
+
: `${req.method} failed`
|
|
1096
|
+
req.reject(new Error(errorMessage))
|
|
1097
|
+
}
|
|
1098
|
+
return
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
ws.onclose = (event) => {
|
|
1103
|
+
const reason = event.reason || 'none'
|
|
1104
|
+
console.log(`[openclaw] Disconnected (code=${event.code}, reason=${reason})`)
|
|
1105
|
+
clearStaleTokenIfNeeded(reason)
|
|
1106
|
+
cleanupSocket()
|
|
1107
|
+
if (!stopped) scheduleReconnect()
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
ws.onerror = () => {
|
|
1111
|
+
console.error('[openclaw] WebSocket error')
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
connect()
|
|
1116
|
+
|
|
1117
|
+
return {
|
|
1118
|
+
connector,
|
|
1119
|
+
async sendMessage(channelId, text, options) {
|
|
1120
|
+
if (!connected) throw new Error('openclaw connector is not connected')
|
|
1121
|
+
await sendChat(channelId || defaultSessionKey, text, options)
|
|
1122
|
+
},
|
|
1123
|
+
async stop() {
|
|
1124
|
+
stopped = true
|
|
1125
|
+
cleanupSocket()
|
|
1126
|
+
console.log('[openclaw] Connector stopped')
|
|
1127
|
+
},
|
|
1128
|
+
}
|
|
1129
|
+
},
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
export default openclaw
|