@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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# 测试指南(简体中文)
|
|
2
|
+
|
|
3
|
+
完全离线地测试 telebibz bot:伪造传输层、构造更新、断言外发调用,并端到端驱动整段对话。
|
|
4
|
+
|
|
5
|
+
## 目录
|
|
6
|
+
|
|
7
|
+
1. [testing 子路径](#1-testing-子路径)
|
|
8
|
+
2. [MockTransport](#2-mocktransport)
|
|
9
|
+
3. [把更新送进 bot](#3-把更新送进-bot)
|
|
10
|
+
4. [断言外发调用](#4-断言外发调用)
|
|
11
|
+
5. [端到端测试向导](#5-端到端测试向导)
|
|
12
|
+
6. [测试文件下载](#6-测试文件下载)
|
|
13
|
+
7. [测试 webhook](#7-测试-webhook)
|
|
14
|
+
8. [测试错误路径](#8-测试错误路径)
|
|
15
|
+
9. [Vitest / Jest 模式](#9-vitest--jest-模式)
|
|
16
|
+
|
|
17
|
+
## 1. testing 子路径
|
|
18
|
+
|
|
19
|
+
所有工具都从 `@xbibzlibrary/telebibz/testing` 导出:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { MockTransport, createTestBot, createMockUpdate, createMockCallbackUpdate, createMockContext } from "@xbibzlibrary/telebibz/testing";
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- `createTestBot()` → `{ bot, transport }` —— 连接到 `MockTransport` 的 `Bot`,branding 已关闭。
|
|
26
|
+
- `createMockUpdate(overrides?)` → 一条普通 `/start` 文本消息更新。
|
|
27
|
+
- `createMockCallbackUpdate(overrides?)` → 一条回调查询更新。
|
|
28
|
+
- `createMockContext(bot, update?)` → 不经过路由的 `Context`。
|
|
29
|
+
|
|
30
|
+
## 2. MockTransport
|
|
31
|
+
|
|
32
|
+
`MockTransport` 记录每个外发请求,并按配置应答:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
const { bot, transport } = createTestBot();
|
|
36
|
+
|
|
37
|
+
transport.respond("getMe", { ok: true, result: { id: 99, is_bot: true, first_name: "TestBot", username: "test_bot" } });
|
|
38
|
+
transport.respond("sendMessage", { ok: true, result: { message_id: 1, date: 0, chat: { id: 1, type: "private" } } });
|
|
39
|
+
transport.respond("getFile", (payload) => ({ // 动态应答
|
|
40
|
+
ok: true,
|
|
41
|
+
result: { file_id: (payload as { file_id: string }).file_id, file_unique_id: "u", file_path: "documents/a.pdf" },
|
|
42
|
+
}));
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
未配置的方法应答 `{ ok: true, result: true }` —— 对结果无所谓的调用足够了。
|
|
46
|
+
|
|
47
|
+
它还实现了下载成员:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
transport.downloadBytes = new TextEncoder().encode("file-content");
|
|
51
|
+
transport.downloads; // 每个传给 download() 的 file_path,按顺序
|
|
52
|
+
transport.fileUrl("a.pdf"); // "mock://files/a.pdf"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## 3. 把更新送进 bot
|
|
56
|
+
|
|
57
|
+
注册 handler,然后投喂更新 —— 无网络、无 token:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
bot.on("message", async (ctx) => { await ctx.reply("hi"); });
|
|
61
|
+
await bot.init(); // 恰好一次 getMe,与生产一致
|
|
62
|
+
|
|
63
|
+
await bot.handleUpdate(createMockUpdate({ message: { ...createMockUpdate().message!, text: "hello" } }));
|
|
64
|
+
await bot.handleUpdates([updateA, updateB, updateC]); // 整批投喂,跨 chat 并行
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
模拟多个 chat 时,变换 chat id:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
function textUpdate(updateId: number, chatId: number, text: string) {
|
|
71
|
+
const base = createMockUpdate();
|
|
72
|
+
return { ...base, update_id: updateId, message: { ...base.message!, chat: { id: chatId, type: "private" as const }, text } };
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 4. 断言外发调用
|
|
77
|
+
|
|
78
|
+
每个请求连同 method 与 payload 记录在 `transport.calls`:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
const replies = transport.calls
|
|
82
|
+
.filter((call) => call.method === "sendMessage")
|
|
83
|
+
.map((call) => (call.payload as { text?: string }).text);
|
|
84
|
+
|
|
85
|
+
expect(replies).toEqual(["你叫什么名字?", "你多大了?"]);
|
|
86
|
+
|
|
87
|
+
// 回调键盘:
|
|
88
|
+
const markup = (transport.calls.at(-1)?.payload as { reply_markup?: { inline_keyboard: unknown[][] } }).reply_markup;
|
|
89
|
+
expect(markup?.inline_keyboard).toHaveLength(2);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 5. 端到端测试向导
|
|
93
|
+
|
|
94
|
+
逐条消息推进对话,并断言用户将看到的回复:
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
import { Bot, Wizard } from "@xbibzlibrary/telebibz";
|
|
98
|
+
|
|
99
|
+
const wizard = new Wizard()
|
|
100
|
+
.step({ id: "ask", run: async (flow) => { flow.next(); await flow.ctx.reply("你叫什么名字?"); } })
|
|
101
|
+
.step({ id: "save", run: async (flow) => { await flow.ctx.reply(`你好,${flow.ctx.message?.text}!`); } });
|
|
102
|
+
bot.useWizard(wizard);
|
|
103
|
+
bot.command("start", async (ctx) => { await wizard.run(ctx); });
|
|
104
|
+
|
|
105
|
+
await bot.handleUpdate(textUpdate(1, 7, "/start"));
|
|
106
|
+
await bot.handleUpdate(textUpdate(2, 7, "小明"));
|
|
107
|
+
|
|
108
|
+
expect(sentTexts()).toEqual(["你叫什么名字?", "你好,小明!"]);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
两个 chat、两个独立向导 —— 交错投喂依然隔离,因为同一 chat 的更新按序串行:
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
await bot.handleUpdate(textUpdate(1, 7, "/start"));
|
|
115
|
+
await bot.handleUpdate(textUpdate(2, 8, "/start"));
|
|
116
|
+
await bot.handleUpdate(textUpdate(3, 7, "小明"));
|
|
117
|
+
await bot.handleUpdate(textUpdate(4, 8, "小红"));
|
|
118
|
+
expect(sentTexts()).toEqual(["你叫什么名字?", "你叫什么名字?", "你好,小明!", "你好,小红!"]);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## 6. 测试文件下载
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
transport.respond("getFile", { ok: true, result: { file_id: "F1", file_unique_id: "U1", file_path: "photos/pic.jpg" } });
|
|
125
|
+
transport.downloadBytes = new TextEncoder().encode("jpeg-bytes");
|
|
126
|
+
|
|
127
|
+
const file = await bot.downloadFile("F1", { destination: tmpFile });
|
|
128
|
+
expect(file.fileName).toBe("pic.jpg");
|
|
129
|
+
expect(transport.downloads).toEqual(["photos/pic.jpg"]);
|
|
130
|
+
expect(await readFile(tmpFile, "utf8")).toBe("jpeg-bytes");
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## 7. 测试 webhook
|
|
134
|
+
|
|
135
|
+
`createWebhookHandler` 接受真实的 Web `Request`:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
const handler = createWebhookHandler(bot, { secretToken: "s3cret" });
|
|
139
|
+
|
|
140
|
+
const response = await handler(new Request("https://example.com/telegram", {
|
|
141
|
+
method: "POST",
|
|
142
|
+
headers: { "content-type": "application/json", "x-telegram-bot-api-secret-token": "s3cret" },
|
|
143
|
+
body: JSON.stringify(createMockUpdate()),
|
|
144
|
+
}));
|
|
145
|
+
expect(response.status).toBe(200);
|
|
146
|
+
|
|
147
|
+
// 错误 secret → 在任何 handler 运行前被拒绝
|
|
148
|
+
const rejected = await handler(new Request("https://example.com/telegram", {
|
|
149
|
+
method: "POST",
|
|
150
|
+
headers: { "content-type": "application/json", "x-telegram-bot-api-secret-token": "wrong" },
|
|
151
|
+
body: JSON.stringify(createMockUpdate()),
|
|
152
|
+
}));
|
|
153
|
+
expect(rejected.status).toBe(401);
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## 8. 测试错误路径
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// Telegram 拒绝调用
|
|
160
|
+
transport.respond("sendMessage", { ok: false, error_code: 400, description: "Bad Request: chat not found" });
|
|
161
|
+
await expect(ctx.reply("hi")).rejects.toThrow(/chat not found/);
|
|
162
|
+
|
|
163
|
+
// handler 抛异常 —— 断言错误边界看到了它
|
|
164
|
+
const errors: unknown[] = [];
|
|
165
|
+
bot.events.on("update:error", ({ error }) => errors.push(error));
|
|
166
|
+
bot.onText("boom", async () => { throw new Error("boom"); });
|
|
167
|
+
await bot.handleUpdate(textUpdate(1, 7, "boom"));
|
|
168
|
+
expect(errors).toHaveLength(1);
|
|
169
|
+
|
|
170
|
+
// handlerTimeout:挂起的更新以 UpdateTimeoutError 拒绝,而 handler 继续运行
|
|
171
|
+
const slow = new Bot({ token: "123456:TEST", transport, handlerTimeout: 50, branding: false, logger: { level: "silent" } });
|
|
172
|
+
let finished = false;
|
|
173
|
+
slow.on("message", async () => { await sleep(200); finished = true; });
|
|
174
|
+
await expect(slow.handleUpdate(createMockUpdate())).rejects.toBeInstanceOf(UpdateTimeoutError);
|
|
175
|
+
// 稍后:
|
|
176
|
+
expect(finished).toBe(true);
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## 9. Vitest / Jest 模式
|
|
180
|
+
|
|
181
|
+
**测试中静音 logger** —— `logger: { level: "silent" }` 不输出任何内容(有回归测试):
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
const bot = new Bot({ token: "123456:TEST", transport, branding: false, logger: { level: "silent" } });
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
**每个测试全新 transport** —— 不要在用例间共享状态:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
beforeEach(() => { ({ bot, transport } = createTestBot()); });
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**快进时间** —— `Scheduler`/TTL 测试用假定时器或小间隔(`1ms`);cron 解析器是纯函数,无需定时器:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { nextCronOccurrence, parseCronExpression } from "@xbibzlibrary/telebibz";
|
|
197
|
+
expect(parseCronExpression("*/15 * * * *")).toBeDefined();
|
|
198
|
+
expect(nextCronOccurrence("0 9 * * 1", new Date("2026-08-29T00:00:00Z")).toISOString()).toContain("T09:00");
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
**运行套件**:`npm test` —— 缺少 `TELEGRAM_BOT_TOKEN` 时,需要真实 token 的 E2E 测试自动跳过。
|
|
202
|
+
|
|
203
|
+
English: [TESTING.md](TESTING.md) · Bahasa Indonesia: [TESTING.id.md](TESTING.id.md)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Webhook (Bahasa Indonesia)
|
|
2
|
+
|
|
3
|
+
Cara melayani update lewat webhook — untuk platform tanpa long-polling (serverless, container), atau saat butuh endpoint terbuka HTTPS.
|
|
4
|
+
|
|
5
|
+
## Daftar isi
|
|
6
|
+
|
|
7
|
+
1. [Polling vs webhook](#1-polling-vs-webhook)
|
|
8
|
+
2. [Handler: `createWebhookHandler()`](#2-handler-createwebhookhandler)
|
|
9
|
+
3. [Framework populer](#3-framework-populer)
|
|
10
|
+
4. [Mendaftarkan URL ke Telegram](#4-mendaftarkan-url-ke-telegram)
|
|
11
|
+
5. [Secret token](#5-secret-token)
|
|
12
|
+
6. [Mode `webhookReply`](#6-mode-webhookreply)
|
|
13
|
+
7. [Local development dengan tunnel](#7-local-development-dengan-tunnel)
|
|
14
|
+
8. [Checklist produksi](#8-checklist-produksi)
|
|
15
|
+
9. [Troubleshooting](#9-troubleshooting)
|
|
16
|
+
|
|
17
|
+
## 1. Polling vs webhook
|
|
18
|
+
|
|
19
|
+
| | Polling (`bot.start()`) | Webhook |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Menghubungi Telegram | Ya (long-polling) | Tidak — Telegram yang menghubungi Anda |
|
|
22
|
+
| Butuh domain + HTTPS publik | Tidak | Ya |
|
|
23
|
+
| Cocok untuk | Skrip lokal, development, VPS | Serverless (Lambda/Workers/Cloud Functions), container, k8s |
|
|
24
|
+
| Konkurensi | Pipeline per-update yang sama | Pipeline per-update yang sama |
|
|
25
|
+
| Menerima `POST /<path>` Anda sendiri | — | Ya — handler mengembalikan `Response`, routing tetap milik Anda |
|
|
26
|
+
|
|
27
|
+
Hanya satu yang aktif: Telegram mengirim update ke webhook terdaftar dan mengabaikan `getUpdates` selama webhook aktif.
|
|
28
|
+
|
|
29
|
+
## 2. Handler: `createWebhookHandler()`
|
|
30
|
+
|
|
31
|
+
Handler menerima **Web-standard `Request`** dan mengembalikan **`Response`** — berjalan di Node, Bun, Deno, dan edge runtime:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
import { createWebhookHandler } from "@xbibzlibrary/telebibz";
|
|
35
|
+
|
|
36
|
+
const handleUpdate = createWebhookHandler(bot, {
|
|
37
|
+
secretToken: process.env.TELEGRAM_WEBHOOK_SECRET, // verifikasi X-Telegram-Bot-Api-Secret-Token
|
|
38
|
+
webhookReply: true, // jawab API via body respons (opsional)
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
export default {
|
|
42
|
+
async fetch(request: Request): Promise<Response> {
|
|
43
|
+
if (request.method === "POST" && new URL(request.url).pathname === "/telegram") {
|
|
44
|
+
return handleUpdate(request);
|
|
45
|
+
}
|
|
46
|
+
return new Response("Not Found", { status: 404 });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- Body > 1 MB → `413 Payload Too Large` (atur `maxBodyBytes`).
|
|
52
|
+
- Secret salah → `401 Unauthorized`.
|
|
53
|
+
- Method non-POST → `405 Method Not Allowed`.
|
|
54
|
+
- Update diproses via pipeline normal — error handler, session, conversation, semuanya bekerja.
|
|
55
|
+
|
|
56
|
+
Untuk server Node ala Express (objek req/res, bukan `Request`), gunakan `webhookCallback()` — lihat [Framework populer](#3-framework-populer).
|
|
57
|
+
|
|
58
|
+
## 3. Framework populer
|
|
59
|
+
|
|
60
|
+
### Express
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import express from "express";
|
|
64
|
+
import { webhookCallback } from "@xbibzlibrary/telebibz";
|
|
65
|
+
|
|
66
|
+
const app = express();
|
|
67
|
+
app.use(express.json({ limit: "1mb" }));
|
|
68
|
+
app.post("/telegram", webhookCallback(bot, "express", { secretToken: process.env.TELEGRAM_WEBHOOK_SECRET }));
|
|
69
|
+
app.listen(3000);
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Node `http`
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import http from "node:http";
|
|
76
|
+
import { webhookCallback } from "@xbibzlibrary/telebibz";
|
|
77
|
+
|
|
78
|
+
const callback = webhookCallback(bot, "http", { secretToken: process.env.TELEGRAM_WEBHOOK_SECRET });
|
|
79
|
+
http
|
|
80
|
+
.createServer(async (req, res) => {
|
|
81
|
+
if (req.method === "POST" && req.url === "/telegram") return callback(req, res);
|
|
82
|
+
res.writeHead(404).end();
|
|
83
|
+
})
|
|
84
|
+
.listen(3000);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### Fastify
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import Fastify from "fastify";
|
|
91
|
+
import { webhookCallback } from "@xbibzlibrary/telebibz";
|
|
92
|
+
|
|
93
|
+
const fastify = Fastify({ bodyLimit: 1_048_576 });
|
|
94
|
+
fastify.post("/telegram", (req, reply) => webhookCallback(bot, "fastify", { secretToken: process.env.TELEGRAM_WEBHOOK_SECRET })(req, reply));
|
|
95
|
+
await fastify.listen({ port: 3000 });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Koa (dengan `koa-bodyparser` agar `ctx.request.body` terisi)
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import Koa from "koa";
|
|
102
|
+
import bodyParser from "koa-bodyparser";
|
|
103
|
+
import { webhookCallback } from "@xbibzlibrary/telebibz";
|
|
104
|
+
|
|
105
|
+
const app = new Koa();
|
|
106
|
+
app.use(bodyParser());
|
|
107
|
+
app.use(async (ctx) => {
|
|
108
|
+
if (ctx.method === "POST" && ctx.path === "/telegram") {
|
|
109
|
+
await webhookCallback(bot, "koa", { secretToken: process.env.TELEGRAM_WEBHOOK_SECRET })(ctx.request, ctx);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
ctx.status = 404;
|
|
113
|
+
});
|
|
114
|
+
app.listen(3000);
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## 4. Mendaftarkan URL ke Telegram
|
|
118
|
+
|
|
119
|
+
Webhook hanya mengirim ke URL yang Anda daftarkan. Setelah server jalan di URL publik, panggil `setWebhook` sekali:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const secret = process.env.TELEGRAM_WEBHOOK_SECRET;
|
|
123
|
+
|
|
124
|
+
await bot.api.methods.setWebhook({
|
|
125
|
+
url: "https://bot.example.com/telegram",
|
|
126
|
+
secret_token: secret, // sama persis dengan secretToken handler
|
|
127
|
+
max_connections: 40, // default 40; sesuaikan dengan kapasitas
|
|
128
|
+
allowed_updates: ["message", "callback_query"], // opsional: kurangi trafik
|
|
129
|
+
drop_pending_updates: false,
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Menghentikan webhook** — dua opsi:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
await bot.api.methods.deleteWebhook({ drop_pending_updates: true }); // kembali ke polling
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Saat bot Anda berjalan di local Bot API server, `setWebhook` juga menerima `ip_address` untuk menghindari resolusi DNS publik.
|
|
140
|
+
|
|
141
|
+
## 5. Secret token
|
|
142
|
+
|
|
143
|
+
Tanpa secret, siapa pun yang tahu URL bisa mengirim update palsu. Secret memverifikasi bahwa request berasal dari Telegram:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
openssl rand -hex 32
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Simpan sebagai environment variable dan berikan **nilai yang sama persis** ke `setWebhook` (parameter `secret_token`) dan ke handler (opsi `secretToken`). Perbandingan dilakukan constant-time — tidak bisa diTiming-attack. Aturan: 1–256 karakter dari `A-Z a-z 0-9 _ -`.
|
|
150
|
+
|
|
151
|
+
Perhatikan `webhookReply` **tidak** terkait secret — mode itu memilih *bagaimana* respons API dikirim, bukan siapa pengirimnya.
|
|
152
|
+
|
|
153
|
+
## 6. Mode `webhookReply`
|
|
154
|
+
|
|
155
|
+
Telegram mengizinkan bot menjawab satu panggilan API langsung di body respons webhook. Mengaktifkan mode ini menghilangkan satu round-trip per balasan — sangat berguna di serverless:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const handleUpdate = createWebhookHandler(bot, { webhookReply: true });
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Hanya **satu** panggilan API per update yang mendapat manfaat ini — panggilan pertama yang selesai menang; sisanya dikirim sebagai request HTTP normal. Ketika body respons sudah dipakai, handler mengembalikan `{}` (Telegram tetap menganggapnya sukses).
|
|
162
|
+
|
|
163
|
+
Telegraf menyebutnya `telegram.webhookReply`; konsep dan default-nya sama persi di telebibz.
|
|
164
|
+
|
|
165
|
+
## 7. Local development dengan tunnel
|
|
166
|
+
|
|
167
|
+
Telegram hanya mengirim ke URL **publik HTTPS**. Saat development, arahkan URL publik ke localhost:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
# cloudflared (tanpa akun)
|
|
171
|
+
cloudflared tunnel --url http://localhost:3000
|
|
172
|
+
|
|
173
|
+
# atau ngrok
|
|
174
|
+
ngrok http 3000
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Lalu daftarkan URL yang dihasilkan:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
TOKEN="…"
|
|
181
|
+
URL="https://random-words.loca.lt" # dari output tunnel
|
|
182
|
+
SECRET="…"
|
|
183
|
+
curl "https://api.telegram.org/bot$TOKEN/setWebhook" \
|
|
184
|
+
-d "url=$URL/telegram" -d "secret_token=$SECRET"
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Lepas webhook setelah selesai (`deleteWebhook`) agar `bot.start()` berfungsi kembali.
|
|
188
|
+
|
|
189
|
+
## 8. Checklist produksi
|
|
190
|
+
|
|
191
|
+
- [ ] HTTPS publik + sertifikat valid (Telegram menolak self-signed)
|
|
192
|
+
- [ ] `secret_token` ter-set dan cocok di kedua sisi
|
|
193
|
+
- [ ] `max_connections` disetel (default 40)
|
|
194
|
+
- [ ] Body parser limit ≥ 1 MB (`express.json({ limit: "1mb" })` dkk.)
|
|
195
|
+
- [ ] Timeout upstream > `handlerTimeout` (agar `UpdateTimeoutError` sempat mengambil alih, bukan 504 load balancer)
|
|
196
|
+
- [ ] `drop_pending_updates` dipertimbangkan saat redeploy
|
|
197
|
+
- [ ] Error ter-observasi: `bot.catch()` + `update:error`
|
|
198
|
+
- [ ] Graceful shutdown: `bot.stop()` sebelum exit
|
|
199
|
+
|
|
200
|
+
## 9. Troubleshooting
|
|
201
|
+
|
|
202
|
+
| Gejala | Penyebab | Perbaikan |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| Telegram selalu timeout (baris `getUpdates` kosong) | Webhook aktif — Telegram mengabaikan polling | `deleteWebhook` atau gunakan handler |
|
|
205
|
+
| 401 di setiap request | Secret handler ≠ `secret_token` yang terdaftar | Samakan nilainya di `setWebhook` dan handler |
|
|
206
|
+
| 413 Payload Too Large | Body melebihi `maxBodyBytes` (default 1 MB) | Naikkan `maxBodyBytes` + limit body parser |
|
|
207
|
+
| 502 dari proxy | Webhook mengirim `content-type: application/json` — proxy menolak | Hapus rewrite content-type; handler menerima JSON |
|
|
208
|
+
| `409 Conflict` saat `getUpdates` | Webhook masih terdaftar | `deleteWebhook` dulu |
|
|
209
|
+
| Update diterima lalu menggantung | Handler menunggu network call yang lambat | Turunkan `handlerTimeout`; pastikan observabilitas via `update:error` |
|
|
210
|
+
| Serverless: jawaban tidak pernah sampai | Terlalu banyak await di satu handler | Aktifkan `webhookReply` agar panggilan pertama menumpang respons |
|
|
211
|
+
|
|
212
|
+
English: [WEBHOOK.md](WEBHOOK.md) · 简体中文: [WEBHOOK.zh-CN.md](WEBHOOK.zh-CN.md)
|
package/docs/WEBHOOK.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Webhook deployment guide (English)
|
|
2
|
+
|
|
3
|
+
Everything needed to run telebibz behind a webhook: choosing polling vs webhook, the four framework integrations, secret tokens, registering the webhook, webhook replies, and local development tunnels.
|
|
4
|
+
|
|
5
|
+
## Contents
|
|
6
|
+
|
|
7
|
+
1. [Polling or webhook?](#1-polling-or-webhook)
|
|
8
|
+
2. [The Web-standard handler](#2-the-web-standard-handler)
|
|
9
|
+
3. [Express, Koa, Fastify, and Node http](#3-express-koa-fastify-and-node-http)
|
|
10
|
+
4. [Registering the webhook](#4-registering-the-webhook)
|
|
11
|
+
5. [Secret tokens](#5-secret-tokens)
|
|
12
|
+
6. [Webhook replies (Telegraf-style)](#6-webhook-replies-telegraf-style)
|
|
13
|
+
7. [Local development with a tunnel](#7-local-development-with-a-tunnel)
|
|
14
|
+
8. [Production checklist](#8-production-checklist)
|
|
15
|
+
9. [Troubleshooting](#9-troubleshooting)
|
|
16
|
+
|
|
17
|
+
## 1. Polling or webhook?
|
|
18
|
+
|
|
19
|
+
| | Long polling (`bot.start()`) | Webhook |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Setup | zero | needs HTTPS endpoint |
|
|
22
|
+
| Works behind NAT/laptop | ✅ | needs tunnel |
|
|
23
|
+
| Best for | development, small bots | production, serverless, high volume |
|
|
24
|
+
| Update delivery | bot pulls | Telegram pushes |
|
|
25
|
+
|
|
26
|
+
Both share the exact same update pipeline (parallel across chats, ordered per chat). Switch freely — handlers do not change.
|
|
27
|
+
|
|
28
|
+
## 2. The Web-standard handler
|
|
29
|
+
|
|
30
|
+
`createWebhookHandler()` takes a Web `Request` and returns a `Response` — it works on Node 22 (via `node:http` bridging below), Bun, Deno, and edge runtimes:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { Bot, createWebhookHandler } from "@xbibzlibrary/telebibz";
|
|
34
|
+
|
|
35
|
+
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
|
|
36
|
+
bot.on("message", async (ctx) => { await ctx.reply("hello"); });
|
|
37
|
+
|
|
38
|
+
export const handler = createWebhookHandler(bot, {
|
|
39
|
+
secretToken: process.env.TELEGRAM_WEBHOOK_SECRET, // verifies X-Telegram-Bot-Api-Secret-Token
|
|
40
|
+
maxBodyBytes: 1_048_576, // reject bodies over 1 MB (default)
|
|
41
|
+
webhookReply: false, // see section 6
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The handler verifies, in order: HTTP method and path, secret token header, body size, JSON parsing, and update shape — answering each failure with the right status code before your handlers ever run.
|
|
46
|
+
|
|
47
|
+
## 3. Express, Koa, Fastify, and Node http
|
|
48
|
+
|
|
49
|
+
`webhookCallback()` adapts the handler to each framework's request/response style:
|
|
50
|
+
|
|
51
|
+
**Express**
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import express from "express";
|
|
55
|
+
import { Bot, webhookCallback } from "@xbibzlibrary/telebibz";
|
|
56
|
+
|
|
57
|
+
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
|
|
58
|
+
bot.on("message", async (ctx) => { await ctx.reply("hello"); });
|
|
59
|
+
|
|
60
|
+
const app = express();
|
|
61
|
+
app.use(express.json({ limit: "1mb" }));
|
|
62
|
+
app.post("/telegram", webhookCallback(bot, "express", { secretToken: process.env.TELEGRAM_WEBHOOK_SECRET }));
|
|
63
|
+
app.get("/healthz", (_req, res) => res.json({ ok: true }));
|
|
64
|
+
|
|
65
|
+
app.listen(3000);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**Node http (no framework)**
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { createServer } from "node:http";
|
|
72
|
+
import { webhookCallback } from "@xbibzlibrary/telebibz";
|
|
73
|
+
|
|
74
|
+
const callback = webhookCallback(bot, "http", { secretToken: process.env.TELEGRAM_WEBHOOK_SECRET });
|
|
75
|
+
createServer((req, res) => {
|
|
76
|
+
if (req.method === "POST" && req.url === "/telegram") return void callback(req, res);
|
|
77
|
+
res.writeHead(404).end();
|
|
78
|
+
}).listen(3000);
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Fastify**
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import Fastify from "fastify";
|
|
85
|
+
import { webhookCallback } from "@xbibzlibrary/telebibz";
|
|
86
|
+
|
|
87
|
+
const fastify = Fastify({ logger: true });
|
|
88
|
+
fastify.post("/telegram", (req, reply) => webhookCallback(bot, "fastify", { secretToken: process.env.TELEGRAM_WEBHOOK_SECRET })(req, reply));
|
|
89
|
+
await fastify.listen({ port: 3000, host: "0.0.0.0" });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Koa** (with `koa-bodyparser` so `ctx.request.body` is parsed)
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import Koa from "koa";
|
|
96
|
+
import bodyParser from "koa-bodyparser";
|
|
97
|
+
import { webhookCallback } from "@xbibzlibrary/telebibz";
|
|
98
|
+
|
|
99
|
+
const app = new Koa();
|
|
100
|
+
app.use(bodyParser());
|
|
101
|
+
app.use(async (ctx) => {
|
|
102
|
+
if (ctx.method === "POST" && ctx.path === "/telegram") {
|
|
103
|
+
await webhookCallback(bot, "koa", { secretToken: process.env.TELEGRAM_WEBHOOK_SECRET })(ctx.request, ctx);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
ctx.status = 404;
|
|
107
|
+
});
|
|
108
|
+
app.listen(3000);
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Framework ids: `"express" | "http" | "fastify" | "koa"`.
|
|
112
|
+
|
|
113
|
+
## 4. Registering the webhook
|
|
114
|
+
|
|
115
|
+
Point Telegram at your endpoint once (not on every boot):
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
await bot.api.methods.setWebhook({
|
|
119
|
+
url: "https://bot.example.com/telegram",
|
|
120
|
+
secret_token: process.env.TELEGRAM_WEBHOOK_SECRET,
|
|
121
|
+
max_connections: 40, // 1–100, default 40
|
|
122
|
+
drop_pending_updates: true, // optional: discard updates queued while down
|
|
123
|
+
allowed_updates: ["message", "callback_query"],
|
|
124
|
+
});
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Check registration and tear it down:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const info = await bot.api.methods.getWebhookInfo();
|
|
131
|
+
await bot.api.methods.deleteWebhook({ drop_pending_updates: false });
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
A small CLI-style script makes this repeatable:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
// scripts/register-webhook.ts — run with: npx tsx scripts/register-webhook.ts
|
|
138
|
+
import { Bot } from "@xbibzlibrary/telebibz";
|
|
139
|
+
const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!);
|
|
140
|
+
await bot.api.methods.setWebhook({
|
|
141
|
+
url: process.env.WEBHOOK_URL!,
|
|
142
|
+
secret_token: process.env.TELEGRAM_WEBHOOK_SECRET,
|
|
143
|
+
max_connections: 40,
|
|
144
|
+
});
|
|
145
|
+
console.log("webhook registered:", process.env.WEBHOOK_URL);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## 5. Secret tokens
|
|
149
|
+
|
|
150
|
+
Always set a secret token. Telegram then sends it in the `X-Telegram-Bot-Api-Secret-Token` header on every update; the handler rejects anything that does not match with `401 Unauthorized`. Generate one with `openssl rand -hex 32`. Two rules:
|
|
151
|
+
|
|
152
|
+
- Between 1 and 256 characters of `A-Z, a-z, 0-9, _` and `-`.
|
|
153
|
+
- Pass the **same value** to `setWebhook` (`secret_token`) and to `createWebhookHandler`/`webhookCallback` (`secretToken`).
|
|
154
|
+
|
|
155
|
+
## 6. Webhook replies (Telegraf-style)
|
|
156
|
+
|
|
157
|
+
With `webhookReply: true`, the **first** API call while handling an update is answered through the webhook HTTP response itself — Telegram executes the method for you and you save one round trip:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
const handler = createWebhookHandler(bot, { webhookReply: true });
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- Only the first call is claimed; later calls go through the transport as usual.
|
|
164
|
+
- The claimed call resolves with `true` (Telegram never sends the method result back through the webhook response).
|
|
165
|
+
- The lazy `getMe` initialization never claims the slot.
|
|
166
|
+
- Per-update override: `bot.handleUpdate(update, { webhookReply: sink })` for fully custom servers.
|
|
167
|
+
|
|
168
|
+
Most bots should keep it off — the default reply-then-200 flow is simpler to reason about, and the transport's connection reuse already keeps latency low.
|
|
169
|
+
|
|
170
|
+
## 7. Local development with a tunnel
|
|
171
|
+
|
|
172
|
+
Telegram must reach your endpoint over HTTPS. For local development, expose your port through a tunnel and register the tunnel URL:
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
# Option A: cloudflared (no account, no install to project)
|
|
176
|
+
cloudflared tunnel --url http://localhost:3000
|
|
177
|
+
# → https://random-name.trycloudflare.com
|
|
178
|
+
|
|
179
|
+
# Option B: ngrok
|
|
180
|
+
ngrok http 3000
|
|
181
|
+
# → https://random-name.ngrok-free.app
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Then:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
WEBHOOK_URL=https://random-name.trycloudflare.com npx tsx scripts/register-webhook.ts
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Tunnel URLs change on restart — re-register after each restart, or keep using polling during development and switch to webhooks only in staging/production (the handler code is identical).
|
|
191
|
+
|
|
192
|
+
## 8. Production checklist
|
|
193
|
+
|
|
194
|
+
- [ ] HTTPS endpoint with a valid certificate (Telegram rejects self-signed certs unless you upload `certificate`)
|
|
195
|
+
- [ ] Secret token set on both `setWebhook` and the handler
|
|
196
|
+
- [ ] `max_connections` tuned (default 40; range 1–100)
|
|
197
|
+
- [ ] Health endpoint (`/healthz`) for your load balancer
|
|
198
|
+
- [ ] Graceful shutdown: `process.on("SIGTERM", () => bot.stop())` — drains in-flight handlers before plugins are disposed
|
|
199
|
+
- [ ] Body limit enforced (the handler rejects oversized bodies, but the framework's own limit should match)
|
|
200
|
+
- [ ] Logging: `logger: { format: "json" }` for structured ingestion
|
|
201
|
+
- [ ] Monitoring: subscribe to `update:error` and `bot:error` events
|
|
202
|
+
- [ ] `getWebhookInfo()` polled by your ops dashboard (watch `pending_update_count`)
|
|
203
|
+
|
|
204
|
+
## 9. Troubleshooting
|
|
205
|
+
|
|
206
|
+
| Symptom | Cause | Fix |
|
|
207
|
+
|---|---|---|
|
|
208
|
+
| Telegram never calls the endpoint | Webhook not registered / wrong URL | `getWebhookInfo()` shows the registered URL and the last error |
|
|
209
|
+
| Every update answers 401 | Secret token mismatch | Same value in `setWebhook` and the handler |
|
|
210
|
+
| 404 from Telegram | Wrong path | Register the exact path you serve (`/telegram`) |
|
|
211
|
+
| Updates arrive twice | Both polling and webhook active | `deleteWebhook()` or stop calling `bot.start()` |
|
|
212
|
+
| `ai_response`/ssl errors in `getWebhookInfo` | Invalid certificate | Use a CA-signed cert or upload the self-signed one as `certificate` |
|
|
213
|
+
| Handler never sees large bodies | Framework body limit below Telegram's payload | Raise the framework's JSON limit (e.g. `express.json({ limit: "1mb" })`) |
|
|
214
|
+
|
|
215
|
+
Bahasa Indonesia: [WEBHOOK.id.md](WEBHOOK.id.md) · 简体中文: [WEBHOOK.zh-CN.md](WEBHOOK.zh-CN.md)
|