用Go语言做一个QQ GPT机器人

胡言乱语

最近在学muduo, 但是由于c++水平太弱, 导致我脑子快给想秃了。于是今天早上心血来潮想做点好的东西消遣下, 给qq机器人接入个ChatGPT。我想实现的最终效果是这样的(这是别的群的机器人, 即灵感来源):

效果图1

效果图2

它应该支持:

  • 冷却时间(CD)
  • 回复内容是markdown转换的图片, 且回复包含消息询问者的问题和询问者的昵称。且告知它CD时间。

实现方案制定

1.首先需要一个qq机器人, 恰好有个开源的golang实现go-cqhttp, 它的接口很友好, 用起来比较舒适。接口文档点击此处进入。

2.除了机器人, 我们还需要一个http处理程序, go-cqhttp收到消息时回调我们的http处理程序, 接收消息并做出响应。这里我们选择了gin作为开发框架。

3.需要把chatgpt的markdown内容转化为图片, 如果我们询问gpt能否写一个golang的hello world程序, gpt的回答通常是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
当然可以!以下是一份简单的 Go 代码,可以输出 "Hello, World!":

```go
package main

import "fmt"

func main() {
fmt.Println("Hello, World!")
}
```

你可以将这段代码保存为 `hello.go` 文件,然后在终端中使用以下命令来编译并运行它:

```
go run hello.go
```

程序将输出 "Hello, World!" 到终端中。

也就是说, gpt的内容包含了markdown格式的内容, 最好把它渲染成图片。要实现这个, 我想到了以下思路:

a.用公开的api接口, 比如vertopal, 但是接口可能不稳定, 而且做起来比较麻烦, 我们不用这种方法

b.先用blackfriday先转成html格式, 然后把内容拼进一个比较好的html模板, 调用无头浏览器截图。这种方法很好, 能完美解决我们的问题, 且可定制度高。我们使用这种方法。

c.用别人造好的轮子来截图网页, gowitness是个不错的选择。这个库知名度挺高, 目前有2k star, 而且支持很屌。既能作为cli工具, 又能开http服务, 功能特别强大。但是很可惜, 这个工具只能截图http/https的网页, 不能截图本地html文件的图片。因此这个方法不行。

如何把markdown转换成html标签?

下面介绍了blackfriday(为什么名字是黑色星期五?)的基本玩法:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"

"github.com/russross/blackfriday/v2"
)

func main() {
fmt.Print(string(blackfriday.Run([]byte("### Hello World"))))
}

// 打印: <h3>Hello World</h3>\n

如何根据html生成图片文件?

要调用无头浏览器, 用chromedp, 对比其它库, 它对chrome driver的支持好。下面是实现html转文件的代码:

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
package main

import (
"context"
"encoding/json"
"fmt"
"os"

"github.com/chromedp/chromedp"
)

func main() {

ctx, cancel := chromedp.NewContext(context.Background())
defer cancel()

result := []byte{}

chromedp.Run(ctx, fullScreenshot("file:///home/markity/Documents/Code/QQBot/program/example.html", 90, &result))
f, _ := os.Create("result.png")
f.Write(result)
}

func fullScreenshot(urlstr string, quality int, res *[]byte) chromedp.Tasks {
return chromedp.Tasks{
chromedp.Navigate(urlstr),
chromedp.FullScreenshot(res, quality),
}
}

这样就完全截图一个网页。我们要做的就是先拼接好网页, 然后调用无头浏览器截图就行了。

Bot Hello World: 如何实现一个QQ群echo服务?

先来做个简单的hello world级别的echo服务。我们需要监听群员的消息, 如果有人at机器人并说一句话, 机器人就at此群员并回显他的话。要做到这样的echo服务, 我们需要编写go-cqhttp的http回调程序, 其中要做到以下几点:

0.注意首先保证你已经配置好了go-cqhttp, 填好账号密码。填写好回调地址(即我们写的gin回调程序的url): 将servers-http-post修改如下, 我们的回调程序将运行在http://127.0.0.1:8080

1
2
3
4
post:           # 反向HTTP POST地址列表
- url: 'http://127.0.0.1:8080' # 地址
# secret: '' # 密钥
max-retries: 3 # 最大重试,0 时禁用

1.在go-cqhttp请求(post方法)我们的回调程序时, 先读出消息本体, 即解析一下json数据就行

2.甄别回调的消息包类型, go-cqhttp有心跳包, 消息包等多种包, 我们需要的只有消息包。再甄别消息是否来自某个特定的群聊, 这里直接判断群聊号码就行了。再甄别用户是否at了机器人, 这里判断raw_message是否以[CQ:at,qq=机器人QQ号]开头就行了。这种[CQ:xxx,xx=xxx]叫CQ代码, 是qq的特殊消息类型, 包含at, qq表情, 图片, 表情包, 它会被qq客户端转义, 显示出特殊的内容。

消息包也有私聊和群聊的区别, 这是一个群聊的json数据包, 此处过多展开太无聊了, 提一下得了, 要了解更多直接去看go-cqhttp的文档:

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
{
"post_type":"message",
"message_type":"group",
"time":1679534793,
"self_id":3402002560,
"sub_type":"normal",
"message_seq":894528,
"group_id":805574759,
"message":"那你得帅,还得有身材",
"raw_message":"那你得帅,还得有身材",
"sender":
{
"age":0,
"area":"",
"card":"萌小瓜",
"level":"",
"nickname":"瓜瓜回去吧",
"role":"member",
"sex":"unknown",
"title":"",
"user_id":10174609
},
"user_id":10174609,
"message_id":1775522907,
"anonymous":null,
"font":0
}

