@clawling/clawchat-plugin-openclaw 2026.7.29-1 → 2026.8.2-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.
- package/dist/src/api-client.js +3 -0
- package/dist/src/awareness-note-turn.js +27 -1
- package/dist/src/client.js +1 -0
- package/dist/src/liveware-sample.js +272 -41
- package/dist/src/no-reply.js +6 -4
- package/dist/src/outbound.js +81 -3
- package/dist/src/permission-result.js +20 -5
- package/dist/src/protocol-types.js +18 -0
- package/dist/src/reply-dispatcher.js +15 -3
- package/dist/src/runtime.js +125 -12
- package/dist/src/tools-schema.js +3 -0
- package/dist/src/tools.js +14 -2
- package/dist/src/ws-alignment.js +18 -1
- package/dist/src/ws-client.js +246 -34
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/skills/clawchat-core/SKILL.md +3 -2
- package/skills/manifest.json +6 -6
- package/src/api-client.ts +4 -0
- package/src/awareness-note-turn.ts +54 -5
- package/src/client.ts +3 -0
- package/src/liveware-sample.ts +287 -41
- package/src/no-reply.ts +6 -4
- package/src/outbound.ts +118 -4
- package/src/permission-result.ts +20 -4
- package/src/protocol-types.ts +19 -0
- package/src/reply-dispatcher.ts +27 -3
- package/src/runtime.ts +145 -12
- package/src/storage.ts +8 -2
- package/src/tools-schema.ts +5 -0
- package/src/tools.ts +21 -1
- package/src/ws-alignment.ts +28 -1
- package/src/ws-client.ts +267 -32
- package/dist/src/friend-request-turn.js +0 -83
package/src/ws-client.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
MessageSendError,
|
|
7
7
|
ProtocolError,
|
|
8
8
|
StateError,
|
|
9
|
+
TERMINAL_CHAT_CODES,
|
|
9
10
|
TransportError,
|
|
10
11
|
isBusinessDispatchEvent,
|
|
11
12
|
type ConnState,
|
|
@@ -128,6 +129,8 @@ export interface ClawChatClientOptions {
|
|
|
128
129
|
};
|
|
129
130
|
heartbeat?: { enabled?: boolean; interval?: number; timeout?: number };
|
|
130
131
|
ack?: { timeout?: number; autoResendOnTimeout?: boolean };
|
|
132
|
+
/** Injectable clock; defaults to Date.now. Exists so TTL logic is testable. */
|
|
133
|
+
now?: () => number;
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
export interface NormalizedClawChatClientOptions {
|
|
@@ -139,6 +142,7 @@ export interface NormalizedClawChatClientOptions {
|
|
|
139
142
|
reconnect: Required<NonNullable<ClawChatClientOptions["reconnect"]>>;
|
|
140
143
|
heartbeat: Required<NonNullable<ClawChatClientOptions["heartbeat"]>>;
|
|
141
144
|
ack: Required<NonNullable<ClawChatClientOptions["ack"]>>;
|
|
145
|
+
now: () => number;
|
|
142
146
|
}
|
|
143
147
|
|
|
144
148
|
type PendingAck = {
|
|
@@ -168,6 +172,63 @@ export function buildConnectCapabilities(): ConnectCapabilities {
|
|
|
168
172
|
/** Bounded FIFO of conversations known to be dissolved. */
|
|
169
173
|
export const DEAD_CHATS_MAX = 512;
|
|
170
174
|
|
|
175
|
+
/**
|
|
176
|
+
* How long a server rejection keeps gating uplinks for a conversation.
|
|
177
|
+
*
|
|
178
|
+
* A rejection is a fact with a shelf life, NOT a permanent verdict: the server
|
|
179
|
+
* soft-deletes conversations and can revive one in place under the SAME `cnv_`
|
|
180
|
+
* id — which is exactly the path an agent takes when it is re-paired. A
|
|
181
|
+
* permanent mark would leave a re-paired agent silently mute forever.
|
|
182
|
+
*
|
|
183
|
+
* 10 minutes: an order of magnitude above the server's own 30s negative cache
|
|
184
|
+
* for unresolvable chats (so a post-TTL retry lands after the server has
|
|
185
|
+
* re-resolved rather than on a stale negative entry), while still bounding the
|
|
186
|
+
* waste to at most one frame per dead conversation per 10 minutes. Two faster
|
|
187
|
+
* clears (`noteChatAlive`) handle the common cases; this is only the backstop.
|
|
188
|
+
*
|
|
189
|
+
* MUST stay identical to the hermes plugin's value — cross-plugin parity is
|
|
190
|
+
* pinned by src/parity.test.ts.
|
|
191
|
+
*/
|
|
192
|
+
export const SERVER_REJECTION_TTL_MS = 600_000;
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Every ClawChat conversation id is minted by member-backend with this prefix,
|
|
196
|
+
* and msghub resolves a chat_id only through member-backend. Anything else — a
|
|
197
|
+
* `usr_…` user idcode, a host-composed `direct:{self}:{peer}` key, a bare
|
|
198
|
+
* adapter name, a placeholder — is refused upstream with
|
|
199
|
+
* `code=400: invalid conversation id`.
|
|
200
|
+
*
|
|
201
|
+
* MUST stay identical to the hermes plugin's `CHAT_ID_PREFIX`.
|
|
202
|
+
*/
|
|
203
|
+
export const CHAT_ID_PREFIX = "cnv_";
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Whether `chatId` can name a ClawChat conversation at all.
|
|
207
|
+
*
|
|
208
|
+
* A purely *static* check: it says nothing about whether the conversation
|
|
209
|
+
* exists, is alive, or admits this sender. Those are server-side questions
|
|
210
|
+
* answered by `message.error`; this one is answerable locally, and a frame that
|
|
211
|
+
* fails it is always wrong to put on the wire.
|
|
212
|
+
*/
|
|
213
|
+
export function isValidChatId(chatId: unknown): chatId is string {
|
|
214
|
+
return typeof chatId === "string" && chatId.startsWith(CHAT_ID_PREFIX);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* How a conversation came to be considered dead. The two sources write to two
|
|
219
|
+
* SEPARATE stores; nothing writes both.
|
|
220
|
+
*
|
|
221
|
+
* - `"signal"` — inferred from a `conversation.dissolved` push. Gates
|
|
222
|
+
* `typing.update` ONLY. The protocol explicitly forbids gating `message.send`
|
|
223
|
+
* on this signal, because the inference can be wrong and a false positive
|
|
224
|
+
* must never swallow a real message. Never expires.
|
|
225
|
+
* - `"server"` — the server itself rejected an uplink on this chat with a
|
|
226
|
+
* terminal code. Gates outbound sends (via `isSendBlocked`) and typing. It
|
|
227
|
+
* never causes inbound frames to be dropped and never evicts local state.
|
|
228
|
+
* Expires; see SERVER_REJECTION_TTL_MS.
|
|
229
|
+
*/
|
|
230
|
+
export type DeadChatSource = "signal" | "server";
|
|
231
|
+
|
|
171
232
|
export class ClawChatClient extends EventEmitter {
|
|
172
233
|
private currentState: ConnState = "idle";
|
|
173
234
|
private connectResolve?: () => void;
|
|
@@ -182,7 +243,12 @@ export class ClawChatClient extends EventEmitter {
|
|
|
182
243
|
private readonly pending = new Map<string, PendingAck>();
|
|
183
244
|
private readonly handledMessageErrorTraces = new Set<string>();
|
|
184
245
|
private readonly sendQueue: string[] = [];
|
|
185
|
-
|
|
246
|
+
// Tier 1 — inferred from `conversation.dissolved`. Gates typing only.
|
|
247
|
+
private readonly dissolvedBySignal = new Set<string>();
|
|
248
|
+
// Tier 2 — server-CONFIRMED rejection → expiry timestamp. Gates sends.
|
|
249
|
+
// Disjoint storage from tier 1: neither write path touches the other set.
|
|
250
|
+
private readonly serverRejectedChats = new Map<string, number>();
|
|
251
|
+
private ownerDirectChatResolver?: () => string | null;
|
|
186
252
|
|
|
187
253
|
constructor(private readonly opts: NormalizedClawChatClientOptions) {
|
|
188
254
|
super();
|
|
@@ -232,38 +298,129 @@ export class ClawChatClient extends EventEmitter {
|
|
|
232
298
|
}
|
|
233
299
|
|
|
234
300
|
/**
|
|
235
|
-
* Record a conversation as
|
|
301
|
+
* Record a conversation as dead so no further uplink is sent for it.
|
|
236
302
|
* Only `cnv_`-prefixed ids are accepted: a bad entity_id must never mute a
|
|
237
303
|
* live conversation. Returns whether the id was accepted (and thus actually
|
|
238
|
-
*
|
|
304
|
+
* recorded) so callers can log/act on rejection distinctly from acceptance.
|
|
305
|
+
*
|
|
306
|
+
* `source` selects WHICH STORE is written — see {@link DeadChatSource}. The
|
|
307
|
+
* two stores are disjoint; a signal never writes the server tier and a server
|
|
308
|
+
* rejection never writes the signal tier.
|
|
239
309
|
*
|
|
240
|
-
*
|
|
241
|
-
* insertion order
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
310
|
+
* Signal tier eviction is FIFO by first-mark order, not LRU: `Set` iteration
|
|
311
|
+
* order is insertion order and the `has()` early-return means re-marking does
|
|
312
|
+
* NOT refresh position. The server tier deliberately does the opposite — a
|
|
313
|
+
* re-rejection re-arms the TTL *and* moves the id to the tail — because its
|
|
314
|
+
* whole point is "how long since the server last said no".
|
|
245
315
|
*/
|
|
246
|
-
markChatDead(chatId: string): boolean {
|
|
247
|
-
if (
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
316
|
+
markChatDead(chatId: string, source: DeadChatSource = "signal"): boolean {
|
|
317
|
+
if (!isValidChatId(chatId)) return false;
|
|
318
|
+
if (source === "server") {
|
|
319
|
+
// Delete-then-set so the re-insert also moves the id to the FIFO tail.
|
|
320
|
+
this.serverRejectedChats.delete(chatId);
|
|
321
|
+
this.serverRejectedChats.set(chatId, this.opts.now() + SERVER_REJECTION_TTL_MS);
|
|
322
|
+
while (this.serverRejectedChats.size > DEAD_CHATS_MAX) {
|
|
323
|
+
const oldest = this.serverRejectedChats.keys().next().value as string | undefined;
|
|
324
|
+
if (oldest === undefined) break;
|
|
325
|
+
this.serverRejectedChats.delete(oldest);
|
|
326
|
+
}
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
if (this.dissolvedBySignal.has(chatId)) return true;
|
|
330
|
+
this.dissolvedBySignal.add(chatId);
|
|
331
|
+
while (this.dissolvedBySignal.size > DEAD_CHATS_MAX) {
|
|
332
|
+
const oldest = this.dissolvedBySignal.values().next().value as string | undefined;
|
|
252
333
|
if (oldest === undefined) break;
|
|
253
|
-
this.
|
|
334
|
+
this.dissolvedBySignal.delete(oldest);
|
|
254
335
|
}
|
|
255
336
|
return true;
|
|
256
337
|
}
|
|
257
338
|
|
|
339
|
+
/**
|
|
340
|
+
* True for a chat dead by EITHER tier — a derived union, not a third store.
|
|
341
|
+
* Gates `typing.update` only. Never gate message delivery on this.
|
|
342
|
+
*/
|
|
258
343
|
isChatDead(chatId: string): boolean {
|
|
259
|
-
return this.
|
|
344
|
+
return this.dissolvedBySignal.has(chatId) || this.isChatRejectedByServer(chatId);
|
|
260
345
|
}
|
|
261
346
|
|
|
262
347
|
/**
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
|
|
348
|
+
* True only for a chat the SERVER rejected with a terminal code, and only
|
|
349
|
+
* while that rejection is still fresh. Expiry is lazy: the entry is dropped
|
|
350
|
+
* on the first read past its deadline, so there is no timer to leak.
|
|
351
|
+
*/
|
|
352
|
+
isChatRejectedByServer(chatId: string): boolean {
|
|
353
|
+
const expiresAt = this.serverRejectedChats.get(chatId);
|
|
354
|
+
if (expiresAt === undefined) return false;
|
|
355
|
+
if (this.opts.now() >= expiresAt) {
|
|
356
|
+
this.serverRejectedChats.delete(chatId);
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* The ONLY predicate allowed to gate an outbound `message.send` /
|
|
364
|
+
* `message.reply` / `message.reaction`. Narrower than `isChatRejectedByServer`
|
|
365
|
+
* by one exemption: the owner's direct conversation is never blocked, because
|
|
366
|
+
* it is the only channel through which the agent can explain why it went
|
|
367
|
+
* dark. The rejection is still RECORDED for the owner chat — the exemption
|
|
368
|
+
* decides whether to send, not whether to remember.
|
|
369
|
+
*/
|
|
370
|
+
isSendBlocked(chatId: string): boolean {
|
|
371
|
+
if (!this.isChatRejectedByServer(chatId)) return false;
|
|
372
|
+
const ownerChatId = this.ownerDirectChatResolver?.();
|
|
373
|
+
return !(ownerChatId && ownerChatId === chatId);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Injected by the runtime once the owner's direct conversation is known. */
|
|
377
|
+
setOwnerDirectChatResolver(resolver: () => string | null): void {
|
|
378
|
+
this.ownerDirectChatResolver = resolver;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Evidence that a conversation is reachable again — drop any server-rejection
|
|
383
|
+
* record for it. Called from the inbound path; see `dispatchInbound`.
|
|
384
|
+
*
|
|
385
|
+
* Only the SERVER tier is cleared. The signal tier is deliberately left
|
|
386
|
+
* alone: `conversation.dissolved` gates nothing but the cosmetic typing
|
|
387
|
+
* indicator, and un-muting it on a replayed frame would be churn with no
|
|
388
|
+
* user-visible benefit.
|
|
389
|
+
*/
|
|
390
|
+
noteChatAlive(chatId: string): void {
|
|
391
|
+
if (typeof chatId !== "string" || !chatId) return;
|
|
392
|
+
this.serverRejectedChats.delete(chatId);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* `conversation.*` signals other than `dissolved` mean the server still has
|
|
397
|
+
* this conversation (created / updated / membership changed) — clear any
|
|
398
|
+
* stale rejection. `conversation.dissolved` is the opposite signal and must
|
|
399
|
+
* not clear anything.
|
|
400
|
+
*/
|
|
401
|
+
private noteConversationSignalAlive(env: Envelope): void {
|
|
402
|
+
const payload = env.payload && typeof env.payload === "object"
|
|
403
|
+
? env.payload as { type?: unknown; entity_id?: unknown }
|
|
404
|
+
: undefined;
|
|
405
|
+
const type = typeof payload?.type === "string" ? payload.type : "";
|
|
406
|
+
if (!type.startsWith("conversation.") || type === "conversation.dissolved") return;
|
|
407
|
+
const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
|
|
408
|
+
if (entityId) this.noteChatAlive(entityId);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Gated on `isChatDead` — BOTH tiers. `sendAckableEnvelope` (backing
|
|
413
|
+
* `message.send` / `message.reply`) is deliberately left unguarded against
|
|
414
|
+
* this predicate: a false-positive SIGNAL-derived dead-mark must never block
|
|
415
|
+
* message delivery, only the cosmetic `typing.update` indicator. The
|
|
416
|
+
* protocol states this explicitly for `conversation.dissolved` — "agents …
|
|
417
|
+
* stop sending typing.update for that chat_id; message.send MUST NOT be
|
|
418
|
+
* gated on this". Do not extend `isChatDead` to the send path.
|
|
419
|
+
*
|
|
420
|
+
* The send path IS gated, but on the narrower `isSendBlocked` (see
|
|
421
|
+
* src/outbound.ts) — a chat the server itself rejected is fact, not
|
|
422
|
+
* inference, so the false-positive argument above does not apply to it.
|
|
423
|
+
* That fact expires; this one does not.
|
|
267
424
|
*/
|
|
268
425
|
typing(chatId: string, isTyping = true): void {
|
|
269
426
|
if (this.isChatDead(chatId)) return;
|
|
@@ -300,11 +457,27 @@ export class ClawChatClient extends EventEmitter {
|
|
|
300
457
|
}
|
|
301
458
|
|
|
302
459
|
sendRawEnvelope(env: Envelope): void {
|
|
460
|
+
// Static addressing check. A frame carrying a chat_id that cannot name a
|
|
461
|
+
// conversation is refused by msghub with `invalid conversation id`, so it
|
|
462
|
+
// never had a recipient; dropping it here costs the caller nothing and
|
|
463
|
+
// keeps the error out of msghub's logs.
|
|
464
|
+
//
|
|
465
|
+
// Unlike the dead-chat gate — a revocable server state a queued frame can
|
|
466
|
+
// fall into, which is why `shouldDropQueuedWire` re-checks on replay — a
|
|
467
|
+
// malformed chat_id is wrong at construction time and can never become
|
|
468
|
+
// valid. Rejecting at this entry point is therefore sufficient: the frame
|
|
469
|
+
// never reaches the reconnect queue, so no replay path can resurrect it.
|
|
470
|
+
//
|
|
471
|
+
// Frames with no chat_id (connect / ping / pong) are untouched.
|
|
472
|
+
if (env.chat_id !== undefined && !isValidChatId(env.chat_id)) return;
|
|
303
473
|
this.sendWire(JSON.stringify(env), { bypassReconnectQueue: env.event === EVENT.CONNECT });
|
|
304
474
|
}
|
|
305
475
|
|
|
306
|
-
// Deliberately does NOT consult
|
|
307
|
-
//
|
|
476
|
+
// Deliberately does NOT consult the dead-chat state — see the comment on
|
|
477
|
+
// `typing()`. Signal-inferred death must never gate message delivery. (The
|
|
478
|
+
// narrower `isSendBlocked` gate lives on the aligned path in src/outbound.ts,
|
|
479
|
+
// which is what the reply dispatcher actually uses; this legacy ackable path
|
|
480
|
+
// is kept ungated so the two tiers stay distinguishable.)
|
|
308
481
|
async sendAckableEnvelope(params: {
|
|
309
482
|
eventName: "message.send" | "message.reply";
|
|
310
483
|
chatId: string;
|
|
@@ -415,6 +588,13 @@ export class ClawChatClient extends EventEmitter {
|
|
|
415
588
|
}
|
|
416
589
|
|
|
417
590
|
private dispatchInbound(env: Envelope): void {
|
|
591
|
+
// A frame the server routed to us for this chat proves the chat is
|
|
592
|
+
// reachable again — a deleted conversation can be revived in place under
|
|
593
|
+
// the same id. `message.error` is excluded: it IS the rejection, not
|
|
594
|
+
// evidence of life.
|
|
595
|
+
if (typeof env.chat_id === "string" && env.event !== EVENT.MESSAGE_ERROR) {
|
|
596
|
+
this.noteChatAlive(env.chat_id);
|
|
597
|
+
}
|
|
418
598
|
if (env.event === EVENT.CONNECT_CHALLENGE) return this.onChallenge(env);
|
|
419
599
|
if (env.event === EVENT.HELLO_OK) return this.onHelloOk(env);
|
|
420
600
|
if (env.event === EVENT.HELLO_FAIL) return this.onHelloFail(env);
|
|
@@ -429,6 +609,7 @@ export class ClawChatClient extends EventEmitter {
|
|
|
429
609
|
if (env.event === EVENT.MESSAGE_FAILED) this.emit("message:failed", env);
|
|
430
610
|
if (env.event === EVENT.TYPING_UPDATE) this.emit("typing", env);
|
|
431
611
|
if (env.event === EVENT.CHAT_METADATA_INVALIDATED) this.emit("metadata:invalidated", env);
|
|
612
|
+
if (env.event === EVENT.NOTIFY_SIGNAL) this.noteConversationSignalAlive(env);
|
|
432
613
|
if (env.event === EVENT.NOTIFY_SIGNAL) this.emit("notify:signal", env);
|
|
433
614
|
if (env.event === EVENT.REPLAY_DONE) this.emit("replay:done", env);
|
|
434
615
|
if (env.event === EVENT.OFFLINE_DONE) this.emit("offline:done");
|
|
@@ -576,6 +757,20 @@ export class ClawChatClient extends EventEmitter {
|
|
|
576
757
|
}
|
|
577
758
|
|
|
578
759
|
private onMessageError(env: Envelope<MessageErrorPayload>): void {
|
|
760
|
+
const payload = env.payload && typeof env.payload === "object"
|
|
761
|
+
? env.payload as { code?: unknown; reason?: unknown; message?: unknown }
|
|
762
|
+
: undefined;
|
|
763
|
+
const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
|
|
764
|
+
// Mark BEFORE the pending-ack lookup: an unmatched error frame still
|
|
765
|
+
// carries an authoritative `chat_id` + `code` and must participate in the
|
|
766
|
+
// dead-chat decision. Unmatched is the NORMAL case here — the aligned queue
|
|
767
|
+
// owns its own ack matching (markMessageErrorHandled makes the branch below
|
|
768
|
+
// return silently) and a reconnect drops pending entries. Deferring the
|
|
769
|
+
// mark until after the lookup would make tier-2 feeding random.
|
|
770
|
+
// Parity: hermes notifies its dead-chat state at the same point.
|
|
771
|
+
if (typeof env.chat_id === "string" && TERMINAL_CHAT_CODES.has(code)) {
|
|
772
|
+
this.markChatDead(env.chat_id, "server");
|
|
773
|
+
}
|
|
579
774
|
const entry = this.pending.get(env.trace_id);
|
|
580
775
|
if (!entry) {
|
|
581
776
|
if (this.handledMessageErrorTraces.delete(env.trace_id)) return;
|
|
@@ -584,10 +779,6 @@ export class ClawChatClient extends EventEmitter {
|
|
|
584
779
|
}
|
|
585
780
|
clearTimeout(entry.timer);
|
|
586
781
|
this.pending.delete(env.trace_id);
|
|
587
|
-
const payload = env.payload && typeof env.payload === "object"
|
|
588
|
-
? env.payload as { code?: unknown; reason?: unknown; message?: unknown }
|
|
589
|
-
: undefined;
|
|
590
|
-
const code = typeof payload?.code === "string" && payload.code ? payload.code : "unknown";
|
|
591
782
|
// §14.3: the human-readable hint is `reason` (fall back to legacy `message`).
|
|
592
783
|
const hint = typeof payload?.reason === "string" && payload.reason
|
|
593
784
|
? payload.reason
|
|
@@ -710,13 +901,24 @@ export class ClawChatClient extends EventEmitter {
|
|
|
710
901
|
|
|
711
902
|
private flushSendQueue(): void {
|
|
712
903
|
while (this.sendQueue.length > 0) {
|
|
713
|
-
const wire = this.sendQueue
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
904
|
+
const wire = this.sendQueue[0]!;
|
|
905
|
+
// Re-check the dead-chat gates at replay time: a conversation can be
|
|
906
|
+
// rejected AFTER a frame was buffered here, and this path bypasses every
|
|
907
|
+
// caller-side guard.
|
|
908
|
+
if (
|
|
909
|
+
shouldDropQueuedWire(wire, {
|
|
910
|
+
isChatDead: (chatId) => this.isChatDead(chatId),
|
|
911
|
+
isSendBlocked: (chatId) => this.isSendBlocked(chatId),
|
|
912
|
+
})
|
|
913
|
+
) {
|
|
914
|
+
this.sendQueue.shift();
|
|
915
|
+
continue;
|
|
719
916
|
}
|
|
917
|
+
// Read the head first and shift only after a successful write, so a
|
|
918
|
+
// failure leaves the frame at the head (same as the previous
|
|
919
|
+
// shift/unshift pair; `onHelloOk`'s catch closes and reconnects).
|
|
920
|
+
this.sendWire(wire);
|
|
921
|
+
this.sendQueue.shift();
|
|
720
922
|
}
|
|
721
923
|
}
|
|
722
924
|
|
|
@@ -735,6 +937,38 @@ export class ClawChatClient extends EventEmitter {
|
|
|
735
937
|
}
|
|
736
938
|
}
|
|
737
939
|
|
|
940
|
+
/**
|
|
941
|
+
* Decide whether a wire string sitting in the transport-level reconnect queue
|
|
942
|
+
* must be dropped instead of replayed.
|
|
943
|
+
*
|
|
944
|
+
* This queue holds ALREADY-SERIALIZED frames, so the only way to see the
|
|
945
|
+
* `chat_id` is to parse. That is acceptable here and nowhere else: the queue
|
|
946
|
+
* fills only during a reconnect window and is drained exactly once per
|
|
947
|
+
* successful handshake — unlike `sendWire`, which is the hot path.
|
|
948
|
+
*
|
|
949
|
+
* Asymmetry mirrors the live path: `typing.update` is gated on EITHER tier
|
|
950
|
+
* (`isChatDead`), everything else only on a server-confirmed, non-owner
|
|
951
|
+
* rejection (`isSendBlocked`). Unparseable or chat-less frames are always kept:
|
|
952
|
+
* `connect` / `ping` / `pong` must never be dropped.
|
|
953
|
+
*
|
|
954
|
+
* Exported for direct unit testing; `flushSendQueue` is the only producer.
|
|
955
|
+
*/
|
|
956
|
+
export function shouldDropQueuedWire(
|
|
957
|
+
wire: string,
|
|
958
|
+
gates: { isChatDead: (chatId: string) => boolean; isSendBlocked: (chatId: string) => boolean },
|
|
959
|
+
): boolean {
|
|
960
|
+
let env: { event?: unknown; chat_id?: unknown };
|
|
961
|
+
try {
|
|
962
|
+
env = JSON.parse(wire) as { event?: unknown; chat_id?: unknown };
|
|
963
|
+
} catch {
|
|
964
|
+
return false;
|
|
965
|
+
}
|
|
966
|
+
const chatId = typeof env.chat_id === "string" ? env.chat_id : "";
|
|
967
|
+
if (!chatId) return false;
|
|
968
|
+
if (env.event === EVENT.TYPING_UPDATE) return gates.isChatDead(chatId);
|
|
969
|
+
return gates.isSendBlocked(chatId);
|
|
970
|
+
}
|
|
971
|
+
|
|
738
972
|
export function createClawChatClient(options: ClawChatClientOptions): ClawChatClient {
|
|
739
973
|
return new ClawChatClient({
|
|
740
974
|
...options,
|
|
@@ -758,6 +992,7 @@ export function createClawChatClient(options: ClawChatClientOptions): ClawChatCl
|
|
|
758
992
|
timeout: options.ack?.timeout ?? 15000,
|
|
759
993
|
autoResendOnTimeout: options.ack?.autoResendOnTimeout ?? false,
|
|
760
994
|
},
|
|
995
|
+
now: options.now ?? (() => Date.now()),
|
|
761
996
|
});
|
|
762
997
|
}
|
|
763
998
|
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Synthetic reasoning-turn builder for `friend.request` notify.signal events.
|
|
3
|
-
*
|
|
4
|
-
* When the server fires a `friend.request` signal the runtime dispatches one
|
|
5
|
-
* deduped synthetic `message.send` envelope so the agent can reason about the
|
|
6
|
-
* pending request. The prompt text varies by the current "friend.add" permission
|
|
7
|
-
* policy state: a deny policy yields a decline/inform prompt; ask or allow
|
|
8
|
-
* yields a prompt that instructs the agent to review the request via the
|
|
9
|
-
* `clawchat_list_friend_requests` tool.
|
|
10
|
-
*/
|
|
11
|
-
import { EVENT } from "./protocol-types.js";
|
|
12
|
-
/**
|
|
13
|
-
* Returns canned prompt text for a friend-request reasoning turn based on the
|
|
14
|
-
* current "friend.add" permission policy state.
|
|
15
|
-
*
|
|
16
|
-
* - `deny` → inform the agent it should decline; no accept instruction.
|
|
17
|
-
* - `ask` / `allow` → instruct the agent to review the pending request.
|
|
18
|
-
*
|
|
19
|
-
* The requester's user id is embedded so the agent does not go looking up the
|
|
20
|
-
* synthetic sender ("clawchat-friend-request" is not a real user).
|
|
21
|
-
*/
|
|
22
|
-
export function friendRequestPromptFor(state, requesterUserId) {
|
|
23
|
-
const arrived = requesterUserId
|
|
24
|
-
? `A new friend request has arrived from user ${requesterUserId}.`
|
|
25
|
-
: "A new friend request has arrived.";
|
|
26
|
-
if (state === "deny") {
|
|
27
|
-
return [
|
|
28
|
-
arrived,
|
|
29
|
-
"Your current friend-add policy is set to deny.",
|
|
30
|
-
"Do not add this contact.",
|
|
31
|
-
"You may inform the requester that you cannot add them at this time.",
|
|
32
|
-
].join(" ");
|
|
33
|
-
}
|
|
34
|
-
return [
|
|
35
|
-
arrived,
|
|
36
|
-
"Please review the pending request by calling `clawchat_list_friend_requests`",
|
|
37
|
-
"and decide whether to accept it.",
|
|
38
|
-
].join(" ");
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Builds a synthetic `message.send` envelope that triggers one agent reasoning
|
|
42
|
-
* turn for a pending friend request. The envelope shape mirrors the
|
|
43
|
-
* activation-bootstrap envelope; it targets the owner's direct conversation so
|
|
44
|
-
* the agent has context about who it is reasoning for.
|
|
45
|
-
*/
|
|
46
|
-
export function buildFriendRequestEnvelope(params) {
|
|
47
|
-
const { account, state, entityId, ownerConversationId } = params;
|
|
48
|
-
const text = friendRequestPromptFor(state, entityId);
|
|
49
|
-
const now = Date.now();
|
|
50
|
-
// Fall back to ownerUserId only when no activation conversation is recorded:
|
|
51
|
-
// the turn still runs (the agent can act via tools, e.g. accept the request),
|
|
52
|
-
// but its in-chat replies will not be deliverable until activation records
|
|
53
|
-
// the owner conversation.
|
|
54
|
-
return {
|
|
55
|
-
version: "2",
|
|
56
|
-
event: EVENT.MESSAGE_SEND,
|
|
57
|
-
trace_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
|
|
58
|
-
emitted_at: now,
|
|
59
|
-
chat_id: ownerConversationId ?? account.ownerUserId,
|
|
60
|
-
chat_type: "direct",
|
|
61
|
-
to: { id: account.userId, type: "direct" },
|
|
62
|
-
sender: {
|
|
63
|
-
id: "clawchat-friend-request",
|
|
64
|
-
type: "direct",
|
|
65
|
-
nick_name: "ClawChat",
|
|
66
|
-
},
|
|
67
|
-
payload: {
|
|
68
|
-
message_id: `clawchat-plugin-openclaw-friend-request-${entityId}-${now}`,
|
|
69
|
-
message_mode: "normal",
|
|
70
|
-
message: {
|
|
71
|
-
body: { fragments: [{ kind: "text", text }] },
|
|
72
|
-
context: { mentions: [], reply: null },
|
|
73
|
-
streaming: {
|
|
74
|
-
status: "static",
|
|
75
|
-
sequence: 0,
|
|
76
|
-
mutation_policy: "sealed",
|
|
77
|
-
started_at: null,
|
|
78
|
-
completed_at: null,
|
|
79
|
-
},
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
};
|
|
83
|
-
}
|