2022 Hackathon Online

异步IO与QQ机器人

作者:苏州大学计算机爱好者协会 王嘉睿

如果是常年混迹各大qq群的同学想必都或多或少地见过qq机器人,它们常常具有丰富的功能,并在不同类型的群中发挥着作用:游戏群里的qq机器人可以在对话中查询游戏的相关数据;主播粉丝群里的机器人可以在主播开播时发消息提醒大家;vtuber字幕组群中的qq机器人可以是兼拉取推文、嵌字和发布于一体的“烤推机”;年级群里的qq机器人也可以被辅导员设置用来提醒同学们及时完成青年大学习;此外有些qq机器人还会安装一些小游戏,例如成语接龙等,用于活跃群里的气氛。

本期魔盒挑战旨在通过引导你动手实现一个简易的qq机器人,让你逐步了解异步编程和python的asyncio(异步io)。

1 背景知识

1.1 异步

了解过异步的同学可以跳过本节。

首先,什么是异步?要回答这个问题,我们可以看看这个例子(网上找的小学奥数题):

玲玲想给客人烧水沏茶。洗水壶要2分钟,烧开水要12分钟,买茶叶要5分钟,洗茶杯要1分钟,冲茶叶要1分钟,要让客人尽可能早的喝上茶,你认为怎样安排才最合理?最少需要多少分钟?

答案很简单,先洗水壶 2 分钟,然后用水壶烧开水,同时买茶叶、洗茶杯,共 min(12,5+1)=12 分钟,最后使用烧好的开水和买来的茶叶泡茶,需要 1 分钟。因此最少需要 2+12+1=15 分钟才能让客人喝上茶。

接下来用伪代码复现一下这个安排:

1
2
3
洗水壶() # 2 min
烧开水() # 12 min
冲茶叶() # 1 min

诶?好像不太对,我们怎么让程序在执行A任务的同时完成B任务呢?

有的同学可能会想到多线程,我们再开一个线程让它等水烧开就行:
(CPython的多线程其实是假的多线程,详见全局解释器锁,不过在这里不重要)

1
2
3
4
5
6
7
8
9
import threading

洗水壶() # 2 min
t=threading.Thread(target=烧开水)
t.start()
买茶叶() # 5 min
洗茶杯() # 1 min
t.join() # 12-5-1=6 min
冲茶叶() # 1 min

但是这样就显得有点大材小用了,只是泡个茶居然要用掉两个线程,有没有更好的方法呢?

让我们来回顾一下解题的过程,首先要理清楚五个任务的因果关系:

1
2
3
4
5
洗水壶

烧开水 买茶叶 洗茶杯
└──────┼──────┘
    冲茶叶

然后我们会把题中的任务分成两类:

  • A类:洗水壶、买茶叶、洗茶杯、冲茶叶
  • B类:烧开水

其中A类任务需要占用玲玲的处理时间,因此不能同时进行,例如玲玲不能在洗茶杯的同时出门买茶叶,称其为“同步”的任务。而B类任务不需要占用处理时间,换言之玲玲在烧开水的时候只需要将热水壶装好水插上电就行,剩下的等待时间她可以用来处理其它A类或者B类(比如再烧一壶水)任务,因此可以称其为“异步”的任务。

因为烧开水是异步的,且和买茶叶、洗茶杯之间不存在因果关系,所以我们安排玲玲在烧开水的同时去买茶叶和洗茶杯,节省了6分钟的时间。又因为剩下的任务必须要按因果顺序进行,所以这个就是时间最短的安排了。

而在python中,上面的程序可以使用asyncio改写成这样,其中的函数虽然都是异步函数的形式,但是只有烧开水是真正的异步:

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
29
30
import asyncio # python的异步io库
import time

def f(text1,text2,duration,block=True):
async def wrapped():
print('{:.0f}'.format(time.time()-t0),text1)
if block:
time.sleep(duration) # 同步的等待
else:
await asyncio.sleep(duration) # 异步的等待
print('{:.0f}'.format(time.time()-t0),text2)
return wrapped

洗水壶=f('开始洗水壶','水壶洗好啦',2,True) # 同步
烧开水=f('开始烧开水','水烧开啦',12,False) # 异步
买茶叶=f('开始买茶叶','茶叶买好了',5,True) # 同步
洗茶杯=f('开始洗茶杯','茶杯洗好了',1,True) # 同步
冲茶叶=f('开始冲茶叶','茶冲好了',1,True) # 同步

async def 泡茶():
await 洗水壶() # 洗水壶
await asyncio.gather( # 等待下面三个任务全部完成
烧开水(), # 首先执行烧开水,因为烧开水是异步的,等待的时间内会依次执行下面两个任务
买茶叶(),
洗茶杯(),
)
await 冲茶叶() # 冲茶叶

t0=time.time() # 记录开始时间
asyncio.run(泡茶()) # 运行异步函数的入口

运行之后可以看到类似这样的结果:

1
2
3
4
5
6
7
8
9
10
0 开始洗水壶
2 水壶洗好啦
2 开始烧开水
2 开始买茶叶
7 茶叶买好了
7 开始洗茶杯
8 茶杯洗好了
14 水烧开啦
14 开始冲茶叶
15 茶冲好了

