@clawling/clawchat-plugin-openclaw 2026.5.12-38 → 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 +7 -0
- package/dist/setup-entry.js +31 -1
- package/dist/src/api-client.js +164 -26
- package/dist/src/client.js +4 -1
- package/dist/src/config-compat.js +120 -0
- package/dist/src/inbound.js +21 -4
- package/dist/src/login.runtime.js +4 -0
- package/dist/src/outbound.js +43 -8
- package/dist/src/plugin-report.js +36 -0
- package/dist/src/protocol-types.js +2 -0
- package/dist/src/refresh-manager.js +278 -0
- package/dist/src/reply-dispatcher.js +30 -21
- package/dist/src/runtime.js +597 -30
- package/dist/src/storage.js +81 -5
- package/dist/src/ws-alignment.js +69 -1
- package/dist/src/ws-client.js +55 -5
- package/index.ts +7 -0
- package/openclaw.plugin.json +7 -0
- package/package.json +1 -1
- package/setup-entry.ts +51 -1
- package/skills/clawchat/SKILL.md +13 -0
- package/src/api-client.ts +210 -31
- package/src/client.ts +12 -1
- package/src/config-compat.ts +154 -0
- package/src/inbound.ts +24 -5
- package/src/login.runtime.ts +4 -0
- package/src/outbound.ts +47 -9
- package/src/plugin-report.ts +53 -0
- package/src/protocol-types.ts +34 -2
- package/src/refresh-manager.ts +371 -0
- package/src/reply-dispatcher.ts +31 -23
- package/src/runtime.ts +679 -27
- package/src/storage.ts +124 -4
- package/src/ws-alignment.ts +99 -1
- package/src/ws-client.ts +51 -5
- package/dist/src/buffered-stream.js +0 -177
- package/dist/src/streaming.js +0 -65
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
|
});
|
package/dist/setup-entry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|
package/dist/src/api-client.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 =
|
|
168
|
+
parsed = text ? JSON.parse(text) : undefined;
|
|
46
169
|
}
|
|
47
|
-
catch
|
|
48
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/src/client.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/src/inbound.js
CHANGED
|
@@ -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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/src/outbound.js
CHANGED
|
@@ -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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
478
|
-
|
|
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",
|