3.拿取用户的输入, 然后调用cq-http的相应http接口做出回应。

下面是实现代码, 机器人的账号是3402002560, 群号是334903763, 程序里写死了:

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
package main

import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"

"github.com/gin-gonic/gin"
)

// 所有Event的公用字段
type EventHeaderStruct struct {
// unix事件戳
Time int64 `json:"time"`
// message, message_sent, request, notice, meta_event
PostType string `json:"post_type"`
// 自身的QQ号
SelfID int64 `json:"self_id"`
}

type sender struct {
// 私聊有下面四个字段

// QQ号
UserID int64 `json:"user_id"`
// 昵称
NickName string `json:"nickname"`
// "male", "female", "unknown"
Sex string `json:"sex"`
// 年龄
Age int32 `json:"age"`

// 群临时会话除了上面的字段, 还有下面的字段
GroupID int64 `json:"group_id"`

// 如果是群聊, 除了上面四个基础字段, 还有下面的字段

// 群名片/备注
Card string `json:"card"`
// 地址
Area string `json:"area"`
// 成员等级
Level string `json:"level"`
// 成员角色, owner, admin, member
Role string `json:"role"`
// 头衔
Title string `json:"title"`
}

type EventMessageStrcut struct {
EventHeaderStruct
MessageType string `json:"message_type"` // 消息类型, 私聊, 群聊 private, group
SubType string `json:"sub_type"` // 消息的子类型: group, public, friend, normal
MessageID int32 `json:"message_id"` // 消息号
RawMessage string `json:"raw_message"` // 原始消息, 带有CQ码
Sender sender `json:"sender"`
GroupID int64 `json:"group_id"` // 对于群聊消息, 才有群号
// 为了简便起见, 请禁群匿名, 这里没有写匿名相关的处理
}

type SendMsgStruct struct {
// 消息类型, private, group
MessageType string `json:"message_type"`
// 仅私聊时需要
UserID int64 `json:"user_id"`
// 仅群聊时需要
GroupID int64 `json:"group_id"`
Message string `json:"message"`
// 是否作为纯文本
AutoEscape bool `json:"auto_escape"`
}

func GetSendToUserBytes(msg string, groupID int64, senderUserID int64) []byte {
sendMsgStruct := SendMsgStruct{
MessageType: "group",
GroupID: groupID,
Message: fmt.Sprintf("[CQ:at,qq=%v]", senderUserID) + msg,
AutoEscape: false,
}
b, _ := json.Marshal(&sendMsgStruct)
return b
}

func main() {
r := gin.Default()
r.POST("/", func(c *gin.Context) {
// 看看包是什么类型, 过滤掉心跳包等其他类型的包, 只管消息包
buf, _ := io.ReadAll(c.Request.Body)
var header EventHeaderStruct
json.Unmarshal(buf, &header)
if header.PostType != "message" {
c.Status(200)
return
}

// 专门处理message包, 解析出消息包
var message EventMessageStrcut
json.Unmarshal(buf, &message)

// 此处过滤掉非群组消息, 过滤掉无关群聊
if message.MessageType != "group" || (message.GroupID != 334903763) {
c.Status(200)
return
}

// 过滤掉非at自己的信息
if !strings.HasPrefix(message.RawMessage, "[CQ:at,qq=3402002560]") {
return
}

// 用户发给机器人的信息, 抹掉前面的at CQ代码就是消息
userSendToRobotMsg := message.RawMessage[21:]
// 发送请求, 请求go-cqhttp, 给用户响应消息, 这里忽略了可能的错误, 对于本地服务没关系,
resp, err := http.Post("http://127.0.0.1:5700/send_msg", "application/json", bytes.NewBuffer(GetSendToUserBytes(userSendToRobotMsg, message.GroupID, message.Sender.UserID)))
if err != nil {
log.Printf("failed to Post to cqhttp service: %v\n", err)
return
}
resp.Body.Close()
})

r.Run()
}

接下来运行go-cqhttp和gin服务, 机器人过一会就能工作了, 下面的命令可以快速启动go-cqhttp。

1
$ go-cqhttp -faststart

演示图片

接下来?

完整实现的代码我已经开源到github上了, 点击此处查看。

有了上面的工具, 实现一个QQ GPT服务就唾手可得了, 但由于篇幅原因, 这里只讲到了最基础的东西。没有涉及GPT接口的调用, 也没有涉及冷却时间的实现。关于GPT的接口调用, 可以自行查询接口文档, 也可以结合我的代码看看。关于CD冷却时间, 直接做张表查询就行了, 这个很简单。对于查询调用GPT接口, 回复用户信息的任务, 最好开一个单独的协程执行消息任务, 这样不会发生竞态, 也不用锁, 也不会因为GPT请求太快而发生风控。此外, 单独的任务协程可以避免go-cqhttp的timeout导致的消息重放(这样可能导致用户提问一次而收到多次回复信息)。

最后来展示下最终实现效果:

效果图1

效果图2