napcat: NapCatQQ (OneBot 11) Go SDK, initial release

This commit is contained in:
2026-10-18 20:20:29 +08:00
commit 7f1738a987
14 changed files with 2879 additions and 0 deletions

238
README.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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("&", "&#38;", "[", "&#91;", "]", "&#93;")
return r.Replace(s)
}
func escapeParam(s string) string {
r := strings.NewReplacer("&", "&#38;", "[", "&#91;", "]", "&#93;", ",", "&#44;")
return r.Replace(s)
}
func unescapeText(s string) string {
r := strings.NewReplacer("&#91;", "[", "&#93;", "]", "&#38;", "&")
return r.Replace(s)
}
func unescapeParam(s string) string {
r := strings.NewReplacer("&#44;", ",", "&#91;", "[", "&#93;", "]", "&#38;", "&")
return r.Replace(s)
}

17
doc.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
}