@clawling/clawchat-plugin-openclaw 2026.5.12-28

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.
Files changed (114) hide show
  1. package/INSTALL.md +64 -0
  2. package/README.md +227 -0
  3. package/dist/index.js +20 -0
  4. package/dist/setup-entry.js +3 -0
  5. package/dist/src/api-client.js +263 -0
  6. package/dist/src/api-types.js +17 -0
  7. package/dist/src/api-types.test-d.js +10 -0
  8. package/dist/src/buffered-stream.js +177 -0
  9. package/dist/src/channel.js +66 -0
  10. package/dist/src/channel.setup.js +119 -0
  11. package/dist/src/clawchat-memory.js +403 -0
  12. package/dist/src/clawchat-metadata.js +310 -0
  13. package/dist/src/client.js +35 -0
  14. package/dist/src/commands.js +35 -0
  15. package/dist/src/config.js +274 -0
  16. package/dist/src/group-message-coalescer.js +119 -0
  17. package/dist/src/inbound.js +170 -0
  18. package/dist/src/llm-context-debug.js +86 -0
  19. package/dist/src/login.runtime.js +204 -0
  20. package/dist/src/media-runtime.js +85 -0
  21. package/dist/src/message-mapper.js +146 -0
  22. package/dist/src/mock-transport.js +31 -0
  23. package/dist/src/outbound.js +628 -0
  24. package/dist/src/plugin-prompts.js +89 -0
  25. package/dist/src/profile-prompt.js +269 -0
  26. package/dist/src/profile-sync.js +110 -0
  27. package/dist/src/prompt-injection.js +25 -0
  28. package/dist/src/protocol-types.js +63 -0
  29. package/dist/src/protocol-types.typecheck.js +1 -0
  30. package/dist/src/protocol.js +33 -0
  31. package/dist/src/reply-dispatcher.js +422 -0
  32. package/dist/src/runtime.js +1254 -0
  33. package/dist/src/storage.js +525 -0
  34. package/dist/src/streaming.js +65 -0
  35. package/dist/src/terminal-send.js +36 -0
  36. package/dist/src/tools-schema.js +208 -0
  37. package/dist/src/tools.js +920 -0
  38. package/dist/src/ws-alignment.js +178 -0
  39. package/dist/src/ws-client.js +588 -0
  40. package/dist/src/ws-log.js +19 -0
  41. package/index.ts +24 -0
  42. package/openclaw.plugin.json +169 -0
  43. package/package.json +80 -0
  44. package/prompts/default-group-bio.md +19 -0
  45. package/prompts/default-owner-behavior.md +27 -0
  46. package/prompts/platform.md +13 -0
  47. package/setup-entry.ts +4 -0
  48. package/skills/clawchat/SKILL.md +91 -0
  49. package/src/api-client.test.ts +827 -0
  50. package/src/api-client.ts +414 -0
  51. package/src/api-types.ts +146 -0
  52. package/src/channel.outbound.test.ts +433 -0
  53. package/src/channel.setup.ts +145 -0
  54. package/src/channel.test.ts +262 -0
  55. package/src/channel.ts +81 -0
  56. package/src/clawchat-memory.test.ts +480 -0
  57. package/src/clawchat-memory.ts +533 -0
  58. package/src/clawchat-metadata.test.ts +477 -0
  59. package/src/clawchat-metadata.ts +429 -0
  60. package/src/client.test.ts +169 -0
  61. package/src/client.ts +56 -0
  62. package/src/commands.test.ts +39 -0
  63. package/src/commands.ts +41 -0
  64. package/src/config.test.ts +344 -0
  65. package/src/config.ts +404 -0
  66. package/src/group-message-coalescer.test.ts +237 -0
  67. package/src/group-message-coalescer.ts +171 -0
  68. package/src/inbound.test.ts +508 -0
  69. package/src/inbound.ts +278 -0
  70. package/src/llm-context-debug.test.ts +55 -0
  71. package/src/llm-context-debug.ts +139 -0
  72. package/src/login.runtime.test.ts +737 -0
  73. package/src/login.runtime.ts +277 -0
  74. package/src/manifest.test.ts +352 -0
  75. package/src/media-runtime.test.ts +207 -0
  76. package/src/media-runtime.ts +152 -0
  77. package/src/message-mapper.test.ts +201 -0
  78. package/src/message-mapper.ts +174 -0
  79. package/src/mock-transport.test.ts +35 -0
  80. package/src/mock-transport.ts +38 -0
  81. package/src/outbound.test.ts +1269 -0
  82. package/src/outbound.ts +803 -0
  83. package/src/plugin-entry.test.ts +38 -0
  84. package/src/plugin-prompts.test.ts +94 -0
  85. package/src/plugin-prompts.ts +107 -0
  86. package/src/profile-prompt.test.ts +274 -0
  87. package/src/profile-prompt.ts +351 -0
  88. package/src/profile-sync.test.ts +539 -0
  89. package/src/profile-sync.ts +191 -0
  90. package/src/prompt-injection.test.ts +39 -0
  91. package/src/prompt-injection.ts +45 -0
  92. package/src/protocol-types.test.ts +69 -0
  93. package/src/protocol-types.ts +296 -0
  94. package/src/protocol-types.typecheck.ts +89 -0
  95. package/src/protocol.test.ts +39 -0
  96. package/src/protocol.ts +42 -0
  97. package/src/reply-dispatcher.test.ts +1324 -0
  98. package/src/reply-dispatcher.ts +555 -0
  99. package/src/runtime.test.ts +4719 -0
  100. package/src/runtime.ts +1493 -0
  101. package/src/scripts.test.ts +85 -0
  102. package/src/storage.test.ts +560 -0
  103. package/src/storage.ts +807 -0
  104. package/src/terminal-send.test.ts +81 -0
  105. package/src/terminal-send.ts +56 -0
  106. package/src/tools-schema.ts +337 -0
  107. package/src/tools.test.ts +933 -0
  108. package/src/tools.ts +1185 -0
  109. package/src/ws-alignment.test.ts +103 -0
  110. package/src/ws-alignment.ts +275 -0
  111. package/src/ws-client.test.ts +1217 -0
  112. package/src/ws-client.ts +662 -0
  113. package/src/ws-log.test.ts +32 -0
  114. package/src/ws-log.ts +31 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,1493 @@
