@clawling/clawchat-plugin-openclaw 2026.6.16-4 → 2026.6.22-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/runtime.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  CHANNEL_ID,
28
28
  effectiveOutputVisibility,
29
29
  effectiveGroupCommandMode,
30
+ effectiveGroupMode,
30
31
  hasOpenclawClawlingConnectCredentials,
31
32
  resolveOpenclawClawlingAccount,
32
33
  type ResolvedOpenclawClawlingAccount,
@@ -48,6 +49,7 @@ import {
48
49
  clawChatDbPathForStateDir,
49
50
  getClawChatStore,
50
51
  type ClawChatStore,
52
+ type MessageRow,
51
53
  } from "./storage.ts";
52
54
  import { getClawChatGroupPrompt, getClawChatUserPrompt } from "./plugin-prompts.ts";
53
55
  import {
@@ -66,6 +68,7 @@ import {
66
68
  } from "./prompt-injection.ts";
67
69
  import { createGroupMessageCoalescer } from "./group-message-coalescer.ts";
68
70
  import { openclawLlmContextDebug } from "./llm-context-debug.ts";
71
+ import { GroupSettingsCache } from "./group-settings.ts";
69
72
 
70
73
  type Log = { debug?: (m: string) => void; info?: (m: string) => void; error?: (m: string) => void };
71
74
  type ChannelContextBuilder = (params: Record<string, unknown>) => unknown;
@@ -96,6 +99,7 @@ type RuntimeConnectionStore = Pick<
96
99
  | "markActivationBootstrapSent"
97
100
  | "getActivationConversation"
98
101
  | "getLastResolvedDeviceId"
102
+ | "listRecentGroupMessages"
99
103
  >
100
104
  >;
101
105
 
@@ -107,6 +111,14 @@ export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
107
111
  const activeClients = new Map<string, ClawlingChatClient>();
108
112
  const CLAWCHAT_PLUGIN_SLASH_COMMANDS = new Set(["clawchat-activate", "clawchat-output"]);
109
113
  const ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS = 2_000;
114
+ // Upper bound (ms) on how long group ingest waits for the FIRST group-settings
115
+ // pull to settle before proceeding anyway. Best-effort governance must never
116
+ // permanently disable group traffic, so a stalled initial GET (endpoint accepts
117
+ // but never responds) can hold the gate at most this long. Matches the sibling
118
+ // hermes plugin's GROUP_SETTINGS_READY_TIMEOUT_SECONDS (5s).
119
+ const GROUP_SETTINGS_READY_TIMEOUT_MS = 5_000;
120
+ /** Number of recent group messages to prepend as context when the agent is @-mentioned. */
121
+ const MENTION_CONTEXT_N = 10;
110
122
  const CLAWCHAT_MEMORY_ROOT_UNAVAILABLE =
111
123
  "ClawChat memory root unavailable: OpenClaw workspaceDir could not be resolved";
112
124
  const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
@@ -641,6 +653,152 @@ export async function maybeAutoActivateFromEnv(
641
653
  return next;
642
654
  }
643
655
 
656
+ // Re-armable "settings pull pending" gate — the openclaw analogue of the hermes
657
+ // adapter's `asyncio.Event` (`_group_settings_ready`). It holds a single "ready"
658
+ // promise that group dispatch awaits (bounded by GROUP_SETTINGS_READY_TIMEOUT_MS
659
+ // at the call site). `arm()` re-closes the gate when a NEW settings pull starts
660
+ // (startup, every reconnect after hello-ok, and on `agent.config.changed`) so a
661
+ // later pull's in-flight cache write is never raced by a held group turn reading
662
+ // the stale cache.
663
+ //
664
+ // Open condition (Finding 2 fix). The gate must NOT open just because the LATEST
665
+ // pull settled: a newer pull can FAIL or return a non-authoritative result and
666
+ // settle FIRST, while an earlier pull is still in flight and about to apply an
667
+ // authoritative (e.g. just-muted) snapshot. Opening on the newer pull's settle
668
+ // would let a held group turn proceed against the stale cache. So the gate opens
669
+ // only when EITHER:
670
+ // (a) any in-flight pull APPLIED an authoritative snapshot (the cache is now
671
+ // fresh for this arm epoch — no need to wait for slower pulls), OR
672
+ // (b) there are no longer any in-flight pulls (every pull settled
673
+ // non-authoritatively / failed — nothing better is coming).
674
+ // The bounded timeout/abort at the call site remains the ultimate backstop. The
675
+ // gate is created already-OPEN so direct traffic and pre-pull harness paths are
676
+ // never blocked; group dispatch only ever waits while a pull is actually pending.
677
+ // Opaque token handed back by `arm()` and round-tripped to `release()`. Carries
678
+ // both the pull's generation (latest-pull tracking) and its epoch (the re-close
679
+ // counter used to retire stale older-epoch pulls). Callers treat it as opaque.
680
+ interface SettingsReadyToken {
681
+ generation: number;
682
+ epoch: number;
683
+ }
684
+
685
+ interface SettingsReadyGate {
686
+ /** Re-close the gate for a starting pull; returns that pull's opaque token. */
687
+ arm(): SettingsReadyToken;
688
+ /**
689
+ * Mark a pull as settled. `authoritative` is true when the pull applied an
690
+ * authoritative snapshot (HTTP 200). A token from a SUPERSEDED epoch (the gate
691
+ * re-closed for a newer pull since this one was armed) is ignored entirely — it
692
+ * neither decrements the current epoch's in-flight count nor opens the gate.
693
+ * Otherwise the gate opens when an authoritative pull has landed for the current
694
+ * arm epoch, or when no in-flight pulls remain for that epoch.
695
+ */
696
+ release(token: SettingsReadyToken, authoritative: boolean): void;
697
+ /** Force the gate open regardless of in-flight pulls (abort/shutdown backstop). */
698
+ forceOpen(): void;
699
+ /** The current ready promise — resolved while open, pending while a pull runs. */
700
+ wait(): Promise<void>;
701
+ }
702
+
703
+ export function createSettingsReadyGate(): SettingsReadyGate {
704
+ let generation = 0;
705
+ // Epoch counter: incremented every time the gate RE-CLOSES (an arm() while the
706
+ // gate was open). `pending` is epoch-scoped — a pull armed in a prior epoch is
707
+ // retired and can neither keep nor reopen the current epoch's gate.
708
+ let epoch = 0;
709
+ // Number of CURRENT-epoch dispatched pulls that have not yet settled (released).
710
+ let pending = 0;
711
+ // Has an authoritative snapshot been applied since the gate last closed? Reset
712
+ // whenever the gate re-closes (the first arm() after an open).
713
+ let authoritativeApplied = false;
714
+ // Starts OPEN: a resolved promise so nothing blocks until the first arm().
715
+ let ready: Promise<void> = Promise.resolve();
716
+ let resolveReady: (() => void) | null = null;
717
+ const open = () => {
718
+ const resolve = resolveReady;
719
+ resolveReady = null;
720
+ resolve?.();
721
+ };
722
+ return {
723
+ arm(): SettingsReadyToken {
724
+ generation += 1;
725
+ // Only create a fresh pending promise when the gate is currently open; if a
726
+ // pull is already in flight (resolveReady set) keep the SAME promise so its
727
+ // existing waiters stay parked. When re-closing an open gate, start a NEW
728
+ // epoch: discard any stale pending count left over from pulls of the prior
729
+ // (now-closed) period — a startup pull that STALLED and never settled must
730
+ // not keep the new epoch's `pending` above zero forever — and clear the
731
+ // authoritative flag so the new epoch must observe a fresh authoritative
732
+ // pull (or drain to zero in-flight) before opening again.
733
+ if (resolveReady === null) {
734
+ epoch += 1;
735
+ pending = 0;
736
+ authoritativeApplied = false;
737
+ ready = new Promise<void>((resolve) => {
738
+ resolveReady = resolve;
739
+ });
740
+ }
741
+ pending += 1;
742
+ return { generation, epoch };
743
+ },
744
+ release(token: SettingsReadyToken, authoritative: boolean): void {
745
+ // Retire stale older-epoch pulls. The gate re-closed (a newer pull started)
746
+ // since this token was armed, so this release belongs to a superseded
747
+ // period. Ignore it entirely: a stalled startup pull settling LATE must
748
+ // neither decrement the current epoch's `pending` (which would spuriously
749
+ // open via pending===0) nor mark the current epoch authoritative.
750
+ if (token.epoch !== epoch) return;
751
+ // Count this current-epoch pull out of the in-flight set.
752
+ if (pending > 0) pending -= 1;
753
+ // Only the LATEST armed pull's authoritative snapshot may open the gate.
754
+ // An OLDER pull (e.g. an on-ready/reconnect pull armed before a config
755
+ // change) can return an authoritative HTTP-200 snapshot while a NEWER
756
+ // `agent.config.changed` pull — carrying the just-muted / mention-only
757
+ // state — is still in flight. Opening on that stale authoritative release
758
+ // would let held group traffic proceed against settings the newer pull is
759
+ // about to replace (Finding #3). So only set `authoritativeApplied` when
760
+ // this release is for the latest armed generation. An older-generation
761
+ // authoritative release merely decrements `pending`.
762
+ if (authoritative && token.generation === generation) authoritativeApplied = true;
763
+ // Open iff the cache is now authoritative for the LATEST epoch, OR no pull
764
+ // is still in flight (a slower, earlier pull could still be applying an
765
+ // authoritative snapshot, so a non-authoritative settle must NOT open the
766
+ // gate while `pending > 0`). The `pending === 0` backstop still fires when
767
+ // every pull has settled — including the older-authoritative-then-newer-
768
+ // failure case, where the older snapshot is the freshest available.
769
+ if (authoritativeApplied || pending === 0) open();
770
+ },
771
+ forceOpen(): void {
772
+ pending = 0;
773
+ open();
774
+ },
775
+ wait(): Promise<void> {
776
+ return ready;
777
+ },
778
+ };
779
+ }
780
+
781
+ // Resolve when `gate` settles, OR after `ms`, OR on abort — whichever comes
782
+ // first. The timer is always cleared so it can't leak under fake timers. Used to
783
+ // bound the first-group-settings-pull gate so a stalled pull can never hang group
784
+ // traffic permanently (best-effort governance must always release).
785
+ function waitWithTimeout(gate: Promise<void>, ms: number, abortSignal: AbortSignal): Promise<void> {
786
+ if (abortSignal.aborted) return Promise.resolve();
787
+ return new Promise<void>((resolve) => {
788
+ let settled = false;
789
+ const finish = () => {
790
+ if (settled) return;
791
+ settled = true;
792
+ clearTimeout(timer);
793
+ abortSignal.removeEventListener("abort", finish);
794
+ resolve();
795
+ };
796
+ const timer = setTimeout(finish, Math.max(1, ms));
797
+ abortSignal.addEventListener("abort", finish, { once: true });
798
+ void gate.then(finish, finish);
799
+ });
800
+ }
801
+
644
802
  function waitForActivationPoll(abortSignal: AbortSignal, ms: number): Promise<boolean> {
645
803
  if (abortSignal.aborted) return Promise.resolve(false);
646
804
  return new Promise((resolve) => {
@@ -1542,6 +1700,84 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1542
1700
  log: (msg) => log?.info?.(msg),
1543
1701
  context: wsLogContext,
1544
1702
  });
1703
+ const groupSettingsCache = new GroupSettingsCache();
1704
+ // Findings 2 & 3 — gate group dispatch on the IN-FLIGHT settings pull, not just
1705
+ // the first one. The pull is fire-and-forget, so replayed/live group messages
1706
+ // could otherwise enter the governance gate against a stale/empty cache
1707
+ // (treating a just-muted/mention-only chat as default "all"). A RE-ARMABLE gate
1708
+ // (`settingsReadyGate`) re-closes whenever a new pull starts — at startup, on
1709
+ // every WS reconnect (after hello-ok), and on `agent.config.changed` — so a
1710
+ // reconnect/config-change pull holds group traffic until the fresh settings
1711
+ // land, mirroring the hermes adapter's `_group_settings_ready` event. Group
1712
+ // ingest awaits it through a bounded race (`waitWithTimeout`,
1713
+ // GROUP_SETTINGS_READY_TIMEOUT_MS) so even a stalled pull — endpoint accepts but
1714
+ // never responds — can hold group traffic at most that long before the gate
1715
+ // releases. Non-group traffic never awaits this.
1716
+ const settingsReadyGate = createSettingsReadyGate();
1717
+ // Best-effort re-pull of per-group agent settings from the backend.
1718
+ // Called at startup, on every successful WS (re)connect, and on
1719
+ // `notify.signal` with type "agent.config.changed".
1720
+ //
1721
+ // Finding 4 — `bypassRefresh` is set for the PRE-connect startup pull only. The
1722
+ // normal pull runs through the REST `withRefresh` wrapper; a 401/403 there
1723
+ // rotates `account.token`. But the WS client captured the token at construction
1724
+ // (`createOpenclawClawlingClient`, `token: account.token`), so a pre-connect
1725
+ // rotation would leave the client connecting with a stale token. The startup
1726
+ // pull therefore calls the api-client WITHOUT the refresh wrapper: on auth
1727
+ // failure it simply fails (empty cache + gate released), leaving the WS
1728
+ // handshake — which has its own auth/refresh path — to own token rotation. The
1729
+ // on-ready pull (post hello-ok) keeps the refresh wrapper.
1730
+ // Monotonic fetch sequence: incremented once per dispatched pull and passed
1731
+ // into `applyFetched` so the cache can order results across overlapping
1732
+ // requests (including empty pulls, which have no row version to compare). A
1733
+ // late result with a lower sequence is ignored, so a stale snapshot — empty
1734
+ // or not — can never roll back newer state.
1735
+ let groupSettingsFetchSequence = 0;
1736
+ const pullGroupSettings = (opts?: { bypassRefresh?: boolean }): void => {
1737
+ const sequence = ++groupSettingsFetchSequence;
1738
+ // Re-close the gate SYNCHRONOUSLY, before the async pull is scheduled, so any
1739
+ // group frame the read loop dispatches concurrently observes the closed gate
1740
+ // (rather than slipping past against the pre-pull cache). The opaque token
1741
+ // scopes the release by generation AND epoch, so a superseded pull (from a
1742
+ // prior re-closed epoch) never re-opens or counts against a newer pull's gate.
1743
+ const gateToken = settingsReadyGate.arm();
1744
+ void (async () => {
1745
+ // Whether THIS pull applied an authoritative (HTTP 200) snapshot. Only an
1746
+ // authoritative apply opens the gate immediately; a non-authoritative /
1747
+ // failed pull must not open the gate while an earlier pull is still in
1748
+ // flight (Finding 2) — it only counts down the in-flight set.
1749
+ let authoritative = false;
1750
+ try {
1751
+ const result = opts?.bypassRefresh
1752
+ ? await (conversationApiClient ??= buildConversationApiClient()).getMyGroupSettings()
1753
+ : await getConversationApiClient().getMyGroupSettings();
1754
+ if (!result.authoritative) {
1755
+ // Non-authoritative outcome (404 / endpoint-absent / malformed body):
1756
+ // preserve the cache. This is NOT "cleared".
1757
+ log?.info?.(
1758
+ `[${accountId}] clawchat-plugin-openclaw group-settings pull non-authoritative seq=${sequence} (cache preserved)`,
1759
+ );
1760
+ return;
1761
+ }
1762
+ groupSettingsCache.applyFetched(result.rows, sequence);
1763
+ authoritative = true;
1764
+ log?.info?.(
1765
+ `[${accountId}] clawchat-plugin-openclaw group-settings pull ok seq=${sequence} rows=${result.rows.length}`,
1766
+ );
1767
+ } catch (err) {
1768
+ log?.error?.(
1769
+ `[${accountId}] clawchat-plugin-openclaw group-settings pull failed: ${err instanceof Error ? err.message : String(err)}`,
1770
+ );
1771
+ } finally {
1772
+ // Pull settled. Open the gate iff this pull applied an authoritative
1773
+ // snapshot OR no in-flight pull remains. A non-authoritative/failed pull
1774
+ // settling ahead of an earlier still-pending authoritative pull does NOT
1775
+ // open the gate — the earlier pull's authoritative apply (or the call-site
1776
+ // timeout/abort backstop) does.
1777
+ settingsReadyGate.release(gateToken, authoritative);
1778
+ }
1779
+ })();
1780
+ };
1545
1781
  const logAuthFailure = (reason: string) => {
1546
1782
  if (authFailureLogged) return;
1547
1783
  authFailureLogged = true;
@@ -1637,6 +1873,9 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1637
1873
  }
1638
1874
  void refreshConversationCacheAfterReady();
1639
1875
  void dispatchActivationBootstrap();
1876
+ // Re-pull per-group agent settings on every successful (re)connect so the
1877
+ // cache stays current after a network interruption or token refresh.
1878
+ pullGroupSettings();
1640
1879
  // §A.1 — arm the proactive refresh timer from the live token's `exp`
1641
1880
  // every time a connection becomes ready (re-armed after every refresh via
1642
1881
  // the gateway re-enter).
@@ -1794,7 +2033,19 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1794
2033
  // cache (friends are fetched on demand via REST tools), so there is nothing
1795
2034
  // to invalidate — observe + dedup only. The live frame and its reliable
1796
2035
  // inbox replay carry the same event_id and collapse to one observation.
1797
- notifySignalObserver.observe(env);
2036
+ const outcome = notifySignalObserver.observe(env);
2037
+ if (outcome === "observed") {
2038
+ const payload = env.payload && typeof env.payload === "object"
2039
+ ? (env.payload as Record<string, unknown>)
2040
+ : undefined;
2041
+ const type = typeof payload?.type === "string" ? payload.type : "";
2042
+ if (type === "agent.config.changed") {
2043
+ log?.info?.(
2044
+ `[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`,
2045
+ );
2046
+ pullGroupSettings();
2047
+ }
2048
+ }
1798
2049
  });
