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