@yaebal/test 0.0.1 → 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/README.md +400 -15
- package/lib/api.d.ts +83 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +186 -0
- package/lib/api.js.map +1 -0
- package/lib/api.test.d.ts +2 -0
- package/lib/api.test.d.ts.map +1 -0
- package/lib/api.test.js +131 -0
- package/lib/api.test.js.map +1 -0
- package/lib/bot-messages.d.ts +42 -0
- package/lib/bot-messages.d.ts.map +1 -0
- package/lib/bot-messages.js +72 -0
- package/lib/bot-messages.js.map +1 -0
- package/lib/chat-actor.d.ts +45 -0
- package/lib/chat-actor.d.ts.map +1 -0
- package/lib/chat-actor.js +72 -0
- package/lib/chat-actor.js.map +1 -0
- package/lib/clock.d.ts +22 -0
- package/lib/clock.d.ts.map +1 -0
- package/lib/clock.js +72 -0
- package/lib/clock.js.map +1 -0
- package/lib/clock.test.d.ts +2 -0
- package/lib/clock.test.d.ts.map +1 -0
- package/lib/clock.test.js +69 -0
- package/lib/clock.test.js.map +1 -0
- package/lib/env.d.ts +100 -0
- package/lib/env.d.ts.map +1 -0
- package/lib/env.js +164 -0
- package/lib/env.js.map +1 -0
- package/lib/env.test.d.ts +2 -0
- package/lib/env.test.d.ts.map +1 -0
- package/lib/env.test.js +302 -0
- package/lib/env.test.js.map +1 -0
- package/lib/fetch.d.ts +3 -0
- package/lib/fetch.d.ts.map +1 -0
- package/lib/fetch.js +12 -0
- package/lib/fetch.js.map +1 -0
- package/lib/index.d.ts +19 -52
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +19 -115
- package/lib/index.js.map +1 -1
- package/lib/internal.d.ts +25 -0
- package/lib/internal.d.ts.map +1 -0
- package/lib/internal.js +19 -0
- package/lib/internal.js.map +1 -0
- package/lib/keyboard.d.ts +15 -0
- package/lib/keyboard.d.ts.map +1 -0
- package/lib/keyboard.js +25 -0
- package/lib/keyboard.js.map +1 -0
- package/lib/keyboard.test.d.ts +2 -0
- package/lib/keyboard.test.d.ts.map +1 -0
- package/lib/keyboard.test.js +31 -0
- package/lib/keyboard.test.js.map +1 -0
- package/lib/normalize.d.ts +10 -0
- package/lib/normalize.d.ts.map +1 -0
- package/lib/normalize.js +27 -0
- package/lib/normalize.js.map +1 -0
- package/lib/reactions.d.ts +3 -0
- package/lib/reactions.d.ts.map +1 -0
- package/lib/reactions.js +17 -0
- package/lib/reactions.js.map +1 -0
- package/lib/updates.d.ts +126 -0
- package/lib/updates.d.ts.map +1 -0
- package/lib/updates.js +200 -0
- package/lib/updates.js.map +1 -0
- package/lib/updates.test.d.ts +2 -0
- package/lib/updates.test.d.ts.map +1 -0
- package/lib/updates.test.js +72 -0
- package/lib/updates.test.js.map +1 -0
- package/lib/user-actor.d.ts +188 -0
- package/lib/user-actor.d.ts.map +1 -0
- package/lib/user-actor.js +465 -0
- package/lib/user-actor.js.map +1 -0
- package/lib/webhook.d.ts +18 -0
- package/lib/webhook.d.ts.map +1 -0
- package/lib/webhook.js +25 -0
- package/lib/webhook.js.map +1 -0
- package/lib/webhook.test.d.ts +2 -0
- package/lib/webhook.test.d.ts.map +1 -0
- package/lib/webhook.test.js +36 -0
- package/lib/webhook.test.js.map +1 -0
- package/package.json +7 -5
- package/src/api.test.ts +180 -0
- package/src/api.ts +289 -0
- package/src/bot-messages.ts +117 -0
- package/src/chat-actor.ts +101 -0
- package/src/clock.test.ts +80 -0
- package/src/clock.ts +118 -0
- package/src/env.test.ts +370 -0
- package/src/env.ts +235 -0
- package/src/fetch.ts +11 -0
- package/src/index.ts +79 -169
- package/src/internal.ts +43 -0
- package/src/keyboard.test.ts +35 -0
- package/src/keyboard.ts +38 -0
- package/src/normalize.ts +34 -0
- package/src/reactions.ts +18 -0
- package/src/updates.test.ts +107 -0
- package/src/updates.ts +354 -0
- package/src/user-actor.ts +702 -0
- package/src/webhook.test.ts +54 -0
- package/src/webhook.ts +48 -0
- package/lib/index.test.d.ts +0 -2
- package/lib/index.test.d.ts.map +0 -1
- package/lib/index.test.js +0 -66
- package/lib/index.test.js.map +0 -1
- package/src/index.test.ts +0 -101
package/src/index.ts
CHANGED
|
@@ -1,173 +1,83 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @yaebal/test —
|
|
2
|
+
* @yaebal/test — an actor-driven test framework for yaebal bots, with zero dependency on any
|
|
3
|
+
* test runner or assertion library. Works with `node:test`, vitest, bun:test, jest, ava —
|
|
4
|
+
* anything that can `await` a promise and call `assert`.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
Context,
|
|
14
|
-
type Message,
|
|
15
|
-
type NextFn,
|
|
16
|
-
type Update,
|
|
17
|
-
type UpdateName,
|
|
18
|
-
} from "@yaebal/core";
|
|
19
|
-
|
|
20
|
-
/** a single recorded api call: the method name and the params it was given. */
|
|
21
|
-
export interface RecordedCall {
|
|
22
|
-
method: string;
|
|
23
|
-
params: Record<string, unknown> | undefined;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** result of {@link mockApi}: the fake `api` plus the array it records into. */
|
|
27
|
-
export interface MockApi {
|
|
28
|
-
api: Api;
|
|
29
|
-
calls: RecordedCall[];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** default results for known methods; everything else resolves to `{}`. */
|
|
33
|
-
function defaultResult(method: string): unknown {
|
|
34
|
-
if (method.startsWith("send") || method === "copyMessage" || method === "forwardMessage") {
|
|
35
|
-
return { message_id: 1 };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
if (method === "answerCallbackQuery") return true;
|
|
39
|
-
if (method === "getMe") return { id: 1, is_bot: true, first_name: "bot", username: "bot" };
|
|
40
|
-
|
|
41
|
-
return {};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* a fake {@link Api} whose every method records `{ method, params }` into `calls`
|
|
46
|
-
* and resolves to a sensible default (`{ message_id: 1 }` for `send*`, `true` for
|
|
47
|
-
* `answerCallbackQuery`, `{}` otherwise). hook registrars (`before`/`after`/
|
|
48
|
-
* `onError`) are no-ops that return the api for chaining.
|
|
49
|
-
*/
|
|
50
|
-
export function mockApi(): MockApi {
|
|
51
|
-
const calls: RecordedCall[] = [];
|
|
52
|
-
|
|
53
|
-
const record = (method: string, params?: Record<string, unknown>): Promise<never> => {
|
|
54
|
-
calls.push({ method, params });
|
|
55
|
-
return Promise.resolve(defaultResult(method) as never);
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
const registrar: Record<string, unknown> = {
|
|
59
|
-
call: (method: string, params?: Record<string, unknown>) => record(method, params),
|
|
60
|
-
fileUrl: (filePath: string) => `https://example.invalid/file/${filePath}`,
|
|
61
|
-
before: () => api,
|
|
62
|
-
after: () => api,
|
|
63
|
-
onError: () => api,
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
const api = new Proxy(registrar, {
|
|
67
|
-
get(obj, prop: string) {
|
|
68
|
-
if (prop in obj) return obj[prop];
|
|
69
|
-
|
|
70
|
-
const method = (params?: Record<string, unknown>) => record(prop, params);
|
|
71
|
-
obj[prop] = method;
|
|
72
|
-
|
|
73
|
-
return method;
|
|
74
|
-
},
|
|
75
|
-
}) as unknown as Api;
|
|
76
|
-
|
|
77
|
-
return { api, calls };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
let updateIdCounter = 0;
|
|
81
|
-
|
|
82
|
-
/** build an {@link Update} from a partial, filling in a fresh `update_id`. */
|
|
83
|
-
export function createUpdate(partial: Partial<Update> = {}): Update {
|
|
84
|
-
return { update_id: ++updateIdCounter, ...partial };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** options for {@link messageUpdate}. */
|
|
88
|
-
export interface MessageUpdateOptions {
|
|
89
|
-
text?: string;
|
|
90
|
-
chatId?: number;
|
|
91
|
-
fromId?: number;
|
|
92
|
-
chatType?: "private" | "group" | "supergroup" | "channel";
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** build a message {@link Update}. */
|
|
96
|
-
export function messageUpdate(options: MessageUpdateOptions = {}): Update {
|
|
97
|
-
const { text = "", chatId = 1, fromId = chatId, chatType = "private" } = options;
|
|
98
|
-
|
|
99
|
-
return createUpdate({
|
|
100
|
-
message: {
|
|
101
|
-
message_id: 1,
|
|
102
|
-
date: 0,
|
|
103
|
-
chat: { id: chatId, type: chatType },
|
|
104
|
-
from: { id: fromId, is_bot: false, first_name: "u" },
|
|
105
|
-
text,
|
|
106
|
-
},
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** options for {@link callbackUpdate}. */
|
|
111
|
-
export interface CallbackUpdateOptions {
|
|
112
|
-
data?: string;
|
|
113
|
-
chatId?: number;
|
|
114
|
-
fromId?: number;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** build a callback_query {@link Update}. */
|
|
118
|
-
export function callbackUpdate(options: CallbackUpdateOptions = {}): Update {
|
|
119
|
-
const { data = "", chatId = 1, fromId = chatId } = options;
|
|
120
|
-
|
|
121
|
-
return createUpdate({
|
|
122
|
-
callback_query: {
|
|
123
|
-
id: "1",
|
|
124
|
-
chat_instance: "0",
|
|
125
|
-
from: { id: fromId, is_bot: false, first_name: "u" },
|
|
126
|
-
message: {
|
|
127
|
-
message_id: 1,
|
|
128
|
-
date: 0,
|
|
129
|
-
chat: { id: chatId, type: "private" },
|
|
130
|
-
},
|
|
131
|
-
data,
|
|
132
|
-
},
|
|
133
|
-
});
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** infer which payload key an update carries; defaults to `"message"`. */
|
|
137
|
-
export function detectUpdateType(update: Update): UpdateName {
|
|
138
|
-
if (update.message) return "message";
|
|
139
|
-
if (update.edited_message) return "edited_message";
|
|
140
|
-
if (update.channel_post) return "channel_post";
|
|
141
|
-
if (update.callback_query) return "callback_query";
|
|
142
|
-
|
|
143
|
-
const bag = update as unknown as Record<string, unknown>;
|
|
144
|
-
for (const key of Object.keys(bag)) {
|
|
145
|
-
if (key !== "update_id" && bag[key] !== undefined) return key as UpdateName;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return "message";
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* wrap an {@link Update} in a core {@link Context}. the api defaults to a fresh
|
|
153
|
-
* {@link mockApi}; pass `updateType` to override the auto-detected one.
|
|
6
|
+
* {@link createTestEnv} wraps your bot and hands you {@link UserActor}/{@link ChatActor} actors
|
|
7
|
+
* that send it real updates — messages, commands, media, reactions, button clicks, joins,
|
|
8
|
+
* payments — the way real Telegram users would. every outgoing api call is intercepted and
|
|
9
|
+
* recorded (no real HTTP), with sensible auto-stubs, `onApi`/`apiError` overrides for the rest,
|
|
10
|
+
* a virtual clock for TTL/retry tests, and satellite-plugin test packs.
|
|
11
|
+
*
|
|
12
|
+
* fixture builders ({@link messageUpdate} & co.), {@link webhookRequest}/{@link collectUpdates},
|
|
13
|
+
* and {@link withFetch} remain as the escape hatch beneath the actor api — reach for them when
|
|
14
|
+
* you need a raw update shape or full control.
|
|
154
15
|
*/
|
|
155
|
-
export function createContext(update: Update, api?: Api, updateType?: UpdateName): Context {
|
|
156
|
-
return new Context({
|
|
157
|
-
api: api ?? mockApi().api,
|
|
158
|
-
update,
|
|
159
|
-
updateType: updateType ?? detectUpdateType(update),
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
const noop: NextFn = async () => {};
|
|
164
|
-
|
|
165
|
-
/** run a composer's middleware against a context. resolves when the chain settles. */
|
|
166
|
-
export async function runMiddleware<C extends Context>(
|
|
167
|
-
composer: Composer<C>,
|
|
168
|
-
ctx: C,
|
|
169
|
-
): Promise<void> {
|
|
170
|
-
await composer.toMiddleware()(ctx, noop);
|
|
171
|
-
}
|
|
172
16
|
|
|
173
|
-
export type
|
|
17
|
+
export { type MockApi, type MockApiOptions, mockApi } from "./api.js";
|
|
18
|
+
export {
|
|
19
|
+
type ApiErrorSentinel,
|
|
20
|
+
apiError,
|
|
21
|
+
type BotMessage,
|
|
22
|
+
ChatActor,
|
|
23
|
+
type ChatMembership,
|
|
24
|
+
type ChatType,
|
|
25
|
+
type CreateChatOptions,
|
|
26
|
+
createTestEnv,
|
|
27
|
+
installTestClock,
|
|
28
|
+
isApiErrorSentinel,
|
|
29
|
+
type LastBotMessageQuery,
|
|
30
|
+
type MediaOptions,
|
|
31
|
+
type MessageOptions,
|
|
32
|
+
type MockResult,
|
|
33
|
+
type OnApiOptions,
|
|
34
|
+
type RecordedCall,
|
|
35
|
+
TestApiError,
|
|
36
|
+
type TestClock,
|
|
37
|
+
TestEnv,
|
|
38
|
+
type TestEnvOptions,
|
|
39
|
+
type TestPack,
|
|
40
|
+
UserActor,
|
|
41
|
+
UserInChatScope,
|
|
42
|
+
UserOnMessageScope,
|
|
43
|
+
} from "./env.js";
|
|
44
|
+
export { withFetch } from "./fetch.js";
|
|
45
|
+
|
|
46
|
+
export { type FoundButton, findButton } from "./keyboard.js";
|
|
47
|
+
export { toPlain } from "./normalize.js";
|
|
48
|
+
export {
|
|
49
|
+
type BuildUserOptions,
|
|
50
|
+
buildUser,
|
|
51
|
+
type CallbackUpdateOptions,
|
|
52
|
+
type ChatJoinRequestUpdateOptions,
|
|
53
|
+
type ChatMemberUpdateOptions,
|
|
54
|
+
type ChosenInlineResultUpdateOptions,
|
|
55
|
+
callbackUpdate,
|
|
56
|
+
channelPostUpdate,
|
|
57
|
+
chatJoinRequestUpdate,
|
|
58
|
+
chatMemberUpdate,
|
|
59
|
+
chosenInlineResultUpdate,
|
|
60
|
+
createUpdate,
|
|
61
|
+
detectUpdateType,
|
|
62
|
+
editedChannelPostUpdate,
|
|
63
|
+
editedMessageUpdate,
|
|
64
|
+
type InlineQueryUpdateOptions,
|
|
65
|
+
inlineQueryUpdate,
|
|
66
|
+
type MessageUpdateOptions,
|
|
67
|
+
messageUpdate,
|
|
68
|
+
myChatMemberUpdate,
|
|
69
|
+
type PollAnswerUpdateOptions,
|
|
70
|
+
type PollUpdateOptions,
|
|
71
|
+
type PreCheckoutQueryUpdateOptions,
|
|
72
|
+
pollAnswerUpdate,
|
|
73
|
+
pollUpdate,
|
|
74
|
+
preCheckoutQueryUpdate,
|
|
75
|
+
type ShippingQueryUpdateOptions,
|
|
76
|
+
shippingQueryUpdate,
|
|
77
|
+
} from "./updates.js";
|
|
78
|
+
export {
|
|
79
|
+
collectUpdates,
|
|
80
|
+
type UpdateCollector,
|
|
81
|
+
type WebhookRequestOptions,
|
|
82
|
+
webhookRequest,
|
|
83
|
+
} from "./webhook.js";
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { FormatResult, MessageEntity, Update } from "@yaebal/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* the sliver of {@link TestEnv} actors need — kept as a separate interface (rather than a
|
|
5
|
+
* circular import of the class) so `chat-actor.ts`/`user-actor.ts` don't depend on `env.ts`.
|
|
6
|
+
*/
|
|
7
|
+
export interface ActorHost {
|
|
8
|
+
dispatch(update: Update): Promise<void>;
|
|
9
|
+
nextMessageId(): number;
|
|
10
|
+
now(): number;
|
|
11
|
+
/** did the bot answer `answerPreCheckoutQuery` for `preCheckoutQueryId` with `ok: true`? */
|
|
12
|
+
answeredPreCheckoutQuery(preCheckoutQueryId: string): boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** text an actor method accepts: a plain string, or a `format`/`fmt` result carrying entities. */
|
|
16
|
+
export type SendText = string | FormatResult;
|
|
17
|
+
|
|
18
|
+
function isFormatResult(value: unknown): value is FormatResult {
|
|
19
|
+
return (
|
|
20
|
+
typeof value === "object" &&
|
|
21
|
+
value !== null &&
|
|
22
|
+
"text" in value &&
|
|
23
|
+
"entities" in value &&
|
|
24
|
+
Array.isArray((value as FormatResult).entities)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** resolve a `SendText` into `{ text, entities }`, merging in any `extraEntities`. */
|
|
29
|
+
export function resolveSendText(
|
|
30
|
+
text: SendText,
|
|
31
|
+
extraEntities: MessageEntity[] = [],
|
|
32
|
+
): { text: string; entities: MessageEntity[] } {
|
|
33
|
+
const resolved = isFormatResult(text) ? text : { text, entities: [] };
|
|
34
|
+
return { text: resolved.text, entities: [...resolved.entities, ...extraEntities] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let fileCounter = 0;
|
|
38
|
+
|
|
39
|
+
/** a fake `{ file_id, file_unique_id }` pair — good enough for tests, never a real download. */
|
|
40
|
+
export function fakeFile(prefix: string): { file_id: string; file_unique_id: string } {
|
|
41
|
+
const n = ++fileCounter;
|
|
42
|
+
return { file_id: `${prefix}_${n}`, file_unique_id: `u${prefix}${n}` };
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { findButton } from "./keyboard.js";
|
|
4
|
+
|
|
5
|
+
test("findButton locates a button by text and reports its position", () => {
|
|
6
|
+
const markup = {
|
|
7
|
+
inline_keyboard: [
|
|
8
|
+
[{ text: "a", callback_data: "a" }],
|
|
9
|
+
[
|
|
10
|
+
{ text: "b", callback_data: "b" },
|
|
11
|
+
{ text: "Next", callback_data: "page:2" },
|
|
12
|
+
],
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const found = findButton(markup, "Next");
|
|
17
|
+
assert.equal(found?.callback_data, "page:2");
|
|
18
|
+
assert.equal(found?.row, 1);
|
|
19
|
+
assert.equal(found?.col, 1);
|
|
20
|
+
|
|
21
|
+
assert.equal(findButton(markup, /^n/i)?.text, "Next");
|
|
22
|
+
assert.equal(findButton(markup, "missing"), undefined);
|
|
23
|
+
assert.equal(findButton(undefined, "missing"), undefined);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("findButton unwraps a builder instance via toJSON()", () => {
|
|
27
|
+
class FakeInlineKeyboard {
|
|
28
|
+
toJSON() {
|
|
29
|
+
return { inline_keyboard: [[{ text: "Restart", callback_data: "restart" }]] };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const found = findButton(new FakeInlineKeyboard(), "Restart");
|
|
34
|
+
assert.equal(found?.callback_data, "restart");
|
|
35
|
+
});
|
package/src/keyboard.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { toPlain } from "./normalize.js";
|
|
2
|
+
|
|
3
|
+
/** an inline keyboard button found by {@link findButton}, with its position. */
|
|
4
|
+
export interface FoundButton {
|
|
5
|
+
text: string;
|
|
6
|
+
row: number;
|
|
7
|
+
col: number;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* search an inline keyboard (a `reply_markup`-shaped object — or an `InlineKeyboard`/`Keyboard`
|
|
13
|
+
* builder instance, unwrapped via `toJSON()` automatically — e.g. from a recorded call's params,
|
|
14
|
+
* or a {@link BotMessage}'s `reply_markup`) for a button whose text matches a string or regex.
|
|
15
|
+
* returns the button (plus its `row`/`col`) or `undefined`.
|
|
16
|
+
*/
|
|
17
|
+
export function findButton(markup: unknown, match: string | RegExp): FoundButton | undefined {
|
|
18
|
+
const plain = toPlain<{ inline_keyboard?: Array<Array<Record<string, unknown>>> } | undefined>(
|
|
19
|
+
markup,
|
|
20
|
+
);
|
|
21
|
+
const rows = plain?.inline_keyboard ?? [];
|
|
22
|
+
|
|
23
|
+
for (let row = 0; row < rows.length; row++) {
|
|
24
|
+
const cols = rows[row] ?? [];
|
|
25
|
+
|
|
26
|
+
for (let col = 0; col < cols.length; col++) {
|
|
27
|
+
const button = cols[col];
|
|
28
|
+
if (!button) continue;
|
|
29
|
+
|
|
30
|
+
const text = typeof button.text === "string" ? button.text : "";
|
|
31
|
+
const matches = typeof match === "string" ? text === match : match.test(text);
|
|
32
|
+
|
|
33
|
+
if (matches) return { ...button, text, row, col };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* builder instances (e.g. `InlineKeyboard`/`Keyboard` from `@yaebal/keyboard`) expose a
|
|
3
|
+
* `toJSON()` the same way `Date`/`Map`-like classes do. unwrap them so recorded calls and
|
|
4
|
+
* assertions see plain JSON, never a class instance — no `@yaebal/keyboard` dependency needed,
|
|
5
|
+
* this is just duck typing on the standard `toJSON` convention.
|
|
6
|
+
*/
|
|
7
|
+
export function toPlain<T = unknown>(value: unknown): T {
|
|
8
|
+
if (value && typeof (value as { toJSON?: unknown }).toJSON === "function") {
|
|
9
|
+
return (value as { toJSON(): T }).toJSON();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return value as T;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** shallow-clone `params`, normalizing `reply_markup` (and, for `answerInlineQuery`, each `results[].reply_markup`) via {@link toPlain}. */
|
|
16
|
+
export function normalizeParams(
|
|
17
|
+
params: Record<string, unknown> | undefined,
|
|
18
|
+
): Record<string, unknown> | undefined {
|
|
19
|
+
if (!params) return params;
|
|
20
|
+
|
|
21
|
+
const out = { ...params };
|
|
22
|
+
|
|
23
|
+
if ("reply_markup" in out) out.reply_markup = toPlain(out.reply_markup);
|
|
24
|
+
|
|
25
|
+
if (Array.isArray(out.results)) {
|
|
26
|
+
out.results = out.results.map((item) =>
|
|
27
|
+
item && typeof item === "object" && "reply_markup" in item
|
|
28
|
+
? { ...item, reply_markup: toPlain((item as Record<string, unknown>).reply_markup) }
|
|
29
|
+
: item,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return out;
|
|
34
|
+
}
|
package/src/reactions.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* per-message reaction state, so {@link UserActor.react} never needs `oldReaction` spelled out
|
|
3
|
+
* by hand — it's inferred from whatever that user's last `react()` on this message left behind.
|
|
4
|
+
* keyed by the `Message` object's identity (a `WeakMap`, so it costs nothing once a message is
|
|
5
|
+
* no longer referenced) rather than `message_id`, since ids aren't guaranteed unique across chats.
|
|
6
|
+
*/
|
|
7
|
+
const store = new WeakMap<object, Map<number, string[]>>();
|
|
8
|
+
|
|
9
|
+
/** the emojis a given user currently has on `message` (per-message, per-user reaction state). */
|
|
10
|
+
export function reactionsOf(message: object): Map<number, string[]> {
|
|
11
|
+
let state = store.get(message);
|
|
12
|
+
if (!state) {
|
|
13
|
+
state = new Map();
|
|
14
|
+
store.set(message, state);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return state;
|
|
18
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildUser,
|
|
5
|
+
callbackUpdate,
|
|
6
|
+
channelPostUpdate,
|
|
7
|
+
chatJoinRequestUpdate,
|
|
8
|
+
chatMemberUpdate,
|
|
9
|
+
chosenInlineResultUpdate,
|
|
10
|
+
createUpdate,
|
|
11
|
+
detectUpdateType,
|
|
12
|
+
editedMessageUpdate,
|
|
13
|
+
inlineQueryUpdate,
|
|
14
|
+
messageUpdate,
|
|
15
|
+
myChatMemberUpdate,
|
|
16
|
+
pollAnswerUpdate,
|
|
17
|
+
pollUpdate,
|
|
18
|
+
preCheckoutQueryUpdate,
|
|
19
|
+
shippingQueryUpdate,
|
|
20
|
+
} from "./updates.js";
|
|
21
|
+
|
|
22
|
+
test("messageUpdate produces a valid message update", () => {
|
|
23
|
+
const update = messageUpdate({ text: "hello", chatId: 42, chatType: "group" });
|
|
24
|
+
|
|
25
|
+
assert.ok(update.update_id > 0);
|
|
26
|
+
assert.equal(update.message?.text, "hello");
|
|
27
|
+
assert.equal(update.message?.chat.id, 42);
|
|
28
|
+
assert.equal(update.message?.chat.type, "group");
|
|
29
|
+
assert.equal(update.message?.from?.id, 42);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("callbackUpdate produces a valid callback_query update", () => {
|
|
33
|
+
const update = callbackUpdate({ data: "click", chatId: 7, fromId: 9 });
|
|
34
|
+
|
|
35
|
+
assert.equal(update.callback_query?.data, "click");
|
|
36
|
+
assert.equal(update.callback_query?.from.id, 9);
|
|
37
|
+
assert.equal(update.callback_query?.message?.chat.id, 7);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("createUpdate fills a fresh update_id", () => {
|
|
41
|
+
const a = createUpdate();
|
|
42
|
+
const b = createUpdate();
|
|
43
|
+
|
|
44
|
+
assert.notEqual(a.update_id, b.update_id);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("buildUser auto-allocates distinct ids and fills defaults", () => {
|
|
48
|
+
const a = buildUser();
|
|
49
|
+
const b = buildUser({ firstName: "Linia", username: "linia" });
|
|
50
|
+
|
|
51
|
+
assert.notEqual(a.id, b.id);
|
|
52
|
+
assert.equal(b.first_name, "Linia");
|
|
53
|
+
assert.equal(b.username, "linia");
|
|
54
|
+
assert.equal(b.is_bot, false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("channelPostUpdate / editedMessageUpdate build the right update key", () => {
|
|
58
|
+
assert.equal(channelPostUpdate({ text: "hi" }).channel_post?.text, "hi");
|
|
59
|
+
assert.equal(channelPostUpdate().channel_post?.chat.type, "channel");
|
|
60
|
+
assert.equal(editedMessageUpdate({ text: "hi" }).edited_message?.text, "hi");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("inlineQueryUpdate / chosenInlineResultUpdate build their payloads", () => {
|
|
64
|
+
const iq = inlineQueryUpdate({ query: "cats", fromId: 5 });
|
|
65
|
+
assert.equal(iq.inline_query?.query, "cats");
|
|
66
|
+
assert.equal(iq.inline_query?.from.id, 5);
|
|
67
|
+
|
|
68
|
+
const cir = chosenInlineResultUpdate({ resultId: "r1", query: "cats" });
|
|
69
|
+
assert.equal(cir.chosen_inline_result?.result_id, "r1");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("shippingQueryUpdate / preCheckoutQueryUpdate build valid payloads", () => {
|
|
73
|
+
const sq = shippingQueryUpdate({ shippingAddress: { city: "Berlin" } });
|
|
74
|
+
assert.equal(sq.shipping_query?.shipping_address.city, "Berlin");
|
|
75
|
+
|
|
76
|
+
const pcq = preCheckoutQueryUpdate({ currency: "EUR", totalAmount: 500 });
|
|
77
|
+
assert.equal(pcq.pre_checkout_query?.currency, "EUR");
|
|
78
|
+
assert.equal(pcq.pre_checkout_query?.total_amount, 500);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("pollUpdate / pollAnswerUpdate build valid payloads", () => {
|
|
82
|
+
const poll = pollUpdate({ question: "?", options: ["a", "b", "c"] });
|
|
83
|
+
assert.equal(poll.poll?.options.length, 3);
|
|
84
|
+
assert.equal(poll.poll?.options[1]?.text, "b");
|
|
85
|
+
|
|
86
|
+
const answer = pollAnswerUpdate({ optionIds: [1] });
|
|
87
|
+
assert.deepEqual(answer.poll_answer?.option_ids, [1]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("myChatMemberUpdate / chatMemberUpdate / chatJoinRequestUpdate build valid payloads", () => {
|
|
91
|
+
const my = myChatMemberUpdate({ oldStatus: "left", newStatus: "member" });
|
|
92
|
+
assert.equal(my.my_chat_member?.old_chat_member.status, "left");
|
|
93
|
+
assert.equal(my.my_chat_member?.new_chat_member.status, "member");
|
|
94
|
+
|
|
95
|
+
const other = chatMemberUpdate({ userId: 7 });
|
|
96
|
+
assert.equal(other.chat_member?.new_chat_member.user.id, 7);
|
|
97
|
+
|
|
98
|
+
const join = chatJoinRequestUpdate({ chatId: 3, fromId: 4, bio: "hi" });
|
|
99
|
+
assert.equal(join.chat_join_request?.chat.id, 3);
|
|
100
|
+
assert.equal(join.chat_join_request?.bio, "hi");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("detectUpdateType infers the payload key", () => {
|
|
104
|
+
assert.equal(detectUpdateType(messageUpdate()), "message");
|
|
105
|
+
assert.equal(detectUpdateType(callbackUpdate()), "callback_query");
|
|
106
|
+
assert.equal(detectUpdateType(pollUpdate()), "poll");
|
|
107
|
+
});
|