前言
在上一篇文章中,我们已经完成了 Bot 项目的创建以及一些前期准备工作。本章内容,我将带领大家开始正式业务逻辑代码的编写,并将 Telegram 作为 OSS 对象存储服务,实现基于 Telegram 的图床。
本章需要实现的功能
在正式开始之前,我先介绍下,本章我们将具体实现的功能。
实现一个
/setup
endpoint,当我们请求/setup
时,对我们的 Telegram bot 进行一些初始化设置。具体为:- 为我们的 Telegram Bot 设置 Webhook endpoint
- 为我们的 Telegram Bot 设置一些可使用的命令(command)
- 实现我们的 Webhook endpoint (
/bot
),/bot
将接收来自 Telegram 服务器的消息,并对消息进行处理后,返回不同的响应结果。 - 实现基础 Bot 逻辑,让 Bot 能够响应
/start
,/help
命令,能够监听私聊中的图片以及文档消息,并返回图片以及文档的file_id
。 - 实现一个
/image
endpoint, 当请求这个 endpoint 时,将根据file_id
参数获取存储在 Telegram 服务器中的图片并返回。
看描述是不是很简单,那么让我们动起手来,实现它们吧!
启动项目
回顾下上篇文章的内容,我们的 package.json 文件中有三条 scripts。
npm run dev
: 本地开发时运行项目npm run deploy
: 将项目部署到 Cloudflare Workersnpm run tunnel
: 内网穿透,将通过npm run dev
运行的本地项目暴露到公网上
我们现在需要本地启动项目,所以需要运行 npm run dev
。运行之后,我们将在终端中看到如下信息:
PS E:\Code\img-mom> npm run dev
> dev
> wrangler dev src/index.ts
⛅️ wrangler 3.72.0
-------------------
⎔ Starting local server...
[wrangler:inf] Ready on http://127.0.0.1:8787
╭──────────────────────────────────────────────────────────────────────────────╮
│ [b open a [d open [l turn off local [c] clear [x to │
│ browser, Devtools, mode, console, exit │
╰──────────────────────────────────────────────────────────────────────────────╯
通过浏览器打开 [http://127.0.0.1:8787](http://127.0.0.1:8787)
, 我们将看到 Hello Hono!
接下来,让我们用 VSCode 打开src/index.ts
, 这个文件是我们项目的入口文件,项目代码将从这个文件开始执行。当我们打开这个文件时,你应该会看到如下代码:
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello Hono!')
})
export default app
这个代码是由 Hono 脚手架生成的模板代码。我们需要进行一些小的修改,以方便我们之后的开发。
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello ImgMom!')
})
app.fire()
- 我们将其中的
Hello Hono!
更改为Hello ImgMom!
。 将
export default app
删除,并添加app.fire()
。Cloudflare Workers 有两种模式:ESModule Workers 以及 Service Workers。 Hono 对这两种模式进行了抽象封装,对应的启用方式为:
export default app
: 使用 ESModule Workersapp.fire()
: 使用 Service Workers
- 我们的 Bot 将使用 Service Workers 模式运行
重新运行下npm run dev
, 此时,如果你看到了Hello ImgMom!
这段文字,那么说明我们的项目更改已经生效。
我们接下来再启动下内网穿透服务,以便将我们的本地服务暴露到公网上去。打开新的终端窗口,运行: npm run tunnel
, 如果一切顺利,你将看到如下一些信息:
PS E:\Code\img-mom> npm run tunnel
> tunnel
> cloudflared tunnel --url http://127.0.0.1:8787
2024-09-09T00:19:31Z INF Thank you for trying Cloudflare Tunnel. Doing so, without a Cloudflare account, is a quick way to experiment and try it out. However, be aware that these account-less Tunnels have no uptime guarantee. If you intend to use Tunnels in production you should use a pre-created named tunnel by following: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps
2024-09-09T00:19:31Z INF Requesting new quick Tunnel on trycloudflare.com...
2024-09-09T00:19:35Z INF +--------------------------------------------------------------------------------------------+
2024-09-09T00:19:35Z INF | Your quick Tunnel has been created! Visit it at (it may take some time to be reachable): |
2024-09-09T00:19:35Z INF | **https://pregnant-mentor-reggae-answer.trycloudflare.com** |
2024-09-09T00:19:35Z INF +--------------------------------------------------------------------------------------------+
2024-09-09T00:19:35Z INF cloudflared will not automatically update on Windows systems.
2024-09-09T00:19:35Z INF Generated Connector ID: bcb2533a-ed9d-4fc1-b846-aa86a40d3551
2024-09-09T00:19:35Z INF Initial protocol quic
2024-09-09T00:19:35Z INF ICMP proxy will use 192.168.0.144 as source for IPv4
2024-09-09T00:19:36Z INF ICMP proxy will use fe80::5c48:77fe:77e3:9825 in zone WLAN as source for IPv6
2024-09-09T00:19:36Z INF cloudflared does not support loading the system root certificate pool on Windows. Please use --origin-ca-pool <PATH> to specify the path to the certificate pool
2024-09-09T00:19:36Z INF Starting metrics server on 127.0.0.1:55137/metrics
2024-09-09T00:19:36Z INF Registered tunnel connection connIndex=0 connection=313572e6-d918-42b4-a0ca-d3edf547fa0e event=0 ip=198.18.0.76 location=sjc05 protocol=quic
其中 https://pregnant-mentor-reggae-answer.trycloudflare.com 便是 Cloudflared 提供给我们的临时外网 URL,每次运行npm run tunnel
, URL 都会不一样。
我们打开这个外网 URL,如果也显示 Hello ImgMom!
,那么说明我们的内网穿透服务也已经启动成功了。接下来,便可以正式开始我们实际代码的编写。
创建 Bot 模块并初始化一个 Bot 实例
src/index.ts
文件是项目的入口文件以及 Hono Web 相关的一些逻辑,而我们具体的 Bot 逻辑其实与 Hono Web 无关,所以我们将创建一个 Bot 模块, 将 Bot 相关的具体业务逻辑全部封装到这个模块里。在 TypeScript 项目中,一个文件就是一个模块,所以让我们在 src
目录中新建一个 bot 文件。然后输入如下代码:
import { Bot } from 'grammy/web';
const bot = new Bot(self.TG_BOT_TOKEN)
export default bot;
GrammY 框架为我们封装了一个 Bot 类,所有 Bot 相关的逻辑都封装在这个 Bot 类中,这个类的构造函数接收一个 bot token 参数, 然后创建对应的 bot 实例对象。这里我们使用了 self.TG_BOT_TOKEN
,这是 Cloudflare Workers ( Service Worker 模式) 使用环境变量的方式,这样可以将我们的 bot token 不硬编码在代码中,更加灵活,并且可以脱敏。
如何在我们的项目中使用环境变量
为了让 self.TG_BOT_TOKEN
以及之后我们将使用到的环境变量生效,我们需要对项目进行一些设置:
打开
wrangler.toml
文件,输入如下代码:[vars] TG_BOT_TOKEN = "<我们通过 botfather 获取到的bot token>"
在
[vars]
表下面配置的键值对就是我们项目可使用的环境变量。我们可以在项目代码中通过self.<变量名>
访问具体的环境变量。类型提示可以方便我们代码的编写,也可以让我们的代码更加安全。当我们在
wrangler.toml
文件中配置完我们将使用的环境变量后,并不能让 TypeScript 知道它们的存在。我们还需要手动进行一些类型的定义才能让 TypeScript 感知到它们。让我们新建worker-configuration.d.ts
, 然后输入如下代码:interface Env { TG_BOT_TOKEN: string; } interface ServiceWorkerGlobalScope extends Env { }
我们尝试在我们的项目代码中输入
self.<变量名>
, VSCode 自动给出了所有可使用的环境变量的提示。
实现 /setup
/setup
是一个 GET 路由,当我们向 /setup
发送 GET 请求时,它会为我们的 Telegram Bot 设置 Webhook endpoint 以及一些命令。让我们在 src/index.ts
中添加一些代码来实现这个功能。
在文件头部导入我们所需要的依赖项
import bot from './bot';
在文件最底部输入如下代码
app.get('/setup', async (ctx) => { const host = new URL(ctx.req.url).host; const botUrl = `https://${host}/bot`; await bot.api.setWebhook(botUrl, { secret_token: self.TG_WEBHOOK_SECRET_TOKEN, }); await bot.api.setMyCommands([{ command: '/settings', description: 'Setting up the bot', }]); return ctx.text(`Webhook(${botUrl}) setup successful`); } return ctx.text('401 Unauthorized. Please visit ImgMom docs (https://github.com/beilunyang/img-mom)', 401) });
- Hono 受 Express, Koa 等 NodeJS Web 框架的影响,实现了与它们类似的 API。通过调用
app.get([path], handler|middleware...)
方法,就可以注册一个 GET 请求的路由,当服务接收到对应的 GET 请求时,handler 或者 middleware 就会执行。 - 这里我们的 handler 会获取当前请求的 host, 然后构造出我们完整的 Webhook endpoint URL, 这里我们默认我们的 Webhook endpoint 是
/bot
。 - 之前我们在
src/bot.ts
模块中构造了一个 bot 实例对象,通过这个 bot 对象的bot.api.setWebhook
方法,我们就可以将我们的 Webhook endpoint 告知给 Telegram 服务器。 - 这里我们还提供了一个可选的
secret_token
参数,这个参数主要作用是为了增强 Webhook 调用的安全性,之后 Telegram 调用我们的 Webhook 时,都会带上这个secret_token
。我们在实现我们的 Webhook endpoint 逻辑时,就可以进行访问控制,如果发送给我们的 Webhook endpoint 请求没有携带或者携带了错误的secret_token
, 那就证明这个请求不是来自 Telegram, 可能是有人在恶意调用我们的 Webhook endpoint, 我们可以直接返回错误信息。 - 通过调用
bot.api.setMyCommands
方法,可以向 Telegram 注册命令,这里我们注册一个/settings
命令,用来在之后对我们的 Bot 进行一些设置。 ctx.text
是 Hono 提供的 API,它会构造一个包含文本数据的 Response, 当 return Response 时,就会结束整个请求的处理流程并向前端返回响应结果。
- Hono 受 Express, Koa 等 NodeJS Web 框架的影响,实现了与它们类似的 API。通过调用
在
wrangler.toml
以及worker-configuration.d.ts
中配置我们的TG_WEBHOOK_SECRET_TOKEN
环境变量[vars] TG_BOT_TOKEN = "my-variable" TG_WEBHOOK_SECRET_TOKEN = 'my-secret-token'
interface Env { TG_BOT_TOKEN: string; TG_WEBHOOK_SECRET_TOKEN: string } interface ServiceWorkerGlobalScope extends Env { }
让我们测试下我们的实现,看看有没有异常,是否符合我们的预期:
- 打开浏览器,访问
https://pregnant-mentor-reggae-answer.trycloudflare.com/setup
。此时浏览器应该显示如下信息:Webhook(https://pregnant-mentor-reggae-answer.trycloudflare.com/bot) setup successful
在 Telegram 中打开我们的 Bot,我们的 Bot 应该会多一个 Menu 按钮,点击 Menu 按钮,会显示我们刚刚通过代码注册的
/settings
命令
OKay. 显然我这边代码运行是符合预期的。成功将我们的 Webhook endpoint 注册到了 Telegram。成功为 Bot 设置了一个/settings
命令。
下一步,让我们实现下我们的 Webhook endpoint /bot
实现 /bot
Telegram 会将 Message 通过 POST 请求发送给我们注册的 Webhook,所以我们/bot
需要是一个 POST 路由,让我们在src/index.ts
文件中添加如下代码:
import { webhookCallback } from 'grammy/web';
app.post('/bot', async (ctx, next) => {
self.host = new URL(ctx.req.url).host;
return next();
}, webhookCallback(bot, 'hono', {
secretToken: self.TG_WEBHOOK_SECRET_TOKEN
}));
- 通过
app.post([path], handler|middleware...)
方法可以注册一个POST
路由 - 通过
self.host = new URL(ctx.req.url).host
, 将当前请求的 host 添加到全局变量中,可以方便之后获取 host 的值 return next()
, 执行下一个 middleware 中的代码。更多关于 Hono middleware 的内容,可以查询 Hono 官方文档 https://hono.dev/docs/guides/middlewarewebhookCallback
是 GrammY 提供的一个函数, 它会为不同的 Web 框架创建中间件,其中就包含 Hono。由于之前通过bot.api.setWebhook
方法注册Webhook
时,我们设置了secretToken
,每次 Telegram 发送请求到 Webhook 时都会携带设置的secretToken
。我们可以通过webhookCallback
函数实现对这个secretToken
正确性的校验,只需要在第三个参数中设置secretToken
,webhookCallback
函数内部会用设置的secretToken
与接收到的 Telegram 请求头中的secretToken
进行对比,如果不一致,则会报错。
由于我们新增了一个全局变量 self.host
, 为了让 TypeScript 不类型报错,还需要在worker-configuration.d.ts
中添加这个全局变量及其类型信息:
interface ServiceWorkerGlobalScope extends Env {
host: string;
}
至此, /bot
已经实现完毕,我们已经成功将我们的 GrammY Bot 实例集成到了 Hono Web 服务上,Hono Web 服务将监听到来自 Telegram 发送过来的 Webhook 请求,并将消息内容传递给 Grammy Bot 实例。
下一步,我们将实现我们 Telegram Bot 的灵魂,Bot 实例的具体业务逻辑。
实现 Bot 业务逻辑
在之前,我们已经创建了 src/bot.ts
文件,我们继续往 /src/bot.ts
文件添加如下代码:
bot.use((ctx, next) => {
console.log(JSON.stringify(ctx.update, null, 2));
return next();
});
bot.command('start', (ctx) => ctx.reply('Welcome to use ImgMom'));
bot.command('help', async (ctx) => {
const commands = await ctx.api.getMyCommands();
const info = commands.reduce((acc, val) => `${acc}/${val.command} - ${val.description}\n`, '');
return ctx.reply(info);
});
bot.on(['message:photo', 'message:document'], async (ctx) => {
const file = await ctx.getFile();
const tgImgUrl = `https://${self.host}/img/${file.file_id}`;
return ctx.reply(
`Successfully uploaded image!\nTelegram:\n${tgImgUrl}`
);
});
bot.use(middleware)
: 添加一个中间件,在这里我们将ctx.update
的内容打印出来,方便我们调试。[wrangler:inf] POST /bot 200 OK (9870ms) { "update_id": 8787067, "message": { "message_id": 4, "from": { "id": 361756774, "is_bot": false, "first_name": "伊卡洛斯", "username": "beilunyang", "language_code": "zh-hans" }, "chat": { "id": 361756774, "first_name": "伊卡洛斯", "username": "beilunyang", "type": "private" }, "date": 1728791059, "text": "/help", "entities": [ { "offset": 0, "length": 5, "type": "bot_command" } ] }
bot.command('start', middleware)
: 监听 start 命令,当用户输入/start
命令时,将触发对应的中间件,在这里我们通过ctx.reply
方法,回复 Welcome to use ImgMom。bot.command('help', middleware)
: 监听 help 命令,当用户输入/help
命令时,将通过ctx.api.getMyComands
方法,获取当前 bot 的所有可用命令,然后将所有命令名以及命令描述,回复给用户。bot.on(['message:photo', 'message:document'], middleware)
: 监听 photo 以及 document 消息,当用户向 Bot 发送 photo 以及 document 时,将通过ctx.getFile
方法获取到用户发送过来的 photo 以及 document,即 file 对象,每个 file 对象都有一个唯一的 ID (file_id), 通过 file_id ,我们就可以通过 Telegram API 重新获取到对应的 File。在这里我们将 file_id 作为我们图片外链的一部分,然后回复给用户。
至此,我们 Bot 的逻辑也就基本完成了。我们向 Telegram Bot 发送一张图片,Bot 返回图片对应的 图片外链给我们,但是我们访问这个外链时,会返回 404 Not Found
,因为我们还没有实现这个外链对应的逻辑,即 /img
,下一步,我们将实现它。
实现 /img
我们切换回 src/index.ts
, 添加/img
路由以及对应的逻辑代码:
import { fileTypeFromBuffer } from 'file-type';
app.get('/img/:fileId', async (ctx) => {
const fileId = ctx.req.param('fileId');
const file = await bot.api.getFile(fileId)
const res = await fetch(`https://api.telegram.org/file/bot${self.TG_BOT_TOKEN}/${file.file_path}`);
if (!res.ok) {
return ctx.text('404 Not Found. Please visit ImgMom docs (https://github.com/beilunyang/img-mom)', 404);
}
const bf = await res.arrayBuffer()
const fileType = await fileTypeFromBuffer(bf)
return ctx.body(bf, 200, {
'Content-Type': fileType?.mime ?? '',
});
});
我们需要知道 file 的 mime type, 设置正确的
Content-Type
, 以便浏览器或其它用户代理能正确处理我们的外链。这里我们通过file-type
包来获取 mime type。- 运行
npm i file-type
安装file-type
包
- 运行
app.get('/img/:fileId', middleware)
: 注册我们的/img
路由,:fileId
是 hono 的路由参数匹配语法,当用户访问/img/123
路由时,123
将自动赋值给 fileId , 通过ctx.req.param('fileId')
方法,即可取到 fileId 的值。- 当获取到 fileId 后,通过调用
bot.api.getFile
方法,可以获取到 fileId 对应的 file 对象。file 对象有一个 file_path 属性,通过 file_path 以及我们之前获取的TG_BOT_TOKEN
, 即可通过请求 Telegram API 获取到存储在 Telegram 服务器中的文件。 - 由于是网络请求,存在一定的失败可能,我们可以判断下请求是否成功,如果失败,则返回错误信息,这里为了简单,统一返回文案:404 Not Found……
- 如果请求成功,我们可以通过调用
res.arrayBuffer
方法,获取文件的 Buffer 二进制数据。 file-type
包提供了一个fileTypeFromBuffer
函数,将我们获取的 Buffer 数据作为参数传给它,它便会解析我们 Buffer 中 magic number#Magic_numbers_in_files) ,获取到文件的 mime type。- 最后,我们只需通过
ctx.body
方法,将正确的http status code (200)
, 正确的Content-Type
以及文件的 Buffer 数据,发送给用户代理即可。
再重新访问下我们之前获取到的图片外链,此时,你应该就能看到我们发送给 Telegram Bot 的图片的内容了。
结语
本篇是《使用 TypeScript 开发你的第一个 Telegram 机器人》 系列文章的第二篇,正式带大家开始编写我们的 Bot 逻辑,通过本篇文章的学习,你应该了解了如何设置 Webhook;如何将 Grammy Bot 实例集成到 Hono Web 服务上;如何监听命令,监听 photo, document 消息,并执行不同的自定义逻辑;如何通过 file_id 获取存储在 Telegram 中的文件。并且在最后,成功实现了将 Telegram 作为图片的 OSS 对象存储服务,实现了一个免费的图床。
在之后的篇章中,我们将继续扩展我们的 Telegram 图床,让我们的图片不仅仅只存储在 Telegram 中 ,还可以存储到其它 OSS 服务中~
最后,希望各位继续关注,你的评论,收藏,点赞,转发,将是我持续更新的动力~
本文首发于公众号 悖论的技术小屋,欢迎关注。🛠️ 分享大前端,Web3,信息安全方面的知识 ❓ 日常交流答疑?知无不言
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。