@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/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";
@@ -43,7 +45,7 @@ import {
43
45
  resolveMemoryScope,
44
46
  type MemoryScope,
45
47
  } from "./memory-scope.ts";
46
- import { clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST } from "./turn-boundary.ts";
48
+ import { clawChatSessionAccountId, NON_OWNER_TOOL_ALLOWLIST, normalizeToolChatId } from "./turn-boundary.ts";
47
49
  import {
48
50
  pullGroupMetadata,
49
51
  pullOwnerMetadata,
@@ -223,6 +225,16 @@ function validationError(message: string) {
223
225
  return jsonResponse({ error: "validation", message });
224
226
  }
225
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
+
226
238
  function genericError(err: unknown) {
227
239
  return jsonResponse({
228
240
  error: "unknown",
@@ -263,7 +275,15 @@ type ToolStore = Pick<
263
275
  ClawChatStore,
264
276
  "recordToolCall"
265
277
  > &
266
- Partial<Pick<ClawChatStore, "lookupActivationIdentity" | "listRecentGroupMessages">>;
278
+ Partial<Pick<
279
+ ClawChatStore,
280
+ | "lookupActivationIdentity"
281
+ | "listRecentGroupMessages"
282
+ | "findSeenMessageInChat"
283
+ | "findStoredChatId"
284
+ | "claimMessageOnce"
285
+ | "markMessageAcknowledged"
286
+ >>;
267
287
 
268
288
  function toolDescription(...parts: string[]): string {
269
289
  return `${parts.join("")} ${DIRECT_TOOL_GUARD}`;
@@ -325,6 +345,13 @@ function planMomentImages(
325
345
  return { ok: true, entries };
326
346
  }
327
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
+
328
355
  function validateMentionMessageParams(params: unknown):
329
356
  | { ok: true; params: ClawchatMentionMessageParams }
330
357
  | { ok: false; message: string } {
@@ -332,7 +359,8 @@ function validateMentionMessageParams(params: unknown):
332
359
  return { ok: false, message: "clawchat-plugin-openclaw: params are required" };
333
360
  }
334
361
  const p = params as Partial<ClawchatMentionMessageParams>;
335
- if (typeof p.chatId !== "string" || !p.chatId.trim()) {
362
+ const chatId = normalizeToolChatId(p.chatId);
363
+ if (!chatId) {
336
364
  return { ok: false, message: "clawchat-plugin-openclaw: chatId is required" };
337
365
  }
338
366
  if (p.chatType !== undefined && p.chatType !== "group" && p.chatType !== "direct") {
@@ -368,11 +396,12 @@ function validateMentionMessageParams(params: unknown):
368
396
  ok: true,
369
397
  params: {
370
398
  ...p,
399
+ chatId,
371
400
  replyToMessageId,
372
401
  } as ClawchatMentionMessageParams,
373
402
  };
374
403
  }
375
- return { ok: true, params: p as ClawchatMentionMessageParams };
404
+ return { ok: true, params: { ...p, chatId } as ClawchatMentionMessageParams };
376
405
  }
377
406
 
378
407
  /**
@@ -404,6 +433,18 @@ export function resolveOpenclawClawlingToolAccountId(ctx: OpenClawPluginToolCont
404
433
  return null;
405
434
  }
406
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
+
407
448
  // Names this plugin registered through its wrapper below. The host-tool guard
408
449
  // lets an allowlisted `clawchat_*` name through only when it is in this set,
409
450
  // so another plugin's tool under a lookalike name is not trusted by prefix.
@@ -476,6 +517,94 @@ export function registerOpenclawClawlingTools(
476
517
  }
477
518
  }
478
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
+
479
608
  function detailsError(details: unknown): string | null {
480
609
  if (!details || typeof details !== "object") return null;
481
610
  const raw = details as { error?: unknown; message?: unknown };
@@ -1288,7 +1417,7 @@ export function registerOpenclawClawlingTools(
1288
1417
  const built = buildClient(accountId);
1289
1418
  if (!built.ok) return built.error;
1290
1419
  try {
1291
- const data = await built.client.getConversation(p.conversationId);
1420
+ const data = await built.client.getConversation(normalizeToolChatId(p.conversationId));
1292
1421
  return jsonResponse(data);
1293
1422
  } catch (err) {
1294
1423
  if (err instanceof ClawlingApiError) {
@@ -1358,7 +1487,7 @@ export function registerOpenclawClawlingTools(
1358
1487
  const built = buildClient(accountId);
1359
1488
  if (!built.ok) return built.error;
1360
1489
  try {
1361
- const data = await built.client.leaveConversation(p.conversationId);
1490
+ const data = await built.client.leaveConversation(normalizeToolChatId(p.conversationId));
1362
1491
  return jsonResponse(data);
1363
1492
  } catch (err) {
1364
1493
  if (err instanceof ClawlingApiError) {
@@ -1393,7 +1522,7 @@ export function registerOpenclawClawlingTools(
1393
1522
  const built = buildClient(accountId);
1394
1523
  if (!built.ok) return built.error;
1395
1524
  try {
1396
- const data = await built.client.addConversationMember(p.conversationId, p.userId);
1525
+ const data = await built.client.addConversationMember(normalizeToolChatId(p.conversationId), p.userId);
1397
1526
  return jsonResponse(data);
1398
1527
  } catch (err) {
1399
1528
  if (err instanceof ClawlingApiError) {
@@ -1434,6 +1563,19 @@ export function registerOpenclawClawlingTools(
1434
1563
  }
1435
1564
  try {
1436
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
+ }
1437
1579
  const client = getOpenclawClawlingClient(account.accountId);
1438
1580
  if (!client) {
1439
1581
  return configError("ClawChat websocket client is not ready");
@@ -1442,26 +1584,38 @@ export function registerOpenclawClawlingTools(
1442
1584
  client,
1443
1585
  account,
1444
1586
  to: {
1445
- chatId: p.chatId.trim(),
1587
+ chatId,
1446
1588
  chatType: p.chatType ?? "group",
1447
1589
  },
1448
1590
  ...(p.text !== undefined ? { text: p.text } : {}),
1449
1591
  mentions: p.mentions,
1450
- ...(p.replyToMessageId
1592
+ ...(replyToMessageId
1451
1593
  ? {
1452
1594
  replyCtx: {
1453
- replyToMessageId: p.replyToMessageId,
1595
+ replyToMessageId,
1454
1596
  },
1455
1597
  }
1456
1598
  : {}),
1457
1599
  log: api.logger,
1458
1600
  });
1459
1601
  if (!result) return jsonResponse(result);
1460
- markTerminalClawChatSend({
1602
+ recordMentionSent({
1461
1603
  accountId: account.accountId,
1462
- chatId: p.chatId.trim(),
1604
+ chatId,
1463
1605
  messageId: result.messageId,
1606
+ text: p.text ?? "",
1607
+ ...(result.acceptedAt !== undefined ? { acceptedAt: result.acceptedAt } : {}),
1464
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
+ }
1465
1619
  return jsonResponse({
1466
1620
  sent: true,
1467
1621
  terminal: true,
@@ -1505,9 +1659,31 @@ export function registerOpenclawClawlingTools(
1505
1659
  if (!account.configured) {
1506
1660
  return configError("clawchat-plugin-openclaw: account is not configured");
1507
1661
  }
1508
- const scope = getCurrentTerminalSendScope();
1509
- const chatId = p.chatId?.trim() || scope?.chatId;
1510
- 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;
1511
1687
  if (!chatId) {
1512
1688
  return validationError(
1513
1689
  "clawchat-plugin-openclaw: chatId is required (no current-turn chat to default from)",
@@ -1518,6 +1694,21 @@ export function registerOpenclawClawlingTools(
1518
1694
  "clawchat-plugin-openclaw: targetMessageId is required (no current-turn message to default from)",
1519
1695
  );
1520
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
+ }
1521
1712
  const client = getOpenclawClawlingClient(account.accountId);
1522
1713
  if (!client) {
1523
1714
  return configError("ClawChat websocket client is not ready");
@@ -1525,7 +1716,10 @@ export function registerOpenclawClawlingTools(
1525
1716
  const emoji = p.emoji.trim();
1526
1717
  const removed = p.remove === true;
1527
1718
  try {
1528
- 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);
1529
1723
  return jsonResponse({ reacted: true, targetMessageId, emoji, removed });
1530
1724
  } catch (err) {
1531
1725
  return genericError(err);
@@ -2136,7 +2330,7 @@ export function registerOpenclawClawlingTools(
2136
2330
  async execute(_callId, params) {
2137
2331
  return await recordClawchatToolCall(accountId, "clawchat_orchestrate_get_group", params, async () => {
2138
2332
  const p = params as ClawchatOrchGetGroupParams;
2139
- return await withClient(accountId, (c) => c.orchGetGroup(p.conversationId));
2333
+ return await withClient(accountId, (c) => c.orchGetGroup(normalizeToolChatId(p.conversationId)));
2140
2334
  });
2141
2335
  },
2142
2336
  };
@@ -2169,7 +2363,7 @@ export function registerOpenclawClawlingTools(
2169
2363
  params,
2170
2364
  async () => {
2171
2365
  const p = params as ClawchatOrchSetGroupPromptParams;
2172
- return await withClient(accountId, (c) => c.orchSetGroupPrompt(p.conversationId, p.description));
2366
+ return await withClient(accountId, (c) => c.orchSetGroupPrompt(normalizeToolChatId(p.conversationId), p.description));
2173
2367
  },
2174
2368
  );
2175
2369
  },
@@ -2219,7 +2413,7 @@ export function registerOpenclawClawlingTools(
2219
2413
  params,
2220
2414
  async () => {
2221
2415
  const p = params as ClawchatOrchGroupMemberParams;
2222
- return await withClient(accountId, (c) => c.orchAddGroupMember(p.conversationId, p.agentId));
2416
+ return await withClient(accountId, (c) => c.orchAddGroupMember(normalizeToolChatId(p.conversationId), p.agentId));
2223
2417
  },
2224
2418
  );
2225
2419
  },
@@ -2246,7 +2440,7 @@ export function registerOpenclawClawlingTools(
2246
2440
  params,
2247
2441
  async () => {
2248
2442
  const p = params as ClawchatOrchGroupMemberParams;
2249
- return await withClient(accountId, (c) => c.orchRemoveGroupMember(p.conversationId, p.agentId));
2443
+ return await withClient(accountId, (c) => c.orchRemoveGroupMember(normalizeToolChatId(p.conversationId), p.agentId));
2250
2444
  },
2251
2445
  );
2252
2446
  },
@@ -2276,7 +2470,7 @@ export function registerOpenclawClawlingTools(
2276
2470
  async () => {
2277
2471
  const p = params as ClawchatOrchSetGroupAgentSettingsParams;
2278
2472
  return await withClient(accountId, (c) =>
2279
- c.orchSetGroupAgentSettings(p.conversationId, p.agentId, {
2473
+ c.orchSetGroupAgentSettings(normalizeToolChatId(p.conversationId), p.agentId, {
2280
2474
  muted: p.muted,
2281
2475
  replyMode: p.replyMode,
2282
2476
  batchDelaySeconds: p.batchDelaySeconds,
@@ -16,6 +16,24 @@ export const NON_OWNER_TOOL_ALLOWLIST: readonly string[] = Object.freeze([
16
16
 
17
17
  const norm = (v: string | undefined | null) => (typeof v === "string" ? v.trim().toLowerCase() : "");
18
18
 
19
+ // `<channel>:` then `direct:`/`group:`, each optional and stripped at most once.
20
+ const TOOL_CHAT_ID_TARGET_PREFIX = new RegExp(`^(?:${CHANNEL_ID}:)?(?:(?:direct|group):)?`, "i");
21
+
22
+ /**
23
+ * The bare ClawChat chat id a tool argument names. The host shows the model
24
+ * this chat's `chat_id` as the channel-prefixed origin target
25
+ * (`clawchat-plugin-openclaw:cnv_…` / `clawchat-plugin-openclaw:group:cnv_…`)
26
+ * and a group's label as `group:cnv_…`; models copy either form into tool
27
+ * arguments. Only that form is stripped; case is kept, and whatever remains is
28
+ * still validated by the send path (`isValidChatId`) or the server. Every tool
29
+ * that takes a ClawChat chat id, and the non-owner current-chat check, use
30
+ * this one helper so they agree on which chat an argument names.
31
+ */
32
+ export function normalizeToolChatId(value: unknown): string {
33
+ if (typeof value !== "string") return "";
34
+ return value.trim().replace(TOOL_CHAT_ID_TARGET_PREFIX, "").trim();
35
+ }
36
+
19
37
  /**
20
38
  * Host session keys (dmScope per-account-channel-peer):
21
39
  * agent:<agent>:<channel>:<account>:direct:<chat>