@clawling/clawchat-plugin-openclaw 2026.7.23-1 → 2026.7.26-1

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.
@@ -79,7 +79,7 @@ export const openclawClawlingSetupPlugin = {
79
79
  capabilities: {
80
80
  chatTypes: ["direct", "group"],
81
81
  media: true,
82
- reactions: false,
82
+ reactions: true,
83
83
  threads: false,
84
84
  polls: false,
85
85
  blockStreaming: true,
@@ -14,6 +14,13 @@ export const AGENTS_CONNECT_PLATFORM = "openclaw";
14
14
  * always a bot; humans don't log in through this flow.
15
15
  */
16
16
  export const AGENTS_CONNECT_TYPE = "clawbot";
17
+ /**
18
+ * `/v1/agents/connect` envelope code for "the supplied user_id matches no
19
+ * agent". Servers that predate the server-side stale-user_id fallback return
20
+ * this instead of degrading to a fresh pairing, so the client sheds the id and
21
+ * retries once.
22
+ */
23
+ export const AGENT_NOT_FOUND_CODE = 16001;
17
24
  /**
18
25
  * Prompt the operator for an invite code.
19
26
  *
@@ -140,8 +147,8 @@ export async function runOpenclawClawlingLogin(params) {
140
147
  });
141
148
  runtime.log("Verifying invite code …");
142
149
  let result;
150
+ const existingUserId = account.userId.trim();
143
151
  try {
144
- const existingUserId = account.userId.trim();
145
152
  result = await apiClient.agentsConnect({
146
153
  code: inviteCode,
147
154
  platform: AGENTS_CONNECT_PLATFORM,
@@ -151,9 +158,37 @@ export async function runOpenclawClawlingLogin(params) {
151
158
  }
152
159
  catch (err) {
153
160
  if (err instanceof ClawlingApiError) {
154
- throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
161
+ // The stored userId is a re-pair hint, not a credential — the invite
162
+ // code alone decides ownership. AGENT_NOT_FOUND means it names an agent
163
+ // this server has never had (owner account deleted, or the config was
164
+ // repointed at another deployment), so replaying it would fail
165
+ // identically for every future invite code. A 16001 leaves the code
166
+ // `pending` server-side, so shedding the id and retrying once cannot
167
+ // double-spend it. Nothing else is retried: an owner mismatch is a real
168
+ // rejection, and a request that sent no id has no stale state to shed.
169
+ if (existingUserId && err.meta?.code === AGENT_NOT_FOUND_CODE) {
170
+ runtime.log(`Stored userId ${existingUserId} no longer exists on this server — retrying as a fresh pairing …`);
171
+ try {
172
+ result = await apiClient.agentsConnect({
173
+ code: inviteCode,
174
+ platform: AGENTS_CONNECT_PLATFORM,
175
+ type: AGENTS_CONNECT_TYPE,
176
+ });
177
+ }
178
+ catch (retryErr) {
179
+ if (retryErr instanceof ClawlingApiError) {
180
+ throw new Error(`agents/connect failed (${retryErr.kind}): ${retryErr.message}`);
181
+ }
182
+ throw retryErr;
183
+ }
184
+ }
185
+ else {
186
+ throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
187
+ }
188
+ }
189
+ else {
190
+ throw err;
155
191
  }
156
- throw err;
157
192
  }
158
193
  const accessToken = requireConnectString(result?.access_token, "access_token");
159
194
  const agentUserId = requireConnectString(result?.agent?.user_id, "agent.user_id");
@@ -1,5 +1,5 @@
1
1
  import { randomInt } from "node:crypto";
2
- import { MessageSendError } from "./protocol-types.js";
2
+ import { EVENT, MessageSendError } from "./protocol-types.js";
3
3
  import { createAttachedChannelResultAdapter, } from "openclaw/plugin-sdk/channel-send-result";
4
4
  import { chunkMarkdownText } from "openclaw/plugin-sdk/reply-runtime";
5
5
  import { createOpenclawClawlingApiClient } from "./api-client.js";
@@ -485,6 +485,28 @@ export async function sendOpenclawClawlingMedia(params) {
485
485
  ...(params.log ? { log: params.log } : {}),
486
486
  });
487
487
  }
488
+ /**
489
+ * Emit a Protocol-v2 `message.reaction`. Fire-and-forget: reactions are NOT
490
+ * ack-aligned (the server mints the slot-key message_id, so the ack-by-trace_id
491
+ * path used for message.send/reply does not apply). Matches the mobile client.
492
+ */
493
+ export function sendOpenclawClawlingReaction(params) {
494
+ const payload = {
495
+ target_message_id: params.targetMessageId,
496
+ emoji: params.emoji,
497
+ removed: params.removed,
498
+ };
499
+ const env = {
500
+ version: "2",
501
+ event: EVENT.MESSAGE_REACTION,
502
+ trace_id: params.client.nextTraceId(),
503
+ emitted_at: Date.now(),
504
+ chat_id: params.chatId,
505
+ payload,
506
+ };
507
+ params.client.sendWire(JSON.stringify(env));
508
+ params.log?.info?.(`clawchat reaction sent chat_id=${params.chatId} target=${params.targetMessageId} emoji=${params.emoji} removed=${params.removed}`);
509
+ }
488
510
  // Crockford base32 alphabet (ULID spec).
489
511
  const ULID_ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
490
512
  /**
@@ -7,6 +7,7 @@ export const EVENT = {
7
7
  MESSAGE_ACK: "message.ack",
8
8
  MESSAGE_ERROR: "message.error",
9
9
  MESSAGE_REPLY: "message.reply",
10
+ MESSAGE_REACTION: "message.reaction",
10
11
  MESSAGE_CREATED: "message.created",
11
12
  MESSAGE_ADD: "message.add",
12
13
  MESSAGE_DONE: "message.done",
@@ -1992,6 +1992,15 @@ export async function startOpenclawClawlingGateway(params) {
1992
1992
  ? env.payload
1993
1993
  : undefined;
1994
1994
  const type = typeof payload?.type === "string" ? payload.type : "";
1995
+ if (type === "conversation.dissolved") {
1996
+ // The conversation is gone. Evict it so no further uplink is emitted
1997
+ // for it. Falls through to the awareness branch so the owner is told.
1998
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
1999
+ const evicted = client.markChatDead(entityId);
2000
+ log?.info?.(evicted
2001
+ ? `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received; evicting ${entityId}`
2002
+ : `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received with missing/invalid entity_id; no eviction performed`);
2003
+ }
1995
2004
  if (type === "agent.config.changed") {
1996
2005
  log?.info?.(`[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`);
1997
2006
  pullGroupSettings();
@@ -34,3 +34,20 @@ export function consumeTerminalClawChatSend(params) {
34
34
  export function clearTerminalClawChatSendsForTest() {
35
35
  terminalSends.clear();
36
36
  }
37
+ /**
38
+ * Read the current turn's ambient send scope (accountId\0chatId\0messageId).
39
+ * Returns null when not inside a dispatch scope, or when the scope id is not
40
+ * the tripartite form (e.g. a test-only opaque scope id).
41
+ */
42
+ export function getCurrentTerminalSendScope() {
43
+ const store = terminalSendScope.getStore();
44
+ if (!store)
45
+ return null;
46
+ const parts = store.split("\0");
47
+ if (parts.length !== 3)
48
+ return null;
49
+ const [accountId, chatId, messageId] = parts;
50
+ if (!accountId || !chatId || !messageId)
51
+ return null;
52
+ return { accountId, chatId, messageId };
53
+ }
@@ -156,6 +156,23 @@ export const ClawchatMentionMessageSchema = Type.Object({
156
156
  description: "Optional ClawChat message id to reply to.",
157
157
  })),
158
158
  });
