@clawling/clawchat-plugin-openclaw 2026.5.12-39 → 2026.5.13-dev.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/src/api-client.js +146 -26
- package/dist/src/client.js +4 -1
- 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/protocol-types.js +2 -0
- package/dist/src/refresh-manager.js +278 -0
- package/dist/src/reply-dispatcher.js +24 -27
- package/dist/src/runtime.js +564 -28
- package/dist/src/storage.js +81 -5
- package/dist/src/ws-alignment.js +69 -1
- package/dist/src/ws-client.js +55 -5
- package/package.json +1 -1
- package/skills/clawchat/SKILL.md +0 -13
- package/src/api-client.ts +174 -31
- package/src/client.ts +12 -1
- package/src/inbound.ts +24 -5
- package/src/login.runtime.ts +4 -0
- package/src/outbound.ts +47 -9
- package/src/protocol-types.ts +34 -2
- package/src/refresh-manager.ts +371 -0
- package/src/reply-dispatcher.ts +26 -28
- package/src/runtime.ts +646 -25
- 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/src/storage.js
CHANGED
|
@@ -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();
|
package/dist/src/ws-alignment.js
CHANGED
|
@@ -159,7 +159,8 @@ export function createProtocolControlHandler(options) {
|
|
|
159
159
|
version: "2",
|
|
160
160
|
event: "pong",
|
|
161
161
|
trace_id: env.trace_id ?? "-",
|
|
162
|
-
|
|
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;
|
|
@@ -176,3 +177,70 @@ export function createProtocolControlHandler(options) {
|
|
|
176
177
|
},
|
|
177
178
|
};
|
|
178
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Observes reliable `notify.signal` frames (§9.4). The agent plugin keeps no
|
|
182
|
+
* friend/roster cache (friends are fetched on demand via REST tools), so there
|
|
183
|
+
* is nothing to invalidate — this is a pure observability hook: it dedups by
|
|
184
|
+
* `event_id` (the live frame and its reliable-inbox replay collapse to one),
|
|
185
|
+
* structured-logs the signal, and returns the outcome. It deliberately takes no
|
|
186
|
+
* action on the agent; wire a real reaction here if the product later needs one.
|
|
187
|
+
*/
|
|
188
|
+
export function createNotifySignalObserver(options) {
|
|
189
|
+
const maxSeen = options.maxSeen ?? 512;
|
|
190
|
+
const seen = new Set();
|
|
191
|
+
const order = [];
|
|
192
|
+
const context = () => options.context?.() ?? { attempt: 1, reconnectCount: 0, state: "ready" };
|
|
193
|
+
const logSignal = (event, action, fields) => {
|
|
194
|
+
const current = context();
|
|
195
|
+
options.log(formatWsLog({
|
|
196
|
+
event,
|
|
197
|
+
accountId: options.accountId,
|
|
198
|
+
attempt: current.attempt,
|
|
199
|
+
reconnectCount: current.reconnectCount,
|
|
200
|
+
state: current.state,
|
|
201
|
+
action,
|
|
202
|
+
fields,
|
|
203
|
+
}));
|
|
204
|
+
};
|
|
205
|
+
return {
|
|
206
|
+
/** Returns whether this signal was newly observed, a duplicate, or malformed. */
|
|
207
|
+
observe(env) {
|
|
208
|
+
const payload = env.payload && typeof env.payload === "object"
|
|
209
|
+
? env.payload
|
|
210
|
+
: undefined;
|
|
211
|
+
const eventId = typeof payload?.event_id === "string" ? payload.event_id : "";
|
|
212
|
+
const type = typeof payload?.type === "string" ? payload.type : "";
|
|
213
|
+
const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
|
|
214
|
+
const version = typeof payload?.version === "number" ? payload.version : undefined;
|
|
215
|
+
if (!eventId || !type) {
|
|
216
|
+
logSignal("notify_signal_invalid", "ignore", [
|
|
217
|
+
["trace_id", env.trace_id],
|
|
218
|
+
["type", type || null],
|
|
219
|
+
["event_id", eventId || null],
|
|
220
|
+
]);
|
|
221
|
+
return "invalid";
|
|
222
|
+
}
|
|
223
|
+
if (seen.has(eventId)) {
|
|
224
|
+
logSignal("notify_signal_duplicate", "ignore", [
|
|
225
|
+
["type", type],
|
|
226
|
+
["event_id", eventId],
|
|
227
|
+
]);
|
|
228
|
+
return "duplicate";
|
|
229
|
+
}
|
|
230
|
+
seen.add(eventId);
|
|
231
|
+
order.push(eventId);
|
|
232
|
+
while (order.length > maxSeen) {
|
|
233
|
+
const evicted = order.shift();
|
|
234
|
+
if (evicted !== undefined)
|
|
235
|
+
seen.delete(evicted);
|
|
236
|
+
}
|
|
237
|
+
logSignal("notify_signal_observed", "observe", [
|
|
238
|
+
["type", type],
|
|
239
|
+
["entity_id", entityId || null],
|
|
240
|
+
["version", version ?? null],
|
|
241
|
+
["event_id", eventId],
|
|
242
|
+
]);
|
|
243
|
+
return "observed";
|
|
244
|
+
},
|
|
245
|
+
};
|
|
246
|
+
}
|
package/dist/src/ws-client.js
CHANGED
|
@@ -301,6 +301,10 @@ export class ClawChatClient extends EventEmitter {
|
|
|
301
301
|
this.emit("typing", env);
|
|
302
302
|
if (env.event === EVENT.CHAT_METADATA_INVALIDATED)
|
|
303
303
|
this.emit("metadata:invalidated", env);
|
|
304
|
+
if (env.event === EVENT.NOTIFY_SIGNAL)
|
|
305
|
+
this.emit("notify:signal", env);
|
|
306
|
+
if (env.event === EVENT.REPLAY_DONE)
|
|
307
|
+
this.emit("replay:done", env);
|
|
304
308
|
if (env.event === EVENT.OFFLINE_DONE)
|
|
305
309
|
this.emit("offline:done");
|
|
306
310
|
}
|
|
@@ -322,7 +326,19 @@ export class ClawChatClient extends EventEmitter {
|
|
|
322
326
|
token: this.opts.token,
|
|
323
327
|
nonce,
|
|
324
328
|
...(this.opts.deviceId ? { device_id: this.opts.deviceId } : {}),
|
|
325
|
-
|
|
329
|
+
// Agent runtime is single-device: multi_device stays off so the server
|
|
330
|
+
// never self-fans-out this connection's own messages. notify_signals is
|
|
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.
|
|
336
|
+
capabilities: {
|
|
337
|
+
multi_device: false,
|
|
338
|
+
device_replay: true,
|
|
339
|
+
chat_meta_events: true,
|
|
340
|
+
notify_signals: true,
|
|
341
|
+
},
|
|
326
342
|
};
|
|
327
343
|
const traceId = this.nextTraceId();
|
|
328
344
|
this.expectedConnectTraceId = traceId;
|
|
@@ -374,7 +390,34 @@ export class ClawChatClient extends EventEmitter {
|
|
|
374
390
|
this.failHandshake(new ProtocolError("invalid hello-fail payload", env), 4002, "protocol error");
|
|
375
391
|
return;
|
|
376
392
|
}
|
|
377
|
-
|
|
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);
|
|
378
421
|
this.authFailed = true;
|
|
379
422
|
this.expectedConnectTraceId = undefined;
|
|
380
423
|
this.sendQueue.length = 0;
|
|
@@ -421,10 +464,17 @@ export class ClawChatClient extends EventEmitter {
|
|
|
421
464
|
}
|
|
422
465
|
clearTimeout(entry.timer);
|
|
423
466
|
this.pending.delete(env.trace_id);
|
|
424
|
-
const payload = env.payload && typeof env.payload === "object"
|
|
467
|
+
const payload = env.payload && typeof env.payload === "object"
|
|
468
|
+
? env.payload
|
|
469
|
+
: undefined;
|
|
425
470
|
const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
|
|
426
|
-
|
|
427
|
-
|
|
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));
|
|
428
478
|
}
|
|
429
479
|
startHeartbeat() {
|
|
430
480
|
if (!this.opts.heartbeat.enabled)
|
package/package.json
CHANGED
package/skills/clawchat/SKILL.md
CHANGED
|
@@ -13,7 +13,6 @@ This skill guides agent behavior for ClawChat-aware tasks. Use the registered Cl
|
|
|
13
13
|
|
|
14
14
|
- Use registered ClawChat plugin tools for account/profile, friends, users, moments, comments, reactions, avatar, media, and read-only conversation lookup.
|
|
15
15
|
- If a requested ClawChat tool is unavailable or returns a config error, report that result and stop instead of bypassing the plugin.
|
|
16
|
-
- Use the `/clawchat-output` slash command when the user asks to change how much ClawChat runtime output is shown in the current conversation.
|
|
17
16
|
|
|
18
17
|
## OpenClaw CLI
|
|
19
18
|
|
|
@@ -31,18 +30,6 @@ Use `update --force` only when local ClawChat plugin or skill files look corrupt
|
|
|
31
30
|
|
|
32
31
|
If `channels add` reports `Unknown channel: clawchat-plugin-openclaw`, use the runtime slash command `/clawchat-activate CODE` after the operator ensures the plugin is loaded.
|
|
33
32
|
|
|
34
|
-
## Output Visibility
|
|
35
|
-
|
|
36
|
-
When the user asks to change ClawChat output verbosity, use the runtime slash command for the current conversation. Treat natural-language wording as aliases for the three supported modes:
|
|
37
|
-
|
|
38
|
-
| User wording | Command |
|
|
39
|
-
| --- | --- |
|
|
40
|
-
| quiet mode, silent mode, minimal output, final-only output, `minimal` | `/clawchat-output minimal` |
|
|
41
|
-
| conversation mode, normal mode, regular mode, default output, `normal` | `/clawchat-output normal` |
|
|
42
|
-
| dev mode, developer mode, verbose mode, full output, `full` | `/clawchat-output full` |
|
|
43
|
-
|
|
44
|
-
Do not edit config files directly for this request. If the slash command returns an error, report that error instead of claiming the mode changed.
|
|
45
|
-
|
|
46
33
|
## Plugin Tool Routing
|
|
47
34
|
|
|
48
35
|
Tool descriptions are authoritative. These routing hints resolve common ambiguity:
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 =
|
|
147
|
-
} catch
|
|
148
|
-
|
|
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 =
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|