@volter/twin-telegram 1.0.0 → 1.0.1

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 (52) hide show
  1. package/README.md +2 -2
  2. package/dist/journeys/customer-life.json +21 -21
  3. package/dist/journeys/customer-life.outline.md +17 -17
  4. package/dist/journeys/customer-life.outline.verdict.json +17 -52
  5. package/dist/journeys/customer-life.verdict.json +24 -98
  6. package/dist/journeys/decisions.json +49 -13
  7. package/dist/journeys/demand.json +0 -91
  8. package/dist/journeys/first-use.json +21 -0
  9. package/dist/journeys/unreachable.json +1 -1
  10. package/dist/journeys/vendor-examples.json +14 -6
  11. package/dist/spec/SOURCE.md +5 -6
  12. package/dist/spec/patches.json +20 -20
  13. package/dist/spec/sources.json +15 -0
  14. package/dist/src/cli.js +1 -1
  15. package/dist/src/engine/media.d.ts +6 -12
  16. package/dist/src/engine/media.js +28 -66
  17. package/dist/src/engine/text.d.ts +0 -7
  18. package/dist/src/engine/text.js +2 -2
  19. package/dist/src/index.js +2 -1
  20. package/dist/src/manifest.js +7 -6
  21. package/dist/src/semantics/available-methods.js +50 -40
  22. package/dist/src/semantics/doors.d.ts +0 -1
  23. package/dist/src/semantics/doors.js +82 -5
  24. package/dist/src/semantics/shared.d.ts +10 -6
  25. package/dist/src/semantics/shared.js +29 -8
  26. package/dist/src/server.js +2 -19
  27. package/generated/pack-facts.json +10 -0
  28. package/journeys/customer-life.json +21 -21
  29. package/journeys/customer-life.outline.md +17 -17
  30. package/journeys/customer-life.outline.verdict.json +17 -52
  31. package/journeys/customer-life.verdict.json +24 -98
  32. package/journeys/decisions.json +49 -13
  33. package/journeys/demand.json +0 -91
  34. package/journeys/first-use.json +21 -0
  35. package/journeys/unreachable.json +1 -1
  36. package/journeys/vendor-examples.json +14 -6
  37. package/package.json +12 -9
  38. package/spec/SOURCE.md +5 -6
  39. package/spec/patches.json +20 -20
  40. package/spec/sources.json +15 -0
  41. package/src/cli.ts +1 -1
  42. package/src/engine/media.ts +21 -58
  43. package/src/engine/text.ts +2 -2
  44. package/src/index.ts +2 -1
  45. package/src/manifest.ts +7 -6
  46. package/src/semantics/available-methods.ts +55 -41
  47. package/src/semantics/doors.ts +74 -8
  48. package/src/semantics/shared.ts +27 -8
  49. package/src/server.ts +2 -11
  50. package/dist/generated/pack-facts.json +0 -53
  51. package/dist/spec/page-to-openapi.ts +0 -258
  52. package/spec/page-to-openapi.ts +0 -258
@@ -9,8 +9,6 @@ import {
9
9
  type Row,
10
10
  } from './shared.ts';
11
11
 
12
- export type { HandlerContext };
13
-
14
12
  const bad = (error: string, status = 400): Response => Response.json({ error }, { status });
15
13
  const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
16
14
  const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && /^-?\d+$/.test(v) ? Number(v) : undefined);
@@ -27,15 +25,83 @@ export async function people(ctx: HandlerContext): Promise<Response> {
27
25
  const row = await ctx.create('user', (id) => ({
28
26
  id: Number(id), is_bot: false, first_name: first,
29
27
  ...(str(ctx.params.last_name) ? { last_name: str(ctx.params.last_name) } : {}),
30
- ...(username ? { username } : {}),
28
+ ...(username ? { username, _username: username.toLowerCase() } : {}),
31
29
  ...(str(ctx.params.language_code) ? { language_code: str(ctx.params.language_code) } : {}),
32
30
  }), 'people');
33
31
  return Response.json(userView(ctx, (row as Row).id ?? ctx.own(row as Row).id));
34
32
  }
35
33
 
