pi-braid-telegram 0.0.0-stage → 0.1.5
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/LICENSE +21 -0
- package/README.md +221 -2
- package/build-info.json +9 -0
- package/docs/live-update.md +190 -0
- package/docs/pause-control.md +151 -0
- package/docs/turn-complete-notify.md +110 -0
- package/package.json +62 -4
- package/src/index.ts +1853 -0
- package/src/lib/audio-normalize.ts +91 -0
- package/src/lib/decision-buttons.ts +574 -0
- package/src/lib/document.ts +203 -0
- package/src/lib/format.ts +822 -0
- package/src/lib/html-check.ts +280 -0
- package/src/lib/inbound-poller.ts +1248 -0
- package/src/lib/inbound-queue.ts +157 -0
- package/src/lib/local-time.ts +181 -0
- package/src/lib/notify/agent-delivery.ts +29 -0
- package/src/lib/notify/craft.ts +157 -0
- package/src/lib/notify/live-update.ts +716 -0
- package/src/lib/notify/model.ts +197 -0
- package/src/lib/notify/rate-limit.ts +44 -0
- package/src/lib/notify/turn-notify.ts +138 -0
- package/src/lib/pause-control.ts +425 -0
- package/src/lib/photo.ts +182 -0
- package/src/lib/poller-lock.ts +127 -0
- package/src/lib/session-role.ts +32 -0
- package/src/lib/state-paths.ts +164 -0
- package/src/lib/stt-keys.ts +211 -0
- package/src/lib/telegram-client.ts +699 -0
- package/src/lib/telegram-inbox.ts +203 -0
- package/src/lib/telegram-settings.ts +92 -0
- package/src/lib/tool-logic.ts +604 -0
- package/src/lib/transcribe.ts +574 -0
- package/src/lib/tts.ts +451 -0
- package/src/lib/venice-catalog-base-url.ts +45 -0
- package/src/lib/voice.ts +192 -0
- package/src/lib/watchdog-tunnel.ts +102 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict local checker for Telegram's HTML `parse_mode` subset.
|
|
3
|
+
*
|
|
4
|
+
* Telegram rejects a message outright when any tag is unsupported, an end tag
|
|
5
|
+
* does not match the innermost open tag, an attribute is malformed, or a
|
|
6
|
+
* `span`/`tg-emoji` misses its required attribute. This module mirrors that
|
|
7
|
+
* parser closely enough to decide, before an API call, whether a body is
|
|
8
|
+
* safe to ship with `parse_mode: "HTML"`: the MarkdownV2→HTML conversion uses
|
|
9
|
+
* it to refuse a conversion whose output would not parse, and the tests and
|
|
10
|
+
* the corpus runner use it as the local stand-in for the live parser. It is
|
|
11
|
+
* calibrated against the live API; `test/live-html-probe.mjs` re-runs that
|
|
12
|
+
* comparison on demand (opt-in, no delivery).
|
|
13
|
+
*
|
|
14
|
+
* Calibrated rules (the committed `test/fixtures/telegram-format-corpus.mjs`
|
|
15
|
+
* carries the recorded live verdicts; `test/live-html-probe.mjs` re-runs the
|
|
16
|
+
* comparison on demand):
|
|
17
|
+
* - only Telegram's tag set, case-insensitively;
|
|
18
|
+
* - attributes: `name`, `name=value`, `name="value"` or `name='value'`;
|
|
19
|
+
* quoted values may contain `>`, `<` and whitespace, unquoted values may
|
|
20
|
+
* not contain `/`, `<`, `=`, `"` or `'`;
|
|
21
|
+
* - an end tag takes no attributes (whitespace before `>` stays legal);
|
|
22
|
+
* - `span` requires class="tg-spoiler" (exactly), `tg-emoji` requires a
|
|
23
|
+
* canonical numeric `emoji-id` (no leading zeros, not zero, at most
|
|
24
|
+
* 9223372036854775807);
|
|
25
|
+
* - strict LIFO nesting, case-insensitively; no self-closing tags;
|
|
26
|
+
* - a `<` that starts no valid tag is an error ("Unsupported start tag");
|
|
27
|
+
* - everything outside tags is literal text: Telegram does not validate
|
|
28
|
+
* entities, so `&`, `&unknown;` and bare `>` are accepted.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Tag names Telegram's HTML parse_mode accepts (lowercased). */
|
|
32
|
+
export const TELEGRAM_HTML_TAGS: ReadonlySet<string> = new Set([
|
|
33
|
+
"b",
|
|
34
|
+
"strong",
|
|
35
|
+
"i",
|
|
36
|
+
"em",
|
|
37
|
+
"u",
|
|
38
|
+
"ins",
|
|
39
|
+
"s",
|
|
40
|
+
"strike",
|
|
41
|
+
"del",
|
|
42
|
+
"code",
|
|
43
|
+
"pre",
|
|
44
|
+
"a",
|
|
45
|
+
"blockquote",
|
|
46
|
+
"span",
|
|
47
|
+
"tg-spoiler",
|
|
48
|
+
"tg-emoji",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/** One attribute of a parsed tag. */
|
|
52
|
+
export interface TelegramTagAttribute {
|
|
53
|
+
name: string;
|
|
54
|
+
value: string | undefined;
|
|
55
|
+
/** True when the value was `"`- or `'`-quoted. */
|
|
56
|
+
quoted: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A tag-shaped token starting at `<`. */
|
|
60
|
+
export interface ParsedTelegramTag {
|
|
61
|
+
/** Index just past the closing `>`. */
|
|
62
|
+
end: number;
|
|
63
|
+
/** Lowercased tag name. */
|
|
64
|
+
name: string;
|
|
65
|
+
closing: boolean;
|
|
66
|
+
/** Syntax problem Telegram would reject (undefined = syntactically fine). */
|
|
67
|
+
error: string | undefined;
|
|
68
|
+
attributes: TelegramTagAttribute[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const NAME_START_RE = /[A-Za-z]/;
|
|
72
|
+
const NAME_CHAR_RE = /[A-Za-z0-9-]/;
|
|
73
|
+
const ATTR_NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
74
|
+
const SPACE_RE = /\s/;
|
|
75
|
+
const EMOJI_ID_RE = /^[1-9]\d{0,18}$/;
|
|
76
|
+
/** The signed 64-bit maximum Telegram accepts as an `emoji-id` value. */
|
|
77
|
+
const EMOJI_ID_MAX = "9223372036854775807";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Live, `emoji-id` is a signed 64-bit integer: a canonical digit string
|
|
81
|
+
* above `EMOJI_ID_MAX` fails with the same error as a malformed one. The
|
|
82
|
+
* canonical form has no leading zeros, so 19 digits is the only length that
|
|
83
|
+
* can exceed the maximum, and equal-length digit strings compare
|
|
84
|
+
* numerically.
|
|
85
|
+
*/
|
|
86
|
+
function isValidEmojiId(value: string): boolean {
|
|
87
|
+
if (!EMOJI_ID_RE.test(value)) return false;
|
|
88
|
+
return value.length < EMOJI_ID_MAX.length || value <= EMOJI_ID_MAX;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse the tag starting at `start` (which must be `<`). Returns null when no
|
|
93
|
+
* `>` terminates a well-formed start token (Telegram: "Unclosed start tag"),
|
|
94
|
+
* i.e. the `<` is plain text. A returned tag may still carry `error` for
|
|
95
|
+
* shapes Telegram rejects (empty attribute name, self-closing, bad value).
|
|
96
|
+
*/
|
|
97
|
+
export function parseTelegramTagAt(
|
|
98
|
+
text: string,
|
|
99
|
+
start: number,
|
|
100
|
+
): ParsedTelegramTag | null {
|
|
101
|
+
if (text[start] !== "<") return null;
|
|
102
|
+
let i = start + 1;
|
|
103
|
+
let closing = false;
|
|
104
|
+
if (text[i] === "/") {
|
|
105
|
+
closing = true;
|
|
106
|
+
i++;
|
|
107
|
+
}
|
|
108
|
+
if (i >= text.length || !NAME_START_RE.test(text[i])) return null;
|
|
109
|
+
const nameStart = i;
|
|
110
|
+
while (i < text.length && NAME_CHAR_RE.test(text[i])) i++;
|
|
111
|
+
const name = text.slice(nameStart, i).toLowerCase();
|
|
112
|
+
|
|
113
|
+
const attributes: TelegramTagAttribute[] = [];
|
|
114
|
+
let error: string | undefined;
|
|
115
|
+
for (;;) {
|
|
116
|
+
while (i < text.length && SPACE_RE.test(text[i])) i++;
|
|
117
|
+
if (i >= text.length) return null; // never closed
|
|
118
|
+
const ch = text[i];
|
|
119
|
+
if (ch === ">") {
|
|
120
|
+
i++;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
if (closing) {
|
|
124
|
+
// An end tag takes no attributes: Telegram fails `</b class="z">`
|
|
125
|
+
// with "Unclosed end tag"; only whitespace may precede the `>`.
|
|
126
|
+
const gt = text.indexOf(">", i);
|
|
127
|
+
if (gt === -1) return null; // never closed
|
|
128
|
+
error ??= `Unclosed end tag at byte offset ${start}`;
|
|
129
|
+
i = gt + 1;
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (ch === "/") {
|
|
133
|
+
// `<b/>`: Telegram reads the name as `b/` and rejects it.
|
|
134
|
+
if (text[i + 1] === ">") {
|
|
135
|
+
error ??= `Unsupported start tag "${name}/"`;
|
|
136
|
+
i += 2;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
error ??= `Unsupported start tag "${name}/"`;
|
|
140
|
+
i++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const attrStart = i;
|
|
144
|
+
while (
|
|
145
|
+
i < text.length &&
|
|
146
|
+
!SPACE_RE.test(text[i]) &&
|
|
147
|
+
text[i] !== "=" &&
|
|
148
|
+
text[i] !== ">" &&
|
|
149
|
+
text[i] !== "/" &&
|
|
150
|
+
text[i] !== "<"
|
|
151
|
+
) {
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
const attrName = text.slice(attrStart, i);
|
|
155
|
+
if (attrName === "" || !ATTR_NAME_RE.test(attrName)) {
|
|
156
|
+
error ??= `Empty attribute name in the tag "${name}"`;
|
|
157
|
+
// Skip the offending character so the scan always progresses.
|
|
158
|
+
i = Math.max(i, attrStart) + 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
let value: string | undefined;
|
|
162
|
+
let quoted = false;
|
|
163
|
+
const save = i;
|
|
164
|
+
while (i < text.length && SPACE_RE.test(text[i])) i++;
|
|
165
|
+
if (text[i] === "=") {
|
|
166
|
+
i++;
|
|
167
|
+
while (i < text.length && SPACE_RE.test(text[i])) i++;
|
|
168
|
+
const quote = text[i];
|
|
169
|
+
if (quote === '"' || quote === "'") {
|
|
170
|
+
const valueStart = i + 1;
|
|
171
|
+
let j = valueStart;
|
|
172
|
+
while (j < text.length && text[j] !== quote) j++;
|
|
173
|
+
if (j >= text.length) return null; // unterminated quoted value
|
|
174
|
+
value = text.slice(valueStart, j);
|
|
175
|
+
quoted = true;
|
|
176
|
+
i = j + 1;
|
|
177
|
+
} else {
|
|
178
|
+
const valueStart = i;
|
|
179
|
+
while (i < text.length && !SPACE_RE.test(text[i]) && text[i] !== ">") {
|
|
180
|
+
i++;
|
|
181
|
+
}
|
|
182
|
+
value = text.slice(valueStart, i);
|
|
183
|
+
// An empty unquoted value (`class=`) parses; `/`, `<`, `=`, `"` and
|
|
184
|
+
// `'` end the name token on Telegram's parser ("Unexpected end of
|
|
185
|
+
// name token").
|
|
186
|
+
if (
|
|
187
|
+
value.includes("/") ||
|
|
188
|
+
value.includes("<") ||
|
|
189
|
+
value.includes("=") ||
|
|
190
|
+
value.includes('"') ||
|
|
191
|
+
value.includes("'")
|
|
192
|
+
) {
|
|
193
|
+
error ??= `Unexpected end of name token at byte offset ${start}`;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
} else {
|
|
197
|
+
i = save;
|
|
198
|
+
}
|
|
199
|
+
attributes.push({ name: attrName.toLowerCase(), value, quoted });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!TELEGRAM_HTML_TAGS.has(name)) {
|
|
203
|
+
error ??= `Unsupported start tag "${name}"`;
|
|
204
|
+
}
|
|
205
|
+
if (name === "span" && !closing) {
|
|
206
|
+
const cls = attributes.find((a) => a.name === "class");
|
|
207
|
+
if (cls?.value !== "tg-spoiler") {
|
|
208
|
+
error ??= 'Tag "span" must have class "tg-spoiler"';
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (name === "tg-emoji" && !closing) {
|
|
212
|
+
const id = attributes.find((a) => a.name === "emoji-id");
|
|
213
|
+
// Live: a leading zero (`00123`), a bare zero, and any value above the
|
|
214
|
+
// signed 64-bit maximum fail with "Invalid custom emoji identifier
|
|
215
|
+
// specified"; `123` and `9223372036854775807` parse.
|
|
216
|
+
if (!id?.value || !isValidEmojiId(id.value)) {
|
|
217
|
+
error ??= "Invalid custom emoji identifier specified";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return { end: i, name, closing, error, attributes };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Verdict of `checkTelegramHtml`. */
|
|
224
|
+
export type TelegramHtmlVerdict = { ok: true } | { ok: false; error: string };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Check a body against Telegram's HTML parser rules. `ok: false` means the
|
|
228
|
+
* API would reject it with "can't parse entities: …". The error text follows
|
|
229
|
+
* the API's wording on the calibrated classes; where it carries an offset,
|
|
230
|
+
* that offset is the checker's own position in the body, not a copy of the
|
|
231
|
+
* API's: it agrees with live on the recorded end-tag rows, while
|
|
232
|
+
* "Unexpected end of name token" reports the tag's start where the API
|
|
233
|
+
* reports the value's start.
|
|
234
|
+
*/
|
|
235
|
+
export function checkTelegramHtml(text: string): TelegramHtmlVerdict {
|
|
236
|
+
if (!text) return { ok: true };
|
|
237
|
+
const open: string[] = [];
|
|
238
|
+
let i = 0;
|
|
239
|
+
while (i < text.length) {
|
|
240
|
+
if (text[i] !== "<") {
|
|
241
|
+
i++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const tag = parseTelegramTagAt(text, i);
|
|
245
|
+
if (!tag) {
|
|
246
|
+
return {
|
|
247
|
+
ok: false,
|
|
248
|
+
error: `Unsupported start tag "" (stray "<" or unclosed tag)`,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
if (tag.error) return { ok: false, error: tag.error };
|
|
252
|
+
if (tag.closing) {
|
|
253
|
+
const top = open[open.length - 1];
|
|
254
|
+
if (top === undefined) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
error: `Unexpected end tag at byte offset ${i}: "</${tag.name}>"`,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
if (top !== tag.name) {
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
error: `Unmatched end tag: expected "</${top}>", found "</${tag.name}>"`,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
open.pop();
|
|
267
|
+
} else {
|
|
268
|
+
open.push(tag.name);
|
|
269
|
+
}
|
|
270
|
+
i = tag.end;
|
|
271
|
+
}
|
|
272
|
+
const unclosed = open[open.length - 1];
|
|
273
|
+
if (unclosed !== undefined) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
error: `Can't find end tag corresponding to start tag "${unclosed}"`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
return { ok: true };
|
|
280
|
+
}
|