@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/dist/src/api-client.js +57 -0
- package/dist/src/group-message-coalescer.js +7 -3
- package/dist/src/group-settings.js +87 -0
- package/dist/src/inbound.js +13 -9
- package/dist/src/no-reply.js +4 -0
- package/dist/src/profile-prompt.js +2 -2
- package/dist/src/runtime.js +339 -7
- package/dist/src/storage.js +18 -0
- package/package.json +1 -1
- package/src/api-client.ts +71 -0
- package/src/group-message-coalescer.ts +15 -3
- package/src/group-settings.ts +126 -0
- package/src/inbound.ts +22 -11
- package/src/no-reply.ts +5 -0
- package/src/profile-prompt.ts +2 -2
- package/src/runtime.ts +409 -6
- package/src/storage.ts +46 -0
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;
|