From 7f1738a987de6c92511e3c7c96390518040560f4 Mon Sep 17 00:00:00 2001 From: Duoduo Date: Sun, 18 Oct 2026 20:20:29 +0800 Subject: [PATCH] napcat: NapCatQQ (OneBot 11) Go SDK, initial release --- README.md | 238 ++++++++++++++++++ api.go | 647 ++++++++++++++++++++++++++++++++++++++++++++++++ caller.go | 127 ++++++++++ client.go | 477 +++++++++++++++++++++++++++++++++++ connect_http.go | 192 ++++++++++++++ connect_ws.go | 220 ++++++++++++++++ context.go | 136 ++++++++++ cqcode.go | 98 ++++++++ doc.go | 17 ++ event.go | 160 ++++++++++++ go.mod | 5 + go.sum | 2 + segment.go | 200 +++++++++++++++ types.go | 360 +++++++++++++++++++++++++++ 14 files changed, 2879 insertions(+) create mode 100644 README.md create mode 100644 api.go create mode 100644 caller.go create mode 100644 client.go create mode 100644 connect_http.go create mode 100644 connect_ws.go create mode 100644 context.go create mode 100644 cqcode.go create mode 100644 doc.go create mode 100644 event.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 segment.go create mode 100644 types.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a684d5 --- /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/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 封顶指数退避)。 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..f2fe1b0 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitea.doicat.com/bot/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 +}