Repository: Mrs4s/go-cqhttp Branch: master Commit: a5923f179b36 Files: 118 Total size: 520.7 KB Directory structure: gitextract_q284w6qz/ ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug-report.yaml │ │ └── feat--.md │ └── workflows/ │ ├── build_docker_image.yml │ ├── ci.yml │ ├── close_pr.yml │ ├── golint.yml │ └── release.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── cmd/ │ ├── api-generator/ │ │ ├── main.go │ │ └── supported.go │ └── gocq/ │ ├── login.go │ ├── main.go │ └── qsign.go ├── coolq/ │ ├── api.go │ ├── api_v12.go │ ├── bot.go │ ├── converter.go │ ├── cqcode.go │ ├── doc.go │ ├── event.go │ └── feed.go ├── db/ │ ├── database.go │ ├── leveldb/ │ │ ├── const.go │ │ ├── leveldb.go │ │ ├── reader.go │ │ ├── structs.go │ │ └── writer.go │ ├── mongodb/ │ │ └── mongodb.go │ ├── multidb.go │ └── sqlite3/ │ ├── model.go │ └── sqlite3.go ├── docker-entrypoint.sh ├── docs/ │ ├── EventFilter.md │ ├── QA.md │ ├── README.md │ ├── adminApi.md │ ├── config.md │ ├── cqhttp.md │ ├── file.md │ ├── guild.md │ ├── quick_start.md │ └── slider.md ├── global/ │ ├── all_test.go │ ├── buffer.go │ ├── codec.go │ ├── doc.go │ ├── fs.go │ ├── log_hook.go │ ├── net.go │ ├── param.go │ ├── signal.go │ ├── signal_unix.go │ ├── signal_windows.go │ └── terminal/ │ ├── doc.go │ ├── double_click.go │ ├── double_click_windows.go │ ├── quick_edit.go │ ├── quick_edit_windows.go │ ├── title.go │ ├── title_windows.go │ ├── vt100.go │ └── vt100_windows.go ├── go.mod ├── go.sum ├── internal/ │ ├── base/ │ │ ├── feature.go │ │ ├── flag.go │ │ └── version.go │ ├── cache/ │ │ └── cache.go │ ├── download/ │ │ └── download.go │ ├── mime/ │ │ └── mime.go │ ├── msg/ │ │ ├── element.go │ │ ├── element_test.go │ │ ├── local.go │ │ ├── parse.go │ │ └── parse_test.go │ ├── param/ │ │ └── param.go │ ├── selfdiagnosis/ │ │ └── diagnoses.go │ └── selfupdate/ │ ├── update.go │ ├── update_others.go │ └── update_windows.go ├── main.go ├── modules/ │ ├── api/ │ │ ├── api.go │ │ └── caller.go │ ├── config/ │ │ ├── config.go │ │ ├── config_test.go │ │ └── default_config.yml │ ├── filter/ │ │ ├── filter.go │ │ └── middlewares.go │ ├── pprof/ │ │ └── pprof.go │ ├── servers/ │ │ └── servers.go │ └── silk/ │ ├── codec.go │ ├── codec_unsupported.go │ └── stubs.go ├── pkg/ │ └── onebot/ │ ├── attr.go │ ├── kind_string.go │ ├── onebot.go │ ├── spec.go │ ├── supported.go │ └── value.go ├── scripts/ │ ├── bootstrap │ └── upload_dist.sh ├── server/ │ ├── daemon.go │ ├── doc.go │ ├── http.go │ ├── http_test.go │ ├── middlewares.go │ ├── scf.go │ └── websocket.go └── winres/ ├── .gitignore ├── gen/ │ └── json.go └── init.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .gitlab-ci.yml .dockerignore Dockerfile README.md LICENSE ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yaml ================================================ name: 回报错误 description: 在使用 go-cqhttp 的过程中遇到了错误 title: '[Bug]: ' labels: [ "bug?" ] body: # User's README and agreement - type: markdown attributes: value: | ## 感谢您愿意填写错误回报! ## 以下是一些注意事项,请务必阅读让我们能够更容易处理 ### ❗ | 确定没有相同问题的ISSUE已被提出. (教程: https://forums.go-cqhttp.org/t/topic/141) ### 🌎| 请准确填写环境信息 ### ❔ | 打开DEBUG模式复现,并提供出现问题前后至少 10 秒的完整日志内容。请自行删除日志内存在的个人信息及敏感内容。 ### ⚠ | 如果涉及内存泄漏/CPU占用异常请打开DEBUG模式并下载pprof性能分析. ## 如果您不知道如何有效、精准地表述,我们建议您先阅读《提问的智慧》 链接: [《提问的智慧》](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md) --- - type: checkboxes id: terms attributes: label: 请确保您已阅读以上注意事项,并勾选下方的确认框。 options: - label: "我已经仔细阅读上述教程和 [\"提问前需知\"](https://forums.go-cqhttp.org/t/topic/141)" required: true - label: "我已经使用 [dev分支版本](https://github.com/Mrs4s/go-cqhttp/actions/workflows/ci.yml) 测试过,问题依旧存在。" required: true - label: "我已经在 [Issue Tracker](https://github.com/Mrs4s/go-cqhttp/issues) 中找过我要提出的问题,没有找到相同问题的ISSUE。" required: true - label: 我已知晓并同意,此处仅用于汇报程序中存在的问题。若这个 Issue 是关于其他非程序本身问题,则我的 Issue 可能会被无条件自动关闭或/并锁定。(这些问题应当在 Discussion 板块提出。) required: true # User's data - type: markdown attributes: value: | ## 环境信息 请根据实际使用环境修改以下信息。 # Env | go-cqhttp Version - type: input id: env-gocq-ver attributes: label: go-cqhttp 版本 validations: required: true # Env | VM Version - type: dropdown id: env-vm-ver attributes: label: 运行环境 description: 选择运行 go-cqhttp 的系统版本 options: - Windows (64) - Windows (32/x84) - MacOS - Linux - Ubuntu - CentOS - ArchLinux - UNIX (Android) - 其它(请在下方说明) validations: required: true # Env | VM Arch - type: dropdown id: env-vm-arch attributes: label: 运行架构 description: (可选) 选择运行 go-cqhttp 的系统架构 options: - AMD64 - x86 - ARM [32] (别名:AArch32 / ARMv7) - ARM [64] (别名:AArch64 / ARMv8) - 其它 # Env | Connection type - type: dropdown id: env-conn-type attributes: label: 连接方式 description: 选择对接机器人的连接方式 options: - HTTP - WebSocket (正向) - WebSocket (反向) - LambdaServer validations: required: true # Env | Protocol - type: dropdown id: env-protocol attributes: label: 使用协议 description: 选择使用的协议 options: - 0 | Default - 1 | Android Phone - 2 | Android Watch - 3 | MacOS - 4 | 企点 - 5 | iPad - 6 | aPad validations: required: true # Input | Reproduce - type: textarea id: reproduce-steps attributes: label: 重现步骤 description: | 我们需要执行哪些操作才能让 bug 出现? 简洁清晰的重现步骤能够帮助我们更迅速地定位问题所在。 validations: required: true # Input | Expected result - type: textarea id: expected attributes: label: 期望的结果是什么? validations: required: true # Input | Actual result - type: textarea id: actual attributes: label: 实际的结果是什么? validations: required: true # Optional | Reproduce code - type: textarea id: reproduce-code attributes: label: 简单的复现代码/链接(可选) render: golang # Optional | Logging - type: textarea id: logging attributes: label: 日志记录(可选) render: golang # Optional | Extra description - type: textarea id: extra-desc attributes: label: 补充说明(可选) ================================================ FILE: .github/ISSUE_TEMPLATE/feat--.md ================================================ --- name: 新功能提议 about: 提出新功能 title: '' labels: feature request assignees: '' --- **环境信息** go-cqhttp版本: **需要添加的功能内容** ================================================ FILE: .github/workflows/build_docker_image.yml ================================================ name: Build And Push Docker Image on: push: branches: - 'master' - 'dev' # Sequence of patterns matched against refs/tags tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 workflow_dispatch: jobs: build: runs-on: ubuntu-latest permissions: packages: write contents: read steps: - uses: actions/checkout@v3 - name: Set time zone uses: szenius/set-timezone@v1.1 with: timezoneLinux: "Asia/Shanghai" timezoneMacos: "Asia/Shanghai" timezoneWindows: "China Standard Time" # # 如果有 dockerhub 账户,可以在github的secrets中配置下面两个,然后取消下面注释的这几行,并在meta步骤的images增加一行 ${{ github.repository }} # - name: Login to DockerHub # uses: docker/login-action@v1 # with: # username: ${{ secrets.DOCKERHUB_USERNAME }} # password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GHCR uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v4 with: images: | ghcr.io/${{ github.repository }} # generate Docker tags based on the following events/attributes # nightly, master, pr-2, 1.2.3, 1.2, 1 tags: | type=schedule,pattern=nightly type=edge type=ref,event=branch type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - name: Set up QEMU uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - name: Build and push id: docker_build uses: docker/build-push-action@v4 with: context: . push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/ppc64le,linux/s390x ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: [push, pull_request,workflow_dispatch] env: BINARY_PREFIX: "go-cqhttp_" BINARY_SUFFIX: "" COMMIT_ID: "${{ github.sha }}" PR_PROMPT: "::warning:: Build artifact will not be uploaded due to the workflow is trigged by pull request." jobs: build: name: Build binary CI runs-on: ubuntu-latest strategy: matrix: # build and publish in parallel: linux/386, linux/amd64, windows/386, windows/amd64, darwin/amd64, darwin/arm64 goos: [linux, windows, darwin] goarch: ["386", amd64, arm, arm64] exclude: - goos: darwin goarch: arm - goos: darwin goarch: "386" fail-fast: true steps: - uses: actions/checkout@v3 - name: Setup Go environment uses: actions/setup-go@v3 with: cache: true go-version: '1.20' - name: Build binary file env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} IS_PR: ${{ !!github.head_ref }} run: | if [ $GOOS = "windows" ]; then export BINARY_SUFFIX="$BINARY_SUFFIX.exe"; fi if $IS_PR ; then echo $PR_PROMPT; fi export BINARY_NAME="$BINARY_PREFIX"$GOOS"_$GOARCH$BINARY_SUFFIX" export CGO_ENABLED=0 export LD_FLAGS="-w -s -X github.com/Mrs4s/go-cqhttp/internal/base.Version=${COMMIT_ID::7}" go build -o "output/$BINARY_NAME" -trimpath -ldflags "$LD_FLAGS" . - name: Upload artifact uses: actions/upload-artifact@v3 if: ${{ !github.head_ref }} with: name: ${{ matrix.goos }}_${{ matrix.goarch }} path: output/ ================================================ FILE: .github/workflows/close_pr.yml ================================================ name: Check and Close Invalid PR on: pull_request_target: types: [opened, reopened] jobs: # This workflow closes invalid PR close_pr: # The type of runner that the job will run on runs-on: ubuntu-latest permissions: write-all # Steps represent a sequence of tasks that will be executed as part of the job steps: - name: Close PR if it is not pointed to dev branch if: github.event.pull_request.base.ref != 'dev' uses: superbrothers/close-pull-request@v3 with: # Optional. Post a issue comment just before closing a pull request. comment: "Invalid PR to `non-dev` branch `${{ github.event.pull_request.base.ref }}`." ================================================ FILE: .github/workflows/golint.yml ================================================ name: Lint on: [push,pull_request,workflow_dispatch] jobs: golangci: name: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Setup Go environment uses: actions/setup-go@v3 with: go-version: '1.20' - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: version: latest - name: Tests run: | go test $(go list ./...) - name: Commit back if: ${{ github.repository_owner == 'Mrs4s' && !github.event.pull_request }} continue-on-error: true run: | git config --local user.name 'github-actions[bot]' git config --local user.email '41898282+github-actions[bot]@users.noreply.github.com' git add --all git commit -m "ci(chore): Fix stylings" git push - name: Suggester if: ${{ github.event.pull_request }} uses: reviewdog/action-suggester@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} tool_name: golangci-lint ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - 'v*' jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout run: | git version git clone "${{ github.event.repository.html_url }}" /home/runner/work/go-cqhttp/go-cqhttp git checkout "${{ github.ref }}" - name: Set up Go uses: actions/setup-go@v3 with: go-version: '1.20' - name: Run GoReleaser uses: goreleaser/goreleaser-action@v4 with: version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} #- name: Checkout Dist # uses: actions/checkout@v2 # with: # repository: 'gocq/dist' # ref: master # ssh-key: ${{ secrets.SSH_KEY }} # path: upstream/dist #- name: Update Dist # run: | # chmod +x scripts/upload_dist.sh # ./scripts/upload_dist.sh ================================================ FILE: .gitignore ================================================ vendor/ .idea .vscode config.hjson config.yml session.token device.json data/ logs/ internal/btree/*.lock internal/btree/*.db # binary builds go-cqhttp *.exe # macos .DS_Store # windwos rc *.syso ================================================ FILE: .golangci.yml ================================================ linters-settings: errcheck: ignore: fmt:.*,io/ioutil:^Read.* ignoretests: true goimports: local-prefixes: github.com/Mrs4s/go-cqhttp gocritic: disabled-checks: - exitAfterDefer forbidigo: # Forbid the following identifiers forbid: - ^fmt\.Errorf$ # consider errors.Errorf in github.com/pkg/errors linters: # please, do not use `enable-all`: it's deprecated and will be removed soon. # inverted configuration with `enable-all` and `disable` is not scalable during updates of golangci-lint disable-all: true fast: false enable: - bodyclose - durationcheck - gofmt - goimports - errcheck - exportloopref - exhaustive - bidichk - gocritic - gosimple - govet - ineffassign #- nolintlint - staticcheck - stylecheck - unconvert - usestdlibvars - unparam - unused - whitespace - prealloc - predeclared - asciicheck - revive - forbidigo - makezero run: # default concurrency is a available CPU number. # concurrency: 4 # explicitly omit this value to fully utilize available resources. deadline: 5m issues-exit-code: 1 skip-dirs: - db - cmd/api-generator - internal/encryption tests: true # output configuration options output: format: "colored-line-number" print-issued-lines: true print-linter-name: true uniq-by-line: true issues: # Fix found issues (if it's supported by the linter) fix: true exclude-use-default: false exclude: - "Error return value of .((os.)?std(out|err)..*|.*Close|.*Seek|.*Flush|os.Remove(All)?|.*print(f|ln)?|os.(Un)?Setenv). is not check" ================================================ FILE: .goreleaser.yml ================================================ env: - GO111MODULE=on before: hooks: - go mod tidy - go install github.com/tc-hib/go-winres@latest - go generate winres/init.go - go-winres make release: draft: true discussion_category_name: General builds: - id: nowin env: - CGO_ENABLED=0 - GO111MODULE=on goos: - linux - darwin goarch: - '386' - amd64 - arm - arm64 goarm: - '7' ignore: - goos: darwin goarch: arm - goos: darwin goarch: '386' mod_timestamp: "{{ .CommitTimestamp }}" flags: - -trimpath ldflags: - -s -w -X github.com/Mrs4s/go-cqhttp/internal/base.Version=v{{.Version}} - id: win env: - CGO_ENABLED=0 - GO111MODULE=on goos: - windows goarch: - '386' - amd64 - arm - arm64 goarm: - '7' mod_timestamp: "{{ .CommitTimestamp }}" flags: - -trimpath ldflags: - -s -w -X github.com/Mrs4s/go-cqhttp/internal/base.Version=v{{.Version}} checksum: name_template: "{{ .ProjectName }}_checksums.txt" changelog: sort: asc filters: exclude: - "^docs:" - "^test:" - fix typo - Merge pull request - Merge branch - Merge remote-tracking - go mod tidy archives: - id: binary builds: - win name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" format_overrides: - goos: windows format: binary - id: nowin builds: - nowin - win name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}" format_overrides: - goos: windows format: zip nfpms: - license: AGPL 3.0 homepage: https://go-cqhttp.org file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" formats: - deb - rpm maintainer: Mrs4s ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to go-cqhttp 想要成为 go-cqhttp 的 Contributor? Awesome! 这个页面提供了一些 Tips ,可能对您的开发提供一些帮助. ## 开发环境准备 go-cqhttp 使用了 `golangci-lint` 检查可能的问题,规范代码风格,为了减少不必要的麻烦, 我们推荐在开发环境中安装 `golangci-lint` 工具. ```shell go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest ``` 在提交代码前运行 `golangci-lint` 检查你的代码: ```shell golangci-lint run ``` **注意**: `golangci-lint` 需要 `diff` 工具,在 windows 环境中,你可能需要使用 `Git Bash` 运行。 ## Pull requests 首先,为了方便项目管理,请将您的 PR 推送至**dev**分支。 ### 检查 issue 列表 不管你是已经明确了要提交什么代码,还是正在寻找一个想法,你都应该先到 issue 列表看一下。 如果在 issue 中找到了感兴趣的,请在 issue 表明正在对这个 issue 进行开发。 ### 项目结构 下面是 go-cqhttp 项目结构的简单介绍.
coolq 包含与 MiraiGo 交互部分, CQ码解析等部分
server 包含 http,ws 通信的实现部分
global 一个实用的工具包
docs 使用教程与文档
## 社区准则 为了让社区保持强大,不断发展,我们向整个社区提出了一些通用准则: **友善**:对社区成员要礼貌,尊重和礼貌。 请不要在社区中发布任何有关种族歧视、性别歧视、 地域歧视、人格侮辱等言论。 **鼓励参与**:在社区中讲礼貌的每个人都受到欢迎,无论他们的贡献程度如何, 我们鼓励一切人参与(不一定需要提交代码) `go-cqhttp` 的开发。 **紧贴主题**:请避免主题外的讨论。当您更新或回复时, 可能会给大量人员发送邮件, 请牢记,没有人喜欢垃圾邮件。 ================================================ FILE: Dockerfile ================================================ FROM golang:1.20-alpine AS builder RUN go env -w GO111MODULE=auto \ && go env -w CGO_ENABLED=0 \ && go env -w GOPROXY=https://goproxy.cn,direct WORKDIR /build COPY ./ . RUN set -ex \ && cd /build \ && go build -ldflags "-s -w -extldflags '-static'" -o cqhttp FROM alpine:latest COPY docker-entrypoint.sh /docker-entrypoint.sh RUN chmod +x /docker-entrypoint.sh && \ apk add --no-cache --update \ ffmpeg \ coreutils \ shadow \ su-exec \ tzdata && \ rm -rf /var/cache/apk/* && \ mkdir -p /app && \ mkdir -p /data && \ mkdir -p /config && \ useradd -d /config -s /bin/sh abc && \ chown -R abc /config && \ chown -R abc /data ENV TZ="Asia/Shanghai" ENV UID=99 ENV GID=100 ENV UMASK=002 COPY --from=builder /build/cqhttp /app/ WORKDIR /data VOLUME [ "/data" ] ENTRYPOINT [ "/docker-entrypoint.sh" ] CMD [ "/app/cqhttp" ] ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: README.md ================================================

go-cqhttp

# go-cqhttp _✨ 基于 [Mirai](https://github.com/mamoe/mirai) 以及 [MiraiGo](https://github.com/Mrs4s/MiraiGo) 的 [OneBot](https://github.com/howmanybots/onebot/blob/master/README.md) Golang 原生实现 ✨_

license release cqhttp action GoReportCard

文档 · 下载 · 开始使用 · 参与贡献

## 重要信息 由于QQ官方针对协议库的围追堵截, 不断更新加密方案, 我们已无力继续维护此项目. 建议Bot开发者尽快迁移至无头NTQQ项目 -> https://github.com/Mrs4s/go-cqhttp/issues/2471 ## 兼容性 go-cqhttp 兼容 [OneBot-v11](https://github.com/botuniverse/onebot-11) 绝大多数内容,并在其基础上做了一些扩展,详情请看 go-cqhttp 的文档。 ### 接口 - [x] HTTP API - [x] 反向 HTTP POST - [x] 正向 WebSocket - [x] 反向 WebSocket ### 拓展支持 > 拓展 API 可前往 [文档](docs/cqhttp.md) 查看 - [x] HTTP POST 多点上报 - [x] 反向 WS 多点连接 - [x] 修改群名 - [x] 消息撤回事件 - [x] 解析/发送 回复消息 - [x] 解析/发送 合并转发 - [x] 使用代理请求网络图片 ### 实现
已实现 CQ 码 #### 符合 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/botuniverse/onebot-11/blob/master/message/segment.md#qq-%E8%A1%A8%E6%83%85 [语音]: https://github.com/botuniverse/onebot-11/blob/master/message/segment.md#%E8%AF%AD%E9%9F%B3 [短视频]: https://github.com/botuniverse/onebot-11/blob/master/message/segment.md#%E7%9F%AD%E8%A7%86%E9%A2%91 [@某人]: https://github.com/botuniverse/onebot-11/blob/master/message/segment.md#%E6%9F%90%E4%BA%BA [链接分享]: https://github.com/botuniverse/onebot-11/blob/master/message/segment.md#%E9%93%BE%E6%8E%A5%E5%88%86%E4%BA%AB [音乐分享]: https://github.com/botuniverse/onebot-11/blob/master/message/segment.md#%E9%9F%B3%E4%B9%90%E5%88%86%E4%BA%AB- [音乐自定义分享]: https://github.com/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/message/segment.md#%E5%9B%9E%E5%A4%8D [合并转发]: https://github.com/botuniverse/onebot-11/blob/master/message/segment.md#%E5%90%88%E5%B9%B6%E8%BD%AC%E5%8F%91- [合并转发节点]: https://github.com/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/message/segment.md#xml-%E6%B6%88%E6%81%AF [json 消息]: https://github.com/botuniverse/onebot-11/blob/master/message/segment.md#json-%E6%B6%88%E6%81%AF #### 拓展 CQ 码及与 OneBot 标准有略微差异的 CQ 码 | 拓展 CQ 码 | 功能 | | -------------- | --------------------------------- | | [CQ:image] | [图片] | | [CQ:redbag] | [红包] | | [CQ:poke] | [戳一戳] | | [CQ:node] | [合并转发消息节点] | | [CQ:cardimage] | [一种 xml 的图片消息(装逼大图)] | | [CQ:tts] | [文本转语音] | [图片]: https://docs.go-cqhttp.org/cqcode/#%E5%9B%BE%E7%89%87 [红包]: https://docs.go-cqhttp.org/cqcode/#%E7%BA%A2%E5%8C%85 [戳一戳]: https://docs.go-cqhttp.org/cqcode/#%E6%88%B3%E4%B8%80%E6%88%B3 [合并转发消息节点]: https://docs.go-cqhttp.org/cqcode/#%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 的图片消息(装逼大图)]: https://docs.go-cqhttp.org/cqcode/#cardimage [文本转语音]: https://docs.go-cqhttp.org/cqcode/#%E6%96%87%E6%9C%AC%E8%BD%AC%E8%AF%AD%E9%9F%B3
已实现 API #### 符合 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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/api/public.md#send_group_msg-%E5%8F%91%E9%80%81%E7%BE%A4%E6%B6%88%E6%81%AF [发送消息]: https://github.com/botuniverse/onebot-11/blob/master/api/public.md#send_msg-%E5%8F%91%E9%80%81%E6%B6%88%E6%81%AF [撤回信息]: https://github.com/botuniverse/onebot-11/blob/master/api/public.md#delete_msg-%E6%92%A4%E5%9B%9E%E6%B6%88%E6%81%AF [群组踢人]: https://github.com/botuniverse/onebot-11/blob/master/api/public.md#set_group_kick-%E7%BE%A4%E7%BB%84%E8%B8%A2%E4%BA%BA [群组单人禁言]: https://github.com/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/api/public.md#set_group_name-%E8%AE%BE%E7%BD%AE%E7%BE%A4%E5%90%8D [退出群组]: https://github.com/botuniverse/onebot-11/blob/master/api/public.md#set_group_leave-%E9%80%80%E5%87%BA%E7%BE%A4%E7%BB%84 [设置群组专属头衔]: https://github.com/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/api/public.md#get_group_info-%E8%8E%B7%E5%8F%96%E7%BE%A4%E4%BF%A1%E6%81%AF [获取群列表]: https://github.com/botuniverse/onebot-11/blob/master/api/public.md#get_group_list-%E8%8E%B7%E5%8F%96%E7%BE%A4%E5%88%97%E8%A1%A8 [获取群成员信息]: https://github.com/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/api/public.md#set_restart-%E9%87%8D%E5%90%AF-onebot-%E5%AE%9E%E7%8E%B0 [对事件执行快速操作]: https://github.com/botuniverse/onebot-11/blob/master/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 | [获取状态] | [设置群头像]: https://docs.go-cqhttp.org/api/#%E8%AE%BE%E7%BD%AE%E7%BE%A4%E5%A4%B4%E5%83%8F [获取图片信息]: https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E5%9B%BE%E7%89%87%E4%BF%A1%E6%81%AF [获取消息]: https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E6%B6%88%E6%81%AF [获取合并转发内容]: https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E5%90%88%E5%B9%B6%E8%BD%AC%E5%8F%91%E5%86%85%E5%AE%B9 [发送合并转发(群)]: https://docs.go-cqhttp.org/api/#%E5%8F%91%E9%80%81%E5%90%88%E5%B9%B6%E8%BD%AC%E5%8F%91-%E7%BE%A4 [获取中文分词]: https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E4%B8%AD%E6%96%87%E5%88%86%E8%AF%8D-%E9%9A%90%E8%97%8F-api [图片 ocr]: https://docs.go-cqhttp.org/api/#%E5%9B%BE%E7%89%87-ocr [获取群系统消息]: https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E7%BE%A4%E7%B3%BB%E7%BB%9F%E6%B6%88%E6%81%AF [获取群文件系统信息]: https://docs.go-cqhttp.org/api/#%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 [获取群根目录文件列表]: https://docs.go-cqhttp.org/api/#%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 [获取群子目录文件列表]: https://docs.go-cqhttp.org/api/#%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 [获取群文件资源链接]: https://docs.go-cqhttp.org/api/#%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 [获取状态]: https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E7%8A%B6%E6%80%81
已实现 Event #### 符合 OneBot 标准的 Event(部分 Event 比 OneBot 标准多上报几个字段,不影响使用) | 事件类型 | Event | | -------- | ---------------- | | 消息事件 | [私聊信息] | | 消息事件 | [群消息] | | 通知事件 | [群文件上传] | | 通知事件 | [群管理员变动] | | 通知事件 | [群成员减少] | | 通知事件 | [群成员增加] | | 通知事件 | [群禁言] | | 通知事件 | [好友添加] | | 通知事件 | [群消息撤回] | | 通知事件 | [好友消息撤回] | | 通知事件 | [群内戳一戳] | | 通知事件 | [群红包运气王] | | 通知事件 | [群成员荣誉变更] | | 请求事件 | [加好友请求] | | 请求事件 | [加群请求/邀请] | [私聊信息]: https://github.com/botuniverse/onebot-11/blob/master/event/message.md#%E7%A7%81%E8%81%8A%E6%B6%88%E6%81%AF [群消息]: https://github.com/botuniverse/onebot-11/blob/master/event/message.md#%E7%BE%A4%E6%B6%88%E6%81%AF [群文件上传]: https://github.com/botuniverse/onebot-11/blob/master/event/notice.md#%E7%BE%A4%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0 [群管理员变动]: https://github.com/botuniverse/onebot-11/blob/master/event/notice.md#%E7%BE%A4%E7%AE%A1%E7%90%86%E5%91%98%E5%8F%98%E5%8A%A8 [群成员减少]: https://github.com/botuniverse/onebot-11/blob/master/event/notice.md#%E7%BE%A4%E6%88%90%E5%91%98%E5%87%8F%E5%B0%91 [群成员增加]: https://github.com/botuniverse/onebot-11/blob/master/event/notice.md#%E7%BE%A4%E6%88%90%E5%91%98%E5%A2%9E%E5%8A%A0 [群禁言]: https://github.com/botuniverse/onebot-11/blob/master/event/notice.md#%E7%BE%A4%E7%A6%81%E8%A8%80 [好友添加]: https://github.com/botuniverse/onebot-11/blob/master/event/notice.md#%E5%A5%BD%E5%8F%8B%E6%B7%BB%E5%8A%A0 [群消息撤回]: https://github.com/botuniverse/onebot-11/blob/master/event/notice.md#%E7%BE%A4%E6%B6%88%E6%81%AF%E6%92%A4%E5%9B%9E [好友消息撤回]: https://github.com/botuniverse/onebot-11/blob/master/event/notice.md#%E5%A5%BD%E5%8F%8B%E6%B6%88%E6%81%AF%E6%92%A4%E5%9B%9E [群内戳一戳]: https://github.com/botuniverse/onebot-11/blob/master/event/notice.md#%E7%BE%A4%E5%86%85%E6%88%B3%E4%B8%80%E6%88%B3 [群红包运气王]: https://github.com/botuniverse/onebot-11/blob/master/event/notice.md#%E7%BE%A4%E7%BA%A2%E5%8C%85%E8%BF%90%E6%B0%94%E7%8E%8B [群成员荣誉变更]: https://github.com/botuniverse/onebot-11/blob/master/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/botuniverse/onebot-11/blob/master/event/request.md#%E5%8A%A0%E5%A5%BD%E5%8F%8B%E8%AF%B7%E6%B1%82 [加群请求/邀请]: https://github.com/botuniverse/onebot-11/blob/master/event/request.md#%E5%8A%A0%E7%BE%A4%E8%AF%B7%E6%B1%82%E9%82%80%E8%AF%B7 #### 拓展 Event | 事件类型 | 拓展 Event | | -------- | ---------------- | | 通知事件 | [好友戳一戳] | | 通知事件 | [群内戳一戳] | | 通知事件 | [群成员名片更新] | | 通知事件 | [接收到离线文件] | [好友戳一戳]: https://docs.go-cqhttp.org/event/#%E5%A5%BD%E5%8F%8B%E6%88%B3%E4%B8%80%E6%88%B3 [群内戳一戳]: https://docs.go-cqhttp.org/event/#%E7%BE%A4%E5%86%85%E6%88%B3%E4%B8%80%E6%88%B3 [群成员名片更新]: https://docs.go-cqhttp.org/event/#%E7%BE%A4%E6%88%90%E5%91%98%E5%90%8D%E7%89%87%E6%9B%B4%E6%96%B0 [接收到离线文件]: https://docs.go-cqhttp.org/event/#%E6%8E%A5%E6%94%B6%E5%88%B0%E7%A6%BB%E7%BA%BF%E6%96%87%E4%BB%B6
## 关于 ISSUE 以下 ISSUE 会被直接关闭 - 提交 BUG 不使用 Template - 询问已知问题 - 提问找不到重点 - 重复提问 > 请注意, 开发者并没有义务回复您的问题. 您应该具备基本的提问技巧。 > 有关如何提问,请阅读[《提问的智慧》](https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md) ## 性能 在关闭数据库的情况下, 加载 25 个好友 128 个群运行 24 小时后内存使用为 15MB 左右. 开启数据库后内存使用将根据消息量增加 10-20MB, 如果系统内存小于 128M 建议关闭数据库使用. ================================================ FILE: cmd/api-generator/main.go ================================================ package main import ( "bytes" "flag" "fmt" "go/ast" "go/format" "go/parser" "go/token" "io" "os" "reflect" "sort" "strconv" "strings" ) var supported = flag.Bool("supported", false, "genRouter supported.go") var output = flag.String("o", "", "output file") var pkg = flag.String("pkg", "", "package name") var src = flag.String("path", "", "source file") type Param struct { Name string Type string Default string } type Router struct { Func string Path []string PathV11 []string // v11 only PathV12 []string // v12 only Params []Param } type generator struct { out io.Writer } const ( PathAll = 0 PathV11 = 11 PathV12 = 12 ) func (g *generator) WriteString(s string) { io.WriteString(g.out, s) } func (g *generator) writef(format string, a ...any) { fmt.Fprintf(g.out, format, a...) } func (g *generator) header() { g.WriteString("// Code generated by cmd/api-generator. DO NOT EDIT.\n\n") g.writef("package %s\n\n", *pkg) } func (g *generator) genRouter(routers []Router) { g.WriteString("import (\n\n") g.WriteString("\"github.com/Mrs4s/go-cqhttp/coolq\"\n") g.WriteString("\"github.com/Mrs4s/go-cqhttp/global\"\n") g.WriteString("\"github.com/Mrs4s/go-cqhttp/pkg/onebot\"\n") g.WriteString(")\n\n") g.WriteString(`func (c *Caller) call(action string, spec *onebot.Spec, p Getter) global.MSG {`) genVer := func(path int) { g.writef(`if spec.Version == %d { switch action { `, path) for _, router := range routers { g.router(router, path) } g.WriteString("}}\n") } genVer(PathV11) genVer(PathV12) // generic path g.WriteString("switch action {\n") for _, router := range routers { g.router(router, PathAll) } g.WriteString("}\n") g.WriteString("return coolq.Failed(404, \"API_NOT_FOUND\", \"API不存在\")}") } func (g *generator) router(router Router, pathVersion int) { path := router.Path if pathVersion == PathV11 { path = router.PathV11 } if pathVersion == PathV12 { path = router.PathV12 } if len(path) == 0 { return } g.WriteString(`case `) for i, p := range path { if i != 0 { g.WriteString(`, `) } g.WriteString(strconv.Quote(p)) } g.WriteString(":\n") for i, p := range router.Params { if p.Type == "*onebot.Spec" { continue } if p.Default == "" { v := "p.Get(" + strconv.Quote(p.Name) + ")" g.writef("p%d := %s\n", i, conv(v, p.Type)) } else { g.writef("p%d := %s\n", i, p.Default) g.writef("if pt := p.Get(%s); pt.Exists() {\n", strconv.Quote(p.Name)) g.writef("p%d = %s\n}\n", i, conv("pt", p.Type)) } } g.WriteString("\t\treturn c.bot." + router.Func + "(") for i, p := range router.Params { if i != 0 { g.WriteString(", ") } if p.Type == "*onebot.Spec" { g.WriteString("spec") continue } g.writef("p%d", i) } g.WriteString(")\n") } func conv(v, t string) string { switch t { default: panic("unsupported type: " + t) case "gjson.Result", "*onebot.Spec": return v case "int64": return v + ".Int()" case "bool": return v + ".Bool()" case "string": return v + ".String()" case "int32", "int": return t + "(" + v + ".Int())" case "uint64": return v + ".Uint()" case "uint32": return "uint32(" + v + ".Uint())" case "uint16": return "uint16(" + v + ".Uint())" } } func main() { var routers []Router flag.Parse() fset := token.NewFileSet() for _, s := range strings.Split(*src, ",") { file, err := parser.ParseFile(fset, s, nil, parser.ParseComments) if err != nil { panic(err) } for _, decl := range file.Decls { switch decl := decl.(type) { case *ast.FuncDecl: if !decl.Name.IsExported() || decl.Recv == nil || typeName(decl.Recv.List[0].Type) != "*CQBot" { continue } router := Router{Func: decl.Name.Name} // compute params for _, p := range decl.Type.Params.List { typ := typeName(p.Type) for _, name := range p.Names { router.Params = append(router.Params, Param{Name: snakecase(name.Name), Type: typ}) } } for _, comment := range decl.Doc.List { annotation, args := match(comment.Text) switch annotation { case "route": for _, route := range strings.Split(args, ",") { router.Path = append(router.Path, unquote(route)) } case "route11": for _, route := range strings.Split(args, ",") { router.PathV11 = append(router.PathV11, unquote(route)) } case "route12": for _, route := range strings.Split(args, ",") { router.PathV12 = append(router.PathV12, unquote(route)) } case "default": for name, value := range parseMap(args, "=") { for i, p := range router.Params { if p.Name == name { router.Params[i].Default = convDefault(value, p.Type) } } } case "rename": for name, value := range parseMap(args, "->") { for i, p := range router.Params { if p.Name == name { router.Params[i].Name = value } } } } sort.Slice(router.Path, func(i, j int) bool { return router.Path[i] < router.Path[j] }) sort.Slice(router.PathV11, func(i, j int) bool { return router.PathV11[i] < router.PathV11[j] }) sort.Slice(router.PathV12, func(i, j int) bool { return router.PathV12[i] < router.PathV12[j] }) } if router.Path != nil || router.PathV11 != nil || router.PathV12 != nil { routers = append(routers, router) } else { println(decl.Name.Name) } } } } sort.Slice(routers, func(i, j int) bool { path := func(r Router) string { if r.Path != nil { return r.Path[0] } if r.PathV11 != nil { return r.PathV11[0] } if r.PathV12 != nil { return r.PathV12[0] } return "" } return path(routers[i]) < path(routers[j]) }) out := new(bytes.Buffer) g := &generator{out: out} g.header() if *supported { g.genSupported(routers) } else { g.genRouter(routers) } source, err := format.Source(out.Bytes()) if err != nil { panic(err) } err = os.WriteFile(*output, source, 0o644) if err != nil { panic(err) } } func unquote(s string) string { switch s[0] { case '"': s, _ = strconv.Unquote(s) case '`': s = strings.Trim(s, "`") } return s } func parseMap(input string, sep string) map[string]string { out := make(map[string]string) for _, arg := range strings.Split(input, ",") { k, v, ok := strings.Cut(arg, sep) if !ok { out[k] = "true" } k = strings.TrimSpace(k) v = unquote(strings.TrimSpace(v)) out[k] = v } return out } func match(text string) (string, string) { text = strings.TrimPrefix(text, "//") text = strings.TrimSpace(text) if !strings.HasPrefix(text, "@") || !strings.HasSuffix(text, ")") { return "", "" } text = strings.Trim(text, "@)") cmd, args, ok := strings.Cut(text, "(") if !ok { return "", "" } return cmd, unquote(args) } // some abbreviations need translation before transforming ro snake case var replacer = strings.NewReplacer("ID", "Id") func snakecase(s string) string { s = replacer.Replace(s) t := make([]byte, 0, 32) for i := 0; i < len(s); i++ { c := s[i] if ('a' <= c && c <= 'z') || ('0' <= c && c <= '9') { t = append(t, c) } else { t = append(t, '_') t = append(t, c^0x20) } } return string(t) } func convDefault(s string, t string) string { switch t { case "bool": if s == "true" { return s } case "uint32": if s != "0" { return t + "(" + s + ")" } default: panic("unhandled default value type:" + t) } return "" } func typeName(x ast.Node) string { switch x := x.(type) { case *ast.Ident: return x.Name case *ast.SelectorExpr: return typeName(x.X) + "." + x.Sel.Name case *ast.StarExpr: return "*" + typeName(x.X) default: panic("unhandled type: " + reflect.TypeOf(x).String()) } } ================================================ FILE: cmd/api-generator/supported.go ================================================ package main import "html/template" func (g *generator) genSupported(routers []Router) { var v11, v12 []string // for onebot v12 get_supported_actions for _, router := range routers { if len(router.PathV11) > 0 { v11 = append(v11, router.PathV11...) } if len(router.PathV11) > 0 { v12 = append(v12, router.PathV12...) } if len(router.Path) > 0 { v11 = append(v11, router.Path...) v12 = append(v12, router.Path...) } } type S struct { V11 []string V12 []string } tmpl, err := template.New("").Parse(supportedTemplete) if err != nil { panic(err) } err = tmpl.Execute(g.out, &S{V11: v11, V12: v12}) if err != nil { panic(err) } } const supportedTemplete = ` var supportedV11 = []string{ {{range .V11}} "{{.}}", {{end}} } var supportedV12 = []string{ {{range .V12}} "{{.}}", {{end}} }` ================================================ FILE: cmd/gocq/login.go ================================================ package gocq import ( "bufio" "bytes" "fmt" "image" "image/png" "os" "strings" "time" "github.com/Mrs4s/MiraiGo/client" "github.com/Mrs4s/MiraiGo/utils" "github.com/mattn/go-colorable" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "gopkg.ilharper.com/x/isatty" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/internal/download" ) var console = bufio.NewReader(os.Stdin) func readLine() (str string) { str, _ = console.ReadString('\n') str = strings.TrimSpace(str) return } func readLineTimeout(t time.Duration) { r := make(chan string) go func() { select { case r <- readLine(): case <-time.After(t): } }() select { case <-r: case <-time.After(t): } } func readIfTTY(de string) (str string) { if isatty.Isatty(os.Stdin.Fd()) { return readLine() } log.Warnf("未检测到输入终端,自动选择%s.", de) return de } var cli *client.QQClient var device *client.DeviceInfo // ErrSMSRequestError SMS请求出错 var ErrSMSRequestError = errors.New("sms request error") func commonLogin() error { res, err := cli.Login() if err != nil { return err } return loginResponseProcessor(res) } func printQRCode(imgData []byte) { const ( black = "\033[48;5;0m \033[0m" white = "\033[48;5;7m \033[0m" ) img, err := png.Decode(bytes.NewReader(imgData)) if err != nil { log.Panic(err) } data := img.(*image.Gray).Pix bound := img.Bounds().Max.X buf := make([]byte, 0, (bound*4+1)*(bound)) i := 0 for y := 0; y < bound; y++ { i = y * bound for x := 0; x < bound; x++ { if data[i] != 255 { buf = append(buf, white...) } else { buf = append(buf, black...) } i++ } buf = append(buf, '\n') } _, _ = colorable.NewColorableStdout().Write(buf) } func qrcodeLogin() error { rsp, err := cli.FetchQRCodeCustomSize(1, 2, 1) if err != nil { return err } _ = os.WriteFile("qrcode.png", rsp.ImageData, 0o644) defer func() { _ = os.Remove("qrcode.png") }() if cli.Uin != 0 { log.Infof("请使用账号 %v 登录手机QQ扫描二维码 (qrcode.png) : ", cli.Uin) } else { log.Infof("请使用手机QQ扫描二维码 (qrcode.png) : ") } time.Sleep(time.Second) printQRCode(rsp.ImageData) s, err := cli.QueryQRCodeStatus(rsp.Sig) if err != nil { return err } prevState := s.State for { time.Sleep(time.Second) s, _ = cli.QueryQRCodeStatus(rsp.Sig) if s == nil { continue } if prevState == s.State { continue } prevState = s.State switch s.State { case client.QRCodeCanceled: log.Fatalf("扫码被用户取消.") case client.QRCodeTimeout: log.Fatalf("二维码过期") case client.QRCodeWaitingForConfirm: log.Infof("扫码成功, 请在手机端确认登录.") case client.QRCodeConfirmed: res, err := cli.QRCodeLogin(s.LoginInfo) if err != nil { return err } return loginResponseProcessor(res) case client.QRCodeImageFetch, client.QRCodeWaitingForScan: // ignore } } } func loginResponseProcessor(res *client.LoginResponse) error { var err error for { if err != nil { return err } if res.Success { return nil } var text string switch res.Error { case client.SliderNeededError: log.Warnf("登录需要滑条验证码, 请验证后重试.") ticket := getTicket(res.VerifyUrl) if ticket == "" { log.Infof("按 Enter 继续....") readLine() os.Exit(0) } res, err = cli.SubmitTicket(ticket) continue case client.NeedCaptcha: log.Warnf("登录需要验证码.") _ = os.WriteFile("captcha.jpg", res.CaptchaImage, 0o644) log.Warnf("请输入验证码 (captcha.jpg): (Enter 提交)") text = readLine() global.DelFile("captcha.jpg") res, err = cli.SubmitCaptcha(text, res.CaptchaSign) continue case client.SMSNeededError: log.Warnf("账号已开启设备锁, 按 Enter 向手机 %v 发送短信验证码.", res.SMSPhone) readLine() if !cli.RequestSMS() { log.Warnf("发送验证码失败,可能是请求过于频繁.") return errors.WithStack(ErrSMSRequestError) } log.Warn("请输入短信验证码: (Enter 提交)") text = readLine() res, err = cli.SubmitSMS(text) continue case client.SMSOrVerifyNeededError: log.Warnf("账号已开启设备锁,请选择验证方式:") log.Warnf("1. 向手机 %v 发送短信验证码", res.SMSPhone) log.Warnf("2. 使用手机QQ扫码验证.") log.Warn("请输入(1 - 2):") text = readIfTTY("2") if strings.Contains(text, "1") { if !cli.RequestSMS() { log.Warnf("发送验证码失败,可能是请求过于频繁.") return errors.WithStack(ErrSMSRequestError) } log.Warn("请输入短信验证码: (Enter 提交)") text = readLine() res, err = cli.SubmitSMS(text) continue } fallthrough case client.UnsafeDeviceError: log.Warnf("账号已开启设备锁,请前往 -> %v <- 验证后重启Bot.", res.VerifyUrl) log.Infof("按 Enter 或等待 5s 后继续....") readLineTimeout(time.Second * 5) os.Exit(0) case client.OtherLoginError, client.UnknownLoginError, client.TooManySMSRequestError: msg := res.ErrorMessage log.Warnf("登录失败: %v Code: %v", msg, res.Code) switch res.Code { case 235: log.Warnf("设备信息被封禁, 请删除 device.json 后重试.") case 237: log.Warnf("登录过于频繁, 请在手机QQ登录并根据提示完成认证后等一段时间重试") case 45: log.Warnf("你的账号被限制登录, 请配置 SignServer 后重试") } log.Infof("按 Enter 继续....") readLine() os.Exit(0) } } } func getTicket(u string) string { log.Warnf("请选择提交滑块ticket方式:") log.Warnf("1. 自动提交") log.Warnf("2. 手动抓取提交") log.Warn("请输入(1 - 2):") text := readLine() id := utils.RandomString(8) auto := !strings.Contains(text, "2") if auto { u = strings.ReplaceAll(u, "https://ssl.captcha.qq.com/template/wireless_mqq_captcha.html?", fmt.Sprintf("https://captcha.go-cqhttp.org/captcha?id=%v&", id)) } log.Warnf("请前往该地址验证 -> %v ", u) if !auto { log.Warn("请输入ticket: (Enter 提交)") return readLine() } for count := 120; count > 0; count-- { str := fetchCaptcha(id) if str != "" { return str } time.Sleep(time.Second) } log.Warnf("验证超时") return "" } func fetchCaptcha(id string) string { g, err := download.Request{URL: "https://captcha.go-cqhttp.org/captcha/ticket?id=" + id}.JSON() if err != nil { log.Debugf("获取 Ticket 时出现错误: %v", err) return "" } if g.Get("ticket").Exists() { return g.Get("ticket").String() } return "" } ================================================ FILE: cmd/gocq/main.go ================================================ // Package gocq 程序的主体部分 package gocq import ( "crypto/aes" "crypto/md5" "crypto/sha1" "encoding/hex" "fmt" "os" "path" "sync" "time" "github.com/Mrs4s/MiraiGo/binary" "github.com/Mrs4s/MiraiGo/client" "github.com/Mrs4s/MiraiGo/wrapper" para "github.com/fumiama/go-hide-param" rotatelogs "github.com/lestrrat-go/file-rotatelogs" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "golang.org/x/crypto/pbkdf2" "golang.org/x/term" "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/db" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/global/terminal" "github.com/Mrs4s/go-cqhttp/internal/base" "github.com/Mrs4s/go-cqhttp/internal/cache" "github.com/Mrs4s/go-cqhttp/internal/download" "github.com/Mrs4s/go-cqhttp/internal/selfdiagnosis" "github.com/Mrs4s/go-cqhttp/internal/selfupdate" "github.com/Mrs4s/go-cqhttp/modules/servers" "github.com/Mrs4s/go-cqhttp/server" ) // 允许通过配置文件设置的状态列表 var allowStatus = [...]client.UserOnlineStatus{ client.StatusOnline, client.StatusAway, client.StatusInvisible, client.StatusBusy, client.StatusListening, client.StatusConstellation, client.StatusWeather, client.StatusMeetSpring, client.StatusTimi, client.StatusEatChicken, client.StatusLoving, client.StatusWangWang, client.StatusCookedRice, client.StatusStudy, client.StatusStayUp, client.StatusPlayBall, client.StatusSignal, client.StatusStudyOnline, client.StatusGaming, client.StatusVacationing, client.StatusWatchingTV, client.StatusFitness, } // InitBase 解析参数并检测 // // 如果在 windows 下双击打开了程序,程序将在此函数释出脚本后终止; // 如果传入 -h 参数,程序将打印帮助后终止; // 如果传入 -d 参数,程序将在启动 daemon 后终止。 func InitBase() { base.Parse() if !base.FastStart && terminal.RunningByDoubleClick() { err := terminal.NoMoreDoubleClick() if err != nil { log.Errorf("遇到错误: %v", err) time.Sleep(time.Second * 5) } os.Exit(0) } switch { case base.LittleH: base.Help() case base.LittleD: server.Daemon() } if base.LittleWD != "" { err := os.Chdir(base.LittleWD) if err != nil { log.Fatalf("重置工作目录时出现错误: %v", err) } } base.Init() } // PrepareData 准备 log, 缓存, 数据库, 必须在 InitBase 之后执行 func PrepareData() { rotateOptions := []rotatelogs.Option{ rotatelogs.WithRotationTime(time.Hour * 24), } rotateOptions = append(rotateOptions, rotatelogs.WithMaxAge(base.LogAging)) if base.LogForceNew { rotateOptions = append(rotateOptions, rotatelogs.ForceNewFile()) } w, err := rotatelogs.New(path.Join("logs", "%Y-%m-%d.log"), rotateOptions...) if err != nil { log.Errorf("rotatelogs init err: %v", err) panic(err) } consoleFormatter := global.LogFormat{EnableColor: base.LogColorful} fileFormatter := global.LogFormat{EnableColor: false} log.AddHook(global.NewLocalHook(w, consoleFormatter, fileFormatter, global.GetLogLevel(base.LogLevel)...)) mkCacheDir := func(path string, _type string) { if !global.PathExists(path) { if err := os.MkdirAll(path, 0o755); err != nil { log.Fatalf("创建%s缓存文件夹失败: %v", _type, err) } } } mkCacheDir(global.ImagePath, "图片") mkCacheDir(global.VoicePath, "语音") mkCacheDir(global.VideoPath, "视频") mkCacheDir(global.CachePath, "发送图片") mkCacheDir(path.Join(global.ImagePath, "guild-images"), "频道图片缓存") mkCacheDir(global.VersionsPath, "版本缓存") cache.Init() db.Init() if err := db.Open(); err != nil { log.Fatalf("打开数据库失败: %v", err) } } // LoginInteract 登录交互, 可能需要键盘输入, 必须在 InitBase, PrepareData 之后执行 func LoginInteract() { 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.SelfUpdate(arg[i+1]) } else { selfupdate.SelfUpdate("") } case "key": p := i + 1 if len(arg) > p { byteKey = []byte(arg[p]) para.Hide(p) } } } } if (base.Account.Uin == 0 || (base.Account.Password == "" && !base.Account.Encrypt)) && !global.PathExists("session.token") { log.Warn("账号密码未配置, 将使用二维码登录.") if !base.FastStart { log.Warn("将在 5秒 后继续.") time.Sleep(time.Second * 5) } } log.Info("当前版本:", base.Version) if base.Debug { log.SetLevel(log.DebugLevel) log.Warnf("已开启Debug模式.") } if !global.PathExists("device.json") { log.Warn("虚拟设备信息不存在, 将自动生成随机设备.") device = client.GenRandomDevice() _ = os.WriteFile("device.json", device.ToJson(), 0o644) log.Info("已生成设备信息并保存到 device.json 文件.") } else { log.Info("将使用 device.json 内的设备信息运行Bot.") device = new(client.DeviceInfo) if err := device.ReadJson([]byte(global.ReadAllText("device.json"))); err != nil { log.Fatalf("加载设备信息失败: %v", err) } } signServer, err := getAvaliableSignServer() // 获取可用签名服务器 if err != nil { log.Warn(err) } if signServer != nil && len(signServer.URL) > 1 { log.Infof("使用签名服务器:%v", signServer.URL) go signStartRefreshToken(base.Account.RefreshInterval) // 定时刷新 token wrapper.DandelionEnergy = energy wrapper.FekitGetSign = sign if !base.IsBelow110 { if !base.Account.AutoRegister { log.Warn("自动注册实例已关闭,请配置 sign-server 端自动注册实例以保持正常签名") } if !base.Account.AutoRefreshToken { log.Info("自动刷新 token 已关闭,token 过期后获取签名时将不会立即尝试刷新获取新 token") } } else { log.Warn("签名服务器版本 <= 1.1.0 ,无法使用刷新 token 等操作,建议使用 1.1.6 版本及以上签名服务器") } } else { log.Warnf("警告: 未配置签名服务器或签名服务器不可用, 这可能会导致登录 45 错误码或发送消息被风控") } if base.Account.Encrypt { if !global.PathExists("password.encrypt") { if base.Account.Password == "" { log.Error("无法进行加密,请在配置文件中的添加密码后重新启动.") } else { log.Infof("密码加密已启用, 请输入Key对密码进行加密: (Enter 提交)") byteKey, _ = term.ReadPassword(int(os.Stdin.Fd())) base.PasswordHash = md5.Sum([]byte(base.Account.Password)) _ = os.WriteFile("password.encrypt", []byte(PasswordHashEncrypt(base.PasswordHash[:], byteKey)), 0o644) log.Info("密码已加密,为了您的账号安全,请删除配置文件中的密码后重新启动.") } readLine() os.Exit(0) } if base.Account.Password != "" { log.Error("密码已加密,为了您的账号安全,请删除配置文件中的密码后重新启动.") readLine() os.Exit(0) } if len(byteKey) == 0 { log.Infof("密码加密已启用, 请输入Key对密码进行解密以继续: (Enter 提交)") cancel := make(chan struct{}, 1) state, _ := term.GetState(int(os.Stdin.Fd())) go func() { select { case <-cancel: return case <-time.After(time.Second * 45): log.Infof("解密key输入超时") time.Sleep(3 * time.Second) _ = term.Restore(int(os.Stdin.Fd()), state) os.Exit(0) } }() byteKey, _ = term.ReadPassword(int(os.Stdin.Fd())) cancel <- struct{}{} } else { log.Infof("密码加密已启用, 使用运行时传递的参数进行解密,按 Ctrl+C 取消.") } encrypt, _ := os.ReadFile("password.encrypt") ph, err := PasswordHashDecrypt(string(encrypt), byteKey) if err != nil { log.Fatalf("加密存储的密码损坏,请尝试重新配置密码") } copy(base.PasswordHash[:], ph) } else if len(base.Account.Password) > 0 { base.PasswordHash = md5.Sum([]byte(base.Account.Password)) } if !base.FastStart { log.Info("Bot将在5秒后登录并开始信息处理, 按 Ctrl+C 取消.") time.Sleep(time.Second * 5) } log.Info("开始尝试登录并同步消息...") log.Infof("使用协议: %s", device.Protocol.Version()) cli = newClient() cli.UseDevice(device) isQRCodeLogin := (base.Account.Uin == 0 || len(base.Account.Password) == 0) && !base.Account.Encrypt isTokenLogin := false if isQRCodeLogin && cli.Device().Protocol != 2 { log.Warn("当前协议不支持二维码登录, 请配置账号密码登录.") os.Exit(0) } // 加载本地版本信息, 一般是在上次登录时保存的 versionFile := path.Join(global.VersionsPath, fmt.Sprint(int(cli.Device().Protocol))+".json") if global.PathExists(versionFile) { b, err := os.ReadFile(versionFile) if err != nil { log.Warnf("从文件 %s 读取本地版本信息文件出错.", versionFile) os.Exit(0) } err = cli.Device().Protocol.Version().UpdateFromJson(b) if err != nil { log.Warnf("从文件 %s 解析本地版本信息出错: %v", versionFile, err) os.Exit(0) } log.Infof("从文件 %s 读取协议版本 %v.", versionFile, cli.Device().Protocol.Version()) } saveToken := func() { base.AccountToken = cli.GenToken() _ = os.WriteFile("session.token", base.AccountToken, 0o644) } if global.PathExists("session.token") { token, err := os.ReadFile("session.token") if err == nil { if base.Account.Uin != 0 { r := binary.NewReader(token) cu := r.ReadInt64() if cu != base.Account.Uin { log.Warnf("警告: 配置文件内的QQ号 (%v) 与缓存内的QQ号 (%v) 不相同", base.Account.Uin, cu) log.Warnf("1. 使用会话缓存继续.") log.Warnf("2. 删除会话缓存并重启.") log.Warnf("请选择:") text := readIfTTY("1") if text == "2" { _ = os.Remove("session.token") log.Infof("缓存已删除.") os.Exit(0) } } } if err = cli.TokenLogin(token); err != nil { _ = os.Remove("session.token") log.Warnf("恢复会话失败: %v , 尝试使用正常流程登录.", err) time.Sleep(time.Second) cli.Disconnect() cli.Release() cli = newClient() cli.UseDevice(device) } else { isTokenLogin = true } } } if base.Account.Uin != 0 && base.PasswordHash != [16]byte{} { cli.Uin = base.Account.Uin cli.PasswordMd5 = base.PasswordHash } download.SetTimeout(time.Duration(base.HTTPTimeout) * time.Second) if !base.FastStart { log.Infof("正在检查协议更新...") currentVersionName := device.Protocol.Version().SortVersionName remoteVersion, err := getRemoteLatestProtocolVersion(int(device.Protocol.Version().Protocol)) if err == nil { remoteVersionName := gjson.GetBytes(remoteVersion, "sort_version_name").String() if remoteVersionName != currentVersionName { switch { case !base.UpdateProtocol: log.Infof("检测到协议更新: %s -> %s", currentVersionName, remoteVersionName) log.Infof("如果登录时出现版本过低错误, 可尝试使用 -update-protocol 参数启动") case !isTokenLogin: _ = device.Protocol.Version().UpdateFromJson(remoteVersion) err := os.WriteFile(versionFile, remoteVersion, 0644) log.Infof("协议版本已更新: %s -> %s", currentVersionName, remoteVersionName) if err != nil { log.Warnln("更新协议版本缓存文件", versionFile, "失败:", err) } default: log.Infof("检测到协议更新: %s -> %s", currentVersionName, remoteVersionName) log.Infof("由于使用了会话缓存, 无法自动更新协议, 请删除缓存后重试") } } } else if err.Error() != "remote version unavailable" { log.Warnf("检查协议更新失败: %v", err) } } if !isTokenLogin { if !isQRCodeLogin { if err := commonLogin(); err != nil { log.Fatalf("登录时发生致命错误: %v", err) } } else { if err := qrcodeLogin(); err != nil { log.Fatalf("登录时发生致命错误: %v", err) } } } var times uint = 1 // 重试次数 var reLoginLock sync.Mutex cli.DisconnectedEvent.Subscribe(func(_ *client.QQClient, e *client.ClientDisconnectedEvent) { reLoginLock.Lock() defer reLoginLock.Unlock() times = 1 if cli.Online.Load() { return } log.Warnf("Bot已离线: %v", e.Message) time.Sleep(time.Second * time.Duration(base.Reconnect.Delay)) for { if base.Reconnect.Disabled { log.Warnf("未启用自动重连, 将退出.") os.Exit(1) } if times > base.Reconnect.MaxTimes && base.Reconnect.MaxTimes != 0 { log.Fatalf("Bot重连次数超过限制, 停止") } times++ if base.Reconnect.Interval > 0 { log.Warnf("将在 %v 秒后尝试重连. 重连次数:%v/%v", base.Reconnect.Interval, times, base.Reconnect.MaxTimes) time.Sleep(time.Second * time.Duration(base.Reconnect.Interval)) } else { time.Sleep(time.Second) } if cli.Online.Load() { log.Infof("登录已完成") break } log.Warnf("尝试重连...") err := cli.TokenLogin(base.AccountToken) if err == nil { saveToken() return } log.Warnf("快速重连失败: %v", err) if isQRCodeLogin { log.Fatalf("快速重连失败, 扫码登录无法恢复会话.") } log.Warnf("快速重连失败, 尝试普通登录. 这可能是因为其他端强行T下线导致的.") time.Sleep(time.Second) if err := commonLogin(); err != nil { log.Errorf("登录时发生致命错误: %v", err) } else { saveToken() break } } }) saveToken() cli.AllowSlider = true log.Infof("登录成功 欢迎使用: %v", cli.Nickname) log.Info("开始加载好友列表...") global.Check(cli.ReloadFriendList(), true) log.Infof("共加载 %v 个好友.", len(cli.FriendList)) log.Infof("开始加载群列表...") global.Check(cli.ReloadGroupList(), true) log.Infof("共加载 %v 个群.", len(cli.GroupList)) if uint(base.Account.Status) >= uint(len(allowStatus)) { base.Account.Status = 0 } cli.SetOnlineStatus(allowStatus[base.Account.Status]) servers.Run(coolq.NewQQBot(cli)) log.Info("资源初始化完成, 开始处理信息.") log.Info("アトリは、高性能ですから!") } // WaitSignal 在新线程检查更新和网络并等待信号, 必须在 InitBase, PrepareData, LoginInteract 之后执行 // // - 直接返回: os.Interrupt, syscall.SIGTERM // - dump stack: syscall.SIGQUIT, syscall.SIGUSR1 func WaitSignal() { go func() { selfupdate.CheckUpdate() selfdiagnosis.NetworkDiagnosis(cli) }() <-global.SetupMainSignalHandler() } // 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 } func newClient() *client.QQClient { c := client.NewClientEmpty() c.UseFragmentMessage = base.ForceFragmented c.OnServerUpdated(func(_ *client.QQClient, _ *client.ServerUpdatedEvent) bool { if !base.UseSSOAddress { log.Infof("收到服务器地址更新通知, 根据配置文件已忽略.") return false } log.Infof("收到服务器地址更新通知, 将在下一次重连时应用. ") return true }) if global.PathExists("address.txt") { log.Infof("检测到 address.txt 文件. 将覆盖目标IP.") addr := global.ReadAddrFile("address.txt") if len(addr) > 0 { c.SetCustomServer(addr) } log.Infof("读取到 %v 个自定义地址.", len(addr)) } c.SetLogger(protocolLogger{}) return c } var remoteVersions = map[int]string{ 1: "https://raw.githubusercontent.com/RomiChan/protocol-versions/master/android_phone.json", 6: "https://raw.githubusercontent.com/RomiChan/protocol-versions/master/android_pad.json", } func getRemoteLatestProtocolVersion(protocolType int) ([]byte, error) { url, ok := remoteVersions[protocolType] if !ok { return nil, errors.New("remote version unavailable") } response, err := download.Request{URL: url}.Bytes() if err != nil { return download.Request{URL: "https://mirror.ghproxy.com/" + url}.Bytes() } return response, nil } type protocolLogger struct{} const fromProtocol = "Protocol -> " func (p protocolLogger) Info(format string, arg ...any) { log.Infof(fromProtocol+format, arg...) } func (p protocolLogger) Warning(format string, arg ...any) { log.Warnf(fromProtocol+format, arg...) } func (p protocolLogger) Debug(format string, arg ...any) { log.Debugf(fromProtocol+format, arg...) } func (p protocolLogger) Error(format string, arg ...any) { log.Errorf(fromProtocol+format, arg...) } func (p protocolLogger) Dump(data []byte, format string, arg ...any) { if !global.PathExists(global.DumpsPath) { _ = os.MkdirAll(global.DumpsPath, 0o755) } dumpFile := path.Join(global.DumpsPath, fmt.Sprintf("%v.dump", time.Now().Unix())) message := fmt.Sprintf(format, arg...) log.Errorf("出现错误 %v. 详细信息已转储至文件 %v 请连同日志提交给开发者处理", message, dumpFile) _ = os.WriteFile(dumpFile, data, 0o644) } ================================================ FILE: cmd/gocq/qsign.go ================================================ package gocq import ( "bytes" "encoding/hex" "fmt" "io" "net/http" "strconv" "strings" "sync" "sync/atomic" "time" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/Mrs4s/MiraiGo/utils" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/internal/base" "github.com/Mrs4s/go-cqhttp/internal/download" "github.com/Mrs4s/go-cqhttp/modules/config" ) type currentSignServer atomic.Pointer[config.SignServer] func (c *currentSignServer) get() *config.SignServer { if len(base.SignServers) == 1 { // 只配置了一个签名服务时不检查以及切换, 在get阶段返回,防止返回nil导致其他bug(可能) return &base.SignServers[0] } return (*atomic.Pointer[config.SignServer])(c).Load() } func (c *currentSignServer) set(server *config.SignServer) { (*atomic.Pointer[config.SignServer])(c).Store(server) } // 当前签名服务器 var ss currentSignServer // 失败计数 type errconut atomic.Uintptr func (ec *errconut) hasOver(count uintptr) bool { return (*atomic.Uintptr)(ec).Load() > count } func (ec *errconut) inc() { (*atomic.Uintptr)(ec).Add(1) } var errn errconut // getAvaliableSignServer 获取可用的签名服务器,没有则返回空和相应错误 func getAvaliableSignServer() (*config.SignServer, error) { cs := ss.get() if cs != nil { return cs, nil } if len(base.SignServers) == 0 { return nil, errors.New("no sign server configured") } maxCount := base.Account.MaxCheckCount if maxCount == 0 { if errn.hasOver(3) { log.Warn("已连续 3 次获取不到可用签名服务器,将固定使用主签名服务器") ss.set(&base.SignServers[0]) return ss.get(), nil } } else if errn.hasOver(uintptr(maxCount)) { log.Fatalf("获取可用签名服务器失败次数超过 %v 次, 正在离线", maxCount) } if cs != nil && len(cs.URL) > 0 { log.Warnf("当前签名服务器 %v 不可用,正在查找可用服务器", cs.URL) } cs = asyncCheckServer(base.SignServers) if cs == nil { return nil, errors.New("no usable sign server") } return cs, nil } func isServerAvaliable(signServer string) bool { resp, err := download.Request{ Method: http.MethodGet, URL: signServer, }.WithTimeout(3 * time.Second).Bytes() if err == nil && gjson.GetBytes(resp, "code").Int() == 0 { return true } log.Warnf("签名服务器 %v 可能不可用,请求出现错误:%v", signServer, err) return false } // asyncCheckServer 按同步顺序检查所有签名服务器直到找到可用的 func asyncCheckServer(servers []config.SignServer) *config.SignServer { doRegister := sync.Once{} wg := sync.WaitGroup{} wg.Add(len(servers)) for i, s := range servers { go func(i int, server config.SignServer) { defer wg.Done() log.Infof("检查签名服务器:%v (%v/%v)", server.URL, i+1, len(servers)) if len(server.URL) < 4 { return } if isServerAvaliable(server.URL) { doRegister.Do(func() { ss.set(&server) log.Infof("使用签名服务器 url=%v, key=%v, auth=%v", server.URL, server.Key, server.Authorization) if base.Account.AutoRegister { // 若配置了自动注册实例则在切换后注册实例,否则不需要注册,签名时由qsign自动注册 signRegister(base.Account.Uin, device.AndroidId, device.Guid, device.QImei36, server.Key) } }) } }(i, s) } wg.Wait() return ss.get() } /* 请求签名服务器 url: api + params 组合的字符串,无须包含签名服务器地址 return: signServer, response, error */ func requestSignServer(method string, url string, headers map[string]string, body io.Reader) (string, []byte, error) { signServer, e := getAvaliableSignServer() if e != nil && len(signServer.URL) == 0 { // 没有可用的 log.Warnf("获取可用签名服务器出错:%v, 将使用主签名服务器进行签名", e) errn.inc() signServer = &base.SignServers[0] // 没有获取到时使用第一个 } if !strings.HasPrefix(url, signServer.URL) { url = strings.TrimSuffix(signServer.URL, "/") + "/" + strings.TrimPrefix(url, "/") } if headers == nil { headers = map[string]string{} } auth := signServer.Authorization if auth != "-" && auth != "" { headers["Authorization"] = auth } req := download.Request{ Method: method, Header: headers, URL: url, Body: body, }.WithTimeout(time.Duration(base.SignServerTimeout) * time.Second) resp, err := req.Bytes() if err != nil { ss.set(nil) // 标记为不可用 } return signServer.URL, resp, err } func energy(uin uint64, id string, _ string, salt []byte) ([]byte, error) { url := "custom_energy" + fmt.Sprintf("?data=%v&salt=%v&uin=%v&android_id=%v&guid=%v", id, hex.EncodeToString(salt), uin, utils.B2S(device.AndroidId), hex.EncodeToString(device.Guid)) if base.IsBelow110 { url = "custom_energy" + fmt.Sprintf("?data=%v&salt=%v", id, hex.EncodeToString(salt)) } signServer, response, err := requestSignServer(http.MethodGet, url, nil, nil) if err != nil { log.Warnf("获取T544 sign时出现错误: %v. server: %v", err, signServer) return nil, err } data, err := hex.DecodeString(gjson.GetBytes(response, "data").String()) if err != nil { log.Warnf("获取T544 sign时出现错误: %v (data: %v)", err, gjson.GetBytes(response, "data").String()) return nil, err } if len(data) == 0 { log.Warnf("获取T544 sign时出现错误: %v.", "data is empty") return nil, errors.New("data is empty") } return data, nil } // signSubmit // 提交回调 buffer func signSubmit(uin string, cmd string, callbackID int64, buffer []byte, t string) { buffStr := hex.EncodeToString(buffer) if base.Debug { tail := 64 endl := "..." if len(buffStr) < tail { tail = len(buffStr) endl = "." } log.Debugf("submit (%v): uin=%v, cmd=%v, callbackID=%v, buffer=%v%s", t, uin, cmd, callbackID, buffStr[:tail], endl) } signServer, _, err := requestSignServer( http.MethodGet, "submit"+fmt.Sprintf("?uin=%v&cmd=%v&callback_id=%v&buffer=%v", uin, cmd, callbackID, buffStr), nil, nil, ) if err != nil { log.Warnf("提交 callback 时出现错误: %v. server: %v", err, signServer) } } // signCallback // 刷新 token 和签名的回调 func signCallback(uin string, results []gjson.Result, t string) { for { // 等待至在线 if cli.Online.Load() { break } time.Sleep(1 * time.Second) } for _, result := range results { cmd := result.Get("cmd").String() callbackID := result.Get("callbackId").Int() body, _ := hex.DecodeString(result.Get("body").String()) ret, err := cli.SendSsoPacket(cmd, body) if err != nil || len(ret) == 0 { log.Warnf("Callback error: %v, or response data is empty", err) continue // 发送 SsoPacket 出错或返回数据为空时跳过 } signSubmit(uin, cmd, callbackID, ret, t) } } func signRequset(seq uint64, uin string, cmd string, qua string, buff []byte) (sign []byte, extra []byte, token []byte, err error) { headers := map[string]string{"Content-Type": "application/x-www-form-urlencoded"} _, response, err := requestSignServer( http.MethodPost, "sign", headers, bytes.NewReader([]byte(fmt.Sprintf("uin=%v&qua=%s&cmd=%s&seq=%v&buffer=%v&android_id=%v&guid=%v", uin, qua, cmd, seq, hex.EncodeToString(buff), utils.B2S(device.AndroidId), hex.EncodeToString(device.Guid)))), ) if err != nil { return nil, nil, nil, err } sign, _ = hex.DecodeString(gjson.GetBytes(response, "data.sign").String()) extra, _ = hex.DecodeString(gjson.GetBytes(response, "data.extra").String()) token, _ = hex.DecodeString(gjson.GetBytes(response, "data.token").String()) if !base.IsBelow110 { go signCallback(uin, gjson.GetBytes(response, "data.requestCallback").Array(), "sign") } return sign, extra, token, nil } var registerLock sync.Mutex func signRegister(uin int64, androidID, guid []byte, qimei36, key string) { if base.IsBelow110 { log.Warn("签名服务器版本低于1.1.0, 跳过实例注册") return } signServer, resp, err := requestSignServer( http.MethodGet, "register"+fmt.Sprintf("?uin=%v&android_id=%v&guid=%v&qimei36=%v&key=%s", uin, utils.B2S(androidID), hex.EncodeToString(guid), qimei36, key), nil, nil, ) if err != nil { log.Warnf("注册QQ实例时出现错误: %v. server: %v", err, signServer) return } msg := gjson.GetBytes(resp, "msg") if gjson.GetBytes(resp, "code").Int() != 0 { log.Warnf("注册QQ实例时出现错误: %v. server: %v", msg, signServer) return } log.Infof("注册QQ实例 %v 成功: %v", uin, msg) } func signRefreshToken(uin string) error { log.Info("正在刷新 token") _, resp, err := requestSignServer( http.MethodGet, "request_token?uin="+uin, nil, nil, ) if err != nil { return err } msg := gjson.GetBytes(resp, "msg") code := gjson.GetBytes(resp, "code") if code.Int() != 0 { return errors.New("code=" + code.String() + ", msg: " + msg.String()) } go signCallback(uin, gjson.GetBytes(resp, "data").Array(), "request token") return nil } var missTokenCount = uint64(0) var lastToken = "" func sign(seq uint64, uin string, cmd string, qua string, buff []byte) (sign []byte, extra []byte, token []byte, err error) { i := 0 for { sign, extra, token, err = signRequset(seq, uin, cmd, qua, buff) cs := ss.get() if cs == nil { // 最好在请求后判断,否则若被设置为nil后不会再请求签名, // 导致在下一次有请求签名服务操作之前,ss无法更新 err = errors.New("nil signserver") log.Warn("nil sign-server") // 返回的err并不会log出来,加条日志 return } if err != nil { log.Warnf("获取sso sign时出现错误: %v. server: %v", err, cs.URL) } if i > 0 { break } i++ if (!base.IsBelow110) && base.Account.AutoRegister && err == nil && len(sign) == 0 { if registerLock.TryLock() { // 避免并发时多处同时销毁并重新注册 log.Debugf("请求签名:cmd=%v, qua=%v, buff=%v", seq, cmd, hex.EncodeToString(buff)) log.Debugf("返回结果:sign=%v, extra=%v, token=%v", hex.EncodeToString(sign), hex.EncodeToString(extra), hex.EncodeToString(token)) log.Warn("获取签名为空,实例可能丢失,正在尝试重新注册") defer registerLock.Unlock() err := signServerDestroy(uin) if err != nil { log.Warnln(err) // 实例真的丢失时则必出错,或许应该不 return , 以重新获取本次签名 // return nil, nil, nil, err } signRegister(base.Account.Uin, device.AndroidId, device.Guid, device.QImei36, cs.Key) } continue } if (!base.IsBelow110) && base.Account.AutoRefreshToken && len(token) == 0 { log.Warnf("token 已过期, 总丢失 token 次数为 %v", atomic.AddUint64(&missTokenCount, 1)) if registerLock.TryLock() { defer registerLock.Unlock() if err := signRefreshToken(uin); err != nil { log.Warnf("刷新 token 出现错误: %v. server: %v", err, cs.URL) } else { log.Info("刷新 token 成功") } } continue } break } if tokenString := hex.EncodeToString(token); lastToken != tokenString { log.Infof("token 已更新:%v -> %v", lastToken, tokenString) lastToken = tokenString } rule := base.Account.RuleChangeSignServer if (len(sign) == 0 && rule >= 1) || (len(token) == 0 && rule >= 2) { ss.set(nil) } return sign, extra, token, err } func signServerDestroy(uin string) error { signServer, signVersion, err := signVersion() if err != nil { return errors.Wrapf(err, "获取签名服务版本出现错误, server: %v", signServer) } if global.VersionNameCompare("v"+signVersion, "v1.1.6") { return errors.Errorf("当前签名服务器版本 %v 低于 1.1.6,无法使用 destroy 接口", signVersion) } cs := ss.get() if cs == nil { return errors.New("nil signserver") } signServer, resp, err := requestSignServer( http.MethodGet, "destroy"+fmt.Sprintf("?uin=%v&key=%v", uin, cs.Key), nil, nil, ) if err != nil || gjson.GetBytes(resp, "code").Int() != 0 { return errors.Wrapf(err, "destroy 实例出现错误, server: %v", signServer) } return nil } func signVersion() (signServer string, version string, err error) { signServer, resp, err := requestSignServer(http.MethodGet, "", nil, nil) if err != nil { return signServer, "", err } if gjson.GetBytes(resp, "code").Int() == 0 { return signServer, gjson.GetBytes(resp, "data.version").String(), nil } return signServer, "", errors.New("empty version") } // 定时刷新 token, interval 为间隔时间(分钟) func signStartRefreshToken(interval int64) { if interval <= 0 { log.Warn("定时刷新 token 已关闭") return } log.Infof("每 %v 分钟将刷新一次签名 token", interval) if interval < 10 { log.Warnf("间隔时间 %v 分钟较短,推荐 30~40 分钟", interval) } if interval > 60 { log.Warn("间隔时间不能超过 60 分钟,已自动设置为 60 分钟") interval = 60 } t := time.NewTicker(time.Duration(interval) * time.Minute) qqstr := strconv.FormatInt(base.Account.Uin, 10) defer t.Stop() for range t.C { cs, master := ss.get(), &base.SignServers[0] if (cs == nil || cs.URL != master.URL) && isServerAvaliable(master.URL) { ss.set(master) log.Infof("主签名服务器可用,已切换至主签名服务器 %v", master.URL) } cs = ss.get() if cs == nil { log.Warn("无法获得可用签名服务器,停止 token 定时刷新") return } err := signRefreshToken(qqstr) if err != nil { log.Warnf("刷新 token 出现错误: %v. server: %v", err, cs.URL) } } } ================================================ FILE: coolq/api.go ================================================ package coolq import ( "crypto/md5" "encoding/hex" "encoding/json" "fmt" "math" "os" "path" "path/filepath" "runtime" "strconv" "strings" "time" "github.com/Mrs4s/MiraiGo/binary" "github.com/Mrs4s/MiraiGo/client" "github.com/Mrs4s/MiraiGo/message" "github.com/Mrs4s/MiraiGo/utils" "github.com/segmentio/asm/base64" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/Mrs4s/go-cqhttp/db" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/internal/base" "github.com/Mrs4s/go-cqhttp/internal/cache" "github.com/Mrs4s/go-cqhttp/internal/download" "github.com/Mrs4s/go-cqhttp/internal/msg" "github.com/Mrs4s/go-cqhttp/internal/param" "github.com/Mrs4s/go-cqhttp/modules/filter" "github.com/Mrs4s/go-cqhttp/pkg/onebot" ) type guildMemberPageToken struct { guildID uint64 nextIndex uint32 nextRoleID uint64 nextQueryParam string } var defaultPageToken = guildMemberPageToken{ guildID: 0, nextIndex: 0, nextRoleID: 2, } // CQGetLoginInfo 获取登录号信息 // // https://git.io/Jtz1I // @route11(get_login_info) // @route12(get_self_info) func (bot *CQBot) CQGetLoginInfo() global.MSG { return OK(global.MSG{"user_id": bot.Client.Uin, "nickname": bot.Client.Nickname}) } // CQGetQiDianAccountInfo 获取企点账号信息 // @route(qidian_get_account_info) func (bot *CQBot) CQGetQiDianAccountInfo() global.MSG { if bot.Client.QiDian == nil { return Failed(100, "QIDIAN_PROTOCOL_REQUEST", "请使用企点协议") } return OK(global.MSG{ "master_id": bot.Client.QiDian.MasterUin, "ext_name": bot.Client.QiDian.ExtName, "create_time": bot.Client.QiDian.CreateTime, }) } // CQGetGuildServiceProfile 获取频道系统个人资料 // @route(get_guild_service_profile) func (bot *CQBot) CQGetGuildServiceProfile() global.MSG { return OK(global.MSG{ "nickname": bot.Client.GuildService.Nickname, "tiny_id": fU64(bot.Client.GuildService.TinyId), "avatar_url": bot.Client.GuildService.AvatarUrl, }) } // CQGetGuildList 获取已加入的频道列表 // @route(get_guild_list) func (bot *CQBot) CQGetGuildList() global.MSG { fs := make([]global.MSG, 0, len(bot.Client.GuildService.Guilds)) for _, info := range bot.Client.GuildService.Guilds { /* 做成单独的 api 可能会好些? channels := make([]global.MSG, 0, len(info.Channels)) for _, channel := range info.Channels { channels = append(channels, global.MSG{ "channel_id": channel.ChannelId, "channel_name": channel.ChannelName, "channel_type": channel.ChannelType, }) } */ fs = append(fs, global.MSG{ "guild_id": fU64(info.GuildId), "guild_name": info.GuildName, "guild_display_id": fU64(info.GuildCode), // "channels": channels, }) } return OK(fs) } // CQGetGuildMetaByGuest 通过访客权限获取频道元数据 // @route(get_guild_meta_by_guest) func (bot *CQBot) CQGetGuildMetaByGuest(guildID uint64) global.MSG { meta, err := bot.Client.GuildService.FetchGuestGuild(guildID) if err != nil { log.Errorf("获取频道元数据时出现错误: %v", err) return Failed(100, "API_ERROR", err.Error()) } return OK(global.MSG{ "guild_id": fU64(meta.GuildId), "guild_name": meta.GuildName, "guild_profile": meta.GuildProfile, "create_time": meta.CreateTime, "max_member_count": meta.MaxMemberCount, "max_robot_count": meta.MaxRobotCount, "max_admin_count": meta.MaxAdminCount, "member_count": meta.MemberCount, "owner_id": fU64(meta.OwnerId), }) } // CQGetGuildChannelList 获取频道列表 // @route(get_guild_channel_list) func (bot *CQBot) CQGetGuildChannelList(guildID uint64, noCache bool) global.MSG { guild := bot.Client.GuildService.FindGuild(guildID) if guild == nil { return Failed(100, "GUILD_NOT_FOUND") } if noCache { channels, err := bot.Client.GuildService.FetchChannelList(guildID) if err != nil { log.Warnf("获取频道 %v 子频道列表时出现错误: %v", guildID, err) return Failed(100, "API_ERROR", err.Error()) } guild.Channels = channels } channels := make([]global.MSG, 0, len(guild.Channels)) for _, c := range guild.Channels { channels = append(channels, convertChannelInfo(c)) } return OK(channels) } // CQGetGuildMembers 获取频道成员列表 // @route(get_guild_member_list) func (bot *CQBot) CQGetGuildMembers(guildID uint64, nextToken string) global.MSG { guild := bot.Client.GuildService.FindGuild(guildID) if guild == nil { return Failed(100, "GUILD_NOT_FOUND") } token := &defaultPageToken if nextToken != "" { i, exists := bot.nextTokenCache.Get(nextToken) if !exists { return Failed(100, "NEXT_TOKEN_NOT_EXISTS") } token = i if token.guildID != guildID { return Failed(100, "GUILD_NOT_MATCH") } } ret, err := bot.Client.GuildService.FetchGuildMemberListWithRole(guildID, 0, token.nextIndex, token.nextRoleID, token.nextQueryParam) if err != nil { return Failed(100, "API_ERROR", err.Error()) } res := global.MSG{ "members": convertGuildMemberInfo(ret.Members), "finished": ret.Finished, "next_token": nil, } if !ret.Finished { next := &guildMemberPageToken{ guildID: guildID, nextIndex: ret.NextIndex, nextRoleID: ret.NextRoleId, nextQueryParam: ret.NextQueryParam, } id := base64.StdEncoding.EncodeToString(binary.NewWriterF(func(w *binary.Writer) { w.WriteUInt64(uint64(time.Now().UnixNano())) w.WriteString(utils.RandomString(5)) })) bot.nextTokenCache.Add(id, next, time.Minute*10) res["next_token"] = id } return OK(res) } // CQGetGuildMemberProfile 获取频道成员资料 // @route(get_guild_member_profile) func (bot *CQBot) CQGetGuildMemberProfile(guildID, userID uint64) global.MSG { if bot.Client.GuildService.FindGuild(guildID) == nil { return Failed(100, "GUILD_NOT_FOUND") } profile, err := bot.Client.GuildService.FetchGuildMemberProfileInfo(guildID, userID) if err != nil { log.Warnf("获取频道 %v 成员 %v 资料时出现错误: %v", guildID, userID, err) return Failed(100, "API_ERROR", err.Error()) } roles := make([]global.MSG, 0, len(profile.Roles)) for _, role := range profile.Roles { roles = append(roles, global.MSG{ "role_id": fU64(role.RoleId), "role_name": role.RoleName, }) } return OK(global.MSG{ "tiny_id": fU64(profile.TinyId), "nickname": profile.Nickname, "avatar_url": profile.AvatarUrl, "join_time": profile.JoinTime, "roles": roles, }) } // CQGetGuildRoles 获取频道角色列表 // @route(get_guild_roles) func (bot *CQBot) CQGetGuildRoles(guildID uint64) global.MSG { r, err := bot.Client.GuildService.GetGuildRoles(guildID) if err != nil { log.Warnf("获取频道 %v 角色列表时出现错误: %v", guildID, err) return Failed(100, "API_ERROR", err.Error()) } roles := make([]global.MSG, len(r)) for i, role := range r { roles[i] = global.MSG{ "role_id": fU64(role.RoleId), "role_name": role.RoleName, "argb_color": role.ArgbColor, "independent": role.Independent, "member_count": role.Num, "max_count": role.MaxNum, "owned": role.Owned, "disabled": role.Disabled, } } return OK(roles) } // CQCreateGuildRole 创建频道角色 // @route(create_guild_role) func (bot *CQBot) CQCreateGuildRole(guildID uint64, name string, color uint32, independent bool, initialUsers gjson.Result) global.MSG { userSlice := []uint64{} if initialUsers.IsArray() { for _, user := range initialUsers.Array() { userSlice = append(userSlice, user.Uint()) } } role, err := bot.Client.GuildService.CreateGuildRole(guildID, name, color, independent, userSlice) if err != nil { log.Warnf("创建频道 %v 角色时出现错误: %v", guildID, err) return Failed(100, "API_ERROR", err.Error()) } return OK(global.MSG{ "role_id": fU64(role), }) } // CQDeleteGuildRole 删除频道角色 // @route(delete_guild_role) func (bot *CQBot) CQDeleteGuildRole(guildID uint64, roleID uint64) global.MSG { err := bot.Client.GuildService.DeleteGuildRole(guildID, roleID) if err != nil { log.Warnf("删除频道 %v 角色时出现错误: %v", guildID, err) return Failed(100, "API_ERROR", err.Error()) } return OK(nil) } // CQSetGuildMemberRole 设置用户在频道中的角色 // @route(set_guild_member_role) func (bot *CQBot) CQSetGuildMemberRole(guildID uint64, set bool, roleID uint64, users gjson.Result) global.MSG { userSlice := []uint64{} if users.IsArray() { for _, user := range users.Array() { userSlice = append(userSlice, user.Uint()) } } err := bot.Client.GuildService.SetUserRoleInGuild(guildID, set, roleID, userSlice) if err != nil { log.Warnf("设置用户在频道 %v 中的角色时出现错误: %v", guildID, err) return Failed(100, "API_ERROR", err.Error()) } return OK(nil) } // CQModifyRoleInGuild 修改频道角色 // @route(update_guild_role) func (bot *CQBot) CQModifyRoleInGuild(guildID uint64, roleID uint64, name string, color uint32, indepedent bool) global.MSG { err := bot.Client.GuildService.ModifyRoleInGuild(guildID, roleID, name, color, indepedent) if err != nil { log.Warnf("修改频道 %v 角色时出现错误: %v", guildID, err) return Failed(100, "API_ERROR", err.Error()) } return OK(nil) } // CQGetTopicChannelFeeds 获取话题频道帖子列表 // @route(get_topic_channel_feeds) func (bot *CQBot) CQGetTopicChannelFeeds(guildID, channelID uint64) global.MSG { guild := bot.Client.GuildService.FindGuild(guildID) if guild == nil { return Failed(100, "GUILD_NOT_FOUND") } channel := guild.FindChannel(channelID) if channel == nil { return Failed(100, "CHANNEL_NOT_FOUND") } if channel.ChannelType != client.ChannelTypeTopic { return Failed(100, "CHANNEL_TYPE_ERROR") } feeds, err := bot.Client.GuildService.GetTopicChannelFeeds(guildID, channelID) if err != nil { log.Warnf("获取频道 %v 帖子时出现错误: %v", channelID, err) return Failed(100, "API_ERROR", err.Error()) } c := make([]global.MSG, 0, len(feeds)) for _, feed := range feeds { c = append(c, convertChannelFeedInfo(feed)) } return OK(c) } // CQGetFriendList 获取好友列表 // // https://git.io/Jtz1L // @route(get_friend_list) func (bot *CQBot) CQGetFriendList(spec *onebot.Spec) global.MSG { fs := make([]global.MSG, 0, len(bot.Client.FriendList)) for _, f := range bot.Client.FriendList { fs = append(fs, global.MSG{ "nickname": f.Nickname, "remark": f.Remark, "user_id": spec.ConvertID(f.Uin), }) } return OK(fs) } // CQGetUnidirectionalFriendList 获取单向好友列表 // // @route(get_unidirectional_friend_list) func (bot *CQBot) CQGetUnidirectionalFriendList() global.MSG { list, err := bot.Client.GetUnidirectionalFriendList() if err != nil { log.Warnf("获取单向好友列表时出现错误: %v", err) return Failed(100, "API_ERROR", err.Error()) } fs := make([]global.MSG, 0, len(list)) for _, f := range list { fs = append(fs, global.MSG{ "nickname": f.Nickname, "user_id": f.Uin, "source": f.Source, }) } return OK(fs) } // CQDeleteUnidirectionalFriend 删除单向好友 // // @route(delete_unidirectional_friend) // @rename(uin->user_id) func (bot *CQBot) CQDeleteUnidirectionalFriend(uin int64) global.MSG { list, err := bot.Client.GetUnidirectionalFriendList() if err != nil { log.Warnf("获取单向好友列表时出现错误: %v", err) return Failed(100, "API_ERROR", err.Error()) } for _, f := range list { if f.Uin == uin { if err = bot.Client.DeleteUnidirectionalFriend(uin); err != nil { log.Warnf("删除单向好友时出现错误: %v", err) return Failed(100, "API_ERROR", err.Error()) } return OK(nil) } } return Failed(100, "FRIEND_NOT_FOUND", "好友不存在") } // CQDeleteFriend 删除好友 // @route(delete_friend) // @rename(uin->"[user_id\x2Cid].0") func (bot *CQBot) CQDeleteFriend(uin int64) global.MSG { if bot.Client.FindFriend(uin) == nil { return Failed(100, "FRIEND_NOT_FOUND", "好友不存在") } if err := bot.Client.DeleteFriend(uin); err != nil { log.Warnf("删除好友时出现错误: %v", err) return Failed(100, "DELETE_API_ERROR", err.Error()) } return OK(nil) } // CQGetGroupList 获取群列表 // // https://git.io/Jtz1t // @route(get_group_list) func (bot *CQBot) CQGetGroupList(noCache bool, spec *onebot.Spec) global.MSG { gs := make([]global.MSG, 0, len(bot.Client.GroupList)) if noCache { _ = bot.Client.ReloadGroupList() } for _, g := range bot.Client.GroupList { gs = append(gs, global.MSG{ "group_id": spec.ConvertID(g.Code), "group_name": g.Name, "group_create_time": g.GroupCreateTime, "group_level": g.GroupLevel, "max_member_count": g.MaxMemberCount, "member_count": g.MemberCount, }) } return OK(gs) } // CQGetGroupInfo 获取群信息 // // https://git.io/Jtz1O // @route(get_group_info) func (bot *CQBot) CQGetGroupInfo(groupID int64, noCache bool, spec *onebot.Spec) global.MSG { group := bot.Client.FindGroup(groupID) if group == nil || noCache { group, _ = bot.Client.GetGroupInfo(groupID) } if group == nil { 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(global.MSG{ "group_id": spec.ConvertID(g.Code), "group_name": g.Name, "group_memo": g.Memo, "group_create_time": 0, "group_level": 0, "max_member_count": 0, "member_count": 0, }) } } } else { return OK(global.MSG{ "group_id": spec.ConvertID(group.Code), "group_name": group.Name, "group_create_time": group.GroupCreateTime, "group_level": group.GroupLevel, "max_member_count": group.MaxMemberCount, "member_count": group.MemberCount, }) } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQGetGroupMemberList 获取群成员列表 // // https://git.io/Jtz13 // @route(get_group_member_list) func (bot *CQBot) CQGetGroupMemberList(groupID int64, noCache bool) global.MSG { group := bot.Client.FindGroup(groupID) if group == nil { 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, "GET_MEMBERS_API_ERROR", err.Error()) } group.Members = t } members := make([]global.MSG, 0, len(group.Members)) for _, m := range group.Members { members = append(members, convertGroupMemberInfo(groupID, m)) } return OK(members) } // CQGetGroupMemberInfo 获取群成员信息 // // https://git.io/Jtz1s // @route(get_group_member_info) func (bot *CQBot) CQGetGroupMemberInfo(groupID, userID int64, noCache bool) global.MSG { group := bot.Client.FindGroup(groupID) if group == nil { return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } var member *client.GroupMemberInfo if noCache { var err error member, err = bot.Client.GetMemberInfo(groupID, userID) if err != nil { log.Warnf("刷新群 %v 中成员 %v 失败: %v", groupID, userID, err) return Failed(100, "GET_MEMBER_INFO_API_ERROR", err.Error()) } } else { member = group.FindMember(userID) } if member == nil { return Failed(100, "MEMBER_NOT_FOUND", "群员不存在") } return OK(convertGroupMemberInfo(groupID, member)) } // CQGetGroupFileSystemInfo 扩展API-获取群文件系统信息 // // https://docs.go-cqhttp.org/api/#%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 // @route(get_group_file_system_info) func (bot *CQBot) CQGetGroupFileSystemInfo(groupID int64) global.MSG { fs, err := bot.Client.GetGroupFileSystem(groupID) if err != nil { log.Warnf("获取群 %v 文件系统信息失败: %v", groupID, err) return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) } return OK(fs) } // CQGetGroupRootFiles 扩展API-获取群根目录文件列表 // // https://docs.go-cqhttp.org/api/#%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 // @route(get_group_root_files) func (bot *CQBot) CQGetGroupRootFiles(groupID int64) global.MSG { fs, err := bot.Client.GetGroupFileSystem(groupID) if err != nil { log.Warnf("获取群 %v 文件系统信息失败: %v", groupID, err) return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) } files, folders, err := fs.Root() if err != nil { log.Warnf("获取群 %v 根目录文件失败: %v", groupID, err) return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) } return OK(global.MSG{ "files": files, "folders": folders, }) } // CQGetGroupFilesByFolderID 扩展API-获取群子目录文件列表 // // https://docs.go-cqhttp.org/api/#%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 // @route(get_group_files_by_folder) func (bot *CQBot) CQGetGroupFilesByFolderID(groupID int64, folderID string) global.MSG { fs, err := bot.Client.GetGroupFileSystem(groupID) if err != nil { log.Warnf("获取群 %v 文件系统信息失败: %v", groupID, err) return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) } files, folders, err := fs.GetFilesByFolder(folderID) if err != nil { log.Warnf("获取群 %v 根目录 %v 子文件失败: %v", groupID, folderID, err) return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) } return OK(global.MSG{ "files": files, "folders": folders, }) } // CQGetGroupFileURL 扩展API-获取群文件资源链接 // // https://docs.go-cqhttp.org/api/#%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 // @route(get_group_file_url) // @rename(bus_id->"[busid\x2Cbus_id].0") func (bot *CQBot) CQGetGroupFileURL(groupID int64, fileID string, busID int32) global.MSG { url := bot.Client.GetGroupFileUrl(groupID, fileID, busID) if url == "" { return Failed(100, "FILE_SYSTEM_API_ERROR") } return OK(global.MSG{ "url": url, }) } // CQUploadGroupFile 扩展API-上传群文件 // // https://docs.go-cqhttp.org/api/#%E4%B8%8A%E4%BC%A0%E7%BE%A4%E6%96%87%E4%BB%B6 // @route(upload_group_file) func (bot *CQBot) CQUploadGroupFile(groupID int64, file, name, folder string) global.MSG { if !global.PathExists(file) { log.Warnf("上传群文件 %v 失败: 文件不存在", file) return Failed(100, "FILE_NOT_FOUND", "文件不存在") } fs, err := bot.Client.GetGroupFileSystem(groupID) if err != nil { log.Warnf("获取群 %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.Warnf("上传群 %v 文件 %v 失败: %v", groupID, file, err) return Failed(100, "FILE_SYSTEM_UPLOAD_API_ERROR", err.Error()) } return OK(nil) } // CQUploadPrivateFile 扩展API-上传私聊文件 // // @route(upload_private_file) func (bot *CQBot) CQUploadPrivateFile(userID int64, file, name string) global.MSG { target := message.Source{ SourceType: message.SourcePrivate, PrimaryID: userID, } fileBody, err := os.Open(file) if err != nil { log.Warnf("上传私聊文件 %v 失败: %+v", file, err) return Failed(100, "OPEN_FILE_ERROR", "打开文件失败") } defer func() { _ = fileBody.Close() }() localFile := &client.LocalFile{ FileName: name, Body: fileBody, } if err := bot.Client.UploadFile(target, localFile); err != nil { log.Warnf("上传私聊 %v 文件 %v 失败: %+v", userID, file, err) return Failed(100, "FILE_SYSTEM_UPLOAD_API_ERROR", err.Error()) } return OK(nil) } // CQGroupFileCreateFolder 拓展API-创建群文件文件夹 // // @route(create_group_file_folder) func (bot *CQBot) CQGroupFileCreateFolder(groupID int64, parentID, name string) global.MSG { fs, err := bot.Client.GetGroupFileSystem(groupID) if err != nil { log.Warnf("获取群 %v 文件系统信息失败: %v", groupID, err) return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) } if err = fs.CreateFolder(parentID, name); err != nil { log.Warnf("创建群 %v 文件夹失败: %v", groupID, err) return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) } return OK(nil) } // CQGroupFileDeleteFolder 拓展API-删除群文件文件夹 // // @route(delete_group_folder) // @rename(id->folder_id) func (bot *CQBot) CQGroupFileDeleteFolder(groupID int64, id string) global.MSG { fs, err := bot.Client.GetGroupFileSystem(groupID) if err != nil { log.Warnf("获取群 %v 文件系统信息失败: %v", groupID, err) return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) } if err = fs.DeleteFolder(id); err != nil { log.Warnf("删除群 %v 文件夹 %v 时出现文件: %v", groupID, id, err) return Failed(200, "FILE_SYSTEM_API_ERROR", err.Error()) } return OK(nil) } // CQGroupFileDeleteFile 拓展API-删除群文件 // // @route(delete_group_file) // @rename(id->file_id, bus_id->"[busid\x2Cbus_id].0") func (bot *CQBot) CQGroupFileDeleteFile(groupID int64, id string, busID int32) global.MSG { fs, err := bot.Client.GetGroupFileSystem(groupID) if err != nil { log.Warnf("获取群 %v 文件系统信息失败: %v", groupID, err) return Failed(100, "FILE_SYSTEM_API_ERROR", err.Error()) } if res := fs.DeleteFile("", id, busID); res != "" { log.Warnf("删除群 %v 文件 %v 时出现文件: %v", groupID, id, res) return Failed(200, "FILE_SYSTEM_API_ERROR", res) } return OK(nil) } // CQGetWordSlices 隐藏API-获取中文分词 // // https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E4%B8%AD%E6%96%87%E5%88%86%E8%AF%8D-%E9%9A%90%E8%97%8F-api // @route(.get_word_slices) func (bot *CQBot) CQGetWordSlices(content string) global.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(global.MSG{"slices": slices}) } // CQSendMessage 发送消息 // // @route11(send_msg) // @rename(m->message) func (bot *CQBot) CQSendMessage(groupID, userID int64, m gjson.Result, messageType string, autoEscape bool) global.MSG { switch { case messageType == "group": return bot.CQSendGroupMessage(groupID, m, autoEscape) case messageType == "private": fallthrough case userID != 0: return bot.CQSendPrivateMessage(userID, groupID, m, autoEscape) case groupID != 0: return bot.CQSendGroupMessage(groupID, m, autoEscape) } return global.MSG{} } // CQSendForwardMessage 发送合并转发消息 // // @route11(send_forward_msg) // @rename(m->messages) func (bot *CQBot) CQSendForwardMessage(groupID, userID int64, m gjson.Result, messageType string) global.MSG { switch { case messageType == "group": return bot.CQSendGroupForwardMessage(groupID, m) case messageType == "private": fallthrough case userID != 0: return bot.CQSendPrivateForwardMessage(userID, m) case groupID != 0: return bot.CQSendGroupForwardMessage(groupID, m) } return global.MSG{} } // CQSendGroupMessage 发送群消息 // // https://git.io/Jtz1c // @route11(send_group_msg) // @rename(m->message) func (bot *CQBot) CQSendGroupMessage(groupID int64, m gjson.Result, autoEscape bool) global.MSG { group := bot.Client.FindGroup(groupID) if group == nil { return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } fixAt := func(elem []message.IMessageElement) { for _, e := range elem { if at, ok := e.(*message.AtElement); ok && at.Target != 0 && at.Display == "" { mem := group.FindMember(at.Target) if mem != nil { at.Display = "@" + mem.DisplayName() } else { at.Display = "@" + strconv.FormatInt(at.Target, 10) } } } } var elem []message.IMessageElement if m.Type == gjson.JSON { elem = bot.ConvertObjectMessage(onebot.V11, m, message.SourceGroup) } else { str := m.String() if str == "" { log.Warnf("群 %v 消息发送失败: 信息为空.", groupID) return Failed(100, "EMPTY_MSG_ERROR", "消息为空") } if autoEscape { elem = []message.IMessageElement{message.NewText(str)} } else { elem = bot.ConvertStringMessage(onebot.V11, str, message.SourceGroup) } } fixAt(elem) mid, err := bot.SendGroupMessage(groupID, &message.SendingMessage{Elements: elem}) if err != nil { return Failed(100, "SEND_MSG_API_ERROR", err.Error()) } log.Infof("发送群 %v(%v) 的消息: %v (%v)", group.Name, groupID, limitedString(m.String()), mid) return OK(global.MSG{"message_id": mid}) } // CQSendGuildChannelMessage 发送频道消息 // // @route(send_guild_channel_msg) // @rename(m->message) func (bot *CQBot) CQSendGuildChannelMessage(guildID, channelID uint64, m gjson.Result, autoEscape bool) global.MSG { guild := bot.Client.GuildService.FindGuild(guildID) if guild == nil { return Failed(100, "GUILD_NOT_FOUND", "频道不存在") } channel := guild.FindChannel(channelID) if channel == nil { return Failed(100, "CHANNEL_NOT_FOUND", "子频道不存在") } if channel.ChannelType != client.ChannelTypeText { log.Warnf("无法发送频道信息: 频道类型错误, 不接受文本信息") return Failed(100, "CHANNEL_NOT_SUPPORTED_TEXT_MSG", "子频道类型错误, 无法发送文本信息") } fixAt := func(elem []message.IMessageElement) { for _, e := range elem { if at, ok := e.(*message.AtElement); ok && at.Target != 0 && at.Display == "" { mem, _ := bot.Client.GuildService.FetchGuildMemberProfileInfo(guildID, uint64(at.Target)) if mem != nil { at.Display = "@" + mem.Nickname } else { at.Display = "@" + strconv.FormatInt(at.Target, 10) } } } } var elem []message.IMessageElement if m.Type == gjson.JSON { elem = bot.ConvertObjectMessage(onebot.V11, m, message.SourceGuildChannel) } else { str := m.String() if str == "" { log.Warn("频道发送失败: 信息为空.") return Failed(100, "EMPTY_MSG_ERROR", "消息为空") } if autoEscape { elem = []message.IMessageElement{message.NewText(str)} } else { elem = bot.ConvertStringMessage(onebot.V11, str, message.SourceGuildChannel) } } fixAt(elem) mid := bot.SendGuildChannelMessage(guildID, channelID, &message.SendingMessage{Elements: elem}) if mid == "" { return Failed(100, "SEND_MSG_API_ERROR", "请参考 go-cqhttp 端输出") } log.Infof("发送频道 %v(%v) 子频道 %v(%v) 的消息: %v (%v)", guild.GuildName, guild.GuildId, channel.ChannelName, channel.ChannelId, limitedString(m.String()), mid) return OK(global.MSG{"message_id": mid}) } func (bot *CQBot) uploadForwardElement(m gjson.Result, target int64, sourceType message.SourceType) *message.ForwardElement { ts := time.Now().Add(-time.Minute * 5) groupID := target source := message.Source{SourceType: sourceType, PrimaryID: target} if sourceType == message.SourcePrivate { // ios 设备的合并转发来源群号不能为 0 if len(bot.Client.GroupList) == 0 { groupID = 1 } else { groupID = bot.Client.GroupList[0].Uin } } builder := bot.Client.NewForwardMessageBuilder(groupID) var convertMessage func(m gjson.Result) *message.ForwardMessage convertMessage = func(m gjson.Result) *message.ForwardMessage { fm := message.NewForwardMessage() var w worker resolveElement := func(elems []message.IMessageElement) []message.IMessageElement { for i, elem := range elems { p := &elems[i] switch o := elem.(type) { case *msg.LocalVideo: w.do(func() { gm, err := bot.uploadLocalVideo(source, o) if err != nil { log.Warnf(uploadFailedTemplate, "合并转发", target, "视频", err) } else { *p = gm } }) case *msg.LocalImage: w.do(func() { gm, err := bot.uploadLocalImage(source, o) if err != nil { log.Warnf(uploadFailedTemplate, "合并转发", target, "图片", err) } else { *p = gm } }) } } return elems } convert := func(e gjson.Result) *message.ForwardNode { if e.Get("type").Str != "node" { return nil } if e.Get("data.id").Exists() { i := e.Get("data.id").Int() m, _ := db.GetMessageByGlobalID(int32(i)) if m != nil { mSource := message.SourcePrivate if m.GetType() == "group" { mSource = message.SourceGroup } msgTime := m.GetAttribute().Timestamp if msgTime == 0 { msgTime = ts.Unix() } return &message.ForwardNode{ SenderId: m.GetAttribute().SenderUin, SenderName: m.GetAttribute().SenderName, Time: int32(msgTime), Message: resolveElement(bot.ConvertContentMessage(m.GetContent(), mSource, false)), } } log.Warnf("警告: 引用消息 %v 错误或数据库未开启.", e.Get("data.id").Str) return nil } uin := e.Get("data.[user_id,uin].0").Int() msgTime := e.Get("data.time").Int() if msgTime == 0 { msgTime = ts.Unix() } name := e.Get("data.[name,nickname].0").Str c := e.Get("data.content") if c.IsArray() { nested := false c.ForEach(func(_, value gjson.Result) bool { if value.Get("type").Str == "node" { nested = true return false } return true }) if nested { // 处理嵌套 nestedNode := builder.NestedNode() builder.Link(nestedNode, convertMessage(c)) return &message.ForwardNode{ SenderId: uin, SenderName: name, Time: int32(msgTime), Message: []message.IMessageElement{nestedNode}, } } } content := bot.ConvertObjectMessage(onebot.V11, c, sourceType) if uin != 0 && name != "" && len(content) > 0 { return &message.ForwardNode{ SenderId: uin, SenderName: name, Time: int32(msgTime), Message: resolveElement(content), } } log.Warnf("警告: 非法 Forward node 将跳过. uin: %v name: %v content count: %v", uin, name, len(content)) return nil } if m.IsArray() { for _, item := range m.Array() { node := convert(item) if node != nil { fm.AddNode(node) } } } else { node := convert(m) if node != nil { fm.AddNode(node) } } w.wait() return fm } return builder.Main(convertMessage(m)) } // CQSendGroupForwardMessage 扩展API-发送合并转发(群) // // https://docs.go-cqhttp.org/api/#%E5%8F%91%E9%80%81%E5%90%88%E5%B9%B6%E8%BD%AC%E5%8F%91-%E7%BE%A4 // @route11(send_group_forward_msg) // @rename(m->messages) func (bot *CQBot) CQSendGroupForwardMessage(groupID int64, m gjson.Result) global.MSG { if m.Type != gjson.JSON { return Failed(100) } source := message.Source{ SourceType: message.SourcePrivate, PrimaryID: 0, } fe := bot.uploadForwardElement(m, groupID, message.SourceGroup) if fe == nil { return Failed(100, "EMPTY_NODES", "未找到任何可发送的合并转发信息") } ret := bot.Client.SendGroupForwardMessage(groupID, fe) if ret == nil || ret.Id == -1 { log.Warnf("合并转发(群 %v)消息发送失败: 账号可能被风控.", groupID) return Failed(100, "SEND_MSG_API_ERROR", "请参考 go-cqhttp 端输出") } mid := bot.InsertGroupMessage(ret, source) log.Infof("发送群 %v(%v) 的合并转发消息: %v (%v)", groupID, groupID, limitedString(m.String()), mid) return OK(global.MSG{ "message_id": mid, "forward_id": fe.ResId, }) } // CQSendPrivateForwardMessage 扩展API-发送合并转发(好友) // // https://docs.go-cqhttp.org/api/#%E5%8F%91%E9%80%81%E5%90%88%E5%B9%B6%E8%BD%AC%E5%8F%91-%E7%BE%A4 // @route11(send_private_forward_msg) // @rename(m->messages) func (bot *CQBot) CQSendPrivateForwardMessage(userID int64, m gjson.Result) global.MSG { if m.Type != gjson.JSON { return Failed(100) } fe := bot.uploadForwardElement(m, userID, message.SourcePrivate) if fe == nil { return Failed(100, "EMPTY_NODES", "未找到任何可发送的合并转发信息") } mid := bot.SendPrivateMessage(userID, 0, &message.SendingMessage{Elements: []message.IMessageElement{fe}}) if mid == -1 { log.Warnf("合并转发(好友 %v)消息发送失败: 账号可能被风控.", userID) return Failed(100, "SEND_MSG_API_ERROR", "请参考 go-cqhttp 端输出") } log.Infof("发送好友 %v(%v) 的合并转发消息: %v (%v)", userID, userID, limitedString(m.String()), mid) return OK(global.MSG{ "message_id": mid, "forward_id": fe.ResId, }) } // CQSendPrivateMessage 发送私聊消息 // // https://git.io/Jtz1l // @route11(send_private_msg) // @rename(m->message) func (bot *CQBot) CQSendPrivateMessage(userID int64, groupID int64, m gjson.Result, autoEscape bool) global.MSG { var elem []message.IMessageElement if m.Type == gjson.JSON { elem = bot.ConvertObjectMessage(onebot.V11, m, message.SourcePrivate) } else { str := m.String() if str == "" { return Failed(100, "EMPTY_MSG_ERROR", "消息为空") } if autoEscape { elem = []message.IMessageElement{message.NewText(str)} } else { elem = bot.ConvertStringMessage(onebot.V11, str, message.SourcePrivate) } } mid := bot.SendPrivateMessage(userID, groupID, &message.SendingMessage{Elements: elem}) if mid == -1 { return Failed(100, "SEND_MSG_API_ERROR", "请参考 go-cqhttp 端输出") } log.Infof("发送好友 %v(%v) 的消息: %v (%v)", userID, userID, limitedString(m.String()), mid) return OK(global.MSG{"message_id": mid}) } // CQSetGroupCard 设置群名片(群备注) // // https://git.io/Jtz1B // @route(set_group_card) func (bot *CQBot) CQSetGroupCard(groupID, userID int64, card string) global.MSG { if g := bot.Client.FindGroup(groupID); g != nil { if m := g.FindMember(userID); m != nil { m.EditCard(card) return OK(nil) } } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQSetGroupSpecialTitle 设置群组专属头衔 // // https://git.io/Jtz10 // @route(set_group_special_title) // @rename(title->special_title) func (bot *CQBot) CQSetGroupSpecialTitle(groupID, userID int64, title string) global.MSG { if g := bot.Client.FindGroup(groupID); g != nil { if m := g.FindMember(userID); m != nil { m.EditSpecialTitle(title) return OK(nil) } } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQSetGroupName 设置群名 // // https://git.io/Jtz12 // @route(set_group_name) // @rename(name->group_name) func (bot *CQBot) CQSetGroupName(groupID int64, name string) global.MSG { if g := bot.Client.FindGroup(groupID); g != nil { g.UpdateName(name) return OK(nil) } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQGetGroupMemo 扩展API-获取群公告 // @route(_get_group_notice) func (bot *CQBot) CQGetGroupMemo(groupID int64) global.MSG { r, err := bot.Client.GetGroupNotice(groupID) if err != nil { return Failed(100, "获取群公告失败", err.Error()) } return OK(r) } // CQSetGroupMemo 扩展API-发送群公告 // // https://docs.go-cqhttp.org/api/#%E5%8F%91%E9%80%81%E7%BE%A4%E5%85%AC%E5%91%8A // @route(_send_group_notice) // @rename(msg->content, img->image) func (bot *CQBot) CQSetGroupMemo(groupID int64, msg, img string) global.MSG { if g := bot.Client.FindGroup(groupID); g != nil { if g.SelfPermission() == client.Member { return Failed(100, "PERMISSION_DENIED", "权限不足") } if img != "" { data, err := global.FindFile(img, "", global.ImagePath) if err != nil { return Failed(100, "IMAGE_NOT_FOUND", "图片未找到") } noticeID, err := bot.Client.AddGroupNoticeWithPic(groupID, msg, data) if err != nil { return Failed(100, "SEND_NOTICE_ERROR", err.Error()) } return OK(global.MSG{"notice_id": noticeID}) } noticeID, err := bot.Client.AddGroupNoticeSimple(groupID, msg) if err != nil { return Failed(100, "SEND_NOTICE_ERROR", err.Error()) } return OK(global.MSG{"notice_id": noticeID}) } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQDelGroupMemo 扩展API-删除群公告 // @route(_del_group_notice) // @rename(fid->notice_id) func (bot *CQBot) CQDelGroupMemo(groupID int64, fid string) global.MSG { if g := bot.Client.FindGroup(groupID); g != nil { if g.SelfPermission() == client.Member { return Failed(100, "PERMISSION_DENIED", "权限不足") } err := bot.Client.DelGroupNotice(groupID, fid) if err != nil { return Failed(100, "DELETE_NOTICE_ERROR", err.Error()) } return OK(nil) } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQSetGroupKick 群组踢人 // // https://git.io/Jtz1V // @route(set_group_kick) // @rename(msg->message, block->reject_add_request) func (bot *CQBot) CQSetGroupKick(groupID int64, userID int64, msg string, block bool) global.MSG { if g := bot.Client.FindGroup(groupID); g != nil { m := g.FindMember(userID) if m == nil { return Failed(100, "MEMBER_NOT_FOUND", "人员不存在") } err := m.Kick(msg, block) if err != nil { return Failed(100, "NOT_MANAGEABLE", "机器人权限不足") } return OK(nil) } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQSetGroupBan 群组单人禁言 // // https://git.io/Jtz1w // @route(set_group_ban) // @default(duration=1800) func (bot *CQBot) CQSetGroupBan(groupID, userID int64, duration uint32) global.MSG { if g := bot.Client.FindGroup(groupID); g != nil { if m := g.FindMember(userID); m != nil { err := m.Mute(duration) if err != nil { if duration >= 2592000 { return Failed(100, "DURATION_IS_NOT_IN_RANGE", "非法的禁言时长") } return Failed(100, "NOT_MANAGEABLE", "机器人权限不足") } return OK(nil) } } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQSetGroupWholeBan 群组全员禁言 // // https://git.io/Jtz1o // @route(set_group_whole_ban) // @default(enable=true) func (bot *CQBot) CQSetGroupWholeBan(groupID int64, enable bool) global.MSG { if g := bot.Client.FindGroup(groupID); g != nil { g.MuteAll(enable) return OK(nil) } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQSetGroupLeave 退出群组 // // https://git.io/Jtz1K // @route(set_group_leave) func (bot *CQBot) CQSetGroupLeave(groupID int64) global.MSG { if g := bot.Client.FindGroup(groupID); g != nil { g.Quit() return OK(nil) } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQGetAtAllRemain 扩展API-获取群 @全体成员 剩余次数 // // https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E7%BE%A4-%E5%85%A8%E4%BD%93%E6%88%90%E5%91%98-%E5%89%A9%E4%BD%99%E6%AC%A1%E6%95%B0 // @route(get_group_at_all_remain) func (bot *CQBot) CQGetAtAllRemain(groupID int64) global.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", "群聊不存在") } // CQProcessFriendRequest 处理加好友请求 // // https://git.io/Jtz11 // @route(set_friend_add_request) // @default(approve=true) func (bot *CQBot) CQProcessFriendRequest(flag string, approve bool) global.MSG { req, ok := bot.friendReqCache.Load(flag) if !ok { return Failed(100, "FLAG_NOT_FOUND", "FLAG不存在") } if approve { req.Accept() } else { req.Reject() } return OK(nil) } // CQProcessGroupRequest 处理加群请求/邀请 // // https://git.io/Jtz1D // @route(set_group_add_request) // @rename(sub_type->"[sub_type\x2Ctype].0") // @default(approve=true) func (bot *CQBot) CQProcessGroupRequest(flag, subType, reason string, approve bool) global.MSG { msgs, err := bot.Client.GetGroupSystemMessages() if err != nil { log.Warnf("获取群系统消息失败: %v", err) return Failed(100, "SYSTEM_MSG_API_ERROR", err.Error()) } if subType == "add" { for _, req := range msgs.JoinRequests { if strconv.FormatInt(req.RequestId, 10) == flag { if req.Checked { log.Warnf("处理群系统消息失败: 无法操作已处理的消息.") return Failed(100, "FLAG_HAS_BEEN_CHECKED", "消息已被处理") } if approve { req.Accept() } else { req.Reject(false, reason) } return OK(nil) } } } else { for _, req := range msgs.InvitedRequests { if strconv.FormatInt(req.RequestId, 10) == flag { if req.Checked { log.Warnf("处理群系统消息失败: 无法操作已处理的消息.") return Failed(100, "FLAG_HAS_BEEN_CHECKED", "消息已被处理") } if approve { req.Accept() } else { req.Reject(false, reason) } return OK(nil) } } } log.Warnf("处理群系统消息失败: 消息 %v 不存在.", flag) return Failed(100, "FLAG_NOT_FOUND", "FLAG不存在") } // CQDeleteMessage 撤回消息 // // https:// git.io/Jtz1y // @route(delete_msg) func (bot *CQBot) CQDeleteMessage(messageID int32) global.MSG { msg, err := db.GetMessageByGlobalID(messageID) if err != nil { log.Warnf("撤回消息时出现错误: %v", err) return Failed(100, "MESSAGE_NOT_FOUND", "消息不存在") } switch o := msg.(type) { case *db.StoredGroupMessage: if err = bot.Client.RecallGroupMessage(o.GroupCode, o.Attribute.MessageSeq, o.Attribute.InternalID); err != nil { log.Warnf("撤回 %v 失败: %v", messageID, err) return Failed(100, "RECALL_API_ERROR", err.Error()) } case *db.StoredPrivateMessage: if o.Attribute.SenderUin != bot.Client.Uin { log.Warnf("撤回 %v 失败: 好友会话无法撤回对方消息.", messageID) return Failed(100, "CANNOT_RECALL_FRIEND_MSG", "无法撤回对方消息") } if err = bot.Client.RecallPrivateMessage(o.TargetUin, o.Attribute.Timestamp, o.Attribute.MessageSeq, o.Attribute.InternalID); err != nil { log.Warnf("撤回 %v 失败: %v", messageID, err) return Failed(100, "RECALL_API_ERROR", err.Error()) } default: return Failed(100, "UNKNOWN_ERROR") } return OK(nil) } // CQSetGroupAdmin 群组设置管理员 // // https://git.io/Jtz1S // @route(set_group_admin) // @default(enable=true) func (bot *CQBot) CQSetGroupAdmin(groupID, userID int64, enable bool) global.MSG { group := bot.Client.FindGroup(groupID) if group == nil || group.OwnerUin != bot.Client.Uin { return Failed(100, "PERMISSION_DENIED", "群不存在或权限不足") } mem := group.FindMember(userID) if mem == nil { 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, "GET_MEMBERS_API_ERROR", err.Error()) } group.Members = t return OK(nil) } // CQSetGroupAnonymous 群组匿名 // // https://beautyyu.one // @route(set_group_anonymous) // @default(enable=true) func (bot *CQBot) CQSetGroupAnonymous(groupID int64, enable bool) global.MSG { if g := bot.Client.FindGroup(groupID); g != nil { g.SetAnonymous(enable) return OK(nil) } return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } // CQGetGroupHonorInfo 获取群荣誉信息 // // https://git.io/Jtz1H // @route(get_group_honor_info) // @rename(t->type) func (bot *CQBot) CQGetGroupHonorInfo(groupID int64, t string) global.MSG { msg := global.MSG{"group_id": groupID} convertMem := func(memList []client.HonorMemberInfo) (ret []global.MSG) { for _, mem := range memList { ret = append(ret, global.MSG{ "user_id": mem.Uin, "nickname": mem.Name, "avatar": mem.Avatar, "description": mem.Desc, }) } return } if t == "talkative" || t == "all" { if honor, err := bot.Client.GetGroupHonorInfo(groupID, client.Talkative); err == nil { if honor.CurrentTalkative.Uin != 0 { msg["current_talkative"] = global.MSG{ "user_id": honor.CurrentTalkative.Uin, "nickname": honor.CurrentTalkative.Name, "avatar": honor.CurrentTalkative.Avatar, "day_count": honor.CurrentTalkative.DayCount, } } msg["talkative_list"] = convertMem(honor.TalkativeList) } else { log.Infof("获取群龙王出错:%v", err) } } if t == "performer" || t == "all" { if honor, err := bot.Client.GetGroupHonorInfo(groupID, client.Performer); err == nil { msg["performer_list"] = convertMem(honor.ActorList) } else { log.Infof("获取群聊之火出错:%v", err) } } if t == "legend" || t == "all" { if honor, err := bot.Client.GetGroupHonorInfo(groupID, client.Legend); err == nil { msg["legend_list"] = convertMem(honor.LegendList) } else { log.Infof("获取群聊炽焰出错:%v", err) } } if t == "strong_newbie" || t == "all" { if honor, err := bot.Client.GetGroupHonorInfo(groupID, client.StrongNewbie); err == nil { msg["strong_newbie_list"] = convertMem(honor.StrongNewbieList) } else { log.Infof("获取冒尖小春笋出错:%v", err) } } if t == "emotion" || t == "all" { if honor, err := bot.Client.GetGroupHonorInfo(groupID, client.Emotion); err == nil { msg["emotion_list"] = convertMem(honor.EmotionList) } else { log.Infof("获取快乐之源出错:%v", err) } } return OK(msg) } // CQGetStrangerInfo 获取陌生人信息 // // https://git.io/Jtz17 // @route11(get_stranger_info) // @route12(get_user_info) func (bot *CQBot) CQGetStrangerInfo(userID int64) global.MSG { info, err := bot.Client.GetSummaryInfo(userID) if err != nil { return Failed(100, "SUMMARY_API_ERROR", err.Error()) } return OK(global.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" }(), "sign": info.Sign, "age": info.Age, "level": info.Level, "login_days": info.LoginDays, "vip_level": info.VipLevel, }) } // CQHandleQuickOperation 隐藏API-对事件执行快速操作 // // https://git.io/Jtz15 // @route11(".handle_quick_operation") func (bot *CQBot) CQHandleQuickOperation(context, operation gjson.Result) global.MSG { postType := context.Get("post_type").Str switch postType { case "message": anonymous := context.Get("anonymous") isAnonymous := anonymous.Type != gjson.Null msgType := context.Get("message_type").Str reply := operation.Get("reply") if reply.Exists() { autoEscape := param.EnsureBool(operation.Get("auto_escape"), false) at := !isAnonymous && operation.Get("at_sender").Bool() && msgType == "group" if at && reply.IsArray() { // 在 reply 数组头部插入CQ码 replySegments := make([]global.MSG, 0) segments := make([]global.MSG, 0) segments = append(segments, global.MSG{ "type": "at", "data": global.MSG{ "qq": context.Get("sender.user_id").Int(), }, }) err := json.Unmarshal(utils.S2B(reply.Raw), &replySegments) if err != nil { log.WithError(err).Warnf("处理 at_sender 过程中发生错误") return Failed(-1, "处理 at_sender 过程中发生错误", err.Error()) } segments = append(segments, replySegments...) modified, err := json.Marshal(segments) if err != nil { log.WithError(err).Warnf("处理 at_sender 过程中发生错误") return Failed(-1, "处理 at_sender 过程中发生错误", err.Error()) } reply = gjson.Parse(utils.B2S(modified)) } else if at && reply.Type == gjson.String { reply = gjson.Parse(fmt.Sprintf( "\"[CQ:at,qq=%d]%s\"", context.Get("sender.user_id").Int(), reply.String(), )) } if msgType == "group" { bot.CQSendGroupMessage(context.Get("group_id").Int(), reply, autoEscape) } if msgType == "private" { bot.CQSendPrivateMessage(context.Get("user_id").Int(), context.Get("group_id").Int(), reply, autoEscape) } } if msgType == "group" { if operation.Get("delete").Bool() { bot.CQDeleteMessage(int32(context.Get("message_id").Int())) } if !isAnonymous && operation.Get("kick").Bool() { 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 if operation.Get("ban_duration").Exists() { duration = uint32(operation.Get("ban_duration").Uint()) } // unsupported anonymous ban yet if !isAnonymous { bot.CQSetGroupBan(context.Get("group_id").Int(), context.Get("user_id").Int(), duration) } } } case "request": reqType := context.Get("request_type").Str if operation.Get("approve").Exists() { if reqType == "friend" { bot.CQProcessFriendRequest(context.Get("flag").String(), operation.Get("approve").Bool()) } if reqType == "group" { bot.CQProcessGroupRequest(context.Get("flag").String(), context.Get("sub_type").Str, operation.Get("reason").Str, operation.Get("approve").Bool()) } } } return OK(nil) } // CQGetImage 获取图片(修改自OneBot) // // https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E5%9B%BE%E7%89%87%E4%BF%A1%E6%81%AF // @route(get_image) func (bot *CQBot) CQGetImage(file string) global.MSG { var b []byte var err error if strings.HasSuffix(file, ".image") { var f []byte f, err = hex.DecodeString(strings.TrimSuffix(file, ".image")) b = cache.Image.Get(f) } if b == nil { if !global.PathExists(path.Join(global.ImagePath, file)) { return Failed(100) } b, err = os.ReadFile(path.Join(global.ImagePath, file)) } if err == nil { r := binary.NewReader(b) r.ReadBytes(16) msg := global.MSG{ "size": r.ReadInt32(), "filename": r.ReadString(), "url": r.ReadString(), } local := path.Join(global.CachePath, file+path.Ext(msg["filename"].(string))) if !global.PathExists(local) { r := download.Request{URL: msg["url"].(string)} if err := r.WriteToFile(local); err != nil { log.Warnf("下载图片 %v 时出现错误: %v", msg["url"], err) return Failed(100, "DOWNLOAD_IMAGE_ERROR", err.Error()) } } msg["file"] = local return OK(msg) } return Failed(100, "LOAD_FILE_ERROR", err.Error()) } // CQDownloadFile 扩展API-下载文件到缓存目录 // // https://docs.go-cqhttp.org/api/#%E4%B8%8B%E8%BD%BD%E6%96%87%E4%BB%B6%E5%88%B0%E7%BC%93%E5%AD%98%E7%9B%AE%E5%BD%95 // @route(download_file) func (bot *CQBot) CQDownloadFile(url string, headers gjson.Result, threadCount int) global.MSG { h := map[string]string{} if headers.IsArray() { for _, sub := range headers.Array() { first, second, ok := strings.Cut(sub.String(), "=") if ok { h[first] = second } } } if headers.Type == gjson.String { lines := strings.Split(headers.String(), "\r\n") for _, sub := range lines { first, second, ok := strings.Cut(sub, "=") if ok { h[first] = second } } } 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()) } } r := download.Request{URL: url, Header: h} if err := r.WriteToFileMultiThreading(file, threadCount); err != nil { log.Warnf("下载链接 %v 时出现错误: %v", url, err) return Failed(100, "DOWNLOAD_FILE_ERROR", err.Error()) } abs, _ := filepath.Abs(file) return OK(global.MSG{ "file": abs, }) } // CQGetForwardMessage 获取合并转发消息 // // https://git.io/Jtz1F // @route(get_forward_msg) // @rename(res_id->"[message_id\x2Cid].0") func (bot *CQBot) CQGetForwardMessage(resID string) global.MSG { m := bot.Client.GetForwardMessage(resID) if m == nil { return Failed(100, "MSG_NOT_FOUND", "消息不存在") } var transformNodes func(nodes []*message.ForwardNode) []global.MSG transformNodes = func(nodes []*message.ForwardNode) []global.MSG { r := make([]global.MSG, len(nodes)) for i, n := range nodes { bot.checkMedia(n.Message, 0) content := ToFormattedMessage(n.Message, message.Source{SourceType: message.SourceGroup}) if len(n.Message) == 1 { if forward, ok := n.Message[0].(*message.ForwardMessage); ok { content = transformNodes(forward.Nodes) } } r[i] = global.MSG{ "sender": global.MSG{ "user_id": n.SenderId, "nickname": n.SenderName, }, "time": n.Time, "content": content, "group_id": n.GroupId, } } return r } return OK(global.MSG{ "messages": transformNodes(m.Nodes), }) } // CQGetMessage 获取消息 // // https://git.io/Jtz1b // @route(get_msg) func (bot *CQBot) CQGetMessage(messageID int32) global.MSG { msg, err := db.GetMessageByGlobalID(messageID) if err != nil { log.Warnf("获取消息时出现错误: %v", err) return Failed(100, "MSG_NOT_FOUND", "消息不存在") } m := global.MSG{ "message_id": msg.GetGlobalID(), "message_id_v2": msg.GetID(), "message_type": msg.GetType(), "real_id": msg.GetAttribute().MessageSeq, "message_seq": msg.GetAttribute().MessageSeq, "group": msg.GetType() == "group", "sender": global.MSG{ "user_id": msg.GetAttribute().SenderUin, "nickname": msg.GetAttribute().SenderName, }, "time": msg.GetAttribute().Timestamp, } switch o := msg.(type) { case *db.StoredGroupMessage: m["group_id"] = o.GroupCode m["message"] = ToFormattedMessage(bot.ConvertContentMessage(o.Content, message.SourceGroup, false), message.Source{SourceType: message.SourceGroup, PrimaryID: o.GroupCode}) case *db.StoredPrivateMessage: m["message"] = ToFormattedMessage(bot.ConvertContentMessage(o.Content, message.SourcePrivate, false), message.Source{SourceType: message.SourcePrivate}) } return OK(m) } // CQGetGuildMessage 获取频道消息 // @route(get_guild_msg) func (bot *CQBot) CQGetGuildMessage(messageID string, noCache bool) global.MSG { source, seq := decodeGuildMessageID(messageID) if source.SourceType == 0 { log.Warnf("获取消息时出现错误: 无效消息ID") return Failed(100, "INVALID_MESSAGE_ID", "无效消息ID") } m := global.MSG{ "message_id": messageID, "message_source": func() string { if source.SourceType == message.SourceGuildDirect { return "direct" } return "channel" }(), "message_seq": seq, "guild_id": fU64(uint64(source.PrimaryID)), "reactions": []int{}, } // nolint: exhaustive switch source.SourceType { case message.SourceGuildChannel: m["channel_id"] = fU64(uint64(source.SecondaryID)) if noCache { pull, err := bot.Client.GuildService.PullGuildChannelMessage(uint64(source.PrimaryID), uint64(source.SecondaryID), seq, seq) if err != nil { log.Warnf("获取消息时出现错误: %v", err) return Failed(100, "API_ERROR", err.Error()) } if len(m) == 0 { log.Warnf("获取消息时出现错误: 消息不存在") return Failed(100, "MSG_NOT_FOUND", "消息不存在") } m["time"] = pull[0].Time m["sender"] = global.MSG{ "user_id": pull[0].Sender.TinyId, "tiny_id": fU64(pull[0].Sender.TinyId), "nickname": pull[0].Sender.Nickname, } m["message"] = ToFormattedMessage(pull[0].Elements, source) m["reactions"] = convertReactions(pull[0].Reactions) bot.InsertGuildChannelMessage(pull[0]) } else { channelMsgByDB, err := db.GetGuildChannelMessageByID(messageID) if err != nil { log.Warnf("获取消息时出现错误: %v", err) return Failed(100, "MSG_NOT_FOUND", "消息不存在") } m["time"] = channelMsgByDB.Attribute.Timestamp m["sender"] = global.MSG{ "user_id": channelMsgByDB.Attribute.SenderTinyID, "tiny_id": fU64(channelMsgByDB.Attribute.SenderTinyID), "nickname": channelMsgByDB.Attribute.SenderName, } m["message"] = ToFormattedMessage(bot.ConvertContentMessage(channelMsgByDB.Content, message.SourceGuildChannel, false), source) } case message.SourceGuildDirect: // todo(mrs4s): 支持 direct 消息 m["tiny_id"] = fU64(uint64(source.SecondaryID)) } return OK(m) } // CQGetGroupSystemMessages 扩展API-获取群文件系统消息 // // https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E7%BE%A4%E7%B3%BB%E7%BB%9F%E6%B6%88%E6%81%AF // @route(get_group_system_msg) func (bot *CQBot) CQGetGroupSystemMessages() global.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) } // CQGetGroupMessageHistory 获取群消息历史记录 // // https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E7%BE%A4%E6%B6%88%E6%81%AF%E5%8E%86%E5%8F%B2%E8%AE%B0%E5%BD%95 // @route(get_group_msg_history) // @rename(seq->message_seq) func (bot *CQBot) CQGetGroupMessageHistory(groupID int64, seq int64) global.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()) } source := message.Source{ SourceType: message.SourcePrivate, PrimaryID: 0, } ms := make([]*event, 0, len(msg)) for _, m := range msg { bot.checkMedia(m.Elements, groupID) id := bot.InsertGroupMessage(m, source) t := bot.formatGroupMessage(m) t.Others["message_id"] = id ms = append(ms, t) } return OK(global.MSG{ "messages": ms, }) } // CQGetOnlineClients 扩展API-获取当前账号在线客户端列表 // // https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E5%BD%93%E5%89%8D%E8%B4%A6%E5%8F%B7%E5%9C%A8%E7%BA%BF%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%88%97%E8%A1%A8 // @route(get_online_clients) func (bot *CQBot) CQGetOnlineClients(noCache bool) global.MSG { if noCache { if err := bot.Client.RefreshStatus(); err != nil { log.Warnf("刷新客户端状态时出现问题 %v", err) return Failed(100, "REFRESH_STATUS_ERROR", err.Error()) } } d := make([]global.MSG, 0, len(bot.Client.OnlineClients)) for _, oc := range bot.Client.OnlineClients { d = append(d, global.MSG{ "app_id": oc.AppId, "device_name": oc.DeviceName, "device_kind": oc.DeviceKind, }) } return OK(global.MSG{ "clients": d, }) } // CQCanSendImage 检查是否可以发送图片(此处永远返回true) // // https://git.io/Jtz1N // @route11(can_send_image) func (bot *CQBot) CQCanSendImage() global.MSG { return OK(global.MSG{"yes": true}) } // CQCanSendRecord 检查是否可以发送语音(此处永远返回true) // // https://git.io/Jtz1x // @route11(can_send_record) func (bot *CQBot) CQCanSendRecord() global.MSG { return OK(global.MSG{"yes": true}) } // CQOcrImage 扩展API-图片OCR // // https://docs.go-cqhttp.org/api/#%E5%9B%BE%E7%89%87-ocr // @route(ocr_image,".ocr_image") // @rename(image_id->image) func (bot *CQBot) CQOcrImage(imageID string) global.MSG { // TODO: fix this var elem msg.Element elem.Type = "image" elem.Data = []msg.Pair{{K: "file", V: imageID}} img, err := bot.makeImageOrVideoElem(elem, false, message.SourceGroup) 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) } // CQSetGroupPortrait 扩展API-设置群头像 // // https://docs.go-cqhttp.org/api/#%E8%AE%BE%E7%BD%AE%E7%BE%A4%E5%A4%B4%E5%83%8F // @route(set_group_portrait) func (bot *CQBot) CQSetGroupPortrait(groupID int64, file, cache string) global.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", "群聊不存在") } // CQSetGroupAnonymousBan 群组匿名用户禁言 // // https://git.io/Jtz1p // @route(set_group_anonymous_ban) // @rename(flag->"[anonymous_flag\x2Canonymous.flag].0") func (bot *CQBot) CQSetGroupAnonymousBan(groupID int64, flag string, duration int32) global.MSG { if flag == "" { return Failed(100, "INVALID_FLAG", "无效的flag") } if g := bot.Client.FindGroup(groupID); g != nil { id, nick, ok := strings.Cut(flag, "|") if !ok { return Failed(100, "INVALID_FLAG", "无效的flag") } 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", "群聊不存在") } // CQGetStatus 获取运行状态 // // https://git.io/JtzMe // @route(get_status) func (bot *CQBot) CQGetStatus(spec *onebot.Spec) global.MSG { if spec.Version == 11 { return OK(global.MSG{ "app_initialized": true, "app_enabled": true, "plugins_good": nil, "app_good": true, "online": bot.Client.Online.Load(), "good": bot.Client.Online.Load(), "stat": bot.Client.GetStatistics(), }) } return OK(global.MSG{ "online": bot.Client.Online.Load(), "good": bot.Client.Online.Load(), "stat": bot.Client.GetStatistics(), }) } // CQSetEssenceMessage 扩展API-设置精华消息 // // https://docs.go-cqhttp.org/api/#%E8%AE%BE%E7%BD%AE%E7%B2%BE%E5%8D%8E%E6%B6%88%E6%81%AF // @route(set_essence_msg) func (bot *CQBot) CQSetEssenceMessage(messageID int32) global.MSG { msg, err := db.GetGroupMessageByGlobalID(messageID) if err != nil { return Failed(100, "MESSAGE_NOT_FOUND", "消息不存在") } if err := bot.Client.SetEssenceMessage(msg.GroupCode, msg.Attribute.MessageSeq, msg.Attribute.InternalID); err != nil { log.Warnf("设置精华消息 %v 失败: %v", messageID, err) return Failed(100, "SET_ESSENCE_MSG_ERROR", err.Error()) } return OK(nil) } // CQDeleteEssenceMessage 扩展API-移出精华消息 // // https://docs.go-cqhttp.org/api/#%E7%A7%BB%E5%87%BA%E7%B2%BE%E5%8D%8E%E6%B6%88%E6%81%AF // @route(delete_essence_msg) func (bot *CQBot) CQDeleteEssenceMessage(messageID int32) global.MSG { msg, err := db.GetGroupMessageByGlobalID(messageID) if err != nil { return Failed(100, "MESSAGE_NOT_FOUND", "消息不存在") } if err := bot.Client.DeleteEssenceMessage(msg.GroupCode, msg.Attribute.MessageSeq, msg.Attribute.InternalID); err != nil { log.Warnf("删除精华消息 %v 失败: %v", messageID, err) return Failed(100, "SET_ESSENCE_MSG_ERROR", err.Error()) } return OK(nil) } // CQGetEssenceMessageList 扩展API-获取精华消息列表 // // https://docs.go-cqhttp.org/api/#%E8%8E%B7%E5%8F%96%E7%B2%BE%E5%8D%8E%E6%B6%88%E6%81%AF%E5%88%97%E8%A1%A8 // @route(get_essence_msg_list) func (bot *CQBot) CQGetEssenceMessageList(groupID int64) global.MSG { g := bot.Client.FindGroup(groupID) if g == nil { return Failed(100, "GROUP_NOT_FOUND", "群聊不存在") } msgList, err := bot.Client.GetGroupEssenceMsgList(groupID) if err != nil { return Failed(100, "GET_ESSENCE_LIST_FOUND", err.Error()) } list := make([]global.MSG, 0, len(msgList)) for _, m := range msgList { msg := global.MSG{ "sender_nick": m.SenderNick, "sender_time": m.SenderTime, "operator_time": m.AddDigestTime, "operator_nick": m.AddDigestNick, "sender_id": m.SenderUin, "operator_id": m.AddDigestUin, } msg["message_id"] = db.ToGlobalID(groupID, int32(m.MessageID)) list = append(list, msg) } return OK(list) } // CQCheckURLSafely 扩展API-检查链接安全性 // // https://docs.go-cqhttp.org/api/#%E6%A3%80%E6%9F%A5%E9%93%BE%E6%8E%A5%E5%AE%89%E5%85%A8%E6%80%A7 // @route(check_url_safely) func (bot *CQBot) CQCheckURLSafely(url string) global.MSG { return OK(global.MSG{ "level": bot.Client.CheckUrlSafely(url), }) } // CQGetVersionInfo 获取版本信息 // // https://git.io/JtwUs // @route11(get_version_info) func (bot *CQBot) CQGetVersionInfo() global.MSG { wd, _ := os.Getwd() return OK(global.MSG{ "app_name": "go-cqhttp", "app_version": base.Version, "app_full_name": fmt.Sprintf("go-cqhttp-%s_%s_%s-%s", base.Version, runtime.GOOS, runtime.GOARCH, runtime.Version()), "protocol_version": "v11", "coolq_directory": wd, "coolq_edition": "pro", "go_cqhttp": true, "plugin_version": "4.15.0", "plugin_build_number": 99, "plugin_build_configuration": "release", "runtime_version": runtime.Version(), "runtime_os": runtime.GOOS, "version": base.Version, "protocol_name": bot.Client.Device().Protocol, }) } // CQGetModelShow 获取在线机型 // // https://club.vip.qq.com/onlinestatus/set // @route(_get_model_show) func (bot *CQBot) CQGetModelShow(model string) global.MSG { variants, err := bot.Client.GetModelShow(model) if err != nil { return Failed(100, "GET_MODEL_SHOW_API_ERROR", "无法获取在线机型") } a := make([]global.MSG, 0, len(variants)) for _, v := range variants { a = append(a, global.MSG{ "model_show": v.ModelShow, "need_pay": v.NeedPay, }) } return OK(global.MSG{ "variants": a, }) } // CQSendGroupSign 群打卡 // // https://club.vip.qq.com/onlinestatus/set // @route(send_group_sign) func (bot *CQBot) CQSendGroupSign(groupID int64) global.MSG { bot.Client.SendGroupSign(groupID) return OK(nil) } // CQSetModelShow 设置在线机型 // // https://club.vip.qq.com/onlinestatus/set // @route(_set_model_show) func (bot *CQBot) CQSetModelShow(model, modelShow string) global.MSG { err := bot.Client.SetModelShow(model, modelShow) if err != nil { return Failed(100, "SET_MODEL_SHOW_API_ERROR", "无法设置在线机型") } return OK(nil) } // CQMarkMessageAsRead 标记消息已读 // @route(mark_msg_as_read) // @rename(msg_id->message_id) func (bot *CQBot) CQMarkMessageAsRead(msgID int32) global.MSG { m, err := db.GetMessageByGlobalID(msgID) if err != nil { return Failed(100, "MSG_NOT_FOUND", "消息不存在") } switch o := m.(type) { case *db.StoredGroupMessage: bot.Client.MarkGroupMessageReaded(o.GroupCode, int64(o.Attribute.MessageSeq)) return OK(nil) case *db.StoredPrivateMessage: bot.Client.MarkPrivateMessageReaded(o.SessionUin, o.Attribute.Timestamp) } return OK(nil) } // CQSetQQProfile 设置 QQ 资料 // // @route(set_qq_profile) func (bot *CQBot) CQSetQQProfile(nickname, company, email, college, personalNote gjson.Result) global.MSG { u := client.NewProfileDetailUpdate() fi := func(f gjson.Result, do func(value string) client.ProfileDetailUpdate) { if f.Exists() { do(f.String()) } } fi(nickname, u.Nick) fi(company, u.Company) fi(email, u.Email) fi(college, u.College) fi(personalNote, u.PersonalNote) bot.Client.UpdateProfile(u) return OK(nil) } // CQReloadEventFilter 重载事件过滤器 // // @route(reload_event_filter) func (bot *CQBot) CQReloadEventFilter(file string) global.MSG { filter.Add(file) return OK(nil) } // CQGetSupportedActions 获取支持的动作列表 // // @route(get_supported_actions) func (bot *CQBot) CQGetSupportedActions(spec *onebot.Spec) global.MSG { return OK(spec.SupportedActions) } // OK 生成成功返回值 func OK(data any) global.MSG { return global.MSG{"data": data, "retcode": 0, "status": "ok", "message": ""} } // Failed 生成失败返回值 func Failed(code int, msg ...string) global.MSG { m, w := "", "" if len(msg) > 0 { m = msg[0] } if len(msg) > 1 { w = msg[1] } return global.MSG{"data": nil, "retcode": code, "msg": m, "wording": w, "message": w, "status": "failed"} } func limitedString(str string) string { limited := [14]rune{10: ' ', 11: '.', 12: '.', 13: '.'} i := 0 for _, r := range str { if i >= 10 { break } limited[i] = r i++ } if i != 10 { return str } return string(limited[:]) } ================================================ FILE: coolq/api_v12.go ================================================ package coolq import ( "runtime" "github.com/tidwall/gjson" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/internal/base" ) // CQGetVersion 获取版本信息 OneBotV12 // // https://git.io/JtwUs // @route12(get_version) func (bot *CQBot) CQGetVersion() global.MSG { return OK(global.MSG{ "impl": "go_cqhttp", "platform": "qq", "version": base.Version, "onebot_version": 12, "runtime_version": runtime.Version(), "runtime_os": runtime.GOOS, }) } // CQSendMessageV12 发送消息 // // @route12(send_message) // @rename(m->message) func (bot *CQBot) CQSendMessageV12(groupID, userID, detailType string, m gjson.Result) global.MSG { // nolint // TODO: implement return OK(nil) } ================================================ FILE: coolq/bot.go ================================================ package coolq import ( "bytes" "encoding/hex" "encoding/json" "fmt" "image/png" "os" "runtime/debug" "strings" "sync" "time" "github.com/Mrs4s/MiraiGo/binary" "github.com/Mrs4s/MiraiGo/client" "github.com/Mrs4s/MiraiGo/message" "github.com/Mrs4s/MiraiGo/utils" "github.com/RomiChan/syncx" "github.com/pkg/errors" "github.com/segmentio/asm/base64" log "github.com/sirupsen/logrus" "golang.org/x/image/webp" "github.com/Mrs4s/go-cqhttp/db" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/internal/base" "github.com/Mrs4s/go-cqhttp/internal/mime" "github.com/Mrs4s/go-cqhttp/internal/msg" "github.com/Mrs4s/go-cqhttp/pkg/onebot" ) // CQBot CQBot结构体,存储Bot实例相关配置 type CQBot struct { Client *client.QQClient lock sync.RWMutex events []func(*Event) friendReqCache syncx.Map[string, *client.NewFriendRequest] tempSessionCache syncx.Map[int64, *client.TempSessionInfo] nextTokenCache *utils.Cache[*guildMemberPageToken] } // Event 事件 type Event struct { once sync.Once Raw *event buffer *bytes.Buffer } func (e *Event) marshal() { if e.buffer == nil { e.buffer = global.NewBuffer() } _ = json.NewEncoder(e.buffer).Encode(e.Raw) } // JSONBytes return byes of json by lazy marshalling. func (e *Event) JSONBytes() []byte { e.once.Do(e.marshal) return e.buffer.Bytes() } // JSONString return string of json without extra allocation // by lazy marshalling. func (e *Event) JSONString() string { e.once.Do(e.marshal) return utils.B2S(e.buffer.Bytes()) } // NewQQBot 初始化一个QQBot实例 func NewQQBot(cli *client.QQClient) *CQBot { bot := &CQBot{ Client: cli, nextTokenCache: utils.NewCache[*guildMemberPageToken](time.Second * 10), } bot.Client.PrivateMessageEvent.Subscribe(bot.privateMessageEvent) bot.Client.GroupMessageEvent.Subscribe(bot.groupMessageEvent) if base.ReportSelfMessage { bot.Client.SelfPrivateMessageEvent.Subscribe(bot.privateMessageEvent) bot.Client.SelfGroupMessageEvent.Subscribe(bot.groupMessageEvent) } bot.Client.TempMessageEvent.Subscribe(bot.tempMessageEvent) bot.Client.GuildService.OnGuildChannelMessage(bot.guildChannelMessageEvent) bot.Client.GuildService.OnGuildMessageReactionsUpdated(bot.guildMessageReactionsUpdatedEvent) bot.Client.GuildService.OnGuildMessageRecalled(bot.guildChannelMessageRecalledEvent) bot.Client.GuildService.OnGuildChannelUpdated(bot.guildChannelUpdatedEvent) bot.Client.GuildService.OnGuildChannelCreated(bot.guildChannelCreatedEvent) bot.Client.GuildService.OnGuildChannelDestroyed(bot.guildChannelDestroyedEvent) bot.Client.GroupMuteEvent.Subscribe(bot.groupMutedEvent) bot.Client.GroupMessageRecalledEvent.Subscribe(bot.groupRecallEvent) bot.Client.GroupNotifyEvent.Subscribe(bot.groupNotifyEvent) bot.Client.FriendNotifyEvent.Subscribe(bot.friendNotifyEvent) bot.Client.MemberSpecialTitleUpdatedEvent.Subscribe(bot.memberTitleUpdatedEvent) bot.Client.FriendMessageRecalledEvent.Subscribe(bot.friendRecallEvent) bot.Client.OfflineFileEvent.Subscribe(bot.offlineFileEvent) bot.Client.GroupJoinEvent.Subscribe(bot.joinGroupEvent) bot.Client.GroupLeaveEvent.Subscribe(bot.leaveGroupEvent) bot.Client.GroupMemberJoinEvent.Subscribe(bot.memberJoinEvent) bot.Client.GroupMemberLeaveEvent.Subscribe(bot.memberLeaveEvent) bot.Client.GroupMemberPermissionChangedEvent.Subscribe(bot.memberPermissionChangedEvent) bot.Client.MemberCardUpdatedEvent.Subscribe(bot.memberCardUpdatedEvent) bot.Client.NewFriendRequestEvent.Subscribe(bot.friendRequestEvent) bot.Client.NewFriendEvent.Subscribe(bot.friendAddedEvent) bot.Client.GroupInvitedEvent.Subscribe(bot.groupInvitedEvent) bot.Client.UserWantJoinGroupEvent.Subscribe(bot.groupJoinReqEvent) bot.Client.OtherClientStatusChangedEvent.Subscribe(bot.otherClientStatusChangedEvent) bot.Client.GroupDigestEvent.Subscribe(bot.groupEssenceMsg) go func() { if base.HeartbeatInterval == 0 { log.Warn("警告: 心跳功能已关闭,若非预期,请检查配置文件。") return } t := time.NewTicker(base.HeartbeatInterval) for { <-t.C bot.dispatchEvent("meta_event/heartbeat", global.MSG{ "status": bot.CQGetStatus(onebot.V11)["data"], "interval": base.HeartbeatInterval.Milliseconds(), }) } }() return bot } // OnEventPush 注册事件上报函数 func (bot *CQBot) OnEventPush(f func(e *Event)) { bot.lock.Lock() bot.events = append(bot.events, f) bot.lock.Unlock() } type worker struct { wg sync.WaitGroup } func (w *worker) do(f func()) { w.wg.Add(1) go func() { defer w.wg.Done() f() }() } func (w *worker) wait() { w.wg.Wait() } // uploadLocalImage 上传本地图片 func (bot *CQBot) uploadLocalImage(target message.Source, img *msg.LocalImage) (message.IMessageElement, error) { if img.File != "" { f, err := os.Open(img.File) if err != nil { return nil, errors.Wrap(err, "open image error") } defer func() { _ = f.Close() }() img.Stream = f } mt, ok := mime.CheckImage(img.Stream) if !ok { return nil, errors.New("image type error: " + mt) } if mt == "image/webp" && base.ConvertWebpImage { img0, err := webp.Decode(img.Stream) if err != nil { return nil, errors.Wrap(err, "decode webp error") } stream := bytes.NewBuffer(nil) err = png.Encode(stream, img0) if err != nil { return nil, errors.Wrap(err, "encode png error") } img.Stream = bytes.NewReader(stream.Bytes()) } i, err := bot.Client.UploadImage(target, img.Stream) if err != nil { return nil, err } switch i := i.(type) { case *message.GroupImageElement: i.Flash = img.Flash i.EffectID = img.EffectID case *message.FriendImageElement: i.Flash = img.Flash } return i, err } // uploadLocalVideo 上传本地短视频至群聊 func (bot *CQBot) uploadLocalVideo(target message.Source, v *msg.LocalVideo) (*message.ShortVideoElement, error) { video, err := os.Open(v.File) if err != nil { return nil, err } defer func() { _ = video.Close() }() return bot.Client.UploadShortVideo(target, video, v.Thumb) } func removeLocalElement(elements []message.IMessageElement) []message.IMessageElement { var j int for i, e := range elements { switch e.(type) { case *msg.LocalImage, *msg.LocalVideo: case *message.VoiceElement: // 未上传的语音消息, 也删除 case nil: default: if j < i { elements[j] = e } j++ } } return elements[:j] } const uploadFailedTemplate = "警告: %s %d %s上传失败: %v" func (bot *CQBot) uploadMedia(target message.Source, elements []message.IMessageElement) []message.IMessageElement { var w worker var source string switch target.SourceType { // nolint:exhaustive case message.SourceGroup: source = "群" case message.SourcePrivate: source = "私聊" case message.SourceGuildChannel: source = "频道" } for i, m := range elements { p := &elements[i] switch e := m.(type) { case *msg.LocalImage: w.do(func() { m, err := bot.uploadLocalImage(target, e) if err != nil { log.Warnf(uploadFailedTemplate, source, target.PrimaryID, "图片", err) } else { *p = m } }) case *message.VoiceElement: w.do(func() { m, err := bot.Client.UploadVoice(target, bytes.NewReader(e.Data)) if err != nil { log.Warnf(uploadFailedTemplate, source, target.PrimaryID, "语音", err) } else { *p = m } }) case *msg.LocalVideo: w.do(func() { m, err := bot.uploadLocalVideo(target, e) if err != nil { log.Warnf(uploadFailedTemplate, source, target.PrimaryID, "视频", err) } else { *p = m } }) } } w.wait() return removeLocalElement(elements) } // SendGroupMessage 发送群消息 func (bot *CQBot) SendGroupMessage(groupID int64, m *message.SendingMessage) (int32, error) { newElem := make([]message.IMessageElement, 0, len(m.Elements)) group := bot.Client.FindGroup(groupID) source := message.Source{ SourceType: message.SourceGroup, PrimaryID: groupID, } m.Elements = bot.uploadMedia(source, m.Elements) for _, e := range m.Elements { switch i := e.(type) { case *msg.Poke: if group != nil { if mem := group.FindMember(i.Target); mem != nil { mem.Poke() } } return 0, nil case *message.MusicShareElement: ret, err := bot.Client.SendGroupMusicShare(groupID, i) if err != nil { log.Warnf("警告: 群 %v 富文本消息发送失败: %v", groupID, err) return -1, errors.Wrap(err, "send group music share error") } return bot.InsertGroupMessage(ret, source), nil case *message.AtElement: if i.Target == 0 && group.SelfPermission() == client.Member { e = message.NewText("@全体成员") } } newElem = append(newElem, e) } if len(newElem) == 0 { log.Warnf("群 %v 消息发送失败: 消息为空.", groupID) return -1, errors.New("empty message") } m.Elements = newElem bot.checkMedia(newElem, groupID) ret := bot.Client.SendGroupMessage(groupID, m) if ret == nil || ret.Id == -1 { log.Warnf("群 %v 发送消息失败: 账号可能被风控.", groupID) return -1, errors.New("send group message failed: blocked by server") } return bot.InsertGroupMessage(ret, source), nil } // SendPrivateMessage 发送私聊消息 func (bot *CQBot) SendPrivateMessage(target int64, groupID int64, m *message.SendingMessage) int32 { newElem := make([]message.IMessageElement, 0, len(m.Elements)) source := message.Source{ SourceType: message.SourcePrivate, PrimaryID: target, } m.Elements = bot.uploadMedia(source, m.Elements) for _, e := range m.Elements { switch i := e.(type) { case *msg.Poke: bot.Client.SendFriendPoke(i.Target) return 0 case *message.MusicShareElement: bot.Client.SendFriendMusicShare(target, i) return 0 } newElem = append(newElem, e) } if len(newElem) == 0 { log.Warnf("好友消息发送失败: 消息为空.") return -1 } m.Elements = newElem bot.checkMedia(newElem, bot.Client.Uin) // 单向好友是否存在 unidirectionalFriendExists := func() bool { list, err := bot.Client.GetUnidirectionalFriendList() if err != nil { return false } for _, f := range list { if f.Uin == target { return true } } return false } session, ok := bot.tempSessionCache.Load(target) var id int32 = -1 switch { case bot.Client.FindFriend(target) != nil: // 双向好友 msg := bot.Client.SendPrivateMessage(target, m) if msg != nil { id = bot.InsertPrivateMessage(msg, source) } case ok || groupID != 0: // 临时会话 if !base.AllowTempSession { log.Warnf("发送临时会话消息失败: 已关闭临时会话信息发送功能") return -1 } switch { case groupID != 0 && bot.Client.FindGroup(groupID) == nil: log.Errorf("错误: 找不到群(%v)", groupID) case groupID != 0 && !bot.Client.FindGroup(groupID).AdministratorOrOwner(): log.Errorf("错误: 机器人在群(%v) 为非管理员或群主, 无法主动发起临时会话", groupID) case groupID != 0 && bot.Client.FindGroup(groupID).FindMember(target) == nil: log.Errorf("错误: 群员(%v) 不在 群(%v), 无法发起临时会话", target, groupID) default: if session == nil && groupID != 0 { msg := bot.Client.SendGroupTempMessage(groupID, target, m) //lint:ignore SA9003 there is a todo if msg != nil { // nolint // todo(Mrs4s) // id = bot.InsertTempMessage(target, msg) } break } msg, err := session.SendMessage(m) if err != nil { log.Errorf("发送临时会话消息失败: %v", err) break } //lint:ignore SA9003 there is a todo if msg != nil { // nolint // todo(Mrs4s) // id = bot.InsertTempMessage(target, msg) } } case unidirectionalFriendExists(): // 单向好友 msg := bot.Client.SendPrivateMessage(target, m) if msg != nil { id = bot.InsertPrivateMessage(msg, source) } default: nickname := "Unknown" if summaryInfo, _ := bot.Client.GetSummaryInfo(target); summaryInfo != nil { nickname = summaryInfo.Nickname } log.Errorf("错误: 请先添加 %v(%v) 为好友", nickname, target) } return id } // SendGuildChannelMessage 发送频道消息 func (bot *CQBot) SendGuildChannelMessage(guildID, channelID uint64, m *message.SendingMessage) string { newElem := make([]message.IMessageElement, 0, len(m.Elements)) source := message.Source{ SourceType: message.SourceGuildChannel, PrimaryID: int64(guildID), SecondaryID: int64(channelID), } m.Elements = bot.uploadMedia(source, m.Elements) for _, e := range m.Elements { switch i := e.(type) { case *message.MusicShareElement: bot.Client.SendGuildMusicShare(guildID, channelID, i) return "-1" // todo: fix this case *message.VoiceElement, *msg.Poke: log.Warnf("警告: 频道暂不支持发送 %v 消息", i.Type().String()) continue } newElem = append(newElem, e) } if len(newElem) == 0 { log.Warnf("频道消息发送失败: 消息为空.") return "" } m.Elements = newElem bot.checkMedia(newElem, bot.Client.Uin) ret, err := bot.Client.GuildService.SendGuildChannelMessage(guildID, channelID, m) if err != nil { log.Warnf("频道消息发送失败: %v", err) return "" } // todo: insert db return fmt.Sprintf("%v-%v", ret.Id, ret.InternalId) } // InsertGroupMessage 群聊消息入数据库 func (bot *CQBot) InsertGroupMessage(m *message.GroupMessage, source message.Source) int32 { t := &message.SendingMessage{Elements: m.Elements} replyElem := t.FirstOrNil(func(e message.IMessageElement) bool { _, ok := e.(*message.ReplyElement) return ok }) msg := &db.StoredGroupMessage{ ID: encodeMessageID(m.GroupCode, m.Id), GlobalID: db.ToGlobalID(m.GroupCode, m.Id), SubType: "normal", Attribute: &db.StoredMessageAttribute{ MessageSeq: m.Id, InternalID: m.InternalId, SenderUin: m.Sender.Uin, SenderName: m.Sender.DisplayName(), Timestamp: int64(m.Time), }, GroupCode: m.GroupCode, AnonymousID: func() string { if m.Sender.IsAnonymous() { return m.Sender.AnonymousInfo.AnonymousId } return "" }(), Content: ToMessageContent(m.Elements, source), } if replyElem != nil { reply := replyElem.(*message.ReplyElement) msg.SubType = "quote" msg.QuotedInfo = &db.QuotedInfo{ PrevID: encodeMessageID(m.GroupCode, reply.ReplySeq), PrevGlobalID: db.ToGlobalID(m.GroupCode, reply.ReplySeq), QuotedContent: ToMessageContent(reply.Elements, source), } } if err := db.InsertGroupMessage(msg); err != nil { log.Warnf("记录聊天数据时出现错误: %v", err) return -1 } return msg.GlobalID } // InsertPrivateMessage 私聊消息入数据库 func (bot *CQBot) InsertPrivateMessage(m *message.PrivateMessage, source message.Source) int32 { t := &message.SendingMessage{Elements: m.Elements} replyElem := t.FirstOrNil(func(e message.IMessageElement) bool { _, ok := e.(*message.ReplyElement) return ok }) msg := &db.StoredPrivateMessage{ ID: encodeMessageID(m.Sender.Uin, m.Id), GlobalID: db.ToGlobalID(m.Sender.Uin, m.Id), SubType: "normal", Attribute: &db.StoredMessageAttribute{ MessageSeq: m.Id, InternalID: m.InternalId, SenderUin: m.Sender.Uin, SenderName: m.Sender.DisplayName(), Timestamp: int64(m.Time), }, SessionUin: func() int64 { if m.Sender.Uin == m.Self { return m.Target } return m.Sender.Uin }(), TargetUin: m.Target, Content: ToMessageContent(m.Elements, source), } if replyElem != nil { reply := replyElem.(*message.ReplyElement) msg.SubType = "quote" msg.QuotedInfo = &db.QuotedInfo{ PrevID: encodeMessageID(reply.Sender, reply.ReplySeq), PrevGlobalID: db.ToGlobalID(reply.Sender, reply.ReplySeq), QuotedContent: ToMessageContent(reply.Elements, source), } } if err := db.InsertPrivateMessage(msg); err != nil { log.Warnf("记录聊天数据时出现错误: %v", err) return -1 } return msg.GlobalID } /* // InsertTempMessage 临时消息入数据库 func (bot *CQBot) InsertTempMessage(target int64, m *message.TempMessage) int32 { val := global.MSG{ "message-id": m.Id, // FIXME(InsertTempMessage) InternalId missing "from-group": m.GroupCode, "group-name": m.GroupName, "target": target, "sender": m.Sender, "time": int32(time.Now().Unix()), "message": ToStringMessage(m.Elements, 0, true), } id := db.ToGlobalID(m.Sender.Uin, m.Id) if bot.db != nil { buf := global.NewBuffer() defer global.PutBuffer(buf) if err := gob.NewEncoder(buf).Encode(val); err != nil { log.Warnf("记录聊天数据时出现错误: %v", err) return -1 } if err := bot.db.Put(binary.ToBytes(id), buf.Bytes(), nil); err != nil { log.Warnf("记录聊天数据时出现错误: %v", err) return -1 } } return id } */ // InsertGuildChannelMessage 频道消息入数据库 func (bot *CQBot) InsertGuildChannelMessage(m *message.GuildChannelMessage) string { id := encodeGuildMessageID(m.GuildId, m.ChannelId, m.Id, message.SourceGuildChannel) source := message.Source{ SourceType: message.SourceGuildChannel, PrimaryID: int64(m.Sender.TinyId), } msg := &db.StoredGuildChannelMessage{ ID: id, Attribute: &db.StoredGuildMessageAttribute{ MessageSeq: m.Id, InternalID: m.InternalId, SenderTinyID: m.Sender.TinyId, SenderName: m.Sender.Nickname, Timestamp: m.Time, }, GuildID: m.GuildId, ChannelID: m.ChannelId, Content: ToMessageContent(m.Elements, source), } if err := db.InsertGuildChannelMessage(msg); err != nil { log.Warnf("记录聊天数据时出现错误: %v", err) return "" } return msg.ID } func (bot *CQBot) event(typ string, others global.MSG) *event { ev := new(event) post, detail, ok := strings.Cut(typ, "/") ev.PostType = post ev.DetailType = detail if ok { detail, sub, _ := strings.Cut(detail, "/") ev.DetailType = detail ev.SubType = sub } ev.Time = time.Now().Unix() ev.SelfID = bot.Client.Uin ev.Others = others return ev } func (bot *CQBot) dispatchEvent(typ string, others global.MSG) { bot.dispatch(bot.event(typ, others)) } func (bot *CQBot) dispatch(ev *event) { bot.lock.RLock() defer bot.lock.RUnlock() event := &Event{Raw: ev} wg := sync.WaitGroup{} wg.Add(len(bot.events)) for _, f := range bot.events { go func(fn func(*Event)) { defer func() { if pan := recover(); pan != nil { log.Warnf("处理事件 %v 时出现错误: %v \n%s", event.JSONString(), pan, debug.Stack()) } wg.Done() }() start := time.Now() fn(event) end := time.Now() if end.Sub(start) > time.Second*5 { log.Debugf("警告: 事件处理耗时超过 5 秒 (%v), 请检查应用是否有堵塞.", end.Sub(start)) } }(f) } wg.Wait() if event.buffer != nil { global.PutBuffer(event.buffer) } } func formatGroupName(group *client.GroupInfo) string { return fmt.Sprintf("%s(%d)", group.Name, group.Code) } func formatMemberName(mem *client.GroupMemberInfo) string { if mem == nil { return "未知" } return fmt.Sprintf("%s(%d)", mem.DisplayName(), mem.Uin) } // encodeMessageID 临时先这样, 暂时用不上 func encodeMessageID(target int64, seq int32) string { return hex.EncodeToString(binary.NewWriterF(func(w *binary.Writer) { w.WriteUInt64(uint64(target)) w.WriteUInt32(uint32(seq)) })) } // encodeGuildMessageID 将频道信息编码为字符串 // 当信息来源为 Channel 时 primaryID 为 guildID , subID 为 channelID // 当信息来源为 Direct 时 primaryID 为 guildID , subID 为 tinyID func encodeGuildMessageID(primaryID, subID, seq uint64, source message.SourceType) string { return base64.StdEncoding.EncodeToString(binary.NewWriterF(func(w *binary.Writer) { w.WriteByte(byte(source)) w.WriteUInt64(primaryID) w.WriteUInt64(subID) w.WriteUInt64(seq) })) } func decodeGuildMessageID(id string) (source message.Source, seq uint64) { b, _ := base64.StdEncoding.DecodeString(id) if len(b) < 25 { return } r := binary.NewReader(b) source = message.Source{ SourceType: message.SourceType(r.ReadByte()), PrimaryID: r.ReadInt64(), SecondaryID: r.ReadInt64(), } seq = uint64(r.ReadInt64()) return } ================================================ FILE: coolq/converter.go ================================================ package coolq import ( "strconv" "strings" "github.com/Mrs4s/MiraiGo/client" "github.com/Mrs4s/MiraiGo/message" "github.com/Mrs4s/MiraiGo/topic" log "github.com/sirupsen/logrus" "github.com/Mrs4s/go-cqhttp/global" ) func convertGroupMemberInfo(groupID int64, m *client.GroupMemberInfo) global.MSG { sex := "unknown" if m.Gender == 1 { // unknown = 0xff sex = "female" } else if m.Gender == 0 { sex = "male" } role := "member" switch m.Permission { // nolint:exhaustive case client.Owner: role = "owner" case client.Administrator: role = "admin" } return global.MSG{ "group_id": groupID, "user_id": m.Uin, "nickname": m.Nickname, "card": m.CardName, "sex": sex, "age": 0, "area": "", "join_time": m.JoinTime, "last_sent_time": m.LastSpeakTime, "shut_up_timestamp": m.ShutUpTimestamp, "level": strconv.FormatInt(int64(m.Level), 10), "role": role, "unfriendly": false, "title": m.SpecialTitle, "title_expire_time": 0, "card_changeable": false, } } func convertGuildMemberInfo(m []*client.GuildMemberInfo) (r []global.MSG) { for _, mem := range m { r = append(r, global.MSG{ "tiny_id": fU64(mem.TinyId), "title": mem.Title, "nickname": mem.Nickname, "role_id": fU64(mem.Role), "role_name": mem.RoleName, }) } return } func (bot *CQBot) formatGroupMessage(m *message.GroupMessage) *event { source := message.Source{ SourceType: message.SourceGroup, PrimaryID: m.GroupCode, } cqm := toStringMessage(m.Elements, source) typ := "message/group/normal" if m.Sender.Uin == bot.Client.Uin { typ = "message_sent/group/normal" } gm := global.MSG{ "anonymous": nil, "font": 0, "group_id": m.GroupCode, "message": ToFormattedMessage(m.Elements, source), "message_seq": m.Id, "raw_message": cqm, "sender": global.MSG{ "age": 0, "area": "", "level": "", "sex": "unknown", "user_id": m.Sender.Uin, }, "user_id": m.Sender.Uin, } if m.Sender.IsAnonymous() { gm["anonymous"] = global.MSG{ "flag": m.Sender.AnonymousInfo.AnonymousId + "|" + m.Sender.AnonymousInfo.AnonymousNick, "id": m.Sender.Uin, "name": m.Sender.AnonymousInfo.AnonymousNick, } gm["sender"].(global.MSG)["nickname"] = "匿名消息" typ = "message/group/anonymous" } else { group := bot.Client.FindGroup(m.GroupCode) mem := group.FindMember(m.Sender.Uin) if mem == nil { log.Warnf("获取 %v 成员信息失败,尝试刷新成员列表", m.Sender.Uin) t, err := bot.Client.GetGroupMembers(group) if err != nil { log.Warnf("刷新群 %v 成员列表失败: %v", group.Uin, err) return nil } group.Members = t mem = group.FindMember(m.Sender.Uin) if mem == nil { return nil } } ms := gm["sender"].(global.MSG) role := "member" switch mem.Permission { // nolint:exhaustive case client.Owner: role = "owner" case client.Administrator: role = "admin" } ms["role"] = role ms["nickname"] = mem.Nickname ms["card"] = mem.CardName ms["title"] = mem.SpecialTitle } ev := bot.event(typ, gm) ev.Time = int64(m.Time) return ev } func convertChannelInfo(c *client.ChannelInfo) global.MSG { slowModes := make([]global.MSG, 0, len(c.Meta.SlowModes)) for _, mode := range c.Meta.SlowModes { slowModes = append(slowModes, global.MSG{ "slow_mode_key": mode.SlowModeKey, "slow_mode_text": mode.SlowModeText, "speak_frequency": mode.SpeakFrequency, "slow_mode_circle": mode.SlowModeCircle, }) } return global.MSG{ "channel_id": fU64(c.ChannelId), "channel_type": c.ChannelType, "channel_name": c.ChannelName, "owner_guild_id": fU64(c.Meta.GuildId), "creator_tiny_id": fU64(c.Meta.CreatorTinyId), "create_time": c.Meta.CreateTime, "current_slow_mode": c.Meta.CurrentSlowMode, "talk_permission": c.Meta.TalkPermission, "visible_type": c.Meta.VisibleType, "slow_modes": slowModes, } } func convertChannelFeedInfo(f *topic.Feed) global.MSG { m := global.MSG{ "id": f.Id, "title": f.Title, "sub_title": f.SubTitle, "create_time": f.CreateTime, "guild_id": fU64(f.GuildId), "channel_id": fU64(f.ChannelId), "poster_info": global.MSG{ "tiny_id": f.Poster.TinyIdStr, "nickname": f.Poster.Nickname, "icon_url": f.Poster.IconUrl, }, "contents": FeedContentsToArrayMessage(f.Contents), } images := make([]global.MSG, 0, len(f.Images)) videos := make([]global.MSG, 0, len(f.Videos)) for _, image := range f.Images { images = append(images, global.MSG{ "file_id": image.FileId, "pattern_id": image.PatternId, "url": image.Url, "width": image.Width, "height": image.Height, }) } for _, video := range f.Videos { videos = append(videos, global.MSG{ "file_id": video.FileId, "pattern_id": video.PatternId, "url": video.Url, "width": video.Width, "height": video.Height, }) } m["resource"] = global.MSG{ "images": images, "videos": videos, } return m } func convertReactions(reactions []*message.GuildMessageEmojiReaction) (r []global.MSG) { r = make([]global.MSG, len(reactions)) for i, re := range reactions { r[i] = global.MSG{ "emoji_id": re.EmojiId, "emoji_index": re.Face.Index, "emoji_type": re.EmojiType, "emoji_name": re.Face.Name, "count": re.Count, "clicked": re.Clicked, } } return } func toStringMessage(m []message.IMessageElement, source message.Source) string { elems := toElements(m, source) var sb strings.Builder for _, elem := range elems { elem.WriteCQCodeTo(&sb) } return sb.String() } func fU64(v uint64) string { return strconv.FormatUint(v, 10) } ================================================ FILE: coolq/cqcode.go ================================================ package coolq import ( "bytes" "crypto/md5" "encoding/hex" "errors" "fmt" "io" "math/rand" "net/url" "os" "path" "runtime" "strconv" "strings" "time" "github.com/Mrs4s/MiraiGo/binary" "github.com/Mrs4s/MiraiGo/message" "github.com/Mrs4s/MiraiGo/utils" b14 "github.com/fumiama/go-base16384" "github.com/segmentio/asm/base64" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/Mrs4s/go-cqhttp/db" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/internal/base" "github.com/Mrs4s/go-cqhttp/internal/cache" "github.com/Mrs4s/go-cqhttp/internal/download" "github.com/Mrs4s/go-cqhttp/internal/mime" "github.com/Mrs4s/go-cqhttp/internal/msg" "github.com/Mrs4s/go-cqhttp/internal/param" "github.com/Mrs4s/go-cqhttp/pkg/onebot" ) // TODO: move this file to internal/msg, internal/onebot // TODO: support OneBot V12 const ( maxImageSize = 1024 * 1024 * 30 // 30MB maxVideoSize = 1024 * 1024 * 100 // 100MB ) func replyID(r *message.ReplyElement, source message.Source) int32 { id := source.PrimaryID seq := r.ReplySeq if r.GroupID != 0 { id = r.GroupID } // 私聊时,部分(不确定)的账号会在 ReplyElement 中带有 GroupID 字段。 // 这里需要判断是由于 “直接回复” 功能,GroupID 为触发直接回复的来源那个群。 if source.SourceType == message.SourcePrivate && (r.Sender == source.PrimaryID || r.GroupID == source.PrimaryID || r.GroupID == 0) { // 私聊似乎腾讯服务器有bug? seq = int32(uint16(seq)) id = r.Sender } return db.ToGlobalID(id, seq) } // toElements 将消息元素数组转为MSG数组以用于消息上报 // // nolint:govet func toElements(e []message.IMessageElement, source message.Source) (r []msg.Element) { // TODO: support OneBot V12 type pair = msg.Pair // simplify code type pairs = []pair r = make([]msg.Element, 0, len(e)) m := &message.SendingMessage{Elements: e} reply := m.FirstOrNil(func(e message.IMessageElement) bool { _, ok := e.(*message.ReplyElement) return ok }) if reply != nil && source.SourceType&(message.SourceGroup|message.SourcePrivate) != 0 { replyElem := reply.(*message.ReplyElement) id := replyID(replyElem, source) elem := msg.Element{ Type: "reply", Data: pairs{ {K: "id", V: strconv.FormatInt(int64(id), 10)}, }, } if base.ExtraReplyData { elem.Data = append(elem.Data, pair{K: "seq", V: strconv.FormatInt(int64(replyElem.ReplySeq), 10)}, pair{K: "qq", V: strconv.FormatInt(replyElem.Sender, 10)}, pair{K: "time", V: strconv.FormatInt(int64(replyElem.Time), 10)}, pair{K: "text", V: toStringMessage(replyElem.Elements, source)}, ) } r = append(r, elem) } for i, elem := range e { var m msg.Element switch o := elem.(type) { case *message.ReplyElement: if base.RemoveReplyAt && i+1 < len(e) { elem, ok := e[i+1].(*message.AtElement) if ok && elem.Target == o.Sender { e[i+1] = nil } } continue case *message.TextElement: m = msg.Element{ Type: "text", Data: pairs{ {K: "text", V: o.Content}, }, } case *message.LightAppElement: m = msg.Element{ Type: "json", Data: pairs{ {K: "data", V: o.Content}, }, } case *message.AtElement: target := "all" if o.Target != 0 { target = strconv.FormatUint(uint64(o.Target), 10) } m = msg.Element{ Type: "at", Data: pairs{ {K: "qq", V: target}, }, } case *message.RedBagElement: m = msg.Element{ Type: "redbag", Data: pairs{ {K: "title", V: o.Title}, }, } case *message.ForwardElement: m = msg.Element{ Type: "forward", Data: pairs{ {K: "id", V: o.ResId}, }, } case *message.FaceElement: m = msg.Element{ Type: "face", Data: pairs{ {K: "id", V: strconv.FormatInt(int64(o.Index), 10)}, }, } case *message.VoiceElement: m = msg.Element{ Type: "record", Data: pairs{ {K: "file", V: o.Name}, {K: "url", V: o.Url}, }, } case *message.ShortVideoElement: m = msg.Element{ Type: "video", Data: pairs{ {K: "file", V: o.Name}, {K: "url", V: o.Url}, }, } case *message.GroupImageElement: data := pairs{ {K: "file", V: hex.EncodeToString(o.Md5) + ".image"}, {K: "subType", V: strconv.FormatInt(int64(o.ImageBizType), 10)}, {K: "url", V: o.Url}, } switch { case o.Flash: data = append(data, pair{K: "type", V: "flash"}) case o.EffectID != 0: data = append(data, pair{K: "type", V: "show"}) data = append(data, pair{K: "id", V: strconv.FormatInt(int64(o.EffectID), 10)}) } m = msg.Element{ Type: "image", Data: data, } case *message.GuildImageElement: data := pairs{ {K: "file", V: hex.EncodeToString(o.Md5) + ".image"}, {K: "url", V: o.Url}, } m = msg.Element{ Type: "image", Data: data, } case *message.FriendImageElement: data := pairs{ {K: "file", V: hex.EncodeToString(o.Md5) + ".image"}, {K: "url", V: o.Url}, } if o.Flash { data = append(data, pair{K: "type", V: "flash"}) } m = msg.Element{ Type: "image", Data: data, } case *message.DiceElement: m = msg.Element{ Type: "dice", Data: pairs{ {K: "value", V: strconv.FormatInt(int64(o.Value), 10)}, }, } case *message.FingerGuessingElement: m = msg.Element{ Type: "rps", Data: pairs{ {K: "value", V: strconv.FormatInt(int64(o.Value), 10)}, }, } case *message.MarketFaceElement: m = msg.Element{ Type: "text", Data: pairs{ {K: "text", V: o.Name}, }, } case *message.ServiceElement: m = msg.Element{ Type: "xml", Data: pairs{ {K: "data", V: o.Content}, {K: "resid", V: o.ResId}, }, } if !strings.Contains(o.Content, " 0 { log.Warnf("警告: 一条信息只能包含一个 Reply 元素.") break } replyCount++ // 将回复消息放置于第一个 r = append([]message.IMessageElement{i}, r...) case message.IMessageElement: r = append(r, i) case []message.IMessageElement: r = append(r, i...) } } return } func (bot *CQBot) reply(spec *onebot.Spec, elem msg.Element, sourceType message.SourceType) (any, error) { mid, err := strconv.Atoi(elem.Get("id")) customText := elem.Get("text") var re *message.ReplyElement switch { case customText != "": var org db.StoredMessage sender, senderErr := strconv.ParseInt(elem.Get("user_id"), 10, 64) if senderErr != nil { sender, senderErr = strconv.ParseInt(elem.Get("qq"), 10, 64) } if senderErr != nil && err != nil { return nil, errors.New("警告: 自定义 reply 元素中必须包含 user_id 或 id") } msgTime, timeErr := strconv.ParseInt(elem.Get("time"), 10, 64) if timeErr != nil { msgTime = time.Now().Unix() } messageSeq, seqErr := strconv.ParseInt(elem.Get("seq"), 10, 64) if err == nil { org, _ = db.GetMessageByGlobalID(int32(mid)) } if org != nil { re = &message.ReplyElement{ ReplySeq: org.GetAttribute().MessageSeq, Sender: org.GetAttribute().SenderUin, Time: int32(org.GetAttribute().Timestamp), Elements: bot.ConvertStringMessage(spec, customText, sourceType), } if senderErr != nil { re.Sender = sender } if timeErr != nil { re.Time = int32(msgTime) } if seqErr != nil { re.ReplySeq = int32(messageSeq) } break } re = &message.ReplyElement{ ReplySeq: int32(messageSeq), Sender: sender, Time: int32(msgTime), Elements: bot.ConvertStringMessage(spec, customText, sourceType), } case err == nil: org, err := db.GetMessageByGlobalID(int32(mid)) if err != nil { return nil, err } re = &message.ReplyElement{ ReplySeq: org.GetAttribute().MessageSeq, Sender: org.GetAttribute().SenderUin, Time: int32(org.GetAttribute().Timestamp), Elements: bot.ConvertContentMessage(org.GetContent(), sourceType, true), } default: return nil, errors.New("reply消息中必须包含 text 或 id") } return re, nil } func (bot *CQBot) voice(elem msg.Element) (m any, err error) { f := elem.Get("file") data, err := global.FindFile(f, elem.Get("cache"), global.VoicePath) if err != nil { return nil, err } if !global.IsAMRorSILK(data) { mt, ok := mime.CheckAudio(bytes.NewReader(data)) if !ok { return nil, errors.New("voice type error: " + mt) } data, err = global.EncoderSilk(data) if err != nil { return nil, err } } return &message.VoiceElement{Data: data}, nil } func (bot *CQBot) at(id, name string) (m any, err error) { t, err := strconv.ParseInt(id, 10, 64) if err != nil { return nil, err } name = strings.TrimSpace(name) if len(name) > 0 { name = "@" + name } return message.NewAt(t, name), nil } // convertV11 ConvertElement11 func (bot *CQBot) convertV11(elem msg.Element) (m any, ok bool, err error) { switch elem.Type { default: // not ok return case "at": qq := elem.Get("qq") if qq == "" { qq = elem.Get("target") } if qq == "all" { m = message.AtAll() break } m, err = bot.at(qq, elem.Get("name")) case "record": m, err = bot.voice(elem) } ok = true return } // convertV12 ConvertElement12 func (bot *CQBot) convertV12(elem msg.Element) (m any, ok bool, err error) { switch elem.Type { default: // not ok return case "mention": m, err = bot.at(elem.Get("user_id"), elem.Get("name")) case "mention_all": m = message.AtAll() case "voice": m, err = bot.voice(elem) } ok = true return } // ConvertElement 将解码后的消息转换为MiraiGoElement. // // 返回 interface{} 存在三种类型 // // message.IMessageElement []message.IMessageElement nil func (bot *CQBot) ConvertElement(spec *onebot.Spec, elem msg.Element, sourceType message.SourceType) (m any, err error) { var ok bool switch spec.Version { case 11: m, ok, err = bot.convertV11(elem) case 12: m, ok, err = bot.convertV12(elem) default: panic("invalid onebot version:" + strconv.Itoa(spec.Version)) } if ok { return m, err } switch elem.Type { case "text": text := elem.Get("text") if base.SplitURL { var ret []message.IMessageElement for _, text := range param.SplitURL(text) { ret = append(ret, message.NewText(text)) } return ret, nil } return message.NewText(text), nil case "image": img, err := bot.makeImageOrVideoElem(elem, false, sourceType) if err != nil { return nil, err } tp := elem.Get("type") flash, id := false, int64(0) switch tp { case "flash": flash = true case "show": id, _ = strconv.ParseInt(elem.Get("id"), 10, 64) if id < 40000 || id >= 40006 { id = 40000 } default: return img, nil } switch img := img.(type) { case *msg.LocalImage: img.Flash = flash img.EffectID = int32(id) case *message.GroupImageElement: img.Flash = flash img.EffectID = int32(id) i, _ := strconv.ParseInt(elem.Get("subType"), 10, 64) img.ImageBizType = message.ImageBizType(i) case *message.FriendImageElement: img.Flash = flash } return img, nil case "reply": return bot.reply(spec, elem, sourceType) case "forward": id := elem.Get("id") if id == "" { return nil, errors.New("forward 消息中必须包含 id") } fwdMsg := bot.Client.DownloadForwardMessage(id) if fwdMsg == nil { return nil, errors.New("forward 消息不存在或已过期") } return fwdMsg, nil case "poke": t, _ := strconv.ParseInt(elem.Get("qq"), 10, 64) return &msg.Poke{Target: t}, nil case "tts": data, err := bot.Client.GetTts(elem.Get("text")) if err != nil { return nil, err } return &message.VoiceElement{Data: base.ResampleSilk(data)}, nil case "face": id, err := strconv.Atoi(elem.Get("id")) if err != nil { return nil, err } if elem.Get("type") == "sticker" { return &message.AnimatedSticker{ID: int32(id)}, nil } return message.NewFace(int32(id)), nil case "share": return message.NewUrlShare(elem.Get("url"), elem.Get("title"), elem.Get("content"), elem.Get("image")), nil case "music": id := elem.Get("id") switch elem.Get("type") { case "qq": info, err := global.QQMusicSongInfo(id) if err != nil { return nil, err } if !info.Get("track_info").Exists() { return nil, errors.New("song not found") } albumMid := info.Get("track_info.album.mid").String() pinfo, _ := download.Request{URL: "https://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\":[\"" + info.Get("track_info.mid").Str + "\"],\"songtype\":[0],\"uin\":\"0\",\"loginflag\":1,\"platform\":\"23\"}}}&_=1599039471576"}.JSON() jumpURL := "https://i.y.qq.com/v8/playsong.html?platform=11&appshare=android_qq&appversion=10030010&hosteuin=oKnlNenz7i-s7c**&songmid=" + info.Get("track_info.mid").Str + "&type=0&appsongtype=1&_wv=1&source=qq&ADTAG=qfshare" content := info.Get("track_info.singer.0.name").String() if elem.Get("content") != "" { content = elem.Get("content") } return &message.MusicShareElement{ MusicType: message.QQMusic, Title: info.Get("track_info.name").Str, Summary: content, Url: jumpURL, PictureUrl: "https://y.gtimg.cn/music/photo_new/T002R180x180M000" + albumMid + ".jpg", MusicUrl: pinfo.Get("url_mid.data.midurlinfo.0.purl").String(), }, nil case "163": info, err := global.NeteaseMusicSongInfo(id) if err != nil { return nil, err } if !info.Exists() { return nil, errors.New("song not found") } artistName := "" if info.Get("artists.0").Exists() { artistName = info.Get("artists.0.name").String() } return &message.MusicShareElement{ MusicType: message.CloudMusic, Title: info.Get("name").String(), Summary: artistName, Url: "https://music.163.com/song/?id=" + id, PictureUrl: info.Get("album.picUrl").String(), MusicUrl: "https://music.163.com/song/media/outer/url?id=" + id, }, nil case "custom": if elem.Get("subtype") != "" { var subType int switch elem.Get("subtype") { default: subType = message.QQMusic case "163": subType = message.CloudMusic case "migu": subType = message.MiguMusic case "kugou": subType = message.KugouMusic case "kuwo": subType = message.KuwoMusic } return &message.MusicShareElement{ MusicType: subType, Title: elem.Get("title"), Summary: elem.Get("content"), Url: elem.Get("url"), PictureUrl: elem.Get("image"), MusicUrl: elem.Get("voice"), }, nil } xml := fmt.Sprintf(`%s%s`, utils.XmlEscape(elem.Get("title")), elem.Get("url"), elem.Get("image"), elem.Get("voice"), utils.XmlEscape(elem.Get("title")), utils.XmlEscape(elem.Get("content"))) return &message.ServiceElement{ Id: 60, Content: xml, SubType: "music", }, nil } return nil, errors.New("unsupported music type: " + elem.Get("type")) case "dice": value := elem.Get("value") i, _ := strconv.ParseInt(value, 10, 64) if i < 0 || i > 6 { return nil, errors.New("invalid dice value " + value) } return message.NewDice(int32(i)), nil case "rps": value := elem.Get("value") i, _ := strconv.ParseInt(value, 10, 64) if i < 0 || i > 2 { return nil, errors.New("invalid finger-guessing value " + value) } return message.NewFingerGuessing(int32(i)), nil case "xml": resID := elem.Get("resid") template := elem.Get("data") i, _ := strconv.ParseInt(resID, 10, 64) m := message.NewRichXml(template, i) return m, nil case "json": resID := elem.Get("resid") data := elem.Get("data") i, _ := strconv.ParseInt(resID, 10, 64) if i == 0 { // 默认情况下走小程序通道 return message.NewLightApp(data), nil } // resid不为0的情况下走富文本通道,后续补全透传service Id,此处暂时不处理 TODO return message.NewRichJson(data), nil case "cardimage": source := elem.Get("source") icon := elem.Get("icon") brief := elem.Get("brief") parseIntWithDefault := func(name string, origin int64) int64 { v, _ := strconv.ParseInt(elem.Get(name), 10, 64) if v <= 0 { return origin } return v } minWidth := parseIntWithDefault("minwidth", 200) maxWidth := parseIntWithDefault("maxwidth", 500) minHeight := parseIntWithDefault("minheight", 200) maxHeight := parseIntWithDefault("maxheight", 1000) img, err := bot.makeImageOrVideoElem(elem, false, sourceType) if err != nil { return nil, errors.New("send cardimage faild") } return bot.makeShowPic(img, source, brief, icon, minWidth, minHeight, maxWidth, maxHeight, sourceType == message.SourceGroup) case "video": file, err := bot.makeImageOrVideoElem(elem, true, sourceType) if err != nil { return nil, err } v, ok := file.(*msg.LocalVideo) if !ok { return file, nil } if v.File == "" { return v, nil } var data []byte if cover := elem.Get("cover"); cover != "" { data, _ = global.FindFile(cover, elem.Get("cache"), global.ImagePath) } else { err = global.ExtractCover(v.File, v.File+".jpg") if err != nil { return nil, err } data, _ = os.ReadFile(v.File + ".jpg") } v.Thumb = bytes.NewReader(data) video, _ := os.Open(v.File) defer video.Close() _, _ = video.Seek(4, io.SeekStart) header := make([]byte, 4) _, _ = video.Read(header) 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 !(elem.Get("cache") == "" || elem.Get("cache") == "1") || !global.PathExists(cacheFile) { err = global.EncodeMP4(v.File, cacheFile) if err != nil { return nil, err } } v.File = cacheFile } return v, nil case "file": path := elem.Get("path") name := elem.Get("name") size, _ := strconv.ParseInt(elem.Get("size"), 10, 64) busid, _ := strconv.ParseInt(elem.Get("busid"), 10, 64) return &message.GroupFileElement{ Name: name, Size: size, Path: path, Busid: int32(busid), }, nil default: return nil, errors.New("unsupported message type: " + elem.Type) } } // makeImageOrVideoElem 图片 elem 生成器,单独拎出来,用于公用 func (bot *CQBot) makeImageOrVideoElem(elem msg.Element, video bool, sourceType message.SourceType) (message.IMessageElement, error) { f := elem.Get("file") u := elem.Get("url") if strings.HasPrefix(f, "http") { hash := md5.Sum([]byte(f)) cacheFile := path.Join(global.CachePath, hex.EncodeToString(hash[:])+".cache") maxSize := int64(maxImageSize) if video { maxSize = maxVideoSize } thread, _ := strconv.Atoi(elem.Get("c")) exist := global.PathExists(cacheFile) if exist && (elem.Get("cache") == "" || elem.Get("cache") == "1") { goto useCacheFile } if exist { _ = os.Remove(cacheFile) } { r := download.Request{URL: f, Limit: maxSize} if err := r.WriteToFileMultiThreading(cacheFile, thread); err != nil { return nil, err } } useCacheFile: if video { return &msg.LocalVideo{File: cacheFile}, nil } return &msg.LocalImage{File: cacheFile, URL: f}, nil } if strings.HasPrefix(f, "file") { fu, err := url.Parse(f) if err != nil { return nil, err } if runtime.GOOS == `windows` && strings.HasPrefix(fu.Path, "/") { fu.Path = fu.Path[1:] } 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 &msg.LocalVideo{File: fu.Path}, nil } if info.Size() == 0 || info.Size() >= maxImageSize { return nil, errors.New("invalid image size") } return &msg.LocalImage{File: fu.Path, URL: f}, nil } if !video && strings.HasPrefix(f, "base64") { b, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(f, "base64://")) if err != nil { return nil, err } return &msg.LocalImage{Stream: bytes.NewReader(b), URL: f}, nil } if !video && strings.HasPrefix(f, "base16384") { b, err := b14.UTF82UTF16BE(utils.S2B(strings.TrimPrefix(f, "base16384://"))) if err != nil { return nil, err } return &msg.LocalImage{Stream: bytes.NewReader(b14.Decode(b)), URL: f}, nil } rawPath := path.Join(global.ImagePath, f) if video { if strings.HasSuffix(f, ".video") { hash, err := hex.DecodeString(strings.TrimSuffix(f, ".video")) if err == nil { if b := cache.Video.Get(hash); b != nil { return bot.readVideoCache(b), nil } } } rawPath = path.Join(global.VideoPath, f) if !global.PathExists(rawPath) { return nil, errors.New("invalid video") } if path.Ext(rawPath) != ".video" { return &msg.LocalVideo{File: rawPath}, nil } b, _ := os.ReadFile(rawPath) return bot.readVideoCache(b), nil } // 目前频道内上传的图片均无法被查询到, 需要单独处理 if sourceType == message.SourceGuildChannel { cacheFile := path.Join(global.ImagePath, "guild-images", f) if global.PathExists(cacheFile) { return &msg.LocalImage{File: cacheFile}, nil } } if strings.HasSuffix(f, ".image") { hash, err := hex.DecodeString(strings.TrimSuffix(f, ".image")) if err == nil { if b := cache.Image.Get(hash); b != nil { return bot.readImageCache(b, sourceType) } } } exist := global.PathExists(rawPath) if !exist { if elem.Get("url") != "" { elem.Data = []msg.Pair{{K: "file", V: elem.Get("url")}} return bot.makeImageOrVideoElem(elem, false, sourceType) } return nil, errors.New("invalid image") } if path.Ext(rawPath) != ".image" { return &msg.LocalImage{File: rawPath, URL: u}, nil } b, err := os.ReadFile(rawPath) if err != nil { return nil, err } return bot.readImageCache(b, sourceType) } func (bot *CQBot) readImageCache(b []byte, sourceType message.SourceType) (message.IMessageElement, error) { var err error if len(b) < 20 { return nil, errors.New("invalid cache") } r := binary.NewReader(b) hash := r.ReadBytes(16) size := r.ReadInt32() r.ReadString() imageURL := r.ReadString() if size == 0 && imageURL != "" { // TODO: fix this var elem msg.Element elem.Type = "image" elem.Data = []msg.Pair{{K: "file", V: imageURL}} return bot.makeImageOrVideoElem(elem, false, sourceType) } var rsp message.IMessageElement switch sourceType { // nolint:exhaustive case message.SourceGroup: rsp, err = bot.Client.QueryGroupImage(int64(rand.Uint32()), hash, size) case message.SourceGuildChannel: if len(bot.Client.GuildService.Guilds) == 0 { err = errors.New("cannot query guild image: not any joined guild") break } guild := bot.Client.GuildService.Guilds[0] rsp, err = bot.Client.GuildService.QueryImage(guild.GuildId, guild.Channels[0].ChannelId, hash, uint64(size)) default: rsp, err = bot.Client.QueryFriendImage(int64(rand.Uint32()), hash, size) } if err != nil && imageURL != "" { var elem msg.Element elem.Type = "image" elem.Data = []msg.Pair{{K: "file", V: imageURL}} return bot.makeImageOrVideoElem(elem, false, sourceType) } return rsp, err } func (bot *CQBot) readVideoCache(b []byte) message.IMessageElement { r := binary.NewReader(b) return &message.ShortVideoElement{ // todo 检查缓存是否有效 Md5: r.ReadBytes(16), ThumbMd5: r.ReadBytes(16), Size: r.ReadInt32(), ThumbSize: r.ReadInt32(), Name: r.ReadString(), Uuid: r.ReadAvailable(), } } // makeShowPic 一种xml 方式发送的群消息图片 func (bot *CQBot) makeShowPic(elem message.IMessageElement, source string, brief string, icon string, minWidth int64, minHeight int64, maxWidth int64, maxHeight int64, group bool) ([]message.IMessageElement, error) { xml := "" var suf message.IMessageElement if brief == "" { brief = "[分享]我看到一张很赞的图片,分享给你,快来看!" } if local, ok := elem.(*msg.LocalImage); ok { r := rand.Uint32() typ := message.SourceGroup if !group { typ = message.SourcePrivate } e, err := bot.uploadLocalImage(message.Source{SourceType: typ, PrimaryID: int64(r)}, local) if err != nil { log.Warnf("警告: 图片上传失败: %v", err) return nil, err } elem = e } switch i := elem.(type) { case *message.GroupImageElement: xml = fmt.Sprintf(``, brief, "", i.Md5, i.Md5, 0, "", minWidth, minHeight, maxWidth, maxHeight, source, icon) suf = i case *message.FriendImageElement: xml = fmt.Sprintf(``, brief, "", i.Md5, i.Md5, 0, "", minWidth, minHeight, maxWidth, maxHeight, source, icon) suf = i } if xml == "" { return nil, errors.New("生成xml图片消息失败") } ret := []message.IMessageElement{suf, message.NewRichXml(xml, 5)} return ret, nil } ================================================ FILE: coolq/doc.go ================================================ // Package coolq 包含CQBot实例,CQ码处理,消息发送,消息处理等的相关函数与结构体 package coolq ================================================ FILE: coolq/event.go ================================================ package coolq import ( "encoding/hex" "encoding/json" "fmt" "path" "strconv" "strings" "github.com/Mrs4s/MiraiGo/binary" "github.com/Mrs4s/MiraiGo/client" "github.com/Mrs4s/MiraiGo/message" log "github.com/sirupsen/logrus" "github.com/Mrs4s/go-cqhttp/db" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/internal/base" "github.com/Mrs4s/go-cqhttp/internal/cache" "github.com/Mrs4s/go-cqhttp/internal/download" ) // ToFormattedMessage 将给定[]message.IMessageElement转换为通过coolq.SetMessageFormat所定义的消息上报格式 func ToFormattedMessage(e []message.IMessageElement, source message.Source) (r any) { if base.PostFormat == "string" { r = toStringMessage(e, source) } else if base.PostFormat == "array" { r = toElements(e, source) } return } type event struct { PostType string DetailType string SubType string Time int64 SelfID int64 Others global.MSG } func (ev *event) MarshalJSON() ([]byte, error) { buf := global.NewBuffer() defer global.PutBuffer(buf) detail := "" switch ev.PostType { case "message", "message_sent": detail = "message_type" case "notice": detail = "notice_type" case "request": detail = "request_type" case "meta_event": detail = "meta_event_type" default: panic("unknown post type: " + ev.PostType) } fmt.Fprintf(buf, `{"post_type":"%s","%s":"%s","time":%d,"self_id":%d`, ev.PostType, detail, ev.DetailType, ev.Time, ev.SelfID) if ev.SubType != "" { fmt.Fprintf(buf, `,"sub_type":"%s"`, ev.SubType) } for k, v := range ev.Others { v, err := json.Marshal(v) if err != nil { log.Warnf("marshal message payload error: %v", err) return nil, err } fmt.Fprintf(buf, `,"%s":%s`, k, v) } buf.WriteByte('}') return append([]byte(nil), buf.Bytes()...), nil } func (bot *CQBot) privateMessageEvent(_ *client.QQClient, m *message.PrivateMessage) { bot.checkMedia(m.Elements, m.Sender.Uin) source := message.Source{ SourceType: message.SourcePrivate, PrimaryID: m.Sender.Uin, } cqm := toStringMessage(m.Elements, source) id := bot.InsertPrivateMessage(m, source) log.Infof("收到好友 %v(%v) 的消息: %v (%v)", m.Sender.DisplayName(), m.Sender.Uin, cqm, id) typ := "message/private/friend" if m.Sender.Uin == bot.Client.Uin { typ = "message_sent/private/friend" } fm := global.MSG{ "message_id": id, "user_id": m.Sender.Uin, "target_id": m.Target, "message": ToFormattedMessage(m.Elements, source), "raw_message": cqm, "font": 0, "sender": global.MSG{ "user_id": m.Sender.Uin, "nickname": m.Sender.Nickname, "sex": "unknown", "age": 0, }, } bot.dispatchEvent(typ, fm) } func (bot *CQBot) groupMessageEvent(c *client.QQClient, m *message.GroupMessage) { bot.checkMedia(m.Elements, m.GroupCode) for _, elem := range m.Elements { if file, ok := elem.(*message.GroupFileElement); ok { log.Infof("群 %v(%v) 内 %v(%v) 上传了文件: %v", m.GroupName, m.GroupCode, m.Sender.DisplayName(), m.Sender.Uin, file.Name) bot.dispatchEvent("notice/group_upload", global.MSG{ "group_id": m.GroupCode, "user_id": m.Sender.Uin, "file": global.MSG{ "id": file.Path, "name": file.Name, "size": file.Size, "busid": file.Busid, "url": c.GetGroupFileUrl(m.GroupCode, file.Path, file.Busid), }, }) // return } } source := message.Source{ SourceType: message.SourceGroup, PrimaryID: m.GroupCode, } cqm := toStringMessage(m.Elements, source) id := bot.InsertGroupMessage(m, source) log.Infof("收到群 %v(%v) 内 %v(%v) 的消息: %v (%v)", m.GroupName, m.GroupCode, m.Sender.DisplayName(), m.Sender.Uin, cqm, id) gm := bot.formatGroupMessage(m) if gm == nil { return } gm.Others["message_id"] = id bot.dispatch(gm) } func (bot *CQBot) tempMessageEvent(_ *client.QQClient, e *client.TempMessageEvent) { m := e.Message bot.checkMedia(m.Elements, m.Sender.Uin) source := message.Source{ SourceType: message.SourcePrivate, PrimaryID: e.Session.Sender, } cqm := toStringMessage(m.Elements, source) if base.AllowTempSession { bot.tempSessionCache.Store(m.Sender.Uin, e.Session) } id := m.Id // todo(Mrs4s) // if bot.db != nil { // nolint // id = bot.InsertTempMessage(m.Sender.Uin, m) // } log.Infof("收到来自群 %v(%v) 内 %v(%v) 的临时会话消息: %v", m.GroupName, m.GroupCode, m.Sender.DisplayName(), m.Sender.Uin, cqm) tm := global.MSG{ "temp_source": e.Session.Source, "message_id": id, "user_id": m.Sender.Uin, "message": ToFormattedMessage(m.Elements, source), "raw_message": cqm, "font": 0, "sender": global.MSG{ "user_id": m.Sender.Uin, "group_id": m.GroupCode, "nickname": m.Sender.Nickname, "sex": "unknown", "age": 0, }, } bot.dispatchEvent("message/private/group", tm) } func (bot *CQBot) guildChannelMessageEvent(c *client.QQClient, m *message.GuildChannelMessage) { bot.checkMedia(m.Elements, int64(m.Sender.TinyId)) guild := c.GuildService.FindGuild(m.GuildId) if guild == nil { return } channel := guild.FindChannel(m.ChannelId) source := message.Source{ SourceType: message.SourceGuildChannel, PrimaryID: int64(m.GuildId), SecondaryID: int64(m.ChannelId), } log.Infof("收到来自频道 %v(%v) 子频道 %v(%v) 内 %v(%v) 的消息: %v", guild.GuildName, guild.GuildId, channel.ChannelName, m.ChannelId, m.Sender.Nickname, m.Sender.TinyId, toStringMessage(m.Elements, source)) id := bot.InsertGuildChannelMessage(m) ev := bot.event("message/guild/channel", global.MSG{ "guild_id": fU64(m.GuildId), "channel_id": fU64(m.ChannelId), "message_id": id, "user_id": fU64(m.Sender.TinyId), "message": ToFormattedMessage(m.Elements, source), // todo: 增加对频道消息 Reply 的支持 "self_tiny_id": fU64(bot.Client.GuildService.TinyId), "sender": global.MSG{ "user_id": m.Sender.TinyId, "tiny_id": fU64(m.Sender.TinyId), "nickname": m.Sender.Nickname, }, }) ev.Time = m.Time bot.dispatch(ev) } func (bot *CQBot) guildMessageReactionsUpdatedEvent(c *client.QQClient, e *client.GuildMessageReactionsUpdatedEvent) { guild := c.GuildService.FindGuild(e.GuildId) if guild == nil { return } msgID := encodeGuildMessageID(e.GuildId, e.ChannelId, e.MessageId, message.SourceGuildChannel) str := fmt.Sprintf("频道 %v(%v) 消息 %v 表情贴片已更新: ", guild.GuildName, guild.GuildId, msgID) currentReactions := make([]global.MSG, len(e.CurrentReactions)) for i, r := range e.CurrentReactions { str += fmt.Sprintf("%v*%v ", r.Face.Name, r.Count) currentReactions[i] = global.MSG{ "emoji_id": r.EmojiId, "emoji_index": r.Face.Index, "emoji_type": r.EmojiType, "emoji_name": r.Face.Name, "count": r.Count, "clicked": r.Clicked, } } if len(e.CurrentReactions) == 0 { str += "无任何表情" } log.Infof(str) bot.dispatchEvent("notice/message_reactions_updated", global.MSG{ "guild_id": fU64(e.GuildId), "channel_id": fU64(e.ChannelId), "message_id": msgID, "operator_id": fU64(e.OperatorId), "current_reactions": currentReactions, "self_tiny_id": fU64(bot.Client.GuildService.TinyId), "user_id": e.OperatorId, }) } func (bot *CQBot) guildChannelMessageRecalledEvent(c *client.QQClient, e *client.GuildMessageRecalledEvent) { guild := c.GuildService.FindGuild(e.GuildId) if guild == nil { return } channel := guild.FindChannel(e.ChannelId) if channel == nil { return } operator, err := c.GuildService.FetchGuildMemberProfileInfo(e.GuildId, e.OperatorId) if err != nil { log.Errorf("处理频道撤回事件时出现错误: 获取操作者资料时出现错误 %v", err) return } msgID := encodeGuildMessageID(e.GuildId, e.ChannelId, e.MessageId, message.SourceGuildChannel) log.Infof("用户 %v(%v) 撤回了频道 %v(%v) 子频道 %v(%v) 的消息 %v", operator.Nickname, operator.TinyId, guild.GuildName, guild.GuildId, channel.ChannelName, channel.ChannelId, msgID) bot.dispatchEvent("notice/guild_channel_recall", global.MSG{ "guild_id": fU64(e.GuildId), "channel_id": fU64(e.ChannelId), "operator_id": fU64(e.OperatorId), "message_id": msgID, "self_tiny_id": fU64(bot.Client.GuildService.TinyId), "user_id": e.OperatorId, }) } func (bot *CQBot) guildChannelUpdatedEvent(c *client.QQClient, e *client.GuildChannelUpdatedEvent) { guild := c.GuildService.FindGuild(e.GuildId) if guild == nil { return } log.Infof("频道 %v(%v) 子频道 %v(%v) 信息已更新", guild.GuildName, guild.GuildId, e.NewChannelInfo.ChannelName, e.NewChannelInfo.ChannelId) bot.dispatchEvent("notice/channel_updated", global.MSG{ "guild_id": fU64(e.GuildId), "channel_id": fU64(e.ChannelId), "operator_id": fU64(e.OperatorId), "self_tiny_id": fU64(bot.Client.GuildService.TinyId), "user_id": e.OperatorId, "old_info": convertChannelInfo(e.OldChannelInfo), "new_info": convertChannelInfo(e.NewChannelInfo), }) } func (bot *CQBot) guildChannelCreatedEvent(c *client.QQClient, e *client.GuildChannelOperationEvent) { guild := c.GuildService.FindGuild(e.GuildId) if guild == nil { return } member, _ := c.GuildService.FetchGuildMemberProfileInfo(e.GuildId, e.OperatorId) if member == nil { member = &client.GuildUserProfile{Nickname: "未知"} } log.Infof("频道 %v(%v) 内用户 %v(%v) 创建了子频道 %v(%v)", guild.GuildName, guild.GuildId, member.Nickname, member.TinyId, e.ChannelInfo.ChannelName, e.ChannelInfo.ChannelId) bot.dispatchEvent("notice/channel_created", global.MSG{ "guild_id": fU64(e.GuildId), "channel_id": fU64(e.ChannelInfo.ChannelId), "operator_id": fU64(e.OperatorId), "self_tiny_id": fU64(bot.Client.GuildService.TinyId), "user_id": e.OperatorId, "channel_info": convertChannelInfo(e.ChannelInfo), }) } func (bot *CQBot) guildChannelDestroyedEvent(c *client.QQClient, e *client.GuildChannelOperationEvent) { guild := c.GuildService.FindGuild(e.GuildId) if guild == nil { return } member, _ := c.GuildService.FetchGuildMemberProfileInfo(e.GuildId, e.OperatorId) if member == nil { member = &client.GuildUserProfile{Nickname: "未知"} } log.Infof("频道 %v(%v) 内用户 %v(%v) 删除了子频道 %v(%v)", guild.GuildName, guild.GuildId, member.Nickname, member.TinyId, e.ChannelInfo.ChannelName, e.ChannelInfo.ChannelId) bot.dispatchEvent("notice/channel_destroyed", global.MSG{ "guild_id": fU64(e.GuildId), "channel_id": fU64(e.ChannelInfo.ChannelId), "operator_id": fU64(e.OperatorId), "self_tiny_id": fU64(bot.Client.GuildService.TinyId), "user_id": e.OperatorId, "channel_info": convertChannelInfo(e.ChannelInfo), }) } func (bot *CQBot) groupMutedEvent(c *client.QQClient, e *client.GroupMuteEvent) { g := c.FindGroup(e.GroupCode) 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 { 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))) } } typ := "notice/group_ban/ban" if e.Time == 0 { typ = "notice/group_ban/lift_ban" } bot.dispatchEvent(typ, global.MSG{ "duration": e.Time, "group_id": e.GroupCode, "operator_id": e.OperatorUin, "user_id": e.TargetUin, }) } func (bot *CQBot) groupRecallEvent(c *client.QQClient, e *client.GroupMessageRecalledEvent) { g := c.FindGroup(e.GroupCode) gid := db.ToGlobalID(e.GroupCode, e.MessageId) log.Infof("群 %v 内 %v 撤回了 %v 的消息: %v.", formatGroupName(g), formatMemberName(g.FindMember(e.OperatorUin)), formatMemberName(g.FindMember(e.AuthorUin)), gid) ev := bot.event("notice/group_recall", global.MSG{ "group_id": e.GroupCode, "user_id": e.AuthorUin, "operator_id": e.OperatorUin, "message_id": gid, }) ev.Time = int64(e.Time) bot.dispatch(ev) } 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.dispatchEvent("notice/notify/poke", global.MSG{ "group_id": group.Code, "user_id": notify.Sender, "sender_id": notify.Sender, "target_id": notify.Receiver, }) 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.dispatchEvent("notice/notify/lucky_king", global.MSG{ "group_id": group.Code, "user_id": notify.Sender, "sender_id": notify.Sender, "target_id": notify.LuckyKing, }) case *client.MemberHonorChangedNotifyEvent: log.Info(notify.Content()) bot.dispatchEvent("notice/notify/honor", global.MSG{ "group_id": group.Code, "user_id": notify.Uin, "honor_type": func() string { switch notify.Honor { case client.Talkative: return "talkative" case client.Performer: return "performer" case client.Emotion: return "emotion" case client.Legend: return "legend" case client.StrongNewbie: return "strong_newbie" default: return "ERROR" } }(), }) } } func (bot *CQBot) friendNotifyEvent(c *client.QQClient, e client.INotifyEvent) { friend := c.FindFriend(e.From()) if notify, ok := e.(*client.FriendPokeNotifyEvent); ok { if notify.Receiver == notify.Sender { log.Infof("好友 %v 戳了戳自己.", friend.Nickname) } else { log.Infof("好友 %v 戳了戳你.", friend.Nickname) } bot.dispatchEvent("notice/notify/poke", global.MSG{ "user_id": notify.Sender, "sender_id": notify.Sender, "target_id": notify.Receiver, }) } } func (bot *CQBot) memberTitleUpdatedEvent(c *client.QQClient, e *client.MemberSpecialTitleUpdatedEvent) { group := c.FindGroup(e.GroupCode) mem := group.FindMember(e.Uin) log.Infof("群 %v(%v) 内成员 %v(%v) 获得了新的头衔: %v", group.Name, group.Code, mem.DisplayName(), mem.Uin, e.NewTitle) bot.dispatchEvent("notice/notify/title", global.MSG{ "group_id": group.Code, "user_id": e.Uin, "title": e.NewTitle, }) } func (bot *CQBot) friendRecallEvent(c *client.QQClient, e *client.FriendMessageRecalledEvent) { f := c.FindFriend(e.FriendUin) gid := db.ToGlobalID(e.FriendUin, e.MessageId) if f != nil { log.Infof("好友 %v(%v) 撤回了消息: %v", f.Nickname, f.Uin, gid) } else { log.Infof("好友 %v 撤回了消息: %v", e.FriendUin, gid) } ev := bot.event("notice/friend_recall", global.MSG{ "user_id": e.FriendUin, "message_id": gid, }) ev.Time = e.Time bot.dispatch(ev) } 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.dispatchEvent("notice/offline_file", global.MSG{ "user_id": e.Sender, "file": global.MSG{ "name": e.FileName, "size": e.FileSize, "url": e.DownloadUrl, }, }) } func (bot *CQBot) joinGroupEvent(c *client.QQClient, group *client.GroupInfo) { if group == nil { return } log.Infof("Bot进入了群 %v.", formatGroupName(group)) bot.dispatch(bot.groupIncrease(group.Code, 0, c.Uin)) } func (bot *CQBot) leaveGroupEvent(c *client.QQClient, e *client.GroupLeaveEvent) { if e.Operator != nil { log.Infof("Bot被 %v T出了群 %v.", formatMemberName(e.Operator), formatGroupName(e.Group)) } else { log.Infof("Bot退出了群 %v.", formatGroupName(e.Group)) } bot.dispatch(bot.groupDecrease(e.Group.Code, c.Uin, e.Operator)) } func (bot *CQBot) memberPermissionChangedEvent(_ *client.QQClient, e *client.MemberPermissionChangedEvent) { st := "unset" if e.NewPermission == client.Administrator { st = "set" } bot.dispatchEvent("notice/group_admin/"+st, global.MSG{ "group_id": e.Group.Code, "user_id": e.Member.Uin, }) } func (bot *CQBot) memberCardUpdatedEvent(_ *client.QQClient, e *client.MemberCardUpdatedEvent) { log.Infof("群 %v 的 %v 更新了名片 %v -> %v", formatGroupName(e.Group), formatMemberName(e.Member), e.OldCard, e.Member.CardName) bot.dispatchEvent("notice/group_card", global.MSG{ "group_id": e.Group.Code, "user_id": e.Member.Uin, "card_new": e.Member.CardName, "card_old": e.OldCard, }) } func (bot *CQBot) memberJoinEvent(_ *client.QQClient, e *client.MemberJoinGroupEvent) { log.Infof("新成员 %v 进入了群 %v.", formatMemberName(e.Member), formatGroupName(e.Group)) bot.dispatch(bot.groupIncrease(e.Group.Code, 0, e.Member.Uin)) } func (bot *CQBot) memberLeaveEvent(_ *client.QQClient, e *client.MemberLeaveGroupEvent) { if e.Operator != nil { log.Infof("成员 %v 被 %v T出了群 %v.", formatMemberName(e.Member), formatMemberName(e.Operator), formatGroupName(e.Group)) } else { log.Infof("成员 %v 离开了群 %v.", formatMemberName(e.Member), formatGroupName(e.Group)) } bot.dispatch(bot.groupDecrease(e.Group.Code, e.Member.Uin, e.Operator)) } func (bot *CQBot) friendRequestEvent(_ *client.QQClient, e *client.NewFriendRequest) { log.Infof("收到来自 %v(%v) 的好友请求: %v", e.RequesterNick, e.RequesterUin, e.Message) flag := strconv.FormatInt(e.RequestId, 10) bot.friendReqCache.Store(flag, e) bot.dispatchEvent("request/friend", global.MSG{ "user_id": e.RequesterUin, "comment": e.Message, "flag": flag, }) } func (bot *CQBot) friendAddedEvent(_ *client.QQClient, e *client.NewFriendEvent) { log.Infof("添加了新好友: %v(%v)", e.Friend.Nickname, e.Friend.Uin) bot.tempSessionCache.Delete(e.Friend.Uin) bot.dispatchEvent("notice/friend_add", global.MSG{ "user_id": e.Friend.Uin, }) } func (bot *CQBot) groupInvitedEvent(_ *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.dispatchEvent("request/group/invite", global.MSG{ "group_id": e.GroupCode, "user_id": e.InvitorUin, "invitor_id": 0, "comment": "", "flag": flag, }) } func (bot *CQBot) groupJoinReqEvent(_ *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.dispatchEvent("request/group/add", global.MSG{ "group_id": e.GroupCode, "user_id": e.RequesterUin, "invitor_id": e.ActionUin, "comment": e.Message, "flag": flag, }) } func (bot *CQBot) otherClientStatusChangedEvent(_ *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.dispatchEvent("notice/client_status", global.MSG{ "online": e.Online, "client": global.MSG{ "app_id": e.Client.AppId, "device_name": e.Client.DeviceName, "device_kind": e.Client.DeviceKind, }, }) } func (bot *CQBot) groupEssenceMsg(c *client.QQClient, e *client.GroupDigestEvent) { g := c.FindGroup(e.GroupCode) gid := db.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 } subtype := "delete" if e.OperationType == 1 { subtype = "add" } bot.dispatchEvent("notice/essence/"+subtype, global.MSG{ "group_id": e.GroupCode, "sender_id": e.SenderUin, "operator_id": e.OperatorUin, "message_id": gid, }) } func (bot *CQBot) groupIncrease(groupCode, operatorUin, userUin int64) *event { return bot.event("notice/group_increase/approve", global.MSG{ "group_id": groupCode, "operator_id": operatorUin, "user_id": userUin, }) } func (bot *CQBot) groupDecrease(groupCode, userUin int64, operator *client.GroupMemberInfo) *event { op := userUin if operator != nil { op = operator.Uin } subtype := "leave" if operator != nil { if userUin == bot.Client.Uin { subtype = "kick_me" } else { subtype = "kick" } } return bot.event("notice/group_decrease/"+subtype, global.MSG{ "group_id": groupCode, "operator_id": op, "user_id": userUin, }) } func (bot *CQBot) checkMedia(e []message.IMessageElement, sourceID int64) { for _, elem := range e { switch i := elem.(type) { case *message.GroupImageElement: if i.Flash && sourceID != 0 { u, err := bot.Client.GetGroupImageDownloadUrl(i.FileId, sourceID, i.Md5) if err != nil { log.Warnf("获取闪照地址时出现错误: %v", err) } else { i.Url = u } } data := binary.NewWriterF(func(w *binary.Writer) { w.Write(i.Md5) w.WriteUInt32(uint32(i.Size)) w.WriteString(i.ImageId) w.WriteString(i.Url) }) cache.Image.Insert(i.Md5, data) case *message.GuildImageElement: data := binary.NewWriterF(func(w *binary.Writer) { w.Write(i.Md5) w.WriteUInt32(uint32(i.Size)) w.WriteString(i.DownloadIndex) w.WriteString(i.Url) }) filename := hex.EncodeToString(i.Md5) + ".image" cache.Image.Insert(i.Md5, data) if i.Url != "" && !global.PathExists(path.Join(global.ImagePath, "guild-images", filename)) { r := download.Request{URL: i.Url} if err := r.WriteToFile(path.Join(global.ImagePath, "guild-images", filename)); err != nil { log.Warnf("下载频道图片时出现错误: %v", err) } } case *message.FriendImageElement: data := binary.NewWriterF(func(w *binary.Writer) { w.Write(i.Md5) w.WriteUInt32(uint32(i.Size)) w.WriteString(i.ImageId) w.WriteString(i.Url) }) cache.Image.Insert(i.Md5, data) case *message.VoiceElement: // todo: don't download original file? i.Name = strings.ReplaceAll(i.Name, "{", "") i.Name = strings.ReplaceAll(i.Name, "}", "") if !global.PathExists(path.Join(global.VoicePath, i.Name)) { err := download.Request{URL: i.Url}.WriteToFile(path.Join(global.VoicePath, i.Name)) if err != nil { log.Warnf("语音文件 %v 下载失败: %v", i.Name, err) continue } } case *message.ShortVideoElement: data := 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) }) filename := hex.EncodeToString(i.Md5) + ".video" cache.Video.Insert(i.Md5, data) i.Name = filename i.Url = bot.Client.GetShortVideoUrl(i.Uuid, i.Md5) } } } ================================================ FILE: coolq/feed.go ================================================ package coolq import ( "github.com/Mrs4s/MiraiGo/topic" "github.com/Mrs4s/go-cqhttp/global" ) // FeedContentsToArrayMessage 将话题频道帖子内容转换为 Array Message func FeedContentsToArrayMessage(contents []topic.IFeedRichContentElement) []global.MSG { r := make([]global.MSG, 0, len(contents)) for _, e := range contents { var m global.MSG switch elem := e.(type) { case *topic.TextElement: m = global.MSG{ "type": "text", "data": global.MSG{"text": elem.Content}, } case *topic.AtElement: m = global.MSG{ "type": "at", "data": global.MSG{"id": elem.Id, "qq": elem.Id}, } case *topic.EmojiElement: m = global.MSG{ "type": "face", "data": global.MSG{"id": elem.Id}, } case *topic.ChannelQuoteElement: m = global.MSG{ "type": "channel_quote", "data": global.MSG{ "guild_id": fU64(elem.GuildId), "channel_id": fU64(elem.ChannelId), "display_text": elem.DisplayText, }, } case *topic.UrlQuoteElement: m = global.MSG{ "type": "url_quote", "data": global.MSG{ "url": elem.Url, "display_text": elem.DisplayText, }, } } if m != nil { r = append(r, m) } } return r } ================================================ FILE: db/database.go ================================================ package db import ( "fmt" "hash/crc32" "github.com/Mrs4s/go-cqhttp/global" ) type ( // Database 数据库操作接口定义 Database interface { // Open 初始化数据库 Open() error // GetMessageByGlobalID 通过 GlobalID 来获取消息 GetMessageByGlobalID(int32) (StoredMessage, error) // GetGroupMessageByGlobalID 通过 GlobalID 来获取群消息 GetGroupMessageByGlobalID(int32) (*StoredGroupMessage, error) // GetPrivateMessageByGlobalID 通过 GlobalID 来获取私聊消息 GetPrivateMessageByGlobalID(int32) (*StoredPrivateMessage, error) // GetGuildChannelMessageByID 通过 ID 来获取频道消息 GetGuildChannelMessageByID(string) (*StoredGuildChannelMessage, error) // InsertGroupMessage 向数据库写入新的群消息 InsertGroupMessage(*StoredGroupMessage) error // InsertPrivateMessage 向数据库写入新的私聊消息 InsertPrivateMessage(*StoredPrivateMessage) error // InsertGuildChannelMessage 向数据库写入新的频道消息 InsertGuildChannelMessage(*StoredGuildChannelMessage) error } StoredMessage interface { GetID() string GetType() string GetGlobalID() int32 GetAttribute() *StoredMessageAttribute GetContent() []global.MSG } // StoredGroupMessage 持久化群消息 StoredGroupMessage struct { ID string `bson:"_id" yaml:"-"` GlobalID int32 `bson:"globalId" yaml:"-"` Attribute *StoredMessageAttribute `bson:"attribute" yaml:"-"` SubType string `bson:"subType" yaml:"-"` QuotedInfo *QuotedInfo `bson:"quotedInfo" yaml:"-"` GroupCode int64 `bson:"groupCode" yaml:"-"` AnonymousID string `bson:"anonymousId" yaml:"-"` Content []global.MSG `bson:"content" yaml:"content"` } // StoredPrivateMessage 持久化私聊消息 StoredPrivateMessage struct { ID string `bson:"_id" yaml:"-"` GlobalID int32 `bson:"globalId" yaml:"-"` Attribute *StoredMessageAttribute `bson:"attribute" yaml:"-"` SubType string `bson:"subType" yaml:"-"` QuotedInfo *QuotedInfo `bson:"quotedInfo" yaml:"-"` SessionUin int64 `bson:"sessionUin" yaml:"-"` TargetUin int64 `bson:"targetUin" yaml:"-"` Content []global.MSG `bson:"content" yaml:"content"` } // StoredGuildChannelMessage 持久化频道消息 StoredGuildChannelMessage struct { ID string `bson:"_id" yaml:"-"` Attribute *StoredGuildMessageAttribute `bson:"attribute" yaml:"-"` GuildID uint64 `bson:"guildId" yaml:"-"` ChannelID uint64 `bson:"channelId" yaml:"-"` QuotedInfo *QuotedInfo `bson:"quotedInfo" yaml:"-"` Content []global.MSG `bson:"content" yaml:"content"` } // StoredMessageAttribute 持久化消息属性 StoredMessageAttribute struct { MessageSeq int32 `bson:"messageSeq" yaml:"-"` InternalID int32 `bson:"internalId" yaml:"-"` SenderUin int64 `bson:"senderUin" yaml:"-"` SenderName string `bson:"senderName" yaml:"-"` Timestamp int64 `bson:"timestamp" yaml:"-"` } // StoredGuildMessageAttribute 持久化频道消息属性 StoredGuildMessageAttribute struct { MessageSeq uint64 `bson:"messageSeq" yaml:"-"` InternalID uint64 `bson:"internalId" yaml:"-"` SenderTinyID uint64 `bson:"senderTinyId" yaml:"-"` SenderName string `bson:"senderName" yaml:"-"` Timestamp int64 `bson:"timestamp" yaml:"-"` } // QuotedInfo 引用回复 QuotedInfo struct { PrevID string `bson:"prevId" yaml:"-"` PrevGlobalID int32 `bson:"prevGlobalId" yaml:"-"` QuotedContent []global.MSG `bson:"quotedContent" yaml:"quoted_content"` } ) // ToGlobalID 构建`code`-`msgID`的字符串并返回其CRC32 Checksum的值 func ToGlobalID(code int64, msgID int32) int32 { return int32(crc32.ChecksumIEEE([]byte(fmt.Sprintf("%d-%d", code, msgID)))) } func (m *StoredGroupMessage) GetID() string { return m.ID } func (m *StoredGroupMessage) GetType() string { return "group" } func (m *StoredGroupMessage) GetGlobalID() int32 { return m.GlobalID } func (m *StoredGroupMessage) GetAttribute() *StoredMessageAttribute { return m.Attribute } func (m *StoredGroupMessage) GetContent() []global.MSG { return m.Content } func (m *StoredPrivateMessage) GetID() string { return m.ID } func (m *StoredPrivateMessage) GetType() string { return "private" } func (m *StoredPrivateMessage) GetGlobalID() int32 { return m.GlobalID } func (m *StoredPrivateMessage) GetAttribute() *StoredMessageAttribute { return m.Attribute } func (m *StoredPrivateMessage) GetContent() []global.MSG { return m.Content } ================================================ FILE: db/leveldb/const.go ================================================ package leveldb const dataVersion = 1 const ( group = 0x0 private = 0x1 guildChannel = 0x2 ) type coder byte const ( coderNil coder = iota coderInt coderUint coderInt32 coderUint32 coderInt64 coderUint64 coderString coderMSG // global.MSG coderArrayMSG // []global.MSG coderStruct // struct{} ) ================================================ FILE: db/leveldb/leveldb.go ================================================ package leveldb import ( "path" "github.com/Mrs4s/MiraiGo/binary" "github.com/Mrs4s/MiraiGo/utils" "github.com/pkg/errors" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" "gopkg.in/yaml.v3" "github.com/Mrs4s/go-cqhttp/db" ) type database struct { db *leveldb.DB } // config leveldb 相关配置 type config struct { Enable bool `yaml:"enable"` } func init() { db.Register("leveldb", func(node yaml.Node) db.Database { conf := new(config) _ = node.Decode(conf) if !conf.Enable { return nil } return &database{} }) } func (ldb *database) Open() error { p := path.Join("data", "leveldb-v3") d, err := leveldb.OpenFile(p, &opt.Options{ WriteBuffer: 32 * opt.KiB, }) if err != nil { return errors.Wrap(err, "open leveldb error") } ldb.db = d return nil } func (ldb *database) GetMessageByGlobalID(id int32) (_ db.StoredMessage, err error) { v, err := ldb.db.Get(binary.ToBytes(id), nil) if err != nil || len(v) == 0 { return nil, errors.Wrap(err, "get value error") } defer func() { if r := recover(); r != nil { err = errors.Errorf("%v", r) } }() r, err := newReader(utils.B2S(v)) if err != nil { return nil, err } switch r.uvarint() { case group: return r.readStoredGroupMessage(), nil case private: return r.readStoredPrivateMessage(), nil default: return nil, errors.New("unknown message flag") } } func (ldb *database) GetGroupMessageByGlobalID(id int32) (*db.StoredGroupMessage, error) { i, err := ldb.GetMessageByGlobalID(id) if err != nil { return nil, err } g, ok := i.(*db.StoredGroupMessage) if !ok { return nil, errors.New("message type error") } return g, nil } func (ldb *database) GetPrivateMessageByGlobalID(id int32) (*db.StoredPrivateMessage, error) { i, err := ldb.GetMessageByGlobalID(id) if err != nil { return nil, err } p, ok := i.(*db.StoredPrivateMessage) if !ok { return nil, errors.New("message type error") } return p, nil } func (ldb *database) GetGuildChannelMessageByID(id string) (*db.StoredGuildChannelMessage, error) { v, err := ldb.db.Get([]byte(id), nil) if err != nil { return nil, errors.Wrap(err, "get value error") } defer func() { if r := recover(); r != nil { err = errors.Errorf("%v", r) } }() r, err := newReader(utils.B2S(v)) if err != nil { return nil, err } switch r.uvarint() { case guildChannel: return r.readStoredGuildChannelMessage(), nil default: return nil, errors.New("unknown message flag") } } func (ldb *database) InsertGroupMessage(msg *db.StoredGroupMessage) error { w := newWriter() w.uvarint(group) w.writeStoredGroupMessage(msg) err := ldb.db.Put(binary.ToBytes(msg.GlobalID), w.bytes(), nil) return errors.Wrap(err, "put data error") } func (ldb *database) InsertPrivateMessage(msg *db.StoredPrivateMessage) error { w := newWriter() w.uvarint(private) w.writeStoredPrivateMessage(msg) err := ldb.db.Put(binary.ToBytes(msg.GlobalID), w.bytes(), nil) return errors.Wrap(err, "put data error") } func (ldb *database) InsertGuildChannelMessage(msg *db.StoredGuildChannelMessage) error { w := newWriter() w.uvarint(guildChannel) w.writeStoredGuildChannelMessage(msg) err := ldb.db.Put(utils.S2B(msg.ID), w.bytes(), nil) return errors.Wrap(err, "put data error") } ================================================ FILE: db/leveldb/reader.go ================================================ package leveldb import ( "encoding/binary" "io" "strconv" "strings" "github.com/pkg/errors" "github.com/Mrs4s/go-cqhttp/global" ) type intReader struct { data string *strings.Reader } func newIntReader(s string) intReader { return intReader{ data: s, Reader: strings.NewReader(s), } } func (r *intReader) varint() int64 { i, _ := binary.ReadVarint(r) return i } func (r *intReader) uvarint() uint64 { i, _ := binary.ReadUvarint(r) return i } // reader implements the index read. // data format is the same as the writer's type reader struct { data intReader strings intReader stringIndex map[uint64]string } func (r *reader) coder() coder { o, _ := r.data.ReadByte(); return coder(o) } func (r *reader) varint() int64 { return r.data.varint() } func (r *reader) uvarint() uint64 { return r.data.uvarint() } func (r *reader) int32() int32 { return int32(r.varint()) } func (r *reader) int64() int64 { return r.varint() } func (r *reader) uint64() uint64 { return r.uvarint() } // func (r *reader) uint32() uint32 { return uint32(r.uvarint()) } // func (r *reader) int() int { return int(r.varint()) } // func (r *reader) uint() uint { return uint(r.uvarint()) } func (r *reader) string() string { off := r.data.uvarint() if s, ok := r.stringIndex[off]; ok { return s } _, _ = r.strings.Seek(int64(off), io.SeekStart) l := int64(r.strings.uvarint()) whence, _ := r.strings.Seek(0, io.SeekCurrent) s := r.strings.data[whence : whence+l] r.stringIndex[off] = s return s } func (r *reader) msg() global.MSG { length := r.uvarint() msg := make(global.MSG, length) for i := uint64(0); i < length; i++ { s := r.string() msg[s] = r.obj() } return msg } func (r *reader) arrayMsg() []global.MSG { length := r.uvarint() msgs := make([]global.MSG, length) for i := range msgs { msgs[i] = r.msg() } return msgs } func (r *reader) obj() any { switch coder := r.coder(); coder { case coderNil: return nil case coderInt: return int(r.varint()) case coderUint: return uint(r.uvarint()) case coderInt32: return int32(r.varint()) case coderUint32: return uint32(r.uvarint()) case coderInt64: return r.varint() case coderUint64: return r.uvarint() case coderString: return r.string() case coderMSG: return r.msg() case coderArrayMSG: return r.arrayMsg() default: panic("db/leveldb: invalid coder " + strconv.Itoa(int(coder))) } } func newReader(data string) (*reader, error) { in := newIntReader(data) v := in.uvarint() if v != dataVersion { return nil, errors.Errorf("db/leveldb: invalid data version %d", v) } sl := int64(in.uvarint()) dl := int64(in.uvarint()) whence, _ := in.Seek(0, io.SeekCurrent) sData := data[whence : whence+sl] dData := data[whence+sl : whence+sl+dl] r := reader{ data: newIntReader(dData), strings: newIntReader(sData), stringIndex: make(map[uint64]string), } return &r, nil } ================================================ FILE: db/leveldb/structs.go ================================================ package leveldb import "github.com/Mrs4s/go-cqhttp/db" func (w *writer) writeStoredGroupMessage(x *db.StoredGroupMessage) { if x == nil { w.nil() return } w.coder(coderStruct) w.string(x.ID) w.int32(x.GlobalID) w.writeStoredMessageAttribute(x.Attribute) w.string(x.SubType) w.writeQuotedInfo(x.QuotedInfo) w.int64(x.GroupCode) w.string(x.AnonymousID) w.arrayMsg(x.Content) } func (r *reader) readStoredGroupMessage() *db.StoredGroupMessage { coder := r.coder() if coder == coderNil { return nil } x := &db.StoredGroupMessage{} x.ID = r.string() x.GlobalID = r.int32() x.Attribute = r.readStoredMessageAttribute() x.SubType = r.string() x.QuotedInfo = r.readQuotedInfo() x.GroupCode = r.int64() x.AnonymousID = r.string() x.Content = r.arrayMsg() return x } func (w *writer) writeStoredPrivateMessage(x *db.StoredPrivateMessage) { if x == nil { w.nil() return } w.coder(coderStruct) w.string(x.ID) w.int32(x.GlobalID) w.writeStoredMessageAttribute(x.Attribute) w.string(x.SubType) w.writeQuotedInfo(x.QuotedInfo) w.int64(x.SessionUin) w.int64(x.TargetUin) w.arrayMsg(x.Content) } func (r *reader) readStoredPrivateMessage() *db.StoredPrivateMessage { coder := r.coder() if coder == coderNil { return nil } x := &db.StoredPrivateMessage{} x.ID = r.string() x.GlobalID = r.int32() x.Attribute = r.readStoredMessageAttribute() x.SubType = r.string() x.QuotedInfo = r.readQuotedInfo() x.SessionUin = r.int64() x.TargetUin = r.int64() x.Content = r.arrayMsg() return x } func (w *writer) writeStoredGuildChannelMessage(x *db.StoredGuildChannelMessage) { if x == nil { w.nil() return } w.coder(coderStruct) w.string(x.ID) w.writeStoredGuildMessageAttribute(x.Attribute) w.uint64(x.GuildID) w.uint64(x.ChannelID) w.writeQuotedInfo(x.QuotedInfo) w.arrayMsg(x.Content) } func (r *reader) readStoredGuildChannelMessage() *db.StoredGuildChannelMessage { coder := r.coder() if coder == coderNil { return nil } x := &db.StoredGuildChannelMessage{} x.ID = r.string() x.Attribute = r.readStoredGuildMessageAttribute() x.GuildID = r.uint64() x.ChannelID = r.uint64() x.QuotedInfo = r.readQuotedInfo() x.Content = r.arrayMsg() return x } func (w *writer) writeStoredMessageAttribute(x *db.StoredMessageAttribute) { if x == nil { w.nil() return } w.coder(coderStruct) w.int32(x.MessageSeq) w.int32(x.InternalID) w.int64(x.SenderUin) w.string(x.SenderName) w.int64(x.Timestamp) } func (r *reader) readStoredMessageAttribute() *db.StoredMessageAttribute { coder := r.coder() if coder == coderNil { return nil } x := &db.StoredMessageAttribute{} x.MessageSeq = r.int32() x.InternalID = r.int32() x.SenderUin = r.int64() x.SenderName = r.string() x.Timestamp = r.int64() return x } func (w *writer) writeStoredGuildMessageAttribute(x *db.StoredGuildMessageAttribute) { if x == nil { w.nil() return } w.coder(coderStruct) w.uint64(x.MessageSeq) w.uint64(x.InternalID) w.uint64(x.SenderTinyID) w.string(x.SenderName) w.int64(x.Timestamp) } func (r *reader) readStoredGuildMessageAttribute() *db.StoredGuildMessageAttribute { coder := r.coder() if coder == coderNil { return nil } x := &db.StoredGuildMessageAttribute{} x.MessageSeq = r.uint64() x.InternalID = r.uint64() x.SenderTinyID = r.uint64() x.SenderName = r.string() x.Timestamp = r.int64() return x } func (w *writer) writeQuotedInfo(x *db.QuotedInfo) { if x == nil { w.nil() return } w.coder(coderStruct) w.string(x.PrevID) w.int32(x.PrevGlobalID) w.arrayMsg(x.QuotedContent) } func (r *reader) readQuotedInfo() *db.QuotedInfo { coder := r.coder() if coder == coderNil { return nil } x := &db.QuotedInfo{} x.PrevID = r.string() x.PrevGlobalID = r.int32() x.QuotedContent = r.arrayMsg() return x } ================================================ FILE: db/leveldb/writer.go ================================================ package leveldb import ( "bytes" "github.com/Mrs4s/go-cqhttp/global" ) type intWriter struct { bytes.Buffer } func (w *intWriter) varint(x int64) { w.uvarint(uint64(x)<<1 ^ uint64(x>>63)) } func (w *intWriter) uvarint(x uint64) { for x >= 0x80 { w.WriteByte(byte(x) | 0x80) x >>= 7 } w.WriteByte(byte(x)) } // writer implements the index write. // // data format(use uvarint to encode integers): // // - version // - string data length // - index data length // - string data // - index data // // for string data part, each string is encoded as: // // - string length // - string // // for index data part, each object value is encoded as: // // - coder // - value // // * coder is the identifier of value's type. // * specially for string, it's value is the offset in string data part. type writer struct { data intWriter strings intWriter stringIndex map[string]uint64 } func newWriter() *writer { return &writer{ stringIndex: make(map[string]uint64), } } func (w *writer) coder(o coder) { w.data.WriteByte(byte(o)) } func (w *writer) varint(x int64) { w.data.varint(x) } func (w *writer) uvarint(x uint64) { w.data.uvarint(x) } func (w *writer) nil() { w.coder(coderNil) } func (w *writer) int(i int) { w.varint(int64(i)) } func (w *writer) uint(i uint) { w.uvarint(uint64(i)) } func (w *writer) int32(i int32) { w.varint(int64(i)) } func (w *writer) uint32(i uint32) { w.uvarint(uint64(i)) } func (w *writer) int64(i int64) { w.varint(i) } func (w *writer) uint64(i uint64) { w.uvarint(i) } func (w *writer) string(s string) { off, ok := w.stringIndex[s] if !ok { // not found write to string data part // | string length | string | off = uint64(w.strings.Len()) w.strings.uvarint(uint64(len(s))) _, _ = w.strings.WriteString(s) w.stringIndex[s] = off } // write offset to index data part w.uvarint(off) } func (w *writer) msg(m global.MSG) { w.uvarint(uint64(len(m))) for s, obj := range m { w.string(s) w.obj(obj) } } func (w *writer) arrayMsg(a []global.MSG) { w.uvarint(uint64(len(a))) for _, v := range a { w.msg(v) } } func (w *writer) obj(o any) { switch x := o.(type) { case nil: w.nil() case int: w.coder(coderInt) w.int(x) case int32: w.coder(coderInt32) w.int32(x) case int64: w.coder(coderInt64) w.int64(x) case uint: w.coder(coderUint) w.uint(x) case uint32: w.coder(coderUint32) w.uint32(x) case uint64: w.coder(coderUint64) w.uint64(x) case string: w.coder(coderString) w.string(x) case global.MSG: w.coder(coderMSG) w.msg(x) case []global.MSG: w.coder(coderArrayMSG) w.arrayMsg(x) default: panic("unsupported type") } } func (w *writer) bytes() []byte { var out intWriter out.uvarint(dataVersion) out.uvarint(uint64(w.strings.Len())) out.uvarint(uint64(w.data.Len())) _, _ = w.strings.WriteTo(&out) _, _ = w.data.WriteTo(&out) return out.Bytes() } ================================================ FILE: db/mongodb/mongodb.go ================================================ package mongodb import ( "context" "github.com/pkg/errors" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" "gopkg.in/yaml.v3" "github.com/Mrs4s/go-cqhttp/db" ) type database struct { uri string db string mongo *mongo.Database } // config mongodb 相关配置 type config struct { Enable bool `yaml:"enable"` URI string `yaml:"uri"` Database string `yaml:"database"` } const ( MongoGroupMessageCollection = "group-messages" MongoPrivateMessageCollection = "private-messages" MongoGuildChannelMessageCollection = "guild-channel-messages" ) func init() { db.Register("database", func(node yaml.Node) db.Database { conf := new(config) _ = node.Decode(conf) if conf.Database == "" { conf.Database = "gocq-database" } if !conf.Enable { return nil } return &database{uri: conf.URI, db: conf.Database} }) } func (m *database) Open() error { cli, err := mongo.Connect(context.Background(), options.Client().ApplyURI(m.uri)) if err != nil { return errors.Wrap(err, "open mongo connection error") } m.mongo = cli.Database(m.db) return nil } func (m *database) GetMessageByGlobalID(id int32) (db.StoredMessage, error) { if r, err := m.GetGroupMessageByGlobalID(id); err == nil { return r, nil } return m.GetPrivateMessageByGlobalID(id) } func (m *database) GetGroupMessageByGlobalID(id int32) (*db.StoredGroupMessage, error) { coll := m.mongo.Collection(MongoGroupMessageCollection) var ret db.StoredGroupMessage if err := coll.FindOne(context.Background(), bson.D{{"globalId", id}}).Decode(&ret); err != nil { return nil, errors.Wrap(err, "query error") } return &ret, nil } func (m *database) GetPrivateMessageByGlobalID(id int32) (*db.StoredPrivateMessage, error) { coll := m.mongo.Collection(MongoPrivateMessageCollection) var ret db.StoredPrivateMessage if err := coll.FindOne(context.Background(), bson.D{{"globalId", id}}).Decode(&ret); err != nil { return nil, errors.Wrap(err, "query error") } return &ret, nil } func (m *database) GetGuildChannelMessageByID(id string) (*db.StoredGuildChannelMessage, error) { coll := m.mongo.Collection(MongoGuildChannelMessageCollection) var ret db.StoredGuildChannelMessage if err := coll.FindOne(context.Background(), bson.D{{"_id", id}}).Decode(&ret); err != nil { return nil, errors.Wrap(err, "query error") } return &ret, nil } func (m *database) InsertGroupMessage(msg *db.StoredGroupMessage) error { coll := m.mongo.Collection(MongoGroupMessageCollection) _, err := coll.UpdateOne(context.Background(), bson.D{{"_id", msg.ID}}, bson.D{{"$set", msg}}, options.Update().SetUpsert(true)) return errors.Wrap(err, "insert error") } func (m *database) InsertPrivateMessage(msg *db.StoredPrivateMessage) error { coll := m.mongo.Collection(MongoPrivateMessageCollection) _, err := coll.UpdateOne(context.Background(), bson.D{{"_id", msg.ID}}, bson.D{{"$set", msg}}, options.Update().SetUpsert(true)) return errors.Wrap(err, "insert error") } func (m *database) InsertGuildChannelMessage(msg *db.StoredGuildChannelMessage) error { coll := m.mongo.Collection(MongoGuildChannelMessageCollection) _, err := coll.UpdateOne(context.Background(), bson.D{{"_id", msg.ID}}, bson.D{{"$set", msg}}, options.Update().SetUpsert(true)) return errors.Wrap(err, "insert error") } ================================================ FILE: db/multidb.go ================================================ package db import ( "github.com/pkg/errors" "gopkg.in/yaml.v3" "github.com/Mrs4s/go-cqhttp/internal/base" ) // backends 多数据库支持, 后端支持 // 写入会对所有 Backend 进行写入 // 读取只会读取第一个库 var backends []Database // drivers 多数据库启动 var drivers = make(map[string]func(node yaml.Node) Database) // DatabaseDisabledError 没有可用的db var DatabaseDisabledError = errors.New("database disabled") // Register 添加数据库后端 func Register(name string, init func(yaml.Node) Database) { if _, ok := drivers[name]; ok { panic("database driver conflict: " + name) } drivers[name] = init } // Init 加载所有后端配置文件 func Init() { backends = make([]Database, 0, len(drivers)) for name, init := range drivers { if n, ok := base.Database[name]; ok { db := init(n) if db != nil { backends = append(backends, db) } } } } func Open() error { for _, b := range backends { if err := b.Open(); err != nil { return errors.Wrap(err, "open backend error") } } base.Database = nil return nil } func GetMessageByGlobalID(id int32) (StoredMessage, error) { if len(backends) == 0 { return nil, DatabaseDisabledError } return backends[0].GetMessageByGlobalID(id) } func GetGroupMessageByGlobalID(id int32) (*StoredGroupMessage, error) { if len(backends) == 0 { return nil, DatabaseDisabledError } return backends[0].GetGroupMessageByGlobalID(id) } func GetPrivateMessageByGlobalID(id int32) (*StoredPrivateMessage, error) { if len(backends) == 0 { return nil, DatabaseDisabledError } return backends[0].GetPrivateMessageByGlobalID(id) } func GetGuildChannelMessageByID(id string) (*StoredGuildChannelMessage, error) { if len(backends) == 0 { return nil, DatabaseDisabledError } return backends[0].GetGuildChannelMessageByID(id) } func InsertGroupMessage(m *StoredGroupMessage) error { for _, b := range backends { if err := b.InsertGroupMessage(m); err != nil { return errors.Wrap(err, "insert message to backend error") } } return nil } func InsertPrivateMessage(m *StoredPrivateMessage) error { for _, b := range backends { if err := b.InsertPrivateMessage(m); err != nil { return errors.Wrap(err, "insert message to backend error") } } return nil } func InsertGuildChannelMessage(m *StoredGuildChannelMessage) error { for _, b := range backends { if err := b.InsertGuildChannelMessage(m); err != nil { return errors.Wrap(err, "insert message to backend error") } } return nil } ================================================ FILE: db/sqlite3/model.go ================================================ package sqlite3 const ( Sqlite3GroupMessageTableName = "grpmsg" Sqlite3MessageAttributeTableName = "msgattr" Sqlite3GuildMessageAttributeTableName = "gmsgattr" Sqlite3QuotedInfoTableName = "quoinf" Sqlite3PrivateMessageTableName = "privmsg" Sqlite3GuildChannelMessageTableName = "guildmsg" Sqlite3UinInfoTableName = "uininf" Sqlite3TinyInfoTableName = "tinyinf" ) // StoredMessageAttribute 持久化消息属性 type StoredMessageAttribute struct { ID int64 // ID is the crc64 of 字段s below MessageSeq int32 InternalID int32 SenderUin int64 // SenderUin is fk to UinInfo Timestamp int64 } // StoredGuildMessageAttribute 持久化频道消息属性 type StoredGuildMessageAttribute struct { ID int64 // ID is the crc64 of 字段s below MessageSeq int64 InternalID int64 SenderTinyID int64 // SenderTinyID is fk to TinyInfo Timestamp int64 } // QuotedInfo 引用回复 type QuotedInfo struct { ID int64 // ID is the crc64 of 字段s below PrevID string PrevGlobalID int32 QuotedContent string // QuotedContent is json of original content } // UinInfo QQ 与 昵称 type UinInfo struct { Uin int64 Name string } // TinyInfo Tiny 与 昵称 type TinyInfo struct { ID int64 Name string } // StoredGroupMessage 持久化群消息 type StoredGroupMessage struct { GlobalID int32 ID string AttributeID int64 SubType string QuotedInfoID int64 GroupCode int64 AnonymousID string Content string // Content is json of original content } // StoredPrivateMessage 持久化私聊消息 type StoredPrivateMessage struct { GlobalID int32 ID string AttributeID int64 SubType string QuotedInfoID int64 SessionUin int64 TargetUin int64 Content string // Content is json of original content } // StoredGuildChannelMessage 持久化频道消息 type StoredGuildChannelMessage struct { ID string AttributeID int64 GuildID int64 ChannelID int64 QuotedInfoID int64 Content string // Content is json of original content } ================================================ FILE: db/sqlite3/sqlite3.go ================================================ package sqlite3 import ( "encoding/base64" "hash/crc64" "os" "path" "strconv" "sync" "time" sql "github.com/FloatTech/sqlite" "github.com/pkg/errors" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" "github.com/Mrs4s/MiraiGo/binary" "github.com/Mrs4s/MiraiGo/utils" "github.com/Mrs4s/go-cqhttp/db" ) type database struct { sync.RWMutex db *sql.Sqlite ttl time.Duration } // config mongodb 相关配置 type config struct { Enable bool `yaml:"enable"` CacheTTL string `yaml:"cachettl"` } func init() { sql.DriverName = "sqlite" db.Register("sqlite3", func(node yaml.Node) db.Database { conf := new(config) _ = node.Decode(conf) if !conf.Enable { return nil } duration, err := time.ParseDuration(conf.CacheTTL) if err != nil { log.Fatalf("illegal ttl config: %v", err) } return &database{db: new(sql.Sqlite), ttl: duration} }) } func (s *database) Open() error { s.db.DBPath = path.Join("data", "sqlite3") _ = os.MkdirAll(s.db.DBPath, 0755) s.db.DBPath += "/msg.db" err := s.db.Open(s.ttl) if err != nil { return errors.Wrap(err, "open sqlite3 error") } _, err = s.db.DB.Exec("PRAGMA foreign_keys = ON;") if err != nil { return errors.Wrap(err, "enable foreign_keys error") } err = s.db.Create(Sqlite3UinInfoTableName, &UinInfo{}) if err != nil { return errors.Wrap(err, "create sqlite3 table error") } err = s.db.Insert(Sqlite3UinInfoTableName, &UinInfo{Name: "null"}) if err != nil { return errors.Wrap(err, "insert into sqlite3 table "+Sqlite3UinInfoTableName+" error") } err = s.db.Create(Sqlite3TinyInfoTableName, &TinyInfo{}) if err != nil { return errors.Wrap(err, "create sqlite3 table error") } err = s.db.Insert(Sqlite3TinyInfoTableName, &TinyInfo{Name: "null"}) if err != nil { return errors.Wrap(err, "insert into sqlite3 table "+Sqlite3TinyInfoTableName+" error") } err = s.db.Create(Sqlite3MessageAttributeTableName, &StoredMessageAttribute{}, "FOREIGN KEY(SenderUin) REFERENCES "+Sqlite3UinInfoTableName+"(Uin)", ) if err != nil { return errors.Wrap(err, "create sqlite3 table error") } err = s.db.Insert(Sqlite3MessageAttributeTableName, &StoredMessageAttribute{}) if err != nil { return errors.Wrap(err, "insert into sqlite3 table "+Sqlite3MessageAttributeTableName+" error") } err = s.db.Create(Sqlite3GuildMessageAttributeTableName, &StoredGuildMessageAttribute{}, "FOREIGN KEY(SenderTinyID) REFERENCES "+Sqlite3TinyInfoTableName+"(ID)", ) if err != nil { return errors.Wrap(err, "create sqlite3 table error") } err = s.db.Insert(Sqlite3GuildMessageAttributeTableName, &StoredGuildMessageAttribute{}) if err != nil { return errors.Wrap(err, "insert into sqlite3 table "+Sqlite3GuildMessageAttributeTableName+" error") } err = s.db.Create(Sqlite3QuotedInfoTableName, &QuotedInfo{}) if err != nil { return errors.Wrap(err, "create sqlite3 table error") } err = s.db.Insert(Sqlite3QuotedInfoTableName, &QuotedInfo{QuotedContent: "null"}) if err != nil { return errors.Wrap(err, "insert into sqlite3 table "+Sqlite3QuotedInfoTableName+" error") } err = s.db.Create(Sqlite3GroupMessageTableName, &StoredGroupMessage{}, "FOREIGN KEY(AttributeID) REFERENCES "+Sqlite3MessageAttributeTableName+"(ID)", "FOREIGN KEY(QuotedInfoID) REFERENCES "+Sqlite3QuotedInfoTableName+"(ID)", ) if err != nil { return errors.Wrap(err, "create sqlite3 table error") } err = s.db.Create(Sqlite3PrivateMessageTableName, &StoredPrivateMessage{}, "FOREIGN KEY(AttributeID) REFERENCES "+Sqlite3MessageAttributeTableName+"(ID)", "FOREIGN KEY(QuotedInfoID) REFERENCES "+Sqlite3QuotedInfoTableName+"(ID)", ) if err != nil { return errors.Wrap(err, "create sqlite3 table error") } err = s.db.Create(Sqlite3GuildChannelMessageTableName, &StoredGuildChannelMessage{}, "FOREIGN KEY(AttributeID) REFERENCES "+Sqlite3MessageAttributeTableName+"(ID)", "FOREIGN KEY(QuotedInfoID) REFERENCES "+Sqlite3QuotedInfoTableName+"(ID)", ) if err != nil { return errors.Wrap(err, "create sqlite3 table error") } return nil } func (s *database) GetMessageByGlobalID(id int32) (db.StoredMessage, error) { if r, err := s.GetGroupMessageByGlobalID(id); err == nil { return r, nil } return s.GetPrivateMessageByGlobalID(id) } func (s *database) GetGroupMessageByGlobalID(id int32) (*db.StoredGroupMessage, error) { var ret db.StoredGroupMessage var grpmsg StoredGroupMessage s.RLock() err := s.db.Find(Sqlite3GroupMessageTableName, &grpmsg, "WHERE GlobalID="+strconv.Itoa(int(id))) s.RUnlock() if err != nil { return nil, errors.Wrap(err, "query error") } ret.ID = grpmsg.ID ret.GlobalID = grpmsg.GlobalID ret.SubType = grpmsg.SubType ret.GroupCode = grpmsg.GroupCode ret.AnonymousID = grpmsg.AnonymousID _ = yaml.Unmarshal(utils.S2B(grpmsg.Content), &ret) if grpmsg.AttributeID != 0 { var attr StoredMessageAttribute s.RLock() err = s.db.Find(Sqlite3MessageAttributeTableName, &attr, "WHERE ID="+strconv.FormatInt(grpmsg.AttributeID, 10)) s.RUnlock() if err == nil { var uin UinInfo s.RLock() err = s.db.Find(Sqlite3UinInfoTableName, &uin, "WHERE Uin="+strconv.FormatInt(attr.SenderUin, 10)) s.RUnlock() if err == nil { ret.Attribute = &db.StoredMessageAttribute{ MessageSeq: attr.MessageSeq, InternalID: attr.InternalID, SenderUin: attr.SenderUin, SenderName: uin.Name, Timestamp: attr.Timestamp, } } } } if grpmsg.QuotedInfoID != 0 { var quoinf QuotedInfo s.RLock() err = s.db.Find(Sqlite3QuotedInfoTableName, &quoinf, "WHERE ID="+strconv.FormatInt(grpmsg.QuotedInfoID, 10)) s.RUnlock() if err == nil { ret.QuotedInfo = &db.QuotedInfo{ PrevID: quoinf.PrevID, PrevGlobalID: quoinf.PrevGlobalID, } _ = yaml.Unmarshal(utils.S2B(quoinf.QuotedContent), &ret.QuotedInfo) } } return &ret, nil } func (s *database) GetPrivateMessageByGlobalID(id int32) (*db.StoredPrivateMessage, error) { var ret db.StoredPrivateMessage var privmsg StoredPrivateMessage s.RLock() err := s.db.Find(Sqlite3PrivateMessageTableName, &privmsg, "WHERE GlobalID="+strconv.Itoa(int(id))) s.RUnlock() if err != nil { return nil, errors.Wrap(err, "query error") } ret.ID = privmsg.ID ret.GlobalID = privmsg.GlobalID ret.SubType = privmsg.SubType ret.SessionUin = privmsg.SessionUin ret.TargetUin = privmsg.TargetUin _ = yaml.Unmarshal(utils.S2B(privmsg.Content), &ret) if privmsg.AttributeID != 0 { var attr StoredMessageAttribute s.RLock() err = s.db.Find(Sqlite3MessageAttributeTableName, &attr, "WHERE ID="+strconv.FormatInt(privmsg.AttributeID, 10)) s.RUnlock() if err == nil { var uin UinInfo s.RLock() err = s.db.Find(Sqlite3UinInfoTableName, &uin, "WHERE Uin="+strconv.FormatInt(attr.SenderUin, 10)) s.RUnlock() if err == nil { ret.Attribute = &db.StoredMessageAttribute{ MessageSeq: attr.MessageSeq, InternalID: attr.InternalID, SenderUin: attr.SenderUin, SenderName: uin.Name, Timestamp: attr.Timestamp, } } } } if privmsg.QuotedInfoID != 0 { var quoinf QuotedInfo s.RLock() err = s.db.Find(Sqlite3QuotedInfoTableName, &quoinf, "WHERE ID="+strconv.FormatInt(privmsg.QuotedInfoID, 10)) s.RUnlock() if err == nil { ret.QuotedInfo = &db.QuotedInfo{ PrevID: quoinf.PrevID, PrevGlobalID: quoinf.PrevGlobalID, } _ = yaml.Unmarshal(utils.S2B(quoinf.QuotedContent), &ret.QuotedInfo) } } return &ret, nil } func (s *database) GetGuildChannelMessageByID(id string) (*db.StoredGuildChannelMessage, error) { b, err := base64.StdEncoding.DecodeString(id) if err != nil { return nil, errors.Wrap(err, "query invalid id error") } if len(b) < 25 { return nil, errors.New("query invalid id error: content too short") } var ret db.StoredGuildChannelMessage var guildmsg StoredGuildChannelMessage s.RLock() err = s.db.Find(Sqlite3GuildChannelMessageTableName, &guildmsg, "WHERE ID='"+id+"'") s.RUnlock() if err != nil { return nil, errors.Wrap(err, "query error") } ret.ID = guildmsg.ID ret.GuildID = uint64(guildmsg.GuildID) ret.ChannelID = uint64(guildmsg.ChannelID) _ = yaml.Unmarshal(utils.S2B(guildmsg.Content), &ret) if guildmsg.AttributeID != 0 { var attr StoredGuildMessageAttribute s.RLock() err = s.db.Find(Sqlite3GuildMessageAttributeTableName, &attr, "WHERE ID="+strconv.FormatInt(guildmsg.AttributeID, 10)) s.RUnlock() if err == nil { var tiny TinyInfo s.RLock() err = s.db.Find(Sqlite3TinyInfoTableName, &tiny, "WHERE ID="+strconv.FormatInt(attr.SenderTinyID, 10)) s.RUnlock() if err == nil { ret.Attribute = &db.StoredGuildMessageAttribute{ MessageSeq: uint64(attr.MessageSeq), InternalID: uint64(attr.InternalID), SenderTinyID: uint64(attr.SenderTinyID), SenderName: tiny.Name, Timestamp: attr.Timestamp, } } } } if guildmsg.QuotedInfoID != 0 { var quoinf QuotedInfo s.RLock() err = s.db.Find(Sqlite3QuotedInfoTableName, &quoinf, "WHERE ID="+strconv.FormatInt(guildmsg.QuotedInfoID, 10)) s.RUnlock() if err == nil { ret.QuotedInfo = &db.QuotedInfo{ PrevID: quoinf.PrevID, PrevGlobalID: quoinf.PrevGlobalID, } _ = yaml.Unmarshal(utils.S2B(quoinf.QuotedContent), &ret.QuotedInfo) } } return &ret, nil } func (s *database) InsertGroupMessage(msg *db.StoredGroupMessage) error { grpmsg := &StoredGroupMessage{ GlobalID: msg.GlobalID, ID: msg.ID, SubType: msg.SubType, GroupCode: msg.GroupCode, AnonymousID: msg.AnonymousID, } h := crc64.New(crc64.MakeTable(crc64.ISO)) if msg.Attribute != nil { h.Write(binary.NewWriterF(func(w *binary.Writer) { w.WriteUInt32(uint32(msg.Attribute.MessageSeq)) w.WriteUInt32(uint32(msg.Attribute.InternalID)) w.WriteUInt64(uint64(msg.Attribute.SenderUin)) w.WriteUInt64(uint64(msg.Attribute.Timestamp)) })) h.Write(utils.S2B(msg.Attribute.SenderName)) id := int64(h.Sum64()) if id == 0 { id++ } s.Lock() err := s.db.Insert(Sqlite3UinInfoTableName, &UinInfo{ Uin: msg.Attribute.SenderUin, Name: msg.Attribute.SenderName, }) if err == nil { err = s.db.Insert(Sqlite3MessageAttributeTableName, &StoredMessageAttribute{ ID: id, MessageSeq: msg.Attribute.MessageSeq, InternalID: msg.Attribute.InternalID, SenderUin: msg.Attribute.SenderUin, Timestamp: msg.Attribute.Timestamp, }) } s.Unlock() if err == nil { grpmsg.AttributeID = id } h.Reset() } if msg.QuotedInfo != nil { h.Write(utils.S2B(msg.QuotedInfo.PrevID)) h.Write(binary.NewWriterF(func(w *binary.Writer) { w.WriteUInt32(uint32(msg.QuotedInfo.PrevGlobalID)) })) content, err := yaml.Marshal(&msg.QuotedInfo) if err != nil { return errors.Wrap(err, "insert marshal QuotedContent error") } h.Write(content) id := int64(h.Sum64()) if id == 0 { id++ } s.Lock() err = s.db.Insert(Sqlite3QuotedInfoTableName, &QuotedInfo{ ID: id, PrevID: msg.QuotedInfo.PrevID, PrevGlobalID: msg.QuotedInfo.PrevGlobalID, QuotedContent: utils.B2S(content), }) s.Unlock() if err == nil { grpmsg.QuotedInfoID = id } } content, err := yaml.Marshal(&msg) if err != nil { return errors.Wrap(err, "insert marshal Content error") } grpmsg.Content = utils.B2S(content) s.Lock() err = s.db.Insert(Sqlite3GroupMessageTableName, grpmsg) s.Unlock() if err != nil { return errors.Wrap(err, "insert error") } return nil } func (s *database) InsertPrivateMessage(msg *db.StoredPrivateMessage) error { privmsg := &StoredPrivateMessage{ GlobalID: msg.GlobalID, ID: msg.ID, SubType: msg.SubType, SessionUin: msg.SessionUin, TargetUin: msg.TargetUin, } h := crc64.New(crc64.MakeTable(crc64.ISO)) if msg.Attribute != nil { h.Write(binary.NewWriterF(func(w *binary.Writer) { w.WriteUInt32(uint32(msg.Attribute.MessageSeq)) w.WriteUInt32(uint32(msg.Attribute.InternalID)) w.WriteUInt64(uint64(msg.Attribute.SenderUin)) w.WriteUInt64(uint64(msg.Attribute.Timestamp)) })) h.Write(utils.S2B(msg.Attribute.SenderName)) id := int64(h.Sum64()) if id == 0 { id++ } s.Lock() err := s.db.Insert(Sqlite3UinInfoTableName, &UinInfo{ Uin: msg.Attribute.SenderUin, Name: msg.Attribute.SenderName, }) if err == nil { err = s.db.Insert(Sqlite3MessageAttributeTableName, &StoredMessageAttribute{ ID: id, MessageSeq: msg.Attribute.MessageSeq, InternalID: msg.Attribute.InternalID, SenderUin: msg.Attribute.SenderUin, Timestamp: msg.Attribute.Timestamp, }) } s.Unlock() if err == nil { privmsg.AttributeID = id } h.Reset() } if msg.QuotedInfo != nil { h.Write(utils.S2B(msg.QuotedInfo.PrevID)) h.Write(binary.NewWriterF(func(w *binary.Writer) { w.WriteUInt32(uint32(msg.QuotedInfo.PrevGlobalID)) })) content, err := yaml.Marshal(&msg.QuotedInfo) if err != nil { return errors.Wrap(err, "insert marshal QuotedContent error") } h.Write(content) id := int64(h.Sum64()) if id == 0 { id++ } s.Lock() err = s.db.Insert(Sqlite3QuotedInfoTableName, &QuotedInfo{ ID: id, PrevID: msg.QuotedInfo.PrevID, PrevGlobalID: msg.QuotedInfo.PrevGlobalID, QuotedContent: utils.B2S(content), }) s.Unlock() if err == nil { privmsg.QuotedInfoID = id } } content, err := yaml.Marshal(&msg) if err != nil { return errors.Wrap(err, "insert marshal Content error") } privmsg.Content = utils.B2S(content) s.Lock() err = s.db.Insert(Sqlite3PrivateMessageTableName, privmsg) s.Unlock() if err != nil { return errors.Wrap(err, "insert error") } return nil } func (s *database) InsertGuildChannelMessage(msg *db.StoredGuildChannelMessage) error { guildmsg := &StoredGuildChannelMessage{ ID: msg.ID, GuildID: int64(msg.GuildID), ChannelID: int64(msg.ChannelID), } h := crc64.New(crc64.MakeTable(crc64.ISO)) if msg.Attribute != nil { h.Write(binary.NewWriterF(func(w *binary.Writer) { w.WriteUInt32(uint32(msg.Attribute.MessageSeq)) w.WriteUInt32(uint32(msg.Attribute.InternalID)) w.WriteUInt64(uint64(msg.Attribute.SenderTinyID)) w.WriteUInt64(uint64(msg.Attribute.Timestamp)) })) h.Write(utils.S2B(msg.Attribute.SenderName)) id := int64(h.Sum64()) if id == 0 { id++ } s.Lock() err := s.db.Insert(Sqlite3TinyInfoTableName, &TinyInfo{ ID: int64(msg.Attribute.SenderTinyID), Name: msg.Attribute.SenderName, }) if err == nil { err = s.db.Insert(Sqlite3MessageAttributeTableName, &StoredGuildMessageAttribute{ ID: id, MessageSeq: int64(msg.Attribute.MessageSeq), InternalID: int64(msg.Attribute.InternalID), SenderTinyID: int64(msg.Attribute.SenderTinyID), Timestamp: msg.Attribute.Timestamp, }) } s.Unlock() if err == nil { guildmsg.AttributeID = id } h.Reset() } if msg.QuotedInfo != nil { h.Write(utils.S2B(msg.QuotedInfo.PrevID)) h.Write(binary.NewWriterF(func(w *binary.Writer) { w.WriteUInt32(uint32(msg.QuotedInfo.PrevGlobalID)) })) content, err := yaml.Marshal(&msg.QuotedInfo) if err != nil { return errors.Wrap(err, "insert marshal QuotedContent error") } h.Write(content) id := int64(h.Sum64()) if id == 0 { id++ } s.Lock() err = s.db.Insert(Sqlite3QuotedInfoTableName, &QuotedInfo{ ID: id, PrevID: msg.QuotedInfo.PrevID, PrevGlobalID: msg.QuotedInfo.PrevGlobalID, QuotedContent: utils.B2S(content), }) s.Unlock() if err == nil { guildmsg.QuotedInfoID = id } } content, err := yaml.Marshal(&msg) if err != nil { return errors.Wrap(err, "insert marshal Content error") } guildmsg.Content = utils.B2S(content) s.Lock() err = s.db.Insert(Sqlite3GuildChannelMessageTableName, guildmsg) s.Unlock() if err != nil { return errors.Wrap(err, "insert error") } return nil } ================================================ FILE: docker-entrypoint.sh ================================================ #!/bin/sh USER=abc echo "---Setup Timezone to ${TZ}---" echo "${TZ}" > /etc/timezone echo "---Checking if UID: ${UID} matches user---" usermod -o -u ${UID} ${USER} echo "---Checking if GID: ${GID} matches user---" groupmod -o -g ${GID} ${USER} > /dev/null 2>&1 ||: usermod -g ${GID} ${USER} echo "---Setting umask to ${UMASK}---" umask ${UMASK} echo "---Taking ownership of data...---" chown -R ${UID}:${GID} /app /data chmod +x /app/cqhttp echo "Starting..." su-exec ${USER} /app/cqhttp "$@" ================================================ FILE: docs/EventFilter.md ================================================ # 事件过滤器 在配置文件填写对应通信方式的 `middlewares.filter` 即可开启事件过滤器,启动时会读取该文件中定义的过滤规则(使用 JSON 编写),若文件不存在,或过滤规则语法错误,则不会启用事件过滤器。 事件过滤器会处理所有事件(包括心跳事件在内的元事件),请谨慎使用!! 注意: 与客户端建立连接的握手事件**不会**经过事件过滤器 > 注意, 最新文档已经移动到 [go-cqhttp-docs](https://github.com/ishkong/go-cqhttp-docs), 当前文档只做兼容性保留, 所以内容可能有不足. ## 示例 这节首先给出一些示例,演示过滤器的基本用法,下一节将给出具体语法说明。 ### 过滤所有事件 ```json { ".not": {} } ``` ### 只上报以「!!」开头的消息 ```json { "raw_message": { ".regex": "^!!" } } ``` ### 只上报群组的非匿名消息 ```json { "message_type": "group", "anonymous": { ".eq": null } } ``` ### 只上报私聊或特定群组的非匿名消息 ```json { ".or": [ { "message_type": "private" }, { "message_type": "group", "group_id": { ".in": [ 123456 ] }, "anonymous": { ".eq": null } } ] } ``` ### 只上报群组 11111、22222、33333 中不是用户 12345 发送的消息,以及用户 66666 发送的所有消息 ```json { ".or": [ { "group_id": { ".in": [11111, 22222, 33333] }, "user_id": { ".neq": 12345 } }, { "user_id": 66666 } ] } ``` ### 一个更复杂的例子 ```json { ".or": [ { "message_type": "private", "user_id": { ".not": { ".in": [11111, 22222, 33333] }, ".neq": 44444 } }, { "message_type": { ".regex": "group|discuss" }, ".or": [ { "group_id": 12345 }, { "raw_message": { ".contains": "通知" } } ] } ] } ``` ## 进阶指南 1. 对于嵌套的值,可以使用 `.` 进行简化,如 ```json { "sender": { "sex": "male" } } ``` 与下面的配置文件作用相同 ```json { "sender.sex": "male" } ``` 2. 对于数组,可以使用数字索引,如 ```json { "message.0.type": "text" } ``` 更多进阶语法请参考[GJSON语法](https://github.com/tidwall/gjson/blob/master/SYNTAX.md) ## 语法说明 过滤规则最外层是一个 JSON 对象,其中的键,如果以 `.`(点号)开头,则表示运算符,其值为运算符的参数,如果不以 `.` 开头,则表示对事件数据对象中相应键的过滤。过滤规则中任何一个对象,只有在它的所有项都匹配的情况下,才会让事件通过(等价于一个 `and` 运算);其中,不以 `.` 开头的键,若其值不是对象,则只有在这个值和事件数据相应值相等的情况下,才会通过(等价于一个 `eq` 运算符)。 下面列出所有运算符(「要求的参数类型」是指运算符的键所对应的值的类型,「可作用于的类型」是指在过滤时事件对象相应值的类型): | 运算符 | 要求的参数类型 | 可作用于的类型 | | ----------- | -------------------------- | ----------------------------------------------------- | | `.not` | object | 任何 | | `.and` | object | 若参数中全为运算符,则任何;若不全为运算符,则 object | | `.or` | array(数组元素为 object) | 任何 | | `.eq` | 任何 | 任何 | | `.neq` | 任何 | 任何 | | `.in` | string/array | 若参数为 string,则 string;若参数为 array,则任何 | | `.contains` | string | string | | `.regex` | string | string | ## 过滤时的事件数据对象 过滤器在go-cqhttp构建好事件数据后运行,各事件的数据字段见[OneBot标准]( https://github.com/botuniverse/onebot-11/blob/master/event/README.md )。 这里有几点需要注意: - `message` 字段在运行过滤器时和上报信息类型相同(见 [消息格式]( https://github.com/botuniverse/onebot-11/blob/master/message/array.md )) - `raw_message` 字段为未经**CQ码**处理的原始消息字符串,这意味着其中可能会出现形如 `[CQ:face,id=123]` 的 CQ 码 ================================================ FILE: docs/QA.md ================================================ # 常见问题 > 注意, 最新文档已经移动到 [go-cqhttp-docs](https://github.com/ishkong/go-cqhttp-docs), 当前文档只做兼容性保留, 所以内容可能有不足. ### Q: 为什么挂一段时间后就会出现 `消息发送失败,账号可能被风控`? ### A: 如果你刚开始使用 go-cqhttp 建议挂机3-7天,即可解除风控 ================================================ FILE: docs/README.md ================================================ # 文档 > 文档目前依旧保留以便往前兼容 \ 下面的文档更易读以及人性化, 强烈建议您查看下面提供的文档 目前文档已移动到位于 [go-cqhttp-docs](https://github.com/ishkong/go-cqhttp-docs) 的仓库 您可以在以下其中任意一个链接查看: - - ================================================ FILE: docs/adminApi.md ================================================ # 管理 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字符串 | ================================================ FILE: docs/config.md ================================================ # 配置 > 注意, 最新文档已经移动到 [go-cqhttp-docs](https://github.com/ishkong/go-cqhttp-docs), 当前文档只做兼容性保留, 所以内容可能有不足. go-cqhttp 包含 `config.yml` 和 `device.json` 两个配置文件, 其中 `config.yml` 为运行配置 `device.json` 为虚拟设备信息. ## 配置信息 go-cqhttp 的配置文件采用 YAML , 在使用之前希望你能了解 YAML 的语法([教程](https://www.runoob.com/w3cnote/yaml-intro.html)) 默认生成的配置文件如下所示: ````yaml # go-cqhttp 默认配置文件 account: # 账号相关 uin: 1233456 # QQ账号 password: '' # 密码为空时使用扫码登录 encrypt: false # 是否开启密码加密 status: 0 # 在线状态 请参考 https://docs.go-cqhttp.org/guide/config.html#在线状态 relogin: # 重连设置 delay: 3 # 首次重连延迟, 单位秒 interval: 3 # 重连间隔 max-times: 0 # 最大重连次数, 0为无限制 # 是否使用服务器下发的新地址进行重连 # 注意, 此设置可能导致在海外服务器上连接情况更差 use-sso-address: true heartbeat: # 心跳频率, 单位秒 # -1 为关闭心跳 interval: 5 message: # 上报数据类型 # 可选: string,array post-format: string # 是否忽略无效的CQ码, 如果为假将原样发送 ignore-invalid-cqcode: false # 是否强制分片发送消息 # 分片发送将会带来更快的速度 # 但是兼容性会有些问题 force-fragment: false # 是否将url分片发送 fix-url: false # 下载图片等请求网络代理 proxy-rewrite: '' # 是否上报自身消息 report-self-message: false # 移除服务端的Reply附带的At remove-reply-at: false # 为Reply附加更多信息 extra-reply-data: false # 跳过 Mime 扫描, 忽略错误数据 skip-mime-scan: false output: # 日志等级 trace,debug,info,warn,error log-level: warn # 日志时效 单位天. 超过这个时间之前的日志将会被自动删除. 设置为 0 表示永久保留. log-aging: 15 # 是否在每次启动时强制创建全新的文件储存日志. 为 false 的情况下将会在上次启动时创建的日志文件续写 log-force-new: true # 是否启用 DEBUG debug: false # 开启调试模式 # 默认中间件锚点 default-middlewares: &default # 访问密钥, 强烈推荐在公网的服务器设置 access-token: '' # 事件过滤器文件目录 filter: '' # 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: 1 # 令牌桶大小 # 连接服务列表 servers: # HTTP 通信设置 - http: # 服务端监听地址 # 如需指定监听ipv4, 可使用 `address: tcp4://0.0.0.0:5700` (ipv6同理) address: 0.0.0.0:5700 # 反向HTTP超时时间, 单位秒 # 最小值为5,小于5将会忽略本项设置 timeout: 5 middlewares: <<: *default # 引用默认中间件 # 反向HTTP POST地址列表 post: #- url: '' # 地址 # secret: '' # 密钥 #- url: 127.0.0.1:5701 # 地址 # secret: '' # 密钥 # 正向WS设置 - ws: # 正向WS服务器监听地址 # 如需指定监听ipv4, 可使用 `address: tcp4://0.0.0.0:6700` (ipv6同理) address: 0.0.0.0:6700 middlewares: <<: *default # 引用默认中间件 - ws-reverse: # 反向WS Universal 地址 # 注意 设置了此项地址后下面两项将会被忽略 universal: ws://your_websocket_universal.server # 反向WS API 地址 api: ws://your_websocket_api.server # 反向WS Event 地址 event: ws://your_websocket_event.server # 重连间隔 单位毫秒 reconnect-interval: 3000 middlewares: <<: *default # 引用默认中间件 # pprof 性能分析服务器, 一般情况下不需要启用. # 如果遇到性能问题请上传报告给开发者处理 # 注意: pprof服务不支持中间件、不支持鉴权. 请不要开放到公网 - pprof: # pprof服务器监听地址 host: 127.0.0.1 # pprof服务器监听端口 port: 7700 # LambdaServer 配置 - lambda: type: scf # 可用 scf,aws (aws未经过测试) middlewares: <<: *default # 引用默认中间件 # 可添加更多 #- ws-reverse: #- ws: #- http: database: # 数据库相关设置 leveldb: # 是否启用内置leveldb数据库 # 启用将会增加10-20MB的内存占用和一定的磁盘空间 # 关闭将无法使用 撤回 回复 get_msg 等上下文相关功能 enable: true ```` > 注1: 开启密码加密后程序将在每次启动时要求输入解密密钥, 密钥错误会导致登录时提示密码错误. > 解密后密码的哈希将储存在内存中,用于自动重连等功能. 所以此加密并不能防止内存读取. > 解密密钥在使用完成后并不会留存在内存中, 所以可用相对简单的字符串作为密钥 > 注2: 对于不需要的通信方式,你可以使用注释将其停用(推荐),或者添加配置 `disabled: true` 将其关闭 > 注3: 分片发送为原酷Q发送长消息的老方案, 发送速度更优/兼容性更好,但在有发言频率限制的群里,可能无法发送。关闭后将优先使用新方案, 能发送更长的消息, 但发送速度更慢,在部分老客户端将无法解析. > 注4:关闭心跳服务可能引起断线,请谨慎关闭 > 注5:关于MIME扫描, 详见[MIME](file.md#MIME) ### 环境变量 go-cqhttp 配置文件可以使用占位符来读取**环境变量**的值。 ```yaml account: # 账号相关 uin: ${CQ_UIN} # 读取环境变量 CQ_UIN password: ${CQ_PWD:123456} # 当 CQ_PWD 为空时使用默认值 123456 ``` ## 在线状态 | 状态 | 值 | | -----|----| | 在线 | 0 | | 离开 | 1 | | 隐身 | 2 | | 忙 | 3 | | 听歌中 | 4 | | 星座运势 | 5 | | 今日天气 | 6 | | 遇见春天 | 7 | | Timi中 | 8 | | 吃鸡中 | 9 | | 恋爱中 | 10 | | 汪汪汪 | 11 | | 干饭中 | 12 | | 学习中 | 13 | | 熬夜中 | 14 | | 打球中 | 15 | | 信号弱 | 16 | | 在线学习 | 17 | | 游戏中 | 18 | | 度假中 | 19 | | 追剧中 | 20 | | 健身中 | 21 | ## 设备信息 默认生成的设备信息如下所示: ``` 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 | 无 | | 4 | 企点 | 只能登录企点账号或企点子账号 | > 注意, 根据协议的不同, 各类消息有所限制 ## 自定义服务器IP > 某些海外服务器使用默认地址可能会存在链路问题,此功能可以指定 go-cqhttp 连接哪些地址以达到最优化. 将文件 `address.txt` 创建到 `go-cqhttp` 工作目录, 并键入 `IP:PORT` 以换行符为分割即可. 示例: ```` 1.1.1.1:53 1.1.2.2:8899 ```` ## 云函数部署 使用CustomRuntime进行部署, bootstrap 文件在 `scripts/bootstrap` 中已给出。 在部署前,请在本地完成登录,并将 `config.yml` , `device.json` ,`bootstrap` 和 `go-cqhttp` 一起打包。 在触发器中创建一个API网关触发器,并启用集成响应,创建完成后即可通过api网关访问go-cqhttp(建议配置 AccessToken)。 > scripts/bootstrap 中使用的工作路径为 /tmp, 这个目录最大能容下500M文件, 如需长期使用, > 请挂载文件存储(CFS). ================================================ FILE: docs/cqhttp.md ================================================ # 拓展API 由于部分 api 原版 CQHTTP 并未实现,go-cqhttp 修改并增加了一些拓展 api > 注意, 最新文档已经移动到 [go-cqhttp-docs](https://github.com/ishkong/go-cqhttp-docs), 当前文档只做兼容性保留, 所以内容可能有不足..
目录

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

## CQCode ### 图片 Type : `image` 范围: **发送/接收** 参数: | 参数名 | 可能的值 | 说明 | | ------- | --------------- | ---------------------------------------------------------------------- | | `file` | - | 图片文件名 | | `type` | `flash`,`show` | 图片类型,`flash` 表示闪照,`show` 表示秀图,默认普通图片 | | `subType`| - | 图片子类型, 只出现在群聊. | | `url` | - | 图片 URL | | `cache` | `0` `1` | 只在通过网络 URL 发送时有效,表示是否使用已缓存的文件,默认 `1` | | `id` | - | 发送秀图时的特效id,默认为40000 | | `c` | `2` `3` | 通过网络下载图片时的线程数, 默认单线程. (在资源不支持并发时会自动处理) | 可用的特效ID: | id | 类型 | | ----- | ---- | | 40000 | 普通 | | 40001 | 幻影 | | 40002 | 抖动 | | 40003 | 生日 | | 40004 | 爱你 | | 40005 | 征友 | 子类型列表: | value | 说明 | | ----- | ---- | | 0 | 正常图片 | | 1 | 表情包, 在客户端会被分类到表情包图片并缩放显示 | | 2 | 热图 | | 3 | 斗图 | | 4 | 智图? | | 7 | 贴图 | | 8 | 自拍 | | 9 | 贴图广告? | | 10 | 有待测试 | | 13 | 热搜图 | 示例: `[CQ:image,file=http://baidu.com/1.jpg,type=show,id=40004]` > 注意:图片总大小不能超过30MB,gif总帧数不能超过300帧 ### 回复 Type : `reply` 范围: **发送/接收** > 注意: 如果id存在则优先处理id 参数: | 参数名 | 类型 | 说明 | | ------ | ------ | --------------------------------------------------- | | `id` | int | 回复时所引用的消息id, 必须为本群消息. | | `text` | string | 自定义回复的信息 | | `qq` | int64 | 自定义回复时的自定义QQ, 如果使用自定义信息必须指定. | | `time` | int64 | 可选. 自定义回复时的时间, 格式为Unix时间 | | `seq` | int64 | 起始消息序号, 可通过 `get_msg` 获得 | 示例: `[CQ:reply,id=123456]` \ 自定义回复示例: `[CQ:reply,text=Hello World,qq=10086,time=3376656000,seq=5123]` ### 音乐分享 ```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]` ### 合并转发 Type: `forward` 范围: **接收** 参数: | 参数名 | 类型 | 说明 | | ------ | ------ | ------------------------------------------------------------- | | `id` | string | 合并转发ID, 需要通过 `/get_forward_msg` API获取转发的具体内容 | 示例: `[CQ:forward,id=xxxx]` ### 合并转发消息节点 Type: `node` 范围: **发送** 参数: | 参数名 | 类型 | 说明 | 特殊说明 | | --------- | ------- | -------------- | -------------------------------------------------------------------------------------- | | `id` | int32 | 转发消息id | 直接引用他人的消息合并转发, 实际查看顺序为原消息发送顺序 **与下面的自定义消息二选一** | | `name` | string | 发送者显示名字 | 用于自定义消息 (自定义消息并合并转发,实际查看顺序为自定义消息段顺序) | | `uin` | int64 | 发送者QQ号 | 用于自定义消息 | | `content` | message | 具体消息 | 用于自定义消息 | | `seq` | message | 具体消息 | 用于自定义消息 | 特殊说明: **需要使用单独的API `/send_group_forward_msg` 发送,并且由于消息段较为复杂,仅支持Array形式入参。 如果引用消息和自定义消息同时出现,实际查看顺序将取消息段顺序. 另外按 [Onebot v11](https://github.com/botuniverse/onebot-11/blob/master/message/array.md) 文档说明, `data` 应全为字符串, 但由于需要接收`message` 类型的消息, 所以 *仅限此Type的content字段* 支持Array套娃** 示例: 直接引用消息合并转发: ````json [ { "type": "node", "data": { "id": "123" } }, { "type": "node", "data": { "id": "456" } } ] ```` 自定义消息合并转发: ````json [ { "type": "node", "data": { "name": "消息发送者A", "uin": "10086", "content": [ { "type": "text", "data": { "text": "测试消息1" } } ] } }, { "type": "node", "data": { "name": "消息发送者B", "uin": "10087", "content": "[CQ:image,file=xxxxx]测试消息2" } } ] ```` 引用自定义混合合并转发: ````json [ { "type": "node", "data": { "name": "自定义发送者", "uin": "10086", "content": "我是自定义消息", "seq": "5123", "time": "3376656000" } }, { "type": "node", "data": { "id": "123" } } ] ```` ### 短视频消息 Type: `video` 范围: **发送/接收** 参数: | 参数名 | 类型 | 说明 | | ------- | ------- | ---------------------------------------------------------------------- | | `file` | string | 支持http和file发送 | | `cover` | string | 视频封面,支持http,file和base64发送,格式必须为jpg | | `c` | `2` `3` | 通过网络下载视频时的线程数, 默认单线程. (在资源不支持并发时会自动处理) | 示例: `[CQ:video,file=file:///C:\\Users\Richard\Videos\1.mp4]` ### XML 消息 Type: `xml` 范围: **发送/接收** 参数: | 参数名 | 类型 | 说明 | | ------- | ------ | ----------------------------------------- | | `data` | string | xml内容,xml中的value部分,记得实体化处理 | | `resid` | int32 | 可以不填 | 示例: `[CQ:xml,data=xxxx]` #### 一些xml样例 #### ps:重要:xml中的value部分,记得html实体化处理后,再打加入到cq码中 #### qq音乐 ```xml ``` #### 网易音乐 ```xml ``` #### 卡片消息1 ```xml 生死8秒!女司机高速急刹,他一个操作救下一车性命 ``` #### 卡片消息2 ```xml test title ``` ### JSON 消息 Type: `json` 范围: **发送/接收** 参数: | 参数名 | 类型 | 说明 | | ------- | ------ | ----------------------------------------------- | | `data` | string | json内容,json的所有字符串记得实体化处理 | | `resid` | int32 | 默认不填为0,走小程序通道,填了走富文本通道发送 | json中的字符串需要进行转义: > ","=> `,` > "&"=> `&` > "["=> `[` > "]"=> `]` 否则无法正确得到解析 示例json 的cq码: ```test [CQ:json,data={"app":"com.tencent.miniapp","desc":"","view":"notification","ver":"0.0.0.1","prompt":"[应用]","appID":"","sourceName":"","actionData":"","actionData_A":"","sourceUrl":"","meta":{"notification":{"appInfo":{"appName":"全国疫情数据统计","appType":4,"appid":1109659848,"iconUrl":"http:\/\/gchat.qpic.cn\/gchatpic_new\/719328335\/-2010394141-6383A777BEB79B70B31CE250142D740F\/0"},"data":[{"title":"确诊","value":"80932"},{"title":"今日确诊","value":"28"},{"title":"疑似","value":"72"},{"title":"今日疑似","value":"5"},{"title":"治愈","value":"60197"},{"title":"今日治愈","value":"1513"},{"title":"死亡","value":"3140"},{"title":"今**亡","value":"17"}],"title":"中国加油,武汉加油","button":[{"name":"病毒:SARS-CoV-2,其导致疾病命名 COVID-19","action":""},{"name":"传染源:新冠肺炎的患者。无症状感染者也可能成为传染源。","action":""}],"emphasis_keyword":""}},"text":"","sourceAd":""}] ``` ### cardimage 一种xml的图片消息(装逼大图) ps: xml 接口的消息都存在风控风险,请自行兼容发送失败后的处理(可以失败后走普通图片模式) 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,可以留空 | 示例cardimage 的cq码: ```test [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=这是一条测试消息]` ### 猜拳消息 Type: `rps` 参数: | 参数名 | 类型 | 说明 | |---------|-----|------------------| | `value` | int | 0:石头, 1:剪刀, 2:布 | 示例: `[CQ:rps,value=0]` ## API ### 设置群名 终结点: `/set_group_name` **参数** | 字段 | 类型 | 说明 | | ------------ | ------ | ---- | | `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失效而失效,请考虑后使用 ### 获取图片信息 终结点: `/get_image` > 该接口为 CQHTTP 接口修改 参数 | 字段 | 类型 | 说明 | | ------ | ------ | -------------- | | `file` | string | 图片缓存文件名 | 响应数据 | 字段 | 类型 | 说明 | | ---------- | ------ | -------------- | | `size` | int32 | 图片源文件大小 | | `filename` | string | 图片文件原名 | | `url` | string | 图片下载地址 | ### 获取消息 终结点: `/get_msg` 参数 | 字段 | 类型 | 说明 | | ------------ | ----- | ------ | | `message_id` | int32 | 消息id | 响应数据 | 字段 | 类型 | 说明 | | ------------ | ------- | ---------- | | `message_id` | int32 | 消息id | | `real_id` | int32 | 消息真实id | | `sender` | object | 发送者 | | `time` | int32 | 发送时间 | | `message` | message | 消息内容 | ### 获取合并转发内容 终结点: `/get_forward_msg` 参数 | 字段 | 类型 | 说明 | | ------------ | ------ | ------ | | `message_id` | string | 消息id | 响应数据 | 字段 | 类型 | 说明 | | ---------- | ----------------- | -------- | | `messages` | forward message[] | 消息列表 | 响应示例 ````json5 { "data": { "messages": [ { "content": "合并转发1", "sender": { "nickname": "发送者A", "user_id": 10086 }, "time": 1595694374 }, { "content": "合并转发2[CQ:image,file=xxxx,url=xxxx]", "sender": { "nickname": "发送者B", "user_id": 10087 }, "time": 1595694393 // 可选 } ] }, "retcode": 0, "status": "ok" } ```` ### 发送合并转发(群/私聊) 终结点: `/send_group_forward_msg`, `send_private_forward_msg`, `send_forward_msg` **参数** | 字段 | 类型 | 说明 | |------------|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------| | `group_id` | int64 | 群号 | | `user_id` | int64 | 私聊QQ号 | | `messages` | forward node[] | 自定义转发消息, 具体看 [CQCode](https://github.com/Mrs4s/go-cqhttp/blob/master/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) | 响应数据 | 字段 | 类型 | 说明 | | ------------ | ------ | ------ | | `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下载 ### 上传私聊文件 终结点: `/upload_private_file` **参数** | 字段 | 类型 | 说明 | |-----------|--------|--------| | `user_id` | int64 | 接收者id | | `file` | string | 本地文件路径 | | `name` | string | 储存名称 | > 只能上传本地文件, 需要上传 `http` 文件的话请先调用 `download_file` API下载 ### 设置 QQ 个人资料 终结点: `/set_qq_profile` **参数** | 字段 | 类型 | 说明 | |-----------------|--------|------| | `nickname` | int64 | 昵称 | | `company` | string | 公司 | | `email` | string | 邮箱 | | `college` | string | 大学 | | `personal_note` | string | 个人签名 | > 所有参数字段都为可选。 ### 获取状态 终结点: `/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 没有响应数据` ### 获取群公告 终结点: `/_get_group_notice` **参数** | 字段名 | 数据类型 | 默认值 | 说明 | | ---------- | -------- | ------ | -------- | | `group_id` | int64 | | 群号 | **响应数据** 数组信息: | 字段名 | 数据类型 | 默认值 | 说明 | |----------------|--------| ------ |-------| | `notice_id` | string | | 公告id | | `sender_id` | string | | 发布者id | | `publish_time` | string | | 发布时间 | | `message` | GroupNoticeMessage | | 公告id | 响应示例 ```json { "data": [ { "notice_id": "8850de2e00000000cc6bbd628a150c00", "sender_id": 1111111, "publish_time": 1656581068, "message": { "text": "这是一条公告", "images": [] } } ], "retcode": 0, "status": "ok" } ``` ### 删除群公告 终结点: `/_del_group_notice` **参数** | 字段名 | 数据类型 | 默认值 | 说明 | |-------------| -------- | ------ |------| | `group_id` | int64 | | 群号 | | `notice_id` | string | | 公告id | `该 API 没有响应数据` ### 获取单向好友列表 终结点: `/get_unidirectional_friend_list` **响应数据** 数组信息: | 字段 | 类型 | 说明 | | ------------- | ------ | -------- | | `nickname` | string | 昵称 | | `user_id` | int64 | 用户QQ号 | | `source` | string | 添加途径 | > 添加途径为用户显示内容, 如 `精确查找` `QQ群 - xxxx` ### 删除单向好友 终结点: `/delete_unidirectional_friend` **参数** | 字段名 | 数据类型 | 默认值 | 说明 | | ---------- | -------- | ------ | -------- | | `user_id` | int64 | | 好友ID | `该 API 没有响应数据` ### 删除好友 终结点: `/delete_friend` **参数** | 字段名 | 数据类型 | 默认值 | 说明 | | ---------- | -------- | ------ | -------- | | `user_id` | int64 | | 好友ID | `该 API 没有响应数据` ### 获取企点账号信息 > 该API只有企点协议可用 终结点: `/qidian_get_account_info` **响应数据** | 字段 | 类型 | 说明 | | ------------------ | ------- | ------------ | | `master_id` | int64 | 父账号ID | | `ext_name` | string | 用户昵称 | | `create_time` | int64 | 账号创建时间 | ### 标记消息已读 终结点: `/mark_msg_as_read` **参数** | 字段名 | 数据类型 | 默认值 | 说明 | | ---------- | -------- | ------ | -------- | | `message_id` | int32 | | 消息ID | ### 重载事件过滤器 终结点:`/reload_event_filter` `该 API 无需参数也没有响应数据` ## 事件 ### 群消息撤回 **上报数据** | 字段 | 类型 | 可能的值 | 说明 | | ------------- | ------ | -------------- | -------------- | | `post_type` | string | `notice` | 上报类型 | | `notice_type` | string | `group_recall` | 消息类型 | | `group_id` | int64 | | 群号 | | `user_id` | int64 | | 消息发送者id | | `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 | `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` | string | | 新名片 | | `card_old` | string | | 旧名片 | > PS: 当名片为空时 `card_xx` 字段为空字符串, 并不是昵称 ### 群成员头衔更新事件 **上报数据** | 字段 | 类型 | 可能的值 | 说明 | | ------------- | ------ | ------------ | -------- | | `post_type` | string | `notice` | 上报类型 | | `notice_type` | string | `notify` | 消息类型 | | `group_id` | int64 | | 群号 | | `user_id` | int64 | | 成员id | | `title` | string | | 新头衔 | ### 接收到离线文件 **上报数据** | 字段 | 类型 | 可能的值 | 说明 | | ------------- | ------ | -------------- | -------- | | `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 | ================================================ FILE: docs/file.md ================================================ # 文件 go-cqhttp 默认生成的文件树如下所示: ``` . ├── go-cqhttp ├── config.yml ├── device.json ├── logs │ └── xx-xx-xx.log └── data ├── images │ └── xxxx.image └── levleldb ``` | 文件 | 用途 | | ------------ | -------------------- | | go-cqhttp | go-cqhttp 可执行文件 | | config.yml | 运行配置文件 | | device.json | 虚拟设备配置文件 | | logs | 日志存放目录 | | data | 数据目录 | | data/leveldb | 数据库目录 | | data/images | 图片缓存目录 | | data/voices | 语音缓存目录 | | data/videos | 视频缓存目录 | | data/cache | 发送图片缓存目录 | ## 图片缓存文件 出于性能考虑,go-cqhttp 并不会将图片源文件下载到本地,而是生成一个可以和 QQ 服务器对应的缓存文件 (.image),该缓存文件结构如下: | 偏移 | 类型 | 说明 | | --------------- | -------- | -------------------- | | 0x00 | [16]byte | 图片源文件 MD5 HASH | | 0x10 | uint32 | 图片源文件大小 | | 0x14 | string | 图片原名(QQ内部ID) | | 0x14 + 原名长度 | string | 图片下载链接 | # MIME 启用MINE检查可以及时发现媒体资源格式错误引起的上传失败(通常表现为,请求网页图片,但服务端返回404.html) 在配置文件中设置 `skip-mine-scan: false`后 ,go-cqhttp 会在上传媒体资源(视频暂不支持)前对MIME进行检查, 详细允许类型如下所示: 图片: > image/bmp > image/gif > image/jpeg > image/png > image/webp 语音: > audio/aac > audio/aiff > audio/amr > audio/ape > audio/flac > audio/midi > audio/mp4 > audio/mpeg > audio/ogg > audio/wav > audio/x-m4a ================================================ FILE: docs/guild.md ================================================ # 频道相关API > 注意: QQ频道功能目前还在测试阶段, go-cqhttp 也在适配的初期阶段, 以下 `API` `Event` 的字段名可能存在错误并均有可能在后续版本修改/添加/删除. > 目前仅供开发者测试以及适配使用 QQ频道相关功能的事件以及API > 注意, 最新文档已经移动到 [go-cqhttp-docs](https://github.com/ishkong/go-cqhttp-docs), 当前文档只做兼容性保留, 所以内容可能有不足. ## 命名说明 API以及字段相关命名均为参考QQ官方命名或相似产品命名规则, 由于QQ频道的账号系统独立于QQ本体, 所以各个 `ID` 并不能和QQ通用.也无法通过 `tiny_id` 获取到 `QQ号` 下表为常见字段命名说明 | 命名 | 说明 | | ------------ | -------------------- | | `tiny_id` | 在频道系统中代表用户ID, 与QQ号并不通用 | | `guild_id` | 频道ID | | `channel_id` | 子频道ID | > 所有频道相关事件的 `user_id` 均为 `tiny_id` ## 特殊说明 - 由于频道的限制, 目前无法通过图片摘要查询到频道图片消息的详细信息, 所以通过频道消息收到的图片均会下载完整文件到 `images/guild-images`. (群图片转发不受此限制) - 由于无法通过 `GlobalID` 放下频道消息的ID, 所以所有频道消息的 `message_id` 均为 `string` 类型 - `send_msg` API将无法发送频道消息 - `get_msg` API暂时无法获取频道消息 - `reply` 等消息类型暂不支持解析 - `at` 消息的 `target` 依然使用 `qq` 字段, 以保证一致性. 但内容为 `tiny_id` - 所有事件的 `self_id` 均为 BOT 的QQ号. `tiny_id` 将放在 `self_tiny_id` 字段 - 遵循我们一贯的原则, 将不会支持主动加频道/主动拉人/红包相关消息类型 - 频道相关的API仅能在 `Android Phone` 和 `iPad` 协议上使用. - 由于频道相关ID的数据类型均为 `uint64` , 为保证不超过某些语言的安全值范围, 在 `v1.0.0-beta8-fix3` 以后, 所有ID相关数据将转换为 `string` 类型, API调用 `uint64` 或 `string` 均可接受. - 为保证一致性, 所有频道接口返回的 `用户ID` 均命名为 `tiny_id`, 所有频道相关接口的 `用户ID` 入参均命名为 `user_id` ## API ### 获取频道系统内BOT的资料 终结点: `/get_guild_service_profile` **响应数据** | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `nickname` | string | 昵称 | | `tiny_id` | string | 自身的ID | | `avatar_url` | string | 头像链接 | ### 获取频道列表 终结点: `/get_guild_list` **响应数据** 正常情况下响应 `GuildInfo` 数组, 未加入任何频道响应 `null` GuildInfo: | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `guild_id` | string | 频道ID | | `guild_name` | string | 频道名称 | | `guild_display_id` | int64 | 频道显示ID, 公测后可能作为搜索ID使用 | ### 通过访客获取频道元数据 终结点: `/get_guild_meta_by_guest` **参数** | 字段 | 类型 | 说明 | | ---------- | ----- | ---- | | `guild_id` | string | 频道ID | **响应数据** | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `guild_id` | string | 频道ID | | `guild_name` | string | 频道名称 | | `guild_profile` | string | 频道简介 | | `create_time` | int64 | 创建时间 | | `max_member_count` | int64 | 频道人数上限 | | `max_robot_count` | int64 | 频道BOT数上限 | | `max_admin_count` | int64 | 频道管理员人数上限 | | `member_count` | int64 | 已加入人数 | | `owner_id` | string | 创建者ID | ### 获取子频道列表 终结点: `/get_guild_channel_list` **参数** | 字段 | 类型 | 说明 | | ---------- | ----- | ---- | | `guild_id` | string | 频道ID | | `no_cache` | bool | 是否无视缓存 | **响应数据** 正常情况下响应 `ChannelInfo` 数组, 未找到任何子频道响应 `null` ChannelInfo: | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `owner_guild_id` | string | 所属频道ID | | `channel_id` | string | 子频道ID | | `channel_type` | int32 | 子频道类型 | | `channel_name` | string | 子频道名称 | | `create_time` | int64 | 创建时间 | | `creator_tiny_id` | string | 创建者ID | | `talk_permission` | int32 | 发言权限类型 | | `visible_type` | int32 | 可视性类型 | | `current_slow_mode` | int32 | 当前启用的慢速模式Key | | `slow_modes` | []SlowModeInfo | 频道内可用慢速模式类型列表| SlowModeInfo: | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `slow_mode_key` | int32 | 慢速模式Key | | `slow_mode_text` | string | 慢速模式说明 | | `speak_frequency` | int32 | 周期内发言频率限制 | | `slow_mode_circle` | int32 | 单位周期时间, 单位秒 | 已知子频道类型列表 | 类型 | 说明 | | ------------- | ---------- | | 1 | 文字频道 | | 2 | 语音频道 | | 5 | 直播频道 | | 7 | 主题频道 | ### 获取频道成员列表 终结点: `/get_guild_member_list` > 由于频道人数较多(数万), 请尽量不要全量拉取成员列表, 这将会导致严重的性能问题 > > 尽量使用 `get_guild_member_profile` 接口代替全量拉取 **参数** | 字段 | 类型 | 说明 | | ---------- | ----- | ---- | | `guild_id` | string | 频道ID | | `next_token` | string | 翻页Token | > `next_token` 为空的情况下, 将返回第一页的数据, 并在返回值附带下一页的 `token` **响应数据** | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `members` | []GuildMemberInfo | 成员列表 | | `finished` | bool | 是否最终页 | | `next_token` | string | 翻页Token | GuildMemberInfo: | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `tiny_id` | string | 成员ID | | `title` | string | 成员头衔 | | `nickname` | string | 成员昵称 | | `role_id` | string | 所在权限组ID | | `role_name` | string | 所在权限组名称 | > 默认情况下频道管理员的权限组ID为 `2`, 部分频道可能会另行创建, 需手动判断 > > 此接口仅展现最新的权限组, 获取用户加入的所有权限组请使用 `get_guild_member_profile` 接口 ### 单独获取频道成员信息 终结点: `/get_guild_member_profile` **参数** | 字段 | 类型 | 说明 | | ---------- | ----- | ---- | | `guild_id` | string | 频道ID | | `user_id` | string | 用户ID | **响应数据** | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `tiny_id` | string | 用户ID | | `nickname` | string | 用户昵称 | | `avatar_url` | string | 头像地址 | | `join_time` | int64 | 加入时间 | | `roles` | []RoleInfo | 加入的所有权限组 | RoleInfo: | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `role_id` | string | 权限组ID | | `role_name` | string | 权限组名称 | ### 发送信息到子频道 终结点: `/send_guild_channel_msg` **参数** | 字段 | 类型 | 说明 | | ---------- | ----- | ---- | | `guild_id` | string | 频道ID | | `channel_id` | string | 子频道ID | | `message` | Message | 消息, 与原有消息类型相同 | **响应数据** | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `message_id` | string | 消息ID | ### 获取话题频道帖子 终结点: `/get_topic_channel_feeds` **参数** | 字段 | 类型 | 说明 | | ---------- | ----- | ---- | | `guild_id` | string | 频道ID | | `channel_id` | string | 子频道ID | **响应数据** 返回 `FeedInfo` 数组 FeedInfo: | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `id` | string | 帖子ID | | `channel_id` | string | 子频道ID | | `guild_id` | string | 频道ID | | `create_time` | int64 | 发帖时间 | | `title` | string | 帖子标题 | | `sub_title` | string | 帖子副标题 | | `poster_info` | PosterInfo | 发帖人信息 | | `resource` | ResourceInfo | 媒体资源信息 | | `resource.images` | []FeedMedia | 帖子附带的图片列表 | | `resource.videos` | []FeedMedia | 帖子附带的视频列表 | | `contents` | []FeedContent | 帖子内容 | PosterInfo: | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `tiny_id` | string | 发帖人ID | | `nickname` | string | 发帖人昵称 | | `icon_url` | string | 发帖人头像链接 | FeedMedia: | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `file_id` | string | 媒体ID | | `pattern_id` | string | 控件ID?(不确定) | | `url` | string | 媒体链接 | | `height` | int32 | 媒体高度 | | `width` | int32 | 媒体宽度 | FeedContent: | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `type` | string | 内容类型 | | `data` | Data | 内容数据 | #### 内容类型列表: | 类型 | 说明 | | ----- | ---------- | | `text` | 文本 | | `face` | 表情 | | `at` | At | | `url_quote` | 链接引用 | | `channel_quote` | 子频道引用 | #### 内容类型对应数据列表: - `text` | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `text` | string | 文本内容 | - `face` | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `id` | string | 表情ID | - `at` | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `id` | string | 目标ID | | `qq` | string | 目标ID, 为确保和 `array message` 的一致性保留 | - `url_quote` | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `display_text` | string | 显示文本 | | `url` | string | 链接 | - `channel_quote` | 字段 | 类型 | 说明 | | ------------- | ----- | ---------- | | `display_text` | string | 显示文本 | | `guild_id` | string | 频道ID | | `channel_id` | string | 子频道ID | ## 事件 ### 收到频道消息 **上报数据** | 字段 | 类型 | 可能的值 | 说明 | | ------------- | ------ | -------------- | -------------- | | `post_type` | string | `message` | 上报类型 | | `message_type` | string | `guild` | 消息类型 | | `sub_type` | string | `channel` | 消息子类型 | | `guild_id` | string | | 频道ID | | `channel_id` | string | | 子频道ID | | `user_id` | string | | 消息发送者ID | | `message_id` | string | | 消息ID | | `sender` | Sender | | 发送者 | | `message` | Message | | 消息内容 | > 注: 此处的 `Sender` 对象为保证一致性, `user_id` 为 `uint64` 类型, 并添加了 `string` 类型的 `tiny_id` 字段 ### 频道消息表情贴更新 **上报数据** | 字段 | 类型 | 可能的值 | 说明 | | ------------- | ------ | -------------- | -------------- | | `post_type` | string | `notice` | 上报类型 | | `notice_type` | string | `message_reactions_updated` | 消息类型 | | `guild_id` | string | | 频道ID | | `channel_id` | string | | 子频道ID | | `user_id` | string | | 操作者ID | | `message_id` | string | | 消息ID | | `current_reactions` | []ReactionInfo | | 当前消息被贴表情列表 | ReactionInfo: | 字段 | 类型 | 说明 | | ---------- | ----- | ---- | | `emoji_id` | string | 表情ID | | `emoji_index` | int32 | 表情对应数值ID | | `emoji_type` | int32 | 表情类型 | | `emoji_name` | string | 表情名字 | | `count` | int32 | 当前表情被贴数量 | | `clicked` | bool | BOT是否点击 | ### 子频道信息更新 **上报数据** | 字段 | 类型 | 可能的值 | 说明 | | ------------- | ------ | -------------- | -------------- | | `post_type` | string | `notice` | 上报类型 | | `notice_type` | string | `channel_updated` | 消息类型 | | `guild_id` | string | | 频道ID | | `channel_id` | string | | 子频道ID | | `user_id` | string | | 操作者ID | | `operator_id` | string | | 操作者ID | | `old_info` | ChannelInfo | | 更新前的频道信息 | | `new_info` | ChannelInfo | | 更新后的频道信息 | ### 子频道创建 **上报数据** | 字段 | 类型 | 可能的值 | 说明 | | ------------- | ------ | -------------- | -------------- | | `post_type` | string | `notice` | 上报类型 | | `notice_type` | string | `channel_created` | 消息类型 | | `guild_id` | string | | 频道ID | | `channel_id` | string | | 子频道ID | | `user_id` | string | | 操作者ID | | `operator_id` | string | | 操作者ID | | `channel_info` | ChannelInfo | | 频道信息 | ### 子频道删除 **上报数据** | 字段 | 类型 | 可能的值 | 说明 | | ------------- | ------ | -------------- | -------------- | | `post_type` | string | `notice` | 上报类型 | | `notice_type` | string | `channel_destroyed` | 消息类型 | | `guild_id` | string | | 频道ID | | `channel_id` | string | | 子频道ID | | `user_id` | string | | 操作者ID | | `operator_id` | string | | 操作者ID | | `channel_info` | ChannelInfo | | 频道信息 | ================================================ FILE: docs/quick_start.md ================================================ # 开始 欢迎来到 go-cqhttp 文档 目前还在咕 > 注意, 最新文档已经移动到 [go-cqhttp-docs](https://github.com/ishkong/go-cqhttp-docs), 当前文档只做兼容性保留, 所以内容可能有不足. # 基础教程 ## 下载 从[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`即可 ================================================ FILE: docs/slider.md ================================================ # 滑块验证码 > 该文档已过期, 最新版本下可直接使用手机扫描二维码通过验证. 由于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) ================================================ FILE: global/all_test.go ================================================ package global import ( "strconv" "testing" "github.com/stretchr/testify/assert" ) func TestVersionNameCompare(t *testing.T) { tests := [...]struct { current string remote string expected bool }{ // Normal Tests: {"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}, {"v1.0.0-alpha", "v1.0.0-alpha2", true}, {"v1.0.0-alpha2", "v1.0.0-beta1", true}, {"v1.0.0", "v1.0.0-beta1", false}, {"v1.0.0-alpha", "v1.0.0", true}, {"v1.0.0", "v1.0.0", false}, {"v1.0.0-alpha", "v1.0.0-rc1", true}, // Issue Fixes: {"v1.0.0-beta1", "v0.9.40-fix5", false}, // issue #877 } for i := 0; i < len(tests); i++ { t.Run("test case "+strconv.Itoa(i), func(t *testing.T) { assert.Equal(t, tests[i].expected, VersionNameCompare(tests[i].current, tests[i].remote)) }) } } ================================================ FILE: global/buffer.go ================================================ package global import ( "bytes" "github.com/Mrs4s/MiraiGo/binary" // 和 MiraiGo 共用同一 buffer 池 ) // NewBuffer 从池中获取新 bytes.Buffer func NewBuffer() *bytes.Buffer { return (*bytes.Buffer)(binary.SelectWriter()) } // PutBuffer 将 Buffer放入池中 func PutBuffer(buf *bytes.Buffer) { binary.PutWriter((*binary.Writer)(buf)) } ================================================ FILE: global/codec.go ================================================ package global import ( "crypto/md5" "encoding/hex" "os" "os/exec" "path" "github.com/pkg/errors" "github.com/Mrs4s/go-cqhttp/internal/base" ) // EncoderSilk 将音频编码为Silk func EncoderSilk(data []byte) ([]byte, error) { h := md5.New() _, err := h.Write(data) if err != nil { return nil, errors.Wrap(err, "calc md5 failed") } tempName := hex.EncodeToString(h.Sum(nil)) if silkPath := path.Join("data/cache", tempName+".silk"); PathExists(silkPath) { return os.ReadFile(silkPath) } slk, err := base.EncodeSilk(data, tempName) 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) if errors.Is(cmd1.Err, exec.ErrDot) { cmd1.Err = nil } err := cmd1.Run() if err != nil { cmd2 := exec.Command("ffmpeg", "-i", src, "-y", "-c:v", "h264", "-c:a", "mp3", dst) if errors.Is(cmd2.Err, exec.ErrDot) { cmd2.Err = nil } 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", "-ss", "0", "-frames:v", "1", target) if errors.Is(cmd.Err, exec.ErrDot) { cmd.Err = nil } return errors.Wrap(cmd.Run(), "extract video cover failed") } ================================================ FILE: global/doc.go ================================================ // Package global 包含文件下载,视频音频编码,本地文件缓存处理,消息过滤器,调用速率限制,gocq主配置等的相关函数与结构体 package global ================================================ FILE: global/fs.go ================================================ package global import ( "bytes" "crypto/md5" "encoding/hex" "errors" "net/netip" "net/url" "os" "path" "runtime" "strings" "github.com/Mrs4s/MiraiGo/utils" b14 "github.com/fumiama/go-base16384" "github.com/segmentio/asm/base64" log "github.com/sirupsen/logrus" "github.com/Mrs4s/go-cqhttp/internal/download" ) const ( // ImagePath go-cqhttp使用的图片缓存目录 ImagePath = "data/images" // VoicePath go-cqhttp使用的语音缓存目录 VoicePath = "data/voices" // VideoPath go-cqhttp使用的视频缓存目录 VideoPath = "data/videos" // VersionsPath go-cqhttp使用的版本信息目录 VersionsPath = "data/versions" // CachePath go-cqhttp使用的缓存目录 CachePath = "data/cache" // DumpsPath go-cqhttp使用错误转储目录 DumpsPath = "dumps" // HeaderAmr AMR文件头 HeaderAmr = "#!AMR" // HeaderSilk Silkv3文件头 HeaderSilk = "\x02#!SILK_V3" ) // PathExists 判断给定path是否存在 func PathExists(path string) bool { _, err := os.Stat(path) return err == nil || errors.Is(err, os.ErrExist) } // ReadAllText 读取给定path对应文件,无法读取时返回空值 func ReadAllText(path string) string { b, err := os.ReadFile(path) if err != nil { log.Error(err) return "" } return string(b) } // WriteAllText 将给定text写入给定path func WriteAllText(path, text string) error { return os.WriteFile(path, utils.S2B(text), 0o644) } // Check 检测err是否为nil func Check(err error, deleteSession bool) { if err != nil { if deleteSession && PathExists("session.token") { _ = os.Remove("session.token") } log.Fatalf("遇到错误: %v", err) } } // IsAMRorSILK 判断给定文件是否为Amr或Silk格式 func IsAMRorSILK(b []byte) bool { return bytes.HasPrefix(b, []byte(HeaderAmr)) || bytes.HasPrefix(b, []byte(HeaderSilk)) } // FindFile 从给定的File寻找文件,并返回文件byte数组。File是一个合法的URL。p为文件寻找位置。 // 对于HTTP/HTTPS形式的URL,Cache为"1"或空时表示启用缓存 func FindFile(file, cache, p string) (data []byte, err error) { data, err = nil, os.ErrNotExist switch { case strings.HasPrefix(file, "http"): // https also has prefix http hash := md5.Sum([]byte(file)) cacheFile := path.Join(CachePath, hex.EncodeToString(hash[:])+".cache") if (cache == "" || cache == "1") && PathExists(cacheFile) { return os.ReadFile(cacheFile) } err = download.Request{URL: file}.WriteToFile(cacheFile) if err != nil { return nil, err } return os.ReadFile(cacheFile) case strings.HasPrefix(file, "base64"): data, err = base64.StdEncoding.DecodeString(strings.TrimPrefix(file, "base64://")) if err != nil { return nil, err } case strings.HasPrefix(file, "base16384"): data, err = b14.UTF82UTF16BE(utils.S2B(strings.TrimPrefix(file, "base16384://"))) if err != nil { return nil, err } data = b14.Decode(data) case 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 = os.ReadFile(fu.Path) if err != nil { return nil, err } case PathExists(path.Join(p, file)): data, err = os.ReadFile(path.Join(p, 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) []netip.AddrPort { d, err := os.ReadFile(path) if err != nil { return nil } str := string(d) lines := strings.Split(str, "\n") var ret []netip.AddrPort for _, l := range lines { addr, err := netip.ParseAddrPort(l) if err == nil { ret = append(ret, addr) } } return ret } ================================================ FILE: global/log_hook.go ================================================ package global import ( "fmt" "io" "os" "path/filepath" "reflect" "strings" "sync" "github.com/mattn/go-colorable" "github.com/sirupsen/logrus" ) // LocalHook logrus本地钩子 type LocalHook struct { lock *sync.Mutex levels []logrus.Level // hook级别 formatter logrus.Formatter // 格式 path string // 写入path writer io.Writer // io } // Levels 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, 0o666) 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 } // Fire ref: logrus/hooks.go impl Hook interface 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 } // SetFormatter 设置日志格式 func (hook *LocalHook) SetFormatter(consoleFormatter, fileFormatter logrus.Formatter) { hook.lock.Lock() defer hook.lock.Unlock() // 支持处理windows平台的console色彩 logrus.SetOutput(colorable.NewColorableStdout()) // 用于在console写出 logrus.SetFormatter(consoleFormatter) // 用于写入文件 hook.formatter = fileFormatter } // SetWriter 设置Writer func (hook *LocalHook) SetWriter(writer io.Writer) { hook.lock.Lock() defer hook.lock.Unlock() hook.writer = writer } // SetPath 设置日志写入路径 func (hook *LocalHook) SetPath(path string) { hook.lock.Lock() defer hook.lock.Unlock() hook.path = path } // NewLocalHook 初始化本地日志钩子实现 func NewLocalHook(args any, consoleFormatter, fileFormatter logrus.Formatter, levels ...logrus.Level) *LocalHook { hook := &LocalHook{ lock: new(sync.Mutex), } hook.SetFormatter(consoleFormatter, fileFormatter) 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 } // GetLogLevel 获取日志等级 // // 可能的值有 // // "trace","debug","info","warn","warn","error" 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, } } } // LogFormat specialize for go-cqhttp type LogFormat struct { EnableColor bool } // Format implements logrus.Formatter func (f LogFormat) Format(entry *logrus.Entry) ([]byte, error) { buf := NewBuffer() defer PutBuffer(buf) if f.EnableColor { buf.WriteString(GetLogLevelColorCode(entry.Level)) } buf.WriteByte('[') buf.WriteString(entry.Time.Format("2006-01-02 15:04:05")) buf.WriteString("] [") buf.WriteString(strings.ToUpper(entry.Level.String())) buf.WriteString("]: ") buf.WriteString(entry.Message) buf.WriteString(" \n") if f.EnableColor { buf.WriteString(colorReset) } ret := make([]byte, len(buf.Bytes())) copy(ret, buf.Bytes()) // copy buffer return ret, nil } const ( colorCodePanic = "\x1b[1;31m" // color.Style{color.Bold, color.Red}.String() colorCodeFatal = "\x1b[1;31m" // color.Style{color.Bold, color.Red}.String() colorCodeError = "\x1b[31m" // color.Style{color.Red}.String() colorCodeWarn = "\x1b[33m" // color.Style{color.Yellow}.String() colorCodeInfo = "\x1b[37m" // color.Style{color.White}.String() colorCodeDebug = "\x1b[32m" // color.Style{color.Green}.String() colorCodeTrace = "\x1b[36m" // color.Style{color.Cyan}.String() colorReset = "\x1b[0m" ) // GetLogLevelColorCode 获取日志等级对应色彩code func GetLogLevelColorCode(level logrus.Level) string { switch level { case logrus.PanicLevel: return colorCodePanic case logrus.FatalLevel: return colorCodeFatal case logrus.ErrorLevel: return colorCodeError case logrus.WarnLevel: return colorCodeWarn case logrus.InfoLevel: return colorCodeInfo case logrus.DebugLevel: return colorCodeDebug case logrus.TraceLevel: return colorCodeTrace default: return colorCodeInfo } } ================================================ FILE: global/net.go ================================================ package global import ( "fmt" "github.com/tidwall/gjson" "github.com/Mrs4s/go-cqhttp/internal/download" ) // QQMusicSongInfo 通过给定id在QQ音乐上查找曲目信息 func QQMusicSongInfo(id string) (gjson.Result, error) { d, err := download.Request{URL: `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}}`}.JSON() if err != nil { return gjson.Result{}, err } return d.Get("songinfo.data"), nil } // NeteaseMusicSongInfo 通过给定id在wdd音乐上查找曲目信息 func NeteaseMusicSongInfo(id string) (gjson.Result, error) { d, err := download.Request{URL: fmt.Sprintf("http://music.163.com/api/song/detail/?id=%s&ids=%%5B%s%%5D", id, id)}.JSON() if err != nil { return gjson.Result{}, err } return d.Get("songs.0"), nil } ================================================ FILE: global/param.go ================================================ package global import ( "regexp" "strconv" log "github.com/sirupsen/logrus" ) // MSG 消息Map type MSG = map[string]any // 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 // // v1.0.0-alpha2 < v1.0.0-beta1 -> true // // v1.0.0 > v1.0.0-beta1 -> false func VersionNameCompare(current, remote string) bool { defer func() { // 应该不会panic, 为了保险还是加个 if err := recover(); err != nil { log.Warn("检查更新失败!") } }() sp := regexp.MustCompile(`v(\d+)\.(\d+)\.(\d+)-?(.+)?`) cur := sp.FindStringSubmatch(current) re := sp.FindStringSubmatch(remote) for i := 1; i <= 3; i++ { curSub, _ := strconv.Atoi(cur[i]) reSub, _ := strconv.Atoi(re[i]) if curSub != reSub { return curSub < reSub } } if cur[4] == "" || re[4] == "" { return re[4] == "" && cur[4] != re[4] } return cur[4] < re[4] } ================================================ FILE: global/signal.go ================================================ package global import ( "fmt" "os" "path/filepath" "runtime" "sync" "time" log "github.com/sirupsen/logrus" ) var ( mainStopCh chan struct{} mainOnce sync.Once dumpMutex sync.Mutex ) func dumpStack() { dumpMutex.Lock() defer dumpMutex.Unlock() log.Info("开始 dump 当前 goroutine stack 信息") buf := make([]byte, 1024) for { n := runtime.Stack(buf, true) if n < len(buf) { buf = buf[:n] break } buf = make([]byte, 2*len(buf)) } fileName := fmt.Sprintf("%s.%d.stacks.%d.log", filepath.Base(os.Args[0]), os.Getpid(), time.Now().Unix()) fd, err := os.Create(fileName) if err != nil { log.Errorf("保存 stackdump 到文件时出现错误: %v", err) log.Warnf("无法保存 stackdump. 将直接打印\n %s", buf) return } defer fd.Close() _, err = fd.Write(buf) if err != nil { log.Errorf("写入 stackdump 失败: %v", err) log.Warnf("无法保存 stackdump. 将直接打印\n %s", buf) return } log.Infof("stackdump 已保存至 %s", fileName) } ================================================ FILE: global/signal_unix.go ================================================ //go:build !windows // +build !windows package global import ( "os" "os/signal" "sync" "syscall" ) // SetupMainSignalHandler is for main to use at last func SetupMainSignalHandler() <-chan struct{} { mainOnce.Do(func() { mainStopCh = make(chan struct{}) mc := make(chan os.Signal, 4) closeOnce := sync.Once{} signal.Notify(mc, os.Interrupt, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGUSR1) go func() { for { switch <-mc { case os.Interrupt, syscall.SIGTERM: closeOnce.Do(func() { close(mainStopCh) }) case syscall.SIGQUIT, syscall.SIGUSR1: dumpStack() } } }() }) return mainStopCh } ================================================ FILE: global/signal_windows.go ================================================ //go:build windows // +build windows package global import ( "errors" "fmt" "net" "os" "os/signal" "strings" "sync" "syscall" "time" "github.com/Microsoft/go-winio" log "github.com/sirupsen/logrus" ) var validTasks = map[string]func(){ "dumpstack": dumpStack, } // SetupMainSignalHandler is for main to use at last func SetupMainSignalHandler() <-chan struct{} { mainOnce.Do(func() { // for stack trace collecting on windows pipeName := fmt.Sprintf(`\\.\pipe\go-cqhttp-%d`, os.Getpid()) pipe, err := winio.ListenPipe(pipeName, &winio.PipeConfig{}) if err != nil { log.Errorf("创建 named pipe 失败. 将无法使用 dumpstack 功能: %v", err) } else { maxTaskLen := 0 for t := range validTasks { if l := len(t); l > maxTaskLen { maxTaskLen = l } } go func() { for { c, err := pipe.Accept() if err != nil { if errors.Is(err, net.ErrClosed) || strings.Contains(err.Error(), "closed") { return } log.Errorf("accept named pipe 失败: %v", err) continue } go func() { defer c.Close() _ = c.SetReadDeadline(time.Now().Add(5 * time.Second)) buf := make([]byte, maxTaskLen) n, err := c.Read(buf) if err != nil { log.Errorf("读取 named pipe 失败: %v", err) return } cmd := string(buf[:n]) if task, ok := validTasks[cmd]; ok { task() return } log.Warnf("named pipe 读取到未知指令: %q", cmd) }() } }() } // setup the main stop channel mainStopCh = make(chan struct{}) mc := make(chan os.Signal, 2) closeOnce := sync.Once{} signal.Notify(mc, os.Interrupt, syscall.SIGTERM) go func() { for { switch <-mc { case os.Interrupt, syscall.SIGTERM: closeOnce.Do(func() { close(mainStopCh) if pipe != nil { _ = pipe.Close() } }) } } }() }) return mainStopCh } ================================================ FILE: global/terminal/doc.go ================================================ // Package terminal 包含用于检测在windows下是否通过双击运行go-cqhttp, 禁用快速编辑, 启用VT100的函数 package terminal ================================================ FILE: global/terminal/double_click.go ================================================ //go:build !windows package terminal // RunningByDoubleClick 检查是否通过双击直接运行,非Windows系统永远返回false func RunningByDoubleClick() bool { return false } // NoMoreDoubleClick 提示用户不要双击运行,非Windows系统永远返回nil func NoMoreDoubleClick() error { return nil } ================================================ FILE: global/terminal/double_click_windows.go ================================================ package terminal import ( "os" "path/filepath" "unsafe" "golang.org/x/sys/windows" "github.com/pkg/errors" ) // RunningByDoubleClick 检查是否通过双击直接运行 func RunningByDoubleClick() bool { kernel32 := windows.NewLazySystemDLL("kernel32.dll") lp := kernel32.NewProc("GetConsoleProcessList") if lp != nil { var ids [2]uint32 var maxCount uint32 = 2 ret, _, _ := lp.Call(uintptr(unsafe.Pointer(&ids)), uintptr(maxCount)) if ret > 1 { return false } } return true } // NoMoreDoubleClick 提示用户不要双击运行,并生成安全启动脚本 func NoMoreDoubleClick() error { toHighDPI() r := boxW(getConsoleWindows(), "请勿通过双击直接运行本程序, 这将导致一些非预料的后果.\n请在shell中运行./go-cqhttp.exe\n点击确认将释出安全启动脚本,点击取消则关闭程序", "警告", 0x00000030|0x00000001) if r == 2 { return nil } r = boxW(0, "点击确认将覆盖go-cqhttp.bat,点击取消则关闭程序", "警告", 0x00000030|0x00000001) if r == 2 { return nil } f, err := os.OpenFile("go-cqhttp.bat", os.O_CREATE|os.O_RDWR, 0o666) if err != nil { return err } if err != nil { return errors.Errorf("打开go-cqhttp.bat失败: %v", err) } _ = f.Truncate(0) ex, _ := os.Executable() exPath := filepath.Base(ex) _, err = f.WriteString("%Created by go-cqhttp. DO NOT EDIT ME!%\nstart cmd /K \"" + exPath + "\"") if err != nil { return errors.Errorf("写入go-cqhttp.bat失败: %v", err) } f.Close() boxW(0, "安全启动脚本已生成,请双击go-cqhttp.bat启动", "提示", 0x00000040|0x00000000) return nil } // BoxW of Win32 API. Check https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messageboxw for more detail. func boxW(hwnd uintptr, caption, title string, flags uint) int { captionPtr, _ := windows.UTF16PtrFromString(caption) titlePtr, _ := windows.UTF16PtrFromString(title) u32 := windows.NewLazySystemDLL("user32.dll") ret, _, _ := u32.NewProc("MessageBoxW").Call( hwnd, uintptr(unsafe.Pointer(captionPtr)), uintptr(unsafe.Pointer(titlePtr)), uintptr(flags)) return int(ret) } // GetConsoleWindows retrieves the window handle used by the console associated with the calling process. func getConsoleWindows() (hWnd uintptr) { hWnd, _, _ = windows.NewLazySystemDLL("kernel32.dll").NewProc("GetConsoleWindow").Call() return } // toHighDPI tries to raise DPI awareness context to DPI_AWARENESS_CONTEXT_UNAWARE_GDISCALED func toHighDPI() { systemAware := ^uintptr(2) + 1 unawareGDIScaled := ^uintptr(5) + 1 u32 := windows.NewLazySystemDLL("user32.dll") proc := u32.NewProc("SetThreadDpiAwarenessContext") if proc.Find() != nil { return } for i := unawareGDIScaled; i <= systemAware; i++ { _, _, _ = u32.NewProc("SetThreadDpiAwarenessContext").Call(i) } } ================================================ FILE: global/terminal/quick_edit.go ================================================ //go:build !windows package terminal // RestoreInputMode 还原输入模式,非Windows系统永远返回nil func RestoreInputMode() error { return nil } // DisableQuickEdit 禁用快速编辑,非Windows系统永远返回nil func DisableQuickEdit() error { return nil } ================================================ FILE: global/terminal/quick_edit_windows.go ================================================ package terminal import ( "os" "golang.org/x/sys/windows" ) var inputmode uint32 // RestoreInputMode 还原输入模式 func RestoreInputMode() error { if inputmode == 0 { return nil } stdin := windows.Handle(os.Stdin.Fd()) return windows.SetConsoleMode(stdin, inputmode) } // DisableQuickEdit 禁用快速编辑 func DisableQuickEdit() error { stdin := windows.Handle(os.Stdin.Fd()) var mode uint32 err := windows.GetConsoleMode(stdin, &mode) if err != nil { return err } inputmode = mode mode &^= windows.ENABLE_QUICK_EDIT_MODE // 禁用快速编辑模式 mode |= windows.ENABLE_EXTENDED_FLAGS // 启用扩展标志 mode &^= windows.ENABLE_MOUSE_INPUT // 禁用鼠标输入 mode |= windows.ENABLE_PROCESSED_INPUT // 启用控制输入 mode &^= windows.ENABLE_INSERT_MODE // 禁用插入模式 mode |= windows.ENABLE_ECHO_INPUT | windows.ENABLE_LINE_INPUT // 启用输入回显&逐行输入 mode &^= windows.ENABLE_WINDOW_INPUT // 禁用窗口输入 mode &^= windows.ENABLE_VIRTUAL_TERMINAL_INPUT // 禁用虚拟终端输入 return windows.SetConsoleMode(stdin, mode) } ================================================ FILE: global/terminal/title.go ================================================ //go:build !windows package terminal import ( "fmt" "time" "github.com/Mrs4s/go-cqhttp/internal/base" ) // SetTitle 设置标题为 go-cqhttp `版本` `版权` func SetTitle() { fmt.Printf("\033]0;go-cqhttp "+base.Version+" © 2020 - %d Mrs4s"+"\007", time.Now().Year()) } ================================================ FILE: global/terminal/title_windows.go ================================================ package terminal import ( "fmt" "syscall" "time" "unsafe" "golang.org/x/sys/windows" "github.com/Mrs4s/go-cqhttp/internal/base" ) func setConsoleTitle(title string) error { p0, err := syscall.UTF16PtrFromString(title) if err != nil { return err } r1, _, err := windows.NewLazySystemDLL("kernel32.dll").NewProc("SetConsoleTitleW").Call(uintptr(unsafe.Pointer(p0))) if r1 == 0 { return err } return nil } // SetTitle 设置标题为 go-cqhttp `版本` `版权` func SetTitle() { _ = setConsoleTitle(fmt.Sprintf("go-cqhttp "+base.Version+" © 2020 - %d Mrs4s", time.Now().Year())) } ================================================ FILE: global/terminal/vt100.go ================================================ //go:build !windows package terminal // EnableVT100 启用颜色、控制字符,非Windows系统永远返回nil func EnableVT100() error { return nil } ================================================ FILE: global/terminal/vt100_windows.go ================================================ package terminal import ( "os" "golang.org/x/sys/windows" ) // EnableVT100 启用颜色、控制字符 func EnableVT100() error { stdout := windows.Handle(os.Stdout.Fd()) var mode uint32 err := windows.GetConsoleMode(stdout, &mode) if err != nil { return err } mode |= windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING // 启用虚拟终端处理 mode |= windows.ENABLE_PROCESSED_OUTPUT // 启用处理后的输出 return windows.SetConsoleMode(stdout, mode) } ================================================ FILE: go.mod ================================================ module github.com/Mrs4s/go-cqhttp go 1.20 require ( github.com/FloatTech/sqlite v1.6.3 github.com/Microsoft/go-winio v0.6.2-0.20230724192519-b29bbd58a65a github.com/Mrs4s/MiraiGo v0.0.0-20230823050531-a8213e127b2b github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e github.com/RomiChan/websocket v1.4.3-0.20220227141055-9b2c6168c9c5 github.com/fumiama/go-base16384 v1.7.0 github.com/fumiama/go-hide-param v0.1.4 github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible github.com/mattn/go-colorable v0.1.13 github.com/pkg/errors v0.9.1 github.com/segmentio/asm v1.2.0 github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.8.1 github.com/syndtr/goleveldb v1.0.0 github.com/tidwall/gjson v1.15.0 github.com/wdvxdr1123/go-silk v0.0.0-20210316130616-d47b553def60 go.mongodb.org/mongo-driver v1.12.0 golang.org/x/crypto v0.17.0 golang.org/x/image v0.10.0 golang.org/x/sys v0.15.0 golang.org/x/term v0.15.0 golang.org/x/time v0.3.0 gopkg.ilharper.com/x/isatty v1.1.1 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/FloatTech/ttl v0.0.0-20220715042055-15612be72f5b // indirect github.com/RomiChan/protobuf v0.1.1-0.20230204044148-2ed269a2e54d // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fumiama/imgsz v0.0.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.3.0 // indirect github.com/jonboulle/clockwork v0.3.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.13.6 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/lestrrat-go/strftime v1.0.6 // indirect github.com/mattn/go-isatty v0.0.16 // indirect github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect github.com/pierrec/lz4/v4 v4.1.15 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.1.2 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect golang.org/x/mod v0.12.0 // indirect golang.org/x/sync v0.3.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.11.0 // indirect lukechampine.com/uint128 v1.2.0 // indirect modernc.org/cc/v3 v3.40.0 // indirect modernc.org/ccgo/v3 v3.16.13 // indirect modernc.org/libc v1.21.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.4.0 // indirect modernc.org/opt v0.1.3 // indirect modernc.org/sqlite v1.20.0 // indirect modernc.org/strutil v1.1.3 // indirect modernc.org/token v1.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/FloatTech/sqlite v1.6.3 h1:MQkqBNlkPuCoKQQgoNLuTL/2Ci3tBTFAnVYBdD0Wy4M= github.com/FloatTech/sqlite v1.6.3/go.mod h1:zFbHzRfB+CJ+VidfjuVbrcin3DAz283F7hF1hIeHzpY= github.com/FloatTech/ttl v0.0.0-20220715042055-15612be72f5b h1:tvciXWq2nuvTbFeJGLDNIdRX3BI546D3O7k7vrVueZw= github.com/FloatTech/ttl v0.0.0-20220715042055-15612be72f5b/go.mod h1:fHZFWGquNXuHttu9dUYoKuNbm3dzLETnIOnm1muSfDs= github.com/Microsoft/go-winio v0.6.2-0.20230724192519-b29bbd58a65a h1:aU1703IHxupjzipvhu16qYKLMR03e+8WuNR+JMsKfGU= github.com/Microsoft/go-winio v0.6.2-0.20230724192519-b29bbd58a65a/go.mod h1:OZqLNXdYJHmx7aqq/T6wAdFEdoGm5nmIfC4kU7M8P8o= github.com/Mrs4s/MiraiGo v0.0.0-20230823050531-a8213e127b2b h1:0GG6kDFgzie0HNdlkrgPwyX4WqUjckTP1xTM4cYaC2g= github.com/Mrs4s/MiraiGo v0.0.0-20230823050531-a8213e127b2b/go.mod h1:mU3fBFU+7eO0kaGes7YRKtzIDtwIU84nSSwTV7NK2b0= github.com/RomiChan/protobuf v0.1.1-0.20230204044148-2ed269a2e54d h1:/Xuj3fIiMY2ls1TwvPKmaqQrtJsPY+c9s+0lOScVHd8= github.com/RomiChan/protobuf v0.1.1-0.20230204044148-2ed269a2e54d/go.mod h1:2Ie+hdBFQpQFDHfeklgxoFmQRCE7O+KwFpISeXq7OwA= github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e h1:wR3MXQ3VbUlPKOOUwLOYgh/QaJThBTYtsl673O3lqSA= github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e/go.mod h1:vD7Ra3Q9onRtojoY5sMCLQ7JBgjUsrXDnDKyFxqpf9w= github.com/RomiChan/websocket v1.4.3-0.20220227141055-9b2c6168c9c5 h1:bBmmB7he0iVN4m5mcehfheeRUEer/Avo4ujnxI3uCqs= github.com/RomiChan/websocket v1.4.3-0.20220227141055-9b2c6168c9c5/go.mod h1:0UcFaCkhp6vZw6l5Dpq0Dp673CoF9GdvA8lTfst0GiU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 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/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fumiama/go-base16384 v1.7.0 h1:6fep7XPQWxRlh4Hu+KsdH+6+YdUp+w6CwRXtMWSsXCA= github.com/fumiama/go-base16384 v1.7.0/go.mod h1:OEn+947GV5gsbTAnyuUW/SrfxJYUdYupSIQXOuGOcXM= github.com/fumiama/go-hide-param v0.1.4 h1:y7TRTzZMdCH9GOXnIzU3B+1BSkcmvejVGmGsz4t0DGU= github.com/fumiama/go-hide-param v0.1.4/go.mod h1:vJkQlJIEI56nIyp7tCQu1/2QOyKtZpudsnJkGk9U1aY= github.com/fumiama/imgsz v0.0.2 h1:fAkC0FnIscdKOXwAxlyw3EUba5NzxZdSxGaq3Uyfxak= github.com/fumiama/imgsz v0.0.2/go.mod h1:dR71mI3I2O5u6+PCpd47M9TZptzP+39tRBcbdIkoqM4= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc h1:RKf14vYWi2ttpEmkA4aQ3j4u9dStX2t4M8UM6qqNsG8= github.com/lestrrat-go/envload v0.0.0-20180220234015-a3eb8ddeffcc/go.mod h1:kopuH9ugFRkIXf3YoqHKyrJ9YfUFsckUU9S7B+XP+is= 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.6 h1:CFGsDEt1pOpFNU+TJB0nhz9jl+K0hZSLE205AhTIGQQ= github.com/lestrrat-go/strftime v1.0.6/go.mod h1:f7jQKgV5nnJpYgdEasS+/y7EsTb8ykN2z68n3TtcTaw= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/syndtr/goleveldb v1.0.0 h1:fBdIW9lB4Iz0n9khmH8w27SJ3QEJ7+IgjPEwGSZiFdE= github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ= github.com/tidwall/gjson v1.15.0 h1:5n/pM+v3r5ujuNl4YLZLsQ+UE5jlkLVm7jMzT5Mpolw= github.com/tidwall/gjson v1.15.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/wdvxdr1123/go-silk v0.0.0-20210316130616-d47b553def60 h1:lRKf10iIOW0VsH5WDF621ihzR+R2wEBZVtNRHuLLCb4= github.com/wdvxdr1123/go-silk v0.0.0-20210316130616-d47b553def60/go.mod h1:ecFKZPX81BaB70I6ruUgEwYcDOtuNgJGnjdK+MIl5ko= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mongodb.org/mongo-driver v1.12.0 h1:aPx33jmn/rQuJXPQLZQ8NtfPQG8CaqgLThFtqRb0PiE= go.mongodb.org/mongo-driver v1.12.0/go.mod h1:AZkxhPnFJUoH7kZlFkVKucV20K387miPfm7oimrSmK0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/image v0.10.0 h1:gXjUUtwtx5yOE0VKWq1CH4IJAClq4UGgUA3i+rpON9M= golang.org/x/image v0.10.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8= golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.ilharper.com/x/isatty v1.1.1 h1:RAg32Pxq/nIK4AVtdm9RBqxsxZZX1uRKRSS21E5SHMk= gopkg.ilharper.com/x/isatty v1.1.1/go.mod h1:ofpv77Td5qQO6R1dmDd3oNt8TZdRo+l5gYAMxopRyS0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI= lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw= modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY= modernc.org/ccorpus v1.11.6 h1:J16RXiiqiCgua6+ZvQot4yUuUy8zxgqbqEEUuGPlISk= modernc.org/httpfs v1.0.6 h1:AAgIpFZRXuYnkjftxTAZwMIiwEqAfk8aVB2/oA6nAeM= modernc.org/libc v1.8.1/go.mod h1:U1eq8YWr/Kc1RWCMFUWEdkTg8OTcfLw2kY8EDwl039w= modernc.org/libc v1.21.5 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI= modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI= modernc.org/mathutil v1.1.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= modernc.org/memory v1.0.4/go.mod h1:nV2OApxradM3/OVbs2/0OsP6nPfakXpi50C7dcoHXlc= modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk= modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/sqlite v1.20.0 h1:80zmD3BGkm8BZ5fUi/4lwJQHiO3GXgIUvZRXpoIfROY= modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw= modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY= modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw= modernc.org/tcl v1.15.0 h1:oY+JeD11qVVSgVvodMJsu7Edf8tr5E/7tuhF5cNYz34= modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/z v1.7.0 h1:xkDw/KepgEjeizO2sNco+hqYkU12taxQFqPEmgm1GWE= ================================================ FILE: internal/base/feature.go ================================================ package base import ( "github.com/pkg/errors" ) // silk encode features var ( EncodeSilk = encodeSilk // 编码 SilkV3 音频 ResampleSilk = resampleSilk // 将silk重新编码为 24000 bit rate ) func encodeSilk(_ []byte, _ string) ([]byte, error) { return nil, errors.New("not supported now") } func resampleSilk(data []byte) []byte { return data } ================================================ FILE: internal/base/flag.go ================================================ // Package base provides base config for go-cqhttp package base import ( "flag" "fmt" "os" "time" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" "github.com/Mrs4s/go-cqhttp/modules/config" ) // command flags var ( LittleC string // config file LittleD bool // daemon LittleH bool // Help LittleWD string // working directory ) // config file flags var ( Debug bool // 是否开启 debug 模式 RemoveReplyAt bool // 是否删除reply后的at ExtraReplyData bool // 是否上报额外reply信息 IgnoreInvalidCQCode bool // 是否忽略无效CQ码 SplitURL bool // 是否分割URL ForceFragmented bool // 是否启用强制分片 SkipMimeScan bool // 是否跳过Mime扫描 ConvertWebpImage bool // 是否转换Webp图片 ReportSelfMessage bool // 是否上报自身消息 UseSSOAddress bool // 是否使用服务器下发的新地址进行重连 LogForceNew bool // 是否在每次启动时强制创建全新的文件储存日志 LogColorful bool // 是否启用日志颜色 FastStart bool // 是否为快速启动 AllowTempSession bool // 是否允许发送临时会话信息 UpdateProtocol bool // 是否更新协议 SignServers []config.SignServer // 使用特定的服务器进行签名 IsBelow110 bool // 签名服务器版本是否低于1.1.0及以下 HTTPTimeout int // download 超时时间 SignServerTimeout int // 签名服务器超时时间 PostFormat string // 上报格式 string or array Proxy string // 存储 proxy_rewrite,用于设置代理 PasswordHash [16]byte // 存储QQ密码哈希供登录使用 AccountToken []byte // 存储 AccountToken 供登录使用 Account *config.Account // 账户配置 Reconnect *config.Reconnect // 重连配置 LogLevel string // 日志等级 LogAging = time.Hour * 24 * 365 // 日志时效 HeartbeatInterval = time.Second * 5 // 心跳间隔 Servers []map[string]yaml.Node // 连接服务列表 Database map[string]yaml.Node // 数据库列表 ) // Parse parse flags func Parse() { flag.StringVar(&LittleC, "c", "config.yml", "configuration filename") flag.BoolVar(&LittleD, "d", false, "running as a daemon") flag.BoolVar(&LittleH, "h", false, "this Help") flag.StringVar(&LittleWD, "w", "", "cover the working directory") d := flag.Bool("D", false, "debug mode") flag.BoolVar(&FastStart, "faststart", false, "skip waiting 5 seconds") flag.BoolVar(&UpdateProtocol, "update-protocol", false, "update protocol") flag.Parse() if *d { Debug = true } } // Init read config from yml file func Init() { conf := config.Parse(LittleC) { // bool config if conf.Output.Debug { Debug = true } IgnoreInvalidCQCode = conf.Message.IgnoreInvalidCQCode SplitURL = conf.Message.FixURL RemoveReplyAt = conf.Message.RemoveReplyAt ExtraReplyData = conf.Message.ExtraReplyData ForceFragmented = conf.Message.ForceFragment SkipMimeScan = conf.Message.SkipMimeScan ConvertWebpImage = conf.Message.ConvertWebpImage ReportSelfMessage = conf.Message.ReportSelfMessage UseSSOAddress = conf.Account.UseSSOAddress AllowTempSession = conf.Account.AllowTempSession SignServers = conf.Account.SignServers IsBelow110 = conf.Account.IsBelow110 HTTPTimeout = conf.Message.HTTPTimeout SignServerTimeout = int(conf.Account.SignServerTimeout) } { // others Proxy = conf.Message.ProxyRewrite Account = conf.Account Reconnect = conf.Account.ReLogin Servers = conf.Servers Database = conf.Database LogLevel = conf.Output.LogLevel LogColorful = conf.Output.LogColorful == nil || *conf.Output.LogColorful if conf.Message.PostFormat != "string" && conf.Message.PostFormat != "array" { log.Warnf("post-format 配置错误, 将自动使用 string") PostFormat = "string" } else { PostFormat = conf.Message.PostFormat } if conf.Output.LogAging > 0 { LogAging = time.Hour * 24 * time.Duration(conf.Output.LogAging) } if conf.Heartbeat.Interval > 0 { HeartbeatInterval = time.Second * time.Duration(conf.Heartbeat.Interval) } if conf.Heartbeat.Disabled || conf.Heartbeat.Interval < 0 { HeartbeatInterval = 0 } } } // Help cli命令行-h的帮助提示 func Help() { fmt.Printf(`go-cqhttp service version: %s Usage: server [OPTIONS] Options: `, Version) flag.PrintDefaults() os.Exit(0) } ================================================ FILE: internal/base/version.go ================================================ package base import "runtime/debug" // Version go-cqhttp的版本信息,在编译时使用ldflags进行覆盖 var Version = "unknown" func init() { if Version != "unknown" { return } info, ok := debug.ReadBuildInfo() if ok { Version = info.Main.Version } } ================================================ FILE: internal/cache/cache.go ================================================ // Package cache impl the cache for gocq package cache import ( log "github.com/sirupsen/logrus" "github.com/syndtr/goleveldb/leveldb" "github.com/syndtr/goleveldb/leveldb/opt" ) // Media Cache DBs var ( Image Cache Video Cache // todo: Voice? ) // Cache wraps the btree.DB for concurrent safe type Cache struct { ldb *leveldb.DB } // Insert 添加媒体缓存 func (c *Cache) Insert(md5, data []byte) { _ = c.ldb.Put(md5, data, nil) } // Get 获取缓存信息 func (c *Cache) Get(md5 []byte) []byte { got, _ := c.ldb.Get(md5, nil) return got } // Delete 删除指定缓存 func (c *Cache) Delete(md5 []byte) { _ = c.ldb.Delete(md5, nil) } // Init 初始化 Cache func Init() { open := func(typ, path string, cache *Cache) { ldb, err := leveldb.OpenFile(path, &opt.Options{ WriteBuffer: 4 * opt.KiB, }) if err != nil { log.Fatalf("open cache %s db failed: %v", typ, err) } cache.ldb = ldb } open("image", "data/images", &Image) open("video", "data/videos", &Video) } ================================================ FILE: internal/download/download.go ================================================ // Package download provide download utility functions package download import ( "bufio" "compress/gzip" "crypto/tls" "fmt" "io" "net/http" "net/url" "os" "strconv" "strings" "sync" "time" "github.com/RomiChan/syncx" "github.com/pkg/errors" "github.com/tidwall/gjson" "github.com/Mrs4s/go-cqhttp/internal/base" ) var client = newClient(time.Second * 15) var clients syncx.Map[time.Duration, *http.Client] var clienth2 = &http.Client{ Transport: &http.Transport{ Proxy: func(request *http.Request) (*url.URL, error) { if base.Proxy == "" { return http.ProxyFromEnvironment(request) } return url.Parse(base.Proxy) }, ForceAttemptHTTP2: true, MaxIdleConnsPerHost: 999, }, Timeout: time.Second * 15, } func newClient(t time.Duration) *http.Client { return &http.Client{ Transport: &http.Transport{ Proxy: func(request *http.Request) (*url.URL, error) { if base.Proxy == "" { return http.ProxyFromEnvironment(request) } return url.Parse(base.Proxy) }, // Disable http2 TLSNextProto: map[string]func(authority string, c *tls.Conn) http.RoundTripper{}, MaxIdleConnsPerHost: 999, }, Timeout: t, } } // ErrOverSize 响应主体过大时返回此错误 var ErrOverSize = errors.New("oversize") // UserAgent HTTP请求时使用的UA const 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" // WithTimeout get a download instance with timeout t func (r Request) WithTimeout(t time.Duration) *Request { if c, ok := clients.Load(t); ok { r.custcli = c } else { c := newClient(t) clients.Store(t, c) r.custcli = c } return &r } // SetTimeout set internal/download client timeout func SetTimeout(t time.Duration) { if t == 0 { t = time.Second * 10 } client.Timeout = t clienth2.Timeout = t } // Request is a file download request type Request struct { Method string URL string Header map[string]string Limit int64 Body io.Reader custcli *http.Client } func (r Request) client() *http.Client { if r.custcli != nil { return r.custcli } if strings.Contains(r.URL, "go-cqhttp.org") { return clienth2 } return client } func (r Request) do() (*http.Response, error) { if r.Method == "" { r.Method = http.MethodGet } req, err := http.NewRequest(r.Method, r.URL, r.Body) if err != nil { return nil, err } req.Header["User-Agent"] = []string{UserAgent} for k, v := range r.Header { req.Header.Set(k, v) } return r.client().Do(req) } func (r Request) body() (io.ReadCloser, error) { resp, err := r.do() if err != nil { return nil, err } limit := r.Limit // check file size limit if limit > 0 && resp.ContentLength > limit { _ = resp.Body.Close() return nil, ErrOverSize } if strings.Contains(resp.Header.Get("Content-Encoding"), "gzip") { return gzipReadCloser(resp.Body) } return resp.Body, err } // Bytes 对给定URL发送请求,返回响应主体 func (r Request) Bytes() ([]byte, error) { rd, err := r.body() if err != nil { return nil, err } defer rd.Close() defer r.client().CloseIdleConnections() return io.ReadAll(rd) } // JSON 发送请求, 并转换响应为JSON func (r Request) JSON() (gjson.Result, error) { rd, err := r.body() if err != nil { return gjson.Result{}, err } defer rd.Close() defer r.client().CloseIdleConnections() var sb strings.Builder _, err = io.Copy(&sb, rd) if err != nil { return gjson.Result{}, err } return gjson.Parse(sb.String()), nil } func writeToFile(reader io.ReadCloser, path string) error { file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o644) if err != nil { return err } defer func() { _ = file.Close() }() _, err = file.ReadFrom(reader) return err } // WriteToFile 下载到制定目录 func (r Request) WriteToFile(path string) error { rd, err := r.body() if err != nil { return err } defer rd.Close() defer r.client().CloseIdleConnections() return writeToFile(rd, path) } // WriteToFileMultiThreading 多线程下载到制定目录 func (r Request) WriteToFileMultiThreading(path string, thread int) error { if thread < 2 { return r.WriteToFile(path) } defer r.client().CloseIdleConnections() limit := r.Limit type BlockMetaData struct { BeginOffset int64 EndOffset int64 DownloadedSize int64 } var blocks []*BlockMetaData var contentLength int64 errUnsupportedMultiThreading := errors.New("unsupported multi-threading") // 初始化分块或直接下载 initOrDownload := func() error { header := make(map[string]string, len(r.Header)) for k, v := range r.Header { // copy headers header[k] = v } header["range"] = "bytes=0-" req := Request{ URL: r.URL, Header: header, } resp, err := req.do() 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)) } if resp.StatusCode == http.StatusOK { if limit > 0 && resp.ContentLength > limit { return ErrOverSize } if err = writeToFile(resp.Body, path); err != nil { return err } return errUnsupportedMultiThreading } if resp.StatusCode == http.StatusPartialContent { contentLength = resp.ContentLength if limit > 0 && resp.ContentLength > limit { return ErrOverSize } blockSize := contentLength if contentLength > 1024*1024 { blockSize = (contentLength / int64(thread)) - 10 } if blockSize == contentLength { return writeToFile(resp.Body, path) } 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 { file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE, 0o666) if err != nil { return err } defer file.Close() _, _ = file.Seek(block.BeginOffset, io.SeekStart) writer := bufio.NewWriter(file) defer writer.Flush() header := make(map[string]string, len(r.Header)) for k, v := range r.Header { // copy headers header[k] = v } header["range"] = fmt.Sprintf("bytes=%d-%d", block.BeginOffset, block.EndOffset) req := Request{ URL: r.URL, Header: header, } resp, err := req.do() 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)) } 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 } type gzipCloser struct { f io.Closer r *gzip.Reader } // gzipReadCloser 从 io.ReadCloser 创建 gunzip io.ReadCloser func gzipReadCloser(reader io.ReadCloser) (io.ReadCloser, error) { gzipReader, err := gzip.NewReader(reader) if err != nil { return nil, err } return &gzipCloser{ f: reader, r: gzipReader, }, nil } // Read impls io.Reader func (g *gzipCloser) Read(p []byte) (n int, err error) { return g.r.Read(p) } // Close impls io.Closer func (g *gzipCloser) Close() error { _ = g.f.Close() return g.r.Close() } ================================================ FILE: internal/mime/mime.go ================================================ // Package mime 提供MIME检查功能 package mime import ( "io" "net/http" "strings" "github.com/Mrs4s/go-cqhttp/internal/base" ) const limit = 4 * 1024 func scan(r io.ReadSeeker) string { _, _ = r.Seek(0, io.SeekStart) defer r.Seek(0, io.SeekStart) in := make([]byte, limit) _, _ = r.Read(in) return http.DetectContentType(in) } // CheckImage 判断给定流是否为合法图片 // 返回 是否合法, 实际Mime // 判断后会自动将 Stream Seek 至 0 func CheckImage(r io.ReadSeeker) (t string, ok bool) { if base.SkipMimeScan { return "", true } if r == nil { return "image/nil-stream", false } t = scan(r) switch t { case "image/bmp", "image/gif", "image/jpeg", "image/png", "image/webp": ok = true } return } // CheckAudio 判断给定流是否为合法音频 func CheckAudio(r io.ReadSeeker) (string, bool) { if base.SkipMimeScan { return "", true } t := scan(r) // std mime type detection is not full supported for audio if strings.Contains(t, "text") || strings.Contains(t, "image") { return t, false } return t, true } ================================================ FILE: internal/msg/element.go ================================================ // Package msg 提供了go-cqhttp消息中间表示,CQ码处理等等 package msg import ( "bytes" "strings" "unicode/utf8" "github.com/Mrs4s/MiraiGo/binary" ) // @@@ CQ码转义处理 @@@ // EscapeText 将字符串raw中部分字符转义 // // - & -> & // - [ -> [ // - ] -> ] func EscapeText(s string) string { count := strings.Count(s, "&") count += strings.Count(s, "[") count += strings.Count(s, "]") if count == 0 { return s } // Apply replacements to buffer. var b strings.Builder b.Grow(len(s) + count*4) start := 0 for i := 0; i < count; i++ { j := start for index, r := range s[start:] { if r == '&' || r == '[' || r == ']' { j += index break } } b.WriteString(s[start:j]) switch s[j] { case '&': b.WriteString("&") case '[': b.WriteString("[") case ']': b.WriteString("]") } start = j + 1 } b.WriteString(s[start:]) return b.String() } // EscapeValue 将字符串value中部分字符转义 // // - , -> , // - & -> & // - [ -> [ // - ] -> ] func EscapeValue(value string) string { ret := EscapeText(value) return strings.ReplaceAll(ret, ",", ",") } // UnescapeText 将字符串content中部分字符反转义 // // - & -> & // - [ -> [ // - ] -> ] func UnescapeText(content string) string { ret := content ret = strings.ReplaceAll(ret, "[", "[") ret = strings.ReplaceAll(ret, "]", "]") ret = strings.ReplaceAll(ret, "&", "&") return ret } // UnescapeValue 将字符串content中部分字符反转义 // // - , -> , // - & -> & // - [ -> [ // - ] -> ] func UnescapeValue(content string) string { ret := strings.ReplaceAll(content, ",", ",") return UnescapeText(ret) } // @@@ 消息中间表示 @@@ // Pair key value pair type Pair struct { K string V string } // Element single message type Element struct { Type string Data []Pair } // Get 获取指定值 func (e *Element) Get(k string) string { for _, datum := range e.Data { if datum.K == k { return datum.V } } return "" } // CQCode convert element to cqcode func (e *Element) CQCode() string { buf := strings.Builder{} e.WriteCQCodeTo(&buf) return buf.String() } // WriteCQCodeTo write element's cqcode into sb func (e *Element) WriteCQCodeTo(sb *strings.Builder) { if e.Type == "text" { sb.WriteString(EscapeText(e.Data[0].V)) // must be {"text": value} return } sb.WriteString("[CQ:") sb.WriteString(e.Type) for _, data := range e.Data { sb.WriteByte(',') sb.WriteString(data.K) sb.WriteByte('=') sb.WriteString(EscapeValue(data.V)) } sb.WriteByte(']') } // MarshalJSON see encoding/json.Marshaler func (e *Element) MarshalJSON() ([]byte, error) { return binary.NewWriterF(func(w *binary.Writer) { buf := (*bytes.Buffer)(w) // fmt.Fprintf(buf, `{"type":"%s","data":{`, e.Type) buf.WriteString(`{"type":"`) buf.WriteString(e.Type) buf.WriteString(`","data":{`) for i, data := range e.Data { if i != 0 { buf.WriteByte(',') } // fmt.Fprintf(buf, `"%s":%q`, data.K, data.V) buf.WriteByte('"') buf.WriteString(data.K) buf.WriteString(`":`) buf.WriteString(QuoteJSON(data.V)) } buf.WriteString(`}}`) }), nil } const hex = "0123456789abcdef" // QuoteJSON 按JSON转义为字符加上双引号 func QuoteJSON(s string) string { i, j := 0, 0 var b strings.Builder b.WriteByte('"') for j < len(s) { c := s[j] if c >= 0x20 && c <= 0x7f && c != '\\' && c != '"' { // fast path: most of the time, printable ascii characters are used j++ continue } switch c { case '\\', '"', '\n', '\r', '\t': b.WriteString(s[i:j]) b.WriteByte('\\') switch c { case '\n': c = 'n' case '\r': c = 'r' case '\t': c = 't' } b.WriteByte(c) j++ i = j continue case '<', '>', '&': b.WriteString(s[i:j]) b.WriteString(`\u00`) b.WriteByte(hex[c>>4]) b.WriteByte(hex[c&0xF]) j++ i = j continue } // This encodes bytes < 0x20 except for \t, \n and \r. if c < 0x20 { b.WriteString(s[i:j]) b.WriteString(`\u00`) b.WriteByte(hex[c>>4]) b.WriteByte(hex[c&0xF]) j++ i = j continue } r, size := utf8.DecodeRuneInString(s[j:]) if r == utf8.RuneError && size == 1 { b.WriteString(s[i:j]) b.WriteString(`\ufffd`) j += size i = j continue } switch r { case '\u2028', '\u2029': // U+2028 is LINE SEPARATOR. // U+2029 is PARAGRAPH SEPARATOR. // They are both technically valid characters in JSON strings, // but don't work in JSONP, which has to be evaluated as JavaScript, // and can lead to security holes there. It is valid JSON to // escape them, so we do so unconditionally. // See http://timelessrepo.com/json-isnt-a-javascript-subset for discussion. b.WriteString(s[i:j]) b.WriteString(`\u202`) b.WriteByte(hex[r&0xF]) j += size i = j continue } j += size } b.WriteString(s[i:]) b.WriteByte('"') return b.String() } ================================================ FILE: internal/msg/element_test.go ================================================ package msg import ( "encoding/json" "testing" ) func jsonMarshal(s string) string { b, err := json.Marshal(s) if err != nil { panic(err) } return string(b) } func TestQuoteJSON(t *testing.T) { testcase := []string{ "\u0005", // issue 1773 "\v", } for _, input := range testcase { got := QuoteJSON(input) expected := jsonMarshal(input) if got != expected { t.Errorf("want %v but got %v", expected, got) } } } ================================================ FILE: internal/msg/local.go ================================================ package msg import ( "io" "github.com/Mrs4s/MiraiGo/message" ) // Poke 拍一拍 type Poke struct { Target int64 } // Type 获取元素类型ID func (e *Poke) Type() message.ElementType { // Make message.IMessageElement Happy return message.At } // LocalImage 本地图片 type LocalImage struct { Stream io.ReadSeeker File string URL string Flash bool EffectID int32 } // Type implements the message.IMessageElement. func (e *LocalImage) Type() message.ElementType { return message.Image } // LocalVideo 本地视频 type LocalVideo struct { File string Thumb io.ReadSeeker } // Type impl message.IMessageElement func (e *LocalVideo) Type() message.ElementType { return message.Video } ================================================ FILE: internal/msg/parse.go ================================================ package msg import ( "github.com/tidwall/gjson" ) // ParseObject 将消息JSON对象转为消息元素数组 func ParseObject(m gjson.Result) (r []Element) { convert := func(e gjson.Result) { var elem Element elem.Type = e.Get("type").Str e.Get("data").ForEach(func(key, value gjson.Result) bool { pair := Pair{K: key.Str, V: value.String()} elem.Data = append(elem.Data, pair) return true }) r = append(r, elem) } if m.IsArray() { m.ForEach(func(_, e gjson.Result) bool { convert(e) return true }) } if m.IsObject() { convert(m) } return } func text(txt string) Element { return Element{ Type: "text", Data: []Pair{ { K: "text", V: txt, }, }, } } // ParseString 将字符串(CQ码)转为消息元素数组 func ParseString(raw string) (r []Element) { var elem Element for raw != "" { i := 0 for i < len(raw) && !(raw[i] == '[' && i+4 < len(raw) && raw[i:i+4] == "[CQ:") { i++ } if i > 0 { r = append(r, text(UnescapeText(raw[:i]))) } if i+4 > len(raw) { return } raw = raw[i+4:] // skip "[CQ:" i = 0 for i < len(raw) && raw[i] != ',' && raw[i] != ']' { i++ } if i+1 > len(raw) { return } elem.Type = raw[:i] elem.Data = nil // reset data raw = raw[i:] i = 0 for { if raw[0] == ']' { r = append(r, elem) raw = raw[1:] break } raw = raw[1:] for i < len(raw) && raw[i] != '=' { i++ } if i+1 > len(raw) { return } key := raw[:i] raw = raw[i+1:] // skip "=" i = 0 for i < len(raw) && raw[i] != ',' && raw[i] != ']' { i++ } if i+1 > len(raw) { return } elem.Data = append(elem.Data, Pair{ K: key, V: UnescapeValue(raw[:i]), }) raw = raw[i:] i = 0 } } return } ================================================ FILE: internal/msg/parse_test.go ================================================ package msg import ( "fmt" "strings" "testing" "github.com/Mrs4s/MiraiGo/utils" "github.com/stretchr/testify/assert" "github.com/tidwall/gjson" ) func TestParseString(_ *testing.T) { // TODO: add more text for _, v := range ParseString(`[CQ:face,id=115,text=111][CQ:face,id=217]] [CQ:text,text=123] [`) { fmt.Println(v) } } var ( bench = `asdfqwerqwerqwer[CQ:face,id=115,text=111]asdfasdfasdfasdfasdfasdfasd[CQ:face,id=217]] 123 [` benchArray = gjson.Parse(`[{"type":"text","data":{"text":"asdfqwerqwerqwer"}},{"type":"face","data":{"id":"115","text":"111"}},{"type":"text","data":{"text":"asdfasdfasdfasdfasdfasdfasd"}},{"type":"face","data":{"id":"217"}},{"type":"text","data":{"text":"] "}},{"type":"text","data":{"text":"123"}},{"type":"text","data":{"text":" ["}}]`) ) func BenchmarkParseString(b *testing.B) { for i := 0; i < b.N; i++ { ParseString(bench) } b.SetBytes(int64(len(bench))) } func BenchmarkParseObject(b *testing.B) { for i := 0; i < b.N; i++ { ParseObject(benchArray) } b.SetBytes(int64(len(benchArray.Raw))) } const bText = `123456789[]&987654321[]&987654321[]&987654321[]&987654321[]&987654321[]&` func BenchmarkCQCodeEscapeText(b *testing.B) { for i := 0; i < b.N; i++ { ret := bText EscapeText(ret) } } func TestCQCodeEscapeText(t *testing.T) { for i := 0; i < 200; i++ { rs := utils.RandomStringRange(3000, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890[]&") ret := rs ret = strings.ReplaceAll(ret, "&", "&") ret = strings.ReplaceAll(ret, "[", "[") ret = strings.ReplaceAll(ret, "]", "]") assert.Equal(t, ret, EscapeText(rs)) } } ================================================ FILE: internal/param/param.go ================================================ // Package param provide some util for param parse package param import ( "math" "regexp" "strings" "sync" "github.com/tidwall/gjson" ) // 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 any, defaultVal bool) bool { var str string if b, ok := p.(bool); ok { return b } if j, ok := p.(gjson.Result); ok { if !j.Exists() { return defaultVal } switch j.Type { // nolint: exhaustive case gjson.True: return true case gjson.False: return false case gjson.String: str = j.Str default: return defaultVal } } else if s, ok := p.(string); ok { str = s } str = strings.ToLower(str) switch str { case "true", "yes", "1": return true case "false", "no", "0": return false default: return defaultVal } } var ( // once lazy compile the reg once sync.Once // reg is splitURL regex pattern. reg *regexp.Regexp ) // SplitURL 将给定URL字符串分割为两部分,用于URL预处理防止风控 func SplitURL(s string) []string { once.Do(func() { // lazy init. 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 } ================================================ FILE: internal/selfdiagnosis/diagnoses.go ================================================ // Package selfdiagnosis 自我诊断相关 package selfdiagnosis import ( "github.com/Mrs4s/MiraiGo/client" log "github.com/sirupsen/logrus" ) // NetworkDiagnosis 诊断网络状态并输出结果 func NetworkDiagnosis(c *client.QQClient) { log.Infof("开始诊断网络情况") qualityInfo := c.ConnectionQualityTest() log.Debugf("聊天服务器连接延迟: %vms", qualityInfo.ChatServerLatency) log.Debugf("聊天服务器丢包率: %v%%", qualityInfo.ChatServerPacketLoss*10) log.Debugf("长消息服务器连接延迟: %vms", qualityInfo.LongMessageServerLatency) log.Debugf("长消息服务器响应延迟: %vms", qualityInfo.LongMessageServerResponseLatency) log.Debugf("媒体服务器连接延迟: %vms", qualityInfo.SrvServerLatency) log.Debugf("媒体服务器丢包率: %v%%", qualityInfo.SrvServerPacketLoss*10) const ( chatServerErrorMessage = "可能出现消息丢失/延迟或频繁掉线等情况, 请检查本地网络状态." longMessageServerErrorMessage = "可能导致无法接收/发送长消息的情况, 请检查本地网络状态." mediaServerErrorMessage = "可能导致无法上传/下载媒体文件, 无法上传群共享, 无法发送消息等情况, 请检查本地网络状态." ) if qualityInfo.ChatServerLatency > 1000 { if qualityInfo.ChatServerLatency == 9999 { log.Errorf("错误: 聊天服务器延迟测试失败, %v", chatServerErrorMessage) } else { log.Warnf("警告: 聊天服务器延迟为 %vms,大于 1000ms, %v", qualityInfo.ChatServerLatency, chatServerErrorMessage) } } if qualityInfo.ChatServerPacketLoss > 0 { log.Warnf("警告: 本地连接聊天服务器丢包率为 %v%%, %v", qualityInfo.ChatServerPacketLoss*10, chatServerErrorMessage) } if qualityInfo.LongMessageServerLatency > 1000 { if qualityInfo.LongMessageServerLatency == 9999 { log.Errorf("错误: 长消息服务器延迟测试失败, %v 如果您使用的腾讯云服务器, 请修改DNS到114.114.114.114", longMessageServerErrorMessage) } else { log.Warnf("警告: 长消息延迟为 %vms, 大于 1000ms, %v", qualityInfo.LongMessageServerLatency, longMessageServerErrorMessage) } } if qualityInfo.LongMessageServerResponseLatency > 2000 { if qualityInfo.LongMessageServerResponseLatency == 9999 { log.Errorf("错误: 长消息服务器响应延迟测试失败, %v 如果您使用的腾讯云服务器, 请修改DNS到114.114.114.114", longMessageServerErrorMessage) } else { log.Warnf("警告: 长消息响应延迟为 %vms, 大于 1000ms, %v", qualityInfo.LongMessageServerResponseLatency, longMessageServerErrorMessage) } } if qualityInfo.SrvServerLatency > 1000 { if qualityInfo.SrvServerPacketLoss == 9999 { log.Errorf("错误: 媒体服务器延迟测试失败, %v", mediaServerErrorMessage) } else { log.Warnf("警告: 媒体服务器延迟为 %vms,大于 1000ms, %v", qualityInfo.SrvServerLatency, mediaServerErrorMessage) } } if qualityInfo.SrvServerPacketLoss > 0 { log.Warnf("警告: 本地连接媒体服务器丢包率为 %v%%, %v", qualityInfo.SrvServerPacketLoss*10, mediaServerErrorMessage) } if qualityInfo.ChatServerLatency > 1000 || qualityInfo.ChatServerPacketLoss > 0 || qualityInfo.LongMessageServerLatency > 1000 || qualityInfo.SrvServerLatency > 1000 || qualityInfo.SrvServerPacketLoss > 0 { log.Infof("网络诊断完成. 发现问题, 请检查日志.") } else { log.Infof("网络诊断完成. 未发现问题") } } // DNSDiagnosis 诊断DNS状态并输出结果 func DNSDiagnosis() { // todo } // EnvironmentDiagnosis 诊断本地环境状态并输出结果 func EnvironmentDiagnosis() { // todo } ================================================ FILE: internal/selfupdate/update.go ================================================ // Package selfupdate 版本升级检查和自更新 package selfupdate import ( "bufio" "bytes" "encoding/hex" "fmt" "hash" "io" "math" "os" "path/filepath" "runtime" "strings" "github.com/sirupsen/logrus" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/internal/base" "github.com/Mrs4s/go-cqhttp/internal/download" ) func readLine() (str string) { console := bufio.NewReader(os.Stdin) str, _ = console.ReadString('\n') str = strings.TrimSpace(str) return } func lastVersion() (string, error) { r, err := download.Request{URL: "https://api.github.com/repos/Mrs4s/go-cqhttp/releases/latest"}.JSON() if err != nil { return "", err } return r.Get("tag_name").Str, nil } // CheckUpdate 检查更新 func CheckUpdate() { logrus.Infof("正在检查更新.") if base.Version == "(devel)" { logrus.Warnf("检查更新失败: 使用的 Actions 测试版或自编译版本.") return } latest, err := lastVersion() if err != nil { logrus.Warnf("检查更新失败: %v", err) return } if global.VersionNameCompare(base.Version, latest) { logrus.Infof("当前有更新的 go-cqhttp 可供更新, 请前往 https://github.com/Mrs4s/go-cqhttp/releases 下载.") logrus.Infof("当前版本: %v 最新版本: %v", base.Version, latest) return } logrus.Infof("检查更新完成. 当前已运行最新版本.") } func binaryName() string { goarch := runtime.GOARCH if goarch == "arm" { goarch += "v7" } ext := "tar.gz" if runtime.GOOS == "windows" { ext = "zip" } return fmt.Sprintf("go-cqhttp_%v_%v.%v", runtime.GOOS, goarch, ext) } func checksum(github, version string) []byte { sumURL := fmt.Sprintf("%v/Mrs4s/go-cqhttp/releases/download/%v/go-cqhttp_checksums.txt", github, version) sum, err := download.Request{URL: sumURL}.Bytes() if err != nil { return nil } rd := bufio.NewReader(bytes.NewReader(sum)) for { str, err := rd.ReadString('\n') if err != nil { break } str = strings.TrimSpace(str) if strings.HasSuffix(str, binaryName()) { sum, _ := hex.DecodeString(strings.TrimSuffix(str, " "+binaryName())) return sum } } return nil } func wait() { logrus.Info("按 Enter 继续....") readLine() os.Exit(0) } // SelfUpdate 自更新 func SelfUpdate(github string) { if github == "" { github = "https://github.com" } logrus.Infof("正在检查更新.") latest, err := lastVersion() if err != nil { logrus.Warnf("获取最新版本失败: %v", err) wait() } url := fmt.Sprintf("%v/Mrs4s/go-cqhttp/releases/download/%v/%v", github, latest, binaryName()) if base.Version == latest { logrus.Info("当前版本已经是最新版本!") wait() } logrus.Info("当前最新版本为 ", latest) logrus.Warn("是否更新(y/N): ") r := strings.TrimSpace(readLine()) if r != "y" && r != "Y" { logrus.Warn("已取消更新!") wait() } logrus.Info("正在更新,请稍等...") sum := checksum(github, latest) if sum != nil { err = update(url, sum) if err != nil { logrus.Error("更新失败: ", err) } else { logrus.Info("更新成功!") } } else { logrus.Error("checksum 失败!") } wait() } // writeSumCounter 写入量计算实例 type writeSumCounter struct { total uint64 hash hash.Hash } // Write 方法将写入的byte长度追加至写入的总长度Total中 func (wc *writeSumCounter) Write(p []byte) (int, error) { n := len(p) wc.total += uint64(n) wc.hash.Write(p) fmt.Printf("\r ") fmt.Printf("\rDownloading... %s complete", humanBytes(wc.total)) return n, nil } func logn(n, b float64) float64 { return math.Log(n) / math.Log(b) } func humanBytes(s uint64) string { sizes := []string{"B", "kB", "MB", "GB"} // GB对于go-cqhttp来说已经够用了 if s < 10 { return fmt.Sprintf("%d B", s) } e := math.Floor(logn(float64(s), 1000)) suffix := sizes[int(e)] val := math.Floor(float64(s)/math.Pow(1000, e)*10+0.5) / 10 f := "%.0f %s" if val < 10 { f = "%.1f %s" } return fmt.Sprintf(f, val, suffix) } // FromStream copy form getlantern/go-update func fromStream(updateWith io.Reader) (err error, errRecover error) { updatePath, err := os.Executable() updatePath = filepath.Clean(updatePath) 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, 0o755) if err != nil { return } // We won't log this error, because it's always going to happen. defer func() { _ = fp.Close() }() if _, err = bufio.NewReader(updateWith).WriteTo(fp); err != nil { logrus.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 { logrus.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 } ================================================ FILE: internal/selfupdate/update_others.go ================================================ //go:build !windows package selfupdate import ( "archive/tar" "bytes" "compress/gzip" "crypto/sha256" "errors" "io" "net/http" ) // update go-cqhttp自我更新 func update(url string, sum []byte) error { resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() wc := writeSumCounter{ hash: sha256.New(), } rsp, err := io.ReadAll(io.TeeReader(resp.Body, &wc)) if err != nil { return err } if !bytes.Equal(wc.hash.Sum(nil), sum) { return errors.New("文件已损坏") } gr, err := gzip.NewReader(bytes.NewReader(rsp)) if err != nil { return err } tr := tar.NewReader(gr) for { header, err := tr.Next() if err != nil { return err } if header.Name == "go-cqhttp" { err, _ := fromStream(tr) if err != nil { return err } return nil } } } ================================================ FILE: internal/selfupdate/update_windows.go ================================================ package selfupdate import ( "archive/zip" "bytes" "crypto/sha256" "errors" "io" "net/http" ) // update go-cqhttp自我更新 func update(url string, sum []byte) error { resp, err := http.Get(url) if err != nil { return err } defer resp.Body.Close() wc := writeSumCounter{ hash: sha256.New(), } rsp, err := io.ReadAll(io.TeeReader(resp.Body, &wc)) if err != nil { return err } if !bytes.Equal(wc.hash.Sum(nil), sum) { return errors.New("文件已损坏") } reader, _ := zip.NewReader(bytes.NewReader(rsp), resp.ContentLength) file, err := reader.Open("go-cqhttp.exe") if err != nil { return err } err, _ = fromStream(file) if err != nil { return err } return nil } ================================================ FILE: main.go ================================================ // Package main package main import ( "github.com/Mrs4s/go-cqhttp/cmd/gocq" "github.com/Mrs4s/go-cqhttp/global/terminal" _ "github.com/Mrs4s/go-cqhttp/db/leveldb" // leveldb 数据库支持 _ "github.com/Mrs4s/go-cqhttp/modules/silk" // silk编码模块 // 其他模块 // _ "github.com/Mrs4s/go-cqhttp/db/sqlite3" // sqlite3 数据库支持 // _ "github.com/Mrs4s/go-cqhttp/db/mongodb" // mongodb 数据库支持 // _ "github.com/Mrs4s/go-cqhttp/modules/pprof" // pprof 性能分析 ) func main() { terminal.SetTitle() gocq.InitBase() gocq.PrepareData() gocq.LoginInteract() _ = terminal.DisableQuickEdit() _ = terminal.EnableVT100() gocq.WaitSignal() _ = terminal.RestoreInputMode() } ================================================ FILE: modules/api/api.go ================================================ // Code generated by cmd/api-generator. DO NOT EDIT. package api import ( "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/pkg/onebot" ) func (c *Caller) call(action string, spec *onebot.Spec, p Getter) global.MSG { if spec.Version == 11 { switch action { case ".handle_quick_operation": p0 := p.Get("context") p1 := p.Get("operation") return c.bot.CQHandleQuickOperation(p0, p1) case "can_send_image": return c.bot.CQCanSendImage() case "can_send_record": return c.bot.CQCanSendRecord() case "get_login_info": return c.bot.CQGetLoginInfo() case "get_stranger_info": p0 := p.Get("user_id").Int() return c.bot.CQGetStrangerInfo(p0) case "get_version_info": return c.bot.CQGetVersionInfo() case "send_forward_msg": p0 := p.Get("group_id").Int() p1 := p.Get("user_id").Int() p2 := p.Get("messages") p3 := p.Get("message_type").String() return c.bot.CQSendForwardMessage(p0, p1, p2, p3) case "send_group_forward_msg": p0 := p.Get("group_id").Int() p1 := p.Get("messages") return c.bot.CQSendGroupForwardMessage(p0, p1) case "send_group_msg": p0 := p.Get("group_id").Int() p1 := p.Get("message") p2 := p.Get("auto_escape").Bool() return c.bot.CQSendGroupMessage(p0, p1, p2) case "send_msg": p0 := p.Get("group_id").Int() p1 := p.Get("user_id").Int() p2 := p.Get("message") p3 := p.Get("message_type").String() p4 := p.Get("auto_escape").Bool() return c.bot.CQSendMessage(p0, p1, p2, p3, p4) case "send_private_forward_msg": p0 := p.Get("user_id").Int() p1 := p.Get("messages") return c.bot.CQSendPrivateForwardMessage(p0, p1) case "send_private_msg": p0 := p.Get("user_id").Int() p1 := p.Get("group_id").Int() p2 := p.Get("message") p3 := p.Get("auto_escape").Bool() return c.bot.CQSendPrivateMessage(p0, p1, p2, p3) } } if spec.Version == 12 { switch action { case "get_self_info": return c.bot.CQGetLoginInfo() case "get_user_info": p0 := p.Get("user_id").Int() return c.bot.CQGetStrangerInfo(p0) case "get_version": return c.bot.CQGetVersion() case "send_message": p0 := p.Get("group_id").String() p1 := p.Get("user_id").String() p2 := p.Get("detail_type").String() p3 := p.Get("message") return c.bot.CQSendMessageV12(p0, p1, p2, p3) } } switch action { case ".get_word_slices": p0 := p.Get("content").String() return c.bot.CQGetWordSlices(p0) case ".ocr_image", "ocr_image": p0 := p.Get("image").String() return c.bot.CQOcrImage(p0) case "_del_group_notice": p0 := p.Get("group_id").Int() p1 := p.Get("notice_id").String() return c.bot.CQDelGroupMemo(p0, p1) case "_get_group_notice": p0 := p.Get("group_id").Int() return c.bot.CQGetGroupMemo(p0) case "_get_model_show": p0 := p.Get("model").String() return c.bot.CQGetModelShow(p0) case "_send_group_notice": p0 := p.Get("group_id").Int() p1 := p.Get("content").String() p2 := p.Get("image").String() return c.bot.CQSetGroupMemo(p0, p1, p2) case "_set_model_show": p0 := p.Get("model").String() p1 := p.Get("model_show").String() return c.bot.CQSetModelShow(p0, p1) case "check_url_safely": p0 := p.Get("url").String() return c.bot.CQCheckURLSafely(p0) case "create_group_file_folder": p0 := p.Get("group_id").Int() p1 := p.Get("parent_id").String() p2 := p.Get("name").String() return c.bot.CQGroupFileCreateFolder(p0, p1, p2) case "create_guild_role": p0 := p.Get("guild_id").Uint() p1 := p.Get("name").String() p2 := uint32(p.Get("color").Uint()) p3 := p.Get("independent").Bool() p4 := p.Get("initial_users") return c.bot.CQCreateGuildRole(p0, p1, p2, p3, p4) case "delete_essence_msg": p0 := int32(p.Get("message_id").Int()) return c.bot.CQDeleteEssenceMessage(p0) case "delete_friend": p0 := p.Get("[user_id,id].0").Int() return c.bot.CQDeleteFriend(p0) case "delete_group_file": p0 := p.Get("group_id").Int() p1 := p.Get("file_id").String() p2 := int32(p.Get("[busid,bus_id].0").Int()) return c.bot.CQGroupFileDeleteFile(p0, p1, p2) case "delete_group_folder": p0 := p.Get("group_id").Int() p1 := p.Get("folder_id").String() return c.bot.CQGroupFileDeleteFolder(p0, p1) case "delete_guild_role": p0 := p.Get("guild_id").Uint() p1 := p.Get("role_id").Uint() return c.bot.CQDeleteGuildRole(p0, p1) case "delete_msg": p0 := int32(p.Get("message_id").Int()) return c.bot.CQDeleteMessage(p0) case "delete_unidirectional_friend": p0 := p.Get("user_id").Int() return c.bot.CQDeleteUnidirectionalFriend(p0) case "download_file": p0 := p.Get("url").String() p1 := p.Get("headers") p2 := int(p.Get("thread_count").Int()) return c.bot.CQDownloadFile(p0, p1, p2) case "get_essence_msg_list": p0 := p.Get("group_id").Int() return c.bot.CQGetEssenceMessageList(p0) case "get_forward_msg": p0 := p.Get("[message_id,id].0").String() return c.bot.CQGetForwardMessage(p0) case "get_friend_list": return c.bot.CQGetFriendList(spec) case "get_group_at_all_remain": p0 := p.Get("group_id").Int() return c.bot.CQGetAtAllRemain(p0) case "get_group_file_system_info": p0 := p.Get("group_id").Int() return c.bot.CQGetGroupFileSystemInfo(p0) case "get_group_file_url": p0 := p.Get("group_id").Int() p1 := p.Get("file_id").String() p2 := int32(p.Get("[busid,bus_id].0").Int()) return c.bot.CQGetGroupFileURL(p0, p1, p2) case "get_group_files_by_folder": p0 := p.Get("group_id").Int() p1 := p.Get("folder_id").String() return c.bot.CQGetGroupFilesByFolderID(p0, p1) case "get_group_honor_info": p0 := p.Get("group_id").Int() p1 := p.Get("type").String() return c.bot.CQGetGroupHonorInfo(p0, p1) case "get_group_info": p0 := p.Get("group_id").Int() p1 := p.Get("no_cache").Bool() return c.bot.CQGetGroupInfo(p0, p1, spec) case "get_group_list": p0 := p.Get("no_cache").Bool() return c.bot.CQGetGroupList(p0, spec) case "get_group_member_info": p0 := p.Get("group_id").Int() p1 := p.Get("user_id").Int() p2 := p.Get("no_cache").Bool() return c.bot.CQGetGroupMemberInfo(p0, p1, p2) case "get_group_member_list": p0 := p.Get("group_id").Int() p1 := p.Get("no_cache").Bool() return c.bot.CQGetGroupMemberList(p0, p1) case "get_group_msg_history": p0 := p.Get("group_id").Int() p1 := p.Get("message_seq").Int() return c.bot.CQGetGroupMessageHistory(p0, p1) case "get_group_root_files": p0 := p.Get("group_id").Int() return c.bot.CQGetGroupRootFiles(p0) case "get_group_system_msg": return c.bot.CQGetGroupSystemMessages() case "get_guild_channel_list": p0 := p.Get("guild_id").Uint() p1 := p.Get("no_cache").Bool() return c.bot.CQGetGuildChannelList(p0, p1) case "get_guild_list": return c.bot.CQGetGuildList() case "get_guild_member_list": p0 := p.Get("guild_id").Uint() p1 := p.Get("next_token").String() return c.bot.CQGetGuildMembers(p0, p1) case "get_guild_member_profile": p0 := p.Get("guild_id").Uint() p1 := p.Get("user_id").Uint() return c.bot.CQGetGuildMemberProfile(p0, p1) case "get_guild_meta_by_guest": p0 := p.Get("guild_id").Uint() return c.bot.CQGetGuildMetaByGuest(p0) case "get_guild_msg": p0 := p.Get("message_id").String() p1 := p.Get("no_cache").Bool() return c.bot.CQGetGuildMessage(p0, p1) case "get_guild_roles": p0 := p.Get("guild_id").Uint() return c.bot.CQGetGuildRoles(p0) case "get_guild_service_profile": return c.bot.CQGetGuildServiceProfile() case "get_image": p0 := p.Get("file").String() return c.bot.CQGetImage(p0) case "get_msg": p0 := int32(p.Get("message_id").Int()) return c.bot.CQGetMessage(p0) case "get_online_clients": p0 := p.Get("no_cache").Bool() return c.bot.CQGetOnlineClients(p0) case "get_status": return c.bot.CQGetStatus(spec) case "get_supported_actions": return c.bot.CQGetSupportedActions(spec) case "get_topic_channel_feeds": p0 := p.Get("guild_id").Uint() p1 := p.Get("channel_id").Uint() return c.bot.CQGetTopicChannelFeeds(p0, p1) case "get_unidirectional_friend_list": return c.bot.CQGetUnidirectionalFriendList() case "mark_msg_as_read": p0 := int32(p.Get("message_id").Int()) return c.bot.CQMarkMessageAsRead(p0) case "qidian_get_account_info": return c.bot.CQGetQiDianAccountInfo() case "reload_event_filter": p0 := p.Get("file").String() return c.bot.CQReloadEventFilter(p0) case "send_group_sign": p0 := p.Get("group_id").Int() return c.bot.CQSendGroupSign(p0) case "send_guild_channel_msg": p0 := p.Get("guild_id").Uint() p1 := p.Get("channel_id").Uint() p2 := p.Get("message") p3 := p.Get("auto_escape").Bool() return c.bot.CQSendGuildChannelMessage(p0, p1, p2, p3) case "set_essence_msg": p0 := int32(p.Get("message_id").Int()) return c.bot.CQSetEssenceMessage(p0) case "set_friend_add_request": p0 := p.Get("flag").String() p1 := true if pt := p.Get("approve"); pt.Exists() { p1 = pt.Bool() } return c.bot.CQProcessFriendRequest(p0, p1) case "set_group_add_request": p0 := p.Get("flag").String() p1 := p.Get("[sub_type,type].0").String() p2 := p.Get("reason").String() p3 := true if pt := p.Get("approve"); pt.Exists() { p3 = pt.Bool() } return c.bot.CQProcessGroupRequest(p0, p1, p2, p3) case "set_group_admin": p0 := p.Get("group_id").Int() p1 := p.Get("user_id").Int() p2 := true if pt := p.Get("enable"); pt.Exists() { p2 = pt.Bool() } return c.bot.CQSetGroupAdmin(p0, p1, p2) case "set_group_anonymous": p0 := p.Get("group_id").Int() p1 := true if pt := p.Get("enable"); pt.Exists() { p1 = pt.Bool() } return c.bot.CQSetGroupAnonymous(p0, p1) case "set_group_anonymous_ban": p0 := p.Get("group_id").Int() p1 := p.Get("[anonymous_flag,anonymous.flag].0").String() p2 := int32(p.Get("duration").Int()) return c.bot.CQSetGroupAnonymousBan(p0, p1, p2) case "set_group_ban": p0 := p.Get("group_id").Int() p1 := p.Get("user_id").Int() p2 := uint32(1800) if pt := p.Get("duration"); pt.Exists() { p2 = uint32(pt.Uint()) } return c.bot.CQSetGroupBan(p0, p1, p2) case "set_group_card": p0 := p.Get("group_id").Int() p1 := p.Get("user_id").Int() p2 := p.Get("card").String() return c.bot.CQSetGroupCard(p0, p1, p2) case "set_group_kick": p0 := p.Get("group_id").Int() p1 := p.Get("user_id").Int() p2 := p.Get("message").String() p3 := p.Get("reject_add_request").Bool() return c.bot.CQSetGroupKick(p0, p1, p2, p3) case "set_group_leave": p0 := p.Get("group_id").Int() return c.bot.CQSetGroupLeave(p0) case "set_group_name": p0 := p.Get("group_id").Int() p1 := p.Get("group_name").String() return c.bot.CQSetGroupName(p0, p1) case "set_group_portrait": p0 := p.Get("group_id").Int() p1 := p.Get("file").String() p2 := p.Get("cache").String() return c.bot.CQSetGroupPortrait(p0, p1, p2) case "set_group_special_title": p0 := p.Get("group_id").Int() p1 := p.Get("user_id").Int() p2 := p.Get("special_title").String() return c.bot.CQSetGroupSpecialTitle(p0, p1, p2) case "set_group_whole_ban": p0 := p.Get("group_id").Int() p1 := true if pt := p.Get("enable"); pt.Exists() { p1 = pt.Bool() } return c.bot.CQSetGroupWholeBan(p0, p1) case "set_guild_member_role": p0 := p.Get("guild_id").Uint() p1 := p.Get("set").Bool() p2 := p.Get("role_id").Uint() p3 := p.Get("users") return c.bot.CQSetGuildMemberRole(p0, p1, p2, p3) case "set_qq_profile": p0 := p.Get("nickname") p1 := p.Get("company") p2 := p.Get("email") p3 := p.Get("college") p4 := p.Get("personal_note") return c.bot.CQSetQQProfile(p0, p1, p2, p3, p4) case "update_guild_role": p0 := p.Get("guild_id").Uint() p1 := p.Get("role_id").Uint() p2 := p.Get("name").String() p3 := uint32(p.Get("color").Uint()) p4 := p.Get("indepedent").Bool() return c.bot.CQModifyRoleInGuild(p0, p1, p2, p3, p4) case "upload_group_file": p0 := p.Get("group_id").Int() p1 := p.Get("file").String() p2 := p.Get("name").String() p3 := p.Get("folder").String() return c.bot.CQUploadGroupFile(p0, p1, p2, p3) case "upload_private_file": p0 := p.Get("user_id").Int() p1 := p.Get("file").String() p2 := p.Get("name").String() return c.bot.CQUploadPrivateFile(p0, p1, p2) } return coolq.Failed(404, "API_NOT_FOUND", "API不存在") } ================================================ FILE: modules/api/caller.go ================================================ // Package api implements the API route for servers. package api import ( "github.com/tidwall/gjson" "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/pkg/onebot" ) //go:generate go run ./../../cmd/api-generator -pkg api -path=./../../coolq/api.go,./../../coolq/api_v12.go -o api.go // Getter 参数获取 type Getter interface { Get(string) gjson.Result } // Handler 中间件 type Handler func(action string, spe *onebot.Spec, p Getter) global.MSG // Caller api route caller type Caller struct { bot *coolq.CQBot handlers []Handler } // Call specific API func (c *Caller) Call(action string, spec *onebot.Spec, p Getter) global.MSG { for _, fn := range c.handlers { if ret := fn(action, spec, p); ret != nil { return ret } } return c.call(action, spec, p) } // Use add handlers to the API caller func (c *Caller) Use(middlewares ...Handler) { c.handlers = append(c.handlers, middlewares...) } // NewCaller create a new API caller func NewCaller(bot *coolq.CQBot) *Caller { return &Caller{ bot: bot, handlers: make([]Handler, 0), } } ================================================ FILE: modules/config/config.go ================================================ // Package config 包含go-cqhttp操作配置文件的相关函数 package config import ( "bufio" _ "embed" // embed the default config file "fmt" "os" "regexp" "strings" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" ) // defaultConfig 默认配置文件 // //go:embed default_config.yml var defaultConfig string // Reconnect 重连配置 type Reconnect struct { Disabled bool `yaml:"disabled"` Delay uint `yaml:"delay"` MaxTimes uint `yaml:"max-times"` Interval int `yaml:"interval"` } // Account 账号配置 type Account struct { Uin int64 `yaml:"uin"` Password string `yaml:"password"` Encrypt bool `yaml:"encrypt"` Status int `yaml:"status"` ReLogin *Reconnect `yaml:"relogin"` UseSSOAddress bool `yaml:"use-sso-address"` AllowTempSession bool `yaml:"allow-temp-session"` SignServers []SignServer `yaml:"sign-servers"` RuleChangeSignServer int `yaml:"rule-change-sign-server"` MaxCheckCount uint `yaml:"max-check-count"` SignServerTimeout uint `yaml:"sign-server-timeout"` IsBelow110 bool `yaml:"is-below-110"` AutoRegister bool `yaml:"auto-register"` AutoRefreshToken bool `yaml:"auto-refresh-token"` RefreshInterval int64 `yaml:"refresh-interval"` } // SignServer 签名服务器 type SignServer struct { URL string `yaml:"url"` Key string `yaml:"key"` Authorization string `yaml:"authorization"` } // Config 总配置文件 type Config struct { Account *Account `yaml:"account"` Heartbeat struct { Disabled bool `yaml:"disabled"` Interval int `yaml:"interval"` } `yaml:"heartbeat"` Message struct { PostFormat string `yaml:"post-format"` ProxyRewrite string `yaml:"proxy-rewrite"` IgnoreInvalidCQCode bool `yaml:"ignore-invalid-cqcode"` ForceFragment bool `yaml:"force-fragment"` FixURL bool `yaml:"fix-url"` ReportSelfMessage bool `yaml:"report-self-message"` RemoveReplyAt bool `yaml:"remove-reply-at"` ExtraReplyData bool `yaml:"extra-reply-data"` SkipMimeScan bool `yaml:"skip-mime-scan"` ConvertWebpImage bool `yaml:"convert-webp-image"` HTTPTimeout int `yaml:"http-timeout"` } `yaml:"message"` Output struct { LogLevel string `yaml:"log-level"` LogAging int `yaml:"log-aging"` LogForceNew bool `yaml:"log-force-new"` LogColorful *bool `yaml:"log-colorful"` Debug bool `yaml:"debug"` } `yaml:"output"` Servers []map[string]yaml.Node `yaml:"servers"` Database map[string]yaml.Node `yaml:"database"` } // Server 的简介和初始配置 type Server struct { Brief string Default string } // Parse 从默认配置文件路径中获取 func Parse(path string) *Config { file, err := os.ReadFile(path) config := &Config{} if err == nil { err = yaml.NewDecoder(strings.NewReader(expand(string(file), os.Getenv))).Decode(config) if err != nil { log.Fatal("配置文件不合法!", err) } } else { generateConfig() os.Exit(0) } return config } var serverconfs []*Server // AddServer 添加该服务的简介和默认配置 func AddServer(s *Server) { serverconfs = append(serverconfs, s) } // generateConfig 生成配置文件 func generateConfig() { fmt.Println("未找到配置文件,正在为您生成配置文件中!") sb := strings.Builder{} sb.WriteString(defaultConfig) hint := "请选择你需要的通信方式:" for i, s := range serverconfs { hint += fmt.Sprintf("\n> %d: %s", i, s.Brief) } hint += ` 请输入你需要的编号(0-9),可输入多个,同一编号也可输入多个(如: 233) 您的选择是:` fmt.Print(hint) input := bufio.NewReader(os.Stdin) readString, err := input.ReadString('\n') if err != nil { log.Fatal("输入不合法: ", err) } rmax := len(serverconfs) if rmax > 10 { rmax = 10 } for _, r := range readString { r -= '0' if r >= 0 && r < rune(rmax) { sb.WriteString(serverconfs[r].Default) } } _ = os.WriteFile("config.yml", []byte(sb.String()), 0o644) fmt.Println("默认配置文件已生成,请修改 config.yml 后重新启动!") _, _ = input.ReadString('\n') } // expand 使用正则进行环境变量展开 // os.ExpandEnv 字符 $ 无法逃逸 // https://github.com/golang/go/issues/43482 func expand(s string, mapping func(string) string) string { r := regexp.MustCompile(`\${([a-zA-Z_]+[a-zA-Z0-9_:/.]*)}`) return r.ReplaceAllStringFunc(s, func(s string) string { s = strings.Trim(s, "${}") before, after, ok := strings.Cut(s, ":") m := mapping(before) if ok && m == "" { return after } return m }) } ================================================ FILE: modules/config/config_test.go ================================================ package config import ( "strings" "testing" ) func Test_expand(t *testing.T) { nullStringMapping := func(_ string) string { return "" } tests := []struct { src string mapping func(string) string expected string }{ { src: "foo: ${bar}", mapping: strings.ToUpper, expected: "foo: BAR", }, { src: "$123", mapping: strings.ToUpper, expected: "$123", }, { src: "foo: ${bar:123456}", mapping: nullStringMapping, expected: "foo: 123456", }, { src: "foo: ${bar:127.0.0.1:5700}", mapping: nullStringMapping, expected: "foo: 127.0.0.1:5700", }, { src: "foo: ${bar:ws//localhost:9999/ws}", mapping: nullStringMapping, expected: "foo: ws//localhost:9999/ws", }, } for i, tt := range tests { if got := expand(tt.src, tt.mapping); got != tt.expected { t.Errorf("testcase %d failed, expected %v but got %v", i, tt.expected, got) } } } ================================================ FILE: modules/config/default_config.yml ================================================ # go-cqhttp 默认配置文件 account: # 账号相关 uin: 1233456 # QQ账号 password: '' # 密码为空时使用扫码登录 encrypt: false # 是否开启密码加密 status: 0 # 在线状态 请参考 https://docs.go-cqhttp.org/guide/config.html#在线状态 relogin: # 重连设置 delay: 3 # 首次重连延迟, 单位秒 interval: 3 # 重连间隔 max-times: 0 # 最大重连次数, 0为无限制 # 是否使用服务器下发的新地址进行重连 # 注意, 此设置可能导致在海外服务器上连接情况更差 use-sso-address: true # 是否允许发送临时会话消息 allow-temp-session: false # 数据包的签名服务器列表,第一个作为主签名服务器,后续作为备用 # 兼容 https://github.com/fuqiuluo/unidbg-fetch-qsign # 如果遇到 登录 45 错误, 或者发送信息风控的话需要填入一个或多个服务器 # 不建议设置过多,设置主备各一个即可,超过 5 个只会取前五个 # 示例: # sign-servers: # - url: 'http://127.0.0.1:8080' # 本地签名服务器 # key: "114514" # 相应 key # authorization: "-" # authorization 内容, 依服务端设置 # - url: 'https://signserver.example.com' # 线上签名服务器 # key: "114514" # authorization: "-" # ... # # 服务器可使用docker在本地搭建或者使用他人开放的服务 sign-servers: - url: '-' # 主签名服务器地址, 必填 key: '114514' # 签名服务器所需要的apikey, 如果签名服务器的版本在1.1.0及以下则此项无效 authorization: '-' # authorization 内容, 依服务端设置,如 'Bearer xxxx' - url: '-' # 备用 key: '114514' authorization: '-' # 判断签名服务不可用(需要切换)的额外规则 # 0: 不设置 (此时仅在请求无法返回结果时判定为不可用) # 1: 在获取到的 sign 为空 (若选此建议关闭 auto-register,一般为实例未注册但是请求签名的情况) # 2: 在获取到的 sign 或 token 为空(若选此建议关闭 auto-refresh-token ) rule-change-sign-server: 1 # 连续寻找可用签名服务器最大尝试次数 # 为 0 时会在连续 3 次没有找到可用签名服务器后保持使用主签名服务器,不再尝试进行切换备用 # 否则会在达到指定次数后 **退出** 主程序 max-check-count: 0 # 签名服务请求超时时间(s) sign-server-timeout: 60 # 如果签名服务器的版本在1.1.0及以下, 请将下面的参数改成true # 建议使用 1.1.6 以上版本,低版本普遍半个月冻结一次 is-below-110: false # 在实例可能丢失(获取到的签名为空)时是否尝试重新注册 # 为 true 时,在签名服务不可用时可能每次发消息都会尝试重新注册并签名。 # 为 false 时,将不会自动注册实例,在签名服务器重启或实例被销毁后需要重启 go-cqhttp 以获取实例 # 否则后续消息将不会正常签名。关闭此项后可以考虑开启签名服务器端 auto_register 避免需要重启 # 由于实现问题,当前建议关闭此项,推荐开启签名服务器的自动注册实例 auto-register: false # 是否在 token 过期后立即自动刷新签名 token(在需要签名时才会检测到,主要防止 token 意外丢失) # 独立于定时刷新 auto-refresh-token: false # 定时刷新 token 间隔时间,单位为分钟, 建议 30~40 分钟, 不可超过 60 分钟 # 目前丢失token也不会有太大影响,可设置为 0 以关闭,推荐开启 refresh-interval: 40 heartbeat: # 心跳频率, 单位秒 # -1 为关闭心跳 interval: 5 message: # 上报数据类型 # 可选: string,array post-format: string # 是否忽略无效的CQ码, 如果为假将原样发送 ignore-invalid-cqcode: false # 是否强制分片发送消息 # 分片发送将会带来更快的速度 # 但是兼容性会有些问题 force-fragment: false # 是否将url分片发送 fix-url: false # 下载图片等请求网络代理 proxy-rewrite: '' # 是否上报自身消息 report-self-message: false # 移除服务端的Reply附带的At remove-reply-at: false # 为Reply附加更多信息 extra-reply-data: false # 跳过 Mime 扫描, 忽略错误数据 skip-mime-scan: false # 是否自动转换 WebP 图片 convert-webp-image: false # download 超时时间(s) http-timeout: 15 output: # 日志等级 trace,debug,info,warn,error log-level: warn # 日志时效 单位天. 超过这个时间之前的日志将会被自动删除. 设置为 0 表示永久保留. log-aging: 15 # 是否在每次启动时强制创建全新的文件储存日志. 为 false 的情况下将会在上次启动时创建的日志文件续写 log-force-new: true # 是否启用日志颜色 log-colorful: true # 是否启用 DEBUG debug: false # 开启调试模式 # 默认中间件锚点 default-middlewares: &default # 访问密钥, 强烈推荐在公网的服务器设置 access-token: '' # 事件过滤器文件目录 filter: '' # 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: 1 # 令牌桶大小 database: # 数据库相关设置 leveldb: # 是否启用内置leveldb数据库 # 启用将会增加10-20MB的内存占用和一定的磁盘空间 # 关闭将无法使用 撤回 回复 get_msg 等上下文相关功能 enable: true sqlite3: # 是否启用内置sqlite3数据库 # 启用将会增加一定的内存占用和一定的磁盘空间 # 关闭将无法使用 撤回 回复 get_msg 等上下文相关功能 enable: false cachettl: 3600000000000 # 1h # 连接服务列表 servers: # 添加方式,同一连接方式可添加多个,具体配置说明请查看文档 #- http: # http 通信 #- ws: # 正向 Websocket #- ws-reverse: # 反向 Websocket #- pprof: #性能分析服务器 ================================================ FILE: modules/filter/filter.go ================================================ // Package filter implements an event filter for go-cqhttp package filter import ( "regexp" "strings" "github.com/tidwall/gjson" ) // Filter 定义了一个消息上报过滤接口 type Filter interface { Eval(payload gjson.Result) bool } type operationNode struct { key string filter Filter } // notOperator 定义了过滤器中Not操作符 type notOperator struct { operand Filter } func newNotOp(argument gjson.Result) Filter { if !argument.IsObject() { panic("the argument of 'not' operator must be an object") } return ¬Operator{operand: Generate("and", argument)} } // Eval 对payload执行Not过滤 func (op *notOperator) Eval(payload gjson.Result) bool { return !op.operand.Eval(payload) } // andOperator 定义了过滤器中And操作符 type andOperator struct { operands []operationNode } func newAndOp(argument gjson.Result) Filter { if !argument.IsObject() { panic("the argument of 'and' operator must be an object") } op := new(andOperator) argument.ForEach(func(key, value gjson.Result) bool { switch { case key.Str[0] == '.': // is an operator // ".foo": { // "bar": "baz" // } opKey := key.Str[1:] op.operands = append(op.operands, operationNode{"", Generate(opKey, value)}) case value.IsObject(): // is a normal key with an object as the value // "foo": { // ".bar": "baz" // } opKey := key.String() op.operands = append(op.operands, operationNode{opKey, Generate("and", value)}) default: // is a normal key with a non-object as the value // "foo": "bar" opKey := key.String() op.operands = append(op.operands, operationNode{opKey, Generate("eq", value)}) } return true }) return op } // Eval 对payload执行And过滤 func (op *andOperator) Eval(payload gjson.Result) bool { res := true for _, operand := range op.operands { if len(operand.key) == 0 { // is an operator res = res && operand.filter.Eval(payload) } else { // is a normal key val := payload.Get(operand.key) res = res && operand.filter.Eval(val) } if !res { break } } return res } // orOperator 定义了过滤器中Or操作符 type orOperator struct { operands []Filter } func newOrOp(argument gjson.Result) Filter { if !argument.IsArray() { panic("the argument of 'or' operator must be an array") } op := new(orOperator) argument.ForEach(func(_, value gjson.Result) bool { op.operands = append(op.operands, Generate("and", value)) return true }) return op } // Eval 对payload执行Or过滤 func (op *orOperator) Eval(payload gjson.Result) bool { res := false for _, operand := range op.operands { res = res || operand.Eval(payload) if res { break } } return res } // eqOperator 定义了过滤器中Equal操作符 type eqOperator struct { operand string } func newEqOp(argument gjson.Result) Filter { return &eqOperator{operand: argument.String()} } // Eval 对payload执行Equal过滤 func (op *eqOperator) Eval(payload gjson.Result) bool { return payload.String() == op.operand } // neqOperator 定义了过滤器中NotEqual操作符 type neqOperator struct { operand string } func newNeqOp(argument gjson.Result) Filter { return &neqOperator{operand: argument.String()} } // Eval 对payload执行NotEqual过滤 func (op *neqOperator) Eval(payload gjson.Result) bool { return !(payload.String() == op.operand) } // inOperator 定义了过滤器中In操作符 type inOperator struct { operandString string operandArray []string } func newInOp(argument gjson.Result) Filter { if argument.IsObject() { panic("the argument of 'in' operator must be an array or a string") } op := new(inOperator) 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 } // Eval 对payload执行In过滤 func (op *inOperator) Eval(payload gjson.Result) bool { payloadStr := payload.String() if op.operandArray != nil { for _, value := range op.operandArray { if value == payloadStr { return true } } return false } return strings.Contains(op.operandString, payloadStr) } // containsOperator 定义了过滤器中Contains操作符 type containsOperator struct { operand string } func newContainOp(argument gjson.Result) Filter { if argument.IsArray() || argument.IsObject() { panic("the argument of 'contains' operator must be a string") } return &containsOperator{operand: argument.String()} } // Eval 对payload执行Contains过滤 func (op *containsOperator) Eval(payload gjson.Result) bool { return strings.Contains(payload.String(), op.operand) } // regexOperator 定义了过滤器中Regex操作符 type regexOperator struct { regex *regexp.Regexp } func newRegexOp(argument gjson.Result) Filter { if argument.IsArray() || argument.IsObject() { panic("the argument of 'regex' operator must be a string") } return ®exOperator{regex: regexp.MustCompile(argument.String())} } // Eval 对payload执行RegexO过滤 func (op *regexOperator) Eval(payload gjson.Result) bool { return op.regex.MatchString(payload.String()) } // Generate 根据给定操作符名opName及操作符参数argument创建一个过滤器实例 func Generate(opName string, argument gjson.Result) Filter { switch opName { case "not": return newNotOp(argument) case "and": return newAndOp(argument) case "or": return newOrOp(argument) case "eq": return newEqOp(argument) case "neq": return newNeqOp(argument) case "in": return newInOp(argument) case "contains": return newContainOp(argument) case "regex": return newRegexOp(argument) default: panic("the operator " + opName + " is not supported") } } ================================================ FILE: modules/filter/middlewares.go ================================================ package filter import ( "os" "sync" "github.com/sirupsen/logrus" "github.com/tidwall/gjson" ) var ( filters = make(map[string]Filter) filterMutex sync.RWMutex ) // Add adds a filter to the list of filters func Add(file string) { if file == "" { return } bs, err := os.ReadFile(file) if err != nil { logrus.Error("init filter error: ", err) return } defer func() { if err := recover(); err != nil { logrus.Error("init filter error: ", err) } }() filter := Generate("and", gjson.ParseBytes(bs)) filterMutex.Lock() filters[file] = filter filterMutex.Unlock() } // Find returns the filter for the given file func Find(file string) Filter { if file == "" { return nil } filterMutex.RLock() defer filterMutex.RUnlock() return filters[file] } ================================================ FILE: modules/pprof/pprof.go ================================================ // Package pprof provide pprof server of go-cqhttp package pprof import ( "fmt" "net/http" "net/http/pprof" "os" "time" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/modules/config" "github.com/Mrs4s/go-cqhttp/modules/servers" ) const pprofDefault = ` # pprof 性能分析服务器, 一般情况下不需要启用. # 如果遇到性能问题请上传报告给开发者处理 # 注意: pprof服务不支持中间件、不支持鉴权. 请不要开放到公网 - pprof: # pprof服务器监听地址 host: 127.0.0.1 # pprof服务器监听端口 port: 7700 ` // pprofServer pprof性能分析服务器相关配置 type pprofServer struct { Disabled bool `yaml:"disabled"` Host string `yaml:"host"` Port int `yaml:"port"` } func init() { config.AddServer(&config.Server{ Brief: "pprof 性能分析服务器", Default: pprofDefault, }) } // runPprof 启动 pprof 性能分析服务器 func runPprof(_ *coolq.CQBot, node yaml.Node) { var conf pprofServer switch err := node.Decode(&conf); { case err != nil: log.Warn("读取pprof配置失败 :", err) fallthrough case conf.Disabled: return } addr := fmt.Sprintf("%s:%d", conf.Host, conf.Port) mux := http.NewServeMux() mux.HandleFunc("/debug/pprof/", pprof.Index) mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) mux.HandleFunc("/debug/pprof/profile", pprof.Profile) mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) mux.HandleFunc("/debug/pprof/trace", pprof.Trace) server := http.Server{Addr: addr, Handler: mux} log.Infof("pprof debug 服务器已启动: %v/debug/pprof", addr) log.Warnf("警告: pprof 服务不支持鉴权, 请不要运行在公网.") if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Error(err) log.Infof("pprof 服务启动失败, 请检查端口是否被占用.") log.Warnf("将在五秒后退出.") time.Sleep(time.Second * 5) os.Exit(1) } } func init() { servers.Register("pprof", runPprof) } ================================================ FILE: modules/servers/servers.go ================================================ // Package servers provide servers register package servers import ( "gopkg.in/yaml.v3" "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/internal/base" ) var ( svr = make(map[string]func(*coolq.CQBot, yaml.Node)) nocfgsvr = make(map[string]func(*coolq.CQBot)) ) // Register 注册 Server func Register(name string, proc func(*coolq.CQBot, yaml.Node)) { _, ok := svr[name] if ok { panic(name + " server has existed") } svr[name] = proc } // RegisterCustom 注册无需 config 的自定义 Server func RegisterCustom(name string, proc func(*coolq.CQBot)) { _, ok := nocfgsvr[name] if ok { panic(name + " server has existed") } nocfgsvr[name] = proc } // Run 运行所有svr func Run(bot *coolq.CQBot) { for _, l := range base.Servers { for name, conf := range l { if fn, ok := svr[name]; ok { go fn(bot, conf) } } } for _, fn := range nocfgsvr { go fn(bot) } base.Servers = nil } ================================================ FILE: modules/silk/codec.go ================================================ //go:build (linux || (windows && !arm && !arm64) || darwin) && (386 || amd64 || arm || arm64) && !race && !nosilk // +build linux windows,!arm,!arm64 darwin // +build 386 amd64 arm arm64 // +build !race // +build !nosilk package silk import ( "os" "os/exec" "path" "github.com/pkg/errors" "github.com/wdvxdr1123/go-silk" "github.com/Mrs4s/go-cqhttp/internal/base" ) const silkCachePath = "data/cache" // encode 将音频编码为Silk func encode(record []byte, tempName string) (silkWav []byte, err error) { // 1. 写入缓存文件 rawPath := path.Join(silkCachePath, tempName+".wav") err = os.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 errors.Is(cmd.Err, exec.ErrDot) { cmd.Err = nil } if base.Debug { cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr } if err = cmd.Run(); err != nil { return nil, errors.Wrap(err, "convert pcm file error") } defer os.Remove(pcmPath) // 3. 转silk pcm, err := os.ReadFile(pcmPath) if err != nil { return nil, errors.Wrap(err, "read pcm file err") } silkWav, err = silk.EncodePcmBuffToSilk(pcm, 24000, 24000, true) if err != nil { return nil, errors.Wrap(err, "silk encode error") } silkPath := path.Join(silkCachePath, tempName+".silk") err = os.WriteFile(silkPath, silkWav, 0o666) return } // resample 将silk重新编码为 24000 bit rate func resample(data []byte) []byte { pcm, err := silk.DecodeSilkBuffToPcm(data, 24000) if err != nil { panic(err) } data, err = silk.EncodePcmBuffToSilk(pcm, 24000, 24000, true) if err != nil { panic(err) } return data } ================================================ FILE: modules/silk/codec_unsupported.go ================================================ //go:build (!arm && !arm64 && !amd64 && !386) || (!windows && !linux && !darwin) || (windows && arm) || (windows && arm64) || race || nosilk // +build !arm,!arm64,!amd64,!386 !windows,!linux,!darwin windows,arm windows,arm64 race nosilk package silk import "errors" // encode 将音频编码为Silk func encode(record []byte, tempName string) ([]byte, error) { return nil, errors.New("not supported now") } // resample 将silk重新编码为 24000 bit rate func resample(data []byte) []byte { return data } ================================================ FILE: modules/silk/stubs.go ================================================ // Package silk Silk编码核心模块 package silk import ( "github.com/Mrs4s/go-cqhttp/internal/base" ) func init() { base.EncodeSilk = encode base.ResampleSilk = resample } ================================================ FILE: pkg/onebot/attr.go ================================================ // Copyright 2022 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package onebot import ( "time" ) // An Attr is a key-value pair. type Attr struct { Key string Value Value } // String returns an Attr for a string value. func String(key, value string) Attr { return Attr{key, StringValue(value)} } // Int64 returns an Attr for an int64. func Int64(key string, value int64) Attr { return Attr{key, Int64Value(value)} } // Int converts an int to an int64 and returns // an Attr with that value. func Int(key string, value int) Attr { return Int64(key, int64(value)) } // Uint64 returns an Attr for a uint64. func Uint64(key string, v uint64) Attr { return Attr{key, Uint64Value(v)} } // Float64 returns an Attr for a floating-point number. func Float64(key string, v float64) Attr { return Attr{key, Float64Value(v)} } // Bool returns an Attr for a bool. func Bool(key string, v bool) Attr { return Attr{key, BoolValue(v)} } // Time returns an Attr for a time.Time. // It discards the monotonic portion. func Time(key string, v time.Time) Attr { return Attr{key, TimeValue(v)} } // Duration returns an Attr for a time.Duration. func Duration(key string, v time.Duration) Attr { return Attr{key, DurationValue(v)} } // Group returns an Attr for a Group Value. // The caller must not subsequently mutate the // argument slice. // // Use Group to collect several Attrs under a single // key on a log line, or as the result of LogValue // in order to log a single value as multiple Attrs. func Group(key string, as ...Attr) Attr { return Attr{key, GroupValue(as...)} } // Any returns an Attr for the supplied value. // See [Value.AnyValue] for how values are treated. func Any(key string, value any) Attr { return Attr{key, AnyValue(value)} } func (a Attr) String() string { return a.Key + "=" + a.Value.String() } ================================================ FILE: pkg/onebot/kind_string.go ================================================ // Code generated by "stringer -type=Kind -trimprefix=Kind"; DO NOT EDIT. package onebot import "strconv" func _() { // An "invalid array index" compiler error signifies that the constant values have changed. // Re-run the stringer command to generate them again. var x [1]struct{} _ = x[KindAny-0] _ = x[KindBool-1] _ = x[KindDuration-2] _ = x[KindFloat64-3] _ = x[KindInt64-4] _ = x[KindString-5] _ = x[KindTime-6] _ = x[KindUint64-7] _ = x[KindGroup-8] } const _Kind_name = "AnyBoolDurationFloat64Int64StringTimeUint64Group" var _Kind_index = [...]uint8{0, 3, 7, 15, 22, 27, 33, 37, 43, 48} func (i Kind) String() string { if i < 0 || i >= Kind(len(_Kind_index)-1) { return "Kind(" + strconv.FormatInt(int64(i), 10) + ")" } return _Kind_name[_Kind_index[i]:_Kind_index[i+1]] } ================================================ FILE: pkg/onebot/onebot.go ================================================ package onebot // Self 机器人自身标识 // // https://12.onebot.dev/connect/data-protocol/basic-types/#_10 type Self struct { Platform string `json:"platform"` UserID string `json:"user_id"` } // Request 动作请求是应用端为了主动向 OneBot 实现请求服务而发送的数据 // // https://12.onebot.dev/connect/data-protocol/action-request/ type Request struct { Action string // 动作名称 Params any // 动作参数 Echo any // 每次请求的唯一标识 } // Response 动作响应是 OneBot 实现收到应用端的动作请求并处理完毕后,发回应用端的数据 // // https://12.onebot.dev/connect/data-protocol/action-response/ type Response struct { Status string `json:"status"` // 执行状态,必须是 ok、failed 中的一个 Code int64 `json:"retcode"` // 返回码 Data any `json:"data"` // 响应数据 Message string `json:"message"` // 错误信息 Echo any `json:"echo"` // 动作请求中的 echo 字段值 } // Event 事件 // // https://12.onebot.dev/connect/data-protocol/event/ type Event struct { ID string Time int64 Type string DetailType string SubType string Self *Self } ================================================ FILE: pkg/onebot/spec.go ================================================ // Package onebot defines onebot protocol struct and some spec info. package onebot import "fmt" //go:generate go run ./../../cmd/api-generator -pkg onebot -path=./../../coolq/api.go,./../../coolq/api_v12.go -supported -o supported.go // Spec OneBot Specification type Spec struct { Version int // must be 11 or 12 SupportedActions []string } // V11 OneBot V11 var V11 = &Spec{ Version: 11, SupportedActions: supportedV11, } // V12 OneBot V12 var V12 = &Spec{ Version: 12, SupportedActions: supportedV12, } // ConvertID 根据版本转换ID func (s *Spec) ConvertID(id any) any { if s.Version == 12 { return fmt.Sprint(id) } return id } ================================================ FILE: pkg/onebot/supported.go ================================================ // Code generated by cmd/api-generator. DO NOT EDIT. package onebot var supportedV11 = []string{ ".get_word_slices", ".handle_quick_operation", ".ocr_image", "ocr_image", "_del_group_notice", "_get_group_notice", "_get_model_show", "_send_group_notice", "_set_model_show", "can_send_image", "can_send_record", "check_url_safely", "create_group_file_folder", "create_guild_role", "delete_essence_msg", "delete_friend", "delete_group_file", "delete_group_folder", "delete_guild_role", "delete_msg", "delete_unidirectional_friend", "download_file", "get_essence_msg_list", "get_forward_msg", "get_friend_list", "get_group_at_all_remain", "get_group_file_system_info", "get_group_file_url", "get_group_files_by_folder", "get_group_honor_info", "get_group_info", "get_group_list", "get_group_member_info", "get_group_member_list", "get_group_msg_history", "get_group_root_files", "get_group_system_msg", "get_guild_channel_list", "get_guild_list", "get_guild_member_list", "get_guild_member_profile", "get_guild_meta_by_guest", "get_guild_msg", "get_guild_roles", "get_guild_service_profile", "get_image", "get_login_info", "get_msg", "get_online_clients", "get_status", "get_stranger_info", "get_supported_actions", "get_topic_channel_feeds", "get_unidirectional_friend_list", "get_version_info", "mark_msg_as_read", "qidian_get_account_info", "reload_event_filter", "send_forward_msg", "send_group_forward_msg", "send_group_msg", "send_group_sign", "send_guild_channel_msg", "send_msg", "send_private_forward_msg", "send_private_msg", "set_essence_msg", "set_friend_add_request", "set_group_add_request", "set_group_admin", "set_group_anonymous", "set_group_anonymous_ban", "set_group_ban", "set_group_card", "set_group_kick", "set_group_leave", "set_group_name", "set_group_portrait", "set_group_special_title", "set_group_whole_ban", "set_guild_member_role", "set_qq_profile", "update_guild_role", "upload_group_file", "upload_private_file", } var supportedV12 = []string{ ".get_word_slices", ".ocr_image", "ocr_image", "_del_group_notice", "_get_group_notice", "_get_model_show", "_send_group_notice", "_set_model_show", "check_url_safely", "create_group_file_folder", "create_guild_role", "delete_essence_msg", "delete_friend", "delete_group_file", "delete_group_folder", "delete_guild_role", "delete_msg", "delete_unidirectional_friend", "download_file", "get_essence_msg_list", "get_forward_msg", "get_friend_list", "get_group_at_all_remain", "get_group_file_system_info", "get_group_file_url", "get_group_files_by_folder", "get_group_honor_info", "get_group_info", "get_group_list", "get_group_member_info", "get_group_member_list", "get_group_msg_history", "get_group_root_files", "get_group_system_msg", "get_guild_channel_list", "get_guild_list", "get_guild_member_list", "get_guild_member_profile", "get_guild_meta_by_guest", "get_guild_msg", "get_guild_roles", "get_guild_service_profile", "get_image", "get_self_info", "get_msg", "get_online_clients", "get_status", "get_user_info", "get_supported_actions", "get_topic_channel_feeds", "get_unidirectional_friend_list", "mark_msg_as_read", "qidian_get_account_info", "reload_event_filter", "send_group_sign", "send_guild_channel_msg", "set_essence_msg", "set_friend_add_request", "set_group_add_request", "set_group_admin", "set_group_anonymous", "set_group_anonymous_ban", "set_group_ban", "set_group_card", "set_group_kick", "set_group_leave", "set_group_name", "set_group_portrait", "set_group_special_title", "set_group_whole_ban", "set_guild_member_role", "set_qq_profile", "update_guild_role", "upload_group_file", "upload_private_file", } ================================================ FILE: pkg/onebot/value.go ================================================ // Copyright 2022 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package onebot import ( "fmt" "math" "strconv" "time" "unsafe" ) // A Value can represent any Go value, but unlike type any, // it can represent most small values without an allocation. // The zero Value corresponds to nil. type Value struct { _ [0]func() // disallow == num uint64 // hold number value any any // hold Kind or other value } type ( stringptr *byte // used in Value.any when the Value is a string groupptr *Attr // used in Value.any when the Value is a []Attr ) //go:generate stringer -type=Kind -trimprefix=Kind // Kind is the kind of Value. type Kind int // Kind const ( KindAny Kind = iota KindBool KindDuration KindFloat64 KindInt64 KindString KindTime KindUint64 KindGroup ) // Unexported version of Kind, just so we can store Kinds in Values. // (No user-provided value has this type.) type kind Kind // Kind returns v's Kind. func (v Value) Kind() Kind { switch x := v.any.(type) { case Kind: return x case stringptr: return KindString case timeLocation: return KindTime case groupptr: return KindGroup case kind: // a kind is just a wrapper for a Kind return KindAny default: return KindAny } } //////////////// Constructors // StringValue returns a new Value for a string. func StringValue(value string) Value { return Value{num: uint64(len(value)), any: stringptr(unsafe.StringData(value))} } // IntValue returns a Value for an int. func IntValue(v int) Value { return Int64Value(int64(v)) } // Int64Value returns a Value for an int64. func Int64Value(v int64) Value { return Value{num: uint64(v), any: KindInt64} } // Uint64Value returns a Value for a uint64. func Uint64Value(v uint64) Value { return Value{num: v, any: KindUint64} } // Float64Value returns a Value for a floating-point number. func Float64Value(v float64) Value { return Value{num: math.Float64bits(v), any: KindFloat64} } // BoolValue returns a Value for a bool. func BoolValue(v bool) Value { u := uint64(0) if v { u = 1 } return Value{num: u, any: KindBool} } // Unexported version of *time.Location, just so we can store *time.Locations in // Values. (No user-provided value has this type.) type timeLocation *time.Location // TimeValue returns a Value for a time.Time. // It discards the monotonic portion. func TimeValue(v time.Time) Value { if v.IsZero() { // UnixNano on the zero time is undefined, so represent the zero time // with a nil *time.Location instead. time.Time.Location method never // returns nil, so a Value with any == timeLocation(nil) cannot be // mistaken for any other Value, time.Time or otherwise. return Value{any: timeLocation(nil)} } return Value{num: uint64(v.UnixNano()), any: timeLocation(v.Location())} } // DurationValue returns a Value for a time.Duration. func DurationValue(v time.Duration) Value { return Value{num: uint64(v.Nanoseconds()), any: KindDuration} } // GroupValue returns a new Value for a list of Attrs. // The caller must not subsequently mutate the argument slice. func GroupValue(as ...Attr) Value { return Value{num: uint64(len(as)), any: groupptr(unsafe.SliceData(as))} } // AnyValue returns a Value for the supplied value. // // If the supplied value is of type Value, it is returned // unmodified. // // Given a value of one of Go's predeclared string, bool, or // (non-complex) numeric types, AnyValue returns a Value of kind // String, Bool, Uint64, Int64, or Float64. The width of the // original numeric type is not preserved. // // Given a time.Time or time.Duration value, AnyValue returns a Value of kind // KindTime or KindDuration. The monotonic time is not preserved. // // For nil, or values of all other types, including named types whose // underlying type is numeric, AnyValue returns a value of kind KindAny. func AnyValue(v any) Value { switch v := v.(type) { case string: return StringValue(v) case int: return Int64Value(int64(v)) case uint: return Uint64Value(uint64(v)) case int64: return Int64Value(v) case uint64: return Uint64Value(v) case bool: return BoolValue(v) case time.Duration: return DurationValue(v) case time.Time: return TimeValue(v) case uint8: return Uint64Value(uint64(v)) case uint16: return Uint64Value(uint64(v)) case uint32: return Uint64Value(uint64(v)) case uintptr: return Uint64Value(uint64(v)) case int8: return Int64Value(int64(v)) case int16: return Int64Value(int64(v)) case int32: return Int64Value(int64(v)) case float64: return Float64Value(v) case float32: return Float64Value(float64(v)) case []Attr: return GroupValue(v...) case Kind: return Value{any: kind(v)} case Value: return v default: return Value{any: v} } } //////////////// Accessors // Any returns v's value as an any. func (v Value) Any() any { switch v.Kind() { case KindAny: if k, ok := v.any.(kind); ok { return Kind(k) } return v.any case KindGroup: return v.group() case KindInt64: return int64(v.num) case KindUint64: return v.num case KindFloat64: return v.float() case KindString: return v.str() case KindBool: return v.bool() case KindDuration: return v.duration() case KindTime: return v.time() default: panic(fmt.Sprintf("bad kind: %s", v.Kind())) } } // String returns Value's value as a string, formatted like fmt.Sprint. Unlike // the methods Int64, Float64, and so on, which panic if v is of the // wrong kind, String never panics. func (v Value) String() string { if sp, ok := v.any.(stringptr); ok { return unsafe.String(sp, v.num) } var buf []byte return string(v.append(buf)) } func (v Value) str() string { return unsafe.String(v.any.(stringptr), v.num) } // Int64 returns v's value as an int64. It panics // if v is not a signed integer. func (v Value) Int64() int64 { if g, w := v.Kind(), KindInt64; g != w { panic(fmt.Sprintf("Value kind is %s, not %s", g, w)) } return int64(v.num) } // Uint64 returns v's value as a uint64. It panics // if v is not an unsigned integer. func (v Value) Uint64() uint64 { if g, w := v.Kind(), KindUint64; g != w { panic(fmt.Sprintf("Value kind is %s, not %s", g, w)) } return v.num } // Bool returns v's value as a bool. It panics // if v is not a bool. func (v Value) Bool() bool { if g, w := v.Kind(), KindBool; g != w { panic(fmt.Sprintf("Value kind is %s, not %s", g, w)) } return v.bool() } func (v Value) bool() bool { return v.num == 1 } // Duration returns v's value as a time.Duration. It panics // if v is not a time.Duration. func (v Value) Duration() time.Duration { if g, w := v.Kind(), KindDuration; g != w { panic(fmt.Sprintf("Value kind is %s, not %s", g, w)) } return v.duration() } func (v Value) duration() time.Duration { return time.Duration(int64(v.num)) } // Float64 returns v's value as a float64. It panics // if v is not a float64. func (v Value) Float64() float64 { if g, w := v.Kind(), KindFloat64; g != w { panic(fmt.Sprintf("Value kind is %s, not %s", g, w)) } return v.float() } func (v Value) float() float64 { return math.Float64frombits(v.num) } // Time returns v's value as a time.Time. It panics // if v is not a time.Time. func (v Value) Time() time.Time { if g, w := v.Kind(), KindTime; g != w { panic(fmt.Sprintf("Value kind is %s, not %s", g, w)) } return v.time() } func (v Value) time() time.Time { loc := v.any.(timeLocation) if loc == nil { return time.Time{} } return time.Unix(0, int64(v.num)).In(loc) } // Group returns v's value as a []Attr. // It panics if v's Kind is not KindGroup. func (v Value) Group() []Attr { if sp, ok := v.any.(groupptr); ok { return unsafe.Slice(sp, v.num) } panic("Group: bad kind") } func (v Value) group() []Attr { return unsafe.Slice((*Attr)(v.any.(groupptr)), v.num) } // append appends a text representation of v to dst. // v is formatted as with fmt.Sprint. func (v Value) append(dst []byte) []byte { switch v.Kind() { case KindString: return append(dst, v.str()...) case KindInt64: return strconv.AppendInt(dst, int64(v.num), 10) case KindUint64: return strconv.AppendUint(dst, v.num, 10) case KindFloat64: return strconv.AppendFloat(dst, v.float(), 'g', -1, 64) case KindBool: return strconv.AppendBool(dst, v.bool()) case KindDuration: return append(dst, v.duration().String()...) case KindTime: return append(dst, v.time().String()...) case KindGroup: return fmt.Append(dst, v.group()) case KindAny: return fmt.Append(dst, v.any) default: panic(fmt.Sprintf("bad kind: %s", v.Kind())) } } ================================================ FILE: scripts/bootstrap ================================================ #!/usr/bin/env bash function index.main_handler() { echo "Start GOCQHTTP~~~" cp -f config.yml /tmp/config.yml cp -f device.json /tmp/device.json ./go-cqhttp -w="/tmp/" faststart } index.main_handler ================================================ FILE: scripts/upload_dist.sh ================================================ #!/bin/bash if [ "$GITHUB_ACTIONS" != "true" ]; then echo "This script is only meant to be run in GitHub Actions." exit 1 fi cp -f dist/*.tar.gz upstream/dist/downloads cp -f dist/*.zip upstream/dist/downloads cd upstream/dist || exit LATEST_VERSION="${GITHUB_REF#"refs/tags/"}" git config --local user.name 'Github Actions' git config --local user.email 'github-actions@users.noreply.github.com' git add --all git commit -m "update to $LATEST_VERSION" git tag -d "$LATEST_VERSION" git tag "$LATEST_VERSION" git push git push --tags ================================================ FILE: server/daemon.go ================================================ package server // daemon 功能写在这,目前仅支持了-d 作为后台运行参数,stop,start,restart这些功能目前看起来并不需要,可以通过api控制,后续需要的话再补全。 import ( "os" "os/exec" "path/filepath" "strconv" "strings" "github.com/Mrs4s/go-cqhttp/global" log "github.com/sirupsen/logrus" ) // Daemon go-cqhttp server 的 daemon的实现函数 func Daemon() { args := os.Args[1:] execArgs := make([]string, 0) l := len(args) for i := 0; i < l; i++ { if strings.Index(args[i], "-d") == 0 { continue } execArgs = append(execArgs, args[i]) } ex, _ := os.Executable() p, _ := filepath.Abs(ex) proc := exec.Command(p, execArgs...) err := proc.Start() if err != nil { panic(err) } log.Info("[PID] ", proc.Process.Pid) // pid写入到pid文件中,方便后续stop的时候kill pidErr := savePid("go-cqhttp.pid", strconv.FormatInt(int64(proc.Process.Pid), 10)) if pidErr != nil { log.Errorf("save pid file error: %v", pidErr) } os.Exit(0) } // savePid 保存pid到文件中,便于后续restart/stop的时候kill pid用。 func savePid(path string, data string) error { return global.WriteAllText(path, data) } ================================================ FILE: server/doc.go ================================================ // Package server 包含HTTP,WebSocket,反向WebSocket请求处理的相关函数与结构体 package server import "github.com/Mrs4s/go-cqhttp/modules/servers" // 注册 func init() { servers.Register("http", runHTTP) servers.Register("ws", runWSServer) servers.Register("ws-reverse", runWSClient) servers.Register("lambda", runLambda) } ================================================ FILE: server/http.go ================================================ package server import ( "bytes" "context" "crypto/hmac" "crypto/sha1" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "io" "net" "net/http" "net/url" "os" "regexp" "strconv" "strings" "time" "github.com/Mrs4s/MiraiGo/utils" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "gopkg.in/yaml.v3" "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/modules/api" "github.com/Mrs4s/go-cqhttp/modules/config" "github.com/Mrs4s/go-cqhttp/modules/filter" "github.com/Mrs4s/go-cqhttp/pkg/onebot" ) // HTTPServer HTTP通信相关配置 type HTTPServer struct { Disabled bool `yaml:"disabled"` Version uint16 `yaml:"version"` Address string `yaml:"address"` Host string `yaml:"host"` Port int `yaml:"port"` Timeout int32 `yaml:"timeout"` LongPolling struct { Enabled bool `yaml:"enabled"` MaxQueueSize int `yaml:"max-queue-size"` } `yaml:"long-polling"` Post []httpServerPost `yaml:"post"` MiddleWares `yaml:"middlewares"` } type httpServerPost struct { URL string `yaml:"url"` Secret string `yaml:"secret"` MaxRetries *uint64 `yaml:"max-retries"` RetriesInterval *uint64 `yaml:"retries-interval"` } type httpServer struct { api *api.Caller accessToken string spec *onebot.Spec // onebot spec } // HTTPClient 反向HTTP上报客户端 type HTTPClient struct { bot *coolq.CQBot secret string addr string filter string apiPort int timeout int32 client *http.Client MaxRetries uint64 RetriesInterval uint64 } type httpCtx struct { json gjson.Result query url.Values postForm url.Values } const httpDefault = ` - http: # HTTP 通信设置 address: 0.0.0.0:5700 # HTTP监听地址 version: 11 # OneBot协议版本, 支持 11/12 timeout: 5 # 反向 HTTP 超时时间, 单位秒,<5 时将被忽略 long-polling: # 长轮询拓展 enabled: false # 是否开启 max-queue-size: 2000 # 消息队列大小,0 表示不限制队列大小,谨慎使用 middlewares: <<: *default # 引用默认中间件 post: # 反向HTTP POST地址列表 #- url: '' # 地址 # secret: '' # 密钥 # max-retries: 3 # 最大重试,0 时禁用 # retries-interval: 1500 # 重试时间,单位毫秒,0 时立即 #- url: http://127.0.0.1:5701/ # 地址 # secret: '' # 密钥 # max-retries: 10 # 最大重试,0 时禁用 # retries-interval: 1000 # 重试时间,单位毫秒,0 时立即 ` func init() { config.AddServer(&config.Server{Brief: "HTTP通信", Default: httpDefault}) } var joinQuery = regexp.MustCompile(`\[(.+?),(.+?)]\.0`) func mayJSONParam(p string) bool { if strings.HasPrefix(p, "{") || strings.HasPrefix(p, "[") { return gjson.Valid(p) } return false } func (h *httpCtx) get(pattern string, join bool) gjson.Result { // support gjson advanced syntax: // h.Get("[a,b].0") see usage in http_test.go. See issue #1241, #1325. if join && strings.HasPrefix(pattern, "[") && joinQuery.MatchString(pattern) { matched := joinQuery.FindStringSubmatch(pattern) if r := h.get(matched[1], false); r.Exists() { return r } return h.get(matched[2], false) } if h.postForm != nil { if form := h.postForm.Get(pattern); form != "" { if mayJSONParam(form) { return gjson.Result{Type: gjson.JSON, Raw: form} } return gjson.Result{Type: gjson.String, Str: form} } } if h.query != nil { if query := h.query.Get(pattern); query != "" { if mayJSONParam(query) { return gjson.Result{Type: gjson.JSON, Raw: query} } return gjson.Result{Type: gjson.String, Str: query} } } return gjson.Result{} } func (h *httpCtx) Get(s string) gjson.Result { j := h.json.Get(s) if j.Exists() { return j } return h.get(s, true) } func (s *httpServer) ServeHTTP(writer http.ResponseWriter, request *http.Request) { var ctx httpCtx contentType := request.Header.Get("Content-Type") switch request.Method { case http.MethodPost: // todo: msg pack if s.spec.Version == 12 && strings.Contains(contentType, "application/msgpack") { log.Warnf("请求 %v 数据类型暂不支持: MsgPack", request.RequestURI) writer.WriteHeader(http.StatusUnsupportedMediaType) return } if strings.Contains(contentType, "application/json") { body, err := io.ReadAll(request.Body) if err != nil { log.Warnf("获取请求 %v 的Body时出现错误: %v", request.RequestURI, err) writer.WriteHeader(http.StatusBadRequest) return } if !gjson.ValidBytes(body) { log.Warnf("已拒绝客户端 %v 的请求: 非法Json", request.RemoteAddr) writer.WriteHeader(http.StatusBadRequest) return } ctx.json = gjson.Parse(utils.B2S(body)) } if strings.Contains(contentType, "application/x-www-form-urlencoded") { err := request.ParseForm() if err != nil { log.Warnf("已拒绝客户端 %v 的请求: %v", request.RemoteAddr, err) writer.WriteHeader(http.StatusBadRequest) } ctx.postForm = request.PostForm } fallthrough case http.MethodGet: ctx.query = request.URL.Query() default: log.Warnf("已拒绝客户端 %v 的请求: 方法错误", request.RemoteAddr) writer.WriteHeader(http.StatusNotFound) return } if status := checkAuth(request, s.accessToken); status != http.StatusOK { writer.WriteHeader(status) return } var response global.MSG if request.URL.Path == "/" { action := strings.TrimSuffix(ctx.Get("action").Str, "_async") log.Debugf("HTTPServer接收到API调用: %v", action) response = s.api.Call(action, s.spec, ctx.Get("params")) } else { action := strings.TrimPrefix(request.URL.Path, "/") action = strings.TrimSuffix(action, "_async") log.Debugf("HTTPServer接收到API调用: %v", action) response = s.api.Call(action, s.spec, &ctx) } writer.Header().Set("Content-Type", "application/json; charset=utf-8") writer.WriteHeader(http.StatusOK) _ = json.NewEncoder(writer).Encode(response) } func checkAuth(req *http.Request, token string) int { if token == "" { // quick path return http.StatusOK } auth := req.Header.Get("Authorization") if auth == "" { auth = req.URL.Query().Get("access_token") } else { _, after, ok := strings.Cut(auth, " ") if ok { auth = after } } switch auth { case token: return http.StatusOK case "": return http.StatusUnauthorized default: return http.StatusForbidden } } func puint64Operator(p *uint64, def uint64) uint64 { if p == nil { return def } return *p } // runHTTP 启动HTTP服务器与HTTP上报客户端 func runHTTP(bot *coolq.CQBot, node yaml.Node) { var conf HTTPServer switch err := node.Decode(&conf); { case err != nil: log.Warn("读取http配置失败 :", err) fallthrough case conf.Disabled: return } network, addr := "tcp", conf.Address s := &httpServer{accessToken: conf.AccessToken} switch conf.Version { default: // default v11 s.spec = onebot.V11 case 12: s.spec = onebot.V12 } switch { case conf.Address != "": uri, err := url.Parse(conf.Address) if err == nil && uri.Scheme != "" { network = uri.Scheme addr = uri.Host + uri.Path } case conf.Host != "" || conf.Port != 0: addr = fmt.Sprintf("%s:%d", conf.Host, conf.Port) log.Warnln("HTTP 服务器使用了过时的配置格式,请更新配置文件!") default: goto client } s.api = api.NewCaller(bot) if conf.RateLimit.Enabled { s.api.Use(rateLimit(conf.RateLimit.Frequency, conf.RateLimit.Bucket)) } if conf.LongPolling.Enabled { s.api.Use(longPolling(bot, conf.LongPolling.MaxQueueSize)) } go func() { listener, err := net.Listen(network, addr) if err != nil { log.Infof("HTTP 服务启动失败, 请检查端口是否被占用: %v", err) log.Warnf("将在五秒后退出.") time.Sleep(time.Second * 5) os.Exit(1) } log.Infof("CQ HTTP 服务器已启动: %v", listener.Addr()) log.Fatal(http.Serve(listener, s)) }() client: for _, c := range conf.Post { if c.URL != "" { go HTTPClient{ bot: bot, secret: c.Secret, addr: c.URL, apiPort: conf.Port, filter: conf.Filter, timeout: conf.Timeout, MaxRetries: puint64Operator(c.MaxRetries, 3), RetriesInterval: puint64Operator(c.RetriesInterval, 1500), }.Run() } } } // Run 运行反向HTTP服务 func (c HTTPClient) Run() { filter.Add(c.filter) if c.timeout < 5 { c.timeout = 5 } rawAddress := c.addr network, address := resolveURI(c.addr) client := &http.Client{ Timeout: time.Second * time.Duration(c.timeout), Transport: &http.Transport{ DialContext: func(_ context.Context, _, addr string) (net.Conn, error) { if network == "unix" { host, _, err := net.SplitHostPort(addr) if err != nil { host = addr } filepath, err := base64.RawURLEncoding.DecodeString(host) if err == nil { addr = string(filepath) } } return net.Dial(network, addr) }, }, } c.addr = address // clean path c.client = client log.Infof("HTTP POST上报器已启动: %v", rawAddress) c.bot.OnEventPush(c.onBotPushEvent) } func (c *HTTPClient) onBotPushEvent(e *coolq.Event) { if c.filter != "" { flt := filter.Find(c.filter) if flt != nil && !flt.Eval(gjson.Parse(e.JSONString())) { log.Debugf("上报Event %v 到 HTTP 服务器 %s 时被过滤.", c.addr, e.JSONBytes()) return } } header := make(http.Header) header.Set("X-Self-ID", strconv.FormatInt(c.bot.Client.Uin, 10)) header.Set("User-Agent", "CQHttp/4.15.0") header.Set("Content-Type", "application/json") if c.secret != "" { mac := hmac.New(sha1.New, []byte(c.secret)) _, _ = mac.Write(e.JSONBytes()) header.Set("X-Signature", "sha1="+hex.EncodeToString(mac.Sum(nil))) } if c.apiPort != 0 { header.Set("X-API-Port", strconv.FormatInt(int64(c.apiPort), 10)) } var req *http.Request var res *http.Response var err error for i := uint64(0); i <= c.MaxRetries; i++ { // see https://stackoverflow.com/questions/31337891/net-http-http-contentlength-222-with-body-length-0 // we should create a new request for every single post trial req, err = http.NewRequest(http.MethodPost, c.addr, bytes.NewReader(e.JSONBytes())) if err != nil { log.Warnf("上报 Event 数据到 %v 时创建请求失败: %v", c.addr, err) return } req.Header = header res, err = c.client.Do(req) // nolint:bodyclose if err == nil { break } if i < c.MaxRetries { log.Warnf("上报 Event 数据到 %v 失败: %v 将进行第 %d 次重试", c.addr, err, i+1) } else { log.Warnf("上报 Event 数据 %s 到 %v 失败: %v 停止上报:已达重试上限", e.JSONBytes(), c.addr, err) return } time.Sleep(time.Millisecond * time.Duration(c.RetriesInterval)) } defer res.Body.Close() log.Debugf("上报Event数据 %s 到 %v", e.JSONBytes(), c.addr) r, err := io.ReadAll(res.Body) if err != nil { return } if gjson.ValidBytes(r) { c.bot.CQHandleQuickOperation(gjson.Parse(e.JSONString()), gjson.ParseBytes(r)) } } ================================================ FILE: server/http_test.go ================================================ package server import ( "net/url" "testing" "github.com/stretchr/testify/assert" "github.com/tidwall/gjson" ) func TestHttpCtx_Get(t *testing.T) { cases := []struct { ctx *httpCtx key string expected string }{ { ctx: &httpCtx{ json: gjson.Result{}, query: url.Values{ "sub_type": []string{"hello"}, "type": []string{"world"}, }, }, key: "[sub_type,type].0", expected: "hello", }, { ctx: &httpCtx{ json: gjson.Result{}, query: url.Values{ "type": []string{"114514"}, }, }, key: "[sub_type,type].0", expected: "114514", }, } for _, c := range cases { assert.Equal(t, c.expected, c.ctx.Get(c.key).String()) } } ================================================ FILE: server/middlewares.go ================================================ package server import ( "container/list" "context" "sync" "time" "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/modules/api" "github.com/Mrs4s/go-cqhttp/pkg/onebot" "golang.org/x/time/rate" ) // MiddleWares 通信中间件 type MiddleWares struct { AccessToken string `yaml:"access-token"` Filter string `yaml:"filter"` RateLimit struct { Enabled bool `yaml:"enabled"` Frequency float64 `yaml:"frequency"` Bucket int `yaml:"bucket"` } `yaml:"rate-limit"` } func rateLimit(frequency float64, bucketSize int) api.Handler { limiter := rate.NewLimiter(rate.Limit(frequency), bucketSize) return func(_ string, _ *onebot.Spec, _ api.Getter) global.MSG { _ = limiter.Wait(context.Background()) return nil } } func longPolling(bot *coolq.CQBot, maxSize int) api.Handler { var mutex sync.Mutex cond := sync.NewCond(&mutex) queue := list.New() bot.OnEventPush(func(event *coolq.Event) { mutex.Lock() defer mutex.Unlock() queue.PushBack(event.Raw) for maxSize != 0 && queue.Len() > maxSize { queue.Remove(queue.Front()) } cond.Signal() }) return func(action string, spec *onebot.Spec, p api.Getter) global.MSG { switch { case spec.Version == 11 && action == "get_updates": // ok case spec.Version == 12 && action == "get_latest_events": // ok default: return nil } var ( ch = make(chan []any) timeout = time.Duration(p.Get("timeout").Int()) * time.Second ) go func() { mutex.Lock() defer mutex.Unlock() for queue.Len() == 0 { cond.Wait() } limit := int(p.Get("limit").Int()) if limit <= 0 || queue.Len() < limit { limit = queue.Len() } ret := make([]any, limit) elem := queue.Front() for i := 0; i < limit; i++ { ret[i] = elem.Value elem = elem.Next() } select { case ch <- ret: for i := 0; i < limit; i++ { // remove sent msg queue.Remove(queue.Front()) } default: // don't block if parent already return due to timeout } }() if timeout != 0 { select { case <-time.After(timeout): return coolq.OK([]any{}) case ret := <-ch: return coolq.OK(ret) } } return coolq.OK(<-ch) } } ================================================ FILE: server/scf.go ================================================ package server import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "runtime/debug" "strings" "github.com/Mrs4s/MiraiGo/utils" log "github.com/sirupsen/logrus" "gopkg.in/yaml.v3" "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/global" api2 "github.com/Mrs4s/go-cqhttp/modules/api" "github.com/Mrs4s/go-cqhttp/modules/config" ) type lambdaClient struct { nextURL string responseURL string lambdaType string client http.Client } type lambdaResponse struct { IsBase64Encoded bool `json:"isBase64Encoded"` StatusCode int `json:"statusCode"` Headers map[string]string `json:"headers"` Body string `json:"body"` } type lambdaResponseWriter struct { statusCode int buf bytes.Buffer header http.Header } func (l *lambdaResponseWriter) Write(p []byte) (n int, err error) { return l.buf.Write(p) } func (l *lambdaResponseWriter) Header() http.Header { return l.header } func (l *lambdaResponseWriter) flush() error { buffer := global.NewBuffer() defer global.PutBuffer(buffer) body := utils.B2S(l.buf.Bytes()) header := make(map[string]string, len(l.header)) for k, v := range l.header { header[k] = v[0] } _ = json.NewEncoder(buffer).Encode(&lambdaResponse{ IsBase64Encoded: false, StatusCode: l.statusCode, Headers: header, Body: body, }) r, _ := http.NewRequest(http.MethodPost, cli.responseURL, buffer) do, err := cli.client.Do(r) if err != nil { return err } return do.Body.Close() } func (l *lambdaResponseWriter) WriteHeader(statusCode int) { l.statusCode = statusCode } var cli *lambdaClient // runLambda type: [scf,aws] func runLambda(bot *coolq.CQBot, node yaml.Node) { var conf LambdaServer switch err := node.Decode(&conf); { case err != nil: log.Warn("读取lambda配置失败 :", err) fallthrough case conf.Disabled: return } cli = &lambdaClient{ lambdaType: conf.Type, client: http.Client{Timeout: 0}, } switch cli.lambdaType { // todo: aws case "scf": // tencent serverless function base := fmt.Sprintf("http://%s:%s/runtime/", os.Getenv("SCF_RUNTIME_API"), os.Getenv("SCF_RUNTIME_API_PORT")) cli.nextURL = base + "invocation/next" cli.responseURL = base + "invocation/response" post, err := http.Post(base+"init/ready", "", nil) if err != nil { log.Warnf("lambda 初始化失败: %v", err) return } _ = post.Body.Close() case "aws": // aws lambda const apiVersion = "2018-06-01" base := fmt.Sprintf("http://%s/%s/runtime/", os.Getenv("AWS_LAMBDA_RUNTIME_API"), apiVersion) cli.nextURL = base + "invocation/next" cli.responseURL = base + "invocation/response" default: log.Fatal("unknown lambda type:", conf.Type) } api := api2.NewCaller(bot) if conf.RateLimit.Enabled { api.Use(rateLimit(conf.RateLimit.Frequency, conf.RateLimit.Bucket)) } server := &httpServer{ api: api, accessToken: conf.AccessToken, } for { req := cli.next() writer := lambdaResponseWriter{statusCode: 200, header: make(http.Header)} func() { defer func() { if e := recover(); e != nil { log.Warnf("Lambda 出现不可恢复错误: %v\n%s", e, debug.Stack()) } }() if req != nil { server.ServeHTTP(&writer, req) } }() if err := writer.flush(); err != nil { log.Warnf("Lambda 发送响应失败: %v", err) } } } type lambdaInvoke struct { Headers map[string]string HTTPMethod string `json:"httpMethod"` Body string `json:"body"` Path string `json:"path"` QueryString map[string]string RequestContext struct { Path string `json:"path"` } `json:"requestContext"` } const lambdaDefault = ` # LambdaServer 配置 - lambda: type: scf # scf: 腾讯云函数 aws: aws Lambda middlewares: <<: *default # 引用默认中间件 ` // LambdaServer 云函数配置 type LambdaServer struct { Disabled bool `yaml:"disabled"` Type string `yaml:"type"` MiddleWares `yaml:"middlewares"` } func init() { config.AddServer(&config.Server{ Brief: "云函数服务", Default: lambdaDefault, }) } func (c *lambdaClient) next() *http.Request { r, err := http.NewRequest(http.MethodGet, c.nextURL, nil) if err != nil { return nil } resp, err := c.client.Do(r) if err != nil { return nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil } var req http.Request var invoke lambdaInvoke _ = json.NewDecoder(resp.Body).Decode(&invoke) if invoke.HTTPMethod == "" { // 不是 api 网关 return nil } req.Method = invoke.HTTPMethod req.Body = io.NopCloser(strings.NewReader(invoke.Body)) req.Header = make(map[string][]string) for k, v := range invoke.Headers { req.Header.Set(k, v) } req.URL = new(url.URL) req.URL.Path = strings.TrimPrefix(invoke.Path, invoke.RequestContext.Path) // todo: avoid encoding query := make(url.Values) for k, v := range invoke.QueryString { query[k] = []string{v} } req.URL.RawQuery = query.Encode() return &req } ================================================ FILE: server/websocket.go ================================================ package server import ( "bytes" "encoding/base64" "encoding/json" "fmt" "net" "net/http" "net/url" "runtime/debug" "strconv" "strings" "sync" "time" "github.com/Mrs4s/MiraiGo/utils" "github.com/RomiChan/websocket" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "gopkg.in/yaml.v3" "github.com/Mrs4s/go-cqhttp/coolq" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/modules/api" "github.com/Mrs4s/go-cqhttp/modules/config" "github.com/Mrs4s/go-cqhttp/modules/filter" "github.com/Mrs4s/go-cqhttp/pkg/onebot" ) type webSocketServer struct { bot *coolq.CQBot conf *WebsocketServer mu sync.Mutex eventConn []*wsConn token string handshake string filter string } // websocketClient WebSocket客户端实例 type websocketClient struct { bot *coolq.CQBot mu sync.Mutex universal *wsConn event *wsConn token string filter string reconnectInterval time.Duration limiter api.Handler } type wsConn struct { mu sync.Mutex conn *websocket.Conn apiCaller *api.Caller } func (c *wsConn) WriteText(b []byte) error { c.mu.Lock() defer c.mu.Unlock() _ = c.conn.SetWriteDeadline(time.Now().Add(time.Second * 15)) return c.conn.WriteMessage(websocket.TextMessage, b) } func (c *wsConn) Close() error { return c.conn.Close() } var upgrader = websocket.Upgrader{ CheckOrigin: func(_ *http.Request) bool { return true }, } const wsDefault = ` # 正向WS设置 - ws: # 正向WS服务器监听地址 address: 0.0.0.0:8080 middlewares: <<: *default # 引用默认中间件 ` const wsReverseDefault = ` # 反向WS设置 - ws-reverse: # 反向WS Universal 地址 # 注意 设置了此项地址后下面两项将会被忽略 universal: ws://your_websocket_universal.server # 反向WS API 地址 api: ws://your_websocket_api.server # 反向WS Event 地址 event: ws://your_websocket_event.server # 重连间隔 单位毫秒 reconnect-interval: 3000 middlewares: <<: *default # 引用默认中间件 ` // WebsocketServer 正向WS相关配置 type WebsocketServer struct { Disabled bool `yaml:"disabled"` Address string `yaml:"address"` Host string `yaml:"host"` Port int `yaml:"port"` MiddleWares `yaml:"middlewares"` } // WebsocketReverse 反向WS相关配置 type WebsocketReverse struct { Disabled bool `yaml:"disabled"` Universal string `yaml:"universal"` API string `yaml:"api"` Event string `yaml:"event"` ReconnectInterval int `yaml:"reconnect-interval"` MiddleWares `yaml:"middlewares"` } func init() { config.AddServer(&config.Server{ Brief: "正向 Websocket 通信", Default: wsDefault, }) config.AddServer(&config.Server{ Brief: "反向 Websocket 通信", Default: wsReverseDefault, }) } // runWSServer 运行一个正向WS server func runWSServer(b *coolq.CQBot, node yaml.Node) { var conf WebsocketServer switch err := node.Decode(&conf); { case err != nil: log.Warn("读取正向Websocket配置失败 :", err) fallthrough case conf.Disabled: return } network, address := "tcp", conf.Address if conf.Address == "" && (conf.Host != "" || conf.Port != 0) { log.Warn("正向 Websocket 使用了过时的配置格式,请更新配置文件") address = fmt.Sprintf("%s:%d", conf.Host, conf.Port) } else { uri, err := url.Parse(conf.Address) if err == nil && uri.Scheme != "" { network = uri.Scheme address = uri.Host + uri.Path } } s := &webSocketServer{ bot: b, conf: &conf, token: conf.AccessToken, filter: conf.Filter, } filter.Add(s.filter) s.handshake = fmt.Sprintf(`{"_post_method":2,"meta_event_type":"lifecycle","post_type":"meta_event","self_id":%d,"sub_type":"connect","time":%d}`, b.Client.Uin, time.Now().Unix()) b.OnEventPush(s.onBotPushEvent) mux := http.ServeMux{} mux.HandleFunc("/event", s.event) mux.HandleFunc("/api", s.api) mux.HandleFunc("/", s.any) listener, err := net.Listen(network, address) if err != nil { log.Fatal(err) } log.Infof("CQ WebSocket 服务器已启动: %v", listener.Addr()) log.Fatal(http.Serve(listener, &mux)) } // runWSClient 运行一个反向向WS client func runWSClient(b *coolq.CQBot, node yaml.Node) { var conf WebsocketReverse switch err := node.Decode(&conf); { case err != nil: log.Warn("读取反向Websocket配置失败 :", err) fallthrough case conf.Disabled: return } c := &websocketClient{ bot: b, token: conf.AccessToken, filter: conf.Filter, } filter.Add(c.filter) if conf.ReconnectInterval != 0 { c.reconnectInterval = time.Duration(conf.ReconnectInterval) * time.Millisecond } else { c.reconnectInterval = time.Second * 5 } if conf.RateLimit.Enabled { c.limiter = rateLimit(conf.RateLimit.Frequency, conf.RateLimit.Bucket) } if conf.Universal != "" { c.connect("Universal", conf.Universal, &c.universal) c.bot.OnEventPush(c.onBotPushEvent("Universal", conf.Universal, &c.universal)) return // 连接到 Universal 后, 不再连接其他 } if conf.API != "" { c.connect("API", conf.API, nil) } if conf.Event != "" { c.connect("Event", conf.Event, &c.event) c.bot.OnEventPush(c.onBotPushEvent("Event", conf.Event, &c.event)) } } func resolveURI(addr string) (network, address string) { network, address = "tcp", addr uri, err := url.Parse(addr) if err == nil && uri.Scheme != "" { scheme, ext, _ := strings.Cut(uri.Scheme, "+") if ext != "" { network = ext uri.Scheme = scheme // remove `+unix`/`+tcp4` if ext == "unix" { uri.Host, uri.Path, _ = strings.Cut(uri.Path, ":") uri.Host = base64.StdEncoding.EncodeToString([]byte(uri.Host)) } address = uri.String() } } return } func (c *websocketClient) connect(typ, addr string, conptr **wsConn) { log.Infof("开始尝试连接到反向WebSocket %s服务器: %v", typ, addr) header := http.Header{ "X-Client-Role": []string{typ}, "X-Self-ID": []string{strconv.FormatInt(c.bot.Client.Uin, 10)}, "User-Agent": []string{"CQHttp/4.15.0"}, } if c.token != "" { header["Authorization"] = []string{"Token " + c.token} } network, address := resolveURI(addr) dialer := websocket.Dialer{ NetDial: func(_, addr string) (net.Conn, error) { if network == "unix" { host, _, err := net.SplitHostPort(addr) if err != nil { host = addr } filepath, err := base64.RawURLEncoding.DecodeString(host) if err == nil { addr = string(filepath) } } return net.Dial(network, addr) // support unix socket transport }, } conn, _, err := dialer.Dial(address, header) // nolint if err != nil { log.Warnf("连接到反向WebSocket %s服务器 %v 时出现错误: %v", typ, addr, err) if c.reconnectInterval != 0 { time.Sleep(c.reconnectInterval) c.connect(typ, addr, conptr) } return } switch typ { case "Event", "Universal": handshake := fmt.Sprintf(`{"meta_event_type":"lifecycle","post_type":"meta_event","self_id":%d,"sub_type":"connect","time":%d}`, c.bot.Client.Uin, time.Now().Unix()) err = conn.WriteMessage(websocket.TextMessage, []byte(handshake)) if err != nil { log.Warnf("反向WebSocket 握手时出现错误: %v", err) } } log.Infof("已连接到反向WebSocket %s服务器 %v", typ, addr) var wrappedConn *wsConn if conptr != nil && *conptr != nil { wrappedConn = *conptr } else { wrappedConn = new(wsConn) if conptr != nil { *conptr = wrappedConn } } wrappedConn.conn = conn wrappedConn.apiCaller = api.NewCaller(c.bot) if c.limiter != nil { wrappedConn.apiCaller.Use(c.limiter) } if typ != "Event" { go c.listenAPI(typ, addr, wrappedConn) } } func (c *websocketClient) listenAPI(typ, url string, conn *wsConn) { defer func() { _ = conn.Close() }() for { buffer := global.NewBuffer() t, reader, err := conn.conn.NextReader() if err != nil { log.Warnf("监听反向WS %s时出现错误: %v", typ, err) break } _, err = buffer.ReadFrom(reader) if err != nil { log.Warnf("监听反向WS %s时出现错误: %v", typ, err) break } if t == websocket.TextMessage { go func(buffer *bytes.Buffer) { defer global.PutBuffer(buffer) conn.handleRequest(c.bot, buffer.Bytes()) }(buffer) } else { global.PutBuffer(buffer) } } if c.reconnectInterval != 0 { time.Sleep(c.reconnectInterval) if typ == "API" { // Universal 不重连,避免多次重连 go c.connect(typ, url, nil) } } } func (c *websocketClient) onBotPushEvent(typ, url string, conn **wsConn) func(e *coolq.Event) { return func(e *coolq.Event) { c.mu.Lock() defer c.mu.Unlock() flt := filter.Find(c.filter) if flt != nil && !flt.Eval(gjson.Parse(e.JSONString())) { log.Debugf("上报Event %s 到 WS服务器 时被过滤.", e.JSONBytes()) return } log.Debugf("向反向WS %s服务器推送Event: %s", typ, e.JSONBytes()) if err := (*conn).WriteText(e.JSONBytes()); err != nil { log.Warnf("向反向WS %s服务器推送 Event 时出现错误: %v", typ, err) _ = (*conn).Close() if c.reconnectInterval != 0 { time.Sleep(c.reconnectInterval) c.connect(typ, url, conn) } } } } func (s *webSocketServer) event(w http.ResponseWriter, r *http.Request) { status := checkAuth(r, s.token) if status != http.StatusOK { log.Warnf("已拒绝 %v 的 WebSocket 请求: Token鉴权失败(code:%d)", r.RemoteAddr, status) w.WriteHeader(status) return } c, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Warnf("处理 WebSocket 请求时出现错误: %v", err) return } err = c.WriteMessage(websocket.TextMessage, []byte(s.handshake)) if err != nil { log.Warnf("WebSocket 握手时出现错误: %v", err) _ = c.Close() return } log.Infof("接受 WebSocket 连接: %v (/event)", r.RemoteAddr) conn := &wsConn{conn: c, apiCaller: api.NewCaller(s.bot)} s.mu.Lock() s.eventConn = append(s.eventConn, conn) s.mu.Unlock() } func (s *webSocketServer) api(w http.ResponseWriter, r *http.Request) { status := checkAuth(r, s.token) if status != http.StatusOK { log.Warnf("已拒绝 %v 的 WebSocket 请求: Token鉴权失败(code:%d)", r.RemoteAddr, status) w.WriteHeader(status) return } c, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Warnf("处理 WebSocket 请求时出现错误: %v", err) return } log.Infof("接受 WebSocket 连接: %v (/api)", r.RemoteAddr) conn := &wsConn{conn: c, apiCaller: api.NewCaller(s.bot)} if s.conf.RateLimit.Enabled { conn.apiCaller.Use(rateLimit(s.conf.RateLimit.Frequency, s.conf.RateLimit.Bucket)) } s.listenAPI(conn) } func (s *webSocketServer) any(w http.ResponseWriter, r *http.Request) { status := checkAuth(r, s.token) if status != http.StatusOK { log.Warnf("已拒绝 %v 的 WebSocket 请求: Token鉴权失败(code:%d)", r.RemoteAddr, status) w.WriteHeader(status) return } c, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Warnf("处理 WebSocket 请求时出现错误: %v", err) return } err = c.WriteMessage(websocket.TextMessage, []byte(s.handshake)) if err != nil { log.Warnf("WebSocket 握手时出现错误: %v", err) _ = c.Close() return } log.Infof("接受 WebSocket 连接: %v (/)", r.RemoteAddr) conn := &wsConn{conn: c, apiCaller: api.NewCaller(s.bot)} if s.conf.RateLimit.Enabled { conn.apiCaller.Use(rateLimit(s.conf.RateLimit.Frequency, s.conf.RateLimit.Bucket)) } s.mu.Lock() s.eventConn = append(s.eventConn, conn) s.mu.Unlock() s.listenAPI(conn) } func (s *webSocketServer) listenAPI(c *wsConn) { defer func() { _ = c.Close() }() for { buffer := global.NewBuffer() t, reader, err := c.conn.NextReader() if err != nil { break } _, err = buffer.ReadFrom(reader) if err != nil { break } if t == websocket.TextMessage { go func(buffer *bytes.Buffer) { defer global.PutBuffer(buffer) c.handleRequest(s.bot, buffer.Bytes()) }(buffer) } else { global.PutBuffer(buffer) } } } func (c *wsConn) handleRequest(_ *coolq.CQBot, payload []byte) { defer func() { if err := recover(); err != nil { log.Errorf("处置WS命令时发生无法恢复的异常:%v\n%s", err, debug.Stack()) _ = c.Close() } }() j := gjson.Parse(utils.B2S(payload)) t := strings.TrimSuffix(j.Get("action").Str, "_async") params := j.Get("params") log.Debugf("WS接收到API调用: %v 参数: %v", t, params.Raw) ret := c.apiCaller.Call(t, onebot.V11, params) if j.Get("echo").Exists() { ret["echo"] = j.Get("echo").Value() } c.mu.Lock() defer c.mu.Unlock() _ = c.conn.SetWriteDeadline(time.Now().Add(time.Second * 15)) writer, err := c.conn.NextWriter(websocket.TextMessage) if err != nil { log.Errorf("无法响应API调用(连接已断开?): %v", err) return } _ = json.NewEncoder(writer).Encode(ret) _ = writer.Close() } func (s *webSocketServer) onBotPushEvent(e *coolq.Event) { flt := filter.Find(s.filter) if flt != nil && !flt.Eval(gjson.Parse(e.JSONString())) { log.Debugf("上报Event %s 到 WS客户端 时被过滤.", e.JSONBytes()) return } s.mu.Lock() defer s.mu.Unlock() j := 0 for i := 0; i < len(s.eventConn); i++ { conn := s.eventConn[i] log.Debugf("向WS客户端推送Event: %s", e.JSONBytes()) if err := conn.WriteText(e.JSONBytes()); err != nil { _ = conn.Close() conn = nil continue } if i != j { // i != j means that some connection has been closed. // use an in-place removal to avoid copying. s.eventConn[j] = conn } j++ } s.eventConn = s.eventConn[:j] } ================================================ FILE: winres/.gitignore ================================================ winres.json ================================================ FILE: winres/gen/json.go ================================================ // Package main generates winres.json package main import ( "bytes" "fmt" "os" "os/exec" "strings" "time" "github.com/Mrs4s/go-cqhttp/internal/base" ) const js = `{ "RT_GROUP_ICON": { "APP": { "0000": [ "icon.png", "icon16.png" ] } }, "RT_MANIFEST": { "#1": { "0409": { "identity": { "name": "go-cqhttp", "version": "%s" }, "description": "", "minimum-os": "vista", "execution-level": "as invoker", "ui-access": false, "auto-elevate": false, "dpi-awareness": "system", "disable-theming": false, "disable-window-filtering": false, "high-resolution-scrolling-aware": false, "ultra-high-resolution-scrolling-aware": false, "long-path-aware": false, "printer-driver-isolation": false, "gdi-scaling": false, "segment-heap": false, "use-common-controls-v6": false } } }, "RT_VERSION": { "#1": { "0000": { "fixed": { "file_version": "%s", "product_version": "%s", "timestamp": "%s" }, "info": { "0409": { "Comments": "Golang implementation of cqhttp.", "CompanyName": "Mrs4s", "FileDescription": "https://github.com/Mrs4s/go-cqhttp", "FileVersion": "%s", "InternalName": "", "LegalCopyright": "©️ 2020 - %d Mrs4s. All Rights Reserved.", "LegalTrademarks": "", "OriginalFilename": "GOCQHTTP.EXE", "PrivateBuild": "", "ProductName": "go-cqhttp", "ProductVersion": "%s", "SpecialBuild": "" } } } } } }` const timeformat = `2006-01-02T15:04:05+08:00` func main() { f, err := os.Create("winres.json") if err != nil { panic(err) } defer f.Close() v := "" if base.Version == "(devel)" { vartag := bytes.NewBuffer(nil) vartagcmd := exec.Command("git", "tag", "--sort=committerdate") vartagcmd.Stdout = vartag err = vartagcmd.Run() if err != nil { panic(err) } s := strings.Split(vartag.String(), "\n") v = s[len(s)-2] } else { v = base.Version } i := strings.Index(v, "-") // remove -rc / -beta if i <= 0 { i = len(v) } commitcnt := strings.Builder{} commitcnt.WriteString(v[1:i]) commitcnt.WriteByte('.') commitcntcmd := exec.Command("git", "rev-list", "--count", "master") commitcntcmd.Stdout = &commitcnt err = commitcntcmd.Run() if err != nil { panic(err) } fv := commitcnt.String()[:commitcnt.Len()-1] _, err = fmt.Fprintf(f, js, fv, fv, v, time.Now().Format(timeformat), fv, time.Now().Year(), v) if err != nil { panic(err) } } ================================================ FILE: winres/init.go ================================================ // Package winres 生成windows资源 package winres //go:generate go run github.com/Mrs4s/go-cqhttp/winres/gen