@volter/twin-telegram 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +53 -0
- package/dist/generated/pack-facts.json +53 -0
- package/dist/journeys/coverage.json +138 -0
- package/dist/journeys/customer-life.json +1229 -0
- package/dist/journeys/customer-life.outline.md +99 -0
- package/dist/journeys/customer-life.outline.verdict.json +76 -0
- package/dist/journeys/customer-life.verdict.json +134 -0
- package/dist/journeys/decisions.json +95 -0
- package/dist/journeys/demand.json +152 -0
- package/dist/journeys/score.json +163 -0
- package/dist/journeys/unreachable.json +29 -0
- package/dist/journeys/vendor-examples.json +1554 -0
- package/dist/journeys/vendor-examples.verdict.json +14 -0
- package/dist/spec/SOURCE.md +51 -0
- package/dist/spec/bot-api.html.gz +0 -0
- package/dist/spec/doc-examples.json +344 -0
- package/dist/spec/grouping.json +1 -0
- package/dist/spec/openapi.json.gz +0 -0
- package/dist/spec/page-to-openapi.ts +258 -0
- package/dist/spec/patches.json +289 -0
- package/dist/spec/sources.json +416 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +21 -0
- package/dist/src/engine/media.d.ts +17 -0
- package/dist/src/engine/media.js +75 -0
- package/dist/src/engine/text.d.ts +45 -0
- package/dist/src/engine/text.js +512 -0
- package/dist/src/fetch.d.ts +2 -0
- package/dist/src/fetch.js +17 -0
- package/dist/src/generated/surface.gen.json +1 -0
- package/dist/src/generated/ui.gen.json +1 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +9 -0
- package/dist/src/manifest.d.ts +2 -0
- package/dist/src/manifest.js +104 -0
- package/dist/src/semantics/around.d.ts +2 -0
- package/dist/src/semantics/around.js +32 -0
- package/dist/src/semantics/available-methods.d.ts +12 -0
- package/dist/src/semantics/available-methods.js +266 -0
- package/dist/src/semantics/doors.d.ts +18 -0
- package/dist/src/semantics/doors.js +292 -0
- package/dist/src/semantics/getting-updates.d.ts +3 -0
- package/dist/src/semantics/getting-updates.js +68 -0
- package/dist/src/semantics/index.d.ts +2 -0
- package/dist/src/semantics/index.js +19 -0
- package/dist/src/semantics/shared.d.ts +112 -0
- package/dist/src/semantics/shared.js +424 -0
- package/dist/src/semantics/states.d.ts +18 -0
- package/dist/src/semantics/states.js +37 -0
- package/dist/src/semantics/updating-messages.d.ts +2 -0
- package/dist/src/semantics/updating-messages.js +48 -0
- package/dist/src/server.d.ts +7 -0
- package/dist/src/server.js +24 -0
- package/generated/pack-facts.json +53 -0
- package/journeys/coverage.json +138 -0
- package/journeys/customer-life.json +1229 -0
- package/journeys/customer-life.outline.md +99 -0
- package/journeys/customer-life.outline.verdict.json +76 -0
- package/journeys/customer-life.verdict.json +134 -0
- package/journeys/decisions.json +95 -0
- package/journeys/demand.json +152 -0
- package/journeys/score.json +163 -0
- package/journeys/unreachable.json +29 -0
- package/journeys/vendor-examples.json +1554 -0
- package/journeys/vendor-examples.verdict.json +14 -0
- package/package.json +37 -0
- package/spec/SOURCE.md +51 -0
- package/spec/bot-api.html.gz +0 -0
- package/spec/doc-examples.json +344 -0
- package/spec/grouping.json +1 -0
- package/spec/openapi.json.gz +0 -0
- package/spec/page-to-openapi.ts +258 -0
- package/spec/patches.json +289 -0
- package/spec/sources.json +416 -0
- package/src/cli.ts +22 -0
- package/src/engine/media.ts +67 -0
- package/src/engine/text.ts +409 -0
- package/src/fetch.ts +18 -0
- package/src/generated/surface.gen.json +1 -0
- package/src/generated/ui.gen.json +1 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +114 -0
- package/src/semantics/around.ts +33 -0
- package/src/semantics/available-methods.ts +253 -0
- package/src/semantics/doors.ts +277 -0
- package/src/semantics/getting-updates.ts +64 -0
- package/src/semantics/index.ts +23 -0
- package/src/semantics/shared.ts +428 -0
- package/src/semantics/states.ts +38 -0
- package/src/semantics/updating-messages.ts +41 -0
- package/src/server.ts +17 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// The World's doors (architecture, "Doors, screens and the gap"): what people do in the Telegram app and in BotFather,
|
|
2
|
+
// which no Bot API method does, each declared in the manifest's `doors` and answered here by the export its id names.
|
|
3
|
+
// A person signs up, makes a bot (and its token again), makes a chat, adds, promotes or removes a member, and writes a
|
|
4
|
+
// message; what a bot is told of it is the update each write puts in its queue, as the Bot API would deliver it.
|
|
5
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
6
|
+
import { finish } from '../engine/text.ts';
|
|
7
|
+
import {
|
|
8
|
+
chatFullView, chatOf, findChat, issueToken, memberKey, memberView, messageView, nextMessageId, queueUpdate, recipientsOf, storeFile, unixOf, userView,
|
|
9
|
+
type Row,
|
|
10
|
+
} from './shared.ts';
|
|
11
|
+
|
|
12
|
+
export type { HandlerContext };
|
|
13
|
+
|
|
14
|
+
const bad = (error: string, status = 400): Response => Response.json({ error }, { status });
|
|
15
|
+
const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
16
|
+
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : typeof v === 'string' && /^-?\d+$/.test(v) ? Number(v) : undefined);
|
|
17
|
+
const userByName = (ctx: HandlerContext, name: string): Row | undefined =>
|
|
18
|
+
ctx.rowsRaw('user').find((u) => u.deleted !== true && typeof u.username === 'string' && u.username.toLowerCase() === name.replace(/^@/, '').toLowerCase());
|
|
19
|
+
|
|
20
|
+
/** A Telegram account, as a person signs up in the app. */
|
|
21
|
+
export async function people(ctx: HandlerContext): Promise<Response> {
|
|
22
|
+
const first = str(ctx.params.first_name);
|
|
23
|
+
if (!first) return bad('first_name is required');
|
|
24
|
+
const username = str(ctx.params.username)?.replace(/^@/, '');
|
|
25
|
+
// source: https://core.telegram.org/bots/api#user "Optional. User's or bot's username"
|
|
26
|
+
if (username && userByName(ctx, username)) return bad(`the username ${username} is taken`, 409);
|
|
27
|
+
const row = await ctx.create('user', (id) => ({
|
|
28
|
+
id: Number(id), is_bot: false, first_name: first,
|
|
29
|
+
...(str(ctx.params.last_name) ? { last_name: str(ctx.params.last_name) } : {}),
|
|
30
|
+
...(username ? { username } : {}),
|
|
31
|
+
...(str(ctx.params.language_code) ? { language_code: str(ctx.params.language_code) } : {}),
|
|
32
|
+
}), 'people');
|
|
33
|
+
return Response.json(userView(ctx, (row as Row).id ?? ctx.own(row as Row).id));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** BotFather's /newbot: a bot (its name, its username ending in "bot") and its token; the same username answers the
|
|
37
|
+
* bot made before and its token as it stands, so a World's credential door answers the same token at every boot. */
|
|
38
|
+
export async function bots(ctx: HandlerContext): Promise<Response> {
|
|
39
|
+
// 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
|
+
const username = (str(ctx.params.username) ?? 'world_twin_bot').replace(/^@/, '');
|
|
41
|
+
if (!/^[A-Za-z0-9_]{5,32}$/.test(username) || !/bot$/i.test(username)) return bad('Sorry, this username is invalid.');
|
|
42
|
+
const name = str(ctx.params.name) ?? 'World Twin Bot';
|
|
43
|
+
const owner = num(ctx.params.owner);
|
|
44
|
+
if (owner !== undefined && !ctx.row('user', String(owner))) return bad(`no person ${owner}`, 404);
|
|
45
|
+
const held = userByName(ctx, username);
|
|
46
|
+
if (held) return heldBot(ctx, held);
|
|
47
|
+
// a bot's own fields, which getMe alone returns: it joins groups, privacy mode is on, and it offers nothing it
|
|
48
|
+
// was not set up for
|
|
49
|
+
// source: https://core.telegram.org/bots/features#privacy-mode "Privacy mode is enabled by default for all bots"
|
|
50
|
+
const row = await ctx.create('user', (id) => ({
|
|
51
|
+
id: Number(id), is_bot: true, first_name: name, username,
|
|
52
|
+
can_join_groups: true, can_read_all_group_messages: false, supports_inline_queries: false, can_connect_to_business: false,
|
|
53
|
+
has_main_web_app: false, has_topics_enabled: false,
|
|
54
|
+
}), 'bots');
|
|
55
|
+
const id = String((row as Row).id ?? ctx.own(row as Row).id);
|
|
56
|
+
const token = await issueToken(ctx, id, 1);
|
|
57
|
+
await ctx.record('_bot', { owner: owner ?? null, generation: 1, token_sha256: ctx.crypto.sha256(token), update_seq: 836_000_000 }, id);
|
|
58
|
+
return Response.json({ id: Number(id), name, username, token });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The bot a username already names, and its token as it stands (a World's credential door asked again at a boot). */
|
|
62
|
+
async function heldBot(ctx: HandlerContext, held: Row): Promise<Response> {
|
|
63
|
+
if (held.is_bot !== true) return bad('Sorry, this username is already taken.', 409);
|
|
64
|
+
const bot = ctx.row('_bot', String(held.id))!;
|
|
65
|
+
return Response.json({ id: Number(ctx.own(held).id), name: held.first_name, username: held.username, token: await issueToken(ctx, String(held.id), Number(bot.generation ?? 1)) });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** BotFather's /token: a new token for the bot; the one before stops working. */
|
|
69
|
+
// source: https://core.telegram.org/bots/features#botfather "If your existing token is compromised or you lost it for some reason, use the /token command to generate a new one."
|
|
70
|
+
export async function botToken(ctx: HandlerContext): Promise<Response> {
|
|
71
|
+
const held = userByName(ctx, String(ctx.call.params.username ?? ''));
|
|
72
|
+
if (!held || held.is_bot !== true) return bad('no such bot', 404);
|
|
73
|
+
const id = String(held.id);
|
|
74
|
+
const bot = ctx.row('_bot', id)!;
|
|
75
|
+
const owner = num(ctx.params.owner);
|
|
76
|
+
if (bot.owner !== null && bot.owner !== undefined && owner !== Number(bot.owner)) return bad('only the bot\'s owner makes its token', 403);
|
|
77
|
+
const generation = Number(bot.generation ?? 1) + 1;
|
|
78
|
+
const token = await issueToken(ctx, id, generation);
|
|
79
|
+
await ctx.record('_bot', { generation, token_sha256: ctx.crypto.sha256(token) }, id);
|
|
80
|
+
return Response.json({ id: Number(ctx.own(held).id), username: held.username, token });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Default administrator rights for a member made an administrator in the app, as the app proposes them, with what
|
|
84
|
+
* the door's `rights` says. */
|
|
85
|
+
function adminRights(type: string, rights: Row): Row {
|
|
86
|
+
const base: Row = {
|
|
87
|
+
can_be_edited: false, is_anonymous: false, can_manage_chat: true, can_delete_messages: false, can_manage_video_chats: false,
|
|
88
|
+
can_restrict_members: false, can_promote_members: false, can_change_info: false, can_invite_users: true,
|
|
89
|
+
can_post_stories: false, can_edit_stories: false, can_delete_stories: false, can_send_welcome_messages: false,
|
|
90
|
+
...(type === 'channel' ? { can_post_messages: false, can_edit_messages: false } : { can_pin_messages: false }),
|
|
91
|
+
};
|
|
92
|
+
for (const [k, v] of Object.entries(rights)) if (k in base && typeof v === 'boolean') base[k] = v;
|
|
93
|
+
return base;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A member's status moved by a person in the app (the machine allows it), its rights kept, and a bot told of its own
|
|
97
|
+
* move (my_chat_member). */
|
|
98
|
+
async function moveMember(ctx: HandlerContext, chat: Row, by: number, user: number, to: string, rights: Row, operation: string): Promise<Row | Response> {
|
|
99
|
+
const key = memberKey(chat.id, user);
|
|
100
|
+
const prior = ctx.row('chat_member', key);
|
|
101
|
+
const current = prior && prior.deleted !== true ? String(prior.status) : 'left';
|
|
102
|
+
const refused = ctx.legal('chat_member', 'status', operation, current, to, key, 'external');
|
|
103
|
+
if (refused) return bad(refused.message, refused.status);
|
|
104
|
+
const before = memberView(ctx, prior, user);
|
|
105
|
+
const fields: Row = { _chat: chat.id, _user: user, status: to };
|
|
106
|
+
if (to === 'administrator') Object.assign(fields, adminRights(String(chat.type), rights));
|
|
107
|
+
// source: https://core.telegram.org/bots/api#chatmemberowner "Represents a chat member that owns the chat and has all administrator privileges."
|
|
108
|
+
if (to === 'creator') Object.assign(fields, { is_anonymous: false });
|
|
109
|
+
// source: https://core.telegram.org/bots/api#chatmemberbanned "If 0, then the user is banned forever"
|
|
110
|
+
if (to === 'kicked') Object.assign(fields, { until_date: 0 });
|
|
111
|
+
await ctx.write('chat_member', key, fields, operation);
|
|
112
|
+
const after = ctx.row('chat_member', key)!;
|
|
113
|
+
if (ctx.row('user', String(user))?.is_bot === true && user !== by) {
|
|
114
|
+
// 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);
|
|
116
|
+
}
|
|
117
|
+
return after;
|
|
118
|
+
}
|
|
119
|
+
|
|
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)));
|
|
122
|
+
|
|
123
|
+
/** A message a person (or the app, for a service message) writes in a chat, kept and delivered to the bots it reaches. */
|
|
124
|
+
async function post(ctx: HandlerContext, chat: Row, fields: Row, from: number | undefined, operation: string, extra: Row = {}): Promise<Row> {
|
|
125
|
+
const chatKey = String(chat.id);
|
|
126
|
+
const messageId = await nextMessageId(ctx, chatKey, operation);
|
|
127
|
+
const key = `${chatKey}:${messageId}`;
|
|
128
|
+
const channel = chat.type === 'channel';
|
|
129
|
+
await ctx.write('message', key, {
|
|
130
|
+
message_id: messageId, date: unixOf(ctx.occurredAt), ...fields,
|
|
131
|
+
_chat: chat.id,
|
|
132
|
+
// source: https://core.telegram.org/bots/api#message "Optional. Sender of the message when sent on behalf of a chat. For example, the supergroup itself for messages sent by its anonymous administrators or a linked channel for messages automatically forwarded to the channel's discussion group."
|
|
133
|
+
...(channel ? { _sender_chat: chat.id } : from !== undefined ? { _from: from } : {}),
|
|
134
|
+
...extra,
|
|
135
|
+
}, operation);
|
|
136
|
+
const row = ctx.row('message', key)!;
|
|
137
|
+
// source: https://core.telegram.org/bots/api#update "Optional. New incoming channel post of any kind - text, photo, sticker, etc."
|
|
138
|
+
const type = channel ? 'channel_post' : 'message';
|
|
139
|
+
for (const bot of recipientsOf(ctx, chatOf(ctx, chatKey)!, row)) await queueUpdate(ctx, bot, type, messageView(ctx, row), operation);
|
|
140
|
+
return row;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** A chat a person makes in the app: a group, a supergroup or a channel, its creator its owner, its members added, and
|
|
144
|
+
* the service message that it was made. */
|
|
145
|
+
export async function chats(ctx: HandlerContext): Promise<Response> {
|
|
146
|
+
const creator = num(ctx.params.creator);
|
|
147
|
+
if (creator === undefined || ctx.row('user', String(creator))?.is_bot !== false) return bad('creator must be a person (a user id)');
|
|
148
|
+
const type = str(ctx.params.type);
|
|
149
|
+
if (!type || !['group', 'supergroup', 'channel'].includes(type)) return bad('type is group, supergroup or channel');
|
|
150
|
+
const title = str(ctx.params.title);
|
|
151
|
+
if (!title) return bad('title is required');
|
|
152
|
+
const username = str(ctx.params.username)?.replace(/^@/, '');
|
|
153
|
+
if (username && (ctx.rowsRaw('chat').some((c) => c.deleted !== true && String(c.username ?? '').toLowerCase() === username.toLowerCase()) || userByName(ctx, username))) return bad(`the username ${username} is taken`, 409);
|
|
154
|
+
if (username && type === 'group') return bad('a basic group has no username; make it a supergroup');
|
|
155
|
+
const id = await ctx.issue(type === 'group' ? '_group' : '_channel');
|
|
156
|
+
const operation = 'chats';
|
|
157
|
+
let photo: Row | undefined;
|
|
158
|
+
const photoData = str(ctx.params.photo);
|
|
159
|
+
if (photoData) {
|
|
160
|
+
const bytes = Uint8Array.from(atob(photoData), (c) => c.charCodeAt(0));
|
|
161
|
+
// a chat photo in its two sizes, each its own file
|
|
162
|
+
// source: https://core.telegram.org/bots/api#chatphoto "File identifier of small (160x160) chat photo."
|
|
163
|
+
const small = await storeFile(ctx, bytes, { kind: 'profile_photos', mime: 'image/jpeg' }, operation);
|
|
164
|
+
const big = await storeFile(ctx, bytes, { kind: 'profile_photos', mime: 'image/jpeg' }, operation);
|
|
165
|
+
photo = { small_file_id: small.file_id, small_file_unique_id: small.file_unique_id, big_file_id: big.file_id, big_file_unique_id: big.file_unique_id };
|
|
166
|
+
}
|
|
167
|
+
const n = Math.abs(Number(id));
|
|
168
|
+
await ctx.write('chat', id, {
|
|
169
|
+
id: Number(id), type, title,
|
|
170
|
+
...(username ? { username, active_usernames: [username] } : {}),
|
|
171
|
+
...(photo ? { photo } : {}),
|
|
172
|
+
...(str(ctx.params.description) ? { description: str(ctx.params.description) } : {}),
|
|
173
|
+
// Where the documentation stops: a chat's own accent color, reaction count and accepted gifts until someone sets
|
|
174
|
+
// them; the page names the colors 0-6 a theme customizes, and the twin takes the id's remainder by 7
|
|
175
|
+
// source: https://core.telegram.org/bots/api#accent-colors "Colors with identifiers 0 (red), 1 (orange), 2 (purple/violet), 3 (green), 4 (cyan), 5 (blue), 6 (pink) can be customized by app themes."
|
|
176
|
+
accent_color_id: n % 7,
|
|
177
|
+
max_reaction_count: 11,
|
|
178
|
+
accepted_gift_types: { unlimited_gifts: false, limited_gifts: false, unique_gifts: false, premium_subscription: false, gifts_from_channels: false },
|
|
179
|
+
}, operation);
|
|
180
|
+
const chat = (): Row => chatOf(ctx, id)!;
|
|
181
|
+
const own = await moveMember(ctx, chat(), creator, creator, 'creator', {}, operation);
|
|
182
|
+
if (own instanceof Response) return own;
|
|
183
|
+
for (const m of (Array.isArray(ctx.params.members) ? ctx.params.members : []) as Row[]) {
|
|
184
|
+
const user = num(m.user);
|
|
185
|
+
if (user === undefined || !ctx.row('user', String(user))) return bad(`no user ${String(m.user)}`, 404);
|
|
186
|
+
const to = m.status === 'administrator' ? 'administrator' : 'member';
|
|
187
|
+
const moved = await moveMember(ctx, chat(), creator, user, to, (m.rights ?? {}) as Row, operation);
|
|
188
|
+
if (moved instanceof Response) return moved;
|
|
189
|
+
}
|
|
190
|
+
// source: https://core.telegram.org/bots/api#message "Optional. Service message: the group has been created"
|
|
191
|
+
const created = type === 'group' ? { group_chat_created: true } : type === 'supergroup' ? { supergroup_chat_created: true } : { channel_chat_created: true };
|
|
192
|
+
await post(ctx, chat(), created, creator, operation);
|
|
193
|
+
return Response.json(chatFullView(ctx, chat()));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A person adds a member to a chat, promotes one, or removes one (kicked); a group is told by a service message who
|
|
197
|
+
* joined or was removed. */
|
|
198
|
+
export async function members(ctx: HandlerContext): Promise<Response> {
|
|
199
|
+
const chat = chatOf(ctx, String(ctx.call.params.chat_id ?? ''));
|
|
200
|
+
if (!chat || chat.deleted === true || chat.type === 'private') return bad('no such group or channel', 404);
|
|
201
|
+
const by = num(ctx.params.by);
|
|
202
|
+
const user = num(ctx.params.user);
|
|
203
|
+
if (by === undefined || user === undefined || !ctx.row('user', String(by)) || !ctx.row('user', String(user))) return bad('by and user are user ids');
|
|
204
|
+
const to = str(ctx.params.status);
|
|
205
|
+
if (!to || !['member', 'administrator', 'kicked'].includes(to)) return bad('status is member, administrator or kicked');
|
|
206
|
+
// a person adds, promotes or removes a member only as an administrator of the chat
|
|
207
|
+
const actor = ctx.row('chat_member', memberKey(chat.id, by));
|
|
208
|
+
if (!actor || !['creator', 'administrator'].includes(String(actor.status))) return bad('only an administrator of the chat does that', 403);
|
|
209
|
+
const prior = ctx.row('chat_member', memberKey(chat.id, user));
|
|
210
|
+
const was = String(prior?.status ?? 'left');
|
|
211
|
+
// what the app does not offer: making a member what it already is, or demoting an administrator (not served)
|
|
212
|
+
if (was === to || (was === 'creator') || (to === 'member' && was === 'administrator')) return bad(`the member is ${was}`, 409);
|
|
213
|
+
const operation = 'members';
|
|
214
|
+
const moved = await moveMember(ctx, chat, by, user, to, (ctx.params.rights ?? {}) as Row, operation);
|
|
215
|
+
if (moved instanceof Response) return moved;
|
|
216
|
+
if (chat.type !== 'channel') {
|
|
217
|
+
// source: https://core.telegram.org/bots/api#message "Optional. New members that were added to the group or supergroup and information about them (the bot itself may be one of these members)"
|
|
218
|
+
if ((to === 'member' || to === 'administrator') && !['member', 'administrator', 'creator'].includes(was)) await post(ctx, chatOf(ctx, chat.id)!, {}, by, operation, { _new_members: [user] });
|
|
219
|
+
// source: https://core.telegram.org/bots/api#message "Optional. A member was removed from the group, information about them (this member may be the bot itself)"
|
|
220
|
+
if (to === 'kicked' && ['member', 'administrator'].includes(was)) await post(ctx, chatOf(ctx, chat.id)!, { left_chat_member: userView(ctx, user) }, by, operation);
|
|
221
|
+
}
|
|
222
|
+
return Response.json(memberView(ctx, moved, user));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A person writes a message in the app: in a group or a channel they are in, or to a bot (its @username) in private,
|
|
226
|
+
* which starts the bot for them. The app marks what the text holds (mentions, commands, links) as the server does. */
|
|
227
|
+
export async function messages(ctx: HandlerContext): Promise<Response> {
|
|
228
|
+
const from = num(ctx.params.from);
|
|
229
|
+
const person = from === undefined ? undefined : ctx.row('user', String(from));
|
|
230
|
+
if (!person || person.is_bot !== false) return bad('from must be a person (a user id)');
|
|
231
|
+
const text = typeof ctx.params.text === 'string' ? ctx.params.text : '';
|
|
232
|
+
if (!text.trim()) return bad('text is required');
|
|
233
|
+
const target = ctx.params.chat;
|
|
234
|
+
const operation = 'messages';
|
|
235
|
+
let chat: Row | undefined;
|
|
236
|
+
let extra: Row = {};
|
|
237
|
+
const bot = typeof target === 'string' && target.startsWith('@') ? userByName(ctx, target) : undefined;
|
|
238
|
+
if (bot && bot.is_bot === true) {
|
|
239
|
+
// a private chat is the person's own id, as a bot sees it
|
|
240
|
+
// source: https://core.telegram.org/bots/api#chat "Unique identifier for this chat."
|
|
241
|
+
const key = String(from);
|
|
242
|
+
if (!ctx.row('chat', key)) {
|
|
243
|
+
await ctx.write('chat', key, {
|
|
244
|
+
id: from, type: 'private', first_name: person.first_name,
|
|
245
|
+
...(person.last_name ? { last_name: person.last_name } : {}), ...(person.username ? { username: person.username } : {}),
|
|
246
|
+
accent_color_id: Math.abs(Number(from)) % 7, max_reaction_count: 11,
|
|
247
|
+
accepted_gift_types: { unlimited_gifts: false, limited_gifts: false, unique_gifts: false, premium_subscription: false, gifts_from_channels: false },
|
|
248
|
+
}, operation);
|
|
249
|
+
}
|
|
250
|
+
// writing to a bot starts it: from now on it may write to the person
|
|
251
|
+
// source: https://core.telegram.org/bots/features#botfather "Users will see a Start button the first time they open a chat with your bot."
|
|
252
|
+
// (a private chat's start sends the bot no my_chat_member: that is only for a block or an unblock)
|
|
253
|
+
// source: https://core.telegram.org/bots/api#update "For private chats, this update is received only when the bot is blocked or unblocked by the user."
|
|
254
|
+
const member = ctx.row('chat_member', memberKey(key, bot.id));
|
|
255
|
+
if (!member || member.status !== 'member') {
|
|
256
|
+
const refused = ctx.legal('chat_member', 'status', operation, String(member?.status ?? 'left'), 'member', memberKey(key, bot.id), 'external');
|
|
257
|
+
if (refused) return bad(refused.message, refused.status);
|
|
258
|
+
await ctx.write('chat_member', memberKey(key, bot.id), { _chat: from, _user: Number(bot.id), status: 'member' }, operation);
|
|
259
|
+
}
|
|
260
|
+
chat = chatOf(ctx, key)!;
|
|
261
|
+
extra = { _with: Number(bot.id) };
|
|
262
|
+
} else {
|
|
263
|
+
chat = num(target) !== undefined ? chatOf(ctx, num(target)) : typeof target === 'string' ? findChat(ctx, target) : undefined;
|
|
264
|
+
if (!chat || chat.type === 'private') return bad('no such chat', 404);
|
|
265
|
+
const member = ctx.row('chat_member', memberKey(chat.id, from));
|
|
266
|
+
const status = String(member?.status ?? 'left');
|
|
267
|
+
if (!['creator', 'administrator', 'member'].includes(status)) return bad('the person is not a member of the chat', 403);
|
|
268
|
+
if (chat.type === 'channel' && !['creator', 'administrator'].includes(status)) return bad('only administrators post in a channel', 403);
|
|
269
|
+
}
|
|
270
|
+
const reply = num(ctx.params.reply_to_message_id);
|
|
271
|
+
if (reply !== undefined && !ctx.row('message', `${chat.id}:${reply}`)) return bad('no such message to reply to', 404);
|
|
272
|
+
const formattedText = finish({ text, entities: [] }, { find: true, skipBotCommands: false });
|
|
273
|
+
const row = await post(ctx, chat, { text: formattedText.text, ...(formattedText.entities.length ? { entities: formattedText.entities } : {}) }, from, operation, {
|
|
274
|
+
...extra, ...(reply !== undefined ? { _reply_to: `${chat.id}:${reply}` } : {}),
|
|
275
|
+
});
|
|
276
|
+
return Response.json(messageView(ctx, row));
|
|
277
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Getting updates (https://core.telegram.org/bots/api#getting-updates): a bot reads the updates waiting for it by
|
|
2
|
+
// polling (getUpdates), and removes a webhook to do so (deleteWebhook). setWebhook and getWebhookInfo are not served:
|
|
3
|
+
// no application of the demand sets a webhook, so a bot's webhook is never set and getUpdates never meets one.
|
|
4
|
+
import type { HandlerContext } from '@volter/world-core';
|
|
5
|
+
import { answer, botOf, intParam, jsonParam, param, type Row } from './shared.ts';
|
|
6
|
+
|
|
7
|
+
// source: https://core.telegram.org/bots/api#getting-updates "Incoming updates are stored on the server until the bot receives them either way, but they will not be kept longer than 24 hours."
|
|
8
|
+
const KEPT_MS = 24 * 60 * 60 * 1000;
|
|
9
|
+
|
|
10
|
+
// source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.h "static constexpr int32 LONG_POLL_MAX_TIMEOUT = 50;"
|
|
11
|
+
const LONG_POLL_MAX_TIMEOUT = 50;
|
|
12
|
+
|
|
13
|
+
/** The updates of a bot still kept, from `from` on, in order. */
|
|
14
|
+
function waiting(ctx: HandlerContext, botId: number, from: number): Row[] {
|
|
15
|
+
const now = Date.parse(ctx.occurredAt);
|
|
16
|
+
return ctx.rowsRaw('update')
|
|
17
|
+
.filter((u) => u.deleted !== true && Number(u._bot) === botId && Number(u.update_id) >= from && now - Date.parse(String(u._made ?? ctx.occurredAt)) < KEPT_MS)
|
|
18
|
+
.sort((a, b) => Number(a.update_id) - Number(b.update_id));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function getUpdates(ctx: HandlerContext): Promise<Response> {
|
|
22
|
+
const { id: botId } = botOf(ctx);
|
|
23
|
+
const operation = ctx.call.operation.id;
|
|
24
|
+
// source: https://core.telegram.org/bots/api#getupdates "Limits the number of updates to be retrieved. Values between 1-100 are accepted. Defaults to 100."
|
|
25
|
+
const limit = Math.min(100, Math.max(1, intParam(ctx, 'limit') ?? 100));
|
|
26
|
+
// source: https://core.telegram.org/bots/api#getupdates "Timeout in seconds for long polling. Defaults to 0, i.e. usual short polling."
|
|
27
|
+
const timeout = Math.min(LONG_POLL_MAX_TIMEOUT, Math.max(0, intParam(ctx, 'timeout') ?? 0));
|
|
28
|
+
let offset = intParam(ctx, 'offset') ?? 0;
|
|
29
|
+
// the update types the bot asks for, kept for the updates made from now on
|
|
30
|
+
// source: https://core.telegram.org/bots/api#getupdates "If not specified, the previous setting will be used."
|
|
31
|
+
// a form's field is read by its exact name, as the server reads a query's arguments: a client that encodes the list
|
|
32
|
+
// as allowed_updates[0]=… (a form library's bracket notation) names no allowed_updates at all
|
|
33
|
+
// source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "auto allowed_update_types = get_allowed_update_types(query->arg("
|
|
34
|
+
const form = (ctx.call.request.headers.get('content-type') ?? '').includes('application/x-www-form-urlencoded');
|
|
35
|
+
const exact = form ? new URLSearchParams(await ctx.call.request.clone().text()).get('allowed_updates') ?? new URL(ctx.call.request.url).searchParams.get('allowed_updates') ?? undefined : undefined;
|
|
36
|
+
const allowed = form ? (exact === undefined ? {} : (() => { try { return { value: JSON.parse(exact) as unknown }; } catch { return { error: true }; } })()) : jsonParam(ctx, 'allowed_updates');
|
|
37
|
+
if ('value' in allowed && Array.isArray(allowed.value)) await ctx.record('_bot', { allowed_updates: (allowed.value as unknown[]).map(String) }, String(botId));
|
|
38
|
+
const mine = ctx.rowsRaw('update').filter((u) => u.deleted !== true && Number(u._bot) === botId).sort((a, b) => Number(a.update_id) - Number(b.update_id));
|
|
39
|
+
// a negative offset keeps only the last -offset updates and forgets the rest
|
|
40
|
+
// source: https://core.telegram.org/bots/api#getupdates "The negative offset can be specified to retrieve updates starting from -offset update from the end of the updates queue. All previous updates will be forgotten."
|
|
41
|
+
if (offset < 0) {
|
|
42
|
+
for (const u of mine.slice(0, Math.max(0, mine.length + offset))) await ctx.remove('update', String(u.id), operation);
|
|
43
|
+
offset = 0;
|
|
44
|
+
}
|
|
45
|
+
// the updates below the offset are confirmed, and so forgotten
|
|
46
|
+
// source: https://core.telegram.org/bots/api#getupdates "An update is considered confirmed as soon as getUpdates is called with an offset higher than its update_id."
|
|
47
|
+
if (offset > 0) for (const u of mine) if (Number(u.update_id) < offset) await ctx.remove('update', String(u.id), operation);
|
|
48
|
+
let found = waiting(ctx, botId, offset);
|
|
49
|
+
// nothing waiting: the request is held until an update comes or the timeout passes (a long poll)
|
|
50
|
+
// source: https://core.telegram.org/bots/api#getupdates "Should be positive, short polling should be used for testing purposes only."
|
|
51
|
+
if (!found.length && timeout > 0 && (await ctx.awaitWrite(['update'], timeout * 1000))) found = waiting(ctx, botId, offset);
|
|
52
|
+
return answer(found.slice(0, limit).map((u) => Object.fromEntries(Object.entries(ctx.own(u)).filter(([k]) => !k.startsWith('_') && k !== 'deleted'))));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function deleteWebhook(ctx: HandlerContext): Promise<Response> {
|
|
56
|
+
const { id: botId } = botOf(ctx);
|
|
57
|
+
// source: https://core.telegram.org/bots/api#deletewebhook "Pass True to drop all pending updates"
|
|
58
|
+
if (ctx.flag(param(ctx, 'drop_pending_updates')) === true) {
|
|
59
|
+
for (const u of ctx.rowsRaw('update')) if (u.deleted !== true && Number(u._bot) === botId) await ctx.remove('update', String(u.id), ctx.call.operation.id);
|
|
60
|
+
}
|
|
61
|
+
// no webhook is ever set in this pack's scope, so the server answers that it is already deleted
|
|
62
|
+
// source: https://raw.githubusercontent.com/tdlib/telegram-bot-api/e3e9dd8e5b3d7ab8537cd5a10dc31d5ffa8f82d1/telegram-bot-api/Client.cpp "Webhook is already deleted"
|
|
63
|
+
return answer(true, 'Webhook is already deleted');
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// GENERATED by scripts/create-pack.ts --index: the handler map, by operationId, from the families' files (each
|
|
2
|
+
// handler named as its operation). Do not edit; add a handler to its family's file and generate again.
|
|
3
|
+
import type { Handler } from '@volter/world-core';
|
|
4
|
+
import * as available_methods from './available-methods.ts';
|
|
5
|
+
import * as getting_updates from './getting-updates.ts';
|
|
6
|
+
import * as updating_messages from './updating-messages.ts';
|
|
7
|
+
|
|
8
|
+
export const telegramSemantics: Record<string, Handler> = {
|
|
9
|
+
deleteMessage: updating_messages.deleteMessage,
|
|
10
|
+
deleteWebhook: getting_updates.deleteWebhook,
|
|
11
|
+
downloadFile: available_methods.downloadFile,
|
|
12
|
+
getChat: available_methods.getChat,
|
|
13
|
+
getChatMember: available_methods.getChatMember,
|
|
14
|
+
getFile: available_methods.getFile,
|
|
15
|
+
getMe: available_methods.getMe,
|
|
16
|
+
getUpdates: getting_updates.getUpdates,
|
|
17
|
+
sendChatAction: available_methods.sendChatAction,
|
|
18
|
+
sendDocument: available_methods.sendDocument,
|
|
19
|
+
sendMediaGroup: available_methods.sendMediaGroup,
|
|
20
|
+
sendMessage: available_methods.sendMessage,
|
|
21
|
+
sendPhoto: available_methods.sendPhoto,
|
|
22
|
+
sendVideo: available_methods.sendVideo,
|
|
23
|
+
};
|