@mulmobridge/telegram 1.3.0 → 1.3.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/router.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // Telegram API, no real MulmoClaude socket, no env reads). The
4
4
  // entrypoint in index.ts is then just: read env → wire real deps →
5
5
  // drive the polling loop.
6
+ import { createReplyStream } from "./stream.js";
6
7
  import { parseDataUrl, replyAfterProcessingIndicator } from "@mulmobridge/client";
7
8
  // Telegram caps a single message at 4096 chars. We split long
8
9
  // replies naively; pretty formatting (preserve markdown, break on
@@ -24,52 +25,22 @@ export function createMessageRouter(deps) {
24
25
  const log = deps.log ?? defaultLog;
25
26
  // One denial reply per chat per bridge lifetime — restart clears.
26
27
  const deniedAlreadyNotified = new Set();
27
- // Streaming state: while a relay is in flight, chunks accumulate
28
- // here. A timer periodically flushes them via editMessageText.
29
- let streamChatId = null;
30
- let streamMessageId = null;
31
- let streamAccumulated = "";
32
- let streamDirty = false;
33
- let streamTimer = null;
34
- function startStream(chatId) {
35
- streamChatId = chatId;
36
- streamMessageId = null;
37
- streamAccumulated = "";
38
- streamDirty = false;
39
- streamTimer = setInterval(flushStream, STREAM_EDIT_INTERVAL_MS);
40
- }
41
- function stopStream() {
42
- if (streamTimer !== null) {
43
- clearInterval(streamTimer);
44
- streamTimer = null;
28
+ // The reply stream of the request in flight. Chunks carry no chat id; the
29
+ // polling loop handles one message at a time, so they belong to this one.
30
+ const inFlight = { stream: null };
31
+ /** Runs `work` while its streamed chunks are mirrored into the chat. The
32
+ * timer is stopped even when `work` throws. */
33
+ async function streamWhile(chatId, work) {
34
+ const stream = createReplyStream(api, chatId, log, STREAM_EDIT_INTERVAL_MS);
35
+ inFlight.stream = stream;
36
+ try {
37
+ const result = await work();
38
+ return { result, streamedMessageId: await stream.finish() };
45
39
  }
46
- streamChatId = null;
47
- streamMessageId = null;
48
- streamAccumulated = "";
49
- streamDirty = false;
50
- }
51
- function flushStream() {
52
- if (!streamDirty || streamChatId === null)
53
- return;
54
- streamDirty = false;
55
- const chatId = streamChatId;
56
- const text = streamAccumulated || "…";
57
- if (streamMessageId === null) {
58
- // First flush: send a new message
59
- api
60
- .sendMessage(chatId, text)
61
- .then((msgId) => {
62
- streamMessageId = msgId;
63
- })
64
- .catch((err) => {
65
- log.error(`[telegram] stream sendMessage failed: ${String(err)}`);
66
- });
67
- }
68
- else {
69
- // Subsequent flushes: edit existing message
70
- api.editMessageText(chatId, streamMessageId, text).catch((err) => {
71
- log.error(`[telegram] stream editMessage failed: ${String(err)}`);
72
- });
40
+ finally {
41
+ stream.stop();
42
+ if (inFlight.stream === stream)
43
+ inFlight.stream = null;
73
44
  }
74
45
  }
75
46
  // Download all media from a message. Telegram messages are usually
@@ -149,26 +120,17 @@ export function createMessageRouter(deps) {
149
120
  }
150
121
  // Surface the attachment failure so the user knows the file was lost.
151
122
  const messageText = resolveMessageText(text, failed);
152
- // Start streaming: chunks will arrive via handleTextChunk and
153
- // be sent/edited in the Telegram chat in real time.
154
- startStream(chatId);
155
- const ack = await sendToMulmo(String(chatId), messageText, attachments.length > 0 ? attachments : undefined);
156
- // Final flush + capture the message id before stopStream clears it.
157
- flushStream();
158
- const sentMsgId = streamMessageId;
159
- stopStream();
160
- return () => deliverFinal(chatId, sentMsgId, ack);
123
+ const { result: ack, streamedMessageId } = await streamWhile(chatId, () => sendToMulmo(String(chatId), messageText, attachments.length > 0 ? attachments : undefined));
124
+ return () => deliverFinal(chatId, streamedMessageId, ack);
161
125
  }
162
- // If streaming sent the text, do a final edit with the full ack
163
- // to catch any trailing chunks. If no streaming happened,
164
- // send the full reply as a new message.
165
- async function deliverFinal(chatId, sentMsgId, ack) {
166
- if (sentMsgId !== null && ack.ok) {
167
- await api.editMessageText(chatId, sentMsgId, ack.reply ?? "").catch(() => { });
168
- }
169
- else if (sentMsgId === null) {
170
- await sendReply(chatId, ack);
126
+ // A streamed reply is completed in place; an error after streaming is sent
127
+ // as its own message so the partial text is not the last word.
128
+ async function deliverFinal(chatId, streamedMessageId, ack) {
129
+ if (streamedMessageId !== null && ack.ok) {
130
+ await api.editMessageText(chatId, streamedMessageId, ack.reply ?? "").catch(() => { });
131
+ return;
171
132
  }
133
+ await sendReply(chatId, ack);
172
134
  }
173
135
  async function handleDenied(msg) {
174
136
  const chatId = msg.chat.id;
@@ -210,10 +172,7 @@ export function createMessageRouter(deps) {
210
172
  }
211
173
  },
212
174
  handleTextChunk(chunk) {
213
- if (streamChatId === null)
214
- return;
215
- streamAccumulated += chunk;
216
- streamDirty = true;
175
+ inFlight.stream?.append(chunk);
217
176
  },
218
177
  deniedAlreadyNotified() {
219
178
  return deniedAlreadyNotified;
@@ -0,0 +1,14 @@
1
+ import type { TelegramApi } from "./api.js";
2
+ interface StreamLog {
3
+ error: (msg: string) => void;
4
+ }
5
+ export interface ReplyStream {
6
+ append(chunk: string): void;
7
+ /** Stops the timer, sends what is pending, and resolves to the id of the
8
+ * streamed message — or null when nothing was streamed. */
9
+ finish(): Promise<number | null>;
10
+ /** Stops the timer without sending anything more. Safe to call twice. */
11
+ stop(): void;
12
+ }
13
+ export declare function createReplyStream(api: TelegramApi, chatId: number, log: StreamLog, interval_ms: number): ReplyStream;
14
+ export {};
package/dist/stream.js ADDED
@@ -0,0 +1,50 @@
1
+ // One streamed reply: chunks accumulate here and a timer mirrors them into a
2
+ // single Telegram message (sent once, then edited). Created per request so a
3
+ // failed request cannot leave its timer or text behind for the next one.
4
+ export function createReplyStream(api, chatId, log, interval_ms) {
5
+ const state = { accumulated: "", dirty: false, messageId: null, flushChain: Promise.resolve(), timer: null };
6
+ async function sendOrEdit(text) {
7
+ if (state.messageId === null) {
8
+ state.messageId = await api.sendMessage(chatId, text);
9
+ return;
10
+ }
11
+ await api.editMessageText(chatId, state.messageId, text);
12
+ }
13
+ async function flushPending() {
14
+ if (!state.dirty)
15
+ return;
16
+ state.dirty = false;
17
+ const action = state.messageId === null ? "sendMessage" : "editMessage";
18
+ try {
19
+ await sendOrEdit(state.accumulated || "…");
20
+ }
21
+ catch (err) {
22
+ log.error(`[telegram] stream ${action} failed: ${String(err)}`);
23
+ }
24
+ }
25
+ function flush() {
26
+ state.flushChain = state.flushChain.then(flushPending);
27
+ return state.flushChain;
28
+ }
29
+ function stop() {
30
+ if (state.timer === null)
31
+ return;
32
+ clearInterval(state.timer);
33
+ state.timer = null;
34
+ }
35
+ state.timer = setInterval(() => void flush(), interval_ms);
36
+ return {
37
+ append(chunk) {
38
+ if (state.timer === null)
39
+ return;
40
+ state.accumulated += chunk;
41
+ state.dirty = true;
42
+ },
43
+ async finish() {
44
+ stop();
45
+ await flush();
46
+ return state.messageId;
47
+ },
48
+ stop,
49
+ };
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/telegram",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
4
4
  "description": "Telegram bridge for MulmoBridge — connect a Telegram bot to MulmoClaude",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",