三年前,我开始了写这一系列,没想到过了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_id
和media_group_id
作为key,那么这个时候就要考虑用锁的机制来让这三个事件变成串行的。一个例子可以看这里
下载媒体文件
对于Telegram而言,无论是文件,还是图片、视频,都有一个file_id
用于唯一代表这个文件。当然,每个类型会有一些细微的不同,比如视频文件会包含分辨率,图片会有预览图,文件会有mime-type。
想要下载文件,可以分为三步
- 拿到对应的对象,比如说想要下载图片,那么就是
message.photo
,想要下载视频,那么就是message.video
- 拿到file_id,参考下对应的数据结构,拿到对应的
file_id
,比如说message.video.file_id
- 进行下载,保存到本地
具体可以参考如下代码,用其他语言也差不多,套路都是一样的:
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只有RUN
、COPY
、ADD
这三个命令会创建新的layer。
其次,以Python为例,一个Python程序的运行环境大概可以这样划分:
- 基础的操作系统
- 某版本的Python解释器及对应的一些系统级使用apt安装的库,比如gcc,git,各种lib比如说libssl
- 对应的Python第三方库,比如说flask,requests
- 代码
那么具体分析一下,就可以发现,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
最后
祝大家圣诞快乐🎅希望你的圣诞老人没有在打电动
Uhhh isn’t he supposed to be busy right now? pic.twitter.com/NeMLXLhObn
— Xbox (@Xbox) December 24, 2020