@clawling/clawchat-plugin-openclaw 2026.5.12-39 → 2026.5.13-1

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/src/api-client.ts CHANGED
@@ -17,6 +17,24 @@ import {
17
17
  } from "./api-types.ts";
18
18
  import { CHANNEL_ID } from "./config.ts";
19
19
 
20
+ export interface PluginReportInput {
21
+ deviceId: string;
22
+ platform: string;
23
+ pluginVersion: string;
24
+ runtimeName: string;
25
+ runtimeVersion: string;
26
+ }
27
+
28
+ export function buildPluginReportBody(input: PluginReportInput): Record<string, string> {
29
+ return {
30
+ device_id: input.deviceId,
31
+ platform: input.platform,
32
+ plugin_version: input.pluginVersion,
33
+ runtime_name: input.runtimeName,
34
+ runtime_version: input.runtimeVersion,
35
+ };
36
+ }
37
+
20
38
  export interface ApiClientOptions {
21
39
  baseUrl: string;
22
40
  token: string;
@@ -28,6 +46,47 @@ export interface ApiClientOptions {
28
46
  fetchImpl?: typeof fetch;
29
47
  }
30
48
 
49
+ /**
50
+ * §A.0 — decode the access token's `exp` claim locally (base64url-decode the
51
+ * JWT payload segment, read `exp` as epoch seconds). Returns `null` when the
52
+ * token is not a parseable JWT or carries no numeric `exp`, in which case the
53
+ * caller falls back to `activated_at + 24h`. We never persist a separate
54
+ * expiry column; this is derived from the token on every load.
55
+ */
56
+ export function decodeJwtExp(token: string): number | null {
57
+ if (typeof token !== "string") return null;
58
+ const segments = token.split(".");
59
+ if (segments.length < 2) return null;
60
+ const payloadSegment = segments[1];
61
+ if (!payloadSegment) return null;
62
+ try {
63
+ const json = Buffer.from(payloadSegment, "base64url").toString("utf8");
64
+ const parsed = JSON.parse(json) as { exp?: unknown };
65
+ const exp = parsed?.exp;
66
+ return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * §0/§B — outcome classes for `POST /v1/auth/refresh`. The endpoint is
74
+ * always HTTP 200; callers branch on the envelope `code`. Distinct kinds keep
75
+ * the permanent/transient decision (and auto-logout vs. backoff) explicit.
76
+ */
77
+ export type AuthRefreshResult =
78
+ | { kind: "success"; accessToken: string; refreshToken: string }
79
+ // `code:10003` invalid refresh OR `code:400` bad request — PERMANENT.
80
+ | { kind: "permanent"; code: number; message: string }
81
+ // `code:1` internal, any non-200, or a network error — TRANSIENT.
82
+ | { kind: "transient"; message: string; status?: number; code?: number };
83
+
84
+ export interface AuthRefreshParams {
85
+ refreshToken: string;
86
+ /** The connect-time `X-Device-Id` (OpenClaw: `CHANNEL_ID`). */
87
+ deviceId: string;
88
+ }
89
+
31
90
  export interface OpenclawClawlingApiClient {
32
91
  getMyProfile(): Promise<Profile>;
33
92
  getAgentProfile(agentId: string): Promise<{ agent: AgentProfile }>;
@@ -90,6 +149,111 @@ export interface OpenclawClawlingApiClient {
90
149
  filename: string;
91
150
  mime?: string;
92
151
  }): Promise<AvatarUploadResult>;
152
+ /**
153
+ * Report this plugin's version + runtime to member-backend. When
154
+ * `authenticated`, posts to the agent-JWT self-report endpoint (links the row
155
+ * to the caller's agent/owner); otherwise the public unpaired endpoint.
156
+ */
157
+ reportPlugin(
158
+ input: PluginReportInput,
159
+ opts?: { authenticated?: boolean },
160
+ ): Promise<void>;
161
+ }
162
+
163
+ /** Backend envelope codes for `POST /v1/auth/refresh` (§0). */
164
+ const CODE_OK = 0;
165
+ const CODE_INTERNAL = 1; // CodeInternal — transient.
166
+ const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
167
+ const CODE_INVALID_REFRESH = 10003; // CodeInvalidRefresh — permanent.
168
+
169
+ /**
170
+ * §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
171
+ *
172
+ * Unauthenticated: the refresh token in the body IS the credential, so we send
173
+ * NO `Authorization` header. `X-Device-Id` MUST equal the connect-time device
174
+ * id (the backend rejects on `sess.DeviceID != X-Device-Id`). The endpoint is
175
+ * always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
176
+ * a standalone function (not a method on the token-bearing client) precisely
177
+ * because no bearer token participates.
178
+ */
179
+ export async function authRefresh(
180
+ opts: { baseUrl: string; fetchImpl?: typeof fetch },
181
+ params: AuthRefreshParams,
182
+ ): Promise<AuthRefreshResult> {
183
+ const baseUrl = opts.baseUrl.replace(/\/+$/, "");
184
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
185
+ if (!params.refreshToken?.trim()) {
186
+ return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
187
+ }
188
+ let res: Response;
189
+ try {
190
+ res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
191
+ method: "POST",
192
+ // No `Authorization` header — refresh is unauthenticated by design.
193
+ headers: {
194
+ "content-type": "application/json",
195
+ "x-device-id": params.deviceId,
196
+ },
197
+ body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
198
+ });
199
+ } catch (err) {
200
+ // Network error / timeout / DNS — TRANSIENT (no rotation committed).
201
+ return {
202
+ kind: "transient",
203
+ message: `refresh fetch failed: ${err instanceof Error ? err.message : String(err)}`,
204
+ };
205
+ }
206
+
207
+ if (res.status !== 200) {
208
+ // Any non-200 (500/LB/transport) — TRANSIENT, regardless of body.
209
+ const text = await res.text().catch(() => "");
210
+ return {
211
+ kind: "transient",
212
+ status: res.status,
213
+ message: `refresh http ${res.status} ${text.slice(0, 200)}`,
214
+ };
215
+ }
216
+
217
+ const text = await res.text().catch(() => "");
218
+ let parsed: { code?: unknown; msg?: unknown; message?: unknown; data?: unknown } | undefined;
219
+ try {
220
+ parsed = text ? (JSON.parse(text) as typeof parsed) : undefined;
221
+ } catch {
222
+ parsed = undefined;
223
+ }
224
+ const code = typeof parsed?.code === "number" ? parsed.code : Number.NaN;
225
+ const message =
226
+ (typeof parsed?.msg === "string" && parsed.msg) ||
227
+ (typeof parsed?.message === "string" && parsed.message) ||
228
+ `code=${code}`;
229
+
230
+ if (!Number.isFinite(code)) {
231
+ // 200 with no usable envelope — treat as transient (do not auto-logout).
232
+ return { kind: "transient", status: 200, message: "refresh: missing numeric code" };
233
+ }
234
+ if (code === CODE_OK) {
235
+ const data = parsed?.data && typeof parsed.data === "object"
236
+ ? (parsed.data as { access_token?: unknown; refresh_token?: unknown })
237
+ : {};
238
+ const accessToken = typeof data.access_token === "string" ? data.access_token : "";
239
+ const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
240
+ if (!accessToken || !refreshToken) {
241
+ // Rotation succeeded server-side but the body is malformed — transient so
242
+ // we retry; the next attempt will return 10003 (rotation single-use) and
243
+ // escalate to permanent (§B transient→permanent).
244
+ return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
245
+ }
246
+ return { kind: "success", accessToken, refreshToken };
247
+ }
248
+ if (code === CODE_INVALID_REFRESH || code === CODE_BAD_REQUEST) {
249
+ return { kind: "permanent", code, message };
250
+ }
251
+ if (code === CODE_INTERNAL) {
252
+ return { kind: "transient", status: 200, code, message };
253
+ }
254
+ // Unknown non-zero code — conservatively transient (never auto-logout on an
255
+ // unrecognized code; only 10003/400 are permanent per §0).
256
+ return { kind: "transient", status: 200, code, message };
93
257
  }
