@clawling/clawchat-plugin-openclaw 2026.5.12-38 → 2026.5.13-dev.0

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.
@@ -13,6 +13,8 @@ export const EVENT = {
13
13
  MESSAGE_FAILED: "message.failed",
14
14
  TYPING_UPDATE: "typing.update",
15
15
  CHAT_METADATA_INVALIDATED: "chat.metadata.invalidated",
16
+ NOTIFY_SIGNAL: "notify.signal",
17
+ REPLAY_DONE: "replay.done",
16
18
  OFFLINE_BATCH: "offline.batch",
17
19
  OFFLINE_ACK: "offline.ack",
18
20
  OFFLINE_DONE: "offline.done",
@@ -12,7 +12,7 @@ import { createOpenclawClawlingReplyDispatcher } from "./reply-dispatcher.js";
12
12
  import { runWithTerminalClawChatSendScope } from "./terminal-send.js";
13
13
  import { flushAlignedOutboundQueue, getAlignedOutboundQueueSize, sendOpenclawClawlingText, setAlignedOutboundLogContext, } from "./outbound.js";
14
14
  import { formatWsLog } from "./ws-log.js";
15
- import { createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.js";
15
+ import { createNotifySignalObserver, createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.js";
16
16
  import { clawChatDbPathForStateDir, getClawChatStore, } from "./storage.js";
17
17
  import { getClawChatGroupPrompt, getClawChatUserPrompt } from "./plugin-prompts.js";
18
18
  import { loadClawChatPromptMetadata, renderClawChatProfilePrompt, resolveSenderRelation, } from "./profile-prompt.js";
@@ -641,6 +641,11 @@ export async function startOpenclawClawlingGateway(params) {
641
641
  send: () => { },
642
642
  context: wsLogContext,
643
643
  });
644
+ const notifySignalObserver = createNotifySignalObserver({
645
+ accountId,
646
+ log: (msg) => log?.info?.(msg),
647
+ context: wsLogContext,
648
+ });
644
649
  const logAuthFailure = (reason) => {
645
650
  if (authFailureLogged)
646
651
  return;
@@ -863,6 +868,19 @@ export async function startOpenclawClawlingGateway(params) {
863
868
  client.on("metadata:invalidated", (env) => {
864
869
  void handleMetadataInvalidation(env);
865
870
  });
871
+ client.on("notify:signal", (env) => {
872
+ // §9.4 reliable system notification. The plugin holds no friend/roster
873
+ // cache (friends are fetched on demand via REST tools), so there is nothing
874
+ // to invalidate — observe + dedup only. The live frame and its reliable
875
+ // inbox replay carry the same event_id and collapse to one observation.
876
+ notifySignalObserver.observe(env);
877
+ });
878
+ client.on("replay:done", (env) => {
879
+ // §11.5 terminal control frame: device replay drained, live delivery begins.
880
+ // Fires on every reconnect (even zero-backlog). Replayed messages are
881
+ // processed inline, so this is a logged boundary marker, not a gate.
882
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw replay.done trace=${env.trace_id}`);
883
+ });
866
884
  client.on("error", (err) => {
867
885
  const classified = classifyClawlingClientError(err);
868
886
  if (classified.kind === "auth") {
@@ -176,3 +176,70 @@ export function createProtocolControlHandler(options) {
176
176
  },
177
177
  };
178
178
  }
179
+ /**
180
+ * Observes reliable `notify.signal` frames (§9.4). The agent plugin keeps no
181
+ * friend/roster cache (friends are fetched on demand via REST tools), so there
182
+ * is nothing to invalidate — this is a pure observability hook: it dedups by
183
+ * `event_id` (the live frame and its reliable-inbox replay collapse to one),
184
+ * structured-logs the signal, and returns the outcome. It deliberately takes no
185
+ * action on the agent; wire a real reaction here if the product later needs one.
186
+ */
187
+ export function createNotifySignalObserver(options) {
188
+ const maxSeen = options.maxSeen ?? 512;
189
+ const seen = new Set();
190
+ const order = [];
191
+ const context = () => options.context?.() ?? { attempt: 1, reconnectCount: 0, state: "ready" };
192
+ const logSignal = (event, action, fields) => {
193
+ const current = context();
194
+ options.log(formatWsLog({
195
+ event,
196
+ accountId: options.accountId,
197
+ attempt: current.attempt,
198
+ reconnectCount: current.reconnectCount,
199
+ state: current.state,
200
+ action,
201
+ fields,
202
+ }));
203
+ };
204
+ return {
205
+ /** Returns whether this signal was newly observed, a duplicate, or malformed. */
206
+ observe(env) {
207
+ const payload = env.payload && typeof env.payload === "object"
208
+ ? env.payload
209
+ : undefined;
210
+ const eventId = typeof payload?.event_id === "string" ? payload.event_id : "";
211
+ const type = typeof payload?.type === "string" ? payload.type : "";
212
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
213
+ const version = typeof payload?.version === "number" ? payload.version : undefined;
214
+ if (!eventId || !type) {
215
+ logSignal("notify_signal_invalid", "ignore", [
216
+ ["trace_id", env.trace_id],
217
+ ["type", type || null],
218
+ ["event_id", eventId || null],
219
+ ]);
220
+ return "invalid";
221
+ }
222
+ if (seen.has(eventId)) {
223
+ logSignal("notify_signal_duplicate", "ignore", [
224
+ ["type", type],
225
+ ["event_id", eventId],
226
+ ]);
227
+ return "duplicate";
228
+ }
229
+ seen.add(eventId);
230
+ order.push(eventId);
231
+ while (order.length > maxSeen) {
232
+ const evicted = order.shift();
233
+ if (evicted !== undefined)
234
+ seen.delete(evicted);
235
+ }
236
+ logSignal("notify_signal_observed", "observe", [
237
+ ["type", type],
238
+ ["entity_id", entityId || null],
239
+ ["version", version ?? null],
240
+ ["event_id", eventId],
241
+ ]);
242
+ return "observed";
243
+ },
244
+ };
245
+ }
@@ -301,6 +301,10 @@ export class ClawChatClient extends EventEmitter {
301
301
  this.emit("typing", env);
302
302
  if (env.event === EVENT.CHAT_METADATA_INVALIDATED)
303
303
  this.emit("metadata:invalidated", env);
304
+ if (env.event === EVENT.NOTIFY_SIGNAL)
305
+ this.emit("notify:signal", env);
306
+ if (env.event === EVENT.REPLAY_DONE)
307
+ this.emit("replay:done", env);
304
308
  if (env.event === EVENT.OFFLINE_DONE)
305
309
  this.emit("offline:done");
306
310
  }
@@ -322,7 +326,15 @@ export class ClawChatClient extends EventEmitter {
322
326
  token: this.opts.token,
323
327
  nonce,
324
328
  ...(this.opts.deviceId ? { device_id: this.opts.deviceId } : {}),
325
- capabilities: { multi_device: true, device_replay: true, chat_meta_events: true },
329
+ // Agent runtime is single-device: multi_device stays off so the server
330
+ // never self-fans-out this connection's own messages. notify_signals is
331
+ // advertised because we now handle the notify.signal frame (§9.4).
332
+ capabilities: {
333
+ multi_device: false,
334
+ device_replay: true,
335
+ chat_meta_events: true,
336
+ notify_signals: true,
337
+ },
326
338
  };
327
339
  const traceId = this.nextTraceId();
328
340
  this.expectedConnectTraceId = traceId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clawling/clawchat-plugin-openclaw",
3
- "version": "2026.5.12-38",
3
+ "version": "2026.5.13-dev.0",
4
4
  "description": "OpenClaw ClawChat channel plugin",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -13,6 +13,8 @@ export const EVENT = {
13
13
  MESSAGE_FAILED: "message.failed",
14
14
  TYPING_UPDATE: "typing.update",
15
15
  CHAT_METADATA_INVALIDATED: "chat.metadata.invalidated",
16
+ NOTIFY_SIGNAL: "notify.signal",
17
+ REPLAY_DONE: "replay.done",
16
18
  OFFLINE_BATCH: "offline.batch",
17
19
  OFFLINE_ACK: "offline.ack",
18
20
  OFFLINE_DONE: "offline.done",
@@ -78,6 +80,11 @@ export interface ConnectCapabilities {
78
80
  multi_device?: boolean;
79
81
  device_replay?: boolean;
80
82
  chat_meta_events?: boolean;
83
+ delivery_receipt?: boolean;
84
+ notify_signals?: boolean;
85
+ permission_events?: boolean;
86
+ history_sync?: boolean;
87
+ e2ee?: boolean;
81
88
  }
82
89
 
83
90
  export interface ConnectPayload {
@@ -207,6 +214,25 @@ export interface ChatMetadataInvalidatedPayload {
207
214
  updated_at?: number;
208
215
  }
209
216
 
217
+ /**
218
+ * Reliable, inbox-coalesced system notification (§9.4). Content-free — only
219
+ * enough identity to dedup and to decide which REST surface to refetch. The
220
+ * agent plugin keeps no friend/roster cache, so this is consumed as an
221
+ * observability signal (see `createNotifySignalObserver`), not a cache refresh.
222
+ */
223
+ export interface NotifySignalPayload {
224
+ /** Logical event type the client routes on, e.g. `friend.added`. */
225
+ type: string;
226
+ /** Id of the changed entity (meaning depends on `type`). */
227
+ entity_id: string;
228
+ /** Monotonic cursor (ms since epoch at mutation time). */
229
+ version: number;
230
+ /** Globally-unique id for this signal occurrence — cross-channel dedup key. */
231
+ event_id: string;
232
+ /** Inbox coalesce key, formatted `notify:{type}:{entity_id}`. */
233
+ message_id: string;
234
+ }
235
+
210
236
  export interface StreamCreatedPayload {
211
237
  message_id: string;
212
238
  message_mode?: string;
package/src/runtime.ts CHANGED
@@ -35,7 +35,7 @@ import {
35
35
  setAlignedOutboundLogContext,
36
36
  } from "./outbound.ts";
37
37
  import { formatWsLog } from "./ws-log.ts";
38
- import { createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.ts";
38
+ import { createNotifySignalObserver, createProtocolControlHandler, createReconnectTracker } from "./ws-alignment.ts";
39
39
  import {
40
40
  clawChatDbPathForStateDir,
41
41
  getClawChatStore,
@@ -837,6 +837,11 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
837
837
  send: () => {},
838
838
  context: wsLogContext,
839
839
  });
840
+ const notifySignalObserver = createNotifySignalObserver({
841
+ accountId,
842
+ log: (msg) => log?.info?.(msg),
843
+ context: wsLogContext,
844
+ });
840
845
  const logAuthFailure = (reason: string) => {
841
846
  if (authFailureLogged) return;
842
847
  authFailureLogged = true;
@@ -1076,6 +1081,21 @@ export async function startOpenclawClawlingGateway(params: StartGatewayParams):
1076
1081
  void handleMetadataInvalidation(env);
1077
1082
  });
1078
1083
 
1084
+ client.on("notify:signal", (env: Envelope) => {
1085
+ // §9.4 reliable system notification. The plugin holds no friend/roster
1086
+ // cache (friends are fetched on demand via REST tools), so there is nothing
1087
+ // to invalidate — observe + dedup only. The live frame and its reliable
1088
+ // inbox replay carry the same event_id and collapse to one observation.
1089
+ notifySignalObserver.observe(env);
1090
+ });
1091
+
1092
+ client.on("replay:done", (env: Envelope) => {
1093
+ // §11.5 terminal control frame: device replay drained, live delivery begins.
1094
+ // Fires on every reconnect (even zero-backlog). Replayed messages are
1095
+ // processed inline, so this is a logged boundary marker, not a gate.
1096
+ log?.info?.(`[${accountId}] clawchat-plugin-openclaw replay.done trace=${env.trace_id}`);
1097
+ });
1098
+
1079
1099
  client.on("error", (err: unknown) => {
1080
1100
  const classified = classifyClawlingClientError(err);
1081
1101
  if (classified.kind === "auth") {
@@ -273,3 +273,100 @@ export function createProtocolControlHandler(options: CreateProtocolControlHandl
273
273
  },
274
274
  };
275
275
  }
276
+
277
+ export interface NotifySignalEnvelope {
278
+ event?: string;
279
+ trace_id?: string;
280
+ payload?: unknown;
281
+ }
282
+
283
+ export interface CreateNotifySignalObserverOptions {
284
+ accountId: string;
285
+ log: (msg: string) => void;
286
+ context?: () => WsLogContext;
287
+ /** Upper bound on retained event_ids for dedup (FIFO eviction). */
288
+ maxSeen?: number;
289
+ }
290
+
291
+ export type NotifySignalOutcome = "observed" | "duplicate" | "invalid";
292
+
293
+ /**
294
+ * Observes reliable `notify.signal` frames (§9.4). The agent plugin keeps no
295
+ * friend/roster cache (friends are fetched on demand via REST tools), so there
296
+ * is nothing to invalidate — this is a pure observability hook: it dedups by
297
+ * `event_id` (the live frame and its reliable-inbox replay collapse to one),
298
+ * structured-logs the signal, and returns the outcome. It deliberately takes no
299
+ * action on the agent; wire a real reaction here if the product later needs one.
300
+ */
301
+ export function createNotifySignalObserver(options: CreateNotifySignalObserverOptions) {
302
+ const maxSeen = options.maxSeen ?? 512;
303
+ const seen = new Set<string>();
304
+ const order: string[] = [];
305
+
306
+ const context = (): WsLogContext =>
307
+ options.context?.() ?? { attempt: 1, reconnectCount: 0, state: "ready" };
308
+
309
+ const logSignal = (
310
+ event: string,
311
+ action: string,
312
+ fields: Array<[string, string | number | boolean | null | undefined]>,
313
+ ) => {
314
+ const current = context();
315
+ options.log(
316
+ formatWsLog({
317
+ event,
318
+ accountId: options.accountId,
319
+ attempt: current.attempt,
320
+ reconnectCount: current.reconnectCount,
321
+ state: current.state,
322
+ action,
323
+ fields,
324
+ }),
325
+ );
326
+ };
327
+
328
+ return {
329
+ /** Returns whether this signal was newly observed, a duplicate, or malformed. */
330
+ observe(env: NotifySignalEnvelope): NotifySignalOutcome {
331
+ const payload = env.payload && typeof env.payload === "object"
332
+ ? env.payload as Record<string, unknown>
333
+ : undefined;
334
+ const eventId = typeof payload?.event_id === "string" ? payload.event_id : "";
335
+ const type = typeof payload?.type === "string" ? payload.type : "";
336
+ const entityId = typeof payload?.entity_id === "string" ? payload.entity_id : "";
337
+ const version = typeof payload?.version === "number" ? payload.version : undefined;
338
+
339
+ if (!eventId || !type) {
340
+ logSignal("notify_signal_invalid", "ignore", [
341
+ ["trace_id", env.trace_id],
342
+ ["type", type || null],
343
+ ["event_id", eventId || null],
344
+ ]);
345
+ return "invalid";
346
+ }
347
+
348
+ if (seen.has(eventId)) {
349
+ logSignal("notify_signal_duplicate", "ignore", [
350
+ ["type", type],
351
+ ["event_id", eventId],
352
+ ]);
353
+ return "duplicate";
354
+ }
355
+
356
+ seen.add(eventId);
357
+ order.push(eventId);
358
+ while (order.length > maxSeen) {
359
+ const evicted = order.shift();
360
+ if (evicted !== undefined) seen.delete(evicted);
361
+ }
362
+
363
+ logSignal("notify_signal_observed", "observe", [
364
+ ["type", type],
365
+ ["entity_id", entityId || null],
366
+ ["version", version ?? null],
367
+ ["event_id", eventId],
368
+ ]);
369
+ return "observed";
370
+ },
371
+ };
372
+ }
package/src/ws-client.ts CHANGED
@@ -369,6 +369,8 @@ export class ClawChatClient extends EventEmitter {
369
369
  if (env.event === EVENT.MESSAGE_FAILED) this.emit("message:failed", env);
370
370
  if (env.event === EVENT.TYPING_UPDATE) this.emit("typing", env);
371
371
  if (env.event === EVENT.CHAT_METADATA_INVALIDATED) this.emit("metadata:invalidated", env);
372
+ if (env.event === EVENT.NOTIFY_SIGNAL) this.emit("notify:signal", env);
373
+ if (env.event === EVENT.REPLAY_DONE) this.emit("replay:done", env);
372
374
  if (env.event === EVENT.OFFLINE_DONE) this.emit("offline:done");
373
375
  }
374
376
 
@@ -389,7 +391,15 @@ export class ClawChatClient extends EventEmitter {
389
391
  token: this.opts.token,
390
392
  nonce,
391
393
  ...(this.opts.deviceId ? { device_id: this.opts.deviceId } : {}),
392
- capabilities: { multi_device: true, device_replay: true, chat_meta_events: true },
394
+ // Agent runtime is single-device: multi_device stays off so the server
395
+ // never self-fans-out this connection's own messages. notify_signals is
396
+ // advertised because we now handle the notify.signal frame (§9.4).
397
+ capabilities: {
398
+ multi_device: false,
399
+ device_replay: true,
400
+ chat_meta_events: true,
401
+ notify_signals: true,
402
+ },
393
403
  };
394
404
  const traceId = this.nextTraceId();
395
405
  this.expectedConnectTraceId = traceId;
@@ -1,177 +0,0 @@
1
- import { emitStreamAdd, emitStreamCreated, emitStreamDone, emitStreamFailed, } from "./client.js";
2
- /**
3
- * Merge two views of the same progressively-revealed text.
4
- *
5
- * The agent runner may give us either:
6
- * - full snapshots ("Hel", "Hello", "Hello, world") where each item is
7
- * a superset of the previous; or
8
- * - overlapping slices ("hello ", "world hello ") that don't share a
9
- * prefix but share an overlap at the join.
10
- *
11
- * This helper returns a longest-sensible combined string. Ported from
12
- * `clawling-channel/src/reply-dispatcher.ts`.
13
- */
14
- export function mergeStreamingText(previousText, nextText) {
15
- const currentSnapshot = typeof previousText === "string" ? previousText : "";
16
- const incomingText = typeof nextText === "string" ? nextText : "";
17
- if (!incomingText)
18
- return currentSnapshot;
19
- if (!currentSnapshot || incomingText === currentSnapshot)
20
- return incomingText;
21
- if (incomingText.startsWith(currentSnapshot))
22
- return incomingText;
23
- if (currentSnapshot.startsWith(incomingText))
24
- return currentSnapshot;
25
- if (incomingText.includes(currentSnapshot))
26
- return incomingText;
27
- if (currentSnapshot.includes(incomingText))
28
- return currentSnapshot;
29
- const maxOverlap = Math.min(currentSnapshot.length, incomingText.length);
30
- for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
31
- if (currentSnapshot.slice(-overlap) === incomingText.slice(0, overlap)) {
32
- return `${currentSnapshot}${incomingText.slice(overlap)}`;
33
- }
34
- }
35
- return `${currentSnapshot}${incomingText}`;
36
- }
37
- function resolveRouting(options) {
38
- if (options.routing)
39
- return options.routing;
40
- if (options.to)
41
- return { chatId: options.to.id, chatType: options.to.type };
42
- throw new Error("openclaw-clawchat buffered stream requires routing");
43
- }
44
- /**
45
- * Build a streaming session wrapper around message.created/add/done events.
46
- *
47
- * Usage pattern (matching clawling-channel):
48
- * const session = openBufferedStreamingSession({...});
49
- * await session.queueSnapshot("Hel");
50
- * await session.queueSnapshot("Hello");
51
- * await session.queueDelta(", world");
52
- * await session.done();
53
- */
54
- export function openBufferedStreamingSession(options) {
55
- const routing = resolveRouting(options);
56
- const emitTyping = options.emitTyping !== false;
57
- if (emitTyping)
58
- options.client.typing(routing.chatId, true);
59
- emitStreamCreated(options.client, {
60
- messageId: options.messageId,
61
- routing,
62
- });
63
- let bufferedSnapshot = "";
64
- let flushedSnapshot = "";
65
- let sequence = -1;
66
- let flushTimer = null;
67
- let pendingFlush = Promise.resolve();
68
- let closed = false;
69
- const clearTimer = () => {
70
- if (flushTimer) {
71
- clearTimeout(flushTimer);
72
- flushTimer = null;
73
- }
74
- };
75
- const performFlush = async () => {
76
- clearTimer();
77
- if (closed)
78
- return;
79
- if (bufferedSnapshot === flushedSnapshot)
80
- return;
81
- const snapshot = bufferedSnapshot;
82
- const delta = snapshot.slice(flushedSnapshot.length);
83
- if (!delta)
84
- return;
85
- sequence += 1;
86
- emitStreamAdd(options.client, {
87
- messageId: options.messageId,
88
- routing,
89
- sequence,
90
- fullText: snapshot,
91
- textDelta: delta,
92
- });
93
- flushedSnapshot = snapshot;
94
- };
95
- const flush = async () => {
96
- pendingFlush = pendingFlush.then(performFlush);
97
- await pendingFlush;
98
- };
99
- const scheduleFlush = () => {
100
- if (flushTimer || closed)
101
- return;
102
- flushTimer = setTimeout(() => {
103
- flushTimer = null;
104
- void flush();
105
- }, options.flushIntervalMs);
106
- };
107
- const queueSnapshot = async (snapshot) => {
108
- if (closed || !snapshot)
109
- return;
110
- const base = bufferedSnapshot.length >= flushedSnapshot.length ? bufferedSnapshot : flushedSnapshot;
111
- const merged = mergeStreamingText(base, snapshot);
112
- if (merged === bufferedSnapshot)
113
- return;
114
- bufferedSnapshot = merged;
115
- const deltaChars = Math.max(0, bufferedSnapshot.length - flushedSnapshot.length);
116
- if (deltaChars >= options.minChunkChars || bufferedSnapshot.length >= options.maxBufferChars) {
117
- await flush();
118
- }
119
- else {
120
- scheduleFlush();
121
- }
122
- };
123
- const queueDelta = async (delta) => {
124
- if (closed || !delta)
125
- return;
126
- bufferedSnapshot = `${bufferedSnapshot}${delta}`;
127
- const deltaChars = Math.max(0, bufferedSnapshot.length - flushedSnapshot.length);
128
- if (deltaChars >= options.minChunkChars || bufferedSnapshot.length >= options.maxBufferChars) {
129
- await flush();
130
- }
131
- else {
132
- scheduleFlush();
133
- }
134
- };
135
- const done = async () => {
136
- if (closed)
137
- return;
138
- await flush();
139
- closed = true;
140
- clearTimer();
141
- emitStreamDone(options.client, {
142
- messageId: options.messageId,
143
- routing,
144
- finalSequence: Math.max(sequence, 0),
145
- finalText: bufferedSnapshot,
146
- });
147
- if (emitTyping)
148
- options.client.typing(routing.chatId, false);
149
- };
150
- const fail = async (reason) => {
151
- if (closed)
152
- return;
153
- closed = true;
154
- clearTimer();
155
- emitStreamFailed(options.client, {
156
- messageId: options.messageId,
157
- routing,
158
- sequence: Math.max(sequence, 0),
159
- ...(reason !== undefined ? { reason } : {}),
160
- });
161
- if (emitTyping)
162
- options.client.typing(routing.chatId, false);
163
- };
164
- return {
165
- get currentText() {
166
- return bufferedSnapshot;
167
- },
168
- get flushedText() {
169
- return flushedSnapshot;
170
- },
171
- queueSnapshot,
172
- queueDelta,
173
- flush,
174
- done,
175
- fail,
176
- };
177
- }
@@ -1,65 +0,0 @@
1
- import { emitStreamAdd, emitStreamCreated, emitStreamDone, emitStreamFailed, } from "./client.js";
2
- function resolveRouting(params) {
3
- if (params.routing)
4
- return params.routing;
5
- if (params.to)
6
- return { chatId: params.to.id, chatType: params.to.type };
7
- throw new Error("openclaw-clawchat streaming requires routing");
8
- }
9
- /**
10
- * Emit one full streaming lifecycle for a pre-chunked reply.
11
- *
12
- * Sequence:
13
- * typing(true)
14
- * message.created (sequence 0)
15
- * message.add (sequence 1..N, one per chunk)
16
- * message.done (sequence N)
17
- * typing(false)
18
- *
19
- * With zero chunks: typing(true) -> created -> done -> typing(false).
20
- */
21
- export async function sendStreamingText(params) {
22
- const routing = resolveRouting(params);
23
- const emitTyping = params.emitTyping !== false;
24
- if (emitTyping) {
25
- params.client.typing(routing.chatId, true);
26
- }
27
- emitStreamCreated(params.client, {
28
- messageId: params.messageId,
29
- routing,
30
- });
31
- let sequence = -1;
32
- let fullText = "";
33
- for (const chunk of params.chunks) {
34
- sequence += 1;
35
- fullText += chunk;
36
- emitStreamAdd(params.client, {
37
- messageId: params.messageId,
38
- routing,
39
- sequence,
40
- fullText,
41
- textDelta: chunk,
42
- });
43
- }
44
- emitStreamDone(params.client, {
45
- messageId: params.messageId,
46
- routing,
47
- finalSequence: Math.max(sequence, 0),
48
- finalText: fullText,
49
- });
50
- if (emitTyping) {
51
- params.client.typing(routing.chatId, false);
52
- }
53
- }
54
- export async function sendStreamingFailure(params) {
55
- const routing = resolveRouting(params);
56
- emitStreamFailed(params.client, {
57
- messageId: params.messageId,
58
- routing,
59
- sequence: params.currentSequence,
60
- reason: params.reason,
61
- });
62
- if (params.emitTyping !== false) {
63
- params.client.typing(routing.chatId, false);
64
- }
65
- }