@clawling/clawchat-plugin-openclaw 2026.7.29-3 → 2026.8.4-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.
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { AckTimeoutError, AuthError, EVENT, MessageSendError, ProtocolError, StateError, TransportError, isBusinessDispatchEvent, } from "./protocol-types.js";
2
+ import { AckTimeoutError, AuthError, EVENT, MessageSendError, ProtocolError, StateError, TERMINAL_CHAT_CODES, TransportError, isBusinessDispatchEvent, } from "./protocol-types.js";
3
3
  export function createWebSocketTransport(WebSocketCtor = globalThis.WebSocket) {
4
4
  let currentState = "closed";
5
5
  let socket;
@@ -103,6 +103,45 @@ export function buildConnectCapabilities() {
103
103
  }
104
104
  /** Bounded FIFO of conversations known to be dissolved. */
105
105
  export const DEAD_CHATS_MAX = 512;
106
+ /**
107
+ * How long a server rejection keeps gating uplinks for a conversation.
108
+ *
109
+ * A rejection is a fact with a shelf life, NOT a permanent verdict: the server
110
+ * soft-deletes conversations and can revive one in place under the SAME `cnv_`
111
+ * id — which is exactly the path an agent takes when it is re-paired. A
112
+ * permanent mark would leave a re-paired agent silently mute forever.
113
+ *
114
+ * 10 minutes: an order of magnitude above the server's own 30s negative cache
115
+ * for unresolvable chats (so a post-TTL retry lands after the server has
116
+ * re-resolved rather than on a stale negative entry), while still bounding the
117
+ * waste to at most one frame per dead conversation per 10 minutes. Two faster
118
+ * clears (`noteChatAlive`) handle the common cases; this is only the backstop.
119
+ *
120
+ * MUST stay identical to the hermes plugin's value — cross-plugin parity is
121
+ * pinned by src/parity.test.ts.
122
+ */
123
+ export const SERVER_REJECTION_TTL_MS = 600_000;
124
+ /**
125
+ * Every ClawChat conversation id is minted by member-backend with this prefix,
126
+ * and msghub resolves a chat_id only through member-backend. Anything else — a
127
+ * `usr_…` user idcode, a host-composed `direct:{self}:{peer}` key, a bare
128
+ * adapter name, a placeholder — is refused upstream with
129
+ * `code=400: invalid conversation id`.
130
+ *
131
+ * MUST stay identical to the hermes plugin's `CHAT_ID_PREFIX`.
132
+ */
133
+ export const CHAT_ID_PREFIX = "cnv_";
134
+ /**
135
+ * Whether `chatId` can name a ClawChat conversation at all.
136
+ *
137
+ * A purely *static* check: it says nothing about whether the conversation
138
+ * exists, is alive, or admits this sender. Those are server-side questions
139
+ * answered by `message.error`; this one is answerable locally, and a frame that
140
+ * fails it is always wrong to put on the wire.
141
+ */
142
+ export function isValidChatId(chatId) {
143
+ return typeof chatId === "string" && chatId.startsWith(CHAT_ID_PREFIX);
144
+ }
106
145
  export class ClawChatClient extends EventEmitter {
107
146
  opts;
108
147
  currentState = "idle";
@@ -118,7 +157,12 @@ export class ClawChatClient extends EventEmitter {
118
157
  pending = new Map();
119
158
  handledMessageErrorTraces = new Set();
120
159
  sendQueue = [];
121
- deadChats = new Set();
160
+ // Tier 1 — inferred from `conversation.dissolved`. Gates typing only.
161
+ dissolvedBySignal = new Set();
162
+ // Tier 2 — server-CONFIRMED rejection → expiry timestamp. Gates sends.
163
+ // Disjoint storage from tier 1: neither write path touches the other set.
164
+ serverRejectedChats = new Map();
165
+ ownerDirectChatResolver;
122
166
  constructor(opts) {
123
167
  super();
124
168
  this.opts = opts;
@@ -160,39 +204,131 @@ export class ClawChatClient extends EventEmitter {
160
204
  this.opts.transport.close(1000, "client close");
161
205
  }
162
206
  /**
163
- * Record a conversation as dissolved so no further uplink is sent for it.
207
+ * Record a conversation as dead so no further uplink is sent for it.
164
208
  * Only `cnv_`-prefixed ids are accepted: a bad entity_id must never mute a
165
209
  * live conversation. Returns whether the id was accepted (and thus actually
166
- * evicted) so callers can log/act on rejection distinctly from acceptance.
210
+ * recorded) so callers can log/act on rejection distinctly from acceptance.
167
211
  *
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.
212
+ * `source` selects WHICH STORE is written — see {@link DeadChatSource}. The
213
+ * two stores are disjoint; a signal never writes the server tier and a server
214
+ * rejection never writes the signal tier.
215
+ *
216
+ * Signal tier eviction is FIFO by first-mark order, not LRU: `Set` iteration
217
+ * order is insertion order and the `has()` early-return means re-marking does
218
+ * NOT refresh position. The server tier deliberately does the opposite — a
219
+ * re-rejection re-arms the TTL *and* moves the id to the tail — because its
220
+ * whole point is "how long since the server last said no".
173
221
  */
174
- markChatDead(chatId) {
175
- if (typeof chatId !== "string" || !chatId.startsWith("cnv_"))
222
+ markChatDead(chatId, source = "signal") {
223
+ if (!isValidChatId(chatId))
176
224
  return false;
177
- if (this.deadChats.has(chatId))
225
+ if (source === "server") {
226
+ // Delete-then-set so the re-insert also moves the id to the FIFO tail.
227
+ this.serverRejectedChats.delete(chatId);
228
+ this.serverRejectedChats.set(chatId, this.opts.now() + SERVER_REJECTION_TTL_MS);
229
+ while (this.serverRejectedChats.size > DEAD_CHATS_MAX) {
230
+ const oldest = this.serverRejectedChats.keys().next().value;
231
+ if (oldest === undefined)
232
+ break;
233
+ this.serverRejectedChats.delete(oldest);
234
+ }
235
+ return true;
236
+ }
237
+ if (this.dissolvedBySignal.has(chatId))
178
238
  return true;
179
- this.deadChats.add(chatId);
180
- while (this.deadChats.size > DEAD_CHATS_MAX) {
181
- const oldest = this.deadChats.values().next().value;
239
+ this.dissolvedBySignal.add(chatId);
240
+ while (this.dissolvedBySignal.size > DEAD_CHATS_MAX) {
241
+ const oldest = this.dissolvedBySignal.values().next().value;
182
242
  if (oldest === undefined)
183
243
  break;
184
- this.deadChats.delete(oldest);
244
+ this.dissolvedBySignal.delete(oldest);
185
245
  }
186
246
  return true;
187
247
  }
248
+ /**
249
+ * True for a chat dead by EITHER tier — a derived union, not a third store.
250
+ * Gates `typing.update` only. Never gate message delivery on this.
251
+ */
188
252
  isChatDead(chatId) {
189
- return this.deadChats.has(chatId);
253
+ return this.dissolvedBySignal.has(chatId) || this.isChatRejectedByServer(chatId);
254
+ }
255
+ /**
256
+ * True only for a chat the SERVER rejected with a terminal code, and only
257
+ * while that rejection is still fresh. Expiry is lazy: the entry is dropped
258
+ * on the first read past its deadline, so there is no timer to leak.
259
+ */
260
+ isChatRejectedByServer(chatId) {
261
+ const expiresAt = this.serverRejectedChats.get(chatId);
262
+ if (expiresAt === undefined)
263
+ return false;
264
+ if (this.opts.now() >= expiresAt) {
265
+ this.serverRejectedChats.delete(chatId);
266
+ return false;
267
+ }
268
+ return true;
269
+ }
270
+ /**
271
+ * The ONLY predicate allowed to gate an outbound `message.send` /
272
+ * `message.reply` / `message.reaction`. Narrower than `isChatRejectedByServer`
273
+ * by one exemption: the owner's direct conversation is never blocked, because
274
+ * it is the only channel through which the agent can explain why it went
275
+ * dark. The rejection is still RECORDED for the owner chat — the exemption
276
+ * decides whether to send, not whether to remember.
277
+ */
278
+ isSendBlocked(chatId) {
279
+ if (!this.isChatRejectedByServer(chatId))
280
+ return false;
281
+ const ownerChatId = this.ownerDirectChatResolver?.();
282
+ return !(ownerChatId && ownerChatId === chatId);
283
+ }
284
+ /** Injected by the runtime once the owner's direct conversation is known. */
285
+ setOwnerDirectChatResolver(resolver) {
286
+ this.ownerDirectChatResolver = resolver;
287
+ }
288
+ /**
289
+ * Evidence that a conversation is reachable again — drop any server-rejection
290
+ * record for it. Called from the inbound path; see `dispatchInbound`.
291
+ *
292
+ * Only the SERVER tier is cleared. The signal tier is deliberately left
293
+ * alone: `conversation.dissolved` gates nothing but the cosmetic typing
294
+ * indicator, and un-muting it on a replayed frame would be churn with no
295
+ * user-visible benefit.
296
+ */
297
+ noteChatAlive(chatId) {
298
+ if (typeof chatId !== "string" || !chatId)
299
+ return;
300
+ this.serverRejectedChats.delete(chatId);
301
+ }
302
+ /**
303
+ * `conversation.*` signals other than `dissolved` mean the server still has
304
+ * this conversation (created / updated / membership changed) — clear any
305
+ * stale rejection. `conversation.dissolved` is the opposite signal and must
306
+ * not clear anything.
307
+ */
308
+ noteConversationSignalAlive(env) {
309
+ const payload = env.payload && typeof env.payload === "object"
310
+ ? env.payload
311
+ : undefined;
312
+ const type = typeof payload?.type === "string" ? payload.type : "";
313
+ if (!type.startsWith("conversation.") || type === "conversation.dissolved")
314
+ return;
315
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
316
+ if (entityId)
317
+ this.noteChatAlive(entityId);
190
318
  }
191
319
  /**
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.
320
+ * Gated on `isChatDead` — BOTH tiers. `sendAckableEnvelope` (backing
321
+ * `message.send` / `message.reply`) is deliberately left unguarded against
322
+ * this predicate: a false-positive SIGNAL-derived dead-mark must never block
323
+ * message delivery, only the cosmetic `typing.update` indicator. The
324
+ * protocol states this explicitly for `conversation.dissolved` — "agents …
325
+ * stop sending typing.update for that chat_id; message.send MUST NOT be
326
+ * gated on this". Do not extend `isChatDead` to the send path.
327
+ *
328
+ * The send path IS gated, but on the narrower `isSendBlocked` (see
329
+ * src/outbound.ts) — a chat the server itself rejected is fact, not
330
+ * inference, so the false-positive argument above does not apply to it.
331
+ * That fact expires; this one does not.
196
332
  */
197
333
  typing(chatId, isTyping = true) {
198
334
  if (this.isChatDead(chatId))
@@ -227,10 +363,27 @@ export class ClawChatClient extends EventEmitter {
227
363
  this.opts.transport.send(wire);
228
364
  }
229
365
  sendRawEnvelope(env) {
366
+ // Static addressing check. A frame carrying a chat_id that cannot name a
367
+ // conversation is refused by msghub with `invalid conversation id`, so it
368
+ // never had a recipient; dropping it here costs the caller nothing and
369
+ // keeps the error out of msghub's logs.
370
+ //
371
+ // Unlike the dead-chat gate — a revocable server state a queued frame can
372
+ // fall into, which is why `shouldDropQueuedWire` re-checks on replay — a
373
+ // malformed chat_id is wrong at construction time and can never become
374
+ // valid. Rejecting at this entry point is therefore sufficient: the frame
375
+ // never reaches the reconnect queue, so no replay path can resurrect it.
376
+ //
377
+ // Frames with no chat_id (connect / ping / pong) are untouched.
378
+ if (env.chat_id !== undefined && !isValidChatId(env.chat_id))
379
+ return;
230
380
  this.sendWire(JSON.stringify(env), { bypassReconnectQueue: env.event === EVENT.CONNECT });
231
381
  }
232
- // Deliberately does NOT consult `deadChats` — see the comment on `typing()`.
233
- // Message delivery must never be gated by the dead-chat guard.
382
+ // Deliberately does NOT consult the dead-chat state — see the comment on
383
+ // `typing()`. Signal-inferred death must never gate message delivery. (The
384
+ // narrower `isSendBlocked` gate lives on the aligned path in src/outbound.ts,
385
+ // which is what the reply dispatcher actually uses; this legacy ackable path
386
+ // is kept ungated so the two tiers stay distinguishable.)
234
387
  async sendAckableEnvelope(params) {
235
388
  const traceId = this.nextTraceId();
236
389
  const env = {
@@ -332,6 +485,13 @@ export class ClawChatClient extends EventEmitter {
332
485
  this.dispatchInbound(env);
333
486
  }
334
487
  dispatchInbound(env) {
488
+ // A frame the server routed to us for this chat proves the chat is
489
+ // reachable again — a deleted conversation can be revived in place under
490
+ // the same id. `message.error` is excluded: it IS the rejection, not
491
+ // evidence of life.
492
+ if (typeof env.chat_id === "string" && env.event !== EVENT.MESSAGE_ERROR) {
493
+ this.noteChatAlive(env.chat_id);
494
+ }
335
495
  if (env.event === EVENT.CONNECT_CHALLENGE)
336
496
  return this.onChallenge(env);
337
497
  if (env.event === EVENT.HELLO_OK)
@@ -360,6 +520,8 @@ export class ClawChatClient extends EventEmitter {
360
520
  this.emit("typing", env);
361
521
  if (env.event === EVENT.CHAT_METADATA_INVALIDATED)
362
522
  this.emit("metadata:invalidated", env);
523
+ if (env.event === EVENT.NOTIFY_SIGNAL)
524
+ this.noteConversationSignalAlive(env);
363
525
  if (env.event === EVENT.NOTIFY_SIGNAL)
364
526
  this.emit("notify:signal", env);
365
527
  if (env.event === EVENT.REPLAY_DONE)
@@ -510,6 +672,20 @@ export class ClawChatClient extends EventEmitter {
510
672
  entry.resolve(env);
511
673
  }
512
674
  onMessageError(env) {
675
+ const payload = env.payload && typeof env.payload === "object"
676
+ ? env.payload
677
+ : undefined;
678
+ const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
679
+ // Mark BEFORE the pending-ack lookup: an unmatched error frame still
680
+ // carries an authoritative `chat_id` + `code` and must participate in the
681
+ // dead-chat decision. Unmatched is the NORMAL case here — the aligned queue
682
+ // owns its own ack matching (markMessageErrorHandled makes the branch below
683
+ // return silently) and a reconnect drops pending entries. Deferring the
684
+ // mark until after the lookup would make tier-2 feeding random.
685
+ // Parity: hermes notifies its dead-chat state at the same point.
686
+ if (typeof env.chat_id === "string" && TERMINAL_CHAT_CODES.has(code)) {
687
+ this.markChatDead(env.chat_id, "server");
688
+ }
513
689
  const entry = this.pending.get(env.trace_id);
514
690
  if (!entry) {
515
691
  if (this.handledMessageErrorTraces.delete(env.trace_id))
@@ -519,10 +695,6 @@ export class ClawChatClient extends EventEmitter {
519
695
  }
520
696
  clearTimeout(entry.timer);
521
697
  this.pending.delete(env.trace_id);
522
- const payload = env.payload && typeof env.payload === "object"
523
- ? env.payload
524
- : undefined;
525
- const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
526
698
  // §14.3: the human-readable hint is `reason` (fall back to legacy `message`).
527
699
  const hint = typeof payload?.reason === "string" && payload.reason
528
700
  ? payload.reason
@@ -644,14 +816,22 @@ export class ClawChatClient extends EventEmitter {
644
816
  }
645
817
  flushSendQueue() {
646
818
  while (this.sendQueue.length > 0) {
647
- const wire = this.sendQueue.shift();
648
- try {
649
- this.sendWire(wire);
650
- }
651
- catch (err) {
652
- this.sendQueue.unshift(wire);
653
- throw err;
819
+ const wire = this.sendQueue[0];
820
+ // Re-check the dead-chat gates at replay time: a conversation can be
821
+ // rejected AFTER a frame was buffered here, and this path bypasses every
822
+ // caller-side guard.
823
+ if (shouldDropQueuedWire(wire, {
824
+ isChatDead: (chatId) => this.isChatDead(chatId),
825
+ isSendBlocked: (chatId) => this.isSendBlocked(chatId),
826
+ })) {
827
+ this.sendQueue.shift();
828
+ continue;
654
829
  }
830
+ // Read the head first and shift only after a successful write, so a
831
+ // failure leaves the frame at the head (same as the previous
832
+ // shift/unshift pair; `onHelloOk`'s catch closes and reconnects).
833
+ this.sendWire(wire);
834
+ this.sendQueue.shift();
655
835
  }
656
836
  }
657
837
  isHandshaking() {
@@ -667,6 +847,37 @@ export class ClawChatClient extends EventEmitter {
667
847
  return null;
668
848
  }
669
849
  }
850
+ /**
851
+ * Decide whether a wire string sitting in the transport-level reconnect queue
852
+ * must be dropped instead of replayed.
853
+ *
854
+ * This queue holds ALREADY-SERIALIZED frames, so the only way to see the
855
+ * `chat_id` is to parse. That is acceptable here and nowhere else: the queue
856
+ * fills only during a reconnect window and is drained exactly once per
857
+ * successful handshake — unlike `sendWire`, which is the hot path.
858
+ *
859
+ * Asymmetry mirrors the live path: `typing.update` is gated on EITHER tier
860
+ * (`isChatDead`), everything else only on a server-confirmed, non-owner
861
+ * rejection (`isSendBlocked`). Unparseable or chat-less frames are always kept:
862
+ * `connect` / `ping` / `pong` must never be dropped.
863
+ *
864
+ * Exported for direct unit testing; `flushSendQueue` is the only producer.
865
+ */
866
+ export function shouldDropQueuedWire(wire, gates) {
867
+ let env;
868
+ try {
869
+ env = JSON.parse(wire);
870
+ }
871
+ catch {
872
+ return false;
873
+ }
874
+ const chatId = typeof env.chat_id === "string" ? env.chat_id : "";
875
+ if (!chatId)
876
+ return false;
877
+ if (env.event === EVENT.TYPING_UPDATE)
878
+ return gates.isChatDead(chatId);
879
+ return gates.isSendBlocked(chatId);
880
+ }
670
881
  export function createClawChatClient(options) {
671
882
  return new ClawChatClient({
672
883
  ...options,
@@ -689,5 +900,6 @@ export function createClawChatClient(options) {
689
900
  timeout: options.ack?.timeout ?? 15000,
690
901
  autoResendOnTimeout: options.ack?.autoResendOnTimeout ?? false,
691
902
  },
903
+ now: options.now ?? (() => Date.now()),
692
904
  });
693
905
  }
@@ -48,6 +48,7 @@
48
48
  "clawchat_get_conversation",
49
49
  "clawchat_leave_group",
50
50
  "clawchat_list_moments",
51
+ "clawchat_get_moment",
51
52
  "clawchat_create_moment",
52
53
  "clawchat_delete_moment",
53
54
  "clawchat_toggle_moment_reaction",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.7.29-3",
3
+ "version": "2026.8.4-1",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "author": "CLAWLING PTE. LTD.",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: clawchat-core
3
- version: 1.1.0
3
+ version: 1.2.0
4
4
  description: Use when a request involves ClawChat profile, friends, user search, moments/dynamics, comments, reactions, avatar, media, memory, output visibility, read-only conversation lookup, sending an image, file, or voice/audio clip into a conversation, or plugin install/update/activation.
5
5
  ---
6
6
 
@@ -74,6 +74,7 @@ Tool descriptions are authoritative. These routing hints resolve common ambiguit
74
74
  | Remove/unfriend contact | `clawchat_remove_friend` with exact `friendUserId`; list friends first when ambiguous |
75
75
  | Inspect one conversation or group by exact id | `clawchat_get_conversation` |
76
76
  | View/browse moments or dynamics | `clawchat_list_moments` |
77
+ | Read one moment and its visible comments by exact id | `clawchat_get_moment` with exact `momentId`; read-only, use after a `moment.comment.created`/`moment.comment.replied` awareness note to read the new comment before deciding whether to reply |
77
78
  | Create a moment/dynamic | `clawchat_create_moment`; upload local images first and pass URLs |
78
79
  | Delete a moment/dynamic | `clawchat_delete_moment` with an exact `momentId` |
79
80
  | React/unreact to a moment | `clawchat_toggle_moment_reaction` with exact `momentId` and emoji |
@@ -111,7 +112,7 @@ If the user only asks to edit a local-only identity detail that is not shown on
111
112
 
112
113
  For avatar changes, save the returned `avatar_url` back to the identity file after `clawchat_upload_avatar_image` succeeds and before the final response. Do not leave only the local image path in `SOUL.md` or `soul.md` when a hosted ClawChat avatar URL was created.
113
114
 
114
- For moments/dynamics, list first when the user refers to "this", "latest", "that post", "the one from earlier", or another ambiguous target. Use exact ids returned by the tools.
115
+ For moments/dynamics, list first when the user refers to "this", "latest", "that post", "the one from earlier", or another ambiguous target. Use exact ids returned by the tools. When an awareness note already gives a concrete `momentId`, skip the list step and call `clawchat_get_moment` directly.
115
116
 
116
117
  For conversations/groups, use only `clawchat_get_conversation` to inspect existing conversation information when the exact conversation id is known.
117
118
 
@@ -3,10 +3,10 @@
3
3
  "skills": {
4
4
  "openclaw": {
5
5
  "clawchat-core": {
6
- "version": "1.1.0",
6
+ "version": "1.2.0",
7
7
  "path": "openclaw/clawchat-core/SKILL.md",
8
- "sha256": "f5a25e584ddf62c2a6880a85a7bd93765d05809f7612791b73600ca830445116",
9
- "bytes": 8909
8
+ "sha256": "a558a23e1ed8c625a3c28a7893d3df1eb651c344f331204c32a42e34486ee1cb",
9
+ "bytes": 9278
10
10
  },
11
11
  "clawchat-liveware": {
12
12
  "version": "1.1.0",
@@ -29,10 +29,10 @@
29
29
  },
30
30
  "hermes": {
31
31
  "clawchat-core": {
32
- "version": "1.2.0",
32
+ "version": "1.4.1",
33
33
  "path": "hermes/clawchat-core/SKILL.md",
34
- "sha256": "4a73379ff38f089b3120160f09a7eb1b7c617ec0bd23b9c905fb4b2184d6e239",
35
- "bytes": 11106
34
+ "sha256": "33488a7a77a1bd950e2d791420370cac27cc9d8717042251d6df5f8b781dc904",
35
+ "bytes": 11992
36
36
  },
37
37
  "clawchat-liveware": {
38
38
  "version": "1.1.0",
package/src/api-client.ts CHANGED
@@ -117,6 +117,7 @@ export interface OpenclawClawlingApiClient {
117
117
  searchUsers(params: { q?: string; limit?: number }): Promise<{ users: UserSearchHit[] }>;
118
118
  listMoments(params: { before?: number; limit?: number }): Promise<{ moments: MomentView[] }>;
119
119
  createMoment(body: { text?: string; images?: string[] }): Promise<{ moment: MomentView }>;
120
+ getMoment(momentId: number): Promise<{ moment: MomentView }>;
120
121
  deleteMoment(momentId: number): Promise<{ ok: boolean }>;
121
122
  toggleMomentReaction(params: {
122
123
  momentId: number;
@@ -584,6 +585,9 @@ export function createOpenclawClawlingApiClient(opts: ApiClientOptions): Opencla
584
585
  headers: { "content-type": "application/json" },
585
586
  });
586
587
  },
588
+ async getMoment(momentId): Promise<{ moment: MomentView }> {
589
+ return await call<{ moment: MomentView }>("GET", `/v1/moments/${encodeURIComponent(String(momentId))}`);
590
+ },
587
591
  async deleteMoment(momentId): Promise<{ ok: boolean }> {
588
592
  return await call<{ ok: boolean }>("DELETE", `/v1/moments/${encodeURIComponent(String(momentId))}`);
589
593
  },
@@ -62,3 +62,48 @@ export function buildAwarenessNoteEnvelope(
62
62
  },
63
63
  } as unknown as Envelope;
64
64
  }
65
+
66
+ export interface BuildMomentCommentNoteEnvelopeParams {
67
+ account: ResolvedOpenclawClawlingAccount;
68
+ /**
69
+ * The owner's direct conversation id (cnv_…) recorded at activation.
70
+ *
71
+ * Required and non-null: there is no valid fallback. Addressing the note to
72
+ * `account.ownerUserId` instead makes the agent's in-turn replies unroutable
73
+ * (member-backend answers "invalid conversation id"), so callers must skip
74
+ * the note entirely while no conversation is on record rather than hand a
75
+ * usr_… id down here.
76
+ */
77
+ ownerConversationId: string;
78
+ momentId: string | number;
79
+ replied: boolean;
80
+ }
81
+
82
+ export function buildMomentCommentNoteEnvelope(
83
+ params: BuildMomentCommentNoteEnvelopeParams,
84
+ ): Envelope {
85
+ const { account, ownerConversationId, momentId, replied } = params;
86
+ const now = Date.now();
87
+ const text = replied
88
+ ? `ClawChat: someone replied to your comment on a moment (id ${momentId}). Use the get_moment tool to view it and reply if appropriate.`
89
+ : `ClawChat: someone commented on your moment (id ${momentId}). Use the get_moment tool to view it and reply if appropriate.`;
90
+ return {
91
+ version: "2",
92
+ event: EVENT.MESSAGE_SEND,
93
+ trace_id: `clawchat-plugin-openclaw-moment-comment-${now}`,
94
+ emitted_at: now,
95
+ chat_id: ownerConversationId,
96
+ chat_type: "direct",
97
+ to: { id: account.userId, type: "direct" },
98
+ sender: { id: "clawchat-awareness", type: "direct", nick_name: "ClawChat" },
99
+ payload: {
100
+ message_id: `clawchat-plugin-openclaw-moment-comment-${now}`,
101
+ message_mode: "normal",
102
+ message: {
103
+ body: { fragments: [{ kind: "text", text }] },
104
+ context: { mentions: [], reply: null },
105
+ streaming: { status: "static", sequence: 0, mutation_policy: "sealed", started_at: null, completed_at: null },
106
+ },
107
+ },
108
+ } as unknown as Envelope;
109
+ }
package/src/client.ts CHANGED
@@ -16,6 +16,8 @@ export interface CreateClientOverrides {
16
16
  * instead of minting a new one and forcing a full inbox replay.
17
17
  */
18
18
  deviceIdOverride?: string;
19
+ /** Injectable clock, forwarded to the WS client. Tests only. */
20
+ now?: () => number;
19
21
  wsLifecycle?: {
20
22
  onConnectFrameSent?: (env: {
21
23
  trace_id?: unknown;
@@ -59,6 +61,7 @@ export function createOpenclawClawlingClient(
59
61
  timeout: account.ack.timeout,
60
62
  autoResendOnTimeout: account.ack.autoResendOnTimeout,
61
63
  },
64
+ ...(overrides.now ? { now: overrides.now } : {}),
62
65
  });
63
66
  if (overrides.wsLifecycle?.onConnectFrameSent) {
64
67
  const sendRawEnvelope = client.sendRawEnvelope.bind(client);