@spectrum-ts/telegram 5.0.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License Copyright (c) 2025 Photon AI
2
+
3
+ Permission is hereby granted,
4
+ free of charge, to any person obtaining a copy of this software and associated
5
+ documentation files (the "Software"), to deal in the Software without
6
+ restriction, including without limitation the rights to use, copy, modify, merge,
7
+ publish, distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to the
9
+ following conditions:
10
+
11
+ The above copyright notice and this permission notice
12
+ (including the next paragraph) shall be included in all copies or substantial
13
+ portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
16
+ ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
18
+ EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
20
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @spectrum-ts/telegram
2
+
3
+ Telegram provider for [spectrum-ts](https://github.com/photon-hq/spectrum-ts). Inbound is delivered through Fusor webhooks; outbound goes through the Telegram Bot API.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ bun add spectrum-ts @spectrum-ts/telegram
9
+ ```
10
+
11
+ ## Use
12
+
13
+ ```ts
14
+ import { Spectrum } from "spectrum-ts";
15
+ import { telegram } from "@spectrum-ts/telegram";
16
+
17
+ const spectrum = Spectrum({
18
+ providers: [telegram.config({ botToken: "..." })],
19
+ });
20
+ ```
21
+
22
+ See the [telegram guide](https://github.com/photon-hq/spectrum-ts/blob/main/docs/telegram.md) for the full setup.
@@ -0,0 +1,43 @@
1
+ import { FusorClient } from "@spectrum-ts/core";
2
+ import z from "zod";
3
+
4
+ //#region src/space.d.ts
5
+ interface TelegramSpace {
6
+ id: string;
7
+ }
8
+ //#endregion
9
+ //#region src/config.d.ts
10
+ declare const configSchema: z.ZodObject<{
11
+ botToken: z.ZodString;
12
+ webhookSecret: z.ZodOptional<z.ZodString>;
13
+ baseUrl: z.ZodDefault<z.ZodURL>;
14
+ }, z.core.$strip>;
15
+ type TelegramConfig = z.infer<typeof configSchema>;
16
+ //#endregion
17
+ //#region src/index.d.ts
18
+ /**
19
+ * Telegram provider for Spectrum.
20
+ *
21
+ * Inbound is delivered through Fusor: `createClient` returns a `fusor(...)`
22
+ * client whose `verify` checks the Telegram webhook secret token and parses the
23
+ * `Update` (pure parsing — no client). The `messages` handler reads `config`
24
+ * from its ctx and builds a photon client inline only to download media bytes.
25
+ * Outbound (`send`) also builds a photon client inline. Both go through
26
+ * `@photon-ai/telegram-ts`. Drop `telegram.config({...})` into
27
+ * `Spectrum({ providers: [...] })`.
28
+ *
29
+ * In cloud mode (`projectConfig` present), `createClient` also self-registers
30
+ * the bot's webhook against the Fusor edge for the project slug — see
31
+ * `ensureWebhook`. Without a slug (local/direct mode) registration is skipped.
32
+ */
33
+ declare const telegram: import("@spectrum-ts/core").Platform<import("@spectrum-ts/core").PlatformDef<"telegram", import("zod").ZodObject<{
34
+ botToken: import("zod").ZodString;
35
+ webhookSecret: import("zod").ZodOptional<import("zod").ZodString>;
36
+ baseUrl: import("zod").ZodDefault<import("zod").ZodURL>;
37
+ }, import("zod/v4/core").$strip>, import("zod").ZodType<object, unknown, import("zod/v4/core").$ZodTypeInternals<object, unknown>> | undefined, import("zod").ZodType<object, unknown, import("zod/v4/core").$ZodTypeInternals<object, unknown>> | undefined, import("zod").ZodType<object, unknown, import("zod/v4/core").$ZodTypeInternals<object, unknown>> | undefined, FusorClient<import("@photon-ai/telegram-ts").Update>, {
38
+ id: string;
39
+ }, TelegramSpace, undefined, import("@spectrum-ts/core").ProviderMessage<{
40
+ id: string;
41
+ }, TelegramSpace, Record<never, never>>, undefined, Record<never, never>, Record<never, never>, Record<never, never>>> & Readonly<Record<never, never>>;
42
+ //#endregion
43
+ export { type TelegramConfig, telegram };
package/dist/index.js ADDED
@@ -0,0 +1,905 @@
1
+ import { UnsupportedError, definePlatform, fusor, toVCard } from "@spectrum-ts/core";
2
+ import z from "zod";
3
+ import { asAttachment, asCustom, asGroup, asMarkdown, asReaction, asText, asVoice, renderInlineTokens } from "@spectrum-ts/core/authoring";
4
+ import { createTelegramClient, editMessageText, getFile, getWebhookInfo, sendChatAction, sendMessageDraft, setMessageReaction, setWebhook } from "@photon-ai/telegram-ts";
5
+ import { Marked } from "marked";
6
+ import { timingSafeEqual } from "node:crypto";
7
+ //#region src/config.ts
8
+ /**
9
+ * The platform identifier — used for ALL THREE of:
10
+ *
11
+ * - the `definePlatform` name (so `message.platform` / `__platform` and
12
+ * the `platformStates` key are this value),
13
+ * - the `fusor(...)` routing key the handler is registered under, and
14
+ * - the value Fusor tags inbound Telegram events with (`event.platform`).
15
+ *
16
+ * Spectrum's webhook delivery looks the runtime up by `event.platform` against
17
+ * the platform name (`platformStates.get(event.platform)`), while routing is by
18
+ * the fusor key — so these MUST be the same string. It must also match Fusor's
19
+ * configured platform identifier for Telegram (the `<platform>` path segment
20
+ * the webhook is delivered under).
21
+ */
22
+ const TELEGRAM_PLATFORM = "telegram";
23
+ const configSchema = z.object({
24
+ /** Bot token from @BotFather (outbound API calls + media downloads). */
25
+ botToken: z.string().regex(/^\d+:[A-Za-z0-9_-]+$/, "botToken must be in the form '<id>:<token>'"),
26
+ /**
27
+ * The `secret_token` passed to `setWebhook`. When present, inbound webhooks
28
+ * are verified against the `X-Telegram-Bot-Api-Secret-Token` header; when
29
+ * omitted, the check is skipped. Telegram does not HMAC-sign the body, so
30
+ * this shared token is the only inbound authentication.
31
+ */
32
+ webhookSecret: z.string().regex(/^[A-Za-z0-9_-]{1,256}$/).optional(),
33
+ /** Override the Bot API base URL. Defaults to `https://api.telegram.org`. */
34
+ baseUrl: z.url().default("https://api.telegram.org")
35
+ });
36
+ /**
37
+ * The bot's own numeric id is the prefix of the token (`<id>:<hash>`). Used to
38
+ * drop inbound updates the bot itself produced, so a bot never echoes its own
39
+ * sends.
40
+ */
41
+ const botIdFromToken = (botToken) => botToken.split(":")[0] ?? "";
42
+ //#endregion
43
+ //#region src/client.ts
44
+ const REQUEST_TIMEOUT_MS = 3e4;
45
+ const TRAILING_SLASHES = /\/+$/;
46
+ /** Build a photon client bound to the bot token. Cheap: no network on construction. */
47
+ const telegramClient = (config) => createTelegramClient({
48
+ token: config.botToken,
49
+ baseUrl: config.baseUrl
50
+ });
51
+ const appendFormValue = (form, key, value) => {
52
+ if (typeof value === "string" || value instanceof Blob) form.append(key, value);
53
+ else if (value instanceof Date) form.append(key, value.toISOString());
54
+ else form.append(key, JSON.stringify(value));
55
+ };
56
+ const toFormData = (body) => {
57
+ const form = new FormData();
58
+ for (const [key, value] of Object.entries(body)) {
59
+ if (value === void 0 || value === null) continue;
60
+ appendFormValue(form, key, value);
61
+ }
62
+ return form;
63
+ };
64
+ /**
65
+ * Execute one Bot API call through the photon client and return the sent
66
+ * message. JSON params go out as `application/json`; a `file` is uploaded as
67
+ * `multipart/form-data` — wrapped in a `File` so the part keeps its filename
68
+ * (`formDataBodySerializer` appends without an explicit name), with
69
+ * `Content-Type: null` dropping the default JSON header so fetch sets the
70
+ * multipart boundary. A failed call throws `TelegramApiError` (token-free).
71
+ */
72
+ const executeSpec = async (client, spec) => {
73
+ const url = `/${spec.method}`;
74
+ if (spec.file) {
75
+ const file = new File([new Uint8Array(spec.file.bytes)], spec.file.filename, { type: spec.file.mimeType });
76
+ return (await client.post({
77
+ body: {
78
+ ...spec.params,
79
+ [spec.file.field]: file
80
+ },
81
+ bodySerializer: toFormData,
82
+ headers: { "Content-Type": null },
83
+ throwOnError: true,
84
+ url
85
+ })).data.result;
86
+ }
87
+ return (await client.post({
88
+ body: spec.params,
89
+ throwOnError: true,
90
+ url
91
+ })).data.result;
92
+ };
93
+ /**
94
+ * Resolve a `file_id` to its bytes. photon has no byte-download helper and the
95
+ * file endpoint is not a Bot API JSON method, so this is the one place that
96
+ * reaches Telegram outside photon: `getFile` (via photon) for the path, then a
97
+ * single authenticated `fetch`. The file URL embeds the bot token, so it is
98
+ * never interpolated into a thrown error.
99
+ */
100
+ const downloadFile = async (config, fileId) => {
101
+ const client = telegramClient(config);
102
+ const filePath = (await getFile({
103
+ body: { file_id: fileId },
104
+ client,
105
+ throwOnError: true
106
+ })).result?.file_path;
107
+ if (!filePath) throw new Error(`Telegram getFile returned no file_path for ${fileId}`);
108
+ const base = config.baseUrl.replace(TRAILING_SLASHES, "");
109
+ const res = await fetch(`${base}/file/bot${config.botToken}/${filePath}`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
110
+ if (!res.ok) throw new Error(`Telegram media download failed: ${res.status} ${res.statusText}`);
111
+ return Buffer.from(await res.arrayBuffer());
112
+ };
113
+ //#endregion
114
+ //#region src/inbound/media.ts
115
+ const DEFAULT_VIDEO_MIME = "video/mp4";
116
+ const DEFAULT_AUDIO_MIME = "audio/mpeg";
117
+ const DEFAULT_VOICE_MIME = "audio/ogg";
118
+ const DEFAULT_DOC_MIME = "application/octet-stream";
119
+ const pixelArea = (photo) => photo.file_size ?? photo.width * photo.height;
120
+ /** Telegram sends several `PhotoSize`s; pick the largest (best quality). */
121
+ const pickLargestPhoto = (photos) => photos.reduce((best, next) => pixelArea(next) > pixelArea(best) ? next : best);
122
+ const lazyRead = (config, fileId) => () => downloadFile(config, fileId);
123
+ /** Build an attachment from any Telegram file, falling back when unnamed/untyped. */
124
+ const fileAttachment = (config, file, fallbackExt, fallbackMime) => asAttachment({
125
+ id: file.file_id,
126
+ name: file.file_name ?? `${file.file_unique_id}.${fallbackExt}`,
127
+ mimeType: file.mime_type ?? fallbackMime,
128
+ size: file.file_size,
129
+ read: lazyRead(config, file.file_id)
130
+ });
131
+ const stickerAttachment = (config, sticker) => {
132
+ let ext = "webp";
133
+ let mimeType = "image/webp";
134
+ if (sticker.is_animated) {
135
+ ext = "tgs";
136
+ mimeType = "application/x-tgsticker";
137
+ } else if (sticker.is_video) {
138
+ ext = "webm";
139
+ mimeType = "video/webm";
140
+ }
141
+ return asAttachment({
142
+ id: sticker.file_id,
143
+ name: `${sticker.file_unique_id}.${ext}`,
144
+ mimeType,
145
+ size: sticker.file_size,
146
+ read: lazyRead(config, sticker.file_id)
147
+ });
148
+ };
149
+ /**
150
+ * Map a Telegram `Message` to its media `Content`, or `undefined` if it carries
151
+ * no media. A message holds at most one media kind, but `animation` also
152
+ * populates `document` (and stickers have sub-types), so detection runs in a
153
+ * fixed first-match order: voice → video_note → animation → video → audio →
154
+ * document → photo → sticker.
155
+ *
156
+ * Bytes are read lazily: each `read()` runs `getFile` + an authenticated
157
+ * download only when the consumer calls it, keeping the webhook ack path free
158
+ * of network I/O. The `as*` builders memoize `read`, so a file downloads once.
159
+ */
160
+ const mediaToContent = (msg, config) => {
161
+ if (msg.voice) return asVoice({
162
+ mimeType: msg.voice.mime_type ?? DEFAULT_VOICE_MIME,
163
+ duration: msg.voice.duration,
164
+ size: msg.voice.file_size,
165
+ read: lazyRead(config, msg.voice.file_id)
166
+ });
167
+ if (msg.video_note) return fileAttachment(config, msg.video_note, "mp4", DEFAULT_VIDEO_MIME);
168
+ if (msg.animation) return fileAttachment(config, msg.animation, "mp4", DEFAULT_VIDEO_MIME);
169
+ if (msg.video) return fileAttachment(config, msg.video, "mp4", DEFAULT_VIDEO_MIME);
170
+ if (msg.audio) return fileAttachment(config, msg.audio, "mp3", DEFAULT_AUDIO_MIME);
171
+ if (msg.document) return fileAttachment(config, msg.document, "bin", DEFAULT_DOC_MIME);
172
+ if (msg.photo && msg.photo.length > 0) return fileAttachment(config, pickLargestPhoto(msg.photo), "jpg", "image/jpeg");
173
+ if (msg.sticker) return stickerAttachment(config, msg.sticker);
174
+ };
175
+ /**
176
+ * Map an inbound `Message` to its Spectrum `Content` parts: the media (if any)
177
+ * plus its `caption` as a leading text part, or a bare `text` message. Returns
178
+ * `[]` when nothing is representable (service messages, etc.) so the caller can
179
+ * drop it.
180
+ */
181
+ const messageToContent = (msg, config) => {
182
+ const media = mediaToContent(msg, config);
183
+ if (media) {
184
+ const caption = msg.caption?.trim();
185
+ return caption ? [asText(caption), media] : [media];
186
+ }
187
+ const text = msg.text;
188
+ if (text !== void 0 && text.length > 0) return [asText(text)];
189
+ return [];
190
+ };
191
+ //#endregion
192
+ //#region src/inbound/messages.ts
193
+ const MILLIS_PER_SECOND$2 = 1e3;
194
+ const stubMessage = (id, content) => ({
195
+ id,
196
+ content
197
+ });
198
+ const senderRef = (user) => ({
199
+ id: String(user.id),
200
+ ...user.username ? { handle: user.username } : {},
201
+ isMe: false
202
+ });
203
+ const toRecordContent = (contents, messageId) => {
204
+ if (contents.length === 0) return;
205
+ if (contents.length === 1) return contents[0];
206
+ return asGroup({ items: contents.map((content, index) => stubMessage(`${messageId}:${index}`, content)) });
207
+ };
208
+ const fromMessage = (msg, config) => {
209
+ if (msg.from && String(msg.from.id) === botIdFromToken(config.botToken)) return;
210
+ const content = toRecordContent(messageToContent(msg, config), String(msg.message_id));
211
+ if (!content) return;
212
+ return {
213
+ id: String(msg.message_id),
214
+ content,
215
+ ...msg.from ? { sender: senderRef(msg.from) } : {},
216
+ space: { id: String(msg.chat.id) },
217
+ timestamp: /* @__PURE__ */ new Date(msg.date * MILLIS_PER_SECOND$2)
218
+ };
219
+ };
220
+ const emojiReactions = (reactions) => reactions.filter((r) => r.type === "emoji").map((r) => r.emoji);
221
+ const fromReaction = (reaction) => {
222
+ const added = emojiReactions(reaction.new_reaction);
223
+ if (added.length === 0) return;
224
+ const previous = new Set(emojiReactions(reaction.old_reaction));
225
+ const emoji = added.find((e) => !previous.has(e)) ?? added[0];
226
+ if (!emoji) return;
227
+ const target = stubMessage(String(reaction.message_id), asCustom({ telegram: "reaction-target" }));
228
+ const actorId = reaction.user ? String(reaction.user.id) : "anonymous";
229
+ return {
230
+ id: `reaction:${reaction.chat.id}:${reaction.message_id}:${reaction.date}:${actorId}:${emoji}`,
231
+ content: asReaction({
232
+ emoji,
233
+ target
234
+ }),
235
+ ...reaction.user ? { sender: senderRef(reaction.user) } : {},
236
+ space: { id: String(reaction.chat.id) },
237
+ timestamp: /* @__PURE__ */ new Date(reaction.date * MILLIS_PER_SECOND$2)
238
+ };
239
+ };
240
+ /**
241
+ * Map a verified Telegram `Update` to the Spectrum message it represents. v1
242
+ * surfaces new messages and channel posts (text + media, with captions) and
243
+ * emoji reactions (`message_reaction`, which requires the operator to list it
244
+ * in `allowed_updates`). Edits, callback queries, polls, membership changes and
245
+ * other update types are ignored (return `undefined`).
246
+ */
247
+ const handleMessages = ({ payload: update, config }) => {
248
+ const message = update.message ?? update.channel_post;
249
+ if (message) return fromMessage(message, config);
250
+ if (update.message_reaction) return fromReaction(update.message_reaction);
251
+ };
252
+ //#endregion
253
+ //#region src/reactions.ts
254
+ /**
255
+ * Telegram reactions ARE emoji (unlike LinQ's tapback enum), so inbound and
256
+ * outbound need no translation — the emoji string maps straight to/from
257
+ * Spectrum. The catch: `setMessageReaction` only accepts a fixed set of
258
+ * standard emoji in non-premium chats; anything else fails with
259
+ * `REACTION_INVALID`. This set is exported so callers can pre-check, and the
260
+ * send path uses it to turn that API error into a clearer message.
261
+ *
262
+ * Source: the default `available_reactions` documented for the Bot API. The set
263
+ * is stable but Telegram can extend it; treat membership as a best-effort hint,
264
+ * not a hard guarantee.
265
+ */
266
+ const ALLOWED_REACTION_EMOJI = new Set([
267
+ "👍",
268
+ "👎",
269
+ "❤",
270
+ "🔥",
271
+ "🥰",
272
+ "👏",
273
+ "😁",
274
+ "🤔",
275
+ "🤯",
276
+ "😱",
277
+ "🤬",
278
+ "😢",
279
+ "🎉",
280
+ "🤩",
281
+ "🤮",
282
+ "💩",
283
+ "🙏",
284
+ "👌",
285
+ "🕊",
286
+ "🤡",
287
+ "🥱",
288
+ "🥴",
289
+ "😍",
290
+ "🐳",
291
+ "❤‍🔥",
292
+ "🌚",
293
+ "🌭",
294
+ "💯",
295
+ "🤣",
296
+ "⚡",
297
+ "🍌",
298
+ "🏆",
299
+ "💔",
300
+ "🤨",
301
+ "😐",
302
+ "🍓",
303
+ "🍾",
304
+ "💋",
305
+ "🖕",
306
+ "😈",
307
+ "😴",
308
+ "😭",
309
+ "🤓",
310
+ "👻",
311
+ "👨‍💻",
312
+ "👀",
313
+ "🎃",
314
+ "🙈",
315
+ "😇",
316
+ "😨",
317
+ "🤝",
318
+ "✍",
319
+ "🤗",
320
+ "🫡",
321
+ "🎅",
322
+ "🎄",
323
+ "☃",
324
+ "💅",
325
+ "🤪",
326
+ "🗿",
327
+ "🆒",
328
+ "💘",
329
+ "🙉",
330
+ "🦄",
331
+ "😘",
332
+ "💊",
333
+ "🙊",
334
+ "😎",
335
+ "👾",
336
+ "🤷‍♂",
337
+ "🤷",
338
+ "🤷‍♀",
339
+ "😡"
340
+ ]);
341
+ const VARIATION_SELECTOR_16 = /️/g;
342
+ const stripVariationSelector = (emoji) => emoji.replace(VARIATION_SELECTOR_16, "");
343
+ /**
344
+ * Telegram's reaction set uses bare codepoints (no U+FE0F variation selector),
345
+ * while clients/Spectrum often carry the emoji-presentation form (e.g. `❤️`).
346
+ * Strip the selector before comparing so both forms validate.
347
+ */
348
+ const isAllowedReactionEmoji = (emoji) => ALLOWED_REACTION_EMOJI.has(stripVariationSelector(emoji));
349
+ /**
350
+ * The form to send to `setMessageReaction`. Known reactions are normalized to
351
+ * the bare codepoint Telegram expects; unknown emoji pass through unchanged so
352
+ * the API's own validation (and our clearer error) can take over.
353
+ */
354
+ const normalizeReactionEmoji = (emoji) => isAllowedReactionEmoji(emoji) ? stripVariationSelector(emoji) : emoji;
355
+ //#endregion
356
+ //#region src/outbound/markdown.ts
357
+ const markdownLexer = new Marked();
358
+ const BULLET = "• ";
359
+ const HR_LINE = "———";
360
+ const NESTED_LIST_INDENT = " ";
361
+ const BLOCK_SEPARATOR = "\n\n";
362
+ const TABLE_CELL_SEPARATOR = " | ";
363
+ const DEFAULT_LIST_START = 1;
364
+ const AMP_PATTERN = /&/g;
365
+ const LT_PATTERN = /</g;
366
+ const GT_PATTERN = />/g;
367
+ const QUOTE_PATTERN = /"/g;
368
+ const escapeHtml = (value) => value.replace(AMP_PATTERN, "&amp;").replace(LT_PATTERN, "&lt;").replace(GT_PATTERN, "&gt;");
369
+ const escapeAttribute = (value) => escapeHtml(value).replace(QUOTE_PATTERN, "&quot;");
370
+ const asMarkedToken = (token) => token;
371
+ const checkboxPrefix = (item) => {
372
+ if (!item.task) return "";
373
+ return item.checked ? "[x] " : "[ ] ";
374
+ };
375
+ const listMarker = (list, index) => {
376
+ if (!list.ordered) return BULLET;
377
+ return `${(list.start === "" ? DEFAULT_LIST_START : list.start) + index}. `;
378
+ };
379
+ const renderLink = (token) => {
380
+ if (token.text === token.href) return escapeHtml(token.href);
381
+ return `<a href="${escapeAttribute(token.href)}">${renderInlineTokens$1(token.tokens)}</a>`;
382
+ };
383
+ const renderImage = (token) => `<a href="${escapeAttribute(token.href)}">${escapeHtml(token.text || token.href)}</a>`;
384
+ const renderText = (token) => {
385
+ if (token.tokens) return renderInlineTokens$1(token.tokens);
386
+ return token.escaped ? token.text : escapeHtml(token.text);
387
+ };
388
+ const renderInlineToken = (token) => {
389
+ switch (token.type) {
390
+ case "strong": return `<b>${renderInlineTokens$1(token.tokens)}</b>`;
391
+ case "em": return `<i>${renderInlineTokens$1(token.tokens)}</i>`;
392
+ case "del": return `<s>${renderInlineTokens$1(token.tokens)}</s>`;
393
+ case "codespan": return `<code>${escapeHtml(token.text)}</code>`;
394
+ case "br": return "\n";
395
+ case "link": return renderLink(token);
396
+ case "image": return renderImage(token);
397
+ case "escape": return escapeHtml(token.text);
398
+ case "text": return renderText(token);
399
+ case "html": return escapeHtml(token.text);
400
+ case "checkbox": return "";
401
+ default: return "raw" in token ? escapeHtml(String(token.raw)) : "";
402
+ }
403
+ };
404
+ const renderInlineTokens$1 = (tokens) => {
405
+ let out = "";
406
+ for (const token of tokens) out += renderInlineToken(asMarkedToken(token));
407
+ return out;
408
+ };
409
+ const renderCode = (token) => {
410
+ if (token.lang) return `<pre><code class="language-${escapeAttribute(token.lang)}">${escapeHtml(token.text)}</code></pre>`;
411
+ return `<pre>${escapeHtml(token.text)}</pre>`;
412
+ };
413
+ const renderQuoteBody = (tokens) => {
414
+ const blocks = [];
415
+ for (const token of tokens) {
416
+ const marked = asMarkedToken(token);
417
+ const rendered = marked.type === "blockquote" ? renderQuoteBody(marked.tokens) : renderBlockToken(marked);
418
+ if (rendered) blocks.push(rendered);
419
+ }
420
+ return blocks.join("\n");
421
+ };
422
+ const renderList = (list) => {
423
+ const lines = [];
424
+ for (const [index, item] of list.items.entries()) {
425
+ const prefix = `${listMarker(list, index)}${checkboxPrefix(item)}`;
426
+ const blocks = [];
427
+ for (const token of item.tokens) {
428
+ const rendered = renderBlockToken(asMarkedToken(token));
429
+ if (rendered) blocks.push(rendered);
430
+ }
431
+ const [first = "", ...rest] = blocks.join("\n").split("\n");
432
+ lines.push(`${prefix}${first}`);
433
+ for (const line of rest) lines.push(`${NESTED_LIST_INDENT}${line}`);
434
+ }
435
+ return lines.join("\n");
436
+ };
437
+ const renderTable = (table) => {
438
+ const renderRow = (cells) => cells.map((cell) => renderInlineTokens(cell.tokens)).join(TABLE_CELL_SEPARATOR);
439
+ const lines = [renderRow(table.header)];
440
+ for (const row of table.rows) lines.push(renderRow(row));
441
+ return `<pre>${escapeHtml(lines.join("\n"))}</pre>`;
442
+ };
443
+ const renderBlockToken = (token) => {
444
+ switch (token.type) {
445
+ case "heading": return `<b>${renderInlineTokens$1(token.tokens)}</b>`;
446
+ case "paragraph": return renderInlineTokens$1(token.tokens);
447
+ case "code": return renderCode(token);
448
+ case "blockquote": return `<blockquote>${renderQuoteBody(token.tokens)}</blockquote>`;
449
+ case "list": return renderList(token);
450
+ case "table": return renderTable(token);
451
+ case "hr": return HR_LINE;
452
+ case "space":
453
+ case "def": return "";
454
+ default: return renderInlineToken(token);
455
+ }
456
+ };
457
+ /**
458
+ * Render standard markdown (CommonMark + GFM) to Telegram-flavored HTML for
459
+ * `parse_mode: "HTML"` sends. Only tags Telegram accepts are emitted; all
460
+ * text (including raw HTML in the source) is entity-escaped so the output
461
+ * can never fail Bot API parsing.
462
+ */
463
+ const markdownToTelegramHtml = (markdown) => {
464
+ const blocks = [];
465
+ for (const token of markdownLexer.lexer(markdown)) {
466
+ const rendered = renderBlockToken(asMarkedToken(token));
467
+ if (rendered) blocks.push(rendered);
468
+ }
469
+ return blocks.join(BLOCK_SEPARATOR).trim();
470
+ };
471
+ //#endregion
472
+ //#region src/outbound/message.ts
473
+ const VCARD_FILENAME = "contact.vcf";
474
+ const VCARD_MIME = "text/vcard";
475
+ const DEFAULT_VOICE_FILENAME = "voice.ogg";
476
+ /**
477
+ * Telegram message ids are positive integers. Reject anything else up front so a
478
+ * malformed `target.id` surfaces a clear error here instead of being coerced to
479
+ * `NaN` and sent on to the Bot API as a confusing 400.
480
+ */
481
+ const parseMessageId = (id) => {
482
+ const messageId = Number(id);
483
+ if (!Number.isInteger(messageId) || messageId <= 0) throw new Error(`Telegram message id must be a positive integer (got "${id}").`);
484
+ return messageId;
485
+ };
486
+ const customToSpec = (raw) => {
487
+ if (typeof raw === "object" && raw !== null && typeof raw.method === "string") {
488
+ const value = raw;
489
+ const { params } = value;
490
+ if (params !== void 0 && (typeof params !== "object" || params === null || Array.isArray(params))) throw new Error("Telegram custom content `raw.params` must be an object when provided.");
491
+ return {
492
+ method: value.method,
493
+ params: params ?? {}
494
+ };
495
+ }
496
+ throw new Error("Telegram custom content `raw` must be a `{ method, params }` Bot API call.");
497
+ };
498
+ const attachmentSpec = async (content) => {
499
+ const bytes = await content.read();
500
+ const file = {
501
+ field: "document",
502
+ filename: content.name,
503
+ mimeType: content.mimeType,
504
+ bytes
505
+ };
506
+ if (content.mimeType.startsWith("image/")) return {
507
+ method: "sendPhoto",
508
+ params: {},
509
+ file: {
510
+ ...file,
511
+ field: "photo"
512
+ }
513
+ };
514
+ if (content.mimeType.startsWith("video/")) return {
515
+ method: "sendVideo",
516
+ params: {},
517
+ file: {
518
+ ...file,
519
+ field: "video"
520
+ }
521
+ };
522
+ return {
523
+ method: "sendDocument",
524
+ params: {},
525
+ file
526
+ };
527
+ };
528
+ /**
529
+ * Turn one message-producing `Content` into a single Bot API call. Telegram has
530
+ * no multi-part message — each content type is its own method/endpoint — so
531
+ * this returns a `TelegramSendSpec` (the caller injects `chat_id` and executes
532
+ * it). `reply` recurses and threads `reply_parameters`; `group` is NOT handled
533
+ * here (it becomes N separate sends in `send.ts`). Fire-and-forget content
534
+ * (reaction, typing, edit) and unsupported types never reach this function.
535
+ */
536
+ const buildSend = async (content) => {
537
+ switch (content.type) {
538
+ case "text": return {
539
+ method: "sendMessage",
540
+ params: { text: content.text }
541
+ };
542
+ case "markdown": return {
543
+ method: "sendMessage",
544
+ params: {
545
+ text: markdownToTelegramHtml(content.markdown),
546
+ parse_mode: "HTML"
547
+ }
548
+ };
549
+ case "richlink": return {
550
+ method: "sendMessage",
551
+ params: { text: content.url }
552
+ };
553
+ case "app": return {
554
+ method: "sendMessage",
555
+ params: { text: await content.url() }
556
+ };
557
+ case "attachment": return await attachmentSpec(content);
558
+ case "voice": {
559
+ const bytes = await content.read();
560
+ return {
561
+ method: "sendVoice",
562
+ params: content.duration === void 0 ? {} : { duration: content.duration },
563
+ file: {
564
+ field: "voice",
565
+ filename: content.name ?? DEFAULT_VOICE_FILENAME,
566
+ mimeType: content.mimeType,
567
+ bytes
568
+ }
569
+ };
570
+ }
571
+ case "contact": {
572
+ const vcf = await toVCard(content);
573
+ return {
574
+ method: "sendDocument",
575
+ params: {},
576
+ file: {
577
+ field: "document",
578
+ filename: VCARD_FILENAME,
579
+ mimeType: VCARD_MIME,
580
+ bytes: Buffer.from(vcf, "utf8")
581
+ }
582
+ };
583
+ }
584
+ case "reply": {
585
+ const inner = await buildSend(content.content);
586
+ return {
587
+ ...inner,
588
+ params: {
589
+ ...inner.params,
590
+ reply_parameters: { message_id: parseMessageId(content.target.id) }
591
+ }
592
+ };
593
+ }
594
+ case "custom": return customToSpec(content.raw);
595
+ default: throw UnsupportedError.content(content.type, TELEGRAM_PLATFORM);
596
+ }
597
+ };
598
+ //#endregion
599
+ //#region src/outbound/stream-text.ts
600
+ const MILLIS_PER_SECOND$1 = 1e3;
601
+ const DRAFT_THROTTLE_MS = 500;
602
+ let nextDraftId = 1;
603
+ /**
604
+ * Deliver a `streamText` content natively via Telegram message drafts
605
+ * (`sendMessageDraft`): an empty draft shows the client's "Thinking…"
606
+ * placeholder, throttled updates animate the accumulated text while the stream
607
+ * runs, and a final `sendMessage` persists the full text — drafts are
608
+ * ~30-second ephemeral previews, so only a real send lands in the chat.
609
+ * Drafts exist only in private chats; group/channel spaces (non-positive chat
610
+ * ids) throw `UnsupportedError` before the stream is consumed, letting the
611
+ * send pipeline's plain-text fallback deliver the accumulated text instead.
612
+ */
613
+ const sendStreamText = async (client, space, content) => {
614
+ const chatId = Number(space.id);
615
+ if (!(Number.isInteger(chatId) && chatId > 0)) throw UnsupportedError.content("streamText", TELEGRAM_PLATFORM, `message drafts work only in private chats (got chat id "${space.id}").`);
616
+ const draftId = nextDraftId;
617
+ nextDraftId += 1;
618
+ const renderBody = (text) => content.format === "markdown" ? {
619
+ text: markdownToTelegramHtml(text),
620
+ parse_mode: "HTML"
621
+ } : { text };
622
+ let lastDraftText;
623
+ let lastDraftAt = 0;
624
+ let draftsAvailable = true;
625
+ const updateDraft = async (text) => {
626
+ if (!draftsAvailable || text === lastDraftText) return;
627
+ try {
628
+ await sendMessageDraft({
629
+ body: {
630
+ chat_id: chatId,
631
+ draft_id: draftId,
632
+ ...renderBody(text)
633
+ },
634
+ client
635
+ });
636
+ lastDraftText = text;
637
+ lastDraftAt = Date.now();
638
+ } catch {
639
+ draftsAvailable = false;
640
+ }
641
+ };
642
+ await updateDraft("");
643
+ let full = "";
644
+ for await (const delta of content.stream()) {
645
+ full += delta;
646
+ if (Date.now() - lastDraftAt >= DRAFT_THROTTLE_MS) await updateDraft(full);
647
+ }
648
+ if (!full) throw UnsupportedError.content("streamText", TELEGRAM_PLATFORM, "stream produced no text — nothing to send.");
649
+ const sent = await executeSpec(client, {
650
+ method: "sendMessage",
651
+ params: {
652
+ chat_id: space.id,
653
+ ...renderBody(full)
654
+ }
655
+ });
656
+ return {
657
+ id: String(sent.message_id),
658
+ content: content.format === "markdown" ? asMarkdown(full) : asText(full),
659
+ space: { id: space.id },
660
+ timestamp: /* @__PURE__ */ new Date(sent.date * MILLIS_PER_SECOND$1)
661
+ };
662
+ };
663
+ //#endregion
664
+ //#region src/outbound/send.ts
665
+ const MILLIS_PER_SECOND = 1e3;
666
+ /** Build one content's spec, inject `chat_id`, execute it, return the record. */
667
+ const sendContent = async (client, space, content) => {
668
+ const spec = await buildSend(content);
669
+ const sent = await executeSpec(client, {
670
+ ...spec,
671
+ params: {
672
+ chat_id: space.id,
673
+ ...spec.params
674
+ }
675
+ });
676
+ return {
677
+ id: String(sent.message_id),
678
+ content,
679
+ space: { id: space.id },
680
+ timestamp: /* @__PURE__ */ new Date(sent.date * MILLIS_PER_SECOND)
681
+ };
682
+ };
683
+ const sendGroup = async (client, space, items) => {
684
+ let last;
685
+ for (const item of items) last = await sendContent(client, space, item.content);
686
+ return last;
687
+ };
688
+ const sendReaction = async (client, space, content) => {
689
+ const messageId = parseMessageId(content.target.id);
690
+ const emoji = normalizeReactionEmoji(content.emoji);
691
+ if (!isAllowedReactionEmoji(emoji)) throw UnsupportedError.content("reaction", TELEGRAM_PLATFORM, `"${content.emoji}" is not an allowed Telegram reaction emoji.`);
692
+ await setMessageReaction({
693
+ body: {
694
+ chat_id: space.id,
695
+ message_id: messageId,
696
+ reaction: [{
697
+ emoji,
698
+ type: "emoji"
699
+ }]
700
+ },
701
+ client
702
+ });
703
+ const timestamp = /* @__PURE__ */ new Date();
704
+ const unixSeconds = Math.floor(timestamp.getTime() / MILLIS_PER_SECOND);
705
+ return {
706
+ id: `reaction:${space.id}:${messageId}:${unixSeconds}:bot:${emoji}`,
707
+ content,
708
+ space: { id: space.id },
709
+ timestamp
710
+ };
711
+ };
712
+ const sendTyping = async (client, space, state) => {
713
+ if (state === "start") await sendChatAction({
714
+ body: {
715
+ action: "typing",
716
+ chat_id: space.id
717
+ },
718
+ client
719
+ });
720
+ };
721
+ const sendEdit = async (client, space, content) => {
722
+ const inner = content.content;
723
+ if (inner.type !== "text" && inner.type !== "markdown") throw UnsupportedError.content("edit", TELEGRAM_PLATFORM, `only text and markdown content can be edited (got "${inner.type}").`);
724
+ const body = inner.type === "markdown" ? {
725
+ text: markdownToTelegramHtml(inner.markdown),
726
+ parse_mode: "HTML"
727
+ } : { text: inner.text };
728
+ await editMessageText({
729
+ body: {
730
+ chat_id: space.id,
731
+ message_id: parseMessageId(content.target.id),
732
+ ...body
733
+ },
734
+ client
735
+ });
736
+ };
737
+ /**
738
+ * Outbound dispatcher. Fire-and-forget signals (typing, edit) return
739
+ * `undefined`; message-producing content returns a record with the Telegram
740
+ * message id. Reactions return a record with a synthetic id (Telegram assigns
741
+ * none). A `group` fans out to one message per item. `streamText` streams
742
+ * natively via message drafts in private chats (see `stream-text.ts`). `poll`,
743
+ * `effect`, `rename` and `avatar` are unsupported in v1 (use `custom` to reach
744
+ * any other Bot API method directly).
745
+ */
746
+ const send = async ({ space, content, config }) => {
747
+ const client = telegramClient(config);
748
+ switch (content.type) {
749
+ case "reaction": return await sendReaction(client, space, content);
750
+ case "typing": return await sendTyping(client, space, content.state);
751
+ case "read": return;
752
+ case "edit": return await sendEdit(client, space, content);
753
+ case "group": return await sendGroup(client, space, content.items);
754
+ case "streamText": return await sendStreamText(client, space, content);
755
+ case "poll":
756
+ case "poll_option":
757
+ case "effect":
758
+ case "rename":
759
+ case "avatar": throw UnsupportedError.content(content.type, TELEGRAM_PLATFORM);
760
+ default: return await sendContent(client, space, content);
761
+ }
762
+ };
763
+ //#endregion
764
+ //#region src/space.ts
765
+ const resolveUser = ({ input }) => Promise.resolve({ id: input.userID });
766
+ /**
767
+ * Create a space for a Telegram `chat_id`. A bot cannot initiate a
768
+ * conversation or create a group, so creation only works for a private chat:
769
+ * the single recipient's user id equals the chat id. Existing chats (groups,
770
+ * supergroups, channels) are addressed by id via `space.get(chatId)` —
771
+ * Telegram chat ids are numbers in the wire format (negative for
772
+ * groups/supergroups); stringify them with `String(chatId)`.
773
+ */
774
+ const createSpace = ({ input }) => {
775
+ const [first, ...rest] = input.users;
776
+ if (first && rest.length === 0) return Promise.resolve({ id: first.id });
777
+ if (!first) throw new Error("Telegram space creation requires a recipient user.");
778
+ throw new Error("Telegram bots cannot create group chats — use space.get(chatId) for an existing chat, or create a space with a single user (their private chat).");
779
+ };
780
+ //#endregion
781
+ //#region src/verify.ts
782
+ /**
783
+ * Telegram echoes the `secret_token` configured in `setWebhook` back in this
784
+ * header (lowercased by Spectrum/Fusor). It is the ONLY inbound authentication
785
+ * — Telegram does not HMAC-sign the request body.
786
+ */
787
+ const SECRET_TOKEN_HEADER = "x-telegram-bot-api-secret-token";
788
+ const safeEqual = (a, b) => {
789
+ const left = Buffer.from(a, "utf8");
790
+ const right = Buffer.from(b, "utf8");
791
+ if (left.length === 0 || left.length !== right.length) return false;
792
+ return timingSafeEqual(left, right);
793
+ };
794
+ const verifySecret = (headers, secret) => {
795
+ const provided = headers[SECRET_TOKEN_HEADER];
796
+ if (!provided) throw new Error("Telegram webhook is missing the secret token header");
797
+ if (!safeEqual(provided, secret)) throw new Error("Telegram webhook secret token mismatch");
798
+ };
799
+ const isUpdate = (value) => typeof value === "object" && value !== null && "update_id" in value && typeof value.update_id === "number";
800
+ const parseUpdate = (bodyText) => {
801
+ let json;
802
+ try {
803
+ json = JSON.parse(bodyText);
804
+ } catch {
805
+ throw new Error("Telegram webhook body is not valid JSON");
806
+ }
807
+ if (!isUpdate(json)) throw new Error("Telegram webhook payload is missing a numeric update_id");
808
+ return json;
809
+ };
810
+ /**
811
+ * Build the Fusor `verify` hook. Receiving is pure parsing: it closes over
812
+ * `config` only to check the webhook secret token, then parses the raw body
813
+ * into an `Update` and returns it as the payload — no client is involved. When
814
+ * no `webhookSecret` is configured the token check is skipped and the body is
815
+ * parsed directly. Throwing rejects the event (Fusor returns 400 — no retry).
816
+ * The inbound mapper reads `config` from its own ctx and builds a client inline
817
+ * only if it needs to download media.
818
+ */
819
+ const verify = (config) => (req) => {
820
+ if (config.webhookSecret) verifySecret(req.headers, config.webhookSecret);
821
+ return parseUpdate(new TextDecoder().decode(req.rawBody));
822
+ };
823
+ //#endregion
824
+ //#region src/webhook.ts
825
+ /**
826
+ * Base domain of the Fusor "super webhook" edge. Telegram delivers updates to
827
+ * `https://{slug}.{domain}/{platform}`, where Fusor forwards them on to
828
+ * Spectrum. Override per-environment (e.g. `staging.spctrm.dev`) via
829
+ * `SPECTRUM_SUPER_WEBHOOK`.
830
+ */
831
+ const DEFAULT_SUPER_WEBHOOK_DOMAIN = "spctrm.dev";
832
+ /**
833
+ * The Bot API webhook URL Telegram should POST updates to: the Fusor edge keyed
834
+ * by the project `slug`, on the Telegram platform path segment.
835
+ */
836
+ const webhookUrl = (slug) => {
837
+ return `https://${slug}.${process.env.SPECTRUM_SUPER_WEBHOOK ?? DEFAULT_SUPER_WEBHOOK_DOMAIN}/${TELEGRAM_PLATFORM}`;
838
+ };
839
+ /**
840
+ * Make Telegram deliver this bot's updates to the Fusor edge for `slug`.
841
+ *
842
+ * Idempotent: reads the current webhook via `getWebhookInfo` and only calls
843
+ * `setWebhook` when the URL differs, so a restart with an already-registered
844
+ * bot makes no write. `config.webhookSecret`, when set, is registered as the
845
+ * `secret_token` Telegram echoes back for inbound verification (see `verify`);
846
+ * when absent, the webhook is registered without one.
847
+ *
848
+ * Note: `getWebhookInfo` does not return the secret, so a secret-only change
849
+ * (same URL) is not re-applied — change the URL or clear the webhook to force it.
850
+ *
851
+ * Failures throw a token-free error (the bot token is never interpolated),
852
+ * failing `Spectrum()` startup fast: a bot that cannot register its webhook
853
+ * receives nothing.
854
+ */
855
+ const ensureWebhook = async (config, slug) => {
856
+ const client = telegramClient(config);
857
+ const url = webhookUrl(slug);
858
+ try {
859
+ if ((await getWebhookInfo({
860
+ client,
861
+ throwOnError: true
862
+ })).result?.url === url) return;
863
+ await setWebhook({
864
+ body: {
865
+ url,
866
+ ...config.webhookSecret ? { secret_token: config.webhookSecret } : {}
867
+ },
868
+ client,
869
+ throwOnError: true
870
+ });
871
+ } catch (error) {
872
+ throw new Error(`Telegram webhook registration failed for ${url}`, { cause: error });
873
+ }
874
+ };
875
+ //#endregion
876
+ //#region src/index.ts
877
+ /**
878
+ * Telegram provider for Spectrum.
879
+ *
880
+ * Inbound is delivered through Fusor: `createClient` returns a `fusor(...)`
881
+ * client whose `verify` checks the Telegram webhook secret token and parses the
882
+ * `Update` (pure parsing — no client). The `messages` handler reads `config`
883
+ * from its ctx and builds a photon client inline only to download media bytes.
884
+ * Outbound (`send`) also builds a photon client inline. Both go through
885
+ * `@photon-ai/telegram-ts`. Drop `telegram.config({...})` into
886
+ * `Spectrum({ providers: [...] })`.
887
+ *
888
+ * In cloud mode (`projectConfig` present), `createClient` also self-registers
889
+ * the bot's webhook against the Fusor edge for the project slug — see
890
+ * `ensureWebhook`. Without a slug (local/direct mode) registration is skipped.
891
+ */
892
+ const telegram = definePlatform(TELEGRAM_PLATFORM, {
893
+ config: configSchema,
894
+ lifecycle: { createClient: async ({ config, projectConfig }) => {
895
+ const slug = projectConfig?.slug;
896
+ if (slug) await ensureWebhook(config, slug);
897
+ return fusor(TELEGRAM_PLATFORM, verify(config));
898
+ } },
899
+ user: { resolve: resolveUser },
900
+ space: { create: createSpace },
901
+ messages: handleMessages,
902
+ send
903
+ });
904
+ //#endregion
905
+ export { telegram };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@spectrum-ts/telegram",
3
+ "version": "5.0.0",
4
+ "description": "Telegram provider for spectrum-ts.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/photon-hq/spectrum-ts.git",
8
+ "directory": "packages/telegram"
9
+ },
10
+ "homepage": "https://photon.codes/spectrum",
11
+ "bugs": {
12
+ "url": "https://github.com/photon-hq/spectrum-ts/issues"
13
+ },
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "spectrum": {
29
+ "key": "telegram",
30
+ "import": "telegram",
31
+ "label": "telegram"
32
+ },
33
+ "dependencies": {
34
+ "@photon-ai/telegram-ts": "10.0.0",
35
+ "marked": "^18.0.5",
36
+ "zod": "^4.2.1"
37
+ },
38
+ "peerDependencies": {
39
+ "@spectrum-ts/core": "^5.0.0",
40
+ "typescript": "^5 || ^6.0.0"
41
+ },
42
+ "license": "MIT"
43
+ }