@clawling/clawchat-plugin-openclaw 2026.8.22-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/runtime.js +11 -7
- package/dist/src/storage.js +58 -0
- package/dist/src/ws-alignment.js +6 -9
- package/dist/src/ws-client.js +4 -11
- package/package.json +1 -1
- package/src/runtime.ts +12 -8
- package/src/storage.ts +68 -0
- package/src/ws-alignment.ts +6 -12
- package/src/ws-client.ts +4 -11
package/dist/src/runtime.js
CHANGED
|
@@ -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({
|
|
@@ -2914,18 +2913,23 @@ export async function startOpenclawClawlingGateway(params) {
|
|
|
2914
2913
|
// Ids only: the frame carries no content, and `payload.message_id` is the
|
|
2915
2914
|
// server's `rcl:<target>` slot — the id to delete is `target_message_id`.
|
|
2916
2915
|
//
|
|
2917
|
-
// Synchronous and fire-and-forget on purpose
|
|
2918
|
-
// frame is BestEffort and non-ackable)
|
|
2919
|
-
//
|
|
2920
|
-
//
|
|
2921
|
-
//
|
|
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.
|
|
2922
2926
|
client.on("message:recall", (env) => {
|
|
2923
2927
|
const target = recallTargetMessageId(env.payload);
|
|
2924
2928
|
if (!target) {
|
|
2925
2929
|
log?.info?.(`[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`);
|
|
2926
2930
|
return;
|
|
2927
2931
|
}
|
|
2928
|
-
const removed = store?.
|
|
2932
|
+
const removed = store?.recallMessage?.({
|
|
2929
2933
|
platform: "openclaw",
|
|
2930
2934
|
accountId,
|
|
2931
2935
|
messageId: target,
|
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,
|
|
@@ -462,6 +483,43 @@ export class ClawChatStore {
|
|
|
462
483
|
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
463
484
|
* store is unavailable.
|
|
464
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
|
+
*/
|
|
465
523
|
deleteMessagesByMessageId(input) {
|
|
466
524
|
// An empty id would match every row whose message_id is '' — deleting
|
|
467
525
|
// everything the store never managed to key. Refuse rather than guess.
|
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)
|
|
@@ -704,15 +706,6 @@ export class ClawChatClient extends EventEmitter {
|
|
|
704
706
|
this.opts.transport.close(4001, "auth failed");
|
|
705
707
|
}
|
|
706
708
|
}
|
|
707
|
-
onPing(env) {
|
|
708
|
-
this.sendRawEnvelope({
|
|
709
|
-
version: "2",
|
|
710
|
-
event: EVENT.PONG,
|
|
711
|
-
trace_id: env.trace_id,
|
|
712
|
-
emitted_at: env.emitted_at,
|
|
713
|
-
payload: {},
|
|
714
|
-
});
|
|
715
|
-
}
|
|
716
709
|
onPong() {
|
|
717
710
|
if (this.pongTimer)
|
|
718
711
|
clearTimeout(this.pongTimer);
|
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -124,7 +124,7 @@ type RuntimeConnectionStore = Pick<
|
|
|
124
124
|
| "getActivationConversation"
|
|
125
125
|
| "getLastResolvedDeviceId"
|
|
126
126
|
| "listRecentGroupMessages"
|
|
127
|
-
| "
|
|
127
|
+
| "recallMessage"
|
|
128
128
|
| "upsertOwnerProfile"
|
|
129
129
|
>
|
|
130
130
|
>;
|
|
@@ -1922,7 +1922,6 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
1922
1922
|
const protocolControlLogger = createProtocolControlHandler({
|
|
1923
1923
|
accountId,
|
|
1924
1924
|
log: (msg) => log?.info?.(msg),
|
|
1925
|
-
send: () => {},
|
|
1926
1925
|
context: wsLogContext,
|
|
1927
1926
|
});
|
|
1928
1927
|
const notifySignalObserver = createNotifySignalObserver({
|
|
@@ -3456,11 +3455,16 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3456
3455
|
// Ids only: the frame carries no content, and `payload.message_id` is the
|
|
3457
3456
|
// server's `rcl:<target>` slot — the id to delete is `target_message_id`.
|
|
3458
3457
|
//
|
|
3459
|
-
// Synchronous and fire-and-forget on purpose
|
|
3460
|
-
// frame is BestEffort and non-ackable)
|
|
3461
|
-
//
|
|
3462
|
-
//
|
|
3463
|
-
//
|
|
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.
|
|
3464
3468
|
client.on("message:recall", (env: Envelope) => {
|
|
3465
3469
|
const target = recallTargetMessageId(env.payload);
|
|
3466
3470
|
if (!target) {
|
|
@@ -3469,7 +3473,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
|
|
|
3469
3473
|
);
|
|
3470
3474
|
return;
|
|
3471
3475
|
}
|
|
3472
|
-
const removed = store?.
|
|
3476
|
+
const removed = store?.recallMessage?.({
|
|
3473
3477
|
platform: "openclaw",
|
|
3474
3478
|
accountId,
|
|
3475
3479
|
messageId: target,
|
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(
|
|
@@ -821,6 +841,54 @@ export class ClawChatStore {
|
|
|
821
841
|
* account was paired, or one dropped by the inbound filter). `null` means the
|
|
822
842
|
* store is unavailable.
|
|
823
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
|
+
*/
|
|
824
892
|
deleteMessagesByMessageId(input: {
|
|
825
893
|
platform: string;
|
|
826
894
|
accountId: string;
|
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,7 +646,10 @@ 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>);
|
|
@@ -785,16 +788,6 @@ export class ClawChatClient extends EventEmitter {
|
|
|
785
788
|
}
|
|
786
789
|
}
|
|
787
790
|
|
|
788
|
-
private onPing(env: Envelope): void {
|
|
789
|
-
this.sendRawEnvelope({
|
|
790
|
-
version: "2",
|
|
791
|
-
event: EVENT.PONG,
|
|
792
|
-
trace_id: env.trace_id,
|
|
793
|
-
emitted_at: env.emitted_at,
|
|
794
|
-
payload: {} satisfies EmptyPayload,
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
|
|
798
791
|
private onPong(): void {
|
|
799
792
|
if (this.pongTimer) clearTimeout(this.pongTimer);
|
|
800
793
|
this.pongTimer = undefined;
|