├── .github ├── dependabot.yml ├── scripts │ ├── bump_cargo_version.py │ ├── extract_version.py │ └── gen_matrix.py └── workflows │ ├── ci.yml │ └── deploy-docs.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── build.rs ├── docs ├── INDEX.nav.template ├── assets │ ├── admonition.js │ ├── chat.css │ ├── curtain.css │ └── extra.css ├── changelog.md ├── gen_ref.py ├── guide │ └── first-steps.md ├── index.md └── overrides │ ├── .icons │ └── graiax.svg │ └── partials │ ├── copyright.html │ └── social.html ├── example.py ├── mkdocs.yml ├── news ├── +fixed-badge-link.fixed.md ├── +fixed-typo.fixed.md ├── +remove-rust-cache.chore.md ├── .gitignore └── template.jinja ├── pdm.lock ├── pyo3-repr ├── Cargo.lock ├── Cargo.toml └── src │ └── lib.rs ├── pyproject.toml ├── python └── ichika │ ├── __init__.py │ ├── build_info.py │ ├── client.py │ ├── core.pyi │ ├── event_defs.py │ ├── exceptions.py │ ├── graia │ ├── __init__.py │ └── event.py │ ├── login │ ├── __init__.py │ ├── password.py │ └── qrcode │ │ ├── __init__.py │ │ └── render │ │ ├── __init__.py │ │ └── dense1x2.py │ ├── message │ ├── __init__.py │ ├── _sealed.py │ ├── _serializer.py │ └── elements.py │ ├── scripts │ ├── __init__.py │ └── device │ │ ├── __init__.py │ │ ├── converter.py │ │ ├── data.json │ │ └── generator.py │ ├── structs.py │ └── utils.py ├── rust-toolchain.toml ├── rustfmt.toml ├── src ├── build_info.rs ├── client │ ├── http.rs │ ├── mod.rs │ ├── params.rs │ └── structs.rs ├── events │ ├── converter.rs │ └── mod.rs ├── exc.rs ├── lib.rs ├── login │ ├── connector.rs │ └── mod.rs ├── loguru.rs ├── message │ ├── convert.rs │ ├── elements.rs │ └── mod.rs └── utils.rs └── towncrier_release.toml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: [] 3 | -------------------------------------------------------------------------------- /.github/scripts/bump_cargo_version.py: -------------------------------------------------------------------------------- 1 | from os import environ as env 2 | from typing import Any 3 | 4 | import tomlkit 5 | 6 | with open("./Cargo.toml") as cargo_file: 7 | doc: Any = tomlkit.load(cargo_file) 8 | doc["package"]["version"] = doc["package"]["version"] + "+dev." + env["GITHUB_SHA"][:7] 9 | with open("./Cargo.toml", "w") as cargo_file: 10 | tomlkit.dump(doc, cargo_file) 11 | -------------------------------------------------------------------------------- /.github/scripts/extract_version.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import tomlkit 4 | from actions_toolkit import core 5 | 6 | with open("./Cargo.toml") as cargo_file: 7 | doc: Any = tomlkit.load(cargo_file) 8 | core.export_variable("VERSION", str(doc["package"]["version"])) 9 | -------------------------------------------------------------------------------- /.github/scripts/gen_matrix.py: -------------------------------------------------------------------------------- 1 | from os import environ as env 2 | 3 | from actions_toolkit import core 4 | 5 | includes = [] 6 | mapping = { 7 | "macos": ["x64", "aarch64", "universal2-apple-darwin"], 8 | "windows": ["x64", "x86", "aarch64"], 9 | "linux-musl": ["x64", "x86", "aarch64", "armv7"], 10 | "linux": ["x64", "x86", "aarch64", "armv7", "s390x", "ppc64", "ppc64le"], 11 | } 12 | core.start_group("Jobs") 13 | 14 | for os, targets in mapping.items(): 15 | for target in targets: 16 | job = { 17 | "name": f"{os}-{target}", 18 | "os": ("ubuntu" if "linux" in os else os) + "-latest", 19 | "target": target, 20 | "build_cmd": "build", 21 | "build_args": ["--out", "dist"], 22 | } 23 | 24 | if env["RELEASE"] == "true": 25 | job["build_args"].append("--release") 26 | 27 | if os == "windows" and target == "x86": 28 | job["py_arch"] = "x86" 29 | 30 | if "linux" in os: 31 | job["manylinux"] = "musllinux_1_2" if "musl" in os else "auto" 32 | 33 | job["build_args"] = " ".join(job["build_args"]) 34 | includes.append(job) 35 | core.info(f"Job: {job}") 36 | 37 | includes.append( 38 | { 39 | "name": "source", 40 | "os": "ubuntu-latest", 41 | "build_cmd": "sdist", 42 | "build_args": "--out dist", 43 | } 44 | ) 45 | core.info(f"Job: {includes[-1]}") 46 | 47 | core.end_group() 48 | 49 | core.set_output("matrix", {"include": includes}) 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Run CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "master" 8 | pull_request: 9 | 10 | env: 11 | RELEASE: ${{ github.event_name == 'workflow_dispatch' }} 12 | 13 | jobs: 14 | lint-python: 15 | name: Lint Python 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/setup-python@v4 20 | with: 21 | python-version: 3.8 22 | - name: Run Black 23 | uses: psf/black@stable 24 | - name: Run ISort 25 | uses: isort/isort-action@v1 26 | 27 | lint-rust: 28 | name: Lint Rust 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 34 | - name: Install Rust nightly 35 | uses: dtolnay/rust-toolchain@nightly 36 | with: 37 | components: rustfmt, clippy 38 | 39 | - name: Run cargo fmt 40 | run: cargo fmt --all -- --check 41 | 42 | - name: Run cargo clippy 43 | run: cargo clippy -- -D warnings 44 | 45 | - name: "Cocogitto: Check Conventional Commit" 46 | run: | 47 | curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash 48 | cargo binstall --no-confirm --version 5.4.0 cocogitto 49 | cog check --ignore-merge-commits 50 | 51 | build-docs: 52 | name: Build Documentation 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v4 56 | - uses: pdm-project/setup-pdm@v3 57 | name: Setup PDM 58 | - name: Install Dependencies 59 | run: pdm install --no-self 60 | - name: MkDocs Build 61 | run: pdm run build-docs 62 | env: 63 | OFFLINE: false 64 | - name: Upload Artifact 65 | uses: actions/upload-artifact@v3 66 | with: 67 | name: docs 68 | path: build 69 | 70 | build-matrix: 71 | name: Create Build Matrix 72 | runs-on: ubuntu-latest 73 | outputs: 74 | matrix: ${{ steps.configure.outputs.matrix }} 75 | steps: 76 | - uses: actions/checkout@v4 77 | - uses: actions/setup-python@v4 78 | with: 79 | python-version: 3.11 80 | - name: Install Actions Toolkit 81 | run: pip install actions_toolkit 82 | - name: Configure Build Matrix 83 | id: configure 84 | run: python ./.github/scripts/gen_matrix.py 85 | 86 | build: 87 | name: Build 88 | needs: [build-matrix] 89 | strategy: 90 | fail-fast: false 91 | matrix: ${{ fromJSON( needs.build-matrix.outputs.matrix ) }} 92 | runs-on: ${{ matrix.os }} 93 | steps: 94 | - uses: actions/checkout@v4 95 | - uses: actions/setup-python@v4 96 | with: 97 | python-version: 3.8 98 | architecture: ${{ matrix.py_arch || 'x64' }} 99 | 100 | - name: Install Dependencies 101 | run: | 102 | pip install tomlkit 103 | pip install actions_toolkit 104 | 105 | - name: Bump Dev Version 106 | if: ${{ env.RELEASE != 'true' }} 107 | run: python ./.github/scripts/bump_cargo_version.py 108 | 109 | - name: Build Wheel 110 | id: build-wheel 111 | uses: PyO3/maturin-action@v1 112 | with: 113 | rust-toolchain: nightly 114 | command: ${{ matrix.build_cmd }} 115 | target: ${{ matrix.target }} 116 | manylinux: ${{ matrix.manylinux }} 117 | args: ${{ matrix.build_args }} 118 | sccache: true 119 | 120 | - name: Upload wheels - Packed 121 | if: ${{ steps.build-wheel.outcome == 'success' }} 122 | uses: actions/upload-artifact@v3 123 | with: 124 | name: wheels 125 | path: dist 126 | 127 | - name: Upload wheels - Unpacked 128 | if: ${{ steps.build-wheel.outcome == 'success' }} 129 | uses: actions/upload-artifact@v3 130 | with: 131 | name: ${{ matrix.name }} 132 | path: dist 133 | 134 | release: 135 | name: Release 136 | runs-on: ubuntu-latest 137 | if: ${{ github.event_name == 'workflow_dispatch' }} 138 | needs: [lint-python, lint-rust, build, build-docs] 139 | environment: release 140 | permissions: write-all 141 | steps: 142 | - uses: actions/checkout@v4 143 | - uses: pdm-project/setup-pdm@v3 144 | name: Setup PDM 145 | - name: Install Dependencies 146 | run: pdm install --no-self 147 | 148 | - name: Download Built Wheels 149 | uses: actions/download-artifact@v3 150 | with: 151 | name: wheels 152 | path: dist 153 | 154 | - name: Build Offline Docs 155 | run: pdm run build-docs 156 | 157 | - name: Compress Documentation Archive 158 | run: zip -9 ./docs.zip -r ./build 159 | 160 | - name: Export Version 161 | run: pdm run python ./.github/scripts/extract_version.py 162 | 163 | - name: Config Git 164 | run: | 165 | git config advice.addIgnoredFile false 166 | git config user.name github-actions[bot] 167 | git config user.email github-actions[bot]@users.noreply.github.com 168 | 169 | - name: Build And Release 170 | run: | 171 | pdm run towncrier build --version $VERSION --keep --config ./towncrier_release.toml 172 | cat ./release-notes.md 173 | pdm run towncrier build --version $VERSION --yes 174 | pdm run pre-commit run --all-files --show-diff-on-failure || true 175 | git add . 176 | git diff-index --quiet HEAD || git commit -m "chore(release): $VERSION" 177 | git push 178 | gh release create "$VERSION" dist/* "./docs.zip#Documentation Archive" --notes-file ./release-notes.md --title "$VERSION" 179 | env: 180 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 181 | 182 | - name: Publish to PyPI 183 | run: pdm publish --no-build 184 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy To Netlify 2 | 3 | on: 4 | workflow_run: 5 | workflows: ["Run CI"] 6 | types: 7 | - completed 8 | 9 | jobs: 10 | deploy-target: 11 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 12 | name: Generate Deployment Target 13 | runs-on: ubuntu-latest 14 | outputs: 15 | environment: ${{ steps.data.outputs.environment }} 16 | alias: ${{ steps.data.outputs.alias }} 17 | pr-number: ${{ steps.data.outputs.pr-number }} 18 | head-sha: ${{ steps.data.outputs.head-sha }} 19 | steps: 20 | - name: Prepare Data 21 | id: data 22 | uses: actions/github-script@v6 23 | with: 24 | github-token: ${{ secrets.GITHUB_TOKEN }} 25 | script: | 26 | const workflowRun = await github.rest.actions.getWorkflowRun({ 27 | owner: context.repo.owner, 28 | repo: context.repo.repo, 29 | run_id: context.payload.workflow_run.id, 30 | }); 31 | if (workflowRun.data.event === "pull_request") { 32 | core.setOutput("alias", `pull-${workflowRun.data.pull_requests[0].number}`); 33 | core.setOutput("environment", "preview"); 34 | core.setOutput("pr-number", workflowRun.data.pull_requests[0].number); 35 | core.setOutput("head-sha", workflowRun.data.pull_requests[0].head.sha); 36 | } else { 37 | core.setOutput("alias", workflowRun.data.head_sha); 38 | core.setOutput("environment", workflowRun.data.event === "workflow_dispatch" ? "release" : "commit"); 39 | core.setOutput("pr-number", 0); 40 | core.setOutput("head-sha", workflowRun.data.head_sha); 41 | } 42 | 43 | 44 | deploy-docs: 45 | name: Deploy To Netlify 46 | runs-on: ubuntu-latest 47 | needs: [deploy-target] 48 | environment: ${{ needs.deploy-target.outputs.environment }} 49 | permissions: 50 | contents: write 51 | pull-requests: write 52 | deployments: write 53 | steps: 54 | - name: Download Documentation Artifact 55 | uses: actions/github-script@v6 56 | with: 57 | github-token: ${{ secrets.GITHUB_TOKEN }} 58 | script: | 59 | const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ 60 | owner: context.repo.owner, 61 | repo: context.repo.repo, 62 | run_id: context.payload.workflow_run.id, 63 | }); 64 | const artifact = artifacts.data.artifacts.find( 65 | (artifact) => artifact.name === "docs" 66 | ); 67 | const response = await github.rest.actions.downloadArtifact({ 68 | owner: context.repo.owner, 69 | repo: context.repo.repo, 70 | artifact_id: artifact.id, 71 | archive_format: "zip", 72 | }); 73 | // Write the artifact to disk 74 | const fs = require("fs"); 75 | fs.writeFileSync("docs.zip", Buffer.from(response.data)); 76 | await exec.exec("unzip", ["docs.zip", "-d", "build"]); 77 | 78 | - name: Deploy to Netlify 79 | id: deploy 80 | uses: nwtgck/actions-netlify@v2.0 81 | with: 82 | publish-dir: "./build" 83 | production-deploy: ${{ github.event.workflow_run.event == 'workflow_dispatch' }} 84 | github-token: ${{ secrets.GITHUB_TOKEN }} 85 | deploy-message: "Deploy ${{ needs.deploy-target.outputs.alias }} to Netlify" 86 | enable-pull-request-comment: false 87 | enable-commit-comment: false 88 | alias: ${{ needs.deploy-target.outputs.alias }} 89 | github-deployment-environment: ${{ needs.deploy-target.outputs.environment }} 90 | env: 91 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 92 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 93 | 94 | - name: Add Sticky Comment to PR 95 | if: ${{ github.event.workflow_run.event == 'pull_request' }} 96 | uses: marocchino/sticky-pull-request-comment@v2 97 | with: 98 | number: ${{ needs.deploy-target.outputs.pr-number }} 99 | message: | 100 | :rocket: Preview is ready at Netlify: ${{ steps.deploy.outputs.deploy-url }} 101 | Built with commit: ${{ needs.deploy-target.outputs.head-sha }} 102 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 103 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | .pytest_cache/ 6 | *.py[cod] 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | .venv/ 14 | env/ 15 | bin/ 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | include/ 26 | man/ 27 | venv/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | pip-selfcheck.json 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | 45 | # Translations 46 | *.mo 47 | 48 | # Mr Developer 49 | .mr.developer.cfg 50 | .project 51 | .pydevproject 52 | 53 | # Rope 54 | .ropeproject 55 | 56 | # Django stuff: 57 | *.log 58 | *.pot 59 | 60 | .DS_Store 61 | 62 | # Sphinx documentation 63 | docs/_build/ 64 | sphinx-docs.zip 65 | 66 | # PyCharm 67 | .idea/ 68 | 69 | # VSCode 70 | .vscode/ 71 | 72 | # Pyenv 73 | .python-version 74 | 75 | # PDM 76 | .pdm-python 77 | 78 | # Ichika Tests 79 | bots/ 80 | local/ 81 | play/ 82 | images/ 83 | release-notes.md 84 | docs.zip 85 | t544_enc/ 86 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | skip: [fmt, clippy] 3 | autoupdate_commit_msg: "chore(deps-pre-commit): pre-commit auto update" 4 | autofix_commit_msg: "chore(fix, ci): apply auto fixes" 5 | repos: 6 | - repo: local 7 | hooks: 8 | - id: fmt 9 | name: Run cargo fmt. 10 | description: Format files with cargo fmt. 11 | entry: cargo fmt 12 | language: system 13 | pass_filenames: false 14 | types: [rust] 15 | files: \.rs$ 16 | args: ["--all"] 17 | - id: clippy 18 | name: Run cargo clippy. 19 | description: Checks a package to catch common mistakes and improve your Rust code. 20 | entry: cargo clippy 21 | language: system 22 | pass_filenames: false 23 | types: [rust] 24 | files: \.rs$ 25 | args: ["--", "-D", "warnings"] 26 | - repo: https://github.com/pre-commit/pre-commit-hooks 27 | rev: v4.4.0 28 | hooks: 29 | - id: trailing-whitespace 30 | - id: end-of-file-fixer 31 | - id: check-toml 32 | - id: check-yaml 33 | args: [--unsafe] 34 | - repo: https://github.com/psf/black 35 | rev: 23.3.0 36 | hooks: 37 | - id: black 38 | - repo: https://github.com/PyCQA/isort 39 | rev: 5.12.0 40 | hooks: 41 | - id: isort 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 更新日志 2 | 3 | 本文件使用 [Keep a Changelog](https://keepachangelog.com/zh-CN) 格式。 4 | 5 | 本项目使用 [towncrier](https://towncrier.readthedocs.io) 作为更新日志生成器。 6 | 所有处理的问题可在 [GitHub Issues](https://github.com/BlueGlassBlock/Ichika/issues) 找到。 7 | 8 | 9 | 10 | ## [0.0.8](https://github.com/GraiaProject/Ichika/tree/0.0.8) - 2023-05-24 11 | 12 | 你可以在 [PyPI](https://pypi.org/project/ichika/0.0.8/) 找到该版本。 13 | 14 | ### 新增 15 | 16 | - 增加了 `At.build` 方法。 17 | - 增加了 `ForwardMessage.build` 方法以方便构建转发消息。 18 | 19 | 20 | ### 更改 21 | 22 | - `Client` 中好友和群组的部分 API 支持直接传入 `Friend` 和 `Group` 对象。 23 | - `Client` 的消息发送方法支持更多样的类型,包括单个字符串/元素/元素列表等。 24 | 25 | 26 | ### 修复 27 | 28 | - 修复了调用 `put` 回调时 Python 异步上下文丢失的问题。 29 | 30 | 31 | ## [0.0.7](https://github.com/GraiaProject/Ichika/tree/0.0.7) - 2023-05-23 32 | 33 | 你可以在 [PyPI](https://pypi.org/project/ichika/0.0.7/) 找到该版本。 34 | 35 | ### 新增 36 | 37 | - 支持解析 `LightApp` 格式的转发消息。[mamoe/mirai#2618](https://github.com/mamoe/mirai/issues/2618) 38 | 39 | 40 | ### 修复 41 | 42 | - 修复了事件在 `Union` 中无法正常获取的问题。 43 | - 修复了扫码登录的逻辑。 44 | 45 | 46 | ## [0.0.6](https://github.com/GraiaProject/Ichika/tree/0.0.6) - 2023-05-13 47 | 48 | 你可以在 [PyPI](https://pypi.org/project/ichika/0.0.6/) 找到该版本。 49 | 50 | ### 新增 51 | 52 | - 使用 [`backon`](https://docs.rs/backon) 提供自动重试。 ([#55](https://github.com/GraiaProject/Ichika/issues/55)) 53 | - `Member.card_name` 现在表示合并后的名片。原始名片存储于 `Member.raw_card_name` 中。 ([#56](https://github.com/GraiaProject/Ichika/issues/56)) 54 | - 支持处理群名更新事件。 55 | - 添加 `Client.get_profile` 以获取用户公开资料。 56 | - 添加了获取群员列表的方法。 57 | 58 | 59 | ### 更改 60 | 61 | - 优化了首次登录即失败以及退出时掉线的逻辑。 ([#57](https://github.com/GraiaProject/Ichika/issues/57)) 62 | - `Client.get_group_admins` 的返回类型更改为 `list[Member]`。 ([#65](https://github.com/GraiaProject/Ichika/issues/65)) 63 | - 使用 `Enum` 表示性别和权限。 ([#68](https://github.com/GraiaProject/Ichika/issues/68)) 64 | - 使用 `Literal` 标注了可用密码登录的协议列表。 65 | - 更改了 Rust 侧日志的显示风格。 66 | - 现在自动重连将采取最小 3s,最大 60s,每次增长 1.2 倍的间隔时间,并不再主动停止重试。 67 | - 现在要使用刷新缓存的 API 应传入 `cache = False` 而不是调用 `get_xxx_raw` 方法。 68 | - 设定每个账号的群员和群的缓存大小为 1024。 69 | - 重命名 `ichika.core.Profile.sex` 为 `ichika.core.Profile.gender`。 70 | - 默认限制使用 4 个线程进行操作。你可以通过 `ICHIKA_RUNTIME_THREAD_COUNT` 环境变量来修改这个限制。 71 | 72 | 73 | ### 修复 74 | 75 | - 修复了事件无法正确在 Union 中分发的 bug。 ([#58](https://github.com/GraiaProject/Ichika/issues/58)) 76 | - 修复 `At` 的 `target` 属性发送时被忽略的问题。 ([#59](https://github.com/GraiaProject/Ichika/issues/59)) 77 | - 修复了 `GroupMute` 在 Rust 端提供参数名不吻合的问题。 ([#60](https://github.com/GraiaProject/Ichika/issues/60)) 78 | - 修复了 `IchikaComponent` 在 cleanup 阶段分发事件导致的错误。 ([#61](https://github.com/GraiaProject/Ichika/issues/61)) 79 | - 客户端注册失败现在会直接报错。 ([#67](https://github.com/GraiaProject/Ichika/issues/67)) 80 | - 修复了因网络原因掉线时,无法多次重试的问题。 ([#69](https://github.com/GraiaProject/Ichika/issues/69)) 81 | - 修复了事件的属性无法被类型检查器正常识别的问题。 82 | 83 | 84 | ## [0.0.5](https://github.com/GraiaProject/Ichika/tree/0.0.5) - 2023-05-03 85 | 86 | 你可以在 [PyPI](https://pypi.org/project/ichika/0.0.5/) 找到该版本。 87 | 88 | ### 新增 89 | 90 | - 增加了适用于 `Launart` 的 `IchikaComponent` 可启动组件。 91 | - 支持上传与发送音频。 92 | - 支持发送和接收“回复”元素。请注意该元素和图片一起使用时可能发生 bug。 93 | - 支持处理“请求”事件(好友申请、加群申请、入群邀请)。 94 | - 支持处理全体禁言和群员禁言事件。 95 | - 支持处理其他群员退群事件。 96 | - 支持处理删除好友事件(无论是主动还是被动)。 97 | - 支持处理新增好友事件。 98 | - 支持处理新成员进群事件。 99 | - 支持处理群员权限更新事件。 100 | - 支持处理群解散事件。 101 | - 支持接收、下载和上传转发消息。 102 | - 支持接收和发送音乐分享。 103 | - 支持接收好友申请、加群申请与被邀请入群事件。 104 | - 添加了 `Android Pad` 协议。 105 | - 添加了基础的 [`Graia Project`](https://github.com/GraiaProject) 绑定。 106 | 107 | 108 | ### 更改 109 | 110 | - 使用异步登录回调。 ([#25](https://github.com/GraiaProject/Ichika/issues/25)) 111 | - 群组事件的 `Group` 对象不再挂靠于 `MemberInfo`,而是存储于 `Group` 属性。 ([#29](https://github.com/GraiaProject/Ichika/issues/29)) 112 | - 使用 `dict` 作为事件传递结构以方便其他框架绑定。 ([#34](https://github.com/GraiaProject/Ichika/issues/34)) 113 | - 使用 `str` 作为 `protocol` 值,并同步所有协议至最新版本。 114 | - 更改了构建信息的键名。 115 | 116 | 117 | ### 修复 118 | 119 | - 暂时删除了来自 RICQ 的无用 `LoginEvent` 以避免启动时的报错。 120 | 121 | 122 | ### 其他 123 | 124 | - 升级 [`syn`](https://github.com/dtolnay/syn) 至 `2.x.x`。 125 | 126 | 127 | ## [0.0.4](https://github.com/GraiaProject/Ichika/tree/0.0.4) - 2023-03-17 128 | 129 | 你可以在 [PyPI](https://pypi.org/project/ichika/0.0.4/) 找到该版本。 130 | 131 | ### 新增 132 | 133 | - 支持处理群聊和好友撤回消息事件 ([#22](https://github.com/GraiaProject/Ichika/issues/22)) 134 | - 修复了消息元素的 `__repr__` 显示。 135 | - 支持好友和群组的拍一拍(双击头像触发)事件。 136 | 137 | 138 | ### 更改 139 | 140 | - 使用 `asyncio.Queue` 而不是回调函数来处理事件。 141 | 142 | `Queue.put` 的任务上下文会与 `ichika.login.xxx_login` 的调用者一致。 143 | 144 | 145 | ## [0.0.3](https://github.com/GraiaProject/Ichika/tree/0.0.3) - 2023-03-16 146 | 147 | 你可以在 [PyPI](https://pypi.org/project/ichika/0.0.3/) 找到该版本。 148 | 149 | 150 | 151 | ### 新增 152 | 153 | - 支持以下 API: 154 | - 发送消息 155 | - 拍一拍 156 | - 撤回消息 157 | - 获取群信息 158 | - 获取好友列表 159 | - 获取群员信息 160 | - 获取好友信息 161 | - 获取自身信息 162 | - 修改名片 163 | - 查询群管理员 164 | - 修改群员信息 165 | - 修改群员权限 166 | - 修改群信息 167 | - 群聊打卡 168 | - 修改自身信息 169 | - 修改在线状态 170 | - 退出群聊 171 | - 设置群精华 172 | - 踢出群员 173 | - 删除好友 174 | - 禁言 175 | - 取消禁言 176 | - 全体禁言 177 | - 取消全体禁言 178 | 179 | ### 其他 180 | 181 | - 使用 [`towncrier`](https://towncrier.readthedocs.io) 和 GitHub Release 来管理项目。 ([#18](https://github.com/GraiaProject/Ichika/issues/18)) 182 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ichika" 3 | authors = ["BlueGlassBlock "] 4 | version = "0.0.8" 5 | license = "AGPL-3.0" 6 | edition = "2021" 7 | include = [ 8 | "/python", 9 | "/src", 10 | "Cargo.lock", 11 | "pyproject.toml", 12 | "!__pycache__", 13 | "!*.so", 14 | ] 15 | 16 | [lib] 17 | name = "ichika" 18 | crate-type = ["cdylib"] 19 | 20 | [dependencies] 21 | pyo3 = { version = "0.18", features = ["extension-module", "anyhow", "abi3", "abi3-py38", "multiple-pymethods", "nightly"] } 22 | pyo3-asyncio = { version = "0.18", features = ["tokio-runtime"] } 23 | ricq = { version = "0.1.20", features = ["image-detail"]} 24 | ricq-core = "0.1" 25 | tokio = { version = "1", features = ["rt"] } 26 | tokio-util = { version = "0.7", features = ["codec"] } 27 | tokio-stream = "0.1" 28 | futures-util = "0.3" 29 | tracing = "0.1" 30 | tracing-subscriber = { version = "0.3" } 31 | serde_json = "1" 32 | hex = "0.4.3" 33 | bytes = "1" 34 | rqrr = "0.6" 35 | qrcode = "0.12" 36 | image = "0.24" 37 | async-trait = "0.1.72" 38 | serde = "1.0" 39 | pythonize = "0.18" 40 | pyo3-repr = { version = "0.1.0", path = "pyo3-repr" } 41 | once_cell = "1.18.0" 42 | lru_time_cache = "0.11.11" 43 | backon = "0.4.1" 44 | t544_enc = { git = "https://github.com/LaoLittle/t544_enc" } 45 | 46 | [patch.crates-io] 47 | ricq = { git = "https://github.com/BlueGlassBlock/ricq.git", branch = "ichika-snapshot"} 48 | ricq-core = { git = "https://github.com/BlueGlassBlock/ricq.git", branch = "ichika-snapshot" } 49 | 50 | [build-dependencies] 51 | built = { version = "0.6", features = ["chrono"] } 52 | 53 | [profile.release] 54 | lto = true 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Ichika 4 | 5 | 6 | [![PyPI](https://img.shields.io/pypi/v/ichika)](https://github.com/GraiaProject/Ichika/blob/master/CHANGELOG.md) 7 | [![Python Version](https://img.shields.io/pypi/pyversions/ichika)](https://pypi.org/project/ichika) 8 | [![License](https://img.shields.io/github/license/BlueGlassBlock/Ichika)](https://github.com/BlueGlassBlock/Ichika/blob/master/LICENSE) 9 | 10 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 11 | [![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) 12 | 13 | [![Deploy Docs](https://github.com/BlueGlassBlock/Ichika/actions/workflows/deploy-docs.yml/badge.svg)](https://github.com/BlueGlassBlock/Ichika/actions/workflows/deploy-docs.yml/badge.svg) 14 | [![Run CI](https://github.com/BlueGlassBlock/Ichika/actions/workflows/ci.yml/badge.svg)](https://github.com/BlueGlassBlock/Ichika/actions/workflows/ci.yml) 15 | [![pre-commit.ci status](https://results.pre-commit.ci/badge/github/BlueGlassBlock/Ichika/master.svg)](https://results.pre-commit.ci/latest/github/BlueGlassBlock/Ichika/master) 16 | 17 |
18 | 19 | ## 示例 20 | 21 | 请参阅 [example.py](./example.py),它包含了一个识别指定指令并发送图片的简单示例。 22 | 23 | 24 | 25 | ## 鸣谢 26 | 27 | - [AWR](https://github.com/wybxc/awr): Ichika 的前身和部分代码来源 28 | - [ricq](https://github.com/lz1998/ricq): 使用 Rust 实现的高性能 QQ 协议 移植于 [OICQ](https://github.com/takayama-lily/oicq) 29 | - [mirai](https://github.com/mamoe/mirai): 高效率 QQ 机器人支持库 30 | - [PyO3](https://github.com/PyO3/PyO3): Python 解释器的 Rust 绑定 31 | - [GraiaProject](https://github.com/GraiaProject): 用于 Bot 开发的一系列高效, 现代化, 充分可扩展的工具链 32 | 33 | ## 许可证 34 | 35 | `Ichika` 使用 [`GNU AGPL-3.0`](https://choosealicense.com/licenses/agpl-3.0/) 作为许可证,这意味着你需要遵守相应的规则。 36 | 37 | 38 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | let src = std::env::var("CARGO_MANIFEST_DIR").unwrap(); 3 | let dst = std::path::Path::new(&std::env::var("OUT_DIR").unwrap()).join("build-info.rs"); 4 | let mut opts = built::Options::default(); 5 | 6 | opts.set_dependencies(true).set_compiler(true).set_env(true); 7 | 8 | built::write_built_file_with_opts(&opts, std::path::Path::new(&src), &dst) 9 | .expect("Failed to acquire build-time information"); 10 | } 11 | -------------------------------------------------------------------------------- /docs/INDEX.nav.template: -------------------------------------------------------------------------------- 1 | * [首页](./index.md) 2 | * [更新日志](./changelog.md) 3 | * 教程 4 | * [第一步](./guide/first-steps.md) 5 | * API 文档 6 | -------------------------------------------------------------------------------- /docs/assets/admonition.js: -------------------------------------------------------------------------------- 1 | 2 | const admonition_titles = document.getElementsByClassName("admonition-title") 3 | for (let i = 0; i < admonition_titles.length; i++) { 4 | let color = window.getComputedStyle(admonition_titles[i]).borderColor 5 | admonition_titles[i].style.color = color 6 | } 7 | const admonition_summaries = document.getElementsByTagName("SUMMARY") 8 | for (let i = 0; i < admonition_summaries.length; i++) { 9 | let color = window.getComputedStyle(admonition_summaries[i]).borderColor 10 | admonition_summaries[i].style.color = color 11 | } 12 | 13 | function reload_color() { 14 | var p = localStorage.getItem("data-md-color-primary"); 15 | if (p) { 16 | document.body.setAttribute('data-md-color-primary', p); 17 | } 18 | var a = localStorage.getItem("data-md-color-accent"); 19 | if (a) { 20 | document.body.setAttribute('data-md-color-accent', a); 21 | } 22 | } 23 | 24 | window.addEventListener('change', reload_color, false); 25 | window.addEventListener('load', reload_color, false); 26 | -------------------------------------------------------------------------------- /docs/assets/chat.css: -------------------------------------------------------------------------------- 1 | html .chat { 2 | clear: both; 3 | padding: 10px; 4 | border-radius: 20px; 5 | margin-bottom: 2px; 6 | color: var(--md-default-bg-color); 7 | font-family: var(--md-code-font-family); 8 | } 9 | 10 | html .left { 11 | float: left; 12 | background: var(--md-code-hl-function-color); 13 | border-top-left-radius: 2px; 14 | } 15 | 16 | html .right { 17 | float: right; 18 | background: var(--md-code-hl-keyword-color); 19 | border-top-right-radius: 2px; 20 | } 21 | -------------------------------------------------------------------------------- /docs/assets/curtain.css: -------------------------------------------------------------------------------- 1 | html .curtain 2 | { 3 | transition: 0.125s linear; 4 | background-color: var(--md-code-fg-color); 5 | color: var(--md-code-fg-color); 6 | text-shadow: none; 7 | text-decoration-line: none; 8 | border-radius: 0.18rem; 9 | } 10 | 11 | html .curtain:hover { 12 | transition: 0.125s linear; 13 | color: var(--md-code-bg-color); 14 | } 15 | -------------------------------------------------------------------------------- /docs/assets/extra.css: -------------------------------------------------------------------------------- 1 | .md-social__link { 2 | font-size: auto; 3 | width: auto; 4 | } 5 | 6 | body { 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | 10 | /* Font with fallback for code */ 11 | --md-code-font-family: var(--md-code-font, _), SFMono-Regular, Consolas, 12 | Menlo, var(--md-text-font, _); 13 | 14 | } 15 | 16 | ul.clear-list { 17 | list-style-type: none; 18 | margin: 0; 19 | padding: 0; 20 | } 21 | 22 | .mdx-switch button>code { 23 | cursor: pointer; 24 | transition: opacity 250ms; 25 | display: block; 26 | color: var(--md-primary-bg-color); 27 | background-color: var(--md-primary-fg-color); 28 | } 29 | 30 | :root { 31 | --md-admonition-icon--graiax: url('data:image/svg+xml;charset=utf-8,') 32 | } 33 | 34 | .md-typeset .admonition.graiax, 35 | .md-typeset details.graiax { 36 | border-color: #1e7a73; 37 | } 38 | 39 | .md-typeset .graiax>.admonition-title, 40 | .md-typeset .graiax>summary { 41 | background-color: #fd9bac0f; 42 | border-color: #1e7a73; 43 | } 44 | 45 | .md-typeset .graiax>.admonition-title::before, 46 | .md-typeset .graiax>summary::before { 47 | background-color: #1e7a73; 48 | -webkit-mask-image: var(--md-admonition-icon--graiax); 49 | mask-image: var(--md-admonition-icon--graiax); 50 | } 51 | 52 | /* Indentation. */ 53 | div.doc-contents:not(.first) { 54 | padding-left: 25px; 55 | border-left: .05rem solid var(--md-default-fg-color--lightest); 56 | margin-bottom: 80px; 57 | } 58 | 59 | 60 | .highlight .gp, 61 | .highlight .go { 62 | /* Generic.Prompt, Generic.Output */ 63 | user-select: none; 64 | } 65 | -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ```python exec="true" 2 | from pathlib import Path 3 | import subprocess as proc 4 | import tomlkit 5 | 6 | REPLACE_TAG = "" 7 | 8 | cwd = Path.cwd() 9 | version: str = tomlkit.loads(Path(cwd, "Cargo.toml").read_text("utf-8"))["package"]["version"] 10 | changelog = Path(cwd, "CHANGELOG.md").read_text("utf-8") 11 | rendered = proc.run( 12 | ["towncrier", "build", "--draft", "--keep", "--name", "ichika", "--version", version], 13 | stdout=proc.PIPE, 14 | stderr=proc.DEVNULL, 15 | text=True 16 | ).stdout 17 | print(changelog.replace(REPLACE_TAG, rendered)) 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/gen_ref.py: -------------------------------------------------------------------------------- 1 | """Generate the code reference pages and navigation.""" 2 | import sys 3 | from pathlib import Path 4 | from textwrap import indent 5 | 6 | from mkdocs_gen_files.editor import FilesEditor 7 | from mkdocs_gen_files.nav import Nav 8 | 9 | nav = Nav() 10 | 11 | fe = FilesEditor.current() 12 | 13 | root = Path(__file__).parent.parent 14 | docs_dir = root / "docs" 15 | 16 | src = (root / "python").resolve() 17 | sys.path.append(src.as_posix()) 18 | 19 | core_path = Path(src, "ichika", "core.pyi") 20 | core_module_path = core_path.relative_to(src).with_suffix("") 21 | core_full_doc_path = core_path.relative_to(src / "ichika").with_suffix(".md") 22 | core_parts = list(core_module_path.parts) 23 | core_full_doc_path = ("api" / core_full_doc_path).as_posix() 24 | nav[tuple(core_parts)] = core_full_doc_path 25 | 26 | core_mkdocstrings_options = """\ 27 | options: 28 | filters: ["!^_"] 29 | """ 30 | with fe.open(core_full_doc_path, "w") as f: 31 | print(f"::: {'.'.join(core_parts)}", file=f) 32 | print(indent(core_mkdocstrings_options, " "), file=f) 33 | 34 | fe.set_edit_path(core_full_doc_path, core_path.as_posix()) 35 | 36 | for path in sorted(Path(src, "ichika").glob("**/*.py")): 37 | module_path = path.relative_to(src).with_suffix("") 38 | full_doc_path = path.relative_to(src / "ichika").with_suffix(".md") 39 | 40 | parts = list(module_path.parts) 41 | if parts[-1] == "__init__": 42 | parts = parts[:-1] 43 | full_doc_path = full_doc_path.with_name("index.md") 44 | elif parts[-1] == "__main__" or parts[-1].startswith("_"): 45 | continue 46 | full_doc_path = ("api" / full_doc_path).as_posix() 47 | nav[tuple(parts)] = full_doc_path 48 | 49 | with fe.open(full_doc_path, "w") as f: 50 | print(f"::: {'.'.join(parts)}", file=f) 51 | 52 | fe.set_edit_path(full_doc_path, path.as_posix()) 53 | 54 | with fe.open("INDEX.nav", "w") as nav_file: 55 | nav_file.write(Path(docs_dir, "./INDEX.nav.template").read_text("utf-8")) 56 | nav_file.writelines(nav.build_literate_nav(indentation=4)) 57 | -------------------------------------------------------------------------------- /docs/guide/first-steps.md: -------------------------------------------------------------------------------- 1 | # 第一步 2 | 3 | ## 安装 4 | 5 | ## 登录 6 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Ichika 文档 2 | 3 | ## 示例 4 | 5 | 以下示例包含了一个识别指定指令并发送图片的简单示例。 6 | 7 | ```python 8 | {! include "../example.py" !} 9 | ``` 10 | 11 | {! 12 | include-markdown "../README.md" 13 | start="" 14 | end="" 15 | !} 16 | -------------------------------------------------------------------------------- /docs/overrides/.icons/graiax.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/overrides/partials/copyright.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /docs/overrides/partials/social.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | {% for social in config.extra.social %} 4 | {% set title = social.name %} 5 | {% if not title and "//" in social.link %} 6 | {% set _, url = social.link.split("//")%} 7 | {% set title = url.split("/")[0] %} 8 | {% endif %} 9 | 10 |
    11 |
  • 12 | {% include ".icons/" ~ social.icon ~ ".svg" %} 13 |
  • 14 |
  • 15 | {{ title }} 16 |
  • 17 |
18 |
19 | {% endfor %} 20 |
21 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from typing_extensions import Annotated 3 | 4 | import creart 5 | from graia.amnesia.message import MessageChain, Text 6 | from graia.broadcast import Broadcast 7 | from graiax.shortcut.text_parser import MatchTemplate, StartsWith 8 | from launart import Launart 9 | 10 | from ichika.client import Client 11 | from ichika.core import Group 12 | from ichika.graia import IchikaComponent 13 | from ichika.graia.event import GroupMessage 14 | from ichika.login import PathCredentialStore 15 | from ichika.message.elements import Image 16 | 17 | broadcast = creart.create(Broadcast) 18 | 19 | 20 | @broadcast.receiver(GroupMessage) 21 | async def listener( 22 | client: Client, 23 | group: Group, # 获取事件发生的群组 24 | image: Annotated[MessageChain, StartsWith("来张图"), MatchTemplate([Text])] 25 | # 获取消息内容,其要求如下: 26 | # 1. 以“来张图”开头,后可跟至多一个空格 27 | # 2. 剩下的部分均为文字 28 | ): 29 | image_bytes = open(f"./images/{str(image)}.png", "rb").read() 30 | await client.send_group_message(group.uin, MessageChain([Text("图来了!\n"), Image.build(image_bytes)])) 31 | 32 | 33 | @broadcast.receiver(GroupMessage) 34 | async def log(event: GroupMessage): 35 | from loguru import logger 36 | 37 | logger.info( 38 | f"[{event.group.name}:{event.group.uin}]({event.sender.card_name}:{event.sender.uin}) -> {event.content}" 39 | ) 40 | 41 | 42 | mgr = Launart() 43 | mgr.add_launchable( 44 | IchikaComponent(PathCredentialStore("./var/bots"), broadcast).add_password_login( 45 | int(environ["ACCOUNT"]), environ["PASSWORD"] 46 | ) 47 | ) 48 | mgr.launch_blocking(loop=broadcast.loop) 49 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Ichika 2 | site_description: 轻量,完备的 Python QQ 自动化框架。 3 | site_author: BlueGlassBlock 4 | 5 | watch: 6 | - python/ 7 | 8 | theme: 9 | features: 10 | - navigation.tabs 11 | - navigation.tabs.sticky 12 | - navigation.expand 13 | - navigation.top 14 | 15 | name: material 16 | language: zh 17 | icon: 18 | repo: fontawesome/brands/git-alt 19 | logo: material/file-document-multiple-outline 20 | 21 | font: 22 | text: Noto Sans Simplified Chinese 23 | code: JetBrains Mono 24 | 25 | custom_dir: docs/overrides 26 | 27 | palette: 28 | - media: "(prefers-color-scheme: light)" 29 | scheme: default 30 | toggle: 31 | icon: material/toggle-switch-off-outline 32 | name: 黑暗模式 33 | - media: "(prefers-color-scheme: dark)" 34 | scheme: slate 35 | toggle: 36 | icon: material/toggle-switch 37 | name: 明亮模式 38 | 39 | extra_css: 40 | - "assets/chat.css" 41 | - "assets/extra.css" 42 | - "assets/curtain.css" 43 | 44 | extra_javascript: 45 | - "assets/admonition.js" 46 | 47 | site_url: https://ichika.graia.cn/ 48 | repo_url: https://github.com/BlueGlassBlock/Ichika 49 | repo_name: BlueGlassBlock/Ichika 50 | edit_uri: blob/master/ 51 | site_dir: build 52 | 53 | copyright: Copyright © BlueGlassBlock 2023 - present. 54 | 55 | extra: 56 | social: 57 | - icon: graiax 58 | link: https://graiax.cn/ 59 | name: GraiaX 60 | - icon: fontawesome/brands/github 61 | link: https://github.com/BlueGlassBlock 62 | name: GitHub 63 | 64 | markdown_extensions: 65 | - attr_list 66 | - md_in_html 67 | - admonition 68 | - footnotes # 脚注 69 | - meta # 定义元数据, 通过文章上下文控制, 如disqus 70 | - pymdownx.caret # 下划线上标 71 | - pymdownx.tilde # 删除线下标 72 | - pymdownx.critic # 增加删除修改高亮注释, 可修饰行内或段落 73 | - pymdownx.details # 提示块可折叠 74 | - pymdownx.inlinehilite # 行内代码高亮 75 | - pymdownx.highlight 76 | - pymdownx.snippets 77 | - pymdownx.mark # 文本高亮 78 | - pymdownx.smartsymbols # 符号转换 79 | - pymdownx.superfences # 代码嵌套在列表里 80 | - pymdownx.keys 81 | - codehilite: # 代码高亮, 显示行号 82 | guess_lang: false 83 | linenums: true 84 | - toc: # 锚点 85 | permalink: 🔗 86 | - pymdownx.arithmatex # 数学公式 87 | - pymdownx.tasklist: # 复选框checklist 88 | custom_checkbox: true 89 | - pymdownx.tabbed: 90 | alternate_style: true 91 | 92 | plugins: 93 | - search 94 | - markdown-exec 95 | - include-markdown: 96 | opening_tag: "{!" 97 | closing_tag: "!}" 98 | - gen-files: 99 | scripts: 100 | - docs/gen_ref.py 101 | - mkdocstrings: 102 | handlers: 103 | python: 104 | paths: [./python] 105 | import: 106 | - https://docs.python.org/zh-cn/3/objects.inv 107 | - https://docs.aiohttp.org/en/stable/objects.inv 108 | options: 109 | docstring_style: sphinx 110 | show_submodules: false 111 | show_signature_annotations: true 112 | separate_signature: true 113 | show_if_no_docstring: true 114 | docstring_section_style: list 115 | line_length: 110 116 | - literate-nav: 117 | nav_file: INDEX.nav 118 | - section-index 119 | - offline: 120 | enabled: !ENV [OFFLINE, true] 121 | -------------------------------------------------------------------------------- /news/+fixed-badge-link.fixed.md: -------------------------------------------------------------------------------- 1 | 修复了 `README.md` 中的一处 badge 链接 2 | -------------------------------------------------------------------------------- /news/+fixed-typo.fixed.md: -------------------------------------------------------------------------------- 1 | 修复了 `core.pyi` 中的一处 Typo 2 | -------------------------------------------------------------------------------- /news/+remove-rust-cache.chore.md: -------------------------------------------------------------------------------- 1 | 删除了 rust 侧 **所有** 的群组, 好友与群成员缓存相关 -------------------------------------------------------------------------------- /news/.gitignore: -------------------------------------------------------------------------------- 1 | !.gitignore 2 | -------------------------------------------------------------------------------- /news/template.jinja: -------------------------------------------------------------------------------- 1 | 你可以在 [PyPI](https://pypi.org/project/ichika/{{ versiondata["version"] }}/) 找到该版本。 2 | {% if sections[""] %} 3 | {% for category, val in definitions.items() if category in sections[""] %} 4 | 5 | ### {{ definitions[category]['name'] }} 6 | 7 | {% for text, values in sections[""][category].items() %} 8 | - {{ text }} {{ values|join(', ') }} 9 | {% endfor %} 10 | 11 | {% endfor %} 12 | {% else %} 13 | 没有重要的改动。 14 | {% endif %} 15 | -------------------------------------------------------------------------------- /pyo3-repr/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "proc-macro2" 7 | version = "1.0.63" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb" 10 | dependencies = [ 11 | "unicode-ident", 12 | ] 13 | 14 | [[package]] 15 | name = "pyo3-repr" 16 | version = "0.1.0" 17 | dependencies = [ 18 | "proc-macro2", 19 | "quote", 20 | "syn", 21 | ] 22 | 23 | [[package]] 24 | name = "quote" 25 | version = "1.0.28" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "1b9ab9c7eadfd8df19006f1cf1a4aed13540ed5cbc047010ece5826e10825488" 28 | dependencies = [ 29 | "proc-macro2", 30 | ] 31 | 32 | [[package]] 33 | name = "syn" 34 | version = "2.0.22" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "2efbeae7acf4eabd6bcdcbd11c92f45231ddda7539edc7806bd1a04a03b24616" 37 | dependencies = [ 38 | "proc-macro2", 39 | "quote", 40 | "unicode-ident", 41 | ] 42 | 43 | [[package]] 44 | name = "unicode-ident" 45 | version = "1.0.6" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" 48 | -------------------------------------------------------------------------------- /pyo3-repr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "pyo3-repr" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | proc-macro2 = "1.0.51" 10 | quote = "1.0.23" 11 | syn = "2" 12 | 13 | [lib] 14 | proc-macro = true 15 | -------------------------------------------------------------------------------- /pyo3-repr/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(let_chains)] 2 | extern crate proc_macro; 3 | 4 | use pm2::Ident; 5 | use proc_macro::TokenStream; 6 | use proc_macro2 as pm2; 7 | use quote::quote; 8 | use syn::{parse_macro_input, Data, DataStruct, DeriveInput, Fields, FieldsNamed}; 9 | 10 | #[proc_macro_derive(PyDebug, attributes(py_debug))] 11 | pub fn py_debug(input: TokenStream) -> TokenStream { 12 | let ast = parse_macro_input!(input as DeriveInput); 13 | match do_expand(&ast, false) { 14 | Ok(token_stream) => token_stream.into(), 15 | Err(e) => e.to_compile_error().into(), 16 | } 17 | } 18 | 19 | #[proc_macro_derive(PyRepr, attributes(py_debug))] 20 | pub fn py_repr(input: TokenStream) -> TokenStream { 21 | let ast = parse_macro_input!(input as DeriveInput); 22 | match do_expand(&ast, true) { 23 | Ok(token_stream) => token_stream.into(), 24 | Err(e) => e.to_compile_error().into(), 25 | } 26 | } 27 | 28 | type MacroResult = syn::Result; 29 | 30 | fn do_expand(ast: &DeriveInput, gen_repr: bool) -> MacroResult { 31 | if !ast.generics.params.is_empty() { 32 | return Err(syn::Error::new_spanned( 33 | ast, 34 | "Generics are not supported by PyRepr.", 35 | )); 36 | } 37 | match &ast.data { 38 | Data::Struct(structure) => impl_struct_repr(ast, structure, gen_repr), 39 | _ => Err(syn::Error::new_spanned( 40 | ast, 41 | "Must define on a Struct".to_string(), 42 | )), 43 | } 44 | } 45 | 46 | fn impl_struct_repr(ast: &DeriveInput, structure: &DataStruct, gen_repr: bool) -> MacroResult { 47 | let fields = &structure.fields; 48 | match fields { 49 | Fields::Named(named) => Ok({ 50 | let mut token_stream = 51 | gen_impl_block(&ast.ident, gen_named_impl(ast.ident.to_string(), named)?); 52 | if gen_repr { 53 | let ident = &ast.ident; 54 | token_stream.extend(quote!( 55 | #[pymethods] 56 | impl #ident { 57 | fn __repr__(&self) -> String { 58 | format!("{:?}", self) 59 | } 60 | } 61 | )); 62 | } 63 | token_stream 64 | }), 65 | Fields::Unnamed(_) => todo!(), 66 | Fields::Unit => unimplemented!(), 67 | } 68 | } 69 | 70 | fn gen_impl_block(ident: &Ident, core_stream: pm2::TokenStream) -> pm2::TokenStream { 71 | quote!( 72 | impl ::std::fmt::Debug for #ident { 73 | fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { 74 | ::pyo3::marker::Python::with_gil(|py| { 75 | #core_stream 76 | }) 77 | } 78 | } 79 | ) 80 | } 81 | 82 | fn is_py_ptr(ty: &syn::Type) -> bool { 83 | if let syn::Type::Path(pth) = ty { 84 | if pth 85 | .path 86 | .segments 87 | .iter() 88 | .any(|seg| seg.ident == "Py" || seg.ident == "PyObject") 89 | { 90 | return true; 91 | } 92 | } 93 | false 94 | } 95 | 96 | fn gen_named_impl(ident: String, fields: &FieldsNamed) -> MacroResult { 97 | let mut core_stream = pm2::TokenStream::new(); 98 | core_stream.extend(quote!( 99 | f.debug_struct(#ident) 100 | )); 101 | for f in fields.named.iter() { 102 | let field_name_ident = f.ident.as_ref().unwrap(); 103 | let field_name_literal = field_name_ident.to_string(); 104 | let mut py_convert = is_py_ptr(&f.ty); 105 | for attr in f.attrs.iter() { 106 | attr.parse_nested_meta(|meta| { 107 | let ident = meta.path.get_ident().ok_or_else(|| { 108 | syn::Error::new_spanned( 109 | meta.path.clone(), 110 | "py_repr only supports bare ident as arg.", 111 | ) 112 | })?; 113 | match ident.to_string().as_str() { 114 | "skip" => return Ok(()), 115 | "py" => { 116 | py_convert = true; 117 | } 118 | "debug" => { 119 | py_convert = false; 120 | } 121 | _ => return Err(syn::Error::new_spanned(ident, "Unexpected option")), 122 | } 123 | Ok(()) 124 | })?; 125 | } 126 | if py_convert { 127 | core_stream.extend(quote!( 128 | .field(#field_name_literal, self.#field_name_ident.as_ref(py)) 129 | )); 130 | } else { 131 | core_stream.extend(quote!( 132 | .field(#field_name_literal, &self.#field_name_ident) 133 | )); 134 | } 135 | } 136 | core_stream.extend(quote!( 137 | .finish() 138 | )); 139 | Ok(core_stream) 140 | } 141 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "ichika" 3 | requires-python = ">=3.8" 4 | description = "基于 RICQ 的轻量级 Python QQ 自动化框架。" 5 | readme = "README.md" 6 | keywords = ["graia", "bot", "qq", "framework", "ricq", "ichika"] 7 | classifiers = [ 8 | "Development Status :: 2 - Pre-Alpha", 9 | "License :: OSI Approved :: GNU Affero General Public License v3", 10 | "Natural Language :: Chinese (Simplified)", 11 | "Programming Language :: Rust", 12 | "Programming Language :: Python :: Implementation :: CPython", 13 | "Topic :: Communications", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.8", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11" 19 | ] 20 | 21 | dependencies = [ 22 | "loguru~=0.6.0", 23 | "dacite>=1.6.0", 24 | "graia-amnesia>=0.7.0", 25 | "aiohttp>=3.8.3", 26 | ] 27 | 28 | dynamic = ["version"] 29 | [project.optional-dependencies] 30 | graia = [ 31 | "graia-broadcast>=0.19.2", 32 | "launart>=0.6.3", 33 | "creart>=0.2.2", 34 | "creart-graia>=0.1.5", 35 | "graiax-shortcut>=0.2.1", 36 | ] 37 | 38 | [build-system] 39 | requires = ["maturin~=1.0"] 40 | build-backend = "maturin" 41 | 42 | [tool.maturin] 43 | python-source = "python" 44 | module-name = "ichika.core" 45 | 46 | [tool.black] 47 | line-length = 120 48 | 49 | [tool.isort] 50 | profile = "black" 51 | py_version = 38 52 | known_first_party = ["ichika"] 53 | extra_standard_library = ["typing_extensions"] 54 | 55 | [tool.pyright] 56 | reportMissingModuleSource = false 57 | pythonVersion = "3.8" 58 | 59 | [tool.towncrier] 60 | directory = "news" 61 | filename = "CHANGELOG.md" 62 | start_string = "\n" 63 | underlines = ["", "", ""] 64 | template = "news/template.jinja" 65 | title_format = "## [{version}](https://github.com/GraiaProject/Ichika/tree/{version}) - {project_date}" 66 | issue_format = "([#{issue}](https://github.com/GraiaProject/Ichika/issues/{issue}))" 67 | 68 | [[tool.towncrier.type]] 69 | directory = "removed" 70 | name = "移除" 71 | showcontent = true 72 | 73 | [[tool.towncrier.type]] 74 | directory = "deprecated" 75 | name = "弃用" 76 | showcontent = true 77 | 78 | [[tool.towncrier.type]] 79 | directory = "added" 80 | name = "新增" 81 | showcontent = true 82 | 83 | [[tool.towncrier.type]] 84 | directory = "changed" 85 | name = "更改" 86 | showcontent = true 87 | 88 | [[tool.towncrier.type]] 89 | directory = "fixed" 90 | name = "修复" 91 | showcontent = true 92 | 93 | [[tool.towncrier.type]] 94 | directory = "misc" 95 | name = "其他" 96 | showcontent = true 97 | 98 | [tool.pdm] 99 | [tool.pdm.dev-dependencies] 100 | lint = [ 101 | "black>=23.1.0", 102 | "isort>=5.12.0", 103 | "pre-commit>=3.2.1", 104 | ] 105 | release = [ 106 | "towncrier>=22.12.0", 107 | "maturin~=1.0", 108 | "tomlkit>=0.11.6", 109 | "actions-toolkit>=0.1.15", 110 | ] 111 | docs = [ 112 | "mkdocs-material~=9.1", 113 | "mkdocstrings[python]~=0.21", 114 | "mkdocs-exclude~=1.0", 115 | "mkdocs-gen-files~=0.4", 116 | "mkdocs-section-index~=0.3", 117 | "mkdocs-literate-nav~=0.6", 118 | "markdown-exec[ansi]>=1.4.0", 119 | "mkdocs-include-markdown-plugin>=3.9.1", 120 | ] 121 | dev = [ 122 | "maturin~=1.0", 123 | "pip>=23.0.1", 124 | "graia-saya>=0.0.17", 125 | "graiax-shortcut>=0.2.1", 126 | ] 127 | [tool.pdm.scripts] 128 | develop= {cmd = "pdm install -v", env = {MATURIN_PEP517_ARGS = "--profile dev"}} 129 | build-docs.cmd = "mkdocs build" 130 | view-docs.cmd = "mkdocs serve" 131 | -------------------------------------------------------------------------------- /python/ichika/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from . import core as core 4 | from .build_info import BuildInfo 5 | 6 | __version__: str = core.__version__ 7 | """Ichika 当前版本号""" 8 | 9 | __build__: BuildInfo = core.__build__ 10 | """Ichika 的构建信息""" 11 | -------------------------------------------------------------------------------- /python/ichika/build_info.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from typing import TypedDict 3 | 4 | 5 | class RustCInfo(TypedDict): 6 | """编译器信息""" 7 | 8 | rustc: str 9 | """RustC 名称""" 10 | rustc_version: str 11 | """RustC 版本""" 12 | opt_level: str 13 | """优化等级""" 14 | debug: bool 15 | """是否为调试编译""" 16 | jobs: int 17 | """编译并行数""" 18 | 19 | 20 | class TargetInfo(TypedDict): 21 | """编译目标信息""" 22 | 23 | arch: str 24 | """目标架构""" 25 | os: str 26 | """目标操作系统""" 27 | family: str 28 | """目标家族""" 29 | compiler: str 30 | """使用的编译器""" 31 | triple: str 32 | """目标架构标识""" 33 | endian: str 34 | """目标端序""" 35 | pointer_width: str 36 | """目标指针宽度""" 37 | profile: str 38 | """使用的配置""" 39 | 40 | 41 | class BuildInfo(TypedDict): 42 | """Ichika 构建信息""" 43 | 44 | builder: RustCInfo 45 | """Rust 编译器信息""" 46 | target: TargetInfo 47 | """编译目标信息""" 48 | build_time: datetime.datetime 49 | """构建时间""" 50 | dependencies: dict 51 | """构建依赖字典""" 52 | host_triple: str 53 | """编译器的架构标识""" 54 | -------------------------------------------------------------------------------- /python/ichika/client.py: -------------------------------------------------------------------------------- 1 | """基于 `ichika.core.PlumbingClient` 封装的高层 API""" 2 | from __future__ import annotations 3 | 4 | from typing import Any, Awaitable, Callable, Iterable, Literal, Protocol 5 | from weakref import WeakValueDictionary 6 | 7 | from graia.amnesia.message import Element, MessageChain 8 | 9 | from .core import Friend, Group, PlumbingClient, RawMessageReceipt 10 | from .exceptions import MessageSendFailed 11 | from .message import _serialize_message as _serialize_msg 12 | from .message.elements import ( 13 | At, 14 | AtAll, 15 | Audio, 16 | Face, 17 | FlashImage, 18 | ForwardCard, 19 | ForwardMessage, 20 | Image, 21 | MusicShare, 22 | Reply, 23 | Text, 24 | ) 25 | 26 | 27 | class HttpClientProto(Protocol): 28 | """HTTP 客户端协议""" 29 | 30 | def __call__( 31 | self, method: Literal["get", "post"], url: str, headers: dict[str, str], body: bytes 32 | ) -> Awaitable[bytes]: 33 | """发起 HTTP 请求 34 | 35 | :param method: 请求方法 36 | :param url: 请求地址 37 | :param headers: 请求头 38 | :param body: 请求体 39 | 40 | :return: 响应体 41 | """ 42 | ... 43 | 44 | 45 | def _uin(obj: Friend | Group | int) -> int: 46 | return obj if isinstance(obj, int) else obj.uin 47 | 48 | 49 | def _chain_coerce(msg: str | Element | MessageChain | Iterable[str | Element]) -> MessageChain: 50 | if isinstance(msg, MessageChain): 51 | return msg 52 | if isinstance(msg, (str, Element)): 53 | msg = [msg] 54 | if isinstance(msg, Iterable): 55 | return MessageChain([Text(e) if isinstance(e, str) else e for e in msg]) 56 | 57 | 58 | class Client(PlumbingClient): 59 | """基于 [`PlumbingClient`][ichika.core.PlumbingClient] 封装的高层 API""" 60 | 61 | async def upload_friend_image(self, friend: int | Friend, data: bytes) -> Image: 62 | """上传好友图片 63 | 64 | :param friend: 好友 QQ 号或好友对象 65 | :param data: 图片数据 66 | 67 | :return: 图片元素 68 | """ 69 | image_dict = await super().upload_friend_image(_uin(friend), data) 70 | image_dict.pop("type") 71 | return Image(**image_dict) 72 | 73 | async def upload_friend_audio(self, friend: int | Friend, data: bytes) -> Audio: 74 | """上传好友语音 75 | 76 | :param friend: 好友 QQ 号或好友对象 77 | :param data: 语音数据,应为 SILK/AMR 编码的音频数据 78 | 79 | :return: 语音元素 80 | """ 81 | audio_dict = await super().upload_friend_audio(_uin(friend), data) 82 | audio_dict.pop("type") 83 | return Audio(**audio_dict) 84 | 85 | async def upload_group_image(self, group: int | Group, data: bytes) -> Image: 86 | """上传群图片 87 | 88 | :param group: 群号或群对象 89 | :param data: 图片数据 90 | 91 | :return: 图片元素 92 | """ 93 | image_dict = await super().upload_group_image(_uin(group), data) 94 | image_dict.pop("type") 95 | return Image(**image_dict) 96 | 97 | async def upload_group_audio(self, group: int | Group, data: bytes) -> Audio: 98 | """上传群语音 99 | 100 | :param group: 群号或群对象 101 | :param data: 语音数据,应为 SILK/AMR 编码的音频数据 102 | 103 | :return: 语音元素 104 | """ 105 | audio_dict = await super().upload_group_audio(_uin(group), data) 106 | audio_dict.pop("type") 107 | return Audio(**audio_dict) 108 | 109 | @classmethod 110 | def _parse_downloaded_fwd(cls, content: dict) -> ForwardMessage: 111 | if content.pop("type") == "Forward": 112 | content["content"] = [cls._parse_downloaded_fwd(sub) for sub in content.pop("content")] 113 | return ForwardMessage(**content) 114 | 115 | async def download_forward_msg(self, downloader: HttpClientProto, res_id: str) -> list[ForwardMessage]: 116 | """下载合并转发消息 117 | 118 | :param downloader: HTTP 客户端 119 | :param res_id: 资源 ID 120 | 121 | :return: 转发消息列表 122 | """ 123 | origin = await super().download_forward_msg(downloader, res_id) 124 | return [self._parse_downloaded_fwd(content) for content in origin] 125 | 126 | @staticmethod 127 | def _validate_chain(chain: MessageChain) -> MessageChain | Element: 128 | if not chain: 129 | raise ValueError("无法发送空消息!") 130 | if any(not isinstance(elem, (Reply, At, AtAll, Text, Image, Face)) for elem in chain): 131 | if len(chain) > 1: 132 | raise ValueError("消息内混合了富文本和非富文本型消息!") 133 | elem = chain[0] 134 | if isinstance(elem, (Audio, MusicShare)): 135 | return chain[0] 136 | return chain 137 | 138 | @staticmethod 139 | async def _validate_mm(uin: int, elem: Element, uploader: Callable[[int, bytes], Awaitable[Image]]) -> Element: 140 | if Image._check(elem) and elem.raw is None: 141 | new_img = await uploader(uin, await elem.fetch()) 142 | if FlashImage._check(elem): 143 | new_img = new_img.as_flash() 144 | return new_img 145 | return elem 146 | 147 | async def _prepare_forward(self, uin: int, fwd: ForwardMessage) -> dict[str, Any]: 148 | data = { 149 | "sender_id": fwd.sender_id, 150 | "sender_name": fwd.sender_name, 151 | "time": int(fwd.time.timestamp()), 152 | } 153 | if isinstance(fwd.content, MessageChain): 154 | data["type"] = "Message" 155 | if isinstance(self._validate_chain(fwd.content), Audio): 156 | raise TypeError(f"转发消息不允许使用音频: {fwd.content:r}") 157 | content = MessageChain( 158 | [await self._validate_mm(uin, elem, self.upload_group_image) for elem in fwd.content] 159 | ) 160 | data["content"] = _serialize_msg(content) 161 | else: 162 | data["type"] = "Forward" 163 | data["content"] = [await self._prepare_forward(uin, f) for f in fwd.content] 164 | return data 165 | 166 | async def upload_forward_msg(self, group: int | Group, msgs: list[ForwardMessage]) -> ForwardCard: 167 | """上传合并转发消息 168 | 169 | :param group: 用于标记的原始群号或群对象 170 | :param msgs: 转发消息列表 171 | 172 | :return: 转发卡片元素 173 | """ 174 | res_id, file_name, content = await super().upload_forward_msg( 175 | _uin(group), [await self._prepare_forward(_uin(group), msg) for msg in msgs] 176 | ) 177 | return ForwardCard(res_id, file_name, content) 178 | 179 | async def _send_special_element(self, uin: int, kind: str, element: Element) -> RawMessageReceipt: 180 | if Audio._check(element): 181 | if element.raw is None: 182 | uploader = self.upload_friend_audio if kind == "friend" else self.upload_group_audio 183 | sealed = (await uploader(uin, await element.fetch())).raw 184 | else: 185 | sealed = element.raw 186 | sender = self.send_friend_audio if kind == "friend" else self.send_group_audio 187 | return await sender(uin, sealed) 188 | 189 | if isinstance(element, MusicShare): 190 | sender = self.send_friend_music_share if kind == "friend" else self.send_group_music_share 191 | return await sender(uin, element) 192 | 193 | raise TypeError(f"无法发送元素: {element!r}") 194 | 195 | async def send_group_message( 196 | self, group: int | Group, chain: str | Element | MessageChain | Iterable[str | Element] 197 | ) -> RawMessageReceipt: 198 | """发送群消息 199 | 200 | :param group: 群号或群对象 201 | :param chain: 消息链 202 | 203 | :return: 消息发送凭据,可用于撤回 204 | """ 205 | uin: int = _uin(group) 206 | chain = _chain_coerce(chain) 207 | if isinstance(validated := self._validate_chain(chain), Element): 208 | return await self._send_special_element(uin, "group", validated) 209 | for idx, elem in enumerate(chain): 210 | chain.content[idx] = await self._validate_mm(uin, elem, self.upload_group_image) 211 | receipt = await super().send_group_message(uin, _serialize_msg(chain)) 212 | if receipt.seq == 0: 213 | raise MessageSendFailed(f"failed on group {uin}, unexcepted zero seq") 214 | return receipt 215 | 216 | async def send_friend_message( 217 | self, friend: int | Friend, chain: str | Element | MessageChain | Iterable[str | Element] 218 | ) -> RawMessageReceipt: 219 | """发送好友消息 220 | 221 | :param friend: 好友 QQ 号或好友对象 222 | :param chain: 消息链 223 | 224 | :return: 消息发送凭据,可用于撤回 225 | """ 226 | uin: int = _uin(friend) 227 | chain = _chain_coerce(chain) 228 | if isinstance(validated := self._validate_chain(chain), Element): 229 | return await self._send_special_element(uin, "friend", validated) 230 | for idx, elem in enumerate(chain): 231 | chain.content[idx] = await self._validate_mm(uin, elem, self.upload_friend_image) 232 | receipt = await super().send_friend_message(uin, _serialize_msg(chain)) 233 | if receipt.seq == 0: 234 | raise MessageSendFailed(f"failed on group {uin}, unexcepted zero seq") 235 | return receipt 236 | 237 | 238 | CLIENT_REFS: WeakValueDictionary[int, Client] = WeakValueDictionary() 239 | -------------------------------------------------------------------------------- /python/ichika/core.pyi: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import datetime 3 | from typing import Awaitable, Literal, Protocol, Sequence, TypeVar, type_check_only 4 | from typing_extensions import Any, TypeAlias 5 | 6 | from ichika.message.elements import MusicShare 7 | from ichika.structs import Gender, GroupPermission 8 | 9 | from . import event_defs as event_defs 10 | from .client import Client, HttpClientProto 11 | from .login import ( 12 | BaseLoginCredentialStore, 13 | PasswordLoginCallbacks, 14 | QRCodeLoginCallbacks, 15 | ) 16 | from .message._sealed import SealedAudio 17 | 18 | __version__: str 19 | __build__: Any 20 | 21 | _T_Event: TypeAlias = event_defs._T_Event 22 | 23 | @type_check_only 24 | class EventCallback(Protocol): 25 | """描述事件处理回调的协议,被刻意设计为与 [`asyncio.Queue`][asyncio.Queue] 兼容。""" 26 | 27 | async def put(self, event: _T_Event, /) -> Any: 28 | """处理事件""" 29 | 30 | # Here, outside wrapper "login_XXX" ensures that a "task locals" can be acquired for event task execution. 31 | 32 | async def password_login( 33 | uin: int, 34 | credential: str | bytes, 35 | use_sms: bool, 36 | protocol: str, 37 | store: BaseLoginCredentialStore, 38 | event_callbacks: Sequence[EventCallback], 39 | login_callbacks: PasswordLoginCallbacks, 40 | ) -> Client: 41 | """使用密码登录。 42 | 43 | :param uin: 账号 44 | :param credential: 登录凭据,str 为密码,bytes 为 MD5 数据 45 | :param use_sms: 是否使用短信验证码验证设备锁 46 | :param protocol: 登录协议,为 AndroidPhone, AndroidPad, AndroidWatch, IPad, MacOS, QiDian 中的一个 47 | :param store: 登录凭据存储器 48 | :param event_callbacks: 事件队列 49 | :param login_callbacks: 用于解析登录的回调 50 | :return: 可操作的客户端 51 | """ 52 | 53 | async def qrcode_login( 54 | uin: int, 55 | protocol: str, 56 | store: BaseLoginCredentialStore, 57 | event_callbacks: Sequence[EventCallback], 58 | login_callbacks: QRCodeLoginCallbacks, 59 | ) -> Client: 60 | """使用二维码登录。 61 | 62 | :param uin: 账号 63 | :param protocol: 登录协议,只能使用 AndroidWatch 64 | :param store: 登录凭据存储器 65 | :param event_callbacks: 事件队列 66 | :param login_callbacks: 用于解析登录的回调 67 | :return: 可操作的客户端 68 | """ 69 | 70 | # region: client 71 | 72 | _internal_repr = dataclass(frozen=True, init=False) 73 | 74 | @_internal_repr 75 | class AccountInfo: 76 | """机器人账号信息""" 77 | 78 | nickname: str 79 | """机器人昵称""" 80 | age: int 81 | """机器人年龄""" 82 | gender: Gender 83 | """机器人标注的性别""" 84 | 85 | @_internal_repr 86 | class OtherClientInfo: 87 | """获取到的其他客户端信息""" 88 | 89 | app_id: int 90 | """应用 ID""" 91 | instance_id: int 92 | """实例 ID""" 93 | sub_platform: str 94 | """子平台""" 95 | device_kind: str 96 | """设备类型""" 97 | 98 | @_internal_repr 99 | class Friend: 100 | """好友信息""" 101 | 102 | uin: int 103 | """账号 ID""" 104 | nick: str 105 | """好友昵称""" 106 | remark: str 107 | """好友备注""" 108 | face_id: int 109 | """未知""" 110 | group_id: int 111 | """好友分组 ID""" 112 | 113 | @_internal_repr 114 | class FriendGroup: 115 | """好友组""" 116 | 117 | group_id: int 118 | """分组 ID""" 119 | name: str 120 | """组名""" 121 | total_count: int 122 | """组内总好友数""" 123 | online_count: int 124 | """组内在线好友数""" 125 | seq_id: int 126 | """SEQ ID""" 127 | 128 | @_internal_repr 129 | class FriendList: 130 | """好友列表,你通过 API 获取到的顶层数据结构""" 131 | 132 | total_count: int 133 | """所有好友数""" 134 | online_count: int 135 | """在线好友数""" 136 | def friends(self) -> tuple[Friend,]: 137 | """获取好友列表。 138 | 139 | :return: 好友列表 140 | """ 141 | def find_friend(self, uin: int) -> Friend | None: 142 | """查找好友。 143 | 144 | :param uin: 好友账号 145 | :return: 好友信息 146 | """ 147 | def friend_groups(self) -> tuple[FriendGroup,]: 148 | """获取好友分组列表。 149 | 150 | :return: 好友分组列表 151 | """ 152 | def find_friend_group(self, group_id: int) -> FriendGroup | None: 153 | """查找好友分组。 154 | 155 | :param group_id: 好友分组 ID 156 | :return: 好友分组信息 157 | """ 158 | 159 | @_internal_repr 160 | class Profile: 161 | """描述账号资料""" 162 | 163 | uin: int 164 | """账号""" 165 | gender: Gender 166 | """性别""" 167 | age: int 168 | """年龄""" 169 | nickname: str 170 | """设置的昵称""" 171 | level: int 172 | """等级""" 173 | city: str 174 | """设置的城市""" 175 | sign: str 176 | """设置的个性签名""" 177 | login_days: int 178 | """连续登录天数""" 179 | 180 | @_internal_repr 181 | class Group: 182 | """群组信息,请注意通过缓存获取的数据可能不精确""" 183 | 184 | uin: int 185 | """群号""" 186 | name: str 187 | """群名""" 188 | memo: str 189 | """群公告""" 190 | owner_uin: int 191 | """群主账号""" 192 | create_time: int 193 | """群创建时间戳""" 194 | level: int 195 | """群等级""" 196 | member_count: int 197 | """群成员数量""" 198 | max_member_count: int 199 | """群最大成员数量""" 200 | global_mute_timestamp: int 201 | """全局禁言时间戳""" 202 | mute_timestamp: int 203 | """群禁言时间戳""" 204 | last_msg_seq: int 205 | """最后一条消息序列号""" 206 | 207 | @_internal_repr 208 | class Member: 209 | """群成员信息""" 210 | 211 | group_uin: int 212 | """群号""" 213 | uin: int 214 | """账号""" 215 | gender: Gender 216 | """性别""" 217 | nickname: str 218 | """昵称""" 219 | card_name: str 220 | """群名片""" 221 | level: int 222 | """成员等级""" 223 | join_time: int 224 | """加入时间""" 225 | last_speak_time: int 226 | """最后发言时间""" 227 | special_title: str 228 | """特殊头衔""" 229 | special_title_expire_time: int 230 | """特殊头衔过期时间""" 231 | mute_timestamp: int 232 | """禁言时间戳""" 233 | permission: GroupPermission 234 | """权限""" 235 | 236 | _T = TypeVar("_T") 237 | 238 | VTuple = tuple[_T,] 239 | 240 | @_internal_repr 241 | class RawMessageReceipt: 242 | seq: int 243 | rand: int 244 | raw_seqs: VTuple[int] 245 | """消息 SEQ ID""" 246 | raw_rands: VTuple[int] 247 | """消息随机数""" 248 | time: int 249 | """发送时间戳""" 250 | kind: str 251 | """消息类型,为 `group` 与 `friend` 中一个""" 252 | target: int 253 | """发送目标""" 254 | 255 | @_internal_repr 256 | class OCRText: 257 | """单条 OCR 结果""" 258 | 259 | detected_text: str 260 | """识别出的文本""" 261 | confidence: int 262 | """置信度""" 263 | polygon: VTuple[tuple[int, int]] | None 264 | """文本所在区域的顶点坐标""" 265 | advanced_info: str 266 | """额外信息""" 267 | 268 | @_internal_repr 269 | class OCRResult: 270 | """OCR 结果""" 271 | 272 | texts: VTuple[OCRText] 273 | """识别出的文本列表""" 274 | language: str 275 | """语言""" 276 | 277 | __OnlineStatus: TypeAlias = ( # TODO: Wrapper 278 | tuple[int, str] # (face_index, wording) 279 | | tuple[ 280 | Literal[False], 281 | Literal[ 282 | 11, # 在线 283 | 21, # 离线,效果未知 284 | 31, # 离开 285 | 41, # 隐身 286 | 50, # 忙 287 | 60, # Q 我吧 288 | 70, # 请勿打扰 289 | ], 290 | ] 291 | | tuple[ 292 | Literal[True], 293 | Literal[ 294 | 1000, # 当前电量 295 | 1028, # 听歌中 296 | 1040, # 星座运势 297 | 1030, # 今日天气 298 | 1069, # 遇见春天 299 | 1027, # Timi中 300 | 1064, # 吃鸡中 301 | 1051, # 恋爱中 302 | 1053, # 汪汪汪 303 | 1019, # 干饭中 304 | 1018, # 学习中 305 | 1032, # 熬夜中 306 | 1050, # 打球中 307 | 1011, # 信号弱 308 | 1024, # 在线学习 309 | 1017, # 游戏中 310 | 1022, # 度假中 311 | 1021, # 追剧中 312 | 1020, # 健身中 313 | ], 314 | ] 315 | ) 316 | 317 | class PlumbingClient: 318 | """Ichika 的底层客户端,暴露了一些底层接口""" 319 | 320 | # [impl 1] 321 | @property 322 | def uin(self) -> int: 323 | """当前登录的账号的 QQ 号。""" 324 | @property 325 | def online(self) -> bool: 326 | """当前账号是否登录成功。""" 327 | def keep_alive(self) -> Awaitable[None]: 328 | """保持在线。 329 | 330 | :return: 承载了维持心跳和重连任务的 [`Future 对象`][asyncio.Future]。 331 | """ 332 | async def stop(self) -> None: 333 | """停止客户端运行。 334 | 335 | 请在本方法返回后再等待 [`keep_alive`][ichika.core.PlumbingClient.keep_alive] 方法返回的 [`Future 对象`][asyncio.Future]。 336 | """ 337 | async def get_profile(self, uin: int) -> Profile: 338 | """获取任意账号的资料 339 | 340 | :param uin: 账号 341 | :return: 对应账号的资料 342 | """ 343 | async def get_account_info(self) -> AccountInfo: 344 | """获取当前登录的账号的信息。 345 | 346 | :return: 当前登录的账号的信息 347 | """ 348 | async def set_account_info( 349 | self, 350 | *, 351 | name: str | None = None, 352 | email: str | None = None, 353 | personal_note: str | None = None, 354 | company: str | None = None, 355 | college: str | None = None, 356 | signature: str | None = None, 357 | ) -> None: 358 | """设置当前登录的账号的信息。 359 | 360 | :param name: 昵称,None 为不修改 361 | :param email: 邮箱,None 为不修改 362 | :param personal_note: 个人说明,None 为不修改 363 | :param company: 公司,None 为不修改 364 | :param college: 学校,None 为不修改 365 | :param signature: 个性签名,None 为不修改 366 | """ 367 | async def get_other_clients(self) -> VTuple[OtherClientInfo]: 368 | """获取其他在线客户端的信息。 369 | 370 | :return: 一个元组,包含其他在线客户端的信息 371 | """ 372 | async def modify_online_status(self, status: __OnlineStatus) -> None: 373 | """修改当前登录的账号的在线状态。 374 | 375 | :param status: 在线状态 376 | """ 377 | async def image_ocr(self, url: str, md5: str, width: int, height: int) -> OCRResult: 378 | """对图片进行 OCR 识别。 379 | 380 | :param url: 图片 URL 381 | :param md5: 图片 MD5 382 | :param width: 图片宽度 383 | :param height: 图片高度 384 | :return: OCR 结果 385 | """ 386 | # [impl 2] 387 | async def get_friend_list(self, cache: bool = True) -> FriendList: 388 | """获取好友列表。 389 | 390 | :param cache: 是否使用缓存 391 | :return: 好友列表 392 | """ 393 | async def get_friends(self) -> VTuple[Friend]: 394 | """获取好友列表。 395 | 396 | :return: 好友列表 397 | """ 398 | async def find_friend(self, uin: int) -> Friend | None: 399 | """查找好友。 400 | 401 | :param uin: 好友 QQ 号 402 | :return: 好友对象,如果不存在则返回 None 403 | """ 404 | async def nudge_friend(self, uin: int) -> None: 405 | """给好友发送窗口抖动。 406 | 407 | :param uin: 好友 QQ 号 408 | """ 409 | async def delete_friend(self, uin: int) -> None: 410 | """删除好友。 411 | 412 | :param uin: 好友 QQ 号 413 | """ 414 | # [impl 3] 415 | async def get_group(self, uin: int, cache: bool = True) -> Group: 416 | """获取群信息。 417 | 418 | :param uin: 群号 419 | :param cache: 是否使用缓存 420 | :return: 群信息 421 | """ 422 | async def find_group(self, uin: int) -> Group | None: 423 | """查找群。 424 | 425 | :param uin: 群号 426 | :return: 群对象,如果不存在则返回 None 427 | """ 428 | async def get_groups(self) -> VTuple[Group]: 429 | """获取群列表。 430 | 431 | :return: 群列表 432 | """ 433 | async def get_group_admins(self, uin: int) -> list[Member]: 434 | """获取群管理员列表。 435 | 436 | :param uin: 群号 437 | :return: 群管理员列表 438 | """ 439 | async def mute_group(self, uin: int, mute: bool) -> None: 440 | """禁言/解禁群成员。 441 | 442 | :param uin: 群号 443 | :param mute: 是否禁言 444 | """ 445 | async def quit_group(self, uin: int) -> None: 446 | """退出群。 447 | 448 | :param uin: 群号 449 | """ 450 | async def modify_group_info(self, uin: int, *, memo: str | None = None, name: str | None = None) -> None: 451 | """修改群信息。 452 | 453 | :param uin: 群号 454 | :param memo: 群公告 455 | :param name: 群名称 456 | """ 457 | async def group_sign_in(self, uin: int) -> None: 458 | """签到群。 459 | 460 | :param uin: 群号 461 | """ 462 | # [impl 4] 463 | async def get_member(self, group_uin: int, uin: int, cache: bool = False) -> Member: 464 | """获取群成员信息。 465 | 466 | :param group_uin: 群号 467 | :param uin: QQ 号 468 | :param cache: 是否使用缓存 469 | :return: 群成员信息 470 | """ 471 | async def get_member_list(self, group_uin: int, cache: bool = True) -> list[Member]: 472 | """获取群成员列表。 473 | 474 | :param group_uin: 群号 475 | :param cache: 是否使用缓存 476 | :return: 群成员列表 477 | """ 478 | async def nudge_member(self, group_uin: int, uin: int) -> None: 479 | """给群成员发送窗口抖动。 480 | 481 | :param group_uin: 群号 482 | :param uin: QQ 号 483 | """ 484 | # Duration -> 0: Unmute 485 | async def mute_member(self, group_uin: int, uin: int, duration: int) -> None: 486 | """禁言/解禁群成员。 487 | 488 | :param group_uin: 群号 489 | :param uin: QQ 号 490 | :param duration: 禁言时长,单位为秒,0 表示解禁 491 | """ 492 | async def kick_member(self, group_uin: int, uin: int, msg: str, block: bool) -> None: 493 | """踢出群成员。 494 | 495 | :param group_uin: 群号 496 | :param uin: QQ 号 497 | :param msg: 踢人理由 498 | :param block: 是否加入黑名单 499 | """ 500 | async def modify_member_special_title(self, group_uin: int, uin: int, special_title: str) -> None: 501 | """修改群成员专属头衔。 502 | 503 | :param group_uin: 群号 504 | :param uin: QQ 号 505 | :param special_title: 专属头衔 506 | """ 507 | async def modify_member_card(self, group_uin: int, uin: int, card_name: str) -> None: 508 | """修改群成员名片。 509 | 510 | :param group_uin: 群号 511 | :param uin: QQ 号 512 | :param card_name: 名片 513 | """ 514 | async def modify_member_admin(self, group_uin: int, uin: int, admin: bool) -> None: 515 | """设置/取消群管理员。 516 | 517 | :param group_uin: 群号 518 | :param uin: QQ 号 519 | :param admin: 是否设置为管理员 520 | """ 521 | # [impl 5] 522 | async def upload_friend_image(self, uin: int, data: bytes) -> dict[str, Any]: 523 | """上传好友图片。 524 | 525 | :param uin: QQ 号 526 | :param data: 图片数据 527 | :return: 上传结果 528 | """ 529 | async def upload_friend_audio(self, uin: int, data: bytes) -> dict[str, Any]: 530 | """上传好友语音。 531 | 532 | :param uin: QQ 号 533 | :param data: 语音数据 534 | :return: 上传结果 535 | """ 536 | async def upload_group_image(self, uin: int, data: bytes) -> dict[str, Any]: 537 | """上传群图片。 538 | 539 | :param uin: QQ 号 540 | :param data: 图片数据 541 | :return: 上传结果 542 | """ 543 | async def upload_group_audio(self, uin: int, data: bytes) -> dict[str, Any]: 544 | """上传群语音。 545 | 546 | :param uin: QQ 号 547 | :param data: 语音数据 548 | :return: 上传结果 549 | """ 550 | async def send_friend_audio(self, uin: int, audio: SealedAudio) -> RawMessageReceipt: 551 | """发送好友语音。 552 | 553 | :param uin: QQ 号 554 | :param audio: 语音数据 555 | :return: 发送结果 556 | """ 557 | async def send_group_audio(self, uin: int, audio: SealedAudio) -> RawMessageReceipt: 558 | """发送群语音。 559 | 560 | :param uin: QQ 号 561 | :param audio: 语音数据 562 | :return: 发送结果 563 | """ 564 | async def send_friend_music_share(self, uin: int, share: MusicShare) -> RawMessageReceipt: 565 | """发送好友音乐分享。 566 | 567 | :param uin: QQ 号 568 | :param share: 音乐分享信息 569 | :return: 发送结果 570 | """ 571 | async def send_group_music_share(self, uin: int, share: MusicShare) -> RawMessageReceipt: 572 | """发送群音乐分享。 573 | 574 | :param uin: QQ 号 575 | :param share: 音乐分享信息 576 | :return: 发送结果 577 | """ 578 | async def download_forward_msg(self, downloader: HttpClientProto, res_id: str) -> list[dict]: 579 | """下载转发消息。 580 | 581 | :param downloader: 下载器 582 | :param res_id: 资源 ID 583 | :return: 转发消息 584 | """ 585 | async def upload_forward_msg(self, group_uin: int, msg: list[dict]) -> tuple[str, str, str]: 586 | """上传转发消息。 587 | 588 | :param group_uin: 群号 589 | :param msg: 转发消息 590 | :return: 上传结果 591 | """ 592 | # [impl 6] 593 | async def send_friend_message(self, uin: int, chain: list[dict[str, Any]]) -> RawMessageReceipt: 594 | """发送好友消息。 595 | 596 | :param uin: QQ 号 597 | :param chain: 消息链 598 | :return: 发送结果 599 | """ 600 | async def send_group_message(self, uin: int, chain: list[dict[str, Any]]) -> RawMessageReceipt: 601 | """发送群消息。 602 | 603 | :param uin: QQ 号 604 | :param chain: 消息链 605 | :return: 发送结果 606 | """ 607 | async def recall_friend_message(self, uin: int, time: int, seq: int, rand: int) -> None: 608 | """撤回好友消息。 609 | 610 | :param uin: QQ 号 611 | :param time: 消息发送时间 612 | :param seq: 消息的 SEQ 613 | :param rand: 消息的随机序列号 614 | """ 615 | async def recall_group_message(self, uin: int, seq: int, rand: int) -> None: 616 | """撤回群消息。 617 | 618 | :param uin: QQ 号 619 | :param seq: 消息的 SEQ 620 | :param rand: 消息的随机序列号 621 | """ 622 | async def modify_group_essence(self, uin: int, seq: int, rand: int, flag: bool) -> None: 623 | """修改群消息精华状态。 624 | 625 | :param uin: QQ 号 626 | :param seq: 消息的 SEQ 627 | :param rand: 消息的随机序列号 628 | :param flag: 是否设为精华 629 | """ 630 | # [impl 7] 631 | async def process_join_group_request( 632 | self, seq: int, request_uin: int, group_uin: int, accept: bool, block: bool, message: str 633 | ) -> None: 634 | """ 635 | 处理加群请求。 636 | 637 | :param seq: 消息的 SEQ 638 | :param request_uin: 请求人 QQ 号 639 | :param group_uin: 群号 640 | :param accept: 是否同意 641 | :param block: 是否拒绝并加入黑名单 642 | :param message: 回复消息 643 | """ 644 | async def process_group_invitation(self, seq: int, invitor_uin: int, group_uin: int, accept: bool) -> None: 645 | """ 646 | 处理群邀请。 647 | 648 | :param seq: 消息的 SEQ 649 | :param invitor_uin: 邀请人 QQ 号 650 | :param group_uin: 群号 651 | :param accept: 是否同意 652 | """ 653 | async def process_new_friend_request(self, seq: int, request_uin: int, accept: bool) -> None: 654 | """ 655 | 处理加好友请求。 656 | 657 | :param seq: 消息的 SEQ 658 | :param request_uin: 请求人 QQ 号 659 | :param accept: 是否同意 660 | """ 661 | 662 | # endregion: client 663 | 664 | def face_id_from_name(name: str) -> int | None: ... 665 | def face_name_from_id(id: int) -> str: ... 666 | @_internal_repr 667 | class MessageSource: 668 | """消息元信息""" 669 | 670 | seq: int 671 | """消息的 SEQ 672 | 673 | 建议搭配聊天类型与上下文 ID (例如 `("group", 123456, seq)`)作为索引的键 674 | """ 675 | rand: int 676 | """消息的随机序列号,撤回需要""" 677 | 678 | raw_seqs: VTuple[int] 679 | """消息的原始 SEQ""" 680 | 681 | raw_rands: VTuple[int] 682 | """消息的原始随机序列号""" 683 | 684 | time: datetime 685 | """消息发送时间""" 686 | -------------------------------------------------------------------------------- /python/ichika/event_defs.py: -------------------------------------------------------------------------------- 1 | """基于 [`TypedDict`][typing.TypedDict] 的事件定义。 2 | 3 | 对接本框架的下游开发者应该参考此处。 4 | """ 5 | from datetime import datetime, timedelta 6 | from typing import Literal, Optional, Type, TypedDict, Union 7 | from typing_extensions import TypeGuard, TypeVar 8 | 9 | from graia.amnesia.message import MessageChain 10 | 11 | from ichika.client import Client 12 | from ichika.core import Friend, Group, Member, MessageSource 13 | 14 | 15 | class BaseEvent(TypedDict): 16 | client: Client 17 | """事件所属的 [`Client`][ichika.client.Client] 对象""" 18 | 19 | 20 | class GroupMessage(BaseEvent): 21 | source: MessageSource 22 | content: MessageChain 23 | group: Group 24 | sender: Member 25 | type_name: Literal["GroupMessage"] 26 | 27 | 28 | class GroupRecallMessage(BaseEvent): 29 | time: datetime 30 | group: Group 31 | author: Member 32 | operator: Member 33 | seq: int 34 | type_name: Literal["GroupRecallMessage"] 35 | 36 | 37 | class FriendMessage(BaseEvent): 38 | source: MessageSource 39 | content: MessageChain 40 | sender: Friend 41 | type_name: Literal["FriendMessage"] 42 | 43 | 44 | class FriendRecallMessage(BaseEvent): 45 | time: datetime 46 | author: Friend 47 | seq: int 48 | type_name: Literal["FriendRecallMessage"] 49 | 50 | 51 | class TempMessage(BaseEvent): 52 | source: MessageSource 53 | content: MessageChain 54 | group: Group 55 | sender: Member 56 | type_name: Literal["TempMessage"] 57 | 58 | 59 | class GroupNudge(BaseEvent): 60 | group: Group 61 | sender: Member 62 | receiver: Member 63 | type_name: Literal["GroupNudge"] 64 | 65 | 66 | class FriendNudge(BaseEvent): 67 | sender: Friend 68 | type_name: Literal["FriendNudge"] 69 | 70 | 71 | class NewFriend(BaseEvent): 72 | friend: Friend 73 | type_name: Literal["NewFriend"] 74 | 75 | 76 | class NewMember(BaseEvent): 77 | group: Group 78 | member: Member 79 | type_name: Literal["NewMember"] 80 | 81 | 82 | class MemberLeaveGroup(BaseEvent): 83 | group_uin: int 84 | member_uin: int 85 | type_name: Literal["MemberLeaveGroup"] 86 | 87 | 88 | class GroupDisband(BaseEvent): 89 | group_uin: int 90 | type_name: Literal["GroupDisband"] 91 | 92 | 93 | class FriendDeleted(BaseEvent): 94 | friend_uin: int 95 | type_name: Literal["FriendDeleted"] 96 | 97 | 98 | class GroupMute(BaseEvent): 99 | group: Group 100 | operator: Member 101 | status: bool 102 | type_name: Literal["GroupMute"] 103 | 104 | 105 | class MemberMute(BaseEvent): 106 | group: Group 107 | operator: Member 108 | target: Member 109 | duration: Union[timedelta, Literal[False]] 110 | type_name: Literal["MemberMute"] 111 | 112 | 113 | class MemberPermissionChange(BaseEvent): 114 | group: Group 115 | target: Member 116 | permission: int 117 | type_name: Literal["MemberPermissionChange"] 118 | 119 | 120 | class _GroupInfo(BaseEvent): 121 | name: str 122 | 123 | 124 | class GroupInfoUpdate(BaseEvent): 125 | group: Group 126 | operator: Member 127 | info: _GroupInfo 128 | type_name: Literal["GroupInfoUpdate"] 129 | 130 | 131 | class NewFriendRequest(BaseEvent): 132 | seq: int 133 | uin: int 134 | nickname: str 135 | message: str 136 | type_name: Literal["NewFriendRequest"] 137 | 138 | 139 | class JoinGroupRequest(BaseEvent): 140 | seq: int 141 | time: datetime 142 | group_uin: int 143 | group_name: str 144 | request_uin: int 145 | request_nickname: str 146 | suspicious: bool 147 | invitor_uin: Optional[int] 148 | invitor_nickname: Optional[str] 149 | type_name: Literal["JoinGroupRequest"] 150 | 151 | 152 | class JoinGroupInvitation(BaseEvent): 153 | seq: int 154 | time: datetime 155 | group_uin: int 156 | group_name: str 157 | invitor_uin: int 158 | invitor_nickname: str 159 | type_name: Literal["JoinGroupInvitation"] 160 | 161 | 162 | class UnknownEvent(BaseEvent): 163 | """未知事件""" 164 | 165 | type_name: Literal["UnknownEvent"] 166 | internal_repr: str 167 | """事件的内部表示""" 168 | 169 | 170 | Event = Union[ 171 | GroupMessage, 172 | GroupRecallMessage, 173 | FriendMessage, 174 | FriendRecallMessage, 175 | TempMessage, 176 | GroupNudge, 177 | FriendNudge, 178 | NewFriend, 179 | NewMember, 180 | MemberLeaveGroup, 181 | GroupDisband, 182 | FriendDeleted, 183 | GroupMute, 184 | MemberMute, 185 | MemberPermissionChange, 186 | GroupInfoUpdate, 187 | NewFriendRequest, 188 | JoinGroupRequest, 189 | JoinGroupInvitation, 190 | UnknownEvent, 191 | ] 192 | 193 | _T_Event = TypeVar("_T_Event", bound=Event) 194 | 195 | 196 | def check_event(e: Event, type: Type[_T_Event]) -> TypeGuard[_T_Event]: 197 | """检查事件是否为指定类型。 198 | 199 | :param e: 事件对象 200 | :param type: 事件类型 201 | 202 | :return: 事件是否为指定类型 203 | """ 204 | return e["type_name"] == type.__name__ 205 | -------------------------------------------------------------------------------- /python/ichika/exceptions.py: -------------------------------------------------------------------------------- 1 | class IchikaError(Exception): 2 | """Ichika 所有异常的基类""" 3 | 4 | 5 | class LoginError(IchikaError, ValueError): 6 | """登录时因为用户操作不正确引发的异常""" 7 | 8 | 9 | class RICQError(IchikaError, RuntimeError): 10 | """由 RICQ 引发的运行时异常""" 11 | 12 | 13 | class MessageSendFailed(IchikaError): 14 | """消息发送失败引发的异常""" 15 | -------------------------------------------------------------------------------- /python/ichika/graia/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from contextvars import ContextVar 5 | from functools import partial 6 | from typing import Any, Awaitable, Literal, Optional, Protocol, Set 7 | from typing_extensions import Literal, Self 8 | 9 | from graia.broadcast import Broadcast 10 | from graia.broadcast.entities.dispatcher import BaseDispatcher 11 | from graia.broadcast.interfaces.dispatcher import DispatcherInterface as DI 12 | from launart import Launart, Launchable 13 | from loguru import logger 14 | 15 | from ichika import core 16 | from ichika.client import Client 17 | from ichika.exceptions import LoginError 18 | from ichika.login import ( 19 | BaseLoginCredentialStore, 20 | PasswordProtocol, 21 | login_password, 22 | login_qrcode, 23 | ) 24 | from ichika.login.password import PasswordLoginCallbacks 25 | from ichika.login.qrcode import QRCodeLoginCallbacks 26 | from ichika.utils import generic_issubclass 27 | 28 | BROADCAST_EVENT = ContextVar("ICHIKA_BROADCAST_EVENT") 29 | CLIENT_INSTANCE = ContextVar("ICHIKA_CLIENT_INSTANCE") 30 | 31 | 32 | class IchikaClientDispatcher(BaseDispatcher): 33 | @staticmethod 34 | async def catch(interface: DI): 35 | if generic_issubclass(Client, interface.annotation): 36 | return CLIENT_INSTANCE.get() 37 | 38 | 39 | class BroadcastCallback: 40 | broadcast: Broadcast 41 | 42 | def __init__(self, broadcast: Optional[Broadcast] = None) -> None: 43 | loop = asyncio.get_running_loop() 44 | if not broadcast: 45 | broadcast = Broadcast(loop=loop) 46 | if broadcast.loop is not loop: 47 | raise ValueError("Graia Broadcast 被绑定至不同事件循环!") 48 | self.broadcast = broadcast 49 | if IchikaClientDispatcher not in broadcast.prelude_dispatchers: 50 | broadcast.prelude_dispatchers.append(IchikaClientDispatcher) 51 | 52 | async def put(self, data: Any) -> None: 53 | from .event import EVENT_TYPES 54 | 55 | client = data.pop("client") 56 | 57 | e = EVENT_TYPES[data.pop("type_name")](**data) 58 | client_token = CLIENT_INSTANCE.set(client) 59 | event_token = BROADCAST_EVENT.set(e) 60 | await self.broadcast.postEvent(e) 61 | BROADCAST_EVENT.reset(event_token) 62 | CLIENT_INSTANCE.reset(client_token) 63 | 64 | 65 | class IchikaComponent(Launchable): 66 | """可用于 Launart 的 Ichika 组件""" 67 | 68 | class _LoginPartial(Protocol): 69 | def __call__( 70 | self, 71 | *, 72 | store: BaseLoginCredentialStore, 73 | event_callbacks: list[core.EventCallback], 74 | ) -> Awaitable[Client]: 75 | ... 76 | 77 | def __init__(self, store: BaseLoginCredentialStore, broadcast: Optional[Broadcast] = None) -> None: 78 | """初始化 Ichika 组件 79 | 80 | :param store: 登录凭据存储, 可以使用 `ichika.login.PathCredentialStore` 81 | :param broadcast: Graia Broadcast 实例 82 | """ 83 | self.broadcast = broadcast 84 | self.store: BaseLoginCredentialStore = store 85 | self.login_partials: dict[int, IchikaComponent._LoginPartial] = {} 86 | self.client_hb_map: dict[int, tuple[Client, Awaitable[None]]] = {} 87 | super().__init__() 88 | 89 | id = "ichika.main" 90 | 91 | @property 92 | def stages(self) -> Set[Literal["preparing", "blocking", "cleanup"]]: 93 | return {"preparing", "blocking", "cleanup"} 94 | 95 | @property 96 | def required(self) -> Set[str]: 97 | return set() 98 | 99 | def add_password_login( 100 | self, 101 | uin: int, 102 | credential: str | bytes, 103 | /, 104 | protocol: PasswordProtocol = "AndroidPad", 105 | callbacks: PasswordLoginCallbacks | None = None, 106 | use_sms: bool = True, 107 | ) -> Self: 108 | if uin in self.login_partials: 109 | raise ValueError(f"账号 {uin} 已经存在") 110 | self.login_partials[uin] = partial( 111 | login_password, 112 | uin, 113 | credential, 114 | protocol=protocol, 115 | login_callbacks=callbacks, 116 | use_sms=use_sms, 117 | ) 118 | return self 119 | 120 | def add_qrcode_login( 121 | self, 122 | uin: int, 123 | /, 124 | protocol: Literal["AndroidWatch"] = "AndroidWatch", 125 | callbacks: QRCodeLoginCallbacks | None = None, 126 | ) -> Self: 127 | if uin in self.login_partials: 128 | raise ValueError(f"账号 {uin} 已经存在") 129 | self.login_partials[uin] = partial(login_qrcode, uin, protocol=protocol, login_callbacks=callbacks) 130 | return self 131 | 132 | async def launch(self, mgr: Launart): 133 | if self.broadcast is None: 134 | self.broadcast = Broadcast(loop=asyncio.get_running_loop()) 135 | elif self.broadcast.loop is not asyncio.get_running_loop(): 136 | raise ValueError("Graia Broadcast 被绑定至不同事件循环!") 137 | broadcast_cb = BroadcastCallback(self.broadcast) 138 | event_cbs: list[core.EventCallback] = [broadcast_cb] 139 | async with self.stage("preparing"): 140 | for uin, login_fn in self.login_partials.items(): 141 | try: 142 | logger.info(f"尝试登录账号: {uin}") 143 | client = await login_fn(store=self.store, event_callbacks=event_cbs) 144 | if not client.online: 145 | raise LoginError(f"账号 {uin} 被服务器断开连接。") 146 | self.client_hb_map[uin] = (client, client.keep_alive()) 147 | except Exception as e: 148 | logger.exception(f"账号 {uin} 登录失败: ", e) 149 | if not self.client_hb_map: 150 | raise LoginError(f"所有账号均登录失败: {list(self.login_partials.keys())}") 151 | 152 | async with self.stage("blocking"): 153 | await mgr.status.wait_for_sigexit() 154 | # 清空事件回调 155 | event_cbs.clear() # LINK: https://github.com/BlueGlassBlock/Ichika/issues/61 156 | logger.info("事件监听已终止。") 157 | 158 | async with self.stage("cleanup"): 159 | for uin, (client, hb) in self.client_hb_map.items(): 160 | logger.info(f"正在停止账号 {uin}。") 161 | if client.online: 162 | try: 163 | await client.stop() 164 | except Exception as e: 165 | logger.exception(f"账号 {uin} 停止失败: ", e) 166 | else: 167 | logger.success(f"客户端 {uin} 已停止。") 168 | else: 169 | logger.info(f"客户端 {uin} 已离线。") 170 | await hb 171 | -------------------------------------------------------------------------------- /python/ichika/graia/event.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from dataclasses import dataclass 3 | from datetime import datetime, timedelta 4 | from typing import Dict, Literal, Optional, Type, TypedDict, TypeVar, Union 5 | from typing_extensions import get_type_hints 6 | 7 | from graia.amnesia.message import MessageChain 8 | from graia.broadcast.entities.event import BaseDispatcher, Dispatchable 9 | from graia.broadcast.entities.signatures import Force 10 | from graia.broadcast.interfaces.dispatcher import DispatcherInterface as DI 11 | 12 | from ichika.core import Friend, Group, Member, MessageSource 13 | from ichika.utils import generic_issubclass 14 | 15 | 16 | class NoneDispatcher(BaseDispatcher): 17 | """给 Optional[...] 提供 None 的 Dispatcher""" 18 | 19 | @staticmethod 20 | async def catch(interface: DI): 21 | if NoneDispatcher in interface.current_oplog: # FIXME: Workaround 22 | return None 23 | # oplog cached NoneDispatcher, which is undesirable 24 | # return "None" causes it to clear the cache 25 | # Then all the dispatchers are revisited 26 | # So that "None" is normally dispatched. 27 | if generic_issubclass(type(None), interface.annotation): 28 | return Force(None) 29 | 30 | 31 | class SourceDispatcher(BaseDispatcher): 32 | @staticmethod 33 | async def catch(interface: DI): 34 | event = interface.event 35 | if isinstance(event, MessageEvent) and generic_issubclass(MessageSource, interface.annotation): 36 | return event.source 37 | 38 | 39 | class MessageChainDispatcher(BaseDispatcher): 40 | @staticmethod 41 | async def catch(interface: DI): 42 | event = interface.event 43 | if isinstance(event, MessageEvent) and generic_issubclass(MessageChain, interface.annotation): 44 | return event.content 45 | 46 | 47 | class SenderDispatcher(BaseDispatcher): 48 | @staticmethod 49 | async def catch(interface: DI): 50 | event = interface.event 51 | with contextlib.suppress(TypeError): 52 | if isinstance(event, MessageEvent) and generic_issubclass(event.sender.__class__, interface.annotation): 53 | return event.sender 54 | 55 | 56 | class GroupDispatcher(BaseDispatcher): 57 | @staticmethod 58 | async def catch(interface: DI): 59 | event = interface.event 60 | if generic_issubclass(Group, interface.annotation): 61 | return event.group 62 | 63 | 64 | _DISPATCHER_MAP: Dict[type, Type[BaseDispatcher]] = { 65 | MessageSource: SourceDispatcher, 66 | MessageChain: MessageChainDispatcher, 67 | Group: GroupDispatcher, 68 | } 69 | 70 | _Dispatch_T = TypeVar("_Dispatch_T", bound=Dispatchable) 71 | 72 | 73 | def auto_dispatch(event_cls: Type[_Dispatch_T]) -> Type[_Dispatch_T]: 74 | mixins: set[Type[BaseDispatcher]] = {NoneDispatcher} 75 | type_map: dict[type, set[str]] = {} 76 | 77 | for name, typ in get_type_hints(event_cls).items(): 78 | if name == "sender": 79 | mixins.add(SenderDispatcher) 80 | elif dispatcher := _DISPATCHER_MAP.get(typ): 81 | mixins.add(dispatcher) 82 | else: 83 | type_map.setdefault(typ, set()).add(name) 84 | 85 | class Dispatcher(BaseDispatcher): 86 | mixin = list(mixins) 87 | _type_dispatch: Dict[type, str] = {t: next(iter(ns)) for t, ns in type_map.items() if len(ns) <= 1} 88 | _name_dispatch: Dict[str, type] = {n: t for t, ns in type_map.items() if len(ns) > 1 for n in ns} 89 | 90 | @classmethod 91 | async def catch(cls, interface: DI): 92 | anno, name, event = interface.annotation, interface.name, interface.event 93 | if name in cls._name_dispatch and generic_issubclass(cls._name_dispatch[name], anno): 94 | return getattr(event, name) 95 | if generic_issubclass(event_cls, anno): 96 | return event 97 | for t, target_name in cls._type_dispatch.items(): 98 | if generic_issubclass(t, anno): 99 | return getattr(event, target_name) 100 | 101 | Dispatcher.__module__ = event_cls.__module__ 102 | Dispatcher.__qualname__ = f"{event_cls.__qualname__}.Dispatcher" 103 | 104 | event_cls.Dispatcher = Dispatcher 105 | return event_cls 106 | 107 | 108 | class MessageEvent(Dispatchable): 109 | source: MessageSource 110 | content: MessageChain 111 | sender: Union[Member, Friend] 112 | 113 | 114 | class GroupEvent(Dispatchable): 115 | group: Group 116 | 117 | 118 | @dataclass 119 | @auto_dispatch 120 | class GroupMessage(MessageEvent, GroupEvent): 121 | source: MessageSource 122 | content: MessageChain 123 | group: Group 124 | sender: Member 125 | 126 | 127 | @dataclass 128 | @auto_dispatch 129 | class FriendMessage(MessageEvent): 130 | source: MessageSource 131 | content: MessageChain 132 | sender: Friend 133 | 134 | 135 | @dataclass 136 | @auto_dispatch 137 | class TempMessage(MessageEvent): 138 | source: MessageSource 139 | content: MessageChain 140 | group: Group 141 | sender: Member 142 | 143 | 144 | @dataclass 145 | @auto_dispatch 146 | class GroupRecallMessage(Dispatchable): 147 | time: datetime 148 | group: Group 149 | author: Member 150 | operator: Member 151 | seq: int 152 | 153 | 154 | @dataclass 155 | @auto_dispatch 156 | class FriendRecallMessage(Dispatchable): 157 | time: datetime 158 | author: Friend 159 | seq: int 160 | 161 | 162 | @dataclass 163 | @auto_dispatch 164 | class GroupNudge(Dispatchable): 165 | group: Group 166 | sender: Member 167 | receiver: Member 168 | 169 | 170 | @dataclass 171 | @auto_dispatch 172 | class FriendNudge(Dispatchable): 173 | sender: Friend 174 | 175 | 176 | @dataclass 177 | @auto_dispatch 178 | class NewFriend(Dispatchable): 179 | friend: Friend 180 | 181 | 182 | @dataclass 183 | @auto_dispatch 184 | class NewMember(Dispatchable): 185 | group: Group 186 | member: Member 187 | 188 | 189 | @dataclass 190 | @auto_dispatch 191 | class MemberLeaveGroup(Dispatchable): 192 | group_uin: int 193 | member_uin: int 194 | 195 | 196 | @dataclass 197 | @auto_dispatch 198 | class GroupDisband(Dispatchable): 199 | group_uin: int 200 | 201 | 202 | @dataclass 203 | @auto_dispatch 204 | class FriendDeleted(Dispatchable): 205 | friend_uin: int 206 | 207 | 208 | @dataclass 209 | @auto_dispatch 210 | class GroupMute(Dispatchable): 211 | group: Group 212 | operator: Member 213 | status: bool 214 | 215 | 216 | @dataclass 217 | @auto_dispatch 218 | class MemberMute(Dispatchable): 219 | group: Group 220 | operator: Member 221 | target: Member 222 | duration: Union[timedelta, Literal[False]] 223 | 224 | 225 | @dataclass 226 | @auto_dispatch 227 | class MemberPermissionChange(Dispatchable): 228 | group: Group 229 | target: Member 230 | permission: int 231 | 232 | 233 | class _GroupInfo(TypedDict): 234 | name: str 235 | 236 | 237 | @dataclass 238 | @auto_dispatch 239 | class GroupInfoUpdate(Dispatchable): 240 | group: Group 241 | operator: Member 242 | info: _GroupInfo 243 | 244 | 245 | @dataclass 246 | @auto_dispatch 247 | class NewFriendRequest(Dispatchable): 248 | seq: int 249 | uin: int 250 | nickname: str 251 | message: str 252 | 253 | 254 | @dataclass 255 | @auto_dispatch 256 | class JoinGroupRequest(Dispatchable): 257 | seq: int 258 | time: datetime 259 | group_uin: int 260 | group_name: str 261 | request_uin: int 262 | request_nickname: str 263 | suspicious: bool 264 | invitor_uin: Optional[int] 265 | invitor_nickname: Optional[str] 266 | 267 | 268 | @dataclass 269 | @auto_dispatch 270 | class JoinGroupInvitation(Dispatchable): 271 | seq: int 272 | time: datetime 273 | group_uin: int 274 | group_name: str 275 | invitor_uin: int 276 | invitor_nickname: str 277 | 278 | 279 | @dataclass 280 | @auto_dispatch 281 | class UnknownEvent(Dispatchable): 282 | internal_repr: str 283 | 284 | 285 | EVENT_TYPES = { 286 | cls.__name__: cls 287 | for cls in ( 288 | GroupMessage, 289 | GroupRecallMessage, 290 | FriendMessage, 291 | FriendRecallMessage, 292 | TempMessage, 293 | GroupNudge, 294 | FriendNudge, 295 | NewFriend, 296 | NewMember, 297 | MemberLeaveGroup, 298 | GroupDisband, 299 | FriendDeleted, 300 | GroupMute, 301 | MemberMute, 302 | MemberPermissionChange, 303 | GroupInfoUpdate, 304 | NewFriendRequest, 305 | JoinGroupRequest, 306 | JoinGroupInvitation, 307 | UnknownEvent, 308 | ) 309 | } 310 | -------------------------------------------------------------------------------- /python/ichika/login/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import os 5 | from asyncio import Queue 6 | from enum import Enum 7 | from pathlib import Path 8 | from typing import Literal, Optional, Sequence, Union, overload 9 | 10 | from loguru import logger as log 11 | 12 | import ichika.core as _core 13 | from ichika.client import Client 14 | 15 | from .password import PasswordLoginCallbacks as PasswordLoginCallbacks 16 | from .qrcode import QRCodeLoginCallbacks as QRCodeLoginCallbacks 17 | 18 | 19 | class BaseLoginCredentialStore: 20 | def get_token(self, uin: int, protocol: str) -> Optional[bytes]: 21 | pass 22 | 23 | def write_token(self, uin: int, protocol: str, token: bytes) -> None: 24 | pass 25 | 26 | def get_device(self, uin: int, protocol: str) -> dict: 27 | from dataclasses import asdict 28 | from random import Random 29 | 30 | from ichika.scripts.device.generator import generate 31 | 32 | return asdict(generate(Random(hash(protocol) ^ uin))) 33 | 34 | 35 | class PathCredentialStore(BaseLoginCredentialStore): 36 | """可以给所有账号共享的,基于路径的凭据存储器""" 37 | 38 | def __init__(self, path: Union[str, os.PathLike[str]], device_name: str = "ricq_device.json") -> None: 39 | """初始化 40 | 41 | :param path: 存储路径 42 | :param device_name: 设备信息文件名,可以使用 `{protocol}` 占位符,注意其为 PascalCase 格式 43 | """ 44 | self.device_name = device_name 45 | self.path = Path(path) 46 | self.path.mkdir(parents=True, exist_ok=True) 47 | 48 | def uin_path(self, uin: int) -> Path: 49 | path = self.path / str(uin) 50 | path.mkdir(parents=True, exist_ok=True) 51 | return path 52 | 53 | def get_device(self, uin: int, protocol: str) -> dict: 54 | ricq_device = self.uin_path(uin) / self.device_name.format(protocol=protocol) 55 | if ricq_device.exists(): 56 | log.info("发现 `ricq_device.json`, 读取") 57 | return json.loads(ricq_device.read_text("utf-8")) 58 | 59 | other_device = self.uin_path(uin) / "device.json" 60 | if other_device.exists(): 61 | from dataclasses import asdict 62 | 63 | from ichika.scripts.device.converter import convert 64 | 65 | log.info("发现其他格式的 `device.json`, 尝试转换") 66 | device_content = asdict(convert(json.loads(other_device.read_text("utf-8")))) 67 | else: 68 | log.info("未发现 `device.json`, 正在生成") 69 | device_content = super().get_device(uin, protocol) 70 | 71 | ricq_device.write_text(json.dumps(device_content, indent=4), "utf-8") 72 | return device_content 73 | 74 | def get_token(self, uin: int, protocol: str) -> Optional[bytes]: 75 | token = self.uin_path(uin) / f"token-{protocol}.bin" 76 | return token.read_bytes() if token.exists() else None 77 | 78 | def write_token(self, uin: int, protocol: str, token: bytes) -> None: 79 | token_path = self.uin_path(uin) / f"token-{protocol}.bin" 80 | token_path.write_bytes(token) 81 | 82 | 83 | PasswordProtocol = Literal["AndroidPhone", "AndroidPad", "IPad", "MacOS", "QiDian"] 84 | """可用密码登录的协议 85 | 86 | 登录成功率较大的: 87 | 88 | - AndroidPad (默认) 89 | - AndroidPhone 90 | """ 91 | 92 | 93 | @overload 94 | async def login_password( 95 | uin: int, 96 | password: str, 97 | /, 98 | protocol: PasswordProtocol, 99 | store: BaseLoginCredentialStore, 100 | event_callbacks: Sequence[_core.EventCallback], 101 | login_callbacks: PasswordLoginCallbacks | None = None, 102 | use_sms: bool = ..., 103 | ) -> Client: 104 | ... 105 | 106 | 107 | @overload 108 | async def login_password( 109 | uin: int, 110 | password_md5: bytes, 111 | /, 112 | protocol: PasswordProtocol, 113 | store: BaseLoginCredentialStore, 114 | event_callbacks: Sequence[_core.EventCallback], 115 | login_callbacks: PasswordLoginCallbacks | None = None, 116 | use_sms: bool = ..., 117 | ) -> Client: 118 | ... 119 | 120 | 121 | @overload 122 | async def login_password( 123 | uin: int, 124 | credential: str | bytes, 125 | /, 126 | protocol: PasswordProtocol, 127 | store: BaseLoginCredentialStore, 128 | event_callbacks: Sequence[_core.EventCallback], 129 | login_callbacks: PasswordLoginCallbacks | None = None, 130 | use_sms: bool = ..., 131 | ) -> Client: 132 | ... 133 | 134 | 135 | async def login_password( 136 | uin: int, 137 | credential: str | bytes, 138 | /, 139 | protocol: PasswordProtocol, 140 | store: BaseLoginCredentialStore, 141 | event_callbacks: Sequence[_core.EventCallback], 142 | login_callbacks: PasswordLoginCallbacks | None = None, 143 | use_sms: bool = True, 144 | ) -> Client: 145 | return await _core.password_login( 146 | uin, credential, use_sms, protocol, store, event_callbacks, login_callbacks or PasswordLoginCallbacks.default() 147 | ) 148 | 149 | 150 | async def login_qrcode( 151 | uin: int, 152 | /, 153 | protocol: Literal["AndroidWatch"], 154 | store: BaseLoginCredentialStore, 155 | event_callbacks: Sequence[_core.EventCallback], 156 | login_callbacks: QRCodeLoginCallbacks | None = None, 157 | ) -> Client: 158 | return await _core.qrcode_login( 159 | uin, protocol, store, event_callbacks, login_callbacks or QRCodeLoginCallbacks.default() 160 | ) 161 | -------------------------------------------------------------------------------- /python/ichika/login/password.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import auto 4 | from typing import Any, Callable, Literal, NoReturn, Optional, overload 5 | from typing_extensions import Self 6 | 7 | from loguru import logger as log 8 | 9 | from ichika.utils import AsyncFn, AutoEnum, Decor 10 | 11 | 12 | class PasswordLoginState(str, AutoEnum): 13 | Success = auto() 14 | AccountFrozen = auto() 15 | TooManySMSRequest = auto() 16 | DeviceLockLogin = auto() 17 | NeedCaptcha = auto() 18 | UnknownStatus = auto() 19 | DeviceLocked = auto() 20 | RequestSMS = auto() 21 | 22 | 23 | class PasswordLoginCallbacks: 24 | def __init__(self, callbacks: dict[PasswordLoginState, Callable] | None = None): 25 | self.callbacks: dict[PasswordLoginState, Optional[Callable]] = {state: None for state in PasswordLoginState} 26 | self.callbacks.update(callbacks or {}) 27 | 28 | @overload 29 | def set_handle(self, state: Literal[PasswordLoginState.DeviceLocked]) -> Decor[AsyncFn[[str, str], Any]]: 30 | ... 31 | 32 | @overload 33 | def set_handle(self, state: Literal[PasswordLoginState.RequestSMS]) -> Decor[AsyncFn[[str, str], str]]: 34 | ... 35 | 36 | @overload 37 | def set_handle(self, state: Literal[PasswordLoginState.NeedCaptcha]) -> Decor[AsyncFn[[str], str]]: 38 | ... 39 | 40 | @overload 41 | def set_handle(self, state: Literal[PasswordLoginState.Success]) -> Decor[AsyncFn[[], Any]]: 42 | ... 43 | 44 | @overload 45 | def set_handle(self, state: Literal[PasswordLoginState.DeviceLockLogin]) -> Decor[AsyncFn[[], Any]]: 46 | ... 47 | 48 | @overload 49 | def set_handle( 50 | self, 51 | state: Literal[PasswordLoginState.AccountFrozen], 52 | ) -> Decor[AsyncFn[[], NoReturn]]: 53 | ... 54 | 55 | @overload 56 | def set_handle( 57 | self, 58 | state: Literal[PasswordLoginState.TooManySMSRequest], 59 | ) -> Decor[AsyncFn[[], NoReturn]]: 60 | ... 61 | 62 | @overload 63 | def set_handle( 64 | self, 65 | state: Literal[PasswordLoginState.UnknownStatus], 66 | ) -> Decor[AsyncFn[[str, int], NoReturn]]: 67 | ... 68 | 69 | def set_handle(self, state) -> Decor[Callable]: 70 | def register_callback(func: Callable) -> Callable: 71 | self.callbacks[state] = func 72 | return func 73 | 74 | return register_callback 75 | 76 | def get_handle(self, state: str) -> Optional[Callable]: 77 | return self.callbacks.get(PasswordLoginState(state)) 78 | 79 | @classmethod 80 | def default(cls) -> Self: 81 | cbs = cls({}) 82 | S = PasswordLoginState 83 | 84 | @cbs.set_handle(S.NeedCaptcha) 85 | async def _(url: str): 86 | log.warning(f"请完成滑块验证,URL: {url}") 87 | return input("完成后请输入 ticket >").strip(" ") 88 | 89 | @cbs.set_handle(S.DeviceLocked) 90 | async def _(message: str, url: str): 91 | log.warning(message) 92 | log.warning(f"请完成设备锁验证,URL: {url}") 93 | input("请在完成后回车") 94 | 95 | @cbs.set_handle(S.RequestSMS) 96 | async def _(message: str, phone_number: str) -> str: 97 | log.warning(message) 98 | log.warning(f"已发送短信验证码至 {phone_number}") 99 | return input("请输入收到的短信验证码 >").strip(" ") 100 | 101 | @cbs.set_handle(S.AccountFrozen) 102 | async def _() -> NoReturn: 103 | msg = "无法登录:账号被冻结" 104 | raise RuntimeError(msg) 105 | 106 | @cbs.set_handle(S.TooManySMSRequest) 107 | async def _() -> NoReturn: 108 | msg = "短信请求次数过多,请稍后再试" 109 | raise RuntimeError(msg) 110 | 111 | @cbs.set_handle(S.UnknownStatus) 112 | async def _(message: str, code: int) -> NoReturn: 113 | msg = f"未知错误(代码 {code}):{message}" 114 | raise RuntimeError(msg) 115 | 116 | @cbs.set_handle(S.Success) 117 | async def _() -> None: 118 | log.success("登录成功") 119 | 120 | @cbs.set_handle(S.DeviceLockLogin) 121 | async def _() -> None: 122 | log.info("尝试设备锁登录") 123 | 124 | return cbs 125 | -------------------------------------------------------------------------------- /python/ichika/login/qrcode/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import auto 4 | from typing import Any, Callable, Literal, Optional, overload 5 | from typing_extensions import Self 6 | 7 | from loguru import logger as log 8 | 9 | from ichika.utils import AsyncFn, AutoEnum, Decor, P, Ref 10 | 11 | from .render import Dense1x2 as Dense1x2 12 | from .render import QRCodeRenderer as QRCodeRenderer 13 | 14 | 15 | class QRCodeLoginState(str, AutoEnum): 16 | WaitingForScan = auto() 17 | WaitingForConfirm = auto() 18 | Canceled = auto() 19 | Timeout = auto() 20 | Success = auto() 21 | DisplayQRCode = auto() 22 | UINMismatch = auto() 23 | 24 | 25 | class QRCodeLoginCallbacks: 26 | def __init__(self, callbacks: dict[QRCodeLoginState, Callable] | None = None, interval: float = 5.0): 27 | self.callbacks: dict[QRCodeLoginState, Optional[Callable]] = {state: None for state in QRCodeLoginState} 28 | self.callbacks.update(callbacks or {}) 29 | self.interval: float = interval 30 | 31 | @overload 32 | def set_handle(self, state: Literal[QRCodeLoginState.WaitingForScan]) -> Decor[AsyncFn[[], Any]]: 33 | ... 34 | 35 | @overload 36 | def set_handle(self, state: Literal[QRCodeLoginState.WaitingForConfirm]) -> Decor[AsyncFn[[], Any]]: 37 | ... 38 | 39 | @overload 40 | def set_handle(self, state: Literal[QRCodeLoginState.Canceled]) -> Decor[AsyncFn[[], Any]]: 41 | ... 42 | 43 | @overload 44 | def set_handle(self, state: Literal[QRCodeLoginState.Timeout]) -> Decor[AsyncFn[[], Any]]: 45 | ... 46 | 47 | @overload 48 | def set_handle(self, state: Literal[QRCodeLoginState.Success]) -> Decor[AsyncFn[[int], Any]]: 49 | ... 50 | 51 | @overload 52 | def set_handle(self, state: Literal[QRCodeLoginState.UINMismatch]) -> Decor[AsyncFn[[int, int], Any]]: 53 | ... 54 | 55 | @overload 56 | def set_handle(self, state: Literal[QRCodeLoginState.DisplayQRCode]) -> Decor[AsyncFn[[list[list[bool]]], Any]]: 57 | ... 58 | 59 | def set_handle(self, state) -> Decor[Callable]: 60 | def register_callback(func: Callable) -> Callable: 61 | self.callbacks[state] = func 62 | return func 63 | 64 | return register_callback 65 | 66 | def get_handle(self, state: str) -> Optional[Callable]: 67 | return self.callbacks.get(QRCodeLoginState(state)) 68 | 69 | @classmethod 70 | def default(cls, qrcode_printer: QRCodeRenderer = Dense1x2(), interval: float = 5.0, merge: bool = True) -> Self: 71 | cbs = QRCodeLoginCallbacks(interval=interval) 72 | S = QRCodeLoginState 73 | 74 | last_state: Ref[Optional[S]] = Ref(None) 75 | 76 | def wrap(state: S) -> Callable[[Callable[P, None]], AsyncFn[P, None]]: 77 | def receiver(func: Callable[P, None]) -> AsyncFn[P, None]: 78 | import functools 79 | 80 | @functools.wraps(func) 81 | async def wrapper(*args: P.args, **kwargs: P.kwargs) -> None: 82 | if last_state.ref == state and merge: 83 | return 84 | last_state.ref = state 85 | return func(*args, **kwargs) 86 | 87 | return wrapper 88 | 89 | return receiver 90 | 91 | @cbs.set_handle(S.Success) 92 | @wrap(S.Success) 93 | def _(uin: int): 94 | log.success("成功登录账号 {}", uin) 95 | 96 | @cbs.set_handle(S.UINMismatch) 97 | @wrap(S.UINMismatch) 98 | def _(uin: int, real_uin: int): 99 | log.error("预期使用账号 {} 登录,实际登录为 {}", uin, real_uin) 100 | log.critical("请重新登录") 101 | 102 | @cbs.set_handle(S.DisplayQRCode) 103 | @wrap(S.DisplayQRCode) 104 | def _(data: list[list[bool]]): 105 | log.info("请扫描二维码登录:\n" + qrcode_printer.render(data)) 106 | 107 | @cbs.set_handle(S.WaitingForScan) 108 | @wrap(S.WaitingForScan) 109 | def _(): 110 | log.debug("等待扫码") 111 | 112 | @cbs.set_handle(S.WaitingForConfirm) 113 | @wrap(S.WaitingForConfirm) 114 | def _(): 115 | log.info("扫码成功,等待确认") 116 | 117 | @cbs.set_handle(S.Canceled) 118 | @wrap(S.Canceled) 119 | def _(): 120 | log.error("取消扫码,重新尝试登录") 121 | 122 | @cbs.set_handle(S.Timeout) 123 | @wrap(S.Timeout) 124 | def _(): 125 | log.error("扫码登录等待超时,尝试重新登录") 126 | 127 | return cbs 128 | -------------------------------------------------------------------------------- /python/ichika/login/qrcode/render/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Protocol, runtime_checkable 4 | 5 | 6 | @runtime_checkable 7 | class QRCodeRenderer(Protocol): 8 | def render(self, data: list[list[bool]], /) -> str: 9 | ... 10 | 11 | 12 | from .dense1x2 import Dense1x2 as Dense1x2 13 | -------------------------------------------------------------------------------- /python/ichika/login/qrcode/render/dense1x2.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class Dense1x2: 5 | chars: list[str] = [" ", "\u2584", "\u2580", "\u2588"] 6 | 7 | def __init__(self, invert: bool = False) -> None: 8 | self.inv = invert 9 | 10 | def render(self, data: list[list[bool]]) -> str: 11 | if not data: 12 | return "" 13 | block: list[str] = [ 14 | "".join( 15 | self.chars[(upper ^ self.inv) * 2 + (lower ^ self.inv)] for upper, lower in zip(data[i], data[i + 1]) 16 | ) 17 | for i in range(0, len(data) - 1, 2) 18 | ] 19 | if len(data) % 2 != 0: 20 | block.append("".join(self.chars[(pixel ^ self.inv) * 2] for pixel in data[-1])) 21 | return "\n".join(block) 22 | 23 | 24 | if __name__ == "__main__": 25 | print(Dense1x2(True).render([[True, False], [False, True], [True, False]])) 26 | -------------------------------------------------------------------------------- /python/ichika/message/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | 5 | from graia.amnesia.message import MessageChain 6 | from graia.amnesia.message.element import Element, Unknown 7 | from loguru import logger 8 | 9 | from ._serializer import _SERIALIZE_INV 10 | from .elements import _DESERIALIZE_INV 11 | 12 | 13 | def _deserialize_message(elements: list[dict[str, Any]]) -> MessageChain: 14 | elem_seq: list[Element] = [] 15 | for e_data in elements: 16 | cls = _DESERIALIZE_INV.get(e_data.pop("type"), None) 17 | if cls is None: 18 | logger.warning(f"未知元素: {e_data!r}") 19 | elem_seq.append(Unknown("Unknown", e_data)) 20 | else: 21 | elem_seq.append(cls(**e_data)) 22 | return MessageChain(elem_seq) 23 | 24 | 25 | def _serialize_message(chain: MessageChain) -> list[dict[str, Any]]: 26 | res: list[dict[str, Any]] = [] 27 | for elem in chain: 28 | if serializer := _SERIALIZE_INV.get(elem.__class__): 29 | res.append(serializer(elem)) 30 | else: 31 | raise TypeError(f"无法转换元素 {elem!r}") 32 | return res 33 | -------------------------------------------------------------------------------- /python/ichika/message/_sealed.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | class SealedMarketFace: # Rust Native 5 | name: str 6 | 7 | 8 | class SealedImage: # Rust Native 9 | md5: bytes 10 | size: int 11 | width: int 12 | height: int 13 | image_type: int 14 | 15 | 16 | class SealedAudio: # Rust Native 17 | md5: bytes 18 | size: int 19 | file_type: int 20 | -------------------------------------------------------------------------------- /python/ichika/message/_serializer.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import functools 4 | from typing import Any, Callable, TypeVar 5 | 6 | from graia.amnesia.message import Element, Text 7 | 8 | from ichika.utils import Decor 9 | 10 | from .elements import ( 11 | At, 12 | AtAll, 13 | Dice, 14 | Face, 15 | FingerGuessing, 16 | FlashImage, 17 | ForwardCard, 18 | Image, 19 | LightApp, 20 | MarketFace, 21 | Reply, 22 | RichMessage, 23 | ) 24 | 25 | _SERIALIZE_INV: dict[type, Callable[[Any], dict[str, Any]]] = {} 26 | 27 | Elem_T = TypeVar("Elem_T", bound=Element) 28 | 29 | 30 | def _serialize( 31 | elem_type: type[Elem_T], 32 | ) -> Decor[Callable[[Elem_T], dict[str, Any]]]: 33 | def func_register(func: Callable[[Elem_T], dict[str, Any]]) -> Callable[[Elem_T], dict[str, Any]]: 34 | @functools.wraps(func) 35 | def wrapper(elem: Elem_T) -> dict[str, Any]: 36 | res = func(elem) 37 | res.setdefault("type", elem.__class__.__name__) 38 | return res 39 | 40 | _SERIALIZE_INV[elem_type] = wrapper 41 | return func 42 | 43 | return func_register 44 | 45 | 46 | _serialize(Reply)(lambda t: {"seq": t.seq, "sender": t.sender, "time": int(t.time.timestamp()), "content": t.content}) 47 | _serialize(Text)(lambda t: {"text": t.text}) 48 | _serialize(AtAll)(lambda _: {}) 49 | _serialize(At)(lambda t: {"target": t.target, "display": t.display}) 50 | _serialize(Dice)(lambda t: {"value": t.value}) 51 | _serialize(FingerGuessing)(lambda t: {"choice": t.choice.name}) 52 | _serialize(Face)(lambda t: {"index": t.index}) 53 | _serialize(MarketFace)(lambda t: {"raw": t.raw}) 54 | _serialize(LightApp)(lambda t: {"content": t.content}) 55 | _serialize(RichMessage)(lambda t: {"service_id": t.service_id, "content": t.content}) 56 | _serialize(ForwardCard)(lambda t: {"service_id": 35, "content": t.content}) 57 | 58 | 59 | @_serialize(Image) 60 | def _serialize_image(elem: Image): 61 | if elem.raw is None: 62 | raise ValueError 63 | return {"raw": elem.raw} 64 | 65 | 66 | @_serialize(FlashImage) 67 | def _serialize_flash_image(elem: FlashImage): 68 | if elem.raw is None: 69 | raise ValueError 70 | return {"raw": elem.raw} 71 | -------------------------------------------------------------------------------- /python/ichika/message/elements.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import pathlib 5 | import re 6 | from dataclasses import dataclass, field 7 | from datetime import datetime 8 | from enum import Enum 9 | from functools import total_ordering 10 | from io import BytesIO 11 | from typing import TYPE_CHECKING, Callable, Generic, Literal, Optional 12 | from typing_extensions import Self, TypeAlias, TypeGuard, TypeVar 13 | 14 | import aiohttp 15 | from graia.amnesia.message import Element, MessageChain 16 | from graia.amnesia.message.element import Text as Text 17 | 18 | from .. import core 19 | from ._sealed import SealedAudio, SealedImage, SealedMarketFace 20 | 21 | if TYPE_CHECKING: 22 | from ..client import Client as __Client 23 | 24 | 25 | @dataclass 26 | class Reply(Element): 27 | """回复元素""" 28 | 29 | seq: int 30 | """回复消息的序列号""" 31 | sender: int 32 | """原消息的发送者 QQ 号""" 33 | time: datetime 34 | """原消息的发送时间""" 35 | content: str 36 | """原消息的内容""" 37 | 38 | 39 | @dataclass 40 | class At(Element): 41 | """@元素""" 42 | 43 | target: int 44 | """@的目标 QQ 号""" 45 | display: str | None = None 46 | """@ 的目标的显示名,包括前导 @ 47 | 48 | 注意: 如果构造时不传入此参数,则会在手Q上显示为 “@{target}”,不过仍会起到通知效果。 49 | 50 | 参见 [#59](https://github.com/BlueGlassBlock/Ichika/issues/59) 51 | """ 52 | 53 | def __str__(self) -> str: 54 | return self.display or f"@{self.target}" 55 | 56 | @classmethod 57 | def build(cls, obj: core.Member) -> At: 58 | return cls(obj.uin, obj.card_name) 59 | 60 | 61 | @dataclass 62 | class AtAll(Element): 63 | """@全体成员元素""" 64 | 65 | def __str__(self) -> str: 66 | return "@全体成员" 67 | 68 | 69 | @dataclass(init=False) 70 | class FingerGuessing(Element): 71 | """猜拳元素""" 72 | 73 | @total_ordering 74 | class Choice(Enum): 75 | """猜拳选项""" 76 | 77 | Rock = "石头" 78 | Scissors = "剪刀" 79 | Paper = "布" 80 | 81 | def __eq__(self, other: Self) -> bool: 82 | if not isinstance(other, FingerGuessing.Choice): 83 | raise TypeError(f"{other} 不是 FingerGuessing.Choice") 84 | return self.value == other.value 85 | 86 | def __lt__(self, other: Self) -> bool: 87 | if not isinstance(other, FingerGuessing.Choice): 88 | raise TypeError(f"{other} 不是 FingerGuessing.Choice") 89 | return (self.name, other.name) in { 90 | ("Rock", "Scissors"), 91 | ("Scissors", "Paper"), 92 | ("Paper", "Rock"), 93 | } 94 | 95 | choice: Choice 96 | """猜拳选项""" 97 | 98 | def __init__( 99 | self, 100 | choice: Literal["Rock", "Paper", "Scissors" "石头", "剪刀", "布"] | Choice, 101 | ) -> None: 102 | """初始化猜拳元素 103 | 104 | :param choice: 猜拳选项 105 | """ 106 | C = FingerGuessing.Choice 107 | if isinstance(choice, str): 108 | self.choice = C[choice] if choice in C else C(choice) 109 | if isinstance(choice, C): 110 | self.choice = choice 111 | raise TypeError(f"无效的猜拳参数:{choice}") 112 | 113 | def __str__(self) -> str: 114 | return f"[猜拳: {self.choice.value}]" 115 | 116 | 117 | DiceValues: TypeAlias = Literal[1, 2, 3, 4, 5, 6] 118 | 119 | 120 | @dataclass 121 | class Dice(Element): 122 | """骰子元素""" 123 | 124 | value: Literal[1, 2, 3, 4, 5, 6] 125 | """骰子点数""" 126 | 127 | def __str__(self) -> str: 128 | return f"[骰子: {self.value}]" 129 | 130 | 131 | @dataclass(init=False) 132 | class Face(Element): 133 | """QQ 表情元素""" 134 | 135 | index: int 136 | """表情索引""" 137 | 138 | name: str 139 | """表情名称""" 140 | 141 | def __init__(self, index: int, name: str | None = None) -> None: 142 | self.index = index 143 | self.name = name or core.face_name_from_id(index) 144 | 145 | @classmethod 146 | def from_name(cls, name: str) -> Self: 147 | index = core.face_id_from_name(name) 148 | if index is None: 149 | raise ValueError("未知表情") 150 | return cls(index, name) 151 | 152 | def __str__(self) -> str: 153 | return f"[表情: {self.name}]" 154 | 155 | 156 | @dataclass 157 | class MusicShare(Element): 158 | """音乐分享 159 | 160 | 音乐分享本质为 “小程序” 161 | 但是可以用不同方式发送 162 | 并且风控几率较小 163 | """ 164 | 165 | kind: Literal["QQ", "Netease", "Migu", "Kugou", "Kuwo"] 166 | """音乐分享的来源""" 167 | title: str 168 | """音乐标题""" 169 | summary: str 170 | """音乐摘要""" 171 | jump_url: str 172 | """跳转链接""" 173 | picture_url: str 174 | """封面链接""" 175 | music_url: str 176 | """音乐链接""" 177 | brief: str 178 | """音乐简介""" 179 | 180 | def __str__(self) -> str: 181 | return f"[{self.kind}音乐分享: {self.title}]" 182 | 183 | 184 | @dataclass 185 | class LightApp(Element): 186 | """小程序 187 | 188 | 本框架不辅助音乐分享外的小程序构造与发送 189 | """ 190 | 191 | content: str 192 | """JSON 内容""" 193 | 194 | def __str__(self) -> str: 195 | return "[小程序]" 196 | 197 | 198 | @dataclass 199 | class ForwardCard(Element): 200 | """未下载的合并转发消息,本质为 XML 卡片""" 201 | 202 | res_id: str 203 | """转发卡片的资源 ID""" 204 | file_name: str 205 | """转发卡片的子文件名""" 206 | content: str 207 | """转发卡片的原始内容,可以为 XML 或 JSON 格式 (Android 8.9.50+?)""" 208 | 209 | def __str__(self) -> str: 210 | return "[合并转发]" 211 | 212 | async def download(self, client: __Client) -> list[ForwardMessage]: 213 | """使用 aiohttp 下载本转发卡片对应的转发消息 214 | 215 | :param client: 已登录的客户端 216 | 217 | :return: 转发消息列表 218 | """ 219 | 220 | async def _downloader(method: Literal["get", "post"], url: str, headers: dict[str, str], body: bytes) -> bytes: 221 | async with aiohttp.ClientSession(headers=headers) as session: 222 | async with session.request(method, url, data=body) as resp: 223 | return await resp.read() 224 | 225 | return await client.download_forward_msg(_downloader, self.res_id) 226 | 227 | 228 | @dataclass 229 | class ForwardMessage: 230 | """已下载的合并转发消息""" 231 | 232 | sender_id: int 233 | """发送者 QQ 号""" 234 | time: datetime 235 | """发送时间""" 236 | sender_name: str 237 | """发送者昵称""" 238 | content: MessageChain | list[ForwardMessage] 239 | """消息内容""" 240 | 241 | @classmethod 242 | def build(cls, sender: core.Friend | core.Member, time: datetime, content: MessageChain) -> ForwardMessage: 243 | return cls(sender.uin, time, sender.card_name if isinstance(sender, core.Member) else sender.nick, content) 244 | 245 | 246 | @dataclass 247 | class RichMessage(Element): 248 | """卡片消息""" 249 | 250 | service_id: int 251 | """服务 ID""" 252 | content: str 253 | """卡片内容""" 254 | 255 | def __str__(self) -> str: 256 | return "[富文本卡片]" 257 | 258 | 259 | T_Audio = TypeVar("T_Audio", bound=Optional[SealedAudio], default=SealedAudio) 260 | 261 | 262 | @dataclass(init=False) 263 | class Audio(Generic[T_Audio], Element): 264 | """音频元素""" 265 | 266 | url: str 267 | """音频链接""" 268 | raw: T_Audio = field(compare=False) 269 | """原始音频数据""" 270 | _data_cache: bytes | None = field(repr=False, compare=False) 271 | 272 | def __init__(self, url: str, raw: T_Audio = None) -> None: 273 | self.url = url 274 | self._data_cache = None 275 | self.raw = raw 276 | 277 | @classmethod 278 | def build(cls, data: bytes | BytesIO | pathlib.Path) -> Audio[None]: 279 | """构造音频元素 280 | 281 | :param data: 音频数据 282 | 283 | :return: 未上传的音频元素 284 | """ 285 | if isinstance(data, BytesIO): 286 | data = data.read() 287 | elif isinstance(data, pathlib.Path): 288 | data = data.read_bytes() 289 | audio = Audio(f"base64://{base64.urlsafe_b64encode(data)}") 290 | audio._data_cache = data 291 | return audio 292 | 293 | @classmethod 294 | def _check(cls, elem: Element) -> TypeGuard[Audio[Optional[SealedAudio]]]: 295 | return isinstance(elem, Audio) 296 | 297 | @property 298 | def md5(self: Audio[SealedAudio]) -> bytes: 299 | """音频 MD5 值""" 300 | return self.raw.md5 301 | 302 | @property 303 | def size(self: Audio[SealedAudio]) -> int: 304 | """音频大小""" 305 | return self.raw.size 306 | 307 | @property 308 | def file_type(self: Audio[SealedAudio]) -> int: 309 | """音频类型""" 310 | return self.raw.file_type 311 | 312 | async def fetch(self) -> bytes: 313 | """获取音频数据 314 | 315 | :return: 音频数据 316 | """ 317 | if self._data_cache is None: 318 | if self.url.startswith("base64://"): 319 | self._data_cache = base64.urlsafe_b64decode(self.url[8:]) 320 | else: 321 | async with aiohttp.ClientSession() as session: 322 | async with session.get(self.url) as resp: 323 | self._data_cache = await resp.read() 324 | return self._data_cache 325 | 326 | def __repr__(self) -> str: 327 | return "[音频]" 328 | 329 | 330 | T_Image = TypeVar("T_Image", bound=Optional[SealedImage], default=SealedImage) 331 | 332 | 333 | @dataclass(init=False) 334 | class Image(Generic[T_Image], Element): 335 | """图片元素""" 336 | 337 | url: str 338 | """图片链接""" 339 | raw: T_Image = field(compare=False) 340 | """原始图片数据""" 341 | _data_cache: bytes | None = field(repr=False, compare=False) 342 | 343 | def __init__(self, url: str, raw: T_Image = None) -> None: 344 | self.url = url 345 | self._data_cache = None 346 | self.raw = raw 347 | 348 | @classmethod 349 | def build(cls, data: bytes | BytesIO | pathlib.Path) -> Image[None]: 350 | """构造图片元素 351 | 352 | :param data: 图片数据 353 | 354 | :return: 未上传的图片元素 355 | """ 356 | if isinstance(data, BytesIO): 357 | data = data.read() 358 | elif isinstance(data, pathlib.Path): 359 | data = data.read_bytes() 360 | img = Image(f"base64://{base64.urlsafe_b64encode(data)}") 361 | img._data_cache = data 362 | return img 363 | 364 | @classmethod 365 | def _check(cls, elem: Element) -> TypeGuard[Image[Optional[SealedImage]]]: 366 | return isinstance(elem, Image) 367 | 368 | @property 369 | def md5(self: Image[SealedImage]) -> bytes: 370 | """图片 MD5 值""" 371 | return self.raw.md5 372 | 373 | @property 374 | def size(self: Image[SealedImage]) -> int: 375 | """图片大小""" 376 | return self.raw.size 377 | 378 | @property 379 | def width(self: Image[SealedImage]) -> int: 380 | """图片宽度""" 381 | return self.raw.width 382 | 383 | @property 384 | def height(self: Image[SealedImage]) -> int: 385 | """图片高度""" 386 | return self.raw.height 387 | 388 | @property 389 | def image_type(self: Image[SealedImage]) -> int: 390 | """图片类型""" 391 | return self.raw.image_type 392 | 393 | async def fetch(self) -> bytes: 394 | """获取图片数据 395 | 396 | :return: 图片数据 397 | """ 398 | if self._data_cache is None: 399 | if self.url.startswith("base64://"): 400 | self._data_cache = base64.urlsafe_b64decode(self.url[8:]) 401 | else: 402 | async with aiohttp.ClientSession() as session: 403 | async with session.get(self.url) as resp: 404 | self._data_cache = await resp.read() 405 | return self._data_cache 406 | 407 | def as_flash(self) -> FlashImage[T_Image]: 408 | """转换为闪照元素 409 | 410 | :return: 闪照元素 411 | """ 412 | img = FlashImage(self.url, self.raw) 413 | img._data_cache = self._data_cache 414 | return img 415 | 416 | def __str__(self) -> str: 417 | return "[图片]" 418 | 419 | 420 | @dataclass(init=False) 421 | class FlashImage(Image[T_Image]): 422 | """闪照元素""" 423 | 424 | @classmethod 425 | def build(cls, data: bytes | BytesIO | pathlib.Path) -> FlashImage[None]: 426 | """构造闪照元素 427 | 428 | :param data: 闪照数据 429 | 430 | :return: 未上传的闪照元素 431 | """ 432 | return Image.build(data).as_flash() 433 | 434 | @classmethod 435 | def _check(cls, elem: Element) -> TypeGuard[FlashImage[Optional[SealedImage]]]: 436 | return isinstance(elem, FlashImage) 437 | 438 | def as_image(self) -> Image[T_Image]: 439 | """转换为图片元素 440 | 441 | :return: 图片元素 442 | """ 443 | img = Image(self.url, self.raw) 444 | img._data_cache = self._data_cache 445 | return img 446 | 447 | def __str__(self) -> str: 448 | return "[闪照]" 449 | 450 | 451 | class Video(Element): 452 | ... 453 | 454 | 455 | class MarketFace(Element): 456 | """商城表情元素""" 457 | 458 | def __init__(self, raw: SealedMarketFace) -> None: 459 | self.raw = raw 460 | 461 | @property 462 | def name(self) -> str: 463 | """表情名称""" 464 | return self.raw.name 465 | 466 | def __str__(self) -> str: 467 | return f"[商城表情:{self.name}]" 468 | 469 | def __repr__(self) -> str: 470 | return f"MarketFace(name={self.name})" 471 | 472 | 473 | _DESERIALIZE_INV: dict[str, Callable[..., Element]] = { 474 | cls.__name__: cls 475 | for cls in Element.__subclasses__() 476 | if cls.__module__.startswith(("ichika", "graia.amnesia")) and cls is not Video 477 | } 478 | 479 | __MUSIC_SHARE_APPID_MAP: dict[int, Literal["QQ", "Netease", "Migu", "Kugou", "Kuwo"]] = { 480 | 100497308: "QQ", 481 | 100495085: "Netease", 482 | 1101053067: "Migu", 483 | 205141: "Kugou", 484 | 100243533: "Kuwo", 485 | } 486 | 487 | 488 | def _light_app_deserializer(**data) -> Element: 489 | import json 490 | from contextlib import suppress 491 | 492 | with suppress(ValueError, KeyError): 493 | app_data = json.loads(data["content"]) 494 | if app_data["app"] == "com.tencent.multimsg": 495 | res_id = app_data["meta"]["resid"] 496 | extra = json.loads(app_data["extra"]) 497 | return ForwardCard(res_id=res_id, file_name=extra["filename"], content=data["content"]) 498 | 499 | # MusicShare resolver 500 | # https://github.com/mamoe/mirai/blob/893fb3e9f653623056f9c4bff73b4dac957cd2a2/mirai-core/src/commonMain/kotlin/message/data/lightApp.kt 501 | if "music" in app_data["meta"]: 502 | music_info = app_data["meta"]["music"] 503 | return MusicShare( 504 | kind=__MUSIC_SHARE_APPID_MAP[app_data["extra"]["appid"]], 505 | title=music_info["title"], 506 | summary=music_info["desc"], 507 | jump_url=music_info["jumpUrl"], 508 | picture_url=music_info["preview"], 509 | music_url=music_info["musicUrl"], 510 | brief=data["prompt"], 511 | ) 512 | 513 | return LightApp(content=data["content"]) 514 | 515 | 516 | __RES_ID_PAT = re.compile(r"m_resid=\"(.*?)\"") 517 | __FILE_NAME_PAT = re.compile(r"m_fileName=\"(.*?)\"") 518 | 519 | 520 | def _rich_msg_deserializer(**data) -> Element: 521 | service_id: int = data["service_id"] 522 | content: str = data["content"] 523 | 524 | if (res_id_match := __RES_ID_PAT.search(content)) and (file_name_match := __FILE_NAME_PAT.search(content)): 525 | return ForwardCard(res_id=res_id_match[1], file_name=file_name_match[1], content=content) 526 | 527 | return RichMessage(service_id=service_id, content=content) 528 | 529 | 530 | _DESERIALIZE_INV["LightApp"] = _light_app_deserializer 531 | _DESERIALIZE_INV["RichMessage"] = _rich_msg_deserializer 532 | -------------------------------------------------------------------------------- /python/ichika/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GraiaProject/Ichika/d1a30322bf5f83552480e32d59b062f7608f2e14/python/ichika/scripts/__init__.py -------------------------------------------------------------------------------- /python/ichika/scripts/device/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib.resources 2 | import json 3 | from dataclasses import dataclass 4 | from typing import Dict, List, Optional 5 | 6 | from dacite.core import from_dict 7 | 8 | 9 | @dataclass 10 | class OSVersion: 11 | incremental: str 12 | release: str 13 | codename: str 14 | sdk: int 15 | 16 | 17 | @dataclass 18 | class RICQDevice: 19 | display: str 20 | product: str 21 | device: str 22 | board: str 23 | model: str 24 | finger_print: str 25 | boot_id: str 26 | proc_version: str 27 | imei: str 28 | brand: str 29 | bootloader: str 30 | base_band: str 31 | version: OSVersion 32 | sim_info: str 33 | os_type: str 34 | mac_address: str 35 | ip_address: List[int] 36 | wifi_bssid: str 37 | wifi_ssid: str 38 | imsi_md5: List[int] 39 | android_id: str 40 | apn: str 41 | vendor_name: str 42 | vendor_os_name: str 43 | 44 | 45 | @dataclass 46 | class Model: 47 | name: str 48 | brand: str 49 | tac: str 50 | fac: str 51 | board: str 52 | device: str 53 | display: str 54 | proc: Optional[str] = None 55 | os_versions: Optional[List[OSVersion]] = None 56 | model: Optional[str] = None 57 | finger: Optional[str] = None 58 | 59 | 60 | @dataclass 61 | class Data: 62 | os_versions: List[OSVersion] 63 | addr: Dict[str, List[str]] 64 | models: List[Model] 65 | 66 | 67 | data: Data = from_dict(Data, json.loads(importlib.resources.read_text(__name__, "data.json", "utf-8"))) 68 | -------------------------------------------------------------------------------- /python/ichika/scripts/device/converter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import string 5 | import uuid 6 | from dataclasses import asdict as to_dict 7 | from random import Random 8 | from typing import Any, List 9 | 10 | from dacite.config import Config 11 | from dacite.core import from_dict 12 | 13 | from . import RICQDevice, data 14 | 15 | 16 | def string_hook(source: str | list[int]) -> str: 17 | return source if isinstance(source, str) else bytes(source).decode("utf-8") 18 | 19 | 20 | def list_int_hook(source: str | list[int]) -> list[int]: 21 | return [t % 256 for t in source] if isinstance(source, list) else list(bytes.fromhex(source)) 22 | 23 | 24 | def camel_to_snake(src: str) -> str: 25 | return "".join([f"_{i.lower()}" if i.isupper() else i for i in src]).lstrip("_") 26 | 27 | 28 | rng = Random() 29 | 30 | 31 | def random_imei() -> str: 32 | tot: int = 0 33 | res: list[str] = [] 34 | for i in range(15): 35 | to_add = rng.randrange(0, 10) 36 | if (i + 2) % 2 == 0: 37 | to_add *= 2 38 | if to_add >= 10: 39 | to_add = (to_add % 10) + 1 40 | tot += to_add 41 | res.append(str(to_add)) 42 | res.append(str(tot * 9 % 10)) 43 | return "".join(res) 44 | 45 | 46 | def make_defaults() -> dict: 47 | from .generator import generate 48 | 49 | return to_dict(generate()) 50 | 51 | 52 | def convert(source: dict) -> RICQDevice: 53 | if "deviceInfoVersion" in source: # mirai 54 | source = source["data"] 55 | if "fingerprint" in source: 56 | source["finger_print"] = source["fingerprint"] 57 | params = make_defaults() 58 | version: Any = source.setdefault("version", params["version"]) 59 | version.update(source.get("version", {})) 60 | for key in source: 61 | converted_key = camel_to_snake(key) 62 | if converted_key in params: 63 | if isinstance(params[converted_key], dict): 64 | params[converted_key].update(source[key]) 65 | else: 66 | params[converted_key] = source[key] 67 | return from_dict(RICQDevice, params, Config({str: string_hook, List[int]: list_int_hook})) 68 | -------------------------------------------------------------------------------- /python/ichika/scripts/device/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "os_versions": [ 3 | { 4 | "incremental": "5891938", 5 | "release": "10", 6 | "codename": "REL", 7 | "sdk": 29 8 | } 9 | ], 10 | "addr": { 11 | "Huawei": [ 12 | "4c:50:77", 13 | "8c:0d:76" 14 | ], 15 | "Xiaomi": [ 16 | "c4:6a:b7" 17 | ] 18 | }, 19 | "models": [ 20 | { 21 | "name": "Redmi Note 9 Pro 5G", 22 | "brand": "Xiaomi", 23 | "tac": "864365", 24 | "fac": "05", 25 | "board": "sm7225", 26 | "device": "sagit", 27 | "display": "OPR1.170623.027" 28 | }, 29 | { 30 | "name": "Redmi Note 8", 31 | "brand": "Xiaomi", 32 | "tac": "863971", 33 | "fac": "05", 34 | "board": "msm8953", 35 | "device": "sagit", 36 | "display": "OPR1.170623.027" 37 | }, 38 | { 39 | "name": "MI 11 PRO", 40 | "brand": "Xiaomi", 41 | "tac": "861033", 42 | "fac": "05", 43 | "board": "sm8350", 44 | "device": "mars", 45 | "display": "SKQ1.211006.001", 46 | "proc": "Linux version 5.4.86-qgki-gda9f45eed743 (builder@bj.idc.xiaomi.com)", 47 | "os_versions": [ 48 | { 49 | "incremental": "22.1.17", 50 | "release": "12", 51 | "codename": "REL", 52 | "sdk": 31 53 | } 54 | ] 55 | }, 56 | { 57 | "name": "Honor Play 4", 58 | "brand": "Huawei", 59 | "tac": "866516", 60 | "fac": "04", 61 | "board": "msm8916", 62 | "device": "hwG620S-UL0", 63 | "model": "G620S-UL0", 64 | "display": "G620S-UL00V100R001C17B360_KangVIP", 65 | "finger": "Huawei/G620S-UL00/hwG620S-UL00:4.4.4/HuaweiG620S-UL00/C17B360:user/release-keys", 66 | "os_versions": [ 67 | { 68 | "incremental": "C17B360", 69 | "release": "4.4.4", 70 | "codename": "REL", 71 | "sdk": 19 72 | } 73 | ] 74 | }, 75 | { 76 | "name": "Huawei Mate 8", 77 | "brand": "Huawei", 78 | "tac": "868632", 79 | "fac": "03", 80 | "board": "hi3650", 81 | "device": "hi3650", 82 | "display": "hi3650", 83 | "os_versions": [ 84 | { 85 | "incremental": "1482960749", 86 | "release": "7.0", 87 | "codename": "REL", 88 | "sdk": 24 89 | } 90 | ] 91 | }, 92 | { 93 | "name": "Huawei Mate 9 Pro", 94 | "brand": "Huawei", 95 | "tac": "862005", 96 | "fac": "03", 97 | "board": "hi3660", 98 | "device": "hi3660", 99 | "display": "hi3660", 100 | "os_versions": [ 101 | { 102 | "incremental": "1484745697", 103 | "release": "7.0", 104 | "codename": "REL", 105 | "sdk": 24 106 | } 107 | ] 108 | }, 109 | { 110 | "name": "Huawei Mediapad M2", 111 | "brand": "Huawei", 112 | "tac": "865881", 113 | "fac": "03", 114 | "board": "hi3635", 115 | "device": "HWMozart", 116 | "display": "M2-801LV100R001C209B007", 117 | "finger": "HUAWEI/M2/HWMozart:5.1.1/HUAWEIM2-801L/C209B007:user/release-keys", 118 | "os_versions": [ 119 | { 120 | "incremental": "C209B007", 121 | "release": "5.1.1", 122 | "codename": "REL", 123 | "sdk": 22 124 | } 125 | ] 126 | }, 127 | { 128 | "name": "Huawei Mediapad M3", 129 | "brand": "Huawei", 130 | "tac": "865881", 131 | "fac": "03", 132 | "board": "hi3650", 133 | "device": "hwbeethoven", 134 | "display": "OPR1.170623.027", 135 | "finger": "Huawei/BTV/hi3650:6.0/MRA58K/huawei12151809:user/release-keys", 136 | "os_versions": [ 137 | { 138 | "incremental": "eng.huawei.20161215.180805", 139 | "release": "6.0", 140 | "codename": "REL", 141 | "sdk": 23 142 | } 143 | ] 144 | }, 145 | { 146 | "name": "Huawei Nova Plus", 147 | "brand": "Huawei", 148 | "tac": "866150", 149 | "fac": "03", 150 | "board": "msm8953", 151 | "device": "msm8953_64", 152 | "display": "OPR1.170623.027", 153 | "os_versions": [ 154 | { 155 | "incremental": "1484322243", 156 | "release": "7.0", 157 | "codename": "REL", 158 | "sdk": 24 159 | } 160 | ] 161 | }, 162 | { 163 | "name": "Huawei P9 Plus", 164 | "brand": "Huawei", 165 | "tac": "869989", 166 | "fac": "02", 167 | "board": "hi3650", 168 | "device": "hi3650", 169 | "display": "OPR1.170623.027", 170 | "os_versions": [ 171 | { 172 | "incremental": "1483046909", 173 | "release": "7.0", 174 | "codename": "REL", 175 | "sdk": 24 176 | } 177 | ] 178 | } 179 | ] 180 | } 181 | -------------------------------------------------------------------------------- /python/ichika/scripts/device/generator.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import string 3 | import uuid 4 | from random import Random 5 | 6 | from . import Model, OSVersion, RICQDevice, data 7 | 8 | 9 | def gen_finger_print(model: Model, version: OSVersion) -> str: 10 | device: str = model.model or model.device 11 | return f"{model.brand}/{device}/{device}:{version.release}/{model.display}/{version.incremental}:user/release-keys" 12 | 13 | 14 | def luhn(code: str) -> int: 15 | tot: int = 0 16 | 17 | def parse_even(p: int) -> int: 18 | return p % 10 + p // 10 19 | 20 | for i in range(len(code)): 21 | tot += int(code[i]) if i % 2 == 0 else parse_even(int(code[i]) * 2) 22 | return tot * 9 % 10 23 | 24 | 25 | def get_imei(rng: Random, model: Model) -> str: 26 | snr = str(rng.randrange(100000, 1000000)) 27 | sp = luhn(model.tac + model.fac + snr) 28 | return model.tac + model.fac + snr + str(sp) 29 | 30 | 31 | def get_mac_addr(rng: Random, model: Model) -> str: 32 | if model.brand in data.addr: 33 | return rng.choice(data.addr[model.brand]) + "".join(f":{rng.randrange(0, 256):02x}" for _ in range(3)) 34 | return ":".join(f"{rng.randrange(0, 256):02x}" for _ in range(6)) 35 | 36 | 37 | def generate(rng: Random = Random(hash(""))) -> RICQDevice: 38 | model = rng.choice(data.models) 39 | os_version: OSVersion = rng.choice(model.os_versions) if model.os_versions else rng.choice(data.os_versions) 40 | return RICQDevice( 41 | display=model.display, 42 | product=model.name, 43 | device=model.device, 44 | board=model.board, 45 | brand=model.brand, 46 | model=model.model or model.device, 47 | bootloader="unknown", 48 | proc_version=model.proc 49 | or f"Linux 5.4.0-54-generic-{''.join(rng.choices(string.hexdigits, k=8))} (android-build@google.com)", 50 | base_band="", 51 | finger_print=gen_finger_print(model, os_version), 52 | boot_id=str(uuid.uuid4()), 53 | imei=get_imei(rng, model), 54 | version=os_version, 55 | sim_info="T-Mobile", 56 | os_type="android", 57 | wifi_bssid="02:00:00:00:00:00", 58 | wifi_ssid="", 59 | imsi_md5=list(hashlib.md5(rng.getrandbits(16 * 8).to_bytes(16, "little")).digest()), 60 | ip_address=[10, 0, 1, 3], 61 | apn="wifi", 62 | mac_address=get_mac_addr(rng, model), 63 | android_id="".join(f"{rng.randrange(0, 256):02x}" for _ in range(8)), 64 | vendor_name=model.brand.lower(), 65 | vendor_os_name="unknown", 66 | ) 67 | -------------------------------------------------------------------------------- /python/ichika/structs.py: -------------------------------------------------------------------------------- 1 | from enum import auto 2 | from functools import total_ordering 3 | 4 | from .utils import AutoEnum 5 | 6 | 7 | class Gender(AutoEnum): 8 | """性别""" 9 | 10 | Male = auto() 11 | """男性""" 12 | Female = auto() 13 | """女性""" 14 | Unknown = auto() 15 | """未知""" 16 | 17 | 18 | @total_ordering 19 | class GroupPermission(AutoEnum): 20 | Owner = auto() 21 | """群主""" 22 | Admin = auto() 23 | """管理员""" 24 | Member = auto() 25 | """群成员""" 26 | 27 | def __lt__(self, other: object): 28 | if not isinstance(other, GroupPermission): 29 | return NotImplemented 30 | if self is GroupPermission.Owner: 31 | return False 32 | if self is GroupPermission.Admin: 33 | return other is GroupPermission.Owner 34 | return other is not GroupPermission.Member 35 | -------------------------------------------------------------------------------- /python/ichika/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import contextlib 4 | import sys 5 | from enum import Enum 6 | from typing import Any, Awaitable, Callable, Generic, Literal, TypeVar, Union 7 | from typing_extensions import Annotated, ParamSpec, TypeAlias, get_args 8 | from typing_extensions import get_origin as typing_get_origin 9 | 10 | C_T = TypeVar("C_T", bound=Callable) 11 | T = TypeVar("T") 12 | R = TypeVar("R") 13 | P = ParamSpec("P") 14 | Decor: TypeAlias = Callable[[C_T], C_T] 15 | AsyncFn: TypeAlias = Callable[P, Awaitable[T]] 16 | 17 | 18 | class AutoEnum(Enum): 19 | """以名字为值的自动枚举""" 20 | 21 | _value_: str 22 | value: str 23 | 24 | def _generate_next_value_(name, *_): 25 | return name 26 | 27 | 28 | class Ref(Generic[T]): 29 | def __init__(self, val: T) -> None: 30 | self.ref: T = val 31 | 32 | 33 | AnnotatedType: type = type(Annotated[int, lambda x: x > 0]) 34 | if sys.version_info >= (3, 10): 35 | import types 36 | 37 | Unions = (Union, types.UnionType) 38 | else: 39 | Unions = (Union,) 40 | 41 | 42 | def get_origin(obj: Any) -> Any: 43 | return typing_get_origin(obj) or obj 44 | 45 | 46 | def generic_issubclass(cls: type, par: Union[type, Any, tuple[type, ...]]) -> bool: 47 | if par is Any: 48 | return True 49 | if cls is type(None) and par is None: 50 | return True 51 | with contextlib.suppress(TypeError): 52 | if isinstance(par, AnnotatedType): 53 | return generic_issubclass(cls, get_args(par)[0]) 54 | if isinstance(par, type): 55 | return issubclass(cls, par) 56 | if get_origin(par) in Unions: 57 | return any(generic_issubclass(cls, p) for p in get_args(par)) 58 | if isinstance(par, TypeVar): 59 | if par.__constraints__: 60 | return any(generic_issubclass(cls, p) for p in par.__constraints__) 61 | if par.__bound__: 62 | return generic_issubclass(cls, par.__bound__) 63 | if isinstance(par, tuple): 64 | return any(generic_issubclass(cls, p) for p in par) 65 | if issubclass(cls, get_origin(par)): 66 | return True 67 | return False 68 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | components = ["rustc", "cargo", "rust-std", "clippy", "rustfmt"] 4 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | reorder_impl_items = true 3 | format_macro_matchers = true 4 | format_macro_bodies = true 5 | blank_lines_upper_bound = 2 6 | control_brace_style = "AlwaysSameLine" 7 | imports_granularity = "Module" 8 | group_imports = "StdExternalCrate" 9 | imports_layout = "HorizontalVertical" 10 | format_strings = true 11 | combine_control_expr = false 12 | -------------------------------------------------------------------------------- /src/build_info.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | use pyo3::types::PyDict; 3 | 4 | use crate::dict; 5 | include!(concat!(env!("OUT_DIR"), "/build-info.rs")); 6 | 7 | pub fn get_info(py: Python) -> PyResult<&PyDict> { 8 | let builder = dict! {py, 9 | rustc: RUSTC, 10 | rustc_version: RUSTC_VERSION, 11 | opt_level: OPT_LEVEL, 12 | debug: DEBUG, 13 | jobs: NUM_JOBS, 14 | }; 15 | 16 | let target = dict! {py, 17 | arch: CFG_TARGET_ARCH, 18 | os: CFG_OS, 19 | family: CFG_FAMILY, 20 | compiler: CFG_ENV, 21 | triple: TARGET, 22 | endian: CFG_ENDIAN, 23 | pointer_width: CFG_POINTER_WIDTH, 24 | profile: PROFILE, 25 | }; 26 | 27 | let dependencies = PyDict::new(py); 28 | for (name, ver) in DEPENDENCIES { 29 | dependencies.set_item(name, ver)?; 30 | } 31 | 32 | let build_time = py 33 | .import("email.utils")? 34 | .getattr("parsedate_to_datetime")? 35 | .call1((BUILT_TIME_UTC,))? 36 | .into_py(py); 37 | 38 | Ok(dict! {py, 39 | builder: builder, 40 | target: target, 41 | dependencies: dependencies, 42 | build_time: build_time, 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /src/client/http.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use async_trait::async_trait; 4 | use pyo3::prelude::*; 5 | use pyo3::types::PyBytes; 6 | use pyo3_asyncio::{into_future_with_locals, TaskLocals}; 7 | use ricq::ext::http::{HttpClient as RQHttpClient, HttpMethod as RQHttpMethod}; 8 | use ricq::RQError; 9 | 10 | use crate::utils::py_try; 11 | 12 | pub fn get_rust_client<'py>(py: Python<'py>, callable: &'py PyAny) -> PyResult { 13 | let locals = TaskLocals::with_running_loop(py)?.copy_context(py)?; 14 | Ok(PyHttpClient { 15 | callable: callable.into_py(py), 16 | locals, 17 | }) 18 | } 19 | 20 | pub struct PyHttpClient { 21 | callable: PyObject, 22 | locals: TaskLocals, 23 | } 24 | 25 | fn http_method_to_string(method: RQHttpMethod) -> String { 26 | match method { 27 | RQHttpMethod::GET => "get".into(), 28 | RQHttpMethod::POST => "post".into(), 29 | } 30 | } 31 | 32 | #[async_trait] 33 | impl RQHttpClient for PyHttpClient { 34 | async fn make_request( 35 | &mut self, 36 | method: RQHttpMethod, 37 | url: String, 38 | header: &HashMap, 39 | body: bytes::Bytes, 40 | ) -> Result { 41 | let py_res = py_try(|py| { 42 | let header = header.clone().into_py(py); 43 | let body = PyBytes::new(py, &body); 44 | into_future_with_locals( 45 | &self.locals, 46 | self.callable.as_ref(py).call1(( 47 | http_method_to_string(method), 48 | url, 49 | header, 50 | body, 51 | ))?, 52 | ) 53 | }) 54 | .map_err(|e| RQError::Other(e.to_string()))? 55 | .await 56 | .map_err(|e| RQError::Other(e.to_string()))?; 57 | py_try(move |py| { 58 | let bin = py_res.as_ref(py).downcast::()?; 59 | Ok(bytes::Bytes::from(Vec::from(bin.as_bytes()))) 60 | }) 61 | .map_err(|e| RQError::Decode(e.to_string())) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/client/params.rs: -------------------------------------------------------------------------------- 1 | use pyo3::exceptions::{PyTypeError, PyValueError}; 2 | use pyo3::prelude::*; 3 | use pyo3::types::*; 4 | use ricq::structs::{ForwardMessage, MusicShare, MusicVersion}; 5 | 6 | use crate::utils::py_try; 7 | 8 | #[derive(FromPyObject)] 9 | pub enum OnlineStatusParam { 10 | #[pyo3(annotation = "tuple[bool, int]")] 11 | Normal(bool, i32), 12 | #[pyo3(annotation = "tuple[int, str]")] 13 | Custom(u64, String), 14 | } 15 | 16 | impl From for ricq::structs::Status { 17 | fn from(value: OnlineStatusParam) -> Self { 18 | use ricq::structs::{CustomOnlineStatus, Status}; 19 | match value { 20 | OnlineStatusParam::Custom(face_index, wording) => Status { 21 | online_status: 11, 22 | ext_online_status: 2000, 23 | custom_status: Some(CustomOnlineStatus { 24 | face_index, 25 | wording, 26 | }), 27 | }, 28 | OnlineStatusParam::Normal(is_ext, index) => Status { 29 | online_status: if is_ext { 11 } else { index }, 30 | ext_online_status: if is_ext { i64::from(index) } else { 0 }, 31 | custom_status: None, 32 | }, 33 | } 34 | } 35 | } 36 | 37 | #[derive(FromPyObject)] 38 | pub struct MusicShareParam { 39 | #[pyo3(attribute)] 40 | kind: String, 41 | #[pyo3(attribute)] 42 | title: String, 43 | #[pyo3(attribute)] 44 | summary: String, 45 | #[pyo3(attribute)] 46 | jump_url: String, 47 | #[pyo3(attribute)] 48 | picture_url: String, 49 | #[pyo3(attribute)] 50 | music_url: String, 51 | #[pyo3(attribute)] 52 | brief: String, 53 | } 54 | 55 | impl TryFrom for (MusicShare, MusicVersion) { 56 | type Error = PyErr; 57 | 58 | fn try_from(value: MusicShareParam) -> Result { 59 | let MusicShareParam { 60 | kind, 61 | title, 62 | summary, 63 | jump_url, 64 | picture_url, 65 | music_url, 66 | brief, 67 | } = value; 68 | let version = match kind.as_str() { 69 | "QQ" => MusicVersion::QQ, 70 | "Netease" => MusicVersion::NETEASE, 71 | "Migu" => MusicVersion::MIGU, 72 | "Kugou" => MusicVersion::KUGOU, 73 | "Kuwo" => MusicVersion::KUWO, 74 | platform => { 75 | return Err(PyValueError::new_err(format!( 76 | "无法识别的音乐平台: {platform}" 77 | ))) 78 | } 79 | }; 80 | let share = MusicShare { 81 | title, 82 | brief, 83 | summary, 84 | url: jump_url, 85 | picture_url, 86 | music_url, 87 | }; 88 | Ok((share, version)) 89 | } 90 | } 91 | 92 | pub struct PyForwardMessage { 93 | sender_id: i64, 94 | time: i32, 95 | sender_name: String, 96 | content: PyInnerForward, 97 | } 98 | 99 | 100 | impl TryFrom for ForwardMessage { 101 | type Error = PyErr; 102 | 103 | fn try_from(value: PyForwardMessage) -> PyResult { 104 | use ricq::structs::{ForwardNode, MessageNode}; 105 | 106 | use crate::message::convert::deserialize_message_chain; 107 | 108 | let PyForwardMessage { 109 | sender_id, 110 | time, 111 | sender_name, 112 | content, 113 | } = value; 114 | Ok(match content { 115 | PyInnerForward::Message(msg) => Self::Message(MessageNode { 116 | sender_id, 117 | time, 118 | sender_name, 119 | elements: py_try(|py| deserialize_message_chain(msg.as_ref(py)))?, 120 | }), 121 | PyInnerForward::Forward(fwd) => Self::Forward(ForwardNode { 122 | sender_id, 123 | time, 124 | sender_name, 125 | nodes: fwd.into_iter().map(|v| v.try_into()).try_collect()?, 126 | }), 127 | }) 128 | } 129 | } 130 | 131 | pub enum PyInnerForward { 132 | Forward(Vec), 133 | Message(Py), 134 | } 135 | 136 | impl<'s> FromPyObject<'s> for PyForwardMessage { 137 | fn extract(obj: &'s PyAny) -> PyResult { 138 | let typ: String = obj.get_item("type")?.extract()?; 139 | let content: &PyList = obj.get_item("content")?.extract()?; 140 | Ok(Self { 141 | sender_id: obj.get_item("sender_id")?.extract()?, 142 | time: obj.get_item("time")?.extract()?, 143 | sender_name: obj.get_item("sender_name")?.extract()?, 144 | content: match typ.as_str() { 145 | "Forward" => { 146 | PyInnerForward::Forward(content.into_iter().map(|o| o.extract()).try_collect()?) 147 | } 148 | "Message" => PyInnerForward::Message(content.into_py(content.py())), 149 | _ => Err(PyTypeError::new_err("Invalid forward content type"))?, 150 | }, 151 | }) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/client/structs.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use pyo3::exceptions::{PyIndexError, PyValueError}; 4 | use pyo3::prelude::*; 5 | use pyo3::types::*; 6 | use pyo3_repr::PyRepr; 7 | use ricq::structs::{FriendGroupInfo, FriendInfo, GroupInfo, GroupMemberInfo, MessageReceipt}; 8 | use ricq_core::command::friendlist::FriendListResponse; 9 | use ricq_core::command::oidb_svc::OcrResponse; 10 | use ricq_core::structs::SummaryCardInfo; 11 | 12 | use crate::utils::{datetime_from_ts, py_try, py_use, to_py_gender, to_py_permission}; 13 | #[pyclass(get_all, module = "ichika.core")] 14 | #[derive(PyRepr, Clone)] 15 | pub struct AccountInfo { 16 | pub nickname: String, 17 | pub age: u8, 18 | pub gender: PyObject, 19 | } 20 | 21 | #[pyclass(get_all, module = "ichika.core")] 22 | #[derive(PyRepr, Clone)] 23 | pub struct OtherClientInfo { 24 | pub app_id: i64, 25 | pub instance_id: i32, 26 | pub sub_platform: String, 27 | pub device_kind: String, 28 | } 29 | 30 | #[pyclass(get_all, module = "ichika.core")] 31 | #[derive(PyRepr, Clone)] 32 | pub struct RawMessageReceipt { 33 | pub seq: i32, 34 | pub rand: i32, 35 | pub raw_seqs: Py, 36 | pub raw_rands: Py, 37 | pub time: PyObject, // datetime 38 | pub kind: String, 39 | pub target: i64, 40 | } 41 | 42 | impl RawMessageReceipt { 43 | pub fn new(origin: MessageReceipt, kind: impl Into, target: i64) -> PyResult { 44 | let kind: String = kind.into(); 45 | let MessageReceipt { seqs, rands, time } = origin; 46 | let seq: i32 = *seqs 47 | .first() 48 | .ok_or_else(|| PyIndexError::new_err("Empty returning seqs"))?; 49 | let rand: i32 = *rands 50 | .first() 51 | .ok_or_else(|| PyIndexError::new_err("Empty returning rands"))?; 52 | py_try(|py| { 53 | let time = datetime_from_ts(py, time)?.to_object(py); 54 | Ok(Self { 55 | seq, 56 | rand, 57 | raw_seqs: PyTuple::new(py, seqs).into_py(py), 58 | raw_rands: PyTuple::new(py, rands).into_py(py), 59 | time, 60 | kind, 61 | target, 62 | }) 63 | }) 64 | } 65 | 66 | pub fn empty(kind: impl Into, target: i64) -> PyResult { 67 | let timestamp = std::time::SystemTime::now() 68 | .duration_since(std::time::SystemTime::UNIX_EPOCH) 69 | .map_err(|_| PyValueError::new_err("SystemTime before UNIX EPOCH"))?; 70 | Self::new( 71 | MessageReceipt { 72 | seqs: vec![0], 73 | rands: vec![0], 74 | time: timestamp.as_secs() as i64, 75 | }, 76 | kind, 77 | target, 78 | ) 79 | } 80 | } 81 | 82 | #[pyclass(get_all, module = "ichika.core")] 83 | #[derive(PyRepr, Clone)] 84 | pub struct OCRResult { 85 | pub texts: Py, // PyTuple 86 | pub language: String, 87 | } 88 | 89 | #[pyclass(get_all, module = "ichika.core")] 90 | #[derive(PyRepr, Clone)] 91 | pub struct OCRText { 92 | pub detected_text: String, 93 | pub confidence: i32, 94 | pub polygon: Option>, // PyTuple<(i32, i32))> 95 | pub advanced_info: String, 96 | } 97 | 98 | impl From for OCRResult { 99 | fn from(value: OcrResponse) -> Self { 100 | py_use(|py| { 101 | let OcrResponse { texts, language } = value; 102 | let text_iter = texts.into_iter().map(|txt| { 103 | let polygon = txt.polygon.map(|poly| { 104 | PyTuple::new( 105 | py, 106 | poly.coordinates 107 | .into_iter() 108 | .map(|coord| (coord.x, coord.y).to_object(py)), 109 | ) 110 | .into_py(py) 111 | }); 112 | OCRText { 113 | detected_text: txt.detected_text, 114 | confidence: txt.confidence, 115 | polygon, 116 | advanced_info: txt.advanced_info, 117 | } 118 | .into_py(py) 119 | }); 120 | OCRResult { 121 | texts: PyTuple::new(py, text_iter).into_py(py), 122 | language, 123 | } 124 | }) 125 | } 126 | } 127 | 128 | #[pyclass(get_all, module = "ichika.core")] 129 | #[derive(PyRepr, Clone)] 130 | pub struct Profile { 131 | pub uin: i64, 132 | pub gender: PyObject, 133 | pub age: u8, 134 | pub nickname: String, 135 | pub level: i32, 136 | pub city: String, 137 | pub sign: String, 138 | pub login_days: i64, 139 | } 140 | 141 | impl From for Profile { 142 | fn from(value: SummaryCardInfo) -> Self { 143 | let SummaryCardInfo { 144 | uin, 145 | sex, 146 | age, 147 | nickname, 148 | level, 149 | city, 150 | sign, 151 | login_days, 152 | .. 153 | } = value; 154 | Self { 155 | uin, 156 | gender: to_py_gender(sex), 157 | age, 158 | nickname, 159 | level, 160 | city, 161 | sign, 162 | login_days, 163 | } 164 | } 165 | } 166 | 167 | #[pyclass(get_all, module = "ichika.core")] 168 | #[derive(PyRepr, Clone)] 169 | pub struct Group { 170 | pub uin: i64, 171 | pub name: String, 172 | pub memo: String, 173 | pub owner_uin: i64, 174 | pub create_time: u32, 175 | pub level: u32, 176 | pub member_count: u16, 177 | pub max_member_count: u16, 178 | // 全群禁言时间 179 | pub global_mute_timestamp: i64, 180 | // 自己被禁言时间 181 | pub mute_timestamp: i64, 182 | // 最后一条信息的 SEQ,只有通过 GetGroupInfo 函数获取的 GroupInfo 才会有 183 | pub last_msg_seq: i64, 184 | } 185 | 186 | impl From for Group { 187 | fn from( 188 | GroupInfo { 189 | code, 190 | name, 191 | memo, 192 | owner_uin, 193 | group_create_time, 194 | group_level, 195 | member_count, 196 | max_member_count, 197 | shut_up_timestamp, 198 | my_shut_up_timestamp, 199 | last_msg_seq, 200 | .. 201 | }: GroupInfo, 202 | ) -> Self { 203 | Group { 204 | uin: code, 205 | name, 206 | memo, 207 | owner_uin, 208 | create_time: group_create_time, 209 | level: group_level, 210 | member_count, 211 | max_member_count, 212 | global_mute_timestamp: shut_up_timestamp, 213 | mute_timestamp: my_shut_up_timestamp, 214 | last_msg_seq, // TODO: maybe `Option`? 215 | } 216 | } 217 | } 218 | 219 | #[pyclass(get_all, module = "ichika.core")] 220 | #[derive(PyRepr, Clone)] 221 | pub struct Member { 222 | pub group_uin: i64, 223 | pub uin: i64, 224 | pub gender: PyObject, 225 | pub nickname: String, 226 | pub raw_card_name: String, 227 | pub level: u16, 228 | pub join_time: i64, // TODO: Datetime 229 | pub last_speak_time: i64, 230 | pub special_title: String, 231 | pub special_title_expire_time: i64, 232 | pub mute_timestamp: i64, 233 | pub permission: PyObject, 234 | } 235 | 236 | impl From for Member { 237 | fn from( 238 | GroupMemberInfo { 239 | group_code, 240 | uin, 241 | gender, 242 | nickname, 243 | card_name, 244 | level, 245 | join_time, 246 | last_speak_time, 247 | special_title, 248 | special_title_expire_time, 249 | shut_up_timestamp, 250 | permission, 251 | }: GroupMemberInfo, 252 | ) -> Self { 253 | Self { 254 | group_uin: group_code, 255 | uin, 256 | gender: to_py_gender(gender), 257 | nickname, 258 | raw_card_name: card_name, 259 | level, 260 | join_time, 261 | last_speak_time, 262 | special_title, 263 | special_title_expire_time, 264 | mute_timestamp: shut_up_timestamp, 265 | permission: to_py_permission(permission), 266 | } 267 | } 268 | } 269 | 270 | #[pymethods] 271 | impl Member { 272 | #[getter] 273 | fn card_name(&self) -> String { 274 | if self.raw_card_name.is_empty() { 275 | self.nickname.clone() 276 | } else { 277 | self.raw_card_name.clone() 278 | } 279 | } 280 | } 281 | 282 | #[pyclass(get_all, module = "ichika.core")] 283 | #[derive(PyRepr, Clone)] 284 | pub struct Friend { 285 | pub uin: i64, 286 | pub nick: String, 287 | pub remark: String, 288 | pub face_id: i16, 289 | pub group_id: u8, 290 | } 291 | 292 | impl From for Friend { 293 | fn from(info: FriendInfo) -> Self { 294 | Friend { 295 | uin: info.uin, 296 | nick: info.nick, 297 | remark: info.remark, 298 | face_id: info.face_id, 299 | group_id: info.group_id, 300 | } 301 | } 302 | } 303 | 304 | #[pyclass(get_all, module = "ichika.core")] 305 | #[derive(PyRepr, Clone)] 306 | pub struct FriendGroup { 307 | pub group_id: u8, 308 | pub name: String, 309 | pub total_count: i32, 310 | pub online_count: i32, 311 | pub seq_id: u8, 312 | } 313 | 314 | impl From for FriendGroup { 315 | fn from( 316 | FriendGroupInfo { 317 | group_id, 318 | group_name, 319 | friend_count, 320 | online_friend_count, 321 | seq_id, 322 | }: FriendGroupInfo, 323 | ) -> Self { 324 | FriendGroup { 325 | group_id, 326 | name: group_name, 327 | total_count: friend_count, 328 | online_count: online_friend_count, 329 | seq_id, 330 | } 331 | } 332 | } 333 | 334 | #[pyclass] 335 | #[derive(Clone, Debug)] 336 | pub struct FriendList { 337 | entries: Vec, 338 | friend_groups: HashMap, 339 | #[pyo3(get)] 340 | pub total_count: i16, 341 | #[pyo3(get)] 342 | pub online_count: i16, 343 | } 344 | 345 | #[pymethods] 346 | impl FriendList { 347 | pub fn friends(&self, py: Python) -> Py { 348 | PyTuple::new( 349 | py, 350 | self.entries 351 | .clone() 352 | .into_iter() 353 | .map(|f| f.into_py(py)) 354 | .collect::>(), 355 | ) 356 | .into_py(py) 357 | } 358 | 359 | pub fn find_friend(&self, uin: i64) -> Option { 360 | self.entries 361 | .iter() 362 | .find(|friend| friend.uin == uin) 363 | .cloned() 364 | } 365 | 366 | pub fn friend_groups(&self, py: Python) -> Py { 367 | PyTuple::new( 368 | py, 369 | self.friend_groups 370 | .clone() 371 | .into_values() 372 | .map(|g| g.into_py(py)) 373 | .collect::>(), 374 | ) 375 | .into_py(py) 376 | } 377 | 378 | pub fn find_friend_group(&self, group_id: u8) -> Option { 379 | self.friend_groups.get(&group_id).cloned() 380 | } 381 | } 382 | 383 | impl From for FriendList { 384 | fn from(resp: FriendListResponse) -> Self { 385 | Self { 386 | entries: resp.friends.into_iter().map(Friend::from).collect(), 387 | friend_groups: resp 388 | .friend_groups 389 | .into_iter() 390 | .map(|(g_id, info)| (g_id, FriendGroup::from(info))) 391 | .collect(), 392 | total_count: resp.total_count, 393 | online_count: resp.online_friend_count, 394 | } 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /src/events/converter.rs: -------------------------------------------------------------------------------- 1 | use pyo3::prelude::*; 2 | use pyo3::types::PyDict; 3 | use ricq::client::event as rce; 4 | use ricq::handler::QEvent; 5 | 6 | use super::MessageSource; 7 | use crate::client::structs::Friend; 8 | use crate::dict_obj; 9 | use crate::exc::MapPyErr; 10 | use crate::message::convert::{serialize_as_py_chain, serialize_audio}; 11 | use crate::utils::{datetime_from_ts, py_try, timedelta_from_secs}; 12 | 13 | type PyDictRet = PyResult>; 14 | 15 | pub async fn convert(event: QEvent) -> PyDictRet { 16 | match event { 17 | QEvent::Login(_) => dict_obj! {}, 18 | QEvent::GroupMessage(event) => handle_group_message(event).await, 19 | QEvent::GroupAudioMessage(event) => handle_group_audio(event).await, 20 | QEvent::FriendMessage(event) => handle_friend_message(event).await, 21 | QEvent::FriendAudioMessage(event) => handle_friend_audio(event).await, 22 | QEvent::GroupTempMessage(event) => handle_temp_message(event).await, 23 | QEvent::GroupMessageRecall(event) => handle_group_recall(event).await, 24 | QEvent::FriendMessageRecall(event) => handle_friend_recall(event).await, 25 | QEvent::GroupPoke(event) => handle_group_nudge(event).await, 26 | QEvent::FriendPoke(event) => handle_friend_nudge(event).await, 27 | QEvent::NewFriend(event) => handle_new_friend(event), 28 | QEvent::NewMember(event) => handle_new_member(event).await, 29 | QEvent::GroupLeave(event) => handle_group_leave(event).await, 30 | QEvent::GroupDisband(event) => handle_group_disband(event).await, 31 | QEvent::DeleteFriend(event) => handle_friend_delete(event).await, 32 | QEvent::GroupMute(event) => handle_mute(event).await, 33 | QEvent::MemberPermissionChange(event) => handle_permission_change(event).await, 34 | QEvent::GroupNameUpdate(event) => handle_group_info_update(event).await, 35 | QEvent::GroupRequest(event) => handle_group_request(event), 36 | QEvent::SelfInvited(event) => handle_group_invitation(event), 37 | QEvent::NewFriendRequest(event) => handle_friend_request(event), 38 | unknown => dict_obj!(type_name: "UnknownEvent", internal_repr: format!("{:?}", unknown)), 39 | } 40 | } 41 | 42 | async fn handle_group_message(event: rce::GroupMessageEvent) -> PyDictRet { 43 | let msg = event.inner; 44 | 45 | let content = py_try(|py| serialize_as_py_chain(py, msg.elements))?; 46 | dict_obj! {py ! 47 | type_name: "GroupMessage", 48 | source: MessageSource::new(py, &msg.seqs, &msg.rands, msg.time)?, 49 | content: content, 50 | group: msg.group_code, 51 | sender: msg.from_uin, 52 | } 53 | } 54 | 55 | async fn handle_group_recall(event: rce::GroupMessageRecallEvent) -> PyDictRet { 56 | let event = event.inner; 57 | let time = py_try(|py| Ok(datetime_from_ts(py, event.time)?.into_py(py)))?; 58 | dict_obj! { 59 | type_name: "GroupRecallMessage", 60 | time: time, 61 | group: event.group_code, 62 | author: event.author_uin, 63 | operator: event.operator_uin, 64 | seq: event.msg_seq, 65 | } 66 | } 67 | 68 | async fn handle_group_audio(event: rce::GroupAudioMessageEvent) -> PyDictRet { 69 | let url = event.url().await.py_res()?; 70 | let msg = event.inner; 71 | let content = py_try(|py| serialize_audio(py, url, &msg.audio.0))?; 72 | dict_obj! {py ! 73 | type_name: "GroupMessage", 74 | source: MessageSource::new(py, &msg.seqs, &msg.rands, msg.time)?, 75 | content: content, 76 | group: msg.group_code, 77 | sender: msg.from_uin, 78 | } 79 | } 80 | 81 | async fn handle_friend_message(event: rce::FriendMessageEvent) -> PyDictRet { 82 | let msg = event.inner; 83 | let content = py_try(|py| serialize_as_py_chain(py, msg.elements))?; 84 | dict_obj! {py ! 85 | type_name: "FriendMessage", 86 | source: MessageSource::new(py, &msg.seqs, &msg.rands, msg.time)?, 87 | content: content, 88 | sender: msg.from_uin, 89 | } 90 | } 91 | 92 | async fn handle_friend_recall(event: rce::FriendMessageRecallEvent) -> PyDictRet { 93 | let event = event.inner; 94 | let time = py_try(|py| Ok(datetime_from_ts(py, event.time)?.into_py(py)))?; 95 | dict_obj! { 96 | type_name: "FriendRecallMessage", 97 | time: time, 98 | author: event.friend_uin, 99 | seq: event.msg_seq, 100 | } 101 | } 102 | 103 | async fn handle_friend_audio(event: rce::FriendAudioMessageEvent) -> PyDictRet { 104 | let url = event.url().await.py_res()?; 105 | let msg = event.inner; 106 | let content = py_try(|py| serialize_audio(py, url, &msg.audio.0))?; 107 | dict_obj! {py ! 108 | type_name: "FriendMessage", 109 | source: MessageSource::new(py, &msg.seqs, &msg.rands, msg.time)?, 110 | content: content, 111 | sender: msg.from_uin, 112 | } 113 | } 114 | 115 | async fn handle_temp_message(event: rce::GroupTempMessageEvent) -> PyDictRet { 116 | let msg = event.inner; 117 | let content = py_try(|py| serialize_as_py_chain(py, msg.elements))?; 118 | 119 | 120 | dict_obj! {py ! 121 | type_name: "TempMessage", 122 | source: MessageSource::new(py, &msg.seqs, &msg.rands, msg.time)?, 123 | content: content, 124 | group: msg.group_code, 125 | sender: msg.from_uin, 126 | } 127 | } 128 | 129 | async fn handle_group_nudge(event: rce::GroupPokeEvent) -> PyDictRet { 130 | let event = event.inner; 131 | 132 | dict_obj! { 133 | type_name: "GroupNudge", 134 | group: event.group_code, 135 | sender: event.sender, 136 | receiver: event.receiver, 137 | } 138 | } 139 | 140 | async fn handle_friend_nudge(event: rce::FriendPokeEvent) -> PyDictRet { 141 | let client = event.client; 142 | if client.uin().await == event.inner.sender { 143 | return dict_obj! {}; 144 | } 145 | let event = event.inner; 146 | dict_obj! { 147 | type_name: "FriendNudge", 148 | sender: event.sender, 149 | } 150 | } 151 | 152 | fn handle_new_friend(event: rce::NewFriendEvent) -> PyDictRet { 153 | let friend: Friend = event.inner.into(); 154 | dict_obj! { 155 | type_name: "NewFriend", 156 | friend: friend, 157 | } 158 | } 159 | async fn handle_new_member(event: rce::NewMemberEvent) -> PyDictRet { 160 | let event = event.inner; 161 | dict_obj! { 162 | type_name: "NewMember", 163 | group: event.group_code, 164 | member: event.member_uin, 165 | } 166 | } 167 | 168 | async fn handle_group_leave(event: rce::GroupLeaveEvent) -> PyDictRet { 169 | let event = event.inner; 170 | 171 | dict_obj! { 172 | type_name: "MemberLeaveGroup", 173 | group_uin: event.group_code, 174 | member_uin: event.member_uin, 175 | } 176 | } 177 | 178 | async fn handle_group_disband(event: rce::GroupDisbandEvent) -> PyDictRet { 179 | let event = event.inner; 180 | dict_obj! { 181 | type_name: "GroupDisband", 182 | group_uin: event.group_code, 183 | operator_uin: event.operator_uin, 184 | } 185 | } 186 | 187 | async fn handle_friend_delete(event: rce::DeleteFriendEvent) -> PyDictRet { 188 | dict_obj! { 189 | type_name: "FriendDeleted", 190 | friend_uin: event.inner.uin, 191 | } 192 | } 193 | 194 | async fn handle_mute(event: rce::GroupMuteEvent) -> PyDictRet { 195 | let event = event.inner; 196 | 197 | if event.target_uin == 0 { 198 | return dict_obj! { 199 | type_name: "GroupMute", 200 | group: event.group_code, 201 | operator: event.operator_uin, 202 | status: event.duration.as_secs() == 0 203 | }; 204 | } 205 | let duration = event.duration.as_secs(); 206 | let duration = py_try(|py| { 207 | Ok(if duration != 0 { 208 | timedelta_from_secs(py, duration)?.into_py(py) 209 | } else { 210 | false.into_py(py) 211 | }) 212 | })?; 213 | dict_obj! { 214 | type_name: "MemberMute", 215 | group: event.group_code, 216 | operator: event.operator_uin, 217 | target: event.target_uin, 218 | duration: duration, 219 | } 220 | } 221 | 222 | async fn handle_permission_change(event: rce::MemberPermissionChangeEvent) -> PyDictRet { 223 | let event = event.inner; 224 | dict_obj! { 225 | type_name: "MemberPermissionChange", 226 | group: event.group_code, 227 | target: event.member_uin, 228 | permission: event.new_permission as u8, 229 | } 230 | } 231 | 232 | async fn handle_group_info_update(event: rce::GroupNameUpdateEvent) -> PyDictRet { 233 | let event = event.inner; 234 | let info: Py = dict_obj! { 235 | name: event.group_name 236 | }?; 237 | dict_obj! { 238 | type_name: "GroupInfoUpdate", 239 | group: event.group_code, 240 | operator: event.operator_uin, 241 | info: info, 242 | } 243 | } 244 | 245 | fn handle_group_request(event: rce::JoinGroupRequestEvent) -> PyDictRet { 246 | let event = event.inner; 247 | dict_obj! {py ! 248 | type_name: "JoinGroupRequest", 249 | seq: event.msg_seq, 250 | time: datetime_from_ts(py, event.msg_time).map(|v| v.into_py(py))?, 251 | group_uin: event.group_code, 252 | group_name: event.group_name, 253 | request_uin: event.req_uin, 254 | request_nickname: event.req_nick, 255 | suspicious: event.suspicious, 256 | invitor_uin: event.invitor_uin, 257 | invitor_nickname: event.invitor_nick, 258 | } 259 | } 260 | 261 | fn handle_group_invitation(event: rce::SelfInvitedEvent) -> PyDictRet { 262 | let event = event.inner; 263 | dict_obj! {py ! 264 | type_name: "JoinGroupInvitation", 265 | seq: event.msg_seq, 266 | time: datetime_from_ts(py, event.msg_time).map(|v| v.into_py(py))?, 267 | group_uin: event.group_code, 268 | group_name: event.group_name, 269 | invitor_uin: event.invitor_uin, 270 | invitor_nickname: event.invitor_nick, 271 | } 272 | } 273 | 274 | fn handle_friend_request(event: rce::NewFriendRequestEvent) -> PyDictRet { 275 | let event = event.inner; 276 | dict_obj! { 277 | type_name: "NewFriendRequest", 278 | seq: event.msg_seq, 279 | uin: event.req_uin, 280 | nickname: event.req_nick, 281 | message: event.message, 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/events/mod.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use pyo3::exceptions::PyIndexError; 3 | use pyo3::prelude::*; 4 | use pyo3::types::*; 5 | use pyo3_asyncio::{into_future_with_locals, TaskLocals}; 6 | use pyo3_repr::PyRepr; 7 | use ricq::client::event::DisconnectReason; 8 | use ricq::client::NetworkStatus; 9 | use ricq::handler::{Handler, QEvent}; 10 | 11 | pub mod converter; 12 | 13 | use crate::utils::{datetime_from_ts, py_client_refs, py_try, py_use}; 14 | 15 | #[pyclass(get_all, module = "ichika.core")] 16 | #[derive(PyRepr, Clone)] 17 | pub struct MessageSource { 18 | pub seq: i32, 19 | pub rand: i32, 20 | pub raw_seqs: Py, 21 | pub raw_rands: Py, 22 | pub time: PyObject, 23 | } 24 | 25 | impl MessageSource { 26 | pub fn new(py: Python, seqs: &[i32], rands: &[i32], time: i32) -> PyResult { 27 | let seq = *seqs 28 | .first() 29 | .ok_or_else(|| PyIndexError::new_err("Empty returning rands"))?; 30 | let rand = *rands 31 | .first() 32 | .ok_or_else(|| PyIndexError::new_err("Empty returning rands"))?; 33 | Ok(Self { 34 | seq, 35 | rand, 36 | raw_seqs: PyTuple::new(py, seqs).into_py(py), 37 | raw_rands: PyTuple::new(py, rands).into_py(py), 38 | time: datetime_from_ts(py, time)?.into_py(py), 39 | }) 40 | } 41 | } 42 | 43 | pub struct PyHandler { 44 | queues: Py, 45 | locals: TaskLocals, 46 | uin: i64, 47 | } 48 | 49 | impl PyHandler { 50 | pub fn new(queues: Py, locals: TaskLocals, uin: i64) -> Self { 51 | Self { 52 | queues, 53 | locals, 54 | uin, 55 | } 56 | } 57 | } 58 | 59 | #[async_trait] 60 | impl Handler for PyHandler { 61 | async fn handle(&self, event: QEvent) { 62 | let event_repr = format!("{event:?}"); 63 | if let QEvent::ClientDisconnect(e) = event { 64 | match e.inner { 65 | DisconnectReason::Network => { 66 | tracing::error!("网络错误, 尝试重连"); 67 | } 68 | DisconnectReason::Actively(net) => match net { 69 | NetworkStatus::Drop => { 70 | tracing::error!("意料之外的内存释放"); 71 | } 72 | NetworkStatus::NetworkOffline => { 73 | tracing::error!("网络离线, 尝试重连"); 74 | } 75 | NetworkStatus::KickedOffline => { 76 | tracing::error!("其他设备登录, 被踢下线"); 77 | } 78 | NetworkStatus::MsfOffline => { 79 | tracing::error!("服务器强制下线"); 80 | } 81 | _ => {} 82 | }, 83 | } 84 | return; 85 | } 86 | let py_event = match self::converter::convert(event).await { 87 | Ok(obj) => obj, 88 | Err(e) => { 89 | tracing::error!("转换事件失败: {}", event_repr); 90 | py_use(|py| e.print_and_set_sys_last_vars(py)); 91 | return; 92 | } 93 | }; 94 | let mut handles: Vec>> = vec![]; 95 | Python::with_gil(|py| { 96 | if py_event.as_ref(py).is_empty() { 97 | return; 98 | } 99 | let client = match py_client_refs(py).get_item(self.uin) { 100 | Ok(client) => client, 101 | Err(e) => { 102 | tracing::error!("获取 client 引用失败: {}", event_repr); 103 | e.print_and_set_sys_last_vars(py); 104 | return; 105 | } 106 | }; 107 | match py_event.as_ref(py).set_item("client", client) { 108 | Ok(_) => {} 109 | Err(e) => { 110 | tracing::error!("设置 client 引用失败: {}", event_repr); 111 | e.print_and_set_sys_last_vars(py); 112 | return; 113 | } 114 | }; 115 | let args: Py = (py_event,).into_py(py); 116 | for q in self.queues.as_ref(py).iter().map(|q| q.into_py(py)) { 117 | let locals = self.locals.clone(); 118 | let args = args.clone_ref(py); 119 | handles.push(tokio::spawn(async move { 120 | py_try(|py| { 121 | into_future_with_locals( 122 | &locals, 123 | q.as_ref(py).getattr("put")?.call1(args.as_ref(py))?, 124 | ) 125 | })? 126 | .await?; 127 | Ok(()) 128 | })); 129 | } 130 | }); 131 | for handle in handles { 132 | match handle.await { 133 | Err(err) => { 134 | tracing::error!("事件处理失败失败: {}", event_repr); 135 | tracing::error!("Rust 无法收集回调结果: {:?}", err); 136 | } 137 | Ok(Err(err)) => { 138 | tracing::error!("事件处理失败: {}", event_repr); 139 | py_use(|py| err.print_and_set_sys_last_vars(py)); 140 | } 141 | Ok(Ok(())) => {} 142 | }; 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/exc.rs: -------------------------------------------------------------------------------- 1 | use std::backtrace::Backtrace; 2 | 3 | use pyo3::import_exception; 4 | use pyo3::prelude::*; 5 | use ricq::RQError; 6 | 7 | use crate::utils::py_use; 8 | 9 | import_exception!(ichika.exceptions, IchikaError); 10 | import_exception!(ichika.exceptions, RICQError); 11 | import_exception!(ichika.exceptions, LoginError); 12 | 13 | #[derive(Debug)] 14 | enum InnerError { 15 | RQ(RQError), 16 | Python(PyErr), 17 | Other(Box), 18 | } 19 | 20 | #[derive(Debug)] 21 | pub struct Error { 22 | inner: InnerError, 23 | backtrace: Backtrace, 24 | } 25 | 26 | impl From for Error { 27 | fn from(value: std::io::Error) -> Self { 28 | Self { 29 | inner: InnerError::RQ(RQError::IO(value)), 30 | backtrace: Backtrace::force_capture(), 31 | } 32 | } 33 | } 34 | 35 | impl From for Error { 36 | fn from(value: RQError) -> Self { 37 | Self { 38 | inner: InnerError::RQ(value), 39 | backtrace: Backtrace::force_capture(), 40 | } 41 | } 42 | } 43 | 44 | impl From for Error { 45 | fn from(value: PyErr) -> Self { 46 | Self { 47 | inner: InnerError::Python(value), 48 | backtrace: Backtrace::force_capture(), 49 | } 50 | } 51 | } 52 | 53 | impl From> for Error { 54 | fn from(value: Box) -> Self { 55 | Self { 56 | inner: InnerError::Other(value), 57 | backtrace: Backtrace::force_capture(), 58 | } 59 | } 60 | } 61 | 62 | impl IntoPy for Error { 63 | fn into_py(self, _: Python) -> PyErr { 64 | let bt = self.backtrace; 65 | match self.inner { 66 | InnerError::RQ(e) => RICQError::new_err(format!("RICQ 发生错误: {e:?}\n{bt}")), 67 | InnerError::Python(e) => e, 68 | InnerError::Other(e) => IchikaError::new_err(format!("未知错误: {e:?}\n{bt}")), 69 | } 70 | } 71 | } 72 | 73 | impl From for PyErr { 74 | fn from(value: Error) -> Self { 75 | py_use(|py| value.into_py(py)) 76 | } 77 | } 78 | 79 | pub(crate) trait MapPyErr { 80 | type Output; 81 | fn py_res(self) -> Result; 82 | } 83 | 84 | impl MapPyErr for Result 85 | where 86 | E: Into, 87 | { 88 | type Output = T; 89 | 90 | fn py_res(self) -> Result { 91 | match self { 92 | Ok(output) => Ok(output), 93 | Err(e) => Err({ 94 | let e: Error = e.into(); 95 | e.into() 96 | }), 97 | } 98 | } 99 | } 100 | 101 | pub type IckResult = Result; 102 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![feature(type_alias_impl_trait)] 2 | #![feature(try_blocks)] 3 | #![feature(concat_idents)] 4 | #![feature(let_chains)] 5 | #![feature(async_closure)] 6 | #![feature(lint_reasons)] 7 | #![feature(result_flattening)] 8 | #![feature(iterator_try_collect)] 9 | 10 | use pyo3::prelude::*; 11 | 12 | mod build_info; 13 | pub mod client; 14 | mod events; 15 | pub(crate) mod exc; 16 | pub mod login; 17 | mod loguru; 18 | pub mod message; 19 | mod utils; 20 | type PyRet = PyResult; 21 | 22 | macro_rules! add_batch { 23 | (@fun $m: ident, $($func: ty),*) => { 24 | $($m.add_function(wrap_pyfunction!($func, $m)?)?;)* 25 | }; 26 | (@cls $m: ident, $($cls: ty),*) => { 27 | $($m.add_class::<$cls>()?;)* 28 | } 29 | } 30 | 31 | #[pymodule] 32 | #[doc(hidden)] 33 | pub fn core(py: Python, m: &PyModule) -> PyResult<()> { 34 | m.add("__version__", env!("CARGO_PKG_VERSION"))?; 35 | m.add("__build__", build_info::get_info(py)?)?; 36 | let tokio_thread_count = std::env::var("ICHIKA_RUNTIME_THREAD_COUNT") 37 | .ok() 38 | .and_then(|s| s.parse().ok().filter(|v| *v > 0)) 39 | .unwrap_or(4); 40 | pyo3_asyncio::tokio::init({ 41 | let mut rt = tokio::runtime::Builder::new_multi_thread(); 42 | rt.worker_threads(tokio_thread_count).enable_all(); 43 | rt 44 | }); 45 | add_batch!(@fun m, 46 | loguru::getframe, 47 | message::elements::face_id_from_name, 48 | message::elements::face_name_from_id, 49 | login::password_login, 50 | login::qrcode_login 51 | ); 52 | add_batch!(@cls m, 53 | client::PlumbingClient, 54 | client::structs::Friend, 55 | client::structs::FriendGroup, 56 | client::structs::FriendList, 57 | client::structs::Group, 58 | client::structs::Member, 59 | client::structs::AccountInfo, 60 | client::structs::OtherClientInfo, 61 | client::structs::RawMessageReceipt, 62 | client::structs::OCRResult, 63 | client::structs::OCRText, 64 | client::structs::Profile, 65 | events::MessageSource 66 | ); 67 | loguru::init(m)?; 68 | Ok(()) 69 | } 70 | -------------------------------------------------------------------------------- /src/login/connector.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::net::SocketAddr; 3 | use std::time::Duration; 4 | 5 | use async_trait::async_trait; 6 | use ricq::client::{Client, Connector}; 7 | use tokio::net::TcpStream; 8 | use tokio::task::JoinSet; 9 | use tracing; 10 | 11 | async fn tcp_connect_timeout(addr: SocketAddr, timeout: Duration) -> tokio::io::Result { 12 | let conn = tokio::net::TcpStream::connect(addr); 13 | tokio::time::timeout(timeout, conn) 14 | .await 15 | .map_err(tokio::io::Error::from) 16 | .flatten() 17 | } 18 | 19 | /// Race the given address, call `join_set.join_next()` to get next fastest `(addr, conn)` pair. 20 | async fn race_addrs( 21 | addrs: Vec, 22 | timeout: Duration, 23 | ) -> JoinSet> { 24 | let mut join_set = JoinSet::new(); 25 | for addr in addrs { 26 | join_set.spawn(async move { 27 | let a = addr; 28 | tcp_connect_timeout(addr, timeout).await.map(|s| { 29 | tracing::info!("地址 {} 连接成功", a); 30 | (a, s) 31 | }) 32 | }); 33 | } 34 | join_set 35 | } 36 | 37 | async fn tcp_connect_fastest( 38 | addrs: Vec, 39 | timeout: Duration, 40 | ) -> tokio::io::Result { 41 | let mut join_set = race_addrs(addrs, timeout).await; 42 | while let Some(result) = join_set.join_next().await { 43 | if let Ok(Ok((_, stream))) = result { 44 | return Ok(stream); 45 | } 46 | } 47 | tracing::error!("无法连接至任何一个服务器"); 48 | Err(tokio::io::Error::new( 49 | tokio::io::ErrorKind::NotConnected, 50 | "NotConnected", 51 | )) 52 | } 53 | 54 | pub struct IchikaConnector; 55 | 56 | #[async_trait] 57 | impl Connector for IchikaConnector { 58 | async fn connect(&self, client: &Client) -> io::Result { 59 | tcp_connect_fastest(client.get_address_list().await, Duration::from_secs(5)).await 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/loguru.rs: -------------------------------------------------------------------------------- 1 | //! [`tracing`] 与 Python 的 Loguru 的桥接模块。 2 | 3 | use std::fmt::Write; 4 | use std::sync::Arc; 5 | 6 | use pyo3::exceptions::PyRuntimeError; 7 | use pyo3::intern; 8 | use pyo3::once_cell::GILOnceCell; 9 | use pyo3::prelude::*; 10 | use pyo3::types::*; 11 | use pyo3_repr::PyRepr; 12 | use tracing::Level; 13 | use tracing_subscriber::layer::SubscriberExt; 14 | use tracing_subscriber::util::SubscriberInitExt; 15 | use tracing_subscriber::Layer; 16 | 17 | use crate::py_dict; 18 | 19 | /// 初始化日志输出。 20 | pub(crate) fn init(module: &PyModule) -> PyResult<()> { 21 | // 输出桥接 22 | let layer = LoguruLayer::new()?; 23 | tracing_subscriber::registry() 24 | .with(layer) 25 | .with( 26 | // 筛选不同包的日志级别 27 | tracing_subscriber::filter::Targets::new() 28 | .with_target("ricq", Level::DEBUG) 29 | .with_target("ichika", Level::DEBUG), 30 | ) 31 | .init(); 32 | // 注入 getframe 33 | Python::with_gil(|py| -> PyResult<()> { 34 | let logger_module = py.import("loguru")?.getattr("_logger")?; 35 | logger_module.setattr("get_frame", module.getattr("_getframe")?) 36 | })?; 37 | Ok(()) 38 | } 39 | 40 | /// 将 [`tracing`] 的输出桥接到 Python 的 Loguru 中。 41 | pub(crate) struct LoguruLayer { 42 | log_fn: PyObject, 43 | } 44 | 45 | impl LoguruLayer { 46 | /// 创建一个新的 `LoguruLayer` 对象。 47 | pub(crate) fn new() -> PyResult { 48 | let log_fn = Python::with_gil(|py| -> PyResult { 49 | let loguru = py.import("loguru")?; 50 | let logger = loguru.getattr("logger")?; 51 | let log_fn = logger.getattr("log")?; 52 | Ok(log_fn.into()) 53 | })?; 54 | Ok(LoguruLayer { log_fn }) 55 | } 56 | } 57 | 58 | impl Layer for LoguruLayer 59 | where 60 | S: tracing::Subscriber, 61 | { 62 | fn on_event(&self, event: &tracing::Event, _ctx: tracing_subscriber::layer::Context<'_, S>) { 63 | // 记录日志发生的位置,保存为伪 Python 堆栈 64 | Python::with_gil(|py| { 65 | if let Ok(mut frame) = LAST_RUST_FRAME 66 | .get_or_init(py, || Arc::new(std::sync::RwLock::new(None))) 67 | .write() 68 | { 69 | let meta = event.metadata(); 70 | 71 | *frame = FakePyFrame::new( 72 | &meta 73 | .module_path() 74 | .unwrap_or_else(|| event.metadata().target()) 75 | .split("::") 76 | .collect::>() 77 | .join("."), 78 | meta.file().unwrap_or(""), 79 | "", 80 | meta.line().unwrap_or(0), 81 | ) 82 | .ok(); 83 | } 84 | }); 85 | 86 | let message = { 87 | let mut visiter = LoguruVisiter::new(); 88 | event.record(&mut visiter); 89 | visiter.0 90 | }; 91 | let level = match event.metadata().level().as_str() { 92 | "WARN" => "WARNING", // 处理两个级别名称不一致的问题 93 | s => s, 94 | }; 95 | Python::with_gil(|py| { 96 | let level: Py = level.into_py(py); 97 | let message: PyObject = message.into_py(py); 98 | self.log_fn.call1(py, (level, message)).unwrap(); 99 | }); 100 | } 101 | } 102 | 103 | /// 遍历并格式化日志信息。 104 | struct LoguruVisiter(String); 105 | 106 | impl LoguruVisiter { 107 | /// 创建一个新的 `LoguruVisiter` 对象。 108 | pub fn new() -> Self { 109 | LoguruVisiter(String::new()) 110 | } 111 | } 112 | 113 | impl tracing::field::Visit for LoguruVisiter { 114 | fn record_str(&mut self, field: &tracing::field::Field, value: &str) { 115 | if field.name() == "message" { 116 | self.0.push_str(value); 117 | } else { 118 | write!(self.0, "{}={value}", field.name()).unwrap(); 119 | } 120 | } 121 | 122 | fn record_error( 123 | &mut self, 124 | field: &tracing::field::Field, 125 | value: &(dyn std::error::Error + 'static), 126 | ) { 127 | write!(self.0, "{}={value}", field.name()).unwrap(); 128 | } 129 | 130 | fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { 131 | if field.name() == "message" { 132 | write!(self.0, "{value:?}").unwrap(); 133 | } else { 134 | write!(self.0, "{}={value:?}", field.name()).unwrap(); 135 | } 136 | } 137 | } 138 | 139 | #[pyclass(get_all, module = "ichika.core")] 140 | #[derive(PyRepr, Clone)] 141 | pub struct FakePyFrame { 142 | f_globals: Py, 143 | f_code: Py, 144 | f_lineno: u32, 145 | } 146 | 147 | #[pyclass(get_all, module = "ichika.core")] 148 | #[derive(PyRepr, Clone)] 149 | pub struct FakePyCode { 150 | co_filename: Py, 151 | co_name: Py, 152 | } 153 | 154 | impl FakePyFrame { 155 | fn new(name: &str, file_path: &str, function: &str, line: u32) -> PyResult { 156 | let f_globals = Python::with_gil(|py| { 157 | let name: Py = name.into_py(py); 158 | py_dict!(py, "__name__" => name).into() 159 | }); 160 | let f_code = Python::with_gil(|py| { 161 | Py::new( 162 | py, 163 | FakePyCode { 164 | co_filename: PyString::new(py, file_path).into(), 165 | co_name: PyString::new(py, function).into(), 166 | }, 167 | ) 168 | })?; 169 | Ok(FakePyFrame { 170 | f_globals, 171 | f_code, 172 | f_lineno: line, 173 | }) 174 | } 175 | } 176 | 177 | #[pyfunction] 178 | #[pyo3(name = "_getframe")] 179 | #[doc(hidden)] 180 | pub fn getframe(py: Python, depth: usize) -> PyResult { 181 | let frames: &PyList = py 182 | .import("inspect")? 183 | .call_method("stack", (), None)? 184 | .extract()?; 185 | Ok(if frames.len() > depth { 186 | let frame_info = frames.get_item(depth)?; 187 | let name = frame_info 188 | .getattr(intern!(py, "frame"))? 189 | .getattr(intern!(py, "f_globals"))? 190 | .get_item(intern!(py, "__name__"))? 191 | .extract()?; 192 | let file_path = frame_info.getattr(intern!(py, "filename"))?.extract()?; 193 | let function = frame_info.getattr(intern!(py, "function"))?.extract()?; 194 | let line = frame_info.getattr(intern!(py, "lineno"))?.extract()?; 195 | FakePyFrame::new(name, file_path, function, line)? 196 | } else { 197 | let frame = LAST_RUST_FRAME 198 | .get_or_init(py, || Arc::new(std::sync::RwLock::new(None))) 199 | .read() 200 | .map(|frame| { 201 | frame.as_ref().map_or_else( 202 | || FakePyFrame::new("", "", "", 0), 203 | |f| Ok(f.clone()), 204 | ) 205 | }) 206 | .map_err(|e| PyRuntimeError::new_err(format!("Unable to create Rust frame: {e:?}"))); 207 | frame?? 208 | }) 209 | } 210 | 211 | /// 最后一次日志记录时的 rust 堆栈 212 | static LAST_RUST_FRAME: GILOnceCell>>> = 213 | GILOnceCell::new(); 214 | -------------------------------------------------------------------------------- /src/message/convert.rs: -------------------------------------------------------------------------------- 1 | use pyo3::exceptions::{PyTypeError, PyValueError}; 2 | use pyo3::prelude::*; 3 | use pyo3::types::*; 4 | use ricq::msg::elem::{FlashImage, LightApp, RQElem, Reply, RichMsg}; 5 | use ricq::msg::MessageChain; 6 | use ricq::structs::ForwardMessage; 7 | use ricq_core::msg::elem::{At, Dice, Face, FingerGuessing, Text}; 8 | 9 | use super::elements::*; 10 | use crate::utils::datetime_from_ts; 11 | use crate::{dict, static_py_fn}; 12 | 13 | pub fn serialize_audio_dict<'py>( 14 | py: Python<'py>, 15 | url: String, 16 | ptt: &ricq_core::pb::msg::Ptt, 17 | ) -> PyResult<&'py PyDict> { 18 | Ok(dict! {py, 19 | type: "Audio", 20 | url: url, 21 | raw: SealedAudio {inner: ptt.clone()}.into_py(py), 22 | }) 23 | } 24 | pub fn serialize_audio( 25 | py: Python, 26 | url: String, 27 | ptt: &ricq_core::pb::msg::Ptt, 28 | ) -> PyResult { 29 | let audio_data = serialize_audio_dict(py, url, ptt)?; 30 | let py_fn: &PyAny = py_deserialize(py); 31 | Ok(py_fn.call1((vec![audio_data],))?.into_py(py)) 32 | } 33 | 34 | pub fn serialize_element(py: Python, e: RQElem) -> PyResult> { 35 | let data = match e { 36 | RQElem::At(a) => match a.target { 37 | 0 => { 38 | dict! {py, type: "AtAll"} 39 | } 40 | target => { 41 | dict! {py, 42 | type: "At", 43 | target: target, 44 | display: a.display, 45 | } 46 | } 47 | }, 48 | RQElem::Text(t) => { 49 | dict! {py, 50 | type: "Text", 51 | text: t.content, 52 | } 53 | } 54 | RQElem::Dice(d) => { 55 | dict! {py, 56 | type: "Dice", 57 | value: d.value, 58 | } 59 | } 60 | RQElem::FingerGuessing(f) => { 61 | let choice = match f { 62 | FingerGuessing::Rock => "Rock", 63 | FingerGuessing::Paper => "Paper", 64 | FingerGuessing::Scissors => "Scissors", 65 | }; 66 | dict! {py, 67 | type: "FingerGuessing", 68 | choice: choice 69 | } 70 | } 71 | RQElem::Face(f) => { 72 | dict! {py, 73 | type: "Face", 74 | index: f.index, 75 | name: f.name 76 | } 77 | } 78 | RQElem::MarketFace(m) => { 79 | let f = SealedMarketFace { inner: m }; 80 | dict! {py, 81 | type: "MarketFace", 82 | raw: f.into_py(py) 83 | } 84 | } 85 | RQElem::GroupImage(i) => { 86 | dict! {py, 87 | type: "Image", 88 | url: i.url(), 89 | raw: (SealedGroupImage {inner: i}).into_py(py) 90 | } 91 | } 92 | RQElem::FriendImage(i) => { 93 | dict! {py, 94 | type: "Image", 95 | url: i.url(), 96 | raw: (SealedFriendImage {inner: i}).into_py(py) 97 | } 98 | } 99 | RQElem::FlashImage(i) => match i { 100 | FlashImage::GroupImage(i) => { 101 | dict! {py, 102 | type: "FlashImage", 103 | url: i.url(), 104 | raw: (SealedGroupImage {inner: i}).into_py(py) 105 | } 106 | } 107 | FlashImage::FriendImage(i) => { 108 | dict! {py, 109 | type: "FlashImage", 110 | url: i.url(), 111 | raw: (SealedFriendImage {inner: i}).into_py(py) 112 | } 113 | } 114 | }, 115 | RQElem::LightApp(app) => { 116 | dict! {py, 117 | type: "LightApp", 118 | content: app.content 119 | } 120 | } 121 | RQElem::RichMsg(rich) => { 122 | dict! {py, 123 | type: "RichMessage", 124 | service_id: rich.service_id, 125 | content: rich.template1 126 | } 127 | } 128 | RQElem::Other(_) => { 129 | return Ok(None); 130 | } 131 | unhandled => { 132 | dict! {py, 133 | type: "Unknown", 134 | raw: format!("{unhandled:?}") 135 | } 136 | } 137 | }; 138 | Ok(Some(data)) 139 | } 140 | 141 | // Reply + Bot Image = skip message ??? 142 | // Needs testing 143 | pub fn serialize_reply(py: Python, reply: Reply) -> PyResult<&PyDict> { 144 | Ok(dict! {py, 145 | type: "Reply", 146 | seq: reply.reply_seq, 147 | sender: reply.sender, 148 | time: datetime_from_ts(py, reply.time)?, 149 | content: reply.elements.to_string() 150 | }) 151 | } 152 | 153 | pub fn render_forward(file_name: &str, res_id: &str, preview: &str, summary: &str) -> String { 154 | format!( 155 | r##"群聊的聊天记录{preview}
{summary}
"## 156 | ) 157 | } 158 | 159 | pub fn serialize_forward(py: Python, forward: ForwardMessage) -> PyResult<&PyDict> { 160 | Ok(match forward { 161 | ForwardMessage::Message(msg) => { 162 | dict! {py, 163 | type: "Message", 164 | sender_id: msg.sender_id, 165 | time: datetime_from_ts(py, msg.time)?, 166 | sender_name: msg.sender_name, 167 | content: serialize_as_py_chain(py, msg.elements)?, 168 | } 169 | } 170 | ForwardMessage::Forward(fwd) => { 171 | dict! {py, 172 | type: "Forward", 173 | sender_id: fwd.sender_id, 174 | time: datetime_from_ts(py, fwd.time)?, 175 | sender_name: fwd.sender_name, 176 | content: fwd.nodes.into_iter().map(|node| serialize_forward(py, node).map(|ok| ok.into_py(py))).try_collect::>()?, 177 | } 178 | } 179 | }) 180 | } 181 | 182 | pub fn serialize_message_chain(py: Python, chain: MessageChain) -> PyResult> { 183 | use ricq_core::msg::MessageElem as BaseElem; 184 | let res = PyList::empty(py); 185 | for e in chain.0 { 186 | match e { 187 | BaseElem::SrcMsg(reply) => { 188 | res.append(serialize_reply(py, reply.into())?)?; 189 | } 190 | BaseElem::AnonGroupMsg(_) => {} // Anonymous information, TODO 191 | elem => { 192 | if let Some(data) = serialize_element(py, RQElem::from(elem))? { 193 | res.append(data)?; 194 | } 195 | } 196 | } 197 | } 198 | Ok(res.into_py(py)) 199 | } 200 | 201 | static_py_fn!( 202 | py_deserialize, 203 | __py_deserialize_cell, 204 | "ichika.message", 205 | ["_deserialize_message"] 206 | ); 207 | 208 | pub fn serialize_as_py_chain(py: Python, chain: MessageChain) -> PyResult // PyMessageChain 209 | { 210 | let py_fn: &PyAny = py_deserialize(py); 211 | Ok(py_fn 212 | .call1((serialize_message_chain(py, chain)?,))? 213 | .into_py(py)) 214 | } 215 | 216 | pub fn deserialize_element(chain: &mut MessageChain, ident: &str, store: &PyAny) -> PyResult<()> { 217 | match ident { 218 | "AtAll" => chain.push(At { 219 | target: 0, 220 | display: "@全体成员".into(), 221 | }), 222 | "At" => { 223 | let target = store.get_item("target")?.extract::()?; 224 | let display = store 225 | .get_item("display")? 226 | .extract::() 227 | .ok() 228 | .unwrap_or_else(|| format!("@{target}")); 229 | chain.push(At { target, display }); 230 | } 231 | "Text" => { 232 | chain.push(Text::new(store.get_item("text")?.extract::()?)); 233 | } 234 | "Dice" => { 235 | chain.push(Dice::new(store.get_item("value")?.extract::()?)); 236 | } 237 | "FingerGuessing" => { 238 | chain.push(match store.get_item("choice")?.extract::<&str>()? { 239 | "Rock" => FingerGuessing::Rock, 240 | "Paper" => FingerGuessing::Paper, 241 | "Scissors" => FingerGuessing::Scissors, 242 | _ => return Ok(()), 243 | }); 244 | } 245 | "MarketFace" => { 246 | chain.push(store.get_item("raw")?.extract::()?.inner); 247 | } 248 | "Face" => { 249 | chain.push(Face::new(store.get_item("index")?.extract::()?)); 250 | } 251 | "Image" => { 252 | let raw = store.get_item("raw")?; 253 | match raw.extract::() { 254 | Ok(i) => chain.push(i.inner), 255 | Err(_) => chain.push(raw.extract::()?.inner), 256 | }; 257 | } 258 | "FlashImage" => { 259 | let raw = store.get_item("raw")?; 260 | match raw.extract::() { 261 | Ok(i) => chain.push(FlashImage::from(i.inner)), 262 | Err(_) => chain.push(FlashImage::from(raw.extract::()?.inner)), 263 | }; 264 | } 265 | "Reply" => { 266 | let seq: i32 = store.get_item("seq")?.extract()?; 267 | let sender: i64 = store.get_item("sender")?.extract()?; 268 | let time: i32 = store.get_item("time")?.extract()?; 269 | let content: String = store.get_item("content")?.extract()?; 270 | chain.with_reply(Reply { 271 | reply_seq: seq, 272 | sender, 273 | time, 274 | elements: MessageChain::new(Text::new(content)), 275 | }); 276 | } 277 | "LightApp" => { 278 | let content: String = store.get_item("content")?.extract()?; 279 | chain.push(LightApp { content }); 280 | } 281 | "ForwardCard" | "RichMessage" => { 282 | let service_id: i32 = store.get_item("service_id")?.extract()?; 283 | let content: String = store.get_item("content")?.extract()?; 284 | chain.push(RichMsg { 285 | service_id, 286 | template1: content, 287 | }); 288 | } 289 | _ => { 290 | return Err(PyTypeError::new_err(format!( 291 | "无法处理元素 {ident} {store}" 292 | ))) 293 | } 294 | } 295 | Ok(()) 296 | } 297 | 298 | pub fn deserialize_message_chain(list: &PyList) -> PyResult { 299 | let mut chain: MessageChain = MessageChain::new(Vec::new()); 300 | for elem_d in list { 301 | let elem_d: &PyDict = elem_d.downcast()?; 302 | let name = elem_d 303 | .get_item("type") 304 | .ok_or_else(|| PyValueError::new_err("Missing `type`!"))? 305 | .extract::<&str>()?; 306 | deserialize_element(&mut chain, name, elem_d.into())?; 307 | } 308 | Ok(chain) 309 | } 310 | -------------------------------------------------------------------------------- /src/message/elements.rs: -------------------------------------------------------------------------------- 1 | //! 消息元素。 2 | 3 | use pyo3::prelude::*; 4 | use pyo3::types::PyBytes; 5 | use ricq::msg::elem::{FriendImage, GroupImage, MarketFace}; 6 | 7 | use crate::props; 8 | use crate::utils::py_bytes; 9 | 10 | #[pyfunction] 11 | pub fn face_name_from_id(id: i32) -> String { 12 | ricq_core::msg::elem::Face::name(id).to_owned() 13 | } 14 | 15 | #[pyfunction] 16 | pub fn face_id_from_name(name: &str) -> Option { 17 | match ricq_core::msg::elem::Face::new_from_name(name) { 18 | Some(f) => Some(f.index), 19 | None => None, 20 | } 21 | } 22 | 23 | macro_rules! py_seal { 24 | ($name:ident => $type:ty) => { 25 | #[::pyo3::pyclass] 26 | #[derive(::pyo3_repr::PyRepr, Clone)] 27 | pub struct $name { 28 | pub inner: $type, 29 | } 30 | }; 31 | } 32 | 33 | py_seal!(SealedMarketFace => MarketFace); 34 | 35 | #[pymethods] 36 | impl SealedMarketFace { 37 | #[getter] 38 | fn name(&self) -> String { 39 | self.inner.name.clone() 40 | } 41 | } 42 | 43 | py_seal!(SealedGroupImage => GroupImage); 44 | py_seal!(SealedFriendImage => FriendImage); 45 | 46 | props!(self @ SealedGroupImage: 47 | md5 => [Py] py_bytes(&self.inner.md5); 48 | size => [u32] self.inner.size; 49 | width => [u32] self.inner.width; 50 | height => [u32] self.inner.height; 51 | image_type => [i32] self.inner.image_type; 52 | ); 53 | 54 | props!(self @ SealedFriendImage: 55 | md5 => [Py] py_bytes(&self.inner.md5); 56 | size => [u32] self.inner.size; 57 | width => [u32] self.inner.width; 58 | height => [u32] self.inner.height; 59 | image_type => [i32] self.inner.image_type; 60 | ); 61 | 62 | py_seal!(SealedAudio => ricq_core::pb::msg::Ptt); 63 | 64 | props!(self @ SealedAudio: 65 | md5 => [Py] py_bytes(self.inner.file_md5()); 66 | size => [i32] self.inner.file_size(); 67 | file_type => [i32] self.inner.file_type(); 68 | ); 69 | -------------------------------------------------------------------------------- /src/message/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod convert; 2 | pub mod elements; 3 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use futures_util::Future; 2 | use pyo3::prelude::*; 3 | use pyo3::types::PyBytes; 4 | 5 | // use pyo3::types::*; 6 | 7 | /// 获取 Python 的 None。 8 | pub fn py_none() -> PyObject { 9 | py_use(|py| py.None()) 10 | } 11 | 12 | pub trait AsPython { 13 | fn obj(self) -> PyObject; 14 | } 15 | 16 | impl AsPython for T 17 | where 18 | T: IntoPy, 19 | { 20 | fn obj(self) -> PyObject { 21 | py_use(|py| self.into_py(py)) 22 | } 23 | } 24 | 25 | pub fn py_bytes(data: &[u8]) -> Py { 26 | py_use(|py| PyBytes::new(py, data).into_py(py)) 27 | } 28 | 29 | /// 构造一个 Python 的 dict。 30 | #[macro_export] 31 | #[doc(hidden)] 32 | macro_rules! py_dict { 33 | ($py:expr, $($name:expr => $value:expr),*) => { 34 | { 35 | let dict = ::pyo3::types::PyDict::new($py); 36 | $( 37 | dict.set_item($name, $value).expect("Failed to set_item on dict"); 38 | )* 39 | dict 40 | } 41 | }; 42 | } 43 | 44 | #[macro_export] 45 | #[doc(hidden)] 46 | macro_rules! dict { 47 | {$py:expr, $($name:ident : $value:expr),* $(,)?} => { 48 | { 49 | let dict = ::pyo3::types::PyDict::new($py); 50 | $( 51 | dict.set_item(stringify!($name), $value)?; 52 | )* 53 | dict 54 | } 55 | }; 56 | } 57 | 58 | #[macro_export] 59 | macro_rules! import_call { 60 | ($py:expr, $module:expr => $attr:expr => $arg:expr) => { 61 | $py.import(::pyo3::intern!($py, $module))? 62 | .getattr(::pyo3::intern!($py, $attr))? 63 | .call1(($arg,)) 64 | }; 65 | ($py:expr, $module:expr => $attr:expr => @tuple $arg:expr) => { 66 | $py.import(::pyo3::intern!($py, $module))? 67 | .getattr(::pyo3::intern!($py, $attr))? 68 | .call1($arg) 69 | }; 70 | } 71 | 72 | #[macro_export] 73 | macro_rules! props { 74 | ($self_t:ident @ $cls:ident : $($name:ident => [$type:ty] $res:stmt);* ;) => { 75 | #[::pyo3::pymethods] 76 | impl $cls { 77 | $( 78 | #[getter] 79 | pub fn $name(&$self_t) -> $type { 80 | $res 81 | } 82 | )* 83 | } 84 | }; 85 | } 86 | 87 | /// 将 [`tokio`] 的 Future 包装为 Python 的 Future。 88 | pub fn py_future(py: Python, future: F) -> PyResult<&PyAny> 89 | where 90 | F: Future> + Send + 'static, 91 | T: IntoPy, 92 | { 93 | pyo3_asyncio::tokio::future_into_py(py, async move { future.await.map_err(|e| e.into()) }) 94 | } 95 | 96 | pub fn py_try(f: F) -> PyResult 97 | where 98 | F: for<'py> FnOnce(Python<'py>) -> PyResult, 99 | { 100 | Python::with_gil(f) 101 | } 102 | 103 | pub fn py_use(f: F) -> R 104 | where 105 | F: for<'py> FnOnce(Python<'py>) -> R, 106 | { 107 | Python::with_gil(f) 108 | } 109 | 110 | #[macro_export] 111 | macro_rules! static_py_fn { 112 | ($name:ident, $cell_name:ident, $module:expr, [$($attr:expr),*]) => { 113 | #[allow(non_upper_case_globals, reason = "Not controllable via declarative macros")] 114 | static $cell_name: ::pyo3::once_cell::GILOnceCell = ::pyo3::once_cell::GILOnceCell::new(); 115 | 116 | pub fn $name(python: ::pyo3::marker::Python) -> &pyo3::PyAny { 117 | $cell_name.get_or_init(python, || { 118 | python 119 | .import(::pyo3::intern!(python, $module)).expect(concat!("Unable to import module ", $module)) 120 | $(.getattr(::pyo3::intern!(python, $attr)).expect(concat!("Unable to get attribute ", $attr)))* 121 | .into() 122 | } 123 | ) 124 | .as_ref(python) 125 | } 126 | }; 127 | } 128 | 129 | #[macro_export] 130 | macro_rules! call_static_py { 131 | ($pth:expr, $py:expr, ($($arg:expr),*)) => { 132 | $pth($py).call1( 133 | ($($arg,)*) 134 | ) 135 | }; 136 | ($pth:expr, $py:expr, ($($arg:expr),*) ! $reason:expr) => { 137 | $pth($py).call1( 138 | ($($arg,)*) 139 | ) 140 | .expect($reason) 141 | .into() 142 | } 143 | } 144 | 145 | static_py_fn!( 146 | _datetime_from_ts, 147 | __DT_CELL, 148 | "datetime", 149 | ["datetime", "fromtimestamp"] 150 | ); 151 | 152 | pub fn datetime_from_ts(py: Python<'_>, time: impl IntoPy) -> PyResult<&PyAny> { 153 | call_static_py!(_datetime_from_ts, py, (time)) 154 | } 155 | 156 | static_py_fn!( 157 | _timedelta_from_secs, 158 | __TDELTA_CELL, 159 | "datetime", 160 | ["timedelta"] 161 | ); 162 | 163 | pub fn timedelta_from_secs(py: Python<'_>, delta: impl IntoPy) -> PyResult<&PyAny> { 164 | _timedelta_from_secs(py).call((), Some(dict!(py, seconds: delta.into_py(py)))) 165 | } 166 | 167 | static_py_fn!(partial, __PARTIAL_CELL, "functools", ["partial"]); 168 | 169 | static_py_fn!( 170 | py_client_refs, 171 | __CLIENT_WEAKREFS_CELL, 172 | "ichika.client", 173 | ["CLIENT_REFS"] 174 | ); 175 | 176 | static_py_fn!( 177 | _to_py_gender, 178 | __PY_GENDER_ENUM_CELL, 179 | "ichika.structs", 180 | ["Gender"] 181 | ); 182 | 183 | pub fn to_py_gender(gender: u8) -> PyObject { 184 | let gender_str = match gender { 185 | 0 => "Male", 186 | 1 => "Female", 187 | _ => "Unknown", 188 | }; 189 | py_use(|py| _to_py_gender(py).call1((gender_str,)).unwrap().into_py(py)) 190 | } 191 | 192 | static_py_fn!( 193 | _to_py_perm, 194 | __PY_GROUP_PERMISSION_CELL, 195 | "ichika.structs", 196 | ["GroupPermission"] 197 | ); 198 | 199 | pub fn to_py_permission(perm: ricq_core::structs::GroupMemberPermission) -> PyObject { 200 | use ricq_core::structs::GroupMemberPermission as Perm; 201 | let perm_str = match perm { 202 | Perm::Owner => "Owner", 203 | Perm::Administrator => "Admin", 204 | Perm::Member => "Member", 205 | }; 206 | py_use(|py| _to_py_perm(py).call1((perm_str,)).unwrap().into_py(py)) 207 | } 208 | 209 | #[macro_export] 210 | macro_rules! dict_obj { 211 | {$py:ident ! $($key:ident : $val:expr),* $(,)?} => { 212 | ::pyo3::Python::with_gil(|$py| -> ::pyo3::PyResult<_> { 213 | let dict = ::pyo3::types::PyDict::new($py); 214 | $( 215 | let _val: ::pyo3::PyObject = $val.into_py($py); 216 | dict.set_item(stringify!($key), _val)?; 217 | )* 218 | Ok(dict.into_py($py)) 219 | }) 220 | }; 221 | {$($key:ident : $val:expr),* $(,)?} => { 222 | dict_obj!(py ! $($key : $val),*) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /towncrier_release.toml: -------------------------------------------------------------------------------- 1 | 2 | [tool.towncrier] 3 | directory = "news" 4 | filename = "release-notes.md" 5 | start_string = "\n" 6 | underlines = ["", "", ""] 7 | template = "news/template.jinja" 8 | title_format = "## [{version}](https://github.com/GraiaProject/Ichika/tree/{version}) - {project_date}" 9 | issue_format = "([#{issue}](https://github.com/GraiaProject/Ichika/issues/{issue}))" 10 | 11 | [[tool.towncrier.type]] 12 | directory = "removed" 13 | name = "移除" 14 | showcontent = true 15 | 16 | [[tool.towncrier.type]] 17 | directory = "deprecated" 18 | name = "弃用" 19 | showcontent = true 20 | 21 | [[tool.towncrier.type]] 22 | directory = "added" 23 | name = "新增" 24 | showcontent = true 25 | 26 | [[tool.towncrier.type]] 27 | directory = "changed" 28 | name = "更改" 29 | showcontent = true 30 | 31 | [[tool.towncrier.type]] 32 | directory = "fixed" 33 | name = "修复" 34 | showcontent = true 35 | 36 | [[tool.towncrier.type]] 37 | directory = "misc" 38 | name = "其他" 39 | showcontent = true 40 | --------------------------------------------------------------------------------