可以看到烧开水的等待时间的确有一部分被玲玲用来买茶叶和洗茶杯了,这样玲玲就可以在一个线程内完成泡茶的任务了。实际程序中的任务模型会比上面的例子复杂许多,可能会有上百个异步的网络请求在同时进行,而python的异步io能帮助我们很好地安排和执行这些任务,同时保持代码的简洁与优雅。

最早在编程语言中大面积使用异步的是JavaScript,关于这个的故事可以参考这篇文章:https://blog.csdn.net/li123128/article/details/80650256 。而python中引入异步io最早是在2015年更新的python3.5,经过几年的打磨和优化,语法逐渐定型成我们今天所见的这个样子。

希望以上的说明能为你理解异步这个概念提供一些帮助。以下是一些关于python的异步IO的参考资料:

1.2 WebSocket

WebSocket是一种网络传输协议,可在单个TCP连接上进行全双工通信,位于OSI模型的应用层。

在我们平时所接触的http/https协议中,所有请求都只能由客户端主动发起,这使得以往要实现从服务端向客户端实时发送数据只能使用轮询的模式,即客户端每隔一段时间向服务端发送http请求,服务端收到请求后返回这段时间内累积的数据。这个轮询方案有几个问题:

其一,传输的损耗很大。假设某实时聊天软件(IM)的客户端每隔5秒向服务器发送一个http的轮询请求,请求和响应的header的大小均为0.5KB,平均情况下每隔15秒就有人给你发一条1KB的消息(大约500个汉字)。这样你每分钟就要花掉1KB*60s/5s+1KB*60s/15s=16KB的流量,一个月下来就是16KB*60*24*30=691.2MB,而其中真正有效的信息只有(1KB/15s)/(1KB/5s+1KB/15s)=25%,无论是用户还是服务提供商都不想承受另外75%的没什么用的流量。

其二,不适合对延迟很敏感的场景。还是上面那个例子,在轮询机制下消息的延迟上限(不计其它网络延迟)与轮询的间隔相等,在上面的例子中你最多要等5秒才能收到朋友发的消息。而在即时对决的网游中为了降低延迟则必须提高轮询的频率,如果画面帧率为60fps,为了让画面保持同步轮询的频率也得大于60Hz,这样会导致巨额的流量(若是按照上面的数据算,相当于每一帧都要花掉至少1KB,20分钟累计1KB*60Hz*60s/min*20min=72MB),即使把header大小降到0(也就是不使用http协议,直接用tcp),高频率的请求依然会给服务器造成很大的压力。

WebSocket则可以很好地避免上述问题。WebSocket是全双工的,换言之ws连接的服务端和客户端都可以主动向对方发送信息,并且发送的消息不像http协议需要耗费一定体积作为header,这样就解决了轮询机制的两个问题。现在ws因为其特性被广泛运用在许多即时性强的应用中,例如实时聊天、直播平台的实时评论/弹幕、游戏的客户端与服务端的通信等等。

项目中使用到的go-cqhttp也提供了http轮询和websocket两种模式,这里我们选择后者是因为后者代码的逻辑更简单。

在python中我们将使用websockets包(package)来和go-cqhttp服务器通信。python中包的安装方法在此不在赘述,建议你为了任务新建一个虚拟环境(参见 venv - 创建虚拟环境),这样提交的时候可以方便一些。你可以从官方文档了解这个包的基本使用方法,在这里我们只要用到它作为ws客户端的用法,因此服务端的部分可以略过不看。

相关资料(感兴趣的同学可以阅读其中的 RFC 6455 了解 WebSocket 协议的技术细节):

2 说明

2.1 结构

这个项目的结构如下图:
项目结构

