@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.
- package/README.md +2 -2
- package/dist/journeys/customer-life.json +21 -21
- package/dist/journeys/customer-life.outline.md +17 -17
- package/dist/journeys/customer-life.outline.verdict.json +17 -52
- package/dist/journeys/customer-life.verdict.json +24 -98
- package/dist/journeys/decisions.json +49 -13
- package/dist/journeys/demand.json +0 -91
- package/dist/journeys/first-use.json +21 -0
- package/dist/journeys/unreachable.json +1 -1
- package/dist/journeys/vendor-examples.json +14 -6
- package/dist/spec/SOURCE.md +5 -6
- package/dist/spec/patches.json +20 -20
- package/dist/spec/sources.json +15 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/engine/media.d.ts +6 -12
- package/dist/src/engine/media.js +28 -66
- package/dist/src/engine/text.d.ts +0 -7
- package/dist/src/engine/text.js +2 -2
- package/dist/src/index.js +2 -1
- package/dist/src/manifest.js +7 -6
- package/dist/src/semantics/available-methods.js +50 -40
- package/dist/src/semantics/doors.d.ts +0 -1
- package/dist/src/semantics/doors.js +82 -5
- package/dist/src/semantics/shared.d.ts +10 -6
- package/dist/src/semantics/shared.js +29 -8
- package/dist/src/server.js +2 -19
- package/generated/pack-facts.json +10 -0
- package/journeys/customer-life.json +21 -21
- package/journeys/customer-life.outline.md +17 -17
- package/journeys/customer-life.outline.verdict.json +17 -52
- package/journeys/customer-life.verdict.json +24 -98
- package/journeys/decisions.json +49 -13
- package/journeys/demand.json +0 -91
- package/journeys/first-use.json +21 -0
- package/journeys/unreachable.json +1 -1
- package/journeys/vendor-examples.json +14 -6
- package/package.json +12 -9
- package/spec/SOURCE.md +5 -6
- package/spec/patches.json +20 -20
- package/spec/sources.json +15 -0
- package/src/cli.ts +1 -1
- package/src/engine/media.ts +21 -58
- package/src/engine/text.ts +2 -2
- package/src/index.ts +2 -1
- package/src/manifest.ts +7 -6
- package/src/semantics/available-methods.ts +55 -41
- package/src/semantics/doors.ts +74 -8
- package/src/semantics/shared.ts +27 -8
- package/src/server.ts +2 -11
- package/dist/generated/pack-facts.json +0 -53
- package/dist/spec/page-to-openapi.ts +0 -258
- package/spec/page-to-openapi.ts +0 -258
package/dist/src/engine/media.js
CHANGED
|
@@ -1,75 +1,37 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
|
|
47
|
-
|
|
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. */
|
package/dist/src/engine/text.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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' };
|
package/dist/src/manifest.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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)
|
|
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
|
-
/**
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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').
|
|
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(
|
|
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 = (
|
|
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(
|
|
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(
|
|
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;
|