@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
@@ -1,75 +1,37 @@
1
- // What Telegram reads from an uploaded file's own bytes: an image's width and height (a PhotoSize), and a video's width,
2
- // height and duration (a Video), each from the format's header as that format defines it. Pure computation over bytes.
3
- const u16be = (b, i) => (b[i] << 8) | b[i + 1];
4
- const u32be = (b, i) => ((b[i] << 24) >>> 0) + (b[i + 1] << 16) + (b[i + 2] << 8) + b[i + 3];
5
- const u16le = (b, i) => b[i] | (b[i + 1] << 8);
6
- const ascii = (b, i, n) => String.fromCharCode(...b.subarray(i, i + n));
7
- /** An image's size from its header: PNG (IHDR), GIF (logical screen), JPEG (the first start-of-frame); undefined for
8
- * anything else (a photo Telegram takes is a .jpg, .jpeg, .gif, .png, .tif or .bmp, MessagesManager.cpp's
9
- * PHOTO_EXT_INVALID; the twin reads the first three). */
10
- export function imageSize(b) {
11
- if (b.length >= 24 && b[0] === 0x89 && ascii(b, 1, 3) === 'PNG' && ascii(b, 12, 4) === 'IHDR')
12
- return { width: u32be(b, 16), height: u32be(b, 20), mime: 'image/png' };
13
- if (b.length >= 10 && ascii(b, 0, 3) === 'GIF')
14
- return { width: u16le(b, 6), height: u16le(b, 8), mime: 'image/gif' };
15
- // a JPEG's markers are walked to its first frame header; a JPEG with none, like any other file, has no size
16
- const jpeg = b.length >= 4 && b[0] === 0xff && b[1] === 0xd8;
17
- for (let i = 2; jpeg && i + 9 < b.length; i += b[i] !== 0xff ? 1 : jpegStep(b, i)) {
18
- const marker = b[i + 1];
19
- // SOF0-SOF15 but DHT (C4), JPG (C8) and DAC (CC) carry the frame's height and width
20
- if (b[i] === 0xff && marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc)
21
- return { height: u16be(b, i + 5), width: u16be(b, i + 7), mime: 'image/jpeg' };
22
- }
23
- return undefined;
1
+ // Telegram's format acceptance and presentation policy over the shared file-header facts.
2
+ import { readImageHeader, readMp4Header } from '@volter/world-core';
3
+ /** Telegram reads PNG's IHDR, GIF's logical screen and JPEG's first frame; other formats have no photo size. */
4
+ export function imageSize(bytes) {
5
+ const image = readImageHeader(bytes);
6
+ if (!image || image.type === 'image/webp')
7
+ return undefined;
8
+ if (image.type === 'image/png' && String.fromCharCode(...bytes.subarray(12, 16)) !== 'IHDR')
9
+ return undefined;
10
+ if (image.type === 'image/jpeg' && (!image.jpeg || image.jpeg.frameEnd >= bytes.length))
11
+ return undefined;
12
+ return { width: image.width, height: image.height };
24
13
  }
25
- /** How far a JPEG's marker at `i` reaches: a marker with no length (SOI, TEM, RSTn) is its two bytes, any other its
26
- * length after them. */
27
- const jpegStep = (b, i) => {
28
- const marker = b[i + 1];
29
- return marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7) ? 2 : 2 + u16be(b, i + 2);
30
- };
31
- /** An MP4's (ISO base media) duration in whole seconds from its movie header (mvhd), and the first track with a size
32
- * from its track header (tkhd: 16.16 fixed-point width and height); undefined for a file that is not one. */
33
- export function mp4Info(b) {
34
- if (b.length < 12 || ascii(b, 4, 4) !== 'ftyp')
14
+ /** Whole-second duration from the last usable movie header, and the first track whose floored width is nonzero. */
15
+ export function mp4Info(bytes) {
16
+ if (bytes.length < 12)
17
+ return undefined;
18
+ const file = readMp4Header(bytes);
19
+ if (!file.fileType)
35
20
  return undefined;
36
21
  let duration;
37
22
  let width = 0;
38
23
  let height = 0;
39
- const walk = (start, end) => {
40
- let i = start;
41
- while (i + 8 <= end) {
42
- let size = u32be(b, i);
43
- const type = ascii(b, i + 4, 4);
44
- let header = 8;
45
- if (size === 1 && i + 16 <= end) {
46
- size = u32be(b, i + 8) * 2 ** 32 + u32be(b, i + 12);
47
- header = 16;
48
- }
49
- if (size === 0)
50
- size = end - i;
51
- if (size < header || i + size > end)
52
- return;
53
- if (type === 'moov' || type === 'trak')
54
- walk(i + header, i + size);
55
- else if (type === 'mvhd') {
56
- const v = b[i + header];
57
- const timescale = v === 1 ? u32be(b, i + header + 20) : u32be(b, i + header + 12);
58
- const units = v === 1 ? u32be(b, i + header + 24) * 2 ** 32 + u32be(b, i + header + 28) : u32be(b, i + header + 16);
59
- if (timescale)
60
- duration = Math.round(units / timescale);
61
- }
62
- else if (type === 'tkhd' && !width) {
63
- const v = b[i + header];
64
- const at = i + header + (v === 1 ? 88 : 76);
65
- if (at + 8 <= i + size) {
66
- width = u32be(b, at) >>> 16;
67
- height = u32be(b, at + 4) >>> 16;
68
- }
24
+ for (const movie of file.movies) {
25
+ for (const header of movie.headers) {
26
+ if (header.timescale)
27
+ duration = Math.round(header.duration / header.timescale);
28
+ }
29
+ for (const track of movie.tracks) {
30
+ if (!width) {
31
+ width = Math.floor(track.width);
32
+ height = Math.floor(track.height);
69
33
  }
70
- i += size;
71
34
  }
72
- };
73
- walk(0, b.length);
35
+ }
74
36
  return duration === undefined ? undefined : { width, height, duration };
75
37
  }
@@ -29,13 +29,6 @@ export declare function parseMarkdownV2(input: string): Formatted | {
29
29
  export declare function parseMarkdown(input: string): Formatted | {
30
30
  error: string;
31
31
  };
32
- /** Entities in TDLib's order: by offset, the longer first. */
33
- export declare function sortEntities(entities: Entity[]): Entity[];
34
- /** The entities the server finds in a text by itself: mentions (@name, 2-32 letters, digits and underscores, not
35
- * inside a word), bot commands (/command, 1-64, optionally @bot), hashtags (#tag with a letter), cashtags ($ABC),
36
- * links (http, https, or www.) and e-mail addresses. Where the twin stops: TDLib's own link matcher also takes bare
37
- * domains by their top-level domain, and finds bank card numbers; the twin does not. */
38
- export declare function findEntities(text: string, skipBotCommands: boolean): Entity[];
39
32
  /** What the server keeps of a formatted text (TDLib's fix_formatted_text): whitespace trimmed from both ends (the
40
33
  * entities moved and cut with it), the entities it finds itself added where they cross none given, sorted. Empty
41
34
  * is the caller's to refuse. */
@@ -446,7 +446,7 @@ export function parseMarkdown(input) {
446
446
  return { text: out, entities: sortEntities(entities) };
447
447
  }
448
448
  /** Entities in TDLib's order: by offset, the longer first. */
449
- export function sortEntities(entities) {
449
+ function sortEntities(entities) {
450
450
  return [...entities].sort((a, b) => a.offset - b.offset || b.length - a.length);
451
451
  }
452
452
  const WORD = /[\p{L}\p{N}_]/u;
@@ -455,7 +455,7 @@ const isWord = (ch) => ch !== undefined && WORD.test(ch);
455
455
  * inside a word), bot commands (/command, 1-64, optionally @bot), hashtags (#tag with a letter), cashtags ($ABC),
456
456
  * links (http, https, or www.) and e-mail addresses. Where the twin stops: TDLib's own link matcher also takes bare
457
457
  * domains by their top-level domain, and finds bank card numbers; the twin does not. */
458
- export function findEntities(text, skipBotCommands) {
458
+ function findEntities(text, skipBotCommands) {
459
459
  const found = [];
460
460
  const scan = (re, type, ok = () => true) => {
461
461
  for (const m of text.matchAll(re)) {
package/dist/src/index.js CHANGED
@@ -1,5 +1,6 @@
1
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").
2
+ // kernel derives the vendor-backed half from the generated surface, unless the manifest declares none (vendorBacked.none;
3
+ // architecture, "The real-system adapters").
3
4
  import { packOf, registerPack } from '@volter/world-core';
4
5
  import { manifest } from "./manifest.js";
5
6
  import surface from './generated/surface.gen.json' with { type: 'json' };
@@ -36,11 +36,12 @@ export const manifest = {
36
36
  // 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
37
37
  // assigns, kept as the subject's id and as its own `id`
38
38
  // source: https://core.telegram.org/bots/api#user "Unique identifier for this user or bot."
39
- 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' } },
39
+ user: { idPrefix: '58104400', ids: '{prefix}{n}', unique: [{ fields: ['_username'], refusal: { status: 409, message: 'This username is taken.' } }], 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' } },
40
40
  // a chat as getChat answers it (ChatFullInfo); a private chat's id is its person's, a group's and a channel's are
41
41
  // the vendor's negative ids (minted by the doors that make them)
42
42
  // source: https://core.telegram.org/bots/api#chat "Unique identifier for this chat."
43
- chat: { idPrefix: '', ...states.chat, refresh: { get: 'getChat', known: true, query: { chat_id: '{id}' } } },
43
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Bad Request: chat not found"
44
+ chat: { idPrefix: '', ...states.chat, refresh: { get: 'getChat', known: true, query: { chat_id: '{id}' }, gone: { status: 400, says: 'chat not found' } } },
44
45
  // one subject per member of a chat, `<chat>:<user>`, stored as getChatMember answers it
45
46
  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' } },
46
47
  // a message, `<chat>:<message_id>`; its message_id counts per chat
@@ -53,7 +54,7 @@ export const manifest = {
53
54
  // 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."
54
55
  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' } },
55
56
  // a bot's own keeping: its token's SHA-256 and its owner, its updates' counter and the allowed_updates it last asked for
56
- _bot: { idPrefix: '' },
57
+ _bot: { idPrefix: '', unique: [{ fields: ['id'], refusal: { status: 409, message: 'This bot already has credentials.' } }] },
57
58
  // the ids the vendor assigns to a basic group and to a channel or supergroup, and a file's download path
58
59
  _group: { idPrefix: '-49870000', ids: '{prefix}{n}' },
59
60
  _channel: { idPrefix: '-10019876540', ids: '{prefix}{n}' },
@@ -88,15 +89,15 @@ export const manifest = {
88
89
  bin: 'world-telegram',
89
90
  resources: ['user', 'chat', 'chat_member', 'message', 'update', 'file'],
90
91
  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)',
91
- 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.",
92
+ 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 calls them.",
92
93
  adoption: { sdks: ['@chat-adapter/telegram', 'node-telegram-bot-api', 'telegraf'], envStems: ['TELEGRAM'] },
93
94
  // the libraries above name Telegram in an application; none is Telegram's own
94
95
  // source: https://core.telegram.org/bots/samples "these projects are not maintained by Telegram"
95
- 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)',
96
+ 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)',
96
97
  hosts: [{ host: 'api.telegram.org' }],
97
98
  // 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)
98
99
  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" },
99
- credentialDoor: { path: '/_twin/bots', body: {}, fill: { TELEGRAM_BOT_TOKEN: 'token', TELEGRAM_TOKEN: 'token', TELEGRAM_BOT_USERNAME: 'username', TELEGRAM_BOT_NAME: 'username' } },
100
+ credentialDoor: { account: { field: 'account', tenancy: 'Telegram person', source: 'https://core.telegram.org/bots/features#botfather' }, deployment: { field: 'deployment', object: 'Bot', source: 'https://core.telegram.org/bots/features#botfather' }, path: '/_twin/bots', body: {}, fill: { TELEGRAM_BOT_TOKEN: 'token', TELEGRAM_TOKEN: 'token', TELEGRAM_BOT_USERNAME: 'username', TELEGRAM_BOT_NAME: 'username' } },
100
101
  // the bot token rides in the path, after /bot (the API's) or /file/bot (a file's download)
101
102
  // source: https://core.telegram.org/bots/api#making-requests "https://api.telegram.org/bot<token>/METHOD_NAME"
102
103
  auth: { in: 'path', pattern: '/(?:file/)?bot([^/]+)/' },
@@ -1,5 +1,5 @@
1
1
  import { imageSize, mp4Info } from "../engine/media.js";
2
- import { answer, botOf, chatFor, chatFullView, fileBytes, fileById, formatted, jsonParam, memberKey, memberView, messageView, param, refuse, replyTarget, storeBotMessage, storeFile, textFields, uploadOf, } from "./shared.js";
2
+ import { answer, botOf, chatFor, chatFullView, fileBytes, fileById, formatted, jsonParam, memberKey, memberView, messageView, param, refuse, replyTarget, storeBotMessage, storeFile, photoThumbnails, textFields, uploadOf, } from "./shared.js";
3
3
  // source: https://core.telegram.org/bots/api#sendmessage "Text of the message to be sent, 1-4096 characters after entities parsing"
4
4
  const TEXT_MAX = 4096;
5
5
  // 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"
@@ -48,11 +48,8 @@ function captionOf(ctx, caption, parseMode, entities) {
48
48
  return { refusal: refuse(ctx, 400, 'Bad Request: message caption is too long') };
49
49
  return textFields(text, 'caption');
50
50
  }
51
- /** 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
52
- * length, a document's name and type. */
53
- async function mediaFields(ctx, botId, kind, upload, given) {
54
- const operation = ctx.call.operation.id;
55
- let file;
51
+ /** Read and validate a medium without creating files, thumbnails or messages. */
52
+ async function prepareMedia(ctx, kind, upload, given) {
56
53
  let bytes;
57
54
  if ('file' in upload) {
58
55
  // a file sent again keeps its type: a photo is not sent as a document, nor a video as a photo
@@ -63,50 +60,58 @@ async function mediaFields(ctx, botId, kind, upload, given) {
63
60
  const wanted = kind === 'photo' ? 'Photo' : kind === 'video' ? 'Video' : 'Document';
64
61
  if (real !== wanted)
65
62
  return { refusal: refuse(ctx, 400, `Bad Request: can't use file of type ${real} as ${wanted}`) };
66
- file = upload.file;
67
- bytes = await fileBytes(ctx, file);
63
+ bytes = await fileBytes(ctx, upload.file);
68
64
  }
69
- else {
65
+ else
70
66
  bytes = upload.bytes;
71
- // source: https://core.telegram.org/bots/api#sendphoto "The photo must be at most 10 MB in size."
72
- // 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."
73
- // 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."
74
- // Where the documentation stops: the words for a file over its limit are the server's for a file too big
75
- // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Bad Request: file is too big"
76
- if (bytes.byteLength > (kind === 'photo' ? 10 : 50) * 1024 * 1024)
77
- return { refusal: refuse(ctx, 400, 'Bad Request: file is too big') };
78
- if (kind === 'photo' && !imageSize(bytes)) {
67
+ // source: https://core.telegram.org/bots/api#sendphoto "The photo must be at most 10 MB in size."
68
+ // 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."
69
+ // 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."
70
+ // Where the documentation stops: the words for a file over its limit are the server's for a file too big
71
+ // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Bad Request: file is too big"
72
+ if (!('file' in upload) && bytes.byteLength > (kind === 'photo' ? 10 : 50) * 1024 * 1024)
73
+ return { refusal: refuse(ctx, 400, 'Bad Request: file is too big') };
74
+ const size = kind === 'photo' ? imageSize(bytes) : undefined;
75
+ if (!('file' in upload)) {
76
+ if (kind === 'photo' && !size) {
79
77
  // an upload that is no image Telegram can read: Telegram's own error for it, which the server relays as it is (its
80
78
  // second character an underscore or a capital)
81
79
  // source: https://core.telegram.org/method/messages.sendMedia "Failure while processing image."
82
80
  // 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
81
  return { refusal: refuse(ctx, 400, 'Bad Request: IMAGE_PROCESS_FAILED') };
84
82
  }
85
- const size = kind === 'photo' ? imageSize(bytes) : undefined;
86
83
  // 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
84
  // (Telegram's own error for it, relayed as it is: an MTProto error's second character is a capital)
88
85
  if (size && (size.width + size.height > 10000 || Math.max(size.width, size.height) > 20 * Math.max(1, Math.min(size.width, size.height))))
89
86
  return { refusal: refuse(ctx, 400, 'Bad Request: PHOTO_INVALID_DIMENSIONS') };
90
- 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);
91
- }
92
- const ids = { file_id: file.file_id, file_unique_id: file.file_unique_id };
93
- if (kind === 'photo') {
94
- const size = imageSize(bytes) ?? { width: 0, height: 0 };
95
- // Where the documentation stops: the page gives a photo's sizes as "Available sizes of the photo" and names none;
96
- // the twin keeps the one it was given
97
- // source: https://core.telegram.org/bots/api#message "Optional. Message is a photo, available sizes of the photo"
98
- return { photo: [{ ...ids, file_size: bytes.byteLength, width: size.width, height: size.height }] };
99
87
  }
100
- const name = typeof file._name === 'string' ? file._name : undefined;
101
- const mime = typeof file._mime === 'string' ? file._mime : undefined;
88
+ if (kind === 'photo')
89
+ return { kind, upload, bytes, size: size ?? { width: 0, height: 0 } };
90
+ const name = 'file' in upload ? upload.file._name : upload.name;
91
+ const mime = 'file' in upload ? upload.file._mime : upload.mime;
92
+ const fields = { ...(typeof name === 'string' && name ? { file_name: name } : {}), ...(typeof mime === 'string' && mime ? { mime_type: mime } : {}), file_size: bytes.byteLength };
102
93
  if (kind === 'video') {
103
94
  const info = mp4Info(bytes);
104
95
  // the request's own width, height and duration, else the file's
105
96
  // source: https://core.telegram.org/bots/api#sendvideo "Duration of sent video in seconds"
106
97
  const num = (v, fallback) => (typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v) ? Number(v) : fallback);
107
- 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 } };
98
+ return { kind, upload, bytes, fields: { width: num(given.width, info?.width ?? 0), height: num(given.height, info?.height ?? 0), duration: num(given.duration, info?.duration ?? 0), ...fields } };
108
99
  }
109
- return { document: { ...ids, ...(name ? { file_name: name } : {}), ...(mime ? { mime_type: mime } : {}), file_size: bytes.byteLength } };
100
+ return { kind, upload, bytes, fields };
101
+ }
102
+ /** Persist a validated medium and assemble its message fields. */
103
+ async function mediaFields(ctx, botId, media) {
104
+ const operation = ctx.call.operation.id;
105
+ const upload = media.upload;
106
+ const file = 'file' in upload ? upload.file : await storeFile(ctx, media.bytes, { kind: media.kind === 'photo' ? 'photos' : media.kind === 'video' ? 'videos' : 'documents', bot: botId, ...(upload.mime ? { mime: upload.mime } : {}), ...(upload.name ? { name: upload.name } : {}) }, operation);
107
+ const ids = { file_id: file.file_id, file_unique_id: file.file_unique_id };
108
+ if (media.kind === 'photo') {
109
+ // its smaller sizes first, then the photo as it was given
110
+ // source: https://core.telegram.org/bots/api#message "Optional. Message is a photo, available sizes of the photo"
111
+ const thumbnails = await photoThumbnails(ctx, file, media.size.width, media.size.height, operation);
112
+ return { photo: [...thumbnails, { ...ids, file_size: media.bytes.byteLength, ...media.size }] };
113
+ }
114
+ return { [media.kind]: { ...ids, ...media.fields } };
110
115
  }
111
116
  /** One medium sent to a chat with its caption. */
112
117
  async function sendMedium(ctx, kind) {
@@ -123,10 +128,11 @@ async function sendMedium(ctx, kind) {
123
128
  const reply = replyTarget(ctx, access.chat, botId);
124
129
  if ('refusal' in reply)
125
130
  return reply.refusal;
126
- const media = await mediaFields(ctx, botId, kind, upload, { width: param(ctx, 'width'), height: param(ctx, 'height'), duration: param(ctx, 'duration') });
131
+ const media = await prepareMedia(ctx, kind, upload, { width: param(ctx, 'width'), height: param(ctx, 'height'), duration: param(ctx, 'duration') });
127
132
  if ('refusal' in media)
128
133
  return media.refusal;
129
- const row = await storeBotMessage(ctx, botId, access.chat, { ...media, ...caption }, reply.key, ctx.call.operation.id);
134
+ const fields = await mediaFields(ctx, botId, media);
135
+ const row = await storeBotMessage(ctx, botId, access.chat, { ...fields, ...caption }, reply.key, ctx.call.operation.id);
130
136
  return answer(messageView(ctx, row));
131
137
  }
132
138
  // source: https://core.telegram.org/bots/api#sendphoto "Use this method to send photos. On success, the sent Message is returned."
@@ -180,16 +186,17 @@ export async function sendMediaGroup(ctx) {
180
186
  const caption = captionOf(ctx, item.caption, item.parse_mode, item.caption_entities);
181
187
  if ('refusal' in caption)
182
188
  return caption.refusal;
183
- prepared.push({ kind: item.type, upload, caption, item });
189
+ const medium = await prepareMedia(ctx, item.type, upload, { width: item.width, height: item.height, duration: item.duration });
190
+ if ('refusal' in medium)
191
+ return medium.refusal;
192
+ prepared.push({ media: medium, caption });
184
193
  }
185
194
  // the album's one media_group_id, the World's for it
186
195
  // source: https://core.telegram.org/bots/api#message "Optional. The unique identifier inside this chat of a media message group this message belongs to"
187
196
  const group = await ctx.issue('_media_group');
188
197
  const sent = [];
189
198
  for (const p of prepared) {
190
- const fields = await mediaFields(ctx, botId, p.kind, p.upload, { width: p.item.width, height: p.item.height, duration: p.item.duration });
191
- if ('refusal' in fields)
192
- return fields.refusal;
199
+ const fields = await mediaFields(ctx, botId, p.media);
193
200
  const row = await storeBotMessage(ctx, botId, access.chat, { media_group_id: group, ...fields, ...p.caption }, reply.key, ctx.call.operation.id);
194
201
  sent.push(messageView(ctx, row));
195
202
  }
@@ -216,7 +223,7 @@ export async function getChat(ctx) {
216
223
  const access = chatFor(ctx, botId, param(ctx, 'chat_id'), 'read');
217
224
  if ('refusal' in access)
218
225
  return access.refusal;
219
- return answer(chatFullView(ctx, access.chat));
226
+ return answer(chatFullView(access.chat));
220
227
  }
221
228
  // source: https://core.telegram.org/bots/api#getchatmember "Use this method to get information about a member of a chat."
222
229
  export async function getChatMember(ctx) {
@@ -239,6 +246,9 @@ export async function getFile(ctx) {
239
246
  // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "invalid file_id"
240
247
  if (!file)
241
248
  return refuse(ctx, 400, 'Bad Request: invalid file_id');
249
+ // a thumbnail's pixels are not rendered by the twin
250
+ if (file._thumbnail_of !== undefined)
251
+ return ctx.gap();
242
252
  // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Bad Request: file is too big"
243
253
  if (Number(file.file_size ?? 0) > 20 * 1024 * 1024)
244
254
  return refuse(ctx, 400, 'Bad Request: file is too big');
@@ -255,9 +265,9 @@ export async function getFile(ctx) {
255
265
  }
256
266
  // 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."
257
267
  export async function downloadFile(ctx) {
258
- botOf(ctx);
268
+ const { id: botId } = botOf(ctx);
259
269
  const path = String(ctx.call.params.file_path ?? '');
260
- const file = ctx.rowsRaw('file').find((r) => r.deleted !== true && r.file_path === path);
270
+ const file = ctx.rowsRaw('file').filter((r) => r.file_path === path).map((r) => fileById(ctx, r.file_id, botId)).find((r) => r?.file_path === path);
261
271
  // source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/HttpConnection.cpp "return send_http_error(404,"
262
272
  if (!file)
263
273
  return refuse(ctx, 404, 'Not Found');
@@ -1,5 +1,4 @@
1
1
  import type { HandlerContext } from '@volter/world-core';
2
- export type { HandlerContext };
3
2
  /** A Telegram account, as a person signs up in the app. */
4
3
  export declare function people(ctx: HandlerContext): Promise<Response>;
5
4
  /** BotFather's /newbot: a bot (its name, its username ending in "bot") and its token; the same username answers the
@@ -16,14 +16,91 @@ export async function people(ctx) {
16
16
  const row = await ctx.create('user', (id) => ({
17
17
  id: Number(id), is_bot: false, first_name: first,
18
18
  ...(str(ctx.params.last_name) ? { last_name: str(ctx.params.last_name) } : {}),
19
- ...(username ? { username } : {}),
19
+ ...(username ? { username, _username: username.toLowerCase() } : {}),
20
20
  ...(str(ctx.params.language_code) ? { language_code: str(ctx.params.language_code) } : {}),
21
21
  }), 'people');
22
22
  return Response.json(userView(ctx, row.id ?? ctx.own(row).id));
23
23
  }
24
+ /** A metadata-issued bot belongs to the first member's Telegram person. */
25
+ async function applicationBot(ctx) {
26
+ const owner = await ctx.tenant('user', async (world) => {
27
+ const email = world.members[0].toLowerCase();
28
+ const existing = ctx.rowsRaw('user').find((row) => row.is_bot !== true && row._email === email);
29
+ return existing ? String(existing.id) : String((await ctx.create('user', (id) => ({
30
+ id: Number(id), is_bot: false, first_name: world.label, _email: email,
31
+ }), 'people')).id);
32
+ });
33
+ const organization = await ctx.account();
34
+ const bot = await ctx.deploymentObject(`bot:${owner.id}`, 'user', async (deployment) => {
35
+ const explicit = str(ctx.params.username)?.replace(/^@/, '');
36
+ const label = (explicit ?? deployment) || 'world_twin';
37
+ const base = label.replace(/_?bot$/i, '') || 'world';
38
+ const identity = deployment ? `${organization}/${deployment}` : organization;
39
+ for (const suffixed of [false, true]) {
40
+ const username = explicit && !suffixed && /^[A-Za-z0-9_]{5,32}$/.test(explicit) && /bot$/i.test(explicit) ? explicit
41
+ : `${ctx.vendorName(base, { max: 28, lower: true, allowed: /[a-z0-9_]/, separator: '_' }, { suffixed, identity })}_bot`;
42
+ if (userByName(ctx, username))
43
+ continue;
44
+ const made = await ctx.createUnique('user', (id) => ({ id: Number(id), is_bot: true,
45
+ first_name: str(ctx.params.name) ?? (deployment || 'World Twin Bot'), username, _username: username.toLowerCase(),
46
+ can_join_groups: true, can_read_all_group_messages: false, supports_inline_queries: false, can_connect_to_business: false,
47
+ has_main_web_app: false, has_topics_enabled: false }), 'bots');
48
+ if (made.created) {
49
+ const id = String(made.row.id);
50
+ await ctx.record('_bot', { owner: owner.id, generation: 1, update_seq: 836_000_000 }, id);
51
+ return id;
52
+ }
53
+ }
54
+ throw bad(`A bot username already exists for ${identity}.`, 409);
55
+ });
56
+ const key = await ctx.accountKey(`bot-token:${bot.id}`, '_bot', {
57
+ make: async () => {
58
+ const id = String(bot.id);
59
+ const before = ctx.row('_bot', id);
60
+ if (!before) {
61
+ const retired = ctx.row('_bot', id, { withDeleted: true });
62
+ const generation = Number(retired?.generation ?? 0) + 1;
63
+ const token = await issueToken(ctx, id, generation);
64
+ await ctx.createUnique('_bot', {
65
+ id, owner: owner.id, generation, token_sha256: ctx.crypto.sha256(token),
66
+ update_seq: retired?.update_seq ?? 836_000_000, deleted: false, _pending: false,
67
+ }, 'bot.token', { id, input: 'omit' });
68
+ return id;
69
+ }
70
+ const current = await issueToken(ctx, String(bot.id), Number(before.generation ?? 1));
71
+ if (before.token_sha256 === ctx.crypto.sha256(current))
72
+ return String(bot.id);
73
+ await ctx.change('_bot', String(bot.id), (row) => row._pending === true || row.token_sha256 !== before.token_sha256 ? undefined
74
+ : { generation: row.token_sha256 === undefined ? Number(row.generation ?? 1) : Number(row.generation ?? 1) + 1, _pending: true }, 'bot.token.reserve');
75
+ const native = ctx.row('_bot', String(bot.id));
76
+ const token = await issueToken(ctx, String(bot.id), Number(native.generation));
77
+ await ctx.change('_bot', String(bot.id), (row) => row.generation === native.generation
78
+ ? { token_sha256: ctx.crypto.sha256(token), _pending: false } : undefined, 'bot.token');
79
+ return String(bot.id);
80
+ },
81
+ read: async (row) => {
82
+ if (row.owner !== owner.id || row._pending === true)
83
+ return undefined;
84
+ const token = await issueToken(ctx, String(bot.id), Number(row.generation));
85
+ return row.token_sha256 === ctx.crypto.sha256(token) ? token : undefined;
86
+ },
87
+ });
88
+ return Response.json({ id: Number(bot.id), name: bot.first_name, username: bot.username, token: key.secret });
89
+ }
24
90
  /** BotFather's /newbot: a bot (its name, its username ending in "bot") and its token; the same username answers the
25
91
  * bot made before and its token as it stands, so a World's credential door answers the same token at every boot. */
26
92
  export async function bots(ctx) {
93
+ const body = ctx.body && typeof ctx.body === 'object' ? ctx.body : {};
94
+ if (body.account !== undefined || body.deployment !== undefined) {
95
+ try {
96
+ return await applicationBot(ctx);
97
+ }
98
+ catch (error) {
99
+ if (error instanceof Response)
100
+ return error;
101
+ throw error;
102
+ }
103
+ }
27
104
  // 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'."
28
105
  const username = (str(ctx.params.username) ?? 'world_twin_bot').replace(/^@/, '');
29
106
  if (!/^[A-Za-z0-9_]{5,32}$/.test(username) || !/bot$/i.test(username))
@@ -39,7 +116,7 @@ export async function bots(ctx) {
39
116
  // was not set up for
40
117
  // source: https://core.telegram.org/bots/features#privacy-mode "Privacy mode is enabled by default for all bots"
41
118
  const row = await ctx.create('user', (id) => ({
42
- id: Number(id), is_bot: true, first_name: name, username,
119
+ id: Number(id), is_bot: true, first_name: name, username, _username: username.toLowerCase(),
43
120
  can_join_groups: true, can_read_all_group_messages: false, supports_inline_queries: false, can_connect_to_business: false,
44
121
  has_main_web_app: false, has_topics_enabled: false,
45
122
  }), 'bots');
@@ -108,11 +185,11 @@ async function moveMember(ctx, chat, by, user, to, rights, operation) {
108
185
  const after = ctx.row('chat_member', key);
109
186
  if (ctx.row('user', String(user))?.is_bot === true && user !== by) {
110
187
  // source: https://core.telegram.org/bots/api#update "Optional. The bot's chat member status was updated in a chat."
111
- 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);
188
+ 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);
112
189
  }
113
190
  return after;
114
191
  }
115
- const chatViewOf = (ctx, chat) => Object.fromEntries(Object.entries(chatFullView(ctx, chat)).filter(([k]) => ['id', 'type', 'title', 'username', 'first_name', 'last_name'].includes(k)));
192
+ const chatViewOf = (chat) => Object.fromEntries(Object.entries(chatFullView(chat)).filter(([k]) => ['id', 'type', 'title', 'username', 'first_name', 'last_name'].includes(k)));
116
193
  /** A message a person (or the app, for a service message) writes in a chat, kept and delivered to the bots it reaches. */
117
194
  async function post(ctx, chat, fields, from, operation, extra = {}) {
118
195
  const chatKey = String(chat.id);
@@ -191,7 +268,7 @@ export async function chats(ctx) {
191
268
  // source: https://core.telegram.org/bots/api#message "Optional. Service message: the group has been created"
192
269
  const created = type === 'group' ? { group_chat_created: true } : type === 'supergroup' ? { supergroup_chat_created: true } : { channel_chat_created: true };
193
270
  await post(ctx, chat(), created, creator, operation);
194
- return Response.json(chatFullView(ctx, chat()));
271
+ return Response.json(chatFullView(chat()));
195
272
  }
196
273
  /** A person adds a member to a chat, promotes one, or removes one (kicked); a group is told by a service message who
197
274
  * joined or was removed. */
@@ -1,7 +1,6 @@
1
1
  import type { HandlerContext } from '@volter/world-core';
2
2
  import { type Formatted } from '../engine/text.js';
3
3
  export type Row = Record<string, unknown>;
4
- export type { HandlerContext };
5
4
  export declare function answer(result: unknown, description?: string): Response;
6
5
  /** A refusal in the Bot API's envelope ({ ok: false, error_code, description }), the HTTP status its code. */
7
6
  export declare function refuse(ctx: HandlerContext, status: number, description: string): Response;
@@ -14,8 +13,6 @@ export declare function jsonParam(ctx: HandlerContext, name: string): {
14
13
  value?: unknown;
15
14
  error?: true;
16
15
  };
17
- /** The vendor's own fields of a stored row: the kernel's bookkeeping (`_` fields, a tombstone) left out. */
18
- export declare function vendorFields(ctx: HandlerContext, row: Row): Row;
19
16
  /** The methods this pack serves, as the page names them: a request naming one in another case is routed to it. */
20
17
  export declare const SERVED_METHODS: string[];
21
18
  /** The token in a request's path: `/bot<token>/<method>`, or `/file/bot<token>/<path>` for a file. */
@@ -36,8 +33,6 @@ export declare function botOf(ctx: HandlerContext): {
36
33
  export declare function issueToken(ctx: HandlerContext, botId: string, generation: number): Promise<string>;
37
34
  /** A User as it appears in a message or an update: the bot-only fields are getMe's alone. */
38
35
  export declare function userView(ctx: HandlerContext, id: unknown): Row;
39
- /** A Chat as a message names it: its id, type and names. */
40
- export declare function chatView(ctx: HandlerContext, id: unknown): Row;
41
36
  /** A ChatMember: its status, its user and what the status carries. */
42
37
  export declare function memberView(ctx: HandlerContext, row: Row | undefined, user: unknown): Row;
43
38
  /** A chat member's row (`<chat>:<user>`). */
@@ -46,7 +41,7 @@ export declare const memberKey: (chat: unknown, user: unknown) => string;
46
41
  * carries no reply of its own. */
47
42
  export declare function messageView(ctx: HandlerContext, row: Row, nested?: boolean): Row;
48
43
  /** A ChatFullInfo as getChat answers it, from a chat as chatOf reads it (its own id and type already restored). */
49
- export declare function chatFullView(_ctx: HandlerContext, chat: Row): Row;
44
+ export declare function chatFullView(chat: Row): Row;
50
45
  /** The chat a chat_id names: an integer id or a public chat's @username. */
51
46
  export declare function findChat(ctx: HandlerContext, chatId: unknown): Row | undefined;
52
47
  /** A chat as stored, its own `id` and `type` (the vendor's, which the kernel keeps apart from its own) restored. */
@@ -86,6 +81,15 @@ export declare function storeFile(ctx: HandlerContext, bytes: Uint8Array, meta:
86
81
  name?: string;
87
82
  bot?: number;
88
83
  }, operation: string): Promise<Row>;
84
+ /** The smaller sizes Telegram makes of an uploaded photo, smallest first: each size type whose box is smaller than the
85
+ * photo's longer side, the photo scaled to fit it (the rounding is the twin's). A thumbnail is kept by its ids and
86
+ * dimensions only: the twin renders no thumbnail's pixels, so its file is a gap. */
87
+ export declare function photoThumbnails(ctx: HandlerContext, original: Row, width: number, height: number, operation: string): Promise<Array<{
88
+ file_id: unknown;
89
+ file_unique_id: unknown;
90
+ width: number;
91
+ height: number;
92
+ }>>;
89
93
  /** 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
90
94
  * bot's that reads the chat). */
91
95
  export declare function fileById(ctx: HandlerContext, fileId: unknown, botId: number): Row | undefined;