@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.
@@ -8,7 +8,7 @@ import { reportPluginVersionSafe, resolvePluginVersion } from "./plugin-report.j
8
8
  import { ClawlingApiError } from "./api-types.js";
9
9
  import { RefreshManager } from "./refresh-manager.js";
10
10
  import { runOpenclawClawlingLogin, } from "./login.runtime.js";
11
- import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
11
+ import { CHANNEL_ID, effectiveOutputVisibility, effectiveGroupCommandMode, effectiveGroupMode, hasOpenclawClawlingConnectCredentials, resolveOpenclawClawlingAccount, } from "./config.js";
12
12
  import { dispatchOpenclawClawlingInbound } from "./inbound.js";
13
13
  import { fetchInboundMedia } from "./media-runtime.js";
14
14
  import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
@@ -25,11 +25,20 @@ import { deleteClawChatMemoryFile, readClawChatMemoryFile } from "./clawchat-mem
25
25
  import { clearClawChatPromptInjectionForSession, stageClawChatPromptInjection, } from "./prompt-injection.js";
26
26
  import { createGroupMessageCoalescer } from "./group-message-coalescer.js";
27
27
  import { openclawLlmContextDebug } from "./llm-context-debug.js";
28
+ import { GroupSettingsCache } from "./group-settings.js";
28
29
  const { setRuntime: setOpenclawClawlingRuntime, getRuntime: getOpenclawClawlingRuntime } = createPluginRuntimeStore("clawchat-plugin-openclaw runtime not initialized");
29
30
  export { setOpenclawClawlingRuntime, getOpenclawClawlingRuntime };
30
31
  const activeClients = new Map();
31
32
  const CLAWCHAT_PLUGIN_SLASH_COMMANDS = new Set(["clawchat-activate", "clawchat-output"]);
32
33
  const ACTIVATION_CREDENTIAL_POLL_INTERVAL_MS = 2_000;
34
+ // Upper bound (ms) on how long group ingest waits for the FIRST group-settings
35
+ // pull to settle before proceeding anyway. Best-effort governance must never
36
+ // permanently disable group traffic, so a stalled initial GET (endpoint accepts
37
+ // but never responds) can hold the gate at most this long. Matches the sibling
38
+ // hermes plugin's GROUP_SETTINGS_READY_TIMEOUT_SECONDS (5s).
39
+ const GROUP_SETTINGS_READY_TIMEOUT_MS = 5_000;
40
+ /** Number of recent group messages to prepend as context when the agent is @-mentioned. */
41
+ const MENTION_CONTEXT_N = 10;
33
42
  const CLAWCHAT_MEMORY_ROOT_UNAVAILABLE = "ClawChat memory root unavailable: OpenClaw workspaceDir could not be resolved";