159
+ export const ClawchatReactMessageSchema = Type.Object({
160
+ emoji: Type.String({
161
+ minLength: 1,
162
+ description: "A single emoji to react with. Prefer the ClawChat quick set 👍 ❤️ 😂 😮 😢 🙏 🎉 👏 🔥 😍 🤔 so it renders in the client's quick-reaction row.",
163
+ }),
164
+ targetMessageId: Type.Optional(Type.String({
165
+ minLength: 1,
166
+ description: "ClawChat message id to react to. Omit to react to the message that triggered the current turn.",
167
+ })),
168
+ chatId: Type.Optional(Type.String({
169
+ minLength: 1,
170
+ description: "ClawChat conversation id. Omit to use the current turn's chat.",
171
+ })),
172
+ remove: Type.Optional(Type.Boolean({
173
+ description: "Set true to remove your existing reaction on the target message. Omit or false to add/overwrite.",
174
+ })),
175
+ });
159
176
  export const ClawchatListMomentsSchema = Type.Object({
160
177
  before: Type.Optional(Type.Integer({
161
178
  minimum: 1,
package/dist/src/tools.js CHANGED
@@ -7,12 +7,12 @@ import { ClawlingApiError, } from "./api-types.js";
7
7
  import { mapGateOutcome } from "./gate-outcome.js";
8
8
  import { resolveOpenclawClawlingAccount } from "./config.js";
9
9
  import { clawChatDbPathForStateDir, getClawChatStore, makeOwnerProfilePersister, } from "./storage.js";
10
- import { sendOpenclawClawlingMentionMessage } from "./outbound.js";
10
+ import { sendOpenclawClawlingMentionMessage, sendOpenclawClawlingReaction } from "./outbound.js";
11
11
  import { getOpenclawClawlingClient, } from "./runtime.js";
12
- import { markTerminalClawChatSend } from "./terminal-send.js";
12
+ import { markTerminalClawChatSend, getCurrentTerminalSendScope } from "./terminal-send.js";
13
13
  import { editClawChatMemoryBody, readClawChatMemoryFile, resolveClawChatMemoryPath, searchClawChatMemory, writeClawChatMemoryBody, } from "./clawchat-memory.js";
14
14
  import { pullGroupMetadata, pullOwnerMetadata, pullUserMetadata, pushMetadata, updateMetadata, } from "./clawchat-metadata.js";
15
- import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, ClawchatAcceptFriendRequestSchema, ClawchatMemoryEditSchema, ClawchatMemoryReadSchema, ClawchatMemorySearchSchema, ClawchatMemoryWriteSchema, ClawchatMetadataSyncSchema, ClawchatMetadataUpdateSchema, ClawchatCreateMomentCommentSchema, ClawchatCreateMomentSchema, ClawchatDeleteMomentCommentSchema, ClawchatDeleteMomentSchema, ClawchatListAccountFriendsSchema, ClawchatListFriendRequestsSchema, ClawchatListMomentsSchema, ClawchatMentionMessageSchema, ClawchatReplyMomentCommentSchema, ClawchatRejectFriendRequestSchema, ClawchatRemoveFriendSchema, ClawchatSearchUsersSchema, ClawchatSendFriendRequestSchema, ClawchatToggleMomentReactionSchema, ClawchatUpdateAccountProfileSchema, ClawchatUploadAvatarImageSchema, ClawchatRegisterAppSchema, ClawchatListAppsSchema, ClawchatUnregisterAppSchema, ClawchatLivewareLoginSchema, } from "./tools-schema.js";
15
+ import { ClawchatGetAccountProfileSchema, ClawchatGetConversationSchema, ClawchatGetUserProfileSchema, ClawchatLeaveGroupSchema, 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, } from "./tools-schema.js";
16
16
  const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
17
17
  // Owner-approval gate business codes (must match the ClawChat backend's owner-approval codes).
18
18
  const CODE_PENDING_APPROVAL = 21001;
@@ -873,6 +873,51 @@ export function registerOpenclawClawlingTools(api, options = {}) {
873
873
  });
874
874
  },
875
875
  }, { name: "clawchat_mention_message" });
