1. 前言

上文 Telegram Bot 开发手记:理论篇 简单介绍了 Telegram Bot 开发的基本思路和相关 API。

本文主要内容包括两大部分:

  • 根据上文提到的相关知识,提出几个 Telegram Bot 项目的架构思路,并对思路进行简单的介绍。
  • 记录博主实际开发一个 Telegram Bot 过程中的思考和实现。

2. 架构

2.1 单线程模型

单线程模型可以说是最简单的模型了,优点在于开发简单,缺点也很明显——在Bot处理大量信息,进行IO请求等时会陷入「假死」状态,无法有效利用计算机资源。因此,单线程模型的适用场景主要是输入频率低、对用户的输入进行的数据处理耗时较短等情景。

单线程模型的思路也很简单,无限重复「长轮询获取输入=>处理数据=>输出信息」三个过程即可,用伪代码可以表示为:

loop {
    # 获取用户输入
    getUpdates();

    # 在当前线程处理输入信息
    # 这一步骤执行期间,程序不会再读取用户输入
    processMessage();

    # 将处理结果输出给用户
    sendMessage();
}

这里提供一个单线程模型的,通过 bash 编写的 telegram bot 程序,功能很简单,只有读取用户输入并且输出相同内容的功能,俗称复读机。

可以在 这里 下载到该程序。

#!/bin/bash

# 之前在 BotFather 处申请得到的 API_TOKEN
token="123456:AAGTHISISANEXAMPLEAPITOKEN"

# 一次轮询的最大时间
timeout=60

offset=0

# 输入、处理、输出,三个模块循环执行
while true
do
    # 获取用户输入
    result=`curl -s "https://api.telegram.org/bot${token}/getUpdates?offset=${offset}&timeout=${timeout}"`

    # 获取消息中 update_id 的最大值,然后增大 1 作为 offset 的值
    offset=$[`echo $result | grep -o \"update_id\":\[0-9\]\* | grep -o "[0-9]\+" | tail -1`+1]

    # 消息的数量
    size=`echo $result | grep -o \"update_id\":\[0-9\]\* |  wc -l`

    for ((i=1; i<=$size; i++))
    do
        # 获取第 $i 条消息的发送人
        from=`echo $result | grep -o \"from\"\:\{\"id\":\[0-9\]\* | grep -o "[0-9]\+" | sed -n "${i}p"`

        # 获取第 $i 条消息的内容
        msg=`echo $result | grep -o text\":\".\*\"\} | sed -n "${i}p" | cut -b 8- | cut -d \" -f 1`

        # 将之前获得的消息,发给发送者
        curl -H "Content-Type: application/json" --data "{\"chat_id\":$from,\"text\":\"$msg\"}" "https://api.telegram.org/bot${token}/sendMessage"
    done
done

注1:以上代码仅为原型程序,对输入处理不足,无法处理复杂的输入,请勿用于要求较高的环境。

注2:代码可以在大多数安装了 curl 的 Linux 发行版下运行。

注3:由于 Linux 下的 GNU grep 的用法与 macOS 下的 grep 用法存在差异,代码在 macOS 平台可能需要对 grep 命令稍作修改方能正常运行。

注4:如前文所说,由于 Telegram Bot API 服务器在中国大陆遭到网络封锁,代码可能无法在大陆网络环境下正常运行。

2.2 单线程轮询、多线程处理信息

该模型的思路仍然是创建一个主线程轮询获取用户信息,但是在获取后并不在主线程对用户信息进行处理,而是在一个新的子线程来处理用户输入并向用户输出处理结果。其中子线程可以是不断创建的,也可以线程池的模式。

该模型的优势在于比起单线程模型可以更加有效地利用系统资源,在某个子线程进行耗时的 IO 操作的同时,其他子线程可以利用 CPU 等资源。

该模型用伪代码可以表示如下。

# 主线程

loop {
    # 获取用户输入
    getUpdate();

    # 将消息分发到子线程或线程池
    # 异步方法,将立刻完成,不会阻塞 IO
    # 子线程将完成处理及输出给用户的工作
    deliverToSubthread();
}
# 子线程

# 处理用户输入的数据
# 可以耗时较长,IO阻塞时不会影响其他线程工作
processMessage();

# 将处理结果输出给用户
sendMessage();

2.3 Webhook

Webhook 方式也是一种多线程的模型,基本思路是运行一个 Web 服务器,然后通过 setWebhook 方法将服务器的 url 指定为 bot 的处理地址。

这样在 Bot 收到消息时, Telegram API 服务器将会对指定的处理地址发送一个包含消息信息的 POST 请求,然后由 Web 服务器创建一个新的线程或进程运行开发者编写的程序,处理输入的内容并输出。

在某种程度上,Webhook的方式和上一个模型类似,但是多线程处理的部分交由 Web 服务器或者 Web 框架进行处理,简化了开发难度。