34
43
  const OPENCLAW_CONFIRM_SLASH_COMMANDS = new Set([
35
44
  "approve",
@@ -406,6 +415,109 @@ export async function maybeAutoActivateFromEnv(params) {
406
415
  params.log?.info?.(`[${accountId}] clawchat-plugin-openclaw ${CONNECT_CODE_ENV} activation succeeded hasToken=${Boolean(next.token)}`);
407
416
  return next;
408
417
  }
418
+ export function createSettingsReadyGate() {
419
+ let generation = 0;
420
+ // Epoch counter: incremented every time the gate RE-CLOSES (an arm() while the
421
+ // gate was open). `pending` is epoch-scoped — a pull armed in a prior epoch is
422
+ // retired and can neither keep nor reopen the current epoch's gate.
423
+ let epoch = 0;
424
+ // Number of CURRENT-epoch dispatched pulls that have not yet settled (released).
425
+ let pending = 0;
426
+ // Has an authoritative snapshot been applied since the gate last closed? Reset
427
+ // whenever the gate re-closes (the first arm() after an open).
428
+ let authoritativeApplied = false;
429
+ // Starts OPEN: a resolved promise so nothing blocks until the first arm().
430
+ let ready = Promise.resolve();
431
+ let resolveReady = null;
432
+ const open = () => {
433
+ const resolve = resolveReady;
434
+ resolveReady = null;
435
+ resolve?.();
436
+ };
437
+ return {
438
+ arm() {
439
+ generation += 1;
440
+ // Only create a fresh pending promise when the gate is currently open; if a
441
+ // pull is already in flight (resolveReady set) keep the SAME promise so its
442
+ // existing waiters stay parked. When re-closing an open gate, start a NEW
443
+ // epoch: discard any stale pending count left over from pulls of the prior
444
+ // (now-closed) period — a startup pull that STALLED and never settled must
445
+ // not keep the new epoch's `pending` above zero forever — and clear the
446
+ // authoritative flag so the new epoch must observe a fresh authoritative
447
+ // pull (or drain to zero in-flight) before opening again.
448
+ if (resolveReady === null) {
449
+ epoch += 1;
450
+ pending = 0;
451
+ authoritativeApplied = false;
452
+ ready = new Promise((resolve) => {
453
+ resolveReady = resolve;
454
+ });
455
+ }
456
+ pending += 1;
457
+ return { generation, epoch };
458
+ },
459
+ release(token, authoritative) {
460
+ // Retire stale older-epoch pulls. The gate re-closed (a newer pull started)
461
+ // since this token was armed, so this release belongs to a superseded
462
+ // period. Ignore it entirely: a stalled startup pull settling LATE must
463
+ // neither decrement the current epoch's `pending` (which would spuriously
464
+ // open via pending===0) nor mark the current epoch authoritative.
465
+ if (token.epoch !== epoch)
466
+ return;
467
+ // Count this current-epoch pull out of the in-flight set.
468
+ if (pending > 0)
469
+ pending -= 1;
470
+ // Only the LATEST armed pull's authoritative snapshot may open the gate.
471
+ // An OLDER pull (e.g. an on-ready/reconnect pull armed before a config
472
+ // change) can return an authoritative HTTP-200 snapshot while a NEWER
473
+ // `agent.config.changed` pull — carrying the just-muted / mention-only
474
+ // state — is still in flight. Opening on that stale authoritative release
475
+ // would let held group traffic proceed against settings the newer pull is
476
+ // about to replace (Finding #3). So only set `authoritativeApplied` when
477
+ // this release is for the latest armed generation. An older-generation
478
+ // authoritative release merely decrements `pending`.
479
+ if (authoritative && token.generation === generation)
480
+ authoritativeApplied = true;
481
+ // Open iff the cache is now authoritative for the LATEST epoch, OR no pull
482
+ // is still in flight (a slower, earlier pull could still be applying an
483
+ // authoritative snapshot, so a non-authoritative settle must NOT open the
484
+ // gate while `pending > 0`). The `pending === 0` backstop still fires when
485
+ // every pull has settled — including the older-authoritative-then-newer-
486
+ // failure case, where the older snapshot is the freshest available.
487
+ if (authoritativeApplied || pending === 0)
488
+ open();
489
+ },
490
+ forceOpen() {
491
+ pending = 0;
492
+ open();
493
+ },
494
+ wait() {
495
+ return ready;
496
+ },
497
+ };
498
+ }
499
+ // Resolve when `gate` settles, OR after `ms`, OR on abort — whichever comes
500
+ // first. The timer is always cleared so it can't leak under fake timers. Used to
501
+ // bound the first-group-settings-pull gate so a stalled pull can never hang group
502
+ // traffic permanently (best-effort governance must always release).
503
+ function waitWithTimeout(gate, ms, abortSignal) {
504
+ if (abortSignal.aborted)
505
+ return Promise.resolve();
506
+ return new Promise((resolve) => {
507
+ let settled = false;
508
+ const finish = () => {
509
+ if (settled)
510
+ return;
511
+ settled = true;
512
+ clearTimeout(timer);
513
+ abortSignal.removeEventListener("abort", finish);
514
+ resolve();
515
+ };
516
+ const timer = setTimeout(finish, Math.max(1, ms));
517
+ abortSignal.addEventListener("abort", finish, { once: true });
518
+ void gate.then(finish, finish);
519
+ });
520
+ }
409
521
  function waitForActivationPoll(abortSignal, ms) {
410
522
  if (abortSignal.aborted)
411
523
  return Promise.resolve(false);
@@ -1224,6 +1336,80 @@ export async function startOpenclawClawlingGateway(params) {
1224
1336
  log: (msg) => log?.info?.(msg),
1225
1337
  context: wsLogContext,
1226
1338
  });
1339
+ const groupSettingsCache = new GroupSettingsCache();
1340
+ // Findings 2 & 3 — gate group dispatch on the IN-FLIGHT settings pull, not just
1341
+ // the first one. The pull is fire-and-forget, so replayed/live group messages
1342
+ // could otherwise enter the governance gate against a stale/empty cache
1343
+ // (treating a just-muted/mention-only chat as default "all"). A RE-ARMABLE gate
1344
+ // (`settingsReadyGate`) re-closes whenever a new pull starts — at startup, on
1345
+ // every WS reconnect (after hello-ok), and on `agent.config.changed` — so a
1346
+ // reconnect/config-change pull holds group traffic until the fresh settings
1347
+ // land, mirroring the hermes adapter's `_group_settings_ready` event. Group
1348
+ // ingest awaits it through a bounded race (`waitWithTimeout`,
1349
+ // GROUP_SETTINGS_READY_TIMEOUT_MS) so even a stalled pull — endpoint accepts but
1350
+ // never responds — can hold group traffic at most that long before the gate
1351
+ // releases. Non-group traffic never awaits this.
1352
+ const settingsReadyGate = createSettingsReadyGate();
1353
+ // Best-effort re-pull of per-group agent settings from the backend.
1354
+ // Called at startup, on every successful WS (re)connect, and on
1355
+ // `notify.signal` with type "agent.config.changed".
1356
+ //
1357
+ // Finding 4 — `bypassRefresh` is set for the PRE-connect startup pull only. The
1358
+ // normal pull runs through the REST `withRefresh` wrapper; a 401/403 there
1359
+ // rotates `account.token`. But the WS client captured the token at construction
1360
+ // (`createOpenclawClawlingClient`, `token: account.token`), so a pre-connect
1361
+ // rotation would leave the client connecting with a stale token. The startup
1362
+ // pull therefore calls the api-client WITHOUT the refresh wrapper: on auth
1363
+ // failure it simply fails (empty cache + gate released), leaving the WS
1364
+ // handshake — which has its own auth/refresh path — to own token rotation. The
1365
+ // on-ready pull (post hello-ok) keeps the refresh wrapper.
1366
+ // Monotonic fetch sequence: incremented once per dispatched pull and passed
1367
+ // into `applyFetched` so the cache can order results across overlapping
1368
+ // requests (including empty pulls, which have no row version to compare). A
1369
+ // late result with a lower sequence is ignored, so a stale snapshot — empty
1370
+ // or not — can never roll back newer state.
1371
+ let groupSettingsFetchSequence = 0;
1372
+ const pullGroupSettings = (opts) => {
1373
+ const sequence = ++groupSettingsFetchSequence;
1374
+ // Re-close the gate SYNCHRONOUSLY, before the async pull is scheduled, so any
1375
+ // group frame the read loop dispatches concurrently observes the closed gate
1376
+ // (rather than slipping past against the pre-pull cache). The opaque token
1377
+ // scopes the release by generation AND epoch, so a superseded pull (from a
1378
+ // prior re-closed epoch) never re-opens or counts against a newer pull's gate.
1379
+ const gateToken = settingsReadyGate.arm();
1380
+ void (async () => {
1381
+ // Whether THIS pull applied an authoritative (HTTP 200) snapshot. Only an
1382
+ // authoritative apply opens the gate immediately; a non-authoritative /
1383
+ // failed pull must not open the gate while an earlier pull is still in
1384
+ // flight (Finding 2) — it only counts down the in-flight set.
1385
+ let authoritative = false;
1386
+ try {
1387
+ const result = opts?.bypassRefresh
1388
+ ? await (conversationApiClient ??= buildConversationApiClient()).getMyGroupSettings()
1389
+ : await getConversationApiClient().getMyGroupSettings();
1390
+ if (!result.authoritative) {
1391
+ // Non-authoritative outcome (404 / endpoint-absent / malformed body):
1392
+ // preserve the cache. This is NOT "cleared".
1393
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw group-settings pull non-authoritative seq=${sequence} (cache preserved)`);
1394
+ return;
1395
+ }
1396
+ groupSettingsCache.applyFetched(result.rows, sequence);
1397
+ authoritative = true;
1398
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw group-settings pull ok seq=${sequence} rows=${result.rows.length}`);
1399
+ }
1400
+ catch (err) {
1401
+ log?.error?.(`[${accountId}] clawchat-plugin-openclaw group-settings pull failed: ${err instanceof Error ? err.message : String(err)}`);
1402
+ }
1403
+ finally {
1404
+ // Pull settled. Open the gate iff this pull applied an authoritative
1405
+ // snapshot OR no in-flight pull remains. A non-authoritative/failed pull
1406
+ // settling ahead of an earlier still-pending authoritative pull does NOT
1407
+ // open the gate — the earlier pull's authoritative apply (or the call-site
1408
+ // timeout/abort backstop) does.
1409
+ settingsReadyGate.release(gateToken, authoritative);
1410
+ }
1411
+ })();
1412
+ };
1227
1413
  const logAuthFailure = (reason) => {
1228
1414
  if (authFailureLogged)
1229
1415
  return;
@@ -1316,6 +1502,9 @@ export async function startOpenclawClawlingGateway(params) {
1316
1502
  }
1317
1503
  void refreshConversationCacheAfterReady();
1318
1504
  void dispatchActivationBootstrap();
1505
+ // Re-pull per-group agent settings on every successful (re)connect so the
1506
+ // cache stays current after a network interruption or token refresh.
1507
+ pullGroupSettings();
1319
1508
  // §A.1 — arm the proactive refresh timer from the live token's `exp`
1320
1509
  // every time a connection becomes ready (re-armed after every refresh via
1321
1510
  // the gateway re-enter).
@@ -1461,7 +1650,17 @@ export async function startOpenclawClawlingGateway(params) {
1461
1650
  // cache (friends are fetched on demand via REST tools), so there is nothing
1462
1651
  // to invalidate — observe + dedup only. The live frame and its reliable
1463
1652
  // inbox replay carry the same event_id and collapse to one observation.
1464
- notifySignalObserver.observe(env);
1653
+ const outcome = notifySignalObserver.observe(env);
1654
+ if (outcome === "observed") {
1655
+ const payload = env.payload && typeof env.payload === "object"
1656
+ ? env.payload
1657
+ : undefined;
1658
+ const type = typeof payload?.type === "string" ? payload.type : "";
1659
+ if (type === "agent.config.changed") {
1660
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`);
1661
+ pullGroupSettings();
1662
+ }
1663
+ }
1465
1664
  });
1466
1665
  client.on("replay:done", (env) => {
1467
1666
  // §11.5 terminal control frame: device replay drained, live delivery begins.
@@ -1862,10 +2061,83 @@ export async function startOpenclawClawlingGateway(params) {
1862
2061
  }
1863
2062
  }
1864
2063
  };
2064
+ // Resolve the live mute / reply-mode / batch-window for a group chat from the
2065
+ // backend settings cache, falling back to the static channel config. Shared by
2066
+ // the enqueue-time gate (ingestTurn) and the flush-time re-gate (coalescer
2067
+ // dispatch) so both decisions read the SAME source of truth.
2068
+ const resolveGroupEffectiveSettings = (chatId) => groupSettingsCache.effective(chatId, {
2069
+ muted: false,
2070
+ replyMode: effectiveGroupMode(account, chatId),
2071
+ batchDelaySeconds: 10,
2072
+ });
1865
2073
  const groupCoalescer = createGroupMessageCoalescer({
1866
2074
  idleMs: 10_000,
1867
2075
  maxWaitMs: 30_000,
1868
2076
  dispatch: async (turn) => {
2077
+ // Finding 3 — gate the FLUSH path too. A queued non-mention batch can flush
2078
+ // (idle/max timer) while an `agent.config.changed` settings pull is in
2079
+ // flight; resolving effective settings here without awaiting the gate reads
2080
+ // the stale cache and dispatches one reply for a chat about to be muted /
2081
+ // flipped to mention-only. Await the same re-armable gate (bounded by
2082
+ // GROUP_SETTINGS_READY_TIMEOUT_MS) so the flush-time re-check below sees the
2083
+ // fresh snapshot.
2084
+ //
2085
+ // Finding #4 — do NOT early-return on abort here. The ingest path DOES early-
2086
+ // return on abort, but only AFTER it has already taken the idempotency claim
2087
+ // (`claimInboundTurn` runs BEFORE the gate wait there, so the frame is durably
2088
+ // persisted before the interruptible wait — required by legacy advance-on-
2089
+ // write delivery, where a frame written before a disconnect is not re-delivered
2090
+ // on reconnect; see docs/client-integration.md §11.7). So even an ingest-time
2091
+ // abort keeps the frame; it just skips dispatch. Here at FLUSH the batch was
2092
+ // likewise already claimed at ingest AND `flush()` has already removed it from
2093
+ // the coalescer's pending set. An abort early-return would therefore silently
2094
+ // DROP an already-claimed, already-dequeued batch — and a replay of the same
2095
+ // message_id is deduped by `claimMessageOnce`, permanently losing the group
2096
+ // reply. The gate wait still runs and resolves immediately on abort (forceOpen)
2097
+ // so we re-resolve against the freshest available settings, then dispatch
2098
+ // best-effort rather than dropping.
2099
+ await waitWithTimeout(settingsReadyGate.wait(), GROUP_SETTINGS_READY_TIMEOUT_MS, abortSignal);
2100
+ // Finding 1 — re-evaluate governance at FLUSH time. Settings can change
2101
+ // (via an agent.config.changed pull) while a coalesced batch sits pending,
2102
+ // so the enqueue-time decision may be stale. Drop the batch if the chat is
2103
+ // now muted, or if it flipped to mention-only and this batch carries no
2104
+ // mention.
2105
+ const eff = resolveGroupEffectiveSettings(turn.peer.id);
2106
+ if (eff.muted) {
2107
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw coalesced group batch dropped at flush (now muted) chat_id=${turn.peer.id} msg=${turn.messageId}`);
2108
+ return;
2109
+ }
2110
+ if (eff.replyMode === "mention" && !turn.wasMentioned) {
2111
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw coalesced group batch dropped at flush (now mention-only, no mention) chat_id=${turn.peer.id} msg=${turn.messageId}`);
2112
+ return;
2113
+ }
2114
+ if (turn.wasMentioned && store?.listRecentGroupMessages) {
2115
+ const chatId = turn.peer.id;
2116
+ // Dedupe: exclude rows whose message_id belongs to the current batch/turn.
2117
+ // For a coalesced multi-message turn this covers every constituent ID,
2118
+ // not just the latest one (turn.messageId), so none of the batched
2119
+ // messages appear twice — once in the coalesced body and once in the
2120
+ // prepended "Prior group context" block.
2121
+ const batchIds = new Set(turn.allMessageIds ?? [turn.messageId]);
2122
+ // Overfetch so the batch's own just-stored messages don't crowd out the
2123
+ // MENTION_CONTEXT_N prior rows: fetch batch-size extra rows, drop the
2124
+ // batch members, then keep the most recent MENTION_CONTEXT_N survivors.
2125
+ const priorRows = store.listRecentGroupMessages("openclaw", accountId, chatId, MENTION_CONTEXT_N + batchIds.size) ?? [];
2126
+ const contextRows = priorRows
2127
+ .filter((row) => row.message_id != null && !batchIds.has(row.message_id))
2128
+ .slice(-MENTION_CONTEXT_N);
2129
+ if (contextRows.length > 0) {
2130
+ const priorContextBody = [
2131
+ "Prior group context:",
2132
+ ...contextRows.map((row, i) => `[prior ${i + 1}] ${row.text ?? "(empty)"}`),
2133
+ ].join("\n");
2134
+ await dispatchTurnToAgent({
2135
+ ...turn,
2136
+ rawBody: `${priorContextBody}\n${turn.rawBody}`,
2137
+ });
2138
+ return;
2139
+ }
2140
+ }
1869
2141
  await dispatchTurnToAgent(turn);
1870
2142
  },
1871
2143
  onError: (err) => {
@@ -1906,10 +2178,49 @@ export async function startOpenclawClawlingGateway(params) {
1906
2178
  senderNickName: resolveSenderNickNameForTurn(rawTurn),
1907
2179
  ...senderBatchIdentity,
1908
2180
  };
1909
- const claimed = claimInboundTurn(turn);
1910
- if (claimed === "skipped")
1911
- return "skipped";
1912
2181
  if (turn.peer.kind === "group") {
2182
+ // Finding 1 corrected (message loss) — claim the inbound idempotency row
2183
+ // BEFORE the (interruptible) settings-gate wait, so every received group
2184
+ // frame is durably persisted EXACTLY ONCE before any wait that a shutdown
2185
+ // can interrupt. This client does NOT advertise `reliable_delivery`
2186
+ // (src/ws-client.ts), so under legacy delivery the server's replay cursor
2187
+ // advances on socket-WRITE (docs/client-integration.md §11.7): a frame
2188
+ // already written before a disconnect is NOT re-delivered on reconnect.
2189
+ // `claimMessageOnce` is the ONLY durability an inbound has, and the store
2190
+ // never reprocesses undispatched inbounds. So if we claimed only AFTER the
2191
+ // wait, a shutdown-mid-wait would leave the frame neither persisted NOR
2192
+ // replayed — permanently lost. Claiming first makes it durable; the abort
2193
+ // check below then returns without dispatching, but the frame is kept.
2194
+ // `claimInboundTurn` is INSERT OR IGNORE, so a genuine duplicate (same
2195
+ // message_id) is still deduped here exactly once.
2196
+ const claimed = claimInboundTurn(turn);
2197
+ if (claimed === "skipped")
2198
+ return "skipped";
2199
+ // Findings 2 & 3 — hold ALL group traffic until the in-flight settings pull
2200
+ // has settled so the mute / reply-mode gate never runs against a stale/empty
2201
+ // cache (startup, reconnect, AND config-change pulls all re-close the gate).
2202
+ // This only delays group traffic; direct messages return below. The wait is
2203
+ // bounded (GROUP_SETTINGS_READY_TIMEOUT_MS): a stalled pull must never hang
2204
+ // group traffic forever, so the gate always releases — falling back to the
2205
+ // (possibly stale) cache rather than blocking indefinitely.
2206
+ await waitWithTimeout(settingsReadyGate.wait(), GROUP_SETTINGS_READY_TIMEOUT_MS, abortSignal);
2207
+ // Finding (#2 follow-up) — the bounded wait also resolves on abort. If the
2208
+ // gateway is closing, stop here instead of enqueuing/dispatching a group turn
2209
+ // into a tearing-down gateway. The frame is already claimed (persisted), so
2210
+ // returning here drops only the dispatch, never the message.
2211
+ if (abortSignal.aborted)
2212
+ return "skipped";
2213
+ // Mute gate — evaluated BEFORE the slash-command branch so a backend-muted
2214
+ // group is fully store-only, consistent with muted normal messages: a
2215
+ // muted group must never forward/dispatch a `/clawchat-*` command (or any
2216
+ // group slash command) to the agent or the owner. Known commands were
2217
+ // previously handled above this gate, which let a muted group still drive
2218
+ // the agent — contradicting the mute semantics. Keep this check first.
2219
+ const eff = resolveGroupEffectiveSettings(turn.peer.id);
2220
+ if (eff.muted) {
2221
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw group message stored but muted chat_id=${turn.peer.id} msg=${turn.messageId}`);
2222
+ return "stored_muted";
2223
+ }
1913
2224
  if (isKnownOpenClawGroupSlashCommand(turn.rawBody, cfg)) {
1914
2225
  if (isOpenClawConfirmSlashCommand(turn.rawBody)) {
1915
2226
  return forwardGroupApprovalSlashToOwner(turn);
@@ -1924,16 +2235,28 @@ export async function startOpenclawClawlingGateway(params) {
1924
2235
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw dispatching group command chat_id=${turn.peer.id} msg=${turn.messageId} mode=${commandMode}`);
1925
2236
  return dispatchTurnToAgent(turn);
1926
2237
  }
2238
+ if (eff.replyMode === "mention" && !turn.wasMentioned) {
2239
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw group message stored but skipped (mention mode) chat_id=${turn.peer.id} msg=${turn.messageId}`);
2240
+ return "stored_skipped";
2241
+ }
2242
+ const idleMsOverride = eff.batchDelaySeconds * 1000;
1927
2243
  if (turn.wasMentioned) {
1928
- groupCoalescer.enqueue(turn);
2244
+ groupCoalescer.enqueue(turn, { idleMsOverride });
1929
2245
  groupCoalescer.flushNow(turn.peer.id);
1930
2246
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw dispatching mentioned group batch chat_id=${turn.peer.id} msg=${turn.messageId}`);
1931
2247
  return "submitted";
1932
2248
  }
1933
- groupCoalescer.enqueue(turn);
2249
+ groupCoalescer.enqueue(turn, { idleMsOverride });
1934
2250
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw queued group batch chat_id=${turn.peer.id} msg=${turn.messageId}`);
1935
2251
  return "submitted";
1936
2252
  }
2253
+ // Direct messages: no gate to await, so claim right before dispatch. (The
2254
+ // group branch claims BEFORE its gate wait so a shutdown-mid-wait cannot lose
2255
+ // the frame under this client's legacy advance-on-write delivery — see
2256
+ // Finding 1 corrected above.)
2257
+ const claimed = claimInboundTurn(turn);
2258
+ if (claimed === "skipped")
2259
+ return "skipped";
1937
2260
  return dispatchTurnToAgent(turn);
1938
2261
  };
1939
2262
  const handleInboundEnvelope = async (env) => {
@@ -1996,6 +2319,15 @@ export async function startOpenclawClawlingGateway(params) {
1996
2319
  await handleInboundEnvelope(env);
1997
2320
  })();
1998
2321
  });