34
+ /** A metadata-issued bot belongs to the first member's Telegram person. */
35
+ async function applicationBot(ctx: HandlerContext): Promise<Response> {
36
+ const owner = await ctx.tenant('user', async (world) => {
37
+ const email = world.members[0]!.toLowerCase();
38
+ const existing = ctx.rowsRaw('user').find((row) => row.is_bot !== true && row._email === email);
39
+ return existing ? String(existing.id) : String((await ctx.create('user', (id) => ({
40
+ id: Number(id), is_bot: false, first_name: world.label, _email: email,
41
+ }), 'people')).id);
42
+ });
43
+ const organization = await ctx.account();
44
+ const bot = await ctx.deploymentObject(`bot:${owner.id}`, 'user', async (deployment) => {
45
+ const explicit = str(ctx.params.username)?.replace(/^@/, '');
46
+ const label = (explicit ?? deployment) || 'world_twin';
47
+ const base = label.replace(/_?bot$/i, '') || 'world';
48
+ const identity = deployment ? `${organization}/${deployment}` : organization;
49
+ for (const suffixed of [false, true]) {
50
+ const username = explicit && !suffixed && /^[A-Za-z0-9_]{5,32}$/.test(explicit) && /bot$/i.test(explicit) ? explicit
51
+ : `${ctx.vendorName(base, { max: 28, lower: true, allowed: /[a-z0-9_]/, separator: '_' }, { suffixed, identity })}_bot`;
52
+ if (userByName(ctx, username)) continue;
53
+ const made = await ctx.createUnique('user', (id) => ({ id: Number(id), is_bot: true,
54
+ first_name: str(ctx.params.name) ?? (deployment || 'World Twin Bot'), username, _username: username.toLowerCase(),
55
+ can_join_groups: true, can_read_all_group_messages: false, supports_inline_queries: false, can_connect_to_business: false,
56
+ has_main_web_app: false, has_topics_enabled: false }), 'bots');
57
+ if (made.created) {
58
+ const id = String(made.row.id);
59
+ await ctx.record('_bot', { owner: owner.id, generation: 1, update_seq: 836_000_000 }, id);
60
+ return id;
61
+ }
62
+ }
63
+ throw bad(`A bot username already exists for ${identity}.`, 409);
64
+ });
65
+ const key = await ctx.accountKey(`bot-token:${bot.id}`, '_bot', {
66
+ make: async () => {
67
+ const id = String(bot.id);
68
+ const before = ctx.row('_bot', id);
69
+ if (!before) {
70
+ const retired = ctx.row('_bot', id, { withDeleted: true });
71
+ const generation = Number(retired?.generation ?? 0) + 1;
72
+ const token = await issueToken(ctx, id, generation);
73
+ await ctx.createUnique('_bot', {
74
+ id, owner: owner.id, generation, token_sha256: ctx.crypto.sha256(token),
75
+ update_seq: retired?.update_seq ?? 836_000_000, deleted: false, _pending: false,
76
+ }, 'bot.token', { id, input: 'omit' });
77
+ return id;
78
+ }
79
+ const current = await issueToken(ctx, String(bot.id), Number(before.generation ?? 1));
80
+ if (before.token_sha256 === ctx.crypto.sha256(current)) return String(bot.id);
81
+ await ctx.change('_bot', String(bot.id), (row) => row._pending === true || row.token_sha256 !== before.token_sha256 ? undefined
82
+ : { generation: row.token_sha256 === undefined ? Number(row.generation ?? 1) : Number(row.generation ?? 1) + 1, _pending: true }, 'bot.token.reserve');
83
+ const native = ctx.row('_bot', String(bot.id))!;
84
+ const token = await issueToken(ctx, String(bot.id), Number(native.generation));
85
+ await ctx.change('_bot', String(bot.id), (row) => row.generation === native.generation
86
+ ? { token_sha256: ctx.crypto.sha256(token), _pending: false } : undefined, 'bot.token');
87
+ return String(bot.id);
88
+ },
89
+ read: async (row) => {
90
+ if (row.owner !== owner.id || row._pending === true) return undefined;
91
+ const token = await issueToken(ctx, String(bot.id), Number(row.generation));
92
+ return row.token_sha256 === ctx.crypto.sha256(token) ? token : undefined;
93
+ },
94
+ });
95
+ return Response.json({ id: Number(bot.id), name: bot.first_name, username: bot.username, token: key.secret });
96
+ }
97
+
36
98
  /** BotFather's /newbot: a bot (its name, its username ending in "bot") and its token; the same username answers the
37
99
  * bot made before and its token as it stands, so a World's credential door answers the same token at every boot. */