同时,根据该模型开发的程序,通常可以方便地部署在虚拟主机 (Web Hosting) 而非 VPS 上。

注:事实上,前两种模型开发的程序也可以运行在许多虚拟主机或 SaaS 平台上,只需用利用 Cron 定时运行即可。但是由于虚拟主机的 CPU 占用时间限制,很难做到不间断运行,因此往往不会采用这两种模式。

对于 Webhook 方式来说,开发思路与多线程模型中子线程的工作类似,只需要简单的进行一次「获取输入=>处理数据=>输出信息」的步骤即可,伪代码可以表示为:

# 从 POST 请求中获取数据
getMessageFromPostRequest();

# 处理用户输入的数据
# 可以耗时较长,IO阻塞时不会影响其他线程工作
processMessage();

# 将处理结果输出给用户
sendMessage();

可以看到,整个流程与之前的多线程模型中子线程的操作流程如出一辙,这是因为 Web 服务器完成了分发消息给子线程的工作,开发者只需要简单的编写一个单线程的程序即可。

2.3.1 补充说明

根据 Telegram 官方文档的要求,Webhook 的 url 必须是运行在指定的四个端口上的,必须对应一个 IPv4 的地址。

但是如果你拥有独立的公网 IPv6 地址,可以免费通过 Cloudflare 的 CDN 服务,将 v6 地址转换为 v4 地址。

如果你没有独立的公网 IPv4/IPv6 地址,也可以通过 Cloudflare 的其他服务使用 Webhook,但是价格甚至高于较低端的 VPS 产品。

3. 一次开发记录

项目已提交至 Github,欢迎查阅。

Akyuu/telegram-bot

3.1 项目目标及设计

在这里,我打算编写一个进行图片和视频转换的 Bot,主要功能包括两点:

  • 将 Sticker 转换为 png 和 jpg 格式的文件
  • 将 mp4 转换为 gif 格式的文件

经过综合考虑,我准备使用 单线程轮询、多线程处理信息 的模型,这个选择主要是出于如下考虑:

  1. 我准备使用 Python 开发项目,Python 处理 http 请求本来就需要运行一个 Web 服务器框架,与手动创建任务分发框架的开发复杂程度相近,无法发挥 Webhook 的优势。从这一点来看,Webhook 更适合 PHP 语言或可以运行在虚拟主机上的其他脚本语言。
  2. 我准备部署项目的 VPS 并没有搭建 Web 服务器环境,同时也没有申请证书,部署这些服务将会耗费大量时间。
  3. 我准备部署项目的 VPS 没有公网 IPv4 ,使用 Webhook 的方式部署需要配合 CloudFlare 等服务使用。

3.2 项目架构及文件功能介绍

项目地址:https://github.com/Akyuu/telegram-bot

handler\*
server.py
config.py

config.py 是项目的配置文件,包含了 API_TOKEN 和超时时间 TIMEOUT。

server.py 则是我们项目的主体,主要包含两个函数,main 函数的功能是循环使用优秀的第三方网络请求库 requests 调用 getUpdate 方法获得用户信息,然后通过消息分发函数 deliver_message 将消息分发到对应的处理器 Handler 上,交由线程池 concurrent.futures.ThreadPoolExecutor 运行。

handler 文件夹中包含的是对消息进行处理的 Handler 的实现,其中包含一个父类 Handler 对收到的信息进行了初步的处理,其他的 Handler 均继承该类,并对消息作出具体的处理。

3.3 项目开发过程中遇到的坑

在开发过程中,编写 Sticker 处理函数和 Video 处理函数时都碰上了一些棘手的问题。

其中 Sticker 函数的主要问题是将 Sticker 对应的 webp 文件下载到本地并转换为 png 格式后,发现通过 API 的 sendPhoto 方法会导致图片被转换为 jpg 格式,无法保留透明通道。而通过 sendDocument 方法发送的 png 文件虽然可以保留 png 格式,但是无法预览。
我对此的解决方案是同时使用两种方法,让用户自由选择 jpg 和 png 格式的文件。

编写 Video 处理函数时遇上的问题更为棘手,Telegram Bot API 对发送的文件的格式有严格的限制,将 mp4 文件转换为 gif 后,无论通过 sendVideo 还是通过 sendDocument 发送均会被重新转换为 mp4 格式,苦思不得解决方案只好寻求第三方服务的帮助。
我的解决方案是利用公益的图床服务,将 gif 文件利用 API 上传到图床,然后将上传后生成的 url 发送给用户,让用户通过浏览器打开 gif 文件来保存,总算跳出了 Telegram 的窠臼。

3.4 项目部署

参见 Github 上的部署部分

另外部署时发现,直接运行 pip3 install webp 无法正常工作,报错编译失败,需要安装依赖的开发库 ibffi-devlibwebp-dev