2322
+ // Initial pull of per-group agent settings at startup (best-effort; errors are
2323
+ // logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
2324
+ // rotating `account.token` out from under the already-constructed WS client.
2325
+ // Settling this releases the Finding-2 group-dispatch gate.
2326
+ pullGroupSettings({ bypassRefresh: true });
2327
+ // Safety: never strand a held group message if the runtime is torn down while a
2328
+ // pull is in flight. `waitWithTimeout` already releases group waiters on abort,
2329
+ // but force the gate open too so any later `wait()` resolves immediately.
2330
+ abortSignal.addEventListener("abort", () => settingsReadyGate.forceOpen(), { once: true });
1999
2331
  // `client.connect()` resolves on `hello-ok` or rejects on `hello-fail`
2000
2332
  // (auth). Transport failures (server unreachable, DNS error, etc.) do
2001
2333
  // NOT reject this promise — the local client handles them internally and
@@ -503,6 +503,24 @@ export class ClawChatStore {
503
503
  listToolCallsForTest() {
504
504
  return this.read(() => this.requireDb().prepare("SELECT * FROM tool_calls ORDER BY id").all()) ?? [];
505
505
  }
506
+ /**
507
+ * Return the last `limit` user-visible messages in a group chat, ordered
508
+ * oldest-first. Only `kind = 'message'` rows are returned; internal kinds such
509
+ * as `'thinking'` are excluded so the @-mention prior-context block never
510
+ * surfaces the agent's intermediate output as conversation history.
511
+ * Uses `idx_clawchat_messages_chat_created` for performance.
512
+ * Returns an empty array if the store is unavailable.
513
+ */
514
+ listRecentGroupMessages(platform, accountId, chatId, limit) {
515
+ return this.read(() => {
516
+ const rows = this.requireDb()
517
+ .prepare(`SELECT * FROM clawchat_messages
518
+ WHERE platform = ? AND account_id = ? AND chat_id = ? AND kind = 'message'
519
+ ORDER BY created_at DESC, rowid DESC LIMIT ?`)
520
+ .all(platform, accountId, chatId, limit);
521
+ return rows.reverse(); // oldest-first for prompt order
522
+ }) ?? [];
523
+ }
506
524
  close() {
507
525
  this.db?.close();
508
526
  this.db = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.6.16-4",
3
+ "version": "2026.6.22-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "files": [
package/src/api-client.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  type UploadResult,
16
16
  type UserSearchHit,
17
17
  } from "./api-types.ts";
18
+ import type { GroupSettings, GroupSettingsFetchResult } from "./group-settings.ts";
18
19
  import { CHANNEL_ID } from "./config.ts";
19
20
 
20
21
  export interface PluginReportInput {
@@ -158,6 +159,17 @@ export interface OpenclawClawlingApiClient {
158
159
  input: PluginReportInput,
159
160
  opts?: { authenticated?: boolean },
160
161
  ): Promise<void>;
162
+ /**
163
+ * Fetch per-group agent settings from `GET /v1/agents/me/group-settings`.
164
+ *
165
+ * Returns `{ authoritative: true, rows }` only for an HTTP 200 whose envelope
166
+ * parsed cleanly — only this may replace the cache (an empty `rows` then means
167
+ * "zero overrides"). Returns `{ authoritative: false }` for a NON-authoritative
168
+ * outcome — HTTP 404 / endpoint-absent (older backend), non-2xx, or a malformed
169
+ * body — which the caller must treat as a cache-preserving no-op. A network
170
+ * error still throws.
171
+ */
172
+ getMyGroupSettings(): Promise<GroupSettingsFetchResult>;
161
173
  }
162
174
 
163
175
  /** Backend envelope codes for `POST /v1/auth/refresh` (§0). */
@@ -659,5 +671,64 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
659
671
  body: JSON.stringify(buildPluginReportBody(input)),
660
672
  });
