diff --git a/.github/ISSUE_TEMPLATE/bug--.md b/.github/ISSUE_TEMPLATE/bug--.md index 69a0bba..9135cd0 100644 --- a/.github/ISSUE_TEMPLATE/bug--.md +++ b/.github/ISSUE_TEMPLATE/bug--.md @@ -7,11 +7,20 @@ assignees: '' --- + + **环境信息** 请根据实际使用环境修改以下信息 -go-cqhttp版本: v0.9.10 -运行环境: windows_amd64 -连接方式: 反向WS +go-cqhttp版本: +运行环境: +连接方式: +使用协议: **bug内容** 请在这里详细描述bug的内容 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bca0fe..336a3cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,21 +14,27 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - # build and publish in parallel: linux/386, linux/amd64, windows/386, windows/amd64, darwin/386, darwin/amd64 + # build and publish in parallel: linux/386, linux/amd64, windows/386, windows/amd64, darwin/amd64 goos: [linux, windows, darwin] - goarch: ["386", amd64, arm] + goarch: ["386", amd64, arm, arm64] exclude: - goos: darwin goarch: arm + - goos: darwin + goarch: arm64 + - goos: darwin + goarch: "386" + - goos: windows + goarch: arm64 fail-fast: true steps: - uses: actions/checkout@v2 - name: Setup Go environment - uses: actions/setup-go@v2.1.1 + uses: actions/setup-go@v2.1.3 with: - go-version: 1.14 + go-version: 1.15 - name: Build binary file env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48c5184..589d51a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,21 +10,28 @@ jobs: matrix: # build and publish in parallel: linux/386, linux/amd64, windows/386, windows/amd64, darwin/386, darwin/amd64 goos: [linux, windows, darwin] - goarch: ["386", amd64, arm] + goarch: ["386", amd64, arm, arm64] exclude: - goos: darwin goarch: arm + - goos: darwin + goarch: arm64 + - goos: darwin + goarch: "386" + - goos: windows + goarch: arm64 steps: - uses: actions/checkout@v2 - name: Set RELEASE_VERSION env - run: echo ::set-env name=RELEASE_VERSION::${GITHUB_REF:10} - - uses: wangyoucao577/go-release-action@master + run: echo "RELEASE_VERSION=${GITHUB_REF:10}" >> $GITHUB_ENV + - uses: pcrbot/go-release-action@master env: CGO_ENABLED: 0 with: github_token: ${{ secrets.GITHUB_TOKEN }} goos: ${{ matrix.goos }} goarch: ${{ matrix.goarch }} + goversion: "https://golang.org/dl/go1.15.3.linux-amd64.tar.gz" ldflags: -w -s -X "github.com/Mrs4s/go-cqhttp/coolq.Version=${{ env.RELEASE_VERSION }}" - \ No newline at end of file + diff --git a/.gitignore b/.gitignore index 31e3ac6..4b43257 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ vendor/ .idea +config.hjson +device.json +codec/ +data/ +logs/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 478327d..13bb038 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,16 @@ -FROM golang:1.14.7-alpine AS builder +FROM golang:1.15.5-alpine AS builder RUN go env -w GO111MODULE=auto \ && go env -w CGO_ENABLED=0 \ - && mkdir /build + && go env -w GOPROXY=https://goproxy.cn,direct WORKDIR /build COPY ./ . -RUN cd /build \ - && go build -ldflags "-s -w -extldflags '-static'" -o cqhttp +RUN set -ex \ + && cd /build \ + && go build -ldflags "-s -w -extldflags '-static'" -o cqhttp FROM alpine:latest diff --git a/README.md b/README.md index 4cd982b..db63ab3 100644 --- a/README.md +++ b/README.md @@ -1,107 +1,282 @@ +

+ go-cqhttp +

+ +
+ # go-cqhttp -使用 [mirai](https://github.com/mamoe/mirai) 以及 [MiraiGo](https://github.com/Mrs4s/MiraiGo) 开发的cqhttp golang原生实现, 并在[cqhttp原版](https://github.com/richardchien/coolq-http-api)的基础上做了部分修改和拓展. -文档暂时可查看 `docs` 目录, 目前还在撰写中. -测试版可前往 Release 下载 +_✨ 基于 [Mirai](https://github.com/mamoe/mirai) 以及 [MiraiGo](https://github.com/Mrs4s/MiraiGo) 的 [cqhttp](https://github.com/howmanybots/onebot/blob/master/README.md) golang 原生实现 ✨_ -# 兼容性 +
+ +

+ + license + + + release + + + cqhttp + + + action + +

+ +

+ 文档 + · + 下载 + · + 开始使用 +

+ +--- + +go-cqhttp 在[原版 cqhttp](https://github.com/richardchien/coolq-http-api)的基础上做了部分修改和拓展. + +--- + +## 兼容性 + +### 接口 -#### 接口 - [x] HTTP API -- [x] 反向HTTP POST -- [x] 正向Websocket -- [x] 反向Websocket +- [x] 反向 HTTP POST +- [x] 正向 Websocket +- [x] 反向 Websocket -#### 拓展支持 -> 拓展API可前往 [文档](docs/cqhttp.md) 查看 -- [x] HTTP POST多点上报 -- [x] 反向WS多点连接 +### 拓展支持 + +> 拓展 API 可前往 [文档](docs/cqhttp.md) 查看 + +- [x] HTTP POST 多点上报 +- [x] 反向 WS 多点连接 - [x] 修改群名 - [x] 消息撤回事件 - [x] 解析/发送 回复消息 - [x] 解析/发送 合并转发 -- [ ] 使用代理请求网络图片 +- [x] 使用代理请求网络图片 + +### 实现 -#### 实现
已实现CQ码 -- [CQ:image] -- [CQ:record] -- [CQ:video] -- [CQ:face] -- [CQ:at] -- [CQ:share] -- [CQ:reply] -- [CQ:forward] -- [CQ:node] +#### 符合 Onebot 标准的 CQ 码 + +| CQ 码 | 功能 | +| ------------ | --------------------------- | +| [CQ:face] | [QQ 表情] | +| [CQ:record] | [语音] | +| [CQ:video] | [短视频] | +| [CQ:at] | [@某人] | +| [CQ:share] | [链接分享] | +| [CQ:music] | [音乐分享] [音乐自定义分享] | +| [CQ:reply] | [回复] | +| [CQ:forward] | [合并转发] | +| [CQ:node] | [合并转发节点] | +| [CQ:xml] | [XML 消息] | +| [CQ:json] | [JSON 消息] | + +[qq 表情]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#qq-%E8%A1%A8%E6%83%85 +[语音]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#%E8%AF%AD%E9%9F%B3 +[短视频]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#%E7%9F%AD%E8%A7%86%E9%A2%91 +[@某人]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#%E6%9F%90%E4%BA%BA +[链接分享]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#%E9%93%BE%E6%8E%A5%E5%88%86%E4%BA%AB +[音乐分享]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#%E9%9F%B3%E4%B9%90%E5%88%86%E4%BA%AB- +[音乐自定义分享]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#%E9%9F%B3%E4%B9%90%E8%87%AA%E5%AE%9A%E4%B9%89%E5%88%86%E4%BA%AB- +[回复]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#%E5%9B%9E%E5%A4%8D +[合并转发]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#%E5%90%88%E5%B9%B6%E8%BD%AC%E5%8F%91- +[合并转发节点]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#%E5%90%88%E5%B9%B6%E8%BD%AC%E5%8F%91%E8%8A%82%E7%82%B9- +[xml 消息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#xml-%E6%B6%88%E6%81%AF +[json 消息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/message/segment.md#json-%E6%B6%88%E6%81%AF + +#### 拓展 CQ 码及与 Onebot 标准有略微差异的 CQ 码 + +| 拓展 CQ 码 | 功能 | +| -------------- | --------------------------------- | +| [CQ:image] | [图片] | +| [CQ:redbag] | [红包] | +| [CQ:poke] | [戳一戳] | +| [CQ:gift] | [礼物] | +| [CQ:node] | [合并转发消息节点] | +| [CQ:cardimage] | [一种 xml 的图片消息(装逼大图)] | +| [CQ:tts] | [文本转语音] | + +[图片]: docs/cqhttp.md#%E5%9B%BE%E7%89%87 +[红包]: docs/cqhttp.md#%E7%BA%A2%E5%8C%85 +[戳一戳]: docs/cqhttp.md#%E6%88%B3%E4%B8%80%E6%88%B3 +[礼物]: docs/cqhttp.md#%E7%A4%BC%E7%89%A9 +[合并转发消息节点]: docs/cqhttp.md#%E5%90%88%E5%B9%B6%E8%BD%AC%E5%8F%91%E6%B6%88%E6%81%AF%E8%8A%82%E7%82%B9 +[一种 xml 的图片消息(装逼大图)]: docs/cqhttp.md#cardimage-%E4%B8%80%E7%A7%8Dxml%E7%9A%84%E5%9B%BE%E7%89%87%E6%B6%88%E6%81%AF%E8%A3%85%E9%80%BC%E5%A4%A7%E5%9B%BE +[文本转语音]: docs/cqhttp.md#%E6%96%87%E6%9C%AC%E8%BD%AC%E8%AF%AD%E9%9F%B3
已实现API -##### 注意: 部分API实现与CQHTTP原版略有差异,请参考文档 -| API | 功能 | -| ------------------------ | ------------------------------------------------------------ | -| /get_login_info | [获取登录号信息](https://cqhttp.cc/docs/4.15/#/API?id=get_login_info-获取登录号信息) | -| /get_friend_list | [获取好友列表](https://cqhttp.cc/docs/4.15/#/API?id=get_friend_list-获取好友列表) | -| /get_group_list | [获取群列表](https://cqhttp.cc/docs/4.15/#/API?id=get_group_list-获取群列表) | -| /get_group_info | [获取群信息](https://cqhttp.cc/docs/4.15/#/API?id=get_group_info-获取群信息) | -| /get_group_member_list | [获取群成员列表](https://cqhttp.cc/docs/4.15/#/API?id=get_group_member_list-获取群成员列表) | -| /get_group_member_info | [获取群成员信息](https://cqhttp.cc/docs/4.15/#/API?id=get_group_member_info-获取群成员信息) | -| /send_msg | [发送消息](https://cqhttp.cc/docs/4.15/#/API?id=send_msg-发送消息) | -| /send_group_msg | [发送群消息](https://cqhttp.cc/docs/4.15/#/API?id=send_group_msg-发送群消息) | -| /send_private_msg | [发送私聊消息](https://cqhttp.cc/docs/4.15/#/API?id=send_private_msg-发送私聊消息) | -| /delete_msg | [撤回信息](https://cqhttp.cc/docs/4.15/#/API?id=delete_msg-撤回消息) | -| /set_friend_add_request | [处理加好友请求](https://cqhttp.cc/docs/4.15/#/API?id=set_friend_add_request-处理加好友请求) | -| /set_group_add_request | [处理加群请求/邀请](https://cqhttp.cc/docs/4.15/#/API?id=set_group_add_request-处理加群请求/邀请) | -| /set_group_card | [设置群名片(群备注)](https://cqhttp.cc/docs/4.15/#/API?id=set_group_card-设置群名片(群备注)) | -| /set_group_special_title | [设置群组专属头衔](https://cqhttp.cc/docs/4.15/#/API?id=set_group_special_title-设置群组专属头衔) | -| /set_group_kick | [群组T人](https://cqhttp.cc/docs/4.15/#/API?id=set_group_kick-群组踢人) | -| /set_group_ban | [群组单人禁言](https://cqhttp.cc/docs/4.15/#/API?id=set_group_ban-群组单人禁言) | -| /set_group_whole_ban | [群组全员禁言](https://cqhttp.cc/docs/4.15/#/API?id=set_group_whole_ban-群组全员禁言) | -| /set_group_leave | [退出群组](https://cqhttp.cc/docs/4.15/#/API?id=set_group_leave-退出群组) | -| /set_group_name | 设置群组名(拓展API) | -| /get_image | 获取图片信息(拓展API) | -| /get_group_msg | 获取群组消息(拓展API) | -| /can_send_image | [检查是否可以发送图片](https://cqhttp.cc/docs/4.15/#/API?id=can_send_image-检查是否可以发送图片) | -| /can_send_record | [检查是否可以发送语音](https://cqhttp.cc/docs/4.15/#/API?id=can_send_record-检查是否可以发送语音) | -| /get_status | [获取插件运行状态](https://cqhttp.cc/docs/4.15/#/API?id=get_status-获取插件运行状态) | -| /get_version_info | [获取 酷Q 及 CQHTTP插件的版本信息](https://cqhttp.cc/docs/4.15/#/API?id=get_version_info-获取-酷q-及-cqhttp-插件的版本信息) | +#### 符合 Onebot 标准的 API + +| API | 功能 | +| ------------------------ | ---------------------- | +| /send_private_msg | [发送私聊消息] | +| /send_group_msg | [发送群消息] | +| /send_msg | [发送消息] | +| /delete_msg | [撤回信息] | +| /set_group_kick | [群组踢人] | +| /set_group_ban | [群组单人禁言] | +| /set_group_whole_ban | [群组全员禁言] | +| /set_group_admin | [群组设置管理员] | +| /set_group_card | [设置群名片(群备注)] | +| /set_group_name | [设置群名] | +| /set_group_leave | [退出群组] | +| /set_group_special_title | [设置群组专属头衔] | +| /set_friend_add_request | [处理加好友请求] | +| /set_group_add_request | [处理加群请求/邀请] | +| /get_login_info | [获取登录号信息] | +| /get_stranger_info | [获取陌生人信息] | +| /get_friend_list | [获取好友列表] | +| /get_group_info | [获取群信息] | +| /get_group_list | [获取群列表] | +| /get_group_member_info | [获取群成员信息] | +| /get_group_member_list | [获取群成员列表] | +| /get_group_honor_info | [获取群荣誉信息] | +| /can_send_image | [检查是否可以发送图片] | +| /can_send_record | [检查是否可以发送语音] | +| /get_version_info | [获取版本信息] | +| /set_restart | [重启 go-cqhttp] | +| /.handle_quick_operation | [对事件执行快速操作] | + +[发送私聊消息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#send_private_msg-%E5%8F%91%E9%80%81%E7%A7%81%E8%81%8A%E6%B6%88%E6%81%AF +[发送群消息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#send_group_msg-%E5%8F%91%E9%80%81%E7%BE%A4%E6%B6%88%E6%81%AF +[发送消息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#send_msg-%E5%8F%91%E9%80%81%E6%B6%88%E6%81%AF +[撤回信息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#delete_msg-%E6%92%A4%E5%9B%9E%E6%B6%88%E6%81%AF +[群组踢人]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_kick-%E7%BE%A4%E7%BB%84%E8%B8%A2%E4%BA%BA +[群组单人禁言]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_ban-%E7%BE%A4%E7%BB%84%E5%8D%95%E4%BA%BA%E7%A6%81%E8%A8%80 +[群组全员禁言]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_whole_ban-%E7%BE%A4%E7%BB%84%E5%85%A8%E5%91%98%E7%A6%81%E8%A8%80 +[群组设置管理员]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_admin-%E7%BE%A4%E7%BB%84%E8%AE%BE%E7%BD%AE%E7%AE%A1%E7%90%86%E5%91%98 +[设置群名片(群备注)]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_card-%E8%AE%BE%E7%BD%AE%E7%BE%A4%E5%90%8D%E7%89%87%E7%BE%A4%E5%A4%87%E6%B3%A8 +[设置群名]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_name-%E8%AE%BE%E7%BD%AE%E7%BE%A4%E5%90%8D +[退出群组]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_leave-%E9%80%80%E5%87%BA%E7%BE%A4%E7%BB%84 +[设置群组专属头衔]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_special_title-%E8%AE%BE%E7%BD%AE%E7%BE%A4%E7%BB%84%E4%B8%93%E5%B1%9E%E5%A4%B4%E8%A1%94 +[处理加好友请求]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_friend_add_request-%E5%A4%84%E7%90%86%E5%8A%A0%E5%A5%BD%E5%8F%8B%E8%AF%B7%E6%B1%82 +[处理加群请求/邀请]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_group_add_request-%E5%A4%84%E7%90%86%E5%8A%A0%E7%BE%A4%E8%AF%B7%E6%B1%82%E9%82%80%E8%AF%B7 +[获取登录号信息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_login_info-%E8%8E%B7%E5%8F%96%E7%99%BB%E5%BD%95%E5%8F%B7%E4%BF%A1%E6%81%AF +[获取陌生人信息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_stranger_info-%E8%8E%B7%E5%8F%96%E9%99%8C%E7%94%9F%E4%BA%BA%E4%BF%A1%E6%81%AF +[获取好友列表]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_friend_list-%E8%8E%B7%E5%8F%96%E5%A5%BD%E5%8F%8B%E5%88%97%E8%A1%A8 +[获取群信息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_group_info-%E8%8E%B7%E5%8F%96%E7%BE%A4%E4%BF%A1%E6%81%AF +[获取群列表]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_group_list-%E8%8E%B7%E5%8F%96%E7%BE%A4%E5%88%97%E8%A1%A8 +[获取群成员信息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_group_member_info-%E8%8E%B7%E5%8F%96%E7%BE%A4%E6%88%90%E5%91%98%E4%BF%A1%E6%81%AF +[获取群成员列表]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_group_member_list-%E8%8E%B7%E5%8F%96%E7%BE%A4%E6%88%90%E5%91%98%E5%88%97%E8%A1%A8 +[获取群荣誉信息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_group_honor_info-%E8%8E%B7%E5%8F%96%E7%BE%A4%E8%8D%A3%E8%AA%89%E4%BF%A1%E6%81%AF +[检查是否可以发送图片]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#can_send_image-%E6%A3%80%E6%9F%A5%E6%98%AF%E5%90%A6%E5%8F%AF%E4%BB%A5%E5%8F%91%E9%80%81%E5%9B%BE%E7%89%87 +[检查是否可以发送语音]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#can_send_record-%E6%A3%80%E6%9F%A5%E6%98%AF%E5%90%A6%E5%8F%AF%E4%BB%A5%E5%8F%91%E9%80%81%E8%AF%AD%E9%9F%B3 +[获取版本信息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_version_info-%E8%8E%B7%E5%8F%96%E7%89%88%E6%9C%AC%E4%BF%A1%E6%81%AF +[重启 go-cqhttp]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#set_restart-%E9%87%8D%E5%90%AF-onebot-%E5%AE%9E%E7%8E%B0 +[对事件执行快速操作]: https://github.com/howmanybots/onebot/blob/master/v11/specs/api/hidden.md#handle_quick_operation-%E5%AF%B9%E4%BA%8B%E4%BB%B6%E6%89%A7%E8%A1%8C%E5%BF%AB%E9%80%9F%E6%93%8D%E4%BD%9C + +#### 拓展 API 及与 Onebot 标准有略微差异的 API + +| 拓展 API | 功能 | +| --------------------------- | ---------------------- | +| /set_group_portrait | [设置群头像] | +| /get_image | [获取图片信息] | +| /get_msg | [获取消息] | +| /get_forward_msg | [获取合并转发内容] | +| /send_group_forward_msg | [发送合并转发(群)] | +| /.get_word_slices | [获取中文分词] | +| /.ocr_image | [图片 OCR] | +| /get_group_system_msg | [获取群系统消息] | +| /get_group_file_system_info | [获取群文件系统信息] | +| /get_group_root_files | [获取群根目录文件列表] | +| /get_group_files_by_folder | [获取群子目录文件列表] | +| /get_group_file_url | [获取群文件资源链接] | +| /get_status | [获取状态] | + +[设置群头像]: docs/cqhttp.md#%E8%AE%BE%E7%BD%AE%E7%BE%A4%E5%A4%B4%E5%83%8F +[获取图片信息]: docs/cqhttp.md#%E8%8E%B7%E5%8F%96%E5%9B%BE%E7%89%87%E4%BF%A1%E6%81%AF +[获取消息]: docs/cqhttp.md#%E8%8E%B7%E5%8F%96%E6%B6%88%E6%81%AF +[获取合并转发内容]: docs/cqhttp.md#%E8%8E%B7%E5%8F%96%E5%90%88%E5%B9%B6%E8%BD%AC%E5%8F%91%E5%86%85%E5%AE%B9 +[发送合并转发(群)]: docs/cqhttp.md#%E5%8F%91%E9%80%81%E5%90%88%E5%B9%B6%E8%BD%AC%E5%8F%91%E7%BE%A4 +[获取中文分词]: docs/cqhttp.md#%E8%8E%B7%E5%8F%96%E4%B8%AD%E6%96%87%E5%88%86%E8%AF%8D +[图片 ocr]: docs/cqhttp.md#%E5%9B%BE%E7%89%87ocr +[获取群系统消息]: docs/cqhttp.md#%E8%8E%B7%E5%8F%96%E7%BE%A4%E7%B3%BB%E7%BB%9F%E6%B6%88%E6%81%AF +[获取群文件系统信息]: docs/cqhttp.md#%E8%8E%B7%E5%8F%96%E7%BE%A4%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F%E4%BF%A1%E6%81%AF +[获取群根目录文件列表]: docs/cqhttp.md#%E8%8E%B7%E5%8F%96%E7%BE%A4%E6%A0%B9%E7%9B%AE%E5%BD%95%E6%96%87%E4%BB%B6%E5%88%97%E8%A1%A8 +[获取群子目录文件列表]: docs/cqhttp.md#%E8%8E%B7%E5%8F%96%E7%BE%A4%E5%AD%90%E7%9B%AE%E5%BD%95%E6%96%87%E4%BB%B6%E5%88%97%E8%A1%A8 +[获取群文件资源链接]: docs/cqhttp.md#%E8%8E%B7%E5%8F%96%E7%BE%A4%E6%96%87%E4%BB%B6%E8%B5%84%E6%BA%90%E9%93%BE%E6%8E%A5 +[获取状态]: docs/cqhttp.md#%E8%8E%B7%E5%8F%96%E7%8A%B6%E6%80%81
已实现Event -##### 注意: 部分Event数据与CQHTTP原版略有差异,请参考文档 -| Event | -| ------------------------------------------------------------ | -| [私聊信息](https://cqhttp.cc/docs/4.15/#/Post?id=私聊消息) | -| [群消息](https://cqhttp.cc/docs/4.15/#/Post?id=群消息) | -| [群消息撤回(拓展Event)](docs/cqhttp.md#群消息撤回) | -| [好友消息撤回(拓展Event)](docs/cqhttp.md#好友消息撤回) | -| [群管理员变动](https://cqhttp.cc/docs/4.15/#/Post?id=群管理员变动) | -| [群成员减少](https://cqhttp.cc/docs/4.15/#/Post?id=群成员减少) | -| [群成员增加](https://cqhttp.cc/docs/4.15/#/Post?id=群成员增加) | -| [群禁言](https://cqhttp.cc/docs/4.15/#/Post?id=群禁言) | -| [群文件上传](https://cqhttp.cc/docs/4.15/#/Post?id=群文件上传)| -| [加好友请求](https://cqhttp.cc/docs/4.15/#/Post?id=加好友请求) | -| [加群请求/邀请](https://cqhttp.cc/docs/4.15/#/Post?id=加群请求/邀请) | +#### 符合 Onebot 标准的 Event(部分 Event 比 Onebot 标准多上报几个字段,不影响使用) + +| 事件类型 | Event | +| -------- | ---------------- | +| 消息事件 | [私聊信息] | +| 消息事件 | [群消息] | +| 通知事件 | [群文件上传] | +| 通知事件 | [群管理员变动] | +| 通知事件 | [群成员减少] | +| 通知事件 | [群成员增加] | +| 通知事件 | [群禁言] | +| 通知事件 | [好友添加] | +| 通知事件 | [群消息撤回] | +| 通知事件 | [好友消息撤回] | +| 通知事件 | [群内戳一戳] | +| 通知事件 | [群红包运气王] | +| 通知事件 | [群成员荣誉变更] | +| 请求事件 | [加好友请求] | +| 请求事件 | [加群请求/邀请] | + +[私聊信息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/message.md#%E7%A7%81%E8%81%8A%E6%B6%88%E6%81%AF +[群消息]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/message.md#%E7%BE%A4%E6%B6%88%E6%81%AF +[群文件上传]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E7%BE%A4%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0 +[群管理员变动]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E7%BE%A4%E7%AE%A1%E7%90%86%E5%91%98%E5%8F%98%E5%8A%A8 +[群成员减少]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E7%BE%A4%E6%88%90%E5%91%98%E5%87%8F%E5%B0%91 +[群成员增加]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E7%BE%A4%E6%88%90%E5%91%98%E5%A2%9E%E5%8A%A0 +[群禁言]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E7%BE%A4%E7%A6%81%E8%A8%80 +[好友添加]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E5%A5%BD%E5%8F%8B%E6%B7%BB%E5%8A%A0 +[群消息撤回]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E7%BE%A4%E6%B6%88%E6%81%AF%E6%92%A4%E5%9B%9E +[好友消息撤回]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E5%A5%BD%E5%8F%8B%E6%B6%88%E6%81%AF%E6%92%A4%E5%9B%9E +[群内戳一戳]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E7%BE%A4%E5%86%85%E6%88%B3%E4%B8%80%E6%88%B3 +[群红包运气王]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E7%BE%A4%E7%BA%A2%E5%8C%85%E8%BF%90%E6%B0%94%E7%8E%8B +[群成员荣誉变更]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/notice.md#%E7%BE%A4%E6%88%90%E5%91%98%E8%8D%A3%E8%AA%89%E5%8F%98%E6%9B%B4 +[加好友请求]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/request.md#%E5%8A%A0%E5%A5%BD%E5%8F%8B%E8%AF%B7%E6%B1%82 +[加群请求/邀请]: https://github.com/howmanybots/onebot/blob/master/v11/specs/event/request.md#%E5%8A%A0%E7%BE%A4%E8%AF%B7%E6%B1%82%E9%82%80%E8%AF%B7 + +#### 拓展 Event + +| 事件类型 | 拓展 Event | +| -------- | ---------------- | +| 通知事件 | [好友戳一戳] | +| 通知事件 | [群成员名片更新] | +| 通知事件 | [接收到离线文件] | + +[好友戳一戳]: docs/cqhttp.md#%E5%A5%BD%E5%8F%8B%E6%88%B3%E4%B8%80%E6%88%B3 +[群成员名片更新]: docs/cqhttp.md#%E7%BE%A4%E6%88%90%E5%91%98%E5%90%8D%E7%89%87%E6%9B%B4%E6%96%B0 +[接收到离线文件]: docs/cqhttp.md#%E6%8E%A5%E6%94%B6%E5%88%B0%E7%A6%BB%E7%BA%BF%E6%96%87%E4%BB%B6
-# 关于ISSUE +## 关于 ISSUE -以下ISSUE会被直接关闭 -- 提交BUG不使用Template +以下 ISSUE 会被直接关闭 + +- 提交 BUG 不使用 Template - 询问已知问题 - 提问找不到重点 - 重复提问 > 请注意, 开发者并没有义务回复您的问题. 您应该具备基本的提问技巧。 -# 性能 +## 性能 -在关闭数据库的情况下, 加载25个好友128个群运行24小时后内存使用为10MB左右. 开启数据库后内存使用将根据消息量增加10-20MB, 如果系统内存小于128M建议关闭数据库使用. +在关闭数据库的情况下, 加载 25 个好友 128 个群运行 24 小时后内存使用为 10MB 左右. 开启数据库后内存使用将根据消息量增加 10-20MB, 如果系统内存小于 128M 建议关闭数据库使用. diff --git a/coolq/api.go b/coolq/api.go index 486c069..19b5770 100644 --- a/coolq/api.go +++ b/coolq/api.go @@ -1,11 +1,16 @@ package coolq import ( + "crypto/md5" + "encoding/hex" "io/ioutil" + "math" "os" "path" + "path/filepath" "runtime" "strconv" + "strings" "time" "github.com/Mrs4s/MiraiGo/binary" @@ -54,10 +59,32 @@ func (bot *CQBot) CQGetGroupList(noCache bool) MSG { } // https://cqhttp.cc/docs/4.15/#/API?id=get_group_info-%E8%8E%B7%E5%8F%96%E7%BE%A4%E4%BF%A1%E6%81%AF -func (bot *CQBot) CQGetGroupInfo(groupId int64) MSG { +func (bot *CQBot) CQGetGroupInfo(groupId int64, noCache bool) MSG { group := bot.Client.FindGroup(groupId) if group == nil { - return Failed(100) + gid := strconv.FormatInt(groupId, 10) + info, err := bot.Client.SearchGroupByKeyword(gid) + if err != nil { + return Failed(100, "GROUP_SEARCH_ERROR", "群聊搜索失败") + } + for _, g := range info { + if g.Code == groupId { + return OK(MSG{ + "group_id": g.Code, + "group_name": g.Name, + "max_member_count": 0, + "member_count": 0, + }) + } + } + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在失败") + } + if noCache { + var err error + group, err = bot.Client.GetGroupInfo(groupId) + if err != nil { + return Failed(100, "GET_GROUP_INFO_API_ERROR", err.Error()) + } } return OK(MSG{ "group_id": group.Code, @@ -71,13 +98,13 @@ func (bot *CQBot) CQGetGroupInfo(groupId int64) MSG { func (bot *CQBot) CQGetGroupMemberList(groupId int64, noCache bool) MSG { group := bot.Client.FindGroup(groupId) if group == nil { - return Failed(100) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } if noCache { t, err := bot.Client.GetGroupMembers(group) if err != nil { log.Warnf("刷新群 %v 成员列表失败: %v", groupId, err) - return Failed(100) + return Failed(100, "GET_MEMBERS_API_ERROR", err.Error()) } group.Members = t } @@ -92,17 +119,105 @@ func (bot *CQBot) CQGetGroupMemberList(groupId int64, noCache bool) MSG { func (bot *CQBot) CQGetGroupMemberInfo(groupId, userId int64) MSG { group := bot.Client.FindGroup(groupId) if group == nil { - return Failed(100) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } member := group.FindMember(userId) if member == nil { - return Failed(102) + return Failed(100, "MEMBER_NOT_FOUND", "群员不存在") } return OK(convertGroupMemberInfo(groupId, member)) } +func (bot *CQBot) CQGetGroupFileSystemInfo(groupId int64) MSG { + fs, err := bot.Client.GetGroupFileSystem(groupId) + if err != nil { + log.Errorf("获取群 %v 文件系统信息失败: %v", groupId, err) + return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) + } + return OK(fs) +} + +func (bot *CQBot) CQGetGroupRootFiles(groupId int64) MSG { + fs, err := bot.Client.GetGroupFileSystem(groupId) + if err != nil { + log.Errorf("获取群 %v 文件系统信息失败: %v", groupId, err) + return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) + } + files, folders, err := fs.Root() + if err != nil { + log.Errorf("获取群 %v 根目录文件失败: %v", groupId, err) + return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) + } + return OK(MSG{ + "files": files, + "folders": folders, + }) +} + +func (bot *CQBot) CQGetGroupFilesByFolderId(groupId int64, folderId string) MSG { + fs, err := bot.Client.GetGroupFileSystem(groupId) + if err != nil { + log.Errorf("获取群 %v 文件系统信息失败: %v", groupId, err) + return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) + } + files, folders, err := fs.GetFilesByFolder(folderId) + if err != nil { + log.Errorf("获取群 %v 根目录 %v 子文件失败: %v", groupId, folderId, err) + return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) + } + return OK(MSG{ + "files": files, + "folders": folders, + }) +} + +func (bot *CQBot) CQGetGroupFileUrl(groupId int64, fileId string, busId int32) MSG { + url := bot.Client.GetGroupFileUrl(groupId, fileId, busId) + if url == "" { + return Failed(100, "FILE_SYSTEM_API_ERROR") + } + return OK(MSG{ + "url": url, + }) +} + +func (bot *CQBot) CQUploadGroupFile(groupId int64, file, name, folder string) MSG { + if !global.PathExists(file) { + log.Errorf("上传群文件 %v 失败: 文件不存在", file) + return Failed(100, "FILE_NOT_FOUND", "文件不存在") + } + fs, err := bot.Client.GetGroupFileSystem(groupId) + if err != nil { + log.Errorf("获取群 %v 文件系统信息失败: %v", groupId, err) + return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) + } + if folder == "" { + folder = "/" + } + if err = fs.UploadFile(file, name, folder); err != nil { + log.Errorf("上传群 %v 文件 %v 失败: %v", groupId, file, err) + return Failed(100, "FILE_SYSTEM_UPLOAD_API_ERROR", err.Error()) + } + return OK(nil) +} + +func (bot *CQBot) CQGetWordSlices(content string) MSG { + slices, err := bot.Client.GetWordSegmentation(content) + if err != nil { + return Failed(100, "WORD_SEGMENTATION_API_ERROR", err.Error()) + } + for i := 0; i < len(slices); i++ { + slices[i] = strings.ReplaceAll(slices[i], "\u0000", "") + } + return OK(MSG{"slices": slices}) +} + // https://cqhttp.cc/docs/4.15/#/API?id=send_group_msg-%E5%8F%91%E9%80%81%E7%BE%A4%E6%B6%88%E6%81%AF func (bot *CQBot) CQSendGroupMessage(groupId int64, i interface{}, autoEscape bool) MSG { + if bot.Client.FindGroup(groupId) == nil { + log.Warnf("群消息发送失败: 群 %v 不存在", groupId) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") + } var str string fixAt := func(elem []message.IMessageElement) { for _, e := range elem { @@ -123,8 +238,9 @@ func (bot *CQBot) CQSendGroupMessage(groupId int64, i interface{}, autoEscape bo fixAt(elem) mid := bot.SendGroupMessage(groupId, &message.SendingMessage{Elements: elem}) if mid == -1 { - return Failed(100) + return Failed(100, "SEND_MSG_API_ERROR", "请参考输出") } + log.Infof("发送群 %v(%v) 的消息: %v (%v)", groupId, groupId, limitedString(m.String()), mid) return OK(MSG{"message_id": mid}) } str = func() string { @@ -138,7 +254,7 @@ func (bot *CQBot) CQSendGroupMessage(groupId int64, i interface{}, autoEscape bo } if str == "" { log.Warnf("群消息发送失败: 信息为空. MSG: %v", i) - return Failed(100) + return Failed(100, "EMPTY_MSG_ERROR", "消息为空") } var elem []message.IMessageElement if autoEscape { @@ -149,8 +265,9 @@ func (bot *CQBot) CQSendGroupMessage(groupId int64, i interface{}, autoEscape bo fixAt(elem) mid := bot.SendGroupMessage(groupId, &message.SendingMessage{Elements: elem}) if mid == -1 { - return Failed(100) + return Failed(100, "SEND_MSG_API_ERROR", "请参考输出") } + log.Infof("发送群 %v(%v) 的消息: %v (%v)", groupId, groupId, limitedString(str), mid) return OK(MSG{"message_id": mid}) } @@ -158,7 +275,7 @@ func (bot *CQBot) CQSendGroupForwardMessage(groupId int64, m gjson.Result) MSG { if m.Type != gjson.JSON { return Failed(100) } - var nodes []*message.ForwardNode + var sendNodes []*message.ForwardNode ts := time.Now().Add(-time.Minute * 5) hasCustom := func() bool { for _, item := range m.Array() { @@ -168,24 +285,26 @@ func (bot *CQBot) CQSendGroupForwardMessage(groupId int64, m gjson.Result) MSG { } return false }() - convert := func(e gjson.Result) { + var convert func(e gjson.Result) []*message.ForwardNode + convert = func(e gjson.Result) (nodes []*message.ForwardNode) { if e.Get("type").Str != "node" { - return + return nil } ts.Add(time.Second) if e.Get("data.id").Exists() { - i, _ := strconv.Atoi(e.Get("data.id").Str) - m := bot.GetGroupMessage(int32(i)) + i, _ := strconv.Atoi(e.Get("data.id").String()) + m := bot.GetMessage(int32(i)) if m != nil { sender := m["sender"].(message.Sender) nodes = append(nodes, &message.ForwardNode{ SenderId: sender.Uin, SenderName: (&sender).DisplayName(), Time: func() int32 { - if hasCustom { + msgTime := m["time"].(int32) + if hasCustom && msgTime == 0 { return int32(ts.Unix()) } - return m["time"].(int32) + return msgTime }(), Message: bot.ConvertStringMessage(m["message"].(string), true), }) @@ -195,13 +314,41 @@ func (bot *CQBot) CQSendGroupForwardMessage(groupId int64, m gjson.Result) MSG { return } uin, _ := strconv.ParseInt(e.Get("data.uin").Str, 10, 64) + msgTime, err := strconv.ParseInt(e.Get("data.time").Str, 10, 64) + if err != nil { + msgTime = ts.Unix() + } name := e.Get("data.name").Str + c := e.Get("data.content") + if c.IsArray() { + flag := false + c.ForEach(func(_, value gjson.Result) bool { + if value.Get("type").String() == "node" { + flag = true + return false + } + return true + }) + if flag { + var taowa []*message.ForwardNode + for _, item := range c.Array() { + taowa = append(taowa, convert(item)...) + } + nodes = append(nodes, &message.ForwardNode{ + SenderId: uin, + SenderName: name, + Time: int32(msgTime), + Message: []message.IMessageElement{bot.Client.UploadGroupForwardMessage(groupId, &message.ForwardMessage{Nodes: taowa})}, + }) + return + } + } content := bot.ConvertObjectMessage(e.Get("data.content"), true) if uin != 0 && name != "" && len(content) > 0 { var newElem []message.IMessageElement for _, elem := range content { - if img, ok := elem.(*message.ImageElement); ok { - gm, err := bot.Client.UploadGroupImage(groupId, img.Data) + if img, ok := elem.(*LocalImageElement); ok { + gm, err := bot.UploadLocalImageAsGroup(groupId, img) if err != nil { log.Warnf("警告:群 %v 图片上传失败: %v", groupId, err) continue @@ -209,29 +356,39 @@ func (bot *CQBot) CQSendGroupForwardMessage(groupId int64, m gjson.Result) MSG { newElem = append(newElem, gm) continue } + if video, ok := elem.(*LocalVideoElement); ok { + gm, err := bot.UploadLocalVideo(groupId, video) + if err != nil { + log.Warnf("警告:群 %v 视频上传失败: %v", groupId, err) + continue + } + newElem = append(newElem, gm) + continue + } newElem = append(newElem, elem) } nodes = append(nodes, &message.ForwardNode{ SenderId: uin, SenderName: name, - Time: int32(ts.Unix()), + Time: int32(msgTime), Message: newElem, }) return } log.Warnf("警告: 非法 Forward node 将跳过") + return } if m.IsArray() { for _, item := range m.Array() { - convert(item) + sendNodes = append(sendNodes, convert(item)...) } } else { - convert(m) + sendNodes = convert(m) } - if len(nodes) > 0 { - gm := bot.Client.SendGroupForwardMessage(groupId, &message.ForwardMessage{Nodes: nodes}) + if len(sendNodes) > 0 { + gm := bot.Client.SendGroupForwardMessage(groupId, &message.ForwardMessage{Nodes: sendNodes}) return OK(MSG{ - "message_id": ToGlobalId(groupId, gm.Id), + "message_id": bot.InsertGroupMessage(gm), }) } return Failed(100) @@ -242,11 +399,12 @@ func (bot *CQBot) CQSendPrivateMessage(userId int64, i interface{}, autoEscape b var str string if m, ok := i.(gjson.Result); ok { if m.Type == gjson.JSON { - elem := bot.ConvertObjectMessage(m, true) + elem := bot.ConvertObjectMessage(m, false) mid := bot.SendPrivateMessage(userId, &message.SendingMessage{Elements: elem}) if mid == -1 { - return Failed(100) + return Failed(100, "SEND_MSG_API_ERROR", "请参考输出") } + log.Infof("发送好友 %v(%v) 的消息: %v (%v)", userId, userId, limitedString(m.String()), mid) return OK(MSG{"message_id": mid}) } str = func() string { @@ -259,7 +417,7 @@ func (bot *CQBot) CQSendPrivateMessage(userId int64, i interface{}, autoEscape b str = s } if str == "" { - return Failed(100) + return Failed(100, "EMPTY_MSG_ERROR", "消息为空") } var elem []message.IMessageElement if autoEscape { @@ -269,8 +427,9 @@ func (bot *CQBot) CQSendPrivateMessage(userId int64, i interface{}, autoEscape b } mid := bot.SendPrivateMessage(userId, &message.SendingMessage{Elements: elem}) if mid == -1 { - return Failed(100) + return Failed(100, "SEND_MSG_API_ERROR", "请参考输出") } + log.Infof("发送好友 %v(%v) 的消息: %v (%v)", userId, userId, limitedString(str), mid) return OK(MSG{"message_id": mid}) } @@ -282,7 +441,7 @@ func (bot *CQBot) CQSetGroupCard(groupId, userId int64, card string) MSG { return OK(nil) } } - return Failed(100) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // https://cqhttp.cc/docs/4.15/#/API?id=set_group_special_title-%E8%AE%BE%E7%BD%AE%E7%BE%A4%E7%BB%84%E4%B8%93%E5%B1%9E%E5%A4%B4%E8%A1%94 @@ -293,7 +452,7 @@ func (bot *CQBot) CQSetGroupSpecialTitle(groupId, userId int64, title string) MS return OK(nil) } } - return Failed(100) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } func (bot *CQBot) CQSetGroupName(groupId int64, name string) MSG { @@ -301,7 +460,7 @@ func (bot *CQBot) CQSetGroupName(groupId int64, name string) MSG { g.UpdateName(name) return OK(nil) } - return Failed(100) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } func (bot *CQBot) CQSetGroupMemo(groupId int64, msg string) MSG { @@ -309,18 +468,18 @@ func (bot *CQBot) CQSetGroupMemo(groupId int64, msg string) MSG { g.UpdateMemo(msg) return OK(nil) } - return Failed(100) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // https://cqhttp.cc/docs/4.15/#/API?id=set_group_kick-%E7%BE%A4%E7%BB%84%E8%B8%A2%E4%BA%BA -func (bot *CQBot) CQSetGroupKick(groupId, userId int64, msg string) MSG { +func (bot *CQBot) CQSetGroupKick(groupId, userId int64, msg string, block bool) MSG { if g := bot.Client.FindGroup(groupId); g != nil { if m := g.FindMember(userId); m != nil { - m.Kick(msg) + m.Kick(msg, block) return OK(nil) } } - return Failed(100) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // https://cqhttp.cc/docs/4.15/#/API?id=set_group_ban-%E7%BE%A4%E7%BB%84%E5%8D%95%E4%BA%BA%E7%A6%81%E8%A8%80 @@ -331,7 +490,7 @@ func (bot *CQBot) CQSetGroupBan(groupId, userId int64, duration uint32) MSG { return OK(nil) } } - return Failed(100) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // https://cqhttp.cc/docs/4.15/#/API?id=set_group_whole_ban-%E7%BE%A4%E7%BB%84%E5%85%A8%E5%91%98%E7%A6%81%E8%A8%80 @@ -340,7 +499,7 @@ func (bot *CQBot) CQSetGroupWholeBan(groupId int64, enable bool) MSG { g.MuteAll(enable) return OK(nil) } - return Failed(100) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // https://cqhttp.cc/docs/4.15/#/API?id=set_group_leave-%E9%80%80%E5%87%BA%E7%BE%A4%E7%BB%84 @@ -349,14 +508,25 @@ func (bot *CQBot) CQSetGroupLeave(groupId int64) MSG { g.Quit() return OK(nil) } - return Failed(100) + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") +} + +func (bot *CQBot) CQGetAtAllRemain(groupId int64) MSG { + if g := bot.Client.FindGroup(groupId); g != nil { + i, err := bot.Client.GetAtAllRemain(groupId) + if err != nil { + return Failed(100, "GROUP_REMAIN_API_ERROR", err.Error()) + } + return OK(i) + } + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // https://cqhttp.cc/docs/4.15/#/API?id=set_friend_add_request-%E5%A4%84%E7%90%86%E5%8A%A0%E5%A5%BD%E5%8F%8B%E8%AF%B7%E6%B1%82 func (bot *CQBot) CQProcessFriendRequest(flag string, approve bool) MSG { req, ok := bot.friendReqCache.Load(flag) if !ok { - return Failed(100) + return Failed(100, "FLAG_NOT_FOUND", "FLAG不存在") } if approve { req.(*client.NewFriendRequest).Accept() @@ -368,39 +538,67 @@ func (bot *CQBot) CQProcessFriendRequest(flag string, approve bool) MSG { // https://cqhttp.cc/docs/4.15/#/API?id=set_group_add_request-%E5%A4%84%E7%90%86%E5%8A%A0%E7%BE%A4%E8%AF%B7%E6%B1%82%EF%BC%8F%E9%82%80%E8%AF%B7 func (bot *CQBot) CQProcessGroupRequest(flag, subType, reason string, approve bool) MSG { + msgs, err := bot.Client.GetGroupSystemMessages() + if err != nil { + log.Errorf("获取群系统消息失败: %v", err) + return Failed(100, "SYSTEM_MSG_API_ERROR", err.Error()) + } if subType == "add" { - req, ok := bot.joinReqCache.Load(flag) - if !ok { - return Failed(100) + for _, req := range msgs.JoinRequests { + if strconv.FormatInt(req.RequestId, 10) == flag { + if req.Checked { + log.Errorf("处理群系统消息失败: 无法操作已处理的消息.") + return Failed(100, "FLAG_HAS_BEEN_CHECKED", "消息已被处理") + } + if approve { + req.Accept() + } else { + req.Reject(false, reason) + } + return OK(nil) + } } - bot.joinReqCache.Delete(flag) - if approve { - req.(*client.UserJoinGroupRequest).Accept() - } else { - req.(*client.UserJoinGroupRequest).Reject(false, reason) + } else { + for _, req := range msgs.InvitedRequests { + if strconv.FormatInt(req.RequestId, 10) == flag { + if req.Checked { + log.Errorf("处理群系统消息失败: 无法操作已处理的消息.") + return Failed(100, "FLAG_HAS_BEEN_CHECKED", "消息已被处理") + } + if approve { + req.Accept() + } else { + req.Reject(false, reason) + } + return OK(nil) + } } - return OK(nil) } - req, ok := bot.invitedReqCache.Load(flag) - if ok { - bot.invitedReqCache.Delete(flag) - if approve { - req.(*client.GroupInvitedRequest).Accept() - } else { - req.(*client.GroupInvitedRequest).Reject(false, reason) - } - return OK(nil) - } - return Failed(100) + log.Errorf("处理群系统消息失败: 消息 %v 不存在.", flag) + return Failed(100, "FLAG_NOT_FOUND", "FLAG不存在") } // https://cqhttp.cc/docs/4.15/#/API?id=delete_msg-%E6%92%A4%E5%9B%9E%E6%B6%88%E6%81%AF func (bot *CQBot) CQDeleteMessage(messageId int32) MSG { - msg := bot.GetGroupMessage(messageId) + msg := bot.GetMessage(messageId) if msg == nil { - return Failed(100) + return Failed(100, "MESSAGE_NOT_FOUND", "消息不存在") + } + if _, ok := msg["group"]; ok { + if err := bot.Client.RecallGroupMessage(msg["group"].(int64), msg["message-id"].(int32), msg["internal-id"].(int32)); err != nil { + log.Warnf("撤回 %v 失败: %v", messageId, err) + return Failed(100, "RECALL_API_ERROR", err.Error()) + } + } else { + if msg["sender"].(message.Sender).Uin != bot.Client.Uin { + log.Warnf("撤回 %v 失败: 好友会话无法撤回对方消息.", messageId) + return Failed(100, "CANNOT_RECALL_FRIEND_MSG", "无法撤回对方消息") + } + if err := bot.Client.RecallPrivateMessage(msg["target"].(int64), int64(msg["time"].(int32)), msg["message-id"].(int32), msg["internal-id"].(int32)); err != nil { + log.Warnf("撤回 %v 失败: %v", messageId, err) + return Failed(100, "RECALL_API_ERROR", err.Error()) + } } - bot.Client.RecallGroupMessage(msg["group"].(int64), msg["message-id"].(int32), msg["internal-id"].(int32)) return OK(nil) } @@ -408,29 +606,28 @@ func (bot *CQBot) CQDeleteMessage(messageId int32) MSG { func (bot *CQBot) CQSetGroupAdmin(groupId, userId int64, enable bool) MSG { group := bot.Client.FindGroup(groupId) if group == nil || group.OwnerUin != bot.Client.Uin { - return Failed(100) + return Failed(100, "PERMISSION_DENIED", "群不存在或权限不足") } mem := group.FindMember(userId) if mem == nil { - return Failed(100) + return Failed(100, "GROUP_MEMBER_NOT_FOUND", "群成员不存在") } mem.SetAdmin(enable) t, err := bot.Client.GetGroupMembers(group) if err != nil { log.Warnf("刷新群 %v 成员列表失败: %v", groupId, err) - return Failed(100) + return Failed(100, "GET_MEMBERS_API_ERROR", err.Error()) } group.Members = t return OK(nil) } func (bot *CQBot) CQGetVipInfo(userId int64) MSG { - msg := MSG{} vip, err := bot.Client.GetVipInfo(userId) if err != nil { - return Failed(100) + return Failed(100, "VIP_API_ERROR", err.Error()) } - msg = MSG{ + msg := MSG{ "user_id": vip.Uin, "nickname": vip.Name, "level": vip.Level, @@ -497,6 +694,31 @@ func (bot *CQBot) CQGetGroupHonorInfo(groupId int64, t string) MSG { return OK(msg) } +// https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_stranger_info-%E8%8E%B7%E5%8F%96%E9%99%8C%E7%94%9F%E4%BA%BA%E4%BF%A1%E6%81%AF +func (bot *CQBot) CQGetStrangerInfo(userId int64) MSG { + info, err := bot.Client.GetSummaryInfo(userId) + if err != nil { + return Failed(100, "SUMMARY_API_ERROR", err.Error()) + } + return OK(MSG{ + "user_id": info.Uin, + "nickname": info.Nickname, + "qid": info.Qid, + "sex": func() string { + if info.Sex == 1 { + return "female" + } else if info.Sex == 0 { + return "male" + } + // unknown = 0x2 + return "unknown" + }(), + "age": info.Age, + "level": info.Level, + "login_days": info.LoginDays, + }) +} + // https://cqhttp.cc/docs/4.15/#/API?id=-handle_quick_operation-%E5%AF%B9%E4%BA%8B%E4%BB%B6%E6%89%A7%E8%A1%8C%E5%BF%AB%E9%80%9F%E6%93%8D%E4%BD%9C // https://github.com/richardchien/coolq-http-api/blob/master/src/cqhttp/plugins/web/http.cpp#L376 func (bot *CQBot) CQHandleQuickOperation(context, operation gjson.Result) MSG { @@ -528,7 +750,7 @@ func (bot *CQBot) CQHandleQuickOperation(context, operation gjson.Result) MSG { bot.CQDeleteMessage(int32(context.Get("message_id").Int())) } if operation.Get("kick").Bool() && !isAnonymous { - bot.CQSetGroupKick(context.Get("group_id").Int(), context.Get("user_id").Int(), "") + bot.CQSetGroupKick(context.Get("group_id").Int(), context.Get("user_id").Int(), "", operation.Get("reject_add_request").Bool()) } if operation.Get("ban").Bool() { var duration uint32 = 30 * 60 @@ -556,10 +778,10 @@ func (bot *CQBot) CQHandleQuickOperation(context, operation gjson.Result) MSG { } func (bot *CQBot) CQGetImage(file string) MSG { - if !global.PathExists(path.Join(global.IMAGE_PATH, file)) { + if !global.PathExists(path.Join(global.ImagePath, file)) { return Failed(100) } - if b, err := ioutil.ReadFile(path.Join(global.IMAGE_PATH, file)); err == nil { + if b, err := ioutil.ReadFile(path.Join(global.ImagePath, file)); err == nil { r := binary.NewReader(b) r.ReadBytes(16) msg := MSG{ @@ -567,7 +789,7 @@ func (bot *CQBot) CQGetImage(file string) MSG { "filename": r.ReadString(), "url": r.ReadString(), } - local := path.Join(global.CACHE_PATH, file+"."+path.Ext(msg["filename"].(string))) + local := path.Join(global.CachePath, file+"."+path.Ext(msg["filename"].(string))) if !global.PathExists(local) { if data, err := global.GetBytes(msg["url"].(string)); err == nil { _ = ioutil.WriteFile(local, data, 0644) @@ -575,14 +797,34 @@ func (bot *CQBot) CQGetImage(file string) MSG { } msg["file"] = local return OK(msg) + } else { + return Failed(100, "LOAD_FILE_ERROR", err.Error()) } - return Failed(100) +} + +func (bot *CQBot) CQDownloadFile(url string, headers map[string]string, threadCount int) MSG { + hash := md5.Sum([]byte(url)) + file := path.Join(global.CachePath, hex.EncodeToString(hash[:])+".cache") + if global.PathExists(file) { + if err := os.Remove(file); err != nil { + log.Warnf("删除缓存文件 %v 时出现错误: %v", file, err) + return Failed(100, "DELETE_FILE_ERROR", err.Error()) + } + } + if err := global.DownloadFileMultiThreading(url, file, 0, threadCount, headers); err != nil { + log.Warnf("下载链接 %v 时出现错误: %v", url, err) + return Failed(100, "DOWNLOAD_FILE_ERROR", err.Error()) + } + abs, _ := filepath.Abs(file) + return OK(MSG{ + "file": abs, + }) } func (bot *CQBot) CQGetForwardMessage(resId string) MSG { m := bot.Client.GetForwardMessage(resId) if m == nil { - return Failed(100) + return Failed(100, "MSG_NOT_FOUND", "消息不存在") } r := make([]MSG, 0) for _, n := range m.Nodes { @@ -601,21 +843,99 @@ func (bot *CQBot) CQGetForwardMessage(resId string) MSG { }) } -func (bot *CQBot) CQGetGroupMessage(messageId int32) MSG { - msg := bot.GetGroupMessage(messageId) +func (bot *CQBot) CQGetMessage(messageId int32) MSG { + msg := bot.GetMessage(messageId) if msg == nil { - return Failed(100) + return Failed(100, "MSG_NOT_FOUND", "消息不存在") } sender := msg["sender"].(message.Sender) + gid, isGroup := msg["group"] + raw := msg["message"].(string) return OK(MSG{ - "message_id": messageId, - "real_id": msg["message-id"], + "message_id": messageId, + "real_id": msg["message-id"], + "message_seq": msg["message-id"], + "group": isGroup, + "group_id": gid, + "message_type": func() string { + if isGroup { + return "group" + } + return "private" + }(), "sender": MSG{ "user_id": sender.Uin, "nickname": sender.Nickname, }, - "time": msg["time"], - "content": msg["message"], + "time": msg["time"], + "raw_message": raw, + "message": ToFormattedMessage(bot.ConvertStringMessage(raw, isGroup), func() int64 { + if isGroup { + return gid.(int64) + } + return sender.Uin + }(), false), + }) +} + +func (bot *CQBot) CQGetGroupSystemMessages() MSG { + msg, err := bot.Client.GetGroupSystemMessages() + if err != nil { + log.Warnf("获取群系统消息失败: %v", err) + return Failed(100, "SYSTEM_MSG_API_ERROR", err.Error()) + } + return OK(msg) +} + +func (bot *CQBot) CQGetGroupMessageHistory(groupId int64, seq int64) MSG { + if g := bot.Client.FindGroup(groupId); g == nil { + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") + } + if seq == 0 { + g, err := bot.Client.GetGroupInfo(groupId) + if err != nil { + return Failed(100, "GROUP_INFO_API_ERROR", err.Error()) + } + seq = g.LastMsgSeq + } + msg, err := bot.Client.GetGroupMessages(groupId, int64(math.Max(float64(seq-19), 1)), seq) + if err != nil { + log.Warnf("获取群历史消息失败: %v", err) + return Failed(100, "MESSAGES_API_ERROR", err.Error()) + } + var ms []MSG + for _, m := range msg { + id := m.Id + bot.checkMedia(m.Elements) + if bot.db != nil { + id = bot.InsertGroupMessage(m) + } + t := bot.formatGroupMessage(m) + t["message_id"] = id + ms = append(ms, t) + } + return OK(MSG{ + "messages": ms, + }) +} + +func (bot *CQBot) CQGetOnlineClients(noCache bool) MSG { + if noCache { + if err := bot.Client.RefreshStatus(); err != nil { + log.Warnf("刷新客户端状态时出现问题 %v", err) + return Failed(100, "REFRESH_STATUS_ERROR", err.Error()) + } + } + var d []MSG + for _, oc := range bot.Client.OnlineClients { + d = append(d, MSG{ + "app_id": oc.AppId, + "device_name": oc.DeviceName, + "device_kind": oc.DeviceKind, + }) + } + return OK(MSG{ + "clients": d, }) } @@ -627,11 +947,59 @@ func (bot *CQBot) CQCanSendRecord() MSG { return OK(MSG{"yes": true}) } +func (bot *CQBot) CQOcrImage(imageId string) MSG { + img, err := bot.makeImageOrVideoElem(map[string]string{"file": imageId}, false, true) + if err != nil { + log.Warnf("load image error: %v", err) + return Failed(100, "LOAD_FILE_ERROR", err.Error()) + } + rsp, err := bot.Client.ImageOcr(img) + if err != nil { + log.Warnf("ocr image error: %v", err) + return Failed(100, "OCR_API_ERROR", err.Error()) + } + return OK(rsp) +} + func (bot *CQBot) CQReloadEventFilter() MSG { global.BootFilter() return OK(nil) } +func (bot *CQBot) CQSetGroupPortrait(groupId int64, file, cache string) MSG { + if g := bot.Client.FindGroup(groupId); g != nil { + img, err := global.FindFile(file, cache, global.ImagePath) + if err != nil { + log.Warnf("set group portrait error: %v", err) + return Failed(100, "LOAD_FILE_ERROR", err.Error()) + } + g.UpdateGroupHeadPortrait(img) + return OK(nil) + } + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") +} + +func (bot *CQBot) CQSetGroupAnonymousBan(groupId int64, flag string, duration int32) MSG { + if flag == "" { + return Failed(100, "INVALID_FLAG", "无效的flag") + } + if g := bot.Client.FindGroup(groupId); g != nil { + s := strings.SplitN(flag, "|", 2) + if len(s) != 2 { + return Failed(100, "INVALID_FLAG", "无效的flag") + } + id := s[0] + nick := s[1] + if err := g.MuteAnonymous(id, nick, duration); err != nil { + log.Warnf("anonymous ban error: %v", err) + return Failed(100, "CALL_API_ERROR", err.Error()) + } + return OK(nil) + } + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") +} + +// https://github.com/howmanybots/onebot/blob/master/v11/specs/api/public.md#get_status-%E8%8E%B7%E5%8F%96%E8%BF%90%E8%A1%8C%E7%8A%B6%E6%80%81 func (bot *CQBot) CQGetStatus() MSG { return OK(MSG{ "app_initialized": true, @@ -639,7 +1007,76 @@ func (bot *CQBot) CQGetStatus() MSG { "plugins_good": nil, "app_good": true, "online": bot.Client.Online, - "good": true, + "good": bot.Client.Online, + "stat": bot.Client.GetStatistics(), + }) +} + +// CQSetEssenceMessage 设置精华消息 +func (bot *CQBot) CQSetEssenceMessage(messageID int32) MSG { + msg := bot.GetMessage(messageID) + if msg == nil { + return Failed(100, "MESSAGE_NOT_FOUND", "消息不存在") + } + if _, ok := msg["group"]; ok { + if err := bot.Client.SetEssenceMessage(msg["group"].(int64), msg["message-id"].(int32), msg["internal-id"].(int32)); err != nil { + log.Warnf("设置精华消息 %v 失败: %v", messageID, err) + return Failed(100, "SET_ESSENCE_MSG_ERROR", err.Error()) + } + } else { + log.Warnf("设置精华消息 %v 失败: 非群聊", messageID) + return Failed(100, "SET_ESSENCE_MSG_ERROR", "非群聊") + } + return OK(nil) +} + +// CQDeleteEssenceMessage 移出精华消息 +func (bot *CQBot) CQDeleteEssenceMessage(messageID int32) MSG { + msg := bot.GetMessage(messageID) + if msg == nil { + return Failed(100, "MESSAGE_NOT_FOUND", "消息不存在") + } + if _, ok := msg["group"]; ok { + if err := bot.Client.DeleteEssenceMessage(msg["group"].(int64), msg["message-id"].(int32), msg["internal-id"].(int32)); err != nil { + log.Warnf("移出精华消息 %v 失败: %v", messageID, err) + return Failed(100, "DEL_ESSENCE_MSG_ERROR", err.Error()) + } + } else { + log.Warnf("移出精华消息 %v 失败: 非群聊", messageID) + return Failed(100, "DEL_ESSENCE_MSG_ERROR", "非群聊") + } + return OK(nil) +} + +// CQGetEssenceMessageList 获取精华消息列表 +func (bot *CQBot) CQGetEssenceMessageList(groupCode int64) MSG { + g := bot.Client.FindGroup(groupCode) + if g == nil { + return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") + } + msgList, err := bot.Client.GetGroupEssenceMsgList(groupCode) + if err != nil { + return Failed(100, "GET_ESSENCE_LIST_FOUND", err.Error()) + } + list := make([]MSG, 0) + for _, m := range msgList { + var msg = MSG{ + "sender_nick": m.SenderNick, + "sender_time": m.SenderTime, + "operator_time": m.AddDigestTime, + "operator_nick": m.AddDigestNick, + } + msg["sender_id"], _ = strconv.ParseUint(m.SenderUin, 10, 64) + msg["operator_id"], _ = strconv.ParseUint(m.AddDigestUin, 10, 64) + msg["message_id"] = ToGlobalId(groupCode, int32(m.MessageID)) + list = append(list, msg) + } + return OK(list) +} + +func (bot *CQBot) CQCheckUrlSafely(url string) MSG { + return OK(MSG{ + "level": bot.Client.CheckUrlSafely(url), }) } @@ -655,6 +1092,20 @@ func (bot *CQBot) CQGetVersionInfo() MSG { "runtime_version": runtime.Version(), "runtime_os": runtime.GOOS, "version": Version, + "protocol": func() int { + switch client.SystemDeviceInfo.Protocol { + case client.IPad: + return 0 + case client.AndroidPhone: + return 1 + case client.AndroidWatch: + return 2 + case client.MacOS: + return 3 + default: + return -1 + } + }(), }) } @@ -662,17 +1113,33 @@ func OK(data interface{}) MSG { return MSG{"data": data, "retcode": 0, "status": "ok"} } -func Failed(code int) MSG { - return MSG{"data": nil, "retcode": code, "status": "failed"} +func Failed(code int, msg ...string) MSG { + m := "" + w := "" + if len(msg) > 0 { + m = msg[0] + } + if len(msg) > 1 { + w = msg[1] + } + return MSG{"data": nil, "retcode": code, "msg": m, "wording": w, "status": "failed"} } func convertGroupMemberInfo(groupId int64, m *client.GroupMemberInfo) MSG { return MSG{ - "group_id": groupId, - "user_id": m.Uin, - "nickname": m.Nickname, - "card": m.CardName, - "sex": "unknown", + "group_id": groupId, + "user_id": m.Uin, + "nickname": m.Nickname, + "card": m.CardName, + "sex": func() string { + if m.Gender == 1 { + return "female" + } else if m.Gender == 0 { + return "male" + } + // unknown = 0xff + return "unknown" + }(), "age": 0, "area": "", "join_time": m.JoinTime, @@ -694,3 +1161,12 @@ func convertGroupMemberInfo(groupId int64, m *client.GroupMemberInfo) MSG { "card_changeable": false, } } + +func limitedString(str string) string { + if strings.Count(str, "") <= 10 { + return str + } + limited := []rune(str) + limited = limited[:10] + return string(limited) + " ..." +} diff --git a/coolq/bot.go b/coolq/bot.go index 063b94d..bad338f 100644 --- a/coolq/bot.go +++ b/coolq/bot.go @@ -3,50 +3,54 @@ package coolq import ( "bytes" "encoding/gob" - "encoding/json" + "encoding/hex" "fmt" "hash/crc32" + "io" + "os" "path" + "runtime/debug" "sync" "time" + "github.com/Mrs4s/MiraiGo/utils" + + "github.com/syndtr/goleveldb/leveldb" + "github.com/Mrs4s/MiraiGo/binary" "github.com/Mrs4s/MiraiGo/client" "github.com/Mrs4s/MiraiGo/message" "github.com/Mrs4s/go-cqhttp/global" + jsoniter "github.com/json-iterator/go" log "github.com/sirupsen/logrus" - "github.com/tidwall/gjson" - "github.com/xujiajun/nutsdb" ) +var json = jsoniter.ConfigCompatibleWithStandardLibrary + type CQBot struct { Client *client.QQClient - events []func(MSG) - db *nutsdb.DB - friendReqCache sync.Map - invitedReqCache sync.Map - joinReqCache sync.Map - tempMsgCache sync.Map - oneWayMsgCache sync.Map + events []func(MSG) + db *leveldb.DB + friendReqCache sync.Map + tempMsgCache sync.Map + oneWayMsgCache sync.Map } type MSG map[string]interface{} var ForceFragmented = false -func NewQQBot(cli *client.QQClient, conf *global.JsonConfig) *CQBot { +func NewQQBot(cli *client.QQClient, conf *global.JSONConfig) *CQBot { bot := &CQBot{ Client: cli, } if conf.EnableDB { - opt := nutsdb.DefaultOptions - opt.Dir = path.Join("data", "db") - opt.EntryIdxMode = nutsdb.HintBPTSparseIdxMode - db, err := nutsdb.Open(opt) + p := path.Join("data", "leveldb") + db, err := leveldb.OpenFile(p, nil) if err != nil { - log.Fatalf("打开数据库失败, 如果频繁遇到此问题请清理 data/db 文件夹或关闭数据库功能。") + log.Fatalf("打开数据库失败, 如果频繁遇到此问题请清理 data/leveldb 文件夹或关闭数据库功能。") } bot.db = db gob.Register(message.Sender{}) @@ -59,16 +63,22 @@ func NewQQBot(cli *client.QQClient, conf *global.JsonConfig) *CQBot { bot.Client.OnTempMessage(bot.tempMessageEvent) bot.Client.OnGroupMuted(bot.groupMutedEvent) bot.Client.OnGroupMessageRecalled(bot.groupRecallEvent) + bot.Client.OnGroupNotify(bot.groupNotifyEvent) + bot.Client.OnFriendNotify(bot.friendNotifyEvent) bot.Client.OnFriendMessageRecalled(bot.friendRecallEvent) + bot.Client.OnReceivedOfflineFile(bot.offlineFileEvent) bot.Client.OnJoinGroup(bot.joinGroupEvent) bot.Client.OnLeaveGroup(bot.leaveGroupEvent) bot.Client.OnGroupMemberJoined(bot.memberJoinEvent) bot.Client.OnGroupMemberLeaved(bot.memberLeaveEvent) bot.Client.OnGroupMemberPermissionChanged(bot.memberPermissionChangedEvent) + bot.Client.OnGroupMemberCardUpdated(bot.memberCardUpdatedEvent) bot.Client.OnNewFriendRequest(bot.friendRequestEvent) bot.Client.OnNewFriendAdded(bot.friendAddedEvent) bot.Client.OnGroupInvited(bot.groupInvitedEvent) bot.Client.OnUserWantJoinGroup(bot.groupJoinReqEvent) + bot.Client.OnOtherClientStatusChanged(bot.otherClientStatusChangedEvent) + bot.Client.OnGroupDigest(bot.groupEssenceMsg) go func() { i := conf.HeartbeatInterval if i < 0 { @@ -85,7 +95,7 @@ func NewQQBot(cli *client.QQClient, conf *global.JsonConfig) *CQBot { "self_id": bot.Client.Uin, "post_type": "meta_event", "meta_event_type": "heartbeat", - "status": nil, + "status": bot.CQGetStatus()["data"], "interval": 1000 * i, }) } @@ -97,31 +107,64 @@ func (bot *CQBot) OnEventPush(f func(m MSG)) { bot.events = append(bot.events, f) } -func (bot *CQBot) GetGroupMessage(mid int32) MSG { +func (bot *CQBot) GetMessage(mid int32) MSG { if bot.db != nil { m := MSG{} - err := bot.db.View(func(tx *nutsdb.Tx) error { - e, err := tx.Get("group-messages", binary.ToBytes(mid)) - if err != nil { - return err - } - buff := new(bytes.Buffer) - buff.Write(binary.GZipUncompress(e.Value)) - return gob.NewDecoder(buff).Decode(&m) - }) + data, err := bot.db.Get(binary.ToBytes(mid), nil) if err == nil { - return m + buff := new(bytes.Buffer) + buff.Write(binary.GZipUncompress(data)) + err = gob.NewDecoder(buff).Decode(&m) + if err == nil { + return m + } } log.Warnf("获取信息时出现错误: %v id: %v", err, mid) } return nil } +func (bot *CQBot) UploadLocalImageAsGroup(groupCode int64, img *LocalImageElement) (*message.GroupImageElement, error) { + if img.Stream != nil { + return bot.Client.UploadGroupImage(groupCode, img.Stream) + } + return bot.Client.UploadGroupImageByFile(groupCode, img.File) +} + +func (bot *CQBot) UploadLocalVideo(target int64, v *LocalVideoElement) (*message.ShortVideoElement, error) { + if v.File != "" { + video, err := os.Open(v.File) + if err != nil { + return nil, err + } + defer video.Close() + hash, _ := utils.ComputeMd5AndLength(io.MultiReader(video, v.thumb)) + cacheFile := path.Join(global.CachePath, hex.EncodeToString(hash[:])+".cache") + _, _ = video.Seek(0, io.SeekStart) + _, _ = v.thumb.Seek(0, io.SeekStart) + return bot.Client.UploadGroupShortVideo(target, video, v.thumb, cacheFile) + } + return &v.ShortVideoElement, nil +} + +func (bot *CQBot) UploadLocalImageAsPrivate(userId int64, img *LocalImageElement) (*message.FriendImageElement, error) { + if img.Stream != nil { + return bot.Client.UploadPrivateImage(userId, img.Stream) + } + // need update. + f, err := os.Open(img.File) + if err != nil { + return nil, err + } + defer f.Close() + return bot.Client.UploadPrivateImage(userId, f) +} + func (bot *CQBot) SendGroupMessage(groupId int64, m *message.SendingMessage) int32 { var newElem []message.IMessageElement for _, elem := range m.Elements { - if i, ok := elem.(*message.ImageElement); ok { - gm, err := bot.Client.UploadGroupImage(groupId, i.Data) + if i, ok := elem.(*LocalImageElement); ok { + gm, err := bot.UploadLocalImageAsGroup(groupId, i) if err != nil { log.Warnf("警告: 群 %v 消息图片上传失败: %v", groupId, err) continue @@ -130,7 +173,7 @@ func (bot *CQBot) SendGroupMessage(groupId int64, m *message.SendingMessage) int continue } if i, ok := elem.(*message.VoiceElement); ok { - gv, err := bot.Client.UploadGroupPtt(groupId, i.Data) + gv, err := bot.Client.UploadGroupPtt(groupId, bytes.NewReader(i.Data)) if err != nil { log.Warnf("警告: 群 %v 消息语音上传失败: %v", groupId, err) continue @@ -138,9 +181,43 @@ func (bot *CQBot) SendGroupMessage(groupId int64, m *message.SendingMessage) int newElem = append(newElem, gv) continue } + if i, ok := elem.(*LocalVideoElement); ok { + gv, err := bot.UploadLocalVideo(groupId, i) + if err != nil { + log.Warnf("警告: 群 %v 消息短视频上传失败: %v", groupId, err) + continue + } + newElem = append(newElem, gv) + continue + } + if i, ok := elem.(*PokeElement); ok { + if group := bot.Client.FindGroup(groupId); group != nil { + if mem := group.FindMember(i.Target); mem != nil { + mem.Poke() + return 0 + } + } + } + if i, ok := elem.(*GiftElement); ok { + bot.Client.SendGroupGift(uint64(groupId), uint64(i.Target), i.GiftId) + return 0 + } + if i, ok := elem.(*message.MusicShareElement); ok { + ret, err := bot.Client.SendGroupMusicShare(groupId, i) + if err != nil { + log.Warnf("警告: 群 %v 富文本消息发送失败: %v", groupId, err) + return -1 + } + return bot.InsertGroupMessage(ret) + } newElem = append(newElem, elem) } + if len(newElem) == 0 { + log.Warnf("群消息发送失败: 消息为空.") + return -1 + } m.Elements = newElem + bot.checkMedia(newElem) ret := bot.Client.SendGroupMessage(groupId, m, ForceFragmented) if ret == nil || ret.Id == -1 { log.Warnf("群消息发送失败: 账号可能被风控.") @@ -152,8 +229,8 @@ func (bot *CQBot) SendGroupMessage(groupId int64, m *message.SendingMessage) int func (bot *CQBot) SendPrivateMessage(target int64, m *message.SendingMessage) int32 { var newElem []message.IMessageElement for _, elem := range m.Elements { - if i, ok := elem.(*message.ImageElement); ok { - fm, err := bot.Client.UploadPrivateImage(target, i.Data) + if i, ok := elem.(*LocalImageElement); ok { + fm, err := bot.UploadLocalImageAsPrivate(target, i) if err != nil { log.Warnf("警告: 私聊 %v 消息图片上传失败.", target) continue @@ -161,30 +238,61 @@ func (bot *CQBot) SendPrivateMessage(target int64, m *message.SendingMessage) in newElem = append(newElem, fm) continue } + if i, ok := elem.(*PokeElement); ok { + bot.Client.SendFriendPoke(i.Target) + return 0 + } + if i, ok := elem.(*message.VoiceElement); ok { + fv, err := bot.Client.UploadPrivatePtt(target, i.Data) + if err != nil { + log.Warnf("警告: 私聊 %v 消息语音上传失败: %v", target, err) + continue + } + newElem = append(newElem, fv) + continue + } + if i, ok := elem.(*LocalVideoElement); ok { + gv, err := bot.UploadLocalVideo(target, i) + if err != nil { + log.Warnf("警告: 私聊 %v 消息短视频上传失败: %v", target, err) + continue + } + newElem = append(newElem, gv) + continue + } + if i, ok := elem.(*message.MusicShareElement); ok { + bot.Client.SendFriendMusicShare(target, i) + return 0 + } newElem = append(newElem, elem) } + if len(newElem) == 0 { + log.Warnf("好友消息发送失败: 消息为空.") + return -1 + } m.Elements = newElem + bot.checkMedia(newElem) var id int32 = -1 - if bot.Client.FindFriend(target) != nil { + if bot.Client.FindFriend(target) != nil { // 双向好友 msg := bot.Client.SendPrivateMessage(target, m) if msg != nil { - id = msg.Id + id = bot.InsertPrivateMessage(msg) } - } else if code, ok := bot.tempMsgCache.Load(target); ok { + } else if code, ok := bot.tempMsgCache.Load(target); ok { // 临时会话 msg := bot.Client.SendTempMessage(code.(int64), target, m) if msg != nil { id = msg.Id } - } else if _, ok := bot.oneWayMsgCache.Load(target); ok { + } else if _, ok := bot.oneWayMsgCache.Load(target); ok { // 单向好友 msg := bot.Client.SendPrivateMessage(target, m) if msg != nil { - id = msg.Id + id = bot.InsertPrivateMessage(msg) } } if id == -1 { return -1 } - return ToGlobalId(target, id) + return id } func (bot *CQBot) InsertGroupMessage(m *message.GroupMessage) int32 { @@ -199,14 +307,36 @@ func (bot *CQBot) InsertGroupMessage(m *message.GroupMessage) int32 { } id := ToGlobalId(m.GroupCode, m.Id) if bot.db != nil { - err := bot.db.Update(func(tx *nutsdb.Tx) error { - buf := new(bytes.Buffer) - if err := gob.NewEncoder(buf).Encode(val); err != nil { - return err - } - return tx.Put("group-messages", binary.ToBytes(id), binary.GZipCompress(buf.Bytes()), 0) - }) - if err != nil { + buf := new(bytes.Buffer) + if err := gob.NewEncoder(buf).Encode(val); err != nil { + log.Warnf("记录聊天数据时出现错误: %v", err) + return -1 + } + if err := bot.db.Put(binary.ToBytes(id), binary.GZipCompress(buf.Bytes()), nil); err != nil { + log.Warnf("记录聊天数据时出现错误: %v", err) + return -1 + } + } + return id +} + +func (bot *CQBot) InsertPrivateMessage(m *message.PrivateMessage) int32 { + val := MSG{ + "message-id": m.Id, + "internal-id": m.InternalId, + "target": m.Target, + "sender": m.Sender, + "time": m.Time, + "message": ToStringMessage(m.Elements, m.Sender.Uin, true), + } + id := ToGlobalId(m.Sender.Uin, m.Id) + if bot.db != nil { + buf := new(bytes.Buffer) + if err := gob.NewEncoder(buf).Encode(val); err != nil { + log.Warnf("记录聊天数据时出现错误: %v", err) + return -1 + } + if err := bot.db.Put(binary.ToBytes(id), binary.GZipCompress(buf.Bytes()), nil); err != nil { log.Warnf("记录聊天数据时出现错误: %v", err) return -1 } @@ -225,25 +355,78 @@ func (bot *CQBot) Release() { } func (bot *CQBot) dispatchEventMessage(m MSG) { - payload := gjson.Parse(m.ToJson()) - filter := global.EventFilter - if filter != nil && (*filter).Eval(payload) == false { + if global.EventFilter != nil && !global.EventFilter.Eval(global.MSG(m)) { log.Debug("Event filtered!") return } for _, f := range bot.events { - fn := f - go func() { + go func(fn func(MSG)) { + defer func() { + if pan := recover(); pan != nil { + log.Warnf("处理事件 %v 时出现错误: %v \n%s", m, pan, debug.Stack()) + } + }() start := time.Now() fn(m) end := time.Now() if end.Sub(start) > time.Second*5 { log.Debugf("警告: 事件处理耗时超过 5 秒 (%v), 请检查应用是否有堵塞.", end.Sub(start)) } - }() + }(f) } } +func (bot *CQBot) formatGroupMessage(m *message.GroupMessage) MSG { + cqm := ToStringMessage(m.Elements, m.GroupCode, true) + gm := MSG{ + "anonymous": nil, + "font": 0, + "group_id": m.GroupCode, + "message": ToFormattedMessage(m.Elements, m.GroupCode, false), + "message_type": "group", + "message_seq": m.Id, + "post_type": "message", + "raw_message": cqm, + "self_id": bot.Client.Uin, + "sender": MSG{ + "age": 0, + "area": "", + "level": "", + "sex": "unknown", + "user_id": m.Sender.Uin, + }, + "sub_type": "normal", + "time": time.Now().Unix(), + "user_id": m.Sender.Uin, + } + if m.Sender.IsAnonymous() { + gm["anonymous"] = MSG{ + "flag": m.Sender.AnonymousInfo.AnonymousId + "|" + m.Sender.AnonymousInfo.AnonymousNick, + "id": m.Sender.Uin, + "name": m.Sender.AnonymousInfo.AnonymousNick, + } + gm["sender"].(MSG)["nickname"] = "匿名消息" + gm["sub_type"] = "anonymous" + } else { + mem := bot.Client.FindGroup(m.GroupCode).FindMember(m.Sender.Uin) + ms := gm["sender"].(MSG) + ms["role"] = func() string { + switch mem.Permission { + case client.Owner: + return "owner" + case client.Administrator: + return "admin" + default: + return "member" + } + }() + ms["nickname"] = mem.Nickname + ms["card"] = mem.CardName + ms["title"] = mem.SpecialTitle + } + return gm +} + func formatGroupName(group *client.GroupInfo) string { return fmt.Sprintf("%s(%d)", group.Name, group.Code) } diff --git a/coolq/cqcode.go b/coolq/cqcode.go index 76929d5..cdfe09e 100644 --- a/coolq/cqcode.go +++ b/coolq/cqcode.go @@ -1,32 +1,98 @@ package coolq import ( + "bytes" "crypto/md5" "encoding/base64" "encoding/hex" + xml2 "encoding/xml" "errors" "fmt" - "github.com/Mrs4s/MiraiGo/binary" - "github.com/Mrs4s/MiraiGo/message" - "github.com/Mrs4s/go-cqhttp/global" - log "github.com/sirupsen/logrus" - "github.com/tidwall/gjson" + "io" "io/ioutil" + "math" + "math/rand" "net/url" + "os" "path" - "regexp" "runtime" "strconv" "strings" + "time" + + "github.com/Mrs4s/MiraiGo/binary" + "github.com/Mrs4s/MiraiGo/message" + "github.com/Mrs4s/MiraiGo/utils" + "github.com/Mrs4s/go-cqhttp/global" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" ) +/* var matchReg = regexp.MustCompile(`\[CQ:\w+?.*?]`) var typeReg = regexp.MustCompile(`\[CQ:(\w+)`) var paramReg = regexp.MustCompile(`,([\w\-.]+?)=([^,\]]+)`) +*/ var IgnoreInvalidCQCode = false +var SplitUrl = false + +const maxImageSize = 1024 * 1024 * 30 // 30MB +const maxVideoSize = 1024 * 1024 * 100 // 100MB + +type PokeElement struct { + Target int64 +} + +type GiftElement struct { + Target int64 + GiftId message.GroupGift +} + +type LocalImageElement struct { + message.ImageElement + Stream io.ReadSeeker + File string +} + +type LocalVoiceElement struct { + message.VoiceElement + Stream io.ReadSeeker +} + +type LocalVideoElement struct { + message.ShortVideoElement + File string + thumb io.ReadSeeker +} + +func (e *GiftElement) Type() message.ElementType { + return message.At +} + +var GiftId = [...]message.GroupGift{ + message.SweetWink, + message.HappyCola, + message.LuckyBracelet, + message.Cappuccino, + message.CatWatch, + message.FleeceGloves, + message.RainbowCandy, + message.Stronger, + message.LoveMicrophone, + message.HoldingYourHand, + message.CuteCat, + message.MysteryMask, + message.ImBusy, + message.LoveMask, +} + +func (e *PokeElement) Type() message.ElementType { + return message.At +} func ToArrayMessage(e []message.IMessageElement, code int64, raw ...bool) (r []MSG) { + r = []MSG{} ur := false if len(raw) != 0 { ur = raw[0] @@ -43,7 +109,7 @@ func ToArrayMessage(e []message.IMessageElement, code int64, raw ...bool) (r []M }) } for _, elem := range e { - m := MSG{} + var m MSG switch o := elem.(type) { case *message.TextElement: m = MSG{ @@ -71,6 +137,11 @@ func ToArrayMessage(e []message.IMessageElement, code int64, raw ...bool) (r []M "data": map[string]string{"qq": fmt.Sprint(o.Target)}, } } + case *message.RedBagElement: + m = MSG{ + "type": "redbag", + "data": map[string]string{"title": o.Title}, + } case *message.ForwardElement: m = MSG{ "type": "forward", @@ -117,6 +188,40 @@ func ToArrayMessage(e []message.IMessageElement, code int64, raw ...bool) (r []M "data": map[string]string{"file": o.Filename, "url": o.Url}, } } + case *message.GroupImageElement: + if ur { + m = MSG{ + "type": "image", + "data": map[string]string{"file": hex.EncodeToString(o.Md5) + ".image"}, + } + } else { + m = MSG{ + "type": "image", + "data": map[string]string{"file": hex.EncodeToString(o.Md5) + ".image", "url": CQCodeEscapeText(o.Url)}, + } + } + case *message.FriendImageElement: + if ur { + m = MSG{ + "type": "image", + "data": map[string]string{"file": hex.EncodeToString(o.Md5) + ".image"}, + } + } else { + m = MSG{ + "type": "image", + "data": map[string]string{"file": hex.EncodeToString(o.Md5) + ".image", "url": CQCodeEscapeText(o.Url)}, + } + } + case *message.GroupFlashImgElement: + return []MSG{{ + "type": "image", + "data": map[string]string{"file": o.Filename, "type": "flash"}, + }} + case *message.FriendFlashImgElement: + return []MSG{{ + "type": "image", + "data": map[string]string{"file": o.Filename, "type": "flash"}, + }} case *message.ServiceElement: if isOk := strings.Contains(o.Content, " si { - text := m[si:idx[0]] - r = append(r, message.NewText(CQCodeUnescapeText(text))) +func (bot *CQBot) ConvertStringMessage(msg string, group bool) (r []message.IMessageElement) { + index := 0 + stat := 0 + rMsg := []rune(msg) + var tempText, cqCode []rune + hasNext := func() bool { + return index < len(rMsg) + } + next := func() rune { + r := rMsg[index] + index++ + return r + } + move := func(steps int) { + index += steps + } + peekN := func(count int) string { + lastIdx := int(math.Min(float64(index+count), float64(len(rMsg)))) + return string(rMsg[index:lastIdx]) + } + isCQCodeBegin := func(r rune) bool { + return r == '[' && peekN(3) == "CQ:" + } + saveTempText := func() { + if len(tempText) != 0 { + if SplitUrl { + for _, t := range global.SplitURL(CQCodeUnescapeValue(string(tempText))) { + r = append(r, message.NewText(t)) + } + } else { + r = append(r, message.NewText(CQCodeUnescapeValue(string(tempText)))) + } } - code := m[idx[0]:idx[1]] - si = idx[1] - t := typeReg.FindAllStringSubmatch(code, -1)[0][1] - ps := paramReg.FindAllStringSubmatch(code, -1) - d := make(map[string]string) - for _, p := range ps { - d[p[1]] = CQCodeUnescapeValue(p[2]) + tempText = []rune{} + cqCode = []rune{} + } + saveCQCode := func() { + defer func() { + cqCode = []rune{} + tempText = []rune{} + }() + s := strings.SplitN(string(cqCode), ",", -1) + if len(s) == 0 { + return } - if t == "reply" && group { + t := s[0] + params := make(map[string]string) + for i := 1; i < len(s); i++ { + p := s[i] + p = strings.TrimSpace(p) + if p == "" { + continue + } + data := strings.SplitN(p, "=", 2) + if len(data) == 2 { + params[data[0]] = CQCodeUnescapeValue(data[1]) + } else { + params[p] = "" + } + } + if t == "reply" { // reply 特殊处理 if len(r) > 0 { if _, ok := r[0].(*message.ReplyElement); ok { log.Warnf("警告: 一条信息只能包含一个 Reply 元素.") - continue + return } } - mid, err := strconv.Atoi(d["id"]) + mid, err := strconv.Atoi(params["id"]) + customText := params["text"] if err == nil { - org := bot.GetGroupMessage(int32(mid)) + org := bot.GetMessage(int32(mid)) if org != nil { r = append([]message.IMessageElement{ &message.ReplyElement{ @@ -230,25 +400,79 @@ func (bot *CQBot) ConvertStringMessage(m string, group bool) (r []message.IMessa Elements: bot.ConvertStringMessage(org["message"].(string), group), }, }, r...) - continue + return } + } else if customText != "" { + sender, err := strconv.ParseInt(params["qq"], 10, 64) + if err != nil { + log.Warnf("警告:自定义 Reply 元素中必须包含Uin") + return + } + msgTime, err := strconv.ParseInt(params["time"], 10, 64) + if err != nil { + msgTime = time.Now().Unix() + } + r = append([]message.IMessageElement{ + &message.ReplyElement{ + ReplySeq: int32(0), + Sender: sender, + Time: int32(msgTime), + Elements: bot.ConvertStringMessage(customText, group), + }, + }, r...) + return } } - elem, err := bot.ToElement(t, d, group) + if t == "forward" { // 单独处理转发 + if id, ok := params["id"]; ok { + r = []message.IMessageElement{bot.Client.DownloadForwardMessage(id)} + return + } + } + elem, err := bot.ToElement(t, params, group) if err != nil { + org := "[CQ:" + string(cqCode) + "]" if !IgnoreInvalidCQCode { - log.Warnf("转换CQ码 %v 到MiraiGo Element时出现错误: %v 将原样发送.", code, err) - r = append(r, message.NewText(code)) + log.Warnf("转换CQ码 %v 时出现错误: %v 将原样发送.", org, err) + r = append(r, message.NewText(org)) } else { - log.Warnf("转换CQ码 %v 到MiraiGo Element时出现错误: %v 将忽略.", code, err) + log.Warnf("转换CQ码 %v 时出现错误: %v 将忽略.", org, err) } - continue + return + } + switch i := elem.(type) { + case message.IMessageElement: + r = append(r, i) + case []message.IMessageElement: + r = append(r, i...) } - r = append(r, elem) } - if si != len(m) { - r = append(r, message.NewText(CQCodeUnescapeText(m[si:]))) + for hasNext() { + ch := next() + switch stat { + case 0: + if isCQCodeBegin(ch) { + saveTempText() + tempText = append(tempText, []rune("[CQ:")...) + move(3) + stat = 1 + } else { + tempText = append(tempText, ch) + } + case 1: + if isCQCodeBegin(ch) { + move(-1) + stat = 0 + } else if ch == ']' { + saveCQCode() + stat = 0 + } else { + cqCode = append(cqCode, ch) + tempText = append(tempText, ch) + } + } } + saveTempText() return } @@ -262,9 +486,10 @@ func (bot *CQBot) ConvertObjectMessage(m gjson.Result, group bool) (r []message. return } } - mid, err := strconv.Atoi(e.Get("data").Get("id").Str) + mid, err := strconv.Atoi(e.Get("data").Get("id").String()) + customText := e.Get("data").Get("text").String() if err == nil { - org := bot.GetGroupMessage(int32(mid)) + org := bot.GetMessage(int32(mid)) if org != nil { r = append([]message.IMessageElement{ &message.ReplyElement{ @@ -276,11 +501,34 @@ func (bot *CQBot) ConvertObjectMessage(m gjson.Result, group bool) (r []message. }, r...) return } + } else if customText != "" { + sender, err := strconv.ParseInt(e.Get("data").Get("qq").String(), 10, 64) + if err != nil { + log.Warnf("警告:自定义 Reply 元素中必须包含Uin") + return + } + msgTime, err := strconv.ParseInt(e.Get("data").Get("time").String(), 10, 64) + if err != nil { + msgTime = time.Now().Unix() + } + r = append([]message.IMessageElement{ + &message.ReplyElement{ + ReplySeq: int32(0), + Sender: sender, + Time: int32(msgTime), + Elements: bot.ConvertStringMessage(customText, group), + }, + }, r...) + return } } + if t == "forward" { + r = []message.IMessageElement{bot.Client.DownloadForwardMessage(e.Get("data.id").String())} + return + } d := make(map[string]string) e.Get("data").ForEach(func(key, value gjson.Result) bool { - d[key.Str] = value.Str + d[key.Str] = value.String() return true }) elem, err := bot.ToElement(t, d, group) @@ -288,7 +536,12 @@ func (bot *CQBot) ConvertObjectMessage(m gjson.Result, group bool) (r []message. log.Warnf("转换CQ码到MiraiGo Element时出现错误: %v 将忽略本段CQ码.", err) return } - r = append(r, elem) + switch i := elem.(type) { + case message.IMessageElement: + r = append(r, i) + case []message.IMessageElement: + r = append(r, i...) + } } if m.Type == gjson.String { return bot.ConvertStringMessage(m.Str, group) @@ -304,55 +557,97 @@ func (bot *CQBot) ConvertObjectMessage(m gjson.Result, group bool) (r []message. return } -func (bot *CQBot) ToElement(t string, d map[string]string, group bool) (message.IMessageElement, error) { +// ToElement 将解码后的CQCode转换为Element. +// 返回 interface{} 存在三种类型 +// message.IMessageElement []message.IMessageElement nil +func (bot *CQBot) ToElement(t string, d map[string]string, group bool) (m interface{}, err error) { switch t { case "text": + if SplitUrl { + var ret []message.IMessageElement + for _, text := range global.SplitURL(d["text"]) { + ret = append(ret, message.NewText(text)) + } + return ret, nil + } return message.NewText(d["text"]), nil case "image": - return bot.makeImageElem(t, d, group) - case "record": + img, err := bot.makeImageOrVideoElem(d, false, group) + if err != nil { + return nil, err + } + tp := d["type"] + if tp != "show" && tp != "flash" { + return img, nil + } + if i, ok := img.(*LocalImageElement); ok { // 秀图,闪照什么的就直接传了吧 + if group { + img, err = bot.UploadLocalImageAsGroup(1, i) + } else { + img, err = bot.UploadLocalImageAsPrivate(1, i) + } + if err != nil { + return nil, err + } + } + switch tp { + case "flash": + if i, ok := img.(*message.GroupImageElement); ok { + return &message.GroupFlashPicElement{GroupImageElement: *i}, nil + } + if i, ok := img.(*message.FriendImageElement); ok { + return &message.FriendFlashPicElement{FriendImageElement: *i}, nil + } + case "show": + id, _ := strconv.ParseInt(d["id"], 10, 64) + if id < 40000 || id >= 40006 { + id = 40000 + } + if i, ok := img.(*message.GroupImageElement); ok { + return &message.GroupShowPicElement{GroupImageElement: *i, EffectId: int32(id)}, nil + } + return img, nil // 私聊还没做 + } + + case "poke": + t, _ := strconv.ParseInt(d["qq"], 10, 64) + return &PokeElement{Target: t}, nil + case "gift": if !group { - return nil, errors.New("private voice unsupported now") + return nil, errors.New("private gift unsupported") // no free private gift } + t, _ := strconv.ParseInt(d["qq"], 10, 64) + id, _ := strconv.Atoi(d["id"]) + if id < 0 || id >= 14 { + return nil, errors.New("invalid gift id") + } + return &GiftElement{Target: t, GiftId: GiftId[id]}, nil + case "tts": + defer func() { + if r := recover(); r != nil { + m = nil + err = errors.New("tts 转换失败") + } + }() + data, err := bot.Client.GetTts(d["text"]) + if err != nil { + return nil, err + } + return &message.VoiceElement{Data: data}, nil + case "record": f := d["file"] - var data []byte - if strings.HasPrefix(f, "http") || strings.HasPrefix(f, "https") { - b, err := global.GetBytes(f) - if err != nil { - return nil, err - } - data = b + data, err := global.FindFile(f, d["cache"], global.VoicePath) + if err == global.ErrSyntax { + data, err = global.FindFile(f, d["cache"], global.VoicePathOld) } - if strings.HasPrefix(f, "base64") { - b, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(f, "base64://", "")) - if err != nil { - return nil, err - } - data = b - } - if strings.HasPrefix(f, "file") { - fu, err := url.Parse(f) - if err != nil { - return nil, err - } - if strings.HasPrefix(fu.Path, "/") && runtime.GOOS == `windows` { - fu.Path = fu.Path[1:] - } - b, err := ioutil.ReadFile(fu.Path) - if err != nil { - return nil, err - } - data = b - } - if global.PathExists(path.Join(global.VOICE_PATH, f)) { - b, err := ioutil.ReadFile(path.Join(global.VOICE_PATH, f)) - if err != nil { - return nil, err - } - data = b + if err != nil { + return nil, err } if !global.IsAMRorSILK(data) { - return nil, errors.New("unsupported voice file format (please use AMR file for now)") + data, err = global.EncoderSilk(data) + if err != nil { + return nil, err + } } return &message.VoiceElement{Data: data}, nil case "face": @@ -380,7 +675,7 @@ func (bot *CQBot) ToElement(t string, d map[string]string, group bool) (message. return nil, errors.New("song not found") } aid := strconv.FormatInt(info.Get("track_info.album.id").Int(), 10) - name := info.Get("track_info.name").Str + " - " + info.Get("track_info.singer.0.name").Str + name := info.Get("track_info.name").Str mid := info.Get("track_info.mid").Str albumMid := info.Get("track_info.album.mid").Str pinfo, _ := global.GetBytes("http://u.y.qq.com/cgi-bin/musicu.fcg?g_tk=2034008533&uin=0&format=json&data={\"comm\":{\"ct\":23,\"cv\":0},\"url_mid\":{\"module\":\"vkey.GetVkeyServer\",\"method\":\"CgiGetVkey\",\"param\":{\"guid\":\"4311206557\",\"songmid\":[\"" + mid + "\"],\"songtype\":[0],\"uin\":\"0\",\"loginflag\":1,\"platform\":\"23\"}}}&_=1599039471576") @@ -390,12 +685,18 @@ func (bot *CQBot) ToElement(t string, d map[string]string, group bool) (message. if len(aid) < 2 { return nil, errors.New("song error") } - content := "来自go-cqhttp" + content := info.Get("track_info.singer.0.name").Str if d["content"] != "" { content = d["content"] } - json := fmt.Sprintf("{\"app\": \"com.tencent.structmsg\",\"desc\": \"音乐\",\"meta\": {\"music\": {\"desc\": \"%s\",\"jumpUrl\": \"%s\",\"musicUrl\": \"%s\",\"preview\": \"%s\",\"tag\": \"QQ音乐\",\"title\": \"%s\"}},\"prompt\": \"[分享]%s\",\"ver\": \"0.0.0.1\",\"view\": \"music\"}", content, jumpUrl, purl, preview, name, name) - return message.NewLightApp(json), nil + return &message.MusicShareElement{ + MusicType: message.QQMusic, + Title: name, + Summary: content, + Url: jumpUrl, + PictureUrl: preview, + MusicUrl: purl, + }, nil } if d["type"] == "163" { info, err := global.NeteaseMusicSongInfo(d["id"]) @@ -413,12 +714,39 @@ func (bot *CQBot) ToElement(t string, d map[string]string, group bool) (message. if info.Get("artists.0").Exists() { artistName = info.Get("artists.0.name").Str } - json := fmt.Sprintf("{\"app\": \"com.tencent.structmsg\",\"desc\":\"音乐\",\"view\":\"music\",\"prompt\":\"[分享]%s\",\"ver\":\"0.0.0.1\",\"meta\":{ \"music\": { \"desc\": \"%s\", \"jumpUrl\": \"%s\", \"musicUrl\": \"%s\", \"preview\": \"%s\", \"tag\": \"网易云音乐\", \"title\":\"%s\"}}}", name, artistName, jumpUrl, musicUrl, picUrl, name) - return message.NewLightApp(json), nil + return &message.MusicShareElement{ + MusicType: message.CloudMusic, + Title: name, + Summary: artistName, + Url: jumpUrl, + PictureUrl: picUrl, + MusicUrl: musicUrl, + }, nil } if d["type"] == "custom" { + if d["subtype"] != "" { + var subtype = map[string]int{ + "qq": message.QQMusic, + "163": message.CloudMusic, + "migu": message.MiguMusic, + "kugou": message.KugouMusic, + "kuwo": message.KuwoMusic, + } + var musicType = 0 + if tp, ok := subtype[d["subtype"]]; ok { + musicType = tp + } + return &message.MusicShareElement{ + MusicType: musicType, + Title: d["title"], + Summary: d["content"], + Url: d["url"], + PictureUrl: d["image"], + MusicUrl: d["purl"], + }, nil + } xml := fmt.Sprintf(``, - d["title"], d["url"], d["image"], d["audio"], d["title"], d["content"]) + XmlEscape(d["title"]), d["url"], d["image"], d["audio"], XmlEscape(d["title"]), XmlEscape(d["content"])) return &message.ServiceElement{ Id: 60, Content: xml, @@ -429,14 +757,12 @@ func (bot *CQBot) ToElement(t string, d map[string]string, group bool) (message. case "xml": resId := d["resid"] template := CQCodeEscapeValue(d["data"]) - //println(template) i, _ := strconv.ParseInt(resId, 10, 64) msg := message.NewRichXml(template, i) return msg, nil case "json": resId := d["resid"] i, _ := strconv.ParseInt(resId, 10, 64) - log.Warnf("json msg=%s", d["data"]) if i == 0 { //默认情况下走小程序通道 msg := message.NewLightApp(CQCodeUnescapeValue(d["data"])) @@ -448,30 +774,84 @@ func (bot *CQBot) ToElement(t string, d map[string]string, group bool) (message. case "cardimage": source := d["source"] icon := d["icon"] - minwidth, _ := strconv.ParseInt(d["minwidth"], 10, 64) - if minwidth == 0 { - minwidth = 200 + minWidth, _ := strconv.ParseInt(d["minwidth"], 10, 64) + if minWidth == 0 { + minWidth = 200 } - minheight, _ := strconv.ParseInt(d["minheight"], 10, 64) - if minheight == 0 { - minheight = 200 + minHeight, _ := strconv.ParseInt(d["minheight"], 10, 64) + if minHeight == 0 { + minHeight = 200 } - maxwidth, _ := strconv.ParseInt(d["maxwidth"], 10, 64) - if maxwidth == 0 { - maxwidth = 500 + maxWidth, _ := strconv.ParseInt(d["maxwidth"], 10, 64) + if maxWidth == 0 { + maxWidth = 500 } - maxheight, _ := strconv.ParseInt(d["maxheight"], 10, 64) - if maxheight == 0 { - maxheight = 1000 + maxHeight, _ := strconv.ParseInt(d["maxheight"], 10, 64) + if maxHeight == 0 { + maxHeight = 1000 } - img, err := bot.makeImageElem(t, d, group) + img, err := bot.makeImageOrVideoElem(d, false, group) if err != nil { return nil, errors.New("send cardimage faild") } - return bot.SendNewPic(img, source, icon, minwidth, minheight, maxwidth, maxheight, group) + return bot.makeShowPic(img, source, icon, minWidth, minHeight, maxWidth, maxHeight, group) + case "video": + cache := d["cache"] + if cache == "" { + cache = "1" + } + file, err := bot.makeImageOrVideoElem(d, true, group) + if err != nil { + return nil, err + } + v := file.(*LocalVideoElement) + if v.File == "" { + return v, nil + } + var data []byte + if cover, ok := d["cover"]; ok { + data, _ = global.FindFile(cover, cache, global.ImagePath) + } else { + _ = global.ExtractCover(v.File, v.File+".jpg") + data, _ = ioutil.ReadFile(v.File + ".jpg") + } + v.thumb = bytes.NewReader(data) + video, _ := os.Open(v.File) + defer video.Close() + _, err = video.Seek(4, io.SeekStart) + if err != nil { + return nil, err + } + var header = make([]byte, 4) + _, err = video.Read(header) + if err != nil { + return nil, err + } + if !bytes.Equal(header, []byte{0x66, 0x74, 0x79, 0x70}) { // check file header ftyp + _, _ = video.Seek(0, io.SeekStart) + hash, _ := utils.ComputeMd5AndLength(video) + cacheFile := path.Join(global.CachePath, hex.EncodeToString(hash[:])+".mp4") + if global.PathExists(cacheFile) && cache == "1" { + goto ok + } + err = global.EncodeMP4(v.File, cacheFile) + if err != nil { + return nil, err + } + ok: + v.File = cacheFile + } + return v, nil default: return nil, errors.New("unsupported cq code: " + t) } + return nil, nil +} + +func XmlEscape(c string) string { + buf := new(bytes.Buffer) + _ = xml2.EscapeText(buf, []byte(c)) + return buf.String() } func CQCodeEscapeText(raw string) string { @@ -503,34 +883,37 @@ func CQCodeUnescapeValue(content string) string { } // 图片 elem 生成器,单独拎出来,用于公用 -func (bot *CQBot) makeImageElem(t string, d map[string]string, group bool) (message.IMessageElement, error) { +func (bot *CQBot) makeImageOrVideoElem(d map[string]string, video, group bool) (message.IMessageElement, error) { f := d["file"] if strings.HasPrefix(f, "http") || strings.HasPrefix(f, "https") { cache := d["cache"] + c := d["c"] if cache == "" { cache = "1" } hash := md5.Sum([]byte(f)) - cacheFile := path.Join(global.CACHE_PATH, hex.EncodeToString(hash[:])+".cache") - if global.PathExists(cacheFile) && cache == "1" { - b, err := ioutil.ReadFile(cacheFile) - if err == nil { - return message.NewImage(b), nil + cacheFile := path.Join(global.CachePath, hex.EncodeToString(hash[:])+".cache") + var maxSize = func() int64 { + if video { + return maxVideoSize } + return maxImageSize + }() + thread, _ := strconv.Atoi(c) + if global.PathExists(cacheFile) && cache == "1" { + goto hasCacheFile } - b, err := global.GetBytes(f) - if err != nil { + if global.PathExists(cacheFile) { + _ = os.Remove(cacheFile) + } + if err := global.DownloadFileMultiThreading(f, cacheFile, maxSize, thread, nil); err != nil { return nil, err } - _ = ioutil.WriteFile(cacheFile, b, 0644) - return message.NewImage(b), nil - } - if strings.HasPrefix(f, "base64") { - b, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(f, "base64://", "")) - if err != nil { - return nil, err + hasCacheFile: + if video { + return &LocalVideoElement{File: cacheFile}, nil } - return message.NewImage(b), nil + return &LocalImageElement{File: cacheFile}, nil } if strings.HasPrefix(f, "file") { fu, err := url.Parse(f) @@ -540,33 +923,81 @@ func (bot *CQBot) makeImageElem(t string, d map[string]string, group bool) (mess if strings.HasPrefix(fu.Path, "/") && runtime.GOOS == `windows` { fu.Path = fu.Path[1:] } - b, err := ioutil.ReadFile(fu.Path) + info, err := os.Stat(fu.Path) + if err != nil { + if !os.IsExist(err) { + return nil, errors.New("file not found") + } + return nil, err + } + if video { + if info.Size() == 0 || info.Size() >= maxVideoSize { + return nil, errors.New("invalid video size") + } + return &LocalVideoElement{File: fu.Path}, nil + } + if info.Size() == 0 || info.Size() >= maxImageSize { + return nil, errors.New("invalid image size") + } + return &LocalImageElement{File: fu.Path}, nil + } + rawPath := path.Join(global.ImagePath, f) + if video { + rawPath = path.Join(global.VideoPath, f) + if !global.PathExists(rawPath) { + return nil, errors.New("invalid video") + } + if path.Ext(rawPath) == ".video" { + b, _ := ioutil.ReadFile(rawPath) + r := binary.NewReader(b) + return &LocalVideoElement{ShortVideoElement: message.ShortVideoElement{ // todo 检查缓存是否有效 + Md5: r.ReadBytes(16), + ThumbMd5: r.ReadBytes(16), + Size: r.ReadInt32(), + ThumbSize: r.ReadInt32(), + Name: r.ReadString(), + Uuid: r.ReadAvailable(), + }}, nil + } else { + return &LocalVideoElement{File: rawPath}, nil + } + } + if strings.HasPrefix(f, "base64") { + b, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(f, "base64://", "")) if err != nil { return nil, err } - return message.NewImage(b), nil + return &LocalImageElement{Stream: bytes.NewReader(b)}, nil + } + if !global.PathExists(rawPath) && global.PathExists(path.Join(global.ImagePathOld, f)) { + rawPath = path.Join(global.ImagePathOld, f) } - rawPath := path.Join(global.IMAGE_PATH, f) if !global.PathExists(rawPath) && global.PathExists(rawPath+".cqimg") { rawPath += ".cqimg" } if !global.PathExists(rawPath) && d["url"] != "" { - return bot.ToElement(t, map[string]string{"file": d["url"]}, group) + return bot.makeImageOrVideoElem(map[string]string{"file": d["url"]}, false, group) } if global.PathExists(rawPath) { - b, err := ioutil.ReadFile(rawPath) + file, err := os.Open(rawPath) if err != nil { return nil, err } if path.Ext(rawPath) != ".image" && path.Ext(rawPath) != ".cqimg" { - return message.NewImage(b), nil + return &LocalImageElement{Stream: file}, nil + } + b, err := ioutil.ReadAll(file) + if err != nil { + return nil, err } if len(b) < 20 { return nil, errors.New("invalid local file") } - var size int32 - var hash []byte - var url string + var ( + size int32 + hash []byte + url string + ) if path.Ext(rawPath) == ".cqimg" { for _, line := range strings.Split(global.ReadAllText(rawPath), "\n") { kv := strings.SplitN(line, "=", 2) @@ -587,27 +1018,23 @@ func (bot *CQBot) makeImageElem(t string, d map[string]string, group bool) (mess } if size == 0 { if url != "" { - return bot.ToElement(t, map[string]string{"file": url}, group) + return bot.makeImageOrVideoElem(map[string]string{"file": url}, false, group) } return nil, errors.New("img size is 0") } if len(hash) != 16 { return nil, errors.New("invalid hash") } + var rsp message.IMessageElement if group { - rsp, err := bot.Client.QueryGroupImage(1, hash, size) - if err != nil { - if url != "" { - return bot.ToElement(t, map[string]string{"file": url}, group) - } - return nil, err - } - return rsp, nil + rsp, err = bot.Client.QueryGroupImage(int64(rand.Uint32()), hash, size) + goto ok } - rsp, err := bot.Client.QueryFriendImage(1, hash, size) + rsp, err = bot.Client.QueryFriendImage(int64(rand.Uint32()), hash, size) + ok: if err != nil { if url != "" { - return bot.ToElement(t, map[string]string{"file": url}, group) + return bot.makeImageOrVideoElem(map[string]string{"file": url}, false, group) } return nil, err } @@ -616,38 +1043,43 @@ func (bot *CQBot) makeImageElem(t string, d map[string]string, group bool) (mess return nil, errors.New("invalid image") } -//SendNewPic 一种xml 方式发送的群消息图片 -func (bot *CQBot) SendNewPic(elem message.IMessageElement, source string, icon string, minwidth int64, minheigt int64, maxwidth int64, maxheight int64, group bool) (*message.ServiceElement, error) { - var xml string - xml = "" - if i, ok := elem.(*message.ImageElement); ok { - if group == false { - gm, err := bot.Client.UploadPrivateImage(1, i.Data) +//makeShowPic 一种xml 方式发送的群消息图片 +func (bot *CQBot) makeShowPic(elem message.IMessageElement, source string, icon string, minWidth int64, minHeight int64, maxWidth int64, maxHeight int64, group bool) ([]message.IMessageElement, error) { + xml := "" + var suf message.IMessageElement + if i, ok := elem.(*LocalImageElement); ok { + if !group { + gm, err := bot.UploadLocalImageAsPrivate(1, i) if err != nil { log.Warnf("警告: 好友消息 %v 消息图片上传失败: %v", 1, err) return nil, err } - xml = fmt.Sprintf(``, "", gm.Md5, gm.Md5, len(i.Data), "", minwidth, minheigt, maxwidth, maxheight, source, icon) - + suf = gm + xml = fmt.Sprintf(``, "", gm.Md5, gm.Md5, len(i.Data), "", minWidth, minHeight, maxWidth, maxHeight, source, icon) } else { - gm, err := bot.Client.UploadGroupImage(1, i.Data) + gm, err := bot.UploadLocalImageAsGroup(1, i) if err != nil { log.Warnf("警告: 群 %v 消息图片上传失败: %v", 1, err) return nil, err } - xml = fmt.Sprintf(``, "", gm.Md5, gm.Md5, len(i.Data), "", minwidth, minheigt, maxwidth, maxheight, source, icon) + suf = gm + xml = fmt.Sprintf(``, "", gm.Md5, gm.Md5, len(i.Data), "", minWidth, minHeight, maxWidth, maxHeight, source, icon) } } + if i, ok := elem.(*message.GroupImageElement); ok { - xml = fmt.Sprintf(``, "", i.Md5, i.Md5, 0, "", minwidth, minheigt, maxwidth, maxheight, source, icon) + xml = fmt.Sprintf(``, "", i.Md5, i.Md5, 0, "", minWidth, minHeight, maxWidth, maxHeight, source, icon) + suf = i } if i, ok := elem.(*message.FriendImageElement); ok { - xml = fmt.Sprintf(``, "", i.Md5, i.Md5, 0, "", minwidth, minheigt, maxwidth, maxheight, source, icon) + xml = fmt.Sprintf(``, "", i.Md5, i.Md5, 0, "", minWidth, minHeight, maxWidth, maxHeight, source, icon) + suf = i } if xml != "" { - log.Warn(xml) - XmlMsg := message.NewRichXml(xml, 5) - return XmlMsg, nil + //log.Warn(xml) + ret := []message.IMessageElement{suf} + ret = append(ret, message.NewRichXml(xml, 5)) + return ret, nil } - return nil, errors.New("发送xml图片消息失败") + return nil, errors.New("生成xml图片消息失败") } diff --git a/coolq/event.go b/coolq/event.go index e91a0ed..9299751 100644 --- a/coolq/event.go +++ b/coolq/event.go @@ -2,47 +2,54 @@ package coolq import ( "encoding/hex" - "github.com/Mrs4s/MiraiGo/binary" - "github.com/Mrs4s/MiraiGo/client" - "github.com/Mrs4s/MiraiGo/message" - "github.com/Mrs4s/go-cqhttp/global" - log "github.com/sirupsen/logrus" "io/ioutil" "path" "strconv" "strings" "time" + + "github.com/Mrs4s/MiraiGo/binary" + "github.com/Mrs4s/MiraiGo/client" + "github.com/Mrs4s/MiraiGo/message" + "github.com/Mrs4s/go-cqhttp/global" + log "github.com/sirupsen/logrus" ) var format = "string" +//SetMessageFormat 设置消息上报格式,默认为string func SetMessageFormat(f string) { format = f } -func ToFormattedMessage(e []message.IMessageElement, code int64, raw ...bool) (r interface{}) { +//ToFormattedMessage 将给定[]message.IMessageElement转换为通过coolq.SetMessageFormat所定义的消息上报格式 +func ToFormattedMessage(e []message.IMessageElement, id int64, raw ...bool) (r interface{}) { if format == "string" { - r = ToStringMessage(e, code, raw...) + r = ToStringMessage(e, id, raw...) } else if format == "array" { - r = ToArrayMessage(e, code, raw...) + r = ToArrayMessage(e, id, raw...) } return } func (bot *CQBot) privateMessageEvent(c *client.QQClient, m *message.PrivateMessage) { bot.checkMedia(m.Elements) - cqm := ToStringMessage(m.Elements, 0, true) + cqm := ToStringMessage(m.Elements, m.Sender.Uin, true) if !m.Sender.IsFriend { bot.oneWayMsgCache.Store(m.Sender.Uin, "") } - log.Infof("收到好友 %v(%v) 的消息: %v", m.Sender.DisplayName(), m.Sender.Uin, cqm) + id := m.Id + if bot.db != nil { + id = bot.InsertPrivateMessage(m) + } + log.Infof("收到好友 %v(%v) 的消息: %v (%v)", m.Sender.DisplayName(), m.Sender.Uin, cqm, id) fm := MSG{ "post_type": "message", "message_type": "private", "sub_type": "friend", - "message_id": ToGlobalId(m.Sender.Uin, m.Id), + "message_id": id, "user_id": m.Sender.Uin, - "message": ToFormattedMessage(m.Elements, 0, false), + "message": ToFormattedMessage(m.Elements, m.Sender.Uin, false), "raw_message": cqm, "font": 0, "self_id": c.Uin, @@ -86,52 +93,8 @@ func (bot *CQBot) groupMessageEvent(c *client.QQClient, m *message.GroupMessage) id = bot.InsertGroupMessage(m) } log.Infof("收到群 %v(%v) 内 %v(%v) 的消息: %v (%v)", m.GroupName, m.GroupCode, m.Sender.DisplayName(), m.Sender.Uin, cqm, id) - gm := MSG{ - "anonymous": nil, - "font": 0, - "group_id": m.GroupCode, - "message": ToFormattedMessage(m.Elements, m.GroupCode, false), - "message_id": id, - "message_type": "group", - "post_type": "message", - "raw_message": cqm, - "self_id": c.Uin, - "sender": MSG{ - "age": 0, - "area": "", - "level": "", - "sex": "unknown", - "user_id": m.Sender.Uin, - }, - "sub_type": "normal", - "time": time.Now().Unix(), - "user_id": m.Sender.Uin, - } - if m.Sender.IsAnonymous() { - gm["anonymous"] = MSG{ - "flag": "", - "id": 0, - "name": m.Sender.Nickname, - } - gm["sender"].(MSG)["nickname"] = "匿名消息" - gm["sub_type"] = "anonymous" - } else { - mem := c.FindGroup(m.GroupCode).FindMember(m.Sender.Uin) - ms := gm["sender"].(MSG) - ms["role"] = func() string { - switch mem.Permission { - case client.Owner: - return "owner" - case client.Administrator: - return "admin" - default: - return "member" - } - }() - ms["nickname"] = mem.Nickname - ms["card"] = mem.CardName - ms["title"] = mem.SpecialTitle - } + gm := bot.formatGroupMessage(m) + gm["message_id"] = id bot.dispatchEventMessage(gm) } @@ -163,13 +126,24 @@ func (bot *CQBot) tempMessageEvent(c *client.QQClient, m *message.TempMessage) { func (bot *CQBot) groupMutedEvent(c *client.QQClient, e *client.GroupMuteEvent) { g := c.FindGroup(e.GroupCode) - if e.Time > 0 { - log.Infof("群 %v 内 %v 被 %v 禁言了 %v秒.", - formatGroupName(g), formatMemberName(g.FindMember(e.TargetUin)), formatMemberName(g.FindMember(e.OperatorUin)), e.Time) + if e.TargetUin == 0 { + if e.Time != 0 { + log.Infof("群 %v 被 %v 开启全员禁言.", + formatGroupName(g), formatMemberName(g.FindMember(e.OperatorUin))) + } else { + log.Infof("群 %v 被 %v 解除全员禁言.", + formatGroupName(g), formatMemberName(g.FindMember(e.OperatorUin))) + } } else { - log.Infof("群 %v 内 %v 被 %v 解除禁言.", - formatGroupName(g), formatMemberName(g.FindMember(e.TargetUin)), formatMemberName(g.FindMember(e.OperatorUin))) + if e.Time > 0 { + log.Infof("群 %v 内 %v 被 %v 禁言了 %v 秒.", + formatGroupName(g), formatMemberName(g.FindMember(e.TargetUin)), formatMemberName(g.FindMember(e.OperatorUin)), e.Time) + } else { + log.Infof("群 %v 内 %v 被 %v 解除禁言.", + formatGroupName(g), formatMemberName(g.FindMember(e.TargetUin)), formatMemberName(g.FindMember(e.OperatorUin))) + } } + bot.dispatchEventMessage(MSG{ "post_type": "notice", "duration": e.Time, @@ -180,10 +154,10 @@ func (bot *CQBot) groupMutedEvent(c *client.QQClient, e *client.GroupMuteEvent) "user_id": e.TargetUin, "time": time.Now().Unix(), "sub_type": func() string { - if e.Time > 0 { - return "ban" + if e.Time == 0 { + return "lift_ban" } - return "lift_ban" + return "ban" }(), }) } @@ -205,20 +179,121 @@ func (bot *CQBot) groupRecallEvent(c *client.QQClient, e *client.GroupMessageRec }) } +func (bot *CQBot) groupNotifyEvent(c *client.QQClient, e client.INotifyEvent) { + group := c.FindGroup(e.From()) + switch notify := e.(type) { + case *client.GroupPokeNotifyEvent: + sender := group.FindMember(notify.Sender) + receiver := group.FindMember(notify.Receiver) + log.Infof("群 %v 内 %v 戳了戳 %v", formatGroupName(group), formatMemberName(sender), formatMemberName(receiver)) + bot.dispatchEventMessage(MSG{ + "post_type": "notice", + "group_id": group.Code, + "notice_type": "notify", + "sub_type": "poke", + "self_id": c.Uin, + "user_id": notify.Sender, + "sender_id": notify.Sender, + "target_id": notify.Receiver, + "time": time.Now().Unix(), + }) + case *client.GroupRedBagLuckyKingNotifyEvent: + sender := group.FindMember(notify.Sender) + luckyKing := group.FindMember(notify.LuckyKing) + log.Infof("群 %v 内 %v 的红包被抢完, %v 是运气王", formatGroupName(group), formatMemberName(sender), formatMemberName(luckyKing)) + bot.dispatchEventMessage(MSG{ + "post_type": "notice", + "group_id": group.Code, + "notice_type": "notify", + "sub_type": "lucky_king", + "self_id": c.Uin, + "user_id": notify.Sender, + "sender_id": notify.Sender, + "target_id": notify.LuckyKing, + "time": time.Now().Unix(), + }) + case *client.MemberHonorChangedNotifyEvent: + log.Info(notify.Content()) + bot.dispatchEventMessage(MSG{ + "post_type": "notice", + "group_id": group.Code, + "notice_type": "notify", + "sub_type": "honor", + "self_id": c.Uin, + "user_id": notify.Uin, + "time": time.Now().Unix(), + "honor_type": func() string { + switch notify.Honor { + case client.Talkative: + return "talkative" + case client.Performer: + return "performer" + case client.Emotion: + return "emotion" + default: + return "ERROR" + } + }(), + }) + } +} + +func (bot *CQBot) friendNotifyEvent(c *client.QQClient, e client.INotifyEvent) { + friend := c.FindFriend(e.From()) + switch notify := e.(type) { + case *client.FriendPokeNotifyEvent: + log.Infof("好友 %v 戳了戳你.", friend.Nickname) + bot.dispatchEventMessage(MSG{ + "post_type": "notice", + "notice_type": "notify", + "sub_type": "poke", + "self_id": c.Uin, + "user_id": notify.Sender, + "sender_id": notify.Sender, + "target_id": notify.Receiver, + "time": time.Now().Unix(), + }) + } +} + func (bot *CQBot) friendRecallEvent(c *client.QQClient, e *client.FriendMessageRecalledEvent) { f := c.FindFriend(e.FriendUin) gid := ToGlobalId(e.FriendUin, e.MessageId) - log.Infof("好友 %v(%v) 撤回了消息: %v", f.Nickname, f.Uin, gid) + if f != nil { + log.Infof("好友 %v(%v) 撤回了消息: %v", f.Nickname, f.Uin, gid) + } else { + log.Infof("好友 %v 撤回了消息: %v", e.FriendUin, gid) + } bot.dispatchEventMessage(MSG{ "post_type": "notice", "notice_type": "friend_recall", "self_id": c.Uin, - "user_id": f.Uin, + "user_id": e.FriendUin, "time": e.Time, "message_id": gid, }) } +func (bot *CQBot) offlineFileEvent(c *client.QQClient, e *client.OfflineFileEvent) { + f := c.FindFriend(e.Sender) + if f == nil { + return + } + log.Infof("好友 %v(%v) 发送了离线文件 %v", f.Nickname, f.Uin, e.FileName) + bot.dispatchEventMessage(MSG{ + "post_type": "notice", + "notice_type": "offline_file", + "user_id": e.Sender, + "file": MSG{ + "name": e.FileName, + "size": e.FileSize, + "url": e.DownloadUrl, + }, + "self_id": c.Uin, + "time": time.Now().Unix(), + }) +} + func (bot *CQBot) joinGroupEvent(c *client.QQClient, group *client.GroupInfo) { log.Infof("Bot进入了群 %v.", formatGroupName(group)) bot.dispatchEventMessage(bot.groupIncrease(group.Code, 0, c.Uin)) @@ -251,6 +326,20 @@ func (bot *CQBot) memberPermissionChangedEvent(c *client.QQClient, e *client.Mem }) } +func (bot *CQBot) memberCardUpdatedEvent(c *client.QQClient, e *client.MemberCardUpdatedEvent) { + log.Infof("群 %v 的 %v 更新了名片 %v -> %v", formatGroupName(e.Group), formatMemberName(e.Member), e.OldCard, e.Member.CardName) + bot.dispatchEventMessage(MSG{ + "post_type": "notice", + "notice_type": "group_card", + "group_id": e.Group.Code, + "user_id": e.Member.Uin, + "card_new": e.Member.CardName, + "card_old": e.OldCard, + "time": time.Now().Unix(), + "self_id": c.Uin, + }) +} + func (bot *CQBot) memberJoinEvent(c *client.QQClient, e *client.MemberJoinGroupEvent) { log.Infof("新成员 %v 进入了群 %v.", formatMemberName(e.Member), formatGroupName(e.Group)) bot.dispatchEventMessage(bot.groupIncrease(e.Group.Code, 0, e.Member.Uin)) @@ -295,7 +384,6 @@ func (bot *CQBot) friendAddedEvent(c *client.QQClient, e *client.NewFriendEvent) func (bot *CQBot) groupInvitedEvent(c *client.QQClient, e *client.GroupInvitedRequest) { log.Infof("收到来自群 %v(%v) 内用户 %v(%v) 的加群邀请.", e.GroupName, e.GroupCode, e.InvitorNick, e.InvitorUin) flag := strconv.FormatInt(e.RequestId, 10) - bot.invitedReqCache.Store(flag, e) bot.dispatchEventMessage(MSG{ "post_type": "request", "request_type": "group", @@ -312,7 +400,6 @@ func (bot *CQBot) groupInvitedEvent(c *client.QQClient, e *client.GroupInvitedRe func (bot *CQBot) groupJoinReqEvent(c *client.QQClient, e *client.UserJoinGroupRequest) { log.Infof("群 %v(%v) 收到来自用户 %v(%v) 的加群请求.", e.GroupName, e.GroupCode, e.RequesterNick, e.RequesterUin) flag := strconv.FormatInt(e.RequestId, 10) - bot.joinReqCache.Store(flag, e) bot.dispatchEventMessage(MSG{ "post_type": "request", "request_type": "group", @@ -326,6 +413,67 @@ func (bot *CQBot) groupJoinReqEvent(c *client.QQClient, e *client.UserJoinGroupR }) } +func (bot *CQBot) otherClientStatusChangedEvent(c *client.QQClient, e *client.OtherClientStatusChangedEvent) { + if e.Online { + log.Infof("Bot 账号在客户端 %v (%v) 登录.", e.Client.DeviceName, e.Client.DeviceKind) + } else { + log.Infof("Bot 账号在客户端 %v (%v) 登出.", e.Client.DeviceName, e.Client.DeviceKind) + } + bot.dispatchEventMessage(MSG{ + "post_type": "notice", + "notice_type": "client_status", + "online": e.Online, + "client": MSG{ + "app_id": e.Client.AppId, + "device_name": e.Client.DeviceName, + "device_kind": e.Client.DeviceKind, + }, + "self_id": c.Uin, + "time": time.Now().Unix(), + }) +} + +func (bot *CQBot) groupEssenceMsg(c *client.QQClient, e *client.GroupDigestEvent) { + g := c.FindGroup(e.GroupCode) + gid := ToGlobalId(e.GroupCode, e.MessageID) + if e.OperationType == 1 { + log.Infof( + "群 %v 内 %v 将 %v 的消息(%v)设为了精华消息.", + formatGroupName(g), + formatMemberName(g.FindMember(e.OperatorUin)), + formatMemberName(g.FindMember(e.SenderUin)), + gid, + ) + } else { + log.Infof( + "群 %v 内 %v 将 %v 的消息(%v)移出了精华消息.", + formatGroupName(g), + formatMemberName(g.FindMember(e.OperatorUin)), + formatMemberName(g.FindMember(e.SenderUin)), + gid, + ) + } + if e.OperatorUin == bot.Client.Uin { + return + } + bot.dispatchEventMessage(MSG{ + "post_type": "notice", + "group_id": e.GroupCode, + "notice_type": "essence", + "sub_type": func() string { + if e.OperationType == 1 { + return "add" + } + return "delete" + }(), + "self_id": c.Uin, + "sender_id": e.SenderUin, + "operator_id": e.OperatorUin, + "time": time.Now().Unix(), + "message_id": gid, + }) +} + func (bot *CQBot) groupIncrease(groupCode, operatorUin, userUin int64) MSG { return MSG{ "post_type": "notice", @@ -370,8 +518,8 @@ func (bot *CQBot) checkMedia(e []message.IMessageElement) { switch i := elem.(type) { case *message.ImageElement: filename := hex.EncodeToString(i.Md5) + ".image" - if !global.PathExists(path.Join(global.IMAGE_PATH, filename)) { - _ = ioutil.WriteFile(path.Join(global.IMAGE_PATH, filename), binary.NewWriterF(func(w *binary.Writer) { + if !global.PathExists(path.Join(global.ImagePath, filename)) { + _ = ioutil.WriteFile(path.Join(global.ImagePath, filename), binary.NewWriterF(func(w *binary.Writer) { w.Write(i.Md5) w.WriteUInt32(uint32(i.Size)) w.WriteString(i.Filename) @@ -379,23 +527,67 @@ func (bot *CQBot) checkMedia(e []message.IMessageElement) { }), 0644) } i.Filename = filename + case *message.GroupImageElement: + filename := hex.EncodeToString(i.Md5) + ".image" + if !global.PathExists(path.Join(global.ImagePath, filename)) { + _ = ioutil.WriteFile(path.Join(global.ImagePath, filename), binary.NewWriterF(func(w *binary.Writer) { + w.Write(i.Md5) + w.WriteUInt32(uint32(i.Size)) + w.WriteString(filename) + w.WriteString(i.Url) + }), 0644) + } + case *message.FriendImageElement: + filename := hex.EncodeToString(i.Md5) + ".image" + if !global.PathExists(path.Join(global.ImagePath, filename)) { + _ = ioutil.WriteFile(path.Join(global.ImagePath, filename), binary.NewWriterF(func(w *binary.Writer) { + w.Write(i.Md5) + w.WriteUInt32(uint32(0)) // 发送时会调用url, 大概没事 + w.WriteString(filename) + w.WriteString(i.Url) + }), 0644) + } + case *message.GroupFlashImgElement: + filename := hex.EncodeToString(i.Md5) + ".image" + if !global.PathExists(path.Join(global.ImagePath, filename)) { + _ = ioutil.WriteFile(path.Join(global.ImagePath, filename), binary.NewWriterF(func(w *binary.Writer) { + w.Write(i.Md5) + w.WriteUInt32(uint32(i.Size)) + w.WriteString(i.Filename) + w.WriteString("") + }), 0644) + } + i.Filename = filename + case *message.FriendFlashImgElement: + filename := hex.EncodeToString(i.Md5) + ".image" + if !global.PathExists(path.Join(global.ImagePath, filename)) { + _ = ioutil.WriteFile(path.Join(global.ImagePath, filename), binary.NewWriterF(func(w *binary.Writer) { + w.Write(i.Md5) + w.WriteUInt32(uint32(i.Size)) + w.WriteString(i.Filename) + w.WriteString("") + }), 0644) + } + i.Filename = filename case *message.VoiceElement: i.Name = strings.ReplaceAll(i.Name, "{", "") i.Name = strings.ReplaceAll(i.Name, "}", "") - if !global.PathExists(path.Join(global.VOICE_PATH, i.Name)) { + if !global.PathExists(path.Join(global.VoicePath, i.Name)) { b, err := global.GetBytes(i.Url) if err != nil { log.Warnf("语音文件 %v 下载失败: %v", i.Name, err) continue } - _ = ioutil.WriteFile(path.Join(global.VOICE_PATH, i.Name), b, 0644) + _ = ioutil.WriteFile(path.Join(global.VoicePath, i.Name), b, 0644) } case *message.ShortVideoElement: filename := hex.EncodeToString(i.Md5) + ".video" - if !global.PathExists(path.Join(global.VIDEO_PATH, filename)) { - _ = ioutil.WriteFile(path.Join(global.VIDEO_PATH, filename), binary.NewWriterF(func(w *binary.Writer) { + if !global.PathExists(path.Join(global.VideoPath, filename)) { + _ = ioutil.WriteFile(path.Join(global.VideoPath, filename), binary.NewWriterF(func(w *binary.Writer) { w.Write(i.Md5) + w.Write(i.ThumbMd5) w.WriteUInt32(uint32(i.Size)) + w.WriteUInt32(uint32(i.ThumbSize)) w.WriteString(i.Name) w.Write(i.Uuid) }), 0644) diff --git a/docs/EventFilter.md b/docs/EventFilter.md index c9ccf05..7f23406 100644 --- a/docs/EventFilter.md +++ b/docs/EventFilter.md @@ -1,12 +1,22 @@ # 事件过滤器 -在go-cqhttp同级目录下新建`filter.json`文件即可开启事件过滤器,启动时会读取该文件中定义的过滤规则(使用 JSON 编写),若文件不存在,或过滤规则语法错误,则会暂停所有上报。 +在go-cqhttp同级目录下新建`filter.json`文件即可开启事件过滤器,启动时会读取该文件中定义的过滤规则(使用 JSON 编写),若文件不存在,或过滤规则语法错误,则不会启用事件过滤器。 事件过滤器会处理所有事件(包括心跳事件在内的元事件),请谨慎使用!! +注意: 与客户端建立连接的握手事件**不会**经过事件过滤器 + ## 示例 这节首先给出一些示例,演示过滤器的基本用法,下一节将给出具体语法说明。 +### 过滤所有事件 + +```json +{ + ".not": {} +} +``` + ### 只上报以「!!」开头的消息 ```json @@ -110,16 +120,16 @@ 下面列出所有运算符(「要求的参数类型」是指运算符的键所对应的值的类型,「可作用于的类型」是指在过滤时事件对象相应值的类型): -| 运算符 | 要求的参数类型 | 可作用于的类型 | -| ----- | ------------ | ----------- | -| `.not` | object | 任何 | -| `.and` | object | 若参数中全为运算符,则任何;若不全为运算符,则 object | -| `.or` | array(数组元素为 object) | 任何 | -| `.eq` | 任何 | 任何 | -| `.neq` | 任何 | 任何 | -| `.in` | string/array | 若参数为 string,则 string;若参数为 array,则任何 | -| `.contains` | string | string | -| `.regex` | string | string | +| 运算符 | 要求的参数类型 | 可作用于的类型 | +| ----------- | -------------------------- | ----------------------------------------------------- | +| `.not` | object | 任何 | +| `.and` | object | 若参数中全为运算符,则任何;若不全为运算符,则 object | +| `.or` | array(数组元素为 object) | 任何 | +| `.eq` | 任何 | 任何 | +| `.neq` | 任何 | 任何 | +| `.in` | string/array | 若参数为 string,则 string;若参数为 array,则任何 | +| `.contains` | string | string | +| `.regex` | string | string | ## 过滤时的事件数据对象 @@ -128,5 +138,5 @@ 这里有几点需要注意: -- `message` 字段在运行过滤器时是消息段数组的形式(见 [消息格式]( https://github.com/howmanybots/onebot/blob/master/v11/specs/message/array.md )) +- `message` 字段在运行过滤器时和上报信息类型相同(见 [消息格式]( https://github.com/howmanybots/onebot/blob/master/v11/specs/message/array.md )) - `raw_message` 字段为未经**CQ码**处理的原始消息字符串,这意味着其中可能会出现形如 `[CQ:face,id=123]` 的 CQ 码 diff --git a/docs/QA.md b/docs/QA.md new file mode 100644 index 0000000..28f455c --- /dev/null +++ b/docs/QA.md @@ -0,0 +1,5 @@ +# 常见问题 + +### Q: 为什么挂一段时间后就会出现 `消息发送失败,账号可能被风控`? + +### A: 如果你刚开始使用 go-cqhttp 建议挂机3-7天,即可解除风控 diff --git a/docs/adminApi.md b/docs/adminApi.md new file mode 100644 index 0000000..a455dcd --- /dev/null +++ b/docs/adminApi.md @@ -0,0 +1,251 @@ +# 管理 API + +> 支持跨域 + +## 公共参数 + +参数: + +| 参数名 | 类型 | 说明 | +| ------------ | ------ | --------------------------- | +| access_token | string | 校验口令,config.hjson中配置 | + + + +## admin/do_restart + +### 热重启 + +> 热重启 + +> ps: 目前不支持ws部分的修改生效 + +method:`POST/GET` + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ---- | ---- | +| 无 | | | + +返回: + +```json +{"data": {}, "retcode": 0, "status": "ok"} +``` + + +### admin/get_web_write + +> 拉取验证码/设备锁 + +method: `GET` + + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ---- | ---- | +| 无 | | | + +返回: + +```json +{"data": {"ispic": true,"picbase64":"xxxxx"}, "retcode": 0, "status": "ok"} +``` +| 参数名 | 类型 | 说明 | +| -------- | ------ | --------------------------------------------------- | +| ispic | bool | 是否是验证码类型 true是,false为不是(比如设备锁 | +| picbas64 | string | 验证码的base64编码内容,加上头,放入img标签即可显示 | + +### admin/do_web_write + +> web输入验证码/设备锁确认 + +method: `POST` formdata + + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ------ | ---------- | +| input | string | 输入的内容 | + +返回: + +```json +{"data": {}, "retcode": 0, "status": "ok"} +``` + + +### admin/do_restart_docker + +> 冷重启 + +> 注意:此api 会直接结束掉进程,需要依赖docker/supervisor等进程管理工具来自动拉起 + +method: `POST` + + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ---- | ---- | +| 无 | | | + +返回: + +```json +{"data": {}, "retcode": 0, "status": "ok"} +``` + +### admin/do_process_restart + +> 冷重启 + +method: `POST` + + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ---- | ---- | +| 无 | | | + +返回: + +```json +{"data": {}, "retcode": 0, "status": "ok"} +``` + +### admin/do_config_base + +> 基础配置 + +method: `POST` formdata + + +参数: + +| 参数名 | 类型 | 说明 | +| ------------ | ------ | ------------------------------------- | +| uin | string | qq号 | +| password | string | qq密码 | +| enable_db | string | 是否启动数据库,填 'true' 或者 'false' | +| access_token | string | 授权 token | + +返回: + +```json +{"data": {}, "retcode": 0, "status": "ok"} +``` + + +### admin/do_config_http + +> http服务配置 + +method: `POST` formdata + +参数: + +| 参数名 | 类型 | 说明 | +| ----------- | ------ | --------------------------------------------- | +| port | string | 服务端口 | +| host | string | 服务监听地址 | +| enable | string | 是否启用 ,填 'true' 或者 'false' | +| timeout | string | http请求超时时间 | +| post_url | string | post上报地址 不需要就填空字符串,或者不填 | +| post_secret | string | post上报的secret 不需要就填空字符串,或者不填 | + +返回: + +```json +{"data": {}, "retcode": 0, "status": "ok"} +``` + + +### admin/do_config_ws + +> 正向ws设置 + +method: `POST` formdata + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ------ | -------------------------------- | +| port | string | 服务端口 | +| host | string | 服务监听地址 | +| enable | string | 是否启用 ,填 'true' 或者 'false' | + + +返回: + +```json +{"data": {}, "retcode": 0, "status": "ok"} +``` + +### admin/do_config_reverse + +> 反向ws配置 + +method: `POST` formdata + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ------ | -------------------------------- | +| port | string | 服务端口 | +| host | string | 服务监听地址 | +| enable | string | 是否启用 ,填 'true' 或者 'false' | + + +返回: + +```json +{"data": {}, "retcode": 0, "status": "ok"} +``` + +### admin/do_config_json + +> 直接修改 config.hjson配置 + +method: `POST` formdata + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ------ | ----------------------------------- | +| json | string | 完整的config.hjson的配合,json字符串 | + + +返回: + +```json +{"data": {}, "retcode": 0, "status": "ok"} +``` + +### admin/get_config_json + +> 获取当前 config.hjson配置 + +method: `GET` + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ---- | ---- | +| 无 | | | + + +返回: + +```json +{"data": {"config":"xxxx"}, "retcode": 0, "status": "ok"} +``` + +| 参数名 | 类型 | 说明 | +| ------ | ------ | ----------------------------------- | +| config | string | 完整的config.hjson的配合,json字符串 | + diff --git a/docs/config.md b/docs/config.md index ccdff7c..495f245 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,6 +1,6 @@ # 配置 -go-cqhttp 包含 `config.json` 和 `device.json` 两个配置文件, 其中 `config.json` 为运行配置 `device.json` 为虚拟设备信息. +go-cqhttp 包含 `config.hjson` 和 `device.json` 两个配置文件, 其中 `config.json` 为运行配置 `device.json` 为虚拟设备信息. ## 从原CQHTTP导入配置 @@ -36,6 +36,7 @@ go-cqhttp 支持导入CQHTTP的配置文件, 具体步骤为: "ignore_invalid_cqcode": false, "force_fragmented": true, "heartbeat_interval": 5, + "use_sso_address": false, "http_config": { "enabled": true, "host": "0.0.0.0", @@ -60,28 +61,30 @@ go-cqhttp 支持导入CQHTTP的配置文件, 具体步骤为: } ```` -| 字段 | 类型 | 说明 | -| ------------------ | -------- | ------------------------------------------------------------------- | -| uin | int64 | 登录用QQ号 | -| password | string | 登录用密码 | -| encrypt_password | bool | 是否对密码进行加密. | -| password_encrypted | string | 加密后的密码(请勿修改) | -| enable_db | bool | 是否开启内置数据库, 关闭后将无法使用 **回复/撤回** 等上下文相关接口 | -| access_token | string | 同CQHTTP的 `access_token` 用于身份验证 | -| relogin | bool | 是否自动重新登录 | -| relogin_delay | int | 重登录延时(秒) | -| max_relogin_times | uint | 最大重登录次数,若0则不设置上限 | -| _rate_limit | bool | 是否启用API调用限速 | -| frequency | float64 | 1s内能调用API的次数 | -| bucket_size | int | 令牌桶的大小,默认为1,修改此值可允许一定程度内连续调用api | -| post_message_format | string | 上报信息类型 | -| ignore_invalid_cqcode| bool | 是否忽略错误的CQ码 | -| force_fragmented | bool | 是否强制分片发送群长消息 | -| heartbeat_interval | int64 | 心跳间隔时间,单位秒,若0则关闭心跳 | -| http_config | object | HTTP API配置 | -| ws_config | object | Websocket API 配置 | -| ws_reverse_servers | object[] | 反向 Websocket API 配置 | -| log_level | string | 指定日志收集级别,将收集的日志单独存放到固定文件中,便于查看日志线索 当前支持 warn,error| +| 字段 | 类型 | 说明 | +| --------------------- | -------- | ---------------------------------------------------------------------------------------- | +| uin | int64 | 登录用QQ号 | +| password | string | 登录用密码 | +| encrypt_password | bool | 是否对密码进行加密. | +| password_encrypted | string | 加密后的密码(请勿修改) | +| enable_db | bool | 是否开启内置数据库, 关闭后将无法使用 **回复/撤回** 等上下文相关接口 | +| access_token | string | 同CQHTTP的 `access_token` 用于身份验证 | +| relogin | bool | 是否自动重新登录 | +| relogin_delay | int | 重登录延时(秒) | +| max_relogin_times | uint | 最大重登录次数,若0则不设置上限 | +| _rate_limit | bool | 是否启用API调用限速 | +| frequency | float64 | 1s内能调用API的次数 | +| bucket_size | int | 令牌桶的大小,默认为1,修改此值可允许一定程度内连续调用api | +| post_message_format | string | 上报信息类型 | +| ignore_invalid_cqcode | bool | 是否忽略错误的CQ码 | +| force_fragmented | bool | 是否强制分片发送群长消息 | +| fix_url | bool | 是否对链接的发送进行预处理, 可缓解链接信息被风控导致无法发送的情况, 但可能影响客户端着色(不影响内容)| +| use_sso_address | bool | 是否使用服务器下发的地址 | +| heartbeat_interval | int64 | 心跳间隔时间,单位秒。小于0则关闭心跳,等于0使用默认值(5秒) | +| http_config | object | HTTP API配置 | +| ws_config | object | Websocket API 配置 | +| ws_reverse_servers | object[] | 反向 Websocket API 配置 | +| log_level | string | 指定日志收集级别,将收集的日志单独存放到固定文件中,便于查看日志线索 当前支持 warn,error | > 注: 开启密码加密后程序将在每次启动时要求输入解密密钥, 密钥错误会导致登录时提示密码错误. > 解密后密码将储存在内存中,用于自动重连等功能. 所以此加密并不能防止内存读取. @@ -90,3 +93,41 @@ go-cqhttp 支持导入CQHTTP的配置文件, 具体步骤为: > 注2: 分片发送为原酷Q发送长消息的老方案, 发送速度更优/兼容性更好,但在有发言频率限制的群里,可能无法发送。关闭后将优先使用新方案, 能发送更长的消息, 但发送速度更慢,在部分老客户端将无法解析. > 注3:关闭心跳服务可能引起断线,请谨慎关闭 + +## 设备信息 + +默认生成的设备信息如下所示: + +``` json +{ + "protocol": 0, + "display": "xxx", + "finger_print": "xxx", + "boot_id": "xxx", + "proc_version": "xxx", + "imei": "xxx" +} +``` + +在大部分情况下 我们只需要关心 `protocol` 字段: + +| 值 | 类型 | 限制 | +| --- | ------------- | ---------------------------------------------------------------- | +| 0 | iPad | 无 | +| 1 | Android Phone | 无 | +| 2 | Android Watch | 无法接收 `notify` 事件、无法接收口令红包、无法接收撤回消息 | +| 3 | MacOS | 无 | + +> 注意, 根据协议的不同, 各类消息有所限制 + +## 自定义服务器IP + +> 某些海外服务器使用默认地址可能会存在链路问题,此功能可以指定 go-cqhttp 连接哪些地址以达到最优化. + +将文件 `address.txt` 创建到 `go-cqhttp` 工作目录, 并键入 `IP:PORT` 以换行符为分割即可. + +示例: +```` +1.1.1.1:53 +1.1.2.2:8899 +```` \ No newline at end of file diff --git a/docs/cqhttp.md b/docs/cqhttp.md index 6c13a80..53cc24e 100644 --- a/docs/cqhttp.md +++ b/docs/cqhttp.md @@ -2,21 +2,235 @@ 由于部分 api 原版 CQHTTP 并未实现,go-cqhttp 修改并增加了一些拓展 api . +
+目录 +

+ +##### CQCode +- [图片](#图片) +- [回复](#回复) +- [红包](#红包) +- [戳一戳](#戳一戳) +- [礼物](#礼物) +- [合并转发](#合并转发) +- [合并转发消息节点](#合并转发消息节点) +- [XML 消息](#xml-消息) +- [JSON 消息](#json-消息) +- [cardimage](#cardimage) +- [文本转语音](#文本转语音) +- [图片](#图片) + +##### API +- [设置群名](#设置群名) +- [设置群头像](#设置群头像) +- [获取图片信息](#获取图片信息) +- [获取消息](#获取消息) +- [获取合并转发内容](#获取合并转发内容) +- [发送合并转发(群)](#发送合并转发(群)) +- [获取中文分词](#获取中文分词) +- [图片OCR](#图片OCR) +- [获取中文分词](#获取中文分词) +- [获取群系统消息](#获取群文件系统信息) +- [获取群文件系统信息](#获取群文件系统信息) +- [获取群根目录文件列表](#获取群根目录文件列表) +- [获取群子目录文件列表](#获取群子目录文件列表) +- [获取群文件资源链接](#获取群文件资源链接) +- [获取状态](#获取状态) +- [获取群子目录文件列表](#设置群名) +- [获取用户VIP信息](#获取用户VIP信息) +- [发送群公告](#发送群公告) +- [设置精华消息](#设置精华消息) +- [移出精华消息](#移出精华消息) +- [获取精华消息列表](#获取精华消息列表) +- [重载事件过滤器](#重载事件过滤器) + +##### 事件 +- [群消息撤回](#群消息撤回) +- [好友消息撤回](#好友消息撤回) +- [好友戳一戳](#好友戳一戳) +- [群内戳一戳](#群内戳一戳) +- [群红包运气王提示](#群红包运气王提示) +- [群成员荣誉变更提示](#群成员荣誉变更提示) +- [群成员名片更新](#群成员名片更新) +- [接收到离线文件](#接收到离线文件) +- [群精华消息](#精华消息) + +

+
+ ## CQCode +### 图片 + +Type : `image` + +范围: **发送/接收** + +参数: + +| 参数名 | 可能的值 | 说明 | +| ------- | --------------- | --------------------------------------------------------------- | +| `file` | - | 图片文件名 | +| `type` | `flash`,`show` | 图片类型,`flash` 表示闪照,`show` 表示秀图,默认普通图片 | +| `url` | - | 图片 URL | +| `cache` | `0` `1` | 只在通过网络 URL 发送时有效,表示是否使用已缓存的文件,默认 `1` | +| `id` | - | 发送秀图时的特效id,默认为40000 | +| `c` | `2` `3` | 通过网络下载图片时的线程数, 默认单线程. (在资源不支持并发时会自动处理)| + +可用的特效ID: + +| id | 类型 | +| ----- | ---- | +| 40000 | 普通 | +| 40001 | 幻影 | +| 40002 | 抖动 | +| 40003 | 生日 | +| 40004 | 爱你 | +| 40005 | 征友 | + +示例: `[CQ:image,file=http://baidu.com/1.jpg,type=show,id=40004]` + +> 注意:图片总大小不能超过30MB,gif总帧数不能超过300帧 + ### 回复 Type : `reply` 范围: **发送/接收** +> 注意: 如果id存在则优先处理id + 参数: | 参数名 | 类型 | 说明 | | ------ | ---- | ------------------------------------- | -| id | int | 回复时所引用的消息id, 必须为本群消息. | +| `id` | int | 回复时所引用的消息id, 必须为本群消息. | +| `text` | string | 自定义回复的信息 | +| `qq` | int64 | 自定义回复时的自定义QQ, 如果使用自定义信息必须指定. | +| `time` | int64 | 自定义回复时的时间, 格式为Unix时间 | -示例: `[CQ:reply,id=123456]` + + +示例: `[CQ:reply,id=123456]` +\ +自定义回复示例: `[CQ:reply,text=Hello World,qq=10086,time=3376656000]` + +### 音乐分享 + +```json +{ + "type": "music", + "data": { + "type": "163", + "id": "28949129" + } +} +``` + +``` +[CQ:music,type=163,id=28949129] +``` + +| 参数名 | 收 | 发 | 可能的值 | 说明 | +| --- | --- | --- | --- | --- | +| `type` | | ✓ | `qq` `163` | 分别表示使用 QQ 音乐、网易云音乐 | +| `id` | | ✓ | - | 歌曲 ID | + +### 音乐自定义分享 + +```json +{ + "type": "music", + "data": { + "type": "custom", + "url": "http://baidu.com", + "audio": "http://baidu.com/1.mp3", + "title": "音乐标题" + } +} +``` + +``` +[CQ:music,type=custom,url=http://baidu.com,audio=http://baidu.com/1.mp3,title=音乐标题] +``` + +| 参数名 | 收 | 发 | 可能的值 | 说明 | +| --- | --- | --- | --- | --- | +| `type` | | ✓ | `custom` | 表示音乐自定义分享 | +| `subtype` | | ✓ | `qq,163,migu,kugou,kuwo` | 表示分享类型,不填写发送为xml卡片,推荐填写提高稳定性 | +| `url` | | ✓ | - | 点击后跳转目标 URL | +| `audio` | | ✓ | - | 音乐 URL | +| `title` | | ✓ | - | 标题 | +| `content` | | ✓ | - | 内容描述 | +| `image` | | ✓ | - | 图片 URL | + +### 红包 + +Type: `redbag` + +范围: **接收** + +参数: + +| 参数名 | 类型 | 说明 | +| ------- | ------ | ----------- | +| `title` | string | 祝福语/口令 | + +示例: `[CQ:redbag,title=恭喜发财]` + +### 戳一戳 + +> 注意:发送戳一戳消息无法撤回,返回的 `message id` 恒定为 `0` + +Type: `poke` + +范围: **发送(仅群聊)** + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ----- | ------------ | +| `qq` | int64 | 需要戳的成员 | + +示例: `[CQ:poke,qq=123456]` + +### 礼物 + +> 注意:仅支持免费礼物,发送群礼物消息无法撤回,返回的 `message id` 恒定为 `0` + +Type: `gift` + +范围: **发送(仅群聊,接收的时候不是CQ码)** + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ----- | -------------- | +| `qq` | int64 | 接收礼物的成员 | +| `id` | int | 礼物的类型 | + +目前支持的礼物ID: + +| id | 类型 | +| --- | ---------- | +| 0 | 甜Wink | +| 1 | 快乐肥宅水 | +| 2 | 幸运手链 | +| 3 | 卡布奇诺 | +| 4 | 猫咪手表 | +| 5 | 绒绒手套 | +| 6 | 彩虹糖果 | +| 7 | 坚强 | +| 8 | 告白话筒 | +| 9 | 牵你的手 | +| 10 | 可爱猫咪 | +| 11 | 神秘面具 | +| 12 | 我超忙的 | +| 13 | 爱心口罩 | + + + +示例: `[CQ:gift,qq=123456,id=8]` ### 合并转发 @@ -28,7 +242,7 @@ Type: `forward` | 参数名 | 类型 | 说明 | | ------ | ------ | ------------------------------------------------------------ | -| id | string | 合并转发ID, 需要通过 `/get_forward_msg` API获取转发的具体内容 | +| `id` | string | 合并转发ID, 需要通过 `/get_forward_msg` API获取转发的具体内容 | 示例: `[CQ:forward,id=xxxx]` @@ -40,12 +254,12 @@ Type: `node` 参数: -| 参数名 | 类型 | 说明 | 特殊说明 | -| ------- | ------- | -------------- | ------------------------------------------------------------ | -| id | int32 | 转发消息id | 直接引用他人的消息合并转发, 实际查看顺序为原消息发送顺序 **与下面的自定义消息二选一** | -| name | string | 发送者显示名字 | 用于自定义消息 (自定义消息并合并转发,实际查看顺序为自定义消息段顺序) | -| uin | int64 | 发送者QQ号 | 用于自定义消息 | -| content | message | 具体消息 | 用于自定义消息 **不支持转发套娃,不支持引用回复** | +| 参数名 | 类型 | 说明 | 特殊说明 | +| --------- | ------- | -------------- | ------------------------------------------------------------ | +| `id` | int32 | 转发消息id | 直接引用他人的消息合并转发, 实际查看顺序为原消息发送顺序 **与下面的自定义消息二选一** | +| `name` | string | 发送者显示名字 | 用于自定义消息 (自定义消息并合并转发,实际查看顺序为自定义消息段顺序) | +| `uin` | int64 | 发送者QQ号 | 用于自定义消息 | +| `content` | message | 具体消息 | 用于自定义消息 | 特殊说明: **需要使用单独的API `/send_group_forward_msg` 发送,并且由于消息段较为复杂,仅支持Array形式入参。 如果引用消息和自定义消息同时出现,实际查看顺序将取消息段顺序. 另外按 [CQHTTP](https://cqhttp.cc/docs/4.15/#/Message?id=格式) 文档说明, `data` 应全为字符串, 但由于需要接收`message` 类型的消息, 所以 *仅限此Type的content字段* 支持Array套娃** @@ -107,7 +321,8 @@ Type: `node` "data": { "name": "自定义发送者", "uin": "10086", - "content": "我是自定义消息" + "content": "我是自定义消息", + "time": "3376656000" } }, { @@ -118,8 +333,22 @@ Type: `node` } ] ```` +### 短视频消息 -### xml支持 +Type: `video` + +范围: **发送/接收** + +参数: + +| 参数名 | 类型 | 说明 | +| ------- | ------ | ------------------------------------------------| +| `file` | string | 支持http和file发送 | +| `cover` | string | 视频封面,支持http,file和base64发送,格式必须为jpg | +| `c` | `2` `3`| 通过网络下载视频时的线程数, 默认单线程. (在资源不支持并发时会自动处理)| +示例: `[CQ:image,file=file:///C:\\Users\Richard\Pictures\1.mp4]` + +### XML 消息 Type: `xml` @@ -127,16 +356,16 @@ Type: `xml` 参数: -| 参数名 | 类型 | 说明 | -| ------ | ------ | ------------------------------------------------------------ | -| data | string | xml内容,xml中的value部分,记得实体化处理| -| resid | int32 | 可以不填| +| 参数名 | 类型 | 说明 | +| ------- | ------ | ----------------------------------------- | +| `data` | string | xml内容,xml中的value部分,记得实体化处理 | +| `resid` | int32 | 可以不填 | 示例: `[CQ:xml,data=xxxx]` -####一些xml样例 +#### 一些xml样例 -####ps:重要:xml中的value部分,记得html实体化处理后,再打加入到cq码中 +#### ps:重要:xml中的value部分,记得html实体化处理后,再打加入到cq码中 #### qq音乐 @@ -168,7 +397,7 @@ Type: `xml` ``` -### json消息支持 +### JSON 消息 Type: `json` @@ -176,20 +405,20 @@ Type: `json` 参数: -| 参数名 | 类型 | 说明 | -| ------ | ------ | ------------------------------------------------------------ | -| data | string | json内容,json的所有字符串记得实体化处理| -| resid | int32 | 默认不填为0,走小程序通道,填了走富文本通道发送| +| 参数名 | 类型 | 说明 | +| ------- | ------ | ----------------------------------------------- | +| `data` | string | json内容,json的所有字符串记得实体化处理 | +| `resid` | int32 | 默认不填为0,走小程序通道,填了走富文本通道发送 | json中的字符串需要进行转义: ->","=>`,`、 +>","=> `,` ->"&"=> `&`、 +>"&"=> `&` ->"["=>`[`、 +>"["=> `[` ->"]"=>`]`、 +>"]"=> `]` 否则无法正确得到解析 @@ -199,7 +428,8 @@ json中的字符串需要进行转义: ``` -### cardimage 一种xml的图片消息(装逼大图) +### cardimage +一种xml的图片消息(装逼大图) ps: xml 接口的消息都存在风控风险,请自行兼容发送失败后的处理(可以失败后走普通图片模式) @@ -209,15 +439,15 @@ Type: `cardimage` 参数: -| 参数名 | 类型 | 说明 | -| ------ | ------ | ------------------------------------------------------------ | -| file | string | 和image的file字段对齐,支持也是一样的| -| minwidth | int64 | 默认不填为400,最小width| -| minheight | int64 | 默认不填为400,最小height| -| maxwidth | int64 | 默认不填为500,最大width| -| maxheight | int64 | 默认不填为1000,最大height| -| source | string | 分享来源的名称,可以留空| -| icon | string | 分享来源的icon图标url,可以留空| +| 参数名 | 类型 | 说明 | +| ----------- | ------ | ------------------------------------- | +| `file` | string | 和image的file字段对齐,支持也是一样的 | +| `minwidth` | int64 | 默认不填为400,最小width | +| `minheight` | int64 | 默认不填为400,最小height | +| `maxwidth` | int64 | 默认不填为500,最大width | +| `maxheight` | int64 | 默认不填为1000,最大height | +| `source` | string | 分享来源的名称,可以留空 | +| `icon` | string | 分享来源的icon图标url,可以留空 | 示例cardimage 的cq码: @@ -225,6 +455,22 @@ Type: `cardimage` [CQ:cardimage,file=https://i.pixiv.cat/img-master/img/2020/03/25/00/00/08/80334602_p0_master1200.jpg] ``` +### 文本转语音 + +> 注意:通过TX的TTS接口,采用的音源与登录账号的性别有关 + +Type: `tts` + +范围: **发送(仅群聊)** + +参数: + +| 参数名 | 类型 | 说明 | +| ------ | ------ | ---- | +| `text` | string | 内容 | + +示例: `[CQ:tts,text=这是一条测试消息]` + ## API ### 设置群名 @@ -233,10 +479,32 @@ Type: `cardimage` **参数** -| 字段 | 类型 | 说明 | -| -------- | ------ | ---- | -| group_id | int64 | 群号 | -| group_name | string | 新名 | +| 字段 | 类型 | 说明 | +| ------------ | ------ | ---- | +| `group_id` | int64 | 群号 | +| `group_name` | string | 新名 | + +### 设置群头像 + +终结点: `/set_group_portrait` + +**参数** + +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------ | +| `group_id` | int64 | 群号 | +| `file` | string | 图片文件名 | +| `cache` | int | 表示是否使用已缓存的文件 | + +[1]`file` 参数支持以下几种格式: + +- 绝对路径,例如 `file:///C:\\Users\Richard\Pictures\1.png`,格式使用 [`file` URI](https://tools.ietf.org/html/rfc8089) +- 网络 URL,例如 `http://i1.piimg.com/567571/fdd6e7b6d93f1ef0.jpg` +- Base64 编码,例如 `base64://iVBORw0KGgoAAAANSUhEUgAAABQAAAAVCAIAAADJt1n/AAAAKElEQVQ4EWPk5+RmIBcwkasRpG9UM4mhNxpgowFGMARGEwnBIEJVAAAdBgBNAZf+QAAAAABJRU5ErkJggg==` + +[2]`cache`参数: 通过网络 URL 发送时有效,`1`表示使用缓存,`0`关闭关闭缓存,默认 为`1` + +[3] 目前这个API在登录一段时间后因cookie失效而失效,请考虑后使用 ### 获取图片信息 @@ -258,9 +526,9 @@ Type: `cardimage` | `filename` | string | 图片文件原名 | | `url` | string | 图片下载地址 | -### 获取群消息 +### 获取消息 -终结点: `/get_group_msg` +终结点: `/get_msg` 参数 @@ -276,7 +544,7 @@ Type: `cardimage` | `real_id` | int32 | 消息真实id | | `sender` | object | 发送者 | | `time` | int32 | 发送时间 | -| `content` | message | 消息内容 | +| `message` | message | 消息内容 | ### 获取合并转发内容 @@ -334,11 +602,459 @@ Type: `cardimage` | `group_id` | int64 | 群号 | | `messages` | forward node[] | 自定义转发消息, 具体看CQCode | -### +响应数据 + +| 字段 | 类型 | 说明 | +| ------------ | ------ | ------ | +| `message_id` | string | 消息id | + +### 获取中文分词 + +终结点: `/.get_word_slices` + +**参数** + +| 字段 | 类型 | 说明 | +| --------- | ------ | ---- | +| `content` | string | 内容 | + +**响应数据** + +| 字段 | 类型 | 说明 | +| -------- | -------- | ---- | +| `slices` | string[] | 词组 | + +### 设置精华消息 + +终结点: `/set_essence_msg` + +**参数** + +| 字段 | 类型 | 说明 | +| --------- | ------ | ---- | +| `message_id` | int32 | 消息ID | + +**响应数据** + +无 + +### 移出精华消息 + +终结点: `/delete_essence_msg` + +**参数** + +| 字段 | 类型 | 说明 | +| --------- | ------ | ---- | +| `message_id` | int32 | 消息ID | + +**响应数据** + +无 + +### 获取精华消息列表 + +终结点: `/get_essence_msg_list` + +**参数** + +| 字段 | 类型 | 说明 | +| --------- | ------ | ---- | +| `group_id` | int64 | 群号 | + +**响应数据** + +响应内容为 JSON 数组,每个元素如下: + +| 字段名 | 数据类型 | 说明 | +| ----- | ------- | --- | +| `sender_id` |int64 | 发送者QQ 号 | +| `sender_nick` | string | 发送者昵称 | +| `sender_time` | int64 | 消息发送时间 | +| `operator_id` |int64 | 发送者QQ 号 | +| `operator_nick` | string | 发送者昵称 | +| `operator_time` | int64 | 消息发送时间| +| `message_id` | int32 | 消息ID | + +### 图片OCR + +> 注意: 目前图片OCR接口仅支持接受的图片 + +终结点: `/ocr_image` + +**参数** + +| 字段 | 类型 | 说明 | +| ------- | ------ | ------ | +| `image` | string | 图片ID | + +**响应数据** + +| 字段 | 类型 | 说明 | +| ---------- | --------------- | ------- | +| `texts` | TextDetection[] | OCR结果 | +| `language` | string | 语言 | + +**TextDetection** + +| 字段 | 类型 | 说明 | +| ------------- | ------- | ------ | +| `text` | string | 文本 | +| `confidence` | int32 | 置信度 | +| `coordinates` | vector2 | 坐标 | + + +### 获取群系统消息 + +终结点: `/get_group_system_msg` + +**响应数据** + +| 字段 | 类型 | 说明 | +| ------------------ | ---------------- | ------------ | +| `invited_requests` | InvitedRequest[] | 邀请消息列表 | +| `join_requests` | JoinRequest[] | 进群消息列表 | + + > 注意: 如果列表不存在任何消息, 将返回 `null` + + **InvitedRequest** + +| 字段 | 类型 | 说明 | +| -------------- | ------ | ----------------- | +| `request_id` | int64 | 请求ID | +| `invitor_uin` | int64 | 邀请者 | +| `invitor_nick` | string | 邀请者昵称 | +| `group_id` | int64 | 群号 | +| `group_name` | string | 群名 | +| `checked` | bool | 是否已被处理 | +| `actor` | int64 | 处理者, 未处理为0 | + + **JoinRequest** + +| 字段 | 类型 | 说明 | +| ---------------- | ------ | ----------------- | +| `request_id` | int64 | 请求ID | +| `requester_uin` | int64 | 请求者ID | +| `requester_nick` | string | 请求者昵称 | +| `message` | string | 验证消息 | +| `group_id` | int64 | 群号 | +| `group_name` | string | 群名 | +| `checked` | bool | 是否已被处理 | +| `actor` | int64 | 处理者, 未处理为0 | + +### 获取群文件系统信息 + +终结点: `/get_group_file_system_info` + +**参数** + +| 字段 | 类型 | 说明 | +| ---------- | ----- | ---- | +| `group_id` | int64 | 群号 | + +**响应数据** + +| 字段 | 类型 | 说明 | +| ------------- | ----- | ---------- | +| `file_count` | int32 | 文件总数 | +| `limit_count` | int32 | 文件上限 | +| `used_space` | int64 | 已使用空间 | +| `total_space` | int64 | 空间上限 | + +### 获取群根目录文件列表 + +> `File` 和 `Folder` 对象信息请参考最下方 + +终结点: `/get_group_root_files` + +**参数** + +| 字段 | 类型 | 说明 | +| ---------- | ----- | ---- | +| `group_id` | int64 | 群号 | + +**响应数据** + +| 字段 | 类型 | 说明 | +| --------- | -------- | ---------- | +| `files` | File[] | 文件列表 | +| `folders` | Folder[] | 文件夹列表 | + +### 获取群子目录文件列表 + +> `File` 和 `Folder` 对象信息请参考最下方 + +终结点: `/get_group_files_by_folder` + +**参数** + +| 字段 | 类型 | 说明 | +| ----------- | ------ | --------------------------- | +| `group_id` | int64 | 群号 | +| `folder_id` | string | 文件夹ID 参考 `Folder` 对象 | + +**响应数据** + +| 字段 | 类型 | 说明 | +| --------- | -------- | ---------- | +| `files` | File[] | 文件列表 | +| `folders` | Folder[] | 文件夹列表 | + +### 获取群文件资源链接 + +> `File` 和 `Folder` 对象信息请参考最下方 + +终结点: `/get_group_file_url` + +**参数** + +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------- | +| `group_id` | int64 | 群号 | +| `file_id` | string | 文件ID 参考 `File` 对象 | +| `busid` | int32 | 文件类型 参考 `File` 对象 | + +**响应数据** + +| 字段 | 类型 | 说明 | +| ----- | ------ | ------------ | +| `url` | string | 文件下载链接 | + + **File** + +| 字段 | 类型 | 说明 | +| ---------------- | ------ | ---------------------- | +| `file_id` | string | 文件ID | +| `file_name` | string | 文件名 | +| `busid` | int32 | 文件类型 | +| `file_size` | int64 | 文件大小 | +| `upload_time` | int64 | 上传时间 | +| `dead_time` | int64 | 过期时间,永久文件恒为0 | +| `modify_time` | int64 | 最后修改时间 | +| `download_times` | int32 | 下载次数 | +| `uploader` | int64 | 上传者ID | +| `uploader_name` | string | 上传者名字 | + + **Folder** + +| 字段 | 类型 | 说明 | +| ------------------ | ------ | ---------- | +| `folder_id` | string | 文件夹ID | +| `folder_name` | string | 文件名 | +| `create_time` | int64 | 创建时间 | +| `creator` | int64 | 创建者 | +| `creator_name` | string | 创建者名字 | +| `total_file_count` | int32 | 子文件数量 | + +### 上传群文件 + +终结点: `/upload_group_file` + +**参数** + +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------- | +| `group_id` | int64 | 群号 | +| `file` | string | 本地文件路径 | +| `name` | string | 储存名称 | +| `folder` | string | 父目录ID | + +> 在不提供 `folder` 参数的情况下默认上传到根目录 +> 只能上传本地文件, 需要上传 `http` 文件的话请先调用 `download_file` API下载 + +### 获取状态 + +终结点: `/get_status` + +**响应数据** + +| 字段 | 类型 | 说明 | +| ----------------- | ---------- | ------------------------------- | +| `app_initialized` | bool | 原 `CQHTTP` 字段, 恒定为 `true` | +| `app_enabled` | bool | 原 `CQHTTP` 字段, 恒定为 `true` | +| `plugins_good` | bool | 原 `CQHTTP` 字段, 恒定为 `true` | +| `app_good` | bool | 原 `CQHTTP` 字段, 恒定为 `true` | +| `online` | bool | 表示BOT是否在线 | +| `good` | bool | 同 `online` | +| `stat` | Statistics | 运行统计 | + +**Statistics** + + +| 字段 | 类型 | 说明 | +| ------------------ | ------ | ---------------- | +| `packet_received` | uint64 | 收到的数据包总数 | +| `packet_sent` | uint64 | 发送的数据包总数 | +| `packet_lost` | uint32 | 数据包丢失总数 | +| `message_received` | uint64 | 接受信息总数 | +| `message_sent` | uint64 | 发送信息总数 | +| `disconnect_times` | uint32 | TCP链接断开次数 | +| `lost_times` | uint32 | 账号掉线次数 | + +> 注意: 所有统计信息都将在重启后重制 + +### 获取群@全体成员剩余次数 + +终结点: `/get_group_at_all_remain` + +**参数** + +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------- | +| `group_id` | int64 | 群号 | + +**响应数据** + +| 字段 | 类型 | 说明 | +| ------------------------------- | ---------- | ------------------------------- | +| `can_at_all` | bool | 是否可以@全体成员 | +| `remain_at_all_count_for_group` | int16 | 群内所有管理当天剩余@全体成员次数 | +| `remain_at_all_count_for_uin` | int16 | BOT当天剩余@全体成员次数 | + +### 下载文件到缓存目录 + +终结点: `/download_file` + +**参数** + +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------- | +| `url` | string | 链接地址 | +| `thread_count` | int32 | 下载线程数 | +| `headers` | string or array | 自定义请求头 | + +**`headers`格式:** + +字符串: + +``` +User-Agent=YOUR_UA[\r\n]Referer=https://www.baidu.com +``` + +> `[\r\n]` 为换行符, 使用http请求时请注意编码 + +JSON数组: + +``` +[ + "User-Agent=YOUR_UA", + "Referer=https://www.baidu.com", +] +``` + +**响应数据** + +| 字段 | 类型 | 说明 | +| ---------- | ---------- | ------------ | +| `file` | string | 下载文件的*绝对路径* | + +> 通过这个API下载的文件能直接放入CQ码作为图片或语音发送 +> 调用后会阻塞直到下载完成后才会返回数据,请注意下载大文件时的超时 + +### 获取群消息历史记录 + +终结点:`/get_group_msg_history` + +**参数** + +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------- | +| `message_seq` | int64 | 起始消息序号, 可通过 `get_msg` 获得 | +| `group_id` | int64 | 群号 | + +**响应数据** + +| 字段 | 类型 | 说明 | +| ---------- | ---------- | ------------ | +| `messages` | []Message | 从起始序号开始的前19条消息 | + +> 不提供起始序号将默认获取最新的消息 + +### 获取当前账号在线客户端列表 + +终结点:`/get_online_clients` + +**参数** + +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------- | +| `no_cache` | bool | 是否无视缓存 | + +**响应数据** + +| 字段 | 类型 | 说明 | +| ---------- | ---------- | ------------ | +| `clients` | []Device | 在线客户端列表 | + +**Device** + +| 字段 | 类型 | 说明 | +| ---------- | ---------- | ------------ | +| `app_id` | int64 | 客户端ID | +| `device_name` | string | 设备名称 | +| `device_kind` | string | 设备类型 | + +### 检查链接安全性 + +终结点:`/check_url_safely` + +**参数** + +| 字段 | 类型 | 说明 | +| ---------- | ------ | ------------------------- | +| `url` | string | 需要检查的链接 | + +**响应数据** + +| 字段 | 类型 | 说明 | +| ---------- | ---------- | ------------ | +| `level` | int | 安全等级, 1: 安全 2: 未知 3: 危险 | + +### 获取用户VIP信息 + +终结点:`/_get_vip_info` + +**参数** + +| 字段名 | 数据类型 | 默认值 | 说明 | +| ----- | ------- | ----- | --- | +| `user_id` | int64 | | QQ 号 | + +**响应数据** + +| 字段 | 类型 | 说明 | +| ------------------ | ------- | ---------- | +| `user_id` | int64 | QQ 号 | +| `nickname` | string | 用户昵称 | +| `level` | int64 | QQ 等级 | +| `level_speed` | float64 | 等级加速度 | +| `vip_level` | string | 会员等级 | +| `vip_growth_speed` | int64 | 会员成长速度 | +| `vip_growth_total` | int64 | 会员成长总值 | + +### 发送群公告 + +终结点: `/_send_group_notice` + +**参数** + +| 字段名 | 数据类型 | 默认值 | 说明 | +| ---------- | ------- | ----- | ------ | +| `group_id` | int64 | | 群号 | +| `content` | string | | 公告内容 | + +`该 API 没有响应数据` + +### 重载事件过滤器 + +终结点:`/reload_event_filter` + +`该 API 无需参数也没有响应数据` + ## 事件 -#### 群消息撤回 +### 群消息撤回 **上报数据** @@ -351,14 +1067,133 @@ Type: `cardimage` | `operator_id` | int64 | | 操作者id | | `message_id` | int64 | | 被撤回的消息id | -#### 好友消息撤回 +### 好友消息撤回 **上报数据** -| 字段 | 类型 | 可能的值 | 说明 | -| ------------- | ------ | -------------- | -------------- | -| `post_type` | string | `notice` | 上报类型 | -| `notice_type` | string | `friend_recall`| 消息类型 | -| `user_id` | int64 | | 好友id | -| `message_id` | int64 | | 被撤回的消息id | +| 字段 | 类型 | 可能的值 | 说明 | +| ------------- | ------ | --------------- | -------------- | +| `post_type` | string | `notice` | 上报类型 | +| `notice_type` | string | `friend_recall` | 消息类型 | +| `user_id` | int64 | | 好友id | +| `message_id` | int64 | | 被撤回的消息id | +## 好友戳一戳 + +**事件数据** + +| 字段名 | 数据类型 | 可能的值 | 说明 | +| ------------- | ------ | -------- | --- | +| `post_type` | string | `notice` | 上报类型 | +| `notice_type` | string | `notify` | 消息类型 | +| `sub_type` | string | `poke` | 提示类型 | +| `self_id` | int64 | | BOT QQ 号 | +| `sender_id` | int64 | | 发送者 QQ 号 | +| `user_id` | int64 | | 发送者 QQ 号 | +| `target_id` | int64 | | 被戳者 QQ 号 | +| `time` | int64 | | 时间 | + +### 群内戳一戳 + +> 注意:此事件无法在平板和手表协议上触发 + +**上报数据** + +| 字段 | 类型 | 可能的值 | 说明 | +| ------------- | ------ | -------- | -------- | +| `post_type` | string | `notice` | 上报类型 | +| `notice_type` | string | `notify` | 消息类型 | +| `group_id` | int64 | | 群号 | +| `sub_type` | string | `poke` | 提示类型 | +| `user_id` | int64 | | 发送者id | +| `target_id` | int64 | | 被戳者id | + +### 群红包运气王提示 + +> 注意:此事件无法在平板和手表协议上触发 + +**上报数据** + +| 字段 | 类型 | 可能的值 | 说明 | +| ------------- | ------ | ------------ | ------------ | +| `post_type` | string | `notice` | 上报类型 | +| `notice_type` | string | `notify` | 消息类型 | +| `group_id` | int64 | | 群号 | +| `sub_type` | string | `lucky_king` | 提示类型 | +| `user_id` | int64 | | 红包发送者id | +| `target_id` | int64 | | 运气王id | + +### 群成员荣誉变更提示 + +> 注意:此事件无法在平板和手表协议上触发 + +**上报数据** + +| 字段 | 类型 | 可能的值 | 说明 | +| ------------- | ------ | -------------------------------------------------------- | -------- | +| `post_type` | string | `notice` | 上报类型 | +| `notice_type` | string | `notify` | 消息类型 | +| `group_id` | int64 | | 群号 | +| `sub_type` | string | `honor` | 提示类型 | +| `user_id` | int64 | | 成员id | +| `honor_type` | string | `talkative:龙王` `performer:群聊之火` `emotion:快乐源泉` | 荣誉类型 | + +### 群成员名片更新 + +> 注意: 此事件不保证时效性,仅在收到消息时校验卡片 + +**上报数据** + +| 字段 | 类型 | 可能的值 | 说明 | +| ------------- | ------ | ------------ | -------- | +| `post_type` | string | `notice` | 上报类型 | +| `notice_type` | string | `group_card` | 消息类型 | +| `group_id` | int64 | | 群号 | +| `user_id` | int64 | | 成员id | +| `card_new` | int64 | | 新名片 | +| `card_old` | int64 | | 旧名片 | + +> PS: 当名片为空时 `card_xx` 字段为空字符串, 并不是昵称 + +### 接收到离线文件 + +**上报数据** + +| 字段 | 类型 | 可能的值 | 说明 | +| ------------- | ------ | -------------- | -------- | +| `post_type` | string | `notice` | 上报类型 | +| `notice_type` | string | `offline_file` | 消息类型 | +| `user_id` | int64 | | 发送者id | +| `file` | object | | 文件数据 | + +**file object** + +| 字段 | 类型 | 可能的值 | 说明 | +| ------ | ------ | -------- | -------- | +| `name` | string | | 文件名 | +| `size` | int64 | | 文件大小 | +| `url` | string | | 下载链接 | + +### 其他客户端在线状态变更 + +**上报数据** + +| 字段 | 类型 | 可能的值 | 说明 | +| ------------- | ------ | -------------- | -------- | +| `post_type` | string | `notice` | 上报类型 | +| `notice_type` | string | `client_status` | 消息类型 | +| `client` | Device | | 客户端信息 | +| `online` | bool | | 当前是否在线 | + +### 精华消息 + +**上报数据** + +| 字段 | 类型 | 可能的值 | 说明 | +| ------------- | ------ | -------------- | -------- | +| `post_type` | string | `notice` | 上报类型 | +| `notice_type` | string | `essence` | 消息类型 | +| `sub_type` | string | `add`,`delete` | 添加为`add`,移出为`delete` | +| `sender_id` | int64 | | 消息发送者ID | +| `operator_id` | int64 | | 操作者ID | +| `message_id` | int32 | | 消息ID | diff --git a/docs/file.md b/docs/file.md index 4fe926e..987a49d 100644 --- a/docs/file.md +++ b/docs/file.md @@ -5,7 +5,7 @@ go-cqhttp 默认生成的文件树如下所示: ```` . ├── go-cqhttp -├── config.json +├── config.hjson ├── device.json ├── logs │ └── xx-xx-xx.log @@ -18,7 +18,7 @@ go-cqhttp 默认生成的文件树如下所示: | 文件 | 用途 | | ----------- | ------------------- | | go-cqhttp | go-cqhttp可执行文件 | -| config.json | 运行配置文件 | +| config.hjson | 运行配置文件 | | device.json | 虚拟设备配置文件 | | logs | 日志存放目录 | | data | 数据目录 | diff --git a/docs/quick_start.md b/docs/quick_start.md index ebd81f6..af91dcb 100644 --- a/docs/quick_start.md +++ b/docs/quick_start.md @@ -1,3 +1,155 @@ # 开始 -欢迎来到 go-cqhttp 文档 \ No newline at end of file +欢迎来到 go-cqhttp 文档 目前还在咕 + +# 基础教程 +## 下载 +从[release](https://github.com/Mrs4s/go-cqhttp/releases)界面下载最新版本的go-cqhttp + +- Windows下32位文件为 `go-cqhttp-v*-windows-386.zip` +- Windows下64位文件为 `go-cqhttp-v*-windows-amd64.zip` +- Windows下arm用(如使用高通CPU的笔记本)文件为 `go-cqhttp-v*-windows-arm.zip` +- Linux下32位文件为 `go-cqhttp-v*-linux-386.tar.gz` +- Linux下64位文件为 `go-cqhttp-v*-linux-amd64.tar.gz` +- Linux下arm用(如树莓派)文件为 `go-cqhttp-v*-linux-arm.tar.gz` +- MD5文件为 `*.md5` ,用于校验文件完整性 +- 如果没有你所使用的系统版本或者希望自己构建,请移步[进阶指南-如何自己构建](#如何自己构建) + +## 解压 + +- Windows下请使用自己熟悉的解压软件自行解压 +- Linux下在命令行中输入 `tar -xzvf [文件名]` + +## 使用 + +### Windows + +#### 标准方法 + +1. 双击`go-cqhttp.exe`此时将提示 +``` +[WARNING]: 尝试加载配置文件 config.hjson 失败: 文件不存在 +[INFO]: 默认配置文件已生成,请编辑 config.hjson 后重启程序. +``` +2. 参照[config.md](https://github.com/Mrs4s/go-cqhttp/blob/master/docs/config.md)和你所用到的插件的 `README` 填入参数 +3. 再次双击`go-cqhttp.exe` +``` +[INFO]: 登录成功 欢迎使用: balabala +``` + +如出现需要认证的信息,请自行认证设备。 + +此时,基础配置完成 + +#### 懒人法 + +1. [下载包含Windows.bat的zip](https://github.com/fkx4-p/go-cqhttp-lazy/archive/master.zip) +2. 解压 +3. 将`Windows.bat`复制/剪切到**go-cqhttp**文件夹 +4. 双击运行 + +效果如下 + +``` +QQ account: +[QQ账号] +QQ password: +[QQ密码] +enable http?(Y/n) +[是否开启http(y/n),默认开启] +enable ws?(Y/n) +[是否开启websocket(y/n),默认开启] +请按任意键继续. . . +``` + +5. 双击`go-cqhttp.exe` +``` +[INFO]: 登录成功 欢迎使用: balabala +``` + +如出现需要认证的信息,请自行认证设备。 + +此时,基础配置完成 + +### Linux + +#### 标准方法 + +1. 打开一个命令行/ssh +2. `cd`到解压目录 +3. 输入 `./go-cqhttp`,`Enter`运行 ,此时将提示 +``` +[WARNING]: 尝试加载配置文件 config.hjson 失败: 文件不存在 +[INFO]: 默认配置文件已生成,请编辑 config.hjson 后重启程序. +``` + +4. 参照[config.md](https://github.com/Mrs4s/go-cqhttp/blob/master/docs/config.md)和你所用到的插件的 `README` 填入参数 +5. 再次输入 `./go-cqhttp`,`Enter`运行 +``` +[INFO]: 登录成功 欢迎使用: balabala +``` + +如出现需要认证的信息,请自行认证设备。 + +此时,基础配置完成 + +#### 懒人法 + +暂时咕咕咕了 + +## 验证http是否成功配置 + +此时,如果在本地开启的服务器,可以在浏览器输入`http://127.0.0.1:5700/send_private_msg?user_id=[接收者qq号]&message=[发送的信息]`来发送一条测试信息 + +如果出现`{"data":{"message_id":balabala},"retcode":0,"status":"ok"}`则证明已经成功配置HTTP + +*注:请 连 中括号 也替换掉,就像这样:*`http://127.0.0.1:5700/send_private_msg?user_id=10001&message=ffeecoishp` + +# 进阶指南 + +## 跳过启动的五秒延时 + +使用命令行参数 `faststart`即可跳过启动的五秒钟延时,例如 + +``` +.\go-cqhttp.exe faststart +``` + +## 如何自己构建 + +1. [下载源码](https://github.com/Mrs4s/go-cqhttp/archive/master.zip)并解压 || 使用`git clone https://github.com/Mrs4s/go-cqhttp.git`来拉取 + +2. [下载golang binary release](https://golang.google.cn/dl/)并安装或者[自己构建golang](https://golang.google.cn/doc/install/source) + +3. 在`cmd`或Linux命令行中,`cd`到目录中 + +4. 输入`go build -ldflags "-s -w -extldflags '-static'"`,`Enter`运行 + +*注:可以使用*`go env -w GOPROXY=https://goproxy.cn,direct`*来加速国内依赖安装速度* + +## 更新 + +### 方法一 + +从[release](https://github.com/Mrs4s/go-cqhttp/releases)界面下载最新版本的go-cqhttp +并替换之前的版本 + +### 方法二 + +使用更新参数,在命令行中打开go-cqhttp所在目录 +#### windows +输入指令 +`go-cqhttp.exe update` + +如果在国内连接github下载速度可能很慢,可以使用镜像源下载 + +`go-cqhttp.exe update https://github.rc1844.workers.dev` + +几个可用的镜像源 +- `https://hub.fastgit.org` +- `https://github.com.cnpmjs.org` +- `https://github.bajins.com` +- `https://github.rc1844.workers.dev` + +#### linux +方法与windows基本一致,将 `go-cqhttp.exe` 替换为 `./go-cqhttp`即可 diff --git a/docs/slider.md b/docs/slider.md new file mode 100644 index 0000000..c29593c --- /dev/null +++ b/docs/slider.md @@ -0,0 +1,74 @@ +# 滑块验证码 + +由于TX最新的限制, 所有协议在陌生设备/IP登录时都有可能被要求通过滑块验证码, 否则将会出现 `当前上网环境异常` 的错误. 目前我们准备了两个临时方案应对该验证码. + +> 如果您有一台运行Windows的PC/Server 并且不会抓包操作, 我们建议直接使用方案B + +## 方案A: 自行抓包 + +由于滑块验证码和QQ本体的协议独立, 我们无法直接处理并提交. 需要在浏览器通过后抓包并获取 `Ticket` 提交. + +该方案为具体的抓包教程, 如果您已经知道如何在浏览器中抓包. 可以略过接下来的文档并直接抓取 `cap_union_new_verify` 的返回值, 提取 `Ticket` 并在命令行提交. + +首先获取滑块验证码的地址, 并在浏览器中打开. 这里以 *Microsoft Edge* 浏览器为例, *Chrome* 同理. + +![image.png](https://i.loli.net/2020/12/27/yXdomOnQ8tkauMe.png) + +首先选择 `1` 并提取链接在浏览器中打开 + +![image.png](https://i.loli.net/2020/12/27/HYhmZv1wARMV7Uq.png) + +![image.png](https://i.loli.net/2020/12/27/otk9Hz7lBCaRFMV.png) + +此时不要滑动验证码, 首先按下 `F12` (键盘右上角退格键上方) 打开 *开发者工具* + +![image.png](https://i.loli.net/2020/12/27/JDioadLPwcKWpt1.png) + +点击 `Network` 选项卡 (在某些浏览器它可能叫做 `网络`) + +![image.png](https://i.loli.net/2020/12/27/qEzTB5jrDZUWSwp.png) + +点开 `Filter` (箭头) 按钮以确定您能看到下面的工具栏, 勾选 `Preserve log`(红框) + +此时可以滑动并通过验证码 + +![image.png](https://i.loli.net/2020/12/27/Id4hxzyDprQuF2G.png) + +回到 *开发者工具*, 我们可以看到已经有了一个请求. + +![image.png](https://i.loli.net/2020/12/27/3C6Y2XVKBRv1z9E.png) + +此时如果有多个请求, 请不要慌张. 看到上面的 `Filter` 没? 此时在 `Filter` 输入框中输入 `cap_union_new`, 就应该只剩一个请求了. + +然后点击该请求. 点开 `Preview` 选项卡 (箭头): + +![image.png](https://i.loli.net/2020/12/27/P1VtxRWpjY8524Z.png) + +此时就能看到一个标准的 `JSON`, 复制 `ticket` 字段并回到 `go-cqhttp` 粘贴. 即可通过滑块验证. + +如果您看到这里还是不会如何操作, 没关系! 我们还准备了方案B. + +## 方案B: 使用专用工具 + +此方案需要您有一台可以操作的 `Windows` 电脑. + +首先下载工具: [蓝奏云](https://wws.lanzous.com/i2vn0jrofte) [Google Drive](https://drive.google.com/file/d/1peMDHqgP8AgWBVp5vP-cfhcGrb2ksSrE/view?usp=sharing) + +解压并打开工具: + +![image.png](https://i.loli.net/2020/12/27/winG4SkxhgLoNDZ.png) + +打开 `go-cqhttp` 并选择 `2`: + +![image.png](https://i.loli.net/2020/12/27/yXdomOnQ8tkauMe.png) + +复制 `ID` 并前往工具粘贴: + +![image.png](https://i.loli.net/2020/12/27/fIwXx5nN9r8Zbc7.png) + +![image.png](https://i.loli.net/2020/12/27/WZsTCyGwSjc9mb5.png) + +点击 `OK` 并处理滑块, 完成即可登录成功. (OK可能反应稍微慢点, 请不要多次点击) + +![image.png](https://i.loli.net/2020/12/27/UnvAuxreijYzgLC.png) + diff --git a/global/codec.go b/global/codec.go new file mode 100644 index 0000000..1a6b35f --- /dev/null +++ b/global/codec.go @@ -0,0 +1,63 @@ +package global + +import ( + "crypto/md5" + "fmt" + "io/ioutil" + "os/exec" + "path" + + "github.com/Mrs4s/go-cqhttp/global/codec" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +var useSilkCodec = true + +//InitCodec 初始化Silk编码器 +func InitCodec() { + log.Info("正在加载silk编码器...") + err := codec.Init() + if err != nil { + log.Error(err) + useSilkCodec = false + } +} + +//EncoderSilk 将音频编码为Silk +func EncoderSilk(data []byte) ([]byte, error) { + if !useSilkCodec { + return nil, errors.New("no silk encoder") + } + h := md5.New() + _, err := h.Write(data) + if err != nil { + return nil, errors.Wrap(err, "calc md5 failed") + } + tempName := fmt.Sprintf("%x", h.Sum(nil)) + if silkPath := path.Join("data/cache", tempName+".silk"); PathExists(silkPath) { + return ioutil.ReadFile(silkPath) + } + slk, err := codec.EncodeToSilk(data, tempName, true) + if err != nil { + return nil, errors.Wrap(err, "encode silk failed") + } + return slk, nil +} + +//EncodeMP4 将给定视频文件编码为MP4 +func EncodeMP4(src string, dst string) error { // -y 覆盖文件 + cmd1 := exec.Command("ffmpeg", "-i", src, "-y", "-c", "copy", "-map", "0", dst) + err := cmd1.Run() + if err != nil { + cmd2 := exec.Command("ffmpeg", "-i", src, "-y", "-c:v", "h264", "-c:a", "mp3", dst) + return errors.Wrap(cmd2.Run(), "convert mp4 failed") + } + return err +} + +//ExtractCover 获取给定视频文件的Cover +func ExtractCover(src string, target string) error { + cmd := exec.Command("ffmpeg", "-i", src, "-y", "-r", "1", "-f", "image2", target) + return errors.Wrap(cmd.Run(), "extract video cover failed") +} diff --git a/global/codec/codec.go b/global/codec/codec.go new file mode 100644 index 0000000..9b6115c --- /dev/null +++ b/global/codec/codec.go @@ -0,0 +1,97 @@ +// +build linux windows darwin +// +build 386 amd64 arm arm64 + +package codec + +import ( + "github.com/pkg/errors" + "io/ioutil" + "net/http" + "os" + "os/exec" + "path" + "runtime" +) + +const ( + silkCachePath = "data/cache" + encoderPath = "codec" +) + +func downloadCodec(url string) (err error) { + resp, err := http.Get(url) + if err != nil { + return + } + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return + } + err = ioutil.WriteFile(getEncoderFilePath(), body, os.ModePerm) + return +} + +func getEncoderFilePath() string { + encoderFile := path.Join(encoderPath, runtime.GOOS+"-"+runtime.GOARCH+"-encoder") + if runtime.GOOS == "windows" { + encoderFile = encoderFile + ".exe" + } + return encoderFile +} + +//Init 下载Silk编码器 +func Init() error { + if !fileExist(silkCachePath) { + _ = os.MkdirAll(silkCachePath, os.ModePerm) + } + if !fileExist(encoderPath) { + _ = os.MkdirAll(encoderPath, os.ModePerm) + } + p := getEncoderFilePath() + if !fileExist(p) { + if err := downloadCodec("https://cdn.jsdelivr.net/gh/wdvxdr1123/tosilk/codec/" + runtime.GOOS + "-" + runtime.GOARCH + "-encoder"); err != nil { + return errors.New("下载依赖失败") + } + } + return nil +} + +//EncodeToSilk 将音频编码为Silk +func EncodeToSilk(record []byte, tempName string, useCache bool) ([]byte, error) { + // 1. 写入缓存文件 + rawPath := path.Join(silkCachePath, tempName+".wav") + err := ioutil.WriteFile(rawPath, record, os.ModePerm) + if err != nil { + return nil, errors.Wrap(err, "write temp file error") + } + defer os.Remove(rawPath) + + // 2.转换pcm + pcmPath := path.Join(silkCachePath, tempName+".pcm") + cmd := exec.Command("ffmpeg", "-i", rawPath, "-f", "s16le", "-ar", "24000", "-ac", "1", pcmPath) + if err = cmd.Run(); err != nil { + return nil, errors.Wrap(err, "convert pcm file error") + } + defer os.Remove(pcmPath) + + // 3. 转silk + silkPath := path.Join(silkCachePath, tempName+".silk") + cmd = exec.Command(getEncoderFilePath(), pcmPath, silkPath, "-rate", "24000", "-quiet", "-tencent") + if err = cmd.Run(); err != nil { + return nil, errors.Wrap(err, "convert silk file error") + } + if !useCache { + defer os.Remove(silkPath) + } + return ioutil.ReadFile(silkPath) +} + +// FileExist 检查文件是否存在 +func fileExist(path string) bool { + if runtime.GOOS == "windows" { + path = path + ".exe" + } + _, err := os.Lstat(path) + return !os.IsNotExist(err) +} diff --git a/global/codec/codec_unsupportedarch.go b/global/codec/codec_unsupportedarch.go new file mode 100644 index 0000000..b0e9890 --- /dev/null +++ b/global/codec/codec_unsupportedarch.go @@ -0,0 +1,15 @@ +// +build !386,!arm64,!amd64,!arm + +package codec + +import "errors" + +//Init 下载silk编码器 +func Init() error { + return errors.New("Unsupport arch now") +} + +//EncodeToSilk 将音频编码为Silk +func EncodeToSilk(record []byte, tempName string, useCache bool) ([]byte, error) { + return nil, errors.New("Unsupport arch now") +} diff --git a/global/codec/codec_unsupportedos.go b/global/codec/codec_unsupportedos.go new file mode 100644 index 0000000..9428fe0 --- /dev/null +++ b/global/codec/codec_unsupportedos.go @@ -0,0 +1,15 @@ +// +build !windows,!linux,!darwin + +package codec + +import "errors" + +//Init 下载silk编码器 +func Init() error { + return errors.New("not support now") +} + +//EncodeToSilk 将音频编码为Silk +func EncodeToSilk(record []byte, tempName string, useCache bool) ([]byte, error) { + return nil, errors.New("not support now") +} diff --git a/global/config.go b/global/config.go index 31ed776..0abf933 100644 --- a/global/config.go +++ b/global/config.go @@ -1,15 +1,145 @@ package global import ( - "encoding/json" "os" "strconv" "time" + "github.com/hjson/hjson-go" + jsoniter "github.com/json-iterator/go" log "github.com/sirupsen/logrus" ) -type JsonConfig struct { +var json = jsoniter.ConfigCompatibleWithStandardLibrary + +//DefaultConfigWithComments 为go-cqhttp的默认配置文件 +var DefaultConfigWithComments = ` +/* + go-cqhttp 默认配置文件 +*/ + +{ + // QQ号 + uin: 0 + // QQ密码 + password: "" + // 是否启用密码加密 + encrypt_password: false + // 加密后的密码, 如未启用密码加密将为空, 请勿随意修改. + password_encrypted: "" + // 是否启用内置数据库 + // 启用将会增加10-20MB的内存占用和一定的磁盘空间 + // 关闭将无法使用 撤回 回复 get_msg 等上下文相关功能 + enable_db: true + // 访问密钥, 强烈推荐在公网的服务器设置 + access_token: "" + // 重连设置 + relogin: { + // 是否启用自动重连 + // 如不启用掉线后将不会自动重连 + enabled: true + // 重连延迟, 单位秒 + relogin_delay: 3 + // 最大重连次数, 0为无限制 + max_relogin_times: 0 + } + // API限速设置 + // 该设置为全局生效 + // 原 cqhttp 虽然启用了 rate_limit 后缀, 但是基本没插件适配 + // 目前该限速设置为令牌桶算法, 请参考: + // https://baike.baidu.com/item/%E4%BB%A4%E7%89%8C%E6%A1%B6%E7%AE%97%E6%B3%95/6597000?fr=aladdin + _rate_limit: { + // 是否启用限速 + enabled: false + // 令牌回复频率, 单位秒 + frequency: 1 + // 令牌桶大小 + bucket_size: 1 + } + // 是否忽略无效的CQ码 + // 如果为假将原样发送 + ignore_invalid_cqcode: false + // 是否强制分片发送消息 + // 分片发送将会带来更快的速度 + // 但是兼容性会有些问题 + force_fragmented: false + // 心跳频率, 单位秒 + // -1 为关闭心跳 + heartbeat_interval: 0 + // HTTP设置 + http_config: { + // 是否启用正向HTTP服务器 + enabled: true + // 服务端监听地址 + host: 0.0.0.0 + // 服务端监听端口 + port: 5700 + // 反向HTTP超时时间, 单位秒 + // 最小值为5,小于5将会忽略本项设置 + timeout: 0 + // 反向HTTP POST地址列表 + // 格式: + // { + // 地址: secret + // } + post_urls: {} + } + // 正向WS设置 + ws_config: { + // 是否启用正向WS服务器 + enabled: true + // 正向WS服务器监听地址 + host: 0.0.0.0 + // 正向WS服务器监听端口 + port: 6700 + } + // 反向WS设置 + ws_reverse_servers: [ + // 可以添加多个反向WS推送 + { + // 是否启用该推送 + enabled: false + // 反向WS Universal 地址 + // 注意 设置了此项地址后下面两项将会被忽略 + // 留空请使用 "" + reverse_url: ws://you_websocket_universal.server + // 反向WS API 地址 + reverse_api_url: ws://you_websocket_api.server + // 反向WS Event 地址 + reverse_event_url: ws://you_websocket_event.server + // 重连间隔 单位毫秒 + reverse_reconnect_interval: 3000 + } + ] + // 上报数据类型 + // 可选: string array + post_message_format: string + // 是否使用服务器下发的新地址进行重连 + // 注意, 此设置可能导致在海外服务器上连接情况更差 + use_sso_address: false + // 是否启用 DEBUG + debug: false + // 日志等级 trace,debug,info,warn,error + log_level: "info" + // WebUi 设置 + web_ui: { + // 是否启用 WebUi + enabled: true + // 监听地址 + host: 127.0.0.1 + // 监听端口 + web_ui_port: 9999 + // 是否接收来自web的输入 + web_input: false + } +} +` + +//PasswordHash 存储QQ密码哈希供登录使用 +var PasswordHash [16]byte + +//JSONConfig Config对应的结构体 +type JSONConfig struct { Uin int64 `json:"uin"` Password string `json:"password"` EncryptPassword bool `json:"encrypt_password"` @@ -28,35 +158,41 @@ type JsonConfig struct { } `json:"_rate_limit"` IgnoreInvalidCQCode bool `json:"ignore_invalid_cqcode"` ForceFragmented bool `json:"force_fragmented"` + FixURL bool `json:"fix_url"` + ProxyRewrite string `json:"proxy_rewrite"` HeartbeatInterval time.Duration `json:"heartbeat_interval"` - HttpConfig *GoCQHttpConfig `json:"http_config"` - WSConfig *GoCQWebsocketConfig `json:"ws_config"` - ReverseServers []*GoCQReverseWebsocketConfig `json:"ws_reverse_servers"` + HTTPConfig *GoCQHTTPConfig `json:"http_config"` + WSConfig *GoCQWebSocketConfig `json:"ws_config"` + ReverseServers []*GoCQReverseWebSocketConfig `json:"ws_reverse_servers"` PostMessageFormat string `json:"post_message_format"` + UseSSOAddress bool `json:"use_sso_address"` Debug bool `json:"debug"` LogLevel string `json:"log_level"` + WebUI *GoCQWebUI `json:"web_ui"` } -type CQHttpApiConfig struct { +//CQHTTPAPIConfig HTTPAPI对应的Config结构体 +type CQHTTPAPIConfig struct { Host string `json:"host"` Port uint16 `json:"port"` - UseHttp bool `json:"use_http"` + UseHTTP bool `json:"use_http"` WSHost string `json:"ws_host"` WSPort uint16 `json:"ws_port"` UseWS bool `json:"use_ws"` - WSReverseUrl string `json:"ws_reverse_url"` - WSReverseApiUrl string `json:"ws_reverse_api_url"` - WSReverseEventUrl string `json:"ws_reverse_event_url"` + WSReverseURL string `json:"ws_reverse_url"` + WSReverseAPIURL string `json:"ws_reverse_api_url"` + WSReverseEventURL string `json:"ws_reverse_event_url"` WSReverseReconnectInterval uint16 `json:"ws_reverse_reconnect_interval"` WSReverseReconnectOnCode1000 bool `json:"ws_reverse_reconnect_on_code_1000"` UseWsReverse bool `json:"use_ws_reverse"` - PostUrl string `json:"post_url"` + PostURL string `json:"post_url"` AccessToken string `json:"access_token"` Secret string `json:"secret"` PostMessageFormat string `json:"post_message_format"` } -type GoCQHttpConfig struct { +//GoCQHTTPConfig 正向HTTP对应config结构体 +type GoCQHTTPConfig struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port uint16 `json:"port"` @@ -64,22 +200,33 @@ type GoCQHttpConfig struct { PostUrls map[string]string `json:"post_urls"` } -type GoCQWebsocketConfig struct { +//GoCQWebSocketConfig 正向WebSocket对应Config结构体 +type GoCQWebSocketConfig struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port uint16 `json:"port"` } -type GoCQReverseWebsocketConfig struct { +//GoCQReverseWebSocketConfig 反向WebSocket对应Config结构体 +type GoCQReverseWebSocketConfig struct { Enabled bool `json:"enabled"` - ReverseUrl string `json:"reverse_url"` - ReverseApiUrl string `json:"reverse_api_url"` - ReverseEventUrl string `json:"reverse_event_url"` + ReverseURL string `json:"reverse_url"` + ReverseAPIURL string `json:"reverse_api_url"` + ReverseEventURL string `json:"reverse_event_url"` ReverseReconnectInterval uint16 `json:"reverse_reconnect_interval"` } -func DefaultConfig() *JsonConfig { - return &JsonConfig{ +//GoCQWebUI WebUI对应Config结构体 +type GoCQWebUI struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + WebUIPort uint64 `json:"web_ui_port"` + WebInput bool `json:"web_input"` +} + +//DefaultConfig 返回一份默认配置对应结构体 +func DefaultConfig() *JSONConfig { + return &JSONConfig{ EnableDB: true, ReLogin: struct { Enabled bool `json:"enabled"` @@ -100,51 +247,67 @@ func DefaultConfig() *JsonConfig { BucketSize: 1, }, PostMessageFormat: "string", - ForceFragmented: true, - HttpConfig: &GoCQHttpConfig{ + ForceFragmented: false, + HTTPConfig: &GoCQHTTPConfig{ Enabled: true, Host: "0.0.0.0", Port: 5700, PostUrls: map[string]string{}, }, - WSConfig: &GoCQWebsocketConfig{ + WSConfig: &GoCQWebSocketConfig{ Enabled: true, Host: "0.0.0.0", Port: 6700, }, - ReverseServers: []*GoCQReverseWebsocketConfig{ + ReverseServers: []*GoCQReverseWebSocketConfig{ { Enabled: false, - ReverseUrl: "ws://you_websocket_universal.server", - ReverseApiUrl: "ws://you_websocket_api.server", - ReverseEventUrl: "ws://you_websocket_event.server", + ReverseURL: "ws://you_websocket_universal.server", + ReverseAPIURL: "ws://you_websocket_api.server", + ReverseEventURL: "ws://you_websocket_event.server", ReverseReconnectInterval: 3000, }, }, + WebUI: &GoCQWebUI{ + Enabled: true, + Host: "127.0.0.1", + WebInput: false, + WebUIPort: 9999, + }, } } -func Load(p string) *JsonConfig { +//LoadConfig 加载配置文件 +func LoadConfig(p string) *JSONConfig { if !PathExists(p) { log.Warnf("尝试加载配置文件 %v 失败: 文件不存在", p) return nil } - c := JsonConfig{} - err := json.Unmarshal([]byte(ReadAllText(p)), &c) + var dat map[string]interface{} + var c = JSONConfig{} + err := hjson.Unmarshal([]byte(ReadAllText(p)), &dat) + if err == nil { + b, _ := json.Marshal(dat) + err = json.Unmarshal(b, &c) + } if err != nil { log.Warnf("尝试加载配置文件 %v 时出现错误: %v", p, err) log.Infoln("原文件已备份") - os.Rename(p, p+".backup"+strconv.FormatInt(time.Now().Unix(), 10)) + _ = os.Rename(p, p+".backup"+strconv.FormatInt(time.Now().Unix(), 10)) return nil } return &c } -func (c *JsonConfig) Save(p string) error { - data, err := json.MarshalIndent(c, "", "\t") +//Save 写入配置文件至path +func (c *JSONConfig) Save(path string) error { + data, err := hjson.MarshalWithOptions(c, hjson.EncoderOptions{ + Eol: "\n", + BracesSameLine: true, + IndentBy: " ", + }) if err != nil { return err } - WriteAllText(p, string(data)) - return nil + return WriteAllText(path, string(data)) } diff --git a/global/filter.go b/global/filter.go index b2ac4cb..fdcf5fb 100644 --- a/global/filter.go +++ b/global/filter.go @@ -1,24 +1,61 @@ package global import ( - log "github.com/sirupsen/logrus" - "github.com/tidwall/gjson" + "fmt" "io/ioutil" "regexp" "strings" + + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" ) -type Filter interface { - Eval(payload gjson.Result) bool +//MSG 消息Map +type MSG map[string]interface{} + +//Get 尝试从消息Map中取出key为s的值,若不存在则返回MSG{} +// +//若所给key对应的值的类型是global.MSG,则返回此值 +// +//若所给key对应值的类型不是global.MSG,则返回MSG{"__str__": Val} +func (m MSG) Get(s string) MSG { + if v, ok := m[s]; ok { + if msg, ok := v.(MSG); ok { + return msg + } + return MSG{"__str__": v} // 用这个名字应该没问题吧 + } + return nil // 不存在为空 } -type OperationNode struct { +//String 将消息Map转化为String。若Map存在key "__str__",则返回此key对应的值,否则将输出整张消息Map对应的JSON字符串 +func (m MSG) String() string { + if m == nil { + return "" // 空 JSON + } + if str, ok := m["__str__"]; ok { + if str == nil { + return "" // 空 JSON + } + return fmt.Sprint(str) + } + str, _ := json.MarshalToString(m) + return str +} + +//Filter 定义了一个消息上报过滤接口 +type Filter interface { + Eval(payload MSG) bool +} + +type operationNode struct { key string filter Filter } +//NotOperator 定义了过滤器中Not操作符 type NotOperator struct { - operand_ Filter + operand Filter } func notOperatorConstruct(argument gjson.Result) *NotOperator { @@ -26,16 +63,18 @@ func notOperatorConstruct(argument gjson.Result) *NotOperator { panic("the argument of 'not' operator must be an object") } op := new(NotOperator) - op.operand_ = Generate("and", argument) + op.operand = Generate("and", argument) return op } -func (notOperator NotOperator) Eval(payload gjson.Result) bool { - return !(notOperator.operand_).Eval(payload) +//Eval 对payload执行Not过滤 +func (op *NotOperator) Eval(payload MSG) bool { + return !op.operand.Eval(payload) } +//AndOperator 定义了过滤器中And操作符 type AndOperator struct { - operands []OperationNode + operands []operationNode } func andOperatorConstruct(argument gjson.Result) *AndOperator { @@ -50,26 +89,27 @@ func andOperatorConstruct(argument gjson.Result) *AndOperator { // "bar": "baz" // } opKey := key.Str[1:] - op.operands = append(op.operands, OperationNode{"", Generate(opKey, value)}) + op.operands = append(op.operands, operationNode{"", Generate(opKey, value)}) } else if value.IsObject() { // is an normal key with an object as the value // "foo": { // ".bar": "baz" // } opKey := key.String() - op.operands = append(op.operands, OperationNode{opKey, Generate("and", value)}) + op.operands = append(op.operands, operationNode{opKey, Generate("and", value)}) } else { // is an normal key with a non-object as the value // "foo": "bar" opKey := key.String() - op.operands = append(op.operands, OperationNode{opKey, Generate("eq", value)}) + op.operands = append(op.operands, operationNode{opKey, Generate("eq", value)}) } return true }) return op } -func (andOperator *AndOperator) Eval(payload gjson.Result) bool { +//Eval 对payload执行And过滤 +func (andOperator *AndOperator) Eval(payload MSG) bool { res := true for _, operand := range andOperator.operands { @@ -82,13 +122,14 @@ func (andOperator *AndOperator) Eval(payload gjson.Result) bool { res = res && operand.filter.Eval(val) } - if res == false { + if !res { break } } return res } +//OrOperator 定义了过滤器中Or操作符 type OrOperator struct { operands []Filter } @@ -105,48 +146,54 @@ func orOperatorConstruct(argument gjson.Result) *OrOperator { return op } -func (orOperator OrOperator) Eval(payload gjson.Result) bool { +//Eval 对payload执行Or过滤 +func (op *OrOperator) Eval(payload MSG) bool { res := false - for _, operand := range orOperator.operands { + for _, operand := range op.operands { res = res || operand.Eval(payload) - - if res == true { + if res { break } } return res } +//EqualOperator 定义了过滤器中Equal操作符 type EqualOperator struct { - value gjson.Result + operand string } func equalOperatorConstruct(argument gjson.Result) *EqualOperator { op := new(EqualOperator) - op.value = argument + op.operand = argument.String() return op } -func (equalOperator EqualOperator) Eval(payload gjson.Result) bool { - return payload.String() == equalOperator.value.String() +//Eval 对payload执行Equal过滤 +func (op *EqualOperator) Eval(payload MSG) bool { + return payload.String() == op.operand } +//NotEqualOperator 定义了过滤器中NotEqual操作符 type NotEqualOperator struct { - value gjson.Result + operand string } func notEqualOperatorConstruct(argument gjson.Result) *NotEqualOperator { op := new(NotEqualOperator) - op.value = argument + op.operand = argument.String() return op } -func (notEqualOperator NotEqualOperator) Eval(payload gjson.Result) bool { - return !(payload.String() == notEqualOperator.value.String()) +//Eval 对payload执行NotEqual过滤 +func (op *NotEqualOperator) Eval(payload MSG) bool { + return !(payload.String() == op.operand) } +//InOperator 定义了过滤器中In操作符 type InOperator struct { - operand gjson.Result + operandString string + operandArray []string } func inOperatorConstruct(argument gjson.Result) *InOperator { @@ -154,22 +201,33 @@ func inOperatorConstruct(argument gjson.Result) *InOperator { panic("the argument of 'in' operator must be an array or a string") } op := new(InOperator) - op.operand = argument + if argument.IsArray() { + op.operandArray = []string{} + argument.ForEach(func(_, value gjson.Result) bool { + op.operandArray = append(op.operandArray, value.String()) + return true + }) + } else { + op.operandString = argument.String() + } return op } -func (inOperator InOperator) Eval(payload gjson.Result) bool { - if inOperator.operand.IsArray() { - res := false - inOperator.operand.ForEach(func(key, value gjson.Result) bool { - res = res || value.String() == payload.String() - return true - }) - return res +//Eval 对payload执行In过滤 +func (op *InOperator) Eval(payload MSG) bool { + payloadStr := payload.String() + if op.operandArray != nil { + for _, value := range op.operandArray { + if value == payloadStr { + return true + } + } + return false } - return strings.Contains(inOperator.operand.String(), payload.String()) + return strings.Contains(op.operandString, payloadStr) } +//ContainsOperator 定义了过滤器中Contains操作符 type ContainsOperator struct { operand string } @@ -183,15 +241,14 @@ func containsOperatorConstruct(argument gjson.Result) *ContainsOperator { return op } -func (containsOperator ContainsOperator) Eval(payload gjson.Result) bool { - if payload.IsObject() || payload.IsArray() { - return false - } - return strings.Contains(payload.String(), containsOperator.operand) +//Eval 对payload执行Contains过滤 +func (op *ContainsOperator) Eval(payload MSG) bool { + return strings.Contains(payload.String(), op.operand) } +//RegexOperator 定义了过滤器中Regex操作符 type RegexOperator struct { - regex string + regex *regexp.Regexp } func regexOperatorConstruct(argument gjson.Result) *RegexOperator { @@ -199,15 +256,17 @@ func regexOperatorConstruct(argument gjson.Result) *RegexOperator { panic("the argument of 'regex' operator must be a string") } op := new(RegexOperator) - op.regex = argument.String() + op.regex = regexp.MustCompile(argument.String()) return op } -func (containsOperator RegexOperator) Eval(payload gjson.Result) bool { - matched, _ := regexp.MatchString(containsOperator.regex, payload.String()) +//Eval 对payload执行RegexO过滤 +func (op *RegexOperator) Eval(payload MSG) bool { + matched := op.regex.MatchString(payload.String()) return matched } +//Generate 根据给定操作符名opName及操作符参数argument创建一个过滤器实例 func Generate(opName string, argument gjson.Result) Filter { switch opName { case "not": @@ -231,8 +290,10 @@ func Generate(opName string, argument gjson.Result) Filter { } } -var EventFilter = new(Filter) +//EventFilter 初始化一个nil过滤器 +var EventFilter Filter = nil +//BootFilter 启动事件过滤器 func BootFilter() { defer func() { if e := recover(); e != nil { @@ -246,6 +307,6 @@ func BootFilter() { if err != nil { panic(err) } else { - *EventFilter = Generate("and", gjson.ParseBytes(f)) + EventFilter = Generate("and", gjson.ParseBytes(f)) } } diff --git a/global/fs.go b/global/fs.go index f3dc6b0..a6760a6 100644 --- a/global/fs.go +++ b/global/fs.go @@ -1,47 +1,253 @@ package global import ( + "bufio" "bytes" + "compress/bzip2" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" "io/ioutil" + "net" + "net/url" "os" "path" + "path/filepath" + "runtime" + "strconv" + "strings" + "github.com/kardianos/osext" + + "github.com/dustin/go-humanize" log "github.com/sirupsen/logrus" ) -var ( - IMAGE_PATH = path.Join("data", "images") - VOICE_PATH = path.Join("data", "voices") - VIDEO_PATH = path.Join("data", "videos") - CACHE_PATH = path.Join("data", "cache") - - HEADER_AMR = []byte("#!AMR") - HEADER_SILK = []byte("\x02#!SILK_V3") +const ( + //ImagePath go-cqhttp使用的图片缓存目录 + ImagePath = "data/images" + //ImagePathOld 兼容旧版go-cqhtto使用的图片缓存目录 + ImagePathOld = "data/image" + //VoicePath go-cqhttp使用的语音缓存目录 + VoicePath = "data/voices" + //VoicePathOld 兼容旧版go-cqhtto使用的语音缓存目录 + VoicePathOld = "data/record" + //VideoPath go-cqhttp使用的视频缓存目录 + VideoPath = "data/videos" + //CachePath go-cqhttp使用的缓存目录 + CachePath = "data/cache" ) +var ( + //ErrSyntax Path语法错误时返回的错误 + ErrSyntax = errors.New("syntax error") + //HeaderAmr AMR文件头 + HeaderAmr = []byte("#!AMR") + //HeaderSilk Silkv3文件头 + HeaderSilk = []byte("\x02#!SILK_V3") +) + +//PathExists 判断给定path是否存在 func PathExists(path string) bool { _, err := os.Stat(path) return err == nil || os.IsExist(err) } +//ReadAllText 读取给定path对应文件,无法读取时返回空值 func ReadAllText(path string) string { b, err := ioutil.ReadFile(path) if err != nil { + log.Error(err) return "" } return string(b) } -func WriteAllText(path, text string) { - _ = ioutil.WriteFile(path, []byte(text), 0644) +//WriteAllText 将给定text写入给定path +func WriteAllText(path, text string) error { + return ioutil.WriteFile(path, []byte(text), 0644) } +//Check 检测err是否为nil func Check(err error) { if err != nil { log.Fatalf("遇到错误: %v", err) } } +//IsAMRorSILK 判断给定文件是否为Amr或Silk格式 func IsAMRorSILK(b []byte) bool { - return bytes.HasPrefix(b, HEADER_AMR) || bytes.HasPrefix(b, HEADER_SILK) + return bytes.HasPrefix(b, HeaderAmr) || bytes.HasPrefix(b, HeaderSilk) +} + +//FindFile 从给定的File寻找文件,并返回文件byte数组。File是一个合法的URL。Path为文件寻找位置。 +//对于HTTP/HTTPS形式的URL,Cache为"1"或空时表示启用缓存 +func FindFile(file, cache, PATH string) (data []byte, err error) { + data, err = nil, ErrSyntax + if strings.HasPrefix(file, "http") || strings.HasPrefix(file, "https") { + if cache == "" { + cache = "1" + } + hash := md5.Sum([]byte(file)) + cacheFile := path.Join(CachePath, hex.EncodeToString(hash[:])+".cache") + if PathExists(cacheFile) && cache == "1" { + return ioutil.ReadFile(cacheFile) + } + data, err = GetBytes(file) + _ = ioutil.WriteFile(cacheFile, data, 0644) + if err != nil { + return nil, err + } + } else if strings.HasPrefix(file, "base64") { + data, err = base64.StdEncoding.DecodeString(strings.ReplaceAll(file, "base64://", "")) + if err != nil { + return nil, err + } + } else if strings.HasPrefix(file, "file") { + var fu *url.URL + fu, err = url.Parse(file) + if err != nil { + return nil, err + } + if strings.HasPrefix(fu.Path, "/") && runtime.GOOS == `windows` { + fu.Path = fu.Path[1:] + } + data, err = ioutil.ReadFile(fu.Path) + if err != nil { + return nil, err + } + } else if PathExists(path.Join(PATH, file)) { + data, err = ioutil.ReadFile(path.Join(PATH, file)) + if err != nil { + return nil, err + } + } + return +} + +//DelFile 删除一个给定path,并返回删除结果 +func DelFile(path string) bool { + err := os.Remove(path) + if err != nil { + // 删除失败 + log.Error(err) + return false + } + // 删除成功 + log.Info(path + "删除成功") + return true +} + +//ReadAddrFile 从给定path中读取合法的IP地址与端口,每个IP地址以换行符"\n"作为分隔 +func ReadAddrFile(path string) []*net.TCPAddr { + d, err := ioutil.ReadFile(path) + if err != nil { + return nil + } + str := string(d) + lines := strings.Split(str, "\n") + var ret []*net.TCPAddr + for _, l := range lines { + ip := strings.Split(strings.TrimSpace(l), ":") + if len(ip) == 2 { + port, _ := strconv.Atoi(ip[1]) + ret = append(ret, &net.TCPAddr{IP: net.ParseIP(ip[0]), Port: port}) + } + } + return ret +} + +//WriteCounter 写入量计算实例 +type WriteCounter struct { + Total uint64 +} + +//Write 方法将写入的byte长度追加至写入的总长度Total中 +func (wc *WriteCounter) Write(p []byte) (int, error) { + n := len(p) + wc.Total += uint64(n) + wc.PrintProgress() + return n, nil +} + +//PrintProgress 方法将打印当前的总写入量 +func (wc *WriteCounter) PrintProgress() { + fmt.Printf("\r%s", strings.Repeat(" ", 35)) + fmt.Printf("\rDownloading... %s complete", humanize.Bytes(wc.Total)) +} + +//UpdateFromStream copy form getlantern/go-update +func UpdateFromStream(updateWith io.Reader) (err error, errRecover error) { + updatePath, err := osext.Executable() + if err != nil { + return + } + var newBytes []byte + // no patch to apply, go on through + var fileHeader []byte + bufBytes := bufio.NewReader(updateWith) + fileHeader, err = bufBytes.Peek(2) + if err != nil { + return + } + // The content is always bzip2 compressed except when running test, in + // which case is not prefixed with the magic byte sequence for sure. + if bytes.Equal([]byte{0x42, 0x5a}, fileHeader) { + // Identifying bzip2 files. + updateWith = bzip2.NewReader(bufBytes) + } else { + updateWith = io.Reader(bufBytes) + } + newBytes, err = ioutil.ReadAll(updateWith) + if err != nil { + return + } + // get the directory the executable exists in + updateDir := filepath.Dir(updatePath) + filename := filepath.Base(updatePath) + // Copy the contents of of newbinary to a the new executable file + newPath := filepath.Join(updateDir, fmt.Sprintf(".%s.new", filename)) + fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return + } + // We won't log this error, because it's always going to happen. + defer func() { _ = fp.Close() }() + if _, err = io.Copy(fp, bytes.NewReader(newBytes)); err != nil { + log.Errorf("Unable to copy data: %v\n", err) + } + + // if we don't call fp.Close(), windows won't let us move the new executable + // because the file will still be "in use" + if err := fp.Close(); err != nil { + log.Errorf("Unable to close file: %v\n", err) + } + // this is where we'll move the executable to so that we can swap in the updated replacement + oldPath := filepath.Join(updateDir, fmt.Sprintf(".%s.old", filename)) + + // delete any existing old exec file - this is necessary on Windows for two reasons: + // 1. after a successful update, Windows can't remove the .old file because the process is still running + // 2. windows rename operations fail if the destination file already exists + _ = os.Remove(oldPath) + + // move the existing executable to a new file in the same directory + err = os.Rename(updatePath, oldPath) + if err != nil { + return + } + + // move the new executable in to become the new program + err = os.Rename(newPath, updatePath) + + if err != nil { + // copy unsuccessful + errRecover = os.Rename(oldPath, updatePath) + } else { + // copy successful, remove the old binary + _ = os.Remove(oldPath) + } + return } diff --git a/global/log_hook.go b/global/log_hook.go new file mode 100644 index 0000000..63865f8 --- /dev/null +++ b/global/log_hook.go @@ -0,0 +1,153 @@ +package global + +import ( + "fmt" + "github.com/sirupsen/logrus" + "io" + "os" + "path/filepath" + "reflect" + "sync" +) + +type LocalHook struct { + lock *sync.Mutex + levels []logrus.Level // hook级别 + formatter logrus.Formatter // 格式 + path string // 写入path + writer io.Writer // io +} + +// ref: logrus/hooks.go. impl Hook interface +func (hook *LocalHook) Levels() []logrus.Level { + if len(hook.levels) == 0 { + return logrus.AllLevels + } + return hook.levels +} + +func (hook *LocalHook) ioWrite(entry *logrus.Entry) error { + log, err := hook.formatter.Format(entry) + if err != nil { + return err + } + + _, err = hook.writer.Write(log) + if err != nil { + return err + } + return nil +} + +func (hook *LocalHook) pathWrite(entry *logrus.Entry) error { + dir := filepath.Dir(hook.path) + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return err + } + + fd, err := os.OpenFile(hook.path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0666) + if err != nil { + return err + } + defer fd.Close() + + log, err := hook.formatter.Format(entry) + + if err != nil { + return err + } + + _, err = fd.Write(log) + return err +} + +func (hook *LocalHook) Fire(entry *logrus.Entry) error { + hook.lock.Lock() + defer hook.lock.Unlock() + + if hook.writer != nil { + return hook.ioWrite(entry) + } + + if hook.path != "" { + return hook.pathWrite(entry) + } + + return nil +} + +func (hook *LocalHook) SetFormatter(formatter logrus.Formatter) { + hook.lock.Lock() + defer hook.lock.Unlock() + + if formatter == nil { + // 用默认的 + formatter = &logrus.TextFormatter{DisableColors: true} + } else { + switch f := formatter.(type) { + case *logrus.TextFormatter: + textFormatter := f + textFormatter.DisableColors = true + default: + // todo + } + } + logrus.SetFormatter(formatter) + hook.formatter = formatter +} + +func (hook *LocalHook) SetWriter(writer io.Writer) { + hook.lock.Lock() + defer hook.lock.Unlock() + hook.writer = writer +} + +func (hook *LocalHook) SetPath(path string) { + hook.lock.Lock() + defer hook.lock.Unlock() + hook.path = path +} + +func NewLocalHook(args interface{}, formatter logrus.Formatter, levels ...logrus.Level) *LocalHook { + hook := &LocalHook{ + lock: new(sync.Mutex), + } + hook.SetFormatter(formatter) + hook.levels = append(hook.levels, levels...) + + switch arg := args.(type) { + case string: + hook.SetPath(arg) + case io.Writer: + hook.SetWriter(arg) + default: + panic(fmt.Sprintf("unsupported type: %v", reflect.TypeOf(args))) + } + + return hook +} + +func GetLogLevel(level string) []logrus.Level { + switch level { + case "trace": + return []logrus.Level{logrus.TraceLevel, logrus.DebugLevel, + logrus.InfoLevel, logrus.WarnLevel, logrus.ErrorLevel, + logrus.FatalLevel, logrus.PanicLevel} + case "debug": + return []logrus.Level{logrus.DebugLevel, logrus.InfoLevel, + logrus.WarnLevel, logrus.ErrorLevel, + logrus.FatalLevel, logrus.PanicLevel} + case "info": + return []logrus.Level{logrus.InfoLevel, logrus.WarnLevel, + logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel} + case "warn": + return []logrus.Level{logrus.WarnLevel, logrus.ErrorLevel, + logrus.FatalLevel, logrus.PanicLevel} + case "error": + return []logrus.Level{logrus.ErrorLevel, logrus.FatalLevel, + logrus.PanicLevel} + default: + return []logrus.Level{logrus.InfoLevel, logrus.WarnLevel, + logrus.ErrorLevel, logrus.FatalLevel, logrus.PanicLevel} + } +} diff --git a/global/net.go b/global/net.go index 55f27de..33ebb66 100644 --- a/global/net.go +++ b/global/net.go @@ -1,22 +1,60 @@ package global import ( + "bufio" "bytes" "compress/gzip" "fmt" - "github.com/tidwall/gjson" + "io" "io/ioutil" "net/http" + "net/url" + "os" + "strconv" "strings" + "sync" + "time" + + "github.com/guonaihong/gout" + "github.com/pkg/errors" + + "github.com/tidwall/gjson" ) +var ( + client = &http.Client{ + Transport: &http.Transport{ + Proxy: func(request *http.Request) (u *url.URL, e error) { + if Proxy == "" { + return http.ProxyFromEnvironment(request) + } + return url.Parse(Proxy) + }, + ForceAttemptHTTP2: true, + MaxConnsPerHost: 0, + MaxIdleConns: 0, + MaxIdleConnsPerHost: 999, + }, + } + + //Proxy 存储Config.proxy_rewrite,用于设置代理 + Proxy string + + //ErrOverSize 响应主体过大时返回此错误 + ErrOverSize = errors.New("oversize") + + //UserAgent HTTP请求时使用的UA + UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36 Edg/87.0.664.66" +) + +//GetBytes 对给定URL发送Get请求,返回响应主体 func GetBytes(url string) ([]byte, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, err } - req.Header["User-Agent"] = []string{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36 Edg/83.0.478.61"} - resp, err := http.DefaultClient.Do(req) + req.Header["User-Agent"] = []string{UserAgent} + resp, err := client.Do(req) if err != nil { return nil, err } @@ -35,6 +73,215 @@ func GetBytes(url string) ([]byte, error) { return body, nil } +//DownloadFile 将给定URL对应的文件下载至给定Path +func DownloadFile(url, path string, limit int64, headers map[string]string) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0666) + if err != nil { + return err + } + defer file.Close() + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + if _, ok := headers["User-Agent"]; !ok { + req.Header["User-Agent"] = []string{UserAgent} + } + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if limit > 0 && resp.ContentLength > limit { + return ErrOverSize + } + _, err = io.Copy(file, resp.Body) + if err != nil { + return err + } + return nil +} + +//DownloadFileMultiThreading 使用threadCount个线程将给定URL对应的文件下载至给定Path +func DownloadFileMultiThreading(url, path string, limit int64, threadCount int, headers map[string]string) error { + if threadCount < 2 { + return DownloadFile(url, path, limit, headers) + } + type BlockMetaData struct { + BeginOffset int64 + EndOffset int64 + DownloadedSize int64 + } + var blocks []*BlockMetaData + var contentLength int64 + errUnsupportedMultiThreading := errors.New("unsupported multi-threading") + // 初始化分块或直接下载 + initOrDownload := func() error { + copyStream := func(s io.ReadCloser) error { + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0666) + if err != nil { + return err + } + defer file.Close() + if _, err = io.Copy(file, s); err != nil { + return err + } + return errUnsupportedMultiThreading + } + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return err + } + + for k, v := range headers { + req.Header.Set(k, v) + + } + if _, ok := headers["User-Agent"]; !ok { + req.Header["User-Agent"] = []string{UserAgent} + } + req.Header.Set("range", "bytes=0-") + resp, err := client.Do(req) + if err != nil { + return err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return errors.New("response status unsuccessful: " + strconv.FormatInt(int64(resp.StatusCode), 10)) + } + if resp.StatusCode == 200 { + if limit > 0 && resp.ContentLength > limit { + return ErrOverSize + } + return copyStream(resp.Body) + } + if resp.StatusCode == 206 { + contentLength = resp.ContentLength + if limit > 0 && resp.ContentLength > limit { + return ErrOverSize + } + blockSize := func() int64 { + if contentLength > 1024*1024 { + return (contentLength / int64(threadCount)) - 10 + } + return contentLength + + }() + if blockSize == contentLength { + return copyStream(resp.Body) + } + var tmp int64 + for tmp+blockSize < contentLength { + blocks = append(blocks, &BlockMetaData{ + BeginOffset: tmp, + EndOffset: tmp + blockSize - 1, + }) + tmp += blockSize + } + blocks = append(blocks, &BlockMetaData{ + BeginOffset: tmp, + EndOffset: contentLength - 1, + }) + return nil + } + return errors.New("unknown status code") + } + // 下载分块 + downloadBlock := func(block *BlockMetaData) error { + req, _ := http.NewRequest("GET", url, nil) + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0666) + if err != nil { + return err + } + defer file.Close() + _, _ = file.Seek(block.BeginOffset, io.SeekStart) + writer := bufio.NewWriter(file) + defer writer.Flush() + + for k, v := range headers { + req.Header.Set(k, v) + } + + if _, ok := headers["User-Agent"]; ok { + req.Header["User-Agent"] = []string{UserAgent} + } + req.Header.Set("range", "bytes="+strconv.FormatInt(block.BeginOffset, 10)+"-"+strconv.FormatInt(block.EndOffset, 10)) + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return errors.New("response status unsuccessful: " + strconv.FormatInt(int64(resp.StatusCode), 10)) + } + var buffer = make([]byte, 1024) + i, err := resp.Body.Read(buffer) + for { + if err != nil && err != io.EOF { + return err + } + i64 := int64(len(buffer[:i])) + needSize := block.EndOffset + 1 - block.BeginOffset + if i64 > needSize { + i64 = needSize + err = io.EOF + } + _, e := writer.Write(buffer[:i64]) + if e != nil { + return e + } + block.BeginOffset += i64 + block.DownloadedSize += i64 + if err == io.EOF || block.BeginOffset > block.EndOffset { + break + } + i, err = resp.Body.Read(buffer) + } + return nil + } + + if err := initOrDownload(); err != nil { + if err == errUnsupportedMultiThreading { + return nil + } + return err + } + wg := sync.WaitGroup{} + wg.Add(len(blocks)) + var lastErr error + for i := range blocks { + go func(b *BlockMetaData) { + defer wg.Done() + if err := downloadBlock(b); err != nil { + lastErr = err + } + }(blocks[i]) + } + wg.Wait() + return lastErr +} + +//GetSliderTicket 通过给定的验证链接raw和id,获取验证结果Ticket +func GetSliderTicket(raw, id string) (string, error) { + var rsp string + if err := gout.POST("https://api.shkong.com/gocqhttpapi/task").SetJSON(gout.H{ + "id": id, + "url": raw, + }).SetTimeout(time.Second * 35).BindBody(&rsp).Do(); err != nil { + return "", err + } + g := gjson.Parse(rsp) + if g.Get("error").Str != "" { + return "", errors.New(g.Get("error").Str) + } + return g.Get("ticket").Str, nil +} + +//QQMusicSongInfo 通过给定id在QQ音乐上查找曲目信息 func QQMusicSongInfo(id string) (gjson.Result, error) { d, err := GetBytes(`https://u.y.qq.com/cgi-bin/musicu.fcg?format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq.json&needNewCode=0&data={%22comm%22:{%22ct%22:24,%22cv%22:0},%22songinfo%22:{%22method%22:%22get_song_detail_yqq%22,%22param%22:{%22song_type%22:0,%22song_mid%22:%22%22,%22song_id%22:` + id + `},%22module%22:%22music.pf_song_detail_svr%22}}`) if err != nil { @@ -43,6 +290,7 @@ func QQMusicSongInfo(id string) (gjson.Result, error) { return gjson.ParseBytes(d).Get("songinfo.data"), nil } +//NeteaseMusicSongInfo 通过给定id在wdd音乐上查找曲目信息 func NeteaseMusicSongInfo(id string) (gjson.Result, error) { d, err := GetBytes(fmt.Sprintf("http://music.163.com/api/song/detail/?id=%s&ids=%%5B%s%%5D", id, id)) if err != nil { @@ -50,4 +298,3 @@ func NeteaseMusicSongInfo(id string) (gjson.Result, error) { } return gjson.ParseBytes(d).Get("songs.0"), nil } - diff --git a/global/param.go b/global/param.go index 25e138f..d89d7d2 100644 --- a/global/param.go +++ b/global/param.go @@ -1,8 +1,12 @@ package global import ( - "github.com/tidwall/gjson" + "math" + "regexp" + "strconv" "strings" + + "github.com/tidwall/gjson" ) var trueSet = map[string]struct{}{ @@ -17,6 +21,15 @@ var falseSet = map[string]struct{}{ "0": {}, } +//EnsureBool 判断给定的p是否可表示为合法Bool类型,否则返回defaultVal +// +//支持的合法类型有 +// +//type bool +// +//type gjson.True or gjson.False +// +//type string "true","yes","1" or "false","no","0" (case insensitive) func EnsureBool(p interface{}, defaultVal bool) bool { var str string if b, ok := p.(bool); ok { @@ -48,3 +61,47 @@ func EnsureBool(p interface{}, defaultVal bool) bool { } return defaultVal } + +// VersionNameCompare 检查版本名是否需要更新, 仅适用于 go-cqhttp 的版本命名规则 +// +// 例: v0.9.29-fix2 == v0.9.29-fix2 -> false +// +// v0.9.29-fix1 < v0.9.29-fix2 -> true +// +// v0.9.29-fix2 > v0.9.29-fix1 -> false +// +// v0.9.29-fix2 < v0.9.30 -> true +func VersionNameCompare(current, remote string) bool { + sp := regexp.MustCompile(`[0-9]\d*`) + cur := sp.FindAllStringSubmatch(current, -1) + re := sp.FindAllStringSubmatch(remote, -1) + for i := 0; i < int(math.Min(float64(len(cur)), float64(len(re)))); i++ { + curSub, _ := strconv.Atoi(cur[i][0]) + reSub, _ := strconv.Atoi(re[i][0]) + if curSub < reSub { + return true + } + } + return len(cur) < len(re) +} + +//SplitURL 将给定URL字符串分割为两部分,用于URL预处理防止风控 +func SplitURL(s string) []string { + reg := regexp.MustCompile(`(?i)[a-z\d][-a-z\d]{0,62}(\.[a-z\d][-a-z\d]{0,62})+\.?`) + idx := reg.FindAllStringIndex(s, -1) + if len(idx) == 0 { + return []string{s} + } + var result []string + last := 0 + for i := 0; i < len(idx); i++ { + if len(idx[i]) != 2 { + continue + } + m := int(math.Abs(float64(idx[i][0]-idx[i][1]))/1.5) + idx[i][0] + result = append(result, s[last:m]) + last = m + } + result = append(result, s[last:]) + return result +} diff --git a/global/ratelimit.go b/global/ratelimit.go index 79594be..5983922 100644 --- a/global/ratelimit.go +++ b/global/ratelimit.go @@ -2,19 +2,22 @@ package global import ( "context" + "golang.org/x/time/rate" ) var limiter *rate.Limiter var limitEnable = false +//RateLimit 执行API调用速率限制 func RateLimit(ctx context.Context) { if limitEnable { _ = limiter.Wait(ctx) } } -func InitLimiter(r float64, b int) { +//InitLimiter 初始化速率限制器 +func InitLimiter(frequency float64, bucketSize int) { limitEnable = true - limiter = rate.NewLimiter(rate.Limit(r), b) + limiter = rate.NewLimiter(rate.Limit(frequency), bucketSize) } diff --git a/go.mod b/go.mod index 5a5723f..c324dd5 100644 --- a/go.mod +++ b/go.mod @@ -1,31 +1,27 @@ module github.com/Mrs4s/go-cqhttp -go 1.14 +go 1.15 require ( - github.com/Mrs4s/MiraiGo v0.0.0-20200910013944-236c0f629099 - github.com/fastly/go-utils v0.0.0-20180712184237-d95a45783239 // indirect + github.com/Mrs4s/MiraiGo v0.0.0-20210206134348-800bf525ed0e + github.com/dustin/go-humanize v1.0.0 + github.com/gin-contrib/pprof v1.3.0 github.com/gin-gonic/gin v1.6.3 - github.com/go-playground/validator/v10 v10.3.0 // indirect github.com/gorilla/websocket v1.4.2 - github.com/guonaihong/gout v0.1.2 - github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869 // indirect - github.com/jonboulle/clockwork v0.2.0 // indirect - github.com/json-iterator/go v1.1.10 // indirect + github.com/guonaihong/gout v0.1.4 + github.com/hjson/hjson-go v3.1.0+incompatible + github.com/jonboulle/clockwork v0.2.2 // indirect + github.com/json-iterator/go v1.1.10 + github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible - github.com/lestrrat-go/strftime v1.0.3 // indirect - github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.1 // indirect - github.com/pkg/errors v0.9.1 // indirect - github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 - github.com/sirupsen/logrus v1.6.0 + github.com/lestrrat-go/strftime v1.0.4 // indirect + github.com/pkg/errors v0.9.1 + github.com/sirupsen/logrus v1.7.0 + github.com/syndtr/goleveldb v1.0.0 github.com/t-tomalak/logrus-easy-formatter v0.0.0-20190827215021-c074f06c5816 - github.com/tebeka/strftime v0.1.5 // indirect - github.com/tidwall/gjson v1.6.1 - github.com/xujiajun/nutsdb v0.5.0 + github.com/tidwall/gjson v1.6.7 github.com/yinghau76/go-ascii-art v0.0.0-20190517192627-e7f465a30189 - golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect - golang.org/x/sys v0.0.0-20200909081042-eff7692f9009 // indirect - golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e - gopkg.in/yaml.v2 v2.3.0 // indirect + golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 + golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf + golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 ) diff --git a/go.sum b/go.sum index 7d5ff26..21574d6 100644 --- a/go.sum +++ b/go.sum @@ -1,37 +1,31 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Mrs4s/MiraiGo v0.0.0-20200909095006-dde8bded28d1 h1:3cmUqA5RaikLx+59SODlBA7tjORQoh4t1w5CzH5bIH8= -github.com/Mrs4s/MiraiGo v0.0.0-20200909095006-dde8bded28d1/go.mod h1:cwYPI2uq6nxNbx0nA6YuAKF1V5szSs6FPlGVLQvRUlo= -github.com/Mrs4s/MiraiGo v0.0.0-20200909103204-808a63a78efe h1:O2BW87BwpwZDsn7YFHLfRGFGvTS4OUZsG2UiA13OxcQ= -github.com/Mrs4s/MiraiGo v0.0.0-20200909103204-808a63a78efe/go.mod h1:cwYPI2uq6nxNbx0nA6YuAKF1V5szSs6FPlGVLQvRUlo= -github.com/Mrs4s/MiraiGo v0.0.0-20200910010455-37409b1f6b9c h1:bhVr3W0+WTVN+vgZGlxD4iFSV9L3CmUg/lt91h+Ll18= -github.com/Mrs4s/MiraiGo v0.0.0-20200910010455-37409b1f6b9c/go.mod h1:cwYPI2uq6nxNbx0nA6YuAKF1V5szSs6FPlGVLQvRUlo= -github.com/Mrs4s/MiraiGo v0.0.0-20200910013944-236c0f629099 h1:b+Tmo9h5leZmQokdUu8c2xSIRkkSYoP1z8G+zcwwyRY= -github.com/Mrs4s/MiraiGo v0.0.0-20200910013944-236c0f629099/go.mod h1:cwYPI2uq6nxNbx0nA6YuAKF1V5szSs6FPlGVLQvRUlo= -github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= -github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= +github.com/Mrs4s/MiraiGo v0.0.0-20210206134348-800bf525ed0e h1:SnN+nyRdqN7sULnHUWCofP+Jxs3VJN/y8AlMpcz0nbk= +github.com/Mrs4s/MiraiGo v0.0.0-20210206134348-800bf525ed0e/go.mod h1:yhqA0NyKxUf7I/0HR/1OMchveFggX8wde04gqdGrNfU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fastly/go-utils v0.0.0-20180712184237-d95a45783239/go.mod h1:Gdwt2ce0yfBxPvZrHkprdPPTTS3N5rwmLE8T22KBXlw= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gin-contrib/pprof v1.3.0 h1:G9eK6HnbkSqDZBYbzG4wrjCsA4e+cvYAHUZw6W+W9K0= +github.com/gin-contrib/pprof v1.3.0/go.mod h1:waMjT1H9b179t3CxuG1cV3DHpga6ybizwfBaM5OXaB0= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= -github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= +github.com/gin-gonic/gin v1.6.0/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= +github.com/gin-gonic/gin v1.6.2/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= -github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= +github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= -github.com/go-playground/validator/v10 v10.3.0 h1:nZU+7q+yJoFmwvNgv/LnPUkwPal62+b2xXj0AU1Es7o= -github.com/go-playground/validator/v10 v10.3.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -43,8 +37,10 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= -github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db h1:woRePGFeVFfLKN/pOkfl+p/TAqKOfFu+7KPlMVpok/w= +github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -54,106 +50,96 @@ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/guonaihong/gout v0.1.2 h1:TR2XCRopGgJdj231IayEoeavgbznFXzzzcZVdT/hG10= -github.com/guonaihong/gout v0.1.2/go.mod h1:vXvv5Kxr70eM5wrp4F0+t9lnLWmq+YPW2GByll2f/EA= -github.com/jehiah/go-strftime v0.0.0-20171201141054-1d33003b3869/go.mod h1:cJ6Cj7dQo+O6GJNiMx+Pa94qKj+TG8ONdKHgMNIyyag= -github.com/jonboulle/clockwork v0.2.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= -github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/guonaihong/gout v0.1.4 h1:uBBoyztMX9okC27OQxqhn6bZ0ROkGyvnEIHwtp3TM4g= +github.com/guonaihong/gout v0.1.4/go.mod h1:0rFYAYyzbcxEg11eY2qUbffJs7hHRPeugAnlVYSp8Ic= +github.com/hjson/hjson-go v3.1.0+incompatible h1:DY/9yE8ey8Zv22bY+mHV1uk2yRy0h8tKhZ77hEdi0Aw= +github.com/hjson/hjson-go v3.1.0+incompatible/go.mod h1:qsetwF8NlsTsOTwZTApNlTCerV+b2GjYRRcIk4JMFio= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA= +github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8= -github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= -github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is= -github.com/lestrrat-go/file-rotatelogs v2.3.0+incompatible h1:4mNlp+/SvALIPFpbXV3kxNJJno9iKFWGxSDE13Kl66Q= -github.com/lestrrat-go/file-rotatelogs v2.3.0+incompatible/go.mod h1:ZQnN8lSECaebrkQytbHj4xNgtg8CR7RYXnPok8e0EHA= github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible h1:Y6sqxHMyB1D2YSzWkLibYKgg+SwmyFU9dF2hn6MdTj4= github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible/go.mod h1:ZQnN8lSECaebrkQytbHj4xNgtg8CR7RYXnPok8e0EHA= -github.com/lestrrat-go/strftime v1.0.3 h1:qqOPU7y+TM8Y803I8fG9c/DyKG3xH/xkng6keC1015Q= -github.com/lestrrat-go/strftime v1.0.3/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR76fd03sz+Qz4g= -github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= +github.com/lestrrat-go/strftime v1.0.4 h1:T1Rb9EPkAhgxKqbcMIPguPq8glqXTA1koF8n9BHElA8= +github.com/lestrrat-go/strftime v1.0.4/go.mod h1:E1nN3pCbtMSu1yjSVeyuRFVm/U0xoR76fd03sz+Qz4g= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5 h1:mZHayPoR0lNmnHyvtYjDeq0zlVHn9K/ZXoy17ylucdo= -github.com/rifflock/lfshook v0.0.0-20180920164130-b9218ef580f5/go.mod h1:GEXHk5HgEKCvEIIrSpFI3ozzG5xOKA2DVlEX/gGnewM= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= -github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I= -github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= +github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= +github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/t-tomalak/logrus-easy-formatter v0.0.0-20190827215021-c074f06c5816 h1:J6v8awz+me+xeb/cUTotKgceAYouhIB3pjzgRd6IlGk= github.com/t-tomalak/logrus-easy-formatter v0.0.0-20190827215021-c074f06c5816/go.mod h1:tzym/CEb5jnFI+Q0k4Qq3+LvRF4gO3E2pxS8fHP8jcA= -github.com/tebeka/strftime v0.1.5/go.mod h1:29/OidkoWHdEKZqzyDLUyC+LmgDgdHo4WAFCDT7D/Ig= -github.com/tidwall/gjson v1.6.1 h1:LRbvNuNuvAiISWg6gxLEFuCe72UKy5hDqhxW/8183ws= -github.com/tidwall/gjson v1.6.1/go.mod h1:BaHyNc5bjzYkPqgLq7mdVzeiRtULKULXLgZFKsxEHI0= -github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc= -github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= +github.com/tidwall/gjson v1.6.7 h1:Mb1M9HZCRWEcXQ8ieJo7auYyyiSux6w9XN3AdTpxJrE= +github.com/tidwall/gjson v1.6.7/go.mod h1:zeFuBCIqD4sN/gmqBzZ4j7Jd6UcA2Fc56x7QFsv+8fI= +github.com/tidwall/match v1.0.3 h1:FQUVvBImDutD8wJLN6c5eMzWtjgONK9MwIBCOrUJKeE= +github.com/tidwall/match v1.0.3/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.0.2 h1:Z7S3cePv9Jwm1KwS0513MRaoUe3S01WPbLNV40pwWZU= github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo= github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw= github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs= github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY= -github.com/xujiajun/gorouter v1.2.0/go.mod h1:yJrIta+bTNpBM/2UT8hLOaEAFckO+m/qmR3luMIQygM= -github.com/xujiajun/mmap-go v1.0.1 h1:7Se7ss1fLPPRW+ePgqGpCkfGIZzJV6JPq9Wq9iv/WHc= -github.com/xujiajun/mmap-go v1.0.1/go.mod h1:CNN6Sw4SL69Sui00p0zEzcZKbt+5HtEnYUsc6BKKRMg= -github.com/xujiajun/nutsdb v0.5.0 h1:j/jM3Zw7Chg8WK7bAcKR0Xr7Mal47U1oJAMgySfDn9E= -github.com/xujiajun/nutsdb v0.5.0/go.mod h1:owdwN0tW084RxEodABLbO7h4Z2s9WiAjZGZFhRh0/1Q= -github.com/xujiajun/utils v0.0.0-20190123093513-8bf096c4f53b h1:jKG9OiL4T4xQN3IUrhUpc1tG+HfDXppkgVcrAiiaI/0= -github.com/xujiajun/utils v0.0.0-20190123093513-8bf096c4f53b/go.mod h1:AZd87GYJlUzl82Yab2kTjx1EyXSQCAfZDhpTo1SQC4k= github.com/yinghau76/go-ascii-art v0.0.0-20190517192627-e7f465a30189 h1:4UJw9if55Fu3HOwbfcaQlJ27p3oeJU2JZqoeT3ITJQk= github.com/yinghau76/go-ascii-art v0.0.0-20190517192627-e7f465a30189/go.mod h1:rIrm5geMiBhPQkdfUm8gDFi/WiHneOp1i9KjmJqc+9I= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA= -golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f h1:Fqb3ao1hUmOR3GkUOg/Y+BadLwykBIzs5q8Ez2SbHyc= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200909081042-eff7692f9009 h1:W0lCpv29Hv0UaM1LXb9QlBHLNP8UFfcKjblhVCWftOM= -golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf h1:MZ2shdL+ZM/XzY3ZGOnh4Nlpnxz5GSOhOmtHo3iPU6M= +golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e h1:EHBhcS0mlXEAVwNyO2dLfjToGsyY4j24pTs2ScHnX7s= -golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -179,11 +165,11 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= -gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/main.go b/main.go index b8a8271..327663b 100644 --- a/main.go +++ b/main.go @@ -2,177 +2,151 @@ package main import ( "bufio" - "bytes" + "crypto/aes" "crypto/md5" + "crypto/sha1" "encoding/base64" - "encoding/json" + "encoding/hex" "fmt" - "image" "io" "io/ioutil" + "net/http" "os" + "os/exec" "os/signal" "path" + "path/filepath" + "runtime" "strconv" "strings" + "syscall" "time" + "github.com/Mrs4s/go-cqhttp/server" + "github.com/guonaihong/gout" + "github.com/tidwall/gjson" + "golang.org/x/crypto/pbkdf2" + "golang.org/x/term" + "github.com/Mrs4s/MiraiGo/binary" "github.com/Mrs4s/MiraiGo/client" "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/global" - "github.com/Mrs4s/go-cqhttp/server" - + jsoniter "github.com/json-iterator/go" rotatelogs "github.com/lestrrat-go/file-rotatelogs" - "github.com/rifflock/lfshook" log "github.com/sirupsen/logrus" easy "github.com/t-tomalak/logrus-easy-formatter" - asciiart "github.com/yinghau76/go-ascii-art" ) +var json = jsoniter.ConfigCompatibleWithStandardLibrary +var conf *global.JSONConfig +var isFastStart = false + func init() { - log.SetFormatter(&easy.Formatter{ - TimestampFormat: "2006-01-02 15:04:05", - LogFormat: "[%time%] [%lvl%]: %msg% \n", - }) - w, err := rotatelogs.New(path.Join("logs", "%Y-%m-%d.log"), rotatelogs.WithRotationTime(time.Hour*24)) - if err == nil { - log.SetOutput(io.MultiWriter(os.Stderr, w)) - } - if !global.PathExists(global.IMAGE_PATH) { - if err := os.MkdirAll(global.IMAGE_PATH, 0755); err != nil { - log.Fatalf("创建图片缓存文件夹失败: %v", err) - } - } - if !global.PathExists(global.VOICE_PATH) { - if err := os.MkdirAll(global.VOICE_PATH, 0755); err != nil { - log.Fatalf("创建语音缓存文件夹失败: %v", err) - } - } - if !global.PathExists(global.VIDEO_PATH) { - if err := os.MkdirAll(global.VIDEO_PATH, 0755); err != nil { - log.Fatalf("创建视频缓存文件夹失败: %v", err) - } - } - if !global.PathExists(global.CACHE_PATH) { - if err := os.MkdirAll(global.CACHE_PATH, 0755); err != nil { - log.Fatalf("创建发送图片缓存文件夹失败: %v", err) - } - } if global.PathExists("cqhttp.json") { log.Info("发现 cqhttp.json 将在五秒后尝试导入配置,按 Ctrl+C 取消.") - log.Warn("警告: 该操作会删除 cqhttp.json 并覆盖 config.json 文件.") + log.Warn("警告: 该操作会删除 cqhttp.json 并覆盖 config.hjson 文件.") time.Sleep(time.Second * 5) - conf := global.CQHttpApiConfig{} + conf := global.CQHTTPAPIConfig{} if err := json.Unmarshal([]byte(global.ReadAllText("cqhttp.json")), &conf); err != nil { log.Fatalf("读取文件 cqhttp.json 失败: %v", err) } goConf := global.DefaultConfig() goConf.AccessToken = conf.AccessToken - goConf.HttpConfig.Host = conf.Host - goConf.HttpConfig.Port = conf.Port + goConf.HTTPConfig.Host = conf.Host + goConf.HTTPConfig.Port = conf.Port goConf.WSConfig.Host = conf.WSHost goConf.WSConfig.Port = conf.WSPort - if conf.PostUrl != "" { - goConf.HttpConfig.PostUrls[conf.PostUrl] = conf.Secret + if conf.PostURL != "" { + goConf.HTTPConfig.PostUrls[conf.PostURL] = conf.Secret } if conf.UseWsReverse { goConf.ReverseServers[0].Enabled = true - goConf.ReverseServers[0].ReverseUrl = conf.WSReverseUrl - goConf.ReverseServers[0].ReverseApiUrl = conf.WSReverseApiUrl - goConf.ReverseServers[0].ReverseEventUrl = conf.WSReverseEventUrl + goConf.ReverseServers[0].ReverseURL = conf.WSReverseURL + goConf.ReverseServers[0].ReverseAPIURL = conf.WSReverseAPIURL + goConf.ReverseServers[0].ReverseEventURL = conf.WSReverseEventURL goConf.ReverseServers[0].ReverseReconnectInterval = conf.WSReverseReconnectInterval } - if err := goConf.Save("config.json"); err != nil { - log.Fatalf("保存 config.json 时出现错误: %v", err) + if err := goConf.Save("config.hjson"); err != nil { + log.Fatalf("保存 config.hjson 时出现错误: %v", err) } _ = os.Remove("cqhttp.json") } + + conf = getConfig() + if conf == nil { + os.Exit(1) + } + + logFormatter := &easy.Formatter{ + TimestampFormat: "2006-01-02 15:04:05", + LogFormat: "[%time%] [%lvl%]: %msg% \n", + } + w, err := rotatelogs.New(path.Join("logs", "%Y-%m-%d.log"), rotatelogs.WithRotationTime(time.Hour*24)) + if err != nil { + log.Errorf("rotatelogs init err: %v", err) + panic(err) + } + + // 在debug模式下,将在标准输出中打印当前执行行数 + if conf.Debug { + log.SetReportCaller(true) + } + + log.AddHook(global.NewLocalHook(w, logFormatter, global.GetLogLevel(conf.LogLevel)...)) + + if !global.PathExists(global.ImagePath) { + if err := os.MkdirAll(global.ImagePath, 0755); err != nil { + log.Fatalf("创建图片缓存文件夹失败: %v", err) + } + } + if !global.PathExists(global.VoicePath) { + if err := os.MkdirAll(global.VoicePath, 0755); err != nil { + log.Fatalf("创建语音缓存文件夹失败: %v", err) + } + } + if !global.PathExists(global.VideoPath) { + if err := os.MkdirAll(global.VideoPath, 0755); err != nil { + log.Fatalf("创建视频缓存文件夹失败: %v", err) + } + } + if !global.PathExists(global.CachePath) { + if err := os.MkdirAll(global.CachePath, 0755); err != nil { + log.Fatalf("创建发送图片缓存文件夹失败: %v", err) + } + } } func main() { - console := bufio.NewReader(os.Stdin) - var conf *global.JsonConfig - if global.PathExists("config.json") || os.Getenv("UIN") == "" { - conf = global.Load("config.json") - } else if os.Getenv("UIN") != "" { - log.Infof("将从环境变量加载配置.") - uin, _ := strconv.ParseInt(os.Getenv("UIN"), 10, 64) - pwd := os.Getenv("PASS") - post := os.Getenv("HTTP_POST") - conf = &global.JsonConfig{ - Uin: uin, - Password: pwd, - HttpConfig: &global.GoCQHttpConfig{ - Enabled: true, - Host: "0.0.0.0", - Port: 5700, - PostUrls: map[string]string{}, - }, - WSConfig: &global.GoCQWebsocketConfig{ - Enabled: true, - Host: "0.0.0.0", - Port: 6700, - }, - PostMessageFormat: "string", - Debug: os.Getenv("DEBUG") == "true", - } - if post != "" { - conf.HttpConfig.PostUrls[post] = os.Getenv("HTTP_SECRET") + + var byteKey []byte + arg := os.Args + if len(arg) > 1 { + for i := range arg { + switch arg[i] { + case "update": + if len(arg) > i+1 { + selfUpdate(arg[i+1]) + } else { + selfUpdate("") + } + case "key": + if len(arg) > i+1 { + b := []byte(arg[i+1]) + byteKey = b + } + case "faststart": + isFastStart = true + } } } - if conf == nil { - err := global.DefaultConfig().Save("config.json") - if err != nil { - log.Fatalf("创建默认配置文件时出现错误: %v", err) - return - } - log.Infof("默认配置文件已生成, 请编辑 config.json 后重启程序.") - time.Sleep(time.Second * 5) - return - } + if conf.Uin == 0 || (conf.Password == "" && conf.PasswordEncrypted == "") { - log.Warnf("请修改 config.json 以添加账号密码.") - time.Sleep(time.Second * 5) - return - } - - // log classified by level - // Collect all records up to the specified level (default level: warn) - logLevel := conf.LogLevel - if logLevel != "" { - date := time.Now().Format("2006-01-02") - var logPathMap lfshook.PathMap - switch conf.LogLevel { - case "warn": - logPathMap = lfshook.PathMap{ - log.WarnLevel: path.Join("logs", date+"-warn.log"), - log.ErrorLevel: path.Join("logs", date+"-warn.log"), - log.FatalLevel: path.Join("logs", date+"-warn.log"), - log.PanicLevel: path.Join("logs", date+"-warn.log"), - } - case "error": - logPathMap = lfshook.PathMap{ - log.ErrorLevel: path.Join("logs", date+"-error.log"), - log.FatalLevel: path.Join("logs", date+"-error.log"), - log.PanicLevel: path.Join("logs", date+"-error.log"), - } - default: - logPathMap = lfshook.PathMap{ - log.WarnLevel: path.Join("logs", date+"-warn.log"), - log.ErrorLevel: path.Join("logs", date+"-warn.log"), - log.FatalLevel: path.Join("logs", date+"-warn.log"), - log.PanicLevel: path.Join("logs", date+"-warn.log"), - } + log.Warnf("请修改 config.hjson 以添加账号密码.") + if !isFastStart { + time.Sleep(time.Second * 5) } - - log.AddHook(lfshook.NewHook( - logPathMap, - &easy.Formatter{ - TimestampFormat: "2006-01-02 15:04:05", - LogFormat: "[%time%] [%lvl%]: %msg% \n", - }, - )) + return } log.Info("当前版本:", coolq.Version) @@ -180,7 +154,12 @@ func main() { log.SetLevel(log.DebugLevel) log.Warnf("已开启Debug模式.") log.Debugf("开发交流群: 192548878") + server.Debug = true + if conf.WebUI == nil || !conf.WebUI.Enabled { + log.Warnf("警告: 在Debug模式下未启用WebUi服务, 将无法进行性能分析.") + } } + log.Info("用户交流群: 721829413") if !global.PathExists("device.json") { log.Warn("虚拟设备信息不存在, 将自动生成随机设备.") client.GenRandomDevice() @@ -194,26 +173,70 @@ func main() { } if conf.EncryptPassword && conf.PasswordEncrypted == "" { log.Infof("密码加密已启用, 请输入Key对密码进行加密: (Enter 提交)") - strKey, _ := console.ReadString('\n') - key := md5.Sum([]byte(strKey)) - if encrypted := EncryptPwd(conf.Password, key[:]); encrypted != "" { - conf.Password = "" - conf.PasswordEncrypted = encrypted - _ = conf.Save("config.json") - } else { - log.Warnf("加密时出现问题.") - } + byteKey, _ = term.ReadPassword(int(os.Stdin.Fd())) + global.PasswordHash = md5.Sum([]byte(conf.Password)) + conf.Password = "" + conf.PasswordEncrypted = "AES:" + PasswordHashEncrypt(global.PasswordHash[:], byteKey) + _ = conf.Save("config.hjson") } if conf.PasswordEncrypted != "" { - log.Infof("密码加密已启用, 请输入Key对密码进行解密以继续: (Enter 提交)") - strKey, _ := console.ReadString('\n') - key := md5.Sum([]byte(strKey)) - conf.Password = DecryptPwd(conf.PasswordEncrypted, key[:]) + if len(byteKey) == 0 { + log.Infof("密码加密已启用, 请输入Key对密码进行解密以继续: (Enter 提交)") + cancel := make(chan struct{}, 1) + go func() { + select { + case <-cancel: + return + case <-time.After(time.Second * 45): + log.Infof("解密key输入超时") + time.Sleep(3 * time.Second) + os.Exit(0) + } + }() + byteKey, _ = term.ReadPassword(int(os.Stdin.Fd())) + cancel <- struct{}{} + } else { + log.Infof("密码加密已启用, 使用运行时传递的参数进行解密,按 Ctrl+C 取消.") + } + + //升级客户端密码加密方案,MD5+TEA 加密密码 -> PBKDF2+AES 加密 MD5 + //升级后的 PasswordEncrypted 字符串以"AES:"开始,其后为 Hex 编码的16字节加密 MD5 + if !strings.HasPrefix(conf.PasswordEncrypted, "AES:") { + password := OldPasswordDecrypt(conf.PasswordEncrypted, byteKey) + passwordHash := md5.Sum([]byte(password)) + newPasswordHash := PasswordHashEncrypt(passwordHash[:], byteKey) + conf.PasswordEncrypted = "AES:" + newPasswordHash + _ = conf.Save("config.hjson") + log.Debug("密码加密方案升级完成") + } + + ph, err := PasswordHashDecrypt(conf.PasswordEncrypted[4:], byteKey) + if err != nil { + log.Fatalf("加密存储的密码损坏,请尝试重新配置密码") + } + copy(global.PasswordHash[:], ph) + } else { + global.PasswordHash = md5.Sum([]byte(conf.Password)) + } + if !isFastStart { + log.Info("Bot将在5秒后登录并开始信息处理, 按 Ctrl+C 取消.") + time.Sleep(time.Second * 5) } - log.Info("Bot将在5秒后登录并开始信息处理, 按 Ctrl+C 取消.") - time.Sleep(time.Second * 5) log.Info("开始尝试登录并同步消息...") - cli := client.NewClient(conf.Uin, conf.Password) + log.Infof("使用协议: %v", func() string { + switch client.SystemDeviceInfo.Protocol { + case client.IPad: + return "iPad" + case client.AndroidPhone: + return "Android Phone" + case client.AndroidWatch: + return "Android Watch" + case client.MacOS: + return "MacOS" + } + return "未知" + }()) + cli := client.NewClientMd5(conf.Uin, global.PasswordHash) cli.OnLog(func(c *client.QQClient, e *client.LogEvent) { switch e.Type { case "INFO": @@ -224,131 +247,272 @@ func main() { log.Debug("Protocol -> " + e.Message) } }) - rsp, err := cli.Login() - for { - global.Check(err) - if !rsp.Success { - switch rsp.Error { - case client.NeedCaptcha: - _ = ioutil.WriteFile("captcha.jpg", rsp.CaptchaImage, 0644) - img, _, _ := image.Decode(bytes.NewReader(rsp.CaptchaImage)) - fmt.Println(asciiart.New("image", img).Art) - log.Warn("请输入验证码 (captcha.jpg): (Enter 提交)") - text, _ := console.ReadString('\n') - rsp, err = cli.SubmitCaptcha(strings.ReplaceAll(text, "\n", ""), rsp.CaptchaSign) - continue - case client.UnsafeDeviceError: - log.Warnf("账号已开启设备锁,请前往 -> %v <- 验证并重启Bot.", rsp.VerifyUrl) - log.Infof(" 按 Enter 继续....") - _, _ = console.ReadString('\n') - return - case client.OtherLoginError, client.UnknownLoginError: - log.Fatalf("登录失败: %v", rsp.ErrorMessage) - } + if global.PathExists("address.txt") { + log.Infof("检测到 address.txt 文件. 将覆盖目标IP.") + addr := global.ReadAddrFile("address.txt") + if len(addr) > 0 { + cli.SetCustomServer(addr) } - break + log.Infof("读取到 %v 个自定义地址.", len(addr)) } - log.Infof("登录成功 欢迎使用: %v", cli.Nickname) - time.Sleep(time.Second) - log.Info("开始加载好友列表...") - global.Check(cli.ReloadFriendList()) - log.Infof("共加载 %v 个好友.", len(cli.FriendList)) - log.Infof("开始加载群列表...") - global.Check(cli.ReloadGroupList()) - log.Infof("共加载 %v 个群.", len(cli.GroupList)) - b := coolq.NewQQBot(cli, conf) - if conf.PostMessageFormat != "string" && conf.PostMessageFormat != "array" { - log.Warnf("post_message_format 配置错误, 将自动使用 string") - coolq.SetMessageFormat("string") - } else { - coolq.SetMessageFormat(conf.PostMessageFormat) - } - if conf.RateLimit.Enabled { - global.InitLimiter(conf.RateLimit.Frequency, conf.RateLimit.BucketSize) - } - log.Info("正在加载事件过滤器.") - global.BootFilter() - coolq.IgnoreInvalidCQCode = conf.IgnoreInvalidCQCode - coolq.ForceFragmented = conf.ForceFragmented - if conf.HttpConfig != nil && conf.HttpConfig.Enabled { - server.HttpServer.Run(fmt.Sprintf("%s:%d", conf.HttpConfig.Host, conf.HttpConfig.Port), conf.AccessToken, b) - for k, v := range conf.HttpConfig.PostUrls { - server.NewHttpClient().Run(k, v, conf.HttpConfig.Timeout, b) + cli.OnServerUpdated(func(bot *client.QQClient, e *client.ServerUpdatedEvent) bool { + if !conf.UseSSOAddress { + log.Infof("收到服务器地址更新通知, 根据配置文件已忽略.") + return false } - } - if conf.WSConfig != nil && conf.WSConfig.Enabled { - server.WebsocketServer.Run(fmt.Sprintf("%s:%d", conf.WSConfig.Host, conf.WSConfig.Port), conf.AccessToken, b) - } - for _, rc := range conf.ReverseServers { - server.NewWebsocketClient(rc, conf.AccessToken, b).Run() - } - log.Info("资源初始化完成, 开始处理信息.") - log.Info("アトリは、高性能ですから!") - cli.OnDisconnected(func(bot *client.QQClient, e *client.ClientDisconnectedEvent) { - if conf.ReLogin.Enabled { - var times uint = 1 - for { - - if conf.ReLogin.MaxReloginTimes == 0 { - } else if times > conf.ReLogin.MaxReloginTimes { - break - } - log.Warnf("Bot已离线 (%v),将在 %v 秒后尝试重连. 重连次数:%v", - e.Message, conf.ReLogin.ReLoginDelay, times) - times++ - time.Sleep(time.Second * time.Duration(conf.ReLogin.ReLoginDelay)) - rsp, err := cli.Login() - if err != nil { - log.Errorf("重连失败: %v", err) - continue - } - if !rsp.Success { - switch rsp.Error { - case client.NeedCaptcha: - log.Fatalf("重连失败: 需要验证码. (验证码处理正在开发中)") - case client.UnsafeDeviceError: - log.Fatalf("重连失败: 设备锁") - default: - log.Errorf("重连失败: %v", rsp.ErrorMessage) - continue - } - } - log.Info("重连成功") - return - - } - log.Fatal("重连失败: 重连次数达到设置的上限值") - } - b.Release() - log.Fatalf("Bot已离线:%v", e.Message) + log.Infof("收到服务器地址更新通知, 将在下一次重连时应用. ") + return true }) - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt, os.Kill) - <-c - b.Release() -} - -func EncryptPwd(pwd string, key []byte) string { - tea := binary.NewTeaCipher(key) - if tea == nil { - return "" + if conf.WebUI == nil { + conf.WebUI = &global.GoCQWebUI{ + Enabled: true, + WebInput: false, + Host: "0.0.0.0", + WebUIPort: 9999, + } + } + if conf.WebUI.WebUIPort <= 0 { + conf.WebUI.WebUIPort = 9999 + } + if conf.WebUI.Host == "" { + conf.WebUI.Host = "127.0.0.1" + } + global.Proxy = conf.ProxyRewrite + b := server.WebServer.Run(fmt.Sprintf("%s:%d", conf.WebUI.Host, conf.WebUI.WebUIPort), cli) + c := server.Console + r := server.Restart + go checkUpdate() + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + select { + case <-c: + b.Release() + case <-r: + log.Info("正在重启中...") + defer b.Release() + restart(arg) } - return base64.StdEncoding.EncodeToString(tea.Encrypt([]byte(pwd))) } -func DecryptPwd(ePwd string, key []byte) string { +// PasswordHashEncrypt 使用key加密给定passwordHash +func PasswordHashEncrypt(passwordHash []byte, key []byte) string { + if len(passwordHash) != 16 { + panic("密码加密参数错误") + } + + key = pbkdf2.Key(key, key, 114514, 32, sha1.New) + + cipher, _ := aes.NewCipher(key) + result := make([]byte, 16) + cipher.Encrypt(result, passwordHash) + + return hex.EncodeToString(result) +} + +// PasswordHashDecrypt 使用key解密给定passwordHash +func PasswordHashDecrypt(encryptedPasswordHash string, key []byte) ([]byte, error) { + ciphertext, err := hex.DecodeString(encryptedPasswordHash) + if err != nil { + return nil, err + } + + key = pbkdf2.Key(key, key, 114514, 32, sha1.New) + + cipher, _ := aes.NewCipher(key) + result := make([]byte, 16) + cipher.Decrypt(result, ciphertext) + + return result, nil +} + +// OldPasswordDecrypt 使用key解密老password,仅供兼容使用 +func OldPasswordDecrypt(encryptedPassword string, key []byte) string { defer func() { if pan := recover(); pan != nil { log.Fatalf("密码解密失败: %v", pan) } }() - encrypted, err := base64.StdEncoding.DecodeString(ePwd) + encKey := md5.Sum(key) + encrypted, err := base64.StdEncoding.DecodeString(encryptedPassword) if err != nil { panic(err) } - tea := binary.NewTeaCipher(key) + tea := binary.NewTeaCipher(encKey[:]) if tea == nil { panic("密钥错误") } return string(tea.Decrypt(encrypted)) } + +func checkUpdate() { + log.Infof("正在检查更新.") + if coolq.Version == "unknown" { + log.Warnf("检查更新失败: 使用的 Actions 测试版或自编译版本.") + return + } + var res string + if err := gout.GET("https://api.github.com/repos/Mrs4s/go-cqhttp/releases").BindBody(&res).Do(); err != nil { + log.Warnf("检查更新失败: %v", err) + return + } + detail := gjson.Parse(res) + if len(detail.Array()) < 1 { + return + } + info := detail.Array()[0] + if global.VersionNameCompare(coolq.Version, info.Get("tag_name").Str) { + log.Infof("当前有更新的 go-cqhttp 可供更新, 请前往 https://github.com/Mrs4s/go-cqhttp/releases 下载.") + log.Infof("当前版本: %v 最新版本: %v", coolq.Version, info.Get("tag_name").Str) + return + } + log.Infof("检查更新完成. 当前已运行最新版本.") +} + +func selfUpdate(imageURL string) { + console := bufio.NewReader(os.Stdin) + readLine := func() (str string) { + str, _ = console.ReadString('\n') + return + } + log.Infof("正在检查更新.") + var res string + if err := gout.GET("https://api.github.com/repos/Mrs4s/go-cqhttp/releases").BindBody(&res).Do(); err != nil { + log.Warnf("检查更新失败: %v", err) + return + } + detail := gjson.Parse(res) + if len(detail.Array()) < 1 { + return + } + info := detail.Array()[0] + version := info.Get("tag_name").Str + if coolq.Version != version { + log.Info("当前最新版本为 ", version) + log.Warn("是否更新(y/N): ") + r := strings.TrimSpace(readLine()) + + doUpdate := func() { + log.Info("正在更新,请稍等...") + url := fmt.Sprintf( + "%v/Mrs4s/go-cqhttp/releases/download/%v/go-cqhttp-%v-%v-%v", + func() string { + if imageURL != "" { + return imageURL + } + return "https://github.com" + }(), + version, + version, + runtime.GOOS, + runtime.GOARCH, + ) + if runtime.GOOS == "windows" { + url = url + ".exe" + } + resp, err := http.Get(url) + if err != nil { + fmt.Println(err) + log.Error("更新失败!") + return + } + wc := global.WriteCounter{} + err, _ = global.UpdateFromStream(io.TeeReader(resp.Body, &wc)) + fmt.Println() + if err != nil { + log.Error("更新失败!") + return + } + log.Info("更新完成!") + } + + if r == "y" || r == "Y" { + doUpdate() + } else { + log.Warn("已取消更新!") + } + } else { + log.Info("当前版本已经是最新版本!") + } + log.Info("按 Enter 继续....") + readLine() + os.Exit(0) +} + +func restart(Args []string) { + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + file, err := exec.LookPath(Args[0]) + if err != nil { + log.Errorf("重启失败:%s", err.Error()) + return + } + path, err := filepath.Abs(file) + if err != nil { + log.Errorf("重启失败:%s", err.Error()) + } + Args = append([]string{"/c", "start ", path, "faststart"}, Args[1:]...) + cmd = &exec.Cmd{ + Path: "cmd.exe", + Args: Args, + Stderr: os.Stderr, + Stdout: os.Stdout, + } + } else { + Args = append(Args, "faststart") + cmd = &exec.Cmd{ + Path: Args[0], + Args: Args, + Stderr: os.Stderr, + Stdout: os.Stdout, + } + } + _ = cmd.Start() +} + +func getConfig() *global.JSONConfig { + var conf *global.JSONConfig + if global.PathExists("config.json") { + conf = global.LoadConfig("config.json") + _ = conf.Save("config.hjson") + _ = os.Remove("config.json") + } else if os.Getenv("UIN") != "" { + log.Infof("将从环境变量加载配置.") + uin, _ := strconv.ParseInt(os.Getenv("UIN"), 10, 64) + pwd := os.Getenv("PASS") + post := os.Getenv("HTTP_POST") + conf = &global.JSONConfig{ + Uin: uin, + Password: pwd, + HTTPConfig: &global.GoCQHTTPConfig{ + Enabled: true, + Host: "0.0.0.0", + Port: 5700, + PostUrls: map[string]string{}, + }, + WSConfig: &global.GoCQWebSocketConfig{ + Enabled: true, + Host: "0.0.0.0", + Port: 6700, + }, + PostMessageFormat: "string", + Debug: os.Getenv("DEBUG") == "true", + } + if post != "" { + conf.HTTPConfig.PostUrls[post] = os.Getenv("HTTP_SECRET") + } + } else { + conf = global.LoadConfig("config.hjson") + } + if conf == nil { + err := global.WriteAllText("config.hjson", global.DefaultConfigWithComments) + if err != nil { + log.Fatalf("创建默认配置文件时出现错误: %v", err) + return nil + } + log.Infof("默认配置文件已生成, 请编辑 config.hjson 后重启程序.") + if !isFastStart { + time.Sleep(time.Second * 5) + } + return nil + } + return conf +} diff --git a/server/apiAdmin.go b/server/apiAdmin.go new file mode 100644 index 0000000..c871e3d --- /dev/null +++ b/server/apiAdmin.go @@ -0,0 +1,630 @@ +package server + +import ( + "bufio" + "bytes" + "encoding/base64" + "fmt" + "image" + "io/ioutil" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/Mrs4s/MiraiGo/utils" + "github.com/gin-contrib/pprof" + + "github.com/Mrs4s/MiraiGo/client" + "github.com/Mrs4s/go-cqhttp/coolq" + "github.com/Mrs4s/go-cqhttp/global" + "github.com/gin-gonic/gin" + jsoniter "github.com/json-iterator/go" + log "github.com/sirupsen/logrus" + "github.com/tidwall/gjson" + asciiart "github.com/yinghau76/go-ascii-art" +) + +var json = jsoniter.ConfigCompatibleWithStandardLibrary + +var WebInput = make(chan string, 1) //长度1,用于阻塞 + +var Console = make(chan os.Signal, 1) + +var Restart = make(chan struct{}, 1) + +var JSONConfig *global.JSONConfig + +type webServer struct { + engine *gin.Engine + bot *coolq.CQBot + Cli *client.QQClient + Conf *global.JSONConfig //old config + Console *bufio.Reader +} + +var WebServer = &webServer{} + +// admin 子站的 路由映射 +var HttpuriAdmin = map[string]func(s *webServer, c *gin.Context){ + "do_restart": AdminDoRestart, //热重启 + "do_process_restart": AdminProcessRestart, //进程重启 + "get_web_write": AdminWebWrite, //获取是否验证码输入 + "do_web_write": AdminDoWebWrite, //web上进行输入操作 + "do_restart_docker": AdminDoRestartDocker, //直接停止(依赖supervisord/docker)重新拉起 + "do_config_base": AdminDoConfigBase, //修改config.json中的基础部分 + "do_config_http": AdminDoConfigHttp, //修改config.json的http部分 + "do_config_ws": AdminDoConfigWs, //修改config.json的正向ws部分 + "do_config_reverse": AdminDoConfigReverse, //修改config.json 中的反向ws部分 + "do_config_json": AdminDoConfigJson, //直接修改 config.json配置 + "get_config_json": AdminGetConfigJson, //拉取 当前的config.json配置 +} + +func Failed(code int, msg string) coolq.MSG { + return coolq.MSG{"data": nil, "retcode": code, "status": "failed", "msg": msg} +} + +func (s *webServer) Run(addr string, cli *client.QQClient) *coolq.CQBot { + s.Cli = cli + s.Conf = GetConf() + JSONConfig = s.Conf + gin.SetMode(gin.ReleaseMode) + s.engine = gin.New() + + s.engine.Use(AuthMiddleWare()) + + //通用路由 + s.engine.Any("/admin/:action", s.admin) + + go func() { + //开启端口监听 + if s.Conf.WebUI != nil && s.Conf.WebUI.Enabled { + if Debug { + pprof.Register(s.engine) + log.Debugf("pprof 性能分析服务已启动在 http://%v/debug/pprof, 如果有任何性能问题请下载报告并提交给开发者", addr) + time.Sleep(time.Second * 3) + } + log.Infof("Admin API 服务器已启动: %v", addr) + err := s.engine.Run(addr) + if err != nil { + log.Error(err) + log.Infof("请检查端口是否被占用.") + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + <-c + os.Exit(1) + } + } else { + //关闭端口监听 + c := make(chan os.Signal, 1) + signal.Notify(c, os.Interrupt, syscall.SIGTERM) + <-c + os.Exit(1) + } + }() + s.Dologin() + s.UpServer() + b := s.bot //外部引入 bot对象,用于操作bot + return b +} + +func (s *webServer) Dologin() { + s.Console = bufio.NewReader(os.Stdin) + readLine := func() (str string) { + str, _ = s.Console.ReadString('\n') + str = strings.TrimSpace(str) + return + } + conf := GetConf() + cli := s.Cli + cli.AllowSlider = true + rsp, err := cli.Login() + count := 0 + for { + global.Check(err) + var text string + if !rsp.Success { + switch rsp.Error { + case client.SliderNeededError: + log.Warnf("登录需要滑条验证码, 请选择解决方案: ") + log.Warnf("1. 自行抓包. (推荐)") + log.Warnf("2. 使用Cef自动处理.") + log.Warnf("3. 不提交滑块并继续.(可能会导致上网环境异常错误)") + log.Warnf("详细信息请参考文档 -> https://github.com/Mrs4s/go-cqhttp/blob/master/docs/slider.md <-") + log.Warn("请输入(1 - 3): ") + text = readLine() + if strings.Contains(text, "1") { + log.Warnf("请用浏览器打开 -> %v <- 并获取Ticket.", rsp.VerifyUrl) + log.Warn("请输入Ticket: (Enter 提交)") + text = readLine() + rsp, err = cli.SubmitTicket(strings.TrimSpace(text)) + continue + } + if strings.Contains(text, "3") { + cli.AllowSlider = false + cli.Disconnect() + rsp, err = cli.Login() + continue + } + id := utils.RandomStringRange(6, "0123456789") + log.Warnf("滑块ID为 %v 请在30S内处理.", id) + ticket, err := global.GetSliderTicket(rsp.VerifyUrl, id) + if err != nil { + log.Warnf("错误: " + err.Error()) + os.Exit(0) + } + rsp, err = cli.SubmitTicket(ticket) + if err != nil { + log.Warnf("错误: " + err.Error()) + os.Exit(0) + } + continue + case client.NeedCaptcha: + _ = ioutil.WriteFile("captcha.jpg", rsp.CaptchaImage, 0644) + img, _, _ := image.Decode(bytes.NewReader(rsp.CaptchaImage)) + fmt.Println(asciiart.New("image", img).Art) + if conf.WebUI != nil && conf.WebUI.WebInput { + log.Warnf("请输入验证码 (captcha.jpg): (http://%s:%d/admin/do_web_write 输入)", conf.WebUI.Host, conf.WebUI.WebUIPort) + text = <-WebInput + } else { + log.Warn("请输入验证码 (captcha.jpg): (Enter 提交)") + text = readLine() + } + rsp, err = cli.SubmitCaptcha(strings.ReplaceAll(text, "\n", ""), rsp.CaptchaSign) + global.DelFile("captcha.jpg") + continue + case client.SMSNeededError: + log.Warnf("账号已开启设备锁, 按下 Enter 向手机 %v 发送短信验证码.", rsp.SMSPhone) + readLine() + if !cli.RequestSMS() { + log.Warnf("发送验证码失败,可能是请求过于频繁.") + time.Sleep(time.Second * 5) + os.Exit(0) + } + log.Warn("请输入短信验证码: (Enter 提交)") + text = readLine() + rsp, err = cli.SubmitSMS(strings.ReplaceAll(strings.ReplaceAll(text, "\n", ""), "\r", "")) + continue + case client.SMSOrVerifyNeededError: + log.Warnf("账号已开启设备锁,请选择验证方式:") + log.Warnf("1. 向手机 %v 发送短信验证码", rsp.SMSPhone) + log.Warnf("2. 使用手机QQ扫码验证.") + log.Warn("请输入(1 - 2): ") + text = readLine() + if strings.Contains(text, "1") { + if !cli.RequestSMS() { + log.Warnf("发送验证码失败,可能是请求过于频繁.") + time.Sleep(time.Second * 5) + os.Exit(0) + } + log.Warn("请输入短信验证码: (Enter 提交)") + text = readLine() + rsp, err = cli.SubmitSMS(strings.ReplaceAll(strings.ReplaceAll(text, "\n", ""), "\r", "")) + continue + } + log.Warnf("请前往 -> %v <- 验证并重启Bot.", rsp.VerifyUrl) + log.Infof("按 Enter 继续....") + readLine() + os.Exit(0) + return + case client.UnsafeDeviceError: + log.Warnf("账号已开启设备锁,请前往 -> %v <- 验证并重启Bot.", rsp.VerifyUrl) + if conf.WebUI != nil && conf.WebUI.WebInput { + log.Infof(" (http://%s:%d/admin/do_web_write 确认后继续)....", conf.WebUI.Host, conf.WebUI.WebUIPort) + text = <-WebInput + } else { + log.Infof("按 Enter 继续....") + readLine() + } + log.Info(text) + os.Exit(0) + return + case client.OtherLoginError, client.UnknownLoginError: + msg := rsp.ErrorMessage + if strings.Contains(msg, "版本") { + msg = "密码错误或账号被冻结" + } + if strings.Contains(msg, "上网环境") && count < 5 { + cli.Disconnect() + rsp, err = cli.Login() + count++ + log.Warnf("错误: 当前上网环境异常. 将更换服务器并重试.") + time.Sleep(time.Second) + continue + } + log.Warnf("登录失败: %v", msg) + log.Infof("按 Enter 继续....") + readLine() + os.Exit(0) + return + } + } + break + } + log.Infof("登录成功 欢迎使用: %v", cli.Nickname) + time.Sleep(time.Second) + log.Info("开始加载好友列表...") + global.Check(cli.ReloadFriendList()) + log.Infof("共加载 %v 个好友.", len(cli.FriendList)) + log.Infof("开始加载群列表...") + global.Check(cli.ReloadGroupList()) + log.Infof("共加载 %v 个群.", len(cli.GroupList)) + s.bot = coolq.NewQQBot(cli, conf) + if conf.PostMessageFormat != "string" && conf.PostMessageFormat != "array" { + log.Warnf("post_message_format 配置错误, 将自动使用 string") + coolq.SetMessageFormat("string") + } else { + coolq.SetMessageFormat(conf.PostMessageFormat) + } + if conf.RateLimit.Enabled { + global.InitLimiter(conf.RateLimit.Frequency, conf.RateLimit.BucketSize) + } + log.Info("正在加载事件过滤器.") + global.BootFilter() + global.InitCodec() + coolq.IgnoreInvalidCQCode = conf.IgnoreInvalidCQCode + coolq.SplitUrl = conf.FixURL + coolq.ForceFragmented = conf.ForceFragmented + log.Info("资源初始化完成, 开始处理信息.") + log.Info("アトリは、高性能ですから!") + cli.OnDisconnected(func(bot *client.QQClient, e *client.ClientDisconnectedEvent) { + if conf.ReLogin.Enabled { + conf.ReLogin.Enabled = false + defer func() { conf.ReLogin.Enabled = true }() + var times uint = 1 + for { + if cli.Online { + log.Warn("Bot已登录") + return + } + if times > conf.ReLogin.MaxReloginTimes && conf.ReLogin.MaxReloginTimes != 0 { + break + } + log.Warnf("Bot已离线 (%v),将在 %v 秒后尝试重连. 重连次数:%v", + e.Message, conf.ReLogin.ReLoginDelay, times) + times++ + time.Sleep(time.Second * time.Duration(conf.ReLogin.ReLoginDelay)) + rsp, err := cli.Login() + if err != nil { + log.Errorf("重连失败: %v", err) + cli.Disconnect() + continue + } + if !rsp.Success { + switch rsp.Error { + case client.NeedCaptcha: + log.Fatalf("重连失败: 需要验证码. (验证码处理正在开发中)") + case client.UnsafeDeviceError: + log.Fatalf("重连失败: 设备锁") + default: + log.Errorf("重连失败: %v", rsp.ErrorMessage) + if strings.Contains(rsp.ErrorMessage, "冻结") { + log.Fatalf("账号被冻结, 放弃重连") + } + cli.Disconnect() + continue + } + } + log.Info("重连成功") + return + } + log.Fatal("重连失败: 重连次数达到设置的上限值") + } + s.bot.Release() + log.Fatalf("Bot已离线:%v", e.Message) + }) +} + +func (s *webServer) admin(c *gin.Context) { + action := c.Param("action") + log.Debugf("WebServer接收到cgi调用: %v", action) + if f, ok := HttpuriAdmin[action]; ok { + f(s, c) + } else { + c.JSON(200, coolq.Failed(404)) + } +} + +// 获取当前配置文件信息 +func GetConf() *global.JSONConfig { + if JSONConfig != nil { + return JSONConfig + } + conf := global.LoadConfig("config.hjson") + return conf +} + +// admin 控制器 登录验证 +func AuthMiddleWare() gin.HandlerFunc { + return func(c *gin.Context) { + conf := GetConf() + //处理跨域问题 + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Headers", "Content-Type,AccessToken,X-CSRF-Token, Authorization, Token") + c.Header("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, PATCH, DELETE") + c.Header("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type") + c.Header("Access-Control-Allow-Credentials", "true") + // 放行所有OPTIONS方法,因为有的模板是要请求两次的 + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + } + if strings.Contains(c.Request.URL.Path, "debug") { + c.Next() + return + } + // 处理请求 + if c.Request.Method != "GET" && c.Request.Method != "POST" { + log.Warnf("已拒绝客户端 %v 的请求: 方法错误", c.Request.RemoteAddr) + c.Status(404) + c.Abort() + } + if c.Request.Method == "POST" && strings.Contains(c.Request.Header.Get("Content-Type"), "application/json") { + d, err := c.GetRawData() + if err != nil { + log.Warnf("获取请求 %v 的Body时出现错误: %v", c.Request.RequestURI, err) + c.Status(400) + c.Abort() + } + if !gjson.ValidBytes(d) { + log.Warnf("已拒绝客户端 %v 的请求: 非法Json", c.Request.RemoteAddr) + c.Status(400) + c.Abort() + } + c.Set("json_body", gjson.ParseBytes(d)) + } + authToken := conf.AccessToken + if auth := c.Request.Header.Get("Authorization"); auth != "" { + if strings.SplitN(auth, " ", 2)[1] != authToken { + c.AbortWithStatus(401) + return + } + } else if c.Query("access_token") != authToken { + c.AbortWithStatus(401) + return + } else { + c.Next() + } + } +} + +func (s *webServer) DoReLogin() { // TODO: 协议层的 ReLogin + JSONConfig = nil + conf := GetConf() + OldConf := s.Conf + cli := client.NewClient(conf.Uin, conf.Password) + log.Info("开始尝试登录并同步消息...") + log.Infof("使用协议: %v", func() string { + switch client.SystemDeviceInfo.Protocol { + case client.IPad: + return "iPad" + case client.AndroidPhone: + return "Android Phone" + case client.AndroidWatch: + return "Android Watch" + case client.MacOS: + return "MacOS" + } + return "未知" + }()) + cli.OnLog(func(c *client.QQClient, e *client.LogEvent) { + switch e.Type { + case "INFO": + log.Info("Protocol -> " + e.Message) + case "ERROR": + log.Error("Protocol -> " + e.Message) + case "DEBUG": + log.Debug("Protocol -> " + e.Message) + } + }) + cli.OnServerUpdated(func(bot *client.QQClient, e *client.ServerUpdatedEvent) bool { + if !conf.UseSSOAddress { + log.Infof("收到服务器地址更新通知, 根据配置文件已忽略.") + return false + } + log.Infof("收到服务器地址更新通知, 将在下一次重连时应用. ") + return true + }) + s.Cli = cli + s.Dologin() + //关闭之前的 server + if OldConf.HTTPConfig != nil && OldConf.HTTPConfig.Enabled { + HttpServer.ShutDown() + } + //if OldConf.WSConfig != nil && OldConf.WSConfig.Enabled { + // server.WsShutdown() + //} + //s.UpServer() + s.ReloadServer() + s.Conf = conf +} + +func (s *webServer) UpServer() { + conf := GetConf() + if conf.HTTPConfig != nil && conf.HTTPConfig.Enabled { + go HttpServer.Run(fmt.Sprintf("%s:%d", conf.HTTPConfig.Host, conf.HTTPConfig.Port), conf.AccessToken, s.bot) + for k, v := range conf.HTTPConfig.PostUrls { + NewHttpClient().Run(k, v, conf.HTTPConfig.Timeout, s.bot) + } + } + if conf.WSConfig != nil && conf.WSConfig.Enabled { + go WebSocketServer.Run(fmt.Sprintf("%s:%d", conf.WSConfig.Host, conf.WSConfig.Port), conf.AccessToken, s.bot) + } + for _, rc := range conf.ReverseServers { + go NewWebSocketClient(rc, conf.AccessToken, s.bot).Run() + } +} + +// 暂不支持ws服务的重启 +func (s *webServer) ReloadServer() { + conf := GetConf() + if conf.HTTPConfig != nil && conf.HTTPConfig.Enabled { + go HttpServer.Run(fmt.Sprintf("%s:%d", conf.HTTPConfig.Host, conf.HTTPConfig.Port), conf.AccessToken, s.bot) + for k, v := range conf.HTTPConfig.PostUrls { + NewHttpClient().Run(k, v, conf.HTTPConfig.Timeout, s.bot) + } + } + for _, rc := range conf.ReverseServers { + go NewWebSocketClient(rc, conf.AccessToken, s.bot).Run() + } +} + +// 热重启 +func AdminDoRestart(s *webServer, c *gin.Context) { + s.bot.Release() + s.bot = nil + s.Cli = nil + s.DoReLogin() + c.JSON(200, coolq.OK(coolq.MSG{})) +} + +// 进程重启 +func AdminProcessRestart(s *webServer, c *gin.Context) { + Restart <- struct{}{} + c.JSON(200, coolq.OK(coolq.MSG{})) +} + +// 冷重启 +func AdminDoRestartDocker(s *webServer, c *gin.Context) { + Console <- os.Kill + c.JSON(200, coolq.OK(coolq.MSG{})) +} + +// web输入 html 页面 +func AdminWebWrite(s *webServer, c *gin.Context) { + pic := global.ReadAllText("captcha.jpg") + var picbase64 string + var ispic = false + if pic != "" { + input := []byte(pic) + // base64编码 + picbase64 = base64.StdEncoding.EncodeToString(input) + ispic = true + } + c.JSON(200, coolq.OK(coolq.MSG{ + "ispic": ispic, //为空则为 设备锁 或者没有需要输入 + "picbase64": picbase64, //web上显示图片 + })) +} + +// web输入 处理 +func AdminDoWebWrite(s *webServer, c *gin.Context) { + input := c.PostForm("input") + WebInput <- input + c.JSON(200, coolq.OK(coolq.MSG{})) +} + +// 普通配置修改 +func AdminDoConfigBase(s *webServer, c *gin.Context) { + conf := GetConf() + conf.Uin, _ = strconv.ParseInt(c.PostForm("uin"), 10, 64) + conf.Password = c.PostForm("password") + if c.PostForm("enable_db") == "true" { + conf.EnableDB = true + } else { + conf.EnableDB = false + } + conf.AccessToken = c.PostForm("access_token") + if err := conf.Save("config.hjson"); err != nil { + log.Fatalf("保存 config.hjson 时出现错误: %v", err) + c.JSON(200, Failed(502, "保存 config.hjson 时出现错误:"+fmt.Sprintf("%v", err))) + } else { + JSONConfig = nil + c.JSON(200, coolq.OK(coolq.MSG{})) + } +} + +// http配置修改 +func AdminDoConfigHttp(s *webServer, c *gin.Context) { + conf := GetConf() + p, _ := strconv.ParseUint(c.PostForm("port"), 10, 16) + conf.HTTPConfig.Port = uint16(p) + conf.HTTPConfig.Host = c.PostForm("host") + if c.PostForm("enable") == "true" { + conf.HTTPConfig.Enabled = true + } else { + conf.HTTPConfig.Enabled = false + } + t, _ := strconv.ParseInt(c.PostForm("timeout"), 10, 32) + conf.HTTPConfig.Timeout = int32(t) + if c.PostForm("post_url") != "" { + conf.HTTPConfig.PostUrls[c.PostForm("post_url")] = c.PostForm("post_secret") + } + if err := conf.Save("config.hjson"); err != nil { + log.Fatalf("保存 config.hjson 时出现错误: %v", err) + c.JSON(200, Failed(502, "保存 config.hjson 时出现错误:"+fmt.Sprintf("%v", err))) + } else { + JSONConfig = nil + c.JSON(200, coolq.OK(coolq.MSG{})) + } +} + +// ws配置修改 +func AdminDoConfigWs(s *webServer, c *gin.Context) { + conf := GetConf() + p, _ := strconv.ParseUint(c.PostForm("port"), 10, 16) + conf.WSConfig.Port = uint16(p) + conf.WSConfig.Host = c.PostForm("host") + if c.PostForm("enable") == "true" { + conf.WSConfig.Enabled = true + } else { + conf.WSConfig.Enabled = false + } + if err := conf.Save("config.hjson"); err != nil { + log.Fatalf("保存 config.hjson 时出现错误: %v", err) + c.JSON(200, Failed(502, "保存 config.hjson 时出现错误:"+fmt.Sprintf("%v", err))) + } else { + JSONConfig = nil + c.JSON(200, coolq.OK(coolq.MSG{})) + } +} + +// 反向ws配置修改 +func AdminDoConfigReverse(s *webServer, c *gin.Context) { + conf := GetConf() + conf.ReverseServers[0].ReverseAPIURL = c.PostForm("reverse_api_url") + conf.ReverseServers[0].ReverseURL = c.PostForm("reverse_url") + conf.ReverseServers[0].ReverseEventURL = c.PostForm("reverse_event_url") + t, _ := strconv.ParseUint(c.PostForm("reverse_reconnect_interval"), 10, 16) + conf.ReverseServers[0].ReverseReconnectInterval = uint16(t) + if c.PostForm("enable") == "true" { + conf.ReverseServers[0].Enabled = true + } else { + conf.ReverseServers[0].Enabled = false + } + if err := conf.Save("config.hjson"); err != nil { + log.Fatalf("保存 config.hjson 时出现错误: %v", err) + c.JSON(200, Failed(502, "保存 config.hjson 时出现错误:"+fmt.Sprintf("%v", err))) + } else { + JSONConfig = nil + c.JSON(200, coolq.OK(coolq.MSG{})) + } +} + +// config.json配置修改 +func AdminDoConfigJson(s *webServer, c *gin.Context) { + conf := GetConf() + Json := c.PostForm("json") + err := json.Unmarshal([]byte(Json), &conf) + if err != nil { + log.Warnf("尝试加载配置文件 %v 时出现错误: %v", "config.hjson", err) + c.JSON(200, Failed(502, "保存 config.hjson 时出现错误:"+fmt.Sprintf("%v", err))) + return + } + if err := conf.Save("config.hjson"); err != nil { + log.Fatalf("保存 config.hjson 时出现错误: %v", err) + c.JSON(200, Failed(502, "保存 config.hjson 时出现错误:"+fmt.Sprintf("%v", err))) + } else { + JSONConfig = nil + c.JSON(200, coolq.OK(coolq.MSG{})) + } +} + +// 拉取config.json配置 +func AdminGetConfigJson(s *webServer, c *gin.Context) { + conf := GetConf() + c.JSON(200, coolq.OK(coolq.MSG{"config": conf})) + +} diff --git a/server/http.go b/server/http.go index 9b10904..2ddfae2 100644 --- a/server/http.go +++ b/server/http.go @@ -5,11 +5,14 @@ import ( "crypto/hmac" "crypto/sha1" "encoding/hex" + "net/http" "os" "strconv" "strings" "time" + "github.com/guonaihong/gout/dataflow" + "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/global" "github.com/gin-gonic/gin" @@ -21,6 +24,7 @@ import ( type httpServer struct { engine *gin.Engine bot *coolq.CQBot + Http *http.Server } type httpClient struct { @@ -31,6 +35,7 @@ type httpClient struct { } var HttpServer = &httpServer{} +var Debug = false func (s *httpServer) Run(addr, authToken string, bot *coolq.CQBot) { gin.SetMode(gin.ReleaseMode) @@ -79,10 +84,14 @@ func (s *httpServer) Run(addr, authToken string, bot *coolq.CQBot) { go func() { log.Infof("CQ HTTP 服务器已启动: %v", addr) - err := s.engine.Run(addr) - if err != nil { + s.Http = &http.Server{ + Addr: addr, + Handler: s.engine, + } + if err := s.Http.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Error(err) - log.Infof("请检查端口是否被占用.") + log.Infof("HTTP 服务启动失败, 请检查端口是否被占用.") + log.Warnf("将在五秒后退出.") time.Sleep(time.Second * 5) os.Exit(1) } @@ -114,15 +123,28 @@ func (c *httpClient) onBotPushEvent(m coolq.MSG) { } if c.secret != "" { mac := hmac.New(sha1.New, []byte(c.secret)) - mac.Write([]byte(m.ToJson())) + _, err := mac.Write([]byte(m.ToJson())) + if err != nil { + log.Error(err) + return nil + } h["X-Signature"] = "sha1=" + hex.EncodeToString(mac.Sum(nil)) } return h - }()).SetTimeout(time.Second * time.Duration(c.timeout)).Do() + }()).SetTimeout(time.Second * time.Duration(c.timeout)).F().Retry().Attempt(5). + WaitTime(time.Millisecond * 500).MaxWaitTime(time.Second * 5). + Func(func(con *dataflow.Context) error { + if con.Error != nil { + log.Warnf("上报Event到 HTTP 服务器 %v 时出现错误: %v 将重试.", c.addr, con.Error) + return con.Error + } + return nil + }).Do() if err != nil { log.Warnf("上报Event数据 %v 到 %v 失败: %v", m.ToJson(), c.addr, err) return } + log.Debugf("上报Event数据 %v 到 %v", m.ToJson(), c.addr) if gjson.Valid(res) { c.bot.CQHandleQuickOperation(gjson.Parse(m.ToJson()), gjson.Parse(res)) } @@ -139,55 +161,84 @@ func (s *httpServer) HandleActions(c *gin.Context) { } } -func (s *httpServer) GetLoginInfo(c *gin.Context) { +func GetLoginInfo(s *httpServer, c *gin.Context) { c.JSON(200, s.bot.CQGetLoginInfo()) } -func (s *httpServer) GetFriendList(c *gin.Context) { +func GetFriendList(s *httpServer, c *gin.Context) { c.JSON(200, s.bot.CQGetFriendList()) } -func (s *httpServer) GetGroupList(c *gin.Context) { +func GetGroupList(s *httpServer, c *gin.Context) { nc := getParamOrDefault(c, "no_cache", "false") c.JSON(200, s.bot.CQGetGroupList(nc == "true")) } -func (s *httpServer) GetGroupInfo(c *gin.Context) { +func GetGroupInfo(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) - c.JSON(200, s.bot.CQGetGroupInfo(gid)) + nc := getParamOrDefault(c, "no_cache", "false") + c.JSON(200, s.bot.CQGetGroupInfo(gid, nc == "true")) } -func (s *httpServer) GetGroupMemberList(c *gin.Context) { +func GetGroupMemberList(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) nc := getParamOrDefault(c, "no_cache", "false") c.JSON(200, s.bot.CQGetGroupMemberList(gid, nc == "true")) } -func (s *httpServer) GetGroupMemberInfo(c *gin.Context) { +func GetGroupMemberInfo(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) uid, _ := strconv.ParseInt(getParam(c, "user_id"), 10, 64) c.JSON(200, s.bot.CQGetGroupMemberInfo(gid, uid)) } -func (s *httpServer) SendMessage(c *gin.Context) { +func GetGroupFileSystemInfo(s *httpServer, c *gin.Context) { + gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) + c.JSON(200, s.bot.CQGetGroupFileSystemInfo(gid)) +} + +func GetGroupRootFiles(s *httpServer, c *gin.Context) { + gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) + c.JSON(200, s.bot.CQGetGroupRootFiles(gid)) +} + +func GetGroupFilesByFolderId(s *httpServer, c *gin.Context) { + gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) + folderId := getParam(c, "folder_id") + c.JSON(200, s.bot.CQGetGroupFilesByFolderId(gid, folderId)) +} + +func GetGroupFileUrl(s *httpServer, c *gin.Context) { + gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) + fid := getParam(c, "file_id") + busid, _ := strconv.ParseInt(getParam(c, "busid"), 10, 32) + c.JSON(200, s.bot.CQGetGroupFileUrl(gid, fid, int32(busid))) +} + +func UploadGroupFile(s *httpServer, c *gin.Context) { + gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) + c.JSON(200, s.bot.CQUploadGroupFile(gid, getParam(c, "file"), getParam(c, "name"), getParam(c, "folder"))) +} + +func SendMessage(s *httpServer, c *gin.Context) { if getParam(c, "message_type") == "private" { - s.SendPrivateMessage(c) + SendPrivateMessage(s, c) return } if getParam(c, "message_type") == "group" { - s.SendGroupMessage(c) + SendGroupMessage(s, c) return } if getParam(c, "group_id") != "" { - s.SendGroupMessage(c) + SendGroupMessage(s, c) return } if getParam(c, "user_id") != "" { - s.SendPrivateMessage(c) + SendPrivateMessage(s, c) } } -func (s *httpServer) SendPrivateMessage(c *gin.Context) { +func SendPrivateMessage(s *httpServer, c *gin.Context) { uid, _ := strconv.ParseInt(getParam(c, "user_id"), 10, 64) msg, t := getParamWithType(c, "message") autoEscape := global.EnsureBool(getParam(c, "auto_escape"), false) @@ -198,7 +249,7 @@ func (s *httpServer) SendPrivateMessage(c *gin.Context) { c.JSON(200, s.bot.CQSendPrivateMessage(uid, msg, autoEscape)) } -func (s *httpServer) SendGroupMessage(c *gin.Context) { +func SendGroupMessage(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) msg, t := getParamWithType(c, "message") autoEscape := global.EnsureBool(getParam(c, "auto_escape"), false) @@ -209,34 +260,34 @@ func (s *httpServer) SendGroupMessage(c *gin.Context) { c.JSON(200, s.bot.CQSendGroupMessage(gid, msg, autoEscape)) } -func (s *httpServer) SendGroupForwardMessage(c *gin.Context) { +func SendGroupForwardMessage(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) msg := getParam(c, "messages") c.JSON(200, s.bot.CQSendGroupForwardMessage(gid, gjson.Parse(msg))) } -func (s *httpServer) GetImage(c *gin.Context) { +func GetImage(s *httpServer, c *gin.Context) { file := getParam(c, "file") c.JSON(200, s.bot.CQGetImage(file)) } -func (s *httpServer) GetGroupMessage(c *gin.Context) { +func GetMessage(s *httpServer, c *gin.Context) { mid, _ := strconv.ParseInt(getParam(c, "message_id"), 10, 32) - c.JSON(200, s.bot.CQGetGroupMessage(int32(mid))) + c.JSON(200, s.bot.CQGetMessage(int32(mid))) } -func (s *httpServer) GetGroupHonorInfo(c *gin.Context) { +func GetGroupHonorInfo(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) c.JSON(200, s.bot.CQGetGroupHonorInfo(gid, getParam(c, "type"))) } -func (s *httpServer) ProcessFriendRequest(c *gin.Context) { +func ProcessFriendRequest(s *httpServer, c *gin.Context) { flag := getParam(c, "flag") approve := getParamOrDefault(c, "approve", "true") c.JSON(200, s.bot.CQProcessFriendRequest(flag, approve == "true")) } -func (s *httpServer) ProcessGroupRequest(c *gin.Context) { +func ProcessGroupRequest(s *httpServer, c *gin.Context) { flag := getParam(c, "flag") subType := getParam(c, "sub_type") if subType == "" { @@ -246,94 +297,146 @@ func (s *httpServer) ProcessGroupRequest(c *gin.Context) { c.JSON(200, s.bot.CQProcessGroupRequest(flag, subType, getParam(c, "reason"), approve == "true")) } -func (s *httpServer) SetGroupCard(c *gin.Context) { +func SetGroupCard(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) uid, _ := strconv.ParseInt(getParam(c, "user_id"), 10, 64) c.JSON(200, s.bot.CQSetGroupCard(gid, uid, getParam(c, "card"))) } -func (s *httpServer) SetSpecialTitle(c *gin.Context) { +func SetSpecialTitle(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) uid, _ := strconv.ParseInt(getParam(c, "user_id"), 10, 64) c.JSON(200, s.bot.CQSetGroupSpecialTitle(gid, uid, getParam(c, "special_title"))) } -func (s *httpServer) SetGroupKick(c *gin.Context) { +func SetGroupKick(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) uid, _ := strconv.ParseInt(getParam(c, "user_id"), 10, 64) msg := getParam(c, "message") - c.JSON(200, s.bot.CQSetGroupKick(gid, uid, msg)) + block := getParamOrDefault(c, "reject_add_request", "false") + c.JSON(200, s.bot.CQSetGroupKick(gid, uid, msg, block == "true")) } -func (s *httpServer) SetGroupBan(c *gin.Context) { +func SetGroupBan(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) uid, _ := strconv.ParseInt(getParam(c, "user_id"), 10, 64) i, _ := strconv.ParseInt(getParamOrDefault(c, "duration", "1800"), 10, 64) c.JSON(200, s.bot.CQSetGroupBan(gid, uid, uint32(i))) } -func (s *httpServer) SetWholeBan(c *gin.Context) { +func SetWholeBan(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) c.JSON(200, s.bot.CQSetGroupWholeBan(gid, getParamOrDefault(c, "enable", "true") == "true")) } -func (s *httpServer) SetGroupName(c *gin.Context) { +func SetGroupName(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) c.JSON(200, s.bot.CQSetGroupName(gid, getParam(c, "group_name"))) } -func (s *httpServer) SetGroupAdmin(c *gin.Context) { +func SetGroupAdmin(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) uid, _ := strconv.ParseInt(getParam(c, "user_id"), 10, 64) c.JSON(200, s.bot.CQSetGroupAdmin(gid, uid, getParamOrDefault(c, "enable", "true") == "true")) } -func (s *httpServer) SendGroupNotice(c *gin.Context) { +func SendGroupNotice(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) c.JSON(200, s.bot.CQSetGroupMemo(gid, getParam(c, "content"))) } -func (s *httpServer) SetGroupLeave(c *gin.Context) { +func SetGroupLeave(s *httpServer, c *gin.Context) { gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) c.JSON(200, s.bot.CQSetGroupLeave(gid)) } -func (s *httpServer) GetForwardMessage(c *gin.Context) { +func SetRestart(s *httpServer, c *gin.Context) { + delay, _ := strconv.ParseInt(getParam(c, "delay"), 10, 64) + c.JSON(200, coolq.MSG{"data": nil, "retcode": 0, "status": "async"}) + go func(delay int64) { + time.Sleep(time.Duration(delay) * time.Millisecond) + Restart <- struct{}{} + }(delay) + +} + +func GetForwardMessage(s *httpServer, c *gin.Context) { resId := getParam(c, "message_id") + if resId == "" { + resId = getParam(c, "id") + } c.JSON(200, s.bot.CQGetForwardMessage(resId)) } -func (s *httpServer) DeleteMessage(c *gin.Context) { +func GetGroupSystemMessage(s *httpServer, c *gin.Context) { + c.JSON(200, s.bot.CQGetGroupSystemMessages()) +} + +func DeleteMessage(s *httpServer, c *gin.Context) { mid, _ := strconv.ParseInt(getParam(c, "message_id"), 10, 32) c.JSON(200, s.bot.CQDeleteMessage(int32(mid))) } -func (s *httpServer) CanSendImage(c *gin.Context) { +func CanSendImage(s *httpServer, c *gin.Context) { c.JSON(200, s.bot.CQCanSendImage()) } -func (s *httpServer) CanSendRecord(c *gin.Context) { +func CanSendRecord(s *httpServer, c *gin.Context) { c.JSON(200, s.bot.CQCanSendRecord()) } -func (s *httpServer) GetStatus(c *gin.Context) { +func GetStatus(s *httpServer, c *gin.Context) { c.JSON(200, s.bot.CQGetStatus()) } -func (s *httpServer) GetVersionInfo(c *gin.Context) { +func GetVersionInfo(s *httpServer, c *gin.Context) { c.JSON(200, s.bot.CQGetVersionInfo()) } -func (s *httpServer) ReloadEventFilter(c *gin.Context) { +func ReloadEventFilter(s *httpServer, c *gin.Context) { c.JSON(200, s.bot.CQReloadEventFilter()) } -func (s *httpServer) GetVipInfo(c *gin.Context) { +func GetVipInfo(s *httpServer, c *gin.Context) { uid, _ := strconv.ParseInt(getParam(c, "user_id"), 10, 64) c.JSON(200, s.bot.CQGetVipInfo(uid)) } -func (s *httpServer) HandleQuickOperation(c *gin.Context) { +func GetStrangerInfo(s *httpServer, c *gin.Context) { + uid, _ := strconv.ParseInt(getParam(c, "user_id"), 10, 64) + c.JSON(200, s.bot.CQGetStrangerInfo(uid)) +} + +func GetGroupAtAllRemain(s *httpServer, c *gin.Context) { + gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) + c.JSON(200, s.bot.CQGetAtAllRemain(gid)) +} + +func SetGroupAnonymousBan(s *httpServer, c *gin.Context) { + gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) + d, _ := strconv.ParseInt(getParam(c, "duration"), 10, 64) + flag := getParam(c, "flag") + if flag == "" { + flag = getParam(c, "anonymous_flag") + } + if flag == "" { + o := gjson.Parse(getParam(c, "anonymous")) + flag = o.Get("flag").String() + } + c.JSON(200, s.bot.CQSetGroupAnonymousBan(gid, flag, int32(d))) +} + +func GetGroupMessageHistory(s *httpServer, c *gin.Context) { + gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) + seq, _ := strconv.ParseInt(getParam(c, "message_seq"), 10, 64) + c.JSON(200, s.bot.CQGetGroupMessageHistory(gid, seq)) +} + +func GetOnlineClients(s *httpServer, c *gin.Context) { + c.JSON(200, s.bot.CQGetOnlineClients(getParamOrDefault(c, "no_cache", "false") == "true")) +} + +func HandleQuickOperation(s *httpServer, c *gin.Context) { if c.Request.Method != "POST" { c.AbortWithStatus(404) return @@ -344,6 +447,69 @@ func (s *httpServer) HandleQuickOperation(c *gin.Context) { } } +func DownloadFile(s *httpServer, c *gin.Context) { + url := getParam(c, "url") + tc, _ := strconv.Atoi(getParam(c, "thread_count")) + h, t := getParamWithType(c, "headers") + headers := map[string]string{} + if t == gjson.Null || t == gjson.String { + lines := strings.Split(h, "\r\n") + for _, sub := range lines { + str := strings.SplitN(sub, "=", 2) + if len(str) == 2 { + headers[str[0]] = str[1] + } + } + } + if t == gjson.JSON { + arr := gjson.Parse(h) + for _, sub := range arr.Array() { + str := strings.SplitN(sub.String(), "=", 2) + if len(str) == 2 { + headers[str[0]] = str[1] + } + } + } + println(url, tc, h, t) + c.JSON(200, s.bot.CQDownloadFile(url, headers, tc)) +} + +func OcrImage(s *httpServer, c *gin.Context) { + img := getParam(c, "image") + c.JSON(200, s.bot.CQOcrImage(img)) +} + +func GetWordSlices(s *httpServer, c *gin.Context) { + content := getParam(c, "content") + c.JSON(200, s.bot.CQGetWordSlices(content)) +} + +func SetGroupPortrait(s *httpServer, c *gin.Context) { + gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) + file := getParam(c, "file") + cache := getParam(c, "cache") + c.JSON(200, s.bot.CQSetGroupPortrait(gid, file, cache)) +} + +func SetEssenceMsg(s *httpServer, c *gin.Context) { + mid, _ := strconv.ParseInt(getParam(c, "message_id"), 10, 64) + c.JSON(200, s.bot.CQSetEssenceMessage(int32(mid))) +} + +func DeleteEssenceMsg(s *httpServer, c *gin.Context) { + mid, _ := strconv.ParseInt(getParam(c, "message_id"), 10, 64) + c.JSON(200, s.bot.CQDeleteEssenceMessage(int32(mid))) +} + +func GetEssenceMsgList(s *httpServer, c *gin.Context) { + gid, _ := strconv.ParseInt(getParam(c, "group_id"), 10, 64) + c.JSON(200, s.bot.CQGetEssenceMessageList(gid)) +} + +func CheckUrlSafely(s *httpServer, c *gin.Context) { + c.JSON(200, s.bot.CQCheckUrlSafely(getParam(c, "url"))) +} + func getParamOrDefault(c *gin.Context, k, def string) string { r := getParam(c, k) if r != "" { @@ -393,103 +559,69 @@ func getParamWithType(c *gin.Context, k string) (string, gjson.Type) { } var httpApi = map[string]func(s *httpServer, c *gin.Context){ - "get_login_info": func(s *httpServer, c *gin.Context) { - s.GetLoginInfo(c) - }, - "get_friend_list": func(s *httpServer, c *gin.Context) { - s.GetFriendList(c) - }, - "get_group_list": func(s *httpServer, c *gin.Context) { - s.GetGroupList(c) - }, - "get_group_info": func(s *httpServer, c *gin.Context) { - s.GetGroupInfo(c) - }, - "get_group_member_list": func(s *httpServer, c *gin.Context) { - s.GetGroupMemberList(c) - }, - "get_group_member_info": func(s *httpServer, c *gin.Context) { - s.GetGroupMemberInfo(c) - }, - "send_msg": func(s *httpServer, c *gin.Context) { - s.SendMessage(c) - }, - "send_group_msg": func(s *httpServer, c *gin.Context) { - s.SendGroupMessage(c) - }, - "send_group_forward_msg": func(s *httpServer, c *gin.Context) { - s.SendGroupForwardMessage(c) - }, - "send_private_msg": func(s *httpServer, c *gin.Context) { - s.SendPrivateMessage(c) - }, - "delete_msg": func(s *httpServer, c *gin.Context) { - s.DeleteMessage(c) - }, - "set_friend_add_request": func(s *httpServer, c *gin.Context) { - s.ProcessFriendRequest(c) - }, - "set_group_add_request": func(s *httpServer, c *gin.Context) { - s.ProcessGroupRequest(c) - }, - "set_group_card": func(s *httpServer, c *gin.Context) { - s.SetGroupCard(c) - }, - "set_group_special_title": func(s *httpServer, c *gin.Context) { - s.SetSpecialTitle(c) - }, - "set_group_kick": func(s *httpServer, c *gin.Context) { - s.SetGroupKick(c) - }, - "set_group_ban": func(s *httpServer, c *gin.Context) { - s.SetGroupBan(c) - }, - "set_group_whole_ban": func(s *httpServer, c *gin.Context) { - s.SetWholeBan(c) - }, - "set_group_name": func(s *httpServer, c *gin.Context) { - s.SetGroupName(c) - }, - "set_group_admin": func(s *httpServer, c *gin.Context) { - s.SetGroupAdmin(c) - }, - "_send_group_notice": func(s *httpServer, c *gin.Context) { - s.SendGroupNotice(c) - }, - "set_group_leave": func(s *httpServer, c *gin.Context) { - s.SetGroupLeave(c) - }, - "get_image": func(s *httpServer, c *gin.Context) { - s.GetImage(c) - }, - "get_forward_msg": func(s *httpServer, c *gin.Context) { - s.GetForwardMessage(c) - }, - "get_group_msg": func(s *httpServer, c *gin.Context) { - s.GetGroupMessage(c) - }, - "get_group_honor_info": func(s *httpServer, c *gin.Context) { - s.GetGroupHonorInfo(c) - }, - "can_send_image": func(s *httpServer, c *gin.Context) { - s.CanSendImage(c) - }, - "can_send_record": func(s *httpServer, c *gin.Context) { - s.CanSendRecord(c) - }, - "get_status": func(s *httpServer, c *gin.Context) { - s.GetStatus(c) - }, - "get_version_info": func(s *httpServer, c *gin.Context) { - s.GetVersionInfo(c) - }, - "_get_vip_info": func(s *httpServer, c *gin.Context) { - s.GetVipInfo(c) - }, - "reload_event_filter": func(s *httpServer, c *gin.Context) { - s.ReloadEventFilter(c) - }, - ".handle_quick_operation": func(s *httpServer, c *gin.Context) { - s.HandleQuickOperation(c) - }, + "get_login_info": GetLoginInfo, + "get_friend_list": GetFriendList, + "get_group_list": GetGroupList, + "get_group_info": GetGroupInfo, + "get_group_member_list": GetGroupMemberList, + "get_group_member_info": GetGroupMemberInfo, + "get_group_file_system_info": GetGroupFileSystemInfo, + "get_group_root_files": GetGroupRootFiles, + "get_group_files_by_folder": GetGroupFilesByFolderId, + "get_group_file_url": GetGroupFileUrl, + "upload_group_file": UploadGroupFile, + "get_essence_msg_list": GetEssenceMsgList, + "send_msg": SendMessage, + "send_group_msg": SendGroupMessage, + "send_group_forward_msg": SendGroupForwardMessage, + "send_private_msg": SendPrivateMessage, + "delete_msg": DeleteMessage, + "delete_essence_msg": DeleteEssenceMsg, + "set_friend_add_request": ProcessFriendRequest, + "set_group_add_request": ProcessGroupRequest, + "set_group_card": SetGroupCard, + "set_group_special_title": SetSpecialTitle, + "set_group_kick": SetGroupKick, + "set_group_ban": SetGroupBan, + "set_group_whole_ban": SetWholeBan, + "set_group_name": SetGroupName, + "set_group_admin": SetGroupAdmin, + "set_essence_msg": SetEssenceMsg, + "set_restart": SetRestart, + "_send_group_notice": SendGroupNotice, + "set_group_leave": SetGroupLeave, + "get_image": GetImage, + "get_forward_msg": GetForwardMessage, + "get_msg": GetMessage, + "get_group_system_msg": GetGroupSystemMessage, + "get_group_honor_info": GetGroupHonorInfo, + "can_send_image": CanSendImage, + "can_send_record": CanSendRecord, + "get_status": GetStatus, + "get_version_info": GetVersionInfo, + "_get_vip_info": GetVipInfo, + "get_stranger_info": GetStrangerInfo, + "reload_event_filter": ReloadEventFilter, + "set_group_portrait": SetGroupPortrait, + "set_group_anonymous_ban": SetGroupAnonymousBan, + "get_group_msg_history": GetGroupMessageHistory, + "check_url_safely": CheckUrlSafely, + "download_file": DownloadFile, + ".handle_quick_operation": HandleQuickOperation, + ".ocr_image": OcrImage, + "ocr_image": OcrImage, + "get_group_at_all_remain": GetGroupAtAllRemain, + "get_online_clients": GetOnlineClients, + ".get_word_slices": GetWordSlices, +} + +func (s *httpServer) ShutDown() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.Http.Shutdown(ctx); err != nil { + log.Fatal("http Server Shutdown:", err) + } + <-ctx.Done() + log.Println("timeout of 5 seconds.") + log.Println("http Server exiting") } diff --git a/server/websocket.go b/server/websocket.go index ecad98a..998a9c4 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "runtime/debug" "strconv" "strings" "sync" @@ -16,36 +17,38 @@ import ( "github.com/tidwall/gjson" ) -type websocketServer struct { +type webSocketServer struct { bot *coolq.CQBot token string - eventConn []*websocketConn + eventConn []*webSocketConn eventConnMutex sync.Mutex handshake string } -type websocketClient struct { - conf *global.GoCQReverseWebsocketConfig +//WebSocketClient Websocket客户端实例 +type WebSocketClient struct { + conf *global.GoCQReverseWebSocketConfig token string bot *coolq.CQBot - universalConn *websocketConn - eventConn *websocketConn + universalConn *webSocketConn + eventConn *webSocketConn } -type websocketConn struct { +type webSocketConn struct { *websocket.Conn sync.Mutex } -var WebsocketServer = &websocketServer{} +//WebSocketServer 初始化一个WebSocketServer实例 +var WebSocketServer = &webSocketServer{} var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } -func (s *websocketServer) Run(addr, authToken string, b *coolq.CQBot) { +func (s *webSocketServer) Run(addr, authToken string, b *coolq.CQBot) { s.token = authToken s.bot = b s.handshake = fmt.Sprintf(`{"_post_method":2,"meta_event_type":"lifecycle","post_type":"meta_event","self_id":%d,"sub_type":"connect","time":%d}`, @@ -60,29 +63,31 @@ func (s *websocketServer) Run(addr, authToken string, b *coolq.CQBot) { }() } -func NewWebsocketClient(conf *global.GoCQReverseWebsocketConfig, authToken string, b *coolq.CQBot) *websocketClient { - return &websocketClient{conf: conf, token: authToken, bot: b} +//NewWebSocketClient 初始化一个NWebSocket客户端 +func NewWebSocketClient(conf *global.GoCQReverseWebSocketConfig, authToken string, b *coolq.CQBot) *WebSocketClient { + return &WebSocketClient{conf: conf, token: authToken, bot: b} } -func (c *websocketClient) Run() { +//Run 运行实例 +func (c *WebSocketClient) Run() { if !c.conf.Enabled { return } - if c.conf.ReverseUrl != "" { + if c.conf.ReverseURL != "" { c.connectUniversal() } else { - if c.conf.ReverseApiUrl != "" { - c.connectApi() + if c.conf.ReverseAPIURL != "" { + c.connectAPI() } - if c.conf.ReverseEventUrl != "" { + if c.conf.ReverseEventURL != "" { c.connectEvent() } } c.bot.OnEventPush(c.onBotPushEvent) } -func (c *websocketClient) connectApi() { - log.Infof("开始尝试连接到反向Websocket API服务器: %v", c.conf.ReverseApiUrl) +func (c *WebSocketClient) connectAPI() { + log.Infof("开始尝试连接到反向Websocket API服务器: %v", c.conf.ReverseAPIURL) header := http.Header{ "X-Client-Role": []string{"API"}, "X-Self-ID": []string{strconv.FormatInt(c.bot.Client.Uin, 10)}, @@ -91,22 +96,22 @@ func (c *websocketClient) connectApi() { if c.token != "" { header["Authorization"] = []string{"Token " + c.token} } - conn, _, err := websocket.DefaultDialer.Dial(c.conf.ReverseApiUrl, header) + conn, _, err := websocket.DefaultDialer.Dial(c.conf.ReverseAPIURL, header) if err != nil { - log.Warnf("连接到反向Websocket API服务器 %v 时出现错误: %v", c.conf.ReverseApiUrl, err) + log.Warnf("连接到反向Websocket API服务器 %v 时出现错误: %v", c.conf.ReverseAPIURL, err) if c.conf.ReverseReconnectInterval != 0 { time.Sleep(time.Millisecond * time.Duration(c.conf.ReverseReconnectInterval)) - c.connectApi() + c.connectAPI() } return } - log.Infof("已连接到反向Websocket API服务器 %v", c.conf.ReverseApiUrl) - wrappedConn := &websocketConn{Conn: conn} - go c.listenApi(wrappedConn, false) + log.Infof("已连接到反向Websocket API服务器 %v", c.conf.ReverseAPIURL) + wrappedConn := &webSocketConn{Conn: conn} + go c.listenAPI(wrappedConn, false) } -func (c *websocketClient) connectEvent() { - log.Infof("开始尝试连接到反向Websocket Event服务器: %v", c.conf.ReverseEventUrl) +func (c *WebSocketClient) connectEvent() { + log.Infof("开始尝试连接到反向Websocket Event服务器: %v", c.conf.ReverseEventURL) header := http.Header{ "X-Client-Role": []string{"Event"}, "X-Self-ID": []string{strconv.FormatInt(c.bot.Client.Uin, 10)}, @@ -115,9 +120,9 @@ func (c *websocketClient) connectEvent() { if c.token != "" { header["Authorization"] = []string{"Token " + c.token} } - conn, _, err := websocket.DefaultDialer.Dial(c.conf.ReverseEventUrl, header) + conn, _, err := websocket.DefaultDialer.Dial(c.conf.ReverseEventURL, header) if err != nil { - log.Warnf("连接到反向Websocket Event服务器 %v 时出现错误: %v", c.conf.ReverseEventUrl, err) + log.Warnf("连接到反向Websocket Event服务器 %v 时出现错误: %v", c.conf.ReverseEventURL, err) if c.conf.ReverseReconnectInterval != 0 { time.Sleep(time.Millisecond * time.Duration(c.conf.ReverseReconnectInterval)) c.connectEvent() @@ -132,12 +137,12 @@ func (c *websocketClient) connectEvent() { log.Warnf("反向Websocket 握手时出现错误: %v", err) } - log.Infof("已连接到反向Websocket Event服务器 %v", c.conf.ReverseEventUrl) - c.eventConn = &websocketConn{Conn: conn} + log.Infof("已连接到反向Websocket Event服务器 %v", c.conf.ReverseEventURL) + c.eventConn = &webSocketConn{Conn: conn} } -func (c *websocketClient) connectUniversal() { - log.Infof("开始尝试连接到反向Websocket Universal服务器: %v", c.conf.ReverseUrl) +func (c *WebSocketClient) connectUniversal() { + log.Infof("开始尝试连接到反向Websocket Universal服务器: %v", c.conf.ReverseURL) header := http.Header{ "X-Client-Role": []string{"Universal"}, "X-Self-ID": []string{strconv.FormatInt(c.bot.Client.Uin, 10)}, @@ -146,9 +151,9 @@ func (c *websocketClient) connectUniversal() { if c.token != "" { header["Authorization"] = []string{"Token " + c.token} } - conn, _, err := websocket.DefaultDialer.Dial(c.conf.ReverseUrl, header) + conn, _, err := websocket.DefaultDialer.Dial(c.conf.ReverseURL, header) if err != nil { - log.Warnf("连接到反向Websocket Universal服务器 %v 时出现错误: %v", c.conf.ReverseUrl, err) + log.Warnf("连接到反向Websocket Universal服务器 %v 时出现错误: %v", c.conf.ReverseURL, err) if c.conf.ReverseReconnectInterval != 0 { time.Sleep(time.Millisecond * time.Duration(c.conf.ReverseReconnectInterval)) c.connectUniversal() @@ -163,12 +168,12 @@ func (c *websocketClient) connectUniversal() { log.Warnf("反向Websocket 握手时出现错误: %v", err) } - wrappedConn := &websocketConn{Conn: conn} - go c.listenApi(wrappedConn, true) + wrappedConn := &webSocketConn{Conn: conn} + go c.listenAPI(wrappedConn, true) c.universalConn = wrappedConn } -func (c *websocketClient) listenApi(conn *websocketConn, u bool) { +func (c *WebSocketClient) listenAPI(conn *webSocketConn, u bool) { defer conn.Close() for { _, buf, err := conn.ReadMessage() @@ -183,12 +188,12 @@ func (c *websocketClient) listenApi(conn *websocketConn, u bool) { if c.conf.ReverseReconnectInterval != 0 { time.Sleep(time.Millisecond * time.Duration(c.conf.ReverseReconnectInterval)) if !u { - go c.connectApi() + go c.connectAPI() } } } -func (c *websocketClient) onBotPushEvent(m coolq.MSG) { +func (c *WebSocketClient) onBotPushEvent(m coolq.MSG) { if c.eventConn != nil { log.Debugf("向WS服务器 %v 推送Event: %v", c.eventConn.RemoteAddr().String(), m.ToJson()) conn := c.eventConn @@ -221,7 +226,7 @@ func (c *websocketClient) onBotPushEvent(m coolq.MSG) { } } -func (s *websocketServer) event(w http.ResponseWriter, r *http.Request) { +func (s *webSocketServer) event(w http.ResponseWriter, r *http.Request) { if s.token != "" { if auth := r.URL.Query().Get("access_token"); auth != s.token { if auth := strings.SplitN(r.Header.Get("Authorization"), " ", 2); len(auth) != 2 || auth[1] != s.token { @@ -245,14 +250,14 @@ func (s *websocketServer) event(w http.ResponseWriter, r *http.Request) { log.Infof("接受 Websocket 连接: %v (/event)", r.RemoteAddr) - conn := &websocketConn{Conn: c} + conn := &webSocketConn{Conn: c} s.eventConnMutex.Lock() s.eventConn = append(s.eventConn, conn) s.eventConnMutex.Unlock() } -func (s *websocketServer) api(w http.ResponseWriter, r *http.Request) { +func (s *webSocketServer) api(w http.ResponseWriter, r *http.Request) { if s.token != "" { if auth := r.URL.Query().Get("access_token"); auth != s.token { if auth := strings.SplitN(r.Header.Get("Authorization"), " ", 2); len(auth) != 2 || auth[1] != s.token { @@ -268,11 +273,11 @@ func (s *websocketServer) api(w http.ResponseWriter, r *http.Request) { return } log.Infof("接受 Websocket 连接: %v (/api)", r.RemoteAddr) - conn := &websocketConn{Conn: c} - go s.listenApi(conn) + conn := &webSocketConn{Conn: c} + go s.listenAPI(conn) } -func (s *websocketServer) any(w http.ResponseWriter, r *http.Request) { +func (s *webSocketServer) any(w http.ResponseWriter, r *http.Request) { if s.token != "" { if auth := r.URL.Query().Get("access_token"); auth != s.token { if auth := strings.SplitN(r.Header.Get("Authorization"), " ", 2); len(auth) != 2 || auth[1] != s.token { @@ -294,12 +299,12 @@ func (s *websocketServer) any(w http.ResponseWriter, r *http.Request) { return } log.Infof("接受 Websocket 连接: %v (/)", r.RemoteAddr) - conn := &websocketConn{Conn: c} + conn := &webSocketConn{Conn: c} s.eventConn = append(s.eventConn, conn) - s.listenApi(conn) + s.listenAPI(conn) } -func (s *websocketServer) listenApi(c *websocketConn) { +func (s *webSocketServer) listenAPI(c *webSocketConn) { defer c.Close() for { t, payload, err := c.ReadMessage() @@ -313,10 +318,10 @@ func (s *websocketServer) listenApi(c *websocketConn) { } } -func (c *websocketConn) handleRequest(bot *coolq.CQBot, payload []byte) { +func (c *webSocketConn) handleRequest(bot *coolq.CQBot, payload []byte) { defer func() { if err := recover(); err != nil { - log.Printf("处置WS命令时发生无法恢复的异常:%v", err) + log.Printf("处置WS命令时发生无法恢复的异常:%v\n%s", err, debug.Stack()) c.Close() } }() @@ -324,7 +329,7 @@ func (c *websocketConn) handleRequest(bot *coolq.CQBot, payload []byte) { j := gjson.ParseBytes(payload) t := strings.ReplaceAll(j.Get("action").Str, "_async", "") log.Debugf("WS接收到API调用: %v 参数: %v", t, j.Get("params").Raw) - if f, ok := wsApi[t]; ok { + if f, ok := wsAPI[t]; ok { ret := f(bot, j.Get("params")) if j.Get("echo").Exists() { ret["echo"] = j.Get("echo").Value() @@ -332,10 +337,18 @@ func (c *websocketConn) handleRequest(bot *coolq.CQBot, payload []byte) { c.Lock() defer c.Unlock() _ = c.WriteJSON(ret) + } else { + ret := coolq.Failed(1404, "API_NOT_FOUND", "API不存在") + if j.Get("echo").Exists() { + ret["echo"] = j.Get("echo").Value() + } + c.Lock() + defer c.Unlock() + _ = c.WriteJSON(ret) } } -func (s *websocketServer) onBotPushEvent(m coolq.MSG) { +func (s *webSocketServer) onBotPushEvent(m coolq.MSG) { s.eventConnMutex.Lock() defer s.eventConnMutex.Unlock() for i, l := 0, len(s.eventConn); i < l; i++ { @@ -359,7 +372,7 @@ func (s *websocketServer) onBotPushEvent(m coolq.MSG) { } } -var wsApi = map[string]func(*coolq.CQBot, gjson.Result) coolq.MSG{ +var wsAPI = map[string]func(*coolq.CQBot, gjson.Result) coolq.MSG{ "get_login_info": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQGetLoginInfo() }, @@ -370,7 +383,7 @@ var wsApi = map[string]func(*coolq.CQBot, gjson.Result) coolq.MSG{ return bot.CQGetGroupList(p.Get("no_cache").Bool()) }, "get_group_info": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { - return bot.CQGetGroupInfo(p.Get("group_id").Int()) + return bot.CQGetGroupInfo(p.Get("group_id").Int(), p.Get("no_cache").Bool()) }, "get_group_member_list": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQGetGroupMemberList(p.Get("group_id").Int(), p.Get("no_cache").Bool()) @@ -433,7 +446,7 @@ var wsApi = map[string]func(*coolq.CQBot, gjson.Result) coolq.MSG{ return bot.CQSetGroupSpecialTitle(p.Get("group_id").Int(), p.Get("user_id").Int(), p.Get("special_title").Str) }, "set_group_kick": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { - return bot.CQSetGroupKick(p.Get("group_id").Int(), p.Get("user_id").Int(), p.Get("message").Str) + return bot.CQSetGroupKick(p.Get("group_id").Int(), p.Get("user_id").Int(), p.Get("message").Str, p.Get("reject_add_request").Bool()) }, "set_group_ban": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQSetGroupBan(p.Get("group_id").Int(), p.Get("user_id").Int(), func() uint32 { @@ -472,32 +485,139 @@ var wsApi = map[string]func(*coolq.CQBot, gjson.Result) coolq.MSG{ return bot.CQGetImage(p.Get("file").Str) }, "get_forward_msg": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { - return bot.CQGetForwardMessage(p.Get("message_id").Str) + id := p.Get("message_id").Str + if id == "" { + id = p.Get("id").Str + } + return bot.CQGetForwardMessage(id) }, - "get_group_msg": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { - return bot.CQGetGroupMessage(int32(p.Get("message_id").Int())) + "get_msg": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetMessage(int32(p.Get("message_id").Int())) + }, + "download_file": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + headers := map[string]string{} + headersToken := p.Get("headers") + if headersToken.IsArray() { + for _, sub := range headersToken.Array() { + str := strings.SplitN(sub.String(), "=", 2) + if len(str) == 2 { + headers[str[0]] = str[1] + } + } + } + if headersToken.Type == gjson.String { + lines := strings.Split(headersToken.String(), "\r\n") + for _, sub := range lines { + str := strings.SplitN(sub, "=", 2) + if len(str) == 2 { + headers[str[0]] = str[1] + } + } + } + return bot.CQDownloadFile(p.Get("url").Str, headers, int(p.Get("thread_count").Int())) }, "get_group_honor_info": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQGetGroupHonorInfo(p.Get("group_id").Int(), p.Get("type").Str) }, + "set_restart": func(c *coolq.CQBot, p gjson.Result) coolq.MSG { + var delay int64 + delay = p.Get("delay").Int() + if delay < 0 { + delay = 0 + } + defer func(delay int64) { + time.Sleep(time.Duration(delay) * time.Millisecond) + Restart <- struct{}{} + }(delay) + return coolq.MSG{"data": nil, "retcode": 0, "status": "async"} + + }, "can_send_image": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQCanSendImage() }, "can_send_record": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQCanSendRecord() }, + "get_stranger_info": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetStrangerInfo(p.Get("user_id").Int()) + }, "get_status": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQGetStatus() }, "get_version_info": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQGetVersionInfo() }, + "get_group_system_msg": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetGroupSystemMessages() + }, + "get_group_file_system_info": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetGroupFileSystemInfo(p.Get("group_id").Int()) + }, + "get_group_root_files": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetGroupRootFiles(p.Get("group_id").Int()) + }, + "get_group_files_by_folder": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetGroupFilesByFolderId(p.Get("group_id").Int(), p.Get("folder_id").Str) + }, + "get_group_file_url": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetGroupFileUrl(p.Get("group_id").Int(), p.Get("file_id").Str, int32(p.Get("busid").Int())) + }, + "upload_group_file": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQUploadGroupFile(p.Get("group_id").Int(), p.Get("file").Str, p.Get("name").Str, p.Get("folder").Str) + }, + "get_group_msg_history": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetGroupMessageHistory(p.Get("group_id").Int(), p.Get("message_seq").Int()) + }, "_get_vip_info": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQGetVipInfo(p.Get("user_id").Int()) }, "reload_event_filter": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQReloadEventFilter() }, + ".ocr_image": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQOcrImage(p.Get("image").Str) + }, + "ocr_image": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQOcrImage(p.Get("image").Str) + }, + "get_group_at_all_remain": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetAtAllRemain(p.Get("group_id").Int()) + }, + "get_online_clients": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetOnlineClients(p.Get("no_cache").Bool()) + }, + ".get_word_slices": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetWordSlices(p.Get("content").Str) + }, + "set_group_portrait": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQSetGroupPortrait(p.Get("group_id").Int(), p.Get("file").String(), p.Get("cache").String()) + }, + "set_essence_msg": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQSetEssenceMessage(int32(p.Get("message_id").Int())) + }, + "delete_essence_msg": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQDeleteEssenceMessage(int32(p.Get("message_id").Int())) + }, + "get_essence_msg_list": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQGetEssenceMessageList(p.Get("group_id").Int()) + }, + "check_url_safely": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + return bot.CQCheckUrlSafely(p.Get("url").String()) + }, + "set_group_anonymous_ban": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { + obj := p.Get("anonymous") + flag := p.Get("anonymous_flag") + if !flag.Exists() { + flag = p.Get("flag") + } + if !flag.Exists() && !obj.Exists() { + return coolq.Failed(100, "FLAG_NOT_FOUND", "flag未找到") + } + if !flag.Exists() { + flag = obj.Get("flag") + } + return bot.CQSetGroupAnonymousBan(p.Get("group_id").Int(), flag.String(), int32(p.Get("duration").Int())) + }, ".handle_quick_operation": func(bot *coolq.CQBot, p gjson.Result) coolq.MSG { return bot.CQHandleQuickOperation(p.Get("context"), p.Get("operation")) },