土豆不好吃

[Telegram bot系列]2: 回顾与Media Group、媒体文件、Next Step、部署维护

文章目录[显示]

三年前,我开始了写这一系列,没想到过了3年,竟然也没几个人去看!连个捧场的都没有

这三年,Telegram也迎来了一些更新,这篇文章当然不会挨个介绍每一个新特性以及相关的用法了,我只会把自己开发过程中提到的地方记录一下。官方文档写的挺清楚了,基本上每一点都介绍的很详细😂

前两篇

[Telegram bot 系列]0:用 Python 写一个 Telegram Bot 简单的回话 bot

[Telegram bot 系列]1:requests 库、Inline Keyboard、Reply Keyboard 与其他细节

回顾:如何开发一个Telegram Bot

开发语言

爱用啥用啥,Python、Go等都没问题的,甚至直接curl都是可以的。毕竟Restful,怎么都可以。

开发方式

想要开发Telegram Bot,从途径上来说有两种方法。

第一种方法是使用Bot API,REST接口,入口在荷兰,使用起来比较容易。

第二种方法是使用MTProto协议,这个会比较困难,需要有一定的异步编程基础。著名的telethon就是干这个的。

大部分情况下使用第一种就足够满足需求了,本系列也会一直以第一种方法为主。

事件

对于Bot而言,最重要的事情就是“事件”,比如说当用户发送一个纯文本的消息,那么对于Bot来说就是一种类型的事件;当用户发送了一个图片,这又是另外一种事件;用户编辑了消息,删除了消息,也同样对应不同的事件。甚至,当用户点击InlineKeyBoardButton,这也是一种特殊事件。

因此,开发一个bot,也就是根据传入的事件不同来进行各种操作,然后给用户反括,比如回复消息,发送文件,整体的流程差不多就是这样的。

Telegram Bot事件类型挺多的,大概一共有这些类型:

// Handler: func(*Message)
OnText = "\atext"
OnPhoto = "\aphoto"
OnAudio = "\aaudio"
OnAnimation = "\aanimation"
OnDocument = "\adocument"
OnSticker = "\asticker"
OnVideo = "\avideo"
OnVoice = "\avoice"
OnVideoNote = "\avideo_note"
OnContact = "\acontact"
OnLocation = "\alocation"
OnVenue = "\avenue"
OnEdited = "\aedited"
OnPinned = "\apinned"
OnChannelPost = "\achan_post"
OnEditedChannelPost = "\achan_edited_post"
OnDice = "\adice"
OnInvoice = "\ainvoice"
OnPayment = "\apayment"

// Will fire when bot is added to a group.
OnAddedToGroup = "\aadded_to_group"

// Group events:
OnUserJoined = "\auser_joined"
OnUserLeft = "\auser_left"
OnNewGroupTitle = "\anew_chat_title"
OnNewGroupPhoto = "\anew_chat_photo"
OnGroupPhotoDeleted = "\achat_photo_del"

// Migration happens when group switches to
// a supergroup. You might want to update
// your internal references to this chat
// upon switching as its ID will change.
//
// Handler: func(from, to int64)
OnMigration = "\amigration"

// Will fire on callback requests.
//
// Handler: func(*Callback)
OnCallback = "\acallback"

// Will fire on incoming inline queries.
//
// Handler: func(*Query)
OnQuery = "\aquery"

// Will fire on chosen inline results.
//
// Handler: func(*ChosenInlineResult)
OnChosenInlineResult = "\achosen_inline_result"

// Will fire on ShippingQuery.
//
// Handler: func(*ShippingQuery)
OnShipping = "\ashipping_query"

// Will fire on PreCheckoutQuery.
//
// Handler: func(*PreCheckoutQuery)
OnCheckout = "\apre_checkout_query"

// Will fire on Poll.
//
// Handler: func(*Poll)
OnPoll = "\apoll"

// Will fire on PollAnswer.
//
// Handler: func(*PollAnswer)
OnPollAnswer = "\apoll_answer"

看到名字就知道是什么意思,就不说了。

运行方式

要么使用polling方式,定期轮询API Server,要么设置webhook,发生事件时Telegram会向设置的地址发送POST请求,请求的BODY里会有对应的数据结构。

Media Group

