@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/dist/index.js CHANGED
@@ -16,5 +16,12 @@ export default defineChannelPluginEntry({
16
16
  registerOpenclawClawlingCommands(api);
17
17
  registerClawChatPromptInjection(api);
18
18
  registerOpenclawClawlingTools(api);
19
+ // NOTE: the legacy-rename config migration is intentionally NOT registered
20
+ // here. The host's setup-migration runner only loads a plugin's SETUP source
21
+ // (`openclaw.setupEntry`/`runtimeSetupEntry` → setup-entry.ts) and calls its
22
+ // `register(api)` in "setup-only" mode; registrations made in `registerFull`
23
+ // (full/tool-discovery modes) are never collected for migrations, and this
24
+ // gated full-load path doesn't run for the rename scenario anyway. The
25
+ // migration is wired into setup-entry.ts instead.
19
26
  },
20
27
  });
@@ -1,3 +1,33 @@
1
1
  import { defineSetupPluginEntry } from "openclaw/plugin-sdk/channel-core";
2
+ import { CHANNEL_ID } from "./src/config.js";
2
3
  import { openclawClawlingSetupPlugin } from "./src/channel.setup.js";
3
- export default defineSetupPluginEntry(openclawClawlingSetupPlugin);
4
+ import { migrateLegacyClawChatChannelConfig } from "./src/config-compat.js";
5
+ /**
6
+ * Host setup-source `register` hook.
7
+ *
8
+ * The OpenClaw setup-migration runner (`setup-registry`) loads this module as
9
+ * the plugin's setup source (resolved from `package.json` `openclaw.setupEntry`
10
+ * / `runtimeSetupEntry`), unwraps the default export, and — when that export is
11
+ * an object exposing a `register` function whose `id` matches the plugin id —
12
+ * calls `register(api)` in setup-only mode. This is the ONLY ungated path that
13
+ * collects config migrations for the plugin-rename scenario.
14
+ *
15
+ * Mirrors the host's own built-in pattern (e.g. amazon-bedrock setup-api.js:
16
+ * `api.registerConfigMigration((config) => migrateAmazonBedrockLegacyConfig(config))`).
17
+ */
18
+ export function register(api) {
19
+ api.registerConfigMigration?.((config) => migrateLegacyClawChatChannelConfig(config));
20
+ }
21
+ /**
22
+ * Default export consumed by BOTH host loaders of this setup source:
23
+ * - the channel-setup loader unwraps `default` and reads `.plugin`
24
+ * (`defineSetupPluginEntry` yields `{ plugin }`);
25
+ * - the setup-migration runner unwraps `default` and reads `.register`,
26
+ * rejecting it unless `.id` matches the plugin id.
27
+ * So the default export must carry `plugin`, `id`, and `register` together.
28
+ */
29
+ export default {
30
+ ...defineSetupPluginEntry(openclawClawlingSetupPlugin),
31
+ id: CHANNEL_ID,
32
+ register,
33
+ };
@@ -1,5 +1,129 @@
1
1
  import { ClawlingApiError, } from "./api-types.js";
2
2
  import { CHANNEL_ID } from "./config.js";
