《基于go语言的QQ机器人提醒打卡功能实现总结》
一、项目背景:
为了实现自动解析图片且@提醒qq群内同学打卡,跟着网上视频学习折腾了qq机器人,具体内容可见《Python + cq-http搭建提醒打卡机器人》
由于qq机器人是本地部署,所以每次为了实现功能,都要给电脑开机且运行两个cmd窗口,有点麻烦。
上次的项目总结中提到了考虑将本功能部署在服务器上,但是手头没有可用的服务器,通过学生认证申请阿里云的服务器也只能用几个月,觉得不够稳定。
想起之前折腾路由器的经历,于是灵光一现考虑能不能把它当作服务器来用,毕竟是一直开机一直联网的设备。(路由器相关内容可见:和路由器打交道的半年、路由器2、搞路由器进展、搞路由器进展2)
这就是本次项目的开端。
二、实现过程
路由器型号:newifi3(cpu=mtk7621a;512MB+32MB,没有写错顺序这一点后面会谈)
2.1 准备工作(网络已有详细的教程,这里不赘述)
2.1.1 go语言环境准备
1、到官网或者镜像下载go的编译器(不知道专业语言是不是叫这个)
https://golang.google.cn/dl/
2、下载windows版本,双击执行安装程序,添加环境变量
3、到vscode中安装相关依赖,测试打印hello world
4、修改go代理
2.1.2 路由器准备
步骤1-3目的:让路由器上网(我这边的环境只能用手机开热点),
1、进入路由器管理界面
2、点击搜索,找到手机热点,输入密码
3、ping百度测试路由器
4、opkg更新,安装nohup
2.2 修改go env,交叉编译go-cqhttp并测试
1、在终端中输入代码Go env -w GOOS=linux GOARCH=mipsle GOMIPS=softfloat, 修改go env
输入go env检查是否修改成功
修改成功
2、go-cqhttp源码的文件夹下,输入代码:go build -o cqhttp
等待一会儿在当前目录下会生成二进制文件cqhttp。注意这一步最好要能科学上网,修改host也行
编译成功,生成二进制文件
3、利用winscp把cqhttp转移到路由器中,打开putty连接路由器,cd到文件夹下,提权,测试运行(配置cqhttp的步骤和之前类似,这里不赘述)
登录成功,警告不用管
2.3 新建文件夹创建main.go文件实现监听路由功能
1、创捷解析json格式的结构体,本项目中会接受到两个json,分别来自百度的图像文字识别结果,以及cqhttp返回的群成员信息
type Namelistdata struct { Data []Namelist //虽然原json是小写,但是构建结构体的value时首字母改成大写才行 //Message string //Retcode string //Status string } type Namelist struct { // Age int // Area string Card string //群名片 // Card_changeable bool // Group_id int // Join_time int // Last_sent_time int // Level int //Nickname string //qq昵称 // Role string // Sex string // Shut_up_timestamp int // Title string // Title_expire_time int // Unfriendly bool User_id uint32 //用string接会变成科学计数法 } type Wordsresult struct { Words_result []Words //虽然原json是小写,但是构建结构体的value时首字母改成大写才行 } type Words struct { Words string }2、导包,本次项目用到了5个本地包和一个来自gihub功能类似flask的gin包
import ( "encoding/json" "fmt" "io" "net/http" "strings" "time" "github.com/gin-gonic/gin" )3、创建函数解析事件上报中的图像url地址
// 传递json解码后的string消息,注意要把json字符转义 func cqmessageToUrl(body map[string]string) []string { a := strings.Split(body["message"], "]") //分割出多条cq模板的信息 var url []string //声明空切片接住url for _, item := range a { b := strings.Split(item, ",") for _, item2 := range b { if strings.Contains(item2, "url=") { //以下为json字符转义 item2 = strings.Replace(item2, "&", "&", -1) item2 = strings.Replace(item2, "[", "[", -1) item2 = strings.Replace(item2, "]", "]", -1) item2 = strings.Replace(item2, "^", ",", -1) fmt.Printf("%v\n", item2) //item2即为url值 url = append(url, item2) } } } fmt.Printf("获取msg提取url的结果: %v\n", url) return url }4、创建函数调用百度的图像文字识别api
在上次项目中是将图像url转为base64格式后传入百度api,经过研究发现百度api可以直接接受url,省去了转换的一步。
const API_KEY = "9Gwmw0R*******zauoTa" //保密信息,隐去 const SECRET_KEY = "roLP*******83" //保密信息,隐去 // 以下为百度提供的文字识别函数 func ocrMain(pictureUrl string) Wordsresult { url := "https://aip.baidubce.com/rest/2.0/ocr/v1/accurate_basic?access_token=" + GetAccessToken() pictureUrl = pictureUrl + "&detect_direction=false¶graph=false&probability=false" payload := strings.NewReader(pictureUrl) client := &http.Client{} req, err := http.NewRequest("POST", url, payload) if err != nil { fmt.Println(err) } req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Accept", "application/json") res, err := client.Do(req) if err != nil { fmt.Println(err) } defer res.Body.Close() body, err := io.ReadAll(res.Body) if err != nil { fmt.Println(err) } //newbody := map[string]any{} 用这个方法接json格式会得到[]interface {},又不能遍历,有点难处理 //_ = json.Unmarshal([]byte(body), &newbody) //_ = json.Unmarshal(body, &newbody) //fmt.Printf("%v 类型是: %T \n", newbody, newbody) []byte(body)似乎影响不大,最后为依然是map[string]interface {}类型 var jsonObject Wordsresult json.Unmarshal([]byte(body), &jsonObject) fmt.Printf("百度识别结果:%v \n", jsonObject) return jsonObject } /** * 使用 AK,SK 生成鉴权签名(Access Token) * @return string 鉴权签名信息(Access Token) */ func GetAccessToken() string { url := "https://aip.baidubce.com/oauth/2.0/token" postData := fmt.Sprintf("grant_type=client_credentials&client_id=%s&client_secret=%s", API_KEY, SECRET_KEY) resp, err := http.Post(url, "application/x-www-form-urlencoded", strings.NewReader(postData)) if err != nil { fmt.Println(err) return "" } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { fmt.Println(err) return "" } accessTokenObj := map[string]any{} _ = json.Unmarshal([]byte(body), &accessTokenObj) //fmt.Printf("%v 类型是: %T \n", accessTokenObj["access_token"], accessTokenObj["access_token"]) 似乎两者没有区别,都是strin类型 //fmt.Printf("%v 类型是: %T \n", accessTokenObj["access_token"].(string), accessTokenObj["access_token"].(string)) return accessTokenObj["access_token"].(string) }5、创建函数与cqhttp通信,拿到群成员信息
// 解析群成员 func all_nameList(groupid string) Namelistdata { tocq := "http://127.0.0.1:5700/get_group_member_list?group_id=" + groupid respons, err := http.Get(tocq) if err != nil { fmt.Println(err) } byteDate, _ := io.ReadAll(respons.Body) newbody := map[string]any{} //打印原始结果 _ = json.Unmarshal(byteDate, &newbody) fmt.Printf("%v \n", newbody) var jsonObject Namelistdata json.Unmarshal([]byte(byteDate), &jsonObject) fmt.Printf("获取群成员信息结果:%v\n ", jsonObject) // var jsonObject2 Namelistdata // d := json.NewDecoder(bytes.NewReader([]byte(byteDate))) // d.UseNumber() // err = d.Decode(&jsonObject2) // if err != nil { // fmt.Println(err) // } // fmt.Printf("测试数字:%v\n ", jsonObject2) return jsonObject }6、创建函数将群成员信息与图像解析信息匹配,并且构建cq格式的at消息
func Urltoat(ocrresult Wordsresult, groupdata Namelistdata) string { var unnameid []uint32 for i := 0; i < len(ocrresult.Words_result); i++ { for j := 0; j < len(groupdata.Data); j++ { if strings.Contains(groupdata.Data[j].Card, ocrresult.Words_result[i].Words) { fmt.Println(groupdata.Data[j]) unnameid = append(unnameid, groupdata.Data[j].User_id) } } } fmt.Println(unnameid) str := fmt.Sprintf("%d", unnameid[0]) result := "[CQ:at,qq=" + str + "]" //result := "[CQ:at,qq=" + unnameid[0] + "]" //测试qq号保存为strin类型能否正常使用 for _, item := range unnameid[1:] { //fmt.Println(strconv.Itoa(unnameid[i])) //fmt.Println(strconv.Itoa(item)) str = fmt.Sprintf("%d", item) result = result + "[CQ:at,qq=" + str + "]" //result = result + "[CQ:at,qq=" + item + "]" //测试qq号保存为strin类型能否正常使用 } //构造cq消息,实现at效果 fmt.Println(result) return result }8、创建函数与cqhttp通信,发送群消息
func sendGroupMassage(message string, group_id string) { sendmessage := "http://127.0.0.1:5700/send_group_msg?group_id=" + group_id + "&message=" + message respons, err := http.Get(sendmessage) if err != nil { fmt.Println(err) } byteDate, _ := io.ReadAll(respons.Body) fmt.Printf("%v\n %v\n %T", string(byteDate), byteDate, byteDate) return }9、主函数判断条件并调用
var groupid string = "*****" //填入群号 func main() { engine := gin.Default() engine.POST("/", func(c *gin.Context) { data, _ := c.GetRawData() //fmt.Printf("%v %T\n", string(data), data) 使用string(data)方法可以完全打印上报的事件,但是后续要解码匹配message之类的方法不熟,先搁置 var body map[string]string json.Unmarshal(data, &body) //解码json消息,但似乎解码不全,数字类型的拿不到 fmt.Println(body) //打印事件上报的结果 messageUrl := cqmessageToUrl(body) //调用cqmessageToUrl函数,返回url的切片数组 if len(messageUrl) != 0 && strings.Contains(body["message"], "图片解析") { groupdata := all_nameList(groupid) //调用all_nameList函数,返回群成员的信息 fmt.Sprintln(groupdata) for i, _ := range messageUrl { a := ocrMain(messageUrl[i]) sendGroupMassage(Urltoat(a, groupdata)+"请打卡", groupid) } } }) engine.Run("127.0.0.1:5701") }2.4 交叉编译路由监控的代码,winscp导入路由器,使用nohup分别启动,同时运行
go mod init main go mod tidy go build -o spyRoute编译成功
2.5 用top命令查找程序PID,用kill命令关闭程序
成功后台运行
三、效果演示
本项目重点是用go语言实现监控路由,以及路由器中同时运行两个程序,已充分体现在章节2“实现过程”中。
而且,本项目效果和上次一致,故不再演示。
四、代码总结——踩过的坑
第一次写go代码,难免会出现低级语法错误导致的报错,考虑到相关的坑都价值不大,相关错误就不再记录,把篇幅留给自认值得的东西。
4.1
在go语言中,json文件还要用结构体解析而且还需要Unmarshal()解码,我是没想到的。Python可以直接调用函数转成字典,然后是键值对索引。第一次接触不方便的模式确实很想吐槽。想着用interfac{}格式接收json,但是后续居然不能遍历,导致我只能老老实实去写结构体。
4.2
想着把解析结构体的代码另外放在一个package里,然后通过给main包导入的形式调用,但是在vscode中导包语句总是有红线报错,折腾好久找各种教程也无法导入。如果忽略红线,每次在vscode中按下保存键,写好的导包语句就消失。所以最终只好把全部的结构体以及函数都放在main包下,非常冗杂。
4.3
因为路由器是32位,存储范围是“-2147483648 ~2147483647”,而现在qq号又大多是十位数,很容易溢出。如果在结构体中写int类型,在windows测试可以拿到的qq号,放到路由器上只会显示0,也即拿不到结果。改成string类型接受又会转成科学计数法,最终选择了无符号的uint32类型,存储范围是“0 ~ 4294967295”
4.4
json里是小写的健,在创建结构体时要首字母大写,才能拿到解析结果。
4.5
使用gin监听路由的各种教程,都是在浏览器中打印展示效果,可是我并不需要这样,而是要在命令窗口打印。
4.6
上次项目运行过程中发现oppo手机qq发送的图片在cqhttp事件上报中产生的url地址无法解析,经过群友点拨,实际不是框架问题,而是需要json字符转义,相关内容已写入函数中。但Ios的依然有问题。
……
五、项目总结:
5.1 折腾历程:
5.1.1
手头这台路由器我拿到手时就经过了刷机,是padavan系统而且有很多插件。刷机改为openwrt的原因有两个:一来,当时还没有计划用U盘外挂,需要腾出存储空间。
二来决定性原因是,padavan的软件源已经失效,根本无法下载python相关的库,更改为大佬的镜像软件源也是各种问题,不得不刷机。
直接在网站“https://openwrt.ai/”找到固件,因为已经有breed在路由器里面,所以傻瓜操作即可。
5.1.2
电脑与路由器交互时一定注意看有没有插网线,有没有禁用网卡。有次通过管理地址一直连不上路由器,多次重启包括刷机那样的重启也不行,我还以为变砖了。最后发现是电脑禁用了网卡,打开就能连上路由器了。禁用网卡是因为在当时的网络拓扑下,电脑网络走路由器是不通外网的,只有连接WiFi才行,又因为不会在电脑上控制调试网络包走向,所以只好直接禁用网卡或者拔网线。还好是虚惊一场,长了教训。
5.1.3
最开始的想法是让路由器跑python监控路由的代码,本以为只需要把上次的结果换个位置就可以运行,甚至不需要写一份报告来总结。没想到python的管理器pip无法正常使用,重装了好几次也无法调用。想到基于go语言的cqhttp能够编译和运行在路由器上,干脆直接用go全部重写监听路由的代码。
5.2 接触go语言:
5.2.1
起初让AI转写python为go偷懒,但是由于完全不懂go,AI的结果语法是否错误,运行过程中报错了也不知道怎么修改。最终还是要从零学习一门新的语言,整个项目的难度陡然增加。
5.2.2
最开始不知道怎么编译cqhttp的源码,文档也没写,根据既往知识应该有个makefile文件入口,也没有。干脆直接go build试试,没想到有了二进制结果,在路由器上运行也成了。
5.2.3
监听路由的功能最好一边写一边在windows平台测试,确保没啥问题后再交叉编译到路由器上测试。
5.3 关于路由器:
5.3.1
没想到路由器可以一边连接WiFi,一边发射WiFi,手机就无法做到这一功能。
5.3.2
路由器的存储大小比例和往常接触的电子设备都不一样,比如手机通常是8+256,说明运存是8gb而内部存储是256gb,电脑也类似,总之硬盘空间都比运存要打。而我使用的newifi3却是32m内部存储,500m运行内存。幸好有个usb口可以外界u盘扩容,不然本项目直接夭折。
5.4 后续更新方向:
5.4.1
在发布了机器人开发视频博主的qq交流群中,发现更加高级的东西。原来官方已经有频道机器人了,而且有现成的文档,后面看看怎么折腾。
5.4.2
由于cqhttp以及监听路由代码运行过程中都会产生大量日志,而目前的部署没有采取任何存储管理措施,可能跑起来一两天或者半个月,整个路由器的存储空间包括U盘都会被吃掉。后续看看要怎么优化,相关操作应该牵涉Linux系统的一些知识。
5.5 项目收获与回味:
5.5.1
通过本次项目不仅学习了go语言,也顺便了解Linux系统上的一些操作,例如挂载u盘,nohup后台运行等
5.5.2
学习了如何修改host的GitHub源,使得编译成功。以及利用软件自动添加host,相关教程见:
5.5.3
因为有毕业论文的实验要做以及一些其他事情,这次项目完成周期相对比较长。正是由于战线太长,甚至有时空闲时还忘记了要推进。
以后再碰到长周期的项目,还是要注意及时记录进展,不管是推进了主线,还是掉进了坑里扑腾,都是项目背后的实现历程。
六、致谢
感谢网上的各种教学视频,还有学校图书馆提供的书籍,方便了学习和使用go语言。感谢“大鸟转转转酒吧”qq群优秀的讨论氛围,感谢群友关于url地址解析的点拨、关于修改host连接GitHub的点拨。
文章评论