@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.
package/dist/src/tools.js CHANGED
@@ -8,12 +8,13 @@ import { ClawlingApiError, } from "./api-types.js";
8
8
  import { mapGateOutcome } from "./gate-outcome.js";
9
9
  import { CHANNEL_ID, normalizeOpenclawClawlingAccountId, resolveOpenclawClawlingAccount, } from "./config.js";
10
10
  import { clawChatDbPathForStateDir, getClawChatStore, makeOwnerProfilePersister, } from "./storage.js";
11
- import { sendOpenclawClawlingMentionMessage, sendOpenclawClawlingReaction } from "./outbound.js";
11
+ import { isPlausibleMessageId, sendOpenclawClawlingMentionMessage, sendOpenclawClawlingReaction } from "./outbound.js";
12
+ import { isValidChatId } from "./ws-client.js";
12
13
  import { getOpenclawClawlingClient, } from "./runtime.js";
13
14
  import { markTerminalClawChatSend, getCurrentTerminalSendScope } from "./terminal-send.js";
14
15
  import { editClawChatMemoryBody, readClawChatMemoryFile, resolveClawChatMemoryPath, searchClawChatMemory, writeClawChatMemoryBody, } from "./clawchat-memory.js";
15
16
  import { canReadMemory, memoryScopeNote, memoryScopeRefusal, resolveMemoryScope, } from "./memory-scope.js";
16
- import { clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST } from "./turn-boundary.js";
17
+ import { clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, normalizeToolChatId } from "./turn-boundary.js";
17
18
  import { pullGroupMetadata, pullOwnerMetadata, pullUserMetadata, pushMetadata, updateMetadata, } from "./clawchat-metadata.js";
18
19
  import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetDirectConversationSchema, ClawchatGetMomentSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAddGroupMemberSchema, ClawchatAcceptFriendRequestSchema, ClawchatMemoryEditSchema, ClawchatMemoryReadSchema, ClawchatMemorySearchSchema, ClawchatMemoryWriteSchema, ClawchatMetadataSyncSchema, ClawchatMetadataUpdateSchema, ClawchatCreateMomentCommentSchema, ClawchatCreateMomentSchema, ClawchatDeleteMomentCommentSchema, ClawchatDeleteMomentSchema, ClawchatListAccountFriendsSchema, ClawchatListFriendRequestsSchema, ClawchatListMomentsSchema, ClawchatMentionMessageSchema, ClawchatReactMessageSchema, ClawchatReplyMomentCommentSchema, ClawchatRejectFriendRequestSchema, ClawchatRemoveFriendSchema, ClawchatSearchUsersSchema, ClawchatSendFriendRequestSchema, ClawchatToggleMomentReactionSchema, ClawchatUpdateAccountProfileSchema, ClawchatUploadAvatarImageSchema, ClawchatRegisterAppSchema, ClawchatListAppsSchema, ClawchatUnregisterAppSchema, ClawchatLivewareLoginSchema, ClawchatOrchListAgentsSchema, ClawchatOrchGetAgentSchema, ClawchatOrchSetAgentBehaviorSchema, ClawchatOrchListGroupsSchema, ClawchatOrchGetGroupSchema, ClawchatOrchSetGroupPromptSchema, ClawchatOrchCreateGroupSchema, ClawchatOrchGroupMemberSchema, ClawchatOrchSetGroupAgentSettingsSchema, ClawchatOrchCreateConnectCodeSchema, ClawchatOrchGetConnectCodeSchema, } from "./tools-schema.js";
19
20
  const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