1799
2050
 
1800
2051
  client.on("replay:done", (env: Envelope) => {
@@ -1865,7 +2116,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1865
2116
  }
1866
2117
  });
1867
2118
 
1868
- type IngestTurnResult = "submitted" | "skipped" | "failed";
2119
+ type IngestTurnResult = "submitted" | "skipped" | "failed" | "stored_muted" | "stored_skipped";
1869
2120
  type GroupIngestTurnParams = IngestTurnParams & { peer: { kind: "group"; id: string } };
1870
2121
 
1871
2122
  const loadGroupParticipantsForPrompt = async (params: {
@@ -2243,10 +2494,95 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2243
2494
  }
2244
2495
  };
2245
2496
 
2497
+ // Resolve the live mute / reply-mode / batch-window for a group chat from the
2498
+ // backend settings cache, falling back to the static channel config. Shared by
2499
+ // the enqueue-time gate (ingestTurn) and the flush-time re-gate (coalescer
2500
+ // dispatch) so both decisions read the SAME source of truth.
2501
+ const resolveGroupEffectiveSettings = (chatId: string) =>
2502
+ groupSettingsCache.effective(chatId, {
2503
+ muted: false,
2504
+ replyMode: effectiveGroupMode(account, chatId),
2505
+ batchDelaySeconds: 10,
2506
+ });
2507
+
2246
2508
  const groupCoalescer = createGroupMessageCoalescer<GroupIngestTurnParams>({
2247
2509
  idleMs: 10_000,
2248
2510
  maxWaitMs: 30_000,
2249
2511
  dispatch: async (turn) => {
2512
+ // Finding 3 — gate the FLUSH path too. A queued non-mention batch can flush
2513
+ // (idle/max timer) while an `agent.config.changed` settings pull is in
2514
+ // flight; resolving effective settings here without awaiting the gate reads
2515
+ // the stale cache and dispatches one reply for a chat about to be muted /
2516
+ // flipped to mention-only. Await the same re-armable gate (bounded by
2517
+ // GROUP_SETTINGS_READY_TIMEOUT_MS) so the flush-time re-check below sees the
2518
+ // fresh snapshot.
2519
+ //
2520
+ // Finding #4 — do NOT early-return on abort here. The ingest path DOES early-
2521
+ // return on abort, but only AFTER it has already taken the idempotency claim
2522
+ // (`claimInboundTurn` runs BEFORE the gate wait there, so the frame is durably
2523
+ // persisted before the interruptible wait — required by legacy advance-on-
2524
+ // write delivery, where a frame written before a disconnect is not re-delivered
2525
+ // on reconnect; see docs/client-integration.md §11.7). So even an ingest-time
2526
+ // abort keeps the frame; it just skips dispatch. Here at FLUSH the batch was
2527
+ // likewise already claimed at ingest AND `flush()` has already removed it from
2528
+ // the coalescer's pending set. An abort early-return would therefore silently
2529
+ // DROP an already-claimed, already-dequeued batch — and a replay of the same
2530
+ // message_id is deduped by `claimMessageOnce`, permanently losing the group
2531
+ // reply. The gate wait still runs and resolves immediately on abort (forceOpen)
2532
+ // so we re-resolve against the freshest available settings, then dispatch
2533
+ // best-effort rather than dropping.
2534
+ await waitWithTimeout(settingsReadyGate.wait(), GROUP_SETTINGS_READY_TIMEOUT_MS, abortSignal);
2535
+ // Finding 1 — re-evaluate governance at FLUSH time. Settings can change
2536
+ // (via an agent.config.changed pull) while a coalesced batch sits pending,
2537
+ // so the enqueue-time decision may be stale. Drop the batch if the chat is
2538
+ // now muted, or if it flipped to mention-only and this batch carries no
2539
+ // mention.
2540
+ const eff = resolveGroupEffectiveSettings(turn.peer.id);
2541
+ if (eff.muted) {
2542
+ log?.info?.(
2543
+ `[${accountId}] clawchat-plugin-openclaw coalesced group batch dropped at flush (now muted) chat_id=${turn.peer.id} msg=${turn.messageId}`,
2544
+ );
2545
+ return;
2546
+ }
2547
+ if (eff.replyMode === "mention" && !turn.wasMentioned) {
2548
+ log?.info?.(
2549
+ `[${accountId}] clawchat-plugin-openclaw coalesced group batch dropped at flush (now mention-only, no mention) chat_id=${turn.peer.id} msg=${turn.messageId}`,
2550
+ );
2551
+ return;
2552
+ }
2553
+ if (turn.wasMentioned && store?.listRecentGroupMessages) {
2554
+ const chatId = turn.peer.id;
2555
+ // Dedupe: exclude rows whose message_id belongs to the current batch/turn.
2556
+ // For a coalesced multi-message turn this covers every constituent ID,
2557
+ // not just the latest one (turn.messageId), so none of the batched
2558
+ // messages appear twice — once in the coalesced body and once in the
2559
+ // prepended "Prior group context" block.
2560
+ const batchIds = new Set(turn.allMessageIds ?? [turn.messageId]);
2561
+ // Overfetch so the batch's own just-stored messages don't crowd out the
2562
+ // MENTION_CONTEXT_N prior rows: fetch batch-size extra rows, drop the
2563
+ // batch members, then keep the most recent MENTION_CONTEXT_N survivors.
2564
+ const priorRows =
2565
+ store.listRecentGroupMessages(
2566
+ "openclaw",
2567
+ accountId,
2568
+ chatId,
2569
+ MENTION_CONTEXT_N + batchIds.size,
2570
+ ) ?? [];
2571
+ const contextRows = priorRows
2572
+ .filter((row) => row.message_id != null && !batchIds.has(row.message_id))
2573
+ .slice(-MENTION_CONTEXT_N);
2574
+ if (contextRows.length > 0) {
2575
+ const priorContextBody = [
2576
+ "Prior group context:",
2577
+ ...contextRows.map((row, i) => `[prior ${i + 1}] ${row.text ?? "(empty)"}`),
2578
+ ].join("\n");
2579
+ await dispatchTurnToAgent({
2580
+ ...turn,
2581
+ rawBody: `${priorContextBody}\n${turn.rawBody}`,
2582
+ });
2583
+ return;
2584
+ }
2585
+ }
2250
2586
  await dispatchTurnToAgent(turn);
2251
2587
  },
2252
2588
  onError: (err) => {
@@ -2298,9 +2634,49 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2298
2634
  senderNickName: resolveSenderNickNameForTurn(rawTurn),
2299
2635
  ...senderBatchIdentity,
2300
2636
  };
2301
- const claimed = claimInboundTurn(turn);
2302
- if (claimed === "skipped") return "skipped";
2303
2637
  if (turn.peer.kind === "group") {
2638
+ // Finding 1 corrected (message loss) — claim the inbound idempotency row
2639
+ // BEFORE the (interruptible) settings-gate wait, so every received group
2640
+ // frame is durably persisted EXACTLY ONCE before any wait that a shutdown
2641
+ // can interrupt. This client does NOT advertise `reliable_delivery`
2642
+ // (src/ws-client.ts), so under legacy delivery the server's replay cursor
2643
+ // advances on socket-WRITE (docs/client-integration.md §11.7): a frame
2644
+ // already written before a disconnect is NOT re-delivered on reconnect.
2645
+ // `claimMessageOnce` is the ONLY durability an inbound has, and the store
2646
+ // never reprocesses undispatched inbounds. So if we claimed only AFTER the
2647
+ // wait, a shutdown-mid-wait would leave the frame neither persisted NOR
2648
+ // replayed — permanently lost. Claiming first makes it durable; the abort
2649
+ // check below then returns without dispatching, but the frame is kept.
2650
+ // `claimInboundTurn` is INSERT OR IGNORE, so a genuine duplicate (same
2651
+ // message_id) is still deduped here exactly once.
2652
+ const claimed = claimInboundTurn(turn);
2653
+ if (claimed === "skipped") return "skipped";
2654
+ // Findings 2 & 3 — hold ALL group traffic until the in-flight settings pull
2655
+ // has settled so the mute / reply-mode gate never runs against a stale/empty
2656
+ // cache (startup, reconnect, AND config-change pulls all re-close the gate).
2657
+ // This only delays group traffic; direct messages return below. The wait is
2658
+ // bounded (GROUP_SETTINGS_READY_TIMEOUT_MS): a stalled pull must never hang
2659
+ // group traffic forever, so the gate always releases — falling back to the
2660
+ // (possibly stale) cache rather than blocking indefinitely.
2661
+ await waitWithTimeout(settingsReadyGate.wait(), GROUP_SETTINGS_READY_TIMEOUT_MS, abortSignal);
2662
+ // Finding (#2 follow-up) — the bounded wait also resolves on abort. If the
2663
+ // gateway is closing, stop here instead of enqueuing/dispatching a group turn
2664
+ // into a tearing-down gateway. The frame is already claimed (persisted), so
2665
+ // returning here drops only the dispatch, never the message.
2666
+ if (abortSignal.aborted) return "skipped";
2667
+ // Mute gate — evaluated BEFORE the slash-command branch so a backend-muted
2668
+ // group is fully store-only, consistent with muted normal messages: a
2669
+ // muted group must never forward/dispatch a `/clawchat-*` command (or any
2670
+ // group slash command) to the agent or the owner. Known commands were
2671
+ // previously handled above this gate, which let a muted group still drive
2672
+ // the agent — contradicting the mute semantics. Keep this check first.
2673
+ const eff = resolveGroupEffectiveSettings(turn.peer.id);
2674
+ if (eff.muted) {
2675
+ log?.info?.(
2676
+ `[${accountId}] clawchat-plugin-openclaw group message stored but muted chat_id=${turn.peer.id} msg=${turn.messageId}`,
2677
+ );
2678
+ return "stored_muted";
2679
+ }
2304
2680
  if (isKnownOpenClawGroupSlashCommand(turn.rawBody, cfg)) {
2305
2681
  if (isOpenClawConfirmSlashCommand(turn.rawBody)) {
2306
2682
  return forwardGroupApprovalSlashToOwner(turn);
@@ -2319,20 +2695,33 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2319
2695
  );
2320
2696
  return dispatchTurnToAgent(turn);
2321
2697
  }
2698
+ if (eff.replyMode === "mention" && !turn.wasMentioned) {
2699
+ log?.info?.(
2700
+ `[${accountId}] clawchat-plugin-openclaw group message stored but skipped (mention mode) chat_id=${turn.peer.id} msg=${turn.messageId}`,
2701
+ );
2702
+ return "stored_skipped";
2703
+ }
2704
+ const idleMsOverride = eff.batchDelaySeconds * 1000;
2322
2705
  if (turn.wasMentioned) {
2323
- groupCoalescer.enqueue(turn as GroupIngestTurnParams);
2706
+ groupCoalescer.enqueue(turn as GroupIngestTurnParams, { idleMsOverride });
2324
2707
  groupCoalescer.flushNow(turn.peer.id);
2325
2708
  log?.info?.(
2326
2709
  `[${accountId}] clawchat-plugin-openclaw dispatching mentioned group batch chat_id=${turn.peer.id} msg=${turn.messageId}`,
2327
2710
  );
2328
2711
  return "submitted";
2329
2712
  }
2330
- groupCoalescer.enqueue(turn as GroupIngestTurnParams);
2713
+ groupCoalescer.enqueue(turn as GroupIngestTurnParams, { idleMsOverride });
2331
2714
  log?.info?.(
2332
2715
  `[${accountId}] clawchat-plugin-openclaw queued group batch chat_id=${turn.peer.id} msg=${turn.messageId}`,
2333
2716
  );
2334
2717
  return "submitted";
2335
2718
  }
2719
+ // Direct messages: no gate to await, so claim right before dispatch. (The
2720
+ // group branch claims BEFORE its gate wait so a shutdown-mid-wait cannot lose
2721
+ // the frame under this client's legacy advance-on-write delivery — see
2722
+ // Finding 1 corrected above.)
2723
+ const claimed = claimInboundTurn(turn);
2724
+ if (claimed === "skipped") return "skipped";
2336
2725
  return dispatchTurnToAgent(turn);
2337
2726
  };
2338
2727
 
@@ -2406,6 +2795,20 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2406
2795
  })();
