@clawling/clawchat-plugin-openclaw 2026.10.9-2 → 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,10 +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";
16
+ import { canReadMemory, memoryScopeNote, memoryScopeRefusal, resolveMemoryScope, } from "./memory-scope.js";
17
+ import { clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, normalizeToolChatId } from "./turn-boundary.js";
15
18
  import { pullGroupMetadata, pullOwnerMetadata, pullUserMetadata, pushMetadata, updateMetadata, } from "./clawchat-metadata.js";
16
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";
17
20
  const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
@@ -89,6 +92,15 @@ export function apiError(err) {
89
92
  function validationError(message) {
90
93
  return jsonResponse({ error: "validation", message });
91
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
+ }
92
104
  function genericError(err) {
93
105
  return jsonResponse({
94
106
  error: "unknown",
@@ -123,8 +135,9 @@ const MEMORY_READ_MAX_LIMIT = 100_000;
123
135
  function toolDescription(...parts) {
124
136
  return `${parts.join("")} ${DIRECT_TOOL_GUARD}`;
125
137
  }
138
+ const MEMORY_SCOPE_DESCRIPTION = "In a non-owner direct chat only that person's note is readable; in a group only that group's note and its members' notes are readable. owner.md is readable only in the owner's direct chat. Replace, edit and metadata operations require a readable target; append may add facts to any target without revealing existing content.";
126
139
  function memoryToolDescription(...parts) {
127
- return parts.join("");
140
+ return `${parts.join("")} ${MEMORY_SCOPE_DESCRIPTION}`;
128
141
  }
129
142
  function inferMimeFromPath(filePath) {
130
143
  const ext = path.extname(filePath).toLowerCase();
@@ -170,12 +183,18 @@ function planMomentImages(images) {
170
183
  }
171
184
  return { ok: true, entries };
172
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
+ }
173
191
  function validateMentionMessageParams(params) {
174
192
  if (!params || typeof params !== "object") {
175
193
  return { ok: false, message: "clawchat-plugin-openclaw: params are required" };
176
194
  }
177
195
  const p = params;
178
- if (typeof p.chatId !== "string" || !p.chatId.trim()) {
196
+ const chatId = normalizeToolChatId(p.chatId);
197
+ if (!chatId) {
179
198
  return { ok: false, message: "clawchat-plugin-openclaw: chatId is required" };
180
199
  }
181
200
  if (p.chatType !== undefined && p.chatType !== "group" && p.chatType !== "direct") {
@@ -211,11 +230,12 @@ function validateMentionMessageParams(params) {
211
230
  ok: true,
212
231
  params: {
213
232
  ...p,
233
+ chatId,
214
234
  replyToMessageId,
215
235
  },
216
236
  };
217
237
  }
218
- return { ok: true, params: p };
238
+ return { ok: true, params: { ...p, chatId } };
219
239
  }
220
240
  /**
221
241
  * Account a tool call belongs to, or `null` when the call carries no ClawChat
@@ -241,6 +261,42 @@ export function resolveOpenclawClawlingToolAccountId(ctx) {
241
261
  }
242
262
  return null;
243
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
+ }
275
+ // Names this plugin registered through its wrapper below. The host-tool guard
276
+ // lets an allowlisted `clawchat_*` name through only when it is in this set,
277
+ // so another plugin's tool under a lookalike name is not trusted by prefix.
278
+ const registeredClawChatToolNames = new Set();
279
+ /** Snapshot of the tool names this plugin registered; callers cannot mutate the record. */
280
+ export function getRegisteredClawChatToolNames() {
281
+ return new Set(registeredClawChatToolNames);
282
+ }
283
+ // Host tool search (OpenClaw 2026.9.6+) hides every tool not marked
284
+ // `catalogMode: "direct-only"` behind tool_search/tool_call, whose hook ctx has
285
+ // no requester. The allowlisted tools stay directly callable so non-owner
286
+ // turns keep the full hook ctx; older hosts ignore the field.
287
+ function markDirectOnly(tool) {
288
+ return Object.assign(tool, { catalogMode: "direct-only" });
289
+ }
290
+ function directOnlyTool(tool) {
291
+ if (typeof tool !== "function")
292
+ return markDirectOnly(tool);
293
+ return (ctx) => {
294
+ const resolved = tool(ctx);
295
+ if (Array.isArray(resolved))
296
+ return resolved.map(markDirectOnly);
297
+ return resolved ? markDirectOnly(resolved) : resolved;
298
+ };
299
+ }
244
300
  export function registerOpenclawClawlingTools(api, options = {}) {
245
301
  if (!api.config) {
246
302
  api.logger.debug?.("clawchat-plugin-openclaw: api.config missing; skipping tool registration");
@@ -250,9 +306,14 @@ export function registerOpenclawClawlingTools(api, options = {}) {
250
306
  // the end is derived from what was actually registered, not hand-maintained.
251
307
  const registeredToolNames = [];
252
308
  const registerTool = (tool, opts) => {
253
- if (opts?.name)
309
+ const directOnly = Boolean(opts?.name && NON_OWNER_TOOL_ALLOWLIST.includes(opts.name));
310
+ api.registerTool(directOnly ? directOnlyTool(tool) : tool, opts);
311
+ // Recorded only after registerTool returned without throwing (the SDK
312
+ // returns void, so host-side duplicate rejection is not observable here).
313
+ if (opts?.name) {
254
314
  registeredToolNames.push(opts.name);
255
- api.registerTool(tool, opts);
315
+ registeredClawChatToolNames.add(opts.name);
316
+ }
256
317
  };
257
318
  // Re-resolve at call time so config reloads pick up new tokens / baseUrl.
258
319
  function resolveCurrent(accountId) {
@@ -273,6 +334,90 @@ export function registerOpenclawClawlingTools(api, options = {}) {
273
334
  return null;
274
335
  }
275
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
+ }
276
421
  function detailsError(details) {
277
422
  if (!details || typeof details !== "object")
278
423
  return null;
@@ -452,6 +597,49 @@ export function registerOpenclawClawlingTools(api, options = {}) {
452
597
  function isUnsafeMemoryPathError(message) {
453
598
  return /Unsafe clawchat memory|Resolved clawchat memory path is outside root/i.test(message);
454
599
  }
600
+ // One warning per failure class per registration; never tool arguments.
601
+ const scopeWarnings = new Set();
602
+ function warnScopeOnce(failure) {
603
+ if (scopeWarnings.has(failure))
604
+ return;
605
+ scopeWarnings.add(failure);
606
+ try {
607
+ api.logger.warn(`clawchat-plugin-openclaw: memory scope ${failure}; no additional notes readable`);
608
+ }
609
+ catch {
610
+ // Logging must not widen or break the (restrictive) scope.
611
+ }
612
+ }
613
+ /**
614
+ * Scope of the conversation behind this call, resolved fresh on every call
615
+ * (activation and group membership can change between calls). The account
616
+ * comes from the direct session key and/or the trusted tool context; a
617
+ * missing or contradictory account reads nothing.
618
+ */
619
+ async function memoryScopeFor(ctx, root) {
620
+ const sessionAccount = clawChatSessionAccountId(ctx.sessionKey) ?? null;
621
+ const contextAccount = resolveOpenclawClawlingToolAccountId(ctx);
622
+ if (sessionAccount && contextAccount && sessionAccount !== contextAccount) {
623
+ warnScopeOnce("account-mismatch");
624
+ return { kind: "unknown", chatId: "", members: new Set() };
625
+ }
626
+ const accountId = sessionAccount ?? contextAccount;
627
+ if (!accountId)
628
+ return { kind: "unknown", chatId: "", members: new Set() };
629
+ const delivery = ctx.deliveryContext;
630
+ return resolveMemoryScope({
631
+ root,
632
+ ctx: {
633
+ sessionKey: ctx.sessionKey,
634
+ requesterSenderId: ctx.requesterSenderId,
635
+ deliveryContext: delivery?.channel === CHANNEL_ID ? { to: delivery.to } : undefined,
636
+ },
637
+ accountId,
638
+ configOwnerUserId: resolveCurrent(accountId).ownerUserId,
639
+ store: resolveStore(),
640
+ warn: warnScopeOnce,
641
+ });
642
+ }
455
643
  function memoryToolError(err) {
456
644
  if (err instanceof ClawlingApiError)
457
645
  return apiError(err);
@@ -478,11 +666,14 @@ export function registerOpenclawClawlingTools(api, options = {}) {
478
666
  }
479
667
  try {
480
668
  const p = params;
481
- return jsonResponse(await searchClawChatMemory(root, {
669
+ const scope = await memoryScopeFor(ctx, root);
670
+ const result = await searchClawChatMemory(root, {
482
671
  query: p.query,
483
672
  targetTypes: p.targetTypes,
484
673
  maxResults: p.maxResults,
485
- }));
674
+ }, { readable: (target) => canReadMemory(scope, target) });
675
+ const scopeNote = memoryScopeNote(scope);
676
+ return jsonResponse({ ...result, ...(scopeNote ? { scopeNote } : {}) });
486
677
  }
487
678
  catch (err) {
488
679
  return memoryToolError(err);
@@ -507,8 +698,12 @@ export function registerOpenclawClawlingTools(api, options = {}) {
507
698
  const targetError = validateTarget(root, p);
508
699
  if (targetError)
509
700
  return memoryToolError(new Error(targetError));
701
+ const target = targetFromParams(p);
702
+ const scope = await memoryScopeFor(ctx, root);
703
+ if (!canReadMemory(scope, target))
704
+ return jsonResponse(memoryScopeRefusal(scope, target));
510
705
  const { offset, limit } = normalizeReadBounds(p);
511
- const file = await readClawChatMemoryFile(root, targetFromParams(p));
706
+ const file = await readClawChatMemoryFile(root, target);
512
707
  if (!file.exists) {
513
708
  return jsonResponse({
514
709
  exists: false,
@@ -553,15 +748,26 @@ export function registerOpenclawClawlingTools(api, options = {}) {
553
748
  if (!root) {
554
749
  return validationError("clawchat-plugin-openclaw: OpenClaw workspaceDir is required");
555
750
  }
751
+ const p = params;
752
+ let readable = false;
556
753
  try {
557
- const p = params;
558
754
  const targetError = validateTarget(root, p);
559
755
  if (targetError)
560
756
  return memoryToolError(new Error(targetError));
561
- await writeClawChatMemoryBody(root, targetFromParams(p), p.mode, p.content);
757
+ const target = targetFromParams(p);
758
+ const scope = await memoryScopeFor(ctx, root);
759
+ readable = canReadMemory(scope, target);
760
+ // Replacing wipes what is there; append may go to any note.
761
+ if (p.mode !== "append" && !readable)
762
+ return jsonResponse(memoryScopeRefusal(scope, target));
763
+ await writeClawChatMemoryBody(root, target, p.mode, p.content);
562
764
  return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId, mode: p.mode });
563
765
  }
564
766
  catch (err) {
767
+ // An unreadable note's failure text could reveal its state.
768
+ if (p.mode === "append" && !readable) {
769
+ return jsonResponse({ error: "memory_write_failed", message: "The memory append could not be completed." });
770
+ }
565
771
  return memoryToolError(err);
566
772
  }
567
773
  },
@@ -586,7 +792,11 @@ export function registerOpenclawClawlingTools(api, options = {}) {
586
792
  const targetError = validateTarget(root, p);
587
793
  if (targetError)
588
794
  return memoryToolError(new Error(targetError));
589
- await editClawChatMemoryBody(root, targetFromParams(p), p.oldText, p.newText);
795
+ const target = targetFromParams(p);
796
+ const scope = await memoryScopeFor(ctx, root);
797
+ if (!canReadMemory(scope, target))
798
+ return jsonResponse(memoryScopeRefusal(scope, target));
799
+ await editClawChatMemoryBody(root, target, p.oldText, p.newText);
590
800
  return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId });
591
801
  }
592
802
  catch (err) {
@@ -612,14 +822,18 @@ export function registerOpenclawClawlingTools(api, options = {}) {
612
822
  if (!root) {
613
823
  return validationError("clawchat-plugin-openclaw: OpenClaw workspaceDir is required");
614
824
  }
615
- const built = buildClient(accountId);
616
- if (!built.ok)
617
- return built.error;
618
825
  try {
619
826
  const p = params;
620
827
  const targetError = validateTarget(root, p);
621
828
  if (targetError)
622
829
  return memoryToolError(new Error(targetError));
830
+ const target = targetFromParams(p);
831
+ const scope = await memoryScopeFor(ctx, root);
832
+ if (!canReadMemory(scope, target))
833
+ return jsonResponse(memoryScopeRefusal(scope, target));
834
+ const built = buildClient(accountId);
835
+ if (!built.ok)
836
+ return built.error;
623
837
  const account = resolveCurrent(accountId);
624
838
  if (p.direction === "push") {
625
839
  if (!Array.isArray(p.fields) || p.fields.length === 0) {
@@ -683,9 +897,6 @@ export function registerOpenclawClawlingTools(api, options = {}) {
683
897
  if (!root) {
684
898
  return validationError("clawchat-plugin-openclaw: OpenClaw workspaceDir is required");
685
899
  }
686
- const built = buildClient(accountId);
687
- if (!built.ok)
688
- return built.error;
689
900
  try {
690
901
  const p = params;
691
902
  const targetError = validateTarget(root, p);
@@ -694,6 +905,13 @@ export function registerOpenclawClawlingTools(api, options = {}) {
694
905
  const patchError = validateMetadataUpdatePatch(p);
695
906
  if (patchError)
696
907
  return validationError(patchError);
908
+ const target = targetFromParams(p);
909
+ const scope = await memoryScopeFor(ctx, root);
910
+ if (!canReadMemory(scope, target))
911
+ return jsonResponse(memoryScopeRefusal(scope, target));
912
+ const built = buildClient(accountId);
913
+ if (!built.ok)
914
+ return built.error;
697
915
  const account = resolveCurrent(accountId);
698
916
  if (p.targetType === "owner" && !account.agentId) {
699
917
  return configError("clawchat-plugin-openclaw: agentId is required");
@@ -895,7 +1113,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
895
1113
  if (!built.ok)
896
1114
  return built.error;
897
1115
  try {
898
- const data = await built.client.getConversation(p.conversationId);
1116
+ const data = await built.client.getConversation(normalizeToolChatId(p.conversationId));
899
1117
  return jsonResponse(data);
900
1118
  }
901
1119
  catch (err) {
@@ -957,7 +1175,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
957
1175
  if (!built.ok)
958
1176
  return built.error;
959
1177
  try {
960
- const data = await built.client.leaveConversation(p.conversationId);
1178
+ const data = await built.client.leaveConversation(normalizeToolChatId(p.conversationId));
961
1179
  return jsonResponse(data);
962
1180
  }
963
1181
  catch (err) {
@@ -988,7 +1206,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
988
1206
  if (!built.ok)
989
1207
  return built.error;
990
1208
  try {
991
- const data = await built.client.addConversationMember(p.conversationId, p.userId);
1209
+ const data = await built.client.addConversationMember(normalizeToolChatId(p.conversationId), p.userId);
992
1210
  return jsonResponse(data);
993
1211
  }
994
1212
  catch (err) {
@@ -1025,6 +1243,20 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1025
1243
  }
1026
1244
  try {
1027
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
+ }
1028
1260
  const client = getOpenclawClawlingClient(account.accountId);
1029
1261
  if (!client) {
1030
1262
  return configError("ClawChat websocket client is not ready");
@@ -1033,15 +1265,15 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1033
1265
  client,
1034
1266
  account,
1035
1267
  to: {
1036
- chatId: p.chatId.trim(),
1268
+ chatId,
1037
1269
  chatType: p.chatType ?? "group",
1038
1270
  },
1039
1271
  ...(p.text !== undefined ? { text: p.text } : {}),
1040
1272
  mentions: p.mentions,
1041
- ...(p.replyToMessageId
1273
+ ...(replyToMessageId
1042
1274
  ? {
1043
1275
  replyCtx: {
1044
- replyToMessageId: p.replyToMessageId,
1276
+ replyToMessageId,
1045
1277
  },
1046
1278
  }
1047
1279
  : {}),
@@ -1049,11 +1281,23 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1049
1281
  });
1050
1282
  if (!result)
1051
1283
  return jsonResponse(result);
1052
- markTerminalClawChatSend({
1284
+ recordMentionSent({
1053
1285
  accountId: account.accountId,
1054
- chatId: p.chatId.trim(),
1286
+ chatId,
1055
1287
  messageId: result.messageId,
1288
+ text: p.text ?? "",
1289
+ ...(result.acceptedAt !== undefined ? { acceptedAt: result.acceptedAt } : {}),
1056
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
+ }
1057
1301
  return jsonResponse({
1058
1302
  sent: true,
1059
1303
  terminal: true,
@@ -1076,7 +1320,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1076
1320
  label: "React to ClawChat Message",
1077
1321
  description: toolDescription("React to a ClawChat message with a single quick emoji (the bubble long-press reaction), instead of sending a text message. " +
1078
1322
  "TRIGGER - invoke when a short acknowledgement or emotional beat (agreement, thanks, laughter, celebration, sympathy) reads more naturally as an emoji on the message than as a sentence, e.g. a simple 👍 to \"done\" or ❤️ to good news. " +
1079
- "Omit targetMessageId and chatId to react to the message that triggered the current turn; pass targetMessageId to react to an earlier message. " +
1323
+ "In the owner's direct chat, omit targetMessageId and chatId to react to the message that triggered the current turn; pass targetMessageId to react to an earlier message. In group chats and other direct chats, always pass this chat's chatId. " +
1080
1324
  "Set remove:true to take back your reaction. " +
1081
1325
  "Prefer the quick set 👍 ❤️ 😂 😮 😢 🙏 🎉 👏 🔥 😍 🤔. " +
1082
1326
  "When a reaction is all you want to send, do not also send a normal text reply."),
@@ -1091,15 +1335,55 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1091
1335
  if (!account.configured) {
1092
1336
  return configError("clawchat-plugin-openclaw: account is not configured");
1093
1337
  }
1094
- const scope = getCurrentTerminalSendScope();
1095
- const chatId = p.chatId?.trim() || scope?.chatId;
1096
- 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;
1097
1364
  if (!chatId) {
1098
1365
  return validationError("clawchat-plugin-openclaw: chatId is required (no current-turn chat to default from)");
1099
1366
  }
1100
1367
  if (!targetMessageId) {
1101
1368
  return validationError("clawchat-plugin-openclaw: targetMessageId is required (no current-turn message to default from)");
1102
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
+ }
1103
1387
  const client = getOpenclawClawlingClient(account.accountId);
1104
1388
  if (!client) {
1105
1389
  return configError("ClawChat websocket client is not ready");
@@ -1107,7 +1391,11 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1107
1391
  const emoji = p.emoji.trim();
1108
1392
  const removed = p.remove === true;
1109
1393
  try {
1110
- 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);
1111
1399
  return jsonResponse({ reacted: true, targetMessageId, emoji, removed });
1112
1400
  }
1113
1401
  catch (err) {
@@ -1576,7 +1864,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1576
1864
  async execute(_callId, params) {
1577
1865
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_get_group", params, async () => {
1578
1866
  const p = params;
1579
- return await withClient(accountId, (c) => c.orchGetGroup(p.conversationId));
1867
+ return await withClient(accountId, (c) => c.orchGetGroup(normalizeToolChatId(p.conversationId)));
1580
1868
  });
1581
1869
  },
1582
1870
  };
@@ -1599,7 +1887,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1599
1887
  async execute(_callId, params) {
1600
1888
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_set_group_prompt", params, async () => {
1601
1889
  const p = params;
1602
- return await withClient(accountId, (c) => c.orchSetGroupPrompt(p.conversationId, p.description));
1890
+ return await withClient(accountId, (c) => c.orchSetGroupPrompt(normalizeToolChatId(p.conversationId), p.description));
1603
1891
  });
1604
1892
  },
1605
1893
  };
@@ -1632,7 +1920,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1632
1920
  async execute(_callId, params) {
1633
1921
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_add_group_member", params, async () => {
1634
1922
  const p = params;
1635
- return await withClient(accountId, (c) => c.orchAddGroupMember(p.conversationId, p.agentId));
1923
+ return await withClient(accountId, (c) => c.orchAddGroupMember(normalizeToolChatId(p.conversationId), p.agentId));
1636
1924
  });
1637
1925
  },
1638
1926
  };
@@ -1648,7 +1936,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1648
1936
  async execute(_callId, params) {
1649
1937
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_remove_group_member", params, async () => {
1650
1938
  const p = params;
1651
- return await withClient(accountId, (c) => c.orchRemoveGroupMember(p.conversationId, p.agentId));
1939
+ return await withClient(accountId, (c) => c.orchRemoveGroupMember(normalizeToolChatId(p.conversationId), p.agentId));
1652
1940
  });
1653
1941
  },
1654
1942
  };
@@ -1666,7 +1954,7 @@ export function registerOpenclawClawlingTools(api, options = {}) {
1666
1954
  async execute(_callId, params) {
1667
1955
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_set_group_agent_settings", params, async () => {
1668
1956
  const p = params;
1669
- return await withClient(accountId, (c) => c.orchSetGroupAgentSettings(p.conversationId, p.agentId, {
1957
+ return await withClient(accountId, (c) => c.orchSetGroupAgentSettings(normalizeToolChatId(p.conversationId), p.agentId, {
1670
1958
  muted: p.muted,
1671
1959
  replyMode: p.replyMode,
1672
1960
  batchDelaySeconds: p.batchDelaySeconds,