@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
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { Composer, Context } from "./index.js";
|
|
4
|
+
import type { Filter } from "./index.js";
|
|
5
|
+
import type { Update } from "./telegram-types.js";
|
|
6
|
+
|
|
7
|
+
/** minimal no-op api stub — composer tests never make real api calls. */
|
|
8
|
+
const stubApi = null as unknown as InstanceType<typeof Context>["api"];
|
|
9
|
+
|
|
10
|
+
function makeCtx(update: Update): Context {
|
|
11
|
+
const keys = Object.keys(update).filter((k) => k !== "update_id") as (keyof Update)[];
|
|
12
|
+
const updateType = keys[0] as Context["updateType"];
|
|
13
|
+
|
|
14
|
+
return new Context({ api: stubApi, update, updateType });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeMessageCtx(text?: string): Context {
|
|
18
|
+
return makeCtx({
|
|
19
|
+
update_id: 1,
|
|
20
|
+
message: {
|
|
21
|
+
message_id: 1,
|
|
22
|
+
date: 0,
|
|
23
|
+
chat: { id: 1, type: "private" as const },
|
|
24
|
+
...(text !== undefined ? { text } : {}),
|
|
25
|
+
},
|
|
26
|
+
} as Update);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeCallbackCtx(data = "btn"): Context {
|
|
30
|
+
return makeCtx({
|
|
31
|
+
update_id: 2,
|
|
32
|
+
callback_query: {
|
|
33
|
+
id: "q1",
|
|
34
|
+
from: { id: 99, is_bot: false, first_name: "U" },
|
|
35
|
+
chat_instance: "ci",
|
|
36
|
+
data,
|
|
37
|
+
},
|
|
38
|
+
} as unknown as Update);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** run `composer.toMiddleware()` against ctx and resolve when the chain ends. */
|
|
42
|
+
async function run(composer: Composer, ctx: Context): Promise<void> {
|
|
43
|
+
await composer.toMiddleware()(ctx, async () => {});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test("filter: runs handlers when filter.test returns true", async () => {
|
|
47
|
+
const alwaysTrue: Filter = {
|
|
48
|
+
test(_ctx): _ctx is Context {
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
let called = false;
|
|
54
|
+
const composer = new Composer().filter(alwaysTrue, (_ctx) => {
|
|
55
|
+
called = true;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await run(composer, makeMessageCtx("hi"));
|
|
59
|
+
assert.ok(called, "handler should have been called");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("filter: skips handlers and calls next when filter.test returns false", async () => {
|
|
63
|
+
const alwaysFalse: Filter = {
|
|
64
|
+
test(_ctx): _ctx is Context {
|
|
65
|
+
return false;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
let handlerCalled = false;
|
|
70
|
+
let nextCalled = false;
|
|
71
|
+
|
|
72
|
+
const composer = new Composer()
|
|
73
|
+
.filter(alwaysFalse, (_ctx) => {
|
|
74
|
+
handlerCalled = true;
|
|
75
|
+
})
|
|
76
|
+
.use((_ctx, next) => {
|
|
77
|
+
nextCalled = true;
|
|
78
|
+
return next();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await run(composer, makeMessageCtx());
|
|
82
|
+
assert.equal(handlerCalled, false, "handler must not run when filter rejects");
|
|
83
|
+
assert.ok(nextCalled, "next middleware must still be reached");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("filter: handler sees data attached by filter.test via Object.assign", async () => {
|
|
87
|
+
interface WithMatch {
|
|
88
|
+
match: RegExpMatchArray;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const regexFilter: Filter<Context, WithMatch> = {
|
|
92
|
+
test(ctx): ctx is Context & WithMatch {
|
|
93
|
+
const m = ctx.text?.match(/hello (\w+)/);
|
|
94
|
+
if (!m) return false;
|
|
95
|
+
|
|
96
|
+
Object.assign(ctx as object, { match: m });
|
|
97
|
+
return true;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
let captured: RegExpMatchArray | undefined;
|
|
102
|
+
const composer = new Composer().filter(regexFilter, (ctx) => {
|
|
103
|
+
captured = (ctx as Context & WithMatch).match;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await run(composer, makeMessageCtx("hello world"));
|
|
107
|
+
assert.ok(captured, "match should be set on ctx");
|
|
108
|
+
assert.equal(captured[1], "world");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("filter: filter.test that attaches data is not called for non-matching ctx", async () => {
|
|
112
|
+
interface WithMatch {
|
|
113
|
+
match: RegExpMatchArray;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const regexFilter: Filter<Context, WithMatch> = {
|
|
117
|
+
test(ctx): ctx is Context & WithMatch {
|
|
118
|
+
const m = ctx.text?.match(/hello (\w+)/);
|
|
119
|
+
if (!m) return false;
|
|
120
|
+
|
|
121
|
+
Object.assign(ctx as object, { match: m });
|
|
122
|
+
return true;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
let handlerCalled = false;
|
|
127
|
+
const composer = new Composer().filter(regexFilter, (_ctx) => {
|
|
128
|
+
handlerCalled = true;
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// callback_query ctx has no text — regex won't match
|
|
132
|
+
await run(composer, makeCallbackCtx());
|
|
133
|
+
assert.equal(handlerCalled, false);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("filter: type guard narrows — handler sees ctx.tag added by filter", async () => {
|
|
137
|
+
const tagFilter: Filter<Context, { tag: number }> = {
|
|
138
|
+
test(ctx): ctx is Context & { tag: number } {
|
|
139
|
+
Object.assign(ctx as object, { tag: 42 });
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
let seenTag: number | undefined;
|
|
145
|
+
const composer = new Composer().filter(tagFilter, (ctx) => {
|
|
146
|
+
// typescript sees ctx.tag as number here (narrowed by the Filter type)
|
|
147
|
+
seenTag = (ctx as Context & { tag: number }).tag;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await run(composer, makeMessageCtx());
|
|
151
|
+
assert.equal(seenTag, 42);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("derive scoped: fn runs for the listed update type and field is present", async () => {
|
|
155
|
+
const composer = new Composer().derive("message", (_ctx) => ({ enriched: true }));
|
|
156
|
+
|
|
157
|
+
const msgCtx = makeMessageCtx("test");
|
|
158
|
+
await run(composer, msgCtx);
|
|
159
|
+
assert.equal((msgCtx as unknown as { enriched?: boolean }).enriched, true);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("derive scoped: fn does NOT run for a different update type", async () => {
|
|
163
|
+
const composer = new Composer().derive("message", (_ctx) => ({ enriched: true }));
|
|
164
|
+
|
|
165
|
+
const cbCtx = makeCallbackCtx();
|
|
166
|
+
await run(composer, cbCtx);
|
|
167
|
+
assert.equal((cbCtx as unknown as { enriched?: boolean }).enriched, undefined);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("derive scoped: both updateTypes work when given an array", async () => {
|
|
171
|
+
const composer = new Composer().derive(["message", "callback_query"], (_ctx) => ({
|
|
172
|
+
enriched: true,
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
const msgCtx = makeMessageCtx();
|
|
176
|
+
const cbCtx = makeCallbackCtx();
|
|
177
|
+
|
|
178
|
+
await run(composer, msgCtx);
|
|
179
|
+
await run(composer, cbCtx);
|
|
180
|
+
|
|
181
|
+
assert.equal((msgCtx as unknown as { enriched?: boolean }).enriched, true);
|
|
182
|
+
assert.equal((cbCtx as unknown as { enriched?: boolean }).enriched, true);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("derive scoped: unscoped array — non-listed type is not enriched", async () => {
|
|
186
|
+
const composer = new Composer().derive("message", (_ctx) => ({ enriched: true }));
|
|
187
|
+
|
|
188
|
+
const cbCtx = makeCallbackCtx();
|
|
189
|
+
await run(composer, cbCtx);
|
|
190
|
+
|
|
191
|
+
assert.equal((cbCtx as unknown as { enriched?: boolean }).enriched, undefined);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("derive scoped: fn receives the context for async enrichment", async () => {
|
|
195
|
+
const composer = new Composer().derive("message", async (ctx) => ({
|
|
196
|
+
textLen: ctx.text?.length ?? 0,
|
|
197
|
+
}));
|
|
198
|
+
|
|
199
|
+
const msgCtx = makeMessageCtx("hello");
|
|
200
|
+
await run(composer, msgCtx);
|
|
201
|
+
assert.equal((msgCtx as unknown as { textLen?: number }).textLen, 5);
|
|
202
|
+
});
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { MessageEntity } from "./telegram-types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* formatting without `parse_mode`: tagged template literals that build proper
|
|
5
|
+
* `MessageEntity` objects, so there is nothing to escape (the GramIO idea).
|
|
6
|
+
*/
|
|
7
|
+
export interface FormatResult {
|
|
8
|
+
text: string;
|
|
9
|
+
entities: MessageEntity[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** a piece of text that carries its own entities, used inside `format`. */
|
|
13
|
+
export class Stringable implements FormatResult {
|
|
14
|
+
constructor(
|
|
15
|
+
readonly text: string,
|
|
16
|
+
readonly entities: MessageEntity[] = [],
|
|
17
|
+
) {}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Insertable = Stringable | FormatResult | string | number | bigint;
|
|
21
|
+
|
|
22
|
+
function isFormatResult(value: unknown): value is FormatResult {
|
|
23
|
+
return (
|
|
24
|
+
typeof value === "object" &&
|
|
25
|
+
value !== null &&
|
|
26
|
+
"text" in value &&
|
|
27
|
+
"entities" in value &&
|
|
28
|
+
Array.isArray((value as FormatResult).entities)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** stitches the literal parts and interpolations into one `{ text, entities }`. */
|
|
33
|
+
export function format(strings: TemplateStringsArray, ...subs: Insertable[]): FormatResult {
|
|
34
|
+
let text = "";
|
|
35
|
+
const entities: MessageEntity[] = [];
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < strings.length; i++) {
|
|
38
|
+
text += strings[i] ?? "";
|
|
39
|
+
|
|
40
|
+
if (i < subs.length) {
|
|
41
|
+
const sub = subs[i] as Insertable;
|
|
42
|
+
const offset = text.length;
|
|
43
|
+
|
|
44
|
+
if (isFormatResult(sub)) {
|
|
45
|
+
text += sub.text;
|
|
46
|
+
|
|
47
|
+
for (const entity of sub.entities) {
|
|
48
|
+
entities.push({ ...entity, offset: entity.offset + offset });
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
text += String(sub);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { text, entities };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function wrap(type: string, extra: Partial<MessageEntity> = {}) {
|
|
60
|
+
return (value: string): Stringable =>
|
|
61
|
+
new Stringable(value, [{ type, offset: 0, length: value.length, ...extra }]);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const bold = wrap("bold");
|
|
65
|
+
export const italic = wrap("italic");
|
|
66
|
+
export const underline = wrap("underline");
|
|
67
|
+
export const strikethrough = wrap("strikethrough");
|
|
68
|
+
export const spoiler = wrap("spoiler");
|
|
69
|
+
export const code = wrap("code");
|
|
70
|
+
export const pre = wrap("pre");
|
|
71
|
+
|
|
72
|
+
export function link(text: string, url: string): Stringable {
|
|
73
|
+
return new Stringable(text, [{ type: "text_link", offset: 0, length: text.length, url }]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function mention(text: string, user: { id: number }): Stringable {
|
|
77
|
+
return new Stringable(text, [
|
|
78
|
+
{ type: "text_mention", offset: 0, length: text.length, user: user as never },
|
|
79
|
+
]);
|
|
80
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @yaebal/core — core of Yet Another tElegram Bot Api Library.
|
|
3
|
+
*
|
|
4
|
+
* public surface: the `Bot`, the standalone `Composer`, the `Context`,
|
|
5
|
+
* the entity-based `format` helpers, the low-level `Api`, and the telegram types.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export { Bot, type BotOptions } from "./bot.js";
|
|
9
|
+
export {
|
|
10
|
+
Composer,
|
|
11
|
+
compose,
|
|
12
|
+
matchQuery,
|
|
13
|
+
type Filter,
|
|
14
|
+
type FilterQuery,
|
|
15
|
+
type Filtered,
|
|
16
|
+
type Middleware,
|
|
17
|
+
type NextFn,
|
|
18
|
+
type Plugin,
|
|
19
|
+
} from "./composer.js";
|
|
20
|
+
export { Context, type ContextOptions } from "./context.js";
|
|
21
|
+
export { media, isMediaSource, type MediaSource } from "./media.js";
|
|
22
|
+
export {
|
|
23
|
+
webhookCallback,
|
|
24
|
+
nodeWebhookCallback,
|
|
25
|
+
type UpdateSink,
|
|
26
|
+
type WebhookOptions,
|
|
27
|
+
} from "./webhook.js";
|
|
28
|
+
export {
|
|
29
|
+
createApi,
|
|
30
|
+
TelegramError,
|
|
31
|
+
type Api,
|
|
32
|
+
type ApiOptions,
|
|
33
|
+
type FileReader,
|
|
34
|
+
type BeforeHook,
|
|
35
|
+
type AfterHook,
|
|
36
|
+
type ErrorHook,
|
|
37
|
+
type ErrorAction,
|
|
38
|
+
} from "./api.js";
|
|
39
|
+
export {
|
|
40
|
+
format,
|
|
41
|
+
Stringable,
|
|
42
|
+
bold,
|
|
43
|
+
italic,
|
|
44
|
+
underline,
|
|
45
|
+
strikethrough,
|
|
46
|
+
spoiler,
|
|
47
|
+
code,
|
|
48
|
+
pre,
|
|
49
|
+
link,
|
|
50
|
+
mention,
|
|
51
|
+
type FormatResult,
|
|
52
|
+
} from "./format.js";
|
|
53
|
+
export type * from "./telegram-types.js";
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// media abstraction (the puregram idea): a uniform way to point at a file —
|
|
2
|
+
// local path, URL, in-memory buffer, or an already-uploaded telegram file_id.
|
|
3
|
+
// the api layer turns each into the right wire form (multipart vs plain string).
|
|
4
|
+
|
|
5
|
+
const MEDIA: unique symbol = Symbol.for("yaebal.media");
|
|
6
|
+
|
|
7
|
+
export type MediaSource = { readonly [MEDIA]: true } & (
|
|
8
|
+
| { kind: "path"; path: string }
|
|
9
|
+
| { kind: "url"; url: string }
|
|
10
|
+
| { kind: "buffer"; buffer: Uint8Array; filename?: string }
|
|
11
|
+
| { kind: "fileId"; fileId: string }
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
/** build a {@link MediaSource}. `media.path("./a.jpg")`, `media.url(...)`, etc. */
|
|
15
|
+
export const media = {
|
|
16
|
+
path: (path: string): MediaSource => ({ [MEDIA]: true, kind: "path", path }),
|
|
17
|
+
url: (url: string): MediaSource => ({ [MEDIA]: true, kind: "url", url }),
|
|
18
|
+
buffer: (buffer: Uint8Array, filename?: string): MediaSource => ({
|
|
19
|
+
[MEDIA]: true,
|
|
20
|
+
kind: "buffer",
|
|
21
|
+
filename,
|
|
22
|
+
buffer,
|
|
23
|
+
}),
|
|
24
|
+
fileId: (fileId: string): MediaSource => ({ [MEDIA]: true, kind: "fileId", fileId }),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function isMediaSource(x: unknown): x is MediaSource {
|
|
28
|
+
return typeof x === "object" && x !== null && MEDIA in x;
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram Bot API types — re-exported from the code-generated `@yaebal/types`,
|
|
3
|
+
* the single source of truth (generated from the official schema on every API
|
|
4
|
+
* release). core only keeps the API-response envelope and the `UpdateName`
|
|
5
|
+
* helper, which aren't part of the object schema.
|
|
6
|
+
*/
|
|
7
|
+
import type { Update } from "@yaebal/types";
|
|
8
|
+
|
|
9
|
+
export type { User, Chat, MessageEntity, Message, CallbackQuery, Update } from "@yaebal/types";
|
|
10
|
+
|
|
11
|
+
/** the keys of `Update` that carry a payload (everything except `update_id`). */
|
|
12
|
+
export type UpdateName = Exclude<keyof Update, "update_id" | symbol | number>;
|
|
13
|
+
|
|
14
|
+
export interface ApiResponseOk<T> {
|
|
15
|
+
ok: true;
|
|
16
|
+
result: T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ApiResponseError {
|
|
20
|
+
ok: false;
|
|
21
|
+
error_code: number;
|
|
22
|
+
description: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ApiResponse<T> = ApiResponseOk<T> | ApiResponseError;
|
package/src/webhook.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { Update } from "./telegram-types.js";
|
|
3
|
+
|
|
4
|
+
/** telegram updates are tiny; reject anything wildly larger to avoid memory abuse. */
|
|
5
|
+
const MAX_BODY = 1 << 20; // 1 MiB
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* constant-time string compare so the secret-token check can't be timed.
|
|
9
|
+
* pure js (no node:crypto / Buffer) so it runs on node, bun, deno and edge/web.
|
|
10
|
+
*/
|
|
11
|
+
function safeEqual(a: string, b: string): boolean {
|
|
12
|
+
if (a.length !== b.length) return false;
|
|
13
|
+
let diff = 0;
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
16
|
+
return diff === 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** anything that can take a single update — i.e. a `Bot`. */
|
|
20
|
+
export interface UpdateSink {
|
|
21
|
+
handleUpdate(update: Update): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WebhookOptions {
|
|
25
|
+
/** if set, require telegram's `X-Telegram-Bot-Api-Secret-Token` header to match. */
|
|
26
|
+
secretToken?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* a fetch-style webhook handler — `(Request) => Response`. works on bun, deno,
|
|
31
|
+
* cloudflare workers, and any runtime with the fetch globals.
|
|
32
|
+
*/
|
|
33
|
+
export function webhookCallback(
|
|
34
|
+
bot: UpdateSink,
|
|
35
|
+
options: WebhookOptions = {},
|
|
36
|
+
): (request: Request) => Promise<Response> {
|
|
37
|
+
return async (request) => {
|
|
38
|
+
if (request.method !== "POST") return new Response("method not allowed", { status: 405 });
|
|
39
|
+
|
|
40
|
+
if (
|
|
41
|
+
options.secretToken &&
|
|
42
|
+
!safeEqual(request.headers.get("x-telegram-bot-api-secret-token") ?? "", options.secretToken)
|
|
43
|
+
) {
|
|
44
|
+
return new Response("unauthorized", { status: 401 });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY) {
|
|
48
|
+
return new Response("payload too large", { status: 413 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let update: Update;
|
|
52
|
+
try {
|
|
53
|
+
update = (await request.json()) as Update;
|
|
54
|
+
} catch {
|
|
55
|
+
return new Response("bad request", { status: 400 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await bot.handleUpdate(update);
|
|
59
|
+
return new Response("ok");
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** a node `http` webhook handler — `http.createServer(nodeWebhookCallback(bot))`. */
|
|
64
|
+
export function nodeWebhookCallback(
|
|
65
|
+
bot: UpdateSink,
|
|
66
|
+
options: WebhookOptions = {},
|
|
67
|
+
): (req: IncomingMessage, res: ServerResponse) => void {
|
|
68
|
+
return (req, res) => {
|
|
69
|
+
if (req.method !== "POST") {
|
|
70
|
+
res.statusCode = 405;
|
|
71
|
+
res.end();
|
|
72
|
+
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const got = req.headers["x-telegram-bot-api-secret-token"];
|
|
77
|
+
if (
|
|
78
|
+
options.secretToken &&
|
|
79
|
+
!safeEqual(typeof got === "string" ? got : "", options.secretToken)
|
|
80
|
+
) {
|
|
81
|
+
res.statusCode = 401;
|
|
82
|
+
res.end();
|
|
83
|
+
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const chunks: Buffer[] = [];
|
|
88
|
+
|
|
89
|
+
let size = 0;
|
|
90
|
+
req.on("data", (c: Buffer) => {
|
|
91
|
+
size += c.length;
|
|
92
|
+
|
|
93
|
+
if (size > MAX_BODY) {
|
|
94
|
+
res.statusCode = 413;
|
|
95
|
+
res.end();
|
|
96
|
+
req.destroy();
|
|
97
|
+
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
chunks.push(c);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
req.on("end", async () => {
|
|
105
|
+
try {
|
|
106
|
+
const update = JSON.parse(Buffer.concat(chunks).toString("utf8")) as Update;
|
|
107
|
+
await bot.handleUpdate(update);
|
|
108
|
+
|
|
109
|
+
res.statusCode = 200;
|
|
110
|
+
res.end("ok");
|
|
111
|
+
} catch {
|
|
112
|
+
res.statusCode = 400;
|
|
113
|
+
res.end();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
}
|