From 9a5f870225b87e5ee5b9f595a20ecafbd3314af9 Mon Sep 17 00:00:00 2001 From: Duoduo Date: Sun, 25 Oct 2026 15:45:35 +0800 Subject: [PATCH] snowluma: SnowLuma (OneBot 11) Go SDK, drop-in compatible with napcat --- .gitignore | 6 + COMPARISON.md | 111 ++++++++ README.md | 173 +++++++++++++ api.go | 650 +++++++++++++++++++++++++++++++++++++++++++++++ api_ext.go | 528 ++++++++++++++++++++++++++++++++++++++ caller.go | 127 +++++++++ client.go | 477 ++++++++++++++++++++++++++++++++++ connect_http.go | 192 ++++++++++++++ connect_ws.go | 240 +++++++++++++++++ context.go | 136 ++++++++++ cqcode.go | 98 +++++++ doc.go | 23 ++ event.go | 160 ++++++++++++ example_test.go | 54 ++++ go.mod | 5 + go.sum | 2 + segment.go | 200 +++++++++++++++ snowluma_test.go | 75 ++++++ types.go | 360 ++++++++++++++++++++++++++ 19 files changed, 3617 insertions(+) create mode 100644 .gitignore create mode 100644 COMPARISON.md create mode 100644 README.md create mode 100644 api.go create mode 100644 api_ext.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 example_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 segment.go create mode 100644 snowluma_test.go create mode 100644 types.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0eaf351 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Local Go build cache (GOCACHE redirected into the workspace) +.gocache/ + +# Go +*.test +*.out diff --git a/COMPARISON.md b/COMPARISON.md new file mode 100644 index 0000000..fdf30ef --- /dev/null +++ b/COMPARISON.md @@ -0,0 +1,111 @@ +# napcat ↔ snowluma 接口差异对比 + +> 结论先行:**可以直接替换**。现有 napcat 机器人项目只需改一行导入(加别名), +> 103 个 API 方法里 100 个零改动可用、2 个已在本包内做动作名适配、1 个 SnowLuma 未实现。 + +## 一、协议层:完全一致 + +| 维度 | napcat(NapCatQQ) | snowluma(SnowLuma) | +| --- | --- | --- | +| 协议 | OneBot v11 + NapCat 兼容 | OneBot v11 + NapCat 兼容 | +| HTTP API | `POST /{action}`,`Authorization: Bearer` | 同 | +| WebSocket | 正向/反向,echo 配对 | 同 | +| HTTP 上报 | X-Signature (HMAC-SHA1) 快速操作 | 同 | +| 应答信封 | `{status,retcode,data,echo,message,wording}` | 同 | +| 事件 | message/message_sent/notice/request/meta_event | 同 | + +因此两包的 Client/Context/Session/消息段/CQ 码/事件结构完全可互换——本包 `snowluma` +就是按 napcat 的公开 API 逐一对齐移植的,方法名、签名、返回类型、`On*` 注册、消息段构造函数全部同名。 + +## 二、迁移方式 + +napcat 项目原来: + +```go +import "gitea.doicat.com/bot/napcat" +``` + +改成: + +```go +import napcat "gitea.doicat.com/bot/snowluma" +``` + +导入别名 `napcat` 覆盖包声明名 `snowluma`,因此代码里所有 `napcat.XXX` 引用保持不变, +`napcat.New()`、`napcat.Text(...)`、`*napcat.Context`、`napcat.ErrNotConnected` 等照常编译。 +这正是你设想的「方法名写一样 + import rename」。 + +## 三、napcat 103 个方法 → SnowLuma 的映射 + +### 3.1 直接可用(100 个) + +以下方法对应的 wire 动作名在 SnowLuma 中完全相同,零改动即可用(示例,非全量): + +消息收发 `SendPrivateMsg/SendGroupMsg/SendMsg/SendPrivateMsgCQ/SendGroupMsgCQ/DeleteMsg/GetMessage/GetForwardMsg/SendLike/Mark*AsRead/SetMsgEmojiLike`; +合并转发 `SendGroupForwardMsg/SendPrivateForwardMsg/SendForwardMsg/Forward*SingleMsg`; +历史 `GetFriendMsgHistory/GetGroupMsgHistory`; +信息 `GetLoginInfo/GetStrangerInfo/GetFriendList/GetFriendsWithCategory/DeleteFriend/GetGroupInfo/GetGroupInfoEx/GetGroupList/GetGroupMemberInfo/GetGroupMemberList/GetGroupHonorInfo`; +群管理 `SetGroupKick/SetGroupBan/SetGroupWholeBan/SetGroupAdmin/SetGroupCard/SetGroupName/SetGroupLeave/SetGroupSpecialTitle/SetGroupPortrait/SetGroupSign/SendGroupSign/GetGroupAtAllRemain/GetGroupShutList`; +戳一戳/状态 `SendPoke/FriendPoke/GroupPoke/SetInputStatus/SetOnlineStatus/SetSelfLongNick/GetUserStatus/GetPacketStatus/GetProfileLike`; +请求 `SetFriendAddRequest/SetGroupAddRequest`; +文件 `UploadGroupFile/UploadPrivateFile/DeleteGroupFile/DeleteGroupFolder/CreateGroupFileFolder/GetGroupFileSystemInfo/GetGroupRootFiles/GetGroupFilesByFolder/GetGroupFileURL/DownloadFile/GetFile/CheckURLSafely/OCRImage`; +媒体 `GetImage/GetRecord/CanSendImage/CanSendRecord/FetchCustomFace/FetchEmojiLike/GetAICharacters/SendGroupAIRecord/GetMiniAppArk/TranslateEn2Zh/GetRKey`; +系统 `GetStatus/GetVersionInfo/CleanCache/SetRestart/GetCookies/GetCSRFToken/GetCredentials/GetOnlineClients/SetQQProfile/SetQQAvatar/GetModelShow/SetModelShow/GetRecentContact`; +精华/公告/系统消息 `GetEssenceMsgList/SetEssenceMsg/DeleteEssenceMsg/SendGroupNotice/GetGroupNotice/DelGroupNotice/GetGroupSystemMsg`。 + +### 3.2 已做动作名适配(2 个) + +napcat 里这两个动作用了 **NapCat 私有的大小写混合名**,SnowLuma 改用 NapCat 标准名。 +本包保持方法名不变,内部改发 SnowLuma 的标准动作名,因此调用方无感知: + +| napcat 方法 | napcat 动作名 | SnowLuma 动作名(本包已适配) | +| --- | --- | --- | +| `ArkShareGroup(groupID)` | `ArkShareGroup` | `send_group_ark_share` | +| `ArkSharePeer(userID)` | `ArkSharePeer` | `send_ark_share` | + +### 3.3 SnowLuma 未实现(1 个) + +| napcat 方法 | 动作名 | SnowLuma 状态 | +| --- | --- | --- | +| `GetRobotUinRange()` | `get_robot_uin_range` | 未实现(调用返回 retcode 错误) | + +该方法是 NapCat 特有扩展(获取官方机器人号段),SnowLuma 动作注册表中不存在。 +方法签名仍保留(保证代码能编译),运行时才会报错——若你的机器人在用这个接口,需要去掉或改为等价方案。 + +## 四、SnowLuma 独有(napcat 没有)的能力 + +SnowLuma 动作注册表共 **200 个动作**,比 napcat 覆盖的 103 个多出约 96 个。 +本包在 `api_ext.go` 中为其中主要动作提供了类型化方法,其余可用 `Call` 裸调: + +| 类别 | 动作数(约) | 本包封装示例 | +| --- | --- | --- | +| 闪传(flash) | 11 | `CreateFlashTask/GetFilesetInfo/ListFilesets/SendFlashMsg/GetFlashFileURL/...` | +| Ark 卡片 | 6 | `SendGroupArkShare/SendArkShare/SharePeer/ShareGroupEx/SendTuwenArk/ClickInlineKeyboardButton` | +| 群待办 | 4 | `GetGroupTodoList/SetGroupTodo/CompleteGroupTodo/CancelGroupTodo` | +| 群分类标签 | 4 | `GetGroupTags/AddGroupTag/DeleteGroupTag/GetGroupAdminSettings` | +| 群相册 | 9 | `GetGroupAlbumList/UploadImageToQunAlbum/DoGroupAlbumComment/...` | +| QQ 空间 | 10 | `GetQzoneMsgList/SendQzoneMsg/LikeQzone/CommentQzone/SetQzoneBan/...` | +| 系统表情 | 4 | `FetchSysFaces/FetchFaceEntity/SearchSysFaces/FetchSuperFaceID` | +| 流式接口 | 6 | `UploadFileStream/DownloadFileStream/DownloadFileImageStream/...` | +| 群管理扩展 | 10+ | `SetGroupKickMembers/SetGroupAddOption/SetGroupSearch/SetGroupMemberPermissions/...` | +| 其他扩展 | 30+ | `SetFriendRemark/SetGroupRemark/SetGroupReaction/FetchPttText/GetGroupDetailInfo/TransGroupFile/BotExit/GetAIRecord/SendPacket/GetCollectionList/...` | + +未收录到类型化方法的动作(如 `get_group_ignored_notifies`、`send_packet` 的细节参数、 +`create_collection` 等占位/未实现动作)一律通过: + +```go +var out map[string]any +err := bot.Call(nil, "action_name", snowluma.H{"k": "v"}, &out) +// 或事件内:c.Call(nil, "action_name", snowluma.H{...}, &out) +``` + +## 五、替换结论 + +1. **编译层**:100% 兼容——改导入(加别名)即可,全部 103 个方法名都存在。 +2. **运行层**:100/103 方法行为一致;2 个 Ark 方法已适配为 SnowLuma 标准名;1 个(`GetRobotUinRange`)SnowLuma 未实现。 +3. **能力层**:SnowLuma 比 NapCat 多出约 96 个动作(闪传/Ark/待办/相册/空间/系统表情/流式等), + 这些是 NapCat SDK 没有的增量能力,迁移后可按需使用。 + +因此:**「直接替换包名」这个思路成立**,且因为本包连公开 API 名都对齐了 napcat, +连「写一个中间适配层」都不需要,一行 `import napcat "gitea.doicat.com/bot/snowluma"` 即可。 +唯一需要你留意的运行时差异就是 `GetRobotUinRange`(未实现)与两个 Ark 方法(已适配,无需你改)。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1807852 --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# snowluma + +对接 [SnowLuma](https://snowluma.github.io/)(OneBot v11 协议)的 Go 软件包,零第三方依赖(仅 gorilla/websocket)。 + +本包与 [gitea.doicat.com/bot/napcat](https://gitea.doicat.com/bot/napcat) 的公开 API **保持一致**:方法名、消息段构造函数、事件结构、Client/Context 用法完全相同。现有的 napcat 机器人项目,只需改一行导入即可切到 SnowLuma。 + +```go +bot := snowluma.New().SetToken("xxx").SetForwardWS("ws://127.0.0.1:3001") +bot.OnGroupMessage(func(c *snowluma.Context) { + if c.HasAtMe() { + c.ReplyText("hi") + } +}) +bot.Run(ctx) +``` + +## 从 napcat 迁移(drop-in) + +napcat 用法: + +```go +import "gitea.doicat.com/bot/napcat" +// ... napcat.New() / napcat.Text(...) / *napcat.Context ... +``` + +改成: + +```go +import napcat "gitea.doicat.com/bot/snowluma" +// ... 其余代码一字不改,napcat.New() / napcat.Text(...) / *napcat.Context 全部照常编译 +``` + +导入别名 `napcat` 覆盖了包的声明名,因此所有 `napcat.XXX` 引用保持不变。差异只在协议/能力覆盖上(见下文「与 napcat 的差异」)。 + +## 特性 + +- **一个对象搞定**:`New()` 创建与 SnowLuma 交互的客户端对象,配置、事件、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 兼容 + SnowLuma 扩展(闪传、Ark 卡片、群待办、群相册、QQ 空间、系统表情、流式接口等);未收录的扩展可用 `Call` 裸调 +- **echo 路由**:WS 上的 action 请求/响应按 echo 精确配对,支持并发调用 +- **连接级路由**:事件处理函数里的 API 调用自动走产生该事件的连接,多 bot 互不串线 +- 内置文本命令路由、请求 Approve/Reject、心跳记录、panic 隔离 + +## 目录结构 + +```text +snowluma/ +├── doc.go 包文档(pkg.go.dev 首页) +├── client.go Client:配置方法、连接汇聚、事件分发、Handler 注册 +├── context.go Context:处理函数上下文(Reply/Approve/快速操作) +├── api.go API:OneBot 11 / go-cqhttp / NapCat 兼容的类型化方法 +├── api_ext.go API:SnowLuma 扩展动作(闪传/Ark/待办/相册/空间/表情/流式) +├── 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 Example) +└── snowluma_test.go 单元测试(CQ 码回环、事件解码、消息段解码) +``` + +## 安装 + +```text +go get gitea.doicat.com/bot/snowluma +``` + +使用指定版本: + +```text +go get gitea.doicat.com/bot/snowluma@v1.0.0 +``` + +**私有仓库使用者**:如果仓库设为私有,除了给 Git 配置 Gitea 凭据外,还需设置: + +```text +set GOPRIVATE=gitea.doicat.com +``` + +## 快速开始 + +### 1. SnowLuma 侧配置 + +在 SnowLuma 的 OneBot 网络配置里按需启用一项(或多项): + +| 配置方法 | SnowLuma 侧配置 | +| --- | --- | +| `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/` | + +若配置了 token,`SetToken` 填同一个值即可,框架会在 WS 握手(query + Authorization 头)和 HTTP 调用(Authorization 头)中自动携带;HTTP 上报场景若配了 secret,`SetSecret` 会自动校验 `X-Signature`(HMAC-SHA1)。 + +### 2. 完整示例 + +见 [example_test.go](./example_test.go) 的 `Example` 函数。 + +## 配置方法一览 + +| 方法 | 说明 | +| --- | --- | +| `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 +bot.OnRequest(fn) // 好友/群请求 +bot.OnMeta(fn) // 心跳/生命周期 +bot.OnCommand(name, fn) // 文本命令,args 为空白切分的参数 +``` + +同一会话的事件**串行分发**(保序);handler 里的 panic 会被隔离并记录日志。 + +## 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 | + +## 消息段 + +```go +snowluma.Text("hello") +snowluma.At(12345) // snowluma.AtAll() +snowluma.Image("C:/a.jpg") // 支持本地路径 / http(s) URL / base64:// +snowluma.Reply(678).With("text", "引用") +snowluma.CustomNode(10001, "某人", []snowluma.Segment{snowluma.Text("...")}) +``` + +## 测试 + +```text +go test ./... +``` + +## 与 napcat 的差异 + +两包面向不同运行时(SnowLuma vs NapCatQQ),但都是 OneBot v11 + NapCat 兼容协议。协议层完全一致,方法名一一对应;能力覆盖上的差异体现在: + +- **SnowLuma 独有**:闪传(flash)、Ark 卡片分享、群待办、群分类标签、群相册、QQ 空间、系统表情、流式文件上传/下载等扩展动作;本包在 `api_ext.go` 中提供类型化封装。 +- **napcat 有而 SnowLuma 可能未实现/改名**:个别 NapCat 私有动作(如 `ArkShareGroup` 这类大小写混合名)在 SnowLuma 中改用了标准名(`send_group_ark_share`),本包对这些动作做了别名适配,保证同名方法仍可用。 + +未收录到类型化方法里的动作,一律可通过 `Client.Call` / `Context.Call`(或 `CallAPI[T]` 泛型)裸调,因此能力差异不会卡住迁移。 diff --git a/api.go b/api.go new file mode 100644 index 0000000..4bcb792 --- /dev/null +++ b/api.go @@ -0,0 +1,650 @@ +package snowluma + +import "context" + +// H 是裸调 action 时的参数表(配合 Context.Call / Client.Call 使用)。 +type H = map[string]any + +// API 聚合全部 OneBot 11 / go-cqhttp / NapCat 兼容 / SnowLuma 扩展 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 获取 SnowLuma 底层包状态(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 通过 SnowLuma 下载文件到本地,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 里这个动作名是 NapCat 私有的 "ArkShareGroup",SnowLuma 使用 +// NapCat 标准名 "send_group_ark_share",此处做了别名适配,同名方法可直接用。 +func (a API) ArkShareGroup(ctx context.Context, groupID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "send_group_ark_share", H{"group_id": groupID}) +} + +// ArkSharePeer 生成单聊分享卡片。 +// 同 ArkShareGroup:napcat 的 "ArkSharePeer" 在 SnowLuma 里是 "send_ark_share"。 +func (a API) ArkSharePeer(ctx context.Context, userID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "send_ark_share", 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 实现(SnowLuma 可能不支持)。 +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/api_ext.go b/api_ext.go new file mode 100644 index 0000000..204f5b3 --- /dev/null +++ b/api_ext.go @@ -0,0 +1,528 @@ +package snowluma + +import "context" + +// 本文件收录 SnowLuma 独有、napcat 未覆盖的扩展动作。 +// 返回结构未结构化的动作统一返回 jsonRaw(map),需要更细结构时用 Call 裸调自行解码。 +// 未收录的动作一律可通过 Client.Call / Context.Call 裸调。 + +// ---- 闪传(flash transfer) ---- + +// CreateFlashTask 创建闪传任务,返回 fileset 信息。 +func (a API) CreateFlashTask(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "create_flash_task", params) +} + +// GetFilesetInfo 获取文件集信息。 +func (a API) GetFilesetInfo(ctx context.Context, filesetID string) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_fileset_info", H{"fileset_id": filesetID}) +} + +// GetFlashFileList 获取闪传文件集内的文件列表。 +func (a API) GetFlashFileList(ctx context.Context, filesetID string) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_flash_file_list", H{"fileset_id": filesetID}) +} + +// ListFilesets 列出当前账号的所有闪传文件集。 +func (a API) ListFilesets(ctx context.Context) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "list_filesets", H{}) +} + +// GetFlashFileURL 获取闪传文件链接。 +func (a API) GetFlashFileURL(ctx context.Context, filesetID, fileID string) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_flash_file_url", H{"fileset_id": filesetID, "file_id": fileID}) +} + +// GetShareLink 获取文件分享链接。 +func (a API) GetShareLink(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_share_link", params) +} + +// DeleteFlashFile 删除闪传文件。 +func (a API) DeleteFlashFile(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "delete_flash_file", params) + return err +} + +// RenameFlashFile 重命名闪传文件。 +func (a API) RenameFlashFile(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "rename_flash_file", params) + return err +} + +// DownloadFileset 解析闪传文件下载直链(不下载,由调用方实现下载)。 +func (a API) DownloadFileset(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "download_fileset", params) +} + +// SendFlashMsg 发送闪传消息(私聊或群聊,引用 fileset_id 让对端下载)。 +func (a API) SendFlashMsg(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "send_flash_msg", params) +} + +// GetFilesetID 从 QQ 闪传分享码或官方分享链接获取 fileset_id。 +func (a API) GetFilesetID(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_fileset_id", params) +} + +// ---- Ark 卡片 ---- + +// SendGroupArkShare 分享群 Ark 卡片(NapCat 标准名)。 +func (a API) SendGroupArkShare(ctx context.Context, groupID int64, ark string) error { + _, err := CallAPI[jsonNull](a.call, ctx, "send_group_ark_share", H{"group_id": groupID, "ark": ark}) + return err +} + +// SendArkShare 分享用户/群 Ark 卡片(NapCat 标准名)。 +func (a API) SendArkShare(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "send_ark_share", params) + return err +} + +// SharePeer 分享用户/群推荐 Ark 卡片。 +func (a API) SharePeer(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "share_peer", params) +} + +// ShareGroupEx 分享群 Ark 卡片。 +func (a API) ShareGroupEx(ctx context.Context, groupID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "share_group_ex", H{"group_id": groupID}) +} + +// SendTuwenArk 发送图文 Ark 卡片(私聊/群聊)。 +func (a API) SendTuwenArk(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "send_tuwen_ark", params) + return err +} + +// ClickInlineKeyboardButton 点击内联键盘按钮。 +func (a API) ClickInlineKeyboardButton(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "click_inline_keyboard_button", params) + return err +} + +// ---- 群待办 ---- + +// GetGroupTodoList 获取群待办列表。 +func (a API) GetGroupTodoList(ctx context.Context, groupID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_group_todo_list", H{"group_id": groupID}) +} + +// SetGroupTodo 设置群待办。 +func (a API) SetGroupTodo(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_todo", params) + return err +} + +// CompleteGroupTodo 完成群待办。 +func (a API) CompleteGroupTodo(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "complete_group_todo", params) + return err +} + +// CancelGroupTodo 取消群待办。 +func (a API) CancelGroupTodo(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "cancel_group_todo", params) + return err +} + +// ---- 群分类标签 / 群管理扩展 ---- + +// GetGroupTags 读取某个群分类下的资料标签。 +func (a API) GetGroupTags(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_group_tags", params) +} + +// AddGroupTag 向某个群分类添加资料标签。 +func (a API) AddGroupTag(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "add_group_tag", params) + return err +} + +// DeleteGroupTag 从某个群分类移除资料标签。 +func (a API) DeleteGroupTag(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "delete_group_tag", params) + return err +} + +// GetGroupAdminSettings 获取群管理设置的当前值。 +func (a API) GetGroupAdminSettings(ctx context.Context, groupID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_group_admin_settings", H{"group_id": groupID}) +} + +// SetGroupKickMembers 批量踢出群成员。 +func (a API) SetGroupKickMembers(ctx context.Context, groupID int64, userIDs []int64, rejectAddRequest bool) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_kick_members", H{"group_id": groupID, "user_ids": userIDs, "reject_add_request": rejectAddRequest}) + return err +} + +// SetGroupAddOption 设置加群选项。 +func (a API) SetGroupAddOption(ctx context.Context, groupID int64, option string) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_add_option", H{"group_id": groupID, "option": option}) + return err +} + +// SetGroupSearch 设置群被搜索方式(群指纹 / 群号搜索开关)。 +func (a API) SetGroupSearch(ctx context.Context, groupID int64, searchable bool) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_search", H{"group_id": groupID, "searchable": searchable}) + return err +} + +// SetGroupMemberInvitePolicy 设置群成员邀请策略。 +func (a API) SetGroupMemberInvitePolicy(ctx context.Context, groupID int64, policy string) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_member_invite_policy", H{"group_id": groupID, "policy": policy}) + return err +} + +// SetGroupNewMemberHistoryVisibility 设置新成员是否可查看历史消息。 +func (a API) SetGroupNewMemberHistoryVisibility(ctx context.Context, groupID int64, visible bool) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_new_member_history_visibility", H{"group_id": groupID, "visible": visible}) + return err +} + +// SetGroupMemberPermissions 设置群成员权限(仅群主可改)。 +func (a API) SetGroupMemberPermissions(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_member_permissions", params) + return err +} + +// SetGroupRobotAddOption 设置群机器人加群选项。 +func (a API) SetGroupRobotAddOption(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_robot_add_option", params) + return err +} + +// ---- 群相册 ---- + +// GetGroupAlbumList 获取群相册列表。 +func (a API) GetGroupAlbumList(ctx context.Context, groupID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_group_album_list", H{"group_id": groupID}) +} + +// GetQunAlbumList 获取群相册列表(NapCat 风格分页)。 +func (a API) GetQunAlbumList(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_qun_album_list", params) +} + +// UploadImageToQunAlbum 上传图片到群相册。 +func (a API) UploadImageToQunAlbum(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "upload_image_to_qun_album", params) +} + +// UploadVideoToQunAlbum 上传视频到群相册。 +func (a API) UploadVideoToQunAlbum(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "upload_video_to_qun_album", params) +} + +// GetGroupAlbumMediaList 获取相册图片/视频列表(分页)。 +func (a API) GetGroupAlbumMediaList(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_group_album_media_list", params) +} + +// DoGroupAlbumComment 评论相册媒体。 +func (a API) DoGroupAlbumComment(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "do_group_album_comment", params) + return err +} + +// SetGroupAlbumMediaLike 点赞相册媒体。 +func (a API) SetGroupAlbumMediaLike(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_album_media_like", params) + return err +} + +// CancelGroupAlbumMediaLike 取消点赞相册媒体。 +func (a API) CancelGroupAlbumMediaLike(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "cancel_group_album_media_like", params) + return err +} + +// DelGroupAlbumMedia 删除相册图片/视频。 +func (a API) DelGroupAlbumMedia(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "del_group_album_media", params) + return err +} + +// ---- QQ 空间 ---- + +// GetQzoneMsgList 获取空间动态列表(自己的或目标用户的)。 +func (a API) GetQzoneMsgList(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_qzone_msg_list", params) +} + +// GetQzoneMsgDetail 获取动态详情(含一页评论)。 +func (a API) GetQzoneMsgDetail(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_qzone_msg_detail", params) +} + +// GetQzoneFeeds 获取好友动态流(结构化内容 + 游标)。 +func (a API) GetQzoneFeeds(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_qzone_feeds", params) +} + +// SendQzoneMsg 发布空间动态(文本/图片,权限)。 +func (a API) SendQzoneMsg(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "send_qzone_msg", params) +} + +// DeleteQzoneMsg 删除动态(按 tid)。 +func (a API) DeleteQzoneMsg(ctx context.Context, tid string) error { + _, err := CallAPI[jsonNull](a.call, ctx, "delete_qzone_msg", H{"tid": tid}) + return err +} + +// SetQzoneMsgRight 修改动态可见权限。 +func (a API) SetQzoneMsgRight(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_qzone_msg_right", params) + return err +} + +// LikeQzone 点赞空间动态。 +func (a API) LikeQzone(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "like_qzone", params) + return err +} + +// UnlikeQzone 取消点赞空间动态。 +func (a API) UnlikeQzone(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "unlike_qzone", params) + return err +} + +// CommentQzone 评论/回复空间动态(可带图片)。 +func (a API) CommentQzone(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "comment_qzone", params) + return err +} + +// SetQzoneBan 在自己的空间黑名单中拉黑/解除拉黑。 +func (a API) SetQzoneBan(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_qzone_ban", params) + return err +} + +// ---- 系统表情 ---- + +// FetchSysFaces 获取 QQ 系统表情目录(按包)。 +func (a API) FetchSysFaces(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "fetch_sys_faces", params) +} + +// FetchFaceEntity 按 face_id 获取单个表情。 +func (a API) FetchFaceEntity(ctx context.Context, faceID int) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "fetch_face_entity", H{"face_id": faceID}) +} + +// SearchSysFaces 按 id/名称/别名/包搜索系统表情。 +func (a API) SearchSysFaces(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "search_sys_faces", params) +} + +// FetchSuperFaceID 判断某表情是否使用 super-face 格式。 +func (a API) FetchSuperFaceID(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "fetch_super_face_id", params) +} + +// ---- 流式接口 ---- + +// UploadFileStream 分块上传文件到 bot 本地路径(stream)。 +func (a API) UploadFileStream(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "upload_file_stream", params) +} + +// CleanStreamTempFile 清理流式上传/下载临时目录。 +func (a API) CleanStreamTempFile(ctx context.Context) error { + _, err := CallAPI[jsonNull](a.call, ctx, "clean_stream_temp_file", H{}) + return err +} + +// DownloadFileStream 流式下载文件(本地/URL/缓存媒体,分块)。 +func (a API) DownloadFileStream(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "download_file_stream", params) +} + +// DownloadFileImageStream 流式下载图片。 +func (a API) DownloadFileImageStream(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "download_file_image_stream", params) +} + +// DownloadFileRecordStream 流式下载语音。 +func (a API) DownloadFileRecordStream(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "download_file_record_stream", params) +} + +// TestDownloadStream 推送 10 帧测试数据验证流式传输。 +func (a API) TestDownloadStream(ctx context.Context) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "test_download_stream", H{}) +} + +// ---- 其他扩展 ---- + +// SendCustomFace 发送账号自定义表情(emoji_id/MD5)作为图片。 +func (a API) SendCustomFace(ctx context.Context, params H) (MessageID, error) { + out, err := CallAPI[messageIDResult](a.call, ctx, "send_custom_face", params) + return MessageID(out.MessageID), err +} + +// GetGroupShareLink 获取群分享/邀请链接。 +func (a API) GetGroupShareLink(ctx context.Context, groupID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_group_share_link", H{"group_id": groupID}) +} + +// SetGroupReaction 群聊表情回应。 +func (a API) SetGroupReaction(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_reaction", params) + return err +} + +// FetchPttText 获取语音转文字结果(别名 get_ptt_text / get_record_text)。 +func (a API) FetchPttText(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "fetch_ptt_text", params) +} + +// SetFriendRemark 设置好友备注。 +func (a API) SetFriendRemark(ctx context.Context, userID int64, remark string) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_friend_remark", H{"user_id": userID, "remark": remark}) + return err +} + +// SetGroupRemark 设置群备注。 +func (a API) SetGroupRemark(ctx context.Context, groupID int64, remark string) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_group_remark", H{"group_id": groupID, "remark": remark}) + return err +} + +// SetDiyOnlineStatus 设置自定义在线状态。 +func (a API) SetDiyOnlineStatus(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_diy_online_status", params) + return err +} + +// GetGroupIgnoredNotifies 获取被过滤的入群请求。 +func (a API) GetGroupIgnoredNotifies(ctx context.Context, groupID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_group_ignored_notifies", H{"group_id": groupID}) +} + +// GetGroupIgnoreAddRequest 获取被忽略的入群请求(NapCat)。 +func (a API) GetGroupIgnoreAddRequest(ctx context.Context, groupID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_group_ignore_add_request", H{"group_id": groupID}) +} + +// GetGroupSignedList 获取群今日打卡列表。 +func (a API) GetGroupSignedList(ctx context.Context, groupID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_group_signed_list", H{"group_id": groupID}) +} + +// FetchCustomFaceDetail 获取自定义表情详情。 +func (a API) FetchCustomFaceDetail(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "fetch_custom_face_detail", params) +} + +// GetEmojiLikes 获取表情回应用户。 +func (a API) GetEmojiLikes(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_emoji_likes", params) +} + +// GetMsgEmojiLikes 获取一条消息的全部表情回应。 +func (a API) GetMsgEmojiLikes(ctx context.Context, messageID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_msg_emoji_likes", H{"message_id": messageID}) +} + +// SetFriendsCategory 移动好友到指定分组。 +func (a API) SetFriendsCategory(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "set_friends_category", params) + return err +} + +// GetUnidirectionalFriendList 获取单向好友列表。 +func (a API) GetUnidirectionalFriendList(ctx context.Context) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_unidirectional_friend_list", H{}) +} + +// GetClientkey 获取 clientkey + 过期时间 + index。 +func (a API) GetClientkey(ctx context.Context, domain string) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_clientkey", H{"domain": domain}) +} + +// GetGroupDetailInfo 获取群详细信息。 +func (a API) GetGroupDetailInfo(ctx context.Context, groupID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_group_detail_info", H{"group_id": groupID}) +} + +// TransGroupFile 转存群文件。 +func (a API) TransGroupFile(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "trans_group_file", params) + return err +} + +// BotExit 退出机器人。 +func (a API) BotExit(ctx context.Context) error { + _, err := CallAPI[jsonNull](a.call, ctx, "bot_exit", H{}) + return err +} + +// GetAIRecord 生成 AI 语音。 +func (a API) GetAIRecord(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_ai_record", params) +} + +// RequestDecryptKey 请求数据库解密密钥。 +func (a API) RequestDecryptKey(ctx context.Context) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "request_decrypt_key", H{}) +} + +// GetRKeyServer 获取 rkey 服务器信息。 +func (a API) GetRKeyServer(ctx context.Context) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_rkey_server", H{}) +} + +// SendPacket 发送原始 SSO 包(cmd + hex data)。 +func (a API) SendPacket(ctx context.Context, cmd string, data string) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "send_packet", H{"cmd": cmd, "data": data}) +} + +// GetCollectionList 获取收藏列表。 +func (a API) GetCollectionList(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_collection_list", params) +} + +// GetFriendDress 获取指定 QQ 号正在使用的个性装扮。 +func (a API) GetFriendDress(ctx context.Context, userID int64) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "_get_friend_dress", H{"user_id": userID}) +} + +// UploadForwardMsg 上传合并转发资源。 +func (a API) UploadForwardMsg(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "upload_forward_msg", params) +} + +// ---- 群文件扩展 ---- + +// MoveGroupFile 在群文件夹间移动文件。 +func (a API) MoveGroupFile(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "move_group_file", params) + return err +} + +// RenameGroupFile 重命名群文件。 +func (a API) RenameGroupFile(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "rename_group_file", params) + return err +} + +// DeleteGroupFileFolder 删除群文件夹(SnowLuma 标准名,与 DeleteGroupFolder 等价)。 +func (a API) DeleteGroupFileFolder(ctx context.Context, groupID int64, folderID string) error { + _, err := CallAPI[jsonNull](a.call, ctx, "delete_group_file_folder", H{"group_id": groupID, "folder_id": folderID}) + return err +} + +// RenameGroupFileFolder 重命名群文件夹。 +func (a API) RenameGroupFileFolder(ctx context.Context, params H) error { + _, err := CallAPI[jsonNull](a.call, ctx, "rename_group_file_folder", params) + return err +} + +// GetPrivateFileURL 获取私聊文件下载链接。 +func (a API) GetPrivateFileURL(ctx context.Context, params H) (jsonRaw, error) { + return CallAPI[jsonRaw](a.call, ctx, "get_private_file_url", params) +} diff --git a/caller.go b/caller.go new file mode 100644 index 0000000..be79f59 --- /dev/null +++ b/caller.go @@ -0,0 +1,127 @@ +package snowluma + +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("snowluma: 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("snowluma: 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("snowluma: 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("snowluma: unmarshal response failed", "err", err) + return false + } + v, ok := w.pending.Load(resp.Echo) + if !ok { + log.Warn("snowluma: 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..4c11c0f --- /dev/null +++ b/client.go @@ -0,0 +1,477 @@ +package snowluma + +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 是与 SnowLuma 交互的客户端对象:Set* 配置连接,On* 注册事件处理, +// Run 启动后直接调用其内嵌的 API 方法即可操作 SnowLuma。 +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 创建一个与 SnowLuma 交互的客户端对象。 +// 用 Set* 方法配置连接后调用 Run 启动: +// +// bot := snowluma.New().SetToken("xxx").SetForwardWS("ws://127.0.0.1:3001") +// bot.OnMessage(func(c *snowluma.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:本框架作为客户端连接 SnowLuma 的 WS 服务。 +// 可传多个地址同时连接多个 SnowLuma 实例;重复调用会替换之前的正向 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,由 SnowLuma 主动连入。 +// 每个监听地址可承接任意多个 SnowLuma 实例;重复调用会替换之前的反向 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 到 SnowLuma 的 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,SnowLuma 把事件 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("snowluma: 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("snowluma: 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("snowluma: invalid payload", "err", err) + return + } + if probe.PostType != "" { + ev, err := decodeEvent(data) + if err != nil { + c.log.Warn("snowluma: 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("snowluma: 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("snowluma: 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("snowluma: 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..608d10a --- /dev/null +++ b/connect_http.go @@ -0,0 +1,192 @@ +package snowluma + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "sync" + "time" +) + +// HTTPReport HTTP POST 上报连接器:框架起一个 HTTP 服务, +// SnowLuma 把事件 POST 过来;处理函数里调用的 Reply/Approve 会以 +// OneBot「快速操作」的形式随 HTTP 响应返回。 +type HTTPReport struct { + addr string + srv *http.Server +} + +// NewHTTPReport 创建 HTTP 上报连接器,监听 addr(如 ":8081"), +// SnowLuma 侧上报地址填 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 校验 SnowLuma 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 到 SnowLuma 的 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("snowluma: 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("snowluma: http api %s: %w", action, err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("snowluma: action %s not found (404)", action) + } + var envelope Response + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return fmt.Errorf("snowluma: 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("snowluma: 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..0492b31 --- /dev/null +++ b/connect_ws.go @@ -0,0 +1,240 @@ +package snowluma + +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("snowluma: event queue full, event dropped", "post_type", ev.PostType, "self_id", ev.SelfID) + } +} + +// ForwardWS 正向 WebSocket 连接器:框架作为客户端连到 SnowLuma 的 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()) + if !sleepBackoff(ctx, backoff) { + return nil + } + backoff = nextBackoff(backoff) + 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) + // 连接建立后立刻被断开时同样退避,避免在服务端立刻拒绝的场景下 + // 形成 dial→断开→dial 的紧循环(例如 access_token 错误)。 + if !sleepBackoff(ctx, backoff) { + return nil + } + backoff = nextBackoff(backoff) + } +} + +// nextBackoff 按 1s 起步、30s 封顶的指数退避递增。 +func nextBackoff(b time.Duration) time.Duration { + if b < 30*time.Second { + return b * 2 + } + return 30 * time.Second +} + +// sleepBackoff 等待退避时长或在 ctx 取消时提前返回 false。 +func sleepBackoff(ctx context.Context, d time.Duration) bool { + select { + case <-ctx.Done(): + return false + case <-time.After(d): + return true + } +} + +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 服务,由 SnowLuma 主动连入。 +// 支持多个 SnowLuma 实例同时连入(每个连接一个独立会话)。 +type ReverseWS struct { + addr string + srv *http.Server +} + +// NewReverseWS 创建反向 WS 连接器,监听 addr(如 ":8080"), +// SnowLuma 侧反向 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..168cf2d --- /dev/null +++ b/context.go @@ -0,0 +1,136 @@ +package snowluma + +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 "snowluma: " + e.msg } + +// MessageID 是 OneBot 消息 ID。 +type MessageID = int64 diff --git a/cqcode.go b/cqcode.go new file mode 100644 index 0000000..1898a6d --- /dev/null +++ b/cqcode.go @@ -0,0 +1,98 @@ +package snowluma + +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..f2e601c --- /dev/null +++ b/doc.go @@ -0,0 +1,23 @@ +// Package snowluma 是对接 SnowLuma(OneBot v11 协议)的 Go 软件包。 +// +// 通过 New 创建与 SnowLuma 交互的客户端对象,Set* 方法配置连接方式 +// (正向/反向 WebSocket、HTTP API、HTTP POST 上报),On* 注册事件处理, +// Run 启动后即可直接调用对象上的 150+ 个 SnowLuma 接口。 +// +// bot := snowluma.New().SetToken("xxx").SetForwardWS("ws://127.0.0.1:3001") +// bot.OnMessage(func(c *snowluma.Context) { +// if c.HasAtMe() { +// c.ReplyText("hi") +// } +// }) +// bot.Run(ctx) +// +// 本包与 [gitea.doicat.com/bot/napcat] 的公开 API 保持一致,现有的 napcat +// 机器人项目只需把导入路径换成本包并加一个别名即可迁移: +// +// import napcat "gitea.doicat.com/bot/snowluma" +// +// 支持 OneBot 11 标准 API、NapCat/go-cqhttp 兼容 API 与 SnowLuma 扩展 API +// (闪传、Ark 卡片、群待办、群相册、QQ 空间、系统表情、流式接口等); +// 未收录的接口可用 Context.Call / Client.Call 裸调。 +package snowluma diff --git a/event.go b/event.go new file mode 100644 index 0000000..4e6b3bc --- /dev/null +++ b/event.go @@ -0,0 +1,160 @@ +package snowluma + +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,避免 SnowLuma 增加字段时丢数据。 +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("snowluma: 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/example_test.go b/example_test.go new file mode 100644 index 0000000..e16d4b3 --- /dev/null +++ b/example_test.go @@ -0,0 +1,54 @@ +package snowluma_test + +import ( + "context" + "os" + "os/signal" + + snowluma "gitea.doicat.com/bot/snowluma" +) + +// 完整示例:配置连接、注册事件与命令,阻塞运行直到收到中断信号。 +func Example() { + bot := snowluma.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 *snowluma.Context, args []string) { + c.ReplyText("pong!") + }) + + // 群里 @机器人 就复读 + bot.OnGroupMessage(func(c *snowluma.Context) { + if c.HasAtMe() && !c.IsSelfMessage() { + c.ReplyText("收到: " + c.PlainText()) + } + }) + + // 进群欢迎 + bot.OnNoticeType("group_increase", func(c *snowluma.Context) { + c.SendGroupMsg(nil, c.Event.Notice.GroupID, snowluma.Text("欢迎新人~")) + }) + + // 被戳就戳回去 + bot.OnNoticeType("notify", func(c *snowluma.Context) { + n := c.Event.Notice + if n.SubType == "poke" && n.TargetID == c.SelfID() { + c.SendPoke(nil, n.GroupID, n.UserID) + } + }) + + // 好友/群请求自动同意 + bot.OnRequest(func(c *snowluma.Context) { + c.Approve() + }) + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + bot.Run(ctx) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..fba866e --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module gitea.doicat.com/bot/snowluma + +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..d4f0352 --- /dev/null +++ b/segment.go @@ -0,0 +1,200 @@ +package snowluma + +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 字段,可链式调用: +// +// snowluma.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("snowluma: 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 消息段(SnowLuma 下需置于合并转发内发送)。 +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("snowluma: decode message segments: %w", err) + } + return segs, nil + } + var s string + if err := json.Unmarshal(trimmed, &s); err != nil { + return nil, fmt.Errorf("snowluma: 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/snowluma_test.go b/snowluma_test.go new file mode 100644 index 0000000..3a91d97 --- /dev/null +++ b/snowluma_test.go @@ -0,0 +1,75 @@ +package snowluma + +import ( + "encoding/json" + "testing" +) + +func TestCQCodeRoundTrip(t *testing.T) { + segs := []Segment{ + Text("hello "), + At(12345), + Text(" ,[bracket]&comma"), + Image("http://x/y.png"), + } + encoded := EncodeCQ(segs) + decoded := ParseCQ(encoded) + + if len(decoded) != len(segs) { + t.Fatalf("segment count mismatch: got %d want %d", len(decoded), len(segs)) + } + if decoded[0].Type != "text" || decoded[0].Data["text"] != "hello " { + t.Errorf("text segment wrong: %+v", decoded[0]) + } + if decoded[1].Type != "at" || decoded[1].Data["qq"] != "12345" { + t.Errorf("at segment wrong: %+v", decoded[1]) + } + if decoded[3].Type != "image" || decoded[3].Data["file"] != "http://x/y.png" { + t.Errorf("image segment wrong: %+v", decoded[3]) + } +} + +func TestDecodeMessageArrayAndString(t *testing.T) { + arr := json.RawMessage(`[{"type":"text","data":{"text":"hi"}},{"type":"at","data":{"qq":"all"}}]`) + segs, err := DecodeMessage(arr) + if err != nil { + t.Fatal(err) + } + if len(segs) != 2 || segs[1].Type != "at" || segs[1].Data["qq"] != "all" { + t.Fatalf("array decode wrong: %+v", segs) + } + + str := json.RawMessage(`"hello [CQ:at,qq=10001] world"`) + segs, err = DecodeMessage(str) + if err != nil { + t.Fatal(err) + } + if len(segs) != 3 || segs[1].Type != "at" || segs[1].Data["qq"] != "10001" { + t.Fatalf("string decode wrong: %+v", segs) + } +} + +func TestDecodeEventNoticeExtra(t *testing.T) { + data := []byte(`{"post_type":"notice","notice_type":"notify","sub_type":"poke","group_id":1,"user_id":2,"target_id":3,"custom_field":"kept"}`) + ev, err := decodeEvent(data) + if err != nil { + t.Fatal(err) + } + if ev.Notice == nil || ev.Notice.SubType != "poke" { + t.Fatalf("notice decode wrong: %+v", ev.Notice) + } + if ev.Notice.Extra["custom_field"] != "kept" { + t.Fatalf("extra field lost: %+v", ev.Notice.Extra) + } +} + +func TestIntString(t *testing.T) { + var a IntString + if err := json.Unmarshal([]byte(`123`), &a); err != nil || a != 123 { + t.Fatalf("numeric intstring failed: %v %d", err, a) + } + var b IntString + if err := json.Unmarshal([]byte(`"456"`), &b); err != nil || b != 456 { + t.Fatalf("string intstring failed: %v %d", err, b) + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..adcf4f2 --- /dev/null +++ b/types.go @@ -0,0 +1,360 @@ +package snowluma + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" +) + +// ErrNotConnected 表示当前没有任何可用的 OneBot 连接来执行 API 调用。 +var ErrNotConnected = errors.New("snowluma: 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("snowluma: 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("snowluma: invalid integer %q: %w", s, err) + } + *v = IntString(n) + return nil +} + +// Sender 是消息事件的发送者信息。群聊时含 Card/Role,私聊时可能为空。 +// 注意:SnowLuma 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("snowluma: 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("snowluma: 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("snowluma: 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 +}