使用 TypeScript 开发你的第一个 Telegram 机器人(2)- 将 Telegram 做为 OSS 对象存储服务

前言

在上一篇文章中,我们已经完成了 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 Workers
  • npm run tunnel : 内网穿透,将通过 npm run dev运行的本地项目暴露到公网上

我们现在需要本地启动项目,所以需要运行 npm run dev 。运行之后,我们将在终端中看到如下信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 , 这个文件是我们项目的入口文件,项目代码将从这个文件开始执行。当我们打开这个文件时,你应该会看到如下代码:

1
2
3
4
5
6
7
8
9
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
return c.text('Hello Hono!')
})

export default app

这个代码是由 Hono 脚手架生成的模板代码。我们需要进行一些小的修改,以方便我们之后的开发。

1
2
3
4
5
6
7
8
9
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 Workers
      • app.fire() : 使用 Service Workers
    • 我们的 Bot 将使用 Service Workers 模式运行

重新运行下npm run dev , 此时,如果你看到了Hello ImgMom! 这段文字,那么说明我们的项目更改已经生效。

我们接下来再启动下内网穿透服务,以便将我们的本地服务暴露到公网上去。打开新的终端窗口,运行: npm run tunnel , 如果一切顺利,你将看到如下一些信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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 文件。然后输入如下代码:

1
2
3
4
5
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文件,输入如下代码:

    1
    2
    [vars]
    TG_BOT_TOKEN = "<我们通过 botfather 获取到的bot token>"

    [vars] 表下面配置的键值对就是我们项目可使用的环境变量。我们可以在项目代码中通过

    self.<变量名>访问具体的环境变量。

  • 类型提示可以方便我们代码的编写,也可以让我们的代码更加安全。当我们在 wrangler.toml 文件中配置完我们将使用的环境变量后,并不能让 TypeScript 知道它们的存在。我们还需要手动进行一些类型的定义才能让 TypeScript 感知到它们。让我们新建 worker-configuration.d.ts , 然后输入如下代码:

    1
    2
    3
    4
    5
    6
    interface Env {
    TG_BOT_TOKEN: string;
    }

    interface ServiceWorkerGlobalScope extends Env {
    }

    我们尝试在我们的项目代码中输入 self.<变量名> , VSCode 自动给出了所有可使用的环境变量的提示。

实现 /setup

/setup 是一个 GET 路由,当我们向 /setup 发送 GET 请求时,它会为我们的 Telegram Bot 设置 Webhook endpoint 以及一些命令。让我们在 src/index.ts 中添加一些代码来实现这个功能。

  • 在文件头部导入我们所需要的依赖项

    1
    import bot from './bot';
  • 在文件最底部输入如下代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    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 时,就会结束整个请求的处理流程并向前端返回响应结果。
  • wrangler.toml以及 worker-configuration.d.ts 中配置我们的 TG_WEBHOOK_SECRET_TOKEN环境变量

    1
    2
    3
    [vars]
    TG_BOT_TOKEN = "my-variable"
    TG_WEBHOOK_SECRET_TOKEN = 'my-secret-token'
    1
    2
    3
    4
    5
    6
    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命令

    https://pic.otaku.ren/20241013/AQAEwDEbzTlhVH0.jpg

OKay. 显然我这边代码运行是符合预期的。成功将我们的 Webhook endpoint 注册到了 Telegram。成功为 Bot 设置了一个/settings 命令。

下一步,让我们实现下我们的 Webhook endpoint /bot

实现 /bot

Telegram 会将 Message 通过 POST 请求发送给我们注册的 Webhook,所以我们/bot 需要是一个 POST 路由,让我们在src/index.ts 文件中添加如下代码:

1
import { webhookCallback } from 'grammy/web';
1
2
3
4
5
6
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/middleware
  • webhookCallback 是 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 中添加这个全局变量及其类型信息:

1
2
3
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 文件添加如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 的内容打印出来,方便我们调试。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    [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 的所有可用命令,然后将所有命令名以及命令描述,回复给用户。

    https://pic.otaku.ren/20241013/AQADkMAxG805WVR9.jpg

  • 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 作为我们图片外链的一部分,然后回复给用户。

    https://pic.otaku.ren/20241013/AQADksAxG805WVR9.jpg

至此,我们 Bot 的逻辑也就基本完成了。我们向 Telegram Bot 发送一张图片,Bot 返回图片对应的 图片外链给我们,但是我们访问这个外链时,会返回 404 Not Found ,因为我们还没有实现这个外链对应的逻辑,即 /img ,下一步,我们将实现它。

实现 /img

我们切换回 src/index.ts , 添加/img 路由以及对应的逻辑代码:

1
import { fileTypeFromBuffer } from 'file-type';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 ,获取到文件的 mime type。
  • 最后,我们只需通过ctx.body方法,将正确的 http status code (200), 正确的Content-Type 以及文件的 Buffer 数据,发送给用户代理即可。

再重新访问下我们之前获取到的图片外链,此时,你应该就能看到我们发送给 Telegram Bot 的图片的内容了。

https://pic.otaku.ren/20241013/AQADMr8xG805YVR-.jpg

结语

本篇是《使用 TypeScript 开发你的第一个 Telegram 机器人》 系列文章的第二篇,正式带大家开始编写我们的 Bot 逻辑,通过本篇文章的学习,你应该了解了如何设置 Webhook;如何将 Grammy Bot 实例集成到 Hono Web 服务上;如何监听命令,监听 photo, document 消息,并执行不同的自定义逻辑;如何通过 file_id 获取存储在 Telegram 中的文件。并且在最后,成功实现了将 Telegram 作为图片的 OSS 对象存储服务,实现了一个免费的图床。

在之后的篇章中,我们将继续扩展我们的 Telegram 图床,让我们的图片不仅仅只存储在 Telegram 中 ,还可以存储到其它 OSS 服务中~

最后,希望各位继续关注,你的评论,收藏,点赞,转发,将是我持续更新的动力~

本文首发于公众号 悖论的技术小屋,欢迎关注。🛠️ 分享大前端,Web3,信息安全方面的知识 ❓ 日常交流答疑?知无不言