@volter/twin-telegram 1.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.
Files changed (92) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +53 -0
  3. package/dist/generated/pack-facts.json +53 -0
  4. package/dist/journeys/coverage.json +138 -0
  5. package/dist/journeys/customer-life.json +1229 -0
  6. package/dist/journeys/customer-life.outline.md +99 -0
  7. package/dist/journeys/customer-life.outline.verdict.json +76 -0
  8. package/dist/journeys/customer-life.verdict.json +134 -0
  9. package/dist/journeys/decisions.json +95 -0
  10. package/dist/journeys/demand.json +152 -0
  11. package/dist/journeys/score.json +163 -0
  12. package/dist/journeys/unreachable.json +29 -0
  13. package/dist/journeys/vendor-examples.json +1554 -0
  14. package/dist/journeys/vendor-examples.verdict.json +14 -0
  15. package/dist/spec/SOURCE.md +51 -0
  16. package/dist/spec/bot-api.html.gz +0 -0
  17. package/dist/spec/doc-examples.json +344 -0
  18. package/dist/spec/grouping.json +1 -0
  19. package/dist/spec/openapi.json.gz +0 -0
  20. package/dist/spec/page-to-openapi.ts +258 -0
  21. package/dist/spec/patches.json +289 -0
  22. package/dist/spec/sources.json +416 -0
  23. package/dist/src/cli.d.ts +2 -0
  24. package/dist/src/cli.js +21 -0
  25. package/dist/src/engine/media.d.ts +17 -0
  26. package/dist/src/engine/media.js +75 -0
  27. package/dist/src/engine/text.d.ts +45 -0
  28. package/dist/src/engine/text.js +512 -0
  29. package/dist/src/fetch.d.ts +2 -0
  30. package/dist/src/fetch.js +17 -0
  31. package/dist/src/generated/surface.gen.json +1 -0
  32. package/dist/src/generated/ui.gen.json +1 -0
  33. package/dist/src/index.d.ts +5 -0
  34. package/dist/src/index.js +9 -0
  35. package/dist/src/manifest.d.ts +2 -0
  36. package/dist/src/manifest.js +104 -0
  37. package/dist/src/semantics/around.d.ts +2 -0
  38. package/dist/src/semantics/around.js +32 -0
  39. package/dist/src/semantics/available-methods.d.ts +12 -0
  40. package/dist/src/semantics/available-methods.js +266 -0
  41. package/dist/src/semantics/doors.d.ts +18 -0
  42. package/dist/src/semantics/doors.js +292 -0
  43. package/dist/src/semantics/getting-updates.d.ts +3 -0
  44. package/dist/src/semantics/getting-updates.js +68 -0
  45. package/dist/src/semantics/index.d.ts +2 -0
  46. package/dist/src/semantics/index.js +19 -0
  47. package/dist/src/semantics/shared.d.ts +112 -0
  48. package/dist/src/semantics/shared.js +424 -0
  49. package/dist/src/semantics/states.d.ts +18 -0
  50. package/dist/src/semantics/states.js +37 -0
  51. package/dist/src/semantics/updating-messages.d.ts +2 -0
  52. package/dist/src/semantics/updating-messages.js +48 -0
  53. package/dist/src/server.d.ts +7 -0
  54. package/dist/src/server.js +24 -0
  55. package/generated/pack-facts.json +53 -0
  56. package/journeys/coverage.json +138 -0
  57. package/journeys/customer-life.json +1229 -0
  58. package/journeys/customer-life.outline.md +99 -0
  59. package/journeys/customer-life.outline.verdict.json +76 -0
  60. package/journeys/customer-life.verdict.json +134 -0
  61. package/journeys/decisions.json +95 -0
  62. package/journeys/demand.json +152 -0
  63. package/journeys/score.json +163 -0
  64. package/journeys/unreachable.json +29 -0
  65. package/journeys/vendor-examples.json +1554 -0
  66. package/journeys/vendor-examples.verdict.json +14 -0
  67. package/package.json +37 -0
  68. package/spec/SOURCE.md +51 -0
  69. package/spec/bot-api.html.gz +0 -0
  70. package/spec/doc-examples.json +344 -0
  71. package/spec/grouping.json +1 -0
  72. package/spec/openapi.json.gz +0 -0
  73. package/spec/page-to-openapi.ts +258 -0
  74. package/spec/patches.json +289 -0
  75. package/spec/sources.json +416 -0
  76. package/src/cli.ts +22 -0
  77. package/src/engine/media.ts +67 -0
  78. package/src/engine/text.ts +409 -0
  79. package/src/fetch.ts +18 -0
  80. package/src/generated/surface.gen.json +1 -0
  81. package/src/generated/ui.gen.json +1 -0
  82. package/src/index.ts +11 -0
  83. package/src/manifest.ts +114 -0
  84. package/src/semantics/around.ts +33 -0
  85. package/src/semantics/available-methods.ts +253 -0
  86. package/src/semantics/doors.ts +277 -0
  87. package/src/semantics/getting-updates.ts +64 -0
  88. package/src/semantics/index.ts +23 -0
  89. package/src/semantics/shared.ts +428 -0
  90. package/src/semantics/states.ts +38 -0
  91. package/src/semantics/updating-messages.ts +41 -0
  92. package/src/server.ts +17 -0