94
258
 
95
259
  export function createOpenclawClawlingApiClient(opts: ApiClientOptions): OpenclawClawlingApiClient {
@@ -134,48 +298,54 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
134
298
  path,
135
299
  });
136
300
  }
137
- if (!res.ok) {
138
- const snippet = await res.text().catch(() => "");
139
- throw new ClawlingApiError("transport", `http ${res.status} ${snippet.slice(0, 200)}`, {
140
- status: res.status,
141
- path,
142
- });
143
- }
301
+ // §15.3: HTTP status stays meaningful for proxies, but application code
302
+ // should branch on the business `code`, not the HTTP status. Parse the body
303
+ // FIRST even on a non-2xx — so callers receive the precise business code
304
+ // (e.g. 41301 vs 41501) rather than a generic transport error. Only fall
305
+ // back to the HTTP status when no structured envelope is present.
306
+ const text = await res.text().catch(() => "");
144
307
  let parsed: unknown;
145
308
  try {
146
- parsed = await res.json();
147
- } catch (err) {
148
- throw new ClawlingApiError(
149
- "transport",
150
- `non-JSON response: ${err instanceof Error ? err.message : String(err)}`,
151
- { status: res.status, path },
152
- );
309
+ parsed = text ? JSON.parse(text) : undefined;
310
+ } catch {
311
+ parsed = undefined;
153
312
  }
