@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/src/tools.ts CHANGED
@@ -22,8 +22,10 @@ import {
22
22
  getClawChatStore,
23
23
  makeOwnerProfilePersister,
24
24
  type ClawChatStore,
25
+ type SeenMessage,
25
26
  } from "./storage.ts";
26
- import { sendOpenclawClawlingMentionMessage, sendOpenclawClawlingReaction } from "./outbound.ts";
27
+ import { isPlausibleMessageId, sendOpenclawClawlingMentionMessage, sendOpenclawClawlingReaction } from "./outbound.ts";
28
+ import { isValidChatId } from "./ws-client.ts";
27
29
  import {
28
30
  getOpenclawClawlingClient,
29
31
  } from "./runtime.ts";
@@ -36,6 +38,14 @@ import {
36
38
  writeClawChatMemoryBody,
37
39
  type ClawChatMemoryTargetType,
38
40
  } from "./clawchat-memory.ts";
41
+ import {
42
+ canReadMemory,
43
+ memoryScopeNote,
44
+ memoryScopeRefusal,
45
+ resolveMemoryScope,
46
+ type MemoryScope,
47
+ } from "./memory-scope.ts";
48
+ import { clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, normalizeToolChatId } from "./turn-boundary.ts";
39
49
  import {
40
50
  pullGroupMetadata,
41
51
  pullOwnerMetadata,
@@ -215,6 +225,16 @@ function validationError(message: string) {
215
225
  return jsonResponse({ error: "validation", message });
216
226
  }
217
227
 
228
+ function reactionBlockedError(chatId: string, reason: "invalid_chat_id" | "chat_rejected_by_server") {
229
+ return jsonResponse({
230
+ error: "send_blocked",
231
+ reason,
232
+ message: reason === "invalid_chat_id"
233
+ ? `clawchat-plugin-openclaw: "${chatId}" is not a ClawChat chat id (expected cnv_…); the reaction was not sent`
234
+ : "clawchat-plugin-openclaw: this chat was rejected by the server; the reaction was not sent",
235
+ });
236
+ }
237
+
218
238
  function genericError(err: unknown) {
219
239
  return jsonResponse({
220
240
  error: "unknown",
@@ -254,14 +274,26 @@ const MEMORY_READ_MAX_LIMIT = 100_000;
254
274
  type ToolStore = Pick<
255
275
  ClawChatStore,
256
276
  "recordToolCall"
257
- >;
277
+ > &
278
+ Partial<Pick<
279
+ ClawChatStore,
280
+ | "lookupActivationIdentity"
281
+ | "listRecentGroupMessages"
282
+ | "findSeenMessageInChat"
283
+ | "findStoredChatId"
284
+ | "claimMessageOnce"
285
+ | "markMessageAcknowledged"
286
+ >>;
258
287
 
259
288
  function toolDescription(...parts: string[]): string {
260
289
  return `${parts.join("")} ${DIRECT_TOOL_GUARD}`;
261
290
  }
262
291
 
292
+ const MEMORY_SCOPE_DESCRIPTION =
293
+ "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.";
294
+
263
295
  function memoryToolDescription(...parts: string[]): string {
264
- return parts.join("");
296
+ return `${parts.join("")} ${MEMORY_SCOPE_DESCRIPTION}`;
265
297
  }
266
298
 
267
299
  function inferMimeFromPath(filePath: string): string {
@@ -313,6 +345,13 @@ function planMomentImages(
313
345
  return { ok: true, entries };
314
346
  }
315
347
 
348
+ const MESSAGE_ID_ECHO_LIMIT = 64;
349
+
350
+ function notAMessageIdMessage(field: string, value: string): string {
351
+ const shown = value.length > MESSAGE_ID_ECHO_LIMIT ? `${value.slice(0, MESSAGE_ID_ECHO_LIMIT)}…` : value;
352
+ 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`;
353
+ }
354
+
316
355
  function validateMentionMessageParams(params: unknown):
317
356
  | { ok: true; params: ClawchatMentionMessageParams }
318
357
  | { ok: false; message: string } {
@@ -320,7 +359,8 @@ function validateMentionMessageParams(params: unknown):
320
359
  return { ok: false, message: "clawchat-plugin-openclaw: params are required" };
321
360
  }
322
361
  const p = params as Partial<ClawchatMentionMessageParams>;
323
- if (typeof p.chatId !== "string" || !p.chatId.trim()) {
362
+ const chatId = normalizeToolChatId(p.chatId);
363
+ if (!chatId) {
324
364
  return { ok: false, message: "clawchat-plugin-openclaw: chatId is required" };
325
365
  }
326
366
  if (p.chatType !== undefined && p.chatType !== "group" && p.chatType !== "direct") {
@@ -356,11 +396,12 @@ function validateMentionMessageParams(params: unknown):
356
396
  ok: true,
357
397
  params: {
358
398
  ...p,
399
+ chatId,
359
400
  replyToMessageId,
360
401
  } as ClawchatMentionMessageParams,
361
402
  };
362
403
  }
363
- return { ok: true, params: p as ClawchatMentionMessageParams };
404
+ return { ok: true, params: { ...p, chatId } as ClawchatMentionMessageParams };
364
405
  }
365
406
 
366
407
  /**
@@ -392,6 +433,48 @@ export function resolveOpenclawClawlingToolAccountId(ctx: OpenClawPluginToolCont
392
433
  return null;
393
434
  }
394
435
 
436
+ /**
437
+ * True when the host says this tool call's run is a ClawChat run: every
438
+ * channel the trusted ctx names (`deliveryContext.channel`, `messageChannel`)
439
+ * is this channel, and at least one is present. Missing or foreign → false.
440
+ */
441
+ export function isClawChatToolRun(ctx: OpenClawPluginToolContext): boolean {
442
+ const channels = [ctx.deliveryContext?.channel, ctx.messageChannel]
443
+ .map((channel) => (typeof channel === "string" ? channel.trim().toLowerCase() : ""))
444
+ .filter(Boolean);
445
+ return channels.length > 0 && channels.every((channel) => channel === CHANNEL_ID);
446
+ }
447
+
448
+ // Names this plugin registered through its wrapper below. The host-tool guard
449
+ // lets an allowlisted `clawchat_*` name through only when it is in this set,
450
+ // so another plugin's tool under a lookalike name is not trusted by prefix.
451
+ const registeredClawChatToolNames = new Set<string>();
452
+
453
+ /** Snapshot of the tool names this plugin registered; callers cannot mutate the record. */
454
+ export function getRegisteredClawChatToolNames(): ReadonlySet<string> {
455
+ return new Set(registeredClawChatToolNames);
456
+ }
457
+
458
+ type RegistrableTool = Parameters<OpenClawPluginApi["registerTool"]>[0];
459
+ type PluginTool = Exclude<RegistrableTool, (...args: never[]) => unknown>;
460
+
461
+ // Host tool search (OpenClaw 2026.9.6+) hides every tool not marked
462
+ // `catalogMode: "direct-only"` behind tool_search/tool_call, whose hook ctx has
463
+ // no requester. The allowlisted tools stay directly callable so non-owner
464
+ // turns keep the full hook ctx; older hosts ignore the field.
465
+ function markDirectOnly(tool: PluginTool): PluginTool {
466
+ return Object.assign(tool, { catalogMode: "direct-only" as const });
467
+ }
468
+
469
+ function directOnlyTool(tool: RegistrableTool): RegistrableTool {
470
+ if (typeof tool !== "function") return markDirectOnly(tool);
471
+ return (ctx) => {
472
+ const resolved = tool(ctx);
473
+ if (Array.isArray(resolved)) return resolved.map(markDirectOnly);
474
+ return resolved ? markDirectOnly(resolved) : resolved;
475
+ };
476
+ }
477
+
395
478
  export function registerOpenclawClawlingTools(
396
479
  api: OpenClawPluginApi,
397
480
  options: { store?: ToolStore | null } = {},
@@ -405,8 +488,14 @@ export function registerOpenclawClawlingTools(
405
488
  // the end is derived from what was actually registered, not hand-maintained.
406
489
  const registeredToolNames: string[] = [];
407
490
  const registerTool: OpenClawPluginApi["registerTool"] = (tool, opts) => {
408
- if (opts?.name) registeredToolNames.push(opts.name);
409
- api.registerTool(tool, opts);
491
+ const directOnly = Boolean(opts?.name && NON_OWNER_TOOL_ALLOWLIST.includes(opts.name));
492
+ api.registerTool(directOnly ? directOnlyTool(tool) : tool, opts);
493
+ // Recorded only after registerTool returned without throwing (the SDK
494
+ // returns void, so host-side duplicate rejection is not observable here).
495
+ if (opts?.name) {
496
+ registeredToolNames.push(opts.name);
497
+ registeredClawChatToolNames.add(opts.name);
498
+ }
410
499
  };
411
500
 
412
501
  // Re-resolve at call time so config reloads pick up new tokens / baseUrl.
@@ -428,6 +517,94 @@ export function registerOpenclawClawlingTools(
428
517
  }
429
518
  }
430
519
 
520
+ let warnedMessageIdStore = false;
521
+ /**
522
+ * An explicit message id (react target, mention reply) must be one the
523
+ * plugin has stored for this chat; the stored chat id and message id (their
524
+ * real case) are what gets sent. When the store cannot answer, fall back to
525
+ * the message-id shape check rather than block every call, keep the ids as
526
+ * given, and warn once without ids or arguments.
527
+ */
528
+ function resolveExplicitMessageId(input: {
529
+ field: "targetMessageId" | "replyToMessageId";
530
+ value: string;
531
+ accountId: string;
532
+ chatId: string;
533
+ }): { ok: true; chatId: string; messageId: string } | { ok: false; error: ReturnType<typeof validationError> } {
534
+ let seen: SeenMessage | false | null = null;
535
+ try {
536
+ const store = resolveStore();
537
+ seen = store?.findSeenMessageInChat
538
+ ? store.findSeenMessageInChat({
539
+ platform: "openclaw",
540
+ accountId: input.accountId,
541
+ chatId: input.chatId,
542
+ messageId: input.value,
543
+ })
544
+ : null;
545
+ } catch {
546
+ seen = null;
547
+ }
548
+ if (seen) return { ok: true, chatId: seen.chatId, messageId: seen.messageId };
549
+ if (seen === null) {
550
+ if (!warnedMessageIdStore) {
551
+ warnedMessageIdStore = true;
552
+ api.logger.warn(
553
+ "clawchat-plugin-openclaw: message-id store check unavailable; falling back to the message-id shape check",
554
+ );
555
+ }
556
+ if (isPlausibleMessageId(input.value)) return { ok: true, chatId: input.chatId, messageId: input.value };
557
+ }
558
+ return { ok: false, error: validationError(notAMessageIdMessage(input.field, input.value)) };
559
+ }
560
+
561
+ /**
562
+ * The chat id in the case the ledger already stores for it, so a send, its
563
+ * terminal marker and its ledger row all name the chat as the inbound path
564
+ * does (a second case splits the chat for memory scope). The id as given
565
+ * when the ledger has no row for the chat or cannot answer.
566
+ */
567
+ function storedChatIdCase(accountId: string, chatId: string): string {
568
+ try {
569
+ const stored = resolveStore()?.findStoredChatId?.({ platform: "openclaw", accountId, chatId });
570
+ return typeof stored === "string" && stored ? stored : chatId;
571
+ } catch {
572
+ return chatId;
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Best-effort ledger row for a sent mention, so its id can be reacted to or
578
+ * quoted later. Written after the ack; a store failure never affects the send.
579
+ */
580
+ function recordMentionSent(input: { accountId: string; chatId: string; messageId: string; text: string; acceptedAt?: number }): void {
581
+ try {
582
+ const store = resolveStore();
583
+ const claimed = store?.claimMessageOnce?.({
584
+ platform: "openclaw",
585
+ accountId: input.accountId,
586
+ kind: "message",
587
+ direction: "outbound",
588
+ eventType: "message.send",
589
+ chatId: input.chatId,
590
+ messageId: input.messageId,
591
+ text: input.text,
592
+ raw: { mode: "tool-mention" },
593
+ });
594
+ if (claimed !== true) return;
595
+ store?.markMessageAcknowledged?.({
596
+ accountId: input.accountId,
597
+ kind: "message",
598
+ direction: "outbound",
599
+ messageId: input.messageId,
600
+ protocolMessageId: input.messageId,
601
+ ackedAt: input.acceptedAt ?? Date.now(),
602
+ });
603
+ } catch {
604
+ // Diagnostics only; the mention has already been sent.
605
+ }
606
+ }
607
+
431
608
  function detailsError(details: unknown): string | null {
432
609
  if (!details || typeof details !== "object") return null;
433
610
  const raw = details as { error?: unknown; message?: unknown };
@@ -636,6 +813,48 @@ export function registerOpenclawClawlingTools(
636
813
  return /Unsafe clawchat memory|Resolved clawchat memory path is outside root/i.test(message);
637
814
  }
638
815
 
816
+ // One warning per failure class per registration; never tool arguments.
817
+ const scopeWarnings = new Set<string>();
818
+ function warnScopeOnce(failure: string): void {
819
+ if (scopeWarnings.has(failure)) return;
820
+ scopeWarnings.add(failure);
821
+ try {
822
+ api.logger.warn(`clawchat-plugin-openclaw: memory scope ${failure}; no additional notes readable`);
823
+ } catch {
824
+ // Logging must not widen or break the (restrictive) scope.
825
+ }
826
+ }
827
+
828
+ /**
829
+ * Scope of the conversation behind this call, resolved fresh on every call
830
+ * (activation and group membership can change between calls). The account
831
+ * comes from the direct session key and/or the trusted tool context; a
832
+ * missing or contradictory account reads nothing.
833
+ */
834
+ async function memoryScopeFor(ctx: OpenClawPluginToolContext, root: string): Promise<MemoryScope> {
835
+ const sessionAccount = clawChatSessionAccountId(ctx.sessionKey) ?? null;
836
+ const contextAccount = resolveOpenclawClawlingToolAccountId(ctx);
837
+ if (sessionAccount && contextAccount && sessionAccount !== contextAccount) {
838
+ warnScopeOnce("account-mismatch");
839
+ return { kind: "unknown", chatId: "", members: new Set() };
840
+ }
841
+ const accountId = sessionAccount ?? contextAccount;
842
+ if (!accountId) return { kind: "unknown", chatId: "", members: new Set() };
843
+ const delivery = ctx.deliveryContext;
844
+ return resolveMemoryScope({
845
+ root,
846
+ ctx: {
847
+ sessionKey: ctx.sessionKey,
848
+ requesterSenderId: ctx.requesterSenderId,
849
+ deliveryContext: delivery?.channel === CHANNEL_ID ? { to: delivery.to } : undefined,
850
+ },
851
+ accountId,
852
+ configOwnerUserId: resolveCurrent(accountId).ownerUserId,
853
+ store: resolveStore(),
854
+ warn: warnScopeOnce,
855
+ });
856
+ }
857
+
639
858
  function memoryToolError(err: unknown) {
640
859
  if (err instanceof ClawlingApiError) return apiError(err);
641
860
  const message = err instanceof Error ? err.message : String(err);
@@ -665,13 +884,14 @@ export function registerOpenclawClawlingTools(
665
884
  }
666
885
  try {
667
886
  const p = params as ClawchatMemorySearchParams;
668
- return jsonResponse(
669
- await searchClawChatMemory(root, {
670
- query: p.query,
671
- targetTypes: p.targetTypes,
672
- maxResults: p.maxResults,
673
- }),
674
- );
887
+ const scope = await memoryScopeFor(ctx, root);
888
+ const result = await searchClawChatMemory(root, {
889
+ query: p.query,
890
+ targetTypes: p.targetTypes,
891
+ maxResults: p.maxResults,
892
+ }, { readable: (target) => canReadMemory(scope, target) });
893
+ const scopeNote = memoryScopeNote(scope);
894
+ return jsonResponse({ ...result, ...(scopeNote ? { scopeNote } : {}) });
675
895
  } catch (err) {
676
896
  return memoryToolError(err);
677
897
  }
@@ -700,8 +920,11 @@ export function registerOpenclawClawlingTools(
700
920
  const p = params as ClawchatMemoryReadParams;
701
921
  const targetError = validateTarget(root, p);
702
922
  if (targetError) return memoryToolError(new Error(targetError));
923
+ const target = targetFromParams(p);
924
+ const scope = await memoryScopeFor(ctx, root);
925
+ if (!canReadMemory(scope, target)) return jsonResponse(memoryScopeRefusal(scope, target));
703
926
  const { offset, limit } = normalizeReadBounds(p);
704
- const file = await readClawChatMemoryFile(root, targetFromParams(p));
927
+ const file = await readClawChatMemoryFile(root, target);
705
928
  if (!file.exists) {
706
929
  return jsonResponse({
707
930
  exists: false,
@@ -751,13 +974,23 @@ export function registerOpenclawClawlingTools(
751
974
  if (!root) {
752
975
  return validationError("clawchat-plugin-openclaw: OpenClaw workspaceDir is required");
753
976
  }
977
+ const p = params as ClawchatMemoryWriteParams;
978
+ let readable = false;
754
979
  try {
755
- const p = params as ClawchatMemoryWriteParams;
756
980
  const targetError = validateTarget(root, p);
757
981
  if (targetError) return memoryToolError(new Error(targetError));
758
- await writeClawChatMemoryBody(root, targetFromParams(p), p.mode, p.content);
982
+ const target = targetFromParams(p);
983
+ const scope = await memoryScopeFor(ctx, root);
984
+ readable = canReadMemory(scope, target);
985
+ // Replacing wipes what is there; append may go to any note.
986
+ if (p.mode !== "append" && !readable) return jsonResponse(memoryScopeRefusal(scope, target));
987
+ await writeClawChatMemoryBody(root, target, p.mode, p.content);
759
988
  return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId, mode: p.mode });
760
989
  } catch (err) {
990
+ // An unreadable note's failure text could reveal its state.
991
+ if (p.mode === "append" && !readable) {
992
+ return jsonResponse({ error: "memory_write_failed", message: "The memory append could not be completed." });
993
+ }
761
994
  return memoryToolError(err);
762
995
  }
763
996
  },
@@ -787,7 +1020,10 @@ export function registerOpenclawClawlingTools(
787
1020
  const p = params as ClawchatMemoryEditParams;
788
1021
  const targetError = validateTarget(root, p);
789
1022
  if (targetError) return memoryToolError(new Error(targetError));
790
- await editClawChatMemoryBody(root, targetFromParams(p), p.oldText, p.newText);
1023
+ const target = targetFromParams(p);
1024
+ const scope = await memoryScopeFor(ctx, root);
1025
+ if (!canReadMemory(scope, target)) return jsonResponse(memoryScopeRefusal(scope, target));
1026
+ await editClawChatMemoryBody(root, target, p.oldText, p.newText);
791
1027
  return jsonResponse({ ok: true, targetType: p.targetType, targetId: p.targetId });
792
1028
  } catch (err) {
793
1029
  return memoryToolError(err);
@@ -818,12 +1054,15 @@ export function registerOpenclawClawlingTools(
818
1054
  if (!root) {
819
1055
  return validationError("clawchat-plugin-openclaw: OpenClaw workspaceDir is required");
820
1056
  }
821
- const built = buildClient(accountId);
822
- if (!built.ok) return built.error;
823
1057
  try {
824
1058
  const p = params as ClawchatMetadataSyncParams;
825
1059
  const targetError = validateTarget(root, p);
826
1060
  if (targetError) return memoryToolError(new Error(targetError));
1061
+ const target = targetFromParams(p);
1062
+ const scope = await memoryScopeFor(ctx, root);
1063
+ if (!canReadMemory(scope, target)) return jsonResponse(memoryScopeRefusal(scope, target));
1064
+ const built = buildClient(accountId);
1065
+ if (!built.ok) return built.error;
827
1066
  const account = resolveCurrent(accountId);
828
1067
  if (p.direction === "push") {
829
1068
  if (!Array.isArray(p.fields) || p.fields.length === 0) {
@@ -899,14 +1138,17 @@ export function registerOpenclawClawlingTools(
899
1138
  if (!root) {
900
1139
  return validationError("clawchat-plugin-openclaw: OpenClaw workspaceDir is required");
901
1140
  }
902
- const built = buildClient(accountId);
903
- if (!built.ok) return built.error;
904
1141
  try {
905
1142
  const p = params as ClawchatMetadataUpdateParams;
906
1143
  const targetError = validateTarget(root, p);
907
1144
  if (targetError) return memoryToolError(new Error(targetError));
908
1145
  const patchError = validateMetadataUpdatePatch(p);
909
1146
  if (patchError) return validationError(patchError);
1147
+ const target = targetFromParams(p);
1148
+ const scope = await memoryScopeFor(ctx, root);
1149
+ if (!canReadMemory(scope, target)) return jsonResponse(memoryScopeRefusal(scope, target));
1150
+ const built = buildClient(accountId);
1151
+ if (!built.ok) return built.error;
910
1152
  const account = resolveCurrent(accountId);
911
1153
  if (p.targetType === "owner" && !account.agentId) {
912
1154
  return configError("clawchat-plugin-openclaw: agentId is required");
@@ -1175,7 +1417,7 @@ export function registerOpenclawClawlingTools(
1175
1417
  const built = buildClient(accountId);
1176
1418
  if (!built.ok) return built.error;
1177
1419
  try {
1178
- const data = await built.client.getConversation(p.conversationId);
1420
+ const data = await built.client.getConversation(normalizeToolChatId(p.conversationId));
1179
1421
  return jsonResponse(data);
1180
1422
  } catch (err) {
1181
1423
  if (err instanceof ClawlingApiError) {
@@ -1245,7 +1487,7 @@ export function registerOpenclawClawlingTools(
1245
1487
  const built = buildClient(accountId);
1246
1488
  if (!built.ok) return built.error;
1247
1489
  try {
1248
- const data = await built.client.leaveConversation(p.conversationId);
1490
+ const data = await built.client.leaveConversation(normalizeToolChatId(p.conversationId));
1249
1491
  return jsonResponse(data);
1250
1492
  } catch (err) {
1251
1493
  if (err instanceof ClawlingApiError) {
@@ -1280,7 +1522,7 @@ export function registerOpenclawClawlingTools(
1280
1522
  const built = buildClient(accountId);
1281
1523
  if (!built.ok) return built.error;
1282
1524
  try {
1283
- const data = await built.client.addConversationMember(p.conversationId, p.userId);
1525
+ const data = await built.client.addConversationMember(normalizeToolChatId(p.conversationId), p.userId);
1284
1526
  return jsonResponse(data);
1285
1527
  } catch (err) {
1286
1528
  if (err instanceof ClawlingApiError) {
@@ -1321,6 +1563,19 @@ export function registerOpenclawClawlingTools(
1321
1563
  }
1322
1564
  try {
1323
1565
  const p = validated.params;
1566
+ let chatId = isValidChatId(p.chatId) ? storedChatIdCase(account.accountId, p.chatId) : p.chatId;
1567
+ let replyToMessageId = p.replyToMessageId;
1568
+ if (replyToMessageId) {
1569
+ const resolved = resolveExplicitMessageId({
1570
+ field: "replyToMessageId",
1571
+ value: replyToMessageId,
1572
+ accountId: account.accountId,
1573
+ chatId,
1574
+ });
1575
+ if (!resolved.ok) return resolved.error;
1576
+ chatId = resolved.chatId;
1577
+ replyToMessageId = resolved.messageId;
1578
+ }
1324
1579
  const client = getOpenclawClawlingClient(account.accountId);
1325
1580
  if (!client) {
1326
1581
  return configError("ClawChat websocket client is not ready");
@@ -1329,26 +1584,38 @@ export function registerOpenclawClawlingTools(
1329
1584
  client,
1330
1585
  account,
1331
1586
  to: {
1332
- chatId: p.chatId.trim(),
1587
+ chatId,
1333
1588
  chatType: p.chatType ?? "group",
1334
1589
  },
1335
1590
  ...(p.text !== undefined ? { text: p.text } : {}),
1336
1591
  mentions: p.mentions,
1337
- ...(p.replyToMessageId
1592
+ ...(replyToMessageId
1338
1593
  ? {
1339
1594
  replyCtx: {
1340
- replyToMessageId: p.replyToMessageId,
1595
+ replyToMessageId,
1341
1596
  },
1342
1597
  }
1343
1598
  : {}),
1344
1599
  log: api.logger,
1345
1600
  });
1346
1601
  if (!result) return jsonResponse(result);
1347
- markTerminalClawChatSend({
1602
+ recordMentionSent({
1348
1603
  accountId: account.accountId,
1349
- chatId: p.chatId.trim(),
1604
+ chatId,
1350
1605
  messageId: result.messageId,
1606
+ text: p.text ?? "",
1607
+ ...(result.acceptedAt !== undefined ? { acceptedAt: result.acceptedAt } : {}),
1351
1608
  });
1609
+ // Same trust rule as the react defaults: only a ClawChat run's
1610
+ // turn may have its follow-up reply suppressed.
1611
+ if (isClawChatToolRun(ctx)) {
1612
+ markTerminalClawChatSend({
1613
+ accountId: account.accountId,
1614
+ chatId,
1615
+ messageId: result.messageId,
1616
+ sessionKey: ctx.sessionKey,
1617
+ });
1618
+ }
1352
1619
  return jsonResponse({
1353
1620
  sent: true,
1354
1621
  terminal: true,
@@ -1376,7 +1643,7 @@ export function registerOpenclawClawlingTools(
1376
1643
  description: toolDescription(
1377
1644
  "React to a ClawChat message with a single quick emoji (the bubble long-press reaction), instead of sending a text message. " +
1378
1645
  "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. " +
1379
- "Omit targetMessageId and chatId to react to the message that triggered the current turn; pass targetMessageId to react to an earlier message. " +
1646
+ "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. " +
1380
1647
  "Set remove:true to take back your reaction. " +
1381
1648
  "Prefer the quick set 👍 ❤️ 😂 😮 😢 🙏 🎉 👏 🔥 😍 🤔. " +
1382
1649
  "When a reaction is all you want to send, do not also send a normal text reply.",
@@ -1392,9 +1659,31 @@ export function registerOpenclawClawlingTools(
1392
1659
  if (!account.configured) {
1393
1660
  return configError("clawchat-plugin-openclaw: account is not configured");
1394
1661
  }
1395
- const scope = getCurrentTerminalSendScope();
1396
- const chatId = p.chatId?.trim() || scope?.chatId;
1397
- const targetMessageId = p.targetMessageId?.trim() || scope?.messageId;
1662
+ // Defaults come only from this call's own turn: the open turn in
1663
+ // its async context registered for the same session key as the
1664
+ // trusted tool ctx (see terminal-send.ts). Anything else fails as
1665
+ // a validation error asking for explicit ids.
1666
+ // The run must also be a ClawChat run: under a session key shared
1667
+ // with another channel, that channel's turns never register or
1668
+ // supersede a ClawChat turn, so its calls get no defaults.
1669
+ const scope = isClawChatToolRun(ctx)
1670
+ ? getCurrentTerminalSendScope({ sessionKey: ctx.sessionKey })
1671
+ : null;
1672
+ // Only a missing or blank chatId defaults. A given one that strips
1673
+ // to nothing (`clawchat-plugin-openclaw:`, `group:`) stays as given
1674
+ // so outbound refuses it as invalid_chat_id instead of it silently
1675
+ // reaching the current turn's chat.
1676
+ const rawChatId = typeof p.chatId === "string" ? p.chatId.trim() : "";
1677
+ let chatId = rawChatId ? normalizeToolChatId(rawChatId) || rawChatId : scope?.chatId;
1678
+ const explicitTargetMessageId = typeof p.targetMessageId === "string" ? p.targetMessageId.trim() : "";
1679
+ // The default target is the current turn's message, so it applies
1680
+ // only to that turn's chat: chatId omitted, or naming the scope's
1681
+ // chat (any case, host target form). The send then goes to the
1682
+ // scope's chat id. In any other chat that id names no message.
1683
+ const defaultsToScopeChat = Boolean(scope)
1684
+ && (!rawChatId || (chatId ?? "").toLowerCase() === scope!.chatId.toLowerCase());
1685
+ let targetMessageId = explicitTargetMessageId || (defaultsToScopeChat ? scope!.messageId : undefined);
1686
+ if (!explicitTargetMessageId && defaultsToScopeChat) chatId = scope!.chatId;
1398
1687
  if (!chatId) {
1399
1688
  return validationError(
1400
1689
  "clawchat-plugin-openclaw: chatId is required (no current-turn chat to default from)",
@@ -1405,6 +1694,21 @@ export function registerOpenclawClawlingTools(
1405
1694
  "clawchat-plugin-openclaw: targetMessageId is required (no current-turn message to default from)",
1406
1695
  );
1407
1696
  }
1697
+ // Only an explicit id is checked; the current-turn default is the
1698
+ // triggering message's own id. A chat id outbound would refuse is
1699
+ // reported as that refusal first.
1700
+ if (explicitTargetMessageId) {
1701
+ if (!isValidChatId(chatId)) return reactionBlockedError(chatId, "invalid_chat_id");
1702
+ const resolved = resolveExplicitMessageId({
1703
+ field: "targetMessageId",
1704
+ value: explicitTargetMessageId,
1705
+ accountId: account.accountId,
1706
+ chatId,
1707
+ });
1708
+ if (!resolved.ok) return resolved.error;
1709
+ chatId = resolved.chatId;
1710
+ targetMessageId = resolved.messageId;
1711
+ }
1408
1712
  const client = getOpenclawClawlingClient(account.accountId);
1409
1713
  if (!client) {
1410
1714
  return configError("ClawChat websocket client is not ready");
@@ -1412,7 +1716,10 @@ export function registerOpenclawClawlingTools(
1412
1716
  const emoji = p.emoji.trim();
1413
1717
  const removed = p.remove === true;
1414
1718
  try {
1415
- sendOpenclawClawlingReaction({ client, chatId, targetMessageId, emoji, removed, log: api.logger });
1719
+ const sent = sendOpenclawClawlingReaction({ client, chatId, targetMessageId, emoji, removed, log: api.logger });
1720
+ // A locally refused reaction never left the plugin: report the
1721
+ // refusal so the model does not tell the user it reacted.
1722
+ if (!sent.sent) return reactionBlockedError(chatId, sent.reason);
1416
1723
  return jsonResponse({ reacted: true, targetMessageId, emoji, removed });
1417
1724
  } catch (err) {
1418
1725
  return genericError(err);
@@ -2023,7 +2330,7 @@ export function registerOpenclawClawlingTools(
2023
2330
  async execute(_callId, params) {
2024
2331
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_get_group", params, async () => {
2025
2332
  const p = params as ClawchatOrchGetGroupParams;
2026
- return await withClient(accountId, (c) => c.orchGetGroup(p.conversationId));
2333
+ return await withClient(accountId, (c) => c.orchGetGroup(normalizeToolChatId(p.conversationId)));
2027
2334
  });
2028
2335
  },
2029
2336
  };
@@ -2056,7 +2363,7 @@ export function registerOpenclawClawlingTools(
2056
2363
  params,
2057
2364
  async () => {
2058
2365
  const p = params as ClawchatOrchSetGroupPromptParams;
2059
- return await withClient(accountId, (c) => c.orchSetGroupPrompt(p.conversationId, p.description));
2366
+ return await withClient(accountId, (c) => c.orchSetGroupPrompt(normalizeToolChatId(p.conversationId), p.description));
2060
2367
  },
2061
2368
  );
2062
2369
  },
@@ -2106,7 +2413,7 @@ export function registerOpenclawClawlingTools(
2106
2413
  params,
2107
2414
  async () => {
2108
2415
  const p = params as ClawchatOrchGroupMemberParams;
2109
- return await withClient(accountId, (c) => c.orchAddGroupMember(p.conversationId, p.agentId));
2416
+ return await withClient(accountId, (c) => c.orchAddGroupMember(normalizeToolChatId(p.conversationId), p.agentId));
2110
2417
  },
2111
2418
  );
2112
2419
  },
@@ -2133,7 +2440,7 @@ export function registerOpenclawClawlingTools(
2133
2440
  params,
2134
2441
  async () => {
2135
2442
  const p = params as ClawchatOrchGroupMemberParams;
2136
- return await withClient(accountId, (c) => c.orchRemoveGroupMember(p.conversationId, p.agentId));
2443
+ return await withClient(accountId, (c) => c.orchRemoveGroupMember(normalizeToolChatId(p.conversationId), p.agentId));
2137
2444
  },
2138
2445
  );
2139
2446
  },
@@ -2163,7 +2470,7 @@ export function registerOpenclawClawlingTools(
2163
2470
  async () => {
2164
2471
  const p = params as ClawchatOrchSetGroupAgentSettingsParams;
2165
2472
  return await withClient(accountId, (c) =>
2166
- c.orchSetGroupAgentSettings(p.conversationId, p.agentId, {
2473
+ c.orchSetGroupAgentSettings(normalizeToolChatId(p.conversationId), p.agentId, {
2167
2474
  muted: p.muted,
2168
2475
  replyMode: p.replyMode,
2169
2476
  batchDelaySeconds: p.batchDelaySeconds,