3
+ export function buildPluginReportBody(input) {
4
+ return {
5
+ device_id: input.deviceId,
6
+ platform: input.platform,
7
+ plugin_version: input.pluginVersion,
8
+ runtime_name: input.runtimeName,
9
+ runtime_version: input.runtimeVersion,
10
+ };
11
+ }
12
+ /**
13
+ * §A.0 — decode the access token's `exp` claim locally (base64url-decode the
14
+ * JWT payload segment, read `exp` as epoch seconds). Returns `null` when the
15
+ * token is not a parseable JWT or carries no numeric `exp`, in which case the
16
+ * caller falls back to `activated_at + 24h`. We never persist a separate
17
+ * expiry column; this is derived from the token on every load.
18
+ */
19
+ export function decodeJwtExp(token) {
20
+ if (typeof token !== "string")
21
+ return null;
22
+ const segments = token.split(".");
23
+ if (segments.length < 2)
24
+ return null;
25
+ const payloadSegment = segments[1];
26
+ if (!payloadSegment)
27
+ return null;
28
+ try {
29
+ const json = Buffer.from(payloadSegment, "base64url").toString("utf8");
30
+ const parsed = JSON.parse(json);
31
+ const exp = parsed?.exp;
32
+ return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }
38
+ /** Backend envelope codes for `POST /v1/auth/refresh` (§0). */
39
+ const CODE_OK = 0;
40
+ const CODE_INTERNAL = 1; // CodeInternal — transient.
41
+ const CODE_BAD_REQUEST = 400; // bad body / device id — permanent (client bug).
42
+ const CODE_INVALID_REFRESH = 10003; // CodeInvalidRefresh — permanent.
43
+ /**
44
+ * §0/§B — call `POST /v1/auth/refresh` to rotate the access+refresh token.
45
+ *
46
+ * Unauthenticated: the refresh token in the body IS the credential, so we send
47
+ * NO `Authorization` header. `X-Device-Id` MUST equal the connect-time device
48
+ * id (the backend rejects on `sess.DeviceID != X-Device-Id`). The endpoint is
49
+ * always HTTP 200 — branch on the envelope `code`, NOT on HTTP status. This is
50
+ * a standalone function (not a method on the token-bearing client) precisely
51
+ * because no bearer token participates.
52
+ */
53
+ export async function authRefresh(opts, params) {
54
+ const baseUrl = opts.baseUrl.replace(/\/+$/, "");
55
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
56
+ if (!params.refreshToken?.trim()) {
57
+ return { kind: "permanent", code: CODE_INVALID_REFRESH, message: "missing refresh token" };
58
+ }
59
+ let res;
60
+ try {
61
+ res = await fetchImpl(`${baseUrl}/v1/auth/refresh`, {
62
+ method: "POST",
63
+ // No `Authorization` header — refresh is unauthenticated by design.
64
+ headers: {
65
+ "content-type": "application/json",
66
+ "x-device-id": params.deviceId,
67
+ },
68
+ body: JSON.stringify({ refresh_token: params.refreshToken.trim() }),
69
+ });
70
+ }
71
+ catch (err) {
72
+ // Network error / timeout / DNS — TRANSIENT (no rotation committed).
73
+ return {
74
+ kind: "transient",
75
+ message: `refresh fetch failed: ${err instanceof Error ? err.message : String(err)}`,
76
+ };
77
+ }
78
+ if (res.status !== 200) {
79
+ // Any non-200 (500/LB/transport) — TRANSIENT, regardless of body.
80
+ const text = await res.text().catch(() => "");
81
+ return {
82
+ kind: "transient",
83
+ status: res.status,
84
+ message: `refresh http ${res.status} ${text.slice(0, 200)}`,
85
+ };
86
+ }
87
+ const text = await res.text().catch(() => "");
88
+ let parsed;
89
+ try {
90
+ parsed = text ? JSON.parse(text) : undefined;
91
+ }
92
+ catch {
93
+ parsed = undefined;
94
+ }
95
+ const code = typeof parsed?.code === "number" ? parsed.code : Number.NaN;
96
+ const message = (typeof parsed?.msg === "string" && parsed.msg) ||
97
+ (typeof parsed?.message === "string" && parsed.message) ||
98
+ `code=${code}`;
99
+ if (!Number.isFinite(code)) {
100
+ // 200 with no usable envelope — treat as transient (do not auto-logout).
101
+ return { kind: "transient", status: 200, message: "refresh: missing numeric code" };
102
+ }
103
+ if (code === CODE_OK) {
104
+ const data = parsed?.data && typeof parsed.data === "object"
105
+ ? parsed.data
106
+ : {};
107
+ const accessToken = typeof data.access_token === "string" ? data.access_token : "";
108
+ const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
109
+ if (!accessToken || !refreshToken) {
110
+ // Rotation succeeded server-side but the body is malformed — transient so
111
+ // we retry; the next attempt will return 10003 (rotation single-use) and
112
+ // escalate to permanent (§B transient→permanent).
113
+ return { kind: "transient", status: 200, message: "refresh: rotation body incomplete" };
114
+ }
115
+ return { kind: "success", accessToken, refreshToken };
116
+ }
117
+ if (code === CODE_INVALID_REFRESH || code === CODE_BAD_REQUEST) {
118
+ return { kind: "permanent", code, message };
119
+ }
120
+ if (code === CODE_INTERNAL) {
121
+ return { kind: "transient", status: 200, code, message };
122
+ }
123
+ // Unknown non-zero code — conservatively transient (never auto-logout on an
124
+ // unrecognized code; only 10003/400 are permanent per §0).
125
+ return { kind: "transient", status: 200, code, message };
126
+ }
3
127
  export function createOpenclawClawlingApiClient(opts) {
4
128
  if (!/^https?:\/\//i.test(opts.baseUrl)) {
5
129
  throw new ClawlingApiError("validation", `clawchat-plugin-openclaw baseUrl must start with http:// or https:// (got "${opts.baseUrl}")`);
@@ -33,44 +157,49 @@ export function createOpenclawClawlingApiClient(opts) {
33
157
  path,
34
158
  });
35
159
  }
36
- if (!res.ok) {
37
- const snippet = await res.text().catch(() => "");
38
- throw new ClawlingApiError("transport", `http ${res.status} ${snippet.slice(0, 200)}`, {
39
- status: res.status,
40
- path,
41
- });
42
- }
160
+ // §15.3: HTTP status stays meaningful for proxies, but application code
161
+ // should branch on the business `code`, not the HTTP status. Parse the body
162
+ // FIRST even on a non-2xx — so callers receive the precise business code
163
+ // (e.g. 41301 vs 41501) rather than a generic transport error. Only fall
164
+ // back to the HTTP status when no structured envelope is present.
165
+ const text = await res.text().catch(() => "");
43
166
  let parsed;
44
167
  try {
45
- parsed = await res.json();
168
+ parsed = text ? JSON.parse(text) : undefined;
46
169
  }
47
- catch (err) {
48
- throw new ClawlingApiError("transport", `non-JSON response: ${err instanceof Error ? err.message : String(err)}`, { status: res.status, path });
170
+ catch {
171
+ parsed = undefined;
49
172
  }
50
173
  // Unified envelope: `{ code: number, msg: string, data: T }`.
51
174
  // `code === 0` means success; any other value is a business error whose
52
175
  // `msg` is surfaced to callers and `code` is preserved on the error meta.
53
- const env = parsed;
54
- const code = typeof env.code === "number" ? env.code : Number.NaN;
55
- const msg = typeof env.msg === "string"
56
- ? env.msg
57
- : typeof env.message === "string"
58
- ? env.message
59
- : "";
60
- if (!Number.isFinite(code)) {
61
- throw new ClawlingApiError("transport", "invalid envelope: missing numeric `code`", {
62
- status: res.status,
63
- path,
64
- });
176
+ const env = parsed && typeof parsed === "object"
177
+ ? parsed
178
+ : undefined;
179
+ const code = typeof env?.code === "number" ? env.code : Number.NaN;
180
+ if (env && Number.isFinite(code)) {
181
+ const msg = typeof env.msg === "string"
182
+ ? env.msg
183
+ : typeof env.message === "string"
184
+ ? env.message
185
+ : "";
186
+ if (code !== 0) {
187
+ throw new ClawlingApiError("api", msg || `code=${code}`, {
188
+ code,
189
+ status: res.status,
190
+ path,
191
+ });
192
+ }
193
+ return env.data;
65
194
  }
66
- if (code !== 0) {
67
- throw new ClawlingApiError("api", msg || `code=${code}`, {
68
- code,
195
+ // No usable envelope — fall back to the HTTP status signal.
196
+ if (!res.ok) {
197
+ throw new ClawlingApiError("transport", `http ${res.status} ${text.slice(0, 200)}`, {
69
198
  status: res.status,
70
199
  path,
71
200
  });
72
201
  }
73
- return env.data;
202
+ throw new ClawlingApiError("transport", text ? "invalid envelope: missing numeric `code`" : "non-JSON response: empty body", { status: res.status, path });
74
203
  }
75
204
  async function call(method, path, init) {
76
205
  let res;
@@ -296,5 +425,14 @@ export function createOpenclawClawlingApiClient(opts) {
296
425
  fd.set("file", file);
297
426
  return await call("POST", "/v1/files/upload-url", { body: fd });
298
427
  },
428
+ async reportPlugin(input, opts) {
429
+ const path = opts?.authenticated
430
+ ? "/v1/agents/me/plugin-report"
431
+ : "/v1/agents/plugin-report";
432
+ await call("POST", path, {
433
+ headers: { "content-type": "application/json" },
434
+ body: JSON.stringify(buildPluginReportBody(input)),
435
+ });
436
+ },
299
437
  };
300
438
  }
@@ -8,10 +8,13 @@ export function resolveOpenclawClawlingDeviceId(account) {
8
8
  return `${CHANNEL_ID}-${digest}`;
9
9
  }
10
10
  export function createOpenclawClawlingClient(account, overrides = {}) {
11
+ const deviceId = overrides.deviceIdOverride && overrides.deviceIdOverride.trim()
12
+ ? overrides.deviceIdOverride.trim()
13
+ : resolveOpenclawClawlingDeviceId(account);
11
14
  const client = createClawChatClient({
12
15
  url: account.websocketUrl,
13
16
  token: account.token,
14
- deviceId: resolveOpenclawClawlingDeviceId(account),
17
+ deviceId,
15
18
  ...(overrides.transport ? { transport: overrides.transport } : {}),
16
19
  reconnect: {
17
20
  enabled: true,
@@ -0,0 +1,120 @@
1
+ import { CHANNEL_ID } from "./config.js";
2
+ /**
3
+ * Compatibility migration for the plugin rename (commit 260044f): the plugin
4
+ * `id` and channel key changed from the OLD `openclaw-clawchat` to the NEW
5
+ * `clawchat-plugin-openclaw` (= {@link CHANNEL_ID}).
6
+ *
7
+ * Users upgrading from the old version have all their state — channel block
8
+ * (token/userId/ownerUserId/refreshToken/…), `plugins.allow`,
9
+ * `plugins.entries`, and `tools.allow`/`tools.alsoAllow` — keyed under the old
10
+ * id. The new code only reads the new id, so the channel silently fails to
11
+ * load. This migration moves the old-keyed state onto the new id.
12
+ *
13
+ * The function is PURE: it clones the input via `structuredClone` and never
14
+ * mutates its argument. It is IDEMPOTENT: a config with no old id anywhere
15
+ * returns an equivalent config with `changes: []`.
16
+ */
17
+ export const LEGACY_CHANNEL_ID = "openclaw-clawchat";
18
+ export const TARGET_CHANNEL_ID = CHANNEL_ID;
19
+ const BLOCKED_OBJECT_KEYS = new Set(["__proto__", "prototype", "constructor"]);
20
+ function isBlockedObjectKey(key) {
21
+ return BLOCKED_OBJECT_KEYS.has(key);
22
+ }
23
+ function isRecord(value) {
24
+ return typeof value === "object" && value !== null && !Array.isArray(value);
25
+ }
26
+ function isNonEmptyRecord(value) {
27
+ return isRecord(value) && Object.keys(value).length > 0;
28
+ }
29
+ /**
30
+ * Copy keys from `source` into `target` only when they are absent/undefined in
31
+ * `target` (target is more current — never overwrite an existing value).
32
+ * Recurses into nested records. Skips prototype-pollution keys.
33
+ */
34
+ function mergeMissing(target, source) {
35
+ for (const [key, value] of Object.entries(source)) {
36
+ if (value === undefined || isBlockedObjectKey(key))
37
+ continue;
38
+ const existing = target[key];
39
+ if (existing === undefined) {
40
+ target[key] = value;
41
+ continue;
42
+ }
43
+ if (isRecord(existing) && isRecord(value))
44
+ mergeMissing(existing, value);
45
+ }
46
+ }
47
+ /** Replace `from` → `to` in a string array, preserving order and deduping. */
48
+ function replaceAndDedup(list, from, to) {
49
+ const out = [];
50
+ for (const raw of list) {
51
+ const value = raw === from ? to : raw;
52
+ if (!out.includes(value))
53
+ out.push(value);
54
+ }
55
+ return out;
56
+ }
57
+ export function migrateLegacyClawChatChannelConfig(config) {
58
+ const changes = [];
59
+ if (!isRecord(config))
60
+ return { config, changes };
61
+ const next = structuredClone(config);
62
+ // 1. channels — move/merge the old channel block onto the new id.
63
+ const channels = next.channels;
64
+ if (isRecord(channels)) {
65
+ const oldChannel = channels[LEGACY_CHANNEL_ID];
66
+ if (isNonEmptyRecord(oldChannel)) {
67
+ const newChannel = channels[TARGET_CHANNEL_ID];
68
+ if (!isNonEmptyRecord(newChannel)) {
69
+ channels[TARGET_CHANNEL_ID] = oldChannel;
70
+ changes.push(`Moved channel "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`);
71
+ }
72
+ else {
73
+ mergeMissing(newChannel, oldChannel);
74
+ changes.push(`Merged channel "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}" (filled missing fields; kept existing values).`);
75
+ }
76
+ delete channels[LEGACY_CHANNEL_ID];
77
+ }
78
+ }
79
+ // plugins.* live under a single `plugins` record.
80
+ const plugins = next.plugins;
81
+ if (isRecord(plugins)) {
82
+ // 2. plugins.allow — replace old id with new id (append if missing), dedup.
83
+ const allow = plugins.allow;
84
+ if (Array.isArray(allow) && allow.includes(LEGACY_CHANNEL_ID)) {
85
+ const replaced = replaceAndDedup(allow.filter((v) => typeof v === "string"), LEGACY_CHANNEL_ID, TARGET_CHANNEL_ID);
86
+ plugins.allow = replaced;
87
+ changes.push(`Updated plugins.allow: "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`);
88
+ }
89
+ // 3. plugins.entries — merge-missing the old entry into the new one.
90
+ const entries = plugins.entries;
91
+ if (isRecord(entries)) {
92
+ const oldEntry = entries[LEGACY_CHANNEL_ID];
93
+ if (oldEntry !== undefined) {
94
+ if (isRecord(oldEntry)) {
95
+ const newEntry = entries[TARGET_CHANNEL_ID];
96
+ if (!isRecord(newEntry)) {
97
+ entries[TARGET_CHANNEL_ID] = oldEntry;
98
+ }
99
+ else {
100
+ mergeMissing(newEntry, oldEntry);
101
+ }
102
+ }
103
+ delete entries[LEGACY_CHANNEL_ID];
104
+ changes.push(`Merged plugins.entries["${LEGACY_CHANNEL_ID}"] → plugins.entries["${TARGET_CHANNEL_ID}"].`);
105
+ }
106
+ }
107
+ }
108
+ // 4. tools.allow + tools.alsoAllow — replace old id with new id, dedup.
109
+ const tools = next.tools;
110
+ if (isRecord(tools)) {
111
+ for (const key of ["allow", "alsoAllow"]) {
112
+ const list = tools[key];
113
+ if (Array.isArray(list) && list.includes(LEGACY_CHANNEL_ID)) {
114
+ tools[key] = replaceAndDedup(list.filter((v) => typeof v === "string"), LEGACY_CHANNEL_ID, TARGET_CHANNEL_ID);
115
+ changes.push(`Updated tools.${key}: "${LEGACY_CHANNEL_ID}" → "${TARGET_CHANNEL_ID}".`);
116
+ }
117
+ }
118
+ }
119
+ return { config: next, changes };
120
+ }
@@ -10,8 +10,10 @@ function normalizeSender(sender) {
10
10
  if (!id)
11
11
  return null;
12
12
  const nickName = typeof s.nick_name === "string" ? s.nick_name : id;
13
- const profileType = s.type === "agent" || s.type === "user" ? s.type : null;
14
- return { id, nickName, profileType };
13
+ // §4.1: `sender.type` is the server-stamped routing type and is always
14
+ // "direct" it never carries the human/agent distinction. The sender's
15
+ // profile_type is resolved downstream from chat metadata, not from the wire.
16
+ return { id, nickName };
15
17
  }
16
18
  function requireChatId(envelope) {
17
19
  const chatId = envelope.chat_id;
@@ -85,6 +87,18 @@ export async function dispatchOpenclawClawlingInbound(params) {
85
87
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip non-business event=${envelope.event} trace=${envelope.trace_id}`);
86
88
  return;
87
89
  }
90
+ // Fail-closed self-echo guard: the self-echo check below
91
+ // (`sender.id === account.userId`) is only meaningful when `account.userId`
92
+ // is known. A reactivation auth failure can leave `account.userId` as an
93
+ // empty string (see runtime.ts reactivation edge); with an empty userId the
94
+ // `account.userId && …` short-circuit would silently treat EVERY frame —
95
+ // including our own echoed messages — as non-self and feed it back into the
96
+ // LLM pipeline (self-reply loop). Refuse to process materialized messages in
97
+ // that state rather than risk echoing our own output.
98
+ if (!account.userId) {
99
+ log?.error?.(`[${account.accountId}] clawchat-plugin-openclaw skip (fail-closed): empty account.userId, cannot apply self-echo guard event=${envelope.event} trace=${envelope.trace_id}`);
100
+ return;
101
+ }
88
102
  if (isMaterializedMessage && !isInboundMessagePayload(envelope.payload)) {
89
103
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip: invalid payload trace=${envelope.trace_id}`);
90
104
  return;
@@ -107,7 +121,11 @@ export async function dispatchOpenclawClawlingInbound(params) {
107
121
  }
108
122
  const chatType = envelope.chat_type === "group" ? "group" : "direct";
109
123
  const isGroup = chatType === "group";
110
- if (isMaterializedMessage && payload.message_mode !== "normal") {
124
+ // §7.5: the server does not default message_mode an omitted field arrives
125
+ // as "" on the downlink. Empty/absent is equivalent to "normal", so only
126
+ // skip genuinely non-normal modes (e.g. "thinking").
127
+ const messageMode = payload.message_mode ?? "";
128
+ if (isMaterializedMessage && messageMode !== "normal" && messageMode !== "") {
111
129
  log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw skip non-normal mode=${payload.message_mode}`);
112
130
  return;
113
131
  }
@@ -152,7 +170,6 @@ export async function dispatchOpenclawClawlingInbound(params) {
152
170
  peer: { kind: isGroup ? "group" : "direct", id: chatId },
153
171
  senderId: sender.id,
154
172
  senderNickName: sender.nickName,
155
- ...(sender.profileType ? { senderProfileType: sender.profileType } : {}),
156
173
  rawBody,
157
174
  messageId: payload.message_id,
158
175
  traceId: envelope.trace_id,
@@ -197,6 +197,10 @@ export async function runOpenclawClawlingLogin(params) {
197
197
  refreshToken: normalizedResult.refresh_token || null,
198
198
  conversationId: normalizedResult.conversation?.id ?? null,
199
199
  loginMethod: "login",
200
+ // §E — record the exact `X-Device-Id` sent at connect (the constant
201
+ // `CHANNEL_ID`, see `authHeaders` in api-client.ts), so a later refresh
202
+ // sends the same device id the backend baked into the session.
203
+ deviceId: CHANNEL_ID,
200
204
  });
201
205
  }
202
206
  catch {
@@ -1,3 +1,4 @@
1
+ import { randomInt } from "node:crypto";
1
2
  import { MessageSendError } from "./protocol-types.js";
2
3
  import { createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result";
3
4
  import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
@@ -198,8 +199,13 @@ async function sendAlignedAckableEnvelope(params) {
198
199
  return;
199
200
  const payload = ack.payload;
200
201
  const code = typeof payload.code === "string" && payload.code ? payload.code : "unknown";
201
- const message = typeof payload.message === "string" && payload.message ? payload.message : "message send failed";
202
- fail(new MessageSendError(traceId, code, message, ack.chat_id));
202
+ // §14.3: the human-readable hint is `reason` (fall back to legacy `message`).
203
+ const hint = typeof payload.reason === "string" && payload.reason
204
+ ? payload.reason
205
+ : typeof payload.message === "string" && payload.message
206
+ ? payload.message
207
+ : "message send failed";
208
+ fail(new MessageSendError(traceId, code, hint, ack.chat_id));
203
209
  return;
204
210
  }
205
211
  if (ack.event !== "message.ack")
@@ -354,13 +360,18 @@ export async function sendOpenclawClawlingText(params) {
354
360
  const useReply = Boolean(params.replyCtx?.replyPreviewSenderId
355
361
  && params.replyCtx.replyPreviewNickName
356
362
  && params.replyCtx.replyPreviewText);
357
- const messageId = params.messageId;
363
+ // Outbound message_id is ALWAYS present (at-least-once delivery, protocol
364
+ // §3.1.9): the server's inbox UNIQUE(recipient, message_id) absorbs a
365
+ // bounded resend of the same frame as one coalesced row. A bounded resend
366
+ // (non-terminal socket close → re-enqueue of the same captured wire) reuses
367
+ // this exact id, so a duplicate write is deduped rather than fanned out.
368
+ const messageId = params.messageId ?? mintMessageId();
358
369
  let ack;
359
370
  let mode;
360
371
  if (useReply && params.replyCtx) {
361
372
  mode = "reply";
362
373
  const payload = {
363
- ...(messageId ? { message_id: messageId } : {}),
374
+ message_id: messageId,
364
375
  message_mode: "normal",
365
376
  message: {
366
377
  body: { fragments },
@@ -395,7 +406,7 @@ export async function sendOpenclawClawlingText(params) {
395
406
  }
396
407
  : null;
397
408
  const payload = {
398
- ...(messageId ? { message_id: messageId } : {}),
409
+ message_id: messageId,
399
410
  message_mode: "normal",
400
411
  message: {
401
412
  body: { fragments },
@@ -411,7 +422,7 @@ export async function sendOpenclawClawlingText(params) {
411
422
  ...(params.log ? { log: params.log } : {}),
412
423
  });
413
424
  }
414
- if (messageId && ack.payload.message_id !== messageId) {
425
+ if (ack.payload.message_id !== messageId) {
415
426
  throw new Error(`ack message_id mismatch: expected ${messageId} got ${ack.payload.message_id}`);
416
427
  }
417
428
  params.log?.info?.(`[${params.account.accountId}] clawchat-plugin-openclaw outbound mode=${mode} msg=${ack.payload.message_id} text_len=${text.length} media=${mediaFragments.length} trace=${ack.trace_id}`);
@@ -474,8 +485,32 @@ export async function sendOpenclawClawlingMedia(params) {
474
485
  ...(params.log ? { log: params.log } : {}),
475
486
  });
476
487
  }
477
- function mintOutboundMessageId(account) {
478
- return `${account.userId}-msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
488
+ // Crockford base32 alphabet (ULID spec).
489
+ const ULID_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
490
+ /**
491
+ * Generate a Canonical ULID: 10 chars of millisecond timestamp + 16 chars of
492
+ * randomness, Crockford base32, 26 chars total. Implemented locally (no extra
493
+ * dependency) because this repo has no ULID dep and only needs minting, not
494
+ * monotonic ordering or decoding.
495
+ */
496
+ function ulid(now = Date.now()) {
497
+ let time = now;
498
+ const out = new Array(26);
499
+ for (let i = 9; i >= 0; i--) {
500
+ out[i] = ULID_ENCODING[time % 32];
501
+ time = Math.floor(time / 32);
502
+ }
503
+ for (let i = 10; i < 26; i++) {
504
+ out[i] = ULID_ENCODING[randomInt(0, 32)];
505
+ }
506
+ return out.join("");
507
+ }
508
+ /** Client-minted message id: `msg-` + ULID (protocol §7.6 format contract). */
509
+ export function mintMessageId() {
510
+ return `msg-${ulid()}`;
511
+ }
512
+ function mintOutboundMessageId(_account) {
513
+ return mintMessageId();
479
514
  }
480
515
  function resolveChannelOutboundStore() {
481
516
  try {
@@ -0,0 +1,36 @@
1
+ import { createRequire } from "node:module";
2
+ import { createOpenclawClawlingApiClient } from "./api-client.js";
3
+ /** Best-effort read of this plugin's package version; "unknown" on failure. */
4
+ export function resolvePluginVersion() {
5
+ try {
6
+ const require = createRequire(import.meta.url);
7
+ const pkg = require("../package.json");
8
+ return pkg.version ?? "unknown";
9
+ }
10
+ catch {
11
+ return "unknown";
12
+ }
13
+ }
14
+ /**
15
+ * Fire one plugin-version report. Best-effort: any failure is logged at debug
16
+ * and swallowed so it can never block, delay, or crash gateway startup.
17
+ */
18
+ export async function reportPluginVersionSafe(p) {
19
+ try {
20
+ const client = createOpenclawClawlingApiClient({
21
+ baseUrl: p.baseUrl,
22
+ mediaBaseUrl: p.mediaBaseUrl,
23
+ token: p.token,
24
+ });
25
+ await client.reportPlugin({
26
+ deviceId: p.deviceId,
27
+ platform: "openclaw",
28
+ pluginVersion: p.pluginVersion,
29
+ runtimeName: "node",
30
+ runtimeVersion: process.version,
31
+ }, { authenticated: p.authenticated });
32
+ }
33
+ catch (err) {
34
+ p.log?.debug?.(`clawchat-plugin-openclaw plugin report failed (authenticated=${p.authenticated}): ${err instanceof Error ? err.message : String(err)}`);
35
+ }
36
+ }
@@ -13,6 +13,8 @@ export const EVENT = {
13
13
  MESSAGE_FAILED: "message.failed",
14
14
  TYPING_UPDATE: "typing.update",
15
15
  CHAT_METADATA_INVALIDATED: "chat.metadata.invalidated",
16
+ NOTIFY_SIGNAL: "notify.signal",
17
+ REPLAY_DONE: "replay.done",
16
18
  OFFLINE_BATCH: "offline.batch",
17
19
  OFFLINE_ACK: "offline.ack",
18
20
  OFFLINE_DONE: "offline.done",