@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 +21 -0
- package/README.md +212 -0
- package/esm/auth/tokens.d.ts +18 -0
- package/esm/auth/tokens.js +42 -0
- package/esm/commands/mod.d.ts +15 -0
- package/esm/commands/mod.js +52 -0
- package/esm/compatibility.d.ts +2 -0
- package/esm/compatibility.js +236 -0
- package/esm/format/markdown_v2.d.ts +35 -0
- package/esm/format/markdown_v2.js +266 -0
- package/esm/mod.d.ts +9 -0
- package/esm/mod.js +28 -0
- package/esm/model/bots.d.ts +53 -0
- package/esm/model/bots.js +84 -0
- package/esm/model/channels.d.ts +25 -0
- package/esm/model/channels.js +45 -0
- package/esm/model/messages.d.ts +108 -0
- package/esm/model/messages.js +269 -0
- package/esm/model/reactions.d.ts +33 -0
- package/esm/model/reactions.js +129 -0
- package/esm/model/updates.d.ts +105 -0
- package/esm/model/updates.js +354 -0
- package/esm/package.json +3 -0
- package/esm/routes/api.d.ts +31 -0
- package/esm/routes/api.js +128 -0
- package/esm/routes/methods.d.ts +10 -0
- package/esm/routes/methods.js +195 -0
- package/package.json +277 -0
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,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;
|