Telegram目前已经支持将图片、文件、视频等作为一组发送,比如说我发送了3张照片为一组,在这种情况下,Bot 所收到的事件就是三个onPhoto,然后这三个事件有一个共同的字段media_group_id,至于caption落在第几个事件里?不知道,遍历完就知道了。

划重点:这三个事件几乎是同时到达的。所以有些需求,比如说提取出这组照片里的file_id和caption,最简单的办法就是共享一个全局的字典,以user_idmedia_group_id作为key,那么这个时候就要考虑用锁的机制来让这三个事件变成串行的。一个例子可以看这里

下载媒体文件

对于Telegram而言,无论是文件,还是图片、视频,都有一个file_id用于唯一代表这个文件。当然,每个类型会有一些细微的不同,比如视频文件会包含分辨率,图片会有预览图,文件会有mime-type。

想要下载文件,可以分为三步

  1. 拿到对应的对象,比如说想要下载图片,那么就是message.photo,想要下载视频,那么就是message.video
  2. 拿到file_id,参考下对应的数据结构,拿到对应的file_id,比如说message.video.file_id
  3. 进行下载,保存到本地

具体可以参考如下代码,用其他语言也差不多,套路都是一样的:

def download_file_from_id(file_id):
    file_info = bot.get_file(file_id)
    content = bot.download_file(file_info.file_path)

    temp = tempfile.NamedTemporaryFile(delete=False)
    temp.write(content)
    return temp

def get_file_id(message) -> str:
    if message.photo:
        object_type = "photo"
    elif message.video:
        object_type = "video"
    elif message.document:
        object_type = "document"
    else:
        object_type = ""

    try:
        file_id = getattr(message, object_type)[-1].file_id
    except Exception:
        file_id = getattr(message, object_type).file_id
    return file_id

需要注意一点,message.photo的结果是一个list,第一个元素是预览图,最后一个元素是正常大小(720P)的图片

另外更需要注意的一点:Telegram对于Bot可以上传、下载的文件、图片等大小有限制,具体可以查文档找找,所以如果用户发送了一个2G的文件,bot这边很有可能是无法下载的……但是Bot可以转发啊!要处理这种情况,只能用client api了,bot收到这种类型的文件,转发给一个正常用户,正常用户处理完,将结果发给bot,bot再转发给初始用户。

这种曲线救国的方式,如果确保整个链条是完整的,可以参考NCMBot的代码。一定要引起注意的是,有些用户的隐私设置中不允许任何人查看转发信息,因此不可以依赖forwarded_message这个字段!bot一定要想办法自己记录下来某条消息(文件)的对应的初始用户是谁。

Next Step

有些时候,我们可能需要一个wizard性质的一步一步的向导,简单的话,类似下图这种效果:

BotFather也是这样设计的。

这个时候就需要一种“Next Step”的概念,要一步一步来才可以。如果使用pytelegrambotapi的话,那么就很简单了,直接使用bot.register_next_step_handler就可以了。示例代码如下:

@bot.message_handler(commands=['sign_in'])
def sign_in_handler(message):
    bot.send_message(message.chat.id, "next step", parse_mode='markdown')
    bot.register_next_step_handler(message, next_step_add_auth)

def next_step_add_auth(message):
    bot.send_chat_action(message.chat.id, 'typing')
    bot.send_message(message.chat.id, "this step", parse_mode='markdown')

如果wrapper不支持next step,那就只能自己设计一个状态机了。需要设计一个临时的数据结构,至少要表明用户的下一步操作应该是什么,然后拦截对应的事件,做dispatch。

具体可以看这里,太长了就不贴了

部署维护

之前开发的bot,每次上线都要写一个systemd service文件,然后systemctl daemon-reload,很麻烦,迁移到其他机器也很麻烦。后来渐渐开始用docker,就很方便了。直接把一堆bot都丢到一个docker-compose.yml中,一个up就可以了。迁移服务器时只要rsync走,重新up就可以。具体可以看《我是如何优雅的开发、部署并管理多个 Telegram Bot 的》

Dockerfile与docker image

之前,不知道在哪看到的文章,说docker image的layer有限制,不能超过127层(还是多少层的),层越少越好。因此很多人可能都有一个疯狂一行式的习惯,把所有操作全都一个RUN解决了,不停的用&&和\。

一个非常典型的例子大概如下:

FROM python:alpine

