commit 48d56c64d0760e3dafc655ba69e9b6558283eb2c Author: Duoduo Date: Sun Oct 18 18:37:07 2026 +0800 napcat: NapCatQQ (OneBot 11) Go SDK, initial release diff --git a/README.md b/README.md new file mode 100644 index 0000000..b610560 --- /dev/null +++ b/README.md @@ -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/duoduo/napcat +``` + +使用指定版本: + +``` +go get gitea.doicat.com/duoduo/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" + + "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 封顶指数退避)。 diff --git a/api.go b/api.go new file mode 100644 index 0000000..f14100d --- /dev/null +++ b/api.go @@ -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{} diff --git a/caller.go b/caller.go new file mode 100644 index 0000000..50c92be --- /dev/null +++ b/caller.go @@ -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) +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..cd2ff9d --- /dev/null +++ b/client.go @@ -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 +} diff --git a/connect_http.go b/connect_http.go new file mode 100644 index 0000000..d06ff04 --- /dev/null +++ b/connect_http.go @@ -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 /,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 +} diff --git a/connect_ws.go b/connect_ws.go new file mode 100644 index 0000000..3a955a2 --- /dev/null +++ b/connect_ws.go @@ -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 +} diff --git a/context.go b/context.go new file mode 100644 index 0000000..4b5502d --- /dev/null +++ b/context.go @@ -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 diff --git a/cqcode.go b/cqcode.go new file mode 100644 index 0000000..106e573 --- /dev/null +++ b/cqcode.go @@ -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) +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..d359dd4 --- /dev/null +++ b/doc.go @@ -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 diff --git a/event.go b/event.go new file mode 100644 index 0000000..9e93af4 --- /dev/null +++ b/event.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4193f4b --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitea.doicat.com/duoduo/napcat + +go 1.24 + +require github.com/gorilla/websocket v1.5.3 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..25a9fc4 --- /dev/null +++ b/go.sum @@ -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= diff --git a/segment.go b/segment.go new file mode 100644 index 0000000..182f8d7 --- /dev/null +++ b/segment.go @@ -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 + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..23281c5 --- /dev/null +++ b/types.go @@ -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 +}