snowluma: SnowLuma (OneBot 11) Go SDK, drop-in compatible with napcat
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
# Local Go build cache (GOCACHE redirected into the workspace)
|
||||
.gocache/
|
||||
|
||||
# Go
|
||||
*.test
|
||||
*.out
|
||||
111
COMPARISON.md
Normal file
111
COMPARISON.md
Normal file
@ -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 方法(已适配,无需你改)。
|
||||
173
README.md
Normal file
173
README.md
Normal file
@ -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]` 泛型)裸调,因此能力差异不会卡住迁移。
|
||||
650
api.go
Normal file
650
api.go
Normal file
@ -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{}
|
||||
528
api_ext.go
Normal file
528
api_ext.go
Normal file
@ -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)
|
||||
}
|
||||
127
caller.go
Normal file
127
caller.go
Normal file
@ -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)
|
||||
}
|
||||
477
client.go
Normal file
477
client.go
Normal file
@ -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
|
||||
}
|
||||
192
connect_http.go
Normal file
192
connect_http.go
Normal file
@ -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 <base>/<action>,body 为 params 对象。
|
||||
func (h *HTTPAPI) Call(ctx context.Context, action string, params any, out any) error {
|
||||
if ctx == nil {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
body := []byte("{}")
|
||||
if params != nil {
|
||||
var err error
|
||||
if body, err = json.Marshal(params); err != nil {
|
||||
return fmt.Errorf("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
|
||||
}
|
||||
240
connect_ws.go
Normal file
240
connect_ws.go
Normal file
@ -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
|
||||
}
|
||||
136
context.go
Normal file
136
context.go
Normal file
@ -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
|
||||
98
cqcode.go
Normal file
98
cqcode.go
Normal file
@ -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)
|
||||
}
|
||||
23
doc.go
Normal file
23
doc.go
Normal file
@ -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
|
||||
160
event.go
Normal file
160
event.go
Normal file
@ -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
|
||||
}
|
||||
54
example_test.go
Normal file
54
example_test.go
Normal file
@ -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)
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@ -0,0 +1,5 @@
|
||||
module gitea.doicat.com/bot/snowluma
|
||||
|
||||
go 1.24
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
@ -0,0 +1,2 @@
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
200
segment.go
Normal file
200
segment.go
Normal file
@ -0,0 +1,200 @@
|
||||
package 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
|
||||
}
|
||||
}
|
||||
75
snowluma_test.go
Normal file
75
snowluma_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
360
types.go
Normal file
360
types.go
Normal file
@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user