876
+ api.registerTool({
877
+ name: "clawchat_react_message",
878
+ label: "React to ClawChat Message",
879
+ description: toolDescription("React to a ClawChat message with a single quick emoji (the bubble long-press reaction), instead of sending a text message. " +
880
+ "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. " +
881
+ "Omit targetMessageId and chatId to react to the message that triggered the current turn; pass targetMessageId to react to an earlier message. " +
882
+ "Set remove:true to take back your reaction. " +
883
+ "Prefer the quick set 👍 ❤️ 😂 😮 😢 🙏 🎉 👏 🔥 😍 🤔. " +
884
+ "When a reaction is all you want to send, do not also send a normal text reply."),
885
+ parameters: ClawchatReactMessageSchema,
886
+ async execute(_callId, params) {
887
+ return await recordClawchatToolCall("clawchat_react_message", params, async () => {
888
+ const p = params;
889
+ if (!p.emoji?.trim()) {
890
+ return validationError("clawchat-plugin-openclaw: emoji is required");
891
+ }
892
+ const account = resolveCurrent();
893
+ if (!account.configured) {
894
+ return configError("clawchat-plugin-openclaw: account is not configured");
895
+ }
896
+ const scope = getCurrentTerminalSendScope();
897
+ const chatId = p.chatId?.trim() || scope?.chatId;
898
+ const targetMessageId = p.targetMessageId?.trim() || scope?.messageId;
899
+ if (!chatId) {
900
+ return validationError("clawchat-plugin-openclaw: chatId is required (no current-turn chat to default from)");
901
+ }
902
+ if (!targetMessageId) {
903
+ return validationError("clawchat-plugin-openclaw: targetMessageId is required (no current-turn message to default from)");
904
+ }
905
+ const client = getOpenclawClawlingClient(account.accountId);
906
+ if (!client) {
907
+ return configError("ClawChat websocket client is not ready");
908
+ }
909
+ const emoji = p.emoji.trim();
910
+ const removed = p.remove === true;
911
+ try {
912
+ sendOpenclawClawlingReaction({ client, chatId, targetMessageId, emoji, removed, log: api.logger });
913
+ return jsonResponse({ reacted: true, targetMessageId, emoji, removed });
914
+ }
915
+ catch (err) {
916
+ return genericError(err);
917
+ }
918
+ });
919
+ },
920
+ }, { name: "clawchat_react_message" });
876
921
  api.registerTool({
877
922
  name: "clawchat_list_moments",
878
923
  label: "List ClawChat Moments",
@@ -101,6 +101,8 @@ export function buildConnectCapabilities() {
101
101
  permission_events: true,
102
102
  };
103
103
  }
104
+ /** Bounded FIFO of conversations known to be dissolved. */
105
+ export const DEAD_CHATS_MAX = 512;
104
106
  export class ClawChatClient extends EventEmitter {
105
107
  opts;
106
108
  currentState = "idle";
@@ -116,6 +118,7 @@ export class ClawChatClient extends EventEmitter {
116
118
  pending = new Map();
117
119
  handledMessageErrorTraces = new Set();
118
120
  sendQueue = [];
121
+ deadChats = new Set();
119
122
  constructor(opts) {
120
123
  super();
121
124
  this.opts = opts;
@@ -156,7 +159,44 @@ export class ClawChatClient extends EventEmitter {
156
159
  }
157
160
  this.opts.transport.close(1000, "client close");
158
161
  }
162
+ /**
163
+ * Record a conversation as dissolved so no further uplink is sent for it.
164
+ * Only `cnv_`-prefixed ids are accepted: a bad entity_id must never mute a
165
+ * live conversation. Returns whether the id was accepted (and thus actually
166
+ * evicted) so callers can log/act on rejection distinctly from acceptance.
167
+ *
168
+ * Eviction is FIFO by first-mark order, not LRU: `Set` iteration order is
169
+ * insertion order, and the `has()` early-return above means re-marking an
170
+ * already-dead id does NOT refresh its position. A chat marked dead long ago
171
+ * is evicted from the set before one marked dead a moment ago, even if the
172
+ * former has been re-signaled dissolved more recently.
173
+ */
174
+ markChatDead(chatId) {
175
+ if (typeof chatId !== "string" || !chatId.startsWith("cnv_"))
176
+ return false;
177
+ if (this.deadChats.has(chatId))
178
+ return true;
179
+ this.deadChats.add(chatId);
180
+ while (this.deadChats.size > DEAD_CHATS_MAX) {
181
+ const oldest = this.deadChats.values().next().value;
182
+ if (oldest === undefined)
183
+ break;
184
+ this.deadChats.delete(oldest);
185
+ }
186
+ return true;
187
+ }
188
+ isChatDead(chatId) {
189
+ return this.deadChats.has(chatId);
190
+ }
191
+ /**
192
+ * The ONLY dead-chat guard in this client. `sendAckableEnvelope` (backing
193
+ * `message.send` / `message.reply`) is deliberately left unguarded: a
194
+ * false-positive dead-mark must never block message delivery, only the
195
+ * cosmetic `typing.update` indicator. Do not extend the guard there.
196
+ */
159
197
  typing(chatId, isTyping = true) {
198
+ if (this.isChatDead(chatId))
199
+ return;
160
200
  this.sendRawEnvelope({
161
201
  version: "2",
162
202
  event: EVENT.TYPING_UPDATE,
@@ -189,6 +229,8 @@ export class ClawChatClient extends EventEmitter {
189
229
  sendRawEnvelope(env) {
190
230
  this.sendWire(JSON.stringify(env), { bypassReconnectQueue: env.event === EVENT.CONNECT });
191
231
  }
232
+ // Deliberately does NOT consult `deadChats` — see the comment on `typing()`.
233
+ // Message delivery must never be gated by the dead-chat guard.
192
234
  async sendAckableEnvelope(params) {
193
235
  const traceId = this.nextTraceId();
194
236
  const env = {
@@ -44,6 +44,7 @@
44
44
  "clawchat_remove_friend",
45
45
  "clawchat_search_users",
46
46
  "clawchat_mention_message",
47
+ "clawchat_react_message",
47
48
  "clawchat_get_conversation",
48
49
  "clawchat_leave_group",
49
50
  "clawchat_list_moments",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.7.23-1",
3
+ "version": "2026.7.26-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -106,7 +106,7 @@ export const openclawClawlingSetupPlugin: OpenclawClawlingSetupPlugin = {
106
106
  capabilities: {
107
107
  chatTypes: ["direct", "group"],
108
108
  media: true,
109
- reactions: false,
109
+ reactions: true,
110
110
  threads: false,
111
111
  polls: false,
112
112
  blockStreaming: true,
@@ -21,6 +21,13 @@ export const AGENTS_CONNECT_PLATFORM = "openclaw" as const;
21
21
  * always a bot; humans don't log in through this flow.
22
22
  */
23
23
  export const AGENTS_CONNECT_TYPE = "clawbot" as const;
24
+ /**
25
+ * `/v1/agents/connect` envelope code for "the supplied user_id matches no
26
+ * agent". Servers that predate the server-side stale-user_id fallback return
27
+ * this instead of degrading to a fresh pairing, so the client sheds the id and
28
+ * retries once.
29
+ */
30
+ export const AGENT_NOT_FOUND_CODE = 16001 as const;
24
31
 
25
32
  export type OpenclawClawchatMutateConfigFile = <T = void>(params: {
26
33
  afterWrite: { mode: "auto" } | { mode: "none" | "restart"; reason: string };
@@ -203,8 +210,8 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
203
210
 
204
211
  runtime.log("Verifying invite code …");
205
212
  let result;
213
+ const existingUserId = account.userId.trim();
206
214
  try {
207
- const existingUserId = account.userId.trim();
208
215
  result = await apiClient.agentsConnect({
209
216
  code: inviteCode,
210
217
  platform: AGENTS_CONNECT_PLATFORM,
@@ -213,9 +220,36 @@ export async function runOpenclawClawlingLogin(params: LoginParams): Promise<voi
213
220
  });
214
221
  } catch (err) {
215
222
  if (err instanceof ClawlingApiError) {
216
- throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
223
+ // The stored userId is a re-pair hint, not a credential — the invite
224
+ // code alone decides ownership. AGENT_NOT_FOUND means it names an agent
225
+ // this server has never had (owner account deleted, or the config was
226
+ // repointed at another deployment), so replaying it would fail
227
+ // identically for every future invite code. A 16001 leaves the code
228
+ // `pending` server-side, so shedding the id and retrying once cannot
229
+ // double-spend it. Nothing else is retried: an owner mismatch is a real
230
+ // rejection, and a request that sent no id has no stale state to shed.
231
+ if (existingUserId && err.meta?.code === AGENT_NOT_FOUND_CODE) {
232
+ runtime.log(
233
+ `Stored userId ${existingUserId} no longer exists on this server — retrying as a fresh pairing …`,
234
+ );
235
+ try {
236
+ result = await apiClient.agentsConnect({
237
+ code: inviteCode,
238
+ platform: AGENTS_CONNECT_PLATFORM,
239
+ type: AGENTS_CONNECT_TYPE,
240
+ });
241
+ } catch (retryErr) {
242
+ if (retryErr instanceof ClawlingApiError) {
243
+ throw new Error(`agents/connect failed (${retryErr.kind}): ${retryErr.message}`);
244
+ }
245
+ throw retryErr;
246
+ }
247
+ } else {
248
+ throw new Error(`agents/connect failed (${err.kind}): ${err.message}`);
249
+ }
250
+ } else {
251
+ throw err;
217
252
  }
218
- throw err;
219
253
  }
220
254
 
221
255
  const accessToken = requireConnectString(result?.access_token, "access_token");
package/src/outbound.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomInt } from "node:crypto";
2
- import { MessageSendError, type Envelope, type Fragment, type MentionFragment, type MessageAckPayload, type MessageErrorPayload } from "./protocol-types.ts";
2
+ import { EVENT, MessageSendError, type Envelope, type Fragment, type MentionFragment, type MessageAckPayload, type MessageErrorPayload, type MessageReactionPayload } from "./protocol-types.ts";
3
3
  import type { ClawlingChatClient } from "./ws-client.ts";
4
4
  import {
5
5
  createAttachedChannelResultAdapter,
@@ -647,6 +647,40 @@ export async function sendOpenclawClawlingMedia(
647
647
  });
648
648
  }
649
649
 
650
+ export interface SendReactionParams {
651
+ client: ClawlingChatClient;
652
+ chatId: string;
653
+ targetMessageId: string;
654
+ emoji: string;
655
+ removed: boolean;
656
+ log?: LogSink;
657
+ }
658
+
659
+ /**
660
+ * Emit a Protocol-v2 `message.reaction`. Fire-and-forget: reactions are NOT
661
+ * ack-aligned (the server mints the slot-key message_id, so the ack-by-trace_id
662
+ * path used for message.send/reply does not apply). Matches the mobile client.
663
+ */
664
+ export function sendOpenclawClawlingReaction(params: SendReactionParams): void {
665
+ const payload: MessageReactionPayload = {
666
+ target_message_id: params.targetMessageId,
667
+ emoji: params.emoji,
668
+ removed: params.removed,
669
+ };
670
+ const env: Envelope<MessageReactionPayload> = {
671
+ version: "2",
672
+ event: EVENT.MESSAGE_REACTION,
673
+ trace_id: params.client.nextTraceId(),
674
+ emitted_at: Date.now(),
675
+ chat_id: params.chatId,
676
+ payload,
677
+ };
678
+ params.client.sendWire(JSON.stringify(env));
679
+ params.log?.info?.(
680
+ `clawchat reaction sent chat_id=${params.chatId} target=${params.targetMessageId} emoji=${params.emoji} removed=${params.removed}`,
681
+ );
682
+ }
683
+
650
684
  type OutboundClaimStore = Pick<ClawChatStore, "claimMessageOnce" | "markMessageAcknowledged">;
651
685
 
652
686
  // Crockford base32 alphabet (ULID spec).
@@ -7,6 +7,7 @@ export const EVENT = {
7
7
  MESSAGE_ACK: "message.ack",
8
8
  MESSAGE_ERROR: "message.error",
9
9
  MESSAGE_REPLY: "message.reply",
10
+ MESSAGE_REACTION: "message.reaction",
10
11
  MESSAGE_CREATED: "message.created",
11
12
  MESSAGE_ADD: "message.add",
12
13
  MESSAGE_DONE: "message.done",
@@ -200,6 +201,12 @@ export interface MessagePayload {
200
201
  };
201
202
  }
202
203
 
204
+ export interface MessageReactionPayload {
205
+ target_message_id: string;
206
+ emoji: string;
207
+ removed: boolean;
208
+ }
209
+
203
210
  export interface MessageAckPayload {
204
211
  message_id: string;
205
212
  accepted_at: number;
package/src/runtime.ts CHANGED
@@ -2401,6 +2401,18 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
2401
2401
  ? (env.payload as Record<string, unknown>)
2402
2402
  : undefined;
2403
2403
  const type = typeof payload?.type === "string" ? payload.type : "";
2404
+ if (type === "conversation.dissolved") {
2405
+ // The conversation is gone. Evict it so no further uplink is emitted
2406
+ // for it. Falls through to the awareness branch so the owner is told.
2407
+ const entityId =
2408
+ typeof payload?.entity_id === "string" ? payload.entity_id : "";
2409
+ const evicted = client.markChatDead(entityId);
2410
+ log?.info?.(
2411
+ evicted
2412
+ ? `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received; evicting ${entityId}`
2413
+ : `[${accountId}] clawchat-plugin-openclaw conversation.dissolved signal received with missing/invalid entity_id; no eviction performed`,
2414
+ );
2415
+ }
2404
2416
  if (type === "agent.config.changed") {
2405
2417
  log?.info?.(
2406
2418
  `[${accountId}] clawchat-plugin-openclaw agent.config.changed signal received; re-pulling group settings`,
@@ -54,3 +54,24 @@ export function consumeTerminalClawChatSend(params: {
54
54
  export function clearTerminalClawChatSendsForTest(): void {
55
55
  terminalSends.clear();
56
56
  }
57
+
58
+ export interface TerminalSendScope {
59
+ accountId: string;
60
+ chatId: string;
61
+ messageId: string;
62
+ }
63
+
64
+ /**
65
+ * Read the current turn's ambient send scope (accountId\0chatId\0messageId).
66
+ * Returns null when not inside a dispatch scope, or when the scope id is not
67
+ * the tripartite form (e.g. a test-only opaque scope id).
68
+ */
69
+ export function getCurrentTerminalSendScope(): TerminalSendScope | null {
70
+ const store = terminalSendScope.getStore();
71
+ if (!store) return null;
72
+ const parts = store.split("\0");
73
+ if (parts.length !== 3) return null;
74
+ const [accountId, chatId, messageId] = parts;
75
+ if (!accountId || !chatId || !messageId) return null;
76
+ return { accountId, chatId, messageId };
77
+ }
@@ -254,6 +254,34 @@ export const ClawchatMentionMessageSchema = Type.Object({
254
254
  });
255
255
  export type ClawchatMentionMessageParams = Static<typeof ClawchatMentionMessageSchema>;
256
256
 
257
+ export const ClawchatReactMessageSchema = Type.Object({
258
+ emoji: Type.String({
259
+ minLength: 1,
260
+ description:
261
+ "A single emoji to react with. Prefer the ClawChat quick set 👍 ❤️ 😂 😮 😢 🙏 🎉 👏 🔥 😍 🤔 so it renders in the client's quick-reaction row.",
262
+ }),
263
+ targetMessageId: Type.Optional(
264
+ Type.String({
265
+ minLength: 1,
266
+ description:
267
+ "ClawChat message id to react to. Omit to react to the message that triggered the current turn.",
268
+ }),
269
+ ),
270
+ chatId: Type.Optional(
271
+ Type.String({
272
+ minLength: 1,
273
+ description: "ClawChat conversation id. Omit to use the current turn's chat.",
274
+ }),
275
+ ),
276
+ remove: Type.Optional(
277
+ Type.Boolean({
278
+ description:
279
+ "Set true to remove your existing reaction on the target message. Omit or false to add/overwrite.",
280
+ }),
281
+ ),
282
+ });
283
+ export type ClawchatReactMessageParams = Static<typeof ClawchatReactMessageSchema>;
284
+
257
285
  export const ClawchatListMomentsSchema = Type.Object({
258
286
  before: Type.Optional(
259
287
  Type.Integer({
package/src/tools.ts CHANGED
@@ -17,11 +17,11 @@ import {
17
17
  makeOwnerProfilePersister,
18
18
  type ClawChatStore,
19
19
  } from "./storage.ts";
20
- import { sendOpenclawClawlingMentionMessage } from "./outbound.ts";
20
+ import { sendOpenclawClawlingMentionMessage, sendOpenclawClawlingReaction } from "./outbound.ts";
21
21
  import {
22
22
  getOpenclawClawlingClient,
23
23
  } from "./runtime.ts";
24
- import { markTerminalClawChatSend } from "./terminal-send.ts";
24
+ import { markTerminalClawChatSend, getCurrentTerminalSendScope } from "./terminal-send.ts";
25
25
  import {
26
26
  editClawChatMemoryBody,
27
27
  readClawChatMemoryFile,
@@ -57,6 +57,7 @@ import {
57
57
  ClawchatListFriendRequestsSchema,
58
58
  ClawchatListMomentsSchema,
59
59
  ClawchatMentionMessageSchema,
60
+ ClawchatReactMessageSchema,
60
61
  ClawchatReplyMomentCommentSchema,
61
62
  ClawchatRejectFriendRequestSchema,
62
63
  ClawchatRemoveFriendSchema,
@@ -86,6 +87,7 @@ import {
86
87
  type ClawchatListFriendRequestsParams,
87
88
  type ClawchatListMomentsParams,
88
89
  type ClawchatMentionMessageParams,
90
+ type ClawchatReactMessageParams,
89
91
  type ClawchatReplyMomentCommentParams,
90
92
  type ClawchatRejectFriendRequestParams,
91
93
  type ClawchatRemoveFriendParams,
@@ -1113,6 +1115,60 @@ export function registerOpenclawClawlingTools(
1113
1115
  { name: "clawchat_mention_message" },
1114
1116
  );
1115
1117
 
1118
+ api.registerTool(
1119
+ {
1120
+ name: "clawchat_react_message",
1121
+ label: "React to ClawChat Message",
1122
+ description: toolDescription(
1123
+ "React to a ClawChat message with a single quick emoji (the bubble long-press reaction), instead of sending a text message. " +
1124
+ "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. " +
1125
+ "Omit targetMessageId and chatId to react to the message that triggered the current turn; pass targetMessageId to react to an earlier message. " +
1126
+ "Set remove:true to take back your reaction. " +
1127
+ "Prefer the quick set 👍 ❤️ 😂 😮 😢 🙏 🎉 👏 🔥 😍 🤔. " +
1128
+ "When a reaction is all you want to send, do not also send a normal text reply.",
1129
+ ),
1130
+ parameters: ClawchatReactMessageSchema,
1131
+ async execute(_callId, params) {
1132
+ return await recordClawchatToolCall("clawchat_react_message", params, async () => {
1133
+ const p = params as ClawchatReactMessageParams;
1134
+ if (!p.emoji?.trim()) {
1135
+ return validationError("clawchat-plugin-openclaw: emoji is required");
1136
+ }
1137
+ const account = resolveCurrent();
1138
+ if (!account.configured) {
1139
+ return configError("clawchat-plugin-openclaw: account is not configured");
1140
+ }
1141
+ const scope = getCurrentTerminalSendScope();
1142
+ const chatId = p.chatId?.trim() || scope?.chatId;
1143
+ const targetMessageId = p.targetMessageId?.trim() || scope?.messageId;
1144
+ if (!chatId) {
1145
+ return validationError(
1146
+ "clawchat-plugin-openclaw: chatId is required (no current-turn chat to default from)",
1147
+ );
1148
+ }
1149
+ if (!targetMessageId) {
1150
+ return validationError(
1151
+ "clawchat-plugin-openclaw: targetMessageId is required (no current-turn message to default from)",
1152
+ );
1153
+ }
1154
+ const client = getOpenclawClawlingClient(account.accountId);
1155
+ if (!client) {
1156
+ return configError("ClawChat websocket client is not ready");
1157
+ }
1158
+ const emoji = p.emoji.trim();
1159
+ const removed = p.remove === true;
1160
+ try {
1161
+ sendOpenclawClawlingReaction({ client, chatId, targetMessageId, emoji, removed, log: api.logger });
1162
+ return jsonResponse({ reacted: true, targetMessageId, emoji, removed });
1163
+ } catch (err) {
1164
+ return genericError(err);
1165
+ }
1166
+ });
1167
+ },
1168
+ },
1169
+ { name: "clawchat_react_message" },
1170
+ );
1171
+
1116
1172
  api.registerTool(
1117
1173
  {
1118
1174
  name: "clawchat_list_moments",
package/src/ws-client.ts CHANGED
@@ -165,6 +165,9 @@ export function buildConnectCapabilities(): ConnectCapabilities {
165
165
  };
166
166
  }
167
167
 
168
+ /** Bounded FIFO of conversations known to be dissolved. */
169
+ export const DEAD_CHATS_MAX = 512;
170
+
168
171
  export class ClawChatClient extends EventEmitter {
169
172
  private currentState: ConnState = "idle";
170
173
  private connectResolve?: () => void;
@@ -179,6 +182,7 @@ export class ClawChatClient extends EventEmitter {
179
182
  private readonly pending = new Map<string, PendingAck>();
180
183
  private readonly handledMessageErrorTraces = new Set<string>();
181
184
  private readonly sendQueue: string[] = [];
185
+ private readonly deadChats = new Set<string>();
182
186
 
183
187
  constructor(private readonly opts: NormalizedClawChatClientOptions) {
184
188
  super();
@@ -227,7 +231,42 @@ export class ClawChatClient extends EventEmitter {
227
231
  this.opts.transport.close(1000, "client close");
228
232
  }
229
233
 
234
+ /**
235
+ * Record a conversation as dissolved so no further uplink is sent for it.
236
+ * Only `cnv_`-prefixed ids are accepted: a bad entity_id must never mute a
237
+ * live conversation. Returns whether the id was accepted (and thus actually
238
+ * evicted) so callers can log/act on rejection distinctly from acceptance.
239
+ *
240
+ * Eviction is FIFO by first-mark order, not LRU: `Set` iteration order is
241
+ * insertion order, and the `has()` early-return above means re-marking an
242
+ * already-dead id does NOT refresh its position. A chat marked dead long ago
243
+ * is evicted from the set before one marked dead a moment ago, even if the
244
+ * former has been re-signaled dissolved more recently.
245
+ */
246
+ markChatDead(chatId: string): boolean {
247
+ if (typeof chatId !== "string" || !chatId.startsWith("cnv_")) return false;
248
+ if (this.deadChats.has(chatId)) return true;
249
+ this.deadChats.add(chatId);
250
+ while (this.deadChats.size > DEAD_CHATS_MAX) {
251
+ const oldest = this.deadChats.values().next().value as string | undefined;
252
+ if (oldest === undefined) break;
253
+ this.deadChats.delete(oldest);
254
+ }
255
+ return true;
256
+ }
257
+
258
+ isChatDead(chatId: string): boolean {
259
+ return this.deadChats.has(chatId);
260
+ }
261
+
262
+ /**
263
+ * The ONLY dead-chat guard in this client. `sendAckableEnvelope` (backing
264
+ * `message.send` / `message.reply`) is deliberately left unguarded: a
265
+ * false-positive dead-mark must never block message delivery, only the
266
+ * cosmetic `typing.update` indicator. Do not extend the guard there.
267
+ */
230
268
  typing(chatId: string, isTyping = true): void {
269
+ if (this.isChatDead(chatId)) return;
231
270
  this.sendRawEnvelope({
232
271
  version: "2",
233
272
  event: EVENT.TYPING_UPDATE,
@@ -264,6 +303,8 @@ export class ClawChatClient extends EventEmitter {
264
303
  this.sendWire(JSON.stringify(env), { bypassReconnectQueue: env.event === EVENT.CONNECT });
265
304
  }
266
305
 
306
+ // Deliberately does NOT consult `deadChats` — see the comment on `typing()`.
307
+ // Message delivery must never be gated by the dead-chat guard.
267
308
  async sendAckableEnvelope(params: {
268
309
  eventName: "message.send" | "message.reply";
269
310
  chatId: string;