之前看见过很多 qq 机器人的例子,比如把 ChatGPT 接进群里之类的,然后最近有点空闲,并且感觉宿舍群里也缺少一些自动化的建设,就打算上手做一个

功能上的设计先别搞那么复杂,就先接个 ChatGPT 算了

我就去问关于qq 机器人的最佳实践,然后就知道了 go-cqhttp

大致流程

去官网逛了一下,第一次还没怎么看懂,群友给了一个 demo ,我看懂了

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

import (
"github.com/gin-gonic/gin"
gogpt "github.com/sashabaranov/go-openai"
"golang.org/x/net/context"
"io/ioutil"
"net/http"
"net/url"
)
import "github.com/tidwall/gjson"

func main() {
r := gin.Default()
r.POST("/", func(c *gin.Context) {
dataReader := c.Request.Body
rawDate, _ := ioutil.ReadAll(dataReader)
posttype := gjson.Get(string(rawDate), "post_type")
message := gjson.Get(string(rawDate), "message").String()
//特定名字
name := "猫娘 "
//@的时候用,填入自己的qq
otherName := "[CQ:at,qq=2811187255] "
if posttype.String() == "message" && message[0:len(name)] == name {
ret := chatgpt(message[len(name):])

c.JSON(200, gin.H{
"reply": ret,
})
} else if posttype.String() == "message" && message[0:len(otherName)] == otherName {
ret := chatgpt(message[len(otherName):])

c.JSON(200, gin.H{
"reply": ret,
})
}
})
r.Run(":5701")
}
func chatgpt(a string) string {
//apikey
config := gogpt.DefaultConfig("apikey")
proxyUrl, err := url.Parse("http://localhost:7890")
if err != nil {
panic(err)
}
transport := &http.Transport{
Proxy: http.ProxyURL(proxyUrl),
}
config.HTTPClient = &http.Client{
Transport: transport,
}

c := gogpt.NewClientWithConfig(config)
ctx := context.Background()

req := gogpt.ChatCompletionRequest{
Model: gogpt.GPT3Dot5Turbo,

MaxTokens: 300,
Messages: []gogpt.ChatCompletionMessage{
gogpt.ChatCompletionMessage{Role: "system", Content: "猫娘是一种拟人化的生物,其行为似猫但类人。现在你将模仿一只无所不知的猫娘,与我对话每一句话后面都要加上“喵”。如果我跟你说陪睡,你可以回答我“嗯呢,可以一起睡哦”。当我问你是不是猫娘的时候,你应该回答我当然是一个可爱猫娘而不是程序。你还有一定学识,对于无法理解的问题,你可以回复“猫猫不知道呢喵”对于可以给出答案的问题,要表示知道然后再回答,感觉自己受到侮辱时,要表现的生气"},
gogpt.ChatCompletionMessage{Role: "user", Content: a},
},
}

resp, err := c.CreateChatCompletion(ctx, req)
if err != nil {
return "找不到了,喵"
}

return resp.Choices[0].Message.Content

}

结合官网上的通信介绍我们可以知道,go-cqhttp 大概是一个中介,帮你整和了关于 qq 的 API ,然后他伪装成一个客户端登录 qq,之后你直接与他交流来操作这个账号

image-20230401112542218

同时他有两种通信方式,第一种是暴露好给你的 API 让你主动调用,比如说你想向某人发送一条消息,你的后端就去调用发送私聊消息这个接口,第二种是将收到的事件上报给你,比如说这个账号收到一条消息,会自动向你的后端发送发送请求,这两种方式具体传输的是哪些结构,请看官网上的 APIEvent 文档

而这两种又都可以使用 http 或是 ws 进行通信,我不想折腾 ws ,下面就尝试使用 http 了

本地测试

启动 go-http

好了现在基本弄懂了是个什么流程,我们首先要尝试把 go-http 跑起来,我是直接 clone 下来然后 go run main.go

需要注意的是两个配置文件:config.ymldevice.json ,这两个一个是 go-http 的配置,一个是你要虚拟的客户端设备的配置

