napcat: NapCatQQ (OneBot 11) Go SDK, initial release
This commit is contained in:
238
README.md
Normal file
238
README.md
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
# napcat
|
||||||
|
|
||||||
|
对接 [NapCatQQ](https://napneko.github.io/)(OneBot 11 协议)的 Go 软件包,零第三方依赖(仅 gorilla/websocket)。
|
||||||
|
|
||||||
|
```go
|
||||||
|
bot := napcat.New().SetToken("xxx").SetForwardWS("ws://127.0.0.1:3001")
|
||||||
|
bot.OnGroupMessage(func(c *napcat.Context) {
|
||||||
|
if c.HasAtMe() {
|
||||||
|
c.ReplyText("hi")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
bot.Run(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- **一个对象搞定**:`New()` 创建与 NapCat 交互的客户端对象,配置、事件、API 全在对象上
|
||||||
|
- **四种通信方式**,链式配置、可任意叠加:
|
||||||
|
- 正向 WebSocket(自动重连 + 指数退避)
|
||||||
|
- 反向 WebSocket(支持多 bot 实例同时连入)
|
||||||
|
- HTTP API
|
||||||
|
- HTTP POST 上报(自动支持 OneBot 快速操作)
|
||||||
|
- **事件模型**:message / message_sent / notice / request / meta_event 全量结构化,
|
||||||
|
notice 未知字段不丢失(收进 `Extra`)
|
||||||
|
- **消息段**:全类型构造函数;收到的 CQ 码字符串与段数组统一解码
|
||||||
|
- **API 覆盖**:OneBot 11 标准 + go-cqhttp 兼容 + NapCat 扩展共 80+ 个类型化方法,
|
||||||
|
未收录的扩展可用 `Call` 裸调
|
||||||
|
- **echo 路由**:WS 上的 action 请求/响应按 echo 精确配对,支持并发调用
|
||||||
|
- **连接级路由**:事件处理函数里的 API 调用自动走产生该事件的连接,多 bot 互不串线
|
||||||
|
- 内置文本命令路由、请求 Approve/Reject、心跳记录、panic 隔离
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
napcat/
|
||||||
|
├── doc.go 包文档(pkg.go.dev 首页)
|
||||||
|
├── client.go Client:配置方法、连接汇聚、事件分发、Handler 注册
|
||||||
|
├── context.go Context:处理函数上下文(Reply/Approve/快速操作)
|
||||||
|
├── api.go API:80+ 类型化 API 方法
|
||||||
|
├── caller.go Caller 接口 + WS echo 路由 + CallAPI 泛型
|
||||||
|
├── connect_ws.go 正向/反向 WebSocket 连接器 + Session
|
||||||
|
├── connect_http.go HTTP 上报(快速操作)+ HTTP API 连接器
|
||||||
|
├── event.go 事件结构(message/notice/request/meta)
|
||||||
|
├── segment.go 消息段构造与解码
|
||||||
|
├── cqcode.go CQ 码解析/编码
|
||||||
|
├── types.go 响应信封与实体类型
|
||||||
|
├── example_test.go 用法示例(godoc Examples)
|
||||||
|
└── *_test.go 单元测试(内置 mock NapCat)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```
|
||||||
|
go get gitea.doicat.com/bot/napcat
|
||||||
|
```
|
||||||
|
|
||||||
|
使用指定版本:
|
||||||
|
|
||||||
|
```
|
||||||
|
go get gitea.doicat.com/bot/napcat@v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
**私有仓库使用者**:如果仓库设为私有,除了给 Git 配置 Gitea 凭据外,还需设置:
|
||||||
|
|
||||||
|
```
|
||||||
|
set GOPRIVATE=gitea.doicat.com
|
||||||
|
```
|
||||||
|
|
||||||
|
这样 go 命令才会走 Git 凭据拉取而不是公共校验和数据库。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. NapCat 侧配置
|
||||||
|
|
||||||
|
在 NapCat WebUI 的「网络配置」里按需启用一项(或多项):
|
||||||
|
|
||||||
|
| 配置方法 | NapCat 侧配置 |
|
||||||
|
|---|---|
|
||||||
|
| `SetForwardWS("ws://127.0.0.1:3001")` | 启用「WebSocket 服务器」,端口 3001 |
|
||||||
|
| `SetReverseWS(":8080")` | 启用「WebSocket 客户端」,URL 填 `ws://<本机IP>:8080/` |
|
||||||
|
| `SetHTTPAPI("http://127.0.0.1:3000")` | 启用「HTTP 服务器」,端口 3000 |
|
||||||
|
| `SetHTTPReport(":8081")` | 启用「HTTP POST 上报」,地址填 `http://<本机IP>:8081/` |
|
||||||
|
|
||||||
|
若配置了 access_token,`SetToken` 填同一个值即可,框架会在 WS 握手
|
||||||
|
(query + Authorization 头)和 HTTP 调用(Authorization 头)中自动携带;
|
||||||
|
HTTP 上报场景若配了 secret,`SetSecret` 会自动校验 `X-Signature`(HMAC-SHA1)。
|
||||||
|
|
||||||
|
### 2. 完整示例
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
|
||||||
|
"gitea.doicat.com/bot/napcat"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
bot := napcat.New().
|
||||||
|
SetToken("your-token").
|
||||||
|
SetForwardWS("ws://127.0.0.1:3001")
|
||||||
|
// 可叠加其他通信方式:
|
||||||
|
// bot.SetReverseWS(":8080")
|
||||||
|
// bot.SetHTTPAPI("http://127.0.0.1:3000")
|
||||||
|
// bot.SetHTTPReport(":8081")
|
||||||
|
|
||||||
|
// 文本命令
|
||||||
|
bot.OnCommand("/ping", func(c *napcat.Context, args []string) {
|
||||||
|
c.ReplyText("pong!")
|
||||||
|
})
|
||||||
|
|
||||||
|
// 群里 @机器人 就复读
|
||||||
|
bot.OnGroupMessage(func(c *napcat.Context) {
|
||||||
|
if c.HasAtMe() && !c.IsSelfMessage() {
|
||||||
|
c.ReplyText("收到: " + c.PlainText())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 进群欢迎
|
||||||
|
bot.OnNoticeType("group_increase", func(c *napcat.Context) {
|
||||||
|
c.SendGroupMsg(nil, c.Event.Notice.GroupID, napcat.Text("欢迎新人~"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 被戳就戳回去
|
||||||
|
bot.OnNoticeType("notify", func(c *napcat.Context) {
|
||||||
|
n := c.Event.Notice
|
||||||
|
if n.SubType == "poke" && n.TargetID == c.SelfID() {
|
||||||
|
c.SendPoke(nil, n.GroupID, n.UserID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 好友/群请求自动同意
|
||||||
|
bot.OnRequest(func(c *napcat.Context) {
|
||||||
|
c.Approve()
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
|
||||||
|
defer stop()
|
||||||
|
bot.Run(ctx)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置方法一览
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `SetToken(t)` | OneBot access_token,所有连接自动携带 |
|
||||||
|
| `SetSecret(s)` | HTTP 上报 X-Signature 校验密钥 |
|
||||||
|
| `SetLogger(l)` | 自定义 *slog.Logger |
|
||||||
|
| `SetForwardWS(urls...)` | 正向 WS 目标,可多个,重复调用替换 |
|
||||||
|
| `SetReverseWS(addrs...)` | 反向 WS 监听地址,可多个,重复调用替换 |
|
||||||
|
| `SetHTTPAPI(baseURL)` | HTTP API 地址 |
|
||||||
|
| `SetHTTPReport(addrs...)` | HTTP 上报监听地址,可多个,重复调用替换 |
|
||||||
|
| `Use(conn...)` | 注册自定义 Connector(高级扩展点) |
|
||||||
|
|
||||||
|
以上方法均返回 `*Client`,支持链式调用。请勿在 `Run` 之后修改配置。
|
||||||
|
|
||||||
|
## 事件处理
|
||||||
|
|
||||||
|
```go
|
||||||
|
bot.OnEvent(fn) // 所有事件
|
||||||
|
bot.OnMessage(fn) // 私聊 + 群聊消息(含 message_sent)
|
||||||
|
bot.OnPrivateMessage(fn) // 私聊消息
|
||||||
|
bot.OnGroupMessage(fn) // 群聊消息
|
||||||
|
bot.OnNotice(fn) // 全部通知
|
||||||
|
bot.OnNoticeType(t, fn) // 指定 notice_type,如 "group_increase" "notify" "group_recall"
|
||||||
|
bot.OnRequest(fn) // 好友/群请求
|
||||||
|
bot.OnMeta(fn) // 心跳/生命周期
|
||||||
|
bot.OnCommand(name, fn) // 文本命令,args 为空白切分的参数
|
||||||
|
```
|
||||||
|
|
||||||
|
同一会话的事件**串行分发**(保序);handler 里的 panic 会被隔离并记录日志。
|
||||||
|
事件队列上限 256 条,溢出时丢弃并告警。
|
||||||
|
|
||||||
|
## Context 常用方法
|
||||||
|
|
||||||
|
| 方法 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `Reply(msg...)` / `ReplyText(s)` | 回复当前消息(群聊/私聊自动判断) |
|
||||||
|
| `Recall()` | 撤回当前消息 |
|
||||||
|
| `Approve()` / `Reject(reason)` | 同意/拒绝当前的请求事件 |
|
||||||
|
| `HasAtMe()` / `PlainText()` / `Segments()` | 消息内容判断 |
|
||||||
|
| `IsGroup()` / `IsPrivate()` / `IsSelfMessage()` | 事件类型判断 |
|
||||||
|
| `SendGroupMsg(nil, gid, ...)` 等 | 直接调用任意 API(ctx 传 nil 用默认 30s 超时) |
|
||||||
|
| `Call(nil, action, params, &out)` | 裸调未收录的扩展 API |
|
||||||
|
|
||||||
|
HTTP 上报模式下,`Reply` / `Approve` / `Reject` 会随 HTTP 响应以
|
||||||
|
OneBot「快速操作」返回,不产生额外请求。
|
||||||
|
|
||||||
|
## 消息段
|
||||||
|
|
||||||
|
```go
|
||||||
|
napcat.Text("hello")
|
||||||
|
napcat.At(12345) // napcat.AtAll()
|
||||||
|
napcat.Image("C:/a.jpg") // 支持本地路径 / http(s) URL / base64://
|
||||||
|
napcat.Reply(678).With("text", "引用") // With 追加任意字段
|
||||||
|
napcat.CustomNode(10001, "某人", []napcat.Segment{napcat.Text("...")})
|
||||||
|
```
|
||||||
|
|
||||||
|
收到的消息统一用 `c.Segments()` 获取,CQ 码字符串会被自动解析;
|
||||||
|
`napcat.ParseCQ` / `napcat.EncodeCQ` 可单独使用。
|
||||||
|
|
||||||
|
## API 调用
|
||||||
|
|
||||||
|
全部 API 挂在 `Client`(默认通道)与 `Context`(事件来源通道)上,
|
||||||
|
命名与 OneBot action 一一对应(驼峰化),例如:
|
||||||
|
|
||||||
|
```go
|
||||||
|
login, _ := bot.GetLoginInfo(nil) // get_login_info
|
||||||
|
mid, err := bot.SendGroupMsg(nil, 123, napcat.Text("hi"))
|
||||||
|
bot.SetGroupBan(nil, 123, 456, 600) // set_group_ban
|
||||||
|
info, _ := bot.GetGroupMemberInfo(nil, 123, 456, false)
|
||||||
|
bot.UploadGroupFile(nil, 123, `C:\file.zip`, "file.zip", "")
|
||||||
|
hist, _ := bot.GetGroupMsgHistory(nil, 123, 0, 20, false)
|
||||||
|
```
|
||||||
|
|
||||||
|
返回未结构化的 NapCat 扩展 API(如 `GetProfileLike`)时得到 `jsonRaw`(map),
|
||||||
|
需要更细的结构时用 `Call` 裸调自行解码。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
```
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
测试内置 mock NapCat(正向 WS、反向 WS、HTTP 上报签名校验、HTTP API),
|
||||||
|
覆盖 echo 往返、错误响应、快速操作、命令分发、CQ 码转义回环等场景。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `message_sent` 是 bot 自己发出的消息事件,同样进入 message 处理链,
|
||||||
|
用 `c.IsSelfMessage()` 区分,避免 bot 复读自己。
|
||||||
|
- 多个 bot 实例同时连入反向 WS 时,`Client.Call`(默认通道)只作用于
|
||||||
|
最近连入的会话;事件处理内请一律用 `Context` 的方法,自动路由正确。
|
||||||
|
- 正向 WS 断开后自动重连(1s 起步、30s 封顶指数退避)。
|
||||||
647
api.go
Normal file
647
api.go
Normal file
@ -0,0 +1,647 @@
|
|||||||
|
package napcat
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// H 是裸调 action 时的参数表(配合 Context.Call / Client.Call 使用)。
|
||||||
|
type H = map[string]any
|
||||||
|
|
||||||
|
// API 聚合全部 OneBot 11 / go-cqhttp / NapCat 扩展 API 方法。
|
||||||
|
// Client 与 Context 均内嵌本类型;Client 走默认通道,
|
||||||
|
// Context 走产生当前事件的连接通道。ctx 传 nil 时用 30 秒超时。
|
||||||
|
type API struct {
|
||||||
|
call Caller
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 消息收发 ----
|
||||||
|
|
||||||
|
// messageIDResult 承接 send_* 类 API 返回的 data: {"message_id": ...}。
|
||||||
|
type messageIDResult struct {
|
||||||
|
MessageID IntString `json:"message_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPrivateMsg 发送私聊消息,返回 message_id。
|
||||||
|
func (a API) SendPrivateMsg(ctx context.Context, userID int64, message ...Segment) (MessageID, error) {
|
||||||
|
return a.sendMsg(ctx, "send_private_msg", H{"user_id": userID, "message": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendGroupMsg 发送群聊消息,返回 message_id。
|
||||||
|
func (a API) SendGroupMsg(ctx context.Context, groupID int64, message ...Segment) (MessageID, error) {
|
||||||
|
return a.sendMsg(ctx, "send_group_msg", H{"group_id": groupID, "message": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMsg 按消息类型发送,messageType 取值 private/group。
|
||||||
|
func (a API) SendMsg(ctx context.Context, messageType string, id int64, message ...Segment) (MessageID, error) {
|
||||||
|
return a.sendMsg(ctx, "send_msg", H{"message_type": messageType, "user_id": id, "group_id": id, "message": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPrivateMsgCQ 用 CQ 码字符串发送私聊消息。
|
||||||
|
func (a API) SendPrivateMsgCQ(ctx context.Context, userID int64, cqCode string) (MessageID, error) {
|
||||||
|
return a.sendMsg(ctx, "send_private_msg", H{"user_id": userID, "message": cqCode})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendGroupMsgCQ 用 CQ 码字符串发送群聊消息。
|
||||||
|
func (a API) SendGroupMsgCQ(ctx context.Context, groupID int64, cqCode string) (MessageID, error) {
|
||||||
|
return a.sendMsg(ctx, "send_group_msg", H{"group_id": groupID, "message": cqCode})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a API) sendMsg(ctx context.Context, action string, params H) (MessageID, error) {
|
||||||
|
out, err := CallAPI[messageIDResult](a.call, ctx, action, params)
|
||||||
|
return MessageID(out.MessageID), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMsg 撤回消息。
|
||||||
|
func (a API) DeleteMsg(ctx context.Context, messageID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "delete_msg", H{"message_id": messageID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMessage 获取一条消息的完整内容。
|
||||||
|
func (a API) GetMessage(ctx context.Context, messageID int64) (MessageInfo, error) {
|
||||||
|
return CallAPI[MessageInfo](a.call, ctx, "get_msg", H{"message_id": messageID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetForwardMsg 获取合并转发的子消息列表。
|
||||||
|
func (a API) GetForwardMsg(ctx context.Context, messageID int64) (ForwardMessages, error) {
|
||||||
|
return CallAPI[ForwardMessages](a.call, ctx, "get_forward_msg", H{"message_id": messageID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendLike 给好友点赞,times 为次数(每日上限内)。
|
||||||
|
func (a API) SendLike(ctx context.Context, userID int64, times int) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "send_like", H{"user_id": userID, "times": times})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkMsgAsRead 标记单条消息已读。
|
||||||
|
func (a API) MarkMsgAsRead(ctx context.Context, messageID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "mark_msg_as_read", H{"message_id": messageID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPrivateMsgAsRead 标记私聊会话已读。
|
||||||
|
func (a API) MarkPrivateMsgAsRead(ctx context.Context, userID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "mark_private_msg_as_read", H{"user_id": userID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkGroupMsgAsRead 标记群消息已读。
|
||||||
|
func (a API) MarkGroupMsgAsRead(ctx context.Context, groupID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "mark_group_msg_as_read", H{"group_id": groupID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAllAsRead 标记全部会话已读。
|
||||||
|
func (a API) MarkAllAsRead(ctx context.Context) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "_mark_all_as_read", H{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMsgEmojiLike 给消息贴表情回应。
|
||||||
|
func (a API) SetMsgEmojiLike(ctx context.Context, messageID int64, faceID int) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_msg_emoji_like", H{"message_id": messageID, "face_id": faceID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 合并转发 ----
|
||||||
|
|
||||||
|
// Node 定义一个合并转发节点:MessageID>0 时引用已有消息,否则用 UserID/Nickname/Content 伪造。
|
||||||
|
type Node struct {
|
||||||
|
MessageID int64
|
||||||
|
UserID int64
|
||||||
|
Nickname string
|
||||||
|
Content []Segment
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n Node) segment() Segment {
|
||||||
|
if n.MessageID != 0 {
|
||||||
|
return NodeSeg(n.MessageID)
|
||||||
|
}
|
||||||
|
return CustomNode(n.UserID, n.Nickname, n.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodesPayload(nodes []Node) []Segment {
|
||||||
|
segs := make([]Segment, 0, len(nodes))
|
||||||
|
for _, n := range nodes {
|
||||||
|
segs = append(segs, n.segment())
|
||||||
|
}
|
||||||
|
return segs
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendGroupForwardMsg 发送群合并转发消息。
|
||||||
|
func (a API) SendGroupForwardMsg(ctx context.Context, groupID int64, nodes ...Node) (SendForwardResult, error) {
|
||||||
|
return CallAPI[SendForwardResult](a.call, ctx, "send_group_forward_msg", H{"group_id": groupID, "messages": nodesPayload(nodes)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendPrivateForwardMsg 发送私聊合并转发消息。
|
||||||
|
func (a API) SendPrivateForwardMsg(ctx context.Context, userID int64, nodes ...Node) (SendForwardResult, error) {
|
||||||
|
return CallAPI[SendForwardResult](a.call, ctx, "send_private_forward_msg", H{"user_id": userID, "messages": nodesPayload(nodes)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendForwardMsg 通过 NapCat 扩展接口发送合并转发(同时适用于群与私聊场景的构造)。
|
||||||
|
func (a API) SendForwardMsg(ctx context.Context, nodes ...Node) (SendForwardResult, error) {
|
||||||
|
return CallAPI[SendForwardResult](a.call, ctx, "send_forward_msg", H{"messages": nodesPayload(nodes)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForwardFriendSingleMsg 把一条已有消息转发给好友。
|
||||||
|
func (a API) ForwardFriendSingleMsg(ctx context.Context, userID, messageID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "forward_friend_single_msg", H{"user_id": userID, "message_id": messageID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForwardGroupSingleMsg 把一条已有消息转发到群。
|
||||||
|
func (a API) ForwardGroupSingleMsg(ctx context.Context, groupID, messageID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "forward_group_single_msg", H{"group_id": groupID, "message_id": messageID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 历史消息 ----
|
||||||
|
|
||||||
|
// GetFriendMsgHistory 拉取好友历史消息。messageSeq 传 0 表示从最新开始。
|
||||||
|
func (a API) GetFriendMsgHistory(ctx context.Context, userID, messageSeq int64, count int) (HistoryMessages, error) {
|
||||||
|
return CallAPI[HistoryMessages](a.call, ctx, "get_friend_msg_history", H{"user_id": userID, "message_seq": messageSeq, "count": count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupMsgHistory 拉取群历史消息。messageSeq 传 0 表示从最新开始。
|
||||||
|
func (a API) GetGroupMsgHistory(ctx context.Context, groupID, messageSeq int64, count int, reverse bool) (HistoryMessages, error) {
|
||||||
|
return CallAPI[HistoryMessages](a.call, ctx, "get_group_msg_history", H{"group_id": groupID, "message_seq": messageSeq, "count": count, "reverse": reverse})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 好友与群信息 ----
|
||||||
|
|
||||||
|
// GetLoginInfo 获取登录号信息。
|
||||||
|
func (a API) GetLoginInfo(ctx context.Context) (LoginInfo, error) {
|
||||||
|
return CallAPI[LoginInfo](a.call, ctx, "get_login_info", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStrangerInfo 获取陌生人信息。
|
||||||
|
func (a API) GetStrangerInfo(ctx context.Context, userID int64, noCache bool) (StrangerInfo, error) {
|
||||||
|
return CallAPI[StrangerInfo](a.call, ctx, "get_stranger_info", H{"user_id": userID, "no_cache": noCache})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFriendList 获取好友列表。
|
||||||
|
func (a API) GetFriendList(ctx context.Context) ([]FriendInfo, error) {
|
||||||
|
return CallAPI[[]FriendInfo](a.call, ctx, "get_friend_list", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFriendsWithCategory 获取分类好友列表(NapCat 扩展)。
|
||||||
|
func (a API) GetFriendsWithCategory(ctx context.Context) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "get_friends_with_category", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFriend 删除好友。
|
||||||
|
func (a API) DeleteFriend(ctx context.Context, userID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "delete_friend", H{"user_id": userID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupInfo 获取群信息。
|
||||||
|
func (a API) GetGroupInfo(ctx context.Context, groupID int64) (GroupInfo, error) {
|
||||||
|
return CallAPI[GroupInfo](a.call, ctx, "get_group_info", H{"group_id": groupID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupInfoEx 获取群扩展信息(NapCat 扩展)。
|
||||||
|
func (a API) GetGroupInfoEx(ctx context.Context, groupID int64) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "get_group_info_ex", H{"group_id": groupID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupList 获取群列表。
|
||||||
|
func (a API) GetGroupList(ctx context.Context) ([]GroupInfo, error) {
|
||||||
|
return CallAPI[[]GroupInfo](a.call, ctx, "get_group_list", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupMemberInfo 获取群成员信息。
|
||||||
|
func (a API) GetGroupMemberInfo(ctx context.Context, groupID, userID int64, noCache bool) (GroupMemberInfo, error) {
|
||||||
|
return CallAPI[GroupMemberInfo](a.call, ctx, "get_group_member_info", H{"group_id": groupID, "user_id": userID, "no_cache": noCache})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupMemberList 获取群成员列表。
|
||||||
|
func (a API) GetGroupMemberList(ctx context.Context, groupID int64) ([]GroupMemberInfo, error) {
|
||||||
|
return CallAPI[[]GroupMemberInfo](a.call, ctx, "get_group_member_list", H{"group_id": groupID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupHonorInfo 获取群荣誉信息,honorType 取值 talky/performer/legend/strong_newbie/emotion/all。
|
||||||
|
func (a API) GetGroupHonorInfo(ctx context.Context, groupID int64, honorType string) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "get_group_honor_info", H{"group_id": groupID, "type": honorType})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 群管理 ----
|
||||||
|
|
||||||
|
// SetGroupKick 踢出群成员,rejectAddRequest 表示同时拒绝其再次加群。
|
||||||
|
func (a API) SetGroupKick(ctx context.Context, groupID, userID int64, rejectAddRequest bool) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_kick", H{"group_id": groupID, "user_id": userID, "reject_add_request": rejectAddRequest})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupBan 禁言群成员,duration 为秒数,0 表示解除禁言。
|
||||||
|
func (a API) SetGroupBan(ctx context.Context, groupID, userID, duration int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_ban", H{"group_id": groupID, "user_id": userID, "duration": duration})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupWholeBan 全员禁言。
|
||||||
|
func (a API) SetGroupWholeBan(ctx context.Context, groupID int64, enable bool) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_whole_ban", H{"group_id": groupID, "enable": enable})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupAdmin 设置/取消群管理员。
|
||||||
|
func (a API) SetGroupAdmin(ctx context.Context, groupID, userID int64, enable bool) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_admin", H{"group_id": groupID, "user_id": userID, "enable": enable})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupCard 设置群名片。
|
||||||
|
func (a API) SetGroupCard(ctx context.Context, groupID, userID int64, card string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_card", H{"group_id": groupID, "user_id": userID, "card": card})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupName 设置群名。
|
||||||
|
func (a API) SetGroupName(ctx context.Context, groupID int64, name string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_name", H{"group_id": groupID, "group_name": name})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupLeave 退群,dismiss 仅群主可用(解散群)。
|
||||||
|
func (a API) SetGroupLeave(ctx context.Context, groupID int64, dismiss bool) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_leave", H{"group_id": groupID, "is_dismiss": dismiss})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupSpecialTitle 设置群专属头衔。
|
||||||
|
func (a API) SetGroupSpecialTitle(ctx context.Context, groupID, userID int64, title string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_special_title", H{"group_id": groupID, "user_id": userID, "special_title": title})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupPortrait 设置群头像。
|
||||||
|
func (a API) SetGroupPortrait(ctx context.Context, groupID int64, file string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_portrait", H{"group_id": groupID, "file": file})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupSign 群签到(go-cqhttp 兼容)。
|
||||||
|
func (a API) SetGroupSign(ctx context.Context, groupID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_sign", H{"group_id": groupID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendGroupSign 发送群签到(NapCat 扩展)。
|
||||||
|
func (a API) SendGroupSign(ctx context.Context, groupID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "send_group_sign", H{"group_id": groupID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupAtAllRemain 获取剩余 @全体成员 次数。
|
||||||
|
func (a API) GetGroupAtAllRemain(ctx context.Context, groupID int64) (AtAllRemain, error) {
|
||||||
|
return CallAPI[AtAllRemain](a.call, ctx, "get_group_at_all_remain", H{"group_id": groupID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupShutList 获取群禁言列表(NapCat 扩展)。
|
||||||
|
func (a API) GetGroupShutList(ctx context.Context, groupID int64) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "get_group_shut_list", H{"group_id": groupID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 戳一戳 / 状态 ----
|
||||||
|
|
||||||
|
// SendPoke 戳一戳(群聊场景)。
|
||||||
|
func (a API) SendPoke(ctx context.Context, groupID, userID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "send_poke", H{"group_id": groupID, "user_id": userID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FriendPoke 戳好友。
|
||||||
|
func (a API) FriendPoke(ctx context.Context, userID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "friend_poke", H{"user_id": userID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupPoke 在群里戳某人。
|
||||||
|
func (a API) GroupPoke(ctx context.Context, groupID, userID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "group_poke", H{"group_id": groupID, "user_id": userID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetInputStatus 设置好友会话的「正在输入」等状态,eventType 取值 1(文字)/2(语音)/3(视频)/4(文件)。
|
||||||
|
func (a API) SetInputStatus(ctx context.Context, userID int64, eventType int) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_input_status", H{"user_id": userID, "event_type": eventType})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOnlineStatus 设置在线状态(NapCat 扩展)。
|
||||||
|
func (a API) SetOnlineStatus(ctx context.Context, statusID, extCode int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_online_status", H{"status": statusID, "ext_status": extCode})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSelfLongNick 设置签名/长昵称。
|
||||||
|
func (a API) SetSelfLongNick(ctx context.Context, longNick string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_self_longnick", H{"long_nick": longNick})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserStatus 获取用户在线状态(NapCat 扩展)。
|
||||||
|
func (a API) GetUserStatus(ctx context.Context, userID int64) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "nc_get_user_status", H{"user_id": userID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPacketStatus 获取NapCat底层包状态(NapCat 扩展)。
|
||||||
|
func (a API) GetPacketStatus(ctx context.Context) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "nc_get_packet_status", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProfileLike 获取用户被点赞信息(NapCat 扩展)。
|
||||||
|
func (a API) GetProfileLike(ctx context.Context, userID int64) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "get_profile_like", H{"user_id": userID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 请求处理 ----
|
||||||
|
|
||||||
|
// SetFriendAddRequest 处理加好友请求,approve 为是否同意,remark 为好友备注。
|
||||||
|
func (a API) SetFriendAddRequest(ctx context.Context, flag string, approve bool, remark string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_friend_add_request", H{"flag": flag, "approve": approve, "remark": remark})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetGroupAddRequest 处理加群/入群邀请,subType 取值 add/invite,reason 为拒绝理由。
|
||||||
|
func (a API) SetGroupAddRequest(ctx context.Context, flag, subType string, approve bool, reason string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_group_add_request", H{"flag": flag, "sub_type": subType, "approve": approve, "reason": reason})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 文件 ----
|
||||||
|
|
||||||
|
// UploadGroupFile 上传群文件,folderID 传空串则上传到根目录。
|
||||||
|
func (a API) UploadGroupFile(ctx context.Context, groupID int64, file, name, folderID string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "upload_group_file", H{"group_id": groupID, "file": file, "name": name, "folder_id": folderID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadPrivateFile 上传私聊文件。
|
||||||
|
func (a API) UploadPrivateFile(ctx context.Context, userID int64, file, name string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "upload_private_file", H{"user_id": userID, "file": file, "name": name})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroupFile 删除群文件。
|
||||||
|
func (a API) DeleteGroupFile(ctx context.Context, groupID int64, fileID string, busID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "delete_group_file", H{"group_id": groupID, "file_id": fileID, "busid": busID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteGroupFolder 删除群文件夹。
|
||||||
|
func (a API) DeleteGroupFolder(ctx context.Context, groupID int64, folderID string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "delete_group_folder", H{"group_id": groupID, "folder_id": folderID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateGroupFileFolder 创建群文件夹,parentID 传 "/" 表示根目录。
|
||||||
|
func (a API) CreateGroupFileFolder(ctx context.Context, groupID int64, name, parentID string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "create_group_file_folder", H{"group_id": groupID, "name": name, "parent_id": parentID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupFileSystemInfo 获取群文件系统信息。
|
||||||
|
func (a API) GetGroupFileSystemInfo(ctx context.Context, groupID int64) (FileSystemInfo, error) {
|
||||||
|
return CallAPI[FileSystemInfo](a.call, ctx, "get_group_file_system_info", H{"group_id": groupID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupRootFiles 获取群根目录文件列表。
|
||||||
|
func (a API) GetGroupRootFiles(ctx context.Context, groupID int64) (FileList, error) {
|
||||||
|
return CallAPI[FileList](a.call, ctx, "get_group_root_files", H{"group_id": groupID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupFilesByFolder 获取群指定文件夹内的文件列表。
|
||||||
|
func (a API) GetGroupFilesByFolder(ctx context.Context, groupID int64, folderID string) (FileList, error) {
|
||||||
|
return CallAPI[FileList](a.call, ctx, "get_group_files_by_folder", H{"group_id": groupID, "folder_id": folderID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupFileURL 获取群文件下载链接。
|
||||||
|
func (a API) GetGroupFileURL(ctx context.Context, groupID int64, fileID string, busID int64) (string, error) {
|
||||||
|
out, err := CallAPI[struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}](a.call, ctx, "get_group_file_url", H{"group_id": groupID, "file_id": fileID, "busid": busID})
|
||||||
|
return out.URL, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DownloadFile 通过 NapCat 下载文件到本地,headers 形如 ["Cookie: a=1"]。
|
||||||
|
func (a API) DownloadFile(ctx context.Context, url string, headers []string, threadCount int) (DownloadedFile, error) {
|
||||||
|
return CallAPI[DownloadedFile](a.call, ctx, "download_file", H{"url": url, "headers": headers, "thread_cnt": threadCount})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFile 获取已收到的文件(消息段里的 file_id),返回本地缓存路径。
|
||||||
|
func (a API) GetFile(ctx context.Context, fileID string) (DownloadedFile, error) {
|
||||||
|
return CallAPI[DownloadedFile](a.call, ctx, "get_file", H{"file_id": fileID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckURLSafely 检查 URL 安全等级(1 安全 2 危险 3 未验证)。
|
||||||
|
func (a API) CheckURLSafely(ctx context.Context, url string) (URLSafety, error) {
|
||||||
|
return CallAPI[URLSafety](a.call, ctx, "check_url_safely", H{"url": url})
|
||||||
|
}
|
||||||
|
|
||||||
|
// OCRImage 对图片执行 OCR(NapCat 扩展)。
|
||||||
|
func (a API) OCRImage(ctx context.Context, image string) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "ocr_image", H{"image": image})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 媒体 ----
|
||||||
|
|
||||||
|
// GetImage 获取图片信息(本地缓存路径与 URL)。
|
||||||
|
func (a API) GetImage(ctx context.Context, file string) (ImageResult, error) {
|
||||||
|
return CallAPI[ImageResult](a.call, ctx, "get_image", H{"file": file})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecord 获取语音并转换为指定格式(如 "mp3"、"amr")。
|
||||||
|
func (a API) GetRecord(ctx context.Context, file, outFormat string) (RecordResult, error) {
|
||||||
|
return CallAPI[RecordResult](a.call, ctx, "get_record", H{"file": file, "out_format": outFormat})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanSendImage 检查能否发送图片。
|
||||||
|
func (a API) CanSendImage(ctx context.Context) (bool, error) {
|
||||||
|
return CallAPI[bool](a.call, ctx, "can_send_image", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanSendRecord 检查能否发送语音。
|
||||||
|
func (a API) CanSendRecord(ctx context.Context) (bool, error) {
|
||||||
|
return CallAPI[bool](a.call, ctx, "can_send_record", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchCustomFace 拉取自定义表情(NapCat 扩展)。
|
||||||
|
func (a API) FetchCustomFace(ctx context.Context, count int) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "fetch_custom_face", H{"count": count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchEmojiLike 拉取表情联动搜索结果(NapCat 扩展)。
|
||||||
|
func (a API) FetchEmojiLike(ctx context.Context, keyword string, page int) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "fetch_emoji_like", H{"key": keyword, "page_no": page, "page_size": 20})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAICharacters 获取 AI 语音角色列表(NapCat 扩展)。
|
||||||
|
func (a API) GetAICharacters(ctx context.Context) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "get_ai_characters", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendGroupAIRecord 发送群 AI 语音(NapCat 扩展)。
|
||||||
|
func (a API) SendGroupAIRecord(ctx context.Context, groupID int64, character, text string) (MessageID, error) {
|
||||||
|
out, err := CallAPI[messageIDResult](a.call, ctx, "send_group_ai_record", H{"group_id": groupID, "character": character, "text": text})
|
||||||
|
return MessageID(out.MessageID), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMiniAppArk 生成小程序卡片 JSON(NapCat 扩展)。
|
||||||
|
func (a API) GetMiniAppArk(ctx context.Context, kind, jsonPayload string) (MiniAppArk, error) {
|
||||||
|
return CallAPI[MiniAppArk](a.call, ctx, "get_mini_app_ark", H{"kind": kind, "json": jsonPayload})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArkShareGroup 生成群分享卡片(NapCat 扩展)。
|
||||||
|
func (a API) ArkShareGroup(ctx context.Context, groupID int64) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "ArkShareGroup", H{"group_id": groupID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArkSharePeer 生成单聊分享卡片(NapCat 扩展)。
|
||||||
|
func (a API) ArkSharePeer(ctx context.Context, userID int64) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "ArkSharePeer", H{"user_id": userID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranslateEn2Zh 英译中(NapCat 扩展)。
|
||||||
|
func (a API) TranslateEn2Zh(ctx context.Context, words []string) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "translate_en2zh", H{"words": words})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRKey 获取图片 rkey(NapCat 扩展)。
|
||||||
|
func (a API) GetRKey(ctx context.Context) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "nc_get_rkey", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 系统与凭证 ----
|
||||||
|
|
||||||
|
// GetStatus 获取运行状态。
|
||||||
|
func (a API) GetStatus(ctx context.Context) (Status, error) {
|
||||||
|
return CallAPI[Status](a.call, ctx, "get_status", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersionInfo 获取版本信息。
|
||||||
|
func (a API) GetVersionInfo(ctx context.Context) (VersionInfo, error) {
|
||||||
|
return CallAPI[VersionInfo](a.call, ctx, "get_version_info", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanCache 清理缓存。
|
||||||
|
func (a API) CleanCache(ctx context.Context) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "clean_cache", H{})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRestart 重启 OneBot 实现。
|
||||||
|
func (a API) SetRestart(ctx context.Context, delayMS int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_restart", H{"delay": delayMS})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCookies 获取指定域名的 Cookies。
|
||||||
|
func (a API) GetCookies(ctx context.Context, domain string) (Cookies, error) {
|
||||||
|
return CallAPI[Cookies](a.call, ctx, "get_cookies", H{"domain": domain})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCSRFToken 获取 CSRF Token。
|
||||||
|
func (a API) GetCSRFToken(ctx context.Context) (CSRFToken, error) {
|
||||||
|
return CallAPI[CSRFToken](a.call, ctx, "get_csrf_token", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCredentials 获取 Cookies 与 CSRF Token。
|
||||||
|
func (a API) GetCredentials(ctx context.Context, domain string) (Credentials, error) {
|
||||||
|
return CallAPI[Credentials](a.call, ctx, "get_credentials", H{"domain": domain})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOnlineClients 获取账号在其他端的在线客户端。
|
||||||
|
func (a API) GetOnlineClients(ctx context.Context, noCache bool) (OnlineClients, error) {
|
||||||
|
return CallAPI[OnlineClients](a.call, ctx, "get_online_clients", H{"no_cache": noCache})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQQProfile 设置登录号资料。
|
||||||
|
func (a API) SetQQProfile(ctx context.Context, nickname, company, email, college, personalNote string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_qq_profile", H{
|
||||||
|
"nickname": nickname, "company": company, "email": email,
|
||||||
|
"college": college, "personal_note": personalNote,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetQQAvatar 设置登录号头像。
|
||||||
|
func (a API) SetQQAvatar(ctx context.Context, file string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_qq_avatar", H{"file": file})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetModelShow 获取在线机型展示(go-cqhttp 兼容)。
|
||||||
|
func (a API) GetModelShow(ctx context.Context, model string) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "_get_model_show", H{"model": model})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetModelShow 设置在线机型展示(go-cqhttp 兼容)。
|
||||||
|
func (a API) SetModelShow(ctx context.Context, model, modelShow string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "_set_model_show", H{"model": model, "model_show": modelShow})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRobotUinRange 获取官方机器人号段(NapCat 扩展)。
|
||||||
|
func (a API) GetRobotUinRange(ctx context.Context) (RobotUinRanges, error) {
|
||||||
|
return CallAPI[RobotUinRanges](a.call, ctx, "get_robot_uin_range", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentContact 获取最近联系人列表(NapCat 扩展)。
|
||||||
|
func (a API) GetRecentContact(ctx context.Context, count int) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "get_recent_contact", H{"count": count})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 精华消息 ----
|
||||||
|
|
||||||
|
// GetEssenceMsgList 获取群精华消息列表。
|
||||||
|
func (a API) GetEssenceMsgList(ctx context.Context, groupID int64) ([]EssenceMessage, error) {
|
||||||
|
return CallAPI[[]EssenceMessage](a.call, ctx, "get_essence_msg_list", H{"group_id": groupID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEssenceMsg 设置精华消息。
|
||||||
|
func (a API) SetEssenceMsg(ctx context.Context, messageID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "set_essence_msg", H{"message_id": messageID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteEssenceMsg 移除精华消息。
|
||||||
|
func (a API) DeleteEssenceMsg(ctx context.Context, messageID int64) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "delete_essence_msg", H{"message_id": messageID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 群公告 ----
|
||||||
|
|
||||||
|
// SendGroupNotice 发布群公告(go-cqhttp 兼容 _send_group_notice)。
|
||||||
|
func (a API) SendGroupNotice(ctx context.Context, groupID int64, text, image string) error {
|
||||||
|
params := H{"group_id": groupID, "content": text}
|
||||||
|
if image != "" {
|
||||||
|
params["image"] = image
|
||||||
|
}
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "_send_group_notice", params)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupNotice 获取群公告列表(go-cqhttp 兼容 _get_group_notice)。
|
||||||
|
func (a API) GetGroupNotice(ctx context.Context, groupID int64) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "_get_group_notice", H{"group_id": groupID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DelGroupNotice 删除群公告(NapCat 扩展 _del_group_notice)。
|
||||||
|
func (a API) DelGroupNotice(ctx context.Context, groupID int64, noticeID string) error {
|
||||||
|
_, err := CallAPI[jsonNull](a.call, ctx, "_del_group_notice", H{"group_id": groupID, "notice_id": noticeID})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 群系统消息 ----
|
||||||
|
|
||||||
|
// GetGroupSystemMsg 获取加群邀请等系统消息。
|
||||||
|
func (a API) GetGroupSystemMsg(ctx context.Context) (jsonRaw, error) {
|
||||||
|
return CallAPI[jsonRaw](a.call, ctx, "get_group_system_msg", H{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonRaw 承接未结构化的扩展 API 返回。
|
||||||
|
type jsonRaw map[string]any
|
||||||
|
|
||||||
|
// jsonNull 用于不需要返回值的 action。
|
||||||
|
type jsonNull = struct{}
|
||||||
127
caller.go
Normal file
127
caller.go
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
package napcat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Caller 是一条可执行 OneBot action 的通道(WS 会话或 HTTP API)。
|
||||||
|
type Caller interface {
|
||||||
|
Call(ctx context.Context, action string, params any, out any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CallAPI 通过指定 Caller 调用一个 action 并把 data 解码为 T。
|
||||||
|
// ctx 为 nil 时使用 30 秒超时。
|
||||||
|
func CallAPI[T any](c Caller, ctx context.Context, action string, params any) (T, error) {
|
||||||
|
var zero T
|
||||||
|
if c == nil {
|
||||||
|
return zero, ErrNotConnected
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
var out T
|
||||||
|
if err := c.Call(ctx, action, params, &out); err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsRequest 是 WS 上发出的 action 请求。
|
||||||
|
type wsRequest struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Params any `json:"params,omitempty"`
|
||||||
|
Echo string `json:"echo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// wsCaller 基于一条 WebSocket 连接实现 Caller:
|
||||||
|
// 发出的请求带自增 echo,读循环把响应按 echo 路由回等待方。
|
||||||
|
type wsCaller struct {
|
||||||
|
wmu sync.Mutex
|
||||||
|
writer messageWriter
|
||||||
|
pending sync.Map // echo(string) -> chan Response
|
||||||
|
seq atomic.Int64
|
||||||
|
done <-chan struct{} // 连接关闭时被关闭
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageWriter 抽象 gorilla/websocket 的写方法,便于单测替换。
|
||||||
|
type messageWriter interface {
|
||||||
|
WriteMessage(messageType int, data []byte) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func newWSCaller(w messageWriter, done <-chan struct{}) *wsCaller {
|
||||||
|
return &wsCaller{writer: w, done: done}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *wsCaller) Call(ctx context.Context, action string, params any, out any) error {
|
||||||
|
if ctx == nil {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
echo := strconv.FormatInt(w.seq.Add(1), 10)
|
||||||
|
payload, err := json.Marshal(wsRequest{Action: action, Params: params, Echo: echo})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("napcat: marshal action %s: %w", action, err)
|
||||||
|
}
|
||||||
|
ch := make(chan Response, 1)
|
||||||
|
w.pending.Store(echo, ch)
|
||||||
|
defer w.pending.Delete(echo)
|
||||||
|
|
||||||
|
w.wmu.Lock()
|
||||||
|
err = w.writer.WriteMessage(1 /* websocket.TextMessage */, payload)
|
||||||
|
w.wmu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("napcat: send action %s: %w", action, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case resp := <-ch:
|
||||||
|
if err := resp.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := resp.Decode(out); err != nil {
|
||||||
|
return fmt.Errorf("napcat: decode data of %s: %w", action, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-w.done:
|
||||||
|
return ErrNotConnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeResponse 由读循环调用:把带 echo 的响应分发给等待者,返回是否路由成功。
|
||||||
|
func (w *wsCaller) routeResponse(data []byte, log logger) bool {
|
||||||
|
var resp Response
|
||||||
|
if err := json.Unmarshal(data, &resp); err != nil {
|
||||||
|
log.Warn("napcat: unmarshal response failed", "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
v, ok := w.pending.Load(resp.Echo)
|
||||||
|
if !ok {
|
||||||
|
log.Warn("napcat: no pending action for echo", "echo", resp.Echo)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ch := v.(chan Response)
|
||||||
|
select {
|
||||||
|
case ch <- resp:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// logger 是框架依赖的最小日志接口(*slog.Logger 满足它)。
|
||||||
|
type logger interface {
|
||||||
|
Warn(msg string, args ...any)
|
||||||
|
Info(msg string, args ...any)
|
||||||
|
Error(msg string, args ...any)
|
||||||
|
Debug(msg string, args ...any)
|
||||||
|
}
|
||||||
477
client.go
Normal file
477
client.go
Normal file
@ -0,0 +1,477 @@
|
|||||||
|
package napcat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandlerFunc 是事件处理函数。
|
||||||
|
type HandlerFunc func(c *Context)
|
||||||
|
|
||||||
|
// CommandFunc 是命令处理函数,args 为按空白切分的参数。
|
||||||
|
type CommandFunc func(c *Context, args []string)
|
||||||
|
|
||||||
|
// Connector 是一种通信方式(正向 WS / 反向 WS / HTTP 上报 / HTTP API)。
|
||||||
|
// 常规连接直接用 Client 的 Set* 方法配置,本接口留给自定义传输扩展。
|
||||||
|
type Connector interface {
|
||||||
|
Name() string
|
||||||
|
Serve(ctx context.Context, bot *Client) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type handlerSet struct {
|
||||||
|
onEvent []HandlerFunc
|
||||||
|
onMessage []HandlerFunc
|
||||||
|
onPrivate []HandlerFunc
|
||||||
|
onGroup []HandlerFunc
|
||||||
|
onNotice []HandlerFunc
|
||||||
|
onRequest []HandlerFunc
|
||||||
|
onMeta []HandlerFunc
|
||||||
|
noticeTypes map[string][]HandlerFunc
|
||||||
|
commands map[string]CommandFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client 是与 NapCat 交互的客户端对象:Set* 配置连接,On* 注册事件处理,
|
||||||
|
// Run 启动后直接调用其内嵌的 API 方法即可操作 NapCat。
|
||||||
|
type Client struct {
|
||||||
|
// API 内嵌了全部类型化 API 方法,走 Client 的默认调用通道。
|
||||||
|
API
|
||||||
|
|
||||||
|
log logger
|
||||||
|
token string // OneBot access_token
|
||||||
|
secret string // HTTP 上报 HMAC-SHA1 密钥
|
||||||
|
connectors []Connector
|
||||||
|
httpAPI *HTTPAPI
|
||||||
|
current atomic.Pointer[Session]
|
||||||
|
lastBeat atomic.Int64
|
||||||
|
|
||||||
|
sessMu sync.Mutex
|
||||||
|
sessions map[*Session]struct{}
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
hs handlerSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// New 创建一个与 NapCat 交互的客户端对象。
|
||||||
|
// 用 Set* 方法配置连接后调用 Run 启动:
|
||||||
|
//
|
||||||
|
// bot := napcat.New().SetToken("xxx").SetForwardWS("ws://127.0.0.1:3001")
|
||||||
|
// bot.OnMessage(func(c *napcat.Context) { c.ReplyText("hi") })
|
||||||
|
// bot.Run(ctx)
|
||||||
|
func New() *Client {
|
||||||
|
c := &Client{log: slog.Default(), sessions: map[*Session]struct{}{}}
|
||||||
|
c.API = API{call: c}
|
||||||
|
c.hs.noticeTypes = map[string][]HandlerFunc{}
|
||||||
|
c.hs.commands = map[string]CommandFunc{}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetToken 设置 OneBot access_token(WS 握手与 HTTP 调用自动携带,可留空)。
|
||||||
|
func (c *Client) SetToken(token string) *Client {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.token = token
|
||||||
|
if c.httpAPI != nil {
|
||||||
|
c.httpAPI.token.Store(token)
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSecret 设置 HTTP 上报的 HMAC-SHA1 校验密钥,留空表示不校验。
|
||||||
|
func (c *Client) SetSecret(secret string) *Client {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.secret = secret
|
||||||
|
c.mu.Unlock()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLogger 设置自定义日志器,默认 slog.Default()。
|
||||||
|
func (c *Client) SetLogger(l *slog.Logger) *Client {
|
||||||
|
if l == nil {
|
||||||
|
l = slog.Default()
|
||||||
|
}
|
||||||
|
c.log = l
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetForwardWS 配置正向 WebSocket:本框架作为客户端连接 NapCat 的 WS 服务。
|
||||||
|
// 可传多个地址同时连接多个 NapCat 实例;重复调用会替换之前的正向 WS 配置。
|
||||||
|
func (c *Client) SetForwardWS(urls ...string) *Client {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.connectors = dropConnectors[*ForwardWS](c.connectors)
|
||||||
|
for _, u := range urls {
|
||||||
|
c.connectors = append(c.connectors, &ForwardWS{url: u})
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReverseWS 配置反向 WebSocket:本框架监听 addr,由 NapCat 主动连入。
|
||||||
|
// 每个监听地址可承接任意多个 NapCat 实例;重复调用会替换之前的反向 WS 配置。
|
||||||
|
func (c *Client) SetReverseWS(addrs ...string) *Client {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.connectors = dropConnectors[*ReverseWS](c.connectors)
|
||||||
|
for _, a := range addrs {
|
||||||
|
c.connectors = append(c.connectors, &ReverseWS{addr: a})
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHTTPAPI 配置 HTTP API:执行 action 时 POST 到 NapCat 的 HTTP 服务。
|
||||||
|
// 没有任何 WS 会话时 Client.Call 走该通道;重复调用替换之前的配置。
|
||||||
|
func (c *Client) SetHTTPAPI(baseURL string) *Client {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.connectors = dropConnectors[*HTTPAPI](c.connectors)
|
||||||
|
api := NewHTTPAPI(baseURL)
|
||||||
|
api.token.Store(c.token)
|
||||||
|
c.httpAPI = api
|
||||||
|
c.connectors = append(c.connectors, api)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetHTTPReport 配置 HTTP POST 上报:本框架监听 addr,NapCat 把事件 POST 过来,
|
||||||
|
// handler 里的 Reply/Approve 以 OneBot 快速操作形式随响应返回。
|
||||||
|
// 可配置多个监听地址;重复调用会替换之前的上报配置。
|
||||||
|
func (c *Client) SetHTTPReport(addrs ...string) *Client {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.connectors = dropConnectors[*HTTPReport](c.connectors)
|
||||||
|
for _, a := range addrs {
|
||||||
|
c.connectors = append(c.connectors, &HTTPReport{addr: a})
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use 注册自定义 Connector(高级用法;常规连接请用 Set* 方法)。
|
||||||
|
func (c *Client) Use(conns ...Connector) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
for _, conn := range conns {
|
||||||
|
if api, ok := conn.(*HTTPAPI); ok {
|
||||||
|
c.httpAPI = api
|
||||||
|
api.token.Store(c.token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.connectors = append(c.connectors, conns...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token 返回当前 access_token。
|
||||||
|
func (c *Client) Token() string { return c.token }
|
||||||
|
|
||||||
|
// dropConnectors 过滤掉类型为 T 的连接器。
|
||||||
|
func dropConnectors[T Connector](conns []Connector) []Connector {
|
||||||
|
out := make([]Connector, 0, len(conns))
|
||||||
|
for _, conn := range conns {
|
||||||
|
if _, ok := any(conn).(T); ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, conn)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelfID 返回当前会话的机器人 QQ 号(连接后有效,否则 0)。
|
||||||
|
func (c *Client) SelfID() int64 {
|
||||||
|
if s := c.current.Load(); s != nil {
|
||||||
|
return s.SelfID()
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastHeartbeat 返回最近一次心跳时间;从未收到过心跳则返回零值。
|
||||||
|
func (c *Client) LastHeartbeat() time.Time {
|
||||||
|
unix := c.lastBeat.Load()
|
||||||
|
if unix == 0 {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
return time.Unix(0, unix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run 启动所有已配置的连接并阻塞,直到 ctx 取消(正常返回 nil)或某个连接致命出错。
|
||||||
|
// 取消后会主动关闭所有会话与监听,Run 返回即代表端口与连接已释放。
|
||||||
|
// 请在 Run 之前完成 Set* 配置与 On* 注册。
|
||||||
|
func (c *Client) Run(ctx context.Context) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
conns := append([]Connector(nil), c.connectors...)
|
||||||
|
c.mu.Unlock()
|
||||||
|
if len(conns) == 0 {
|
||||||
|
return errors.New("napcat: no connection configured, call SetForwardWS/SetReverseWS/SetHTTPReport first")
|
||||||
|
}
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errCh := make(chan error, len(conns))
|
||||||
|
for _, conn := range conns {
|
||||||
|
conn := conn
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
err := conn.Serve(runCtx, c)
|
||||||
|
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case errCh <- fmt.Errorf("connector %s: %w", conn.Name(), err):
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
var runErr error
|
||||||
|
select {
|
||||||
|
case runErr = <-errCh:
|
||||||
|
c.log.Error("napcat: connector fatal, shutting down", "err", runErr)
|
||||||
|
case <-ctx.Done():
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
c.closeSessions()
|
||||||
|
shCtx, shCancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer shCancel()
|
||||||
|
for _, conn := range conns {
|
||||||
|
if sh, ok := conn.(interface{ Shutdown(context.Context) error }); ok {
|
||||||
|
_ = sh.Shutdown(shCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if runErr != nil {
|
||||||
|
return runErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) closeSessions() {
|
||||||
|
c.sessMu.Lock()
|
||||||
|
defer c.sessMu.Unlock()
|
||||||
|
for s := range c.sessions {
|
||||||
|
_ = s.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call 实现 Caller:优先走当前 WS 会话,其次走 HTTP API,都没有则报错。
|
||||||
|
func (c *Client) Call(ctx context.Context, action string, params any, out any) error {
|
||||||
|
if s := c.current.Load(); s != nil {
|
||||||
|
return s.Call(ctx, action, params, out)
|
||||||
|
}
|
||||||
|
if c.httpAPI != nil {
|
||||||
|
return c.httpAPI.Call(ctx, action, params, out)
|
||||||
|
}
|
||||||
|
return ErrNotConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) bindSession(s *Session) {
|
||||||
|
c.sessMu.Lock()
|
||||||
|
c.sessions[s] = struct{}{}
|
||||||
|
c.sessMu.Unlock()
|
||||||
|
c.current.Store(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) unbindSession(s *Session) {
|
||||||
|
c.sessMu.Lock()
|
||||||
|
delete(c.sessions, s)
|
||||||
|
c.sessMu.Unlock()
|
||||||
|
c.current.CompareAndSwap(s, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleRaw 处理 WS 连接上的一条原始消息:事件则入队分发,带 echo 的则为 API 响应。
|
||||||
|
func (c *Client) handleRaw(s *Session, data []byte) {
|
||||||
|
var probe struct {
|
||||||
|
PostType string `json:"post_type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &probe); err != nil {
|
||||||
|
c.log.Warn("napcat: invalid payload", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if probe.PostType != "" {
|
||||||
|
ev, err := decodeEvent(data)
|
||||||
|
if err != nil {
|
||||||
|
c.log.Warn("napcat: decode event failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ev.SelfID != 0 {
|
||||||
|
s.selfID.Store(ev.SelfID)
|
||||||
|
}
|
||||||
|
if ev.Meta != nil {
|
||||||
|
if ev.Meta.MetaType == "heartbeat" {
|
||||||
|
c.lastBeat.Store(time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
if ev.Meta.MetaType == "lifecycle" && ev.Meta.SubType == "connect" {
|
||||||
|
c.log.Info("napcat: bot connected", "self_id", ev.SelfID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.enqueue(ev, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.caller.routeResponse(data, c.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 事件处理注册 ----
|
||||||
|
|
||||||
|
// OnEvent 注册处理所有事件的函数。
|
||||||
|
func (c *Client) OnEvent(fn HandlerFunc) { c.add(&c.hs.onEvent, fn) }
|
||||||
|
|
||||||
|
// OnMessage 注册处理私聊+群聊消息事件(含 message_sent)的函数。
|
||||||
|
func (c *Client) OnMessage(fn HandlerFunc) { c.add(&c.hs.onMessage, fn) }
|
||||||
|
|
||||||
|
// OnPrivateMessage 注册处理私聊消息事件的函数。
|
||||||
|
func (c *Client) OnPrivateMessage(fn HandlerFunc) { c.add(&c.hs.onPrivate, fn) }
|
||||||
|
|
||||||
|
// OnGroupMessage 注册处理群聊消息事件的函数。
|
||||||
|
func (c *Client) OnGroupMessage(fn HandlerFunc) { c.add(&c.hs.onGroup, fn) }
|
||||||
|
|
||||||
|
// OnNotice 注册处理所有通知事件的函数。
|
||||||
|
func (c *Client) OnNotice(fn HandlerFunc) { c.add(&c.hs.onNotice, fn) }
|
||||||
|
|
||||||
|
// OnNoticeType 注册处理指定 notice_type 通知事件的函数(如 "notify"、"group_recall")。
|
||||||
|
func (c *Client) OnNoticeType(noticeType string, fn HandlerFunc) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.hs.noticeTypes[noticeType] = append(c.hs.noticeTypes[noticeType], fn)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnRequest 注册处理所有请求事件(好友/群)的函数。
|
||||||
|
func (c *Client) OnRequest(fn HandlerFunc) { c.add(&c.hs.onRequest, fn) }
|
||||||
|
|
||||||
|
// OnMeta 注册处理元事件(心跳/生命周期)的函数。
|
||||||
|
func (c *Client) OnMeta(fn HandlerFunc) { c.add(&c.hs.onMeta, fn) }
|
||||||
|
|
||||||
|
// OnCommand 注册文本命令:消息文本等于 name 或以 name+空白 开头时触发。
|
||||||
|
// 命令名自带头尾风格(如 "/ping" 或 "ping")。
|
||||||
|
func (c *Client) OnCommand(name string, fn CommandFunc) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.hs.commands[name] = fn
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) add(dst *[]HandlerFunc, fn HandlerFunc) {
|
||||||
|
c.mu.Lock()
|
||||||
|
*dst = append(*dst, fn)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) snapshot() handlerSet {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
hs := handlerSet{
|
||||||
|
onEvent: slicesClone(c.hs.onEvent),
|
||||||
|
onMessage: slicesClone(c.hs.onMessage),
|
||||||
|
onPrivate: slicesClone(c.hs.onPrivate),
|
||||||
|
onGroup: slicesClone(c.hs.onGroup),
|
||||||
|
onNotice: slicesClone(c.hs.onNotice),
|
||||||
|
onRequest: slicesClone(c.hs.onRequest),
|
||||||
|
onMeta: slicesClone(c.hs.onMeta),
|
||||||
|
noticeTypes: mapsCloneOf(c.hs.noticeTypes),
|
||||||
|
commands: mapsCloneCmd(c.hs.commands),
|
||||||
|
}
|
||||||
|
return hs
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch 按事件类型把事件交给已注册的处理函数;HTTP 上报模式下 quick 非空。
|
||||||
|
func (c *Client) dispatch(ev *Event, caller Caller, quick *quickBox) {
|
||||||
|
if caller == nil {
|
||||||
|
caller = c
|
||||||
|
}
|
||||||
|
ctx := &Context{Bot: c, Event: ev, API: API{call: caller}, quick: quick}
|
||||||
|
hs := c.snapshot()
|
||||||
|
|
||||||
|
run := func(fns []HandlerFunc) {
|
||||||
|
for _, fn := range fns {
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
c.log.Error("napcat: handler panic", "post_type", ev.PostType, "panic", fmt.Sprint(r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run(hs.onEvent)
|
||||||
|
switch ev.PostType {
|
||||||
|
case "message", "message_sent":
|
||||||
|
run(hs.onMessage)
|
||||||
|
if ev.Message != nil {
|
||||||
|
if ev.Message.MessageType == "group" {
|
||||||
|
run(hs.onGroup)
|
||||||
|
} else {
|
||||||
|
run(hs.onPrivate)
|
||||||
|
}
|
||||||
|
c.matchCommand(ctx, hs, run)
|
||||||
|
}
|
||||||
|
case "notice":
|
||||||
|
run(hs.onNotice)
|
||||||
|
if ev.Notice != nil {
|
||||||
|
run(hs.noticeTypes[ev.Notice.NoticeType])
|
||||||
|
}
|
||||||
|
case "request":
|
||||||
|
run(hs.onRequest)
|
||||||
|
case "meta_event":
|
||||||
|
run(hs.onMeta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchCommand 命中已注册命令则调用对应处理函数。
|
||||||
|
func (c *Client) matchCommand(ctx *Context, hs handlerSet, run func([]HandlerFunc)) {
|
||||||
|
if len(hs.commands) == 0 || ctx.Event.Message == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
text := strings.TrimSpace(SegmentsText(ctx.Segments()))
|
||||||
|
if text == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for name, fn := range hs.commands {
|
||||||
|
if text != name && !strings.HasPrefix(text, name+" ") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rest := strings.TrimSpace(strings.TrimPrefix(text, name))
|
||||||
|
args := []string{}
|
||||||
|
if rest != "" {
|
||||||
|
args = strings.Fields(rest)
|
||||||
|
}
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
c.log.Error("napcat: command handler panic", "cmd", name, "panic", fmt.Sprint(r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn(ctx, args)
|
||||||
|
}()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 小工具:避免每个文件都引 slices/maps 包。
|
||||||
|
func slicesClone(s []HandlerFunc) []HandlerFunc {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]HandlerFunc, len(s))
|
||||||
|
copy(out, s)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapsCloneOf(m map[string][]HandlerFunc) map[string][]HandlerFunc {
|
||||||
|
out := make(map[string][]HandlerFunc, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = slicesClone(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapsCloneCmd(m map[string]CommandFunc) map[string]CommandFunc {
|
||||||
|
out := make(map[string]CommandFunc, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
192
connect_http.go
Normal file
192
connect_http.go
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
package napcat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPReport HTTP POST 上报连接器:框架起一个 HTTP 服务,
|
||||||
|
// NapCat 把事件 POST 过来;处理函数里调用的 Reply/Approve 会以
|
||||||
|
// OneBot「快速操作」的形式随 HTTP 响应返回。
|
||||||
|
type HTTPReport struct {
|
||||||
|
addr string
|
||||||
|
srv *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPReport 创建 HTTP 上报连接器,监听 addr(如 ":8081"),
|
||||||
|
// NapCat 侧上报地址填 http://<本机IP>:8081/(任意路径均可)。
|
||||||
|
func NewHTTPReport(addr string) *HTTPReport { return &HTTPReport{addr: addr} }
|
||||||
|
|
||||||
|
func (h *HTTPReport) Name() string { return "http-report(" + h.addr + ")" }
|
||||||
|
|
||||||
|
func (h *HTTPReport) Serve(ctx context.Context, bot *Client) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := readAllLimited(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "read body: "+err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if bot.secret != "" && !verifySignature(bot.secret, body, r.Header.Get("X-Signature")) {
|
||||||
|
http.Error(w, "invalid signature", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ev, err := decodeEvent(body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
quick := &quickBox{params: map[string]any{}}
|
||||||
|
bot.dispatch(ev, bot, quick)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(quick.params)
|
||||||
|
})
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: h.addr,
|
||||||
|
Handler: mux,
|
||||||
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||||
|
}
|
||||||
|
h.srv = srv
|
||||||
|
err := srv.ListenAndServe()
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown 关闭监听(Run 的退出流程会调用)。
|
||||||
|
func (h *HTTPReport) Shutdown(ctx context.Context) error {
|
||||||
|
if h.srv == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return h.srv.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// quickBox 收集 HTTP 上报模式下随响应返回的快速操作参数。
|
||||||
|
type quickBox struct {
|
||||||
|
params map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *quickBox) set(key string, value any) {
|
||||||
|
if q.params == nil {
|
||||||
|
q.params = map[string]any{}
|
||||||
|
}
|
||||||
|
q.params[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySignature 校验 NapCat HTTP 上报的 X-Signature(HMAC-SHA1)。
|
||||||
|
func verifySignature(secret string, body []byte, got string) bool {
|
||||||
|
mac := hmac.New(sha1.New, []byte(secret))
|
||||||
|
mac.Write(body)
|
||||||
|
want := "sha1=" + hex.EncodeToString(mac.Sum(nil))
|
||||||
|
return hmac.Equal([]byte(got), []byte(want))
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAllLimited(r io.Reader) ([]byte, error) {
|
||||||
|
const maxBody = 32 << 20
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
if _, err := io.Copy(buf, io.LimitReader(r, maxBody+1)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if buf.Len() > maxBody {
|
||||||
|
return nil, errors.New("request body too large")
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPAPI HTTP API 连接器:框架作为客户端,POST 到 NapCat 的 HTTP 服务执行 action。
|
||||||
|
// 仅配置了 HTTPAPI 而没有 WS 会话时,Client.Call 走这条通道。
|
||||||
|
type HTTPAPI struct {
|
||||||
|
base string
|
||||||
|
token atomicString
|
||||||
|
hc *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type atomicString struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
v string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *atomicString) Store(v string) {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.v = v
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *atomicString) Load() string {
|
||||||
|
a.mu.RLock()
|
||||||
|
defer a.mu.RUnlock()
|
||||||
|
return a.v
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHTTPAPI 创建 HTTP API 连接器,base 形如 http://127.0.0.1:3000。
|
||||||
|
func NewHTTPAPI(base string) *HTTPAPI {
|
||||||
|
return &HTTPAPI{base: base, hc: &http.Client{Timeout: 30 * time.Second}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTTPAPI) Name() string { return "http-api(" + h.base + ")" }
|
||||||
|
|
||||||
|
// Serve 对 HTTP API 连接器只是完成鉴权配置并阻塞到框架退出。
|
||||||
|
func (h *HTTPAPI) Serve(ctx context.Context, bot *Client) error {
|
||||||
|
h.token.Store(bot.token)
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call 实现 Caller:POST <base>/<action>,body 为 params 对象。
|
||||||
|
func (h *HTTPAPI) Call(ctx context.Context, action string, params any, out any) error {
|
||||||
|
if ctx == nil {
|
||||||
|
var cancel context.CancelFunc
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
}
|
||||||
|
body := []byte("{}")
|
||||||
|
if params != nil {
|
||||||
|
var err error
|
||||||
|
if body, err = json.Marshal(params); err != nil {
|
||||||
|
return fmt.Errorf("napcat: marshal params of %s: %w", action, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.base+"/"+action, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token := h.token.Load(); token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := h.hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("napcat: http api %s: %w", action, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusNotFound {
|
||||||
|
return fmt.Errorf("napcat: action %s not found (404)", action)
|
||||||
|
}
|
||||||
|
var envelope Response
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
|
||||||
|
return fmt.Errorf("napcat: http api %s: bad response (http %d): %w", action, resp.StatusCode, err)
|
||||||
|
}
|
||||||
|
if err := envelope.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := envelope.Decode(out); err != nil {
|
||||||
|
return fmt.Errorf("napcat: decode data of %s: %w", action, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
220
connect_ws.go
Normal file
220
connect_ws.go
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
package napcat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
neturl "net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session 是一条已建立的 OneBot WebSocket 会话,同时实现了 Caller。
|
||||||
|
// 事件经 evCh 交给独立的分发协程串行处理(保序),读循环因此永远不会
|
||||||
|
// 被 handler 里的 API 调用阻塞(否则 API 响应帧将无人读取而死锁)。
|
||||||
|
type Session struct {
|
||||||
|
bot *Client
|
||||||
|
conn *websocket.Conn
|
||||||
|
caller *wsCaller
|
||||||
|
selfID atomic.Int64
|
||||||
|
closeOnce sync.Once
|
||||||
|
done chan struct{}
|
||||||
|
evCh chan sessionEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionEvent 是会话分发给 worker 的一项。
|
||||||
|
type sessionEvent struct {
|
||||||
|
ev *Event
|
||||||
|
quick *quickBox
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSession(bot *Client, conn *websocket.Conn) *Session {
|
||||||
|
s := &Session{bot: bot, conn: conn, done: make(chan struct{}), evCh: make(chan sessionEvent, 256)}
|
||||||
|
s.caller = newWSCaller(conn, s.done)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call 实现 Caller:在当前连接上执行 action。
|
||||||
|
func (s *Session) Call(ctx context.Context, action string, params any, out any) error {
|
||||||
|
return s.caller.Call(ctx, action, params, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SelfID 返回该会话绑定的机器人 QQ 号(收到事件/生命周期后有效)。
|
||||||
|
func (s *Session) SelfID() int64 { return s.selfID.Load() }
|
||||||
|
|
||||||
|
// Close 主动断开会话。
|
||||||
|
func (s *Session) Close() error {
|
||||||
|
var err error
|
||||||
|
s.closeOnce.Do(func() {
|
||||||
|
close(s.done)
|
||||||
|
err = s.conn.Close()
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve 阻塞读取连接上的数据,直到连接关闭。
|
||||||
|
func (s *Session) serve() error {
|
||||||
|
defer s.Close()
|
||||||
|
go s.dispatchWorker()
|
||||||
|
for {
|
||||||
|
_, data, err := s.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.bot.handleRaw(s, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatchWorker 串行分发本会话的事件,直到会话关闭。
|
||||||
|
func (s *Session) dispatchWorker() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-s.done:
|
||||||
|
return
|
||||||
|
case job := <-s.evCh:
|
||||||
|
s.bot.dispatch(job.ev, s, job.quick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enqueue 把事件放入分发队列;队列满时丢弃并告警,避免阻塞读循环。
|
||||||
|
func (s *Session) enqueue(ev *Event, quick *quickBox) {
|
||||||
|
select {
|
||||||
|
case s.evCh <- sessionEvent{ev: ev, quick: quick}:
|
||||||
|
case <-s.done:
|
||||||
|
default:
|
||||||
|
s.bot.log.Warn("napcat: event queue full, event dropped", "post_type", ev.PostType, "self_id", ev.SelfID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForwardWS 正向 WebSocket 连接器:框架作为客户端连到 NapCat 的 WS 服务。
|
||||||
|
type ForwardWS struct {
|
||||||
|
url string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewForwardWS 创建正向 WS 连接器,url 形如 ws://127.0.0.1:3001。
|
||||||
|
func NewForwardWS(url string) *ForwardWS { return &ForwardWS{url: url} }
|
||||||
|
|
||||||
|
func (f *ForwardWS) Name() string { return "forward-ws(" + f.url + ")" }
|
||||||
|
|
||||||
|
func (f *ForwardWS) Serve(ctx context.Context, bot *Client) error {
|
||||||
|
backoff := time.Second
|
||||||
|
for {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
conn, err := f.dial(bot)
|
||||||
|
if err != nil {
|
||||||
|
bot.log.Warn("forward ws dial failed", "err", err, "retry_in", backoff.String())
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
if backoff < 30*time.Second {
|
||||||
|
backoff *= 2
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
backoff = time.Second
|
||||||
|
sess := newSession(bot, conn)
|
||||||
|
bot.bindSession(sess)
|
||||||
|
bot.log.Info("forward ws connected", "url", f.url)
|
||||||
|
err = sess.serve()
|
||||||
|
bot.unbindSession(sess)
|
||||||
|
bot.log.Warn("forward ws disconnected", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *ForwardWS) dial(bot *Client) (*websocket.Conn, error) {
|
||||||
|
u, err := neturl.Parse(f.url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if bot.token != "" {
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("access_token", bot.token)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
}
|
||||||
|
header := http.Header{}
|
||||||
|
if bot.token != "" {
|
||||||
|
header.Set("Authorization", "Bearer "+bot.token)
|
||||||
|
}
|
||||||
|
dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second}
|
||||||
|
conn, _, err := dialer.Dial(u.String(), header)
|
||||||
|
return conn, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReverseWS 反向 WebSocket 连接器:框架起一个 WS 服务,由 NapCat 主动连入。
|
||||||
|
// 支持多个 NapCat 实例同时连入(每个连接一个独立会话)。
|
||||||
|
type ReverseWS struct {
|
||||||
|
addr string
|
||||||
|
srv *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReverseWS 创建反向 WS 连接器,监听 addr(如 ":8080"),
|
||||||
|
// NapCat 侧反向 WS 地址填 ws://<本机IP>:8080/(任意路径均可)。
|
||||||
|
func NewReverseWS(addr string) *ReverseWS { return &ReverseWS{addr: addr} }
|
||||||
|
|
||||||
|
func (r *ReverseWS) Name() string { return "reverse-ws(" + r.addr + ")" }
|
||||||
|
|
||||||
|
func (r *ReverseWS) Serve(ctx context.Context, bot *Client) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if !verifyToken(bot, req) {
|
||||||
|
http.Error(w, "invalid access_token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
up := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }}
|
||||||
|
conn, err := up.Upgrade(w, req, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sess := newSession(bot, conn)
|
||||||
|
bot.bindSession(sess)
|
||||||
|
bot.log.Info("reverse ws session connected", "remote", req.RemoteAddr)
|
||||||
|
defer bot.unbindSession(sess)
|
||||||
|
_ = sess.serve()
|
||||||
|
})
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: r.addr,
|
||||||
|
Handler: mux,
|
||||||
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||||
|
}
|
||||||
|
r.srv = srv
|
||||||
|
err := srv.ListenAndServe()
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shutdown 关闭监听(Run 的退出流程会调用),已建立的会话由 Client 统一关闭。
|
||||||
|
func (r *ReverseWS) Shutdown(ctx context.Context) error {
|
||||||
|
if r.srv == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return r.srv.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyToken 校验请求携带的 access_token(查询参数或 Authorization 头)。
|
||||||
|
func verifyToken(bot *Client, r *http.Request) bool {
|
||||||
|
if bot.token == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("access_token") == bot.token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
auth := r.Header.Get("Authorization")
|
||||||
|
for _, prefix := range []string{"Bearer ", "Token "} {
|
||||||
|
if after, ok := strings.CutPrefix(auth, prefix); ok && after == bot.token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
136
context.go
Normal file
136
context.go
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
package napcat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Context 是交给处理函数的上下文:携带事件,并把 API 调用路由到
|
||||||
|
// 产生该事件的连接;HTTP 上报模式下 Reply/Approve 走快速操作。
|
||||||
|
type Context struct {
|
||||||
|
// API 内嵌全部类型化 API 方法,调用时路由到本事件的来源连接。
|
||||||
|
API
|
||||||
|
Bot *Client
|
||||||
|
Event *Event
|
||||||
|
|
||||||
|
quick *quickBox
|
||||||
|
segOnce sync.Once
|
||||||
|
segs []Segment
|
||||||
|
segErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call 在本事件来源的连接上执行任意 action(未收录 API 的兜底入口)。
|
||||||
|
func (c *Context) Call(ctx context.Context, action string, params any, out any) error {
|
||||||
|
return c.API.call.Call(ctx, action, params, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segments 返回消息事件的消息段数组(CQ 码字符串也会被解析),结果带缓存。
|
||||||
|
func (c *Context) Segments() []Segment {
|
||||||
|
c.segOnce.Do(func() {
|
||||||
|
if c.Event.Message != nil {
|
||||||
|
c.segs, c.segErr = DecodeMessage(c.Event.Message.Message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if c.segErr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.segs
|
||||||
|
}
|
||||||
|
|
||||||
|
// PlainText 返回消息里所有 text 段拼接出的文本。
|
||||||
|
func (c *Context) PlainText() string { return SegmentsText(c.Segments()) }
|
||||||
|
|
||||||
|
// SelfID 返回产生本事件的机器人 QQ 号。
|
||||||
|
func (c *Context) SelfID() int64 { return c.Event.SelfID }
|
||||||
|
|
||||||
|
// IsGroup 判断是否群聊消息事件。
|
||||||
|
func (c *Context) IsGroup() bool {
|
||||||
|
return c.Event.Message != nil && c.Event.Message.MessageType == "group"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPrivate 判断是否私聊消息事件。
|
||||||
|
func (c *Context) IsPrivate() bool {
|
||||||
|
return c.Event.Message != nil && c.Event.Message.MessageType == "private"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsSelfMessage 判断是否 bot 自己发出的消息(message_sent)。
|
||||||
|
func (c *Context) IsSelfMessage() bool { return c.Event.PostType == "message_sent" }
|
||||||
|
|
||||||
|
// HasAtMe 判断消息是否 @ 了机器人(不含 @全体)。
|
||||||
|
func (c *Context) HasAtMe() bool {
|
||||||
|
me := c.SelfID()
|
||||||
|
for _, s := range c.Segments() {
|
||||||
|
if s.Type == "at" && intOf(s.Data["qq"]) == me {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reply 回复当前消息:群聊发 send_group_msg,私聊发 send_private_msg。
|
||||||
|
// HTTP 上报模式下改为随响应返回快速操作 reply(此时 message_id 返回 0)。
|
||||||
|
func (c *Context) Reply(message ...Segment) (MessageID, error) {
|
||||||
|
ev := c.Event
|
||||||
|
if ev.Message == nil {
|
||||||
|
return 0, errReplyNotMessage
|
||||||
|
}
|
||||||
|
if c.quick != nil {
|
||||||
|
c.quick.set("reply", message)
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if ev.Message.MessageType == "group" {
|
||||||
|
return c.SendGroupMsg(nil, ev.Message.GroupID, message...)
|
||||||
|
}
|
||||||
|
return c.SendPrivateMsg(nil, ev.Message.UserID, message...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplyText 回复一段纯文本。
|
||||||
|
func (c *Context) ReplyText(text string) (MessageID, error) { return c.Reply(Text(text)) }
|
||||||
|
|
||||||
|
// Recall 撤回触发本事件的那条消息(需要 bot 有管理员权限/为私聊双方)。
|
||||||
|
func (c *Context) Recall() error {
|
||||||
|
if c.Event.Message == nil {
|
||||||
|
return errReplyNotMessage
|
||||||
|
}
|
||||||
|
return c.DeleteMsg(nil, c.Event.Message.MessageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approve 同意当前好友/群请求。
|
||||||
|
func (c *Context) Approve() error { return c.approveRequest(true, "") }
|
||||||
|
|
||||||
|
// Reject 拒绝当前好友/群请求,reason 为拒绝理由(好友请求则为验证备注)。
|
||||||
|
func (c *Context) Reject(reason string) error { return c.approveRequest(false, reason) }
|
||||||
|
|
||||||
|
func (c *Context) approveRequest(approve bool, text string) error {
|
||||||
|
ev := c.Event
|
||||||
|
if ev.Request == nil {
|
||||||
|
return errRequestOnly
|
||||||
|
}
|
||||||
|
if c.quick != nil {
|
||||||
|
c.quick.set("approve", approve)
|
||||||
|
if text != "" {
|
||||||
|
if ev.Request.RequestType == "friend" {
|
||||||
|
c.quick.set("remark", text)
|
||||||
|
} else {
|
||||||
|
c.quick.set("reason", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if ev.Request.RequestType == "friend" {
|
||||||
|
return c.SetFriendAddRequest(nil, ev.Request.Flag, approve, text)
|
||||||
|
}
|
||||||
|
return c.SetGroupAddRequest(nil, ev.Request.Flag, ev.Request.SubType, approve, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
errReplyNotMessage = &frameError{"reply is only available for message events"}
|
||||||
|
errRequestOnly = &frameError{"approve/reject is only available for request events"}
|
||||||
|
)
|
||||||
|
|
||||||
|
type frameError struct{ msg string }
|
||||||
|
|
||||||
|
func (e *frameError) Error() string { return "napcat: " + e.msg }
|
||||||
|
|
||||||
|
// MessageID 是 OneBot 消息 ID。
|
||||||
|
type MessageID = int64
|
||||||
98
cqcode.go
Normal file
98
cqcode.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package napcat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseCQ 将 CQ 码字符串解析为消息段数组。
|
||||||
|
// 文本与 [CQ:type,k=v] 混排时会还原为对应的段序列。
|
||||||
|
func ParseCQ(s string) []Segment {
|
||||||
|
var out []Segment
|
||||||
|
var text strings.Builder
|
||||||
|
flush := func() {
|
||||||
|
if text.Len() > 0 {
|
||||||
|
out = append(out, Text(unescapeText(text.String())))
|
||||||
|
text.Reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < len(s); {
|
||||||
|
if strings.HasPrefix(s[i:], "[CQ:") {
|
||||||
|
end := strings.IndexByte(s[i:], ']')
|
||||||
|
if end < 0 {
|
||||||
|
text.WriteString(s[i:])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end += i
|
||||||
|
flush()
|
||||||
|
out = append(out, parseCQBody(s[i+4:end]))
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
text.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCQBody(body string) Segment {
|
||||||
|
parts := strings.Split(body, ",")
|
||||||
|
seg := Segment{Type: strings.ToLower(strings.TrimSpace(parts[0])), Data: map[string]any{}}
|
||||||
|
for _, p := range parts[1:] {
|
||||||
|
k, v, ok := strings.Cut(p, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seg.Data[strings.TrimSpace(k)] = unescapeParam(v)
|
||||||
|
}
|
||||||
|
return seg
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeCQ 将消息段数组编码为 CQ 码字符串。
|
||||||
|
func EncodeCQ(segs []Segment) string {
|
||||||
|
var b strings.Builder
|
||||||
|
for _, s := range segs {
|
||||||
|
if s.Type == "text" {
|
||||||
|
b.WriteString(escapeText(strOf(s.Data["text"])))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.WriteString("[CQ:" + s.Type)
|
||||||
|
keys := make([]string, 0, len(s.Data))
|
||||||
|
for k := range s.Data {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
b.WriteString(",")
|
||||||
|
b.WriteString(k)
|
||||||
|
b.WriteString("=")
|
||||||
|
b.WriteString(escapeParam(strOf(s.Data[k])))
|
||||||
|
}
|
||||||
|
b.WriteString("]")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// String 将单个消息段编码为 CQ 码。
|
||||||
|
func (s Segment) String() string { return EncodeCQ([]Segment{s}) }
|
||||||
|
|
||||||
|
func escapeText(s string) string {
|
||||||
|
r := strings.NewReplacer("&", "&", "[", "[", "]", "]")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeParam(s string) string {
|
||||||
|
r := strings.NewReplacer("&", "&", "[", "[", "]", "]", ",", ",")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeText(s string) string {
|
||||||
|
r := strings.NewReplacer("[", "[", "]", "]", "&", "&")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unescapeParam(s string) string {
|
||||||
|
r := strings.NewReplacer(",", ",", "[", "[", "]", "]", "&", "&")
|
||||||
|
return r.Replace(s)
|
||||||
|
}
|
||||||
17
doc.go
Normal file
17
doc.go
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
// Package napcat 是对接 NapCatQQ(OneBot 11 协议)的 Go 软件包。
|
||||||
|
//
|
||||||
|
// 通过 New 创建与 NapCat 交互的客户端对象,Set* 方法配置连接方式
|
||||||
|
// (正向/反向 WebSocket、HTTP API、HTTP POST 上报),On* 注册事件处理,
|
||||||
|
// Run 启动后即可直接调用对象上的 80+ 个 NapCat 接口。
|
||||||
|
//
|
||||||
|
// bot := napcat.New().SetToken("xxx").SetForwardWS("ws://127.0.0.1:3001")
|
||||||
|
// bot.OnMessage(func(c *napcat.Context) {
|
||||||
|
// if c.HasAtMe() {
|
||||||
|
// c.ReplyText("hi")
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// bot.Run(ctx)
|
||||||
|
//
|
||||||
|
// 支持 OneBot 11 标准 API、go-cqhttp 兼容 API 与 NapCat 扩展 API;
|
||||||
|
// 未收录的接口可用 Context.Call / Client.Call 裸调。
|
||||||
|
package napcat
|
||||||
160
event.go
Normal file
160
event.go
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
package napcat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Event 是收到的一条 OneBot 事件。按 PostType 取对应的子事件指针。
|
||||||
|
type Event struct {
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
SelfID int64 `json:"self_id"`
|
||||||
|
PostType string `json:"post_type"`
|
||||||
|
Raw json.RawMessage `json:"-"`
|
||||||
|
|
||||||
|
Message *MessageEvent // post_type == "message" / "message_sent"
|
||||||
|
Notice *NoticeEvent // post_type == "notice"
|
||||||
|
Request *RequestEvent // post_type == "request"
|
||||||
|
Meta *MetaEvent // post_type == "meta_event"
|
||||||
|
}
|
||||||
|
|
||||||
|
type BaseEvent struct {
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
SelfID int64 `json:"self_id"`
|
||||||
|
PostType string `json:"post_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageEvent 是私聊/群聊消息事件。bot 自己发出的消息以 message_sent 上报。
|
||||||
|
type MessageEvent struct {
|
||||||
|
BaseEvent
|
||||||
|
MessageType string `json:"message_type"` // private | group
|
||||||
|
SubType string `json:"sub_type"` // friend/group/normal/anonymous/notice
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
TargetID int64 `json:"target_id"` // 群临时会话目标
|
||||||
|
TempSource int64 `json:"temp_source"` // 群临时会话来源
|
||||||
|
Message json.RawMessage `json:"message"` // 段数组或 CQ 码字符串
|
||||||
|
RawMessage string `json:"raw_message"`
|
||||||
|
Font int64 `json:"font"`
|
||||||
|
Sender Sender `json:"sender"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NoticeEvent 是通知事件(撤回、入群、禁言、戳一戳等)。
|
||||||
|
// 未映射的字段收进 Extra,避免 NapCat 增加字段时丢数据。
|
||||||
|
type NoticeEvent struct {
|
||||||
|
BaseEvent
|
||||||
|
NoticeType string `json:"notice_type"`
|
||||||
|
SubType string `json:"sub_type"`
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
OperatorID int64 `json:"operator_id"`
|
||||||
|
TargetID int64 `json:"target_id"`
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
Duration int64 `json:"duration"` // 禁言时长(秒)
|
||||||
|
File *FileNotice `json:"file"`
|
||||||
|
HonorType string `json:"honor_type"`
|
||||||
|
CardNew string `json:"card_new"`
|
||||||
|
CardOld string `json:"card_old"`
|
||||||
|
|
||||||
|
Extra map[string]any `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *NoticeEvent) UnmarshalJSON(data []byte) error {
|
||||||
|
type alias NoticeEvent
|
||||||
|
var a alias
|
||||||
|
if err := json.Unmarshal(data, &a); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*n = NoticeEvent(a)
|
||||||
|
|
||||||
|
var m map[string]any
|
||||||
|
if err := json.Unmarshal(data, &m); err == nil {
|
||||||
|
for k := range knownNoticeKeys {
|
||||||
|
delete(m, k)
|
||||||
|
}
|
||||||
|
if len(m) > 0 {
|
||||||
|
n.Extra = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownNoticeKeys = map[string]struct{}{
|
||||||
|
"time": {}, "self_id": {}, "post_type": {}, "notice_type": {}, "sub_type": {},
|
||||||
|
"group_id": {}, "user_id": {}, "operator_id": {}, "target_id": {},
|
||||||
|
"message_id": {}, "duration": {}, "file": {}, "honor_type": {},
|
||||||
|
"card_new": {}, "card_old": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileNotice 是群文件上传/离线文件通知里的文件信息。
|
||||||
|
type FileNotice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
BusID int64 `json:"busid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestEvent 是好友/群请求事件(加好友、加群、被邀请入群)。
|
||||||
|
type RequestEvent struct {
|
||||||
|
BaseEvent
|
||||||
|
RequestType string `json:"request_type"` // friend | group
|
||||||
|
SubType string `json:"sub_type"` // group 时为 add/invite
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
Comment string `json:"comment"`
|
||||||
|
Flag string `json:"flag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetaEvent 是元事件(连接生命周期与心跳)。
|
||||||
|
type MetaEvent struct {
|
||||||
|
BaseEvent
|
||||||
|
MetaType string `json:"meta_event_type"` // lifecycle | heartbeat
|
||||||
|
SubType string `json:"sub_type"` // connect/enable/disable
|
||||||
|
HeartbeatInterval int64 `json:"interval"`
|
||||||
|
Status *Status `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeEvent(data []byte) (*Event, error) {
|
||||||
|
var probe struct {
|
||||||
|
PostType string `json:"post_type"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &probe); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if probe.PostType == "" {
|
||||||
|
return nil, fmt.Errorf("napcat: missing post_type: %s", truncate(data))
|
||||||
|
}
|
||||||
|
ev := &Event{Raw: append(json.RawMessage(nil), data...)}
|
||||||
|
var base BaseEvent
|
||||||
|
_ = json.Unmarshal(data, &base)
|
||||||
|
ev.Time, ev.SelfID, ev.PostType = base.Time, base.SelfID, probe.PostType
|
||||||
|
|
||||||
|
switch probe.PostType {
|
||||||
|
case "message", "message_sent":
|
||||||
|
var m MessageEvent
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ev.Message = &m
|
||||||
|
case "notice":
|
||||||
|
var n NoticeEvent
|
||||||
|
if err := json.Unmarshal(data, &n); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ev.Notice = &n
|
||||||
|
case "request":
|
||||||
|
var r RequestEvent
|
||||||
|
if err := json.Unmarshal(data, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ev.Request = &r
|
||||||
|
case "meta_event":
|
||||||
|
var m MetaEvent
|
||||||
|
if err := json.Unmarshal(data, &m); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ev.Meta = &m
|
||||||
|
}
|
||||||
|
return ev, nil
|
||||||
|
}
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module gitea.doicat.com/bot/napcat
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.5.3
|
||||||
2
go.sum
Normal file
2
go.sum
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
200
segment.go
Normal file
200
segment.go
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
package napcat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Segment 是 OneBot 11 的消息段:{"type": "...", "data": {...}}。
|
||||||
|
// 收到的事件里 data 值可能是数字或字符串,因此统一用 map 承载,
|
||||||
|
// 发送时用本包的构造函数生成,避免字段名拼错。
|
||||||
|
type Segment struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Data map[string]any `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// With 为消息段附加一个 data 字段,可链式调用:
|
||||||
|
//
|
||||||
|
// napcat.Image("1.jpg").With("summary", "看图")
|
||||||
|
func (s Segment) With(key string, value any) Segment {
|
||||||
|
if s.Data == nil {
|
||||||
|
s.Data = map[string]any{}
|
||||||
|
}
|
||||||
|
s.Data[key] = value
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func seg(t string, kv ...any) Segment {
|
||||||
|
data := map[string]any{}
|
||||||
|
for i := 0; i+1 < len(kv); i += 2 {
|
||||||
|
key, ok := kv[i].(string)
|
||||||
|
if !ok {
|
||||||
|
panic(fmt.Sprintf("napcat: segment key must be string, got %T", kv[i]))
|
||||||
|
}
|
||||||
|
data[key] = kv[i+1]
|
||||||
|
}
|
||||||
|
return Segment{Type: t, Data: data}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text 构造纯文本消息段。
|
||||||
|
func Text(text string) Segment { return seg("text", "text", text) }
|
||||||
|
|
||||||
|
// Face 构造 QQ 系统表情消息段。
|
||||||
|
func Face(id int) Segment { return seg("face", "id", id) }
|
||||||
|
|
||||||
|
// Image 构造图片消息段。file 支持本地路径、http(s) URL、base64://。
|
||||||
|
func Image(file string) Segment { return seg("image", "file", file) }
|
||||||
|
|
||||||
|
// Record 构造语音消息段。
|
||||||
|
func Record(file string) Segment { return seg("record", "file", file) }
|
||||||
|
|
||||||
|
// Video 构造视频消息段。
|
||||||
|
func Video(file string) Segment { return seg("video", "file", file) }
|
||||||
|
|
||||||
|
// At 构造 @ 消息段。
|
||||||
|
func At(qq int64) Segment { return seg("at", "qq", strconv.FormatInt(qq, 10)) }
|
||||||
|
|
||||||
|
// AtAll 构造 @全体成员 消息段。
|
||||||
|
func AtAll() Segment { return seg("at", "qq", "all") }
|
||||||
|
|
||||||
|
// Reply 构造回复消息段。
|
||||||
|
func Reply(messageID int64) Segment { return seg("reply", "id", messageID) }
|
||||||
|
|
||||||
|
// Forward 构造合并转发消息段(引用一条已存在的合并转发)。
|
||||||
|
func Forward(id string) Segment { return seg("forward", "id", id) }
|
||||||
|
|
||||||
|
// NodeSeg 引用一条已有消息作为合并转发节点。
|
||||||
|
// 注意:与 api.go 里的 Node 结构体(构造转发节点参数)区分开。
|
||||||
|
func NodeSeg(messageID int64) Segment {
|
||||||
|
return seg("node", "id", messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomNode 构造伪造发送者的合并转发节点。
|
||||||
|
func CustomNode(userID int64, nickname string, content []Segment) Segment {
|
||||||
|
return seg("node",
|
||||||
|
"user_id", strconv.FormatInt(userID, 10),
|
||||||
|
"nickname", nickname,
|
||||||
|
"content", content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON 构造 JSON 卡片消息段(json/lightapp 卡片)。
|
||||||
|
func JSON(payload string) Segment { return seg("json", "data", payload) }
|
||||||
|
|
||||||
|
// Music 构造音乐分享消息段,musicType 取值 qq/163/kugou/migu/kuwo。
|
||||||
|
func Music(musicType, id string) Segment {
|
||||||
|
return seg("music", "type", musicType, "id", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CustomMusic 构造自定义音乐分享消息段。
|
||||||
|
func CustomMusic(url, audio, title string) Segment {
|
||||||
|
return seg("music", "type", "custom", "url", url, "audio", audio, "title", title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contact 构造好友/群推荐卡片,contactType 取值 qq/group。
|
||||||
|
func Contact(contactType, id string) Segment {
|
||||||
|
return seg("contact", "type", contactType, "id", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Markdown 构造 markdown 消息段(NapCat 下需置于合并转发内发送)。
|
||||||
|
func Markdown(content string) Segment { return seg("markdown", "content", content) }
|
||||||
|
|
||||||
|
// Poke 构造戳一戳消息段。
|
||||||
|
func Poke(qq int64) Segment { return seg("poke", "qq", strconv.FormatInt(qq, 10)) }
|
||||||
|
|
||||||
|
// Dice 构造随机骰子消息段。
|
||||||
|
func Dice() Segment { return Segment{Type: "dice", Data: map[string]any{}} }
|
||||||
|
|
||||||
|
// RPS 构造猜拳消息段。
|
||||||
|
func RPS() Segment { return Segment{Type: "rps", Data: map[string]any{}} }
|
||||||
|
|
||||||
|
// Shake 构造窗口抖动消息段(仅私聊可用)。
|
||||||
|
func Shake() Segment { return Segment{Type: "shake", Data: map[string]any{}} }
|
||||||
|
|
||||||
|
// FileSegment 构造文件消息段(一般推荐使用 UploadGroupFile/UploadPrivateFile)。
|
||||||
|
func FileSegment(file, name string) Segment {
|
||||||
|
return seg("file", "file", file, "name", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeMessage 将事件或 API 返回中的 message 字段解码为消息段数组,
|
||||||
|
// 兼容段数组和 CQ 码字符串两种形态。
|
||||||
|
func DecodeMessage(raw json.RawMessage) ([]Segment, error) {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
trimmed := trimSpaceBytes(raw)
|
||||||
|
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||||
|
var segs []Segment
|
||||||
|
if err := json.Unmarshal(trimmed, &segs); err != nil {
|
||||||
|
return nil, fmt.Errorf("napcat: decode message segments: %w", err)
|
||||||
|
}
|
||||||
|
return segs, nil
|
||||||
|
}
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(trimmed, &s); err != nil {
|
||||||
|
return nil, fmt.Errorf("napcat: message is neither array nor string: %w", err)
|
||||||
|
}
|
||||||
|
return ParseCQ(s), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SegmentsText 拼接所有 text 段的文本内容。
|
||||||
|
func SegmentsText(segs []Segment) string {
|
||||||
|
out := ""
|
||||||
|
for _, s := range segs {
|
||||||
|
if s.Type == "text" {
|
||||||
|
out += strOf(s.Data["text"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSpaceBytes(b []byte) []byte {
|
||||||
|
start, end := 0, len(b)
|
||||||
|
for start < end && (b[start] == ' ' || b[start] == '\t' || b[start] == '\r' || b[start] == '\n') {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
for end > start && (b[end-1] == ' ' || b[end-1] == '\t' || b[end-1] == '\r' || b[end-1] == '\n') {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
return b[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
// strOf 把 data 里的值统一转成字符串(兼容 float64/string/json.Number 等)。
|
||||||
|
func strOf(v any) string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return t
|
||||||
|
case float64:
|
||||||
|
if t == float64(int64(t)) {
|
||||||
|
return strconv.FormatInt(int64(t), 10)
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||||
|
case json.Number:
|
||||||
|
return t.String()
|
||||||
|
case bool:
|
||||||
|
return strconv.FormatBool(t)
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// intOf 把 data 里的值尽量转成 int64(兼容 float64/string)。
|
||||||
|
func intOf(v any) int64 {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case nil:
|
||||||
|
return 0
|
||||||
|
case float64:
|
||||||
|
return int64(t)
|
||||||
|
case string:
|
||||||
|
n, _ := strconv.ParseInt(t, 10, 64)
|
||||||
|
return n
|
||||||
|
case json.Number:
|
||||||
|
n, _ := t.Int64()
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
360
types.go
Normal file
360
types.go
Normal file
@ -0,0 +1,360 @@
|
|||||||
|
package napcat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotConnected 表示当前没有任何可用的 OneBot 连接来执行 API 调用。
|
||||||
|
var ErrNotConnected = errors.New("napcat: no connected session")
|
||||||
|
|
||||||
|
// Response 是 OneBot 11 的通用动作响应信封。
|
||||||
|
type Response struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Retcode int `json:"retcode"`
|
||||||
|
Data json.RawMessage `json:"data"`
|
||||||
|
Echo string `json:"echo"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
Wording string `json:"wording,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Err 在响应失败时返回非 nil 错误。
|
||||||
|
func (r *Response) Err() error {
|
||||||
|
if r == nil || r.Retcode == 0 || r.Status == "ok" || r.Status == "async" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
msg := r.Wording
|
||||||
|
if msg == "" {
|
||||||
|
msg = r.Message
|
||||||
|
}
|
||||||
|
if msg == "" {
|
||||||
|
msg = "unknown error"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("napcat: action failed retcode=%d status=%s: %s", r.Retcode, r.Status, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode 将响应的 data 字段解码到 out。
|
||||||
|
func (r *Response) Decode(out any) error {
|
||||||
|
if r == nil || out == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(r.Data) == 0 || string(r.Data) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.Unmarshal(r.Data, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IntString 兼容数字或字符串形式出现的整数字段(如群成员 level)。
|
||||||
|
type IntString int64
|
||||||
|
|
||||||
|
func (v *IntString) UnmarshalJSON(b []byte) error {
|
||||||
|
s := strings.Trim(string(b), `"`)
|
||||||
|
if s == "" || s == "null" {
|
||||||
|
*v = 0
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("napcat: invalid integer %q: %w", s, err)
|
||||||
|
}
|
||||||
|
*v = IntString(n)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sender 是消息事件的发送者信息。群聊时含 Card/Role,私聊时可能为空。
|
||||||
|
// 注意:NapCat v4 的消息事件 sender 是精简的,实测不含 Role 字段
|
||||||
|
// (完整角色需调 GetGroupMemberInfo 查询);本结构保留 Role 以兼容
|
||||||
|
// 标准 OneBot 11 实现和需要 role 的接口返回。
|
||||||
|
type Sender struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Sex string `json:"sex,omitempty"`
|
||||||
|
Card string `json:"card,omitempty"`
|
||||||
|
Role string `json:"role,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginInfo struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type StrangerInfo struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Sex string `json:"sex,omitempty"`
|
||||||
|
Age int64 `json:"age,omitempty"`
|
||||||
|
QID string `json:"qid,omitempty"`
|
||||||
|
LongNick string `json:"long_nick,omitempty"`
|
||||||
|
Level int64 `json:"level,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FriendInfo struct {
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
Sex string `json:"sex,omitempty"`
|
||||||
|
Level int64 `json:"level,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupInfo struct {
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
GroupName string `json:"group_name"`
|
||||||
|
MemberCount int64 `json:"member_count"`
|
||||||
|
MaxMemberCount int64 `json:"max_member_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupMemberInfo struct {
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Card string `json:"card"`
|
||||||
|
CardChangeable bool `json:"card_changeable"`
|
||||||
|
Sex string `json:"sex"`
|
||||||
|
Level IntString `json:"level"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
JoinTime int64 `json:"join_time"`
|
||||||
|
LastSentTime int64 `json:"last_sent_time"`
|
||||||
|
ShutUpTimestamp int64 `json:"shut_up_timestamp"`
|
||||||
|
Unfriendly bool `json:"unfriendly"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
Online bool `json:"online"`
|
||||||
|
Good bool `json:"good"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VersionInfo struct {
|
||||||
|
AppName string `json:"app_name"`
|
||||||
|
AppVersion string `json:"app_version"`
|
||||||
|
ProtocolVersion string `json:"protocol_version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageInfo 是 get_msg 等 API 返回的单条消息。
|
||||||
|
type MessageInfo struct {
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
RealID int64 `json:"real_id"`
|
||||||
|
MessageSeq int64 `json:"message_seq,omitempty"`
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
Sender Sender `json:"sender"`
|
||||||
|
Message json.RawMessage `json:"message"`
|
||||||
|
RawMessage string `json:"raw_message"`
|
||||||
|
Font int64 `json:"font,omitempty"`
|
||||||
|
GroupID int64 `json:"group_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segments 将 message 字段解码为消息段数组。
|
||||||
|
func (m MessageInfo) Segments() ([]Segment, error) { return DecodeMessage(m.Message) }
|
||||||
|
|
||||||
|
// HistoryMessage 是历史消息接口(get_group_msg_history 等)返回的单条消息。
|
||||||
|
type HistoryMessage struct {
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
MessageSeq int64 `json:"message_seq"`
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
Sender Sender `json:"sender"`
|
||||||
|
Message json.RawMessage `json:"message"`
|
||||||
|
RawMessage string `json:"raw_message"`
|
||||||
|
GroupID int64 `json:"group_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m HistoryMessage) Segments() ([]Segment, error) { return DecodeMessage(m.Message) }
|
||||||
|
|
||||||
|
// HistoryMessages 兼容 data 为 {"messages": [...]} 或直接数组两种返回形态。
|
||||||
|
type HistoryMessages []HistoryMessage
|
||||||
|
|
||||||
|
func (h *HistoryMessages) UnmarshalJSON(b []byte) error {
|
||||||
|
var probe struct {
|
||||||
|
Messages []HistoryMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &probe); err == nil && probe.Messages != nil {
|
||||||
|
*h = probe.Messages
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var arr []HistoryMessage
|
||||||
|
if err := json.Unmarshal(b, &arr); err == nil {
|
||||||
|
*h = arr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("napcat: unexpected history message payload: %s", truncate(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForwardMessage 是合并转发中的一条消息。
|
||||||
|
type ForwardMessage struct {
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
Sender Sender `json:"sender"`
|
||||||
|
Message json.RawMessage `json:"message"`
|
||||||
|
Content json.RawMessage `json:"content,omitempty"`
|
||||||
|
RawMessage string `json:"raw_message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segments 将消息体解码为消息段数组,优先取 message 字段,其次 content。
|
||||||
|
func (m ForwardMessage) Segments() ([]Segment, error) {
|
||||||
|
if len(m.Message) > 0 {
|
||||||
|
return DecodeMessage(m.Message)
|
||||||
|
}
|
||||||
|
return DecodeMessage(m.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForwardMessages 兼容 data 为 {"messages": [...]} 或直接数组两种返回形态。
|
||||||
|
type ForwardMessages []ForwardMessage
|
||||||
|
|
||||||
|
func (f *ForwardMessages) UnmarshalJSON(b []byte) error {
|
||||||
|
var probe struct {
|
||||||
|
Messages []ForwardMessage `json:"messages"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &probe); err == nil && probe.Messages != nil {
|
||||||
|
*f = probe.Messages
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var arr []ForwardMessage
|
||||||
|
if err := json.Unmarshal(b, &arr); err == nil {
|
||||||
|
*f = arr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("napcat: unexpected forward message payload: %s", truncate(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendForwardResult 是发送合并转发消息的返回。
|
||||||
|
type SendForwardResult struct {
|
||||||
|
MessageID IntString `json:"message_id"`
|
||||||
|
ResID string `json:"res_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EssenceMessage struct {
|
||||||
|
GroupID int64 `json:"group_id"`
|
||||||
|
SenderID int64 `json:"sender_id"`
|
||||||
|
OperatorID int64 `json:"operator_id"`
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
MessageID int64 `json:"message_id"`
|
||||||
|
Message json.RawMessage `json:"message,omitempty"`
|
||||||
|
RawMessage string `json:"raw_message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupFile struct {
|
||||||
|
FileID string `json:"file_id"`
|
||||||
|
FileName string `json:"file_name"`
|
||||||
|
BusID int64 `json:"busid"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
UploadTime int64 `json:"upload_time"`
|
||||||
|
DeadTime int64 `json:"dead_time"`
|
||||||
|
ModifyTime int64 `json:"modify_time"`
|
||||||
|
DownloadTimes int64 `json:"download_times"`
|
||||||
|
Uploader string `json:"uploader,omitempty"`
|
||||||
|
UploaderID int64 `json:"uploader_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GroupFolder struct {
|
||||||
|
FolderID string `json:"folder_id"`
|
||||||
|
FolderName string `json:"folder_name"`
|
||||||
|
CreateTime int64 `json:"create_time"`
|
||||||
|
Creator string `json:"creator,omitempty"`
|
||||||
|
CreatorID int64 `json:"creator_id,omitempty"`
|
||||||
|
TotalFileCount int64 `json:"total_file_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileList struct {
|
||||||
|
Files []GroupFile `json:"files"`
|
||||||
|
Folders []GroupFolder `json:"folders"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileSystemInfo struct {
|
||||||
|
FileCount int64 `json:"file_count"`
|
||||||
|
LimitCount int64 `json:"limit_count"`
|
||||||
|
UsedSpace int64 `json:"used_space"`
|
||||||
|
TotalSpace int64 `json:"total_space"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageResult struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Size int64 `json:"size,omitempty"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordResult struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadedFile struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
URL string `json:"url,omitempty"`
|
||||||
|
FileName string `json:"file_name,omitempty"`
|
||||||
|
FileSize int64 `json:"file_size,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Cookies struct {
|
||||||
|
Cookies string `json:"cookies"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CSRFToken struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Credentials struct {
|
||||||
|
Cookies string `json:"cookies"`
|
||||||
|
CSRFToken string `json:"csrf_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type URLSafety struct {
|
||||||
|
Level int `json:"level"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AtAllRemain struct {
|
||||||
|
CanAtAll bool `json:"can_at_all"`
|
||||||
|
RemainAtAllCount int64 `json:"remain_at_all_count"`
|
||||||
|
RemainAtAllCountForGroup int64 `json:"remain_at_all_count_for_group"`
|
||||||
|
RemainAtAllCountForUser int64 `json:"remain_at_all_count_for_user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RobotRange struct {
|
||||||
|
MinUin int64 `json:"min_uin"`
|
||||||
|
MaxUin int64 `json:"max_uin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RobotUinRanges 兼容 data 为 {"robot_uin_range": [...]} 或直接数组两种形态。
|
||||||
|
type RobotUinRanges []RobotRange
|
||||||
|
|
||||||
|
func (r *RobotUinRanges) UnmarshalJSON(b []byte) error {
|
||||||
|
var probe struct {
|
||||||
|
RobotUinRange []RobotRange `json:"robot_uin_range"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &probe); err == nil && probe.RobotUinRange != nil {
|
||||||
|
*r = probe.RobotUinRange
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var arr []RobotRange
|
||||||
|
if err := json.Unmarshal(b, &arr); err == nil {
|
||||||
|
*r = arr
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("napcat: unexpected robot uin range payload: %s", truncate(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnlineClient struct {
|
||||||
|
AppID int64 `json:"app_id"`
|
||||||
|
DeviceName string `json:"device_name"`
|
||||||
|
DeviceKind string `json:"device_kind"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OnlineClients struct {
|
||||||
|
Clients []OnlineClient `json:"clients"`
|
||||||
|
Online bool `json:"online"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MiniAppArk struct {
|
||||||
|
Ark string `json:"ark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(b []byte) string {
|
||||||
|
s := string(b)
|
||||||
|
if len(s) > 128 {
|
||||||
|
s = s[:128] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user