@zhin.js/adapter-telegram 1.1.20 → 1.1.21

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.
@@ -1,76 +1,120 @@
1
- /**
2
- * Canonical Markdown → Telegram Bot API HTML.
3
- *
4
- * Telegram's MarkdownV2 is not CommonMark-compatible and rejects unescaped
5
- * punctuation. HTML gives the adapter a safer dialect seam: user text is
6
- * escaped first and only the subset supported by Bot API is emitted as tags.
7
- */
1
+ /** Canonical Markdown → Telegram Bot API HTML, with optional source positions for streaming. */
2
+ interface ProjectedText {
3
+ readonly text: string;
4
+ /** UTF-16 source position for every HTML code unit; -1 denotes generated markup. */
5
+ readonly source?: readonly number[];
6
+ }
7
+
8
8
  export function markdownToTelegramHtml(markdown: string): string {
9
- const protectedFragments: string[] = [];
10
- const protect = (html: string): string => {
11
- const token = `\uE000${protectedFragments.length}\uE001`;
12
- protectedFragments.push(html);
9
+ return projectMarkdown({ text: markdown }).text;
10
+ }
11
+
12
+ /** Shares exactly the complete-message codec; generated tags never consume source text. */
13
+ export function projectTelegramMarkdown(markdown: string): { readonly html: string; readonly source: readonly number[] } {
14
+ const value = projectMarkdown({ text: markdown, source: Array.from({ length: markdown.length }, (_, index) => index) });
15
+ return { html: value.text, source: value.source! };
16
+ }
17
+
18
+ function projectMarkdown(markdown: ProjectedText): ProjectedText {
19
+ const fragments: ProjectedText[] = [];
20
+ const literal = (text: string, origin = -1): ProjectedText => ({ text,
21
+ ...(markdown.source ? { source: Array<number>(text.length).fill(origin) } : {}) });
22
+ const protect = (value: ProjectedText): ProjectedText => {
23
+ const token = literal(`\uE000${fragments.length}\uE001`);
24
+ fragments.push(value);
13
25
  return token;
14
26
  };
27
+ const wrapped = (tag: string, value: ProjectedText): ProjectedText => join([
28
+ literal(`<${tag}>`), value, literal(`</${tag.split(' ')[0]}>`),
29
+ ]);
30
+ let value = fencedCode(markdown, code => protect(code), literal);
31
+ value = replace(value, /`([^`\n]+)`/g, (_match, group) => protect(wrapped('code', escape(group(1)))));
32
+ value = escape(value);
33
+ value = replace(value, /!\[([^\[\]]*)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_match, group) => group(1));
34
+ value = replace(value, /\[([^\[\]]+)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, (_match, group) => {
35
+ const label = group(1), href = group(2);
36
+ return isSafeTelegramHref(href.text) ? join([literal('<a href="'), href, literal('">'), label, literal('</a>')]) : label;
37
+ });
38
+ value = replace(value, /^#{1,6}\s+(.+)$/gm, (_match, group) => wrapped('b', group(1)));
39
+ value = replace(value, /^&gt;\s?(.*)$/gm, (_match, group) => wrapped('blockquote', group(1)));
40
+ value = replace(value, /^\s*[-+*]\s+(.+)$/gm, (_match, group) => join([
41
+ literal('• ', group(0).source?.find(position => position >= 0)), group(1),
42
+ ]));
43
+ for (const [pattern, tag] of [[/\*\*([^*\n]+)\*\*/g, 'b'], [/__([^_\n]+)__/g, 'b'], [/~~([^~\n]+)~~/g, 's']] as const) {
44
+ value = replace(value, pattern, (_match, group) => wrapped(tag, group(1)));
45
+ }
46
+ for (const pattern of [/(^|[^*])\*([^*\n]+)\*/g, /(^|[^_])_([^_\n]+)_/g]) {
47
+ value = replace(value, pattern, (_match, group) => join([group(1), wrapped('i', group(2))]));
48
+ }
49
+ for (let index = 0; index < fragments.length; index++) {
50
+ value = replace(value, new RegExp(`\uE000${index}\uE001`), () => fragments[index]!);
51
+ }
52
+ return value;
53
+ }
15
54
 
16
- let value = protectTelegramFencedCode(markdown, protect);
17
- value = value.replace(/`([^`\n]+)`/g, (_match, code) => (
18
- protect(`<code>${escapeTelegramHtml(String(code))}</code>`)
19
- ));
20
- value = escapeTelegramHtml(value);
55
+ function slice(value: ProjectedText, start: number, end?: number): ProjectedText {
56
+ return { text: value.text.slice(start, end), ...(value.source ? { source: value.source.slice(start, end) } : {}) };
57
+ }
21
58
 
22
- value = value
23
- .replace(/!\[([^\[\]]*)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g, '$1')
24
- .replace(/\[([^\[\]]+)\]\(([^()\s]+)(?:\s+&quot;[^&]*&quot;)?\)/g,
25
- (_match, label: string, href: string) => isSafeTelegramHref(href)
26
- ? `<a href="${href}">${label}</a>`
27
- : label)
28
- .replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
29
- .replace(/^&gt;\s?(.*)$/gm, '<blockquote>$1</blockquote>')
30
- .replace(/^\s*[-+*]\s+(.+)$/gm, '• $1')
31
- .replace(/\*\*([^*\n]+)\*\*/g, '<b>$1</b>')
32
- .replace(/__([^_\n]+)__/g, '<b>$1</b>')
33
- .replace(/~~([^~\n]+)~~/g, '<s>$1</s>')
34
- .replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<i>$2</i>')
35
- .replace(/(^|[^_])_([^_\n]+)_/g, '$1<i>$2</i>');
59
+ function join(values: readonly ProjectedText[]): ProjectedText {
60
+ const mapped = values.some(value => value.source);
61
+ return { text: values.map(value => value.text).join(''),
62
+ ...(mapped ? { source: values.flatMap(value => value.source ? [...value.source] : Array<number>(value.text.length).fill(-1)) } : {}) };
63
+ }
36
64
 
37
- for (let index = 0; index < protectedFragments.length; index += 1) {
38
- value = value.replace(`\uE000${index}\uE001`, protectedFragments[index]!);
65
+ function replace(value: ProjectedText, pattern: RegExp,
66
+ project: (match: RegExpExecArray, group: (index: number) => ProjectedText) => ProjectedText,
67
+ ): ProjectedText {
68
+ const matcher = new RegExp(pattern.source, `${pattern.flags}d`);
69
+ const chunks: ProjectedText[] = [];
70
+ let cursor = 0;
71
+ for (let match = matcher.exec(value.text); match; match = matcher.global ? matcher.exec(value.text) : null) {
72
+ chunks.push(slice(value, cursor, match.index));
73
+ const matched = match;
74
+ chunks.push(project(match, index => {
75
+ const [start, end] = matched.indices![index]!;
76
+ return slice(value, start, end);
77
+ }));
78
+ cursor = match.index + match[0].length;
79
+ if (!matcher.global) break;
39
80
  }
40
- return value;
81
+ chunks.push(slice(value, cursor));
82
+ return join(chunks);
83
+ }
84
+
85
+ function escape(value: ProjectedText): ProjectedText {
86
+ const entities: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' };
87
+ return replace(value, /[&<>"]/g, (match, group) => {
88
+ const origin = group(0).source?.[0];
89
+ const text = entities[match[0]]!;
90
+ return { text, ...(origin !== undefined ? { source: Array<number>(text.length).fill(origin) } : {}) };
91
+ });
41
92
  }
42
93
 
43
- function protectTelegramFencedCode(
44
- markdown: string,
45
- protect: (html: string) => string,
46
- ): string {
47
- const chunks: string[] = [];
94
+ function fencedCode(value: ProjectedText, protect: (value: ProjectedText) => ProjectedText,
95
+ literal: (text: string) => ProjectedText,
96
+ ): ProjectedText {
97
+ const chunks: ProjectedText[] = [];
48
98
  let cursor = 0;
49
- while (cursor < markdown.length) {
50
- const opening = markdown.indexOf('```', cursor);
51
- if (opening < 0) {
52
- chunks.push(markdown.slice(cursor));
53
- break;
54
- }
55
- chunks.push(markdown.slice(cursor, opening));
99
+ while (cursor < value.text.length) {
100
+ const opening = value.text.indexOf('```', cursor);
101
+ if (opening < 0) { chunks.push(slice(value, cursor)); break; }
102
+ chunks.push(slice(value, cursor, opening));
56
103
  const contentStart = opening + 3;
57
- const closing = markdown.indexOf('```', contentStart);
58
- if (closing < 0) {
59
- chunks.push(markdown.slice(opening));
60
- break;
61
- }
62
- const newline = markdown.indexOf('\n', contentStart);
104
+ const closing = value.text.indexOf('```', contentStart);
105
+ if (closing < 0) { chunks.push(slice(value, opening)); break; }
106
+ const newline = value.text.indexOf('\n', contentStart);
63
107
  const hasHeader = newline >= 0 && newline < closing;
64
- const language = markdown.slice(contentStart, hasHeader ? newline : closing).trim();
65
- let code = hasHeader ? markdown.slice(newline + 1, closing) : '';
66
- if (code.endsWith('\n')) code = code.slice(0, -1);
67
- const escapedCode = escapeTelegramHtml(code);
68
- chunks.push(/^[A-Za-z0-9_+-]+$/.test(language)
69
- ? protect(`<pre><code class="language-${language}">${escapedCode}</code></pre>`)
70
- : protect(`<pre>${escapedCode}</pre>`));
108
+ const language = value.text.slice(contentStart, hasHeader ? newline : closing).trim();
109
+ let code = hasHeader ? slice(value, newline + 1, closing) : literal('');
110
+ if (code.text.endsWith('\n')) code = slice(code, 0, -1);
111
+ const escaped = escape(code);
112
+ chunks.push(protect(/^[A-Za-z0-9_+-]+$/.test(language)
113
+ ? join([literal(`<pre><code class="language-${language}">`), escaped, literal('</code></pre>')])
114
+ : join([literal('<pre>'), escaped, literal('</pre>')])));
71
115
  cursor = closing + 3;
72
116
  }
73
- return chunks.join('');
117
+ return join(chunks);
74
118
  }
75
119
 
76
120
  function isSafeTelegramHref(value: string): boolean {
@@ -78,9 +122,5 @@ function isSafeTelegramHref(value: string): boolean {
78
122
  }
79
123
 
80
124
  export function escapeTelegramHtml(value: string): string {
81
- return value
82
- .replace(/&/g, '&amp;')
83
- .replace(/</g, '&lt;')
84
- .replace(/>/g, '&gt;')
85
- .replace(/"/g, '&quot;');
125
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
86
126
  }
package/src/protocol.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { EndpointProfileConfiguration } from 'zhin.js/adapter';
1
2
  /**
2
3
  * Telegram Bot API protocol helpers — no legacy Adapter/Endpoint / segment-mapper.
3
4
  * Canonicalization is owned by gateway/core before endpoint.send.
@@ -12,11 +13,14 @@ import { escapeTelegramHtml, markdownToTelegramHtml } from './markdown-to-html.j
12
13
  const logger = getLogger('telegram');
13
14
 
14
15
  /** One endpoint config after AdapterIndex expands `plugins.<instanceKey>.endpoints`. */
15
- export interface TelegramEndpointConfig {
16
+ export interface TelegramEndpointConfig extends EndpointProfileConfiguration {
16
17
  readonly id: string;
17
18
  readonly token: string;
18
19
  /** Default true. `false` selects webhook mode (requires httpHostToken). */
19
20
  readonly polling?: boolean;
21
+ /** Show private-chat drafts while generating; false keeps complete delivery. */
22
+ readonly streaming?: boolean;
23
+ readonly streamingIntervalMs?: number;
20
24
  readonly webhook?: {
21
25
  readonly domain?: string;
22
26
  readonly path?: string;
@@ -33,6 +37,8 @@ export interface ResolvedTelegramConfig {
33
37
  readonly mode: 'polling' | 'webhook';
34
38
  readonly allowedUpdates: readonly string[];
35
39
  readonly apiBaseUrl: string;
40
+ readonly streaming?: boolean;
41
+ readonly streamingIntervalMs?: number;
36
42
  readonly webhook?: {
37
43
  readonly domain: string;
38
44
  readonly path: string;
@@ -242,6 +248,8 @@ export type TelegramOutboundAction =
242
248
  export function resolveTelegramConfig(config: TelegramEndpointConfig): ResolvedTelegramConfig {
243
249
  const id = requiredEndpointField(config.id, 'id');
244
250
  const token = requiredEndpointField(config.token, 'token');
251
+ const streamingIntervalMs = config.streamingIntervalMs ?? 500;
252
+ if (!Number.isFinite(streamingIntervalMs) || streamingIntervalMs < 0) throw new TypeError('streamingIntervalMs must be a non-negative finite number');
245
253
  const polling = config.polling;
246
254
  const webhookSource = config.webhook;
247
255
  const mode: 'polling' | 'webhook' = polling === false ? 'webhook' : 'polling';
@@ -261,6 +269,8 @@ export function resolveTelegramConfig(config: TelegramEndpointConfig): ResolvedT
261
269
  mode,
262
270
  allowedUpdates: [...allowedUpdates],
263
271
  apiBaseUrl,
272
+ streaming: config.streaming !== false,
273
+ streamingIntervalMs,
264
274
  webhook,
265
275
  };
266
276
  }
@@ -522,6 +532,19 @@ function buildOutboundActions(
522
532
  replyTo != null ? { reply_parameters: { message_id: replyTo } } : {}
523
533
  );
524
534
 
535
+ const flushText = (): void => {
536
+ if (!textContent.trim() && !keyboard) return;
537
+ actions.push({ method: 'sendMessage', params: {
538
+ chat_id: chatId,
539
+ text: textContent.trim() || ' ',
540
+ ...htmlMode,
541
+ ...replyParams(),
542
+ ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
543
+ } });
544
+ textContent = '';
545
+ keyboard = undefined;
546
+ };
547
+
525
548
  /**
526
549
  * 媒体来源归一:唯一来源是 canonical `data.media` MediaRef。
527
550
  * kind=url/file → 字符串直发(file 即 Telegram file_id 不透明引用);
@@ -619,6 +642,7 @@ function buildOutboundActions(
619
642
  break;
620
643
  }
621
644
  case 'image': {
645
+ if (keyboard) flushText();
622
646
  const photo = mediaSource('image', data, 'image.png');
623
647
  if (photo) {
624
648
  actions.push({
@@ -636,6 +660,7 @@ function buildOutboundActions(
636
660
  break;
637
661
  }
638
662
  case 'video': {
663
+ if (keyboard) flushText();
639
664
  const video = mediaSource('video', data, 'video.mp4');
640
665
  if (video) {
641
666
  actions.push({
@@ -653,6 +678,7 @@ function buildOutboundActions(
653
678
  break;
654
679
  }
655
680
  case 'audio': {
681
+ if (keyboard) flushText();
656
682
  const audio = mediaSource('audio', data, 'audio.mp3');
657
683
  if (audio) {
658
684
  actions.push({
@@ -670,6 +696,7 @@ function buildOutboundActions(
670
696
  break;
671
697
  }
672
698
  case 'voice': {
699
+ if (keyboard) flushText();
673
700
  const voice = mediaSource('voice', data, 'voice.ogg');
674
701
  if (voice) {
675
702
  actions.push({
@@ -687,6 +714,7 @@ function buildOutboundActions(
687
714
  break;
688
715
  }
689
716
  case 'file': {
717
+ if (keyboard) flushText();
690
718
  const document = mediaSource('file', data, 'file');
691
719
  if (document) {
692
720
  actions.push({
@@ -706,6 +734,7 @@ function buildOutboundActions(
706
734
  case 'sticker': {
707
735
  const sticker = mediaSource('sticker', data, 'sticker.webp');
708
736
  if (sticker) {
737
+ flushText();
709
738
  actions.push({
710
739
  method: 'sendSticker',
711
740
  params: { chat_id: chatId, sticker, ...replyParams() },
@@ -714,6 +743,7 @@ function buildOutboundActions(
714
743
  break;
715
744
  }
716
745
  case 'location': {
746
+ flushText();
717
747
  actions.push({
718
748
  method: 'sendLocation',
719
749
  params: {
@@ -730,33 +760,8 @@ function buildOutboundActions(
730
760
  }
731
761
  }
732
762
 
733
- if (actions.length === 0) {
734
- const text = textContent.trim() || (keyboard ? ' ' : '');
735
- if (!text && !keyboard) throw new Error('No Telegram content to send');
736
- return [{
737
- method: 'sendMessage',
738
- params: {
739
- chat_id: chatId,
740
- text: text || ' ',
741
- ...htmlMode,
742
- ...replyParams(),
743
- ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
744
- },
745
- }];
746
- }
747
-
748
- if (textContent.trim() || keyboard) {
749
- actions.unshift({
750
- method: 'sendMessage',
751
- params: {
752
- chat_id: chatId,
753
- text: textContent.trim() || ' ',
754
- ...htmlMode,
755
- ...replyParams(),
756
- ...(keyboard ? { reply_markup: { inline_keyboard: keyboard } } : {}),
757
- },
758
- });
759
- }
763
+ flushText();
764
+ if (actions.length === 0) throw new Error('No Telegram content to send');
760
765
 
761
766
  return actions;
762
767
  }
@@ -0,0 +1,58 @@
1
+ import type { EndpointProfile } from 'zhin.js/adapter';
2
+ import type { SelfProfile } from '@zhin.js/im-contract';
3
+ import type { TelegramClientApi } from './telegram-client.js';
4
+ import { TelegramApiError } from './api-error.js';
5
+
6
+ interface TelegramSelfProfileOptions {
7
+ readonly client: Pick<TelegramClientApi, 'callApi' | 'callApiForm'>;
8
+ readonly current: () => Readonly<SelfProfile>;
9
+ readonly nicknameChanged: (nickname: string) => void;
10
+ }
11
+
12
+ /** Bot API names/descriptions are account fields, not the title/description of a chat. */
13
+ export function createTelegramSelfProfile({ client, current, nicknameChanged }: TelegramSelfProfileOptions): EndpointProfile {
14
+ return Object.freeze<EndpointProfile>({
15
+ writableFields: Object.freeze(['nickname', 'signature', 'bio', 'avatar']),
16
+ get current() { return current(); },
17
+ async read({ signal }) {
18
+ const [name, description, shortDescription] = await Promise.all([
19
+ client.callApi<{ name: string }>('getMyName', {}, signal),
20
+ client.callApi<{ description: string }>('getMyDescription', {}, signal),
21
+ client.callApi<{ short_description: string }>('getMyShortDescription', {}, signal),
22
+ ]);
23
+ if (typeof name.name !== 'string' || typeof description.description !== 'string' || typeof shortDescription.short_description !== 'string') {
24
+ throw new Error('Telegram returned an incomplete bot profile');
25
+ }
26
+ return { nickname: name.name, signature: shortDescription.short_description, bio: description.description };
27
+ },
28
+ async write({ change, signal }) {
29
+ try {
30
+ if (change.field === 'avatar') {
31
+ if (change.value.kind !== 'base64' || change.value.mime_type !== 'image/jpeg') {
32
+ return { status: 'rejected', message: 'Telegram profile photos require resolved JPEG image bytes' };
33
+ }
34
+ const form = new FormData();
35
+ form.set('photo', JSON.stringify({ type: 'static', photo: 'attach://avatar' }));
36
+ form.set('avatar', new Blob([Uint8Array.from(Buffer.from(change.value.value, 'base64'))], { type: 'image/jpeg' }), 'avatar.jpg');
37
+ const accepted = await client.callApiForm<boolean>('setMyProfilePhoto', form, signal);
38
+ return accepted === true ? { status: 'updated' } : { status: 'rejected', message: 'Telegram rejected the avatar change' };
39
+ }
40
+ let method: string;
41
+ let params: Record<string, unknown>;
42
+ switch (change.field) {
43
+ case 'nickname': method = 'setMyName'; params = { name: change.value }; break;
44
+ case 'signature': method = 'setMyShortDescription'; params = { short_description: change.value }; break;
45
+ case 'bio': method = 'setMyDescription'; params = { description: change.value }; break;
46
+ default: return { status: 'rejected', message: `Telegram bot profile does not expose ${change.field} through this port` };
47
+ }
48
+ const accepted = await client.callApi<boolean>(method, params, signal);
49
+ if (accepted !== true) return { status: 'rejected', message: `Telegram rejected the ${change.field} change` };
50
+ if (change.field === 'nickname') nicknameChanged(change.value);
51
+ return { status: 'updated', profile: { [change.field]: change.value } };
52
+ } catch (error) {
53
+ if (!(error instanceof TelegramApiError) || error.disposition === 'not_sent') throw error;
54
+ return { status: error.disposition === 'rejected' ? 'rejected' : 'unknown', message: error.message };
55
+ }
56
+ },
57
+ });
58
+ }
@@ -0,0 +1,67 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+ import { projectTelegramMarkdown } from './markdown-to-html.js';
3
+
4
+ interface HtmlTag { readonly name: string; readonly html: string; }
5
+ interface VisibleUnit {
6
+ readonly html: string;
7
+ readonly sourceStart: number;
8
+ readonly sourceEnd: number;
9
+ readonly tags: readonly HtmlTag[];
10
+ }
11
+
12
+ /** Render a physical slice while retaining the original Markdown's source coordinates. */
13
+ export function sliceTelegramMarkdown(text: string, precedingText: string, capacity: number): {
14
+ readonly html: string;
15
+ readonly acceptedText: string;
16
+ readonly exhausted: boolean;
17
+ } {
18
+ const full = precedingText + text;
19
+ const projection = projectTelegramMarkdown(full);
20
+ const units: VisibleUnit[] = [];
21
+ const tags: HtmlTag[] = [];
22
+ const trimStart = projection.html.length - projection.html.trimStart().length;
23
+ const trimEnd = projection.html.trimEnd().length;
24
+ const html = projection.html.slice(trimStart, trimEnd);
25
+ const matcher = /<\/?[a-z][^>]*>|&(?:amp|lt|gt|quot);|[\s\S]/gu;
26
+ for (const match of html.matchAll(matcher)) {
27
+ const raw = match[0];
28
+ if (raw.startsWith('<')) {
29
+ const tag = /^<(\/?)([a-z]+)/.exec(raw)!;
30
+ if (tag[1]) {
31
+ if (tags.at(-1)?.name !== tag[2]) throw unsupportedProjection();
32
+ tags.pop();
33
+ } else tags.push({ name: tag[2], html: raw });
34
+ continue;
35
+ }
36
+ const origins = projection.source.slice(trimStart + match.index, trimStart + match.index + raw.length).filter(position => position >= 0);
37
+ if (!origins.length) throw unsupportedProjection();
38
+ const sourceStart = Math.min(...origins), sourceEnd = Math.max(...origins) + 1;
39
+ if (sourceEnd > precedingText.length) units.push({ html: raw, sourceStart, sourceEnd, tags: [...tags] });
40
+ }
41
+ if (tags.length) throw unsupportedProjection();
42
+ if (!units.length) return { html: '', acceptedText: text, exhausted: false };
43
+
44
+ // One decoded code point is one unit. Keep replacement expansions together
45
+ // when their source positions coincide (for example a Markdown bullet).
46
+ let take = Math.min(capacity, units.length);
47
+ while (take > 0 && take < units.length && units[take].sourceStart < units[take - 1].sourceEnd) take--;
48
+ if (take <= 0) throw new EndpointDeliveryError('stream_capacity', 'Telegram prefix leaves no room for a Markdown unit', 'not_sent');
49
+ const end = take < units.length ? units[take].sourceStart : full.length;
50
+ if (end <= precedingText.length) throw unsupportedProjection();
51
+ let rendered = '';
52
+ let active: readonly HtmlTag[] = [];
53
+ for (const unit of units.slice(0, take)) {
54
+ let common = 0;
55
+ while (common < active.length && common < unit.tags.length && active[common].html === unit.tags[common].html) common++;
56
+ for (let index = active.length - 1; index >= common; index--) rendered += `</${active[index].name}>`;
57
+ for (const tag of unit.tags.slice(common)) rendered += tag.html;
58
+ rendered += unit.html;
59
+ active = unit.tags;
60
+ }
61
+ for (let index = active.length - 1; index >= 0; index--) rendered += `</${active[index].name}>`;
62
+ return { html: rendered, acceptedText: text.slice(0, end - precedingText.length), exhausted: take < units.length || take === capacity };
63
+ }
64
+
65
+ function unsupportedProjection(): EndpointDeliveryError {
66
+ return new EndpointDeliveryError('unsupported_operation', 'Telegram Markdown projection has no safe source mapping', 'not_sent');
67
+ }
@@ -0,0 +1,110 @@
1
+ import { randomInt } from 'node:crypto';
2
+ import type { EndpointMessageStream, EndpointStreamAcknowledgement, EndpointStreaming, EndpointStreamRequest } from 'zhin.js/adapter';
3
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
4
+ import { formatOutboundPlan, type TelegramWireSegment } from './protocol.js';
5
+ import { escapeTelegramHtml } from './markdown-to-html.js';
6
+ import { sliceTelegramMarkdown } from './stream-markdown.js';
7
+ import type { TelegramClientApi } from './telegram-client.js';
8
+
9
+ function supportsTelegramStream(request: Pick<EndpointStreamRequest, 'conversation' | 'source'>): boolean {
10
+ return request.conversation.kind === 'private' && !request.conversation.parent;
11
+ }
12
+
13
+ /** Drafts are previews; only finish performs a persistent send. */
14
+ export function createTelegramStreaming(getClient: () => Pick<TelegramClientApi, 'callApi'>, minIntervalMs = 500): EndpointStreaming {
15
+ return {
16
+ mode: 'draft',
17
+ minIntervalMs,
18
+ supports: supportsTelegramStream,
19
+ open(request): EndpointMessageStream | undefined {
20
+ if (!supportsTelegramStream(request)) return undefined;
21
+ if (request.prefix?.some(segment => segment.type !== 'reply' && segment.type !== 'mention')) return undefined;
22
+ const prefix: TelegramWireSegment[] = (request.prefix ?? []).map(segment => segment.type === 'mention'
23
+ ? { type: 'at', data: { id: 'target' in segment.data ? segment.data.target : undefined,
24
+ name: 'name' in segment.data ? segment.data.name : undefined } }
25
+ : { type: segment.type, data: { ...segment.data } });
26
+ const draftId = randomInt(1, 2_147_483_647);
27
+ const cancellation = new AbortController();
28
+ const signal = request.signal ? AbortSignal.any([request.signal, cancellation.signal]) : cancellation.signal;
29
+ const threadId = request.conversation.threadId === undefined ? undefined : Number(request.conversation.threadId);
30
+ if (threadId !== undefined && (!Number.isSafeInteger(threadId) || threadId <= 0)) {
31
+ throw new EndpointDeliveryError('invalid_payload', 'Telegram topic id must be a positive integer', 'not_sent');
32
+ }
33
+ let acceptedText = '';
34
+ let messageId: string | undefined;
35
+ let exhausted = false;
36
+ let contentUnavailable = false;
37
+ let confirmed: { readonly source: string; readonly params: Record<string, unknown> & { readonly text: string }; readonly exhausted: boolean } | undefined;
38
+ let failure: unknown;
39
+ let completion: Promise<EndpointStreamAcknowledgement> | undefined;
40
+ const acknowledgement = (): EndpointStreamAcknowledgement => ({
41
+ status: messageId ? 'sent' : acceptedText ? 'preview' : 'buffered', acceptedText,
42
+ ...(messageId ? { messageId } : {}),
43
+ ...(exhausted ? { exhausted: true as const } : {}),
44
+ ...(contentUnavailable ? { contentUnavailable: true as const } : {}),
45
+ });
46
+ const prepare = (text: string) => {
47
+ const mentionText = prefix.map(segment => segment.type === 'at' && segment.data?.id
48
+ ? `@${segment.data.name || segment.data.id}` : '').join('');
49
+ const mentionLength = Array.from(mentionText).length;
50
+ const capacity = 4096 - mentionLength;
51
+ if (capacity <= 0) throw new EndpointDeliveryError('stream_capacity', 'Telegram prefix exceeds its text container', 'not_sent');
52
+ const markdown = request.format === 'markdown'
53
+ ? sliceTelegramMarkdown(text, request.continuation?.precedingText ?? '', capacity) : undefined;
54
+ const source = markdown?.acceptedText ?? Array.from(text).slice(0, capacity).join('');
55
+ const plan = formatOutboundPlan(request.conversation.id, [...prefix, request.format === 'markdown'
56
+ ? { type: 'markdown', data: { content: source } }
57
+ : { type: 'text', data: { text: source } }]);
58
+ const action = plan.actions[0];
59
+ if (plan.actions.length !== 1 || action?.method !== 'sendMessage') {
60
+ throw new EndpointDeliveryError('unsupported_operation', 'Telegram draft requires one text container', 'not_sent');
61
+ }
62
+ const params = { ...action.params,
63
+ ...(markdown ? { text: escapeTelegramHtml(mentionText) + markdown.html } : {}),
64
+ ...(threadId !== undefined ? { message_thread_id: threadId } : {}) };
65
+ return { source, params, exhausted: markdown?.exhausted ?? (source.length < text.length || Array.from(params.text).length === 4096) };
66
+ };
67
+ const send = async (text: string, final: boolean): Promise<EndpointStreamAcknowledgement> => {
68
+ if (failure) throw failure;
69
+ if (signal.aborted) throw new EndpointDeliveryError('endpoint_inactive', 'Telegram draft cancelled', 'not_sent');
70
+ if (!text.startsWith(acceptedText)) throw new EndpointDeliveryError('invalid_payload', 'Telegram draft cannot replace its accepted source prefix', 'not_sent');
71
+ if (!text.trim() || (!final && text === acceptedText)) return acknowledgement();
72
+ const next = final && text === acceptedText && confirmed ? confirmed : prepare(text);
73
+ const { source, params } = next;
74
+ if (!final && source === acceptedText) return acknowledgement();
75
+ if (!params.text) return acknowledgement();
76
+ try {
77
+ if (final) {
78
+ const result = await getClient().callApi<{ message_id?: number }>('sendMessage', params, signal);
79
+ if (!Number.isSafeInteger(result?.message_id) || result.message_id! <= 0) {
80
+ throw new EndpointDeliveryError('delivery_unconfirmed', 'Telegram final send returned no valid message_id', 'unknown');
81
+ }
82
+ messageId = String(result.message_id);
83
+ } else {
84
+ const { reply_parameters: _reply, ...draftParams } = params;
85
+ const result = await getClient().callApi<unknown>('sendMessageDraft', { ...draftParams, draft_id: draftId }, signal);
86
+ if (result !== true) throw new EndpointDeliveryError('delivery_unconfirmed', 'Telegram draft response is unconfirmed', 'unknown');
87
+ }
88
+ acceptedText = source;
89
+ // A temporary Markdown preview can still reinterpret an unfinished fence/link.
90
+ // Rollover persists this container, so only the final source may trigger it.
91
+ exhausted = next.exhausted && (final || request.format !== 'markdown');
92
+ contentUnavailable = request.format === 'markdown'
93
+ && (Boolean(request.continuation?.precedingText) || exhausted || source.length < text.length);
94
+ confirmed = next;
95
+ return acknowledgement();
96
+ } catch (error) {
97
+ failure = error;
98
+ throw error;
99
+ }
100
+ };
101
+ return {
102
+ write: text => completion
103
+ ? Promise.reject(new EndpointDeliveryError('stream_closed', 'Telegram draft is finishing', 'not_sent'))
104
+ : send(text, false),
105
+ finish: text => completion ??= send(text, true),
106
+ cancel: () => { cancellation.abort(); },
107
+ };
108
+ },
109
+ };
110
+ }