@emulon/telegram 0.2.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
2
+
3
+ Copyright (c) 2026 Igor Katsuba
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # @emulon/telegram
2
+
3
+ A local Telegram Bot API for the polling-only slice selected in
4
+ [ADR 0037](../../docs/decisions/0037-telegram-bot-api-polling-slice.md). The
5
+ [compatibility manifest](src/compatibility.ts) is the authoritative tested
6
+ scope. This release issues bots, authenticates their tokens, answers `getMe`,
7
+ publishes plain or MarkdownV2 channel posts with `sendMessage` and delivers
8
+ reaction counts through `getUpdates` polling; every other method the pinned
9
+ client types returns an explicit 501.
10
+
11
+ ```ts
12
+ import { Emulon } from 'emulon';
13
+ import telegram from '@emulon/telegram';
14
+ import { Bot } from 'grammy';
15
+
16
+ await using env = await Emulon.start({
17
+ services: {
18
+ tg: telegram({
19
+ fixtures: {
20
+ channels: [{
21
+ id: -1001234567890,
22
+ title: 'Local News',
23
+ username: 'local_news',
24
+ }],
25
+ },
26
+ }),
27
+ },
28
+ });
29
+ const { token } = await env.services.tg.bots.create({
30
+ username: 'poster_bot',
31
+ firstName: 'Poster',
32
+ });
33
+ const bot = new Bot(token, { client: { apiRoot: env.endpoints.tg.api } });
34
+ console.log(await bot.api.getMe());
35
+
36
+ const post = await bot.api.sendMessage('@local_news', '*Hello*, world\\!', {
37
+ parse_mode: 'MarkdownV2',
38
+ });
39
+ console.log(post.message_id, post.text, post.entities);
40
+ console.log(await env.services.tg.messages.list({ chatId: -1001234567890 }));
41
+ ```
42
+
43
+ ## Run the example
44
+
45
+ From the repository root:
46
+
47
+ ```sh
48
+ deno task example:telegram
49
+ ```
50
+
51
+ The [example](../../examples/telegram/README.md) starts an instance with one
52
+ channel, issues a bot, has it convert a Markdown post with `md-to-telegram`,
53
+ split it and publish each part to `@local_news` as silent MarkdownV2 messages
54
+ with link previews enabled, sets a reaction on the post and reads it back
55
+ through `getUpdates`. It prints the published messages with their entities, the
56
+ `message_reaction_count` update and the offset to store for the next run.
57
+
58
+ ## Connection and commands
59
+
60
+ The endpoint URL is grammY's `apiRoot` as is, without a trailing slash; grammY
61
+ requests `<apiRoot>/bot<token>/<method>`. For a running project configured with
62
+ instance `tg`:
63
+
64
+ ```sh
65
+ emulon tg bots create --username poster_bot --first-name Poster --json
66
+ emulon tg messages list --chat-id -1001234567890 --limit 10 --json
67
+ emulon tg reactions set --chat-id -1001234567890 --message-id 1 \
68
+ --reactions '[{"type":{"type":"emoji","emoji":"👍"},"total_count":3}]' --json
69
+ emulon tg updates inspect --bot-id 7000000001 --json
70
+ emulon tg compatibility get --json
71
+ ```
72
+
73
+ `bots create` is the only output that contains a token, and it shows it once.
74
+ Tokens are `<bot id>:<secret>` with a 256-bit random secret; the instance keeps
75
+ only a SHA-256 verifier. A durable restart keeps issued tokens valid, and reset
76
+ removes every bot, so earlier tokens fail even when a new bot gets the same ID.
77
+ Bot usernames end in `bot` and share one case-insensitive namespace with channel
78
+ usernames.
79
+
80
+ `fixtures.channels` entries have a negative `-100…` `id`, a `title` and a
81
+ `username` unique without regard to case.
82
+
83
+ ## Channel posts
84
+
85
+ `sendMessage` posts to a configured channel addressed by its ID or by
86
+ `@username`. It accepts `chat_id`, `text`, `parse_mode: "MarkdownV2"` (or no
87
+ parse mode), `disable_notification` and `link_preview_options` with
88
+ `is_disabled: false`; notifications and link previews are not simulated. Any
89
+ other field, `is_disabled: true`, and the HTML and legacy Markdown parse modes
90
+ fail with 501 rather than being ignored.
91
+
92
+ MarkdownV2 is parsed the way Telegram parses it into text and entities whose
93
+ offsets and lengths count UTF-16 code units: escapes, bold, italic, underline,
94
+ strikethrough, spoilers, inline code, fenced code with a language, links, block
95
+ quotations and expandable `**>…||` quotations, with Telegram's nesting rules.
96
+ This covers everything `md-to-telegram` emits for MarkdownV2, including posts
97
+ cut by its `splitMessage`; send each part as its own message. Custom emoji,
98
+ date-time entities, `tg://` mentions, non-HTTP link targets and code spanning
99
+ quoted lines are not emulated and fail with 400 `can't parse entities`, and no
100
+ URL, mention or hashtag entities are detected automatically.
101
+
102
+ Malformed markup fails with 400 `Bad Request: can't parse entities` before the
103
+ length check; text longer than 4096 UTF-16 units after parsing fails with 400
104
+ `Bad Request: message is too long`. A failed send writes nothing. Each
105
+ successful send takes the next `message_id` of its channel in the same
106
+ transaction as the write, so IDs increase without gaps across bots, concurrent
107
+ calls and restarts.
108
+
109
+ `messages list` (`messages.list`) takes a numeric `chatId` and an optional
110
+ `limit` from 1 to 100 (default 100), and returns the most recent messages oldest
111
+ first with the submitted `source`, `parseMode`, rendered `text` and `entities`.
112
+
113
+ ## Reaction polling
114
+
115
+ A bot receives `message_reaction_count` updates only after it asks for them, as
116
+ in Telegram: call `getUpdates` with
117
+ `allowed_updates: ['message_reaction_count']` first. A reaction set before that
118
+ never reaches the bot.
119
+
120
+ A consumer that runs on a schedule keeps the offset between runs. On its first
121
+ run it has none; each run drains until an empty batch, and that empty call with
122
+ the new offset is what confirms the batch before it:
123
+
124
+ ```ts
125
+ async function drain(bot: Bot, stored: number | undefined) {
126
+ let offset = stored;
127
+
128
+ while (true) {
129
+ const updates = await bot.api.getUpdates({
130
+ ...(offset === undefined ? {} : { offset }),
131
+ limit: 100,
132
+ timeout: 0,
133
+ allowed_updates: ['message_reaction_count'],
134
+ });
135
+
136
+ if (updates.length === 0) {
137
+ return offset; // store it for the next run
138
+ }
139
+
140
+ for (const update of updates) {
141
+ console.log(update.message_reaction_count?.reactions);
142
+ }
143
+
144
+ offset = updates.at(-1)!.update_id + 1;
145
+ }
146
+ }
147
+
148
+ let offset = await drain(bot, undefined); // subscribes, finds nothing
149
+ await env.services.tg.reactions.set({
150
+ chatId: -1001234567890,
151
+ messageId: post.message_id,
152
+ reactions: [{ type: { type: 'emoji', emoji: '👍' }, total_count: 3 }],
153
+ });
154
+ offset = await drain(bot, offset); // prints the 👍 3 count
155
+ ```
156
+
157
+ `reactions set` (`reactions.set`) replaces the absolute counts of a sent channel
158
+ message with a list of Bot API `ReactionCount` objects: `emoji` reactions with
159
+ one of the emoji the Bot API types, `custom_emoji` with a numeric ID, and
160
+ `paid`, each at most once, with a nonnegative `total_count`. Zero counts are
161
+ dropped. When the counts change, every bot subscribed at that moment gets one
162
+ update with the chat, `message_id`, Unix `date` and the full `reactions` list,
163
+ in the same transaction; setting the same counts again queues nothing. Counts
164
+ are ordered by `total_count`, then paid, emoji and custom emoji.
165
+
166
+ Each bot has its own durable queue with update IDs from 1. `getUpdates` takes
167
+ `offset`, `limit` (1–100, default 100), `timeout` (seconds, default 0) and
168
+ `allowed_updates`. A nonnegative offset confirms every smaller ID, a negative
169
+ offset keeps only that many of the latest updates, and returning a batch
170
+ confirms nothing. An omitted `allowed_updates` keeps the last list and an empty
171
+ one restores Telegram's default, which leaves out reaction counts; a change
172
+ never touches updates already queued. A positive `timeout` waits in real time
173
+ and returns as soon as an update arrives. One `getUpdates` call per bot runs at
174
+ a time; an overlapping call receives 409. Disconnecting ends a waiting poll, and
175
+ reset or shutdown ends it with a 503 envelope rather than an empty batch.
176
+
177
+ `updates inspect` (`updates.inspect`) takes a `botId` and an optional `limit`
178
+ from 1 to 100, and returns the subscription, the number of pending updates and
179
+ the oldest ones, without confirming anything or showing a token.
180
+
181
+ ## Envelopes
182
+
183
+ Responses are Bot API envelopes: `{ "ok": true, "result": ... }` on success and
184
+ `{ "ok": false, "error_code": ..., "description": ... }` with the same HTTP
185
+ status on failure. The token is checked before the method: a malformed token
186
+ receives 404 and a well-formed token of no issued bot 401. With a valid token,
187
+ an unknown method receives 404 and a known but unimplemented one, including
188
+ `setWebhook`, `deleteWebhook` and `getWebhookInfo`, 501. Only POST with a JSON
189
+ object body is emulated. Descriptions are fixed and never contain the token, the
190
+ path or request input.
191
+
192
+ ## Limitations
193
+
194
+ The [compatibility manifest](src/compatibility.ts) lists every limitation; in
195
+ short, this is not a general Bot API:
196
+
197
+ - Only `getMe`, `sendMessage` and `getUpdates` exist; every other method,
198
+ including the webhook methods, returns 501. There is no webhook mode, so
199
+ updates arrive only by polling.
200
+ - Bots post only to configured channels; there are no users, groups, private
201
+ chats, media, keyboards, inline mode or payments.
202
+ - The only update type is `message_reaction_count`, and only the `reactions set`
203
+ command produces it; bots cannot set reactions.
204
+ - MarkdownV2 is limited to what `md-to-telegram` emits, and error descriptions
205
+ are fixed, without the offset Telegram appends.
206
+ - An overlapping `getUpdates` receives 409 instead of ending the earlier one.
207
+
208
+ Tests use `grammy@1.44.0` and `md-to-telegram@0.1.1` on loopback and never reach
209
+ Telegram. The consumer suite runs the complete publish-and-drain loop above
210
+ through grammY, with reactions set by the CLI and SDK. Neither client is a
211
+ dependency of this package, and the installed archive checks under Node and Deno
212
+ assert that.
@@ -0,0 +1,18 @@
1
+ /** Bot tokens are `<id>:<secret>`; grammY puts the whole token in the path. */
2
+ export interface ParsedToken {
3
+ botId: number;
4
+ token: string;
5
+ }
6
+ /** 32 random bytes: 256 bits, base64url without padding (43 characters). */
7
+ export declare const secretBytes = 32;
8
+ export declare function issueToken(botId: number): string;
9
+ /**
10
+ * A token that cannot name a bot is malformed, which Telegram answers with 404
11
+ * rather than 401. The ID must be a canonical positive safe integer so one bot
12
+ * is never reachable through two spellings.
13
+ */
14
+ export declare function parseToken(raw: string): ParsedToken | null;
15
+ /** Only this digest of the full token is stored; the token is shown once. */
16
+ export declare function verifier(token: string): Promise<string>;
17
+ /** Compares every position, so timing does not reveal a matching prefix. */
18
+ export declare function sameVerifier(actual: string, expected: string): boolean;
@@ -0,0 +1,42 @@
1
+ /** 32 random bytes: 256 bits, base64url without padding (43 characters). */
2
+ export const secretBytes = 32;
3
+ const shape = /^([1-9][0-9]*):([A-Za-z0-9_-]+)$/;
4
+ export function issueToken(botId) {
5
+ const bytes = crypto.getRandomValues(new Uint8Array(secretBytes));
6
+ const secret = btoa(String.fromCharCode(...bytes))
7
+ .replaceAll('+', '-')
8
+ .replaceAll('/', '_')
9
+ .replace(/=+$/, '');
10
+ return `${botId}:${secret}`;
11
+ }
12
+ /**
13
+ * A token that cannot name a bot is malformed, which Telegram answers with 404
14
+ * rather than 401. The ID must be a canonical positive safe integer so one bot
15
+ * is never reachable through two spellings.
16
+ */
17
+ export function parseToken(raw) {
18
+ const match = shape.exec(raw);
19
+ if (!match) {
20
+ return null;
21
+ }
22
+ const botId = Number(match[1]);
23
+ return Number.isSafeInteger(botId) && String(botId) === match[1]
24
+ ? { botId, token: raw }
25
+ : null;
26
+ }
27
+ /** Only this digest of the full token is stored; the token is shown once. */
28
+ export async function verifier(token) {
29
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(token));
30
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
31
+ }
32
+ /** Compares every position, so timing does not reveal a matching prefix. */
33
+ export function sameVerifier(actual, expected) {
34
+ if (actual.length !== expected.length) {
35
+ return false;
36
+ }
37
+ let difference = 0;
38
+ for (let i = 0; i < expected.length; i++) {
39
+ difference |= actual.charCodeAt(i) ^ expected.charCodeAt(i);
40
+ }
41
+ return difference === 0;
42
+ }
@@ -0,0 +1,15 @@
1
+ import { defineCommand } from 'emulon';
2
+ import { z } from 'zod';
3
+ import { type CreateInput, type IssuedBot } from '../model/bots.js';
4
+ import { type ListInput, type StoredMessage } from '../model/messages.js';
5
+ import { type ReactionsInput, type ReactionsResult } from '../model/reactions.js';
6
+ import { type InspectInput, type QueueView } from '../model/updates.js';
7
+ type Operation<I, O> = ReturnType<typeof defineCommand<z.ZodType<I, I>, z.ZodType<O, O>>>;
8
+ export type Commands = {
9
+ 'bots.create': Operation<CreateInput, IssuedBot>;
10
+ 'messages.list': Operation<ListInput, StoredMessage[]>;
11
+ 'reactions.set': Operation<ReactionsInput, ReactionsResult>;
12
+ 'updates.inspect': Operation<InspectInput, QueueView>;
13
+ };
14
+ export declare const commands: Commands;
15
+ export {};
@@ -0,0 +1,52 @@
1
+ import { defineCommand } from 'emulon';
2
+ import { z } from 'zod';
3
+ import { createBot, createInput, issuedSchema, } from '../model/bots.js';
4
+ import { listInput, listMessages, storedSchema, } from '../model/messages.js';
5
+ import { reactionsInput, reactionsResultSchema, setReactions, } from '../model/reactions.js';
6
+ import { inspectInput, inspectUpdates, queueViewSchema, } from '../model/updates.js';
7
+ export const commands = {
8
+ 'bots.create': defineCommand({
9
+ description: 'Create a bot administering every configured channel and issue its token once (explicit secret output)',
10
+ input: createInput,
11
+ output: issuedSchema,
12
+ cli: {
13
+ path: ['bots', 'create'],
14
+ flags: { username: 'username', 'first-name': 'firstName' },
15
+ },
16
+ execute: (ctx, input) => createBot(ctx.store, input),
17
+ }),
18
+ 'messages.list': defineCommand({
19
+ description: 'List the most recent messages of a channel by ID, oldest first',
20
+ input: listInput,
21
+ output: z.array(storedSchema),
22
+ cli: {
23
+ path: ['messages', 'list'],
24
+ flags: { 'chat-id': 'chatId', limit: 'limit' },
25
+ },
26
+ execute: (ctx, input) => listMessages(ctx.store, input),
27
+ }),
28
+ 'reactions.set': defineCommand({
29
+ description: 'Replace the absolute reaction counts of a channel message and queue one update per subscribed bot when they change',
30
+ input: reactionsInput,
31
+ output: reactionsResultSchema,
32
+ cli: {
33
+ path: ['reactions', 'set'],
34
+ flags: {
35
+ 'chat-id': 'chatId',
36
+ 'message-id': 'messageId',
37
+ reactions: 'reactions',
38
+ },
39
+ },
40
+ execute: (ctx, input) => setReactions(ctx.store, input, ctx.clock.now()),
41
+ }),
42
+ 'updates.inspect': defineCommand({
43
+ description: "Show a bot's pending updates and subscription without confirming them",
44
+ input: inspectInput,
45
+ output: queueViewSchema,
46
+ cli: {
47
+ path: ['updates', 'inspect'],
48
+ flags: { 'bot-id': 'botId', limit: 'limit' },
49
+ },
50
+ execute: (ctx, input) => inspectUpdates(ctx.store, input),
51
+ }),
52
+ };
@@ -0,0 +1,2 @@
1
+ import { type CompatibilityManifest } from 'emulon';
2
+ export declare const compatibility: CompatibilityManifest;
@@ -0,0 +1,236 @@
1
+ import { defineCompatibility } from 'emulon';
2
+ export const compatibility = defineCompatibility({
3
+ 'schemaVersion': 1,
4
+ 'plugin': '@emulon/telegram',
5
+ 'provider': {
6
+ 'name': 'Telegram',
7
+ 'api': 'Telegram Bot API',
8
+ },
9
+ 'operations': [
10
+ {
11
+ 'id': 'getMe',
12
+ 'method': 'POST',
13
+ 'path': '/bot:token/getMe',
14
+ 'surface': 'api',
15
+ 'version': 'bot-api',
16
+ 'auth': [
17
+ 'local-bot-token-path',
18
+ ],
19
+ 'input': [],
20
+ 'output': 'ok, result: id, is_bot, first_name, username, can_join_groups, can_read_all_group_messages, supports_inline_queries, can_connect_to_business, has_main_web_app, has_topics_enabled, allows_users_to_create_topics, can_manage_bots, supports_join_request_queries',
21
+ 'events': [],
22
+ 'cases': [
23
+ 'telegram.bots.1',
24
+ 'telegram.bots.2',
25
+ 'telegram.bots.3',
26
+ 'telegram.consumer.1',
27
+ ],
28
+ },
29
+ {
30
+ 'id': 'sendMessage',
31
+ 'method': 'POST',
32
+ 'path': '/bot:token/sendMessage',
33
+ 'surface': 'api',
34
+ 'version': 'bot-api',
35
+ 'auth': [
36
+ 'local-bot-token-path',
37
+ ],
38
+ 'input': [
39
+ 'chat_id',
40
+ 'text',
41
+ 'parse_mode',
42
+ 'disable_notification',
43
+ 'link_preview_options',
44
+ ],
45
+ 'output': 'ok, result: message_id, from, sender_chat, chat, date, text, entities',
46
+ 'events': [],
47
+ 'cases': [
48
+ 'telegram.messages.1',
49
+ 'telegram.messages.2',
50
+ 'telegram.messages.3',
51
+ 'telegram.consumer.1',
52
+ ],
53
+ },
54
+ {
55
+ 'id': 'getUpdates',
56
+ 'method': 'POST',
57
+ 'path': '/bot:token/getUpdates',
58
+ 'surface': 'api',
59
+ 'version': 'bot-api',
60
+ 'auth': [
61
+ 'local-bot-token-path',
62
+ ],
63
+ 'input': [
64
+ 'offset',
65
+ 'limit',
66
+ 'timeout',
67
+ 'allowed_updates',
68
+ ],
69
+ 'output': 'ok, result: update_id, message_reaction_count: chat, message_id, date, reactions',
70
+ 'events': [],
71
+ 'cases': [
72
+ 'telegram.updates.1',
73
+ 'telegram.updates.2',
74
+ 'telegram.updates.3',
75
+ 'telegram.consumer.1',
76
+ ],
77
+ },
78
+ ],
79
+ 'versions': [
80
+ {
81
+ 'id': 'bot-api',
82
+ 'accepted': [
83
+ 'bot-api',
84
+ ],
85
+ 'headers': [],
86
+ 'missing': 'The Bot API has no version selector; the method set is the one typed by @grammyjs/types@3.28.0, the types package of grammy@1.44.0.',
87
+ 'unknown': 'No version header is read; an unknown method returns a 404 Bot API envelope and a typed but unimplemented method returns 501.',
88
+ },
89
+ ],
90
+ 'authentication': {
91
+ 'flows': [
92
+ 'local-bot-token-path',
93
+ ],
94
+ 'keyFormats': [
95
+ '<bot id>:<256-bit base64url secret> in the bot<token> path segment',
96
+ ],
97
+ 'ownership': 'One token authorizes one bot of one local instance; only a SHA-256 verifier is stored, reset removes bots so earlier tokens fail even when a bot ID is reused, and a durable restart keeps issued tokens valid.',
98
+ 'unsupported': [
99
+ 'Real Telegram bots and BotFather',
100
+ 'Token revocation or regeneration commands',
101
+ 'Test environment /test paths and local Bot API server modes',
102
+ 'MTProto and user accounts',
103
+ ],
104
+ },
105
+ 'events': [],
106
+ 'webhooks': {
107
+ 'signing': 'Unsupported: this slice is polling-only; setWebhook, deleteWebhook and getWebhookInfo return 501 and no webhook can be installed',
108
+ 'id': 'Unsupported',
109
+ 'body': 'Unsupported',
110
+ 'success': 'Unsupported',
111
+ 'timeoutMs': 0,
112
+ 'retries': 'Unsupported',
113
+ 'redelivery': 'Unsupported',
114
+ 'recovery': 'Unsupported',
115
+ 'cases': [],
116
+ },
117
+ 'capabilities': [
118
+ 'http',
119
+ 'authorization',
120
+ 'reset',
121
+ ],
122
+ 'limitations': [
123
+ {
124
+ 'id': 'telegram.limitation.1',
125
+ 'description': 'Only getMe, sendMessage and getUpdates are implemented; every other method typed by @grammyjs/types@3.28.0, including setWebhook, deleteWebhook and getWebhookInfo, returns a 501 Bot API envelope',
126
+ },
127
+ {
128
+ 'id': 'telegram.limitation.2',
129
+ 'description': 'Only POST with an application/json object body; GET, query parameters, form and multipart bodies return 501, and any getMe parameter returns 501 instead of being ignored',
130
+ },
131
+ {
132
+ 'id': 'telegram.limitation.3',
133
+ 'description': 'getMe reports a bot without optional capabilities: can_join_groups is true and every other capability flag is false',
134
+ },
135
+ {
136
+ 'id': 'telegram.limitation.4',
137
+ 'description': 'Failures use fixed descriptions: 404 Not Found for a path that is not bot<token>/<method>, a malformed token or an unknown method; 401 Unauthorized for a well-formed token of no issued bot; 400 for a body that is not a JSON object',
138
+ },
139
+ {
140
+ 'id': 'telegram.limitation.5',
141
+ 'description': 'Bot IDs are allocated locally from 7000000001 upward and bot secrets are 43 base64url characters rather than the 35 of issued Telegram tokens',
142
+ },
143
+ {
144
+ 'id': 'telegram.limitation.6',
145
+ 'description': 'sendMessage targets configured channels only, by ID or @username; it accepts chat_id, text, parse_mode MarkdownV2 or none, disable_notification and link_preview_options with is_disabled false, without simulating notifications or link previews. Any other field, is_disabled true or another link preview option returns 501, as do the HTML and legacy Markdown parse modes',
146
+ },
147
+ {
148
+ 'id': 'telegram.limitation.7',
149
+ 'description': "MarkdownV2 covers escapes, bold, italic, underline, strikethrough, spoiler, inline code, fenced code with a language, links to http and https targets, block quotations and expandable block quotations, which is everything md-to-telegram@0.1.1 emits. Custom emoji, date-time entities, user mentions through tg:// links and other link schemes return 400 can't parse entities, as does code spanning quoted lines",
150
+ },
151
+ {
152
+ 'id': 'telegram.limitation.8',
153
+ 'description': 'Failures carry fixed descriptions without the offending character or offset Telegram appends; entities are only those written in markup, with no automatic url, mention or hashtag detection, and rendered text keeps leading and trailing whitespace',
154
+ },
155
+ {
156
+ 'id': 'telegram.limitation.9',
157
+ 'description': 'A link target without a scheme gets http://, and a target that is not a URL keeps its text without an entity; targets are normalized by the WHATWG URL parser, which can differ from Telegram in trailing slashes and percent-encoding',
158
+ },
159
+ {
160
+ 'id': 'telegram.limitation.10',
161
+ 'description': 'getUpdates only ever returns message_reaction_count updates, produced by the reactions set command for configured channel messages; there are no users, per-user message_reaction updates, channel_post or other update types, and no provider method sets reactions',
162
+ },
163
+ {
164
+ 'id': 'telegram.limitation.11',
165
+ 'description': 'getUpdates accepts only offset, limit, timeout and allowed_updates as JSON numbers and a list of update type names typed by @grammyjs/types@3.28.0; a limit outside 1 to 100, a non-integer or negative timeout and an unknown update type return 400 instead of being clamped or ignored, and any other parameter returns 501',
166
+ },
167
+ {
168
+ 'id': 'telegram.limitation.12',
169
+ 'description': 'One getUpdates call per bot may run at a time; an overlapping call receives the 409 Conflict envelope instead of ending the earlier one. A poll cancelled by reset or shutdown receives a 503 envelope, never an empty batch',
170
+ },
171
+ {
172
+ 'id': 'telegram.limitation.13',
173
+ 'description': 'reactions set accepts the reaction emoji typed by @grammyjs/types@3.28.0, numeric custom emoji IDs and paid reactions, and orders counts by total_count, then paid, emoji and custom emoji, then by emoji or ID; Telegram does not document its order',
174
+ },
175
+ ],
176
+ 'verification': {
177
+ 'mode': 'official-client',
178
+ 'client': 'grammy@1.44.0',
179
+ 'suites': [
180
+ {
181
+ 'path': 'packages/telegram/tests/bot_cases.ts',
182
+ 'cases': [
183
+ 'telegram.bots.1',
184
+ 'telegram.bots.2',
185
+ 'telegram.bots.3',
186
+ ],
187
+ },
188
+ {
189
+ 'path': 'packages/telegram/tests/message_cases.ts',
190
+ 'cases': [
191
+ 'telegram.messages.1',
192
+ 'telegram.messages.2',
193
+ 'telegram.messages.3',
194
+ ],
195
+ },
196
+ {
197
+ 'path': 'packages/telegram/tests/update_cases.ts',
198
+ 'cases': [
199
+ 'telegram.updates.1',
200
+ 'telegram.updates.2',
201
+ 'telegram.updates.3',
202
+ ],
203
+ },
204
+ {
205
+ 'path': 'packages/telegram/tests/consumer_cases.ts',
206
+ 'cases': [
207
+ 'telegram.consumer.1',
208
+ ],
209
+ },
210
+ ],
211
+ 'sources': [
212
+ 'https://core.telegram.org/bots/api',
213
+ 'https://core.telegram.org/bots/api#making-requests',
214
+ 'https://core.telegram.org/bots/api#getme',
215
+ 'https://core.telegram.org/bots/api#sendmessage',
216
+ 'https://core.telegram.org/bots/api#markdownv2-style',
217
+ 'https://core.telegram.org/bots/api#messageentity',
218
+ 'https://core.telegram.org/bots/api#getting-updates',
219
+ 'https://core.telegram.org/bots/api#getupdates',
220
+ 'https://core.telegram.org/bots/api#messagereactioncountupdated',
221
+ 'https://core.telegram.org/bots/api#reactioncount',
222
+ 'https://grammy.dev/ref/core/apiclientoptions',
223
+ ],
224
+ 'retrieved': '2026-09-25',
225
+ 'liveProviderCompared': false,
226
+ },
227
+ 'details': {
228
+ 'route': 'grammY builds <apiRoot>/bot<token>/<method>; the endpoint URL is apiRoot with no trailing slash. The token is validated before the method is resolved, and method names are case-insensitive.',
229
+ 'envelopes': 'Success is HTTP 200 with {ok: true, result}; failure uses the matching HTTP status with {ok: false, error_code, description}, and no description contains the token, the path or caller input.',
230
+ 'channels': 'fixtures.channels declares channels with a -100 prefixed negative ID, a title and a username unique without regard to case; bot usernames share that namespace.',
231
+ 'messages': 'Every successful sendMessage allocates the next message_id of its chat in the transaction that stores the message, so IDs are gapless and monotone across bots, concurrent calls and durable restart; a failed send writes nothing. Malformed MarkdownV2 fails before the 4096 UTF-16 unit limit on rendered text. messages list returns the stored source and rendered text of up to the latest 100 messages of a channel, oldest first.',
232
+ 'updates': 'Each bot has a durable queue with its own update IDs from 1 and a remembered allowed_updates. reactions set replaces the absolute counts of a message and, when they change, queues one message_reaction_count update for every bot subscribed at that moment, in the same transaction; an unchanged snapshot queues nothing and a reaction set before a bot subscribes never reaches it. A nonnegative offset confirms smaller IDs, a negative one keeps only the last updates, returning a batch confirms nothing, and updates inspect reads a queue without confirming it.',
233
+ 'longPolling': 'A positive timeout waits that many seconds of real time, not the instance clock, and returns as soon as an update commits. A client disconnect ends the wait, and reset or shutdown ends it before waiting for requests to drain.',
234
+ 'transportLimit': 'The host body limit answers oversized bodies with a plain 413 before the route runs, outside the Bot API envelope.',
235
+ },
236
+ });
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Telegram MarkdownV2 parsing, modelled on the TDLib parser the Bot API uses:
3
+ * one left-to-right pass over the source with a stack of open entities, so
4
+ * nesting, escaping and delimiter ambiguity resolve the way Telegram resolves
5
+ * them. Offsets and lengths are UTF-16 code units of the rendered text, which
6
+ * is exactly how a JavaScript string counts.
7
+ */
8
+ export type EntityType = 'bold' | 'italic' | 'underline' | 'strikethrough' | 'spoiler' | 'code' | 'pre' | 'text_link' | 'blockquote' | 'expandable_blockquote';
9
+ /** A Bot API `MessageEntity` limited to the types this parser produces. */
10
+ export interface MessageEntity {
11
+ type: EntityType;
12
+ offset: number;
13
+ length: number;
14
+ url?: string;
15
+ language?: string;
16
+ }
17
+ export interface ParsedText {
18
+ text: string;
19
+ entities: MessageEntity[];
20
+ }
21
+ /**
22
+ * Malformed or unsupported markup. It carries no detail: Telegram's own
23
+ * message names the offending character, which would echo caller input.
24
+ */
25
+ export declare class MarkdownError extends Error {
26
+ constructor();
27
+ }
28
+ /**
29
+ * Link targets are unescaped verbatim. Telegram adds `http://` to a target
30
+ * without a scheme and silently drops a link it cannot read as a URL; mention,
31
+ * custom emoji, mail and other schemes are outside this emulator's scope and
32
+ * are refused rather than rendered differently.
33
+ */
34
+ export declare function linkTarget(raw: string): string | null;
35
+ export declare function parseMarkdownV2(source: string): ParsedText;