@clawling/clawchat-plugin-openclaw 2026.10.11-2 → 2026.10.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/src/api-client.js +1 -0
- package/dist/src/inbound.js +33 -0
- package/dist/src/refresh-manager.js +23 -22
- package/dist/src/reply-dispatcher.js +78 -4
- package/dist/src/runtime.js +217 -69
- package/dist/src/storage.js +38 -0
- package/dist/src/tools.js +26 -1
- package/package.json +1 -1
- package/prompts/platform.md +2 -0
- package/src/api-client.ts +1 -0
- package/src/inbound.ts +39 -0
- package/src/refresh-manager.ts +30 -25
- package/src/reply-dispatcher.ts +94 -1
- package/src/runtime.ts +227 -71
- package/src/storage.ts +42 -0
- package/src/tools.ts +28 -1
package/src/inbound.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface IngestTurnParams {
|
|
|
27
27
|
senderIsOwner?: boolean;
|
|
28
28
|
senderIsGroupOwner?: boolean;
|
|
29
29
|
rawBody: string;
|
|
30
|
+
/**
|
|
31
|
+
* Group only: the text after the @-mentions the message opens with, when
|
|
32
|
+
* those mentions address this agent (or @everyone). `@agent /new` yields
|
|
33
|
+
* `/new`, which the runtime checks as a group slash command.
|
|
34
|
+
*/
|
|
35
|
+
addressedCommandBody?: string;
|
|
30
36
|
messageId: string;
|
|
31
37
|
/**
|
|
32
38
|
* All constituent message IDs represented by this turn. Single messages
|
|
@@ -66,6 +72,34 @@ export interface IngestTurnParams {
|
|
|
66
72
|
envelope: Envelope<unknown>;
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
/**
|
|
76
|
+
* The text after the mention fragments a message opens with (only blank text
|
|
77
|
+
* may sit between them), when one of those mentions is `userId` or
|
|
78
|
+
* @everyone. Mention fragments are the source of truth: a display name may
|
|
79
|
+
* contain spaces, so the rendered `@name` cannot be split off by pattern.
|
|
80
|
+
*/
|
|
81
|
+
export function textAfterLeadingMentionsOf(
|
|
82
|
+
fragments: Array<Record<string, unknown>>,
|
|
83
|
+
userId: string | undefined,
|
|
84
|
+
): string | null {
|
|
85
|
+
if (!userId || !Array.isArray(fragments)) return null;
|
|
86
|
+
const ids: string[] = [];
|
|
87
|
+
let index = 0;
|
|
88
|
+
for (; index < fragments.length; index += 1) {
|
|
89
|
+
const fragment = fragments[index];
|
|
90
|
+
if (fragment?.kind === "mention") {
|
|
91
|
+
const id = typeof fragment.user_id === "string" ? fragment.user_id.trim() : "";
|
|
92
|
+
if (id) ids.push(id);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (fragment?.kind === "text" && typeof fragment.text === "string" && !fragment.text.trim()) continue;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (!ids.includes(userId) && !ids.includes(MENTION_ALL_USER_ID)) return null;
|
|
99
|
+
const rest = fragmentsToText(fragments.slice(index) as never);
|
|
100
|
+
return rest || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
69
103
|
export interface DispatchInboundParams {
|
|
70
104
|
envelope: Envelope<unknown>;
|
|
71
105
|
cfg: OpenClawConfig;
|
|
@@ -313,6 +347,10 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
313
347
|
mentionFallbackIds: mentionIds,
|
|
314
348
|
});
|
|
315
349
|
const mediaItems = extractMediaFragments(message.body.fragments as never);
|
|
350
|
+
const afterMentions = isGroup
|
|
351
|
+
? textAfterLeadingMentionsOf(message.body.fragments as Array<Record<string, unknown>>, account.userId)
|
|
352
|
+
: null;
|
|
353
|
+
const addressedCommandBody = afterMentions?.startsWith("/") ? afterMentions : null;
|
|
316
354
|
const wasMentioned = detectMention({
|
|
317
355
|
mentions: mentionIds,
|
|
318
356
|
chatType,
|
|
@@ -376,6 +414,7 @@ export async function dispatchOpenclawClawlingInbound(
|
|
|
376
414
|
senderId: sender.id,
|
|
377
415
|
senderNickName: sender.nickName,
|
|
378
416
|
rawBody,
|
|
417
|
+
...(addressedCommandBody ? { addressedCommandBody } : {}),
|
|
379
418
|
messageId: payload.message_id,
|
|
380
419
|
// Single inbound message — allMessageIds starts as a one-element array.
|
|
381
420
|
// The group coalescer merges these into the full set for batched turns.
|
package/src/refresh-manager.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import {
|
|
2
4
|
authRefresh,
|
|
3
5
|
CODE_INVALID_REFRESH,
|
|
@@ -5,6 +7,13 @@ import {
|
|
|
5
7
|
type AuthRefreshResult,
|
|
6
8
|
} from "./api-client.ts";
|
|
7
9
|
|
|
10
|
+
export type RevokedRefreshMarker = { fingerprint: string; revokedAt: number };
|
|
11
|
+
|
|
12
|
+
/** Fingerprint only: revoked refresh credentials must never enter marker storage. */
|
|
13
|
+
export function fingerprintRefreshToken(token: string): string {
|
|
14
|
+
return createHash("sha256").update(token.trim()).digest("hex");
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/**
|
|
9
18
|
* §A–§D — ClawChat token refresh + auto-logout orchestration for the OpenClaw
|
|
10
19
|
* plugin.
|
|
@@ -77,7 +86,7 @@ export interface RefreshManagerPorts {
|
|
|
77
86
|
/** Swap the in-memory access+refresh token AFTER persistence (§0). */
|
|
78
87
|
swapInMemoryTokens: (tokens: { accessToken: string; refreshToken: string }) => void;
|
|
79
88
|
/** §C — auto-logout: clear creds in both stores, flip not-configured, notify. */
|
|
80
|
-
onPermanentFailure: (info: { code: number; message: string }) => Promise<void> | void;
|
|
89
|
+
onPermanentFailure: (info: { code: number; message: string }, revokedRefresh: RevokedRefreshMarker) => Promise<void> | void;
|
|
81
90
|
/**
|
|
82
91
|
* §A.1/§D — invoked AFTER a successful PROACTIVE-timer refresh has persisted +
|
|
83
92
|
* swapped the in-memory token, so the runtime can perform the §D close-then-
|
|
@@ -138,12 +147,9 @@ export class RefreshManager {
|
|
|
138
147
|
private inFlight: Promise<RefreshOutcome> | null = null;
|
|
139
148
|
/** §A.3 — the access token a refresh was last attempted for. */
|
|
140
149
|
private rejectedToken: string | null = null;
|
|
141
|
-
/**
|
|
142
|
-
* §B race — the refresh token we last successfully rotated AWAY from (now
|
|
143
|
-
* consumed server-side). A subsequent `code:10003` for this exact token is a
|
|
144
|
-
* consumed-rotation race, NOT a revocation, so it must not auto-logout.
|
|
145
|
-
*/
|
|
150
|
+
/** Consumed-token history is diagnostic; a live successor is required to recover. */
|
|
146
151
|
private lastRotatedFromToken: string | null = null;
|
|
152
|
+
private consecutiveRaceDowngrades = 0;
|
|
147
153
|
/** §A.3 — epoch-ms of the last refresh attempt (any token). */
|
|
148
154
|
private lastAttemptAt = 0;
|
|
149
155
|
private proactiveTimer: TimerHandle | null = null;
|
|
@@ -217,11 +223,13 @@ export class RefreshManager {
|
|
|
217
223
|
{ refreshToken, deviceId: this.ports.deviceId },
|
|
218
224
|
);
|
|
219
225
|
} catch (err) {
|
|
226
|
+
this.consecutiveRaceDowngrades = 0;
|
|
220
227
|
const message = err instanceof Error ? err.message : String(err);
|
|
221
228
|
return { kind: "transient", message };
|
|
222
229
|
}
|
|
223
230
|
|
|
224
231
|
if (result.kind === "success") {
|
|
232
|
+
this.consecutiveRaceDowngrades = 0;
|
|
225
233
|
// §0 rotation hazard — persist FIRST, swap SECOND. If persistence REJECTS
|
|
226
234
|
// (a store/config write failed), DO NOT swap the in-memory token: a
|
|
227
235
|
// sqlite-sourced agent must not keep a now-dead refresh token in its row
|
|
@@ -247,8 +255,7 @@ export class RefreshManager {
|
|
|
247
255
|
accessToken: result.accessToken,
|
|
248
256
|
refreshToken: result.refreshToken,
|
|
249
257
|
});
|
|
250
|
-
//
|
|
251
|
-
// is recognized as a rotation race rather than a revocation.
|
|
258
|
+
// Keep rotation history for inspection; it never overrides the live-token check.
|
|
252
259
|
this.lastRotatedFromToken = refreshToken;
|
|
253
260
|
// Clear the latch; the access token has actually changed.
|
|
254
261
|
this.rejectedToken = null;
|
|
@@ -257,25 +264,15 @@ export class RefreshManager {
|
|
|
257
264
|
}
|
|
258
265
|
|
|
259
266
|
if (result.kind === "permanent") {
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
// no longer covers it). Before auto-logging-out
|
|
263
|
-
// (which wipes credentials and bricks the agent), distinguish that race
|
|
264
|
-
// from a genuine revocation. It is a race when EITHER the submitted token
|
|
265
|
-
// is one we already rotated away from, OR the live store refresh token has
|
|
266
|
-
// moved on since we read it (another worker rotated underneath us). In
|
|
267
|
-
// either case the credential chain is still alive — report transient (no
|
|
268
|
-
// latch, no logout) so the next attempt proceeds with the live token. A
|
|
269
|
-
// `code:400` (client bug) is never a race.
|
|
267
|
+
// A consumed-token race is recoverable only with a real live successor.
|
|
268
|
+
// Bound repeated downgrades, including across gateway re-enters.
|
|
270
269
|
if (result.code === CODE_INVALID_REFRESH) {
|
|
271
270
|
const liveRefresh = this.ports.getRefreshToken();
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
!!liveRefresh && liveRefresh.trim().length > 0 && liveRefresh !== refreshToken;
|
|
276
|
-
if (rotatedByUs || rotatedUnderneath) {
|
|
271
|
+
const hasSuccessor = !!liveRefresh?.trim() && liveRefresh !== refreshToken;
|
|
272
|
+
if (hasSuccessor && this.consecutiveRaceDowngrades < 2) {
|
|
273
|
+
this.consecutiveRaceDowngrades += 1;
|
|
277
274
|
this.ports.log?.info?.(
|
|
278
|
-
|
|
275
|
+
"clawchat-plugin-openclaw refresh 10003 treated as consumed-rotation race; recovering with live token",
|
|
279
276
|
);
|
|
280
277
|
return { kind: "transient", message: "refresh token already rotated (race); retrying with current token" };
|
|
281
278
|
}
|
|
@@ -285,10 +282,14 @@ export class RefreshManager {
|
|
|
285
282
|
this.ports.log?.error?.(
|
|
286
283
|
`clawchat-plugin-openclaw refresh permanent failure code=${result.code}: ${result.message}`,
|
|
287
284
|
);
|
|
288
|
-
await this.ports.onPermanentFailure({
|
|
285
|
+
await this.ports.onPermanentFailure({
|
|
286
|
+
code: result.code,
|
|
287
|
+
message: result.message,
|
|
288
|
+
}, { fingerprint: fingerprintRefreshToken(refreshToken), revokedAt: this.now() });
|
|
289
289
|
return { kind: "permanent", code: result.code, message: result.message };
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
this.consecutiveRaceDowngrades = 0;
|
|
292
293
|
// Transient — never auto-logout; the old refresh token is still valid.
|
|
293
294
|
this.ports.log?.info?.(
|
|
294
295
|
`clawchat-plugin-openclaw refresh transient failure: ${result.message}`,
|
|
@@ -470,11 +471,13 @@ export class RefreshManager {
|
|
|
470
471
|
rejectedToken: string | null;
|
|
471
472
|
lastAttemptAt: number;
|
|
472
473
|
lastRotatedFromToken: string | null;
|
|
474
|
+
consecutiveRaceDowngrades: number;
|
|
473
475
|
} {
|
|
474
476
|
return {
|
|
475
477
|
rejectedToken: this.rejectedToken,
|
|
476
478
|
lastAttemptAt: this.lastAttemptAt,
|
|
477
479
|
lastRotatedFromToken: this.lastRotatedFromToken,
|
|
480
|
+
consecutiveRaceDowngrades: this.consecutiveRaceDowngrades,
|
|
478
481
|
};
|
|
479
482
|
}
|
|
480
483
|
|
|
@@ -483,10 +486,12 @@ export class RefreshManager {
|
|
|
483
486
|
rejectedToken: string | null;
|
|
484
487
|
lastAttemptAt: number;
|
|
485
488
|
lastRotatedFromToken?: string | null;
|
|
489
|
+
consecutiveRaceDowngrades?: number;
|
|
486
490
|
}): void {
|
|
487
491
|
this.rejectedToken = state.rejectedToken;
|
|
488
492
|
this.lastAttemptAt = state.lastAttemptAt;
|
|
489
493
|
this.lastRotatedFromToken = state.lastRotatedFromToken ?? null;
|
|
494
|
+
this.consecutiveRaceDowngrades = state.consecutiveRaceDowngrades ?? 0;
|
|
490
495
|
}
|
|
491
496
|
}
|
|
492
497
|
|
package/src/reply-dispatcher.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "./media-runtime.ts";
|
|
30
30
|
import {
|
|
31
31
|
mintMessageId,
|
|
32
|
+
sendOpenclawClawlingReaction,
|
|
32
33
|
sendOpenclawClawlingText,
|
|
33
34
|
type OutboundMessageMode,
|
|
34
35
|
type OutboundReplyCtx,
|
|
@@ -94,6 +95,14 @@ export interface ReplyDispatcherOptions {
|
|
|
94
95
|
* paths are never loaded. Legacy standalone callers default to owner behavior.
|
|
95
96
|
*/
|
|
96
97
|
nonOwnerTurn?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* Set for an owner's slash command in a group (not one that starts an agent
|
|
100
|
+
* turn). Each reply goes to the owner's direct chat, led by `header`, and
|
|
101
|
+
* the first one that lands puts a ✅ on the command (`commandMessageId`) in
|
|
102
|
+
* the group. With no owner chat on record, or a failed direct send, the
|
|
103
|
+
* reply goes to the group as before and a warning is logged.
|
|
104
|
+
*/
|
|
105
|
+
ownerCommandReceipt?: { header: string; commandMessageId: string };
|
|
97
106
|
log?: { info?: (m: string) => void; warn?: (m: string) => void; error?: (m: string) => void };
|
|
98
107
|
}
|
|
99
108
|
|
|
@@ -438,6 +447,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
438
447
|
store,
|
|
439
448
|
resolveOwnerConversationId,
|
|
440
449
|
resolveOwnerLocale,
|
|
450
|
+
ownerCommandReceipt,
|
|
441
451
|
log,
|
|
442
452
|
} = options;
|
|
443
453
|
const isGroupTarget = target.chatType === "group";
|
|
@@ -592,6 +602,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
592
602
|
messageId: string,
|
|
593
603
|
text: string,
|
|
594
604
|
raw: unknown,
|
|
605
|
+
chatId: string = target.chatId,
|
|
595
606
|
): true | false | null => {
|
|
596
607
|
if (!store || !messageId) return null;
|
|
597
608
|
try {
|
|
@@ -601,7 +612,7 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
601
612
|
kind: "message",
|
|
602
613
|
direction: "outbound",
|
|
603
614
|
eventType,
|
|
604
|
-
chatId
|
|
615
|
+
chatId,
|
|
605
616
|
messageId,
|
|
606
617
|
text,
|
|
607
618
|
raw,
|
|
@@ -786,6 +797,79 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
786
797
|
replyStream = null;
|
|
787
798
|
};
|
|
788
799
|
|
|
800
|
+
// ----- Owner command receipt -------------------------------------------
|
|
801
|
+
//
|
|
802
|
+
// The owner's group command answers in the owner's direct chat, so the
|
|
803
|
+
// group sees only a ✅ on the command. Any failure falls back to the group:
|
|
804
|
+
// a noisy group beats an owner who never hears back.
|
|
805
|
+
let receiptReacted = false;
|
|
806
|
+
const receiptTarget = (): { chatId: string; chatType: "direct" } | null => {
|
|
807
|
+
if (!ownerCommandReceipt || !isGroupTarget) return null;
|
|
808
|
+
const ownerTarget = ownerDirectTarget();
|
|
809
|
+
if (!ownerTarget) {
|
|
810
|
+
log?.warn?.(
|
|
811
|
+
`[${account.accountId}] clawchat-plugin-openclaw owner command receipt kept in group reason=missing_owner_conversation group=${target.chatId}`,
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
return ownerTarget;
|
|
815
|
+
};
|
|
816
|
+
const markCommandReceived = () => {
|
|
817
|
+
if (!ownerCommandReceipt || receiptReacted) return;
|
|
818
|
+
receiptReacted = true;
|
|
819
|
+
try {
|
|
820
|
+
sendOpenclawClawlingReaction({
|
|
821
|
+
client,
|
|
822
|
+
chatId: target.chatId,
|
|
823
|
+
targetMessageId: ownerCommandReceipt.commandMessageId,
|
|
824
|
+
emoji: "✅",
|
|
825
|
+
removed: false,
|
|
826
|
+
log,
|
|
827
|
+
});
|
|
828
|
+
} catch (error) {
|
|
829
|
+
log?.warn?.(
|
|
830
|
+
`[${account.accountId}] clawchat-plugin-openclaw owner command ✅ failed group=${target.chatId}: ${String(error)}`,
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
const sendOwnerCommandReceipt = async (
|
|
835
|
+
ownerTarget: { chatId: string; chatType: "direct" },
|
|
836
|
+
text: string,
|
|
837
|
+
mediaFragments: ClawlingMediaFragment[],
|
|
838
|
+
richFragments: Fragment[],
|
|
839
|
+
messageMode: OutboundMessageMode,
|
|
840
|
+
): Promise<{ result: SendResult | null; messageId: string } | null> => {
|
|
841
|
+
const messageId = mintStaticMessageId();
|
|
842
|
+
const receiptText = text.trim()
|
|
843
|
+
? `${ownerCommandReceipt!.header}\n\n${text}`
|
|
844
|
+
: ownerCommandReceipt!.header;
|
|
845
|
+
const raw = { target: ownerTarget, mode: "owner-command-receipt", group: target.chatId };
|
|
846
|
+
if (claimOutbound("message.send", messageId, receiptText, raw, ownerTarget.chatId) !== true) return null;
|
|
847
|
+
try {
|
|
848
|
+
const result = await sendOpenclawClawlingText({
|
|
849
|
+
client,
|
|
850
|
+
account,
|
|
851
|
+
to: ownerTarget,
|
|
852
|
+
text: receiptText,
|
|
853
|
+
messageId,
|
|
854
|
+
...(richFragments.length > 0 ? { richFragments } : {}),
|
|
855
|
+
...(mediaFragments.length > 0 ? { mediaFragments } : {}),
|
|
856
|
+
...(messageMode !== "normal" ? { messageMode } : {}),
|
|
857
|
+
log,
|
|
858
|
+
});
|
|
859
|
+
if (!result) return null;
|
|
860
|
+
log?.info?.(
|
|
861
|
+
`[${account.accountId}] clawchat-plugin-openclaw owner command receipt sent to owner chat group=${target.chatId} msg=${messageId}`,
|
|
862
|
+
);
|
|
863
|
+
markCommandReceived();
|
|
864
|
+
return { result, messageId };
|
|
865
|
+
} catch (error) {
|
|
866
|
+
log?.warn?.(
|
|
867
|
+
`[${account.accountId}] clawchat-plugin-openclaw owner command receipt kept in group reason=owner_send_failed group=${target.chatId}: ${String(error)}`,
|
|
868
|
+
);
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
|
|
789
873
|
// ----- Static send ------------------------------------------------------
|
|
790
874
|
|
|
791
875
|
const sendStatic = async (
|
|
@@ -823,6 +907,15 @@ export function createOpenclawClawlingReplyDispatcher(options: ReplyDispatcherOp
|
|
|
823
907
|
}
|
|
824
908
|
if (!text.trim() && mediaFragments.length === 0 && richFragments.length === 0) return null;
|
|
825
909
|
if (consumeTerminalSend("static")) return null;
|
|
910
|
+
const ownerTarget = receiptTarget();
|
|
911
|
+
if (ownerTarget) {
|
|
912
|
+
const receipt = await sendOwnerCommandReceipt(ownerTarget, text, mediaFragments, richFragments, messageMode);
|
|
913
|
+
if (receipt) {
|
|
914
|
+
recordOutboundAck("message", receipt.messageId, receipt.result);
|
|
915
|
+
if (messageMode === "normal") outboundDeliveredCount += 1;
|
|
916
|
+
return receipt.result;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
826
919
|
log?.info?.(
|
|
827
920
|
`[${account.accountId}] clawchat-plugin-openclaw sending static mode=${messageMode} text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`,
|
|
828
921
|
);
|