其中OneBot标准是一个聊天机器人应用接口标准(官网:https://onebot.dev/ ),go-cqhttp是OneBot协议的服务端的golang实现(官网:https://docs.go-cqhttp.org/ ),在我们的项目中它的作用是连接QQ服务器,将其发来的消息解包后再用OneBot标准打包发送给我们的程序,同样我们发送的请求也会被go-cqhttp以QQ服务器可解析的方式重新打包后发送。

2.2 比赛说明

2.2.1 赛程

  • 第一部分 开幕式:6月5日下午
  • 第二部分 Hackathon主体:6月5日 - 6月12日
  • 第三部分 作品展示与评选:6月12日 13:30-17:30

请于作品展示开始前(即 6月12日 13:30 之前)将你所写的程序的源码压缩后以你的小组名命名并发送至 furffy@outlook.com

2.2.2 评分方式

比赛一共有6道题目,各题的分值与评价方式如下:

题号 标题 分值 评价标准
#1 运行 go-cqhttp 服务端 5分 引导任务,不需要展示。
只要完成#2就能得到这5分。
#2 复读机 5分 引导任务,不需要展示。
只要完成#3就能得到这5分。
#3 天气助手 15分 基础分15分
#4 定时任务 20分 基础分10分,评委分10分
#5 小游戏 20分 基础分10分,评委分10分
#6 自由开发 30分 基础分10分,评委分20分

其中,只要你能完成题目的要求就可以拿到基础分,评委分是评委对该题给出的评分的均值。

2.3 注意事项

  1. 不可以使用现成的python机器人框架,例如nonebotnonebot2等,但是你可以在编写代码时参考这些框架的结构与实现。
  2. 虽然我们不会公开发布你上传的代码,但是上传时请务必抹去所有可能会导致你个人隐私泄漏的信息,包括但不限于:在go-cqhttp配置文件中填写的qq号与密码。要对你自己的信息安全负责哦~
  3. qq机器人在群里发消息时有一定概率会被腾讯风控,具体表现为机器人无法正常在群聊中发送消息,这在go-cqhttp的输出中会有体现(风控 != 封号,若是你的机器人被风控了也不必惊慌,大概过几天就好了,但是这并不代表用自己的大号作为qq机器人就完全没有风险)。目前私信是不受风控影响的,因此比赛中的所有问题都只要求在私信的场景下能正常工作。

2.4 参考资料

以下这些python包或许会有用:

还有一些资料:

  1. go-cqhttp api文档
  2. onebot-11 协议
  3. stack overflow - Python async: Waiting for stdin input while doing other stuff
  4. TenApi (一些有用的公共api)

3 题目

3.1 运行 go-cqhttp 服务端

(5分) 参考go-cqhttp的官方文档的快速开始配置,下载、配置并在本地(或者你的VPS上)运行一个go-cqhttp服务端。

注:配置时请使用你的QQ号或者你的小号,servers部分选择“正向ws”模式。

3.1.1 测试程序

以下是一个测试程序,它可以通过ws协议连接go-cqhttp服务器(正向ws),并实时输出它发送的信息,其中地址和<port>要替换为你在本地运行的go-cqhttp服务器的地址和端口,<access-token>替换为你在go-cqhttp的设置文件中设定的值。

1
2
3
4
5
6
7
8
9
import asyncio
import websockets

async def main():
async with websockets.connect("ws://localhost:<port>/?access_token=<access-token>") as websocket:
while 1:
print(await websocket.recv())

asyncio.run(main())

如果配置没问题,程序会首先收到连接成功的信息,长这个样子:

1
{"_post_method":2,"meta_event_type":"lifecycle","post_type":"meta_event","self_id":<当前qq号>,"sub_type":"connect","time":<当前时间戳>}

如果你在cqhttp的设置里打开了心跳,在程序启动后每隔一段时间就能收到来自服务器的heartbeat。如果你试着用别的号给登录机器人的号发送了消息,应该可以在输出中看到go-cqhttp给客户端打包转发的消息。

3.2 复读机

(5分) 以上面的测试程序为基础,参考go-cqhttp的api文档,编写程序实现当某特定的qq号(你或者你的好友,当然要事先联系一下)发来消息时,向该qq号回复相同的消息。

示例对话(其中“我”为该特定的qq号,bot为你编写的机器人):

1
2
3
4
我> 你好
bot> 你好
我> 人类的本质就是复读机
bot> 人类的本质就是复读机

3.3 天气助手

(15分) 以上一题的复读机为基础,删去复读功能,改为当有人向机器人发送天气 <城市名>时,从公共api(例如 2.4 参考资料 中的TenApi)获得该城市的天气并返回。这个功能应该包含在最终提交的代码内。

示例对话(只是示例,bot回复的模板由你来决定):

1
2
3
4
5
6
7
8
9
10
我> 天气 苏州
bot> 苏州今天的天气是:21-29°C 西南风2级 小雨

# 或者
我> 天气预报 苏州
bot>
苏州今天的天气是:21-29°C 西南风2级 小雨
明天星期四:21-31°C 东南风 晴
后天星期五:……
…………

3.4 定时任务

(20分) 利用asynciocreate_task,让机器人可以定时执行任务。这个功能应该包含在最终提交的代码内。

提示:任务循环不应阻塞正常的消息处理,另建议使用aioschedule包简化任务设置的流程。

定时任务由你来决定,例如:

  • 每天晚上8点提醒你去刷leetcode;
  • 工作日12点-16点每隔一个小时发送补水提醒;
  • 每隔五分钟向直播平台的api(这个就需要一些爬虫和抓包的技术了)发送请求获得直播间开播情况。如果某个主播开播,则向你发送开播提醒。

3.5 小游戏

(20分) 以上一题的天气助手为基础,编写一个单人小游戏,要求能做到在私信中与人互动。这个功能应该包含在最终提交的代码内。

如果你想不到什么单人小游戏,可以实现以下样例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
我> 猜数 100
bot> 开始猜数,目标在0-100之间,请输入你的答案
我> 50
bot> 小了
我> 75
bot> 大了
我> 62
bot> 小了
我> 68
bot> 小了
我> 71
bot> 小了
我> 73
bot> 恭喜你在第6次尝试猜对啦!

3.6 自由开发

(30分) 编写代码为机器人添加功能。

注:什么功能都行。可以参考nonebot2 的插件列表以获取灵感。