@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 +21 -0
- package/README.md +22 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +905 -0
- package/package.json +43 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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, "&").replace(LT_PATTERN, "<").replace(GT_PATTERN, ">");
|
|
369
|
+
const escapeAttribute = (value) => escapeHtml(value).replace(QUOTE_PATTERN, """);
|
|
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
|
+
}
|