38
100
  export async function bots(ctx: HandlerContext): Promise<Response> {
101
+ const body = ctx.body && typeof ctx.body === 'object' ? ctx.body as Row : {};
102
+ if (body.account !== undefined || body.deployment !== undefined) {
103
+ try { return await applicationBot(ctx); } catch (error) { if (error instanceof Response) return error; throw error; }
104
+ }
39
105
  // source: https://core.telegram.org/bots/features#botfather "Usernames are 5-32 characters long and not case sensitive – but may only include Latin characters, numbers, and underscores. Your bot's username must end in 'bot’, like 'tetris_bot' or 'TetrisBot'."
40
106
  const username = (str(ctx.params.username) ?? 'world_twin_bot').replace(/^@/, '');
41
107
  if (!/^[A-Za-z0-9_]{5,32}$/.test(username) || !/bot$/i.test(username)) return bad('Sorry, this username is invalid.');
@@ -48,7 +114,7 @@ export async function bots(ctx: HandlerContext): Promise<Response> {
48
114
  // was not set up for
49
115
  // source: https://core.telegram.org/bots/features#privacy-mode "Privacy mode is enabled by default for all bots"
50
116
  const row = await ctx.create('user', (id) => ({
51
- id: Number(id), is_bot: true, first_name: name, username,
117
+ id: Number(id), is_bot: true, first_name: name, username, _username: username.toLowerCase(),
52
118
  can_join_groups: true, can_read_all_group_messages: false, supports_inline_queries: false, can_connect_to_business: false,
53
119
  has_main_web_app: false, has_topics_enabled: false,
54
120
  }), 'bots');
@@ -112,13 +178,13 @@ async function moveMember(ctx: HandlerContext, chat: Row, by: number, user: numb
112
178
  const after = ctx.row('chat_member', key)!;
113
179
  if (ctx.row('user', String(user))?.is_bot === true && user !== by) {
114
180
  // source: https://core.telegram.org/bots/api#update "Optional. The bot's chat member status was updated in a chat."
115
- await queueUpdate(ctx, user, 'my_chat_member', { chat: chatViewOf(ctx, chat), from: userView(ctx, by), date: unixOf(ctx.occurredAt), old_chat_member: before, new_chat_member: memberView(ctx, after, user) }, operation);
181
+ await queueUpdate(ctx, user, 'my_chat_member', { chat: chatViewOf(chat), from: userView(ctx, by), date: unixOf(ctx.occurredAt), old_chat_member: before, new_chat_member: memberView(ctx, after, user) }, operation);
116
182
  }
117
183
  return after;
118
184
  }
119
185
 
120
- const chatViewOf = (ctx: HandlerContext, chat: Row): Row =>
121
- Object.fromEntries(Object.entries(chatFullView(ctx, chat)).filter(([k]) => ['id', 'type', 'title', 'username', 'first_name', 'last_name'].includes(k)));
186
+ const chatViewOf = (chat: Row): Row =>
187
+ Object.fromEntries(Object.entries(chatFullView(chat)).filter(([k]) => ['id', 'type', 'title', 'username', 'first_name', 'last_name'].includes(k)));
122
188
 
123
189
  /** A message a person (or the app, for a service message) writes in a chat, kept and delivered to the bots it reaches. */
124
190
  async function post(ctx: HandlerContext, chat: Row, fields: Row, from: number | undefined, operation: string, extra: Row = {}): Promise<Row> {
@@ -190,7 +256,7 @@ export async function chats(ctx: HandlerContext): Promise<Response> {
190
256
  // source: https://core.telegram.org/bots/api#message "Optional. Service message: the group has been created"
191
257
  const created = type === 'group' ? { group_chat_created: true } : type === 'supergroup' ? { supergroup_chat_created: true } : { channel_chat_created: true };
192
258
  await post(ctx, chat(), created, creator, operation);
193
- return Response.json(chatFullView(ctx, chat()));
259
+ return Response.json(chatFullView(chat()));
194
260
  }
195
261
 
196
262
  /** A person adds a member to a chat, promotes one, or removes one (kicked); a group is told by a service message who
@@ -8,7 +8,6 @@ import type { HandlerContext } from '@volter/world-core';
8
8
  import { finish, parseHtml, parseMarkdown, parseMarkdownV2, type Entity, type Formatted } from '../engine/text.ts';
9
9
 
10
10
  export type Row = Record<string, unknown>;
11
- export type { HandlerContext };
12
11
 
13
12
  // ── answers ─────────────────────────────────────────────────────────────────────────────────────────────────────
14
13
 
@@ -45,7 +44,7 @@ export function jsonParam(ctx: HandlerContext, name: string): { value?: unknown;
45
44
  }
46
45
 
47
46
  /** 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 {
47
+ function vendorFields(ctx: HandlerContext, row: Row): Row {
49
48
  return Object.fromEntries(Object.entries(ctx.own(row)).filter(([k]) => !k.startsWith('_') && k !== 'deleted'));
50
49
  }
51
50
 
@@ -100,7 +99,7 @@ export function userView(ctx: HandlerContext, id: unknown): Row {
100
99
  }
101
100
 
102
101
  /** A Chat as a message names it: its id, type and names. */
103
- export function chatView(ctx: HandlerContext, id: unknown): Row {
102
+ function chatView(ctx: HandlerContext, id: unknown): Row {
104
103
  const row = ctx.row('chat', String(id));
105
104
  const v = row ? vendorFields(ctx, row) : { id: Number(id), type: 'private' };
106
105
  return Object.fromEntries(Object.entries(v).filter(([k]) => ['id', 'type', 'title', 'username', 'first_name', 'last_name', 'is_forum', 'is_direct_messages'].includes(k)));
@@ -141,7 +140,7 @@ export function messageView(ctx: HandlerContext, row: Row, nested = false): Row
141
140
  }
142
141
 
143
142
  /** 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 {
143
+ export function chatFullView(chat: Row): Row {
145
144
  return Object.fromEntries(Object.entries(chat).filter(([k]) => !k.startsWith('_') && k !== 'deleted'));
146
145
  }
147
146
 
@@ -220,11 +219,13 @@ export function formatted(ctx: HandlerContext, text: string, parseMode: unknown,
220
219
  // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Text is too long"
221
220
  if (new TextEncoder().encode(text).length > 1 << 15) return { refusal: refuse(ctx, 400, 'Bad Request: text is too long') };
222
221
  const mode = typeof parseMode === 'string' ? parseMode.toLowerCase() : '';
222
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Unsupported parse_mode"
223
+ if (!['', 'none', 'html', 'markdownv2', 'markdown'].includes(mode)) return { refusal: refuse(ctx, 400, 'Bad Request: unsupported parse_mode') };
223
224
  let parsed: Formatted;
224
225
  if (mode === '' || mode === 'none') {
225
226
  const given = Array.isArray(entitiesParam) ? (entitiesParam as Entity[]) : [];
226
227
  parsed = { text, entities: given };
227
- } else if (mode === 'html' || mode === 'markdownv2' || mode === 'markdown') {
228
+ } else {
228
229
  // 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
230
  // source: https://core.telegram.org/bots/api#markdownv2-style "To use this mode, pass MarkdownV2 in the parse_mode field."
230
231
  // source: https://core.telegram.org/bots/api#markdown-style "This is a legacy mode, retained for backward compatibility."
@@ -234,9 +235,6 @@ export function formatted(ctx: HandlerContext, text: string, parseMode: unknown,
234
235
  // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessageEntity.cpp "is reserved and must be escaped with the preceding"
235
236
  if ('error' in r) return { refusal: refuse(ctx, 400, `Bad Request: can't parse entities: ${r.error}`) };
236
237
  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
238
  }
241
239
  // a bot's text gets the entities the server finds (TDLib's fix_formatted_text: a bot finds new entities)
242
240
  // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/MessageEntity.cpp "add_entities(MessageEntity::Type::Mention, find_mentions);"
@@ -338,6 +336,27 @@ export async function storeFile(ctx: HandlerContext, bytes: Uint8Array, meta: {
338
336
  return ctx.row('file', id)!;
339
337
  }
340
338
 
339
+ /** The smaller sizes Telegram makes of an uploaded photo, smallest first: each size type whose box is smaller than the
340
+ * photo's longer side, the photo scaled to fit it (the rounding is the twin's). A thumbnail is kept by its ids and
341
+ * dimensions only: the twin renders no thumbnail's pixels, so its file is a gap. */
342
+ // source: https://core.telegram.org/api/files "s - Server-side resized image, bounded by 100x100 pixels."
343
+ // source: https://core.telegram.org/api/files "w - Server-side resized image, bounded by 2560x2560 pixels."
344
+ // source: https://raw.githubusercontent.com/tdlib/td/42e6a5259551178d1dab54a22ad96d14bd906e20/td/telegram/PhotoSize.cpp "get_photo_sizes_object"
345
+ export async function photoThumbnails(ctx: HandlerContext, original: Row, width: number, height: number, operation: string): Promise<Array<{ file_id: unknown; file_unique_id: unknown; width: number; height: number }>> {
346
+ const longer = Math.max(width, height), out: Array<{ file_id: unknown; file_unique_id: unknown; width: number; height: number }> = [];
347
+ // a photo's sizes are made once: the same photo sent again (by its file_id) answers the sizes it already has
348
+ // 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."
349
+ const made = ctx.rowsRaw('file').filter((r) => r.deleted !== true && r._thumbnail_of === original.file_unique_id && typeof r._width === 'number');
350
+ if (made.length) return made.map((r) => ({ file_id: r.file_id, file_unique_id: r.file_unique_id, width: r._width as number, height: r._height as number })).sort((x, y) => x.width - y.width);
351
+ for (const box of [100, 320, 800, 1280, 2560]) {
352
+ if (box >= longer) break;
353
+ const scale = box / longer, w = Math.max(1, Math.round(width * scale)), h = Math.max(1, Math.round(height * scale));
354
+ const row = await ctx.create('file', (id) => ({ file_id: `AgACAgIAAxkBAA${id.slice(4)}`, file_unique_id: id, _kind: 'photos', _thumbnail_of: original.file_unique_id, _width: w, _height: h, ...(original._bot !== undefined ? { _bot: original._bot } : {}) }), operation);
355
+ out.push({ file_id: (row as Row).file_id, file_unique_id: (row as Row).file_unique_id, width: w, height: h });
356
+ }
357
+ return out;
358
+ }
359
+
341
360
  /** 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
361
  * bot's that reads the chat). */
343
362
  // 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."
package/src/server.ts CHANGED
@@ -1,17 +1,8 @@
1
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';
2
+ import { serveHttp, type PackFetchOptions } from '@volter/world-core';
3
3
  import { createTelegramFetch } from './fetch.ts';
4
- import { manifest } from './manifest.ts';
5
4
 
6
5
  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 });
6
+ const server = await serveHttp({ port: options.port ?? 0, idleTimeout: 60, fetch: createTelegramFetch(options) });
16
7
  return { port: server.port ?? options.port ?? 0, stop: () => server.stop(true) };
17
8
  }
@@ -1,53 +0,0 @@
1
- {
2
- "//": "GENERATED by scripts/pack-facts.ts — current pack descriptors and explicit world-runtime server backings; no prior-artifact entries are retained.",
3
- "packs": {
4
- "telegram": {
5
- "adoption": {
6
- "sdks": [
7
- "@chat-adapter/telegram",
8
- "node-telegram-bot-api",
9
- "telegraf"
10
- ],
11
- "envStems": [
12
- "TELEGRAM"
13
- ]
14
- },
15
- "hosts": [
16
- {
17
- "host": "api.telegram.org"
18
- }
19
- ],
20
- "archetype": "crud",
21
- "protocol": {
22
- "declared": "3",
23
- "major": 3,
24
- "standing": "current"
25
- },
26
- "transport": "web-api",
27
- "resources": [
28
- "user",
29
- "chat",
30
- "chat_member",
31
- "message",
32
- "update",
33
- "file"
34
- ],
35
- "endpointEnv": {
36
- "name": "TELEGRAM_API_BASE_URL",
37
- "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"
38
- },
39
- "credentialDoor": {
40
- "path": "/_twin/bots",
41
- "body": {},
42
- "fill": {
43
- "TELEGRAM_BOT_TOKEN": "token",
44
- "TELEGRAM_TOKEN": "token",
45
- "TELEGRAM_BOT_USERNAME": "username",
46
- "TELEGRAM_BOT_NAME": "username"
47
- }
48
- },
49
- "serveExport": "createTelegramTwinServer"
50
- }
51
- },
52
- "undeclared": []
53
- }
@@ -1,258 +0,0 @@
1
- // Writes an OpenAPI 3.1 document of the Telegram Bot API from its reference page (https://core.telegram.org/bots/api),
2
- // read as HTML (SOURCE.md names the bytes it read). Usage: bun spec/page-to-openapi.ts <page.html> <out.json>, then
3
- // `gzip -n -9` the output to spec/openapi.json.gz.
4
- //
5
- // Every method's parameters are declared twice: as its body (JSON, form or multipart) and in its query string, since
6
- // the Bot API reads them from either (Making requests: "We support four ways of passing parameters in Bot API
7
- // requests", the first "URL query string").
8
- import { readFileSync, writeFileSync } from 'node:fs';
9
-
10
- const [, , inPath, outPath] = process.argv;
11
- const html = readFileSync(inPath!, 'utf8');
12
-
13
- const decode = (s: string) =>
14
- s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&');
15
- const text = (s: string) => decode(s.replace(/<br\s*\/?>/g, ' ').replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
16
-
17
- // the page in order: h3 sections and h4 items, each item's HTML up to the next heading
18
- type Item = { section: string; name: string; anchor: string; body: string };
19
- const items: Item[] = [];
20
- const heads = [...html.matchAll(/<h([34])><a class="anchor" name="([^"]+)"[^>]*><i class="anchor-icon"><\/i><\/a>([\s\S]*?)<\/h\1>/g)];
21
- let section = '';
22
- for (let i = 0; i < heads.length; i++) {
23
- const h = heads[i]!;
24
- const end = i + 1 < heads.length ? heads[i + 1]!.index! : html.indexOf('</div>', h.index!) > 0 ? html.length : html.length;
25
- const body = html.slice(h.index! + h[0].length, end);
26
- if (h[1] === '3') { section = text(h[3]!); continue; }
27
- items.push({ section, name: text(h[3]!), anchor: h[2]!, body });
28
- }
29
- const METHOD_SECTIONS = new Set(['Getting updates', 'Available methods', 'Updating messages', 'Stickers', 'Rich messages', 'Inline mode', 'Payments', 'Telegram Passport', 'Games']);
30
- const isType = (it: Item) => /^[A-Z][A-Za-z0-9]+$/.test(it.name) && it.section !== 'Recent changes';
31
- const isMethod = (it: Item) => /^[a-z][A-Za-z0-9]+$/.test(it.name) && METHOD_SECTIONS.has(it.section) && !/\s/.test(it.name);
32
- const typeNames = new Set(items.filter(isType).map((t) => t.name));
33
-
34
- function tableRows(body: string): string[][] | null {
35
- const t = body.match(/<table class="table">([\s\S]*?)<\/table>/);
36
- if (!t) return null;
37
- const rows = [...t[1]!.matchAll(/<tr>([\s\S]*?)<\/tr>/g)].map((r) => [...r[1]!.matchAll(/<t[dh]>([\s\S]*?)<\/t[dh]>/g)].map((c) => c[1]!));
38
- return rows;
39
- }
40
- function beforeTable(body: string): string {
41
- const i = body.indexOf('<table');
42
- return i < 0 ? body : body.slice(0, i);
43
- }
44
-
45
- // a Type cell: Integer, String, Boolean, True, Float, InputFile, a type, "Array of X", "X or Y", "X, Y and Z"
46
- function schemaOf(cell: string): any {
47
- const t = text(cell).replace(/\s+/g, ' ').trim();
48
- return parseType(t);
49
- }
50
- function parseType(t: string): any {
51
- t = t.trim();
52
- const arr = t.match(/^Array of (.+)$/);
53
- if (arr) return { type: 'array', items: parseType(arr[1]!) };
54
- const parts = t.split(/\s*,\s*|\s+or\s+|\s+and\s+/).filter(Boolean);
55
- if (parts.length > 1) return { oneOf: parts.map(parseType) };
56
- switch (t) {
57
- case 'Integer': case 'Int': return { type: 'integer' };
58
- case 'String': return { type: 'string' };
59
- case 'Boolean': return { type: 'boolean' };
60
- case 'True': return { type: 'boolean', enum: [true] };
61
- case 'False': return { type: 'boolean', enum: [false] };
62
- case 'Float': case 'Float number': return { type: 'number' };
63
- case 'InputFile': return { type: 'string', format: 'binary' };
64
- }
65
- if (typeNames.has(t)) return { $ref: `#/components/schemas/${t}` };
66
- throw new Error(`unknown type: ${t}`);
67
- }
68
-
69
- // quoted values the description states a field can take (“private”, “group” …)
70
- function enumOf(desc: string, schema: any): any {
71
- if (schema.type !== 'string') return schema;
72
- const d = text(desc);
73
- const m = d.match(/(?:\balways\b|\bcan be\b|\bmust be\b|\bone of\b)/);
74
- if (!m) return schema;
75
- // the rest of the sentence the phrase opens (a sentence ends at ". " before a capital, outside quotes)
76
- const rest = d.slice(m.index!);
77
- const end = rest.search(/\.\s+[A-Z]/);
78
- const sentence = end < 0 ? rest : rest.slice(0, end);
79
- const values = [...sentence.matchAll(/“([^”]+)”/g)].map((x) => x[1]!);
80
- return values.length ? { ...schema, enum: [...new Set(values)] } : schema;
81
- }
82
-
83
- const schemas: Record<string, any> = {};
84
- for (const it of items.filter(isType)) {
85
- const rows = tableRows(it.body);
86
- const desc = text(beforeTable(it.body));
87
- if (rows && rows[0]?.[0]?.trim() === 'Field') {
88
- const properties: Record<string, any> = {};
89
- const required: string[] = [];
90
- for (const r of rows.slice(1)) {
91
- const [f, ty, d] = r;
92
- const name = text(f!);
93
- const s = enumOf(d!, schemaOf(ty!));
94
- properties[name] = { ...s, description: text(d!) };
95
- if (!/^Optional\./.test(text(d!))) required.push(name);
96
- }
97
- schemas[it.name] = { type: 'object', description: desc, properties, ...(required.length ? { required } : {}) };
98
- continue;
99
- }
100
- const list = it.body.match(/<ul>([\s\S]*?)<\/ul>/);
101
- if (list && !rows) {
102
- const lis = [...list[1]!.matchAll(/<li>([\s\S]*?)<\/li>/g)].map((m) => m[1]!.trim());
103
- const refs = lis.map((li) => li.match(/^<a href="#[a-z0-9]+">([A-Za-z0-9]+)<\/a>$/)?.[1]).filter((n): n is string => Boolean(n && typeNames.has(n)));
104
- if (refs.length && refs.length === lis.length) { schemas[it.name] = { description: desc, oneOf: refs.map((n) => ({ $ref: `#/components/schemas/${n}` })) }; continue; }
105
- }
106
- // a type that holds no information yet ("Currently holds no information"), or one described in prose only
107
- schemas[it.name] = { type: 'object', description: desc };
108
- }
109
- // InputFile is the multipart upload itself, not a JSON object
110
- schemas.InputFile = { type: 'string', format: 'binary', description: schemas.InputFile?.description ?? 'This object represents the contents of a file to be uploaded.' };
111
-
112
- // the result an operation's description says is returned
113
- const RETURN_OVERRIDES: Record<string, any> = {};
114
- function resultOf(it: Item): any {
115
- if (RETURN_OVERRIDES[it.name]) return RETURN_OVERRIDES[it.name];
116
- const descHtml = beforeTable(it.body);
117
- const sentences = descHtml.split(/(?<=\.)\s+(?=[A-Z<])/).filter((s) => /return/i.test(text(s)));
118
- const found: any[] = [];
119
- const seen = new Set<string>();
120
- const add = (key: string, s: any) => { if (!seen.has(key)) { seen.add(key); found.push(s); } };
121
- for (const s of sentences) {
122
- const tx = s;
123
- for (const m of tx.matchAll(/(Array of |array of |an array of )?<a href="#([a-z0-9]+)">([A-Za-z0-9]+)<\/a>(s?)/g)) {
124
- const name = m[3]!;
125
- if (!typeNames.has(name)) continue;
126
- const isArr = Boolean(m[1]) || (m[4] === 's' && /array of/i.test(tx.slice(Math.max(0, m.index! - 40), m.index!)));
127
- add(`${isArr ? '[]' : ''}${name}`, isArr ? { type: 'array', items: { $ref: `#/components/schemas/${name}` } } : { $ref: `#/components/schemas/${name}` });
128
- }
129
- const plain = text(tx);
130
- if (/\bTrue\b/.test(plain)) add('true', { type: 'boolean', enum: [true] });
131
- if (/\bas String\b|\bString on success\b|\bin form of a String\b|new invite link as String/.test(plain)) add('string', { type: 'string' });
132
- if (/\bReturns Int(eger)?\b|\bas Int(eger)?\b|\bInt(eger)? on success\b/.test(plain)) add('integer', { type: 'integer' });
133
- if (/\bReturns String\b/.test(plain)) add('string', { type: 'string' });
134
- }
135
- if (!found.length) { console.error(`NO RESULT ${it.name}: ${text(descHtml)}`); return {}; }
136
- return found.length === 1 ? found[0] : { oneOf: found };
137
- }
138
-
139
- // the types a method's result names (by itself, as an array's items, or as one of several): each is what the Bot API
140
- // answers under `result` ("If 'ok' equals True, the request was successful and the result of the query can be found
141
- // in the 'result' field", Making requests), so each is declared a resource under its own name (`x-resourceId`, the
142
- // declaration the spec IR reads), and the IR reads every such answer under its envelope key, `answers.key: result`.
143
- // The page has no GET read to infer them from: every method is written as a POST.
144
- const answered = new Set<string>();
145
- const namedIn = (s: any): void => {
146
- if (!s || typeof s !== 'object') return;
147
- if (typeof s.$ref === 'string') answered.add(s.$ref.split('/').pop());
148
- if (s.items) namedIn(s.items);
149
- for (const alt of s.oneOf ?? []) namedIn(alt);
150
- };
151
-
152
- const TAG: Record<string, string> = {};
153
- const paths: Record<string, any> = {};
154
- const methods = items.filter(isMethod);
155
- for (const it of methods) {
156
- const rows = tableRows(it.body);
157
- const properties: Record<string, any> = {};
158
- const required: string[] = [];
159
- let hasFile = false;
160
- if (rows && rows[0]?.[0]?.trim() === 'Parameter') {
161
- for (const r of rows.slice(1)) {
162
- const [p, ty, req, d] = r;
163
- const name = text(p!);
164
- const s = enumOf(d!, schemaOf(ty!));
165
- if (/InputFile/.test(text(ty!))) hasFile = true;
166
- properties[name] = { ...s, description: text(d!) };
167
- if (text(req!) === 'Yes') required.push(name);
168
- }
169
- }
170
- const body = { type: 'object', properties, ...(required.length ? { required } : {}) };
171
- // the same parameters in the query string, none required there (a request carries each in one place or the other)
172
- const query = Object.entries(properties).map(([name, schema]) => ({ name, in: 'query', required: false, description: schema.description, schema: Object.fromEntries(Object.entries(schema).filter(([k]) => k !== 'description')) }));
173
- const result = resultOf(it);
174
- namedIn(result);
175
- paths[`/bot{token}/${it.name}`] = {
176
- post: {
177
- operationId: it.name,
178
- tags: [it.section],
179
- summary: it.name,
180
- description: text(beforeTable(it.body)),
181
- externalDocs: { url: `https://core.telegram.org/bots/api#${it.anchor}` },
182
- parameters: [{ $ref: '#/components/parameters/token' }, ...query],
183
- ...(Object.keys(properties).length
184
- ? {
185
- requestBody: {
186
- required: required.length > 0,
187
- content: {
188
- 'application/json': { schema: body },
189
- 'application/x-www-form-urlencoded': { schema: body },
190
- 'multipart/form-data': { schema: body },
191
- },
192
- },
193
- }
194
- : {}),
195
- responses: {
196
- '200': {
197
- description: 'The request was successful: the result of the query is in the result field.',
198
- content: { 'application/json': { schema: { type: 'object', required: ['ok', 'result'], properties: { ok: { type: 'boolean', enum: [true] }, result, description: { type: 'string' } } } } },
199
- },
200
- default: { $ref: '#/components/responses/Error' },
201
- },
202
- },
203
- };
204
- TAG[it.section] = it.section;
205
- }
206
- // the file download path getFile names ("https://api.telegram.org/file/bot<token>/<file_path>")
207
- paths['/file/bot{token}/{file_path}'] = {
208
- get: {
209
- operationId: 'downloadFile',
210
- tags: ['Available methods'],
211
- summary: 'Download a file',
212
- description: text(beforeTable(items.find((i) => i.name === 'getFile')!.body)),
213
- externalDocs: { url: 'https://core.telegram.org/bots/api#getfile' },
214
- parameters: [{ $ref: '#/components/parameters/token' }, { name: 'file_path', in: 'path', required: true, schema: { type: 'string' } }],
215
- responses: { '200': { description: 'The file', content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }, default: { $ref: '#/components/responses/Error' } },
216
- },
217
- };
218
-
219
- for (const name of answered) if (schemas[name]) schemas[name]['x-resourceId'] = name;
220
-
221
- const ver = text(html.match(/<strong>Bot API ([0-9.]+)<\/strong>/)?.[0] ?? html.match(/Bot API ([0-9.]+)/)?.[0] ?? '');
222
- const doc = {
223
- openapi: '3.1.0',
224
- info: {
225
- title: 'Telegram Bot API',
226
- version: ver.replace(/^Bot API /, ''),
227
- description: 'Written from https://core.telegram.org/bots/api (spec/SOURCE.md): every method and type the page documents.',
228
- },
229
- servers: [{ url: 'https://api.telegram.org' }],
230
- tags: [...new Set(methods.map((m) => m.section))].map((name) => ({ name })),
231
- paths,
232
- components: {
233
- parameters: {
234
- token: { name: 'token', in: 'path', required: true, description: "The bot's authentication token, as BotFather gives it (123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11).", schema: { type: 'string' } },
235
- },
236
- responses: {
237
- Error: {
238
- description: "The request was unsuccessful: 'ok' equals False and the error is explained in the 'description'.",
239
- content: { 'application/json': { schema: { $ref: '#/components/schemas/Error' } } },
240
- },
241
- },
242
- schemas: {
243
- Error: {
244
- type: 'object',
245
- required: ['ok', 'error_code', 'description'],
246
- properties: {
247
- ok: { type: 'boolean', enum: [false] },
248
- error_code: { type: 'integer' },
249
- description: { type: 'string' },
250
- parameters: { $ref: '#/components/schemas/ResponseParameters' },
251
- },
252
- },
253
- ...schemas,
254
- },
255
- },
256
- };
257
- writeFileSync(outPath!, JSON.stringify(doc, null, 1) + '\n');
258
- console.log(`methods ${methods.length}, types ${Object.keys(schemas).length}, version ${doc.info.version}`);