@clawling/clawchat-plugin-openclaw 2026.5.13-dev.0 → 2026.5.13-dev.2

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.
@@ -126,6 +126,13 @@ ALTER TABLE clawchat_messages ADD COLUMN send_status TEXT;
126
126
  ALTER TABLE clawchat_messages ADD COLUMN protocol_message_id TEXT;
127
127
  ALTER TABLE clawchat_messages ADD COLUMN acked_at INTEGER;
128
128
  ALTER TABLE clawchat_messages ADD COLUMN send_error TEXT;
129
+ `,
130
+ },
131
+ {
132
+ version: 7,
133
+ name: "activation_device_id",
134
+ sql: `
135
+ ALTER TABLE activations ADD COLUMN device_id TEXT;
129
136
  `,
130
137
  },
131
138
  ];
@@ -193,12 +200,13 @@ export class ClawChatStore {
193
200
  const ownerUserId = input.ownerUserId?.trim() || null;
194
201
  const accessToken = input.accessToken?.trim() || null;
195
202
  const refreshToken = input.refreshToken?.trim() || null;
203
+ const deviceId = input.deviceId?.trim() || null;
196
204
  this.requireDb()
197
205
  .prepare(`INSERT INTO activations(
198
206
  platform, account_id, user_id, owner_user_id, access_token, refresh_token,
199
207
  activated_at, login_method, conversation_id, bootstrap_sent,
200
- bootstrap_claimed_at, updated_at
201
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
208
+ bootstrap_claimed_at, device_id, updated_at
209
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
202
210
  ON CONFLICT(platform, account_id) DO UPDATE SET
203
211
  user_id = excluded.user_id,
204
212
  owner_user_id = excluded.owner_user_id,
@@ -209,15 +217,53 @@ export class ClawChatStore {
209
217
  conversation_id = excluded.conversation_id,
210
218
  bootstrap_sent = excluded.bootstrap_sent,
211
219
  bootstrap_claimed_at = NULL,
220
+ device_id = excluded.device_id,
212
221
  updated_at = excluded.updated_at`)
213
- .run(input.platform, input.accountId, userId, ownerUserId, accessToken, refreshToken, now, input.loginMethod ?? null, conversationId, conversationId ? 0 : 1, null, now);
222
+ .run(input.platform, input.accountId, userId, ownerUserId, accessToken, refreshToken, now, input.loginMethod ?? null, conversationId, conversationId ? 0 : 1, null, deviceId, now);
214
223
  void conversationId;
215
224
  });
216
225
  }
226
+ /**
227
+ * §0/§A.3 — persist a rotated access+refresh pair durably BEFORE the caller
228
+ * swaps the in-memory token. Identity columns are untouched. Returns whether
229
+ * a row was updated (false ⇒ no activation row exists yet).
230
+ */
231
+ rotateActivationTokens(input) {
232
+ return this.write(() => {
233
+ const now = input.rotatedAt ?? Date.now();
234
+ const result = this.requireDb()
235
+ .prepare(`UPDATE activations SET
236
+ access_token = ?,
237
+ refresh_token = ?,
238
+ activated_at = ?,
239
+ updated_at = ?
240
+ WHERE platform = ? AND account_id = ?`)
241
+ .run(input.accessToken, input.refreshToken, now, now, input.platform, input.accountId);
242
+ return result.changes > 0;
243
+ });
244
+ }
245
+ /**
246
+ * §C.1 — auto-logout: blank the `access_token` / `refresh_token` columns so
247
+ * the agent can no longer mint tokens, but KEEP `user_id` / `owner_user_id` /
248
+ * `device_id` so a `/clawchat-activate <code>` re-pair reuses the identity.
249
+ */
250
+ clearActivationCredentials(input) {
251
+ return this.write(() => {
252
+ const now = Date.now();
253
+ const result = this.requireDb()
254
+ .prepare(`UPDATE activations SET
255
+ access_token = NULL,
256
+ refresh_token = NULL,
257
+ updated_at = ?
258
+ WHERE platform = ? AND account_id = ?`)
259
+ .run(now, input.platform, input.accountId);
260
+ return result.changes > 0;
261
+ });
262
+ }
217
263
  getActivationCredentials(input) {
218
264
  return this.read(() => {
219
265
  const row = this.requireDb()
220
- .prepare(`SELECT user_id, owner_user_id, access_token, refresh_token
266
+ .prepare(`SELECT user_id, owner_user_id, access_token, refresh_token, activated_at, device_id
221
267
  FROM activations
222
268
  WHERE platform = ?
223
269
  AND account_id = ?`)
@@ -228,9 +274,15 @@ export class ClawChatStore {
228
274
  const refreshToken = typeof row?.refresh_token === "string" && row.refresh_token.trim()
229
275
  ? row.refresh_token.trim()
230
276
  : null;
277
+ const activatedAt = typeof row?.activated_at === "number" && Number.isFinite(row.activated_at)
278
+ ? row.activated_at
279
+ : null;
280
+ const deviceId = typeof row?.device_id === "string" && row.device_id.trim()
281
+ ? row.device_id.trim()
282
+ : null;
231
283
  if (!userId || !ownerUserId || !accessToken)
232
284
  return null;
233
- return { userId, ownerUserId, accessToken, refreshToken };
285
+ return { userId, ownerUserId, accessToken, refreshToken, activatedAt, deviceId };
234
286
  }) ?? null;
235
287
  }
