@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.
@@ -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,
@@ -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
- // §B race — record the now-consumed refresh token so a later `10003` for it
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
- // §B race — a `code:10003` is also returned for a refresh token already
176
- // CONSUMED by a prior successful rotation (once the backend grace window
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 rotatedByUs = this.lastRotatedFromToken !== null && refreshToken === this.lastRotatedFromToken;
188
- const rotatedUnderneath = !!liveRefresh && liveRefresh.trim().length > 0 && liveRefresh !== refreshToken;
189
- if (rotatedByUs || rotatedUnderneath) {
190
- this.ports.log?.info?.(`clawchat-plugin-openclaw refresh 10003 treated as consumed-rotation race (rotatedByUs=${rotatedByUs} rotatedUnderneath=${rotatedUnderneath}); recovering with live token`);
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({ code: result.code, message: result.message });
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: target.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();