154
313
  // Unified envelope: `{ code: number, msg: string, data: T }`.
155
314
  // `code === 0` means success; any other value is a business error whose
156
315
  // `msg` is surfaced to callers and `code` is preserved on the error meta.
157
- const env = parsed as { code?: unknown; msg?: unknown; message?: unknown; data?: T };
158
- const code = typeof env.code === "number" ? env.code : Number.NaN;
159
- const msg =
160
- typeof env.msg === "string"
161
- ? env.msg
162
- : typeof env.message === "string"
163
- ? env.message
164
- : "";
165
- if (!Number.isFinite(code)) {
166
- throw new ClawlingApiError("transport", "invalid envelope: missing numeric `code`", {
167
- status: res.status,
168
- path,
169
- });
316
+ const env =
317
+ parsed && typeof parsed === "object"
318
+ ? (parsed as { code?: unknown; msg?: unknown; message?: unknown; data?: T })
319
+ : undefined;
320
+ const code = typeof env?.code === "number" ? env.code : Number.NaN;
321
+ if (env && Number.isFinite(code)) {
322
+ const msg =
323
+ typeof env.msg === "string"
324
+ ? env.msg
325
+ : typeof env.message === "string"
326
+ ? env.message
327
+ : "";
328
+ if (code !== 0) {
329
+ throw new ClawlingApiError("api", msg || `code=${code}`, {
330
+ code,
331
+ status: res.status,
332
+ path,
333
+ });
334
+ }
335
+ return env.data as T;
170
336
  }
171
- if (code !== 0) {
172
- throw new ClawlingApiError("api", msg || `code=${code}`, {
173
- code,
337
+ // No usable envelope — fall back to the HTTP status signal.
338
+ if (!res.ok) {
339
+ throw new ClawlingApiError("transport", `http ${res.status} ${text.slice(0, 200)}`, {
174
340
  status: res.status,
175
341
  path,
176
342
  });
177
343
  }
178
- return env.data as T;
344
+ throw new ClawlingApiError(
345
+ "transport",
346
+ text ? "invalid envelope: missing numeric `code`" : "non-JSON response: empty body",
347
+ { status: res.status, path },
348
+ );
179
349
  }
180
350
 
181
351
  async function call<T>(
@@ -472,5 +642,14 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
472
642
  fd.set("file", file);
473
643
  return await call<AvatarUploadResult>("POST", "/v1/files/upload-url", { body: fd });
474
644
  },
645
+ async reportPlugin(input, opts): Promise<void> {
646
+ const path = opts?.authenticated
647
+ ? "/v1/agents/me/plugin-report"
648
+ : "/v1/agents/plugin-report";
649
+ await call<unknown>("POST", path, {
650
+ headers: { "content-type": "application/json" },
651
+ body: JSON.stringify(buildPluginReportBody(input)),
652
+ });
653
+ },
475
654
  };
476
655
  }
package/src/client.ts CHANGED
@@ -9,6 +9,13 @@ export type { ChatType } from "./protocol-types.ts";
9
9
  export interface CreateClientOverrides {
10
10
  /** Transport override — only intended for tests (e.g. MockTransport). */
11
11
  transport?: Transport;
12
+ /**
13
+ * Device id to present on `connect`, overriding the hostname-derived default.
14
+ * Supplied with a previously server-resolved `device_id` (persisted from an
15
+ * earlier `hello-ok`) so a pod restart reuses the same device identity
16
+ * instead of minting a new one and forcing a full inbox replay.
17
+ */
18
+ deviceIdOverride?: string;
12
19
  wsLifecycle?: {
13
20
  onConnectFrameSent?: (env: {
14
21
  trace_id?: unknown;
@@ -27,10 +34,14 @@ export function createOpenclawClawlingClient(
27
34
  account: ResolvedOpenclawClawlingAccount,
28
35
  overrides: CreateClientOverrides = {},
29
36
  ): ClawlingChatClient {
37
+ const deviceId =
38
+ overrides.deviceIdOverride && overrides.deviceIdOverride.trim()
39
+ ? overrides.deviceIdOverride.trim()
40
+ : resolveOpenclawClawlingDeviceId(account);
30
41
  const client = createClawChatClient({
31
42
  url: account.websocketUrl,
32
43
  token: account.token,
33
- deviceId: resolveOpenclawClawlingDeviceId(account),
44
+ deviceId,
34
45
  ...(overrides.transport ? { transport: overrides.transport } : {}),
35
46
  reconnect: {
36
47
  enabled: true,
@@ -0,0 +1,154 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
2
+ import { CHANNEL_ID } from "./config.ts";
3
+
4
+ /**
5
+ * Compatibility migration for the plugin rename (commit 260044f): the plugin
6
+ * `id` and channel key changed from the OLD `openclaw-clawchat` to the NEW
7
+ * `clawchat-plugin-openclaw` (= {@link CHANNEL_ID}).
8
+ *
9
+ * Users upgrading from the old version have all their state — channel block
10
+ * (token/userId/ownerUserId/refreshToken/…), `plugins.allow`,
11
+ * `plugins.entries`, and `tools.allow`/`tools.alsoAllow` — keyed under the old
12
+ * id. The new code only reads the new id, so the channel silently fails to
13
+ * load. This migration moves the old-keyed state onto the new id.
14
+ *
15
+ * The function is PURE: it clones the input via `structuredClone` and never
16
+ * mutates its argument. It is IDEMPOTENT: a config with no old id anywhere
17
+ * returns an equivalent config with `changes: []`.
18
+ */
19
+
20
+ export const LEGACY_CHANNEL_ID = "openclaw-clawchat" as const;
21
+ export const TARGET_CHANNEL_ID = CHANNEL_ID;
22
+
23
+ const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
24
+
25
+ function isBlockedObjectKey(key: string): boolean {
26
+ return BLOCKED_OBJECT_KEYS.has(key);
27
+ }
28
+
29
+ function isRecord(value: unknown): value is Record<string, unknown> {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+
33
+ function isNonEmptyRecord(value: unknown): value is Record<string, unknown> {
34
+ return isRecord(value) && Object.keys(value).length > 0;
35
+ }
36
+
37
+ /**
38
+ * Copy keys from `source` into `target` only when they are absent/undefined in
39
+ * `target` (target is more current — never overwrite an existing value).
40
+ * Recurses into nested records. Skips prototype-pollution keys.
41
+ */
42
+ function mergeMissing(
43
+ target: Record<string, unknown>,
44
+ source: Record<string, unknown>,
45
+ ): void {
46
+ for (const [key, value] of Object.entries(source)) {
47
+ if (value === undefined || isBlockedObjectKey(key)) continue;
48
+ const existing = target[key];
49
+ if (existing === undefined) {
50
+ target[key] = value;
51
+ continue;
52
+ }
53
+ if (isRecord(existing) && isRecord(value)) mergeMissing(existing, value);
54
+ }
55
+ }
56
+
57
+ /** Replace `from` → `to` in a string array, preserving order and deduping. */
58
+ function replaceAndDedup(list: string[], from: string, to: string): string[] {
59
+ const out: string[] = [];
60
+ for (const raw of list) {
61
+ const value = raw === from ? to : raw;
62
+ if (!out.includes(value)) out.push(value);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ export function migrateLegacyClawChatChannelConfig(config: OpenClawConfig): {
68
+ config: OpenClawConfig;
69
+ changes: string[];
70
+ } {
71
+ const changes: string[] = [];
72
+ if (!isRecord(config)) return { config, changes };
73
+
74
+ const next = structuredClone(config) as Record<string, unknown>;
75
+
76
+ // 1. channels — move/merge the old channel block onto the new id.
77
+ const channels = next.channels;
78
+ if (isRecord(channels)) {
79
+ const oldChannel = channels[LEGACY_CHANNEL_ID];
80
+ if (isNonEmptyRecord(oldChannel)) {
81
+ const newChannel = channels[TARGET_CHANNEL_ID];
82
+ if (!isNonEmptyRecord(newChannel)) {
83
+ channels[TARGET_CHANNEL_ID] = oldChannel;
84
+ changes.push(
85
+ `Moved channel "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`,
86
+ );
87
+ } else {
88
+ mergeMissing(newChannel, oldChannel);
89
+ changes.push(
90
+ `Merged channel "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}" (filled missing fields; kept existing values).`,
91
+ );
92
+ }
93
+ delete channels[LEGACY_CHANNEL_ID];
94
+ }
95
+ }
96
+
97
+ // plugins.* live under a single `plugins` record.
98
+ const plugins = next.plugins;
99
+ if (isRecord(plugins)) {
100
+ // 2. plugins.allow — replace old id with new id (append if missing), dedup.
101
+ const allow = plugins.allow;
102
+ if (Array.isArray(allow) && allow.includes(LEGACY_CHANNEL_ID)) {
103
+ const replaced = replaceAndDedup(
104
+ allow.filter((v): v is string => typeof v === "string"),
105
+ LEGACY_CHANNEL_ID,
106
+ TARGET_CHANNEL_ID,
107
+ );
108
+ plugins.allow = replaced;
109
+ changes.push(
110
+ `Updated plugins.allow: "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`,
111
+ );
112
+ }
113
+
114
+ // 3. plugins.entries — merge-missing the old entry into the new one.
115
+ const entries = plugins.entries;
116
+ if (isRecord(entries)) {
117
+ const oldEntry = entries[LEGACY_CHANNEL_ID];
118
+ if (oldEntry !== undefined) {
119
+ if (isRecord(oldEntry)) {
120
+ const newEntry = entries[TARGET_CHANNEL_ID];
121
+ if (!isRecord(newEntry)) {
122
+ entries[TARGET_CHANNEL_ID] = oldEntry;
123
+ } else {
124
+ mergeMissing(newEntry, oldEntry);
125
+ }
126
+ }
127
+ delete entries[LEGACY_CHANNEL_ID];
128
+ changes.push(
129
+ `Merged plugins.entries["${LEGACY_CHANNEL_ID}"] → plugins.entries["${TARGET_CHANNEL_ID}"].`,
130
+ );
131
+ }
132
+ }
133
+ }
134
+
135
+ // 4. tools.allow + tools.alsoAllow — replace old id with new id, dedup.
136
+ const tools = next.tools;
137
+ if (isRecord(tools)) {
138
+ for (const key of ["allow", "alsoAllow"] as const) {
139
+ const list = tools[key];
140
+ if (Array.isArray(list) && list.includes(LEGACY_CHANNEL_ID)) {
141
+ tools[key] = replaceAndDedup(
142
+ list.filter((v): v is string => typeof v === "string"),
143
+ LEGACY_CHANNEL_ID,
144
+ TARGET_CHANNEL_ID,
145
+ );
146
+ changes.push(
147
+ `Updated tools.${key}: "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`,
148
+ );
149
+ }
150
+ }
151
+ }
152
+
153
+ return { config: next as OpenClawConfig, changes };
154
+ }
package/src/inbound.ts CHANGED
@@ -72,14 +72,16 @@ type SenderLike = {
72
72
  type?: unknown;
73
73
  };
74
74
 
75
- function normalizeSender(sender: unknown): { id: string; nickName: string; profileType: string | null } | null {
75
+ function normalizeSender(sender: unknown): { id: string; nickName: string } | null {
76
76
  if (!sender || typeof sender !== "object") return null;
77
77
  const s = sender as SenderLike;
78
78
  const id = typeof s.id === "string" ? s.id : "";
79
79
  if (!id) return null;
80
80
  const nickName = typeof s.nick_name === "string" ? s.nick_name : id;
81
- const profileType = s.type === "agent" || s.type === "user" ? s.type : null;
82
- return { id, nickName, profileType };
81
+ // §4.1: `sender.type` is the server-stamped routing type and is always
82
+ // "direct" it never carries the human/agent distinction. The sender's
83
+ // profile_type is resolved downstream from chat metadata, not from the wire.
84
+ return { id, nickName };
83
85
  }
84
86
 
85
87
  function requireChatId(envelope: Envelope<unknown>): string | null {
@@ -166,6 +168,20 @@ export async function dispatchOpenclawClawlingInbound(
166
168
  );
167
169
  return;
168
170
  }
171
+ // Fail-closed self-echo guard: the self-echo check below
172
+ // (`sender.id === account.userId`) is only meaningful when `account.userId`
173
+ // is known. A reactivation auth failure can leave `account.userId` as an
174
+ // empty string (see runtime.ts reactivation edge); with an empty userId the
175
+ // `account.userId && …` short-circuit would silently treat EVERY frame —
176
+ // including our own echoed messages — as non-self and feed it back into the
177
+ // LLM pipeline (self-reply loop). Refuse to process materialized messages in
178
+ // that state rather than risk echoing our own output.
179
+ if (!account.userId) {
180
+ log?.error?.(
181
+ `[${account.accountId}] clawchat-plugin-openclaw skip (fail-closed): empty account.userId, cannot apply self-echo guard event=${envelope.event} trace=${envelope.trace_id}`,
182
+ );
183
+ return;
184
+ }
169
185
  if (isMaterializedMessage && !isInboundMessagePayload(envelope.payload)) {
170
186
  log?.info?.(
171
187
  `[${account.accountId}] clawchat-plugin-openclaw skip: invalid payload trace=${envelope.trace_id}`,
@@ -214,7 +230,11 @@ export async function dispatchOpenclawClawlingInbound(
214
230
  }
215
231
  const chatType: ChatType = envelope.chat_type === "group" ? "group" : "direct";
216
232
  const isGroup = chatType === "group";
217
- if (isMaterializedMessage && payload.message_mode !== "normal") {
233
+ // §7.5: the server does not default message_mode an omitted field arrives
234
+ // as "" on the downlink. Empty/absent is equivalent to "normal", so only
235
+ // skip genuinely non-normal modes (e.g. "thinking").
236
+ const messageMode = payload.message_mode ?? "";
237
+ if (isMaterializedMessage && messageMode !== "normal" && messageMode !== "") {
218
238
  log?.info?.(
219
239
  `[${account.accountId}] clawchat-plugin-openclaw skip non-normal mode=${payload.message_mode}`,
220
240
  );
@@ -271,7 +291,6 @@ export async function dispatchOpenclawClawlingInbound(
271
291
  peer: { kind: isGroup ? "group" : "direct", id: chatId },
272
292
  senderId: sender.id,
273
293
  senderNickName: sender.nickName,
274
- ...(sender.profileType ? { senderProfileType: sender.profileType } : {}),
275
294
  rawBody,
276
295
  messageId: payload.message_id,
277
296
  traceId: envelope.trace_id,
@@ -268,6 +268,10 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
268
268
  refreshToken: normalizedResult.refresh_token || null,
269
269
  conversationId: normalizedResult.conversation?.id ?? null,
270
270
  loginMethod: "login",
271
+ // §E — record the exact `X-Device-Id` sent at connect (the constant
272
+ // `CHANNEL_ID`, see `authHeaders` in api-client.ts), so a later refresh
273
+ // sends the same device id the backend baked into the session.
274
+ deviceId: CHANNEL_ID,
271
275
  });
272
276
  } catch {
273
277
  runtime.log("clawchat-plugin-openclaw sqlite activation persistence failed; login continues.");
package/src/outbound.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomInt } from "node:crypto";
1
2
  import { MessageSendError, type Envelope, type Fragment, type MentionFragment, type MessageAckPayload, type MessageErrorPayload } from "./protocol-types.ts";
2
3
  import type { ClawlingChatClient } from "./ws-client.ts";
3
4
  import {
@@ -321,10 +322,15 @@ async function sendAlignedAckableEnvelope(params: {
321
322
  if (ack.trace_id !== traceId) return;
322
323
  if (ack.event === "message.error") {
323
324
  if (state === "acked" || state === "failed") return;
324
- const payload = ack.payload as Partial<MessageErrorPayload>;
325
+ const payload = ack.payload as Partial<MessageErrorPayload> & { message?: unknown };
325
326
  const code = typeof payload.code === "string" && payload.code ? payload.code : "unknown";
326
- const message = typeof payload.message === "string" && payload.message ? payload.message : "message send failed";
327
- fail(new MessageSendError(traceId, code, message, ack.chat_id));
327
+ // §14.3: the human-readable hint is `reason` (fall back to legacy `message`).
328
+ const hint = typeof payload.reason === "string" && payload.reason
329
+ ? payload.reason
330
+ : typeof payload.message === "string" && payload.message
331
+ ? payload.message
332
+ : "message send failed";
333
+ fail(new MessageSendError(traceId, code, hint, ack.chat_id));
328
334
  return;
329
335
  }
330
336
  if (ack.event !== "message.ack") return;
@@ -491,14 +497,19 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
491
497
  && params.replyCtx.replyPreviewNickName
492
498
  && params.replyCtx.replyPreviewText,
493
499
  );
494
- const messageId = params.messageId;
500
+ // Outbound message_id is ALWAYS present (at-least-once delivery, protocol
501
+ // §3.1.9): the server's inbox UNIQUE(recipient, message_id) absorbs a
502
+ // bounded resend of the same frame as one coalesced row. A bounded resend
503
+ // (non-terminal socket close → re-enqueue of the same captured wire) reuses
504
+ // this exact id, so a duplicate write is deduped rather than fanned out.
505
+ const messageId = params.messageId ?? mintMessageId();
495
506
 
496
507
  let ack: Envelope<MessageAckPayload>;
497
508
  let mode: "send" | "reply";
498
509
  if (useReply && params.replyCtx) {
499
510
  mode = "reply";
500
511
  const payload = {
501
- ...(messageId ? { message_id: messageId } : {}),
512
+ message_id: messageId,
502
513
  message_mode: "normal",
503
514
  message: {
504
515
  body: { fragments },
@@ -532,7 +543,7 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
532
543
  }
533
544
  : null;
534
545
  const payload = {
535
- ...(messageId ? { message_id: messageId } : {}),
546
+ message_id: messageId,
536
547
  message_mode: "normal",
537
548
  message: {
538
549
  body: { fragments },
@@ -548,7 +559,7 @@ export async function sendOpenclawClawlingText(params: SendParams): Promise<Send
548
559
  ...(params.log ? { log: params.log } : {}),
549
560
  });
550
561
  }
551
- if (messageId && ack.payload.message_id !== messageId) {
562
+ if (ack.payload.message_id !== messageId) {
552
563
  throw new Error(
553
564
  `ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`,
554
565
  );
@@ -638,8 +649,35 @@ export async function sendOpenclawClawlingMedia(
638
649
 
639
650
  type OutboundClaimStore = Pick<ClawChatStore, "claimMessageOnce" | "markMessageAcknowledged">;
640
651
 
641
- function mintOutboundMessageId(account: ResolvedOpenclawClawlingAccount): string {
642
- return `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
652
+ // Crockford base32 alphabet (ULID spec).
653
+ const ULID_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
654
+
655
+ /**
656
+ * Generate a Canonical ULID: 10 chars of millisecond timestamp + 16 chars of
657
+ * randomness, Crockford base32, 26 chars total. Implemented locally (no extra
658
+ * dependency) because this repo has no ULID dep and only needs minting, not
659
+ * monotonic ordering or decoding.
660
+ */
661
+ function ulid(now: number = Date.now()): string {
662
+ let time = now;
663
+ const out = new Array<string>(26);
664
+ for (let i = 9; i >= 0; i--) {
665
+ out[i] = ULID_ENCODING[time % 32]!;
666
+ time = Math.floor(time / 32);
667
+ }
668
+ for (let i = 10; i < 26; i++) {
669
+ out[i] = ULID_ENCODING[randomInt(0, 32)]!;
670
+ }
671
+ return out.join("");
672
+ }
673
+
674
+ /** Client-minted message id: `msg-` + ULID (protocol §7.6 format contract). */
675
+ export function mintMessageId(): string {
676
+ return `msg-${ulid()}`;
677
+ }
678
+
679
+ function mintOutboundMessageId(_account: ResolvedOpenclawClawlingAccount): string {
680
+ return mintMessageId();
643
681
  }
644
682
 
645
683
  function resolveChannelOutboundStore(): OutboundClaimStore | null {