@krischoichoi/channel-telegram 0.6.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.
Files changed (82) hide show
  1. package/README.md +129 -0
  2. package/lib/adapter.d.ts +94 -0
  3. package/lib/adapter.d.ts.map +1 -0
  4. package/lib/adapter.js +417 -0
  5. package/lib/adapter.js.map +1 -0
  6. package/lib/api-error.d.ts +74 -0
  7. package/lib/api-error.d.ts.map +1 -0
  8. package/lib/api-error.js +103 -0
  9. package/lib/api-error.js.map +1 -0
  10. package/lib/config.d.ts +115 -0
  11. package/lib/config.d.ts.map +1 -0
  12. package/lib/config.js +67 -0
  13. package/lib/config.js.map +1 -0
  14. package/lib/definition.d.ts +59 -0
  15. package/lib/definition.d.ts.map +1 -0
  16. package/lib/definition.js +158 -0
  17. package/lib/definition.js.map +1 -0
  18. package/lib/inbound.d.ts +61 -0
  19. package/lib/inbound.d.ts.map +1 -0
  20. package/lib/inbound.js +125 -0
  21. package/lib/inbound.js.map +1 -0
  22. package/lib/index.d.ts +52 -0
  23. package/lib/index.d.ts.map +1 -0
  24. package/lib/index.js +114 -0
  25. package/lib/index.js.map +1 -0
  26. package/lib/manifest.d.ts +16 -0
  27. package/lib/manifest.d.ts.map +1 -0
  28. package/lib/manifest.js +26 -0
  29. package/lib/manifest.js.map +1 -0
  30. package/lib/mapper.d.ts +51 -0
  31. package/lib/mapper.d.ts.map +1 -0
  32. package/lib/mapper.js +314 -0
  33. package/lib/mapper.js.map +1 -0
  34. package/lib/media-hydrator.d.ts +46 -0
  35. package/lib/media-hydrator.d.ts.map +1 -0
  36. package/lib/media-hydrator.js +85 -0
  37. package/lib/media-hydrator.js.map +1 -0
  38. package/lib/outbound.d.ts +42 -0
  39. package/lib/outbound.d.ts.map +1 -0
  40. package/lib/outbound.js +215 -0
  41. package/lib/outbound.js.map +1 -0
  42. package/lib/render/html.d.ts +13 -0
  43. package/lib/render/html.d.ts.map +1 -0
  44. package/lib/render/html.js +110 -0
  45. package/lib/render/html.js.map +1 -0
  46. package/lib/render/index.d.ts +64 -0
  47. package/lib/render/index.d.ts.map +1 -0
  48. package/lib/render/index.js +76 -0
  49. package/lib/render/index.js.map +1 -0
  50. package/lib/render/markdown.d.ts +38 -0
  51. package/lib/render/markdown.d.ts.map +1 -0
  52. package/lib/render/markdown.js +187 -0
  53. package/lib/render/markdown.js.map +1 -0
  54. package/lib/render/plain.d.ts +43 -0
  55. package/lib/render/plain.d.ts.map +1 -0
  56. package/lib/render/plain.js +97 -0
  57. package/lib/render/plain.js.map +1 -0
  58. package/lib/render/segment.d.ts +39 -0
  59. package/lib/render/segment.d.ts.map +1 -0
  60. package/lib/render/segment.js +117 -0
  61. package/lib/render/segment.js.map +1 -0
  62. package/lib/rich-message.d.ts +75 -0
  63. package/lib/rich-message.d.ts.map +1 -0
  64. package/lib/rich-message.js +11 -0
  65. package/lib/rich-message.js.map +1 -0
  66. package/lib/rich-streaming-reply.d.ts +69 -0
  67. package/lib/rich-streaming-reply.d.ts.map +1 -0
  68. package/lib/rich-streaming-reply.js +207 -0
  69. package/lib/rich-streaming-reply.js.map +1 -0
  70. package/lib/streaming-reply.d.ts +98 -0
  71. package/lib/streaming-reply.d.ts.map +1 -0
  72. package/lib/streaming-reply.js +274 -0
  73. package/lib/streaming-reply.js.map +1 -0
  74. package/lib/transport.d.ts +38 -0
  75. package/lib/transport.d.ts.map +1 -0
  76. package/lib/transport.js +91 -0
  77. package/lib/transport.js.map +1 -0
  78. package/lib/upstream.d.ts +174 -0
  79. package/lib/upstream.d.ts.map +1 -0
  80. package/lib/upstream.js +452 -0
  81. package/lib/upstream.js.map +1 -0
  82. package/package.json +52 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * `TelegramRichStreamingReply` — DSH `ReplyHandle` over Bot API 10.1 rich
