telegix 1.1.1 → 1.1.2

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/lib/stream.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Telegix - Text Streaming Engine for Telegram Bots
3
+ * Handles smooth real-time response streaming (LLM tokens, AI chats, live output)
4
+ * with rate-limit protection, edit throttling, and live draft mode.
5
+ * @module telegix/stream
6
+ */
7
+
8
+ /**
9
+ * Sleeps for the specified number of milliseconds
10
+ * @param {number} ms
11
+ * @returns {Promise<void>}
12
+ */
13
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+
15
+ /**
16
+ * Converts an arbitrary stream/iterable input into an AsyncIterable of string chunks
17
+ * @param {AsyncIterable<string>|Iterable<string>|ReadableStream|Array<string>} input
18
+ * @returns {AsyncIterable<string>}
19
+ */
20
+ export async function* toTextStream(input) {
21
+ if (!input) return;
22
+
23
+ // Handle strings directly
24
+ if (typeof input === 'string') {
25
+ yield input;
26
+ return;
27
+ }
28
+
29
+ // Handle web ReadableStream (e.g. fetch response.body, OpenAI / Gemini streams)
30
+ if (typeof input.getReader === 'function') {
31
+ const reader = input.getReader();
32
+ const decoder = new TextDecoder();
33
+ try {
34
+ while (true) {
35
+ const { done, value } = await reader.read();
36
+ if (done) break;
37
+ if (typeof value === 'string') {
38
+ yield value;
39
+ } else if (value) {
40
+ yield decoder.decode(value, { stream: true });
41
+ }
42
+ }
43
+ } finally {
44
+ reader.releaseLock();
45
+ }
46
+ return;
47
+ }
48
+
49
+ // Handle AsyncIterable (e.g. ai sdk, openai stream, gemini stream)
50
+ if (typeof input[Symbol.asyncIterator] === 'function') {
51
+ for await (const chunk of input) {
52
+ if (chunk === null || chunk === undefined) continue;
53
+ if (typeof chunk === 'string') {
54
+ yield chunk;
55
+ } else if (typeof chunk?.text === 'string') {
56
+ yield chunk.text;
57
+ } else if (typeof chunk?.content === 'string') {
58
+ yield chunk.content;
59
+ } else if (chunk?.choices?.[0]?.delta?.content) {
60
+ yield chunk.choices[0].delta.content;
61
+ } else if (chunk?.candidates?.[0]?.content?.parts?.[0]?.text) {
62
+ yield chunk.candidates[0].content.parts[0].text;
63
+ } else {
64
+ yield String(chunk);
65
+ }
66
+ }
67
+ return;
68
+ }
69
+
70
+ // Handle standard synchronous Iterable (Array, Set, Generator)
71
+ if (typeof input[Symbol.iterator] === 'function') {
72
+ for (const chunk of input) {
73
+ if (chunk !== null && chunk !== undefined) {
74
+ yield String(chunk);
75
+ }
76
+ }
77
+ return;
78
+ }
79
+
80
+ // Fallback for single object
81
+ yield String(input);
82
+ }
83
+
84
+ /**
85
+ * Stream text to a Telegram chat with rate-limit throttling and edit buffering
86
+ * @param {object} telegram - Telegix Telegram API instance
87
+ * @param {number|string} chatId - Target chat ID
88
+ * @param {AsyncIterable<string>|Iterable<string>|ReadableStream|Array<string>} textStream - Text stream or token source
89
+ * @param {object} [options]
90
+ * @param {string} [options.initialPlaceholder='⏳ Thinking...'] - Initial placeholder text
91
+ * @param {number} [options.intervalMs=600] - Throttle interval between message edits in milliseconds (default: 600ms)
92
+ * @param {number} [options.minDeltaChars=1] - Minimum new characters before attempting an edit
93
+ * @param {string} [options.parse_mode] - Parse mode for final message ('HTML', 'MarkdownV2', etc.)
94
+ * @param {object} [options.reply_markup] - Inline keyboard to attach upon stream completion
95
+ * @param {boolean} [options.useDraft=false] - Use live draft action (sendMessageDraft) instead of message editing
96
+ * @param {AbortSignal} [options.signal] - AbortSignal to cancel streaming
97
+ * @param {function} [options.onChunk] - Hook called when a new chunk is received: (currentFullText, chunk)
98
+ * @param {function} [options.onEdit] - Hook called when Telegram message is edited: (messageId, currentText)
99
+ * @returns {Promise<{ message_id?: number, text: string, done: boolean }>}
100
+ */
101
+ export async function streamText(telegram, chatId, textStream, options = {}) {
102
+ const {
103
+ initialPlaceholder = '⏳ Thinking...',
104
+ intervalMs = 600,
105
+ minDeltaChars = 1,
106
+ parse_mode,
107
+ reply_markup,
108
+ useDraft = false,
109
+ signal,
110
+ onChunk,
111
+ onEdit,
112
+ ...extra
113
+ } = options;
114
+
115
+ if (!telegram || typeof telegram.call !== 'function') {
116
+ throw new Error('Telegix streamText: valid telegram API instance is required.');
117
+ }
118
+ if (!chatId) {
119
+ throw new Error('Telegix streamText: chatId is required.');
120
+ }
121
+
122
+ let accumulatedText = '';
123
+ let lastSentText = '';
124
+ let messageId = null;
125
+ let lastEditTime = 0;
126
+ let draftId = useDraft ? Math.floor(Math.random() * 2147483647) + 1 : null;
127
+
128
+ const sendMsg = (cid, txt, opt = {}) => {
129
+ if (typeof telegram.sendMessage === 'function') {
130
+ return telegram.sendMessage(cid, txt, opt);
131
+ }
132
+ return telegram.call('sendMessage', { chat_id: cid, text: txt, ...opt });
133
+ };
134
+
135
+ const editMsg = (cid, mid, txt, opt = {}) => {
136
+ if (typeof telegram.editMessageText === 'function') {
137
+ return telegram.editMessageText(cid, mid, null, txt, opt);
138
+ }
139
+ return telegram.call('editMessageText', { chat_id: cid, message_id: mid, text: txt, ...opt });
140
+ };
141
+
142
+ const sendDraftMsg = (cid, txt, opt = {}) => {
143
+ if (typeof telegram.sendMessageDraft === 'function') {
144
+ return telegram.sendMessageDraft(cid, txt, opt);
145
+ }
146
+ return telegram.call('sendMessageDraft', { chat_id: cid, text: txt, ...opt });
147
+ };
148
+
149
+ // Mode 1: Live Draft Streaming (Bot API live draft preview)
150
+ if (useDraft) {
151
+ for await (const chunk of toTextStream(textStream)) {
152
+ if (signal?.aborted) break;
153
+ accumulatedText += chunk;
154
+ if (typeof onChunk === 'function') onChunk(accumulatedText, chunk);
155
+
156
+ const now = Date.now();
157
+ if (now - lastEditTime >= intervalMs && accumulatedText.length - lastSentText.length >= minDeltaChars) {
158
+ lastEditTime = now;
159
+ lastSentText = accumulatedText;
160
+ try {
161
+ await sendDraftMsg(chatId, accumulatedText, { draft_id: draftId, ...extra });
162
+ } catch {
163
+ // Non-critical, ignore transient draft update errors
164
+ }
165
+ }
166
+ }
167
+
168
+ // Finalize: send complete message to chat
169
+ const finalMsg = await sendMsg(chatId, accumulatedText || '...', {
170
+ parse_mode,
171
+ reply_markup,
172
+ ...extra,
173
+ });
174
+ return {
175
+ message_id: finalMsg?.message_id,
176
+ text: accumulatedText,
177
+ done: true,
178
+ message: finalMsg,
179
+ };
180
+ }
181
+
182
+ // Mode 2: Real-time Message Edit Streaming
183
+ // Step 1: Send placeholder message
184
+ const initialMsg = await sendMsg(chatId, initialPlaceholder, {
185
+ ...extra,
186
+ });
187
+ messageId = initialMsg?.message_id;
188
+
189
+ try {
190
+ for await (const chunk of toTextStream(textStream)) {
191
+ if (signal?.aborted) break;
192
+ accumulatedText += chunk;
193
+ if (typeof onChunk === 'function') onChunk(accumulatedText, chunk);
194
+
195
+ const now = Date.now();
196
+ const timeSinceLastEdit = now - lastEditTime;
197
+ const charDelta = accumulatedText.length - lastSentText.length;
198
+
199
+ if (timeSinceLastEdit >= intervalMs && charDelta >= minDeltaChars) {
200
+ lastEditTime = now;
201
+ lastSentText = accumulatedText;
202
+ try {
203
+ await editMsg(chatId, messageId, accumulatedText);
204
+ if (typeof onEdit === 'function') onEdit(messageId, accumulatedText);
205
+ } catch (err) {
206
+ // Ignore "message is not modified" errors from Telegram
207
+ if (!err?.message?.includes('message is not modified')) {
208
+ // If rate limited, back off slightly
209
+ if (err?.parameters?.retry_after) {
210
+ await sleep(err.parameters.retry_after * 1000);
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ // Step 2: Final message update with final text, parse_mode, and reply_markup
218
+ const finalText = accumulatedText.trim() || ' ';
219
+ if (finalText !== lastSentText || reply_markup || parse_mode) {
220
+ try {
221
+ await editMsg(chatId, messageId, finalText, {
222
+ parse_mode,
223
+ reply_markup,
224
+ ...extra,
225
+ });
226
+ } catch (err) {
227
+ if (!err?.message?.includes('message is not modified')) {
228
+ // Try sending as fallback if edit failed
229
+ // e.g. text formatting error
230
+ }
231
+ }
232
+ }
233
+
234
+ return {
235
+ message_id: messageId,
236
+ text: accumulatedText,
237
+ done: true,
238
+ message: initialMsg,
239
+ };
240
+ } catch (err) {
241
+ // If stream fails, ensure we leave clean text if possible
242
+ if (messageId && accumulatedText) {
243
+ try {
244
+ await telegram.editMessageText(chatId, messageId, null, accumulatedText);
245
+ } catch {
246
+ // Ignore fallback error
247
+ }
248
+ }
249
+ throw err;
250
+ }
251
+ }