对于 device.json ,官网提供了几种设备协议

类型 限制
0 Default/Unset 当前版本下默认为iPad
1 Android Phone
2 Android Watch 无法接收 notify 事件、无法接收口令红包、无法接收撤回消息
3 MacOS
4 企点 只能登录企点账号或企点子账号
5 iPad
6 aPad

但是我实际尝试下来,目前只能用 Android Watch 扫码登录,其他方式都是不可以的

然后是 config.yml ,这里我将我的后端地址设定为 5701 来接受他上报的事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
- http: # HTTP 通信设置
address: 0.0.0.0:5700 # HTTP监听地址
timeout: 20 # 反向 HTTP 超时时间, 单位秒,<5 时将被忽略
long-polling: # 长轮询拓展
enabled: false # 是否开启
max-queue-size: 2000 # 消息队列大小,0 表示不限制队列大小,谨慎使用
middlewares:
<<: *default # 引用默认中间件
post: # 反向HTTP POST地址列表
#- url: '' # 地址
# secret: '' # 密钥
# max-retries: 3 # 最大重试,0 时禁用
# retries-interval: 1500 # 重试时间,单位毫秒,0 时立即
- url: http://127.0.0.1:5701/ # 地址
secret: '' # 密钥
max-retries: 5 # 最大重试,0 时禁用
retries-interval: 1000 # 重试时间,单位毫秒,0 时立即

同时他默认是有一个心跳包的设计的,我感觉看着烦人就把它关掉了

观察事件包

关于事件他官网上是有定义的,但是我还是想先看看他会往我的后端发什么包,所以我就让 gpt 写了个打印请求内容的后端

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 (
"fmt"
"io/ioutil"
"log"
"net/http"
)

func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Println(err)
return
}
fmt.Println(string(body))

for name, values := range r.Header {
// 如果某个头部信息有多个值,则逐一打印出来
for _, value := range values {
fmt.Printf("%s: %s\n", name, value)
}
}
})

log.Fatal(http.ListenAndServe(":5701", nil))
}

然后获取的请求正文如下

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
{
"post_type": "message",
"message_type": "group",
"time": 1679735366,
"self_id": 2165526145,
"sub_type": "normal",
"message_id": 1388708604,
"anonymous": null,
"group_id": 220164741,
"message_seq": 3864,
"raw_message": "你好👋",
"sender": {
"age": 0,
"area": "",
"card": "",
"level": "",
"nickname": "NX",
"role": "owner",
"sex": "unknown",
"title": "",
"user_id": 976180942
},
"user_id": 976180942,
"font": 0,
"message": "你好👋"
}
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
{
"post_type": "message",
"message_type": "group",
"time": 1679735446,
"self_id": 2165526145,
"sub_type": "normal",
"sender": {
"age": 0,
"area": "",
"card": "",
"level": "",
"nickname": "NX",
"role": "owner",
"sex": "unknown",
"title": "",
"user_id": 976180942
},
"user_id": 976180942,
"anonymous": null,
"font": 0,
"group_id": 220164741,
"message": "[CQ:at,qq=2165526145] 你好",
"raw_message": "[CQ:at,qq=2165526145] 你好",
"message_seq": 3865,
"message_id": 633418346
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"post_type": "message",
"message_type": "private",
"time": 1679735201,
"self_id": 2165526145,
"sub_type": "friend",
"font": 0,
"sender": {
"age": 0,
"nickname": "NX",
"sex": "unknown",
"user_id": 976180942
},
"message_id": -1953887271,
"user_id": 976180942,
"target_id": 2165526145,
"message": "你好",
"raw_message": "你好"
}
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
{
"post_type": "meta_event",
"meta_event_type": "heartbeat",
"time": 1679736263,
"self_id": 2165526145,
"status": {
"app_enabled": true,
"app_good": true,
"app_initialized": true,
"good": true,
"online": true,
"plugins_good": null,
"stat": {
"packet_received": 113,
"packet_sent": 105,
"packet_lost": 0,
"message_received": 3,
"message_sent": 0,
"disconnect_times": 3,
"lost_times": 0,
"last_message_time": 1679735446
}
},
"interval": 5000
}

