@volter/twin-telegram 1.0.0

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