661
673
  },
674
+ async getMyGroupSettings(): Promise<GroupSettingsFetchResult> {
675
+ // GET /v1/agents/me/group-settings returns { settings: [...] }.
676
+ //
677
+ // Only an authoritative HTTP 200 whose envelope parses cleanly carries the
678
+ // agent's real override set and may replace the cache (an empty `settings`
679
+ // then legitimately means "zero overrides"). A non-authoritative outcome
680
+ // — HTTP 404 / endpoint-absent (older backend), or a malformed body — must
681
+ // NOT be reported as "cleared"; it returns `{ authoritative: false }` so
682
+ // the caller preserves the cache. Auth (401/403) and non-2xx-without-
683
+ // envelope still throw from `readEnvelope` (the caller's catch is itself a
684
+ // no-op, and auth throws drive token refresh), as does a network error.
685
+ const path = "/v1/agents/me/group-settings";
686
+ let res: Response;
687
+ try {
688
+ res = await fetchImpl(url(path), { method: "GET", headers: authHeaders() });
689
+ } catch (err) {
690
+ throw new ClawlingApiError(
691
+ "transport",
692
+ `fetch failed: ${err instanceof Error ? err.message : String(err)}`,
693
+ { path },
694
+ );
695
+ }
696
+ if (res.status === 404) return { authoritative: false };
697
+ // Auth (401/403) must still throw so the caller can drive a token refresh;
698
+ // `readEnvelope` handles that at the top before any body parsing. But ONLY
699
+ // an HTTP 200 is an authoritative snapshot that may replace/clear the cache
700
+ // — any other non-2xx (e.g. 500/502) is non-authoritative even when its body
701
+ // happens to carry a parseable `{ code: 0, data: { settings } }` envelope.
702
+ const data = await readEnvelope<{ settings: unknown[] }>(res, path);
703
+ if (res.status !== 200) return { authoritative: false };
704
+ if (!data || !Array.isArray(data.settings)) return { authoritative: false };
705
+ const rows: GroupSettings[] = [];
706
+ for (const item of data.settings) {
707
+ // Guard non-object rows (`null`, primitives) before any field access: a
708
+ // `null` row would throw on `r.conversation_id` and fail the whole pull
709
+ // (treated as a transport error) instead of being skipped as invalid.
710
+ if (typeof item !== "object" || item === null) {
711
+ continue;
712
+ }
713
+ const r = item as Record<string, unknown>;
714
+ if (
715
+ typeof r.conversation_id !== "string" ||
716
+ typeof r.muted !== "boolean" ||
717
+ (r.reply_mode !== "all" && r.reply_mode !== "mention") ||
718
+ typeof r.batch_delay_seconds !== "number" ||
719
+ typeof r.version !== "number"
720
+ ) {
721
+ continue;
722
+ }
723
+ rows.push({
724
+ conversationId: r.conversation_id,
725
+ muted: r.muted,
726
+ replyMode: r.reply_mode as "all" | "mention",
727
+ batchDelaySeconds: r.batch_delay_seconds,
728
+ version: r.version,
729
+ });
730
+ }
731
+ return { authoritative: true, rows };
732
+ },
662
733
  };
