@clawling/clawchat-plugin-openclaw 2026.8.20-1 → 2026.8.26-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/protocol-types.js +18 -0
- package/dist/src/runtime.js +33 -2
- package/dist/src/storage.js +90 -0
- package/dist/src/ws-alignment.js +6 -9
- package/dist/src/ws-client.js +9 -11
- package/package.json +1 -1
- package/src/protocol-types.ts +37 -0
- package/src/runtime.ts +39 -1
- package/src/storage.ts +106 -0
- package/src/ws-alignment.ts +6 -12
- package/src/ws-client.ts +8 -11
|
@@ -8,6 +8,7 @@ export const EVENT = {
|
|
|
8
8
|
MESSAGE_ERROR: "message.error",
|
|
9
9
|
MESSAGE_REPLY: "message.reply",
|
|
10
10
|
MESSAGE_REACTION: "message.reaction",
|
|
11
|
+
MESSAGE_RECALL: "message.recall",
|
|
11
12
|
MESSAGE_CREATED: "message.created",
|
|
12
13
|
MESSAGE_ADD: "message.add",
|
|
13
14
|
MESSAGE_DONE: "message.done",
|
|
@@ -90,3 +91,20 @@ export class StateError extends Error {
|
|
|
90
91
|
export function isBusinessDispatchEvent(event) {
|
|
91
92
|
return event === EVENT.MESSAGE_SEND || event === EVENT.MESSAGE_REPLY;
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Pull the id to delete out of a `message.recall` payload.
|
|
96
|
+
*
|
|
97
|
+
* Exists as a pure function, apart from the socket, for the same reason the
|
|
98
|
+
* mistake it guards is worth guarding: `payload.message_id` is right there and
|
|
99
|
+
* looks like the answer, but it is the `rcl:` slot. Deleting by it removes
|
|
100
|
+
* nothing and reads exactly like working code.
|
|
101
|
+
*
|
|
102
|
+
* Answers `null` for anything unusable, so a recall we cannot resolve deletes
|
|
103
|
+
* nothing rather than something adjacent.
|
|
104
|
+
*/
|
|
105
|
+
export function recallTargetMessageId(payload) {
|
|
106
|
+
if (!payload || typeof payload !== "object")
|
|
107
|
+
return null;
|
|
108
|
+
const target = payload.target_message_id;
|
|
109
|
+
return typeof target === "string" && target.length > 0 ? target : null;
|
|
110
|
+
}
|
package/dist/src/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, } from "./protocol-types.js";
|
|
1
|
+
import { AckTimeoutError, AuthError, ProtocolError, StateError, TransportError, EVENT, recallTargetMessageId, } from "./protocol-types.js";
|
|
2
2
|
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
3
3
|
import { hasControlCommand } from "openclaw/plugin-sdk/command-detection";
|
|
4
4
|
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
|
@@ -1534,7 +1534,6 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
1534
1534
|
const protocolControlLogger = createProtocolControlHandler({
|
|
1535
1535
|
accountId,
|
|
1536
1536
|
log: (msg) => log?.info?.(msg),
|
|
1537
|
-
send: () => { },
|
|
1538
1537
|
context: wsLogContext,
|
|
1539
1538
|
});
|
|
1540
1539
|
const notifySignalObserver = createNotifySignalObserver({
|
|
@@ -2907,6 +2906,38 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2907
2906
|
await handleInboundEnvelope(env);
|
|
2908
2907
|
})();
|
|
2909
2908
|
});
|
|
2909
|
+
// `message.recall` — the sender withdrew a message. Erase our own ledger row
|
|
2910
|
+
// so the "Prior group context" block above (MENTION_CONTEXT_N rows, no time
|
|
2911
|
+
// predicate, no retention) can never read it back into a turn.
|
|
2912
|
+
//
|
|
2913
|
+
// Ids only: the frame carries no content, and `payload.message_id` is the
|
|
2914
|
+
// server's `rcl:<target>` slot — the id to delete is `target_message_id`.
|
|
2915
|
+
//
|
|
2916
|
+
// Synchronous and fire-and-forget on purpose: there is no ack to send (the
|
|
2917
|
+
// frame is BestEffort and non-ackable).
|
|
2918
|
+
//
|
|
2919
|
+
// §9.8 requires a permanent tombstone consulted on every ingest path, and
|
|
2920
|
+
// `store.recallMessage` writes one in the same transaction as the delete.
|
|
2921
|
+
// That is what makes this handler's timing irrelevant: the inbound ledger
|
|
2922
|
+
// write sits behind an await, so on a replay carrying the original and its
|
|
2923
|
+
// recall back-to-back the DELETE runs first — and the tombstone then refuses
|
|
2924
|
+
// the INSERT. It also closes the Kafka-redelivery resurrection this comment
|
|
2925
|
+
// previously accepted as a known limitation.
|
|
2926
|
+
client.on("message:recall", (env) => {
|
|
2927
|
+
const target = recallTargetMessageId(env.payload);
|
|
2928
|
+
if (!target) {
|
|
2929
|
+
log?.info?.(`[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`);
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
const removed = store?.recallMessage?.({
|
|
2933
|
+
platform: "openclaw",
|
|
2934
|
+
accountId,
|
|
2935
|
+
messageId: target,
|
|
2936
|
+
});
|
|
2937
|
+
const rowsLabel = typeof removed === "number" ? String(removed) : "unavailable";
|
|
2938
|
+
const logFn = typeof removed === "number" ? log?.info : log?.error;
|
|
2939
|
+
logFn?.(`[${accountId}] clawchat-plugin-openclaw recall purged chat_id=${String(env.chat_id ?? "")} msg=${target} rows=${rowsLabel}`);
|
|
2940
|
+
});
|
|
2910
2941
|
// Initial pull of per-group agent settings at startup (best-effort; errors are
|
|
2911
2942
|
// logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
|
|
2912
2943
|
// rotating `account.token` out from under the already-constructed WS client.
|
package/dist/src/storage.js
CHANGED
|
@@ -171,6 +171,19 @@ CREATE TABLE IF NOT EXISTS owner_profile (
|
|
|
171
171
|
updated_at INTEGER NOT NULL,
|
|
172
172
|
PRIMARY KEY (platform, account_id)
|
|
173
173
|
);
|
|
174
|
+
`,
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
version: 10,
|
|
178
|
+
name: "recalled_messages",
|
|
179
|
+
sql: `
|
|
180
|
+
CREATE TABLE IF NOT EXISTS recalled_messages (
|
|
181
|
+
platform TEXT NOT NULL,
|
|
182
|
+
account_id TEXT NOT NULL,
|
|
183
|
+
message_id TEXT NOT NULL,
|
|
184
|
+
recalled_at INTEGER NOT NULL,
|
|
185
|
+
PRIMARY KEY (platform, account_id, message_id)
|
|
186
|
+
);
|
|
174
187
|
`,
|
|
175
188
|
},
|
|
176
189
|
];
|
|
@@ -412,6 +425,14 @@ export class ClawChatStore {
|
|
|
412
425
|
}
|
|
413
426
|
claimMessageOnce(input) {
|
|
414
427
|
return this.write(() => {
|
|
428
|
+
// Protocol v2 §9.8: consult the recall tombstone on every ingest path.
|
|
429
|
+
// Doing it inside this same write transaction is what makes the delete
|
|
430
|
+
// and the insert order-independent: whichever frame the server delivers
|
|
431
|
+
// first, a recalled message_id can never end up in the ledger. Without
|
|
432
|
+
// it, a replay carrying the original and its recall back-to-back ran the
|
|
433
|
+
// DELETE before the INSERT and the row survived.
|
|
434
|
+
if (input.messageId && this.isMessageRecalled(input))
|
|
435
|
+
return false;
|
|
415
436
|
const result = this.requireDb()
|
|
416
437
|
.prepare(`INSERT OR IGNORE INTO clawchat_messages(
|
|
417
438
|
platform, account_id, kind, direction, event_type, trace_id, chat_id,
|
|
@@ -443,6 +464,75 @@ export class ClawChatStore {
|
|
|
443
464
|
.run(input.eventType, input.traceId ?? null, input.chatId ?? null, input.text ?? null, toJson(input.raw), input.accountId, input.kind, input.direction, input.messageId);
|
|
444
465
|
});
|
|
445
466
|
}
|
|
467
|
+
/**
|
|
468
|
+
* Erase every ledger row for one `message_id` — the local half of a
|
|
469
|
+
* server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
|
|
470
|
+
*
|
|
471
|
+
* **The only DELETE in this store, and deliberately so.** There is no prune
|
|
472
|
+
* and no retention on `clawchat_messages`: without this, a message recalled
|
|
473
|
+
* ninety seconds after it was sent stays one @-mention away from being read
|
|
474
|
+
* back aloud under "Prior group context" for as long as the group is quiet.
|
|
475
|
+
*
|
|
476
|
+
* Scoped to `(platform, account_id)` so one account's recall cannot reach
|
|
477
|
+
* another's rows, and matched on `message_id`, which seeks via the
|
|
478
|
+
* single-column `idx_clawchat_messages_message_id` before the `platform` /
|
|
479
|
+
* `account_id` filter is applied — so this is a lookup, not a table scan.
|
|
480
|
+
*
|
|
481
|
+
* Answers the number of rows removed. `0` is a normal outcome, not a failure:
|
|
482
|
+
* the row may never have been stored (a message that arrived before this
|
|
483
|
+
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
484
|
+
* store is unavailable.
|
|
485
|
+
*/
|
|
486
|
+
/** True when `message_id` was recalled for this (platform, account). */
|
|
487
|
+
isMessageRecalled(input) {
|
|
488
|
+
if (!input.messageId)
|
|
489
|
+
return false;
|
|
490
|
+
const row = this.requireDb()
|
|
491
|
+
.prepare(`SELECT 1 FROM recalled_messages
|
|
492
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`)
|
|
493
|
+
.get(input.platform, input.accountId, input.messageId);
|
|
494
|
+
return row !== undefined;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Record the permanent recall tombstone and purge any row already stored,
|
|
498
|
+
* atomically. Returns the number of ledger rows deleted (0 when the recall
|
|
499
|
+
* arrived before the message did — the tombstone still stands and the later
|
|
500
|
+
* ingest is refused by {@link claimMessageOnce}).
|
|
501
|
+
*/
|
|
502
|
+
recallMessage(input) {
|
|
503
|
+
// An empty id would tombstone and delete rows keyed by '' — every row the
|
|
504
|
+
// store never managed to key. Refuse rather than guess.
|
|
505
|
+
if (!input.messageId)
|
|
506
|
+
return 0;
|
|
507
|
+
return this.write(() => {
|
|
508
|
+
const db = this.requireDb();
|
|
509
|
+
db.prepare(`INSERT OR IGNORE INTO recalled_messages(platform, account_id, message_id, recalled_at)
|
|
510
|
+
VALUES (?, ?, ?, ?)`).run(input.platform, input.accountId, input.messageId, input.recalledAt ?? Date.now());
|
|
511
|
+
const result = db
|
|
512
|
+
.prepare(`DELETE FROM clawchat_messages
|
|
513
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`)
|
|
514
|
+
.run(input.platform, input.accountId, input.messageId);
|
|
515
|
+
return Number(result.changes);
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Low-level purge by message id. NOT the recall path: it writes no tombstone,
|
|
520
|
+
* so a recall routed through here loses to a redelivery or to an ingest that
|
|
521
|
+
* is still behind an await. Use {@link recallMessage} for `message.recall`.
|
|
522
|
+
*/
|
|
523
|
+
deleteMessagesByMessageId(input) {
|
|
524
|
+
// An empty id would match every row whose message_id is '' — deleting
|
|
525
|
+
// everything the store never managed to key. Refuse rather than guess.
|
|
526
|
+
if (!input.messageId)
|
|
527
|
+
return 0;
|
|
528
|
+
return this.write(() => {
|
|
529
|
+
const result = this.requireDb()
|
|
530
|
+
.prepare(`DELETE FROM clawchat_messages
|
|
531
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`)
|
|
532
|
+
.run(input.platform, input.accountId, input.messageId);
|
|
533
|
+
return Number(result.changes);
|
|
534
|
+
});
|
|
535
|
+
}
|
|
446
536
|
startConnection(input) {
|
|
447
537
|
return this.write(() => {
|
|
448
538
|
const now = input.connectStartedAt ?? Date.now();
|
package/dist/src/ws-alignment.js
CHANGED
|
@@ -171,15 +171,12 @@ export function createProtocolControlHandler(options) {
|
|
|
171
171
|
return {
|
|
172
172
|
handleInbound(env) {
|
|
173
173
|
if (env.event === "ping") {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
emitted_at: env.emitted_at ?? Date.now(),
|
|
181
|
-
payload: {},
|
|
182
|
-
}));
|
|
174
|
+
// §12: the server never emits a JSON-level `ping`, so this is an
|
|
175
|
+
// anomaly worth seeing — but answering it would send exactly the
|
|
176
|
+
// unsolicited `pong` the spec bans, which the server drops as an
|
|
177
|
+
// unknown uplink event. Log it and drop it; this handler has no send
|
|
178
|
+
// capability, so the banned frame cannot be produced here at all.
|
|
179
|
+
logControl("protocol_ping_received", "drop", [["trace_id", env.trace_id]]);
|
|
183
180
|
return true;
|
|
184
181
|
}
|
|
185
182
|
if (env.event === "pong") {
|
package/dist/src/ws-client.js
CHANGED
|
@@ -548,8 +548,10 @@ export class ClawChatClient extends EventEmitter {
|
|
|
548
548
|
return this.onHelloOk(env);
|
|
549
549
|
if (env.event === EVENT.HELLO_FAIL)
|
|
550
550
|
return this.onHelloFail(env);
|
|
551
|
-
|
|
552
|
-
|
|
551
|
+
// §12: the server never emits a JSON-level `ping`, and an unsolicited
|
|
552
|
+
// `pong` is an unknown uplink event the server drops. We therefore do NOT
|
|
553
|
+
// answer a downlink ping. `pong` below is still handled: it is the reply
|
|
554
|
+
// to the client-initiated heartbeat probe §12 does allow.
|
|
553
555
|
if (env.event === EVENT.PONG)
|
|
554
556
|
return this.onPong();
|
|
555
557
|
if (env.event === EVENT.MESSAGE_ACK)
|
|
@@ -558,6 +560,11 @@ export class ClawChatClient extends EventEmitter {
|
|
|
558
560
|
return this.onMessageError(env);
|
|
559
561
|
if (isBusinessDispatchEvent(env.event))
|
|
560
562
|
this.emit("message", env);
|
|
563
|
+
// Deliberately NOT a business dispatch event: `message` feeds the turn
|
|
564
|
+
// pipeline, and a recall has no body to turn into a prompt. It is an
|
|
565
|
+
// instruction to delete, so it gets its own emitter and its own consumer.
|
|
566
|
+
if (env.event === EVENT.MESSAGE_RECALL)
|
|
567
|
+
this.emit("message:recall", env);
|
|
561
568
|
if (env.event === EVENT.MESSAGE_CREATED)
|
|
562
569
|
this.emit("message:created", env);
|
|
563
570
|
if (env.event === EVENT.MESSAGE_ADD)
|
|
@@ -699,15 +706,6 @@ export class ClawChatClient extends EventEmitter {
|
|
|
699
706
|
this.opts.transport.close(4001, "auth failed");
|
|
700
707
|
}
|
|
701
708
|
}
|
|
702
|
-
onPing(env) {
|
|
703
|
-
this.sendRawEnvelope({
|
|
704
|
-
version: "2",
|
|
705
|
-
event: EVENT.PONG,
|
|
706
|
-
trace_id: env.trace_id,
|
|
707
|
-
emitted_at: env.emitted_at,
|
|
708
|
-
payload: {},
|
|
709
|
-
});
|
|
710
|
-
}
|
|
711
709
|
onPong() {
|
|
712
710
|
if (this.pongTimer)
|
|
713
711
|
clearTimeout(this.pongTimer);
|
package/package.json
CHANGED
package/src/protocol-types.ts
CHANGED
|
@@ -8,6 +8,7 @@ export const EVENT = {
|
|
|
8
8
|
MESSAGE_ERROR: "message.error",
|
|
9
9
|
MESSAGE_REPLY: "message.reply",
|
|
10
10
|
MESSAGE_REACTION: "message.reaction",
|
|
11
|
+
MESSAGE_RECALL: "message.recall",
|
|
11
12
|
MESSAGE_CREATED: "message.created",
|
|
12
13
|
MESSAGE_ADD: "message.add",
|
|
13
14
|
MESSAGE_DONE: "message.done",
|
|
@@ -361,3 +362,39 @@ export class StateError extends Error {
|
|
|
361
362
|
export function isBusinessDispatchEvent(event: string): boolean {
|
|
362
363
|
return event === EVENT.MESSAGE_SEND || event === EVENT.MESSAGE_REPLY;
|
|
363
364
|
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* `message.recall` downlink payload (docs/client-integration.md §9.8).
|
|
368
|
+
*
|
|
369
|
+
* **Ids only, never content.** The frame relays an instruction to delete, not
|
|
370
|
+
* a copy of what is being deleted — which is what makes it compatible with the
|
|
371
|
+
* feature's "no trace anywhere" requirement.
|
|
372
|
+
*/
|
|
373
|
+
export interface MessageRecallPayload {
|
|
374
|
+
/**
|
|
375
|
+
* The server's deterministic slot id, `rcl:<target_message_id>`. Present so
|
|
376
|
+
* the marker can occupy one durable inbox row per recalled message. It is
|
|
377
|
+
* NOT the id to delete.
|
|
378
|
+
*/
|
|
379
|
+
message_id: string;
|
|
380
|
+
|
|
381
|
+
/** The id of the message being erased. This is the one to delete by. */
|
|
382
|
+
target_message_id: string;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Pull the id to delete out of a `message.recall` payload.
|
|
387
|
+
*
|
|
388
|
+
* Exists as a pure function, apart from the socket, for the same reason the
|
|
389
|
+
* mistake it guards is worth guarding: `payload.message_id` is right there and
|
|
390
|
+
* looks like the answer, but it is the `rcl:` slot. Deleting by it removes
|
|
391
|
+
* nothing and reads exactly like working code.
|
|
392
|
+
*
|
|
393
|
+
* Answers `null` for anything unusable, so a recall we cannot resolve deletes
|
|
394
|
+
* nothing rather than something adjacent.
|
|
395
|
+
*/
|
|
396
|
+
export function recallTargetMessageId(payload: unknown): string | null {
|
|
397
|
+
if (!payload || typeof payload !== "object") return null;
|
|
398
|
+
const target = (payload as { target_message_id?: unknown }).target_message_id;
|
|
399
|
+
return typeof target === "string" && target.length > 0 ? target : null;
|
|
400
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
StateError,
|
|
6
6
|
TransportError,
|
|
7
7
|
EVENT,
|
|
8
|
+
recallTargetMessageId,
|
|
8
9
|
type Envelope,
|
|
9
10
|
type Transport,
|
|
10
11
|
} from "./protocol-types.ts";
|
|
@@ -123,6 +124,7 @@ type RuntimeConnectionStore = Pick<
|
|
|
123
124
|
| "getActivationConversation"
|
|
124
125
|
| "getLastResolvedDeviceId"
|
|
125
126
|
| "listRecentGroupMessages"
|
|
127
|
+
| "recallMessage"
|
|
126
128
|
| "upsertOwnerProfile"
|
|
127
129
|
>
|
|
128
130
|
>;
|
|
@@ -1920,7 +1922,6 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1920
1922
|
const protocolControlLogger = createProtocolControlHandler({
|
|
1921
1923
|
accountId,
|
|
1922
1924
|
log: (msg) => log?.info?.(msg),
|
|
1923
|
-
send: () => {},
|
|
1924
1925
|
context: wsLogContext,
|
|
1925
1926
|
});
|
|
1926
1927
|
const notifySignalObserver = createNotifySignalObserver({
|
|
@@ -3447,6 +3448,43 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3447
3448
|
})();
|
|
3448
3449
|
});
|
|
3449
3450
|
|
|
3451
|
+
// `message.recall` — the sender withdrew a message. Erase our own ledger row
|
|
3452
|
+
// so the "Prior group context" block above (MENTION_CONTEXT_N rows, no time
|
|
3453
|
+
// predicate, no retention) can never read it back into a turn.
|
|
3454
|
+
//
|
|
3455
|
+
// Ids only: the frame carries no content, and `payload.message_id` is the
|
|
3456
|
+
// server's `rcl:<target>` slot — the id to delete is `target_message_id`.
|
|
3457
|
+
//
|
|
3458
|
+
// Synchronous and fire-and-forget on purpose: there is no ack to send (the
|
|
3459
|
+
// frame is BestEffort and non-ackable).
|
|
3460
|
+
//
|
|
3461
|
+
// §9.8 requires a permanent tombstone consulted on every ingest path, and
|
|
3462
|
+
// `store.recallMessage` writes one in the same transaction as the delete.
|
|
3463
|
+
// That is what makes this handler's timing irrelevant: the inbound ledger
|
|
3464
|
+
// write sits behind an await, so on a replay carrying the original and its
|
|
3465
|
+
// recall back-to-back the DELETE runs first — and the tombstone then refuses
|
|
3466
|
+
// the INSERT. It also closes the Kafka-redelivery resurrection this comment
|
|
3467
|
+
// previously accepted as a known limitation.
|
|
3468
|
+
client.on("message:recall", (env: Envelope) => {
|
|
3469
|
+
const target = recallTargetMessageId(env.payload);
|
|
3470
|
+
if (!target) {
|
|
3471
|
+
log?.info?.(
|
|
3472
|
+
`[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`,
|
|
3473
|
+
);
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
const removed = store?.recallMessage?.({
|
|
3477
|
+
platform: "openclaw",
|
|
3478
|
+
accountId,
|
|
3479
|
+
messageId: target,
|
|
3480
|
+
});
|
|
3481
|
+
const rowsLabel = typeof removed === "number" ? String(removed) : "unavailable";
|
|
3482
|
+
const logFn = typeof removed === "number" ? log?.info : log?.error;
|
|
3483
|
+
logFn?.(
|
|
3484
|
+
`[${accountId}] clawchat-plugin-openclaw recall purged chat_id=${String(env.chat_id ?? "")} msg=${target} rows=${rowsLabel}`,
|
|
3485
|
+
);
|
|
3486
|
+
});
|
|
3487
|
+
|
|
3450
3488
|
// Initial pull of per-group agent settings at startup (best-effort; errors are
|
|
3451
3489
|
// logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
|
|
3452
3490
|
// rotating `account.token` out from under the already-constructed WS client.
|
package/src/storage.ts
CHANGED
|
@@ -389,6 +389,19 @@ CREATE TABLE IF NOT EXISTS owner_profile (
|
|
|
389
389
|
updated_at INTEGER NOT NULL,
|
|
390
390
|
PRIMARY KEY (platform, account_id)
|
|
391
391
|
);
|
|
392
|
+
`,
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
version: 10,
|
|
396
|
+
name: "recalled_messages",
|
|
397
|
+
sql: `
|
|
398
|
+
CREATE TABLE IF NOT EXISTS recalled_messages (
|
|
399
|
+
platform TEXT NOT NULL,
|
|
400
|
+
account_id TEXT NOT NULL,
|
|
401
|
+
message_id TEXT NOT NULL,
|
|
402
|
+
recalled_at INTEGER NOT NULL,
|
|
403
|
+
PRIMARY KEY (platform, account_id, message_id)
|
|
404
|
+
);
|
|
392
405
|
`,
|
|
393
406
|
},
|
|
394
407
|
];
|
|
@@ -722,6 +735,13 @@ export class ClawChatStore {
|
|
|
722
735
|
|
|
723
736
|
claimMessageOnce(input: MessageInput): true | false | null {
|
|
724
737
|
return this.write(() => {
|
|
738
|
+
// Protocol v2 §9.8: consult the recall tombstone on every ingest path.
|
|
739
|
+
// Doing it inside this same write transaction is what makes the delete
|
|
740
|
+
// and the insert order-independent: whichever frame the server delivers
|
|
741
|
+
// first, a recalled message_id can never end up in the ledger. Without
|
|
742
|
+
// it, a replay carrying the original and its recall back-to-back ran the
|
|
743
|
+
// DELETE before the INSERT and the row survived.
|
|
744
|
+
if (input.messageId && this.isMessageRecalled(input)) return false;
|
|
725
745
|
const result = this.requireDb()
|
|
726
746
|
.prepare(
|
|
727
747
|
`INSERT OR IGNORE INTO clawchat_messages(
|
|
@@ -802,6 +822,92 @@ export class ClawChatStore {
|
|
|
802
822
|
});
|
|
803
823
|
}
|
|
804
824
|
|
|
825
|
+
/**
|
|
826
|
+
* Erase every ledger row for one `message_id` — the local half of a
|
|
827
|
+
* server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
|
|
828
|
+
*
|
|
829
|
+
* **The only DELETE in this store, and deliberately so.** There is no prune
|
|
830
|
+
* and no retention on `clawchat_messages`: without this, a message recalled
|
|
831
|
+
* ninety seconds after it was sent stays one @-mention away from being read
|
|
832
|
+
* back aloud under "Prior group context" for as long as the group is quiet.
|
|
833
|
+
*
|
|
834
|
+
* Scoped to `(platform, account_id)` so one account's recall cannot reach
|
|
835
|
+
* another's rows, and matched on `message_id`, which seeks via the
|
|
836
|
+
* single-column `idx_clawchat_messages_message_id` before the `platform` /
|
|
837
|
+
* `account_id` filter is applied — so this is a lookup, not a table scan.
|
|
838
|
+
*
|
|
839
|
+
* Answers the number of rows removed. `0` is a normal outcome, not a failure:
|
|
840
|
+
* the row may never have been stored (a message that arrived before this
|
|
841
|
+
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
842
|
+
* store is unavailable.
|
|
843
|
+
*/
|
|
844
|
+
/** True when `message_id` was recalled for this (platform, account). */
|
|
845
|
+
isMessageRecalled(input: { platform: string; accountId: string; messageId?: string | null }): boolean {
|
|
846
|
+
if (!input.messageId) return false;
|
|
847
|
+
const row = this.requireDb()
|
|
848
|
+
.prepare(
|
|
849
|
+
`SELECT 1 FROM recalled_messages
|
|
850
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`,
|
|
851
|
+
)
|
|
852
|
+
.get(input.platform, input.accountId, input.messageId);
|
|
853
|
+
return row !== undefined;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* Record the permanent recall tombstone and purge any row already stored,
|
|
858
|
+
* atomically. Returns the number of ledger rows deleted (0 when the recall
|
|
859
|
+
* arrived before the message did — the tombstone still stands and the later
|
|
860
|
+
* ingest is refused by {@link claimMessageOnce}).
|
|
861
|
+
*/
|
|
862
|
+
recallMessage(input: {
|
|
863
|
+
platform: string;
|
|
864
|
+
accountId: string;
|
|
865
|
+
messageId: string;
|
|
866
|
+
recalledAt?: number;
|
|
867
|
+
}): number | null {
|
|
868
|
+
// An empty id would tombstone and delete rows keyed by '' — every row the
|
|
869
|
+
// store never managed to key. Refuse rather than guess.
|
|
870
|
+
if (!input.messageId) return 0;
|
|
871
|
+
return this.write(() => {
|
|
872
|
+
const db = this.requireDb();
|
|
873
|
+
db.prepare(
|
|
874
|
+
`INSERT OR IGNORE INTO recalled_messages(platform, account_id, message_id, recalled_at)
|
|
875
|
+
VALUES (?, ?, ?, ?)`,
|
|
876
|
+
).run(input.platform, input.accountId, input.messageId, input.recalledAt ?? Date.now());
|
|
877
|
+
const result = db
|
|
878
|
+
.prepare(
|
|
879
|
+
`DELETE FROM clawchat_messages
|
|
880
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`,
|
|
881
|
+
)
|
|
882
|
+
.run(input.platform, input.accountId, input.messageId);
|
|
883
|
+
return Number(result.changes);
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Low-level purge by message id. NOT the recall path: it writes no tombstone,
|
|
889
|
+
* so a recall routed through here loses to a redelivery or to an ingest that
|
|
890
|
+
* is still behind an await. Use {@link recallMessage} for `message.recall`.
|
|
891
|
+
*/
|
|
892
|
+
deleteMessagesByMessageId(input: {
|
|
893
|
+
platform: string;
|
|
894
|
+
accountId: string;
|
|
895
|
+
messageId: string;
|
|
896
|
+
}): number | null {
|
|
897
|
+
// An empty id would match every row whose message_id is '' — deleting
|
|
898
|
+
// everything the store never managed to key. Refuse rather than guess.
|
|
899
|
+
if (!input.messageId) return 0;
|
|
900
|
+
return this.write(() => {
|
|
901
|
+
const result = this.requireDb()
|
|
902
|
+
.prepare(
|
|
903
|
+
`DELETE FROM clawchat_messages
|
|
904
|
+
WHERE platform = ? AND account_id = ? AND message_id = ?`,
|
|
905
|
+
)
|
|
906
|
+
.run(input.platform, input.accountId, input.messageId);
|
|
907
|
+
return Number(result.changes);
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
805
911
|
startConnection(input: StartConnectionInput): number | null {
|
|
806
912
|
return this.write(() => {
|
|
807
913
|
const now = input.connectStartedAt ?? Date.now();
|
package/src/ws-alignment.ts
CHANGED
|
@@ -238,7 +238,6 @@ export interface ProtocolControlEnvelope {
|
|
|
238
238
|
export interface CreateProtocolControlHandlerOptions {
|
|
239
239
|
accountId: string;
|
|
240
240
|
log: (msg: string) => void;
|
|
241
|
-
send: (wire: string) => void;
|
|
242
241
|
scheduleReconnect?: (reason: string) => void;
|
|
243
242
|
attempt?: number;
|
|
244
243
|
reconnectCount?: number;
|
|
@@ -275,17 +274,12 @@ export function createProtocolControlHandler(options: CreateProtocolControlHandl
|
|
|
275
274
|
return {
|
|
276
275
|
handleInbound(env: ProtocolControlEnvelope): boolean {
|
|
277
276
|
if (env.event === "ping") {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
// §12: echo the sender's emitted_at verbatim (do not restamp).
|
|
285
|
-
emitted_at: env.emitted_at ?? Date.now(),
|
|
286
|
-
payload: {},
|
|
287
|
-
}),
|
|
288
|
-
);
|
|
277
|
+
// §12: the server never emits a JSON-level `ping`, so this is an
|
|
278
|
+
// anomaly worth seeing — but answering it would send exactly the
|
|
279
|
+
// unsolicited `pong` the spec bans, which the server drops as an
|
|
280
|
+
// unknown uplink event. Log it and drop it; this handler has no send
|
|
281
|
+
// capability, so the banned frame cannot be produced here at all.
|
|
282
|
+
logControl("protocol_ping_received", "drop", [["trace_id", env.trace_id]]);
|
|
289
283
|
return true;
|
|
290
284
|
}
|
|
291
285
|
if (env.event === "pong") {
|
package/src/ws-client.ts
CHANGED
|
@@ -646,11 +646,18 @@ export class ClawChatClient extends EventEmitter {
|
|
|
646
646
|
if (env.event === EVENT.CONNECT_CHALLENGE) return this.onChallenge(env);
|
|
647
647
|
if (env.event === EVENT.HELLO_OK) return this.onHelloOk(env);
|
|
648
648
|
if (env.event === EVENT.HELLO_FAIL) return this.onHelloFail(env);
|
|
649
|
-
|
|
649
|
+
// §12: the server never emits a JSON-level `ping`, and an unsolicited
|
|
650
|
+
// `pong` is an unknown uplink event the server drops. We therefore do NOT
|
|
651
|
+
// answer a downlink ping. `pong` below is still handled: it is the reply
|
|
652
|
+
// to the client-initiated heartbeat probe §12 does allow.
|
|
650
653
|
if (env.event === EVENT.PONG) return this.onPong();
|
|
651
654
|
if (env.event === EVENT.MESSAGE_ACK) return this.onAck(env as Envelope<MessageAckPayload>);
|
|
652
655
|
if (env.event === EVENT.MESSAGE_ERROR) return this.onMessageError(env as Envelope<MessageErrorPayload>);
|
|
653
656
|
if (isBusinessDispatchEvent(env.event)) this.emit("message", env);
|
|
657
|
+
// Deliberately NOT a business dispatch event: `message` feeds the turn
|
|
658
|
+
// pipeline, and a recall has no body to turn into a prompt. It is an
|
|
659
|
+
// instruction to delete, so it gets its own emitter and its own consumer.
|
|
660
|
+
if (env.event === EVENT.MESSAGE_RECALL) this.emit("message:recall", env);
|
|
654
661
|
if (env.event === EVENT.MESSAGE_CREATED) this.emit("message:created", env);
|
|
655
662
|
if (env.event === EVENT.MESSAGE_ADD) this.emit("message:add", env);
|
|
656
663
|
if (env.event === EVENT.MESSAGE_DONE) this.emit("message:done", env);
|
|
@@ -781,16 +788,6 @@ export class ClawChatClient extends EventEmitter {
|
|
|
781
788
|
}
|
|
782
789
|
}
|
|
783
790
|
|
|
784
|
-
private onPing(env: Envelope): void {
|
|
785
|
-
this.sendRawEnvelope({
|
|
786
|
-
version: "2",
|
|
787
|
-
event: EVENT.PONG,
|
|
788
|
-
trace_id: env.trace_id,
|
|
789
|
-
emitted_at: env.emitted_at,
|
|
790
|
-
payload: {} satisfies EmptyPayload,
|
|
791
|
-
});
|
|
792
|
-
}
|
|
793
|
-
|
|
794
791
|
private onPong(): void {
|
|
795
792
|
if (this.pongTimer) clearTimeout(this.pongTimer);
|
|
796
793
|
this.pongTimer = undefined;
|