2407
2796
  });
2408
2797
 
2798
+ // Initial pull of per-group agent settings at startup (best-effort; errors are
2799
+ // logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
2800
+ // rotating `account.token` out from under the already-constructed WS client.
2801
+ // Settling this releases the Finding-2 group-dispatch gate.
2802
+ pullGroupSettings({ bypassRefresh: true });
2803
+ // Safety: never strand a held group message if the runtime is torn down while a
2804
+ // pull is in flight. `waitWithTimeout` already releases group waiters on abort,
2805
+ // but force the gate open too so any later `wait()` resolves immediately.
2806
+ abortSignal.addEventListener(
2807
+ "abort",
2808
+ () => settingsReadyGate.forceOpen(),
2809
+ { once: true },
2810
+ );
2811
+
2409
2812
  // `client.connect()` resolves on `hello-ok` or rejects on `hello-fail`
2410
2813
  // (auth). Transport failures (server unreachable, DNS error, etc.) do
2411
2814
  // NOT reject this promise — the local client handles them internally and
package/src/storage.ts CHANGED
@@ -113,6 +113,26 @@ export type FinishConnectionInput = {
113
113
  error?: string | null;
114
114
  };
115
115
 
116
+ /** A row from `clawchat_messages` as returned by `listRecentGroupMessages`. */
117
+ export type MessageRow = {
118
+ id: number;
119
+ platform: string;
120
+ account_id: string;
121
+ kind: string;
122
+ direction: string;
123
+ event_type: string;
124
+ trace_id: string | null;
125
+ chat_id: string | null;
126
+ message_id: string | null;
127
+ text: string | null;
128
+ raw_json: string | null;
129
+ created_at: number;
130
+ send_status: string | null;
131
+ protocol_message_id: string | null;
132
+ acked_at: number | null;
133
+ send_error: string | null;
134
+ };
135
+
116
136
  export type ToolCallInput = {
117
137
  platform: string;
118
138
  accountId?: string | null;
@@ -845,6 +865,32 @@ export class ClawChatStore {
845
865
  ) ?? [];
846
866
  }
