@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/dist/src/runtime.js
CHANGED
|
@@ -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
|
package/dist/src/storage.js
CHANGED
|
@@ -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
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),
|
|
160
|
+
existing.idleTimer = setTimeout(() => flush(chatId), idleMs);
|
|
149
161
|
return;
|
|
150
162
|
}
|
|
151
|
-
const idleTimer = setTimeout(() => flush(chatId),
|
|
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
|
},
|