RUN apk update \
&& apk add git ffmpeg flac --no-cache \
&& git clone https://github.com/tgbot-collection/ExpressBot \
&& pip3 install --no-cache-dir -r /ExpressBot/requirements.txt

WORKDIR /ExpressBot

CMD ["python3", "expressbot/main.py"]

现在想想,这™️的是谁说的,出来让我暴打一顿,可真是害人不浅呐!

现在docker engine的layer是否有层数限制我不了解,但是层越少越好,这点要怎么理解?

比如说,有人看到官网文档Best practices for writing Dockerfiles里这么说的:

Minimize the number of layers

In older versions of Docker, it was important that you minimized the number卧槽这™️什么玩意这也太长了吧看不下去了啊,能不能来个中文,啊好困。行吧,反正有一个minimize,还说 important,那直接无条件一层达人不就得了。

愚以为,宫中之事 技术学习中是非常忌讳这种囫囵吞枣的阅读方式的。

如何正确的使用RUN

为什么要存在layer,一个最直观的原因就是,大家可以共用同样的layer,这样pull起来也快很多啊。另外还有一个原因就是,如果某一个layer已经有了,那么在build image的时候,直接拿来用不就好了?毕竟安装东西很慢啊,有些可能还是要去编译的,就更慢了。

写Dockerfile不要怕多创建layer,不要总是一层达人(这里使用了夸张的修辞手法,FROM也是要一层的,这里指的是一个RUN指令)

怎么来了解这个事情呢?

首先要知道,docker只有RUNCOPYADD这三个命令会创建新的layer。

其次,以Python为例,一个Python程序的运行环境大概可以这样划分:

  1. 基础的操作系统
  2. 某版本的Python解释器及对应的一些系统级使用apt安装的库,比如gcc,git,各种lib比如说libssl
  3. 对应的Python第三方库,比如说flask,requests
  4. 代码

那么具体分析一下,就可以发现,1和2是基本不会变的,3变的概率比较大,但是不会很频繁,4是经常变化的。那么我们的Dockerfile就应该同样保证1和2作为一层,3作为1层,4作为一层。

其次要知道,docker在build的时候,默认会使用缓存的,但是需要注意的是,如果低层的缓存失效了,那么后面的全部都要重新跑。

也就是说,在上面的例子中,如果我只改代码,那么对应的是4,那么123都会使用缓存,4重新跑一次。但是如果我修改了2,那么234都要重新跑,即使我没有修改3和4。哦对了,2020年了,没有人会傻到用文件的访问时间、修改时间来判断你有没有改这个文件。docker使用的是checksum(SHA256)啦。

总结一下,应该把同样变化频率的命令放在一起作为一条RUN,然后把不常变化的放在上面,经常变化的放在下面,这样才是最优解。

那么对应如上Dockerfile,就可以这样优化一下:

FROM python:alpine

# 安装个啥git,docker build的时候context是干啥的?dockerhub在build的时候第一件事情就是clone你的repo
RUN apk update && apk add --no-cache ffmpeg flac # 加上--no-cache

# 拷贝我们的requirements.txt到临时目录,如果是go,可以拷贝go.mod
COPY requirements.txt /tmp/requirements.txt

# 安装 Python的库,然后删掉它,如果是go,可以用go mod download
# 如果要安装的东西很少,连requirements.txt也没有,那么直接pip install a b c d也可以
RUN pip install --no-cache-dir -r /tmp/requirements.txt && rm /tmp/requirements.txt

# 把当前目录拷贝到容器里的/ExpressBot 隐藏目录如.git 也会被拷贝
COPY . /ExpressBot

WORKDIR /ExpressBot

CMD ["python3", "expressbot/main.py"]

这样就可以用到cache啦

具体改进都写到了注释里了。需要注意的是,.git也会被拷贝,对于一个有很多commit的项目来说……还是整个.dockerignore吧!

这样一波操作下来,确实是多了几层,但是下次我改代码,docker hub就不用全部重新跑了,一个简单的COPY就完事。

呃,当然,build rule也要开启build caching哦

参考文档

Best practices for writing Dockerfiles

最后

祝大家圣诞快乐🎅希望你的圣诞老人没有在打电动


文章版权归原作者所有丨本站默认采用CC-BY-NC-SA 4.0协议进行授权|
转载必须包含本声明,并以超链接形式注明原作者和本文原始地址:
https://dmesg.app/tgbot2.html
退出移动版