1
+ import {
2
+ AckTimeoutError,
3
+ AuthError,
4
+ ProtocolError,
5
+ StateError,
6
+ TransportError,
7
+ EVENT,
8
+ type Envelope,
9
+ type Transport,
10
+ } from "./protocol-types.ts";
11
+ import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
12
+ import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
13
+ import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
14
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
15
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
16
+ import { createOpenclawClawlingClient } from "./client.ts";
17
+ import { createOpenclawClawlingApiClient } from "./api-client.ts";
18
+ import { ClawlingApiError } from "./api-types.ts";
19
+ import {
20
+ CHANNEL_ID,
21
+ effectiveGroupCommandMode,
22
+ type ResolvedOpenclawClawlingAccount,
23
+ } from "./config.ts";
24
+ import type { ClawlingChatClient } from "./ws-client.ts";
25
+ import { dispatchOpenclawClawlingInbound, type IngestTurnParams } from "./inbound.ts";
26
+ import { fetchInboundMedia } from "./media-runtime.ts";
27
+ import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.ts";
28
+ import { runWithTerminalClawChatSendScope } from "./terminal-send.ts";
29
+ import {
30
+ flushAlignedOutboundQueue,
31
+ getAlignedOutboundQueueSize,
32
+ setAlignedOutboundLogContext,
33
+ } from "./outbound.ts";
34
+ import { formatWsLog } from "./ws-log.ts";
35
+ import { createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.ts";
36
+ import {
37
+ clawChatDbPathForStateDir,
38
+ getClawChatStore,
39
+ type ClawChatStore,
40
+ } from "./storage.ts";
41
+ import { getClawChatGroupPrompt, getClawChatUserPrompt } from "./plugin-prompts.ts";
42
+ import {
43
+ loadClawChatPromptMetadata,
44
+ type ClawChatGroupParticipantPrompt,
45
+ type ClawChatPromptMetadata,
46
+ renderClawChatProfilePrompt,
47
+ resolveSenderRelation,
48
+ } from "./profile-prompt.ts";
49
+ import { refreshGroupProfile, syncFirstSeenClawChatProfiles } from "./profile-sync.ts";
50
+ import { pullGroupMetadata, pullOwnerMetadata } from "./clawchat-metadata.ts";
51
+ import { deleteClawChatMemoryFile, readClawChatMemoryFile } from "./clawchat-memory.ts";
52
+ import {
53
+ clearClawChatPromptInjectionForSession,
54
+ stageClawChatPromptInjection,
55
+ } from "./prompt-injection.ts";
56
+ import { createGroupMessageCoalescer } from "./group-message-coalescer.ts";
57
+ import { openclawLlmContextDebug } from "./llm-context-debug.ts";
58
+
59
+ type Log = { info?: (m: string) => void; error?: (m: string) => void };
60
+ type ChannelContextBuilder = (params: Record<string, unknown>) => unknown;
61
+ type CompatibleChannelRuntime = PluginRuntime["channel"] & {
62
+ inbound?: { buildContext?: ChannelContextBuilder };
63
+ turn?: { buildContext?: ChannelContextBuilder };
64
+ };
65
+ type OpenClawReplyContext = Parameters<
66
+ PluginRuntime["channel"]["reply"]["dispatchReplyFromConfig"]
67
+ >[0]["ctx"];
68
+ type MutableOpenClawReplyContext = OpenClawReplyContext & Record<string, unknown>;
69
+ type RuntimeConnectionStore = Pick<
70
+ ClawChatStore,
71
+ "startConnection" | "markConnectSent" | "markConnectionReady" | "finishConnection"
72
+ > &
73
+ Partial<
74
+ Pick<
75
+ ClawChatStore,
76
+ | "insertMessage"
77
+ | "claimMessageOnce"
78
+ | "markMessageAcknowledged"
79
+ | "updateMessageByIdentity"
80
+ | "claimPendingActivationBootstrap"
81
+ | "releaseActivationBootstrapClaim"
82
+ | "markActivationBootstrapSent"
83
+ | "getActivationConversation"
84
+ >
85
+ >;
86
+
87
+ const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingRuntime } =
88
+ createPluginRuntimeStore<PluginRuntime>("clawchat-plugin-openclaw runtime not initialized");
89
+
90
+ export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
91
+
92
+ const activeClients = new Map<string, ClawlingChatClient>();
93
+ const CLAWCHAT_PLUGIN_SLASH_COMMANDS = new Set(["clawchat-activate"]);
94
+ const CLAWCHAT_MEMORY_ROOT_UNAVAILABLE =
95
+ "ClawChat memory root unavailable: OpenClaw workspaceDir could not be resolved";
96
+ const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
97
+ "approve",
98
+ "deny",
99
+ "always",
100
+ "cancel",
101
+ "yes",
102
+ "no",
103
+ "ok",
104
+ "confirm",
105
+ "remember",
106
+ "nevermind",
107
+ ]);
108
+
109
+ function resolveChannelContextBuilder(rt: PluginRuntime["channel"]): ChannelContextBuilder {
110
+ const channel = rt as CompatibleChannelRuntime;
111
+ const buildContext = channel.inbound?.buildContext ?? channel.turn?.buildContext;
112
+ if (!buildContext) {
113
+ throw new Error("OpenClaw channel runtime missing inbound/turn buildContext");
114
+ }
115
+ return buildContext;
116
+ }
117
+
118
+ function parseSlashCommandName(rawBody: string): string | null {
119
+ const stripped = rawBody.trimStart();
120
+ if (!stripped.startsWith("/")) return null;
121
+ const token = stripped.split(/\s+/, 1)[0] ?? "";
122
+ const name = token.slice(1).replace(/_/g, "-").toLowerCase();
123
+ if (!name || name.includes("/")) return null;
124
+ return name;
125
+ }
126
+
127
+ function isKnownOpenClawGroupSlashCommand(rawBody: string, cfg: OpenClawConfig): boolean {
128
+ const name = parseSlashCommandName(rawBody);
129
+ if (!name) return false;
130
+ return hasControlCommand(rawBody, cfg)
131
+ || CLAWCHAT_PLUGIN_SLASH_COMMANDS.has(name)
132
+ || OPENCLAW_CONFIRM_SLASH_COMMANDS.has(name);
133
+ }
134
+
135
+ export function getOpenclawClawlingClient(accountId: string): ClawlingChatClient | undefined {
136
+ return activeClients.get(accountId);
137
+ }
138
+
139
+ export function resolveClawChatMemoryRoot(
140
+ runtime: PluginRuntime,
141
+ cfg: OpenClawConfig,
142
+ agentId: string,
143
+ ): string {
144
+ const resolver = runtime.agent?.resolveAgentWorkspaceDir;
145
+ if (typeof resolver !== "function") {
146
+ throw new Error(CLAWCHAT_MEMORY_ROOT_UNAVAILABLE);
147
+ }
148
+
149
+ let workspaceDir: string;
150
+ try {
151
+ workspaceDir = resolver(cfg, agentId);
152
+ } catch {
153
+ throw new Error(CLAWCHAT_MEMORY_ROOT_UNAVAILABLE);
154
+ }
155
+
156
+ const memoryRoot = typeof workspaceDir === "string" ? workspaceDir.trim() : "";
157
+ if (!memoryRoot) {
158
+ throw new Error(CLAWCHAT_MEMORY_ROOT_UNAVAILABLE);
159
+ }
160
+ return memoryRoot;
161
+ }
162
+
163
+ export async function waitForOpenclawClawlingClient(
164
+ accountId: string,
165
+ options: { timeoutMs?: number; pollMs?: number } = {},
166
+ ): Promise<ClawlingChatClient> {
167
+ const timeoutMs = options.timeoutMs ?? 15_000;
168
+ const pollMs = options.pollMs ?? 100;
169
+ const deadline = Date.now() + timeoutMs;
170
+
171
+ for (;;) {
172
+ const client = activeClients.get(accountId);
173
+ if (client && (client as { state?: string }).state === "connected") {
174
+ return client;
175
+ }
176
+ if (Date.now() >= deadline) {
177
+ throw new Error(
178
+ `clawchat-plugin-openclaw client did not activate within ${timeoutMs}ms for account ${accountId}`,
179
+ );
180
+ }
181
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
182
+ }
183
+ }
184
+
185
+ export type ClawlingState =
186
+ | "idle"
187
+ | "connecting"
188
+ | "challenging"
189
+ | "authenticating"
190
+ | "connected"
191
+ | "reconnecting"
192
+ | "disconnected";
193
+
194
+ export function mapClawlingStateToStatus(state: ClawlingState): {
195
+ connected: boolean;
196
+ running: boolean;
197
+ lastStartAt?: number;
198
+ lastStopAt?: number;
199
+ } {
200
+ const now = Date.now();
201
+ switch (state) {
202
+ case "connected":
203
+ return { connected: true, running: true, lastStartAt: now };
204
+ case "reconnecting":
205
+ return { connected: false, running: true };
206
+ case "disconnected":
207
+ return { connected: false, running: false, lastStopAt: now };
208
+ default:
209
+ return { connected: false, running: true };
210
+ }
211
+ }
212
+
213
+ export function classifyClawlingClientError(err: unknown): {
214
+ kind: "auth" | "transport" | "protocol" | "ack-timeout" | "state" | "unknown";
215
+ retry: boolean;
216
+ message: string;
217
+ } {
218
+ if (err instanceof AuthError) return { kind: "auth", retry: false, message: err.message };
219
+ if (err instanceof TransportError)
220
+ return { kind: "transport", retry: true, message: err.message };
221
+ if (err instanceof AckTimeoutError)
222
+ return { kind: "ack-timeout", retry: false, message: err.message };
223
+ if (err instanceof ProtocolError) return { kind: "protocol", retry: false, message: err.message };
224
+ if (err instanceof StateError) return { kind: "state", retry: false, message: err.message };
225
+ return {
226
+ kind: "unknown",
227
+ retry: false,
228
+ message: err instanceof Error ? err.message : String(err),
229
+ };
230
+ }
231
+
232
+ function formatConversationSubject(peer: { kind: "direct" | "group"; id: string }): string {
233
+ return peer.kind === "group" ? `group:${peer.id}` : peer.id;
234
+ }
235
+
236
+ function asRecord(value: unknown): Record<string, unknown> | null {
237
+ return value && typeof value === "object" ? value as Record<string, unknown> : null;
238
+ }
239
+
240
+ function optionalString(value: unknown): string | undefined {
241
+ return typeof value === "string" ? value : undefined;
242
+ }
243
+
244
+ function hasOwn(record: Record<string, unknown>, key: string): boolean {
245
+ return Object.prototype.hasOwnProperty.call(record, key);
246
+ }
247
+
248
+ function optionalNullableString(record: Record<string, unknown>, key: string): string | null | undefined {
249
+ if (!hasOwn(record, key)) return undefined;
250
+ const value = record[key];
251
+ if (value === null) return null;
252
+ return optionalString(value);
253
+ }
254
+
255
+ function isConversationNotFoundError(err: unknown): boolean {
256
+ if (!(err instanceof ClawlingApiError)) return false;
257
+ return err.meta?.status === 404 || err.meta?.status === 410 ||
258
+ err.meta?.code === 404 || err.meta?.code === 410 || err.meta?.code === 40401 ||
259
+ err.message.toLowerCase().includes("conversation not found");
260
+ }
261
+
262
+ function metadataVersionFromEnvelope(env: Envelope): number | undefined {
263
+ const payload = asRecord(env.payload);
264
+ const version = payload?.version;
265
+ return typeof version === "number" && Number.isFinite(version) ? version : undefined;
266
+ }
267
+
268
+ function metadataScopesFromEnvelope(env: Envelope): string[] {
269
+ const scope = asRecord(env.payload)?.scope;
270
+ return Array.isArray(scope) ? scope.filter((item): item is string => typeof item === "string") : [];
271
+ }
272
+
273
+ function shouldRefreshBehaviorForScopes(scopes: string[]): boolean {
274
+ return scopes.includes("behavior");
275
+ }
276
+
277
+ function shouldRefreshConversationForScopes(scopes: string[]): boolean {
278
+ if (scopes.length === 0) return true;
279
+ return scopes.some((scope) => scope === "title" || scope === "description" || scope !== "behavior");
280
+ }
281
+
282
+ function withClawChatSessionScope(cfg: OpenClawConfig): OpenClawConfig {
283
+ return {
284
+ ...cfg,
285
+ session: {
286
+ ...(cfg.session ?? {}),
287
+ dmScope: "per-account-channel-peer",
288
+ },
289
+ };
290
+ }
291
+
292
+ function buildActivationBootstrapText(): string {
293
+ return [
294
+ "ClawChat activation bootstrap: You are now connected to this ClawChat direct conversation.",
295
+ "Please do both:",
296
+ "1. Send a brief, friendly greeting to the user in this ClawChat direct conversation.",
297
+ "2. If you have local profile information for yourself, such as display name, bio, or avatar, update the connected ClawChat account profile using the available ClawChat tools. Use `clawchat_update_account_profile` for display name/bio/avatar URL, and use `clawchat_upload_avatar_image` first if the avatar is only available as a local image path. If you do not have local profile information, skip profile updates and only greet the user.",
298
+ "Do not ask the user for profile information just for this bootstrap.",
299
+ ].join("\n");
300
+ }
301
+
302
+ function buildActivationBootstrapEnvelope(params: {
303
+ account: ResolvedOpenclawClawlingAccount;
304
+ conversationId: string;
305
+ }): Envelope {
306
+ const text = buildActivationBootstrapText();
307
+ const now = Date.now();
308
+ return {
309
+ version: "2",
310
+ event: EVENT.MESSAGE_SEND,
311
+ trace_id: `clawchat-plugin-openclaw-bootstrap-${now}`,
312
+ emitted_at: now,
313
+ chat_id: params.conversationId,
314
+ chat_type: "direct",
315
+ to: { id: params.account.userId, type: "direct" },
316
+ sender: {
317
+ id: "clawchat-bootstrap",
318
+ type: "direct",
319
+ nick_name: "ClawChat Activation",
320
+ },
321
+ payload: {
322
+ message_id: `clawchat-plugin-openclaw-bootstrap-${params.conversationId}-${now}`,
323
+ message_mode: "normal",
324
+ message: {
325
+ body: { fragments: [{ kind: "text", text }] },
326
+ context: { mentions: [], reply: null },
327
+ streaming: {
328
+ status: "static",
329
+ sequence: 0,
330
+ mutation_policy: "sealed",
331
+ started_at: null,
332
+ completed_at: null,
333
+ },
334
+ },
335
+ },
336
+ };
337
+ }
338
+
339
+ export interface StartGatewayParams {
340
+ cfg: OpenClawConfig;
341
+ account: ResolvedOpenclawClawlingAccount;
342
+ abortSignal: AbortSignal;
343
+ setStatus: (next: ChannelAccountSnapshot) => void;
344
+ getStatus: () => ChannelAccountSnapshot;
345
+ log?: Log;
346
+ /** Test hook only. */
347
+ store?: RuntimeConnectionStore | null;
348
+ /** Test hook only. */
349
+ transport?: Transport;
350
+ }
351
+
352
+ function resolveConnectionStore(
353
+ params: StartGatewayParams,
354
+ runtime: PluginRuntime,
355
+ ): RuntimeConnectionStore | null {
356
+ if (params.store !== undefined) return params.store;
357
+ if (params.transport) return null;
358
+ try {
359
+ const stateDir = runtime.state?.resolveStateDir?.();
360
+ return getClawChatStore({
361
+ ...(stateDir ? { dbPath: clawChatDbPathForStateDir(stateDir) } : {}),
362
+ log: { error: (message) => params.log?.error?.(message) },
363
+ });
364
+ } catch {
365
+ params.log?.error?.("clawchat-plugin-openclaw sqlite connection persistence unavailable; continuing.");
366
+ return null;
367
+ }
368
+ }
369
+
370
+ export async function startOpenclawClawlingGateway(params: StartGatewayParams): Promise<void> {
371
+ const { cfg, account, abortSignal, setStatus, getStatus, log } = params;
372
+ // Obtain PluginRuntime from the stored runtime set via setOpenclawClawlingRuntime.
373
+ const runtime = getOpenclawClawlingRuntime();
374
+ const accountId = account.accountId;
375
+ const store = resolveConnectionStore(params, runtime);
376
+ let conversationApiClient: ReturnType<typeof createOpenclawClawlingApiClient> | undefined;
377
+ const getConversationApiClient = () => {
378
+ conversationApiClient ??= createOpenclawClawlingApiClient({
379
+ baseUrl: account.baseUrl,
380
+ token: account.token,
381
+ userId: account.userId,
382
+ });
383
+ return conversationApiClient;
384
+ };
385
+
386
+ log?.info?.(
387
+ `[${accountId}] clawchat-plugin-openclaw runtime start entered configured=${account.configured} enabled=${account.enabled} hasToken=${Boolean(account.token)} hasUserId=${Boolean(account.userId)} hasOwnerUserId=${Boolean(account.ownerUserId)} websocketUrl=${account.websocketUrl || "(empty)"}`,
388
+ );
389
+ let lastHelloFailTraceId = "-";
390
+ let lastHelloFailReason = "";
391
+ let lastConnectTraceId = "-";
392
+ let lastHelloOkDeviceId: string | undefined;
393
+ let lastHelloOkDeliveryMode: string | undefined;
394
+ let currentAttemptStartedAt = 0;
395
+ let authFailureLogged = false;
396
+ let closingForAbort = false;
397
+ let wsReady = false;
398
+ let currentConnectionId: number | null = null;
399
+ let currentConnectionFinished = false;
400
+ const reconnectTracker = createReconnectTracker({
401
+ accountId,
402
+ log: (msg) => log?.info?.(msg),
403
+ maxDelayMs: account.reconnect.maxDelay,
404
+ });
405
+ const wsLogContext = () => {
406
+ const snapshot = reconnectTracker.snapshot();
407
+ return {
408
+ attempt: snapshot.attempt || 1,
409
+ reconnectCount: snapshot.reconnectCount,
410
+ state: snapshot.state === "connected" ? "ready" : snapshot.state,
411
+ };
412
+ };
413
+ const recordConnection = <T>(action: string, fn: () => T): T | undefined => {
414
+ try {
415
+ return fn();
416
+ } catch {
417
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw sqlite ${action} failed; continuing`);
418
+ return undefined;
419
+ }
420
+ };
421
+ const finishCurrentConnection = (input: {
422
+ state: string;
423
+ disconnectedAt?: number;
424
+ closeCode?: number | null;
425
+ closeReason?: string | null;
426
+ error?: string | null;
427
+ }) => {
428
+ if (!store || currentConnectionId == null || currentConnectionFinished) return;
429
+ const connectionId = currentConnectionId;
430
+ recordConnection("finish", () => store.finishConnection(connectionId, input));
431
+ currentConnectionFinished = true;
432
+ currentConnectionId = null;
433
+ };
434
+ const refreshConversationDetails = async (
435
+ conversationId: string,
436
+ options: { metadataVersion?: number; source: string; memoryRoot?: string },
437
+ ): Promise<void> => {
438
+ try {
439
+ const memoryRoot = options.memoryRoot;
440
+ if (!memoryRoot) {
441
+ await getConversationApiClient().getConversation(conversationId);
442
+ return;
443
+ }
444
+ const result = await pullGroupMetadata({
445
+ memoryRoot,
446
+ groupId: conversationId,
447
+ api: getConversationApiClient(),
448
+ skipUserIds: [account.userId, account.ownerUserId],
449
+ });
450
+ if (result.failures.length > 0) {
451
+ log?.error?.(
452
+ `[${accountId}] clawchat-plugin-openclaw group participant metadata refresh partially failed: ${result.failures.map((failure) => `${failure.targetId}: ${failure.error}`).join("; ")}`,
453
+ );
454
+ }
455
+ if (!result.conversation) throw new Error("ClawChat conversation metadata response is missing conversation");
456
+ } catch (err) {
457
+ if (isConversationNotFoundError(err)) {
458
+ if (options.memoryRoot) {
459
+ await deleteClawChatMemoryFile(options.memoryRoot, {
460
+ targetType: "group",
461
+ targetId: conversationId,
462
+ }).catch((deleteError) => {
463
+ log?.error?.(
464
+ `[${accountId}] clawchat-plugin-openclaw group metadata file delete failed conversation=${conversationId}: ${
465
+ deleteError instanceof Error ? deleteError.message : String(deleteError)
466
+ }`,
467
+ );
468
+ });
469
+ }
470
+ return;
471
+ }
472
+ log?.error?.(
473
+ `[${accountId}] clawchat-plugin-openclaw metadata refresh failed source=${options.source} conversation=${conversationId}: ${err instanceof Error ? err.message : String(err)}`,
474
+ );
475
+ }
476
+ };
477
+ const refreshAgentBehavior = async (options: {
478
+ metadataVersion?: number;
479
+ source: string;
480
+ memoryRoot: string;
481
+ }): Promise<void> => {
482
+ try {
483
+ await pullOwnerMetadata({
484
+ memoryRoot: options.memoryRoot,
485
+ agentId: account.agentId,
486
+ accountUserId: account.userId,
487
+ accountOwnerUserId: account.ownerUserId,
488
+ api: getConversationApiClient(),
489
+ });
490
+ } catch (err) {
491
+ log?.error?.(
492
+ `[${accountId}] clawchat-plugin-openclaw behavior refresh failed source=${options.source} agent=${account.userId}: ${err instanceof Error ? err.message : String(err)}`,
493
+ );
494
+ }
495
+ };
496
+ const refreshConversationCacheAfterReady = async (): Promise<void> => {
497
+ void store;
498
+ };
499
+ const resolveMemoryRootForPeer = (peer: { kind: "direct" | "group"; id: string }): string | null => {
500
+ try {
501
+ const route = runtime.channel.routing.resolveAgentRoute({
502
+ cfg: withClawChatSessionScope(cfg),
503
+ channel: CHANNEL_ID,
504
+ accountId,
505
+ peer,
506
+ });
507
+ return resolveClawChatMemoryRoot(runtime, cfg, route.agentId);
508
+ } catch (err) {
509
+ log?.error?.(
510
+ `[${accountId}] clawchat-plugin-openclaw metadata refresh memory root unavailable: ${err instanceof Error ? err.message : String(err)}`,
511
+ );
512
+ return null;
513
+ }
514
+ };
515
+ const handleMetadataInvalidation = async (env: Envelope): Promise<void> => {
516
+ const conversationId = typeof env.chat_id === "string" && env.chat_id.trim()
517
+ ? env.chat_id
518
+ : "";
519
+ if (!conversationId) {
520
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw metadata invalidation missing chat_id trace=${env.trace_id}`);
521
+ return;
522
+ }
523
+
524
+ const version = metadataVersionFromEnvelope(env);
525
+ const scopes = metadataScopesFromEnvelope(env);
526
+ const refreshBehavior = shouldRefreshBehaviorForScopes(scopes);
527
+ const refreshConversation = shouldRefreshConversationForScopes(scopes);
528
+ if (!refreshBehavior && !refreshConversation) return;
529
+ const peer = {
530
+ kind: env.chat_type === "group" ? "group" as const : "direct" as const,
531
+ id: conversationId,
532
+ };
533
+ const memoryRoot = resolveMemoryRootForPeer(peer);
534
+ if (!memoryRoot) return;
535
+
536
+ if (refreshBehavior) {
537
+ await refreshAgentBehavior({
538
+ source: "metadata_invalidation",
539
+ ...(version !== undefined ? { metadataVersion: version } : {}),
540
+ memoryRoot,
541
+ });
542
+ }
543
+
544
+ if (!refreshConversation) return;
545
+
546
+ await refreshConversationDetails(conversationId, {
547
+ source: "metadata_invalidation",
548
+ ...(version !== undefined ? { metadataVersion: version } : {}),
549
+ memoryRoot,
550
+ });
551
+ };
552
+ const syncMessagePathProfiles = async (turn: IngestTurnParams, memoryRoot: string): Promise<void> => {
553
+ if (turn.senderId === "clawchat-bootstrap") {
554
+ await pullOwnerMetadata({
555
+ memoryRoot,
556
+ agentId: account.agentId,
557
+ accountUserId: account.userId,
558
+ accountOwnerUserId: account.ownerUserId,
559
+ api: getConversationApiClient(),
560
+ });
561
+ return;
562
+ }
563
+ if (turn.peer.kind === "direct" && (turn.senderId === account.ownerUserId || turn.senderId === account.userId)) {
564
+ await pullOwnerMetadata({
565
+ memoryRoot,
566
+ agentId: account.agentId,
567
+ accountUserId: account.userId,
568
+ accountOwnerUserId: account.ownerUserId,
569
+ api: getConversationApiClient(),
570
+ });
571
+ return;
572
+ }
573
+ if (turn.peer.kind === "group") {
574
+ await refreshGroupProfile({
575
+ platform: "openclaw",
576
+ accountId,
577
+ accountUserId: account.userId,
578
+ accountOwnerUserId: account.ownerUserId,
579
+ conversationId: turn.peer.id,
580
+ api: getConversationApiClient(),
581
+ store: {},
582
+ memoryRoot,
583
+ log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
584
+ });
585
+ return;
586
+ }
587
+ await syncFirstSeenClawChatProfiles({
588
+ platform: "openclaw",
589
+ accountId,
590
+ accountUserId: account.userId,
591
+ accountOwnerUserId: account.ownerUserId,
592
+ chat: {
593
+ id: turn.peer.id,
594
+ type: "direct",
595
+ lastSeenAt: turn.timestamp,
596
+ },
597
+ sender: {
598
+ id: turn.senderId,
599
+ ...(turn.senderNickName ? { nickname: turn.senderNickName } : {}),
600
+ },
601
+ api: getConversationApiClient(),
602
+ store: {},
603
+ memoryRoot,
604
+ log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
605
+ });
606
+ };
607
+ const client = createOpenclawClawlingClient(account, {
608
+ ...(params.transport ? { transport: params.transport } : {}),
609
+ wsLifecycle: {
610
+ onConnectFrameSent: (env) => {
611
+ lastConnectTraceId = typeof env.trace_id === "string" ? env.trace_id : "-";
612
+ if (store && currentConnectionId != null) {
613
+ const connectionId = currentConnectionId;
614
+ recordConnection("connect-sent", () => store.markConnectSent(connectionId));
615
+ }
616
+ const deviceId =
617
+ typeof env.payload?.device_id === "string" ? env.payload.device_id : "-";
618
+ const current = wsLogContext();
619
+ log?.info?.(
620
+ formatWsLog({
621
+ event: "connect_sent",
622
+ accountId,
623
+ attempt: current.attempt,
624
+ reconnectCount: current.reconnectCount,
625
+ state: "handshaking",
626
+ action: "await_hello",
627
+ fields: [
628
+ ["trace_id", lastConnectTraceId],
629
+ ["device_id", deviceId],
630
+ ],
631
+ }),
632
+ );
633
+ },
634
+ },
635
+ });
636
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client created`);
637
+
638
+ setAlignedOutboundLogContext(client, wsLogContext);
639
+ client.on("hello:ok", (env: Envelope) => {
640
+ const payload = env.payload && typeof env.payload === "object"
641
+ ? env.payload as { device_id?: unknown; delivery_mode?: unknown }
642
+ : {};
643
+ lastHelloOkDeviceId = typeof payload.device_id === "string" ? payload.device_id : undefined;
644
+ lastHelloOkDeliveryMode = typeof payload.delivery_mode === "string" ? payload.delivery_mode : undefined;
645
+ });
646
+ const protocolControlLogger = createProtocolControlHandler({
647
+ accountId,
648
+ log: (msg) => log?.info?.(msg),
649
+ send: () => {},
650
+ context: wsLogContext,
651
+ });
652
+ const logAuthFailure = (reason: string) => {
653
+ if (authFailureLogged) return;
654
+ authFailureLogged = true;
655
+ const current = wsLogContext();
656
+ log?.error?.(
657
+ formatWsLog({
658
+ event: "auth_failed",
659
+ accountId,
660
+ attempt: current.attempt,
661
+ reconnectCount: current.reconnectCount,
662
+ state: "auth_failed",
663
+ action: "stop_reconnect",
664
+ fields: [
665
+ ["trace_id", lastHelloFailTraceId],
666
+ ["reason", reason || lastHelloFailReason],
667
+ ],
668
+ }),
669
+ );
670
+ };
671
+ let dispatchActivationBootstrap: () => Promise<void> = async () => {};
672
+
673
+ client.on("state", ({ from, to }) => {
674
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw state ${from} -> ${to}`);
675
+ wsReady = to === "connected";
676
+ if (to === "connecting") {
677
+ reconnectTracker.connectStart();
678
+ currentAttemptStartedAt = Date.now();
679
+ const current = wsLogContext();
680
+ if (store) {
681
+ recordConnection("start", () => {
682
+ currentConnectionId = store.startConnection({
683
+ platform: "openclaw",
684
+ accountId,
685
+ attempt: current.attempt,
686
+ reconnectCount: current.reconnectCount,
687
+ connectStartedAt: currentAttemptStartedAt,
688
+ });
689
+ currentConnectionFinished = false;
690
+ });
691
+ }
692
+ log?.info?.(
693
+ formatWsLog({
694
+ event: "connect_start",
695
+ accountId,
696
+ attempt: current.attempt,
697
+ reconnectCount: current.reconnectCount,
698
+ state: "connecting",
699
+ action: "connect",
700
+ fields: [
701
+ ["url", account.websocketUrl],
702
+ ["queue_size", getAlignedOutboundQueueSize(client)],
703
+ ],
704
+ }),
705
+ );
706
+ } else if (to === "connected") {
707
+ const elapsedMs = Math.max(0, Date.now() - currentAttemptStartedAt);
708
+ const queueSize = getAlignedOutboundQueueSize(client);
709
+ reconnectTracker.markReady();
710
+ const current = wsLogContext();
711
+ if (store && currentConnectionId != null) {
712
+ const connectionId = currentConnectionId;
713
+ recordConnection("ready", () => {
714
+ if (lastHelloOkDeviceId === undefined && lastHelloOkDeliveryMode === undefined) {
715
+ store.markConnectionReady(connectionId);
716
+ return;
717
+ }
718
+ store.markConnectionReady(connectionId, {
719
+ ...(lastHelloOkDeviceId !== undefined ? { resolvedDeviceId: lastHelloOkDeviceId } : {}),
720
+ ...(lastHelloOkDeliveryMode !== undefined ? { deliveryMode: lastHelloOkDeliveryMode } : {}),
721
+ });
722
+ });
723
+ }
724
+ log?.info?.(
725
+ formatWsLog({
726
+ event: "handshake_ok",
727
+ accountId,
728
+ attempt: current.attempt,
729
+ reconnectCount: current.reconnectCount,
730
+ state: "ready",
731
+ action: "flush_queue",
732
+ fields: [
733
+ ["trace_id", lastConnectTraceId],
734
+ ["elapsed_ms", elapsedMs],
735
+ ["queue_size", queueSize],
736
+ ],
737
+ }),
738
+ );
739
+ try {
740
+ flushAlignedOutboundQueue(client);
741
+ } catch {
742
+ // The queue keeps the failed frame at the head and will retry after the next reconnect.
743
+ }
744
+ void refreshConversationCacheAfterReady();
745
+ void dispatchActivationBootstrap();
746
+ } else if (to === "disconnected") {
747
+ reconnectTracker.markClosed();
748
+ }
749
+ const next = { ...getStatus(), ...mapClawlingStateToStatus(to as ClawlingState) };
750
+ setStatus(next);
751
+ });
752
+
753
+ client.on("close", ({ code, reason }: { code?: number; reason?: string }) => {
754
+ if (closingForAbort || (code === 1000 && reason === "client close")) return;
755
+ finishCurrentConnection({
756
+ state: "disconnected",
757
+ closeCode: code ?? null,
758
+ closeReason: reason ?? null,
759
+ });
760
+ const current = wsLogContext();
761
+ log?.info?.(
762
+ formatWsLog({
763
+ event: "connection_lost",
764
+ accountId,
765
+ attempt: current.attempt,
766
+ reconnectCount: current.reconnectCount,
767
+ state: current.state,
768
+ action: "reconnect",
769
+ fields: [
770
+ ["code", code],
771
+ ["reason", reason],
772
+ ],
773
+ }),
774
+ );
775
+ });
776
+
777
+ client.on("reconnect:scheduled", ({ delay }: { delay?: number }) => {
778
+ reconnectTracker.scheduleReconnect("connection_lost", {
779
+ delayMs: delay,
780
+ maxDelayMs: account.reconnect.maxDelay,
781
+ });
782
+ });
783
+
784
+ client.on("raw", (env: Envelope) => {
785
+ if (env.event === "connect.challenge") {
786
+ const payload = env.payload as { nonce?: unknown } | undefined;
787
+ const current = wsLogContext();
788
+ log?.info?.(
789
+ formatWsLog({
790
+ event: "challenge_received",
791
+ accountId,
792
+ attempt: current.attempt,
793
+ reconnectCount: current.reconnectCount,
794
+ state: "handshaking",
795
+ action: "send_connect",
796
+ fields: [
797
+ ["challenge_trace_id", env.trace_id],
798
+ ["has_nonce", typeof payload?.nonce === "string" && payload.nonce.length > 0],
799
+ ],
800
+ }),
801
+ );
802
+ }
803
+ if (env.event === "ping" || env.event === "pong") {
804
+ protocolControlLogger.handleInbound(env);
805
+ }
806
+ if (wsReady) {
807
+ const sender = env.sender as { id?: unknown } | undefined;
808
+ const senderId = typeof sender?.id === "string" ? sender.id : "-";
809
+ if (env.event === "message.send" || env.event === "message.reply") {
810
+ const current = wsLogContext();
811
+ log?.info?.(
812
+ formatWsLog({
813
+ event: "inbound_dispatch",
814
+ accountId,
815
+ attempt: current.attempt,
816
+ reconnectCount: current.reconnectCount,
817
+ state: "ready",
818
+ action: "dispatch",
819
+ fields: [
820
+ ["event_name", env.event],
821
+ ["trace_id", env.trace_id],
822
+ ["chat_id", env.chat_id],
823
+ ["sender_id", senderId],
824
+ ],
825
+ }),
826
+ );
827
+ } else if (env.event === "message.ack") {
828
+ const current = wsLogContext();
829
+ log?.info?.(
830
+ formatWsLog({
831
+ event: "inbound_control",
832
+ accountId,
833
+ attempt: current.attempt,
834
+ reconnectCount: current.reconnectCount,
835
+ state: "ready",
836
+ action: "ack",
837
+ fields: [
838
+ ["event_name", env.event],
839
+ ["trace_id", env.trace_id],
840
+ ],
841
+ }),
842
+ );
843
+ } else if (
844
+ env.event === "offline.batch" ||
845
+ env.event === "offline.ack" ||
846
+ env.event === "offline.done"
847
+ ) {
848
+ const current = wsLogContext();
849
+ log?.info?.(
850
+ formatWsLog({
851
+ event: "inbound_control",
852
+ accountId,
853
+ attempt: current.attempt,
854
+ reconnectCount: current.reconnectCount,
855
+ state: "ready",
856
+ action: "ignore_legacy",
857
+ fields: [
858
+ ["event_name", env.event],
859
+ ["trace_id", env.trace_id],
860
+ ],
861
+ }),
862
+ );
863
+ } else if (env.event !== "ping" && env.event !== "pong") {
864
+ const current = wsLogContext();
865
+ log?.info?.(
866
+ formatWsLog({
867
+ event: "inbound_ignored",
868
+ accountId,
869
+ attempt: current.attempt,
870
+ reconnectCount: current.reconnectCount,
871
+ state: "ready",
872
+ action: "ignore",
873
+ fields: [
874
+ ["event_name", env.event],
875
+ ["trace_id", env.trace_id],
876
+ ],
877
+ }),
878
+ );
879
+ }
880
+ }
881
+ if (env.event !== "hello-fail") return;
882
+ lastHelloFailTraceId = env.trace_id;
883
+ const payload = env.payload as { reason?: unknown } | undefined;
884
+ lastHelloFailReason = typeof payload?.reason === "string" ? payload.reason : "";
885
+ });
886
+
887
+ client.on("metadata:invalidated", (env: Envelope) => {
888
+ void handleMetadataInvalidation(env);
889
+ });
890
+
891
+ client.on("error", (err: unknown) => {
892
+ const classified = classifyClawlingClientError(err);
893
+ if (classified.kind === "auth") {
894
+ finishCurrentConnection({
895
+ state: "auth_failed",
896
+ error: lastHelloFailReason || classified.message,
897
+ });
898
+ logAuthFailure(classified.message);
899
+ setStatus({
900
+ ...getStatus(),
901
+ connected: false,
902
+ configured: false,
903
+ running: false,
904
+ lastError: classified.message,
905
+ });
906
+ } else if (classified.kind === "transport") {
907
+ finishCurrentConnection({ state: "transport_error", error: classified.message });
908
+ const current = wsLogContext();
909
+ log?.info?.(
910
+ formatWsLog({
911
+ event: "connection_lost",
912
+ accountId,
913
+ attempt: current.attempt,
914
+ reconnectCount: current.reconnectCount,
915
+ state: current.state,
916
+ action: "reconnect",
917
+ fields: [
918
+ ["code", "-"],
919
+ ["reason", classified.message],
920
+ ],
921
+ }),
922
+ );
923
+ log?.info?.(
924
+ `[${accountId}] clawchat-plugin-openclaw transport error (reconnecting): ${classified.message}`,
925
+ );
926
+ setStatus({ ...getStatus(), connected: false, running: true });
927
+ } else if (classified.kind === "ack-timeout") {
928
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw ack timeout: ${classified.message}`);
929
+ } else if (classified.kind === "protocol") {
930
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw protocol error: ${classified.message}`);
931
+ } else if (classified.kind === "state") {
932
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw state error: ${classified.message}`);
933
+ } else {
934
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw client error: ${classified.message}`);
935
+ }
936
+ });
937
+
938
+ type IngestTurnResult = "submitted" | "skipped" | "failed";
939
+ type GroupIngestTurnParams = IngestTurnParams & { peer: { kind: "group"; id: string } };
940
+
941
+ const loadGroupParticipantsForPrompt = async (params: {
942
+ memoryRoot: string;
943
+ groupId: string;
944
+ ownerMetadata: ClawChatPromptMetadata | null;
945
+ }): Promise<ClawChatGroupParticipantPrompt[]> => {
946
+ const groupFile = await readClawChatMemoryFile(params.memoryRoot, {
947
+ targetType: "group",
948
+ targetId: params.groupId,
949
+ });
950
+ const participantIds = (groupFile.metadata.participant_ids ?? "")
951
+ .split(",")
952
+ .map((value) => value.trim())
953
+ .filter((value) => value.length > 0);
954
+ const agentOwnerId = params.ownerMetadata?.agent_owner_id ?? account.ownerUserId;
955
+ const groupOwnerId = groupFile.metadata.group_owner_id;
956
+ const participants: ClawChatGroupParticipantPrompt[] = [];
957
+ for (const userId of participantIds) {
958
+ let metadata: ClawChatPromptMetadata | null = null;
959
+ try {
960
+ const file = await readClawChatMemoryFile(params.memoryRoot, {
961
+ targetType: "user",
962
+ targetId: userId,
963
+ });
964
+ metadata = file.exists ? file.metadata : null;
965
+ } catch {
966
+ metadata = null;
967
+ }
968
+ const isAgentOwner = Boolean(agentOwnerId && userId === agentOwnerId);
969
+ const isGroupOwner = Boolean(groupOwnerId && userId === groupOwnerId);
970
+ const name = isAgentOwner
971
+ ? params.ownerMetadata?.agent_owner_nickname ?? metadata?.nickname ?? userId
972
+ : userId === account.userId
973
+ ? params.ownerMetadata?.agent_nickname ?? metadata?.nickname ?? userId
974
+ : isGroupOwner
975
+ ? groupFile.metadata.group_owner_nickname ?? metadata?.nickname ?? userId
976
+ : metadata?.nickname ?? userId;
977
+ const profileType = metadata?.profile_type ?? (userId === account.userId ? "agent" : "user");
978
+ participants.push({ id: userId, name, profileType, isAgentOwner, isGroupOwner });
979
+ }
980
+ return participants;
981
+ };
982
+
983
+ const buildPromptForTurn = async (turn: IngestTurnParams, memoryRoot: string): Promise<string> => {
984
+ const senderRelation = resolveSenderRelation({
985
+ senderId: turn.senderId,
986
+ accountUserId: account.userId,
987
+ accountOwnerUserId: account.ownerUserId,
988
+ senderProfileType: turn.senderProfileType,
989
+ });
990
+ const promptChatType = turn.peer.kind === "group" ? "group" : "dm";
991
+ const promptMetadata = await loadClawChatPromptMetadata({
992
+ memoryRoot,
993
+ turn: {
994
+ chatType: promptChatType,
995
+ senderId: turn.senderId,
996
+ senderIsOwner: senderRelation === "owner",
997
+ groupId: turn.peer.kind === "group" ? turn.peer.id : null,
998
+ },
999
+ log: { error: (message) => log?.error?.(`[${accountId}] ${message}`) },
1000
+ });
1001
+ const metadataSenderProfileType = promptMetadata.userMetadata?.profile_type ?? null;
1002
+ const promptSenderProfileType = metadataSenderProfileType ?? turn.senderProfileType ?? (
1003
+ senderRelation === "self_agent" || senderRelation === "peer_agent" ? "agent" : "user"
1004
+ );
1005
+ const groupParticipants = turn.peer.kind === "group"
1006
+ ? await loadGroupParticipantsForPrompt({
1007
+ memoryRoot,
1008
+ groupId: turn.peer.id,
1009
+ ownerMetadata: promptMetadata.ownerMetadata,
1010
+ })
1011
+ : [];
1012
+ return renderClawChatProfilePrompt({
1013
+ basePrompt: turn.peer.kind === "group" ? getClawChatGroupPrompt() : getClawChatUserPrompt(),
1014
+ ...promptMetadata,
1015
+ ownerIdFallback: account.ownerUserId,
1016
+ groupParticipants,
1017
+ turn: {
1018
+ chatType: promptChatType,
1019
+ chatId: turn.peer.id,
1020
+ senderId: turn.senderId,
1021
+ senderName: promptMetadata.userMetadata?.nickname ?? (turn.senderNickName || turn.senderId),
1022
+ senderProfileType: promptSenderProfileType,
1023
+ senderIsOwner: senderRelation === "owner",
1024
+ groupId: turn.peer.kind === "group" ? turn.peer.id : null,
1025
+ coalescedGroupBatch: turn.coalescedGroupBatch === true,
1026
+ wasMentioned: turn.wasMentioned,
1027
+ mentionedUserIds: turn.mentionedUserIds,
1028
+ mentionedUsers: turn.mentionedUsers,
1029
+ messageText: turn.rawBody,
1030
+ },
1031
+ });
1032
+ };
1033
+
1034
+ const resolveSenderNickNameForTurn = (turn: IngestTurnParams): string => {
1035
+ if (turn.senderNickName && turn.senderNickName !== turn.senderId) return turn.senderNickName;
1036
+ return turn.senderNickName || turn.senderId;
1037
+ };
1038
+
1039
+ const resolveSenderBatchIdentityForTurn = (turn: IngestTurnParams): {
1040
+ senderRelation: NonNullable<IngestTurnParams["senderRelation"]>;
1041
+ senderProfileType: string;
1042
+ senderIsOwner: boolean;
1043
+ senderIsGroupOwner: boolean;
1044
+ } => {
1045
+ const senderRelation = resolveSenderRelation({
1046
+ senderId: turn.senderId,
1047
+ accountUserId: account.userId,
1048
+ accountOwnerUserId: account.ownerUserId,
1049
+ senderProfileType: turn.senderProfileType,
1050
+ });
1051
+ return {
1052
+ senderRelation,
1053
+ senderProfileType: turn.senderProfileType ??
1054
+ (senderRelation === "self_agent" || senderRelation === "peer_agent" ? "agent" : "user"),
1055
+ senderIsOwner: senderRelation === "owner",
1056
+ senderIsGroupOwner: turn.senderIsGroupOwner ?? false,
1057
+ };
1058
+ };
1059
+
1060
+ const claimInboundTurn = (turn: IngestTurnParams): "claimed" | "skipped" => {
1061
+ const env = turn.envelope;
1062
+ if (store?.claimMessageOnce) {
1063
+ const claimed = recordConnection("message claim", () =>
1064
+ store.claimMessageOnce?.({
1065
+ platform: "openclaw",
1066
+ accountId,
1067
+ kind: "message",
1068
+ direction: "inbound",
1069
+ eventType: String(env.event),
1070
+ traceId: turn.traceId,
1071
+ chatId: turn.peer.id,
1072
+ messageId: turn.messageId,
1073
+ text: turn.rawBody,
1074
+ raw: env,
1075
+ }),
1076
+ );
1077
+ if (claimed === false) {
1078
+ log?.info?.(
1079
+ `[${accountId}] clawchat-plugin-openclaw skip duplicate stored msg=${turn.messageId}`,
1080
+ );
1081
+ return "skipped";
1082
+ }
1083
+ }
1084
+ return "claimed";
1085
+ };
1086
+
1087
+ const dispatchTurnToAgent = async (turn: IngestTurnParams): Promise<IngestTurnResult> => {
1088
+ const rt = runtime.channel;
1089
+ const storePath = rt.session.resolveStorePath(cfg.session?.store);
1090
+ const routeCfg = withClawChatSessionScope(cfg);
1091
+ const route = rt.routing.resolveAgentRoute({
1092
+ cfg: routeCfg,
1093
+ channel: CHANNEL_ID,
1094
+ accountId,
1095
+ peer: turn.peer,
1096
+ });
1097
+ const memoryRoot = resolveClawChatMemoryRoot(runtime, cfg, route.agentId);
1098
+ const body = rt.reply.formatAgentEnvelope({
1099
+ channel: "Clawling Chat",
1100
+ from: formatConversationSubject(turn.peer),
1101
+ body: turn.rawBody,
1102
+ timestamp: turn.timestamp,
1103
+ ...rt.reply.resolveEnvelopeFormatOptions(cfg),
1104
+ });
1105
+ try {
1106
+ await syncMessagePathProfiles(turn, memoryRoot);
1107
+ } catch (err) {
1108
+ log?.error?.(
1109
+ `[${accountId}] clawchat-plugin-openclaw message metadata refresh failed: ${err instanceof Error ? err.message : String(err)}`,
1110
+ );
1111
+ }
1112
+ const conversationTarget = `${CHANNEL_ID}:${formatConversationSubject(turn.peer)}`;
1113
+ const turnPrompt = await buildPromptForTurn(turn, memoryRoot);
1114
+ const ctxPayload = resolveChannelContextBuilder(rt)({
1115
+ channel: CHANNEL_ID,
1116
+ accountId: route.accountId ?? accountId,
1117
+ provider: CHANNEL_ID,
1118
+ surface: CHANNEL_ID,
1119
+ messageId: turn.messageId,
1120
+ messageIdFull: turn.messageId,
1121
+ timestamp: turn.timestamp,
1122
+ from: conversationTarget,
1123
+ sender: {
1124
+ id: turn.senderId,
1125
+ name: turn.senderNickName || turn.senderId,
1126
+ displayLabel: turn.senderNickName || turn.senderId,
1127
+ },
1128
+ conversation: {
1129
+ kind: turn.peer.kind,
1130
+ id: turn.peer.id,
1131
+ label: formatConversationSubject(turn.peer),
1132
+ routePeer: turn.peer,
1133
+ },
1134
+ route: {
1135
+ agentId: route.agentId,
1136
+ accountId: route.accountId ?? accountId,
1137
+ routeSessionKey: route.sessionKey,
1138
+ },
1139
+ reply: {
1140
+ to: `${CHANNEL_ID}:${account.userId}`,
1141
+ originatingTo: conversationTarget,
1142
+ },
1143
+ message: {
1144
+ body,
1145
+ rawBody: turn.rawBody,
1146
+ bodyForAgent: turn.rawBody,
1147
+ commandBody: turn.rawBody,
1148
+ envelopeFrom: conversationTarget,
1149
+ },
1150
+ access: {
1151
+ mentions: {
1152
+ canDetectMention: true,
1153
+ wasMentioned: turn.wasMentioned,
1154
+ hasAnyMention: turn.mentionedUserIds.length > 0,
1155
+ },
1156
+ },
1157
+ ...(memoryRoot ? { extra: { memoryRoot } } : {}),
1158
+ ...(turn.peer.kind === "group"
1159
+ ? { supplemental: { groupSystemPrompt: turnPrompt } }
1160
+ : {}),
1161
+ }) as MutableOpenClawReplyContext;
1162
+ if (memoryRoot) {
1163
+ (ctxPayload as Record<string, unknown>).memoryRoot = memoryRoot;
1164
+ }
1165
+ if (turn.mentionedUserIds.length > 0) {
1166
+ (ctxPayload as Record<string, unknown>).MentionedUserIds = turn.mentionedUserIds;
1167
+ }
1168
+ // Fetch any inbound media attachments and populate MediaPath/MediaPaths in context.
1169
+ const inboundPaths =
1170
+ turn.mediaItems.length > 0
1171
+ ? await fetchInboundMedia(turn.mediaItems, {
1172
+ runtime,
1173
+ log,
1174
+ maxBytes: 20 * 1024 * 1024,
1175
+ })
1176
+ : [];
1177
+ if (inboundPaths.length > 0) {
1178
+ (ctxPayload as Record<string, unknown>).MediaPath = inboundPaths[0];
1179
+ (ctxPayload as Record<string, unknown>).MediaPaths = inboundPaths;
1180
+ }
1181
+
1182
+ const resolvedSessionKey = optionalString(ctxPayload.SessionKey) ?? route.sessionKey;
1183
+
1184
+ openclawLlmContextDebug.writeSnapshot({
1185
+ visibility: "injected_only",
1186
+ trace: {
1187
+ traceId: turn.traceId,
1188
+ messageId: turn.messageId,
1189
+ chatId: turn.peer.id,
1190
+ chatType: turn.peer.kind,
1191
+ senderId: turn.senderId,
1192
+ sessionKey: resolvedSessionKey,
1193
+ },
1194
+ input: {
1195
+ injectedPrompt: turnPrompt,
1196
+ eventText: turn.rawBody,
1197
+ },
1198
+ });
1199
+
1200
+ clearClawChatPromptInjectionForSession(resolvedSessionKey);
1201
+ const stagedDirectPrompt = turn.peer.kind !== "group";
1202
+ if (stagedDirectPrompt) {
1203
+ stageClawChatPromptInjection({
1204
+ sessionKey: resolvedSessionKey,
1205
+ prompt: turnPrompt,
1206
+ });
1207
+ }
1208
+
1209
+ try {
1210
+ try {
1211
+ await rt.session.recordInboundSession({
1212
+ storePath,
1213
+ sessionKey: resolvedSessionKey,
1214
+ ctx: ctxPayload,
1215
+ onRecordError: (err) => {
1216
+ log?.error?.(
1217
+ `[${accountId}] clawchat-plugin-openclaw failed to record inbound session: ${String(err)}`,
1218
+ );
1219
+ },
1220
+ });
1221
+ } catch (err) {
1222
+ log?.error?.(
1223
+ `[${accountId}] clawchat-plugin-openclaw failed to record inbound session: ${String(err)}`,
1224
+ );
1225
+ }
1226
+
1227
+ const replyCtx = turn.replyCtx;
1228
+ const terminalSendScopeId = `${account.accountId}\0${turn.peer.id}\0${turn.messageId}`;
1229
+ const { dispatcher, replyOptions, markDispatchIdle } =
1230
+ createOpenclawClawlingReplyDispatcher({
1231
+ cfg,
1232
+ runtime,
1233
+ account,
1234
+ client,
1235
+ target: { chatId: turn.peer.id, chatType: turn.peer.kind },
1236
+ ...(replyCtx ? { replyCtx } : {}),
1237
+ inboundMessageId: turn.messageId,
1238
+ terminalSendScopeId,
1239
+ inboundForFinalReply: {
1240
+ chatId: turn.peer.id,
1241
+ senderId: turn.senderId,
1242
+ senderNickName: turn.senderNickName || turn.senderId,
1243
+ bodyText: turn.rawBody,
1244
+ },
1245
+ store: store
1246
+ ? {
1247
+ insertMessage: (input) => store.insertMessage?.(input) ?? null,
1248
+ claimMessageOnce: (input) => store.claimMessageOnce?.(input) ?? null,
1249
+ markMessageAcknowledged: (input) => store.markMessageAcknowledged?.(input) ?? null,
1250
+ updateMessageByIdentity: (input) => store.updateMessageByIdentity?.(input),
1251
+ }
1252
+ : null,
1253
+ log,
1254
+ });
1255
+
1256
+ const agentsConfigured = Object.keys((cfg as { agents?: Record<string, unknown> }).agents ?? {});
1257
+ log?.info?.(
1258
+ `[${accountId}] clawchat-plugin-openclaw dispatching reply msg=${turn.messageId} session=${resolvedSessionKey} agent=${route.agentId} agentsConfigured=[${agentsConfigured.join(",")}]`,
1259
+ );
1260
+
1261
+ try {
1262
+ const dispatchResult = await rt.reply.withReplyDispatcher({
1263
+ dispatcher,
1264
+ onSettled: () => markDispatchIdle(),
1265
+ run: () =>
1266
+ runWithTerminalClawChatSendScope(terminalSendScopeId, () =>
1267
+ rt.reply.dispatchReplyFromConfig({ ctx: ctxPayload, cfg, dispatcher, replyOptions }),
1268
+ ),
1269
+ });
1270
+ const counts = (dispatchResult as { counts?: Record<string, number> } | undefined)?.counts ?? {};
1271
+ const queuedFinal = Boolean(
1272
+ (dispatchResult as { queuedFinal?: boolean } | undefined)?.queuedFinal,
1273
+ );
1274
+ log?.info?.(
1275
+ `[${accountId}] clawchat-plugin-openclaw dispatch complete msg=${turn.messageId} queuedFinal=${queuedFinal} counts=${JSON.stringify(counts)}`,
1276
+ );
1277
+ if (!queuedFinal && Object.values(counts).every((n) => !n)) {
1278
+ log?.info?.(
1279
+ `[${accountId}] clawchat-plugin-openclaw NO reply was produced (no final / block / tool dispatched). ` +
1280
+ `Likely causes: agent='${route.agentId}' not configured in cfg.agents (configured: [${agentsConfigured.join(",")}]); ` +
1281
+ `or send-policy denied; or a plugin claimed the binding.`,
1282
+ );
1283
+ }
1284
+ return "submitted";
1285
+ } catch (err) {
1286
+ log?.error?.(
1287
+ `[${accountId}] clawchat-plugin-openclaw dispatch failed msg=${turn.messageId}: ${String(err)}`,
1288
+ );
1289
+ return "failed";
1290
+ }
1291
+ } finally {
1292
+ if (stagedDirectPrompt) {
1293
+ clearClawChatPromptInjectionForSession(resolvedSessionKey);
1294
+ }
1295
+ }
1296
+ };
1297
+
1298
+ const groupCoalescer = createGroupMessageCoalescer<GroupIngestTurnParams>({
1299
+ idleMs: 10_000,
1300
+ maxWaitMs: 30_000,
1301
+ dispatch: async (turn) => {
1302
+ await dispatchTurnToAgent(turn);
1303
+ },
1304
+ onError: (err) => {
1305
+ log?.error?.(
1306
+ `[${accountId}] clawchat-plugin-openclaw coalesced group dispatch failed: ${String(err)}`,
1307
+ );
1308
+ },
1309
+ onDrop: (chatId, count) => {
1310
+ log?.info?.(
1311
+ `[${accountId}] clawchat-plugin-openclaw dropped pending group batch chat_id=${chatId} count=${count} reason=shutdown`,
1312
+ );
1313
+ },
1314
+ });
1315
+
1316
+ const ingestTurn = async (rawTurn: IngestTurnParams): Promise<IngestTurnResult> => {
1317
+ const senderBatchIdentity = rawTurn.peer.kind === "group"
1318
+ ? resolveSenderBatchIdentityForTurn(rawTurn)
1319
+ : {};
1320
+ const turn: IngestTurnParams = {
1321
+ ...rawTurn,
1322
+ senderNickName: resolveSenderNickNameForTurn(rawTurn),
1323
+ ...senderBatchIdentity,
1324
+ };
1325
+ const claimed = claimInboundTurn(turn);
1326
+ if (claimed === "skipped") return "skipped";
1327
+ if (turn.peer.kind === "group") {
1328
+ if (isKnownOpenClawGroupSlashCommand(turn.rawBody, cfg)) {
1329
+ const commandMode = effectiveGroupCommandMode(account, turn.peer.id);
1330
+ const commandAllowed = commandMode === "all"
1331
+ || (commandMode === "owner" && turn.senderIsOwner === true);
1332
+ if (!commandAllowed) {
1333
+ log?.info?.(
1334
+ `[${accountId}] clawchat-plugin-openclaw group command dropped chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode} owner=${turn.senderIsOwner === true}`,
1335
+ );
1336
+ return "skipped";
1337
+ }
1338
+ log?.info?.(
1339
+ `[${accountId}] clawchat-plugin-openclaw dispatching group command chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode}`,
1340
+ );
1341
+ return dispatchTurnToAgent(turn);
1342
+ }
1343
+ if (turn.wasMentioned) {
1344
+ groupCoalescer.enqueue(turn as GroupIngestTurnParams);
1345
+ groupCoalescer.flushNow(turn.peer.id);
1346
+ log?.info?.(
1347
+ `[${accountId}] clawchat-plugin-openclaw dispatching mentioned group batch chat_id=${turn.peer.id} msg=${turn.messageId}`,
1348
+ );
1349
+ return "submitted";
1350
+ }
1351
+ groupCoalescer.enqueue(turn as GroupIngestTurnParams);
1352
+ log?.info?.(
1353
+ `[${accountId}] clawchat-plugin-openclaw queued group batch chat_id=${turn.peer.id} msg=${turn.messageId}`,
1354
+ );
1355
+ return "submitted";
1356
+ }
1357
+ return dispatchTurnToAgent(turn);
1358
+ };
1359
+
1360
+ const handleInboundEnvelope = async (env: Envelope): Promise<IngestTurnResult | undefined> => {
1361
+ let ingestResult: IngestTurnResult | undefined;
1362
+ try {
1363
+ await dispatchOpenclawClawlingInbound({
1364
+ envelope: env as Envelope<unknown>,
1365
+ cfg,
1366
+ runtime,
1367
+ account,
1368
+ log,
1369
+ ingest: async (turn) => {
1370
+ ingestResult = await ingestTurn(turn);
1371
+ },
1372
+ });
1373
+ } catch (err) {
1374
+ log?.error?.(
1375
+ `[${accountId}] clawchat-plugin-openclaw message handler error: ${err instanceof Error ? err.stack || err.message : String(err)}`,
1376
+ );
1377
+ return "failed";
1378
+ }
1379
+ return ingestResult;
1380
+ };
1381
+
1382
+ dispatchActivationBootstrap = async (): Promise<void> => {
1383
+ if (!store?.claimPendingActivationBootstrap || !store.markActivationBootstrapSent) return;
1384
+ let bootstrap: { conversationId: string } | null | undefined;
1385
+ const releaseBootstrap = () => {
1386
+ if (!bootstrap || !store.releaseActivationBootstrapClaim) return;
1387
+ const claimedBootstrap = bootstrap;
1388
+ recordConnection("activation bootstrap release", () =>
1389
+ store.releaseActivationBootstrapClaim?.({
1390
+ platform: "openclaw",
1391
+ accountId,
1392
+ conversationId: claimedBootstrap.conversationId,
1393
+ }),
1394
+ );
1395
+ };
1396
+ try {
1397
+ bootstrap = recordConnection("activation bootstrap claim", () =>
1398
+ store.claimPendingActivationBootstrap?.({ platform: "openclaw", accountId }),
1399
+ );
1400
+ if (!bootstrap) return;
1401
+ const claimedBootstrap = bootstrap;
1402
+ const result = await handleInboundEnvelope(
1403
+ buildActivationBootstrapEnvelope({ account, conversationId: claimedBootstrap.conversationId }),
1404
+ );
1405
+ if (result !== "submitted") {
1406
+ releaseBootstrap();
1407
+ return;
1408
+ }
1409
+ recordConnection("activation bootstrap sent", () =>
1410
+ store.markActivationBootstrapSent?.({
1411
+ platform: "openclaw",
1412
+ accountId,
1413
+ conversationId: claimedBootstrap.conversationId,
1414
+ }),
1415
+ );
1416
+ } catch (err) {
1417
+ releaseBootstrap();
1418
+ log?.error?.(
1419
+ `[${accountId}] clawchat-plugin-openclaw activation bootstrap failed: ${err instanceof Error ? err.message : String(err)}`,
1420
+ );
1421
+ }
1422
+ };
1423
+
1424
+ client.on("message", (env: Envelope) => {
1425
+ void (async () => {
1426
+ await handleInboundEnvelope(env);
1427
+ })();
1428
+ });
1429
+
1430
+ // `client.connect()` resolves on `hello-ok` or rejects on `hello-fail`
1431
+ // (auth). Transport failures (server unreachable, DNS error, etc.) do
1432
+ // NOT reject this promise — the local client handles them internally and
1433
+ // drives its own exponential-backoff reconnect loop (`initialDelay * 2^attempt`
1434
+ // capped at `maxDelay`, with jitter). So we never throw here on anything
1435
+ // other than auth failure; on auth we tear the account down cleanly and
1436
+ // return without throwing (which would make the gateway supervisor
1437
+ // restart us immediately in a tight loop).
1438
+ try {
1439
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime calling client.connect()`);
1440
+ await client.connect();
1441
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime client.connect() resolved`);
1442
+ } catch (err) {
1443
+ const classified = classifyClawlingClientError(err);
1444
+ setStatus({
1445
+ ...getStatus(),
1446
+ connected: false,
1447
+ configured: classified.kind !== "auth",
1448
+ running: false,
1449
+ lastError: classified.message,
1450
+ });
1451
+ if (classified.kind === "auth") {
1452
+ finishCurrentConnection({
1453
+ state: "auth_failed",
1454
+ error: lastHelloFailReason || classified.message,
1455
+ });
1456
+ logAuthFailure(classified.message);
1457
+ return;
1458
+ }
1459
+ log?.error?.(
1460
+ `[${accountId}] clawchat-plugin-openclaw connect failed (${classified.kind}): ${classified.message}`,
1461
+ );
1462
+ return;
1463
+ }
1464
+ activeClients.set(accountId, client);
1465
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime active client registered`);
1466
+ setStatus({
1467
+ ...getStatus(),
1468
+ connected: true,
1469
+ running: true,
1470
+ lastStartAt: Date.now(),
1471
+ });
1472
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw connected`);
1473
+
1474
+ await waitUntilAbort(abortSignal, async () => {
1475
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw runtime abort received; closing client`);
1476
+ activeClients.delete(accountId);
1477
+ closingForAbort = true;
1478
+ groupCoalescer.cancelAll();
1479
+ finishCurrentConnection({
1480
+ state: "disconnected",
1481
+ closeCode: 1000,
1482
+ closeReason: "client close",
1483
+ });
1484
+ client.close();
1485
+ setStatus({
1486
+ ...getStatus(),
1487
+ connected: false,
1488
+ running: false,
1489
+ lastStopAt: Date.now(),
1490
+ });
1491
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw disconnected`);
1492
+ });
1493
+ }