@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 @@
1
+ {"generatedBy":"scripts/derive-pack.ts","resources":{"user":{"actions":[],"states":{},"embeds":{}},"chat":{"actions":[],"states":{},"embeds":{}},"chat_member":{"actions":[],"states":{"status":["administrator","creator","kicked","left","member"]},"embeds":{}},"message":{"actions":[],"states":{},"embeds":{}},"update":{"actions":[],"states":{},"embeds":{}},"file":{"actions":[],"states":{},"embeds":{}},"_bot":{"actions":[],"states":{},"embeds":{}},"_group":{"actions":[],"states":{},"embeds":{}},"_channel":{"actions":[],"states":{},"embeds":{}},"_file_path":{"actions":[],"states":{},"embeds":{}},"_media_group":{"actions":[],"states":{},"embeds":{}}}}
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // @volter/twin-telegram — the telegram twin (Protocol 3). A fixed file (create-pack): the descriptor is the manifest's, and the
2
+ // kernel derives the vendor-backed half from the generated surface (architecture, "The real-system adapters").
3
+ import { packOf, registerPack, type DerivedSurface } from '@volter/world-core';
4
+ import { manifest } from './manifest.ts';
5
+ import surface from './generated/surface.gen.json' with { type: 'json' };
6
+
7
+ export { createTelegramFetch } from './fetch.ts';
8
+ export { createTelegramTwinServer } from './server.ts';
9
+ export { manifest };
10
+
11
+ export const pack = registerPack(packOf(manifest, surface as unknown as DerivedSurface));
@@ -0,0 +1,114 @@
1
+ // Telegram's manifest: the vendor facts its spec (../spec, written from the Bot API page) does not carry. Served is what
2
+ // the demand's applications call as they ship and what the life reaches (../journeys/demand.json,
3
+ // ../journeys/decisions.json): a bot reading itself, removing its webhook and polling for updates (a long poll), showing
4
+ // it is typing, sending text, a photo, a video, a document and an album, deleting a message, reading a chat, a chat
5
+ // member and a file, and the file's download. Every other method of the page answers the vendor's unknown method. Every
6
+ // request names its bot by the token in its path (`/bot<token>/<method>`), checked by ./semantics/around.ts before the
7
+ // method is routed, as the Bot API server checks it. People, chats and bots are made in the Telegram app and BotFather,
8
+ // which no Bot API method does: the World's doors stand in for them (./semantics/doors.ts).
9
+ import type { DerivedManifest } from '@volter/world-core';
10
+ import { states } from './semantics/states.ts';
11
+
12
+ export const manifest: DerivedManifest = {
13
+ vendor: 'telegram',
14
+ service: 'telegram',
15
+ // the four ways the Bot API takes parameters: query, form, JSON and multipart, each read as its content type says
16
+ // source: https://core.telegram.org/bots/api#making-requests "We support four ways of passing parameters in Bot API requests"
17
+ body: {},
18
+ // ids are integers the vendor assigns (a user's, a chat's, a message's per chat); each resource below says its form
19
+ ids: { template: '{prefix}{n}' },
20
+ // source: https://core.telegram.org/bots/api#message "Date the message was sent in Unix time"
21
+ time: 'unix',
22
+ // source: https://core.telegram.org/bots/api#making-requests "In case of an unsuccessful request, 'ok' equals False and the error is explained in the 'description'."
23
+ error: { ok: false, error_code: '{status}', description: '{message}' },
24
+ // the Bot API has no read-only mode; a World started read-only refuses a write with the class of refusal the server
25
+ // gives an act it does not allow (Where the documentation stops: the words are the twin's)
26
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "error_code = 403;"
27
+ readOnly: { status: 403, message: 'Forbidden: this World was started read-only' },
28
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/HttpConnection.cpp "return send_http_error(404,"
29
+ notFound: { status: 404, message: 'Not Found' },
30
+ // a method the twin does not serve answers as an unknown method does
31
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Not Found: method not found"
32
+ gap: { status: 404, message: 'Not Found: method not found' },
33
+ // the Bot API pages nothing; getUpdates' limit is the one bounded read
34
+ // source: https://core.telegram.org/bots/api#getupdates "Values between 1-100 are accepted. Defaults to 100."
35
+ list: { style: 'envelope', envelope: { ok: true, result: '{data}' }, limit: { param: 'limit', default: 100, max: 100 } },
36
+ deleted: { ok: true, result: true },
37
+ // every method this pack serves is a handler (an RPC wire: no create, read, update or delete of a path), and no
38
+ // declared resource has an operation the core would serve, so nothing is listed unmodeled
39
+ unmodeled: [],
40
+ // getUpdates, getMe, getChat, getChatMember and getFile only read, though asked by POST
41
+ reads: ['getMe', 'getChat', 'getChatMember', 'getFile'],
42
+ // a file's path holds its folder: profile_photos/file_1.jpg
43
+ // source: https://core.telegram.org/bots/api#file "Optional. File path. Use https://api.telegram.org/file/bot<token>/<file_path> to get the file."
44
+ spanning: ['file_path'],
45
+ resources: {
46
+ // a Telegram account, a person's or a bot's, stored as the Bot API answers a User; its id is an integer the vendor
47
+ // assigns, kept as the subject's id and as its own `id`
48
+ // source: https://core.telegram.org/bots/api#user "Unique identifier for this user or bot."
49
+ user: { idPrefix: '58104400', ids: '{prefix}{n}', refresh: { none: 'the Bot API reads no user by id and lists none: getMe answers the bot itself, and a person is seen only in the messages and updates that name them' } },
50
+ // a chat as getChat answers it (ChatFullInfo); a private chat's id is its person's, a group's and a channel's are
51
+ // the vendor's negative ids (minted by the doors that make them)
52
+ // source: https://core.telegram.org/bots/api#chat "Unique identifier for this chat."
53
+ chat: { idPrefix: '', ...states.chat, refresh: { get: 'getChat', known: true, query: { chat_id: '{id}' } } },
54
+ // one subject per member of a chat, `<chat>:<user>`, stored as getChatMember answers it
55
+ chat_member: { idPrefix: '', ...states.chat_member, refresh: { none: 'the Bot API lists only administrators (getChatAdministrators), and getChatMember reads one member by a user id the World learns only from updates' } },
56
+ // a message, `<chat>:<message_id>`; its message_id counts per chat
57
+ // source: https://core.telegram.org/bots/api#message "Unique message identifier inside this chat;"
58
+ message: { idPrefix: '', refresh: { none: 'the Bot API reads no message back: a bot sees one only as an update or as the answer to its own send' } },
59
+ // an update waiting for its bot, `<bot>:<update_id>`, stored as getUpdates answers it; confirmed, it is gone
60
+ // source: https://core.telegram.org/bots/api#getupdates "An update is considered confirmed as soon as getUpdates is called with an offset higher than its update_id."
61
+ update: { idPrefix: '', refresh: { none: 'reading the queue (getUpdates) confirms what it reads: a refresh would take updates from the application the World serves' } },
62
+ // a stored file (a photo, a video, a document, a chat photo), its bytes the pack's blobs, by its file_unique_id
63
+ // source: https://core.telegram.org/bots/api#file "Unique identifier for this file, which is supposed to be the same over time and for different bots."
64
+ file: { idPrefix: 'AgAD', ids: '{prefix}{letters:12}', refresh: { none: 'getFile reads a file by a file_id the World learns only from messages, and its answer is the download path, not the file' } },
65
+ // a bot's own keeping: its token's SHA-256 and its owner, its updates' counter and the allowed_updates it last asked for
66
+ _bot: { idPrefix: '' },
67
+ // the ids the vendor assigns to a basic group and to a channel or supergroup, and a file's download path
68
+ _group: { idPrefix: '-49870000', ids: '{prefix}{n}' },
69
+ _channel: { idPrefix: '-10019876540', ids: '{prefix}{n}' },
70
+ _file_path: { idPrefix: 'file_', ids: '{prefix}{n}' },
71
+ _media_group: { idPrefix: '137292810', ids: '{prefix}{n}' },
72
+ },
73
+ doors: [
74
+ { id: 'people', method: 'POST', path: '/_twin/people', note: '{ first_name, last_name?, username?, language_code? }: a Telegram account, as a person signs up in the app' },
75
+ { id: 'bots', method: 'POST', path: '/_twin/bots', note: "{ owner?, name?, username? }: BotFather's /newbot, for the person `owner` (a user id): the bot and its token; the same username answers the same bot" },
76
+ { id: 'botToken', method: 'POST', path: '/_twin/bots/{username}/token', note: "{ owner }: BotFather's /token: a new token, the old one revoked" },
77
+ { id: 'chats', method: 'POST', path: '/_twin/chats', note: '{ creator, type: group | supergroup | channel, title, username?, description?, photo? (base64 bytes), members?: [{ user, status?: member | administrator, rights? }] }: a person makes a chat in the app' },
78
+ { id: 'members', method: 'POST', path: '/_twin/chats/{chat_id}/members', note: '{ by, user, status: member | administrator | kicked, rights? }: a person adds, promotes or removes a member in the app' },
79
+ { id: 'messages', method: 'POST', path: '/_twin/messages', note: "{ from, chat (a chat id, a chat's @username, or a bot's @username for a private chat with it), text, reply_to_message_id? }: a person writes in the app" },
80
+ ],
81
+ // the API's limits (https://core.telegram.org/bots/faq): a bot broadcasts about 30 messages a second; the kernel's
82
+ // bound is 20 a second, so the World spends at most that
83
+ // source: https://core.telegram.org/bots/faq "bots are not able to broadcast more than about 30 messages per second"
84
+ rateBudget: {
85
+ allowance: { perMinute: 1800, source: 'https://core.telegram.org/bots/faq' },
86
+ reason: "Telegram's FAQ: about 30 messages a second for bulk notifications, one a second in a single chat, 20 a minute in a group (https://core.telegram.org/bots/faq). The World stays at the kernel's bound of 20 a second over a one-minute window (1000).",
87
+ windowMs: 60_000, ceiling: 1000, defaultWeight: 1, maxRetryAfterSeconds: 300,
88
+ },
89
+ discovery: {
90
+ twinOf: "Telegram's Bot API as bots use it: a bot reading itself, polling for its updates (a long poll), typing, sending text (plain, HTML, MarkdownV2 or Markdown), photos, videos, documents and albums, deleting messages, reading chats, members and files, and downloading a file",
91
+ stores: 'people and bots (tokens by their SHA-256), chats with their members, messages, the updates waiting for each bot, and files (their bytes as blobs)',
92
+ identity: 'the bot token in the path: https://api.telegram.org/bot<token>/<method> (the bots door, standing in for BotFather, issues it)',
93
+ },
94
+ descriptor: {
95
+ protocol: '3',
96
+ transport: 'web-api',
97
+ archetype: 'crud',
98
+ bin: 'world-telegram',
99
+ resources: ['user', 'chat', 'chat_member', 'message', 'update', 'file'],
100
+ specSource: 'the Telegram Bot API page (https://core.telegram.org/bots/api, Bot API 10.3), written as OpenAPI in spec/ (provenance in spec/SOURCE.md)',
101
+ description: "Telegram Bot API twin — a bot's updates by long polling, its messages (text, HTML, MarkdownV2, photos, videos, documents, albums), chats, members and files, as Volter Harness's Chat SDK channel and Postiz call them.",
102
+ adoption: { sdks: ['@chat-adapter/telegram', 'node-telegram-bot-api', 'telegraf'], envStems: ['TELEGRAM'] },
103
+ // the libraries above name Telegram in an application; none is Telegram's own
104
+ // source: https://core.telegram.org/bots/samples "these projects are not maintained by Telegram"
105
+ clientCasesNone: 'Telegram publishes no client library of its own (its samples page lists libraries others maintain): the demand applications call the Bot API through the Chat SDK adapter (Volter Harness) and node-telegram-bot-api (Postiz)',
106
+ hosts: [{ host: 'api.telegram.org' }],
107
+ // the Chat SDK's adapter reads TELEGRAM_API_BASE_URL as its API's base URL (@chat-adapter/telegram 4.39.0, dist/index.js:1039)
108
+ endpointEnv: { name: 'TELEGRAM_API_BASE_URL', note: "the Chat SDK's Telegram adapter reads it as its API base (@chat-adapter/telegram 4.39.0, dist/index.js:1039); node-telegram-bot-api takes a baseApiUrl option only, and reaches the twin by its host" },
109
+ credentialDoor: { path: '/_twin/bots', body: {}, fill: { TELEGRAM_BOT_TOKEN: 'token', TELEGRAM_TOKEN: 'token', TELEGRAM_BOT_USERNAME: 'username', TELEGRAM_BOT_NAME: 'username' } },
110
+ // the bot token rides in the path, after /bot (the API's) or /file/bot (a file's download)
111
+ // source: https://core.telegram.org/bots/api#making-requests "https://api.telegram.org/bot<token>/METHOD_NAME"
112
+ auth: { in: 'path', pattern: '/(?:file/)?bot([^/]+)/' },
113
+ },
114
+ };
@@ -0,0 +1,33 @@
1
+ // What the Bot API does around every request (architecture, around.ts): the token in the path names the bot before the
2
+ // method is routed, as the server's client manager reads it. A token of a form BotFather never issues is refused as
3
+ // invalid; one it did not issue, or one made again since (BotFather's /token revokes the token before), as
4
+ // unauthorized. A path that names no bot at all is not the API's.
5
+ import type { HandlerContext } from '@volter/world-core';
6
+ import { botOfToken, refuse, SERVED_METHODS, tokenOf } from './shared.ts';
7
+
8
+ export async function around(ctx: HandlerContext, next: (request?: Request) => Promise<Response>): Promise<Response> {
9
+ const path = new URL(ctx.call.request.url).pathname;
10
+ if (path.startsWith('/_twin/') || path === '/twin' || path.startsWith('/twin/')) return next();
11
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/HttpConnection.cpp "if (!url_path_parser.try_skip("
12
+ const token = tokenOf(ctx.call.request);
13
+ if (token === undefined) return refuse(ctx, 404, 'Not Found');
14
+ const found = botOfToken(ctx, token);
15
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/ClientManager.cpp "Unauthorized: invalid token specified"
16
+ if (found === 'invalid') return refuse(ctx, 401, 'Unauthorized: invalid token specified');
17
+ // a token the bot does not hold fails its login
18
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "result.code = 401;"
19
+ if (found === 'unknown') return refuse(ctx, 401, 'Unauthorized');
20
+ // a method asked by GET, or named in another case, is the same method: the server takes both methods and lowers the
21
+ // name before it routes
22
+ // source: https://core.telegram.org/bots/api#making-requests "We support GET and POST HTTP methods."
23
+ // source: https://core.telegram.org/bots/api#making-requests "All methods in the Bot API are case-insensitive."
24
+ const method = /^\/bot[^/]+\/([^/]+)$/.exec(path)?.[1];
25
+ const canonical = method ? SERVED_METHODS.find((m) => m.toLowerCase() === method.toLowerCase()) : undefined;
26
+ if (method && (ctx.call.request.method === 'GET' || (canonical && canonical !== method))) {
27
+ const url = new URL(ctx.call.request.url);
28
+ if (canonical) url.pathname = path.slice(0, path.length - method.length) + canonical;
29
+ const body = ctx.call.request.method === 'GET' ? undefined : await ctx.call.request.clone().arrayBuffer();
30
+ return next(new Request(url, { method: 'POST', headers: ctx.call.request.headers, ...(body ? { body } : {}) }));
31
+ }
32
+ return next();
33
+ }
@@ -0,0 +1,253 @@
1
+ // Available methods (https://core.telegram.org/bots/api#available-methods) the demand reaches: the bot itself, text and
2
+ // media sent to a chat, the typing indicator, a chat, a member and a file read, and a file's download.
3
+ import type { HandlerContext } from '@volter/world-core';
4
+ import { imageSize, mp4Info } from '../engine/media.ts';
5
+ import {
6
+ answer, botOf, chatFor, chatFullView, fileBytes, fileById, formatted, jsonParam, memberKey, memberView, messageView, param,
7
+ refuse, replyTarget, storeBotMessage, storeFile, textFields, uploadOf, type Row, type Upload,
8
+ } from './shared.ts';
9
+
10
+ // source: https://core.telegram.org/bots/api#sendmessage "Text of the message to be sent, 1-4096 characters after entities parsing"
11
+ const TEXT_MAX = 4096;
12
+ // source: https://core.telegram.org/bots/api#sendphoto "Photo caption (may also be used when resending photos by file_id), 0-1024 characters after entities parsing"
13
+ const CAPTION_MAX = 1024;
14
+ const characters = (s: string): number => [...s].length;
15
+
16
+ export async function getMe(ctx: HandlerContext): Promise<Response> {
17
+ const { user } = botOf(ctx);
18
+ // the bot's User with the fields getMe alone returns
19
+ // source: https://core.telegram.org/bots/api#getme "Returns basic information about the bot in form of a User object."
20
+ return answer(Object.fromEntries(Object.entries(ctx.own(user)).filter(([k]) => !k.startsWith('_') && k !== 'deleted')));
21
+ }
22
+
23
+ export async function sendMessage(ctx: HandlerContext): Promise<Response> {
24
+ const { id: botId } = botOf(ctx);
25
+ const access = chatFor(ctx, botId, param(ctx, 'chat_id'), 'write');
26
+ if ('refusal' in access) return access.refusal;
27
+ const raw = param(ctx, 'text');
28
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Message text is empty"
29
+ if (raw === undefined || String(raw) === '') return refuse(ctx, 400, 'Bad Request: message text is empty');
30
+ const entities = jsonParam(ctx, 'entities');
31
+ const text = formatted(ctx, String(raw), param(ctx, 'parse_mode'), entities.value);
32
+ if ('refusal' in text) return text.refusal;
33
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessageEntity.cpp "Text must be non-empty"
34
+ if (text.text === '') return refuse(ctx, 400, 'Bad Request: text must be non-empty');
35
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessagesManager.cpp "Message is too long"
36
+ if (characters(text.text) > TEXT_MAX) return refuse(ctx, 400, 'Bad Request: message is too long');
37
+ const reply = replyTarget(ctx, access.chat, botId);
38
+ if ('refusal' in reply) return reply.refusal;
39
+ const row = await storeBotMessage(ctx, botId, access.chat, textFields(text, 'text'), reply.key, ctx.call.operation.id);
40
+ return answer(messageView(ctx, row));
41
+ }
42
+
43
+ /** A caption checked as the server checks it: parsed, then at most 1024 characters. */
44
+ function captionOf(ctx: HandlerContext, caption: unknown, parseMode: unknown, entities: unknown): Row | { refusal: Response } {
45
+ if (caption === undefined || caption === null || String(caption) === '') return {};
46
+ const text = formatted(ctx, String(caption), parseMode, entities);
47
+ if ('refusal' in text) return text;
48
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessagesManager.cpp "Message caption is too long"
49
+ if (characters(text.text) > CAPTION_MAX) return { refusal: refuse(ctx, 400, 'Bad Request: message caption is too long') };
50
+ return textFields(text, 'caption');
51
+ }
52
+
53
+ /** A medium's fields on its message, from an upload or a file sent again: a photo's one size, a video's size and
54
+ * length, a document's name and type. */
55
+ async function mediaFields(ctx: HandlerContext, botId: number, kind: 'photo' | 'video' | 'document', upload: Upload, given: { width?: unknown; height?: unknown; duration?: unknown }): Promise<Row | { refusal: Response }> {
56
+ const operation = ctx.call.operation.id;
57
+ let file: Row;
58
+ let bytes: Uint8Array;
59
+ if ('file' in upload) {
60
+ // a file sent again keeps its type: a photo is not sent as a document, nor a video as a photo
61
+ // source: https://core.telegram.org/bots/api#sending-files "It is not possible to change the file type when resending by file_id ."
62
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/files/FileManager.cpp "Can't use file of type"
63
+ const types: Record<string, string> = { photos: 'Photo', videos: 'Video', documents: 'Document', profile_photos: 'ProfilePhoto' };
64
+ const real = types[String(upload.file._kind)] ?? 'Document';
65
+ const wanted = kind === 'photo' ? 'Photo' : kind === 'video' ? 'Video' : 'Document';
66
+ if (real !== wanted) return { refusal: refuse(ctx, 400, `Bad Request: can't use file of type ${real} as ${wanted}`) };
67
+ file = upload.file;
68
+ bytes = await fileBytes(ctx, file);
69
+ }
70
+ else {
71
+ bytes = upload.bytes;
72
+ // source: https://core.telegram.org/bots/api#sendphoto "The photo must be at most 10 MB in size."
73
+ // source: https://core.telegram.org/bots/api#sendvideo "Bots can currently send video files of up to 50 MB in size, this limit may be changed in the future."
74
+ // source: https://core.telegram.org/bots/api#senddocument "Bots can currently send files of any type of up to 50 MB in size, this limit may be changed in the future."
75
+ // Where the documentation stops: the words for a file over its limit are the server's for a file too big
76
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Bad Request: file is too big"
77
+ if (bytes.byteLength > (kind === 'photo' ? 10 : 50) * 1024 * 1024) return { refusal: refuse(ctx, 400, 'Bad Request: file is too big') };
78
+ if (kind === 'photo' && !imageSize(bytes)) {
79
+ // an upload that is no image Telegram can read: Telegram's own error for it, which the server relays as it is (its
80
+ // second character an underscore or a capital)
81
+ // source: https://core.telegram.org/method/messages.sendMedia "Failure while processing image."
82
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "(error_message[1] == '_' || ('A' <= error_message[1] && error_message[1] <= 'Z'))) {"
83
+ return { refusal: refuse(ctx, 400, 'Bad Request: IMAGE_PROCESS_FAILED') };
84
+ }
85
+ const size = kind === 'photo' ? imageSize(bytes)! : undefined;
86
+ // source: https://core.telegram.org/bots/api#sendphoto "The photo's width and height must not exceed 10000 in total. Width and height ratio must be at most 20."
87
+ // (Telegram's own error for it, relayed as it is: an MTProto error's second character is a capital)
88
+ if (size && (size.width + size.height > 10000 || Math.max(size.width, size.height) > 20 * Math.max(1, Math.min(size.width, size.height)))) return { refusal: refuse(ctx, 400, 'Bad Request: PHOTO_INVALID_DIMENSIONS') };
89
+ file = await storeFile(ctx, bytes, { kind: kind === 'photo' ? 'photos' : kind === 'video' ? 'videos' : 'documents', bot: botId, ...(upload.mime ? { mime: upload.mime } : {}), ...(upload.name ? { name: upload.name } : {}) }, operation);
90
+ }
91
+ const ids = { file_id: file.file_id, file_unique_id: file.file_unique_id };
92
+ if (kind === 'photo') {
93
+ const size = imageSize(bytes) ?? { width: 0, height: 0 };
94
+ // Where the documentation stops: the page gives a photo's sizes as "Available sizes of the photo" and names none;
95
+ // the twin keeps the one it was given
96
+ // source: https://core.telegram.org/bots/api#message "Optional. Message is a photo, available sizes of the photo"
97
+ return { photo: [{ ...ids, file_size: bytes.byteLength, width: size.width, height: size.height }] };
98
+ }
99
+ const name = typeof file._name === 'string' ? file._name : undefined;
100
+ const mime = typeof file._mime === 'string' ? file._mime : undefined;
101
+ if (kind === 'video') {
102
+ const info = mp4Info(bytes);
103
+ // the request's own width, height and duration, else the file's
104
+ // source: https://core.telegram.org/bots/api#sendvideo "Duration of sent video in seconds"
105
+ const num = (v: unknown, fallback: number): number => (typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : fallback);
106
+ return { video: { ...ids, width: num(given.width, info?.width ?? 0), height: num(given.height, info?.height ?? 0), duration: num(given.duration, info?.duration ?? 0), ...(name ? { file_name: name } : {}), ...(mime ? { mime_type: mime } : {}), file_size: bytes.byteLength } };
107
+ }
108
+ return { document: { ...ids, ...(name ? { file_name: name } : {}), ...(mime ? { mime_type: mime } : {}), file_size: bytes.byteLength } };
109
+ }
110
+
111
+ /** One medium sent to a chat with its caption. */
112
+ async function sendMedium(ctx: HandlerContext, kind: 'photo' | 'video' | 'document'): Promise<Response> {
113
+ const { id: botId } = botOf(ctx);
114
+ const access = chatFor(ctx, botId, param(ctx, 'chat_id'), 'write');
115
+ if ('refusal' in access) return access.refusal;
116
+ const upload = uploadOf(ctx, ctx.params[kind], botId);
117
+ if ('refusal' in upload) return upload.refusal;
118
+ const caption = captionOf(ctx, param(ctx, 'caption'), param(ctx, 'parse_mode'), jsonParam(ctx, 'caption_entities').value);
119
+ if ('refusal' in caption) return caption.refusal as Response;
120
+ const reply = replyTarget(ctx, access.chat, botId);
121
+ if ('refusal' in reply) return reply.refusal;
122
+ const media = await mediaFields(ctx, botId, kind, upload, { width: param(ctx, 'width'), height: param(ctx, 'height'), duration: param(ctx, 'duration') });
123
+ if ('refusal' in media) return media.refusal as Response;
124
+ const row = await storeBotMessage(ctx, botId, access.chat, { ...media, ...caption }, reply.key, ctx.call.operation.id);
125
+ return answer(messageView(ctx, row));
126
+ }
127
+
128
+ // source: https://core.telegram.org/bots/api#sendphoto "Use this method to send photos. On success, the sent Message is returned."
129
+ export async function sendPhoto(ctx: HandlerContext): Promise<Response> {
130
+ return sendMedium(ctx, 'photo');
131
+ }
132
+
133
+ // source: https://core.telegram.org/bots/api#sendvideo "Use this method to send video files, Telegram clients support MPEG4 videos (other formats may be sent as Document)."
134
+ export async function sendVideo(ctx: HandlerContext): Promise<Response> {
135
+ return sendMedium(ctx, 'video');
136
+ }
137
+
138
+ // source: https://core.telegram.org/bots/api#senddocument "Use this method to send general files. On success, the sent Message is returned."
139
+ export async function sendDocument(ctx: HandlerContext): Promise<Response> {
140
+ return sendMedium(ctx, 'document');
141
+ }
142
+
143
+ // source: https://core.telegram.org/bots/api#sendmediagroup "On success, an Array of Message objects that were sent is returned."
144
+ export async function sendMediaGroup(ctx: HandlerContext): Promise<Response> {
145
+ const { id: botId } = botOf(ctx);
146
+ const access = chatFor(ctx, botId, param(ctx, 'chat_id'), 'write');
147
+ if ('refusal' in access) return access.refusal;
148
+ const media = jsonParam(ctx, 'media');
149
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Can't parse media JSON object"
150
+ if (media.error || (media.value !== undefined && !Array.isArray(media.value))) return refuse(ctx, 400, "Bad Request: can't parse media JSON object");
151
+ const items = (media.value ?? []) as Row[];
152
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessageContent.cpp "There are no messages to send"
153
+ if (!items.length) return refuse(ctx, 400, 'Bad Request: there are no messages to send');
154
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessageContent.cpp "Too many messages to send as an album"
155
+ if (items.length > 10) return refuse(ctx, 400, 'Bad Request: too many messages to send as an album');
156
+ const kinds = items.map((i) => String(i?.type ?? ''));
157
+ for (const k of kinds) {
158
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessageContent.cpp "Invalid message content type"
159
+ if (!['photo', 'video', 'document'].includes(k)) return refuse(ctx, 400, `Bad Request: can't parse InputMedia: ${k ? 'unsupported media type' : 'media type is not specified'}`);
160
+ }
161
+ // source: https://core.telegram.org/bots/api#sendmediagroup "Documents and audio files can be only grouped in an album with messages of the same type."
162
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessageContent.cpp "can't be mixed with other media types"
163
+ if (kinds.includes('document') && kinds.some((k) => k !== 'document')) return refuse(ctx, 400, "Bad Request: document can't be mixed with other media types");
164
+ const reply = replyTarget(ctx, access.chat, botId);
165
+ if ('refusal' in reply) return reply.refusal;
166
+ // every part checked before any is sent
167
+ const prepared: Array<{ kind: 'photo' | 'video' | 'document'; upload: Upload; caption: Row; item: Row }> = [];
168
+ for (const item of items) {
169
+ const upload = uploadOf(ctx, item.media, botId);
170
+ if ('refusal' in upload) return upload.refusal;
171
+ const caption = captionOf(ctx, item.caption, item.parse_mode, item.caption_entities);
172
+ if ('refusal' in caption) return caption.refusal as Response;
173
+ prepared.push({ kind: item.type as 'photo' | 'video' | 'document', upload, caption, item });
174
+ }
175
+ // the album's one media_group_id, the World's for it
176
+ // source: https://core.telegram.org/bots/api#message "Optional. The unique identifier inside this chat of a media message group this message belongs to"
177
+ const group = await ctx.issue('_media_group');
178
+ const sent: Row[] = [];
179
+ for (const p of prepared) {
180
+ const fields = await mediaFields(ctx, botId, p.kind, p.upload, { width: p.item.width, height: p.item.height, duration: p.item.duration });
181
+ if ('refusal' in fields) return fields.refusal as Response;
182
+ const row = await storeBotMessage(ctx, botId, access.chat, { media_group_id: group, ...fields, ...p.caption }, reply.key, ctx.call.operation.id);
183
+ sent.push(messageView(ctx, row));
184
+ }
185
+ return answer(sent);
186
+ }
187
+
188
+ // source: https://core.telegram.org/bots/api#sendchataction "Use this method when you need to tell the user that something is happening on the bot's side."
189
+ export async function sendChatAction(ctx: HandlerContext): Promise<Response> {
190
+ const { id: botId } = botOf(ctx);
191
+ const access = chatFor(ctx, botId, param(ctx, 'chat_id'), 'write');
192
+ if ('refusal' in access) return access.refusal;
193
+ const action = String(param(ctx, 'action') ?? '').toLowerCase();
194
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Wrong parameter action in request"
195
+ if (!['cancel', 'typing', 'record_video', 'upload_video', 'record_audio', 'record_voice', 'upload_audio', 'upload_voice', 'upload_photo', 'upload_document', 'choose_sticker', 'pick_up_location', 'find_location', 'record_video_note', 'upload_video_note'].includes(action)) {
196
+ return refuse(ctx, 400, 'Bad Request: wrong parameter action in request');
197
+ }
198
+ // the status shows for five seconds in the person's app; nothing is stored
199
+ // source: https://core.telegram.org/bots/api#sendchataction "The status is set for 5 seconds or less (when a message arrives from your bot, Telegram clients clear its typing status). Returns True on success."
200
+ return answer(true);
201
+ }
202
+
203
+ // source: https://core.telegram.org/bots/api#getchat "Use this method to get up-to-date information about the chat. Returns a ChatFullInfo object on success."
204
+ export async function getChat(ctx: HandlerContext): Promise<Response> {
205
+ const { id: botId } = botOf(ctx);
206
+ const access = chatFor(ctx, botId, param(ctx, 'chat_id'), 'read');
207
+ if ('refusal' in access) return access.refusal;
208
+ return answer(chatFullView(ctx, access.chat));
209
+ }
210
+
211
+ // source: https://core.telegram.org/bots/api#getchatmember "Use this method to get information about a member of a chat."
212
+ export async function getChatMember(ctx: HandlerContext): Promise<Response> {
213
+ const { id: botId } = botOf(ctx);
214
+ const access = chatFor(ctx, botId, param(ctx, 'chat_id'), 'read');
215
+ if ('refusal' in access) return access.refusal;
216
+ const userId = param(ctx, 'user_id');
217
+ const user = typeof userId === 'number' ? userId : typeof userId === 'string' && /^\d+$/.test(userId) ? Number(userId) : undefined;
218
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "user not found"
219
+ if (user === undefined || !ctx.row('user', String(user))) return refuse(ctx, 400, 'Bad Request: user not found');
220
+ const member = ctx.row('chat_member', memberKey(access.chat.id, user));
221
+ return answer(memberView(ctx, member, user));
222
+ }
223
+
224
+ // source: https://core.telegram.org/bots/api#getfile "Use this method to get basic information about a file and prepare it for downloading. For the moment, bots can download files of up to 20MB in size."
225
+ export async function getFile(ctx: HandlerContext): Promise<Response> {
226
+ const { id: botId } = botOf(ctx);
227
+ const file = fileById(ctx, param(ctx, 'file_id'), botId);
228
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "invalid file_id"
229
+ if (!file) return refuse(ctx, 400, 'Bad Request: invalid file_id');
230
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Bad Request: file is too big"
231
+ if (Number(file.file_size ?? 0) > 20 * 1024 * 1024) return refuse(ctx, 400, 'Bad Request: file is too big');
232
+ let path = typeof file.file_path === 'string' ? file.file_path : undefined;
233
+ if (!path) {
234
+ // the path a file is downloaded by: its kind's folder and a name the server gives it
235
+ // source: https://core.telegram.org/bots/api#file "Optional. File path. Use https://api.telegram.org/file/bot<token>/<file_path> to get the file."
236
+ const name = await ctx.issue('_file_path');
237
+ const ext = typeof file._name === 'string' && file._name.includes('.') ? file._name.slice(file._name.lastIndexOf('.')) : file._kind === 'videos' ? '.mp4' : file._kind === 'documents' ? '' : '.jpg';
238
+ path = `${String(file._kind ?? 'documents')}/${name}${ext}`;
239
+ await ctx.write('file', String(file.id), { file_path: path }, ctx.call.operation.id);
240
+ }
241
+ return answer({ file_id: file.file_id, file_unique_id: file.file_unique_id, file_size: file.file_size, file_path: path });
242
+ }
243
+
244
+ // source: https://core.telegram.org/bots/api#getfile "The file can then be downloaded via the link https://api.telegram.org/file/bot<token>/<file_path> , where <file_path> is taken from the response."
245
+ export async function downloadFile(ctx: HandlerContext): Promise<Response> {
246
+ botOf(ctx);
247
+ const path = String(ctx.call.params.file_path ?? '');
248
+ const file = ctx.rowsRaw('file').find((r) => r.deleted !== true && r.file_path === path);
249
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/HttpConnection.cpp "return send_http_error(404,"
250
+ if (!file) return refuse(ctx, 404, 'Not Found');
251
+ const bytes = await fileBytes(ctx, file);
252
+ return ctx.raw(bytes.slice(), { status: 200, headers: { 'content-type': String(file._mime ?? 'application/octet-stream'), 'content-length': String(bytes.byteLength) } });
253
+ }