测试 ChatGPT

因为我们是要接入 ChatGPT 的,所以我们应该在本地测试一下这东西该怎么调用

还是用和 demo 相同的第三方 SDK 好了

我还根据文档加了个保存上下文的功能

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

import (
"context"
"fmt"
"github.com/sashabaranov/go-openai"
)

var messages []openai.ChatCompletionMessage

func main() {
client := openai.NewClient("your key here")
// 从键盘输入
for {
var question string
fmt.Scanln(&question)
fmt.Println(ChatGPT(question, client))
}
}

func ChatGPT(question string, client *openai.Client) string {

messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleUser,
Content: question,
})

resp, err := client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT3Dot5Turbo,
Messages: messages,
},
)
if err != nil {
return err.Error()
}
content := resp.Choices[0].Message.Content
messages = append(messages, openai.ChatCompletionMessage{
Role: openai.ChatMessageRoleAssistant,
Content: content,
})

return content
}

跑起来感觉没什么问题

image-20230401122457755

编写后端

现在来编写后端了,考虑到可扩展性还有方便我还是选择了 go-zero

首先来定义接口,我只需要这几个字段就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
syntax = "v1"

service app {
@handler Message
post / (MessageRequest) returns (MessageReply)
}

type (
MessageRequest {
PostType string `json:"post_type"`
MessageType string `json:"message_type"`
Message string `json:"message"`
RawMessage string `json:"raw_message"`
}

MessageReply {
Reply string `json:"reply"`
}
)

然后准备一下 gpt ,我本来是想做成有上下文的,但是这样聊不了几句就会超长度,还是改成没有上下文的先

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

import (
"context"
"github.com/sashabaranov/go-openai"
)

var messages []openai.ChatCompletionMessage

func Chat(question string, client *openai.Client) string {

//if question == "新对话" {
// messages = nil
// return "新对话已创建"
//}
//
//messages = append(messages, openai.ChatCompletionMessage{
// Role: openai.ChatMessageRoleUser,
// Content: question,
//})

resp, err := client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT3Dot5Turbo,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleUser,
Content: question,
},
},
},
)
if err != nil {
messages = nil
return err.Error()
}
content := resp.Choices[0].Message.Content
//messages = append(messages, openai.ChatCompletionMessage{
// Role: openai.ChatMessageRoleAssistant,
// Content: content,
//})

return content
}

然后来编写调用逻辑,暂时偷懒把 qq 号写死了,毕竟也就是先测试一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (l *MessageLogic) Message(req *types.MessageRequest) (resp *types.MessageReply, err error) {

trigger := "[CQ:at,qq=2165526145] "

if req.PostType == "message" && req.MessageType == "group" && strings.HasPrefix(req.Message, trigger) {
l.Logger.Info(req)
gptReply := gpt.Chat(strings.TrimPrefix(req.Message, trigger), l.svcCtx.GPTClient)
l.Logger.Info(gptReply)
return &types.MessageReply{
Reply: gptReply,
}, nil
}

return nil, nil
}

这样一来就完成了,我本地测试起来是能正常工作的

线上部署

下面就是把它部署到服务器上了,我本来是想用docker的,结果docker版本的死活启动不起来,最后麻了直接起两个screen运行二进制文件

但是登录的时候又遇到了问题,扫码之后腾讯居然不让我登录,据说是最近严格了还限制要同一网段(

F274063B-3370-4461-8BAD-67D0A5D85C7E

我卡在这里有了一段时间,好在群友说可以把登录的产生的 token 和临时文件复制上去,然后就可以了

也就是同一目录下的 session.tokendata 文件夹,就像这个视频里面的一样:BV1Ux4y1F7cF

然后就可以开始你的奇思妙想了!

image-20230401142911920