@@ -91,6 +92,15 @@ export function apiError(err) {
91
92
  function validationError(message) {
92
93
  return jsonResponse({ error: "validation", message });
93
94
  }
95
+ function reactionBlockedError(chatId, reason) {
96
+ return jsonResponse({
97
+ error: "send_blocked",
98
+ reason,
99
+ message: reason === "invalid_chat_id"
100
+ ? `clawchat-plugin-openclaw: "${chatId}" is not a ClawChat chat id (expected cnv_…); the reaction was not sent`
101
+ : "clawchat-plugin-openclaw: this chat was rejected by the server; the reaction was not sent",
102
+ });
103
+ }
94
104
  function genericError(err) {
95
105
  return jsonResponse({
96
106
  error: "unknown",
@@ -173,12 +183,18 @@ function planMomentImages(images) {
173
183
  }
174
184
  return { ok: true, entries };
175
185
  }
186
+ const MESSAGE_ID_ECHO_LIMIT = 64;
187
+ function notAMessageIdMessage(field, value) {
188
+ const shown = value.length > MESSAGE_ID_ECHO_LIMIT ? `${value.slice(0, MESSAGE_ID_ECHO_LIMIT)}…` : value;
189
+ return `clawchat-plugin-openclaw: ${field} "${shown}" is not a message in this chat; use the real message id from this chat's message metadata (e.g. msg-…), or omit it`;
190
+ }
176
191
  function validateMentionMessageParams(params) {
177
192
  if (!params || typeof params !== "object") {
178
193
  return { ok: false, message: "clawchat-plugin-openclaw: params are required" };
179
194
  }
180
195
  const p = params;
181
- if (typeof p.chatId !== "string" || !p.chatId.trim()) {
196
+ const chatId = normalizeToolChatId(p.chatId);
197
+ if (!chatId) {
182
198
  return { ok: false, message: "clawchat-plugin-openclaw: chatId is required" };
183
199
  }
184
200
  if (p.chatType !== undefined && p.chatType !== "group" && p.chatType !== "direct") {
@@ -214,11 +230,12 @@ function validateMentionMessageParams(params) {
214
230
  ok: true,
215
231
  params: {
216
232
  ...p,
233
+ chatId,
217
234
  replyToMessageId,
218
235
  },
219
236
  };
220
237
  }
221
- return { ok: true, params: p };
238
+ return { ok: true, params: { ...p, chatId } };
222
239
  }
223
240
  /**
224
241
  * Account a tool call belongs to, or `null` when the call carries no ClawChat
@@ -244,6 +261,17 @@ export function resolveOpenclawClawlingToolAccountId(ctx) {
244
261
  }
245
262
  return null;
246
263
  }
264
+ /**
265
+ * True when the host says this tool call's run is a ClawChat run: every
266
+ * channel the trusted ctx names (`deliveryContext.channel`, `messageChannel`)
267
+ * is this channel, and at least one is present. Missing or foreign → false.
268
+ */
269
+ export function isClawChatToolRun(ctx) {
270
+ const channels = [ctx.deliveryContext?.channel, ctx.messageChannel]
271
+ .map((channel) => (typeof channel === "string" ? channel.trim().toLowerCase() : ""))
272
+ .filter(Boolean);
273
+ return channels.length > 0 && channels.every((channel) => channel === CHANNEL_ID);
274
+ }
247
275
  // Names this plugin registered through its wrapper below. The host-tool guard
248
276
  // lets an allowlisted `clawchat_*` name through only when it is in this set,
249
277
  // so another plugin's tool under a lookalike name is not trusted by prefix.
@@ -306,6 +334,90 @@ export function registerOpenclawClawlingTools(api, options = {}) {
306
334
  return null;
307
335
  }
308
336
  }
337
+ let warnedMessageIdStore = false;
338
+ /**
339
+ * An explicit message id (react target, mention reply) must be one the
340
+ * plugin has stored for this chat; the stored chat id and message id (their
341
+ * real case) are what gets sent. When the store cannot answer, fall back to
342
+ * the message-id shape check rather than block every call, keep the ids as
343
+ * given, and warn once without ids or arguments.
344
+ */
345
+ function resolveExplicitMessageId(input) {
346
+ let seen = null;
347
+ try {
348
+ const store = resolveStore();
349
+ seen = store?.findSeenMessageInChat
350
+ ? store.findSeenMessageInChat({
351
+ platform: "openclaw",
352
+ accountId: input.accountId,
353
+ chatId: input.chatId,
354
+ messageId: input.value,
355
+ })
356
+ : null;
357
+ }
358
+ catch {
359
+ seen = null;
360
+ }
361
+ if (seen)
362
+ return { ok: true, chatId: seen.chatId, messageId: seen.messageId };
363
+ if (seen === null) {
364
+ if (!warnedMessageIdStore) {
365
+ warnedMessageIdStore = true;
366
+ api.logger.warn("clawchat-plugin-openclaw: message-id store check unavailable; falling back to the message-id shape check");
367
+ }
368
+ if (isPlausibleMessageId(input.value))
369
+ return { ok: true, chatId: input.chatId, messageId: input.value };
370
+ }
371
+ return { ok: false, error: validationError(notAMessageIdMessage(input.field, input.value)) };
372
+ }
373
+ /**
374
+ * The chat id in the case the ledger already stores for it, so a send, its
375
+ * terminal marker and its ledger row all name the chat as the inbound path
376
+ * does (a second case splits the chat for memory scope). The id as given
377
+ * when the ledger has no row for the chat or cannot answer.
378
+ */
379
+ function storedChatIdCase(accountId, chatId) {
380
+ try {
381
+ const stored = resolveStore()?.findStoredChatId?.({ platform: "openclaw", accountId, chatId });
382
+ return typeof stored === "string" && stored ? stored : chatId;
383
+ }
384
+ catch {
385
+ return chatId;
386
+ }
387
+ }
388
+ /**
389
+ * Best-effort ledger row for a sent mention, so its id can be reacted to or
390
+ * quoted later. Written after the ack; a store failure never affects the send.
391
+ */
392
+ function recordMentionSent(input) {
393
+ try {
394
+ const store = resolveStore();
395
+ const claimed = store?.claimMessageOnce?.({
396
+ platform: "openclaw",
397
+ accountId: input.accountId,
398
+ kind: "message",
399
+ direction: "outbound",
400
+ eventType: "message.send",
401
+ chatId: input.chatId,
402
+ messageId: input.messageId,
403
+ text: input.text,
404
+ raw: { mode: "tool-mention" },
405
+ });
406
+ if (claimed !== true)
407
+ return;
408
+ store?.markMessageAcknowledged?.({
409
+ accountId: input.accountId,
410
+ kind: "message",
411
+ direction: "outbound",
412
+ messageId: input.messageId,
413
+ protocolMessageId: input.messageId,
414
+ ackedAt: input.acceptedAt ?? Date.now(),
415
+ });
416
+ }
417
+ catch {
418
+ // Diagnostics only; the mention has already been sent.
419
+ }
420
+ }
309
421
  function detailsError(details) {
310
422
  if (!details || typeof details !== "object")
311
423
  return null;
@@ -1001,7 +1113,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1001
1113
  if (!built.ok)
1002
1114
  return built.error;
1003
1115
  try {
1004
- const data = await built.client.getConversation(p.conversationId);
1116
+ const data = await built.client.getConversation(normalizeToolChatId(p.conversationId));
1005
1117
  return jsonResponse(data);
1006
1118
  }
1007
1119
  catch (err) {
@@ -1063,7 +1175,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1063
1175
  if (!built.ok)
1064
1176
  return built.error;
1065
1177
  try {
1066
- const data = await built.client.leaveConversation(p.conversationId);
1178
+ const data = await built.client.leaveConversation(normalizeToolChatId(p.conversationId));
1067
1179
  return jsonResponse(data);
1068
1180
  }
1069
1181
  catch (err) {
@@ -1094,7 +1206,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1094
1206
  if (!built.ok)
1095
1207
  return built.error;
1096
1208
  try {
1097
- const data = await built.client.addConversationMember(p.conversationId, p.userId);
1209
+ const data = await built.client.addConversationMember(normalizeToolChatId(p.conversationId), p.userId);
1098
1210
  return jsonResponse(data);
1099
1211
  }
1100
1212
  catch (err) {
@@ -1131,6 +1243,20 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1131
1243
  }
1132
1244
  try {
1133
1245
  const p = validated.params;
1246
+ let chatId = isValidChatId(p.chatId) ? storedChatIdCase(account.accountId, p.chatId) : p.chatId;
1247
+ let replyToMessageId = p.replyToMessageId;
1248
+ if (replyToMessageId) {
1249
+ const resolved = resolveExplicitMessageId({
1250
+ field: "replyToMessageId",
1251
+ value: replyToMessageId,
1252
+ accountId: account.accountId,
1253
+ chatId,
1254
+ });
1255
+ if (!resolved.ok)
1256
+ return resolved.error;
1257
+ chatId = resolved.chatId;
1258
+ replyToMessageId = resolved.messageId;
1259
+ }
1134
1260
  const client = getOpenclawClawlingClient(account.accountId);
1135
1261
  if (!client) {
1136
1262
  return configError("ClawChat websocket client is not ready");
@@ -1139,15 +1265,15 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1139
1265
  client,
1140
1266
  account,
1141
1267
  to: {
1142
- chatId: p.chatId.trim(),
1268
+ chatId,
1143
1269
  chatType: p.chatType ?? "group",
1144
1270
  },
1145
1271
  ...(p.text !== undefined ? { text: p.text } : {}),
1146
1272
  mentions: p.mentions,
1147
- ...(p.replyToMessageId
1273
+ ...(replyToMessageId
1148
1274
  ? {
1149
1275
  replyCtx: {
1150
- replyToMessageId: p.replyToMessageId,
1276
+ replyToMessageId,
1151
1277
  },
1152
1278
  }
1153
1279
  : {}),
@@ -1155,11 +1281,23 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1155
1281
  });
1156
1282
  if (!result)
1157
1283
  return jsonResponse(result);
1158
- markTerminalClawChatSend({
1284
+ recordMentionSent({
1159
1285
  accountId: account.accountId,
1160
- chatId: p.chatId.trim(),
1286
+ chatId,
1161
1287
  messageId: result.messageId,
1288
+ text: p.text ?? "",
1289
+ ...(result.acceptedAt !== undefined ? { acceptedAt: result.acceptedAt } : {}),
1162
1290
  });
1291
+ // Same trust rule as the react defaults: only a ClawChat run's
1292
+ // turn may have its follow-up reply suppressed.
1293
+ if (isClawChatToolRun(ctx)) {
1294
+ markTerminalClawChatSend({
1295
+ accountId: account.accountId,
1296
+ chatId,
1297
+ messageId: result.messageId,
1298
+ sessionKey: ctx.sessionKey,
1299
+ });
1300
+ }
1163
1301
  return jsonResponse({
1164
1302
  sent: true,
1165
1303
  terminal: true,
@@ -1197,15 +1335,55 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1197
1335
  if (!account.configured) {
1198
1336
  return configError("clawchat-plugin-openclaw: account is not configured");
1199
1337
  }
1200
- const scope = getCurrentTerminalSendScope();
1201
- const chatId = p.chatId?.trim() || scope?.chatId;
1202
- const targetMessageId = p.targetMessageId?.trim() || scope?.messageId;
1338
+ // Defaults come only from this call's own turn: the open turn in
1339
+ // its async context registered for the same session key as the
1340
+ // trusted tool ctx (see terminal-send.ts). Anything else fails as
1341
+ // a validation error asking for explicit ids.
1342
+ // The run must also be a ClawChat run: under a session key shared
1343
+ // with another channel, that channel's turns never register or
1344
+ // supersede a ClawChat turn, so its calls get no defaults.
1345
+ const scope = isClawChatToolRun(ctx)
1346
+ ? getCurrentTerminalSendScope({ sessionKey: ctx.sessionKey })
1347
+ : null;
1348
+ // Only a missing or blank chatId defaults. A given one that strips
1349
+ // to nothing (`clawchat-plugin-openclaw:`, `group:`) stays as given
1350
+ // so outbound refuses it as invalid_chat_id instead of it silently
1351
+ // reaching the current turn's chat.
1352
+ const rawChatId = typeof p.chatId === "string" ? p.chatId.trim() : "";
1353
+ let chatId = rawChatId ? normalizeToolChatId(rawChatId) || rawChatId : scope?.chatId;
1354
+ const explicitTargetMessageId = typeof p.targetMessageId === "string" ? p.targetMessageId.trim() : "";
1355
+ // The default target is the current turn's message, so it applies
1356
+ // only to that turn's chat: chatId omitted, or naming the scope's
1357
+ // chat (any case, host target form). The send then goes to the
1358
+ // scope's chat id. In any other chat that id names no message.
1359
+ const defaultsToScopeChat = Boolean(scope)
1360
+ && (!rawChatId || (chatId ?? "").toLowerCase() === scope.chatId.toLowerCase());
1361
+ let targetMessageId = explicitTargetMessageId || (defaultsToScopeChat ? scope.messageId : undefined);
1362
+ if (!explicitTargetMessageId && defaultsToScopeChat)
1363
+ chatId = scope.chatId;
1203
1364
  if (!chatId) {
1204
1365
  return validationError("clawchat-plugin-openclaw: chatId is required (no current-turn chat to default from)");
1205
1366
  }
1206
1367
  if (!targetMessageId) {
1207
1368
  return validationError("clawchat-plugin-openclaw: targetMessageId is required (no current-turn message to default from)");
1208
1369
  }
1370
+ // Only an explicit id is checked; the current-turn default is the
1371
+ // triggering message's own id. A chat id outbound would refuse is
1372
+ // reported as that refusal first.
1373
+ if (explicitTargetMessageId) {
1374
+ if (!isValidChatId(chatId))
1375
+ return reactionBlockedError(chatId, "invalid_chat_id");
1376
+ const resolved = resolveExplicitMessageId({
1377
+ field: "targetMessageId",
1378
+ value: explicitTargetMessageId,
1379
+ accountId: account.accountId,
1380
+ chatId,
1381
+ });
1382
+ if (!resolved.ok)
1383
+ return resolved.error;
1384
+ chatId = resolved.chatId;
1385
+ targetMessageId = resolved.messageId;
1386
+ }
1209
1387
  const client = getOpenclawClawlingClient(account.accountId);
1210
1388
  if (!client) {
1211
1389
  return configError("ClawChat websocket client is not ready");
@@ -1213,7 +1391,11 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1213
1391
  const emoji = p.emoji.trim();
1214
1392
  const removed = p.remove === true;
1215
1393
  try {
1216
- sendOpenclawClawlingReaction({ client, chatId, targetMessageId, emoji, removed, log: api.logger });
1394
+ const sent = sendOpenclawClawlingReaction({ client, chatId, targetMessageId, emoji, removed, log: api.logger });
1395
+ // A locally refused reaction never left the plugin: report the
1396
+ // refusal so the model does not tell the user it reacted.
1397
+ if (!sent.sent)
1398
+ return reactionBlockedError(chatId, sent.reason);
1217
1399
  return jsonResponse({ reacted: true, targetMessageId, emoji, removed });
1218
1400
  }
1219
1401
  catch (err) {
@@ -1682,7 +1864,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1682
1864
  async execute(_callId, params) {
1683
1865
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_get_group", params, async () => {
1684
1866
  const p = params;
1685
- return await withClient(accountId, (c) => c.orchGetGroup(p.conversationId));
1867
+ return await withClient(accountId, (c) => c.orchGetGroup(normalizeToolChatId(p.conversationId)));
1686
1868
  });
1687
1869
  },
1688
1870
  };
@@ -1705,7 +1887,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1705
1887
  async execute(_callId, params) {
1706
1888
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_set_group_prompt", params, async () => {
1707
1889
  const p = params;
1708
- return await withClient(accountId, (c) => c.orchSetGroupPrompt(p.conversationId, p.description));
1890
+ return await withClient(accountId, (c) => c.orchSetGroupPrompt(normalizeToolChatId(p.conversationId), p.description));
1709
1891
  });
1710
1892
  },
1711
1893
  };
@@ -1738,7 +1920,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1738
1920
  async execute(_callId, params) {
1739
1921
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_add_group_member", params, async () => {
1740
1922
  const p = params;
1741
- return await withClient(accountId, (c) => c.orchAddGroupMember(p.conversationId, p.agentId));
1923
+ return await withClient(accountId, (c) => c.orchAddGroupMember(normalizeToolChatId(p.conversationId), p.agentId));
1742
1924
  });
1743
1925
  },
1744
1926
  };
@@ -1754,7 +1936,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1754
1936
  async execute(_callId, params) {
1755
1937
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_remove_group_member", params, async () => {
1756
1938
  const p = params;
1757
- return await withClient(accountId, (c) => c.orchRemoveGroupMember(p.conversationId, p.agentId));
1939
+ return await withClient(accountId, (c) => c.orchRemoveGroupMember(normalizeToolChatId(p.conversationId), p.agentId));
1758
1940
  });
1759
1941
  },
1760
1942
  };
@@ -1772,7 +1954,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1772
1954
  async execute(_callId, params) {
1773
1955
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_set_group_agent_settings", params, async () => {
1774
1956
  const p = params;
1775
- return await withClient(accountId, (c) => c.orchSetGroupAgentSettings(p.conversationId, p.agentId, {
1957
+ return await withClient(accountId, (c) => c.orchSetGroupAgentSettings(normalizeToolChatId(p.conversationId), p.agentId, {
1776
1958
  muted: p.muted,
1777
1959
  replyMode: p.replyMode,
1778
1960
  batchDelaySeconds: p.batchDelaySeconds,
@@ -10,6 +10,23 @@ export const NON_OWNER_TOOL_ALLOWLIST = Object.freeze([
10
10
  "web_search",
11
11
  ]);
12
12
  const norm = (v) => (typeof v === "string" ? v.trim().toLowerCase() : "");
13
+ // `<channel>:` then `direct:`/`group:`, each optional and stripped at most once.
14
+ const TOOL_CHAT_ID_TARGET_PREFIX = new RegExp(`^(?:${CHANNEL_ID}:)?(?:(?:direct|group):)?`, "i");
15
+ /**
16
+ * The bare ClawChat chat id a tool argument names. The host shows the model
17
+ * this chat's `chat_id` as the channel-prefixed origin target
18
+ * (`clawchat-plugin-openclaw:cnv_…` / `clawchat-plugin-openclaw:group:cnv_…`)
19
+ * and a group's label as `group:cnv_…`; models copy either form into tool
20
+ * arguments. Only that form is stripped; case is kept, and whatever remains is
21
+ * still validated by the send path (`isValidChatId`) or the server. Every tool
22
+ * that takes a ClawChat chat id, and the non-owner current-chat check, use
23
+ * this one helper so they agree on which chat an argument names.
24
+ */
25
+ export function normalizeToolChatId(value) {
26
+ if (typeof value !== "string")
27
+ return "";
28
+ return value.trim().replace(TOOL_CHAT_ID_TARGET_PREFIX, "").trim();
29
+ }
13
30
  /**
14
31
  * Host session keys (dmScope per-account-channel-peer):
15
32
  * agent:<agent>:<channel>:<account>:direct:<chat>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.10.9-3",
3
+ "version": "2026.10.9-4",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -10,6 +10,7 @@ import {
10
10
  classifyChat,
11
11
  clawChatSessionAccountId,
12
12
  NON_OWNER_TOOL_ALLOWLIST,
13
+ normalizeToolChatId,
13
14
  parseClawChatSessionKey,
14
15
  } from "./turn-boundary.ts";
15
16
  import { clawChatDbPathForStateDir, getClawChatStore } from "./storage.ts";
@@ -76,8 +77,10 @@ export function decideHostToolCall(input: HostToolInput): HostToolDecision {
76
77
  if (HOST_TOOL_SEARCH_CONTROLS.includes(input.toolName)) return undefined;
77
78
  if (ACCOUNT_SCOPED_TOOLS.includes(input.toolName)) {
78
79
  // D9: a non-owner turn may only act on its own chat, named explicitly.
79
- const target = input.params.chatId;
80
- if (typeof target !== "string" || !target.trim() || target.trim().toLowerCase() !== parsed.chatId) {
80
+ // The tool resolves the same argument with the same helper, so the chat
81
+ // checked here is the chat the call reaches.
82
+ const target = normalizeToolChatId(input.params.chatId).toLowerCase();
83
+ if (!target || target !== parsed.chatId) {
81
84
  return {
82
85
  block: true,
83
86
  blockReason: input.toolName === "clawchat_react_message"
package/src/outbound.ts CHANGED
@@ -814,23 +814,33 @@ export interface SendReactionParams {
814
814
  log?: LogSink;
815
815
  }
816
816
 
817
+ /**
818
+ * Outcome of {@link sendOpenclawClawlingReaction}. `sent: false` means the
819
+ * frame never left the plugin; callers that report to the model must surface
820
+ * it as a failure, never as a reaction.
821
+ */
822
+ export type SendReactionResult =
823
+ | { sent: true }
824
+ | { sent: false; reason: "invalid_chat_id" | "chat_rejected_by_server" };
825
+
817
826
  /**
818
827
  * Emit a Protocol-v2 `message.reaction`. Fire-and-forget: reactions are NOT
819
828
  * ack-aligned (the server mints the slot-key message_id, so the ack-by-trace_id
820
829
  * path used for message.send/reply does not apply). Matches the mobile client.
830
+ * Local refusals are returned, not thrown; a transport failure still throws.
821
831
  */
822
- export function sendOpenclawClawlingReaction(params: SendReactionParams): void {
832
+ export function sendOpenclawClawlingReaction(params: SendReactionParams): SendReactionResult {
823
833
  if (!isValidChatId(params.chatId)) {
824
834
  params.log?.info?.(
825
835
  `clawchat reaction blocked chat_id=${params.chatId} reason=invalid_chat_id`,
826
836
  );
827
- return;
837
+ return { sent: false, reason: "invalid_chat_id" };
828
838
  }
829
839
  if (isOutboundBlocked(params.client, params.chatId)) {
830
840
  params.log?.info?.(
831
841
  `clawchat reaction blocked chat_id=${params.chatId} reason=chat_rejected_by_server`,
832
842
  );
833
- return;
843
+ return { sent: false, reason: "chat_rejected_by_server" };
834
844
  }
835
845
  const payload: MessageReactionPayload = {
836
846
  target_message_id: params.targetMessageId,
@@ -849,6 +859,7 @@ export function sendOpenclawClawlingReaction(params: SendReactionParams): void {
849
859
  params.log?.info?.(
850
860
  `clawchat reaction sent chat_id=${params.chatId} target=${params.targetMessageId} emoji=${params.emoji} removed=${params.removed}`,
851
861
  );
862
+ return { sent: true };
852
863
  }
853
864
 
854
865
  type OutboundClaimStore = Pick<ClawChatStore, "claimMessageOnce" | "markMessageAcknowledged">;
@@ -875,6 +886,31 @@ function ulid(now: number = Date.now()): string {
875
886
  return out.join("");
876
887
  }
877
888
 
889
+ // Every message id shape a ClawChat message can carry. The server preserves a
890
+ // client-supplied id verbatim and never parses it, so this is deliberately a
891
+ // plausibility check, not the mint contract:
892
+ // - `msg-<ULID>`: clients (this plugin, mobile, hermes) and the server's own
893
+ // fallback mint; `msg_…` / short `msg-…` forms are kept for older ids;
894
+ // - `sys-<ULID>` / `sys-<request_id>`: server system injections;
895
+ // - a bare ULID: the server's user-message publisher;
896
+ // - `clawchat-plugin-openclaw-…`: this plugin's synthetic turn ids.
897
+ // UUIDs are not accepted: no ClawChat message id the model sees has that
898
+ // shape, while host transcript ids (e.g. from `sessions_search`) do.
899
+ const PREFIXED_MESSAGE_ID = /^(?:msg[-_]|sys-|clawchat-plugin-openclaw-)\S+$/i;
900
+ const BARE_ULID = /^[0-9A-HJKMNP-TV-Z]{26}$/i;
901
+
902
+ /**
903
+ * Whether `id` can be a real ClawChat message id. Rejects what a model makes
904
+ * up when it has no id (a label like `B`, `msg`, prose); the server only checks
905
+ * that a reaction target is non-empty, so such a value would "succeed" on no
906
+ * message. Callers trim first. Tools use this only as the fallback when the
907
+ * plugin store cannot answer whether it has seen the id in the chat.
908
+ */
909
+ export function isPlausibleMessageId(id: unknown): id is string {
910
+ if (typeof id !== "string") return false;
911
+ return PREFIXED_MESSAGE_ID.test(id) || BARE_ULID.test(id);
912
+ }
913
+
878
914
  /** Client-minted message id: `msg-` + ULID (protocol §7.6 format contract). */
879
915
  export function mintMessageId(): string {
880
916
  return `msg-${ulid()}`;
package/src/runtime.ts CHANGED
@@ -3574,7 +3574,7 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
3574
3574
  dispatcher,
3575
3575
  onSettled: () => markDispatchIdle(),
3576
3576
  run: () =>
3577
- runWithTerminalClawChatSendScope(terminalSendScopeId, () =>
3577
+ runWithTerminalClawChatSendScope({ scopeId: terminalSendScopeId, sessionKey: resolvedSessionKey }, () =>
3578
3578
  rt.reply.dispatchReplyFromConfig({
3579
3579
  ctx: ctxPayload,
3580
3580
  cfg: dispatchCfg,