3
+ * drafts (plan §6.1 / §20.4).
4
+ *
5
+ * For private DMs with rich output active, the plan replaces per-delta
6
+ * `editMessageText` with the native rich draft flow:
7
+ *
8
+ * sendRichMessageDraft(draft_id) → 30s temporary preview, updated in place
9
+ * … (throttled by ReplyRouter) → same draft_id re-sent with the growing
10
+ * partial Markdown
11
+ * sendRichMessage(final) → persists the final result; the draft
12
+ * preview is superseded
13
+ *
14
+ * The draft is inherently *partial-safe*: the server renders whatever has been
15
+ * sent so far, so an unfinished code fence / table / link never breaks the
16
+ * pipeline (plan §20.4 Partial Markdown Gate). Only the final `sendRichMessage`
17
+ * renders the *complete* Markdown; on a format failure the final falls back to
18
+ * plain text once (never an infinite rich→plain→rich loop).
19
+ *
20
+ * The handle does NOT throttle internally — ReplyRouter owns throttling via
21
+ * config `reply.updateIntervalMs`. Operations are serialized so concurrent
22
+ * router flushes cannot interleave.
23
+ */
24
+ import type { ChannelTarget, OutboundMessage, ReplyHandle } from '@krischoichoi/channel-core';
25
+ import type { TelegramUpstream } from './upstream.js';
26
+ import { type RenderOptions } from './render/index.js';
27
+ export interface TelegramRichStreamingReplyOptions {
28
+ /** Output formatting policy (mode/fallback) for the final rich send. */
29
+ formatting?: Partial<RenderOptions>;
30
+ }
31
+ export declare class TelegramRichStreamingReply implements ReplyHandle {
32
+ private readonly upstream;
33
+ private readonly target;
34
+ private readonly placeholder;
35
+ private readonly options;
36
+ private text;
37
+ private draftId;
38
+ private started;
39
+ private finalized;
40
+ private draftPending;
41
+ private finalPending;
42
+ private cooldownUntil;
43
+ private cooldownTimer;
44
+ private queue;
45
+ constructor(upstream: TelegramUpstream, target: ChannelTarget, placeholder?: string, options?: TelegramRichStreamingReplyOptions, draftId?: number);
46
+ get activeDraftId(): number;
47
+ /** Open the temporary draft preview. */
48
+ start(): Promise<void>;
49
+ append(delta: string): Promise<void>;
50
+ replace(message: OutboundMessage): Promise<void>;
51
+ finish(message?: OutboundMessage): Promise<void>;
52
+ fail(error: unknown): Promise<void>;
53
+ /** Re-send the same draft id with the newest partial Markdown preview. */
54
+ private flushDraft;
55
+ /**
56
+ * Persist the final result. For DM rich mode the final is one (or, when
57
+ * overflowing, several) `sendRichMessage` calls carrying the complete
58
+ * Markdown; a format failure falls back to plain exactly once.
59
+ */
60
+ private flushFinal;
61
+ private sendFinal;
62
+ private enqueue;
63
+ private inCooldown;
64
+ /** Keep only the current draft/final state while Telegram asks us to wait. */
65
+ private enterCooldown;
66
+ private scheduleCooldown;
67
+ private clearCooldown;
68
+ }
69
+ //# sourceMappingURL=rich-streaming-reply.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rich-streaming-reply.d.ts","sourceRoot":"","sources":["../src/rich-streaming-reply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,OAAO,KAAK,EAAE,aAAa,EAAE,eAAe,EAAE,WAAW,EAAE,MAAM,4BAA4B,CAAC;AAC9F,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAItD,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAQzE,MAAM,WAAW,iCAAiC;IAChD,wEAAwE;IACxE,UAAU,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;CACrC;AAED,qBAAa,0BAA2B,YAAW,WAAW;IAY1D,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAd1B,OAAO,CAAC,IAAI,CAAM;IAClB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,aAAa,CAA4C;IACjE,OAAO,CAAC,KAAK,CAAoC;gBAG9B,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,aAAa,EACrB,WAAW,SAAM,EACjB,OAAO,GAAE,iCAAsC,EAChE,OAAO,CAAC,EAAE,MAAM;IAKlB,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED,wCAAwC;IAClC,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAO5B,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIpC,OAAO,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAUhD,MAAM,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAUhD,IAAI,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAkBnC,0EAA0E;YAC5D,UAAU;IAkBxB;;;;OAIG;YACW,UAAU;YAaV,SAAS;IAyBvB,OAAO,CAAC,OAAO;IASf,OAAO,CAAC,UAAU;IAMlB,8EAA8E;IAC9E,OAAO,CAAC,aAAa;IASrB,OAAO,CAAC,gBAAgB;IAgBxB,OAAO,CAAC,aAAa;CAOtB"}
@@ -0,0 +1,207 @@
1
+ import { TelegramApiError } from './api-error.js';
2
+ import { RICH_MESSAGE_MAX_UTF8 } from './rich-message.js';
3
+ import { truncateGraphemes } from './render/segment.js';
4
+ import { sendWithFallback } from './render/index.js';
5
+ /** A non-zero integer that identifies this reply stream across draft updates. */
6
+ function newDraftId() {
7
+ const value = globalThis.crypto.getRandomValues(new Uint32Array(1))[0] ?? 0;
8
+ return value === 0 ? 1 : value;
9
+ }
10
+ export class TelegramRichStreamingReply {
11
+ upstream;
12
+ target;
13
+ placeholder;
14
+ options;
15
+ text = '';
16
+ draftId;
17
+ started = false;
18
+ finalized = false;
19
+ draftPending = false;
20
+ finalPending = false;
21
+ cooldownUntil = 0;
22
+ cooldownTimer;
23
+ queue = Promise.resolve();
24
+ constructor(upstream, target, placeholder = '…', options = {}, draftId) {
25
+ this.upstream = upstream;
26
+ this.target = target;
27
+ this.placeholder = placeholder;
28
+ this.options = options;
29
+ this.draftId = draftId ?? newDraftId();
30
+ }
31
+ get activeDraftId() {
32
+ return this.draftId;
33
+ }
34
+ /** Open the temporary draft preview. */
35
+ async start() {
36
+ if (this.started)
37
+ return;
38
+ this.started = true;
39
+ this.draftPending = true;
40
+ await this.flushDraft(this.placeholder);
41
+ }
42
+ append(delta) {
43
+ return this.replace({ text: this.text + delta });
44
+ }
45
+ replace(message) {
46
+ return this.enqueue(async () => {
47
+ if (this.finalized)
48
+ return;
49
+ if (message.text === undefined)
50
+ return;
51
+ this.text = message.text;
52
+ this.draftPending = true;
53
+ await this.flushDraft();
54
+ });
55
+ }
56
+ finish(message) {
57
+ return this.enqueue(async () => {
58
+ if (this.finalized)
59
+ return;
60
+ if (message?.text !== undefined)
61
+ this.text = message.text;
62
+ this.draftPending = false;
63
+ this.finalPending = true;
64
+ await this.flushFinal();
65
+ });
66
+ }
67
+ fail(error) {
68
+ return this.enqueue(async () => {
69
+ if (this.finalized)
70
+ return;
71
+ if (isRateLimit(error))
72
+ return;
73
+ this.finalized = true;
74
+ this.clearCooldown();
75
+ const msg = `Warning: ${messageOf(error)}`;
76
+ try {
77
+ await sendWithFallback(msg, { mode: 'plain' }, (plan) => {
78
+ const text = plan.kind === 'rich' ? (plan.texts[0] ?? '') : (plan.chunks[0] ?? '');
79
+ return this.upstream.sendMessage(this.target.conversationId, text);
80
+ });
81
+ }
82
+ catch {
83
+ // Marking failure must not mask the original error.
84
+ }
85
+ });
86
+ }
87
+ /** Re-send the same draft id with the newest partial Markdown preview. */
88
+ async flushDraft(placeholder) {
89
+ if (!this.started || !this.draftPending || this.inCooldown())
90
+ return;
91
+ const source = this.text || placeholder;
92
+ if (!source)
93
+ return;
94
+ try {
95
+ await this.upstream.sendRichMessageDraft(this.target.conversationId, this.draftId, { markdown: truncateGraphemes(source, RICH_MESSAGE_MAX_UTF8) }, targetOptions(this.target));
96
+ this.draftPending = false;
97
+ }
98
+ catch (error) {
99
+ if (!isRateLimit(error))
100
+ throw error;
101
+ this.enterCooldown(error);
102
+ }
103
+ }
104
+ /**
105
+ * Persist the final result. For DM rich mode the final is one (or, when
106
+ * overflowing, several) `sendRichMessage` calls carrying the complete
107
+ * Markdown; a format failure falls back to plain exactly once.
108
+ */
109
+ async flushFinal() {
110
+ if (!this.finalPending || this.inCooldown())
111
+ return;
112
+ try {
113
+ await this.sendFinal();
114
+ this.finalPending = false;
115
+ this.finalized = true;
116
+ this.clearCooldown();
117
+ }
118
+ catch (error) {
119
+ if (!isRateLimit(error))
120
+ throw error;
121
+ this.enterCooldown(error);
122
+ }
123
+ }
124
+ async sendFinal() {
125
+ const options = {
126
+ mode: this.options.formatting?.mode ?? 'auto',
127
+ };
128
+ const source = this.text;
129
+ if (!source)
130
+ return;
131
+ await sendWithFallback(source, options, async (plan) => {
132
+ if (plan.kind === 'rich') {
133
+ for (const text of plan.texts) {
134
+ await this.upstream.sendRichMessage(this.target.conversationId, { markdown: text }, targetOptions(this.target));
135
+ }
136
+ return;
137
+ }
138
+ // plain fallback path.
139
+ for (const chunk of plan.chunks) {
140
+ await this.upstream.sendMessage(this.target.conversationId, chunk, targetOptions(this.target), plan.parseMode ? { parseMode: plan.parseMode } : undefined);
141
+ }
142
+ });
143
+ }
144
+ enqueue(task) {
145
+ const next = this.queue.then(task, task);
146
+ this.queue = next.then(() => undefined, () => undefined);
147
+ return next;
148
+ }
149
+ inCooldown() {
150
+ if (Date.now() >= this.cooldownUntil)
151
+ return false;
152
+ this.scheduleCooldown();
153
+ return true;
154
+ }
155
+ /** Keep only the current draft/final state while Telegram asks us to wait. */
156
+ enterCooldown(error) {
157
+ const seconds = error.parameters?.retryAfter;
158
+ const delay = typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
159
+ ? Math.ceil(seconds * 1000)
160
+ : 1_000;
161
+ this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + delay);
162
+ this.scheduleCooldown();
163
+ }
164
+ scheduleCooldown() {
165
+ if (this.cooldownTimer || this.finalized)
166
+ return;
167
+ const delay = Math.max(0, this.cooldownUntil - Date.now());
168
+ this.cooldownTimer = setTimeout(() => {
169
+ this.cooldownTimer = undefined;
170
+ void this.enqueue(async () => {
171
+ if (this.finalized)
172
+ return;
173
+ if (this.finalPending) {
174
+ await this.flushFinal();
175
+ }
176
+ else {
177
+ await this.flushDraft();
178
+ }
179
+ });
180
+ }, delay);
181
+ }
182
+ clearCooldown() {
183
+ this.cooldownUntil = 0;
184
+ if (this.cooldownTimer) {
185
+ clearTimeout(this.cooldownTimer);
186
+ this.cooldownTimer = undefined;
187
+ }
188
+ }
189
+ }
190
+ function isRateLimit(error) {
191
+ return error instanceof TelegramApiError || (typeof error === 'object'
192
+ && error !== null
193
+ && 'kind' in error
194
+ && error.kind === 'rate-limit');
195
+ }
196
+ function messageOf(error) {
197
+ if (error instanceof Error)
198
+ return error.message;
199
+ return String(error);
200
+ }
201
+ function targetOptions(target) {
202
+ return {
203
+ replyToMessageId: target.replyToMessageId,
204
+ messageThreadId: target.threadId,
205
+ };
206
+ }
207
+ //# sourceMappingURL=rich-streaming-reply.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rich-streaming-reply.js","sourceRoot":"","sources":["../src/rich-streaming-reply.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `TelegramStreamingReply` — DSH `ReplyHandle` over Telegram edit streaming.
3
+ *
4
+ * Telegram streaming is modeled as: send one message, then edit it in place
5
+ * with `editMessageText` as full-text previews arrive. This maps onto the
6
+ * reply contract used by ReplyRouter for `streaming: 'edit'`:
7
+ * - `replace(message)` -> create the message on first content, then edit
8
+ * - `append(delta)` -> accumulate and edit full text (kept for symmetry)
9
+ * - `finish(message?)` -> render + send the final text and close
10
+ * - `fail(error)` -> edit a short failure note when a message exists
11
+ *
12
+ * ## Preview-freeze fix (plan §3.2 / §6.3 / §20.5)
13
+ *
14
+ * The old implementation always edited `chunks[0]`, so once the model passed
15
+ * 4096 chars the first chunk stopped changing and the preview appeared frozen.
16
+ * This version shows a *rolling preview*: up to 4096 visible chars it shows the
17
+ * whole text; beyond that it shows `…` + the newest `4095` graphemes, so the
18
+ * preview keeps visibly moving while the model streams. The final message is
19
+ * always sent with complete, stable segmentation.
20
+ *
21
+ * ## Group rich final (plan §6.2)
22
+ *
23
+ * When configured for rich output on a non-DM target, `finish` upgrades the
24
+ * same message to a Rich Message with `editMessageRich` (first segment) plus
25
+ * `sendRichMessage` for any overflow. A formatting error falls back to plain
26
+ * once; the preview phase always uses plain edits so partial Markdown never
27
+ * triggers a rich parse (plan §20.5).
28
+ *
29
+ * ReplyRouter owns normal throttling via config `reply.updateIntervalMs`.
30
+ * This handle additionally honors Telegram 429 cooldowns by retaining only
31
+ * the newest desired preview until the retry window expires.
32
+ */
33
+ import type { ChannelTarget, OutboundMessage, ReplyHandle } from '@krischoichoi/channel-core';
34
+ import type { TelegramUpstream } from './upstream.js';
35
+ import { type RenderOptions } from './render/index.js';
36
+ export interface TelegramStreamingReplyOptions {
37
+ /** Output formatting policy for the final message (mode/fallback). */
38
+ formatting?: Partial<RenderOptions>;
39
+ /**
40
+ * When true and rich formatting is active, `finish` edits the SAME message
41
+ * into a Rich Message (group path); otherwise the final is plain text.
42
+ */
43
+ richFinal?: boolean;
44
+ }
45
+ export declare class TelegramStreamingReply implements ReplyHandle {
46
+ private readonly upstream;
47
+ private readonly target;
48
+ private readonly placeholder;
49
+ private readonly options;
50
+ private text;
51
+ private sentPreview;
52
+ private messageId;
53
+ private finalized;
54
+ private queue;
55
+ /** Latest desired preview; deliberately not an edit queue. */
56
+ private pendingPreview;
57
+ private cooldownUntil;
58
+ private cooldownTimer;
59
+ private finishing;
60
+ constructor(upstream: TelegramUpstream, target: ChannelTarget, placeholder?: string, options?: TelegramStreamingReplyOptions);
61
+ /** Send the initial placeholder message (called once by the adapter). */
62
+ start(): Promise<void>;
63
+ append(delta: string): Promise<void>;
64
+ replace(message: OutboundMessage): Promise<void>;
65
+ finish(message?: OutboundMessage): Promise<void>;
66
+ fail(error: unknown): Promise<void>;
67
+ /** Edit the rolling preview now; on `final` render + send the complete result. */
68
+ private sync;
69
+ /**
70
+ * Send the final message. Plain mode: first chunk edits the existing message,
71
+ * overflow chunks are sent as new messages. Rich mode (richFinal + rich
72
+ * formatting): upgrade the same message to a Rich Message; fall back to plain
73
+ * once on a format error.
74
+ */
75
+ private sendFinal;
76
+ /** Upgrade the in-place message to a Rich Message with plain-once fallback. */
77
+ private sendRichFinal;
78
+ /** Retry only the in-place final edit after a Telegram-requested cooldown. */
79
+ private editFinalText;
80
+ /** The rich final path uses Telegram's edit endpoint too, so it shares 429 recovery. */
81
+ private editFinalRich;
82
+ private isCoolingDown;
83
+ /** Honor the server's retry hint, extending (never shortening) cooldown. */
84
+ private startCooldown;
85
+ /** One timer, one latest-value flush. It never replays a sequence of edits. */
86
+ private scheduleCooldownFlush;
87
+ private clearCooldownTimer;
88
+ private waitForCooldown;
89
+ private enqueue;
90
+ }
91
+ /**
92
+ * Build a rolling preview for an in-progress stream: show the whole text up to
93
+ * `limit`, otherwise `…` + the newest `limit - 1` graphemes. This keeps the
94
+ * preview visibly changing once the model exceeds the single-message limit
95
+ * (plan §6.3), fixing the frozen `chunks[0]` behaviour.
96
+ */
97
+ export declare function makePreview(text: string, limit: number): string;
98
+ //# sourceMappingURL=streaming-reply.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"streaming-reply.d.ts","sourceRoot":"","sources":["../src/streaming-reply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,OAAO,KAAK,EACV,aAAa,EACb,eAAe,EACf,WAAW,EACZ,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAItD,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAEzE,MAAM,WAAW,6BAA6B;IAC5C,sEAAsE;IACtE,UAAU,CAAC,EAAE,OAAO,CAAC,aAAa,CAAC,CAAC;IACpC;;;OAGG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,qBAAa,sBAAuB,YAAW,WAAW;IAatD,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACzB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,WAAW;IAC5B,OAAO,CAAC,QAAQ,CAAC,OAAO;IAf1B,OAAO,CAAC,IAAI,CAAM;IAClB,OAAO,CAAC,WAAW,CAAqB;IACxC,OAAO,CAAC,SAAS,CAAqB;IACtC,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,KAAK,CAAoC;IACjD,8DAA8D;IAC9D,OAAO,CAAC,cAAc,CAAqB;IAC3C,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,aAAa,CAA4C;IACjE,OAAO,CAAC,SAAS,CAAS;gBAGP,QAAQ,EAAE,gBAAgB,EAC1B,MAAM,EAAE,aAAa,EACrB,WAAW,SAAM,EACjB,OAAO,GAAE,6BAAkC;IAG9D,yEAAyE;IACnE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAY5B,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIpC,OAAO,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAUhD,MAAM,CAAC,OAAO,CAAC,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC;IAehD,IAAI,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAenC,kFAAkF;YACpE,IAAI;IA2BlB;;;;;OAKG;YACW,SAAS;IAyBvB,+EAA+E;YACjE,aAAa;IAkC3B,8EAA8E;YAChE,aAAa;IAa3B,wFAAwF;YAC1E,aAAa;IAa3B,OAAO,CAAC,aAAa;IAIrB,4EAA4E;IAC5E,OAAO,CAAC,aAAa;IASrB,+EAA+E;IAC/E,OAAO,CAAC,qBAAqB;IAQ7B,OAAO,CAAC,kBAAkB;YAKZ,eAAe;IAQ7B,OAAO,CAAC,OAAO;CAQhB;AAkBD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/D"}
@@ -0,0 +1,274 @@
1
+ import { TelegramApiError } from './api-error.js';
2
+ import { REGULAR_MESSAGE_MAX } from './rich-message.js';
3
+ import { truncateGraphemes, tailGraphemes, visibleLength, splitByGraphemes } from './render/segment.js';
4
+ import { sendWithFallback } from './render/index.js';
5
+ export class TelegramStreamingReply {
6
+ upstream;
7
+ target;
8
+ placeholder;
9
+ options;
10
+ text = '';
11
+ sentPreview;
12
+ messageId;
13
+ finalized = false;
14
+ queue = Promise.resolve();
15
+ /** Latest desired preview; deliberately not an edit queue. */
16
+ pendingPreview;
17
+ cooldownUntil = 0;
18
+ cooldownTimer;
19
+ finishing = false;
20
+ constructor(upstream, target, placeholder = '…', options = {}) {
21
+ this.upstream = upstream;
22
+ this.target = target;
23
+ this.placeholder = placeholder;
24
+ this.options = options;
25
+ }
26
+ /** Send the initial placeholder message (called once by the adapter). */
27
+ async start() {
28
+ if (this.messageId)
29
+ return;
30
+ const preview = makePreview(this.placeholder, REGULAR_MESSAGE_MAX);
31
+ const sent = await this.upstream.sendMessage(this.target.conversationId, preview, targetOptions(this.target));
32
+ this.messageId = sent.messageId;
33
+ this.sentPreview = preview;
34
+ }
35
+ append(delta) {
36
+ return this.replace({ text: this.text + delta });
37
+ }
38
+ replace(message) {
39
+ return this.enqueue(async () => {
40
+ if (this.finalized || this.finishing)
41
+ return;
42
+ if (message.text === undefined)
43
+ return;
44
+ this.text = message.text;
45
+ this.pendingPreview = makePreview(this.text, REGULAR_MESSAGE_MAX);
46
+ await this.sync(false);
47
+ });
48
+ }
49
+ finish(message) {
50
+ // Suppress a deferred preview immediately. `finish` itself may wait in the
51
+ // operation queue behind one in-flight edit, but no newer preview should
52
+ // be sent once the turn has ended.
53
+ this.finishing = true;
54
+ this.clearCooldownTimer();
55
+ return this.enqueue(async () => {
56
+ if (this.finalized)
57
+ return;
58
+ if (message?.text !== undefined)
59
+ this.text = message.text;
60
+ this.pendingPreview = undefined;
61
+ await this.sync(true);
62
+ this.finalized = true;
63
+ });
64
+ }
65
+ fail(error) {
66
+ return this.enqueue(async () => {
67
+ if (this.finalized)
68
+ return;
69
+ if (isRateLimitError(error))
70
+ return;
71
+ this.finalized = true;
72
+ if (!this.messageId)
73
+ return;
74
+ const text = truncateGraphemes(`Warning: ${messageOf(error)}`, REGULAR_MESSAGE_MAX);
75
+ try {
76
+ await this.upstream.editMessageText(this.target.conversationId, this.messageId, text);
77
+ }
78
+ catch {
79
+ // Marking the message failed must not mask the original error.
80
+ }
81
+ });
82
+ }
83
+ /** Edit the rolling preview now; on `final` render + send the complete result. */
84
+ async sync(final) {
85
+ if (!this.text)
86
+ return;
87
+ if (final) {
88
+ await this.waitForCooldown();
89
+ await this.sendFinal();
90
+ return;
91
+ }
92
+ if (!this.messageId)
93
+ return;
94
+ const preview = this.pendingPreview ?? makePreview(this.text, REGULAR_MESSAGE_MAX);
95
+ if (this.finishing || this.isCoolingDown()) {
96
+ this.pendingPreview = preview;
97
+ if (!this.finishing)
98
+ this.scheduleCooldownFlush();
99
+ return;
100
+ }
101
+ if (this.sentPreview !== preview) {
102
+ this.pendingPreview = undefined;
103
+ try {
104
+ await this.upstream.editMessageText(this.target.conversationId, this.messageId, preview);
105
+ this.sentPreview = preview;
106
+ }
107
+ catch (error) {
108
+ if (!isRateLimitError(error))
109
+ throw error;
110
+ if (this.pendingPreview === undefined)
111
+ this.pendingPreview = preview;
112
+ this.startCooldown(error);
113
+ }
114
+ }
115
+ }
116
+ /**
117
+ * Send the final message. Plain mode: first chunk edits the existing message,
118
+ * overflow chunks are sent as new messages. Rich mode (richFinal + rich
119
+ * formatting): upgrade the same message to a Rich Message; fall back to plain
120
+ * once on a format error.
121
+ */
122
+ async sendFinal() {
123
+ const mode = this.options.formatting?.mode ?? 'auto';
124
+ if (this.options.richFinal && mode !== 'plain') {
125
+ await this.sendRichFinal();
126
+ return;
127
+ }
128
+ const chunks = splitByGraphemes(this.text, REGULAR_MESSAGE_MAX);
129
+ if (this.messageId && this.sentPreview !== chunks[0]) {
130
+ await this.editFinalText(chunks[0] ?? '');
131
+ this.sentPreview = chunks[0];
132
+ }
133
+ else if (!this.messageId) {
134
+ const sent = await this.upstream.sendMessage(this.target.conversationId, chunks[0] ?? '', targetOptions(this.target));
135
+ this.messageId = sent.messageId;
136
+ }
137
+ for (const chunk of chunks.slice(1)) {
138
+ await this.upstream.sendMessage(this.target.conversationId, chunk, {
139
+ messageThreadId: this.target.threadId,
140
+ });
141
+ }
142
+ }
143
+ /** Upgrade the in-place message to a Rich Message with plain-once fallback. */
144
+ async sendRichFinal() {
145
+ const source = this.text;
146
+ if (!source)
147
+ return;
148
+ await sendWithFallback(source, { mode: 'rich-markdown' }, async (plan) => {
149
+ if (plan.kind === 'rich') {
150
+ // Edit the same message into the first rich segment; overflow as new
151
+ // rich messages.
152
+ if (this.messageId) {
153
+ await this.editFinalRich({ markdown: plan.texts[0] ?? '' });
154
+ }
155
+ for (const text of plan.texts.slice(1)) {
156
+ await this.upstream.sendRichMessage(this.target.conversationId, { markdown: text });
157
+ }
158
+ return;
159
+ }
160
+ // Plain fallback on the same message + new overflow messages.
161
+ if (this.messageId) {
162
+ await this.editFinalText(plan.chunks[0] ?? '');
163
+ }
164
+ else {
165
+ const sent = await this.upstream.sendMessage(this.target.conversationId, plan.chunks[0] ?? '', targetOptions(this.target));
166
+ this.messageId = sent.messageId;
167
+ }
168
+ for (const chunk of plan.chunks.slice(1)) {
169
+ await this.upstream.sendMessage(this.target.conversationId, chunk, {
170
+ messageThreadId: this.target.threadId,
171
+ });
172
+ }
173
+ });
174
+ }
175
+ /** Retry only the in-place final edit after a Telegram-requested cooldown. */
176
+ async editFinalText(text) {
177
+ while (true) {
178
+ await this.waitForCooldown();
179
+ try {
180
+ await this.upstream.editMessageText(this.target.conversationId, this.messageId, text);
181
+ return;
182
+ }
183
+ catch (error) {
184
+ if (!isRateLimitError(error))
185
+ throw error;
186
+ this.startCooldown(error);
187
+ }
188
+ }
189
+ }
190
+ /** The rich final path uses Telegram's edit endpoint too, so it shares 429 recovery. */
191
+ async editFinalRich(message) {
192
+ while (true) {
193
+ await this.waitForCooldown();
194
+ try {
195
+ await this.upstream.editMessageRich(this.target.conversationId, this.messageId, message);
196
+ return;
197
+ }
198
+ catch (error) {
199
+ if (!isRateLimitError(error))
200
+ throw error;
201
+ this.startCooldown(error);
202
+ }
203
+ }
204
+ }
205
+ isCoolingDown() {
206
+ return Date.now() < this.cooldownUntil;
207
+ }
208
+ /** Honor the server's retry hint, extending (never shortening) cooldown. */
209
+ startCooldown(error) {
210
+ const retryAfter = error.parameters?.retryAfter;
211
+ const delayMs = Number.isFinite(retryAfter)
212
+ ? Math.max(1, Math.ceil(retryAfter)) * 1000
213
+ : 1000;
214
+ this.cooldownUntil = Math.max(this.cooldownUntil, Date.now() + delayMs);
215
+ this.scheduleCooldownFlush();
216
+ }
217
+ /** One timer, one latest-value flush. It never replays a sequence of edits. */
218
+ scheduleCooldownFlush() {
219
+ if (this.finishing || this.finalized || this.cooldownTimer || !this.pendingPreview)
220
+ return;
221
+ this.cooldownTimer = setTimeout(() => {
222
+ this.cooldownTimer = undefined;
223
+ void this.enqueue(() => this.sync(false)).catch(() => { });
224
+ }, Math.max(1, this.cooldownUntil - Date.now()));
225
+ }
226
+ clearCooldownTimer() {
227
+ if (this.cooldownTimer)
228
+ clearTimeout(this.cooldownTimer);
229
+ this.cooldownTimer = undefined;
230
+ }
231
+ async waitForCooldown() {
232
+ while (this.isCoolingDown()) {
233
+ await new Promise((resolve) => {
234
+ setTimeout(resolve, Math.max(1, this.cooldownUntil - Date.now()));
235
+ });
236
+ }
237
+ }
238
+ enqueue(task) {
239
+ const next = this.queue.then(task, task);
240
+ this.queue = next.then(() => undefined, () => undefined);
241
+ return next;
242
+ }
243
+ }
244
+ function messageOf(error) {
245
+ if (error instanceof Error)
246
+ return error.message;
247
+ return String(error);
248
+ }
249
+ function isRateLimitError(error) {
250
+ return error instanceof TelegramApiError && error.kind === 'rate-limit';
251
+ }
252
+ function targetOptions(target) {
253
+ return {
254
+ replyToMessageId: target.replyToMessageId,
255
+ messageThreadId: target.threadId,
256
+ };
257
+ }
258
+ /**
259
+ * Build a rolling preview for an in-progress stream: show the whole text up to
260
+ * `limit`, otherwise `…` + the newest `limit - 1` graphemes. This keeps the
261
+ * preview visibly changing once the model exceeds the single-message limit
262
+ * (plan §6.3), fixing the frozen `chunks[0]` behaviour.
263
+ */
264
+ export function makePreview(text, limit) {
265
+ return makeRollingPreview(text, limit);
266
+ }
267
+ /** Internal: rolling head/tail preview implementation. */
268
+ function makeRollingPreview(text, limit) {
269
+ if (visibleLength(text) <= limit)
270
+ return text;
271
+ // Show `…` + the newest (limit-1) graphemes so the user sees output continue.
272
+ return `…${tailGraphemes(text, limit - 1)}`;
273
+ }
274
+ //# sourceMappingURL=streaming-reply.js.map