663
734
  }
@@ -8,6 +8,14 @@ export type CoalescableGroupTurn = {
8
8
  senderIsGroupOwner?: boolean;
9
9
  rawBody: string;
10
10
  messageId: string;
11
+ /**
12
+ * All constituent message IDs in this turn. For a single (non-coalesced)
13
+ * turn this is `[messageId]`. For a coalesced batch this contains every
14
+ * individual message ID that was merged into this turn, oldest first.
15
+ * Used by the @-mention prior-context dedup filter to exclude ALL batch
16
+ * messages from the "Prior group context" block, not just the latest one.
17
+ */
18
+ allMessageIds: string[];
11
19
  traceId: string;
12
20
  timestamp: number;
13
21
  wasMentioned: boolean;
@@ -110,6 +118,9 @@ export function mergeGroupTurns<T extends CoalescableGroupTurn>(
110
118
  .map((mention) => [mention.id, mention]),
111
119
  ).values(),
112
120
  ),
121
+ // Collect ALL constituent message IDs so callers can exclude every batch
122
+ // member (not just the latest) from prior-context dedup.
123
+ allMessageIds: turns.flatMap((turn) => turn.allMessageIds),
113
124
  coalescedGroupBatch: true,
114
125
  };
115
126
  }
@@ -139,16 +150,17 @@ export function createGroupMessageCoalescer<T extends CoalescableGroupTurn>(para
139
150
  };
140
151
 
141
152
  return {
142
- enqueue(turn: T): void {
153
+ enqueue(turn: T, options?: { idleMsOverride?: number }): void {
143
154
  const chatId = turn.peer.id;
155
+ const idleMs = options?.idleMsOverride ?? params.idleMs;
144
156
  const existing = pending.get(chatId);
145
157
  if (existing) {
146
158
  existing.turns.push(turn);
147
159
  clearTimeout(existing.idleTimer);
148
- existing.idleTimer = setTimeout(() => flush(chatId), params.idleMs);
160
+ existing.idleTimer = setTimeout(() => flush(chatId), idleMs);
149
161
  return;
150
162
  }
151
- const idleTimer = setTimeout(() => flush(chatId), params.idleMs);
163
+ const idleTimer = setTimeout(() => flush(chatId), idleMs);
152
164
  const maxWaitTimer = setTimeout(() => flush(chatId), params.maxWaitMs);
153
165
  pending.set(chatId, { turns: [turn], idleTimer, maxWaitTimer });
154
166
  },