@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 +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 +5 -2
- 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/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 +5 -2
- 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/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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 =
|
|
147
|
-
} catch
|
|
148
|
-
|
|
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 =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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,
|
package/src/login.runtime.ts
CHANGED
|
@@ -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
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
642
|
-
|
|
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 {
|