@@ -0,0 +1,428 @@
1
+ // What every family of telegram shares: the bot a request's token names, a chat's access as the Bot API server checks
2
+ // it, the objects as the Bot API answers them (a user, a chat, a member, a message), a message's text, a stored file,
3
+ // and the updates a write puts in each bot's queue.
4
+ //
5
+ // The Bot API server's own source is the evidence where the page says nothing (tdlib/telegram-bot-api, which the page
6
+ // names at "Using a Local Bot API Server", read at e3e9dd8; and TDLib, tdlib/td at 42e6a52, whose errors it relays).
7
+ import type { HandlerContext } from '@volter/world-core';
8
+ import { finish, parseHtml, parseMarkdown, parseMarkdownV2, type Entity, type Formatted } from '../engine/text.ts';
9
+
10
+ export type Row = Record<string, unknown>;
11
+ export type { HandlerContext };
12
+
13
+ // ── answers ─────────────────────────────────────────────────────────────────────────────────────────────────────
14
+
15
+ // source: https://core.telegram.org/bots/api#making-requests "If 'ok' equals True, the request was successful and the result of the query can be found in the 'result' field."
16
+ export function answer(result: unknown, description?: string): Response {
17
+ return Response.json({ ok: true, result, ...(description !== undefined ? { description } : {}) });
18
+ }
19
+
20
+ /** A refusal in the Bot API's envelope ({ ok: false, error_code, description }), the HTTP status its code. */
21
+ export function refuse(ctx: HandlerContext, status: number, description: string): Response {
22
+ return ctx.refuse({ status, message: description });
23
+ }
24
+
25
+ /** A parameter as the request gave it: a query or form value is text, a JSON body's value keeps its type. */
26
+ export function param(ctx: HandlerContext, name: string): unknown {
27
+ const v = ctx.params[name];
28
+ return v === '' ? undefined : v;
29
+ }
30
+
31
+ /** An integer parameter, or undefined when absent or not one. */
32
+ export function intParam(ctx: HandlerContext, name: string): number | undefined {
33
+ const v = param(ctx, name);
34
+ if (typeof v === 'number') return Number.isInteger(v) ? v : undefined;
35
+ if (typeof v === 'string' && /^-?\d+$/.test(v.trim())) return Number(v.trim());
36
+ return undefined;
37
+ }
38
+
39
+ /** A JSON-serialized parameter (reply_parameters, media, allowed_updates): a form value is its JSON text. */
40
+ export function jsonParam(ctx: HandlerContext, name: string): { value?: unknown; error?: true } {
41
+ const v = param(ctx, name);
42
+ if (v === undefined) return {};
43
+ if (typeof v !== 'string') return { value: v };
44
+ try { return { value: JSON.parse(v) }; } catch { return { error: true }; }
45
+ }
46
+
47
+ /** The vendor's own fields of a stored row: the kernel's bookkeeping (`_` fields, a tombstone) left out. */
48
+ export function vendorFields(ctx: HandlerContext, row: Row): Row {
49
+ return Object.fromEntries(Object.entries(ctx.own(row)).filter(([k]) => !k.startsWith('_') && k !== 'deleted'));
50
+ }
51
+
52
+ /** The methods this pack serves, as the page names them: a request naming one in another case is routed to it. */
53
+ export const SERVED_METHODS = ['getUpdates', 'deleteWebhook', 'getMe', 'sendMessage', 'sendPhoto', 'sendVideo', 'sendDocument', 'sendMediaGroup', 'sendChatAction', 'getChat', 'getChatMember', 'getFile', 'deleteMessage'];
54
+
55
+ // ── the bot ─────────────────────────────────────────────────────────────────────────────────────────────────────
56
+
57
+ /** The token in a request's path: `/bot<token>/<method>`, or `/file/bot<token>/<path>` for a file. */
58
+ export function tokenOf(request: Request): string | undefined {
59
+ const m = /^\/(?:file\/)?bot([^/]*)\//.exec(new URL(request.url).pathname);
60
+ return m ? decodeURIComponent(m[1]!) : undefined;
61
+ }
62
+
63
+ /** The bot a well-formed token names when its secret is the one BotFather last issued (a token made again revokes
64
+ * the one before), or why not. */
65
+ export function botOfToken(ctx: HandlerContext, token: string | undefined): { bot: Row; user: Row } | 'invalid' | 'unknown' {
66
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/ClientManager.cpp "if (token[0] == '0' || token.size() > 80u || token.find('/') != td::string::npos ||"
67
+ if (!token || token[0] === '0' || token.length > 80 || token.includes('/') || !token.includes(':')) return 'invalid';
68
+ const id = token.slice(0, token.indexOf(':'));
69
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/ClientManager.cpp "auto r_user_id = td::to_integer_safe<td::int64>(query->token().substr(0, token.find(':')));"
70
+ if (!/^\d+$/.test(id)) return 'invalid';
71
+ const bot = ctx.row('_bot', id);
72
+ const user = ctx.row('user', id);
73
+ if (!bot || !user || bot.token_sha256 !== ctx.crypto.sha256(token)) return 'unknown';
74
+ return { bot, user };
75
+ }
76
+
77
+ /** The bot the request's token names (around.ts has refused any other before the method ran). */
78
+ export function botOf(ctx: HandlerContext): { id: number; bot: Row; user: Row } {
79
+ const found = botOfToken(ctx, tokenOf(ctx.call.request));
80
+ if (typeof found === 'string') throw new Error('telegram: a method ran without its bot');
81
+ return { id: Number(found.bot.id), ...found };
82
+ }
83
+
84
+ /** A bot's token: its user id and a secret drawn from the World's seed for this bot and this issue of it. */
85
+ export async function issueToken(ctx: HandlerContext, botId: string, generation: number): Promise<string> {
86
+ // source: https://core.telegram.org/bots/features#botfather "The token is a string, like 110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw"
87
+ const secret = (await ctx.secret(`telegram-bot-token:${botId}:${generation}`)).replace(/[^A-Za-z0-9_-]/g, '');
88
+ return `${botId}:AA${secret.slice(0, 33)}`;
89
+ }
90
+
91
+ // ── objects as the Bot API answers them ──────────────────────────────────────────────────────────────────────────
92
+
93
+ /** A User as it appears in a message or an update: the bot-only fields are getMe's alone. */
94
+ // source: https://core.telegram.org/bots/api#user "Optional. True, if the bot can be invited to groups. Returned only in getMe."
95
+ export function userView(ctx: HandlerContext, id: unknown): Row {
96
+ const row = ctx.row('user', String(id));
97
+ if (!row) return { id: Number(id), is_bot: false, first_name: '' };
98
+ const v = vendorFields(ctx, row);
99
+ return Object.fromEntries(Object.entries(v).filter(([k]) => ['id', 'is_bot', 'first_name', 'last_name', 'username', 'language_code', 'is_premium'].includes(k)));
100
+ }
101
+
102
+ /** A Chat as a message names it: its id, type and names. */
103
+ export function chatView(ctx: HandlerContext, id: unknown): Row {
104
+ const row = ctx.row('chat', String(id));
105
+ const v = row ? vendorFields(ctx, row) : { id: Number(id), type: 'private' };
106
+ return Object.fromEntries(Object.entries(v).filter(([k]) => ['id', 'type', 'title', 'username', 'first_name', 'last_name', 'is_forum', 'is_direct_messages'].includes(k)));
107
+ }
108
+
109
+ /** A ChatMember: its status, its user and what the status carries. */
110
+ export function memberView(ctx: HandlerContext, row: Row | undefined, user: unknown): Row {
111
+ const status = String(row?.status ?? 'left');
112
+ const fields = row ? vendorFields(ctx, row) : {};
113
+ const rest = Object.fromEntries(Object.entries(fields).filter(([k]) => k !== 'status'));
114
+ // source: https://core.telegram.org/bots/api#chatmemberbanned "Date when restrictions will be lifted for this user; Unix time. If 0, then the user is banned forever"
115
+ if (status === 'kicked') return { status, user: userView(ctx, user), until_date: Number(rest.until_date ?? 0) };
116
+ if (status === 'left') return { status, user: userView(ctx, user) };
117
+ if (status === 'member') return { status, user: userView(ctx, user) };
118
+ return { status, user: userView(ctx, user), ...rest };
119
+ }
120
+
121
+ /** A chat member's row (`<chat>:<user>`). */
122
+ export const memberKey = (chat: unknown, user: unknown): string => `${chat}:${user}`;
123
+
124
+ /** A Message as the Bot API answers it, its user, chat and replied message read as they stand. A replied message
125
+ * carries no reply of its own. */
126
+ // source: https://core.telegram.org/bots/api#message "Note that the Message object in this field will not contain further reply_to_message fields even if it itself is a reply."
127
+ export function messageView(ctx: HandlerContext, row: Row, nested = false): Row {
128
+ const v = vendorFields(ctx, row);
129
+ const out: Row = { message_id: v.message_id };
130
+ if (row._from !== undefined) out.from = userView(ctx, row._from);
131
+ if (row._sender_chat !== undefined) out.sender_chat = chatView(ctx, row._sender_chat);
132
+ out.chat = chatView(ctx, row._chat);
133
+ out.date = v.date;
134
+ if (!nested && typeof row._reply_to === 'string') {
135
+ const replied = ctx.row('message', row._reply_to);
136
+ if (replied && replied.deleted !== true) out.reply_to_message = messageView(ctx, replied, true);
137
+ }
138
+ for (const [k, value] of Object.entries(v)) if (!(k in out)) out[k] = value;
139
+ if (Array.isArray(row._new_members)) out.new_chat_members = row._new_members.map((u) => userView(ctx, u));
140
+ return out;
141
+ }
142
+
143
+ /** A ChatFullInfo as getChat answers it, from a chat as chatOf reads it (its own id and type already restored). */
144
+ export function chatFullView(_ctx: HandlerContext, chat: Row): Row {
145
+ return Object.fromEntries(Object.entries(chat).filter(([k]) => !k.startsWith('_') && k !== 'deleted'));
146
+ }
147
+
148
+ // ── a chat's access ─────────────────────────────────────────────────────────────────────────────────────────────
149
+
150
+ /** The chat a chat_id names: an integer id or a public chat's @username. */
151
+ export function findChat(ctx: HandlerContext, chatId: unknown): Row | undefined {
152
+ if (typeof chatId === 'string' && chatId.startsWith('@')) {
153
+ const name = chatId.slice(1).toLowerCase();
154
+ const row = ctx.rowsRaw('chat').find((r) => r.deleted !== true && typeof r.username === 'string' && r.username.toLowerCase() === name);
155
+ return row ? ctx.own(row) : undefined;
156
+ }
157
+ const id = typeof chatId === 'number' ? chatId : typeof chatId === 'string' && /^-?\d+$/.test(chatId.trim()) ? Number(chatId.trim()) : undefined;
158
+ return id === undefined ? undefined : chatOf(ctx, id);
159
+ }
160
+
161
+ /** A chat as stored, its own `id` and `type` (the vendor's, which the kernel keeps apart from its own) restored. */
162
+ export function chatOf(ctx: HandlerContext, key: unknown): Row | undefined {
163
+ const row = ctx.row('chat', String(key));
164
+ return row && row.deleted !== true ? ctx.own(row) : undefined;
165
+ }
166
+
167
+ /** The chat a bot reaches with chat_id, and its own membership there, or the server's refusal: a read needs the chat
168
+ * known to the bot; a write in a group, a supergroup or a channel needs the bot a member, in a channel an
169
+ * administrator that may post, and in a private chat the person having started the bot. */
170
+ export function chatFor(ctx: HandlerContext, botId: number, chatId: unknown, need: 'read' | 'write'): { chat: Row; member: Row | undefined } | { refusal: Response } {
171
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Bad Request: chat not found"
172
+ const notFound = { refusal: refuse(ctx, 400, 'Bad Request: chat not found') };
173
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Bad Request: chat_id is empty"
174
+ if (chatId === undefined) return { refusal: refuse(ctx, 400, 'Bad Request: chat_id is empty') };
175
+ const chat = findChat(ctx, chatId);
176
+ if (!chat) return notFound;
177
+ const member = ctx.row('chat_member', memberKey(chat.id, botId));
178
+ const status = member?.deleted === true ? undefined : (member?.status as string | undefined);
179
+ const type = String(chat.type);
180
+ if (type === 'private') {
181
+ // a bot knows a person's private chat only once the person has written to it (pressed Start)
182
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessagesManager.cpp "Bot can't initiate conversation with a user"
183
+ if (!status) return notFound;
184
+ return { chat, member };
185
+ }
186
+ if (!status && !chat.username) return notFound;
187
+ if (type === 'group') {
188
+ if (need === 'write' && status === 'kicked') {
189
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Forbidden: bot was kicked from the group chat"
190
+ return { refusal: refuse(ctx, 403, 'Forbidden: bot was kicked from the group chat') };
191
+ }
192
+ // (a bot that left a group by itself, "bot is not a member of the group chat", is not in this pack's scope: leaveChat
193
+ // is not served and the doors make no member leave)
194
+ return { chat, member };
195
+ }
196
+ const kind = type === 'channel' ? 'channel' : 'supergroup';
197
+ if (status === 'kicked') {
198
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Forbidden: bot was kicked from the channel chat"
199
+ return { refusal: refuse(ctx, 403, `Forbidden: bot was kicked from the ${kind} chat`) };
200
+ }
201
+ // a public chat is read by anyone; a write, or any use of a private one, needs the bot in it
202
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "bool need_more_access_rights = is_public ? need_edit_access : need_read_access;"
203
+ if ((!status || status === 'left') && (need === 'write' || !chat.username)) {
204
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Forbidden: bot is not a member of the channel chat"
205
+ return { refusal: refuse(ctx, 403, `Forbidden: bot is not a member of the ${kind} chat`) };
206
+ }
207
+ if (need === 'write' && type === 'channel' && !(status === 'creator' || (status === 'administrator' && member?.can_post_messages === true))) {
208
+ // source: https://core.telegram.org/bots/api#chatmemberadministrator "True , if the administrator can post messages in the channel"
209
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/DialogManager.cpp "Need administrator rights in the channel chat"
210
+ return { refusal: refuse(ctx, 400, 'Bad Request: need administrator rights in the channel chat') };
211
+ }
212
+ return { chat, member };
213
+ }
214
+
215
+ // ── a message's text ────────────────────────────────────────────────────────────────────────────────────────────
216
+
217
+ /** A text or a caption as the server keeps it: parsed by its parse_mode (HTML, MarkdownV2 or Markdown, named in any
218
+ * case; plain when none), with the entities the server finds itself, trimmed; or the server's refusal. */
219
+ export function formatted(ctx: HandlerContext, text: string, parseMode: unknown, entitiesParam: unknown): Formatted | { refusal: Response } {
220
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Text is too long"
221
+ if (new TextEncoder().encode(text).length > 1 << 15) return { refusal: refuse(ctx, 400, 'Bad Request: text is too long') };
222
+ const mode = typeof parseMode === 'string' ? parseMode.toLowerCase() : '';
223
+ let parsed: Formatted;
224
+ if (mode === '' || mode === 'none') {
225
+ const given = Array.isArray(entitiesParam) ? (entitiesParam as Entity[]) : [];
226
+ parsed = { text, entities: given };
227
+ } else if (mode === 'html' || mode === 'markdownv2' || mode === 'markdown') {
228
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "text_parse_mode = make_object<td_api::textParseModeMarkdown>(2);"
229
+ // source: https://core.telegram.org/bots/api#markdownv2-style "To use this mode, pass MarkdownV2 in the parse_mode field."
230
+ // source: https://core.telegram.org/bots/api#markdown-style "This is a legacy mode, retained for backward compatibility."
231
+ const r = mode === 'html' ? parseHtml(text) : mode === 'markdownv2' ? parseMarkdownV2(text) : parseMarkdown(text);
232
+ // the parser's words, as the Bot API relays them
233
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessageEntity.cpp "Unsupported start tag"
234
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessageEntity.cpp "is reserved and must be escaped with the preceding"
235
+ if ('error' in r) return { refusal: refuse(ctx, 400, `Bad Request: can't parse entities: ${r.error}`) };
236
+ parsed = r;
237
+ } else {
238
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Unsupported parse_mode"
239
+ return { refusal: refuse(ctx, 400, 'Bad Request: unsupported parse_mode') };
240
+ }
241
+ // a bot's text gets the entities the server finds (TDLib's fix_formatted_text: a bot finds new entities)
242
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessageEntity.cpp "add_entities(MessageEntity::Type::Mention, find_mentions);"
243
+ const done = finish(parsed, { find: true, skipBotCommands: false });
244
+ const entities: Entity[] = [];
245
+ for (const e of done.entities) {
246
+ // a custom emoji is kept only for a bot with a username bought on Fragment, or one whose owner has Premium; no bot of
247
+ // a World has either, so its emoji shows as the text it stands on
248
+ // source: https://core.telegram.org/bots/api#formatting-options "Custom emoji entities can only be used by bots that purchased additional usernames on Fragment or in the messages directly sent by the bot to private, group and supergroup chats if the owner of the bot has a Telegram Premium subscription."
249
+ if (e.type === 'custom_emoji') continue;
250
+ if (e.type === 'text_mention') {
251
+ // a mention by id names a user the World holds; one it does not know is no mention
252
+ // source: https://core.telegram.org/bots/api#formatting-options "Links tg://user?id=<user_id> can be used to mention a user by their identifier without using a username."
253
+ const user = ctx.row('user', String(e.user_id));
254
+ if (!user) continue;
255
+ entities.push({ type: 'text_mention', offset: e.offset, length: e.length, user: userView(ctx, e.user_id) } as unknown as Entity);
256
+ continue;
257
+ }
258
+ entities.push(e);
259
+ }
260
+ return { text: done.text, entities };
261
+ }
262
+
263
+ /** A message's text, its entities only where it has some. */
264
+ export const textFields = (f: Formatted, as: 'text' | 'caption'): Row =>
265
+ f.text === '' ? {} : { [as]: f.text, ...(f.entities.length ? { [as === 'text' ? 'entities' : 'caption_entities']: f.entities } : {}) };
266
+
267
+ // ── messages ────────────────────────────────────────────────────────────────────────────────────────────────────
268
+
269
+ /** The next message_id of a chat: one past the last it holds. */
270
+ // source: https://core.telegram.org/bots/api#message "Unique message identifier inside this chat;"
271
+ export async function nextMessageId(ctx: HandlerContext, chatKey: string, operation: string): Promise<number> {
272
+ const row = await ctx.change('chat', chatKey, (current) => ({ _last_message_id: Number(current._last_message_id ?? 0) + 1 }), operation);
273
+ return Number((row as Row | undefined)?._last_message_id ?? ctx.row('chat', chatKey)?._last_message_id);
274
+ }
275
+
276
+ /** The message a reply names in this chat, or the server's refusal; `allow` sends without it when it is gone. */
277
+ export function replyTarget(ctx: HandlerContext, chat: Row, botId: number): { key?: string } | { refusal: Response } {
278
+ const params = jsonParam(ctx, 'reply_parameters');
279
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Can't parse reply parameters JSON object"
280
+ if (params.error) return { refusal: refuse(ctx, 400, "Bad Request: can't parse reply parameters JSON object") };
281
+ const given = params.value as Row | undefined;
282
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "result.reply_to_message_id = get_message_id(query,"
283
+ const messageId = given ? Number(given.message_id ?? 0) : intParam(ctx, 'reply_to_message_id') ?? 0;
284
+ const allow = given ? given.allow_sending_without_reply === true : ctx.flag(param(ctx, 'allow_sending_without_reply')) === true;
285
+ if (!(messageId > 0)) return {};
286
+ const key = `${chat.id}:${messageId}`;
287
+ const target = ctx.row('message', key);
288
+ const visible = target && target.deleted !== true && (chat.type !== 'private' || Number(target._with) === botId);
289
+ if (visible) return { key };
290
+ if (allow) return {};
291
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "message to be replied not found"
292
+ return { refusal: refuse(ctx, 400, 'Bad Request: message to be replied not found') };
293
+ }
294
+
295
+ /** A bot's message stored in a chat (it writes no update for its sender: a bot is not sent its own messages), and
296
+ * the bot kept as the last that wrote in a group (privacy mode reads it). */
297
+ export async function storeBotMessage(ctx: HandlerContext, botId: number, chat: Row, fields: Row, replyKey: string | undefined, operation: string): Promise<Row> {
298
+ const chatKey = String(chat.id);
299
+ const messageId = await nextMessageId(ctx, chatKey, operation);
300
+ const key = `${chatKey}:${messageId}`;
301
+ const channel = chat.type === 'channel';
302
+ // a channel's post names the channel as its sender, not the bot
303
+ // source: https://core.telegram.org/bots/api#message "Optional. Sender of the message; may be empty for messages sent to channels."
304
+ await ctx.write('message', key, {
305
+ message_id: messageId,
306
+ date: unixOf(ctx.occurredAt),
307
+ ...fields,
308
+ _chat: chat.id,
309
+ ...(channel ? { _sender_chat: chat.id } : { _from: botId }),
310
+ _sent_by: botId,
311
+ ...(chat.type === 'private' ? { _with: botId } : {}),
312
+ ...(replyKey ? { _reply_to: replyKey } : {}),
313
+ }, operation);
314
+ if (chat.type === 'group' || chat.type === 'supergroup') await ctx.write('chat', chatKey, { _last_bot: botId }, operation);
315
+ return ctx.row('message', key)!;
316
+ }
317
+
318
+ export const unixOf = (iso: string): number => Math.floor(Date.parse(iso) / 1000);
319
+
320
+ // ── files ───────────────────────────────────────────────────────────────────────────────────────────────────────
321
+
322
+ /** A file kept: its bytes as a blob, its row naming them; a file_id that leads with the kind of file it is. */
323
+ export async function storeFile(ctx: HandlerContext, bytes: Uint8Array, meta: { kind: string; mime?: string; name?: string; bot?: number }, operation: string): Promise<Row> {
324
+ const prefix: Record<string, string> = { photos: 'AgACAgIAAxkBAA', videos: 'BAACAgIAAxkBAA', documents: 'BQACAgIAAxkBAA', profile_photos: 'AQADAgAT' };
325
+ const row = await ctx.create('file', (id) => ({
326
+ file_id: `${prefix[meta.kind] ?? 'BQACAgIAAxkBAA'}${id.slice(4)}`,
327
+ file_unique_id: id,
328
+ file_size: bytes.byteLength,
329
+ _blob: `file:${id}`,
330
+ _kind: meta.kind,
331
+ ...(meta.mime ? { _mime: meta.mime } : {}),
332
+ ...(meta.name ? { _name: meta.name } : {}),
333
+ ...(meta.bot !== undefined ? { _bot: meta.bot } : {}),
334
+ }), operation);
335
+ const id = String((row as Row).file_unique_id);
336
+ await ctx.blobs.put(`file:${id}`, bytes);
337
+ // the row as kept, its own `_` fields with it (the create answers the vendor's view)
338
+ return ctx.row('file', id)!;
339
+ }
340
+
341
+ /** A file by its file_id, as one bot holds it: a file a bot uploaded is that bot's alone (a chat's photo is every
342
+ * bot's that reads the chat). */
343
+ // source: https://core.telegram.org/bots/api#sending-files "file_id is unique for each individual bot and can't be transferred from one bot to another."
344
+ export function fileById(ctx: HandlerContext, fileId: unknown, botId: number): Row | undefined {
345
+ if (typeof fileId !== 'string' || !fileId) return undefined;
346
+ return ctx.rowsRaw('file').find((r) => r.deleted !== true && r.file_id === fileId && (r._bot === undefined || Number(r._bot) === botId));
347
+ }
348
+
349
+ /** An upload's part as the request carries it: multipart bytes, the name `attach://<name>` points at, a file_id sent
350
+ * again, or an HTTP URL; or the server's refusal. */
351
+ export type Upload = { bytes: Uint8Array; name?: string; mime?: string } | { file: Row };
352
+ export function uploadOf(ctx: HandlerContext, value: unknown, botId: number): Upload | { refusal: Response } {
353
+ const part = (v: unknown): { bytes: Uint8Array; name?: string; mime?: string } | undefined => {
354
+ const p = v as { name?: string; type?: string; bytes?: Uint8Array } | undefined;
355
+ return p && typeof p === 'object' && p.bytes instanceof Uint8Array ? { bytes: p.bytes, ...(p.name ? { name: p.name } : {}), ...(p.type ? { mime: p.type } : {}) } : undefined;
356
+ };
357
+ const direct = part(value);
358
+ if (direct) return direct;
359
+ const text = typeof value === 'string' ? value : '';
360
+ const attached = text.startsWith('attach://') ? part(ctx.params[text.slice(9)]) : undefined;
361
+ if (attached) return attached;
362
+ if (/^https?:\/\//i.test(text)) return urlUnreachable(ctx);
363
+ const file = fileById(ctx, text, botId);
364
+ if (file) return { file };
365
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessagesManager.cpp "Wrong file identifier/HTTP URL specified"
366
+ return { refusal: refuse(ctx, 400, 'Bad Request: wrong file identifier/HTTP URL specified') };
367
+ }
368
+
369
+ /** A medium sent by an HTTP URL, which Telegram downloads: a World lets the twin reach nothing outside it, so it answers
370
+ * as Telegram does for a URL it could not get. */
371
+ function urlUnreachable(ctx: HandlerContext): { refusal: Response } {
372
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Failed to get HTTP URL content"
373
+ return { refusal: refuse(ctx, 400, 'Bad Request: failed to get HTTP URL content') };
374
+ }
375
+
376
+ /** A file's bytes. */
377
+ export async function fileBytes(ctx: HandlerContext, file: Row): Promise<Uint8Array> {
378
+ return (await ctx.blobs.get(String(file._blob))) ?? new Uint8Array();
379
+ }
380
+
381
+ // ── updates ─────────────────────────────────────────────────────────────────────────────────────────────────────
382
+
383
+ // source: https://core.telegram.org/bots/api#getupdates "Specify an empty list to receive all update types except chat_member, message_reaction, and message_reaction_count (default)."
384
+ const NOT_BY_DEFAULT = ['chat_member', 'message_reaction', 'message_reaction_count'];
385
+
386
+ /** An update put in a bot's queue, when the allowed_updates the bot last asked for take its type: a setting does not
387
+ * reach the updates made before it, so a type is decided when its update is made. */
388
+ // source: https://core.telegram.org/bots/api#getupdates "Please note that this parameter doesn't affect updates created before the call to getUpdates, so unwanted updates may be received for a short period of time."
389
+ export async function queueUpdate(ctx: HandlerContext, botId: unknown, type: string, payload: Row, operation: string): Promise<void> {
390
+ const bot = ctx.row('_bot', String(botId));
391
+ if (!bot) return;
392
+ const allowed = Array.isArray(bot.allowed_updates) && bot.allowed_updates.length ? (bot.allowed_updates as string[]) : undefined;
393
+ if (allowed ? !allowed.includes(type) : NOT_BY_DEFAULT.includes(type)) return;
394
+ // source: https://core.telegram.org/bots/api#update "Update identifiers start from a certain positive number and increase sequentially."
395
+ const next = await ctx.change('_bot', String(botId), (current) => ({ update_seq: Number(current.update_seq ?? 0) + 1 }), operation);
396
+ const updateId = Number((next as Row | undefined)?.update_seq ?? ctx.row('_bot', String(botId))?.update_seq);
397
+ await ctx.write('update', `${botId}:${updateId}`, { update_id: updateId, [type]: payload, _bot: Number(botId), _type: type, _made: ctx.occurredAt }, operation);
398
+ }
399
+
400
+ /** The bots a person's message in a chat reaches, as privacy mode lets each: in a private chat its bot; in a channel
401
+ * every bot member; in a group an administrator bot every message, any other the service messages, the commands
402
+ * meant for it and the replies to it. */
403
+ export function recipientsOf(ctx: HandlerContext, chat: Row, message: Row): number[] {
404
+ if (chat.type === 'private') return message._with !== undefined ? [Number(message._with)] : [];
405
+ const bots = ctx.rowsRaw('chat_member').filter((m) => m.deleted !== true && Number(m._chat) === Number(chat.id) && ['creator', 'administrator', 'member'].includes(String(m.status)) && ctx.row('user', String(m._user))?.is_bot === true);
406
+ // source: https://core.telegram.org/bots/features#privacy-mode "All messages from channels where they are a member."
407
+ if (chat.type === 'channel') return bots.map((m) => Number(m._user));
408
+ const text = typeof message.text === 'string' ? message.text : typeof message.caption === 'string' ? message.caption : '';
409
+ const entities = ((message.entities ?? message.caption_entities ?? []) as Entity[]);
410
+ const service = Array.isArray(message._new_members) || message.group_chat_created === true || message.left_chat_member !== undefined;
411
+ return bots.filter((m) => {
412
+ const bot = ctx.row('user', String(m._user))!;
413
+ const username = String(bot.username ?? '').toLowerCase();
414
+ // source: https://core.telegram.org/bots/features#privacy-mode "Privacy mode is enabled by default for all bots, except bots that were added to a group as admins (bot admins always receive all messages )."
415
+ if (m.status === 'administrator' || bot.can_read_all_group_messages === true) return true;
416
+ // source: https://core.telegram.org/bots/features#privacy-mode "All service messages."
417
+ if (service) return true;
418
+ const pieces = entities.map((e) => ({ type: e.type, text: text.slice(e.offset, e.offset + e.length).toLowerCase() }));
419
+ // source: https://core.telegram.org/bots/features#privacy-mode "Commands explicitly meant for them (e.g., /command@this_bot )."
420
+ if (pieces.some((p) => p.type === 'bot_command' && p.text.endsWith(`@${username}`))) return true;
421
+ // source: https://core.telegram.org/bots/features#privacy-mode "General commands (e.g. /start ) if the bot was the last bot to send a message to the group."
422
+ if (pieces.some((p) => p.type === 'bot_command' && !p.text.includes('@')) && Number(chat._last_bot) === Number(m._user)) return true;
423
+ // source: https://core.telegram.org/bots/features#privacy-mode "Replies to any messages implicitly or explicitly meant for this bot."
424
+ if (typeof message._reply_to === 'string' && Number(ctx.row('message', message._reply_to)?._sent_by) === Number(m._user)) return true;
425
+ // a plain @mention is on neither list: a bot in privacy mode is not sent it
426
+ return false;
427
+ }).map((m) => Number(m._user));
428
+ }
@@ -0,0 +1,38 @@
1
+ // Telegram's state machines (architecture, "State"). One field the demand reaches moves: a chat member's `status`,
2
+ // which people move in the Telegram app (the World's doors stand in for it: a chat made, a member added, promoted or
3
+ // removed). A bot's API moves none of it in this pack's scope (promoteChatMember, banChatMember, leaveChat are not
4
+ // served). A message is made and deleted, an update queued and confirmed (deleted), a file stored: none has a state.
5
+ const MEMBER = 'https://core.telegram.org/bots/api#chatmember';
6
+ const MEMBER_BANNED = 'https://core.telegram.org/bots/api#chatmemberbanned';
7
+
8
+ export const states = {
9
+ chat_member: {
10
+ state: {
11
+ // source: https://core.telegram.org/bots/api#chatmember "Currently, the following 6 types of chat members are supported"
12
+ status: {
13
+ // a person or bot never in the chat answers as one who left: "isn't currently a member of the chat"
14
+ initial: 'left',
15
+ transitions: [
16
+ // the person who makes a chat is its owner
17
+ // source: https://core.telegram.org/bots/api#chatmemberowner "Represents a chat member that owns the chat and has all administrator privileges."
18
+ { actor: 'external' as const, from: ['left'], to: 'creator', source: `${MEMBER} "ChatMemberOwner"` },
19
+ // a member added by a person in the app (or one who joins a public chat)
20
+ // source: https://core.telegram.org/bots/api#chatmembermember "Represents a chat member that has no additional privileges or restrictions."
21
+ { actor: 'external' as const, from: ['left', 'kicked'], to: 'member', source: `${MEMBER} "ChatMemberMember"` },
22
+ // added as an administrator, or promoted
23
+ // source: https://core.telegram.org/bots/api#chatmemberadministrator "Represents a chat member that has some additional privileges."
24
+ { actor: 'external' as const, from: ['left', 'member', 'kicked'], to: 'administrator', source: `${MEMBER} "ChatMemberAdministrator"` },
25
+ // removed by an administrator: banned, it cannot come back by itself
26
+ // source: https://core.telegram.org/bots/api#chatmemberbanned "Represents a chat member that was banned in the chat and can't return to the chat or view chat messages."
27
+ { actor: 'external' as const, from: ['member', 'administrator'], to: 'kicked', source: `${MEMBER_BANNED} "can't return to the chat"` },
28
+ ],
29
+ // Not declared, as no step asks for them: an administrator demoted to a member, and a member leaving by itself
30
+ // (left: "isn't currently a member of the chat, but may join it themselves", https://core.telegram.org/bots/api#chatmemberleft);
31
+ // the doors do not offer them, and leaveChat and promoteChatMember are not served.
32
+ },
33
+ },
34
+ },
35
+ // a chat's `type` (private, group, supergroup, channel) is its kind, fixed when it is made
36
+ // source: https://core.telegram.org/bots/api#chat "Type of the chat, can be either"
37
+ chat: { notState: ['type'] },
38
+ };
@@ -0,0 +1,41 @@
1
+ // Updating messages (https://core.telegram.org/bots/api#updating-messages) the demand reaches: a message deleted.
2
+ import type { HandlerContext } from '@volter/world-core';
3
+ import { answer, botOf, chatFor, intParam, param, refuse, unixOf } from './shared.ts';
4
+
5
+ // source: https://core.telegram.org/bots/api#deletemessage "Use this method to delete a message, including service messages, with the following limitations:"
6
+ export async function deleteMessage(ctx: HandlerContext): Promise<Response> {
7
+ const { id: botId } = botOf(ctx);
8
+ const access = chatFor(ctx, botId, param(ctx, 'chat_id'), 'write');
9
+ if ('refusal' in access) return access.refusal;
10
+ const { chat, member } = access;
11
+ const messageId = intParam(ctx, 'message_id') ?? 0;
12
+ const key = `${chat.id}:${messageId}`;
13
+ const message = messageId > 0 ? ctx.row('message', key) : undefined;
14
+ // a private chat's messages with another bot are not this bot's to see
15
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "message to delete"
16
+ // (the server names the message type it looked for, followed by "not found")
17
+ if (!message || message.deleted === true || (chat.type === 'private' && Number(message._with) !== botId)) return refuse(ctx, 400, 'Bad Request: message to delete not found');
18
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "message can't be deleted"
19
+ const cannot = (): Response => refuse(ctx, 400, "Bad Request: message can't be deleted");
20
+ // source: https://core.telegram.org/bots/api#deletemessage "A message can only be deleted if it was sent less than 48 hours ago."
21
+ if (unixOf(ctx.occurredAt) - Number(message.date) >= 48 * 60 * 60) return cannot();
22
+ // source: https://core.telegram.org/bots/api#deletemessage "Service messages about a supergroup, channel, or forum topic creation can't be deleted."
23
+ if (message.channel_chat_created === true || message.supergroup_chat_created === true) return cannot();
24
+ const outgoing = Number(message._sent_by) === botId;
25
+ const status = String(member?.status ?? '');
26
+ const admin = status === 'creator' || status === 'administrator';
27
+ let allowed = false;
28
+ // source: https://core.telegram.org/bots/api#deletemessage "Bots can delete outgoing messages in private chats, groups, and supergroups."
29
+ if (outgoing && chat.type !== 'channel') allowed = true;
30
+ // source: https://core.telegram.org/bots/api#deletemessage "Bots can delete incoming messages in private chats."
31
+ if (chat.type === 'private') allowed = true;
32
+ // source: https://core.telegram.org/bots/api#deletemessage "Bots granted can_post_messages permissions can delete outgoing messages in channels."
33
+ if (outgoing && chat.type === 'channel' && (status === 'creator' || member?.can_post_messages === true)) allowed = true;
34
+ // source: https://core.telegram.org/bots/api#deletemessage "If the bot is an administrator of a group, it can delete any message there."
35
+ if (chat.type === 'group' && admin) allowed = true;
36
+ // source: https://core.telegram.org/bots/api#deletemessage "If the bot has can_delete_messages administrator right in a supergroup or a channel, it can delete any message there."
37
+ if ((chat.type === 'supergroup' || chat.type === 'channel') && (status === 'creator' || member?.can_delete_messages === true)) allowed = true;
38
+ if (!allowed) return cannot();
39
+ await ctx.remove('message', key, ctx.call.operation.id);
40
+ return answer(true);
41
+ }
package/src/server.ts ADDED
@@ -0,0 +1,17 @@
1
+ // FETCH-FIRST (runtime contract R12b): a fixed file, the kernel's serve seam around the pack's fetch (./fetch.ts).
2
+ import { grpc, serveHttp, type PackFetchOptions } from '@volter/world-core';
3
+ import { createTelegramFetch } from './fetch.ts';
4
+ import { manifest } from './manifest.ts';
5
+
6
+ export async function createTelegramTwinServer(options: PackFetchOptions & { port?: number } = {}): Promise<{ port: number; stop: () => void }> {
7
+ const fetch = createTelegramFetch(options);
8
+ // a vendor that speaks gRPC (its manifest's `grpc`): HTTP/2 and HTTP/1 on one port, each rpc through the fetch
9
+ if ('grpc' in manifest && manifest.grpc) {
10
+ const schemaPath = './generated/proto.gen.json';
11
+ const schema = (await import(schemaPath, { with: { type: 'json' } })).default as unknown as Parameters<typeof grpc.serveGrpc>[0]['schema'];
12
+ return grpc.serveGrpc({ ...(options.port ? { port: options.port } : {}), fetch: async (r) => fetch(r), schema });
13
+ }
14
+ // the vendor's sockets, when its manifest declares any (the kernel's sockets.ts)
15
+ const server = await serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch });
16
+ return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
17
+ }