236
288
  getActivationConversation(input) {
@@ -398,6 +450,30 @@ export class ClawChatStore {
398
450
  .run(input.state, now, input.closeCode ?? null, input.closeReason ?? null, input.error ?? null, now, connectionId);
399
451
  });
400
452
  }
453
+ /**
454
+ * Return the most recent server-resolved `device_id` recorded from a
455
+ * `hello-ok` for this account, if any. Reused on reconnect so a pod restart
456
+ * (which mints a fresh hostname and therefore a fresh derived device id)
457
+ * does not present a brand-new device to the server — that would trigger a
458
+ * full inbox replay and orphan the previous device's cursor.
459
+ */
460
+ getLastResolvedDeviceId(input) {
461
+ return this.read(() => {
462
+ const row = this.requireDb()
463
+ .prepare(`SELECT resolved_device_id
464
+ FROM connections
465
+ WHERE platform = ?
466
+ AND account_id = ?
467
+ AND resolved_device_id IS NOT NULL
468
+ AND resolved_device_id <> ''
469
+ ORDER BY id DESC
470
+ LIMIT 1`)
471
+ .get(input.platform, input.accountId);
472
+ return typeof row?.resolved_device_id === "string" && row.resolved_device_id.trim()
473
+ ? row.resolved_device_id.trim()
474
+ : null;
475
+ }) ?? null;
476
+ }
401
477
  recordToolCall(input) {
402
478
  this.write(() => {
403
479
  const startedAt = input.startedAt ?? Date.now();
@@ -159,7 +159,8 @@ export function createProtocolControlHandler(options) {
159
159
  version: "2",
160
160
  event: "pong",
161
161
  trace_id: env.trace_id ?? "-",
162
- emitted_at: Date.now(),
162
+ // §12: echo the sender's emitted_at verbatim (do not restamp).
163
+ emitted_at: env.emitted_at ?? Date.now(),
163
164
  payload: {},
164
165
  }));
165
166
  return true;
@@ -329,6 +329,10 @@ export class ClawChatClient extends EventEmitter {
329
329
  // Agent runtime is single-device: multi_device stays off so the server
330
330
  // never self-fans-out this connection's own messages. notify_signals is
331
331
  // advertised because we now handle the notify.signal frame (§9.4).
332
+ // history_sync (§11.4) is intentionally omitted: it is only required to
333
+ // *send* history.transit, which a single-device agent never does, and we
334
+ // do not handle inbound history.transit — advertising it would invite
335
+ // frames we cannot process. This is a deliberate, spec-legal omission.
332
336
  capabilities: {
333
337
  multi_device: false,
334
338
  device_replay: true,
@@ -386,7 +390,34 @@ export class ClawChatClient extends EventEmitter {
386
390
  this.failHandshake(new ProtocolError("invalid hello-fail payload", env), 4002, "protocol error");
387
391
  return;
388
392
  }
389
- const err = new AuthError(typeof reason === "string" ? reason : "authentication failed");
393
+ // §14.1: distinguish upstream auth-service unavailability (5xx) from token
394
+ // rejection (4xx). On a 5xx the token may still be valid and the auth backend
395
+ // (member-backend) is down — backoff-reconnect with the SAME token and do
396
+ // NOT refresh (a 5xx storm must not become a mass token-refresh storm). Until
397
+ // the server emits the distinct 5xx reason, every other hello-fail is treated
398
+ // as a terminal token rejection (the caller acquires a fresh token first).
399
+ if (/auth service unavailable/i.test(reason)) {
400
+ const err = new TransportError(reason);
401
+ this.expectedConnectTraceId = undefined;
402
+ this.clearTimers();
403
+ this.rejectPending(err);
404
+ this.connectReject?.(err);
405
+ this.connectResolve = undefined;
406
+ this.connectReject = undefined;
407
+ this.emitError(err);
408
+ if (this.opts.transport.state !== "closed") {
409
+ // Close WITHOUT marking closing/authFailed so handleClose backoff-reconnects.
410
+ this.opts.transport.close(4001, "auth service unavailable");
411
+ }
412
+ else if (!this.closing && this.opts.reconnect.enabled) {
413
+ this.scheduleReconnect(reason);
414
+ }
415
+ else {
416
+ this.transition("disconnected");
417
+ }
418
+ return;
419
+ }
420
+ const err = new AuthError(reason);
390
421
  this.authFailed = true;
391
422
  this.expectedConnectTraceId = undefined;
392
423
  this.sendQueue.length = 0;
@@ -433,10 +464,17 @@ export class ClawChatClient extends EventEmitter {
433
464
  }
434
465
  clearTimeout(entry.timer);
435
466
  this.pending.delete(env.trace_id);
436
- const payload = env.payload && typeof env.payload === "object" ? env.payload : undefined;
467
+ const payload = env.payload && typeof env.payload === "object"
468
+ ? env.payload
469
+ : undefined;
437
470
  const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
438
- const message = typeof payload?.message === "string" && payload.message ? payload.message : "message send failed";
439
- entry.reject(new MessageSendError(env.trace_id, code, message, env.chat_id));
471
+ // §14.3: the human-readable hint is `reason` (fall back to legacy `message`).
472
+ const hint = typeof payload?.reason === "string" && payload.reason
473
+ ? payload.reason
474
+ : typeof payload?.message === "string" && payload.message
475
+ ? payload.message
476
+ : "message send failed";
477
+ entry.reject(new MessageSendError(env.trace_id, code, hint, env.chat_id));
440
478
  }
441
479
  startHeartbeat() {
442
480
  if (!this.opts.heartbeat.enabled)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.5.13-dev.0",
3
+ "version": "2026.5.13-dev.2",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "files": [
package/src/api-client.ts CHANGED
@@ -28,6 +28,47 @@ export interface ApiClientOptions {
28
28
  fetchImpl?: typeof fetch;
29
29
  }
30
30
 
31
+ /**
32
+ * §A.0 — decode the access token's `exp` claim locally (base64url-decode the
33
+ * JWT payload segment, read `exp` as epoch seconds). Returns `null` when the
34
+ * token is not a parseable JWT or carries no numeric `exp`, in which case the
35
+ * caller falls back to `activated_at + 24h`. We never persist a separate
36
+ * expiry column; this is derived from the token on every load.
37
+ */
38
+ export function decodeJwtExp(token: string): number | null {
39
+ if (typeof token !== "string") return null;
40
+ const segments = token.split(".");
41
+ if (segments.length < 2) return null;
42
+ const payloadSegment = segments[1];
43
+ if (!payloadSegment) return null;
44
+ try {
45
+ const json = Buffer.from(payloadSegment, "base64url").toString("utf8");
46
+ const parsed = JSON.parse(json) as { exp?: unknown };
47
+ const exp = parsed?.exp;
48
+ return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * §0/§B — outcome classes for `POST /v1/auth/refresh`. The endpoint is
56
+ * always HTTP 200; callers branch on the envelope `code`. Distinct kinds keep
57
+ * the permanent/transient decision (and auto-logout vs. backoff) explicit.
58
+ */
59
+ export type AuthRefreshResult =
60
+ | { kind: "success"; accessToken: string; refreshToken: string }
61
+ // `code:10003` invalid refresh OR `code:400` bad request — PERMANENT.
62
+ | { kind: "permanent"; code: number; message: string }
63
+ // `code:1` internal, any non-200, or a network error — TRANSIENT.
64
+ | { kind: "transient"; message: string; status?: number; code?: number };
65
+
66
+ export interface AuthRefreshParams {
67
+ refreshToken: string;
68
+ /** The connect-time `X-Device-Id` (OpenClaw: `CHANNEL_ID`). */
69
+ deviceId: string;
70
+ }
71
+
31
72
  export interface OpenclawClawlingApiClient {
32
73
  getMyProfile(): Promise<Profile>;
33
74
  getAgentProfile(agentId: string): Promise<{ agent: AgentProfile }>;
@@ -92,6 +133,102 @@ export interface OpenclawClawlingApiClient {
92
133
  }): Promise<AvatarUploadResult>;
93
134
  }
94
135
 
136
+ /** Backend envelope codes for `POST /v1/auth/refresh` (§0). */
137
+ const CODE_OK = 0;
138
+ const CODE_INTERNAL = 1; // CodeInternal — transient.
139
+ const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
140
+ const CODE_INVALID_REFRESH = 10003; // CodeInvalidRefresh — permanent.
141
+
142
+ /**
143
+ * §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
144
+ *
145
+ * Unauthenticated: the refresh token in the body IS the credential, so we send
146
+ * NO `Authorization` header. `X-Device-Id` MUST equal the connect-time device
147
+ * id (the backend rejects on `sess.DeviceID != X-Device-Id`). The endpoint is
148
+ * always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
149
+ * a standalone function (not a method on the token-bearing client) precisely
150
+ * because no bearer token participates.
151
+ */
152
+ export async function authRefresh(
153
+ opts: { baseUrl: string; fetchImpl?: typeof fetch },
154
+ params: AuthRefreshParams,
155
+ ): Promise<AuthRefreshResult> {
156
+ const baseUrl = opts.baseUrl.replace(/\/+$/, "");
157
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
158
+ if (!params.refreshToken?.trim()) {
159
+ return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
160
+ }
161
+ let res: Response;
162
+ try {
163
+ res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
164
+ method: "POST",
165
+ // No `Authorization` header — refresh is unauthenticated by design.
166
+ headers: {
167
+ "content-type": "application/json",
168
+ "x-device-id": params.deviceId,
169
+ },
170
+ body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
171
+ });
172
+ } catch (err) {
173
+ // Network error / timeout / DNS — TRANSIENT (no rotation committed).
174
+ return {
175
+ kind: "transient",
176
+ message: `refresh fetch failed: ${err instanceof Error ? err.message : String(err)}`,
177
+ };
178
+ }
179
+
180
+ if (res.status !== 200) {
181
+ // Any non-200 (500/LB/transport) — TRANSIENT, regardless of body.
182
+ const text = await res.text().catch(() => "");
183
+ return {
184
+ kind: "transient",
185
+ status: res.status,
186
+ message: `refresh http ${res.status} ${text.slice(0, 200)}`,
187
+ };
188
+ }
189
+
190
+ const text = await res.text().catch(() => "");
191
+ let parsed: { code?: unknown; msg?: unknown; message?: unknown; data?: unknown } | undefined;
192
+ try {
193
+ parsed = text ? (JSON.parse(text) as typeof parsed) : undefined;
194
+ } catch {
195
+ parsed = undefined;
196
+ }
197
+ const code = typeof parsed?.code === "number" ? parsed.code : Number.NaN;
198
+ const message =
199
+ (typeof parsed?.msg === "string" && parsed.msg) ||
200
+ (typeof parsed?.message === "string" && parsed.message) ||
201
+ `code=${code}`;
202
+
203
+ if (!Number.isFinite(code)) {
204
+ // 200 with no usable envelope — treat as transient (do not auto-logout).
205
+ return { kind: "transient", status: 200, message: "refresh: missing numeric code" };
206
+ }
207
+ if (code === CODE_OK) {
208
+ const data = parsed?.data && typeof parsed.data === "object"
209
+ ? (parsed.data as { access_token?: unknown; refresh_token?: unknown })
210
+ : {};
211
+ const accessToken = typeof data.access_token === "string" ? data.access_token : "";
212
+ const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
213
+ if (!accessToken || !refreshToken) {
214
+ // Rotation succeeded server-side but the body is malformed — transient so
215
+ // we retry; the next attempt will return 10003 (rotation single-use) and
216
+ // escalate to permanent (§B transient→permanent).
217
+ return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
218
+ }
219
+ return { kind: "success", accessToken, refreshToken };
220
+ }
221
+ if (code === CODE_INVALID_REFRESH || code === CODE_BAD_REQUEST) {
222
+ return { kind: "permanent", code, message };
223
+ }
224
+ if (code === CODE_INTERNAL) {
225
+ return { kind: "transient", status: 200, code, message };
226
+ }
227
+ // Unknown non-zero code — conservatively transient (never auto-logout on an
228
+ // unrecognized code; only 10003/400 are permanent per §0).
229
+ return { kind: "transient", status: 200, code, message };
230
+ }
231
+
95
232
  export function createOpenclawClawlingApiClient(opts: ApiClientOptions): OpenclawClawlingApiClient {
96
233
  if (!/^https?:\/\//i.test(opts.baseUrl)) {
97
234
  throw new ClawlingApiError(
@@ -134,48 +271,54 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
134
271
  path,
135
272
  });
136
273
  }
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
- }
274
+ // §15.3: HTTP status stays meaningful for proxies, but application code
275
+ // should branch on the business `code`, not the HTTP status. Parse the body
276
+ // FIRST even on a non-2xx — so callers receive the precise business code
277
+ // (e.g. 41301 vs 41501) rather than a generic transport error. Only fall
278
+ // back to the HTTP status when no structured envelope is present.
279
+ const text = await res.text().catch(() => "");
144
280
  let parsed: unknown;
145
281
  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
- );
282
+ parsed = text ? JSON.parse(text) : undefined;
283
+ } catch {
284
+ parsed = undefined;
153
285
  }
