@clawling/clawchat-plugin-openclaw 2026.10.9-3 → 2026.10.9-4

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.
@@ -1,5 +1,5 @@
1
1
  import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount } from "./config.js";
2
- import { classifyChat, clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, parseClawChatSessionKey, } from "./turn-boundary.js";
2
+ import { classifyChat, clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, normalizeToolChatId, parseClawChatSessionKey, } from "./turn-boundary.js";
3
3
  import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.js";
4
4
  import { resolveOwnerIdentity } from "./owner-identity.js";
5
5
  import { getRegisteredClawChatToolNames } from "./tools.js";
@@ -53,8 +53,10 @@ export function decideHostToolCall(input) {
53
53
  return undefined;
54
54
  if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
55
55
  // D9: a non-owner turn may only act on its own chat, named explicitly.
56
- const target = input.params.chatId;
57
- if (typeof target !== "string" || !target.trim() || target.trim().toLowerCase() !== parsed.chatId) {
56
+ // The tool resolves the same argument with the same helper, so the chat
57
+ // checked here is the chat the call reaches.
58
+ const target = normalizeToolChatId(input.params.chatId).toLowerCase();
59
+ if (!target || target !== parsed.chatId) {
58
60
  return {
59
61
  block: true,
60
62
  blockReason: input.toolName === "clawchat_react_message"
@@ -583,15 +583,16 @@ export async function sendOpenclawClawlingMedia(params) {
583
583
  * Emit a Protocol-v2 `message.reaction`. Fire-and-forget: reactions are NOT
584
584
  * ack-aligned (the server mints the slot-key message_id, so the ack-by-trace_id
585
585
  * path used for message.send/reply does not apply). Matches the mobile client.
586
+ * Local refusals are returned, not thrown; a transport failure still throws.
586
587
  */
587
588
  export function sendOpenclawClawlingReaction(params) {
588
589
  if (!isValidChatId(params.chatId)) {
589
590
  params.log?.info?.(`clawchat reaction blocked chat_id=${params.chatId} reason=invalid_chat_id`);
590
- return;
591
+ return { sent: false, reason: "invalid_chat_id" };
591
592
  }
592
593
  if (isOutboundBlocked(params.client, params.chatId)) {
593
594
  params.log?.info?.(`clawchat reaction blocked chat_id=${params.chatId} reason=chat_rejected_by_server`);
594
- return;
595
+ return { sent: false, reason: "chat_rejected_by_server" };
595
596
  }
596
597
  const payload = {
597
598
  target_message_id: params.targetMessageId,
@@ -608,6 +609,7 @@ export function sendOpenclawClawlingReaction(params) {
608
609
  };
609
610
  params.client.sendWire(JSON.stringify(env));
610
611
  params.log?.info?.(`clawchat reaction sent chat_id=${params.chatId} target=${params.targetMessageId} emoji=${params.emoji} removed=${params.removed}`);
612
+ return { sent: true };
611
613
  }
612
614
  // Crockford base32 alphabet (ULID spec).
613
615
  const ULID_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
@@ -629,6 +631,30 @@ function ulid(now = Date.now()) {
629
631
  }
630
632
  return out.join("");
631
633
  }
634
+ // Every message id shape a ClawChat message can carry. The server preserves a
635
+ // client-supplied id verbatim and never parses it, so this is deliberately a
636
+ // plausibility check, not the mint contract:
637
+ // - `msg-<ULID>`: clients (this plugin, mobile, hermes) and the server's own
638
+ // fallback mint; `msg_…` / short `msg-…` forms are kept for older ids;
639
+ // - `sys-<ULID>` / `sys-<request_id>`: server system injections;
640
+ // - a bare ULID: the server's user-message publisher;
641
+ // - `clawchat-plugin-openclaw-…`: this plugin's synthetic turn ids.
642
+ // UUIDs are not accepted: no ClawChat message id the model sees has that
643
+ // shape, while host transcript ids (e.g. from `sessions_search`) do.
644
+ const PREFIXED_MESSAGE_ID = /^(?:msg[-_]|sys-|clawchat-plugin-openclaw-)\S+$/i;
645
+ const BARE_ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
646
+ /**
647
+ * Whether `id` can be a real ClawChat message id. Rejects what a model makes
648
+ * up when it has no id (a label like `B`, `msg`, prose); the server only checks
649
+ * that a reaction target is non-empty, so such a value would "succeed" on no
650
+ * message. Callers trim first. Tools use this only as the fallback when the
651
+ * plugin store cannot answer whether it has seen the id in the chat.
652
+ */
653
+ export function isPlausibleMessageId(id) {
654
+ if (typeof id !== "string")
655
+ return false;
656
+ return PREFIXED_MESSAGE_ID.test(id) || BARE_ULID.test(id);
657
+ }
632
658
  /** Client-minted message id: `msg-` + ULID (protocol §7.6 format contract). */
633
659
  export function mintMessageId() {
634
660
  return `msg-${ulid()}`;
@@ -3049,7 +3049,7 @@ export async function startOpenclawClawlingGateway(params) {
3049
3049
  const dispatchResult = await rt.reply.withReplyDispatcher({
3050
3050
  dispatcher,
3051
3051
  onSettled: () => markDispatchIdle(),
3052
- run: () => runWithTerminalClawChatSendScope(terminalSendScopeId, () => rt.reply.dispatchReplyFromConfig({
3052
+ run: () => runWithTerminalClawChatSendScope({ scopeId: terminalSendScopeId, sessionKey: resolvedSessionKey }, () => rt.reply.dispatchReplyFromConfig({
3053
3053
  ctx: ctxPayload,
3054
3054
  cfg: dispatchCfg,
3055
3055
  dispatcher,
@@ -184,6 +184,30 @@ CREATE TABLE IF NOT EXISTS recalled_messages (
184
184
  recalled_at INTEGER NOT NULL,
185
185
  PRIMARY KEY (platform, account_id, message_id)
186
186
  );
187
+ `,
188
+ },
189
+ {
190
+ // Explicit message-id arguments are matched case-insensitively
191
+ // (findSeenMessageInChat); without these the lookup scans the whole
192
+ // unpruned ledger synchronously.
193
+ version: 11,
194
+ name: "message_id_nocase_indexes",
195
+ sql: `
196
+ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_message_id_nocase
197
+ ON clawchat_messages(message_id COLLATE NOCASE);
198
+ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_protocol_message_id_nocase
199
+ ON clawchat_messages(protocol_message_id COLLATE NOCASE);
200
+ `,
201
+ },
202
+ {
203
+ // A tool chat id from the model is mapped to the case already stored for
204
+ // that chat (findStoredChatId) before a row is written, so one chat never
205
+ // ends up under two cases.
206
+ version: 12,
207
+ name: "chat_id_nocase_index",
208
+ sql: `
209
+ CREATE INDEX IF NOT EXISTS idx_clawchat_messages_chat_id_nocase
210
+ ON clawchat_messages(chat_id COLLATE NOCASE);
187
211
  `,
188
212
  },
189
213
  ];
@@ -503,6 +527,55 @@ export class ClawChatStore {
503
527
  .run(input.eventType, input.traceId ?? null, input.chatId ?? null, input.text ?? null, toJson(input.raw), input.accountId, input.kind, input.direction, input.messageId);
504
528
  });
505
529
  }
530
+ /**
531
+ * The stored chat id and message id of this account's ledger row that has
532
+ * `messageId` in `chatId`, both compared case-insensitively. The message id
533
+ * matches the client id (`message_id`) or the acked protocol id
534
+ * (`protocol_message_id`); the one that matched is returned, in its stored
535
+ * case, so callers put the real id on the wire. Answers `false` when no row
536
+ * matches and `null` when the store is unavailable, so callers can tell
537
+ * "not seen" from "cannot say". Seeks via the NOCASE message-id indexes
538
+ * (migration 11); platform, account and chat are post-filters.
539
+ */
540
+ findSeenMessageInChat(input) {
541
+ if (!input.chatId || !input.messageId)
542
+ return false;
543
+ return this.read(() => {
544
+ const row = this.requireDb()
545
+ .prepare(FIND_SEEN_MESSAGE_SQL)
546
+ .get(input.messageId, input.platform, input.accountId, input.chatId, input.messageId, input.platform, input.accountId, input.chatId);
547
+ if (typeof row?.chat_id !== "string" || typeof row.matched_id !== "string")
548
+ return false;
549
+ return { chatId: row.chat_id, messageId: row.matched_id };
550
+ });
551
+ }
552
+ /**
553
+ * The chat id as this account's ledger already stores it for `chatId`
554
+ * (compared case-insensitively), preferring an inbound row, whose case is
555
+ * the server's. `false` when the ledger has no row for that chat, `null`
556
+ * when the store is unavailable. Seeks via the NOCASE chat-id index
557
+ * (migration 12); platform, account and direction are post-filters.
558
+ */
559
+ findStoredChatId(input) {
560
+ if (!input.chatId)
561
+ return false;
562
+ return this.read(() => {
563
+ const row = this.requireDb()
564
+ .prepare(FIND_STORED_CHAT_ID_SQL)
565
+ .get(input.chatId, input.platform, input.accountId, input.chatId, input.platform, input.accountId);
566
+ return typeof row?.chat_id === "string" ? row.chat_id : false;
567
+ });
568
+ }
569
+ /** True when `message_id` was recalled for this (platform, account). */
570
+ isMessageRecalled(input) {
571
+ if (!input.messageId)
572
+ return false;
573
+ const row = this.requireDb()
574
+ .prepare(`SELECT 1 FROM recalled_messages
575
+ WHERE platform = ? AND account_id = ? AND message_id = ?`)
576
+ .get(input.platform, input.accountId, input.messageId);
577
+ return row !== undefined;
578
+ }
506
579
  /**
507
580
  * Erase every ledger row for one `message_id` — the local half of a
508
581
  * server-authorised recall (`message.recall`, docs/client-integration.md §9.8).
@@ -521,19 +594,8 @@ export class ClawChatStore {
521
594
  * the row may never have been stored (a message that arrived before this
522
595
  * account was paired, or one dropped by the inbound filter). `null` means the
523
596
  * store is unavailable.
524
- */
525
- /** True when `message_id` was recalled for this (platform, account). */
526
- isMessageRecalled(input) {
527
- if (!input.messageId)
528
- return false;
529
- const row = this.requireDb()
530
- .prepare(`SELECT 1 FROM recalled_messages
531
- WHERE platform = ? AND account_id = ? AND message_id = ?`)
532
- .get(input.platform, input.accountId, input.messageId);
533
- return row !== undefined;
534
- }
535
- /**
536
- * Record the permanent recall tombstone and purge any row already stored,
597
+ *
598
+ * Records the permanent recall tombstone and purges any row already stored,
537
599
  * atomically. Returns the number of ledger rows deleted (0 when the recall
538
600
  * arrived before the message did — the tombstone still stands and the later
539
601
  * ingest is refused by {@link claimMessageOnce}).
@@ -860,6 +922,43 @@ export class ClawChatStore {
860
922
  this.log?.error?.(`${prefix}: ${safeErrorMessage(err)}`);
861
923
  }
862
924
  }
925
+ /**
926
+ * Lookup behind {@link ClawChatStore.findStoredChatId}: an inbound row first,
927
+ * then any row. Both halves seek `idx_clawchat_messages_chat_id_nocase`.
928
+ * Exported so a test can pin the query plan.
929
+ */
930
+ export const FIND_STORED_CHAT_ID_SQL = `SELECT chat_id FROM (
931
+ SELECT chat_id, 0 AS rank FROM (
932
+ SELECT chat_id FROM clawchat_messages
933
+ WHERE chat_id = ? COLLATE NOCASE AND platform = ? AND account_id = ? AND direction = 'inbound'
934
+ LIMIT 1
935
+ )
936
+ UNION ALL
937
+ SELECT chat_id, 1 AS rank FROM (
938
+ SELECT chat_id FROM clawchat_messages
939
+ WHERE chat_id = ? COLLATE NOCASE AND platform = ? AND account_id = ?
940
+ LIMIT 1
941
+ )
942
+ ) ORDER BY rank LIMIT 1`;
943
+ /**
944
+ * Lookup behind {@link ClawChatStore.findSeenMessageInChat}. Two index seeks
945
+ * (`message_id` / `protocol_message_id`, both COLLATE NOCASE, migration 11);
946
+ * platform, account and chat are post-filters. A `message_id` match wins.
947
+ * `INDEXED BY` pins the seek: without table statistics the planner would
948
+ * otherwise pick the chat-id index (migration 12) and walk the whole chat.
949
+ * Exported so a test can pin the query plan.
950
+ */
951
+ export const FIND_SEEN_MESSAGE_SQL = `SELECT chat_id, matched_id FROM (
952
+ SELECT chat_id, message_id AS matched_id, 0 AS rank
953
+ FROM clawchat_messages INDEXED BY idx_clawchat_messages_message_id_nocase
954
+ WHERE message_id = ? COLLATE NOCASE
955
+ AND platform = ? AND account_id = ? AND chat_id = ? COLLATE NOCASE
956
+ UNION ALL
957
+ SELECT chat_id, protocol_message_id AS matched_id, 1 AS rank
958
+ FROM clawchat_messages INDEXED BY idx_clawchat_messages_protocol_message_id_nocase
959
+ WHERE protocol_message_id = ? COLLATE NOCASE
960
+ AND platform = ? AND account_id = ? AND chat_id = ? COLLATE NOCASE
961
+ ) ORDER BY rank LIMIT 1`;
863
962
  let singleton = null;
864
963
  export function createClawChatStore(options = {}) {
865
964
  return new ClawChatStore(options);
@@ -1,12 +1,67 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  const DEFAULT_TTL_MS = 60_000;
3
3
  const terminalSends = new Map();
4
- const terminalSendScope = new AsyncLocalStorage();
4
+ const turnScopeStorage = new AsyncLocalStorage();
5
+ const openScopesBySession = new Map();
6
+ function normalizeSessionKey(sessionKey) {
7
+ const normalized = sessionKey?.trim().toLowerCase();
8
+ return normalized ? normalized : null;
9
+ }
5
10
  function key(accountId, chatId, scopeId) {
6
11
  return `${accountId}\0${chatId}\0${scopeId}`;
7
12
  }
8
- export function runWithTerminalClawChatSendScope(scopeId, fn) {
9
- return terminalSendScope.run(scopeId, fn);
13
+ function isPromiseLike(value) {
14
+ return typeof value?.then === "function";
15
+ }
16
+ /**
17
+ * Run one dispatched turn. `sessionKey` is the session the host runs the turn
18
+ * in; consumers only see this turn when their trusted session key matches.
19
+ * The scope closes when `fn` returns or its promise settles.
20
+ */
21
+ export function runWithTerminalClawChatSendScope(init, fn) {
22
+ const scope = {
23
+ scopeId: init.scopeId,
24
+ sessionKey: normalizeSessionKey(init.sessionKey),
25
+ open: true,
26
+ superseded: false,
27
+ };
28
+ const sessionKey = scope.sessionKey;
29
+ if (sessionKey) {
30
+ const open = openScopesBySession.get(sessionKey) ?? new Set();
31
+ for (const earlier of open)
32
+ earlier.superseded = true;
33
+ open.add(scope);
34
+ openScopesBySession.set(sessionKey, open);
35
+ }
36
+ const close = () => {
37
+ scope.open = false;
38
+ if (!sessionKey)
39
+ return;
40
+ const open = openScopesBySession.get(sessionKey);
41
+ open?.delete(scope);
42
+ if (open?.size === 0)
43
+ openScopesBySession.delete(sessionKey);
44
+ };
45
+ let result;
46
+ try {
47
+ result = turnScopeStorage.run(scope, fn);
48
+ }
49
+ catch (err) {
50
+ close();
51
+ throw err;
52
+ }
53
+ if (isPromiseLike(result)) {
54
+ return Promise.resolve(result).finally(close);
55
+ }
56
+ close();
57
+ return result;
58
+ }
59
+ /** The caller's own open turn, or null when it cannot be tied to the caller's session. */
60
+ function resolveCallerScope(sessionKey) {
61
+ const scope = turnScopeStorage.getStore();
62
+ if (!scope?.open || !scope.sessionKey)
63
+ return null;
64
+ return scope.sessionKey === normalizeSessionKey(sessionKey) ? scope : null;
10
65
  }
11
66
  /**
12
67
  * Drop every marker whose TTL has lapsed. A marker is only otherwise deleted
@@ -22,7 +77,7 @@ function sweepExpiredTerminalSends(nowMs) {
22
77
  }
23
78
  }
24
79
  export function markTerminalClawChatSend(params) {
25
- const scopeId = params.scopeId ?? terminalSendScope.getStore();
80
+ const scopeId = params.scopeId ?? resolveCallerScope(params.sessionKey)?.scopeId;
26
81
  if (!scopeId)
27
82
  return;
28
83
  const nowMs = params.nowMs ?? Date.now();
@@ -34,7 +89,7 @@ export function markTerminalClawChatSend(params) {
34
89
  });
35
90
  }
36
91
  export function consumeTerminalClawChatSend(params) {
37
- const scopeId = params.scopeId ?? terminalSendScope.getStore();
92
+ const scopeId = params.scopeId;
38
93
  if (!scopeId)
39
94
  return null;
40
95
  const recordKey = key(params.accountId, params.chatId, scopeId);
@@ -48,17 +103,19 @@ export function consumeTerminalClawChatSend(params) {
48
103
  }
49
104
  export function clearTerminalClawChatSendsForTest() {
50
105
  terminalSends.clear();
106
+ openScopesBySession.clear();
51
107
  }
52
108
  /**
53
- * Read the current turn's ambient send scope (accountId\0chatId\0messageId).
54
- * Returns null when not inside a dispatch scope, or when the scope id is not
55
- * the tripartite form (e.g. a test-only opaque scope id).
109
+ * The calling turn's default reaction target (accountId\0chatId\0messageId).
110
+ * Returns null unless the tool call's trusted `sessionKey` matches the open
111
+ * turn in its async context and no later turn of that session has started
112
+ * since; also null for a non-tripartite scope id (e.g. a test-only one).
56
113
  */
57
- export function getCurrentTerminalSendScope() {
58
- const store = terminalSendScope.getStore();
59
- if (!store)
114
+ export function getCurrentTerminalSendScope(params) {
115
+ const scope = resolveCallerScope(params.sessionKey);
116
+ if (!scope || scope.superseded)
60
117
  return null;
61
- const parts = store.split("\0");
118
+ const parts = scope.scopeId.split("\0");
62
119
  if (parts.length !== 3)
63
120
  return null;
64
121
  const [accountId, chatId, messageId] = parts;