@emulon/telegram 0.2.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 +21 -0
- package/README.md +212 -0
- package/esm/auth/tokens.d.ts +18 -0
- package/esm/auth/tokens.js +42 -0
- package/esm/commands/mod.d.ts +15 -0
- package/esm/commands/mod.js +52 -0
- package/esm/compatibility.d.ts +2 -0
- package/esm/compatibility.js +236 -0
- package/esm/format/markdown_v2.d.ts +35 -0
- package/esm/format/markdown_v2.js +266 -0
- package/esm/mod.d.ts +9 -0
- package/esm/mod.js +28 -0
- package/esm/model/bots.d.ts +53 -0
- package/esm/model/bots.js +84 -0
- package/esm/model/channels.d.ts +25 -0
- package/esm/model/channels.js +45 -0
- package/esm/model/messages.d.ts +108 -0
- package/esm/model/messages.js +269 -0
- package/esm/model/reactions.d.ts +33 -0
- package/esm/model/reactions.js +129 -0
- package/esm/model/updates.d.ts +105 -0
- package/esm/model/updates.js +354 -0
- package/esm/package.json +3 -0
- package/esm/routes/api.d.ts +31 -0
- package/esm/routes/api.js +128 -0
- package/esm/routes/methods.d.ts +10 -0
- package/esm/routes/methods.js +195 -0
- package/package.json +277 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// Bot API objects are snake_case on the wire.
|
|
2
|
+
// deno-lint-ignore-file camelcase
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { DomainError } from 'emulon';
|
|
5
|
+
import { MarkdownError, parseMarkdownV2, } from '../format/markdown_v2.js';
|
|
6
|
+
import { botUser } from './bots.js';
|
|
7
|
+
import { channelSchema, usernameKey } from './channels.js';
|
|
8
|
+
/** Telegram's limit on message text, in UTF-16 code units after parsing. */
|
|
9
|
+
export const maxTextLength = 4096;
|
|
10
|
+
/**
|
|
11
|
+
* A rejected send. The route turns it into a Bot API envelope; the reason is
|
|
12
|
+
* one of the fixed descriptions below and never contains caller input.
|
|
13
|
+
*/
|
|
14
|
+
export class SendError extends Error {
|
|
15
|
+
status;
|
|
16
|
+
description;
|
|
17
|
+
constructor(status, description) {
|
|
18
|
+
super(description);
|
|
19
|
+
this.status = status;
|
|
20
|
+
this.description = description;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const badRequest = (reason) => new SendError(400, `Bad Request: ${reason}`);
|
|
24
|
+
export const sendErrors = {
|
|
25
|
+
parse: () => badRequest("can't parse entities"),
|
|
26
|
+
tooLong: () => badRequest('message is too long'),
|
|
27
|
+
empty: () => badRequest('message text is empty'),
|
|
28
|
+
chatEmpty: () => badRequest('chat_id is empty'),
|
|
29
|
+
chatNotFound: () => badRequest('chat not found'),
|
|
30
|
+
parseMode: () => badRequest('unsupported parse_mode'),
|
|
31
|
+
invalid: () => badRequest('invalid parameter value'),
|
|
32
|
+
unsupported: () => new SendError(501, 'Not Implemented: parameter is not emulated'),
|
|
33
|
+
unsupportedMode: () => new SendError(501, 'Not Implemented: parse mode is not emulated'),
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Parsing comes before the length rule, so malformed markup is reported even
|
|
37
|
+
* when it is also too long; the limit counts rendered text only.
|
|
38
|
+
*/
|
|
39
|
+
export function render(source, parseMode) {
|
|
40
|
+
let rendered;
|
|
41
|
+
if (parseMode === 'MarkdownV2') {
|
|
42
|
+
try {
|
|
43
|
+
rendered = parseMarkdownV2(source);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error instanceof MarkdownError) {
|
|
47
|
+
throw sendErrors.parse();
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
rendered = { text: source, entities: [] };
|
|
54
|
+
}
|
|
55
|
+
if (rendered.text.length === 0) {
|
|
56
|
+
throw sendErrors.empty();
|
|
57
|
+
}
|
|
58
|
+
if (rendered.text.length > maxTextLength) {
|
|
59
|
+
throw sendErrors.tooLong();
|
|
60
|
+
}
|
|
61
|
+
return rendered;
|
|
62
|
+
}
|
|
63
|
+
export function chatReference(value) {
|
|
64
|
+
if (typeof value === 'number') {
|
|
65
|
+
return Number.isSafeInteger(value) ? { id: value } : null;
|
|
66
|
+
}
|
|
67
|
+
if (typeof value !== 'string') {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (/^-?[1-9][0-9]*$/.test(value)) {
|
|
71
|
+
const id = Number(value);
|
|
72
|
+
return Number.isSafeInteger(id) ? { id } : null;
|
|
73
|
+
}
|
|
74
|
+
return value.startsWith('@') && value.length > 1
|
|
75
|
+
? { username: usernameKey(value.slice(1)) }
|
|
76
|
+
: null;
|
|
77
|
+
}
|
|
78
|
+
const accepted = new Set([
|
|
79
|
+
'chat_id',
|
|
80
|
+
'text',
|
|
81
|
+
'parse_mode',
|
|
82
|
+
'disable_notification',
|
|
83
|
+
'link_preview_options',
|
|
84
|
+
]);
|
|
85
|
+
/**
|
|
86
|
+
* Only the fields the declared consumer sends are emulated. Any other field,
|
|
87
|
+
* or a value that would change behavior the emulator does not model, is
|
|
88
|
+
* refused instead of being silently ignored.
|
|
89
|
+
*/
|
|
90
|
+
export function sendRequest(params) {
|
|
91
|
+
if (Object.keys(params).some((key) => !accepted.has(key))) {
|
|
92
|
+
throw sendErrors.unsupported();
|
|
93
|
+
}
|
|
94
|
+
const { chat_id, text, parse_mode, disable_notification, link_preview_options, } = params;
|
|
95
|
+
if (disable_notification !== undefined) {
|
|
96
|
+
if (typeof disable_notification !== 'boolean') {
|
|
97
|
+
throw sendErrors.invalid();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (link_preview_options !== undefined) {
|
|
101
|
+
if (typeof link_preview_options !== 'object' ||
|
|
102
|
+
link_preview_options === null || Array.isArray(link_preview_options)) {
|
|
103
|
+
throw sendErrors.invalid();
|
|
104
|
+
}
|
|
105
|
+
const options = link_preview_options;
|
|
106
|
+
if (Object.keys(options).some((key) => key !== 'is_disabled') ||
|
|
107
|
+
(options.is_disabled !== undefined && options.is_disabled !== false)) {
|
|
108
|
+
throw sendErrors.unsupported();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let parseMode = null;
|
|
112
|
+
if (parse_mode !== undefined && parse_mode !== '') {
|
|
113
|
+
if (typeof parse_mode !== 'string') {
|
|
114
|
+
throw sendErrors.invalid();
|
|
115
|
+
}
|
|
116
|
+
// The Bot API compares parse modes without regard to case.
|
|
117
|
+
const mode = parse_mode.toLowerCase();
|
|
118
|
+
if (mode === 'html' || mode === 'markdown') {
|
|
119
|
+
throw sendErrors.unsupportedMode();
|
|
120
|
+
}
|
|
121
|
+
if (mode !== 'markdownv2') {
|
|
122
|
+
throw sendErrors.parseMode();
|
|
123
|
+
}
|
|
124
|
+
parseMode = 'MarkdownV2';
|
|
125
|
+
}
|
|
126
|
+
if (chat_id === undefined || chat_id === '') {
|
|
127
|
+
throw sendErrors.chatEmpty();
|
|
128
|
+
}
|
|
129
|
+
const chat = chatReference(chat_id);
|
|
130
|
+
if (!chat) {
|
|
131
|
+
throw sendErrors.chatNotFound();
|
|
132
|
+
}
|
|
133
|
+
if (text === undefined || text === '') {
|
|
134
|
+
throw sendErrors.empty();
|
|
135
|
+
}
|
|
136
|
+
if (typeof text !== 'string') {
|
|
137
|
+
throw sendErrors.invalid();
|
|
138
|
+
}
|
|
139
|
+
return { chat, source: text, parseMode };
|
|
140
|
+
}
|
|
141
|
+
export const entitySchema = z
|
|
142
|
+
.strictObject({
|
|
143
|
+
type: z.enum([
|
|
144
|
+
'bold',
|
|
145
|
+
'italic',
|
|
146
|
+
'underline',
|
|
147
|
+
'strikethrough',
|
|
148
|
+
'spoiler',
|
|
149
|
+
'code',
|
|
150
|
+
'pre',
|
|
151
|
+
'text_link',
|
|
152
|
+
'blockquote',
|
|
153
|
+
'expandable_blockquote',
|
|
154
|
+
]),
|
|
155
|
+
offset: z.number().int().nonnegative(),
|
|
156
|
+
length: z.number().int().positive(),
|
|
157
|
+
url: z.string().optional(),
|
|
158
|
+
language: z.string().optional(),
|
|
159
|
+
});
|
|
160
|
+
export const storedSchema = z
|
|
161
|
+
.strictObject({
|
|
162
|
+
chatId: z.number().int(),
|
|
163
|
+
messageId: z.number().int().positive(),
|
|
164
|
+
botId: z.number().int().positive(),
|
|
165
|
+
date: z.number().int().nonnegative(),
|
|
166
|
+
parseMode: z.literal('MarkdownV2').nullable(),
|
|
167
|
+
source: z.string(),
|
|
168
|
+
text: z.string(),
|
|
169
|
+
entities: z.array(entitySchema),
|
|
170
|
+
});
|
|
171
|
+
export function chatView(channel) {
|
|
172
|
+
return {
|
|
173
|
+
id: channel.id,
|
|
174
|
+
title: channel.title,
|
|
175
|
+
username: channel.username,
|
|
176
|
+
type: 'channel',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
export function messageView(message, channel, bot) {
|
|
180
|
+
const user = botUser(bot);
|
|
181
|
+
return {
|
|
182
|
+
message_id: message.messageId,
|
|
183
|
+
from: {
|
|
184
|
+
id: user.id,
|
|
185
|
+
is_bot: true,
|
|
186
|
+
first_name: user.first_name,
|
|
187
|
+
username: user.username,
|
|
188
|
+
},
|
|
189
|
+
sender_chat: chatView(channel),
|
|
190
|
+
chat: chatView(channel),
|
|
191
|
+
date: message.date,
|
|
192
|
+
text: message.text,
|
|
193
|
+
...(message.entities.length > 0 ? { entities: message.entities } : {}),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export async function findChannel(tx, chat) {
|
|
197
|
+
if ('id' in chat) {
|
|
198
|
+
const row = await tx.get('channels', String(chat.id));
|
|
199
|
+
return row === undefined ? null : channelSchema.parse(row);
|
|
200
|
+
}
|
|
201
|
+
const channels = (await tx.list('channels')).map((row) => channelSchema.parse(row.value));
|
|
202
|
+
return channels.find((channel) => usernameKey(channel.username) === chat.username) ?? null;
|
|
203
|
+
}
|
|
204
|
+
/** Message IDs sort as text in store order and as numbers in the key. */
|
|
205
|
+
export const messageKey = (chatId, messageId) => `${chatId}:${String(messageId).padStart(16, '0')}`;
|
|
206
|
+
const sequenceSchema = z.strictObject({
|
|
207
|
+
next: z.number().int().positive(),
|
|
208
|
+
});
|
|
209
|
+
/**
|
|
210
|
+
* The chat lookup, rendering, ID allocation and message write share one
|
|
211
|
+
* transaction: a rejected send leaves no trace and allocates no ID, and
|
|
212
|
+
* concurrent sends to one chat never share or skip an ID.
|
|
213
|
+
*/
|
|
214
|
+
export async function sendMessage(store, bot, request, now) {
|
|
215
|
+
return await store.transaction(async (tx) => {
|
|
216
|
+
const channel = await findChannel(tx, request.chat);
|
|
217
|
+
if (!channel) {
|
|
218
|
+
throw sendErrors.chatNotFound();
|
|
219
|
+
}
|
|
220
|
+
const rendered = render(request.source, request.parseMode);
|
|
221
|
+
const sequence = await tx.get('messageSequences', String(channel.id));
|
|
222
|
+
const messageId = sequence === undefined
|
|
223
|
+
? 1
|
|
224
|
+
: sequenceSchema.parse(sequence).next;
|
|
225
|
+
const message = {
|
|
226
|
+
chatId: channel.id,
|
|
227
|
+
messageId,
|
|
228
|
+
botId: bot.id,
|
|
229
|
+
date: Math.floor(now / 1000),
|
|
230
|
+
parseMode: request.parseMode,
|
|
231
|
+
source: request.source,
|
|
232
|
+
text: rendered.text,
|
|
233
|
+
entities: rendered.entities,
|
|
234
|
+
};
|
|
235
|
+
await tx.put({
|
|
236
|
+
collection: 'messageSequences',
|
|
237
|
+
id: String(channel.id),
|
|
238
|
+
value: { next: messageId + 1 },
|
|
239
|
+
});
|
|
240
|
+
await tx.put({
|
|
241
|
+
collection: 'messages',
|
|
242
|
+
id: messageKey(channel.id, messageId),
|
|
243
|
+
value: message,
|
|
244
|
+
});
|
|
245
|
+
return messageView(message, channel, bot);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
export const listInput = z.strictObject({
|
|
249
|
+
chatId: z.number().int(),
|
|
250
|
+
limit: z.number().int().min(1).max(100).optional(),
|
|
251
|
+
});
|
|
252
|
+
/**
|
|
253
|
+
* The most recent `limit` messages (default 100) of one channel, oldest
|
|
254
|
+
* first, so a multipart post reads in the order it was sent.
|
|
255
|
+
*/
|
|
256
|
+
export async function listMessages(store, raw) {
|
|
257
|
+
const input = listInput.parse(raw);
|
|
258
|
+
return await store.transaction(async (tx) => {
|
|
259
|
+
const channel = await findChannel(tx, { id: input.chatId });
|
|
260
|
+
if (!channel) {
|
|
261
|
+
throw new DomainError('CHAT_NOT_FOUND', 'No such channel.');
|
|
262
|
+
}
|
|
263
|
+
const messages = (await tx.list('messages'))
|
|
264
|
+
.map((row) => storedSchema.parse(row.value))
|
|
265
|
+
.filter((message) => message.chatId === channel.id)
|
|
266
|
+
.sort((a, b) => a.messageId - b.messageId);
|
|
267
|
+
return messages.slice(-(input.limit ?? 100));
|
|
268
|
+
});
|
|
269
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type PluginContext } from 'emulon';
|
|
3
|
+
import { type ReactionCount } from './updates.js';
|
|
4
|
+
type Store = PluginContext['store'];
|
|
5
|
+
export declare const reactionsInput: z.ZodType<ReactionsInput, ReactionsInput>;
|
|
6
|
+
export interface ReactionsInput {
|
|
7
|
+
chatId: number;
|
|
8
|
+
messageId: number;
|
|
9
|
+
reactions: ReactionCount[];
|
|
10
|
+
}
|
|
11
|
+
export interface ReactionsResult {
|
|
12
|
+
chatId: number;
|
|
13
|
+
messageId: number;
|
|
14
|
+
reactions: ReactionCount[];
|
|
15
|
+
changed: boolean;
|
|
16
|
+
queued: number;
|
|
17
|
+
}
|
|
18
|
+
export declare const reactionsResultSchema: z.ZodType<ReactionsResult, ReactionsResult>;
|
|
19
|
+
/**
|
|
20
|
+
* The stored form of an absolute snapshot: zero counts are dropped, and the
|
|
21
|
+
* rest are ordered by count, then paid before emoji before custom emoji, then
|
|
22
|
+
* by emoji or ID, so equal snapshots compare equal. A type listed twice is
|
|
23
|
+
* ambiguous and refused.
|
|
24
|
+
*/
|
|
25
|
+
export declare function normalizeReactions(reactions: readonly ReactionCount[]): ReactionCount[];
|
|
26
|
+
/**
|
|
27
|
+
* Replaces a message's aggregate counts. The check of the target, the counts
|
|
28
|
+
* and the update of every subscribed bot commit together, so a poll either
|
|
29
|
+
* sees the new counts in its queue or not at all. An unchanged snapshot queues
|
|
30
|
+
* nothing.
|
|
31
|
+
*/
|
|
32
|
+
export declare function setReactions(store: Store, raw: ReactionsInput, now: number): Promise<ReactionsResult>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { DomainError } from 'emulon';
|
|
3
|
+
import { botSchema } from './bots.js';
|
|
4
|
+
import { chatView, findChannel, messageKey } from './messages.js';
|
|
5
|
+
import { enqueue, queueOf, reactionCountSchema, receives, } from './updates.js';
|
|
6
|
+
export const reactionsInput = z
|
|
7
|
+
.strictObject({
|
|
8
|
+
chatId: z.number().int(),
|
|
9
|
+
messageId: z.number().int().positive().safe(),
|
|
10
|
+
reactions: z.array(reactionCountSchema).max(100),
|
|
11
|
+
});
|
|
12
|
+
export const reactionsResultSchema = z.strictObject({
|
|
13
|
+
chatId: z.number().int(),
|
|
14
|
+
messageId: z.number().int().positive(),
|
|
15
|
+
reactions: z.array(reactionCountSchema),
|
|
16
|
+
changed: z.boolean(),
|
|
17
|
+
queued: z.number().int().nonnegative(),
|
|
18
|
+
});
|
|
19
|
+
const kinds = ['paid', 'emoji', 'custom_emoji'];
|
|
20
|
+
function canonicalType(type) {
|
|
21
|
+
switch (type.type) {
|
|
22
|
+
case 'emoji':
|
|
23
|
+
return { type: 'emoji', emoji: type.emoji };
|
|
24
|
+
case 'custom_emoji':
|
|
25
|
+
return { type: 'custom_emoji', custom_emoji_id: type.custom_emoji_id };
|
|
26
|
+
case 'paid':
|
|
27
|
+
return { type: 'paid' };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const reactionKey = (type) => `${type.type}:${reactionId(type)}`;
|
|
31
|
+
function reactionId(type) {
|
|
32
|
+
switch (type.type) {
|
|
33
|
+
case 'emoji':
|
|
34
|
+
return type.emoji;
|
|
35
|
+
case 'custom_emoji':
|
|
36
|
+
return type.custom_emoji_id;
|
|
37
|
+
case 'paid':
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The stored form of an absolute snapshot: zero counts are dropped, and the
|
|
43
|
+
* rest are ordered by count, then paid before emoji before custom emoji, then
|
|
44
|
+
* by emoji or ID, so equal snapshots compare equal. A type listed twice is
|
|
45
|
+
* ambiguous and refused.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeReactions(reactions) {
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
for (const { type } of reactions) {
|
|
50
|
+
const key = reactionKey(type);
|
|
51
|
+
if (seen.has(key)) {
|
|
52
|
+
throw new DomainError('DUPLICATE_REACTION', 'Each reaction type may appear only once.');
|
|
53
|
+
}
|
|
54
|
+
seen.add(key);
|
|
55
|
+
}
|
|
56
|
+
const id = (reaction) => reactionId(reaction.type);
|
|
57
|
+
return reactions
|
|
58
|
+
.filter((reaction) => reaction.total_count > 0)
|
|
59
|
+
.map((reaction) => ({
|
|
60
|
+
type: canonicalType(reaction.type),
|
|
61
|
+
total_count: reaction.total_count,
|
|
62
|
+
}))
|
|
63
|
+
.sort((a, b) => b.total_count - a.total_count ||
|
|
64
|
+
kinds.indexOf(a.type.type) - kinds.indexOf(b.type.type) ||
|
|
65
|
+
(id(a) < id(b) ? -1 : id(a) > id(b) ? 1 : 0));
|
|
66
|
+
}
|
|
67
|
+
function sameReactions(a, b) {
|
|
68
|
+
return a.length === b.length &&
|
|
69
|
+
a.every((reaction, index) => reactionKey(reaction.type) === reactionKey(b[index].type) &&
|
|
70
|
+
reaction.total_count === b[index].total_count);
|
|
71
|
+
}
|
|
72
|
+
const storedSchema = z.strictObject({
|
|
73
|
+
reactions: z.array(reactionCountSchema),
|
|
74
|
+
});
|
|
75
|
+
/**
|
|
76
|
+
* Replaces a message's aggregate counts. The check of the target, the counts
|
|
77
|
+
* and the update of every subscribed bot commit together, so a poll either
|
|
78
|
+
* sees the new counts in its queue or not at all. An unchanged snapshot queues
|
|
79
|
+
* nothing.
|
|
80
|
+
*/
|
|
81
|
+
export async function setReactions(store, raw, now) {
|
|
82
|
+
const input = reactionsInput.parse(raw);
|
|
83
|
+
const reactions = normalizeReactions(input.reactions);
|
|
84
|
+
return await store.transaction(async (tx) => {
|
|
85
|
+
const channel = await findChannel(tx, { id: input.chatId });
|
|
86
|
+
if (!channel) {
|
|
87
|
+
throw new DomainError('CHAT_NOT_FOUND', 'No such channel.');
|
|
88
|
+
}
|
|
89
|
+
const key = messageKey(channel.id, input.messageId);
|
|
90
|
+
if (await tx.get('messages', key) === undefined) {
|
|
91
|
+
throw new DomainError('MESSAGE_NOT_FOUND', 'No such message.');
|
|
92
|
+
}
|
|
93
|
+
const stored = await tx.get('reactions', key);
|
|
94
|
+
const previous = stored === undefined
|
|
95
|
+
? []
|
|
96
|
+
: storedSchema.parse(stored).reactions;
|
|
97
|
+
const result = {
|
|
98
|
+
chatId: channel.id,
|
|
99
|
+
messageId: input.messageId,
|
|
100
|
+
reactions,
|
|
101
|
+
changed: !sameReactions(previous, reactions),
|
|
102
|
+
queued: 0,
|
|
103
|
+
};
|
|
104
|
+
if (!result.changed) {
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
await tx.put({ collection: 'reactions', id: key, value: { reactions } });
|
|
108
|
+
const bots = (await tx.list('bots'))
|
|
109
|
+
.map((row) => botSchema.parse(row.value))
|
|
110
|
+
.sort((a, b) => a.id - b.id);
|
|
111
|
+
for (const bot of bots) {
|
|
112
|
+
const queue = await queueOf(tx, bot.id);
|
|
113
|
+
if (!receives(queue, 'message_reaction_count')) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
await enqueue(tx, bot.id, queue, (updateId) => ({
|
|
117
|
+
update_id: updateId,
|
|
118
|
+
message_reaction_count: {
|
|
119
|
+
chat: chatView(channel),
|
|
120
|
+
message_id: input.messageId,
|
|
121
|
+
date: Math.floor(now / 1000),
|
|
122
|
+
reactions,
|
|
123
|
+
},
|
|
124
|
+
}));
|
|
125
|
+
result.queued++;
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { type PluginContext } from 'emulon';
|
|
3
|
+
import type { ChatView } from './messages.js';
|
|
4
|
+
type Store = PluginContext['store'];
|
|
5
|
+
type Transaction = Parameters<Parameters<Store['transaction']>[0]>[0];
|
|
6
|
+
/**
|
|
7
|
+
* Every update type typed by `@grammyjs/types@3.28.0`; a test keeps this list
|
|
8
|
+
* equal to those types. Only `message_reaction_count` is ever generated.
|
|
9
|
+
*/
|
|
10
|
+
export declare const updateTypes: readonly ["message", "edited_message", "channel_post", "edited_channel_post", "business_connection", "business_message", "edited_business_message", "deleted_business_messages", "guest_message", "message_reaction", "message_reaction_count", "inline_query", "chosen_inline_result", "callback_query", "shipping_query", "pre_checkout_query", "poll", "poll_answer", "my_chat_member", "chat_member", "managed_bot", "chat_join_request", "chat_boost", "removed_chat_boost", "purchased_paid_media"];
|
|
11
|
+
export type UpdateType = typeof updateTypes[number];
|
|
12
|
+
/** The reaction emoji typed by `@grammyjs/types@3.28.0`. */
|
|
13
|
+
export declare const reactionEmoji: readonly ["๐", "๐", "โค", "๐ฅ", "๐ฅฐ", "๐", "๐", "๐ค", "๐คฏ", "๐ฑ", "๐คฌ", "๐ข", "๐", "๐คฉ", "๐คฎ", "๐ฉ", "๐", "๐", "๐", "๐คก", "๐ฅฑ", "๐ฅด", "๐", "๐ณ", "โคโ๐ฅ", "๐", "๐ญ", "๐ฏ", "๐คฃ", "โก", "๐", "๐", "๐", "๐คจ", "๐", "๐", "๐พ", "๐", "๐", "๐", "๐ด", "๐ญ", "๐ค", "๐ป", "๐จโ๐ป", "๐", "๐", "๐", "๐", "๐จ", "๐ค", "โ", "๐ค", "๐ซก", "๐
", "๐", "โ", "๐
", "๐คช", "๐ฟ", "๐", "๐", "๐", "๐ฆ", "๐", "๐", "๐", "๐", "๐พ", "๐คทโโ", "๐คท", "๐คทโโ", "๐ก"];
|
|
14
|
+
export type ReactionType = {
|
|
15
|
+
type: 'emoji';
|
|
16
|
+
emoji: typeof reactionEmoji[number];
|
|
17
|
+
} | {
|
|
18
|
+
type: 'custom_emoji';
|
|
19
|
+
custom_emoji_id: string;
|
|
20
|
+
} | {
|
|
21
|
+
type: 'paid';
|
|
22
|
+
};
|
|
23
|
+
export interface ReactionCount {
|
|
24
|
+
type: ReactionType;
|
|
25
|
+
total_count: number;
|
|
26
|
+
}
|
|
27
|
+
export declare const reactionTypeSchema: z.ZodType<ReactionType, ReactionType>;
|
|
28
|
+
export declare const reactionCountSchema: z.ZodType<ReactionCount, ReactionCount>;
|
|
29
|
+
export interface ReactionCountUpdate {
|
|
30
|
+
update_id: number;
|
|
31
|
+
message_reaction_count: {
|
|
32
|
+
chat: ChatView;
|
|
33
|
+
message_id: number;
|
|
34
|
+
date: number;
|
|
35
|
+
reactions: ReactionCount[];
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export declare const updateSchema: z.ZodType<ReactionCountUpdate, ReactionCountUpdate>;
|
|
39
|
+
/** A rejected `getUpdates`; descriptions are fixed and never echo input. */
|
|
40
|
+
export declare class UpdatesError extends Error {
|
|
41
|
+
readonly status: 400 | 409 | 501 | 503;
|
|
42
|
+
readonly description: string;
|
|
43
|
+
constructor(status: 400 | 409 | 501 | 503, description: string);
|
|
44
|
+
}
|
|
45
|
+
export declare const updatesErrors: {
|
|
46
|
+
readonly invalid: () => UpdatesError;
|
|
47
|
+
readonly limit: () => UpdatesError;
|
|
48
|
+
readonly allowedUpdates: () => UpdatesError;
|
|
49
|
+
readonly unsupported: () => UpdatesError;
|
|
50
|
+
readonly conflict: () => UpdatesError;
|
|
51
|
+
readonly cancelled: () => UpdatesError;
|
|
52
|
+
};
|
|
53
|
+
export interface UpdatesRequest {
|
|
54
|
+
offset?: number;
|
|
55
|
+
limit: number;
|
|
56
|
+
timeout: number;
|
|
57
|
+
allowedUpdates?: UpdateType[];
|
|
58
|
+
}
|
|
59
|
+
/** Only the documented polling fields; anything else is refused, not ignored. */
|
|
60
|
+
export declare function updatesRequest(params: Record<string, unknown>): UpdatesRequest;
|
|
61
|
+
/**
|
|
62
|
+
* Per-bot queue state. An empty `allowedUpdates` is Telegram's default, which
|
|
63
|
+
* leaves out `message_reaction_count`.
|
|
64
|
+
*/
|
|
65
|
+
export interface Queue {
|
|
66
|
+
nextUpdateId: number;
|
|
67
|
+
allowedUpdates: UpdateType[];
|
|
68
|
+
}
|
|
69
|
+
export declare function queueOf(tx: Transaction, botId: number): Promise<Queue>;
|
|
70
|
+
export declare function receives(queue: Queue, type: UpdateType): boolean;
|
|
71
|
+
/** The bot's queued updates in ID order. */
|
|
72
|
+
export declare function queued(tx: Transaction, botId: number): Promise<ReactionCountUpdate[]>;
|
|
73
|
+
/** Allocates the next update ID of one bot and queues the update it builds. */
|
|
74
|
+
export declare function enqueue(tx: Transaction, botId: number, queue: Queue, build: (updateId: number) => ReactionCountUpdate): Promise<void>;
|
|
75
|
+
/**
|
|
76
|
+
* The first read of a `getUpdates` call: it confirms by offset, replaces the
|
|
77
|
+
* subscription when one is given, then returns the head of the queue without
|
|
78
|
+
* confirming it.
|
|
79
|
+
*/
|
|
80
|
+
export declare function takeUpdates(tx: Transaction, botId: number, request: Pick<UpdatesRequest, 'offset' | 'limit' | 'allowedUpdates'>): Promise<ReactionCountUpdate[]>;
|
|
81
|
+
/**
|
|
82
|
+
* Waits on real time for committed updates. The store listener is attached
|
|
83
|
+
* before every read, so a commit between the read and the wait is not lost.
|
|
84
|
+
* Cancellation is an error, never an empty batch: after a reset the caller
|
|
85
|
+
* must not mistake a cancelled poll for a drained queue.
|
|
86
|
+
*/
|
|
87
|
+
export declare function pollUpdates(store: Store, botId: number, request: UpdatesRequest, signal: AbortSignal): Promise<ReactionCountUpdate[]>;
|
|
88
|
+
export declare const inspectInput: z.ZodType<InspectInput, InspectInput>;
|
|
89
|
+
export interface InspectInput {
|
|
90
|
+
botId: number;
|
|
91
|
+
limit?: number | undefined;
|
|
92
|
+
}
|
|
93
|
+
export interface QueueView {
|
|
94
|
+
botId: number;
|
|
95
|
+
allowedUpdates: UpdateType[];
|
|
96
|
+
pending: number;
|
|
97
|
+
updates: ReactionCountUpdate[];
|
|
98
|
+
}
|
|
99
|
+
export declare const queueViewSchema: z.ZodType<QueueView, QueueView>;
|
|
100
|
+
/**
|
|
101
|
+
* The oldest `limit` pending updates (default 100) and the subscription of one
|
|
102
|
+
* bot. Reading confirms nothing and shows no credential.
|
|
103
|
+
*/
|
|
104
|
+
export declare function inspectUpdates(store: Store, raw: InspectInput): Promise<QueueView>;
|
|
105
|
+
export {};
|