847
867
 
868
+ /**
869
+ * Return the last `limit` user-visible messages in a group chat, ordered
870
+ * oldest-first. Only `kind = 'message'` rows are returned; internal kinds such
871
+ * as `'thinking'` are excluded so the @-mention prior-context block never
872
+ * surfaces the agent's intermediate output as conversation history.
873
+ * Uses `idx_clawchat_messages_chat_created` for performance.
874
+ * Returns an empty array if the store is unavailable.
875
+ */
876
+ listRecentGroupMessages(
877
+ platform: string,
878
+ accountId: string,
879
+ chatId: string,
880
+ limit: number,
881
+ ): MessageRow[] {
882
+ return this.read(() => {
883
+ const rows = this.requireDb()
884
+ .prepare(
885
+ `SELECT * FROM clawchat_messages
886
+ WHERE platform = ? AND account_id = ? AND chat_id = ? AND kind = 'message'
887
+ ORDER BY created_at DESC, rowid DESC LIMIT ?`,
888
+ )
889
+ .all(platform, accountId, chatId, limit) as MessageRow[];
890
+ return rows.reverse(); // oldest-first for prompt order
891
+ }) ?? [];
892
+ }
893
+
848
894
  close(): void {
849
895
  this.db?.close();
850
896
  this.db = null;