@yaebal/core 0.0.1
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 +39 -0
- package/lib/api.d.ts +66 -0
- package/lib/api.d.ts.map +1 -0
- package/lib/api.js +176 -0
- package/lib/api.js.map +1 -0
- package/lib/bot.d.ts +58 -0
- package/lib/bot.d.ts.map +1 -0
- package/lib/bot.js +125 -0
- package/lib/bot.js.map +1 -0
- package/lib/composer.d.ts +97 -0
- package/lib/composer.d.ts.map +1 -0
- package/lib/composer.js +178 -0
- package/lib/composer.js.map +1 -0
- package/lib/context.d.ts +40 -0
- package/lib/context.d.ts.map +1 -0
- package/lib/context.js +78 -0
- package/lib/context.js.map +1 -0
- package/lib/core.test.d.ts +2 -0
- package/lib/core.test.d.ts.map +1 -0
- package/lib/core.test.js +87 -0
- package/lib/core.test.js.map +1 -0
- package/lib/filter.test.d.ts +2 -0
- package/lib/filter.test.d.ts.map +1 -0
- package/lib/filter.test.js +158 -0
- package/lib/filter.test.js.map +1 -0
- package/lib/format.d.ts +31 -0
- package/lib/format.d.ts.map +1 -0
- package/lib/format.js +57 -0
- package/lib/format.js.map +1 -0
- package/lib/index.d.ts +15 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +14 -0
- package/lib/index.js.map +1 -0
- package/lib/media.d.ts +27 -0
- package/lib/media.d.ts.map +1 -0
- package/lib/media.js +20 -0
- package/lib/media.js.map +1 -0
- package/lib/telegram-types.d.ts +21 -0
- package/lib/telegram-types.d.ts.map +1 -0
- package/lib/telegram-types.js +2 -0
- package/lib/telegram-types.js.map +1 -0
- package/lib/webhook.d.ts +18 -0
- package/lib/webhook.d.ts.map +1 -0
- package/lib/webhook.js +82 -0
- package/lib/webhook.js.map +1 -0
- package/package.json +49 -0
- package/src/api.ts +276 -0
- package/src/bot.ts +168 -0
- package/src/composer.ts +280 -0
- package/src/context.ts +109 -0
- package/src/core.test.ts +108 -0
- package/src/filter.test.ts +202 -0
- package/src/format.ts +80 -0
- package/src/index.ts +53 -0
- package/src/media.ts +29 -0
- package/src/telegram-types.ts +25 -0
- package/src/webhook.ts +117 -0
package/src/bot.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { type Api, type FileReader, createApi } from "./api.js";
|
|
2
|
+
import { Composer, type Middleware } from "./composer.js";
|
|
3
|
+
import { Context } from "./context.js";
|
|
4
|
+
import type { Update, UpdateName, User } from "./telegram-types.js";
|
|
5
|
+
|
|
6
|
+
export interface BotOptions {
|
|
7
|
+
apiRoot?: string;
|
|
8
|
+
/**
|
|
9
|
+
* resolve `media.path()` into bytes. injected per runtime so core stays free of any
|
|
10
|
+
* `node:` import. the `yaebal` package wires an auto-detecting default; on bare core
|
|
11
|
+
* (or edge) leave it unset and `media.path()` throws — send `media.buffer()`/`url()`.
|
|
12
|
+
*/
|
|
13
|
+
readFile?: FileReader;
|
|
14
|
+
/** update types to request; `undefined` = telegram default. */
|
|
15
|
+
allowedUpdates?: UpdateName[];
|
|
16
|
+
/**
|
|
17
|
+
* build the context for each update. defaults to the base {@link Context}.
|
|
18
|
+
* gigher-level packages (e.g. the `yaebal` meta-package) inject a factory here
|
|
19
|
+
* to produce richer per-update contexts with auto-generated shortcut methods.
|
|
20
|
+
*/
|
|
21
|
+
contextFactory?: (api: Api, update: Update, updateType: UpdateName) => Context;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type StartHandler = (info: User) => unknown | Promise<unknown>;
|
|
25
|
+
type ErrorHandler = (error: unknown, ctx: Context) => unknown | Promise<unknown>;
|
|
26
|
+
|
|
27
|
+
function detectUpdateType(update: Update): UpdateName {
|
|
28
|
+
for (const key of Object.keys(update)) {
|
|
29
|
+
if (key === "update_id") continue;
|
|
30
|
+
return key as UpdateName;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return "message" as UpdateName;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* the bot. extends {@link Composer}, so the whole chainable, type-accumulating
|
|
38
|
+
* surface is available — and `derive` / `decorate` / `extend` keep returning a
|
|
39
|
+
* `Bot` (not a bare `Composer`) so lifecycle methods stay reachable down the chain.
|
|
40
|
+
*/
|
|
41
|
+
export class Bot<C extends Context = Context> extends Composer<C> {
|
|
42
|
+
readonly api: Api;
|
|
43
|
+
#running = false;
|
|
44
|
+
#offset = 0;
|
|
45
|
+
#info?: User;
|
|
46
|
+
readonly #options: BotOptions;
|
|
47
|
+
readonly #startHandlers: StartHandler[] = [];
|
|
48
|
+
#errorHandler: ErrorHandler = (error) => {
|
|
49
|
+
console.error("[yaebal] unhandled error in middleware:", error);
|
|
50
|
+
};
|
|
51
|
+
#handle?: Middleware<Context>;
|
|
52
|
+
|
|
53
|
+
constructor(token: string, options: BotOptions = {}) {
|
|
54
|
+
super();
|
|
55
|
+
if (!token) throw new Error("Bot(token): token is required");
|
|
56
|
+
|
|
57
|
+
this.#options = options;
|
|
58
|
+
this.api = createApi(token, { apiRoot: options.apiRoot, readFile: options.readFile });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** bot account info, available after `start()`. */
|
|
62
|
+
get info(): User | undefined {
|
|
63
|
+
return this.#info;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
override derive<D extends object>(fn: (ctx: C) => D | Promise<D>): Bot<C & D>;
|
|
67
|
+
override derive<D extends object>(
|
|
68
|
+
updates: UpdateName | UpdateName[],
|
|
69
|
+
fn: (ctx: C) => D | Promise<D>,
|
|
70
|
+
): Bot<C & Partial<D>>;
|
|
71
|
+
// biome-ignore lint/suspicious/noExplicitAny: overload implementation
|
|
72
|
+
override derive(a: any, b?: any): any {
|
|
73
|
+
// biome-ignore lint/suspicious/noExplicitAny: forwarding to the overloaded base
|
|
74
|
+
(super.derive as any)(a, b);
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
override decorate<D extends object>(value: D): Bot<C & D> {
|
|
79
|
+
super.decorate(value);
|
|
80
|
+
return this as unknown as Bot<C & D>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
override extend<C2 extends Context>(other: Composer<C2>): Bot<C & C2> {
|
|
84
|
+
super.extend(other);
|
|
85
|
+
return this as unknown as Bot<C & C2>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override install<Add extends object>(
|
|
89
|
+
plugin: (composer: Composer<C>) => Composer<C & Add>,
|
|
90
|
+
): Bot<C & Add> {
|
|
91
|
+
plugin(this);
|
|
92
|
+
return this as unknown as Bot<C & Add>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** register a callback fired once the bot has started. */
|
|
96
|
+
onStart(handler: StartHandler): this {
|
|
97
|
+
this.#startHandlers.push(handler);
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** replace the default error handler. */
|
|
102
|
+
onError(handler: ErrorHandler): this {
|
|
103
|
+
this.#errorHandler = handler;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* run the middleware chain for a single update. this is the webhook entry
|
|
109
|
+
* point — call it from your HTTP handler. errors go to the error handler.
|
|
110
|
+
*
|
|
111
|
+
* the chain is realized (and frozen) on the first call, so register all
|
|
112
|
+
* middleware/plugins before the first `handleUpdate` / `start`.
|
|
113
|
+
*/
|
|
114
|
+
async handleUpdate(update: Update): Promise<void> {
|
|
115
|
+
if (!this.#handle) this.#handle = this.toMiddleware() as unknown as Middleware<Context>;
|
|
116
|
+
|
|
117
|
+
const updateType = detectUpdateType(update);
|
|
118
|
+
const ctx = this.#options.contextFactory
|
|
119
|
+
? this.#options.contextFactory(this.api, update, updateType)
|
|
120
|
+
: new Context({ api: this.api, update, updateType });
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await this.#handle(ctx, async () => {});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
await this.#errorHandler(error, ctx);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** start long polling. resolves only when `stop()` is called. */
|
|
130
|
+
async start(): Promise<void> {
|
|
131
|
+
if (this.#running) return;
|
|
132
|
+
this.#running = true;
|
|
133
|
+
|
|
134
|
+
this.#info = await this.api.getMe();
|
|
135
|
+
for (const handler of this.#startHandlers) await handler(this.#info);
|
|
136
|
+
|
|
137
|
+
while (this.#running) {
|
|
138
|
+
let updates: Update[];
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
updates = await this.api.getUpdates({
|
|
142
|
+
offset: this.#offset,
|
|
143
|
+
timeout: 30,
|
|
144
|
+
...(this.#options.allowedUpdates
|
|
145
|
+
? { allowed_updates: this.#options.allowedUpdates }
|
|
146
|
+
: {}),
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (!this.#running) break;
|
|
150
|
+
|
|
151
|
+
console.error("[yaebal] getUpdates failed, retrying in 3s:", error);
|
|
152
|
+
|
|
153
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const update of updates) {
|
|
158
|
+
this.#offset = update.update_id + 1;
|
|
159
|
+
await this.handleUpdate(update);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** stop the polling loop. */
|
|
165
|
+
stop(): void {
|
|
166
|
+
this.#running = false;
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/composer.ts
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import type { Context } from "./context.js";
|
|
2
|
+
import type { CallbackQuery, MessageEntity, UpdateName } from "./telegram-types.js";
|
|
3
|
+
|
|
4
|
+
export type NextFn = () => Promise<void>;
|
|
5
|
+
export type Middleware<C> = (ctx: C, next: NextFn) => unknown | Promise<unknown>;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* a plugin enriches the context. dependencies are expressed by the type it
|
|
9
|
+
* requires (`In`), so installing a plugin before its dependency is a compile
|
|
10
|
+
* error — not a runtime surprise (core invariant #4).
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const session: Plugin<Context, { session: Session }>;
|
|
14
|
+
* const auth: Plugin<Context & { session: Session }, { user: User }>;
|
|
15
|
+
* bot.install(auth); // ❌ no `session` on the context
|
|
16
|
+
* bot.install(session).install(auth); // ✅
|
|
17
|
+
*/
|
|
18
|
+
export type Plugin<In extends Context = Context, Out extends object = Record<never, never>> = <
|
|
19
|
+
C extends In,
|
|
20
|
+
>(
|
|
21
|
+
composer: Composer<C>,
|
|
22
|
+
) => Composer<C & Out>;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* a composable filter (the mtcute idea). `test` is a type guard, so a matching
|
|
26
|
+
* filter narrows the context to `C & Add`; filters may also attach `Add` fields
|
|
27
|
+
* onto the context as a side effect (e.g. `regex` exposes `ctx.match`). combine
|
|
28
|
+
* with `and` / `or` / `not` from `@yaebal/filters`.
|
|
29
|
+
*/
|
|
30
|
+
export interface Filter<C = Context, Add extends object = Record<never, never>> {
|
|
31
|
+
test(ctx: C): ctx is C & Add;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* filter query mini-language (the grammY idea), e.g.
|
|
36
|
+
* `"message:text"`, `"callback_query:data"`, `":photo"`.
|
|
37
|
+
*/
|
|
38
|
+
export type FilterQuery = UpdateName | `${UpdateName}:${string}` | `:${string}`;
|
|
39
|
+
|
|
40
|
+
/** narrows the context type for known queries so handlers get non-optional fields. */
|
|
41
|
+
export type Filtered<C, Q extends string> = Q extends `${string}:text` | `${string}:caption`
|
|
42
|
+
? C & { text: string }
|
|
43
|
+
: Q extends `${string}:data` | "callback_query"
|
|
44
|
+
? C & { callbackQuery: CallbackQuery }
|
|
45
|
+
: Q extends `${string}:entities${string}`
|
|
46
|
+
? C & { entities: MessageEntity[] }
|
|
47
|
+
: C;
|
|
48
|
+
|
|
49
|
+
/** koa-style middleware composer with single-`next()` protection. */
|
|
50
|
+
export function compose<C>(middlewares: Middleware<C>[]): (ctx: C, next?: NextFn) => Promise<void> {
|
|
51
|
+
return function composed(ctx, next) {
|
|
52
|
+
let lastIndex = -1;
|
|
53
|
+
|
|
54
|
+
const dispatch = async (i: number): Promise<void> => {
|
|
55
|
+
if (i <= lastIndex) throw new Error("next() called multiple times");
|
|
56
|
+
lastIndex = i;
|
|
57
|
+
|
|
58
|
+
const fn = i === middlewares.length ? next : middlewares[i];
|
|
59
|
+
if (!fn) return;
|
|
60
|
+
|
|
61
|
+
await fn(ctx, () => dispatch(i + 1));
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
return dispatch(0);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function checkField(ctx: Context, field: string): boolean {
|
|
69
|
+
switch (field) {
|
|
70
|
+
case "text":
|
|
71
|
+
case "caption":
|
|
72
|
+
return typeof ctx.text === "string" && ctx.text.length > 0;
|
|
73
|
+
case "data":
|
|
74
|
+
return Boolean(ctx.callbackQuery?.data);
|
|
75
|
+
case "entities":
|
|
76
|
+
return Boolean(ctx.message?.entities?.length);
|
|
77
|
+
default: {
|
|
78
|
+
const msg = ctx.message as Record<string, unknown> | undefined;
|
|
79
|
+
return Boolean(msg?.[field]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function matchQuery(ctx: Context, query: string): boolean {
|
|
85
|
+
const [head, ...rest] = query.split(":");
|
|
86
|
+
if (head && head.length > 0 && ctx.updateType !== head) return false;
|
|
87
|
+
|
|
88
|
+
for (const field of rest) {
|
|
89
|
+
if (!checkField(ctx, field)) return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* the chainable middleware pipeline. every context-enriching method returns a
|
|
97
|
+
* composer whose context type carries the new properties — types flow through
|
|
98
|
+
* the whole chain (the GramIO idea). `Composer` is also usable standalone, so
|
|
99
|
+
* feature files can be plain composers with no `Bot` and no token.
|
|
100
|
+
*/
|
|
101
|
+
export class Composer<C extends Context = Context> {
|
|
102
|
+
protected middlewares: Middleware<C>[] = [];
|
|
103
|
+
|
|
104
|
+
/** raw middleware. call `next()` to continue the chain. */
|
|
105
|
+
use(...middleware: Middleware<C>[]): this {
|
|
106
|
+
this.middlewares.push(...middleware);
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** handle a specific update, optionally narrowed by a filter query. */
|
|
111
|
+
on<Q extends FilterQuery>(query: Q, ...handlers: Middleware<Filtered<C, Q>>[]): this {
|
|
112
|
+
const handler = compose(handlers as unknown as Middleware<C>[]);
|
|
113
|
+
|
|
114
|
+
this.middlewares.push((ctx, next) =>
|
|
115
|
+
matchQuery(ctx, query as string) ? handler(ctx, next) : next(),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** handle `/<name>` commands. Strips a trailing `@botname` and parses args. */
|
|
122
|
+
command(name: string, ...handlers: Middleware<C & { command: string; args: string[] }>[]): this {
|
|
123
|
+
const handler = compose(handlers as unknown as Middleware<C>[]);
|
|
124
|
+
|
|
125
|
+
this.middlewares.push((ctx, next) => {
|
|
126
|
+
const text = ctx.text;
|
|
127
|
+
if (text === undefined || !text.startsWith("/")) return next();
|
|
128
|
+
|
|
129
|
+
const [head, ...args] = text.slice(1).split(/\s+/);
|
|
130
|
+
const base = head?.split("@")[0];
|
|
131
|
+
if (base !== name) return next();
|
|
132
|
+
|
|
133
|
+
Object.assign(ctx as object, { command: base, args });
|
|
134
|
+
return handler(ctx, next);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return this;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** match message text/caption against a string or regex; exposes `ctx.match`. */
|
|
141
|
+
hears(
|
|
142
|
+
trigger: string | RegExp,
|
|
143
|
+
...handlers: Middleware<C & { match: string | RegExpMatchArray }>[]
|
|
144
|
+
): this {
|
|
145
|
+
const handler = compose(handlers as unknown as Middleware<C>[]);
|
|
146
|
+
|
|
147
|
+
this.middlewares.push((ctx, next) => {
|
|
148
|
+
const text = ctx.text;
|
|
149
|
+
if (text === undefined) return next();
|
|
150
|
+
|
|
151
|
+
if (typeof trigger === "string") {
|
|
152
|
+
if (text !== trigger) return next();
|
|
153
|
+
|
|
154
|
+
Object.assign(ctx as object, { match: text });
|
|
155
|
+
} else {
|
|
156
|
+
const m = text.match(trigger);
|
|
157
|
+
if (!m) return next();
|
|
158
|
+
|
|
159
|
+
Object.assign(ctx as object, { match: m });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return handler(ctx, next);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
return this;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** match callback-query data against a string or regex; exposes `ctx.match`. */
|
|
169
|
+
callbackQuery(
|
|
170
|
+
trigger: string | RegExp,
|
|
171
|
+
...handlers: Middleware<
|
|
172
|
+
C & { match: string | RegExpMatchArray; callbackQuery: CallbackQuery }
|
|
173
|
+
>[]
|
|
174
|
+
): this {
|
|
175
|
+
const handler = compose(handlers as unknown as Middleware<C>[]);
|
|
176
|
+
|
|
177
|
+
this.middlewares.push((ctx, next) => {
|
|
178
|
+
const data = ctx.callbackQuery?.data;
|
|
179
|
+
|
|
180
|
+
if (data === undefined) return next();
|
|
181
|
+
|
|
182
|
+
if (typeof trigger === "string") {
|
|
183
|
+
if (data !== trigger) return next();
|
|
184
|
+
|
|
185
|
+
Object.assign(ctx as object, { match: data });
|
|
186
|
+
} else {
|
|
187
|
+
const m = data.match(trigger);
|
|
188
|
+
if (!m) return next();
|
|
189
|
+
|
|
190
|
+
Object.assign(ctx as object, { match: m });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return handler(ctx, next);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
return this;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** continue only if the predicate holds. */
|
|
200
|
+
guard(predicate: (ctx: C) => boolean | Promise<boolean>): this {
|
|
201
|
+
this.middlewares.push(async (ctx, next) => {
|
|
202
|
+
if (await predicate(ctx)) await next();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
return this;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* run `handlers` only when `filter` matches. the filter narrows the context
|
|
210
|
+
* (and may attach data), so handlers see `C & Add`. compose filters with
|
|
211
|
+
* `and` / `or` / `not` from `@yaebal/filters`.
|
|
212
|
+
*/
|
|
213
|
+
filter<Add extends object>(
|
|
214
|
+
filter: Filter<Context, Add>,
|
|
215
|
+
...handlers: Middleware<C & Add>[]
|
|
216
|
+
): this {
|
|
217
|
+
const handler = compose(handlers as unknown as Middleware<C>[]);
|
|
218
|
+
|
|
219
|
+
this.middlewares.push((ctx, next) => (filter.test(ctx) ? handler(ctx, next) : next()));
|
|
220
|
+
return this;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** apply a plugin. its required context (`In`) is checked at compile time. */
|
|
224
|
+
install<Add extends object>(
|
|
225
|
+
plugin: (composer: Composer<C>) => Composer<C & Add>,
|
|
226
|
+
): Composer<C & Add> {
|
|
227
|
+
return plugin(this);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** async, per-request context enrichment. adds `D` to the context type. */
|
|
231
|
+
derive<D extends object>(fn: (ctx: C) => D | Promise<D>): Composer<C & D>;
|
|
232
|
+
/**
|
|
233
|
+
* scoped enrichment (the GramIO idea): `fn` runs only for the listed update
|
|
234
|
+
* types, so irrelevant updates pay nothing. the fields are typed as optional
|
|
235
|
+
* (`Partial<D>`) since they are absent on other update types.
|
|
236
|
+
*/
|
|
237
|
+
derive<D extends object>(
|
|
238
|
+
updates: UpdateName | UpdateName[],
|
|
239
|
+
fn: (ctx: C) => D | Promise<D>,
|
|
240
|
+
): Composer<C & Partial<D>>;
|
|
241
|
+
// biome-ignore lint/suspicious/noExplicitAny: overload implementation
|
|
242
|
+
derive(a: any, b?: any): any {
|
|
243
|
+
const scoped = typeof a !== "function";
|
|
244
|
+
const only: UpdateName[] | null = scoped ? (Array.isArray(a) ? a : [a]) : null;
|
|
245
|
+
const fn = (scoped ? b : a) as (ctx: C) => object | Promise<object>;
|
|
246
|
+
|
|
247
|
+
this.middlewares.push(async (ctx, next) => {
|
|
248
|
+
if (!only || only.includes(ctx.updateType)) Object.assign(ctx as object, await fn(ctx));
|
|
249
|
+
await next();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return this;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* static context enrichment. adds `D` to the context type.
|
|
257
|
+
* NOTE: a production build would hoist this out of the per-request path entirely;
|
|
258
|
+
* here it is applied once at the top of the chain for simplicity.
|
|
259
|
+
*/
|
|
260
|
+
decorate<D extends object>(value: D): Composer<C & D> {
|
|
261
|
+
this.middlewares.push((ctx, next) => {
|
|
262
|
+
Object.assign(ctx as object, value);
|
|
263
|
+
return next();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return this as unknown as Composer<C & D>;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** merge another composer in, inheriting its full context type. */
|
|
270
|
+
extend<C2 extends Context>(other: Composer<C2>): Composer<C & C2> {
|
|
271
|
+
this.middlewares.push(other.toMiddleware() as unknown as Middleware<C>);
|
|
272
|
+
return this as unknown as Composer<C & C2>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** collapse this composer into a single middleware (used by `extend` and `Bot`). */
|
|
276
|
+
toMiddleware(): Middleware<C> {
|
|
277
|
+
const composed = compose(this.middlewares);
|
|
278
|
+
return (ctx, next) => composed(ctx, next);
|
|
279
|
+
}
|
|
280
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { Api } from "./api.js";
|
|
2
|
+
import type { FormatResult } from "./format.js";
|
|
3
|
+
import type { MediaSource } from "./media.js";
|
|
4
|
+
import type { CallbackQuery, Chat, Message, Update, UpdateName, User } from "./telegram-types.js";
|
|
5
|
+
|
|
6
|
+
export interface ContextOptions {
|
|
7
|
+
api: Api;
|
|
8
|
+
update: Update;
|
|
9
|
+
updateType: UpdateName;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
type SendText = string | FormatResult;
|
|
13
|
+
|
|
14
|
+
function resolveText(text: SendText): { text: string; entities?: FormatResult["entities"] } {
|
|
15
|
+
if (typeof text === "string") return { text };
|
|
16
|
+
return { text: text.text, entities: text.entities };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* the base context every update is wrapped in. plugins and `derive`/`decorate`
|
|
21
|
+
* enrich it with extra properties; those extras are tracked at the type level
|
|
22
|
+
* by the Composer, so handlers see them without casting.
|
|
23
|
+
*/
|
|
24
|
+
export class Context {
|
|
25
|
+
readonly api: Api;
|
|
26
|
+
readonly update: Update;
|
|
27
|
+
readonly updateType: UpdateName;
|
|
28
|
+
|
|
29
|
+
constructor(options: ContextOptions) {
|
|
30
|
+
this.api = options.api;
|
|
31
|
+
this.update = options.update;
|
|
32
|
+
this.updateType = options.updateType;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get message(): Message | undefined {
|
|
36
|
+
return this.update.message ?? this.update.edited_message ?? this.update.channel_post;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get callbackQuery(): CallbackQuery | undefined {
|
|
40
|
+
return this.update.callback_query;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get from(): User | undefined {
|
|
44
|
+
return this.message?.from ?? this.callbackQuery?.from;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get chat(): Chat | undefined {
|
|
48
|
+
return this.message?.chat ?? this.callbackQuery?.message?.chat;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get text(): string | undefined {
|
|
52
|
+
return this.message?.text ?? this.message?.caption;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** narrowing helper in the puregram spirit: `if (ctx.is("callback_query"))`. */
|
|
56
|
+
is(updateType: UpdateName): boolean {
|
|
57
|
+
return this.updateType === updateType;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** send a message to the current chat. accepts a plain string or a `format` result. */
|
|
61
|
+
send(text: SendText, extra: Record<string, unknown> = {}): Promise<Message> {
|
|
62
|
+
const chatId = this.chat?.id;
|
|
63
|
+
if (chatId === undefined) {
|
|
64
|
+
return Promise.reject(new Error("send(): no chat in this update"));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return this.api.sendMessage({ chat_id: chatId, ...resolveText(text), ...extra });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** reply to the triggering message. */
|
|
71
|
+
reply(text: SendText, extra: Record<string, unknown> = {}): Promise<Message> {
|
|
72
|
+
const replyTo = this.message?.message_id;
|
|
73
|
+
|
|
74
|
+
return this.send(text, {
|
|
75
|
+
...(replyTo !== undefined ? { reply_parameters: { message_id: replyTo } } : {}),
|
|
76
|
+
...extra,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** send a photo. accepts a {@link MediaSource} or a raw file_id / URL string. */
|
|
81
|
+
sendPhoto(photo: MediaSource | string, extra: Record<string, unknown> = {}): Promise<Message> {
|
|
82
|
+
const chatId = this.chat?.id;
|
|
83
|
+
if (chatId === undefined)
|
|
84
|
+
return Promise.reject(new Error("sendPhoto(): no chat in this update"));
|
|
85
|
+
|
|
86
|
+
return this.api.call<Message>("sendPhoto", { chat_id: chatId, photo, ...extra });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** send a document. accepts a {@link MediaSource} or a raw file_id / URL string. */
|
|
90
|
+
sendDocument(
|
|
91
|
+
document: MediaSource | string,
|
|
92
|
+
extra: Record<string, unknown> = {},
|
|
93
|
+
): Promise<Message> {
|
|
94
|
+
const chatId = this.chat?.id;
|
|
95
|
+
if (chatId === undefined) {
|
|
96
|
+
return Promise.reject(new Error("sendDocument(): no chat in this update"));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return this.api.call<Message>("sendDocument", { chat_id: chatId, document, ...extra });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** answer the current callback query (no-op if there is none). */
|
|
103
|
+
answerCallbackQuery(extra: Record<string, unknown> = {}): Promise<boolean> {
|
|
104
|
+
const id = this.callbackQuery?.id;
|
|
105
|
+
if (id === undefined) return Promise.resolve(false);
|
|
106
|
+
|
|
107
|
+
return this.api.answerCallbackQuery({ callback_query_id: id, ...extra });
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/core.test.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { encodeRequest } from "./api.js";
|
|
4
|
+
import { Bot } from "./bot.js";
|
|
5
|
+
import { isMediaSource, media } from "./media.js";
|
|
6
|
+
import { webhookCallback } from "./webhook.js";
|
|
7
|
+
|
|
8
|
+
test("media helpers are branded and discriminated", () => {
|
|
9
|
+
assert.ok(isMediaSource(media.fileId("AgAC")));
|
|
10
|
+
assert.ok(isMediaSource(media.url("https://x/y.png")));
|
|
11
|
+
assert.equal(isMediaSource({ kind: "fileId", fileId: "x" }), false); // unbranded
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("encodeRequest sends JSON when there is no upload", async () => {
|
|
15
|
+
const r = await encodeRequest({ chat_id: 1, photo: media.fileId("AgAC") });
|
|
16
|
+
assert.equal(r.contentType, "application/json");
|
|
17
|
+
assert.deepEqual(JSON.parse(r.body as string), { chat_id: 1, photo: "AgAC" });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("encodeRequest inlines a url media to its string", async () => {
|
|
21
|
+
const r = await encodeRequest({ photo: media.url("https://e/p.png") });
|
|
22
|
+
assert.deepEqual(JSON.parse(r.body as string), { photo: "https://e/p.png" });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("encodeRequest builds multipart for a buffer upload", async () => {
|
|
26
|
+
const r = await encodeRequest({
|
|
27
|
+
chat_id: 7,
|
|
28
|
+
photo: media.buffer(new Uint8Array([1, 2, 3]), "pic.png"),
|
|
29
|
+
reply_markup: { inline_keyboard: [] },
|
|
30
|
+
});
|
|
31
|
+
assert.ok(r.body instanceof FormData);
|
|
32
|
+
const form = r.body as FormData;
|
|
33
|
+
assert.equal(form.get("photo"), "attach://_file0");
|
|
34
|
+
assert.ok(form.get("_file0") instanceof Blob);
|
|
35
|
+
assert.equal(form.get("chat_id"), "7"); // non-string serialized
|
|
36
|
+
assert.equal(form.get("reply_markup"), '{"inline_keyboard":[]}'); // object → JSON
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("encodeRequest handles no params", async () => {
|
|
40
|
+
const r = await encodeRequest(undefined);
|
|
41
|
+
assert.equal(r.body, undefined);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("encodeRequest rejects nested media (fails loud, no garbage)", async () => {
|
|
45
|
+
await assert.rejects(
|
|
46
|
+
() => encodeRequest({ media: [{ type: "photo", media: media.path("./a.jpg") }] }),
|
|
47
|
+
/nested MediaSource/,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("encodeRequest throws a helpful error for media.path() without a readFile (edge)", async () => {
|
|
52
|
+
await assert.rejects(() => encodeRequest({ photo: media.path("./a.jpg") }), /needs a filesystem/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("encodeRequest uses an injected readFile for media.path() (runtime-agnostic)", async () => {
|
|
56
|
+
const readFile = async (p: string) => {
|
|
57
|
+
assert.equal(p, "./pics/cat.jpg"); // path forwarded verbatim
|
|
58
|
+
return new Uint8Array([9, 8, 7]);
|
|
59
|
+
};
|
|
60
|
+
const r = await encodeRequest({ chat_id: 1, photo: media.path("./pics/cat.jpg") }, readFile);
|
|
61
|
+
const form = r.body as FormData;
|
|
62
|
+
assert.ok(form.get("_file0") instanceof Blob);
|
|
63
|
+
assert.equal(form.get("photo"), "attach://_file0");
|
|
64
|
+
assert.equal((form.get("_file0") as File).name, "cat.jpg"); // pure-js basename
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("webhookCallback dispatches a POSTed update and guards method/secret", async () => {
|
|
68
|
+
const seen: number[] = [];
|
|
69
|
+
const sink = { handleUpdate: async (u: { update_id: number }) => void seen.push(u.update_id) };
|
|
70
|
+
const handler = webhookCallback(sink as never, { secretToken: "s3cret" });
|
|
71
|
+
const body = JSON.stringify({ update_id: 7, message: { text: "hi" } });
|
|
72
|
+
|
|
73
|
+
const ok = await handler(
|
|
74
|
+
new Request("http://x/hook", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "x-telegram-bot-api-secret-token": "s3cret" },
|
|
77
|
+
body,
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
assert.equal(ok.status, 200);
|
|
82
|
+
assert.deepEqual(seen, [7]);
|
|
83
|
+
|
|
84
|
+
const wrongMethod = await handler(new Request("http://x/hook"));
|
|
85
|
+
assert.equal(wrongMethod.status, 405);
|
|
86
|
+
|
|
87
|
+
const wrongSecret = await handler(
|
|
88
|
+
new Request("http://x/hook", {
|
|
89
|
+
method: "POST",
|
|
90
|
+
headers: { "x-telegram-bot-api-secret-token": "nope" },
|
|
91
|
+
body,
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
assert.equal(wrongSecret.status, 401);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("Bot.handleUpdate runs the middleware chain (webhook entry)", async () => {
|
|
98
|
+
let seen = "";
|
|
99
|
+
const bot = new Bot("123:abc").on("message:text", (ctx) => {
|
|
100
|
+
seen = ctx.text;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await bot.handleUpdate({
|
|
104
|
+
update_id: 1,
|
|
105
|
+
message: { message_id: 1, date: 0, chat: { id: 1, type: "private" }, text: "hi" },
|
|
106
|
+
} as never);
|
|
107
|
+
assert.equal(seen, "hi");
|
|
108
|
+
});
|