154
286
  // Unified envelope: `{ code: number, msg: string, data: T }`.
155
287
  // `code === 0` means success; any other value is a business error whose
156
288
  // `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
- });
289
+ const env =
290
+ parsed && typeof parsed === "object"
291
+ ? (parsed as { code?: unknown; msg?: unknown; message?: unknown; data?: T })
292
+ : undefined;
293
+ const code = typeof env?.code === "number" ? env.code : Number.NaN;
294
+ if (env && Number.isFinite(code)) {
295
+ const msg =
296
+ typeof env.msg === "string"
297
+ ? env.msg
298
+ : typeof env.message === "string"
299
+ ? env.message
300
+ : "";
301
+ if (code !== 0) {
302
+ throw new ClawlingApiError("api", msg || `code=${code}`, {
303
+ code,
304
+ status: res.status,
305
+ path,
306
+ });
307
+ }
308
+ return env.data as T;
170
309
  }
171
- if (code !== 0) {
172
- throw new ClawlingApiError("api", msg || `code=${code}`, {
173
- code,
310
+ // No usable envelope — fall back to the HTTP status signal.
311
+ if (!res.ok) {
312
+ throw new ClawlingApiError("transport", `http ${res.status} ${text.slice(0, 200)}`, {
174
313
  status: res.status,
175
314
  path,
176
315
  });
177
316
  }
178
- return env.data as T;
317
+ throw new ClawlingApiError(
318
+ "transport",
319
+ text ? "invalid envelope: missing numeric `code`" : "non-JSON response: empty body",
320
+ { status: res.status, path },
321
+ );
179
322
  }
180
323
 
181
324
  async function call<T>(
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,
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 {