@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/dist/src/api-client.js
CHANGED
|
@@ -251,6 +251,7 @@ export function createOpenclawClawlingApiClient(opts) {
|
|
|
251
251
|
? env.message
|
|
252
252
|
: "";
|
|
253
253
|
if (code !== 0) {
|
|
254
|
+
// Business-code 401 is an API error, not HTTP auth expiry; do not refresh.
|
|
254
255
|
throw new ClawlingApiError("api", msg || `code=${code}`, {
|
|
255
256
|
code,
|
|
256
257
|
status: res.status,
|
package/dist/src/inbound.js
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
import { EVENT, MENTION_ALL_USER_ID, } from "./protocol-types.js";
|
|
2
2
|
import { extractMediaFragments, fragmentsToText } from "./message-mapper.js";
|
|
3
3
|
import { hasRenderableText, isInboundMessagePayload } from "./protocol.js";
|
|
4
|
+
/**
|
|
5
|
+
* The text after the mention fragments a message opens with (only blank text
|
|
6
|
+
* may sit between them), when one of those mentions is `userId` or
|
|
7
|
+
* @everyone. Mention fragments are the source of truth: a display name may
|
|
8
|
+
* contain spaces, so the rendered `@name` cannot be split off by pattern.
|
|
9
|
+
*/
|
|
10
|
+
export function textAfterLeadingMentionsOf(fragments, userId) {
|
|
11
|
+
if (!userId || !Array.isArray(fragments))
|
|
12
|
+
return null;
|
|
13
|
+
const ids = [];
|
|
14
|
+
let index = 0;
|
|
15
|
+
for (; index < fragments.length; index += 1) {
|
|
16
|
+
const fragment = fragments[index];
|
|
17
|
+
if (fragment?.kind === "mention") {
|
|
18
|
+
const id = typeof fragment.user_id === "string" ? fragment.user_id.trim() : "";
|
|
19
|
+
if (id)
|
|
20
|
+
ids.push(id);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (fragment?.kind === "text" && typeof fragment.text === "string" && !fragment.text.trim())
|
|
24
|
+
continue;
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
if (!ids.includes(userId) && !ids.includes(MENTION_ALL_USER_ID))
|
|
28
|
+
return null;
|
|
29
|
+
const rest = fragmentsToText(fragments.slice(index));
|
|
30
|
+
return rest || null;
|
|
31
|
+
}
|
|
4
32
|
function normalizeSender(sender) {
|
|
5
33
|
if (!sender || typeof sender !== "object")
|
|
6
34
|
return null;
|
|
@@ -160,6 +188,10 @@ export async function dispatchOpenclawClawlingInbound(params) {
|
|
|
160
188
|
mentionFallbackIds: mentionIds,
|
|
161
189
|
});
|
|
162
190
|
const mediaItems = extractMediaFragments(message.body.fragments);
|
|
191
|
+
const afterMentions = isGroup
|
|
192
|
+
? textAfterLeadingMentionsOf(message.body.fragments, account.userId)
|
|
193
|
+
: null;
|
|
194
|
+
const addressedCommandBody = afterMentions?.startsWith("/") ? afterMentions : null;
|
|
163
195
|
const wasMentioned = detectMention({
|
|
164
196
|
mentions: mentionIds,
|
|
165
197
|
chatType,
|
|
@@ -209,6 +241,7 @@ export async function dispatchOpenclawClawlingInbound(params) {
|
|
|
209
241
|
senderId: sender.id,
|
|
210
242
|
senderNickName: sender.nickName,
|
|
211
243
|
rawBody,
|
|
244
|
+
...(addressedCommandBody ? { addressedCommandBody } : {}),
|
|
212
245
|
messageId: payload.message_id,
|
|
213
246
|
// Single inbound message — allMessageIds starts as a one-element array.
|
|
214
247
|
// The group coalescer merges these into the full set for batched turns.
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { authRefresh, CODE_INVALID_REFRESH, decodeJwtExp, } from "./api-client.js";
|
|
3
|
+
/** Fingerprint only: revoked refresh credentials must never enter marker storage. */
|
|
4
|
+
export function fingerprintRefreshToken(token) {
|
|
5
|
+
return createHash("sha256").update(token.trim()).digest("hex");
|
|
6
|
+
}
|
|
2
7
|
/**
|
|
3
8
|
* §A–§D — ClawChat token refresh + auto-logout orchestration for the OpenClaw
|
|
4
9
|
* plugin.
|
|
@@ -70,12 +75,9 @@ export class RefreshManager {
|
|
|
70
75
|
inFlight = null;
|
|
71
76
|
/** §A.3 — the access token a refresh was last attempted for. */
|
|
72
77
|
rejectedToken = null;
|
|
73
|
-
/**
|
|
74
|
-
* §B race — the refresh token we last successfully rotated AWAY from (now
|
|
75
|
-
* consumed server-side). A subsequent `code:10003` for this exact token is a
|
|
76
|
-
* consumed-rotation race, NOT a revocation, so it must not auto-logout.
|
|
77
|
-
*/
|
|
78
|
+
/** Consumed-token history is diagnostic; a live successor is required to recover. */
|
|
78
79
|
lastRotatedFromToken = null;
|
|
80
|
+
consecutiveRaceDowngrades = 0;
|
|
79
81
|
/** §A.3 — epoch-ms of the last refresh attempt (any token). */
|
|
80
82
|
lastAttemptAt = 0;
|
|
81
83
|
proactiveTimer = null;
|
|
@@ -135,10 +137,12 @@ export class RefreshManager {
|
|
|
135
137
|
result = await authRefresh({ baseUrl: this.ports.baseUrl, ...(this.ports.fetchImpl ? { fetchImpl: this.ports.fetchImpl } : {}) }, { refreshToken, deviceId: this.ports.deviceId });
|
|
136
138
|
}
|
|
137
139
|
catch (err) {
|
|
140
|
+
this.consecutiveRaceDowngrades = 0;
|
|
138
141
|
const message = err instanceof Error ? err.message : String(err);
|
|
139
142
|
return { kind: "transient", message };
|
|
140
143
|
}
|
|
141
144
|
if (result.kind === "success") {
|
|
145
|
+
this.consecutiveRaceDowngrades = 0;
|
|
142
146
|
// §0 rotation hazard — persist FIRST, swap SECOND. If persistence REJECTS
|
|
143
147
|
// (a store/config write failed), DO NOT swap the in-memory token: a
|
|
144
148
|
// sqlite-sourced agent must not keep a now-dead refresh token in its row
|
|
@@ -163,8 +167,7 @@ export class RefreshManager {
|
|
|
163
167
|
accessToken: result.accessToken,
|
|
164
168
|
refreshToken: result.refreshToken,
|
|
165
169
|
});
|
|
166
|
-
//
|
|
167
|
-
// is recognized as a rotation race rather than a revocation.
|
|
170
|
+
// Keep rotation history for inspection; it never overrides the live-token check.
|
|
168
171
|
this.lastRotatedFromToken = refreshToken;
|
|
169
172
|
// Clear the latch; the access token has actually changed.
|
|
170
173
|
this.rejectedToken = null;
|
|
@@ -172,31 +175,27 @@ export class RefreshManager {
|
|
|
172
175
|
return { kind: "success", accessToken: result.accessToken, refreshToken: result.refreshToken };
|
|
173
176
|
}
|
|
174
177
|
if (result.kind === "permanent") {
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
// no longer covers it). Before auto-logging-out
|
|
178
|
-
// (which wipes credentials and bricks the agent), distinguish that race
|
|
179
|
-
// from a genuine revocation. It is a race when EITHER the submitted token
|
|
180
|
-
// is one we already rotated away from, OR the live store refresh token has
|
|
181
|
-
// moved on since we read it (another worker rotated underneath us). In
|
|
182
|
-
// either case the credential chain is still alive — report transient (no
|
|
183
|
-
// latch, no logout) so the next attempt proceeds with the live token. A
|
|
184
|
-
// `code:400` (client bug) is never a race.
|
|
178
|
+
// A consumed-token race is recoverable only with a real live successor.
|
|
179
|
+
// Bound repeated downgrades, including across gateway re-enters.
|
|
185
180
|
if (result.code === CODE_INVALID_REFRESH) {
|
|
186
181
|
const liveRefresh = this.ports.getRefreshToken();
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
this.ports.log?.info?.(
|
|
182
|
+
const hasSuccessor = !!liveRefresh?.trim() && liveRefresh !== refreshToken;
|
|
183
|
+
if (hasSuccessor && this.consecutiveRaceDowngrades < 2) {
|
|
184
|
+
this.consecutiveRaceDowngrades += 1;
|
|
185
|
+
this.ports.log?.info?.("clawchat-plugin-openclaw refresh 10003 treated as consumed-rotation race; recovering with live token");
|
|
191
186
|
return { kind: "transient", message: "refresh token already rotated (race); retrying with current token" };
|
|
192
187
|
}
|
|
193
188
|
}
|
|
194
189
|
// §A.3 — latch the dead token so reconnect storms don't re-fire refresh.
|
|
195
190
|
this.rejectedToken = accessToken;
|
|
196
191
|
this.ports.log?.error?.(`clawchat-plugin-openclaw refresh permanent failure code=${result.code}: ${result.message}`);
|
|
197
|
-
await this.ports.onPermanentFailure({
|
|
192
|
+
await this.ports.onPermanentFailure({
|
|
193
|
+
code: result.code,
|
|
194
|
+
message: result.message,
|
|
195
|
+
}, { fingerprint: fingerprintRefreshToken(refreshToken), revokedAt: this.now() });
|
|
198
196
|
return { kind: "permanent", code: result.code, message: result.message };
|
|
199
197
|
}
|
|
198
|
+
this.consecutiveRaceDowngrades = 0;
|
|
200
199
|
// Transient — never auto-logout; the old refresh token is still valid.
|
|
201
200
|
this.ports.log?.info?.(`clawchat-plugin-openclaw refresh transient failure: ${result.message}`);
|
|
202
201
|
return { kind: "transient", message: result.message };
|
|
@@ -362,6 +361,7 @@ export class RefreshManager {
|
|
|
362
361
|
rejectedToken: this.rejectedToken,
|
|
363
362
|
lastAttemptAt: this.lastAttemptAt,
|
|
364
363
|
lastRotatedFromToken: this.lastRotatedFromToken,
|
|
364
|
+
consecutiveRaceDowngrades: this.consecutiveRaceDowngrades,
|
|
365
365
|
};
|
|
366
366
|
}
|
|
367
367
|
/** §A.3/§A.4 — restore guard state from a prior manager (see `exportState`). */
|
|
@@ -369,6 +369,7 @@ export class RefreshManager {
|
|
|
369
369
|
this.rejectedToken = state.rejectedToken;
|
|
370
370
|
this.lastAttemptAt = state.lastAttemptAt;
|
|
371
371
|
this.lastRotatedFromToken = state.lastRotatedFromToken ?? null;
|
|
372
|
+
this.consecutiveRaceDowngrades = state.consecutiveRaceDowngrades ?? 0;
|
|
372
373
|
}
|
|
373
374
|
}
|
|
374
375
|
function decodeJwtIat(token) {
|
|
@@ -4,7 +4,7 @@ import { classifyFrameworkError, formatFrameworkErrorForOwner, isFrameworkErrorR
|
|
|
4
4
|
import { createOpenclawClawlingApiClient } from "./api-client.js";
|
|
5
5
|
import { effectiveOutputVisibility, } from "./config.js";
|
|
6
6
|
import { describeOutboundMediaShortfall, uploadOutboundMedia, } from "./media-runtime.js";
|
|
7
|
-
import { mintMessageId, sendOpenclawClawlingText, } from "./outbound.js";
|
|
7
|
+
import { mintMessageId, sendOpenclawClawlingReaction, sendOpenclawClawlingText, } from "./outbound.js";
|
|
8
8
|
import { isClawChatNoopResponseText } from "./profile-prompt.js";
|
|
9
9
|
import { consumeTerminalClawChatSend } from "./terminal-send.js";
|
|
10
10
|
import { createReplyStream, streamableText } from "./reply-stream.js";
|
|
@@ -266,7 +266,7 @@ function formatApprovalSummary(payload) {
|
|
|
266
266
|
* as separate ClawChat messages.
|
|
267
267
|
*/
|
|
268
268
|
export function createOpenclawClawlingReplyDispatcher(options) {
|
|
269
|
-
const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, store, resolveOwnerConversationId, resolveOwnerLocale, log, } = options;
|
|
269
|
+
const { cfg, runtime, account, client, target, replyCtx, inboundMessageId, store, resolveOwnerConversationId, resolveOwnerLocale, ownerCommandReceipt, log, } = options;
|
|
270
270
|
const isGroupTarget = target.chatType === "group";
|
|
271
271
|
let warnedLocalReplyMedia = false;
|
|
272
272
|
// Non-owner turns may only send remote (http/https) media. Local sources are
|
|
@@ -411,7 +411,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
411
411
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw suppressing ${reason} reply after terminal tool send msg=${terminal.messageId} to=${target.chatId}`);
|
|
412
412
|
return true;
|
|
413
413
|
};
|
|
414
|
-
const claimOutbound = (eventType, messageId, text, raw) => {
|
|
414
|
+
const claimOutbound = (eventType, messageId, text, raw, chatId = target.chatId) => {
|
|
415
415
|
if (!store || !messageId)
|
|
416
416
|
return null;
|
|
417
417
|
try {
|
|
@@ -421,7 +421,7 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
421
421
|
kind: "message",
|
|
422
422
|
direction: "outbound",
|
|
423
423
|
eventType,
|
|
424
|
-
chatId
|
|
424
|
+
chatId,
|
|
425
425
|
messageId,
|
|
426
426
|
text,
|
|
427
427
|
raw,
|
|
@@ -607,6 +607,70 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
607
607
|
// terminal tool send), the stream is closed now.
|
|
608
608
|
replyStream = null;
|
|
609
609
|
};
|
|
610
|
+
// ----- Owner command receipt -------------------------------------------
|
|
611
|
+
//
|
|
612
|
+
// The owner's group command answers in the owner's direct chat, so the
|
|
613
|
+
// group sees only a ✅ on the command. Any failure falls back to the group:
|
|
614
|
+
// a noisy group beats an owner who never hears back.
|
|
615
|
+
let receiptReacted = false;
|
|
616
|
+
const receiptTarget = () => {
|
|
617
|
+
if (!ownerCommandReceipt || !isGroupTarget)
|
|
618
|
+
return null;
|
|
619
|
+
const ownerTarget = ownerDirectTarget();
|
|
620
|
+
if (!ownerTarget) {
|
|
621
|
+
log?.warn?.(`[${account.accountId}] clawchat-plugin-openclaw owner command receipt kept in group reason=missing_owner_conversation group=${target.chatId}`);
|
|
622
|
+
}
|
|
623
|
+
return ownerTarget;
|
|
624
|
+
};
|
|
625
|
+
const markCommandReceived = () => {
|
|
626
|
+
if (!ownerCommandReceipt || receiptReacted)
|
|
627
|
+
return;
|
|
628
|
+
receiptReacted = true;
|
|
629
|
+
try {
|
|
630
|
+
sendOpenclawClawlingReaction({
|
|
631
|
+
client,
|
|
632
|
+
chatId: target.chatId,
|
|
633
|
+
targetMessageId: ownerCommandReceipt.commandMessageId,
|
|
634
|
+
emoji: "✅",
|
|
635
|
+
removed: false,
|
|
636
|
+
log,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
catch (error) {
|
|
640
|
+
log?.warn?.(`[${account.accountId}] clawchat-plugin-openclaw owner command ✅ failed group=${target.chatId}: ${String(error)}`);
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
const sendOwnerCommandReceipt = async (ownerTarget, text, mediaFragments, richFragments, messageMode) => {
|
|
644
|
+
const messageId = mintStaticMessageId();
|
|
645
|
+
const receiptText = text.trim()
|
|
646
|
+
? `${ownerCommandReceipt.header}\n\n${text}`
|
|
647
|
+
: ownerCommandReceipt.header;
|
|
648
|
+
const raw = { target: ownerTarget, mode: "owner-command-receipt", group: target.chatId };
|
|
649
|
+
if (claimOutbound("message.send", messageId, receiptText, raw, ownerTarget.chatId) !== true)
|
|
650
|
+
return null;
|
|
651
|
+
try {
|
|
652
|
+
const result = await sendOpenclawClawlingText({
|
|
653
|
+
client,
|
|
654
|
+
account,
|
|
655
|
+
to: ownerTarget,
|
|
656
|
+
text: receiptText,
|
|
657
|
+
messageId,
|
|
658
|
+
...(richFragments.length > 0 ? { richFragments } : {}),
|
|
659
|
+
...(mediaFragments.length > 0 ? { mediaFragments } : {}),
|
|
660
|
+
...(messageMode !== "normal" ? { messageMode } : {}),
|
|
661
|
+
log,
|
|
662
|
+
});
|
|
663
|
+
if (!result)
|
|
664
|
+
return null;
|
|
665
|
+
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw owner command receipt sent to owner chat group=${target.chatId} msg=${messageId}`);
|
|
666
|
+
markCommandReceived();
|
|
667
|
+
return { result, messageId };
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
log?.warn?.(`[${account.accountId}] clawchat-plugin-openclaw owner command receipt kept in group reason=owner_send_failed group=${target.chatId}: ${String(error)}`);
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
610
674
|
// ----- Static send ------------------------------------------------------
|
|
611
675
|
const sendStatic = async (text, mediaFragments = [], richFragments = [], options = {}) => {
|
|
612
676
|
const messageMode = options.messageMode ?? "normal";
|
|
@@ -639,6 +703,16 @@ export function createOpenclawClawlingReplyDispatcher(options) {
|
|
|
639
703
|
return null;
|
|
640
704
|
if (consumeTerminalSend("static"))
|
|
641
705
|
return null;
|
|
706
|
+
const ownerTarget = receiptTarget();
|
|
707
|
+
if (ownerTarget) {
|
|
708
|
+
const receipt = await sendOwnerCommandReceipt(ownerTarget, text, mediaFragments, richFragments, messageMode);
|
|
709
|
+
if (receipt) {
|
|
710
|
+
recordOutboundAck("message", receipt.messageId, receipt.result);
|
|
711
|
+
if (messageMode === "normal")
|
|
712
|
+
outboundDeliveredCount += 1;
|
|
713
|
+
return receipt.result;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
642
716
|
log?.info?.(`[${account.accountId}] clawchat-plugin-openclaw sending static mode=${messageMode} text_len=${text.length} media=${mediaFragments.length} rich=${richFragments.length} to=${target.chatId}`);
|
|
643
717
|
const streamMessageId = messageMode === "normal" ? claimStreamMessageId(text) : null;
|
|
644
718
|
const messageId = streamMessageId ?? mintStaticMessageId();
|