@clawling/clawchat-plugin-openclaw 2026.8.20-1 → 2026.8.22-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,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
+ }
@@ -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";
@@ -2907,6 +2907,33 @@ export async function startOpenclawClawlingGateway(params) {
2907
2907
  await handleInboundEnvelope(env);
2908
2908
  })();
2909
2909
  });
2910
+ // `message.recall` — the sender withdrew a message. Erase our own ledger row
2911
+ // so the "Prior group context" block above (MENTION_CONTEXT_N rows, no time
2912
+ // predicate, no retention) can never read it back into a turn.
2913
+ //
2914
+ // Ids only: the frame carries no content, and `payload.message_id` is the
2915
+ // server's `rcl:<target>` slot — the id to delete is `target_message_id`.
2916
+ //
2917
+ // Synchronous and fire-and-forget on purpose. There is no ack to send (the
2918
+ // frame is BestEffort and non-ackable), and no tombstone to write: a Kafka
2919
+ // redelivery of the original can resurrect the row, which is an accepted
2920
+ // known limitation rather than a fourth reimplementation of the mobile
2921
+ // client's tombstone table.
2922
+ client.on("message:recall", (env) => {
2923
+ const target = recallTargetMessageId(env.payload);
2924
+ if (!target) {
2925
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`);
2926
+ return;
2927
+ }
2928
+ const removed = store?.deleteMessagesByMessageId?.({
2929
+ platform: "openclaw",
2930
+ accountId,
2931
+ messageId: target,
2932
+ });
2933
+ const rowsLabel = typeof removed === "number" ? String(removed) : "unavailable";
2934
+ const logFn = typeof removed === "number" ? log?.info : log?.error;
2935
+ logFn?.(`[${accountId}] clawchat-plugin-openclaw recall purged chat_id=${String(env.chat_id ?? "")} msg=${target} rows=${rowsLabel}`);
2936
+ });
2910
2937
  // Initial pull of per-group agent settings at startup (best-effort; errors are
2911
2938
  // logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
2912
2939
  // rotating `account.token` out from under the already-constructed WS client.
@@ -443,6 +443,38 @@ export class ClawChatStore {
443
443
  .run(input.eventType, input.traceId ?? null, input.chatId ?? null, input.text ?? null, toJson(input.raw), input.accountId, input.kind, input.direction, input.messageId);
444
444
  });
445
445
  }
446
+ /**
447
+ * Erase every ledger row for one `message_id` — the local half of a
448
+ * server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
449
+ *
450
+ * **The only DELETE in this store, and deliberately so.** There is no prune
451
+ * and no retention on `clawchat_messages`: without this, a message recalled
452
+ * ninety seconds after it was sent stays one @-mention away from being read
453
+ * back aloud under "Prior group context" for as long as the group is quiet.
454
+ *
455
+ * Scoped to `(platform, account_id)` so one account's recall cannot reach
456
+ * another's rows, and matched on `message_id`, which seeks via the
457
+ * single-column `idx_clawchat_messages_message_id` before the `platform` /
458
+ * `account_id` filter is applied — so this is a lookup, not a table scan.
459
+ *
460
+ * Answers the number of rows removed. `0` is a normal outcome, not a failure:
461
+ * the row may never have been stored (a message that arrived before this
462
+ * account was paired, or one dropped by the inbound filter). `null` means the
463
+ * store is unavailable.
464
+ */
465
+ deleteMessagesByMessageId(input) {
466
+ // An empty id would match every row whose message_id is '' — deleting
467
+ // everything the store never managed to key. Refuse rather than guess.
468
+ if (!input.messageId)
469
+ return 0;
470
+ return this.write(() => {
471
+ const result = this.requireDb()
472
+ .prepare(`DELETE FROM clawchat_messages
473
+ WHERE platform = ? AND account_id = ? AND message_id = ?`)
474
+ .run(input.platform, input.accountId, input.messageId);
475
+ return Number(result.changes);
476
+ });
477
+ }
446
478
  startConnection(input) {
447
479
  return this.write(() => {
448
480
  const now = input.connectStartedAt ?? Date.now();
@@ -558,6 +558,11 @@ export class ClawChatClient extends EventEmitter {
558
558
  return this.onMessageError(env);
559
559
  if (isBusinessDispatchEvent(env.event))
560
560
  this.emit("message", env);
561
+ // Deliberately NOT a business dispatch event: `message` feeds the turn
562
+ // pipeline, and a recall has no body to turn into a prompt. It is an
563
+ // instruction to delete, so it gets its own emitter and its own consumer.
564
+ if (env.event === EVENT.MESSAGE_RECALL)
565
+ this.emit("message:recall", env);
561
566
  if (env.event === EVENT.MESSAGE_CREATED)
562
567
  this.emit("message:created", env);
563
568
  if (env.event === EVENT.MESSAGE_ADD)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.8.20-1",
3
+ "version": "2026.8.22-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -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
+ | "deleteMessagesByMessageId"
126
128
  | "upsertOwnerProfile"
127
129
  >
128
130
  >;
@@ -3447,6 +3449,38 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3447
3449
  })();
3448
3450
  });
3449
3451
 
3452
+ // `message.recall` — the sender withdrew a message. Erase our own ledger row
3453
+ // so the "Prior group context" block above (MENTION_CONTEXT_N rows, no time
3454
+ // predicate, no retention) can never read it back into a turn.
3455
+ //
3456
+ // Ids only: the frame carries no content, and `payload.message_id` is the
3457
+ // server's `rcl:<target>` slot — the id to delete is `target_message_id`.
3458
+ //
3459
+ // Synchronous and fire-and-forget on purpose. There is no ack to send (the
3460
+ // frame is BestEffort and non-ackable), and no tombstone to write: a Kafka
3461
+ // redelivery of the original can resurrect the row, which is an accepted
3462
+ // known limitation rather than a fourth reimplementation of the mobile
3463
+ // client's tombstone table.
3464
+ client.on("message:recall", (env: Envelope) => {
3465
+ const target = recallTargetMessageId(env.payload);
3466
+ if (!target) {
3467
+ log?.info?.(
3468
+ `[${accountId}] clawchat-plugin-openclaw recall ignored (no target_message_id) chat_id=${String(env.chat_id ?? "")}`,
3469
+ );
3470
+ return;
3471
+ }
3472
+ const removed = store?.deleteMessagesByMessageId?.({
3473
+ platform: "openclaw",
3474
+ accountId,
3475
+ messageId: target,
3476
+ });
3477
+ const rowsLabel = typeof removed === "number" ? String(removed) : "unavailable";
3478
+ const logFn = typeof removed === "number" ? log?.info : log?.error;
3479
+ logFn?.(
3480
+ `[${accountId}] clawchat-plugin-openclaw recall purged chat_id=${String(env.chat_id ?? "")} msg=${target} rows=${rowsLabel}`,
3481
+ );
3482
+ });
3483
+
3450
3484
  // Initial pull of per-group agent settings at startup (best-effort; errors are
3451
3485
  // logged). `bypassRefresh: true` (Finding 4) keeps this pre-connect pull from
3452
3486
  // rotating `account.token` out from under the already-constructed WS client.
package/src/storage.ts CHANGED
@@ -802,6 +802,44 @@ export class ClawChatStore {
802
802
  });
803
803
  }
804
804
 
805
+ /**
806
+ * Erase every ledger row for one `message_id` — the local half of a
807
+ * server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
808
+ *
809
+ * **The only DELETE in this store, and deliberately so.** There is no prune
810
+ * and no retention on `clawchat_messages`: without this, a message recalled
811
+ * ninety seconds after it was sent stays one @-mention away from being read
812
+ * back aloud under "Prior group context" for as long as the group is quiet.
813
+ *
814
+ * Scoped to `(platform, account_id)` so one account's recall cannot reach
815
+ * another's rows, and matched on `message_id`, which seeks via the
816
+ * single-column `idx_clawchat_messages_message_id` before the `platform` /
817
+ * `account_id` filter is applied — so this is a lookup, not a table scan.
818
+ *
819
+ * Answers the number of rows removed. `0` is a normal outcome, not a failure:
820
+ * the row may never have been stored (a message that arrived before this
821
+ * account was paired, or one dropped by the inbound filter). `null` means the
822
+ * store is unavailable.
823
+ */
824
+ deleteMessagesByMessageId(input: {
825
+ platform: string;
826
+ accountId: string;
827
+ messageId: string;
828
+ }): number | null {
829
+ // An empty id would match every row whose message_id is '' — deleting
830
+ // everything the store never managed to key. Refuse rather than guess.
831
+ if (!input.messageId) return 0;
832
+ return this.write(() => {
833
+ const result = this.requireDb()
834
+ .prepare(
835
+ `DELETE FROM clawchat_messages
836
+ WHERE platform = ? AND account_id = ? AND message_id = ?`,
837
+ )
838
+ .run(input.platform, input.accountId, input.messageId);
839
+ return Number(result.changes);
840
+ });
841
+ }
842
+
805
843
  startConnection(input: StartConnectionInput): number | null {
806
844
  return this.write(() => {
807
845
  const now = input.connectStartedAt ?? Date.now();
package/src/ws-client.ts CHANGED
@@ -651,6 +651,10 @@ export class ClawChatClient extends EventEmitter {
651
651
  if (env.event === EVENT.MESSAGE_ACK) return this.onAck(env as Envelope<MessageAckPayload>);
652
652
  if (env.event === EVENT.MESSAGE_ERROR) return this.onMessageError(env as Envelope<MessageErrorPayload>);
653
653
  if (isBusinessDispatchEvent(env.event)) this.emit("message", env);
654
+ // Deliberately NOT a business dispatch event: `message` feeds the turn
655
+ // pipeline, and a recall has no body to turn into a prompt. It is an
656
+ // instruction to delete, so it gets its own emitter and its own consumer.
657
+ if (env.event === EVENT.MESSAGE_RECALL) this.emit("message:recall", env);
654
658
  if (env.event === EVENT.MESSAGE_CREATED) this.emit("message:created", env);
655
659
  if (env.event === EVENT.MESSAGE_ADD) this.emit("message:add", env);
656
660
  if (env.event === EVENT.MESSAGE_DONE) this.emit("message:done", env);