@yaebal/test 0.0.1 → 0.1.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/README.md +214 -2
- package/lib/index.d.ts +166 -13
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +308 -32
- package/lib/index.js.map +1 -1
- package/lib/index.test.js +149 -2
- package/lib/index.test.js.map +1 -1
- package/package.json +2 -2
- package/src/index.test.ts +220 -1
- package/src/index.ts +495 -34
package/src/index.ts
CHANGED
|
@@ -1,38 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @yaebal/test — testing utilities for yaebal bots
|
|
2
|
+
* @yaebal/test — testing utilities for yaebal bots, with zero dependency on any
|
|
3
|
+
* test runner or assertion library. Works with `node:test`, vitest, bun:test,
|
|
4
|
+
* jest, ava — anything that can `await` a promise and call `assert`.
|
|
3
5
|
*
|
|
4
6
|
* every plugin test used to hand-build fake updates and a mock api. this package
|
|
5
|
-
* extracts that boilerplate: {@link mockApi} records every call
|
|
6
|
-
*
|
|
7
|
-
*
|
|
7
|
+
* extracts that boilerplate: {@link mockApi} records every call (and can drive
|
|
8
|
+
* real `before`/`after`/`onError` hooks and simulate failures), the `*Update`
|
|
9
|
+
* factories produce real {@link Update} shapes for every update kind, and
|
|
10
|
+
* {@link createContext} wraps one in a core {@link Context}.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import {
|
|
14
|
+
type AfterHook,
|
|
11
15
|
type Api,
|
|
16
|
+
type BeforeHook,
|
|
12
17
|
type Composer,
|
|
13
18
|
Context,
|
|
19
|
+
type ErrorAction,
|
|
20
|
+
type ErrorHook,
|
|
14
21
|
type Message,
|
|
15
22
|
type NextFn,
|
|
16
23
|
type Update,
|
|
17
24
|
type UpdateName,
|
|
25
|
+
type UpdateSink,
|
|
18
26
|
} from "@yaebal/core";
|
|
19
27
|
|
|
28
|
+
/** the payload type for a given update kind — reuses core's `Update` shape, no extra types package needed. */
|
|
29
|
+
type Payload<K extends UpdateName> = NonNullable<Update[K]>;
|
|
30
|
+
|
|
20
31
|
/** a single recorded api call: the method name and the params it was given. */
|
|
21
32
|
export interface RecordedCall {
|
|
22
33
|
method: string;
|
|
23
34
|
params: Record<string, unknown> | undefined;
|
|
24
35
|
}
|
|
25
36
|
|
|
26
|
-
/** result
|
|
37
|
+
/** a canned result for one method: a static value, an `Error` to throw, or a function of `(params, attempt)`. */
|
|
38
|
+
export type MockResult =
|
|
39
|
+
| unknown
|
|
40
|
+
| Error
|
|
41
|
+
| ((params: Record<string, unknown> | undefined, attempt: number) => unknown);
|
|
42
|
+
|
|
43
|
+
export interface MockApiOptions {
|
|
44
|
+
/**
|
|
45
|
+
* per-method canned results/errors, keyed by method name — overrides the
|
|
46
|
+
* built-in defaults. a function receives `attempt`, a 1-based count of how
|
|
47
|
+
* many times that method has been called (including retries), so you can
|
|
48
|
+
* simulate "fails twice, then succeeds": `sendMessage: (p, a) => a <= 2 ? new
|
|
49
|
+
* TelegramError(...) : { message_id: 1 }`.
|
|
50
|
+
*/
|
|
51
|
+
results?: Record<string, MockResult>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** result of {@link mockApi}: the fake `api`, its recorded calls, and inspection helpers. */
|
|
27
55
|
export interface MockApi {
|
|
28
56
|
api: Api;
|
|
29
57
|
calls: RecordedCall[];
|
|
58
|
+
/** hooks registered on `api` via `before`/`after`/`onError` — inspect them or invoke them yourself. */
|
|
59
|
+
hooks: { before: BeforeHook[]; after: AfterHook[]; onError: ErrorHook[] };
|
|
60
|
+
/** the most recent recorded call, optionally filtered to a method. */
|
|
61
|
+
lastCall(method?: string): RecordedCall | undefined;
|
|
62
|
+
/** every recorded call to a given method, in call order. */
|
|
63
|
+
callsTo(method: string): RecordedCall[];
|
|
64
|
+
/** set (or replace) the canned result/error for a method after creation. */
|
|
65
|
+
setResult(method: string, result: MockResult): void;
|
|
66
|
+
/** clear recorded calls and per-method attempt counters. keeps hooks and result overrides. */
|
|
67
|
+
reset(): void;
|
|
30
68
|
}
|
|
31
69
|
|
|
32
70
|
/** default results for known methods; everything else resolves to `{}`. */
|
|
33
|
-
function
|
|
71
|
+
function builtinResult(method: string, nextMessageId: () => number): unknown {
|
|
34
72
|
if (method.startsWith("send") || method === "copyMessage" || method === "forwardMessage") {
|
|
35
|
-
return { message_id:
|
|
73
|
+
return { message_id: nextMessageId() };
|
|
36
74
|
}
|
|
37
75
|
|
|
38
76
|
if (method === "answerCallbackQuery") return true;
|
|
@@ -43,38 +81,118 @@ function defaultResult(method: string): unknown {
|
|
|
43
81
|
|
|
44
82
|
/**
|
|
45
83
|
* a fake {@link Api} whose every method records `{ method, params }` into `calls`
|
|
46
|
-
* and resolves to a sensible default (`
|
|
47
|
-
* `answerCallbackQuery`, `{}` otherwise)
|
|
48
|
-
* `onError`
|
|
84
|
+
* and resolves to a sensible default (auto-incrementing `message_id` for `send*`,
|
|
85
|
+
* `true` for `answerCallbackQuery`, `{}` otherwise) — or to whatever `options.results`
|
|
86
|
+
* says. `before`/`after`/`onError` are real hook registrars (not no-ops): register
|
|
87
|
+
* a hook the same way you would on the production `Api` and it actually runs,
|
|
88
|
+
* including retries requested by an `onError` hook. the mock never actually waits
|
|
89
|
+
* on a requested `delayMs` — retries settle instantly, so tests stay fast.
|
|
49
90
|
*/
|
|
50
|
-
export function mockApi(): MockApi {
|
|
91
|
+
export function mockApi(options: MockApiOptions = {}): MockApi {
|
|
51
92
|
const calls: RecordedCall[] = [];
|
|
93
|
+
const overrides: Record<string, MockResult> = { ...options.results };
|
|
94
|
+
const attempts = new Map<string, number>();
|
|
95
|
+
let nextMessageId = 1;
|
|
52
96
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
97
|
+
const hooks = {
|
|
98
|
+
before: [] as BeforeHook[],
|
|
99
|
+
after: [] as AfterHook[],
|
|
100
|
+
onError: [] as ErrorHook[],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function resolveResult(method: string, params: Record<string, unknown> | undefined): unknown {
|
|
104
|
+
const attempt = (attempts.get(method) ?? 0) + 1;
|
|
105
|
+
attempts.set(method, attempt);
|
|
106
|
+
|
|
107
|
+
const override = overrides[method];
|
|
108
|
+
if (typeof override === "function") {
|
|
109
|
+
return (override as (p: typeof params, a: number) => unknown)(params, attempt);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return override !== undefined ? override : builtinResult(method, () => nextMessageId++);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const call = async (method: string, params?: Record<string, unknown>): Promise<never> => {
|
|
116
|
+
let p = params;
|
|
117
|
+
for (const hook of hooks.before) {
|
|
118
|
+
const next = await hook(method, p);
|
|
119
|
+
if (next !== undefined) p = next;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (let attempt = 1; ; attempt++) {
|
|
123
|
+
calls.push({ method, params: p });
|
|
124
|
+
|
|
125
|
+
let result: unknown;
|
|
126
|
+
try {
|
|
127
|
+
result = resolveResult(method, p);
|
|
128
|
+
if (result instanceof Error) throw result;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
let retry: ErrorAction | undefined;
|
|
131
|
+
for (const hook of hooks.onError) {
|
|
132
|
+
const action = await hook(method, error, attempt);
|
|
133
|
+
if (action?.retry) {
|
|
134
|
+
retry = action;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!retry) throw error;
|
|
140
|
+
continue; // the mock never actually waits on retry.delayMs
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const hook of hooks.after) {
|
|
144
|
+
const next = await hook(method, p, result);
|
|
145
|
+
if (next !== undefined) result = next;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return result as never;
|
|
149
|
+
}
|
|
56
150
|
};
|
|
57
151
|
|
|
58
152
|
const registrar: Record<string, unknown> = {
|
|
59
|
-
call: (method: string, params?: Record<string, unknown>) =>
|
|
153
|
+
call: (method: string, params?: Record<string, unknown>) => call(method, params),
|
|
60
154
|
fileUrl: (filePath: string) => `https://example.invalid/file/${filePath}`,
|
|
61
|
-
before:
|
|
62
|
-
|
|
63
|
-
|
|
155
|
+
before(hook: BeforeHook) {
|
|
156
|
+
hooks.before.push(hook);
|
|
157
|
+
return api;
|
|
158
|
+
},
|
|
159
|
+
after(hook: AfterHook) {
|
|
160
|
+
hooks.after.push(hook);
|
|
161
|
+
return api;
|
|
162
|
+
},
|
|
163
|
+
onError(hook: ErrorHook) {
|
|
164
|
+
hooks.onError.push(hook);
|
|
165
|
+
return api;
|
|
166
|
+
},
|
|
64
167
|
};
|
|
65
168
|
|
|
66
169
|
const api = new Proxy(registrar, {
|
|
67
170
|
get(obj, prop: string) {
|
|
68
171
|
if (prop in obj) return obj[prop];
|
|
69
172
|
|
|
70
|
-
const method = (params?: Record<string, unknown>) =>
|
|
173
|
+
const method = (params?: Record<string, unknown>) => call(prop, params);
|
|
71
174
|
obj[prop] = method;
|
|
72
175
|
|
|
73
176
|
return method;
|
|
74
177
|
},
|
|
75
178
|
}) as unknown as Api;
|
|
76
179
|
|
|
77
|
-
return {
|
|
180
|
+
return {
|
|
181
|
+
api,
|
|
182
|
+
calls,
|
|
183
|
+
hooks,
|
|
184
|
+
lastCall: (method) =>
|
|
185
|
+
method ? [...calls].reverse().find((c) => c.method === method) : calls.at(-1),
|
|
186
|
+
callsTo: (method) => calls.filter((c) => c.method === method),
|
|
187
|
+
setResult: (method, result) => {
|
|
188
|
+
overrides[method] = result;
|
|
189
|
+
},
|
|
190
|
+
reset: () => {
|
|
191
|
+
calls.length = 0;
|
|
192
|
+
attempts.clear();
|
|
193
|
+
nextMessageId = 1;
|
|
194
|
+
},
|
|
195
|
+
};
|
|
78
196
|
}
|
|
79
197
|
|
|
80
198
|
let updateIdCounter = 0;
|
|
@@ -84,7 +202,9 @@ export function createUpdate(partial: Partial<Update> = {}): Update {
|
|
|
84
202
|
return { update_id: ++updateIdCounter, ...partial };
|
|
85
203
|
}
|
|
86
204
|
|
|
87
|
-
|
|
205
|
+
const stubUser = (id: number) => ({ id, is_bot: false, first_name: "u" });
|
|
206
|
+
|
|
207
|
+
/** options shared by the message-shaped factories (`message`, `edited_message`, `channel_post`, ...). */
|
|
88
208
|
export interface MessageUpdateOptions {
|
|
89
209
|
text?: string;
|
|
90
210
|
chatId?: number;
|
|
@@ -92,19 +212,39 @@ export interface MessageUpdateOptions {
|
|
|
92
212
|
chatType?: "private" | "group" | "supergroup" | "channel";
|
|
93
213
|
}
|
|
94
214
|
|
|
95
|
-
|
|
215
|
+
function buildMessage(
|
|
216
|
+
options: MessageUpdateOptions,
|
|
217
|
+
defaultChatType: NonNullable<MessageUpdateOptions["chatType"]>,
|
|
218
|
+
): Message {
|
|
219
|
+
const { text = "", chatId = 1, fromId = chatId, chatType = defaultChatType } = options;
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
message_id: 1,
|
|
223
|
+
date: 0,
|
|
224
|
+
chat: { id: chatId, type: chatType },
|
|
225
|
+
from: stubUser(fromId),
|
|
226
|
+
text,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** build a `message` {@link Update}. */
|
|
96
231
|
export function messageUpdate(options: MessageUpdateOptions = {}): Update {
|
|
97
|
-
|
|
232
|
+
return createUpdate({ message: buildMessage(options, "private") });
|
|
233
|
+
}
|
|
98
234
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
235
|
+
/** build an `edited_message` {@link Update}. */
|
|
236
|
+
export function editedMessageUpdate(options: MessageUpdateOptions = {}): Update {
|
|
237
|
+
return createUpdate({ edited_message: buildMessage(options, "private") });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** build a `channel_post` {@link Update}. */
|
|
241
|
+
export function channelPostUpdate(options: MessageUpdateOptions = {}): Update {
|
|
242
|
+
return createUpdate({ channel_post: buildMessage(options, "channel") });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** build an `edited_channel_post` {@link Update}. */
|
|
246
|
+
export function editedChannelPostUpdate(options: MessageUpdateOptions = {}): Update {
|
|
247
|
+
return createUpdate({ edited_channel_post: buildMessage(options, "channel") });
|
|
108
248
|
}
|
|
109
249
|
|
|
110
250
|
/** options for {@link callbackUpdate}. */
|
|
@@ -114,7 +254,7 @@ export interface CallbackUpdateOptions {
|
|
|
114
254
|
fromId?: number;
|
|
115
255
|
}
|
|
116
256
|
|
|
117
|
-
/** build a callback_query {@link Update}. */
|
|
257
|
+
/** build a `callback_query` {@link Update}. */
|
|
118
258
|
export function callbackUpdate(options: CallbackUpdateOptions = {}): Update {
|
|
119
259
|
const { data = "", chatId = 1, fromId = chatId } = options;
|
|
120
260
|
|
|
@@ -122,7 +262,7 @@ export function callbackUpdate(options: CallbackUpdateOptions = {}): Update {
|
|
|
122
262
|
callback_query: {
|
|
123
263
|
id: "1",
|
|
124
264
|
chat_instance: "0",
|
|
125
|
-
from:
|
|
265
|
+
from: stubUser(fromId),
|
|
126
266
|
message: {
|
|
127
267
|
message_id: 1,
|
|
128
268
|
date: 0,
|
|
@@ -133,6 +273,222 @@ export function callbackUpdate(options: CallbackUpdateOptions = {}): Update {
|
|
|
133
273
|
});
|
|
134
274
|
}
|
|
135
275
|
|
|
276
|
+
/** options for {@link inlineQueryUpdate}. */
|
|
277
|
+
export interface InlineQueryUpdateOptions {
|
|
278
|
+
query?: string;
|
|
279
|
+
fromId?: number;
|
|
280
|
+
id?: string;
|
|
281
|
+
offset?: string;
|
|
282
|
+
chatType?: Payload<"inline_query">["chat_type"];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** build an `inline_query` {@link Update}. */
|
|
286
|
+
export function inlineQueryUpdate(options: InlineQueryUpdateOptions = {}): Update {
|
|
287
|
+
const { query = "", fromId = 1, id = "1", offset = "", chatType } = options;
|
|
288
|
+
|
|
289
|
+
return createUpdate({
|
|
290
|
+
inline_query: {
|
|
291
|
+
id,
|
|
292
|
+
from: stubUser(fromId),
|
|
293
|
+
query,
|
|
294
|
+
offset,
|
|
295
|
+
...(chatType ? { chat_type: chatType } : {}),
|
|
296
|
+
},
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** options for {@link chosenInlineResultUpdate}. */
|
|
301
|
+
export interface ChosenInlineResultUpdateOptions {
|
|
302
|
+
resultId?: string;
|
|
303
|
+
fromId?: number;
|
|
304
|
+
query?: string;
|
|
305
|
+
inlineMessageId?: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** build a `chosen_inline_result` {@link Update}. */
|
|
309
|
+
export function chosenInlineResultUpdate(options: ChosenInlineResultUpdateOptions = {}): Update {
|
|
310
|
+
const { resultId = "1", fromId = 1, query = "", inlineMessageId } = options;
|
|
311
|
+
|
|
312
|
+
return createUpdate({
|
|
313
|
+
chosen_inline_result: {
|
|
314
|
+
result_id: resultId,
|
|
315
|
+
from: stubUser(fromId),
|
|
316
|
+
query,
|
|
317
|
+
...(inlineMessageId ? { inline_message_id: inlineMessageId } : {}),
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** options for {@link shippingQueryUpdate}. */
|
|
323
|
+
export interface ShippingQueryUpdateOptions {
|
|
324
|
+
id?: string;
|
|
325
|
+
fromId?: number;
|
|
326
|
+
invoicePayload?: string;
|
|
327
|
+
shippingAddress?: Partial<Payload<"shipping_query">["shipping_address"]>;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** build a `shipping_query` {@link Update}. */
|
|
331
|
+
export function shippingQueryUpdate(options: ShippingQueryUpdateOptions = {}): Update {
|
|
332
|
+
const { id = "1", fromId = 1, invoicePayload = "", shippingAddress = {} } = options;
|
|
333
|
+
|
|
334
|
+
return createUpdate({
|
|
335
|
+
shipping_query: {
|
|
336
|
+
id,
|
|
337
|
+
from: stubUser(fromId),
|
|
338
|
+
invoice_payload: invoicePayload,
|
|
339
|
+
shipping_address: {
|
|
340
|
+
country_code: "US",
|
|
341
|
+
state: "",
|
|
342
|
+
city: "New York",
|
|
343
|
+
street_line1: "",
|
|
344
|
+
street_line2: "",
|
|
345
|
+
post_code: "10001",
|
|
346
|
+
...shippingAddress,
|
|
347
|
+
},
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** options for {@link preCheckoutQueryUpdate}. */
|
|
353
|
+
export interface PreCheckoutQueryUpdateOptions {
|
|
354
|
+
id?: string;
|
|
355
|
+
fromId?: number;
|
|
356
|
+
currency?: string;
|
|
357
|
+
totalAmount?: number;
|
|
358
|
+
invoicePayload?: string;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** build a `pre_checkout_query` {@link Update}. */
|
|
362
|
+
export function preCheckoutQueryUpdate(options: PreCheckoutQueryUpdateOptions = {}): Update {
|
|
363
|
+
const {
|
|
364
|
+
id = "1",
|
|
365
|
+
fromId = 1,
|
|
366
|
+
currency = "USD",
|
|
367
|
+
totalAmount = 100,
|
|
368
|
+
invoicePayload = "",
|
|
369
|
+
} = options;
|
|
370
|
+
|
|
371
|
+
return createUpdate({
|
|
372
|
+
pre_checkout_query: {
|
|
373
|
+
id,
|
|
374
|
+
from: stubUser(fromId),
|
|
375
|
+
currency,
|
|
376
|
+
total_amount: totalAmount,
|
|
377
|
+
invoice_payload: invoicePayload,
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** options for {@link pollUpdate}. */
|
|
383
|
+
export interface PollUpdateOptions {
|
|
384
|
+
id?: string;
|
|
385
|
+
question?: string;
|
|
386
|
+
options?: string[];
|
|
387
|
+
isClosed?: boolean;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** build a `poll` {@link Update}. */
|
|
391
|
+
export function pollUpdate(options: PollUpdateOptions = {}): Update {
|
|
392
|
+
const { id = "1", question = "", options: choices = ["yes", "no"], isClosed = false } = options;
|
|
393
|
+
|
|
394
|
+
return createUpdate({
|
|
395
|
+
poll: {
|
|
396
|
+
id,
|
|
397
|
+
question,
|
|
398
|
+
options: choices.map((text, i) => ({ persistent_id: String(i), text, voter_count: 0 })),
|
|
399
|
+
total_voter_count: 0,
|
|
400
|
+
is_closed: isClosed,
|
|
401
|
+
is_anonymous: true,
|
|
402
|
+
type: "regular",
|
|
403
|
+
allows_multiple_answers: false,
|
|
404
|
+
allows_revoting: false,
|
|
405
|
+
members_only: false,
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** options for {@link pollAnswerUpdate}. */
|
|
411
|
+
export interface PollAnswerUpdateOptions {
|
|
412
|
+
pollId?: string;
|
|
413
|
+
fromId?: number;
|
|
414
|
+
optionIds?: number[];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** build a `poll_answer` {@link Update}. */
|
|
418
|
+
export function pollAnswerUpdate(options: PollAnswerUpdateOptions = {}): Update {
|
|
419
|
+
const { pollId = "1", fromId = 1, optionIds = [0] } = options;
|
|
420
|
+
|
|
421
|
+
return createUpdate({
|
|
422
|
+
poll_answer: {
|
|
423
|
+
poll_id: pollId,
|
|
424
|
+
user: stubUser(fromId),
|
|
425
|
+
option_ids: optionIds,
|
|
426
|
+
option_persistent_ids: optionIds.map(String),
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** options for {@link myChatMemberUpdate} / {@link chatMemberUpdate}. */
|
|
432
|
+
export interface ChatMemberUpdateOptions {
|
|
433
|
+
chatId?: number;
|
|
434
|
+
fromId?: number;
|
|
435
|
+
userId?: number;
|
|
436
|
+
oldStatus?: string;
|
|
437
|
+
newStatus?: string;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function buildChatMemberUpdate(options: ChatMemberUpdateOptions): Payload<"my_chat_member"> {
|
|
441
|
+
const {
|
|
442
|
+
chatId = 1,
|
|
443
|
+
fromId = chatId,
|
|
444
|
+
userId = fromId,
|
|
445
|
+
oldStatus = "member",
|
|
446
|
+
newStatus = "member",
|
|
447
|
+
} = options;
|
|
448
|
+
const user = stubUser(userId);
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
chat: { id: chatId, type: "group" },
|
|
452
|
+
from: stubUser(fromId),
|
|
453
|
+
date: 0,
|
|
454
|
+
old_chat_member: { status: oldStatus, user },
|
|
455
|
+
new_chat_member: { status: newStatus, user },
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** build a `my_chat_member` {@link Update} (the bot's own membership changed). */
|
|
460
|
+
export function myChatMemberUpdate(options: ChatMemberUpdateOptions = {}): Update {
|
|
461
|
+
return createUpdate({ my_chat_member: buildChatMemberUpdate(options) });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** build a `chat_member` {@link Update} (another member's membership changed). */
|
|
465
|
+
export function chatMemberUpdate(options: ChatMemberUpdateOptions = {}): Update {
|
|
466
|
+
return createUpdate({ chat_member: buildChatMemberUpdate(options) });
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** options for {@link chatJoinRequestUpdate}. */
|
|
470
|
+
export interface ChatJoinRequestUpdateOptions {
|
|
471
|
+
chatId?: number;
|
|
472
|
+
fromId?: number;
|
|
473
|
+
userChatId?: number;
|
|
474
|
+
bio?: string;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** build a `chat_join_request` {@link Update}. */
|
|
478
|
+
export function chatJoinRequestUpdate(options: ChatJoinRequestUpdateOptions = {}): Update {
|
|
479
|
+
const { chatId = 1, fromId = 1, userChatId = fromId, bio } = options;
|
|
480
|
+
|
|
481
|
+
return createUpdate({
|
|
482
|
+
chat_join_request: {
|
|
483
|
+
chat: { id: chatId, type: "group" },
|
|
484
|
+
from: stubUser(fromId),
|
|
485
|
+
user_chat_id: userChatId,
|
|
486
|
+
date: 0,
|
|
487
|
+
...(bio ? { bio } : {}),
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
136
492
|
/** infer which payload key an update carries; defaults to `"message"`. */
|
|
137
493
|
export function detectUpdateType(update: Update): UpdateName {
|
|
138
494
|
if (update.message) return "message";
|
|
@@ -160,6 +516,16 @@ export function createContext(update: Update, api?: Api, updateType?: UpdateName
|
|
|
160
516
|
});
|
|
161
517
|
}
|
|
162
518
|
|
|
519
|
+
/** shortcut: build a `message` update and wrap it in a {@link Context} in one call. */
|
|
520
|
+
export function messageContext(options: MessageUpdateOptions = {}, api?: Api): Context {
|
|
521
|
+
return createContext(messageUpdate(options), api);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** shortcut: build a `callback_query` update and wrap it in a {@link Context} in one call. */
|
|
525
|
+
export function callbackContext(options: CallbackUpdateOptions = {}, api?: Api): Context {
|
|
526
|
+
return createContext(callbackUpdate(options), api);
|
|
527
|
+
}
|
|
528
|
+
|
|
163
529
|
const noop: NextFn = async () => {};
|
|
164
530
|
|
|
165
531
|
/** run a composer's middleware against a context. resolves when the chain settles. */
|
|
@@ -170,4 +536,99 @@ export async function runMiddleware<C extends Context>(
|
|
|
170
536
|
await composer.toMiddleware()(ctx, noop);
|
|
171
537
|
}
|
|
172
538
|
|
|
539
|
+
/** an inline keyboard button found by {@link findButton}, with its position. */
|
|
540
|
+
export interface FoundButton {
|
|
541
|
+
text: string;
|
|
542
|
+
row: number;
|
|
543
|
+
col: number;
|
|
544
|
+
[key: string]: unknown;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* search an inline keyboard (a `reply_markup`-shaped object, e.g. from a
|
|
549
|
+
* recorded `sendMessage` call's params) for a button whose text matches a
|
|
550
|
+
* string or regex. returns the button (plus its `row`/`col`) or `undefined`.
|
|
551
|
+
*/
|
|
552
|
+
export function findButton(
|
|
553
|
+
markup: { inline_keyboard?: Array<Array<Record<string, unknown>>> } | undefined,
|
|
554
|
+
match: string | RegExp,
|
|
555
|
+
): FoundButton | undefined {
|
|
556
|
+
const rows = markup?.inline_keyboard ?? [];
|
|
557
|
+
|
|
558
|
+
for (let row = 0; row < rows.length; row++) {
|
|
559
|
+
const cols = rows[row] ?? [];
|
|
560
|
+
|
|
561
|
+
for (let col = 0; col < cols.length; col++) {
|
|
562
|
+
const button = cols[col];
|
|
563
|
+
if (!button) continue;
|
|
564
|
+
|
|
565
|
+
const text = typeof button.text === "string" ? button.text : "";
|
|
566
|
+
const matches = typeof match === "string" ? text === match : match.test(text);
|
|
567
|
+
|
|
568
|
+
if (matches) return { ...button, text, row, col };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return undefined;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** result of {@link collectUpdates}: an {@link UpdateSink} plus the updates it received, in order. */
|
|
576
|
+
export interface UpdateCollector {
|
|
577
|
+
sink: UpdateSink;
|
|
578
|
+
updates: Update[];
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** a minimal {@link UpdateSink} (the `{ handleUpdate }` shape `webhookCallback`/runners expect) that just records. */
|
|
582
|
+
export function collectUpdates(): UpdateCollector {
|
|
583
|
+
const updates: Update[] = [];
|
|
584
|
+
|
|
585
|
+
return {
|
|
586
|
+
sink: {
|
|
587
|
+
handleUpdate: async (update) => {
|
|
588
|
+
updates.push(update);
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
updates,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** options for {@link webhookRequest}. */
|
|
596
|
+
export interface WebhookRequestOptions {
|
|
597
|
+
url?: string;
|
|
598
|
+
method?: string;
|
|
599
|
+
secretToken?: string;
|
|
600
|
+
headers?: Record<string, string>;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** build a `Request` carrying `update` as JSON, as telegram would POST it to a webhook handler. */
|
|
604
|
+
export function webhookRequest(update: Update, options: WebhookRequestOptions = {}): Request {
|
|
605
|
+
const {
|
|
606
|
+
url = "https://example.invalid/webhook",
|
|
607
|
+
method = "POST",
|
|
608
|
+
secretToken,
|
|
609
|
+
headers = {},
|
|
610
|
+
} = options;
|
|
611
|
+
|
|
612
|
+
const finalHeaders: Record<string, string> = { "content-type": "application/json", ...headers };
|
|
613
|
+
if (secretToken) finalHeaders["x-telegram-bot-api-secret-token"] = secretToken;
|
|
614
|
+
|
|
615
|
+
return new Request(url, {
|
|
616
|
+
method,
|
|
617
|
+
headers: finalHeaders,
|
|
618
|
+
body: method === "GET" || method === "HEAD" ? undefined : JSON.stringify(update),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/** stub `globalThis.fetch` for the duration of `fn`, restoring the original afterwards (even on throw). */
|
|
623
|
+
export async function withFetch<T>(handler: typeof fetch, fn: () => T | Promise<T>): Promise<T> {
|
|
624
|
+
const realFetch = globalThis.fetch;
|
|
625
|
+
globalThis.fetch = handler;
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
return await fn();
|
|
629
|
+
} finally {
|
|
630
|
+
globalThis.fetch = realFetch;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
173
634
|
export type { Message };
|