@zhin.js/adapter-telegram 1.1.20 → 1.1.22
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/CHANGELOG.md +76 -0
- package/README.md +28 -0
- package/adapters/telegram/index.js +20 -5
- package/adapters/telegram/index.ts +20 -5
- package/lib/adapter.js +1 -1
- package/lib/console-spec.js +2 -0
- package/lib/endpoint.js +11 -0
- package/lib/markdown-to-html.d.ts +5 -7
- package/lib/markdown-to-html.js +90 -51
- package/lib/protocol.d.ts +7 -1
- package/lib/protocol.js +33 -31
- package/lib/self-profile.d.ts +11 -0
- package/lib/self-profile.js +61 -0
- package/lib/stream-markdown.d.ts +6 -0
- package/lib/stream-markdown.js +66 -0
- package/lib/streaming.d.ts +4 -0
- package/lib/streaming.js +118 -0
- package/package.json +22 -21
- package/plugin.js +1 -0
- package/schema.json +126 -1
- package/src/adapter.ts +1 -1
- package/src/console-spec.ts +2 -0
- package/src/endpoint.ts +10 -0
- package/src/markdown-to-html.ts +104 -64
- package/src/protocol.ts +33 -28
- package/src/self-profile.ts +58 -0
- package/src/stream-markdown.ts +67 -0
- package/src/streaming.ts +110 -0
package/src/markdown-to-html.ts
CHANGED
|
@@ -1,76 +1,120 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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+"[^&]*")?\)/g, (_match, group) => group(1));
|
|
34
|
+
value = replace(value, /\[([^\[\]]+)\]\(([^()\s]+)(?:\s+"[^&]*")?\)/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, /^>\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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
: label)
|
|
28
|
-
.replace(/^#{1,6}\s+(.+)$/gm, '<b>$1</b>')
|
|
29
|
-
.replace(/^>\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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
81
|
+
chunks.push(slice(value, cursor));
|
|
82
|
+
return join(chunks);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function escape(value: ProjectedText): ProjectedText {
|
|
86
|
+
const entities: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"' };
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 <
|
|
50
|
-
const opening =
|
|
51
|
-
if (opening < 0) {
|
|
52
|
-
|
|
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 =
|
|
58
|
-
if (closing < 0) {
|
|
59
|
-
|
|
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 =
|
|
65
|
-
let code = hasHeader ?
|
|
66
|
-
if (code.endsWith('\n')) code =
|
|
67
|
-
const
|
|
68
|
-
chunks.push(/^[A-Za-z0-9_+-]+$/.test(language)
|
|
69
|
-
?
|
|
70
|
-
:
|
|
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
|
|
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, '&')
|
|
83
|
-
.replace(/</g, '<')
|
|
84
|
-
.replace(/>/g, '>')
|
|
85
|
-
.replace(/"/g, '"');
|
|
125
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
|
|
734
|
-
|
|
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
|
+
}
|
package/src/streaming.ts
ADDED
|
@@ -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
|
+
}
|