@xbibzlibrary/telebibz 0.4.4 → 0.4.5
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.id.md +14 -0
- package/README.md +14 -0
- package/README.zh-CN.md +14 -0
- package/docs/COOKBOOK.id.md +321 -0
- package/docs/COOKBOOK.md +321 -0
- package/docs/COOKBOOK.zh-CN.md +321 -0
- package/docs/ERRORS.id.md +194 -0
- package/docs/ERRORS.md +194 -0
- package/docs/ERRORS.zh-CN.md +194 -0
- package/docs/FILES.id.md +243 -0
- package/docs/FILES.md +243 -0
- package/docs/FILES.zh-CN.md +243 -0
- package/docs/GETTING_STARTED.id.md +6 -2
- package/docs/GETTING_STARTED.md +6 -2
- package/docs/GETTING_STARTED.zh-CN.md +6 -2
- package/docs/MIGRATION_TELEGRAF.id.md +147 -0
- package/docs/MIGRATION_TELEGRAF.md +154 -0
- package/docs/MIGRATION_TELEGRAF.zh-CN.md +147 -0
- package/docs/README.md +38 -19
- package/docs/TESTING.id.md +203 -0
- package/docs/TESTING.md +203 -0
- package/docs/TESTING.zh-CN.md +203 -0
- package/docs/WEBHOOK.id.md +212 -0
- package/docs/WEBHOOK.md +215 -0
- package/docs/WEBHOOK.zh-CN.md +212 -0
- package/package.json +1 -1
package/docs/COOKBOOK.md
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# Production cookbook (English)
|
|
2
|
+
|
|
3
|
+
Complete, verified recipes for the things real bots need: per-user rate limiting, auth middleware, broadcasts, scheduled jobs, background queues, menus with pagination, forms, caching, Mini App validation, and payments. Each recipe is self-contained — copy it into your bot and adjust names.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
1. [Per-user rate limiting](#1-per-user-rate-limiting)
|
|
8
|
+
2. [Auth middleware (allowlist / admin only)](#2-auth-middleware-allowlist--admin-only)
|
|
9
|
+
3. [Broadcast to thousands of users](#3-broadcast-to-thousands-of-users)
|
|
10
|
+
4. [Scheduled messages (interval, one-shot, cron)](#4-scheduled-messages-interval-one-shot-cron)
|
|
11
|
+
5. [Background jobs with retries](#5-background-jobs-with-retries)
|
|
12
|
+
6. [Paginated menus](#6-paginated-menus)
|
|
13
|
+
7. [Permission-aware menus](#7-permission-aware-menus)
|
|
14
|
+
8. [Multi-step forms with validation](#8-multi-step-forms-with-validation)
|
|
15
|
+
9. [Editing messages and inline keyboards](#9-editing-messages-and-inline-keyboards)
|
|
16
|
+
10. [Caching expensive results](#10-caching-expensive-results)
|
|
17
|
+
11. [Mini App initData validation](#11-mini-app-initdata-validation)
|
|
18
|
+
12. [Payments with Telegram Stars / invoices](#12-payments-with-telegram-stars--invoices)
|
|
19
|
+
13. [Structured logging and metrics hooks](#13-structured-logging-and-metrics-hooks)
|
|
20
|
+
|
|
21
|
+
## 1. Per-user rate limiting
|
|
22
|
+
|
|
23
|
+
`TokenBucketLimiter` keeps an independent bucket per key — key it by user or chat:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { Bot, TokenBucketLimiter } from "@xbibzlibrary/telebibz";
|
|
27
|
+
|
|
28
|
+
const limiter = new TokenBucketLimiter(5, 0.5); // burst of 5, refill 0.5 token/s (= 1 msg per 2s sustained)
|
|
29
|
+
|
|
30
|
+
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
|
|
31
|
+
bot.use(async (ctx, next) => {
|
|
32
|
+
const key = `user:${ctx.from?.id ?? "anon"}`;
|
|
33
|
+
const result = limiter.consume(key);
|
|
34
|
+
if (!result.allowed) {
|
|
35
|
+
const seconds = Math.ceil((result.retryAfterMs ?? 1000) / 1000);
|
|
36
|
+
await ctx.reply(`⏳ Terlalu banyak permintaan. Coba lagi dalam ${seconds} detik.`);
|
|
37
|
+
return; // do not call next(): update is dropped
|
|
38
|
+
}
|
|
39
|
+
await next();
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`consume(key, cost)` supports weighted actions (e.g. uploads cost 5, text costs 1). `limiter.clear(key?)` resets state. Combine with the transport flood gate — this limiter shapes *your users*; the flood gate obeys *Telegram*.
|
|
44
|
+
|
|
45
|
+
## 2. Auth middleware (allowlist / admin only)
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const ADMINS = new Set([Number(process.env.ADMIN_ID)]);
|
|
49
|
+
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
|
|
50
|
+
|
|
51
|
+
bot.use(async (ctx, next) => {
|
|
52
|
+
if (ADMINS.has(ctx.from?.id ?? 0)) return await next(); // admins: everything
|
|
53
|
+
if (ctx.chat?.type === "private") return await next(); // DMs: allowed
|
|
54
|
+
return undefined; // groups: silent drop
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
bot.command("stats", async (ctx) => { // admin-only route
|
|
58
|
+
if (!ADMINS.has(ctx.from?.id ?? 0)) return;
|
|
59
|
+
await ctx.reply("Secret stats");
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 3. Broadcast to thousands of users
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const report = await bot.broadcast(
|
|
67
|
+
subscriberIds,
|
|
68
|
+
(chatId) => bot.api.methods.sendMessage({ chat_id: chatId, text: "📰 Newsletter #42" }),
|
|
69
|
+
{
|
|
70
|
+
concurrency: 64, // cap when your downstream needs it (default: fully parallel)
|
|
71
|
+
onProgress: (p) => console.log(`${p.delivered}/${p.total}`),
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
console.log(`Delivered ${report.delivered}/${report.total} in ${report.durationMs}ms`);
|
|
76
|
+
for (const failure of report.failures) {
|
|
77
|
+
console.error(`chat ${failure.chatId}: ${failure.error}`);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Every chat is attempted; 429s are retried after exactly the `retry_after` Telegram orders. Failures never abort the run — they land in the report.
|
|
82
|
+
|
|
83
|
+
## 4. Scheduled messages (interval, one-shot, cron)
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { Scheduler, parseCronExpression, nextCronOccurrence } from "@xbibzlibrary/telebibz";
|
|
87
|
+
|
|
88
|
+
const scheduler = new Scheduler({ onError: (error, id) => console.error(`job ${id} failed`, error) });
|
|
89
|
+
|
|
90
|
+
// Every 6 hours
|
|
91
|
+
scheduler.every("digest", 6 * 60 * 60 * 1000, async () => {
|
|
92
|
+
await bot.api.methods.sendMessage({ chat_id: ADMIN_CHAT, text: "Scheduled digest" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Once, after 5 minutes (reminder pattern)
|
|
96
|
+
scheduler.after("remind-42", 5 * 60 * 1000, async () => {
|
|
97
|
+
await bot.api.methods.sendMessage({ chat_id: 42, text: "⏰ Reminder!" });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Cron: weekdays 09:00 (five-field expression)
|
|
101
|
+
scheduler.cron("morning", "0 9 * * 1-5", async () => {
|
|
102
|
+
await bot.api.methods.sendMessage({ chat_id: 42, text: "Good morning!" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
scheduler.cancel("digest"); // stop one job
|
|
106
|
+
scheduler.clear(); // stop all
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Pure helpers for tests and previews — no timers involved:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
parseCronExpression("*/15 * * * *"); // validated fields
|
|
113
|
+
nextCronOccurrence("0 9 * * 1", new Date()); // the next run as a Date
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 5. Background jobs with retries
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { TaskQueue } from "@xbibzlibrary/telebibz";
|
|
120
|
+
|
|
121
|
+
const queue = new TaskQueue(
|
|
122
|
+
async (job) => {
|
|
123
|
+
await fetch(`https://api.example.com/process`, { method: "POST", body: JSON.stringify(job.data) });
|
|
124
|
+
},
|
|
125
|
+
{ concurrency: 8, retries: 3, backoffMs: 500, maxBackoffMs: 30_000, onError: (error, job) => log.error("job failed", { job: job.id, error }) },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
bot.command("process", async (ctx) => {
|
|
129
|
+
const job = queue.add({ url: ctx.message?.text?.split(" ")[1] }, { priority: 10 }); // higher runs first
|
|
130
|
+
await ctx.reply(`Queued job ${job.id}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
bot.command("cancel", async (ctx) => {
|
|
134
|
+
const id = ctx.message?.text?.split(" ")[1];
|
|
135
|
+
if (id && queue.cancel(id)) await ctx.reply("Cancelled");
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## 6. Paginated menus
|
|
140
|
+
|
|
141
|
+
`MenuController` renders one page at a time and routes `prev:`/`next:` callbacks:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { Bot, MenuController, InlineKeyboard } from "@xbibzlibrary/telebibz";
|
|
145
|
+
|
|
146
|
+
const products = Array.from({ length: 57 }, (_v, i) => ({ id: i + 1, name: `Product ${i + 1}` }));
|
|
147
|
+
|
|
148
|
+
const menu = new MenuController({
|
|
149
|
+
id: "products",
|
|
150
|
+
items: () => products, // or an async () => await db.products()
|
|
151
|
+
pageSize: 10,
|
|
152
|
+
label: (item) => item.name,
|
|
153
|
+
callback: async (item) => { /* user picked a product */ },
|
|
154
|
+
labels: { previous: "◀", next: "▶" },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
bot.callback("products:*", async (ctx) => {
|
|
158
|
+
// Pass the FULL callback data — the controller expects its own "products:" prefix.
|
|
159
|
+
const result = await menu.handle(ctx.callbackQuery?.data ?? "");
|
|
160
|
+
if (result === undefined) return void (await ctx.answerCallbackQuery());
|
|
161
|
+
if (result.type === "noop") return void (await ctx.answerCallbackQuery());
|
|
162
|
+
if (result.type === "page") {
|
|
163
|
+
await ctx.reply(`Halaman ${result.page.page + 1}/${result.page.pageCount}`, { reply_markup: result.keyboard });
|
|
164
|
+
} else {
|
|
165
|
+
await ctx.answerCallbackQuery(`Picked: ${result.item.name}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
bot.command("shop", async (ctx) => {
|
|
169
|
+
const result = await menu.handle("products:page:0"); // "<id>:page:<n>"
|
|
170
|
+
if (result?.type === "page") await ctx.reply("Products:", { reply_markup: result.keyboard });
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## 7. Permission-aware menus
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { Menu } from "@xbibzlibrary/telebibz";
|
|
178
|
+
|
|
179
|
+
const menu = new Menu("main")
|
|
180
|
+
.breadcrumb("Home")
|
|
181
|
+
.item({ id: "profile", label: "👤 Profile", callbackData: "open:profile" })
|
|
182
|
+
.item({ id: "stats", label: "📊 Stats", permission: (context) => context.permissions?.includes("admin") ?? false })
|
|
183
|
+
.item({ id: "help", label: "❓ Help", url: "https://example.com/help" });
|
|
184
|
+
|
|
185
|
+
// build() is async: it evaluates visibility/permissions for the given context.
|
|
186
|
+
const keyboard = await menu.build({ permissions: ["admin"] }, { columns: 1, includeBreadcrumbs: true });
|
|
187
|
+
await ctx.reply("Main menu:", { reply_markup: keyboard.build() });
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`visible` hides items entirely; `permission` receives your `MenuContext` (`{ userId, permissions }`).
|
|
191
|
+
|
|
192
|
+
## 8. Multi-step forms with validation
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { Bot, Form, validators } from "@xbibzlibrary/telebibz";
|
|
196
|
+
|
|
197
|
+
const registration = new Form({
|
|
198
|
+
name: { parse: validators.string, required: true },
|
|
199
|
+
age: { parse: validators.integer, validate: (age) => (age >= 13 ? undefined : "Must be 13+") },
|
|
200
|
+
email: { parse: validators.email },
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Wire it through a `Wizard` step or `ConversationManager` — feed one field per message; `validators` covers `string`, `number`, `integer`, `email`, `url`, and custom checks return the error message.
|
|
205
|
+
|
|
206
|
+
## 9. Editing messages and inline keyboards
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { InlineKeyboard } from "@xbibzlibrary/telebibz";
|
|
210
|
+
|
|
211
|
+
bot.action("vote:up", async (ctx) => {
|
|
212
|
+
votes += 1;
|
|
213
|
+
const keyboard = new InlineKeyboard()
|
|
214
|
+
.text(`👍 ${votes}`, "vote:up")
|
|
215
|
+
.text("👎 0", "vote:down")
|
|
216
|
+
.build();
|
|
217
|
+
// Swap the keyboard of the button's message in place
|
|
218
|
+
await ctx.api.methods.editMessageReplyMarkup({
|
|
219
|
+
chat_id: ctx.chat!.id,
|
|
220
|
+
message_id: ctx.callbackQuery!.message!.message_id,
|
|
221
|
+
reply_markup: keyboard,
|
|
222
|
+
});
|
|
223
|
+
await ctx.answerCallbackQuery(); // stop the spinner
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`ctx.edit(text, extra)` rewrites the current message's text (keyboard included via `reply_markup` in `extra`); `editMessageLiveLocation`, `stopPoll`, and the full method surface are available on `ctx.api.methods`. Button data is limited to **64 bytes** — the builder validates at construction time, not at runtime crash time.
|
|
228
|
+
|
|
229
|
+
## 10. Caching expensive results
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { MemoryCache } from "@xbibzlibrary/telebibz";
|
|
233
|
+
|
|
234
|
+
const weather = new MemoryCache<string>("weather"); // namespace; TTL is set per write
|
|
235
|
+
bot.command("weather", async (ctx) => {
|
|
236
|
+
const city = ctx.message?.text?.split(" ")[1] ?? "Jakarta";
|
|
237
|
+
let text = await weather.get(city);
|
|
238
|
+
if (text === undefined) {
|
|
239
|
+
text = await fetchWeather(city);
|
|
240
|
+
await weather.set(city, text, 5 * 60 * 1000); // cache for 5 minutes
|
|
241
|
+
}
|
|
242
|
+
await ctx.reply(text);
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## 11. Mini App initData validation
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { validateWebAppInitData } from "@xbibzlibrary/telebibz";
|
|
250
|
+
|
|
251
|
+
bot.command("app", async (ctx) => {
|
|
252
|
+
await ctx.reply("Open the app:", {
|
|
253
|
+
reply_markup: new InlineKeyboard().webApp("🚀 Open", "https://app.example.com").build(),
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// In your app's backend endpoint — verify what the Mini App sends:
|
|
258
|
+
app.post("/api/data", express.json(), (req, res) => {
|
|
259
|
+
try {
|
|
260
|
+
const initData = validateWebAppInitData(req.body.initData, process.env.TELEGRAM_BOT_TOKEN!, 3600);
|
|
261
|
+
res.json({ user: initData.user, ok: true }); // signature + freshness verified
|
|
262
|
+
} catch {
|
|
263
|
+
res.status(401).json({ ok: false });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`validateWebAppInitData` checks the HMAC signature and the `auth_date` freshness window (default 24 h; here 1 h).
|
|
269
|
+
|
|
270
|
+
## 12. Payments with Telegram Stars / invoices
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { Bot, PaymentsClient, InlineKeyboard } from "@xbibzlibrary/telebibz";
|
|
274
|
+
|
|
275
|
+
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
|
|
276
|
+
const payments = new PaymentsClient(bot.api);
|
|
277
|
+
|
|
278
|
+
// Link that works anywhere (bio, website, chat)
|
|
279
|
+
const link = await payments.createInvoiceLink({
|
|
280
|
+
title: "Premium",
|
|
281
|
+
description: "30 days of premium",
|
|
282
|
+
payload: "premium-30d",
|
|
283
|
+
currency: "XTR",
|
|
284
|
+
prices: [{ label: "Premium", amount: 100 }],
|
|
285
|
+
});
|
|
286
|
+
await ctx.reply(`Pay here: ${link}`);
|
|
287
|
+
|
|
288
|
+
// In-chat invoice + pre-checkout + successful payment
|
|
289
|
+
bot.on("pre_checkout_query", async (ctx) => {
|
|
290
|
+
const query = ctx.update.pre_checkout_query;
|
|
291
|
+
if (!query) return;
|
|
292
|
+
await ctx.api.methods.answerPreCheckoutQuery({ pre_checkout_query_id: query.id, ok: true });
|
|
293
|
+
});
|
|
294
|
+
bot.on("message:successful_payment", async (ctx) => {
|
|
295
|
+
await ctx.reply("✅ Payment received. Thank you!");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Stars history & refunds
|
|
299
|
+
const history = await payments.getStarTransactions({ limit: 50 });
|
|
300
|
+
await payments.refundStarPayment({ user_id: userId, telegram_payment_charge_id: "charge-id" });
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## 13. Structured logging and metrics hooks
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const bot = new Bot({
|
|
307
|
+
token: process.env.TELEGRAM_BOT_TOKEN!,
|
|
308
|
+
logger: { level: "info", format: "json" }, // machine-readable lines for ingestion
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
bot.events.on("api:response", ({ method, durationMs }) => {
|
|
312
|
+
if (durationMs > 3_000) console.warn(JSON.stringify({ event: "slow_api", method, durationMs }));
|
|
313
|
+
});
|
|
314
|
+
bot.events.on("update:error", ({ error }) => {
|
|
315
|
+
console.error(JSON.stringify({ event: "handler_error", error: String(error) }));
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Sensitive values (tokens, phone numbers) are redacted automatically; `includeUpdateContent: true` opts into logging message text when you truly need it.
|
|
320
|
+
|
|
321
|
+
Bahasa Indonesia: [COOKBOOK.id.md](COOKBOOK.id.md) · 简体中文: [COOKBOOK.zh-CN.md](COOKBOOK.zh-CN.md)
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# 生产实战手册(简体中文)
|
|
2
|
+
|
|
3
|
+
真实 bot 所需功能的完整、已验证配方:按用户限流、鉴权中间件、广播、定时任务、后台队列、分页菜单、表单、缓存、Mini App 校验与支付。每个配方自成一体 —— 复制进你的 bot 后改名即可。
|
|
4
|
+
|
|
5
|
+
## 目录
|
|
6
|
+
|
|
7
|
+
1. [按用户限流](#1-按用户限流)
|
|
8
|
+
2. [鉴权中间件(白名单 / 仅管理员)](#2-鉴权中间件白名单--仅管理员)
|
|
9
|
+
3. [向数千用户广播](#3-向数千用户广播)
|
|
10
|
+
4. [定时消息(间隔、单次、cron)](#4-定时消息间隔单次cron)
|
|
11
|
+
5. [带重试的后台任务](#5-带重试的后台任务)
|
|
12
|
+
6. [分页菜单](#6-分页菜单)
|
|
13
|
+
7. [权限感知菜单](#7-权限感知菜单)
|
|
14
|
+
8. [带校验的多步表单](#8-带校验的多步表单)
|
|
15
|
+
9. [编辑消息与内联键盘](#9-编辑消息与内联键盘)
|
|
16
|
+
10. [缓存昂贵结果](#10-缓存昂贵结果)
|
|
17
|
+
11. [Mini App initData 校验](#11-mini-app-initdata-校验)
|
|
18
|
+
12. [Telegram Stars / 账单支付](#12-telegram-stars--账单支付)
|
|
19
|
+
13. [结构化日志与指标钩子](#13-结构化日志与指标钩子)
|
|
20
|
+
|
|
21
|
+
## 1. 按用户限流
|
|
22
|
+
|
|
23
|
+
`TokenBucketLimiter` 为每个键维护独立桶 —— 以用户或 chat 为键:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { Bot, TokenBucketLimiter } from "@xbibzlibrary/telebibz";
|
|
27
|
+
|
|
28
|
+
const limiter = new TokenBucketLimiter(5, 0.5); // 突发 5 个,每秒回填 0.5 个(持续速率 = 每 2 秒 1 条)
|
|
29
|
+
|
|
30
|
+
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
|
|
31
|
+
bot.use(async (ctx, next) => {
|
|
32
|
+
const key = `user:${ctx.from?.id ?? "anon"}`;
|
|
33
|
+
const result = limiter.consume(key);
|
|
34
|
+
if (!result.allowed) {
|
|
35
|
+
const seconds = Math.ceil((result.retryAfterMs ?? 1000) / 1000);
|
|
36
|
+
await ctx.reply(`⏳ 请求太频繁,请在 ${seconds} 秒后重试。`);
|
|
37
|
+
return; // 不调用 next():该更新被丢弃
|
|
38
|
+
}
|
|
39
|
+
await next();
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`consume(key, cost)` 支持加权动作(如上传花 5、文本花 1)。`limiter.clear(key?)` 重置状态。可与传输层 flood gate 组合 —— 限流器约束*你的用户*;flood gate 服从 *Telegram*。
|
|
44
|
+
|
|
45
|
+
## 2. 鉴权中间件(白名单 / 仅管理员)
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
const ADMINS = new Set([Number(process.env.ADMIN_ID)]);
|
|
49
|
+
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
|
|
50
|
+
|
|
51
|
+
bot.use(async (ctx, next) => {
|
|
52
|
+
if (ADMINS.has(ctx.from?.id ?? 0)) return await next(); // 管理员:全放行
|
|
53
|
+
if (ctx.chat?.type === "private") return await next(); // 私聊:放行
|
|
54
|
+
return undefined; // 群组:静默丢弃
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
bot.command("stats", async (ctx) => { // 仅管理员路由
|
|
58
|
+
if (!ADMINS.has(ctx.from?.id ?? 0)) return;
|
|
59
|
+
await ctx.reply("机密统计");
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 3. 向数千用户广播
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const report = await bot.broadcast(
|
|
67
|
+
subscriberIds,
|
|
68
|
+
(chatId) => bot.api.methods.sendMessage({ chat_id: chatId, text: "📰 第 42 期通讯" }),
|
|
69
|
+
{
|
|
70
|
+
concurrency: 64, // 下游需要时才设上限(默认:完全并行)
|
|
71
|
+
onProgress: (p) => console.log(`${p.delivered}/${p.total}`),
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
console.log(`已送达 ${report.delivered}/${report.total},耗时 ${report.durationMs}ms`);
|
|
76
|
+
for (const failure of report.failures) {
|
|
77
|
+
console.error(`chat ${failure.chatId}: ${failure.error}`);
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
每个 chat 都会尝试;429 精确按 Telegram 指示的 `retry_after` 重试。失败从不中断整体 —— 全部落入报告。
|
|
82
|
+
|
|
83
|
+
## 4. 定时消息(间隔、单次、cron)
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { Scheduler, parseCronExpression, nextCronOccurrence } from "@xbibzlibrary/telebibz";
|
|
87
|
+
|
|
88
|
+
const scheduler = new Scheduler({ onError: (error, id) => console.error(`任务 ${id} 失败`, error) });
|
|
89
|
+
|
|
90
|
+
// 每 6 小时
|
|
91
|
+
scheduler.every("digest", 6 * 60 * 60 * 1000, async () => {
|
|
92
|
+
await bot.api.methods.sendMessage({ chat_id: ADMIN_CHAT, text: "定时摘要" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// 一次性,5 分钟后(提醒模式)
|
|
96
|
+
scheduler.after("remind-42", 5 * 60 * 1000, async () => {
|
|
97
|
+
await bot.api.methods.sendMessage({ chat_id: 42, text: "⏰ 提醒!" });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// cron:工作日 09:00(五字段表达式)
|
|
101
|
+
scheduler.cron("morning", "0 9 * * 1-5", async () => {
|
|
102
|
+
await bot.api.methods.sendMessage({ chat_id: 42, text: "早上好!" });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
scheduler.cancel("digest"); // 停止单个任务
|
|
106
|
+
scheduler.clear(); // 停止全部
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
用于测试与预览的纯函数助手 —— 不涉及定时器:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
parseCronExpression("*/15 * * * *"); // 已校验的字段
|
|
113
|
+
nextCronOccurrence("0 9 * * 1", new Date()); // 下次运行时间(Date)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## 5. 带重试的后台任务
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { TaskQueue } from "@xbibzlibrary/telebibz";
|
|
120
|
+
|
|
121
|
+
const queue = new TaskQueue(
|
|
122
|
+
async (job) => {
|
|
123
|
+
await fetch(`https://api.example.com/process`, { method: "POST", body: JSON.stringify(job.data) });
|
|
124
|
+
},
|
|
125
|
+
{ concurrency: 8, retries: 3, backoffMs: 500, maxBackoffMs: 30_000, onError: (error, job) => log.error("任务失败", { job: job.id, error }) },
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
bot.command("process", async (ctx) => {
|
|
129
|
+
const job = queue.add({ url: ctx.message?.text?.split(" ")[1] }, { priority: 10 }); // 高优先级先执行
|
|
130
|
+
await ctx.reply(`已入队任务 ${job.id}`);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
bot.command("cancel", async (ctx) => {
|
|
134
|
+
const id = ctx.message?.text?.split(" ")[1];
|
|
135
|
+
if (id && queue.cancel(id)) await ctx.reply("已取消");
|
|
136
|
+
});
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## 6. 分页菜单
|
|
140
|
+
|
|
141
|
+
`MenuController` 一次渲染一页并路由翻页回调:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
import { Bot, MenuController, InlineKeyboard } from "@xbibzlibrary/telebibz";
|
|
145
|
+
|
|
146
|
+
const products = Array.from({ length: 57 }, (_v, i) => ({ id: i + 1, name: `商品 ${i + 1}` }));
|
|
147
|
+
|
|
148
|
+
const menu = new MenuController({
|
|
149
|
+
id: "products",
|
|
150
|
+
items: () => products, // 或 async () => await db.products()
|
|
151
|
+
pageSize: 10,
|
|
152
|
+
label: (item) => item.name,
|
|
153
|
+
callback: async (item) => { /* 用户选中了一件商品 */ },
|
|
154
|
+
labels: { previous: "◀", next: "▶" },
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
bot.callback("products:*", async (ctx) => {
|
|
158
|
+
// 传入完整的回调数据 —— 控制器需要它自己的 "products:" 前缀。
|
|
159
|
+
const result = await menu.handle(ctx.callbackQuery?.data ?? "");
|
|
160
|
+
if (result === undefined) return void (await ctx.answerCallbackQuery());
|
|
161
|
+
if (result.type === "noop") return void (await ctx.answerCallbackQuery());
|
|
162
|
+
if (result.type === "page") {
|
|
163
|
+
await ctx.reply(`第 ${result.page.page + 1}/${result.page.pageCount} 页`, { reply_markup: result.keyboard });
|
|
164
|
+
} else {
|
|
165
|
+
await ctx.answerCallbackQuery(`已选:${result.item.name}`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
bot.command("shop", async (ctx) => {
|
|
169
|
+
const result = await menu.handle("products:page:0"); // "<id>:page:<n>"
|
|
170
|
+
if (result?.type === "page") await ctx.reply("商品:", { reply_markup: result.keyboard });
|
|
171
|
+
});
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## 7. 权限感知菜单
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
import { Menu } from "@xbibzlibrary/telebibz";
|
|
178
|
+
|
|
179
|
+
const menu = new Menu("main")
|
|
180
|
+
.breadcrumb("主页")
|
|
181
|
+
.item({ id: "profile", label: "👤 个人资料", callbackData: "open:profile" })
|
|
182
|
+
.item({ id: "stats", label: "📊 统计", permission: (context) => context.permissions?.includes("admin") ?? false })
|
|
183
|
+
.item({ id: "help", label: "❓ 帮助", url: "https://example.com/help" });
|
|
184
|
+
|
|
185
|
+
// build() 是异步的:它为给定 context 计算可见性/权限。
|
|
186
|
+
const keyboard = await menu.build({ permissions: ["admin"] }, { columns: 1, includeBreadcrumbs: true });
|
|
187
|
+
await ctx.reply("主菜单:", { reply_markup: keyboard.build() });
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`visible` 彻底隐藏条目;`permission` 接收你的 `MenuContext`(`{ userId, permissions }`)。
|
|
191
|
+
|
|
192
|
+
## 8. 带校验的多步表单
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
import { Bot, Form, validators } from "@xbibzlibrary/telebibz";
|
|
196
|
+
|
|
197
|
+
const registration = new Form({
|
|
198
|
+
name: { parse: validators.string, required: true },
|
|
199
|
+
age: { parse: validators.integer, validate: (age) => (age >= 13 ? undefined : "需满 13 岁") },
|
|
200
|
+
email: { parse: validators.email },
|
|
201
|
+
});
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
通过 `Wizard` 步骤或 `ConversationManager` 接线 —— 每条消息填一个字段;`validators` 覆盖 `string`、`number`、`integer`、`email`、`url`,自定义检查返回错误消息。
|
|
205
|
+
|
|
206
|
+
## 9. 编辑消息与内联键盘
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { InlineKeyboard } from "@xbibzlibrary/telebibz";
|
|
210
|
+
|
|
211
|
+
bot.action("vote:up", async (ctx) => {
|
|
212
|
+
votes += 1;
|
|
213
|
+
const keyboard = new InlineKeyboard()
|
|
214
|
+
.text(`👍 ${votes}`, "vote:up")
|
|
215
|
+
.text("👎 0", "vote:down")
|
|
216
|
+
.build();
|
|
217
|
+
// 原地替换按钮所在消息的键盘
|
|
218
|
+
await ctx.api.methods.editMessageReplyMarkup({
|
|
219
|
+
chat_id: ctx.chat!.id,
|
|
220
|
+
message_id: ctx.callbackQuery!.message!.message_id,
|
|
221
|
+
reply_markup: keyboard,
|
|
222
|
+
});
|
|
223
|
+
await ctx.answerCallbackQuery(); // 停止转圈
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
`ctx.edit(text, extra)` 改写当前消息的文本(键盘通过 `extra` 中的 `reply_markup` 一并替换);`editMessageLiveLocation`、`stopPoll` 及完整方法面都在 `ctx.api.methods` 上。按钮数据上限 **64 字节** —— builder 在构造时校验,而不是运行时崩溃。
|
|
228
|
+
|
|
229
|
+
## 10. 缓存昂贵结果
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { MemoryCache } from "@xbibzlibrary/telebibz";
|
|
233
|
+
|
|
234
|
+
const weather = new MemoryCache<string>("weather"); // 命名空间;TTL 按每次写入设置
|
|
235
|
+
bot.command("weather", async (ctx) => {
|
|
236
|
+
const city = ctx.message?.text?.split(" ")[1] ?? "北京";
|
|
237
|
+
let text = await weather.get(city);
|
|
238
|
+
if (text === undefined) {
|
|
239
|
+
text = await fetchWeather(city);
|
|
240
|
+
await weather.set(city, text, 5 * 60 * 1000); // 缓存 5 分钟
|
|
241
|
+
}
|
|
242
|
+
await ctx.reply(text);
|
|
243
|
+
});
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## 11. Mini App initData 校验
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
import { validateWebAppInitData } from "@xbibzlibrary/telebibz";
|
|
250
|
+
|
|
251
|
+
bot.command("app", async (ctx) => {
|
|
252
|
+
await ctx.reply("打开应用:", {
|
|
253
|
+
reply_markup: new InlineKeyboard().webApp("🚀 打开", "https://app.example.com").build(),
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// 在你应用的后端端点里 —— 校验 Mini App 发来的数据:
|
|
258
|
+
app.post("/api/data", express.json(), (req, res) => {
|
|
259
|
+
try {
|
|
260
|
+
const initData = validateWebAppInitData(req.body.initData, process.env.TELEGRAM_BOT_TOKEN!, 3600);
|
|
261
|
+
res.json({ user: initData.user, ok: true }); // 签名 + 新鲜度已验证
|
|
262
|
+
} catch {
|
|
263
|
+
res.status(401).json({ ok: false });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
`validateWebAppInitData` 检查 HMAC 签名与 `auth_date` 新鲜度窗口(默认 24 小时;此处 1 小时)。
|
|
269
|
+
|
|
270
|
+
## 12. Telegram Stars / 账单支付
|
|
271
|
+
|
|
272
|
+
```ts
|
|
273
|
+
import { Bot, PaymentsClient, InlineKeyboard } from "@xbibzlibrary/telebibz";
|
|
274
|
+
|
|
275
|
+
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
|
|
276
|
+
const payments = new PaymentsClient(bot.api);
|
|
277
|
+
|
|
278
|
+
// 可在任何地方使用的链接(简介、网站、聊天)
|
|
279
|
+
const link = await payments.createInvoiceLink({
|
|
280
|
+
title: "Premium",
|
|
281
|
+
description: "30 天会员",
|
|
282
|
+
payload: "premium-30d",
|
|
283
|
+
currency: "XTR",
|
|
284
|
+
prices: [{ label: "Premium", amount: 100 }],
|
|
285
|
+
});
|
|
286
|
+
await ctx.reply(`在此支付:${link}`);
|
|
287
|
+
|
|
288
|
+
// 聊天内账单 + 预检 + 支付成功
|
|
289
|
+
bot.on("pre_checkout_query", async (ctx) => {
|
|
290
|
+
const query = ctx.update.pre_checkout_query;
|
|
291
|
+
if (!query) return;
|
|
292
|
+
await ctx.api.methods.answerPreCheckoutQuery({ pre_checkout_query_id: query.id, ok: true });
|
|
293
|
+
});
|
|
294
|
+
bot.on("message:successful_payment", async (ctx) => {
|
|
295
|
+
await ctx.reply("✅ 已收到付款,谢谢!");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Stars 流水与退款
|
|
299
|
+
const history = await payments.getStarTransactions({ limit: 50 });
|
|
300
|
+
await payments.refundStarPayment({ user_id: userId, telegram_payment_charge_id: "charge-id" });
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
## 13. 结构化日志与指标钩子
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
const bot = new Bot({
|
|
307
|
+
token: process.env.TELEGRAM_BOT_TOKEN!,
|
|
308
|
+
logger: { level: "info", format: "json" }, // 供采集系统消费的机器可读日志行
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
bot.events.on("api:response", ({ method, durationMs }) => {
|
|
312
|
+
if (durationMs > 3_000) console.warn(JSON.stringify({ event: "slow_api", method, durationMs }));
|
|
313
|
+
});
|
|
314
|
+
bot.events.on("update:error", ({ error }) => {
|
|
315
|
+
console.error(JSON.stringify({ event: "handler_error", error: String(error) }));
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
敏感值(token、手机号)自动脱敏;确有需要时用 `includeUpdateContent: true` 开启消息文本记录。
|
|
320
|
+
|
|
321
|
+
English: [COOKBOOK.md](COOKBOOK.md) · Bahasa Indonesia: [COOKBOOK.id.md](COOKBOOK.id.md)
|