├── .env ├── .gitattributes ├── .github └── workflows │ ├── build_docs.yml │ └── package.yml ├── .gitignore ├── .vscode └── launch.json ├── ComWeChatBotClient.code-workspace ├── LICENSE ├── README.md ├── depends ├── CWeChatRobot.exe ├── DWeChatRobot.dll ├── install.bat └── uninstall.bat ├── docs ├── .vuepress │ ├── config.ts │ ├── public │ │ └── image │ │ │ ├── logo.gif │ │ │ └── logo.png │ └── styles │ │ └── config.scss ├── README.md ├── action │ ├── README.md │ ├── expand.md │ ├── file.md │ ├── group.md │ ├── message.md │ ├── meta.md │ └── private.md ├── event │ ├── README.md │ ├── message.md │ ├── meta.md │ ├── notice.md │ └── request.md ├── guide │ └── README.md └── message │ └── README.md ├── main.py ├── package-lock.json ├── package.json ├── requirements.txt └── wechatbot_client ├── __init__.py ├── action_manager ├── __init__.py ├── check.py ├── file_router.py ├── manager.py └── model.py ├── com_wechat ├── __init__.py ├── com_wechat.py ├── message.py ├── model.py └── type.py ├── config.py ├── consts.py ├── driver ├── __init__.py ├── base.py ├── driver.py └── model.py ├── exception.py ├── file_manager ├── __init__.py ├── manager.py └── model.py ├── log.py ├── onebot12 ├── __init__.py ├── base_message.py ├── event.py ├── face.py └── message.py ├── scheduler.py ├── startup.py ├── typing.py ├── utils.py └── wechat ├── __init__.py ├── adapter.py ├── utils.py └── wechat.py /.env: -------------------------------------------------------------------------------- 1 | ################################################## 2 | # onebot12的配置项 # 3 | ################################################# 4 | 5 | # 服务host 6 | host = 127.0.0.1 7 | # 服务端口 8 | port = 8000 9 | # 访问令牌 10 | access_token = "" 11 | # 心跳事件 12 | heartbeat_enabled = false 13 | # 心跳间隔(毫秒) 14 | heartbeat_interval = 5000 15 | 16 | # HTTP 通信 17 | # 是否开启http api 18 | enable_http_api = true 19 | # 是否启用 get_latest_events 元动作,启用http api时生效 20 | event_enabled = true 21 | # 事件缓冲区大小,超过该大小将会丢弃最旧的事件,0 表示不限大小 22 | event_buffer_size = 0 23 | 24 | # HTTP Webhook 25 | # 是否启用http webhook 26 | enable_http_webhook = false 27 | # webhook 上报地址,启用webhook生效 28 | webhook_url = ["http://127.0.0.1:8080/onebot/v12/http/"] 29 | # 上报请求超时时间,单位:毫秒,0 表示不超时 30 | webhook_timeout = 5000 31 | 32 | # websocket连接方式,只能是以下值 33 | # - Unable 不开启websocket连接 34 | # - Forward 正向websocket连接 35 | # - Backward 反向websocket连接 36 | websocekt_type = "Unable" 37 | # 反向 WebSocket 连接地址,使用反向websocket时生效 38 | websocket_url = ["ws://127.0.0.1:8080/onebot/v12/ws/"] 39 | # 反向 WebSocket 重连间隔,单位:毫秒,必须大于 0 40 | reconnect_interval = 5000 41 | # 反向 WebSocket 的缓冲区大小,单位(Mb) 42 | websocket_buffer_size = 4 43 | ################################################## 44 | # 项目其他的配置项 # 45 | ################################################# 46 | 47 | # 日志显示等级 48 | log_level = "INFO" 49 | # 日志保存天数 50 | log_days = 10 51 | # 文件缓存天数,为0则不清理缓存,每天凌晨清理 52 | cache_days = 3 53 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .env linguist-detectable=false 2 | .bat linguist-detectable=false 3 | *.py text eol=lf 4 | -------------------------------------------------------------------------------- /.github/workflows/build_docs.yml: -------------------------------------------------------------------------------- 1 | name: Build Docs 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - 'v*' 8 | jobs: 9 | build-and-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@master 14 | 15 | - name: vuepress-deploy 16 | uses: jenkey2011/vuepress-deploy@master 17 | env: 18 | ACCESS_TOKEN: ${{ secrets.BUILD_DOC }} 19 | TARGET_BRANCH: docs 20 | BUILD_SCRIPT: git config --global --add safe.directory "*" && npm ci && npm run docs:build 21 | BUILD_DIR: docs/.vuepress/dist/ 22 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | name: Package client with nuitka 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | build_windows: 10 | runs-on: windows-latest 11 | steps: 12 | - name: 检出 13 | uses: actions/checkout@v3 14 | 15 | - name: 获取tag名 16 | uses: actions-ecosystem/action-regex-match@v2 17 | id: regex-match 18 | with: 19 | text: ${{ github.ref }} 20 | regex: 'refs/tags/([\S]+)' 21 | 22 | - name: 创建python 23 | uses: actions/setup-python@v4 24 | with: 25 | python-version: '3.10' 26 | architecture: 'x64' 27 | 28 | - name: 安装依赖 29 | run: | 30 | python -m pip install --upgrade pip && 31 | python -m pip install -r requirements.txt 32 | 33 | - name: 构建exe 34 | uses: JustUndertaker/Nuitka-Action@v1.0 35 | with: 36 | nuitka-version: main 37 | script-name: main.py 38 | include-module: wechatbot_client.startup, tortoise.backends.sqlite, apscheduler.triggers.interval, apscheduler.triggers.cron 39 | output-dir: dist 40 | 41 | - name: 移动文件 42 | run: | 43 | mv ./dist/main.exe ./ComWeChat-Client-${{ steps.regex-match.outputs.group1 }}.exe && 44 | mv ./depends/CWeChatRobot.exe ./CWeChatRobot.exe && 45 | mv ./depends/DWeChatRobot.dll ./DWeChatRobot.dll && 46 | mv ./depends/install.bat ./install.bat && 47 | mv ./depends/uninstall.bat ./uninstall.bat 48 | 49 | - name: 打包文件 50 | uses: vimtor/action-zip@v1 51 | with: 52 | files: ComWeChat-Client-${{ steps.regex-match.outputs.group1 }}.exe .env CWeChatRobot.exe DWeChatRobot.dll install.bat uninstall.bat 53 | dest: ComWeChat-Client-${{ steps.regex-match.outputs.group1 }}.zip 54 | 55 | - name: 发布版本 56 | uses: actions/create-release@v1 57 | id: create_release 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.BUILD_DOC }} 60 | with: 61 | tag_name: ${{ steps.regex-match.outputs.group1 }} 62 | release_name: ${{ steps.regex-match.outputs.group1 }} 63 | body: release of ComWeChat-Client-${{ steps.regex-match.outputs.group1 }} 64 | 65 | - name: 上传文件 66 | uses: actions/upload-release-asset@v1 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.BUILD_DOC }} 69 | with: 70 | upload_url: ${{ steps.create_release.outputs.upload_url }} 71 | asset_path: ./ComWeChat-Client-${{ steps.regex-match.outputs.group1 }}.zip 72 | asset_name: ComWeChat-Client-${{ steps.regex-match.outputs.group1 }}.zip 73 | asset_content_type: application/zip 74 | 75 | 76 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .venv 106 | env/ 107 | venv/ 108 | ENV/ 109 | env.bak/ 110 | venv.bak/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | /site 121 | 122 | # mypy 123 | .mypy_cache/ 124 | .dmypy.json 125 | dmypy.json 126 | 127 | # Pyre type checker 128 | .pyre/ 129 | DWeChatRobot.dll 130 | logs/ 131 | log/ 132 | file_cache/ 133 | data.json 134 | data/ 135 | node_modules/ 136 | .temp 137 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // 使用 IntelliSense 了解相关属性。 3 | // 悬停以查看现有属性的描述。 4 | // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python: 当前文件", 9 | "type": "python", 10 | "request": "launch", 11 | "program": "${file}", 12 | "console": "integratedTerminal", 13 | "justMyCode": false 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /ComWeChatBotClient.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "." 5 | } 6 | ], 7 | "settings": {}, 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![ComWeChatBotClient](https://socialify.git.ci/JustUndertaker/ComWeChatBotClient/image?description=1&font=Inter&name=1&pattern=Circuit%20Board&theme=Auto) 2 |

3 | onebot12 4 | License 5 | release 6 |

7 | 8 | ## 简介 9 | 10 | `ComWeChatRobot`的客户端封装,支持`onebot12`通信协议。 11 | 12 | ## 许可证 13 | `ComWeChatRobot Client` 采用 [AGPLv3](https://github.com/JustUndertaker/ComWeChatBotClient/blob/main/LICENSE) 协议开源,不鼓励、不支持一切商业使用。 14 | 15 | ## 上游依赖 16 | 17 | - [ComWeChatRobot](https://github.com/ljc545w/ComWeChatRobot):PC微信机器人,实现获取通讯录,发送文本、图片、文件等消息,封装COM接口供Python、C#调用 18 | 19 | ## 支持的微信版本 20 | 21 | - **3.7.0.30**: 下载连接在 [这里](https://github.com/tom-snow/wechat-windows-versions/releases/download/v3.7.0.30/WeChatSetup-3.7.0.30.exe) 22 | 23 | ## 文档 24 | 25 | 文档连接:[传送门](https://justundertaker.github.io/ComWeChatBotClient/) 26 | 27 | ## Onebot12支持 28 | 29 | - [x] HTTP 30 | - [x] HTTP Webhook 31 | - [x] 正向 Websocket 32 | - [x] 反向 Websocket 33 | 34 | ## 更新日志 35 | ### 2023/6/5 V0.0.8 36 | - 修复status_update元事件缺失 37 | - 发送消息遇到错误不会直接返回failed,而是尝试将所有消息段发出 38 | - 处理其他程序分享的app消息 39 | ### 2023/4/16 v0.0.7 40 | - 修复需要两次ctrl+c才能退出的问题 41 | - 修复备份数据库接口 42 | ### 2023/4/8 v0.0.6 43 | - 修复`反向 Websocket` 子协议问题 44 | - `Webhook` 和 `反向 Websocket` 支持多地址 45 | ### 2023/4/6 v0.0.5 46 | - 修复定时器模块启动 47 | ### 2023/4/4 v0.0.4 48 | - 修复定时模块启动问题 49 | - 限制请求action日志的长度 50 | - 添加自动清理文件缓存任务 51 | ### 2023/4/2 v0.0.3 52 | - 修复ws buffer缓冲区大小问题。 53 | - 重写file manager保存文件的命名逻辑 54 | - 修复部分bug 55 | ### 2023/4/1 v0.0.2 56 | - 修复群聊发送不了纯文本消息的bug。 57 | - 修改部分文档。 58 | ### 2023/3/29 v0.0.1 59 | - 初步可用,写个文档应该不会被发现吧。 60 | 61 | ### 2023/3/16 62 | 63 | - 随便写写啦,反正不会写。 64 | -------------------------------------------------------------------------------- /depends/CWeChatRobot.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustUndertaker/ComWeChatBotClient/091ce4dd047715cd96a4af1a6d3f9e4128f8aa6e/depends/CWeChatRobot.exe -------------------------------------------------------------------------------- /depends/DWeChatRobot.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustUndertaker/ComWeChatBotClient/091ce4dd047715cd96a4af1a6d3f9e4128f8aa6e/depends/DWeChatRobot.dll -------------------------------------------------------------------------------- /depends/install.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | chcp 65001 3 | >nul 2>&1 "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system" 4 | if '%errorlevel%' NEQ '0' ( 5 | goto UACPrompt 6 | ) else ( goto gotAdmin ) 7 | :UACPrompt 8 | echo Set UAC = CreateObject^("Shell.Application"^) > "%temp%\getadmin.vbs" 9 | echo UAC.ShellExecute "%~s0", "", "", "runas", 1 >> "%temp%\getadmin.vbs" 10 | "%temp%\getadmin.vbs" 11 | exit /B 12 | :gotAdmin 13 | if exist "%temp%\getadmin.vbs" ( del "%temp%\getadmin.vbs" ) 14 | set path=%~dp0 15 | 16 | CWeChatRobot.exe /regserver 17 | echo 安装com组件成功! 18 | pause 19 | -------------------------------------------------------------------------------- /depends/uninstall.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | chcp 65001 3 | >nul 2>&1 "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system" 4 | if '%errorlevel%' NEQ '0' ( 5 | goto UACPrompt 6 | ) else ( goto gotAdmin ) 7 | :UACPrompt 8 | echo Set UAC = CreateObject^("Shell.Application"^) > "%temp%\getadmin.vbs" 9 | echo UAC.ShellExecute "%~s0", "", "", "runas", 1 >> "%temp%\getadmin.vbs" 10 | "%temp%\getadmin.vbs" 11 | exit /B 12 | :gotAdmin 13 | if exist "%temp%\getadmin.vbs" ( del "%temp%\getadmin.vbs" ) 14 | set path=%~dp0 15 | 16 | CWeChatRobot.exe /unregserver 17 | echo 卸载com组件成功! 18 | pause 19 | -------------------------------------------------------------------------------- /docs/.vuepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineUserConfig } from 'vuepress'; 2 | import { hopeTheme } from "vuepress-theme-hope"; 3 | import { searchProPlugin } from "vuepress-plugin-search-pro"; 4 | import { shikiPlugin } from "@vuepress/plugin-shiki"; 5 | 6 | export default defineUserConfig({ 7 | lang: 'zh-CN', 8 | base: '/ComWeChatBotClient/', 9 | title: 'ComWeChat Client', 10 | markdown: { 11 | code: { 12 | lineNumbers: false, 13 | } 14 | }, 15 | head: [['link', { rel: 'icon', href: '/image/logo.png' }]], 16 | description: '基于ComWeCahtRobot的微信协议端,支持onebot12', 17 | theme: hopeTheme({ 18 | // url 19 | hostname: 'https://justundertaker.github.io/ComWeChatBotClient/', 20 | // 站点图标 21 | favicon: '/image/logo.png', 22 | // logo 23 | logo: '/image/logo.png', 24 | // 打印 25 | print: false, 26 | // repo 27 | repo: 'JustUndertaker/ComWeChatBotClient', 28 | // 热更新,debug用 29 | hotReload: false, 30 | // 编辑功能 31 | editLink: false, 32 | // 纯净版 33 | pure: true, 34 | // 图标资源 35 | iconAssets: 'iconify', 36 | // 显示页脚 37 | displayFooter: true, 38 | // 页脚 39 | footer: "AGPL-3.0 LICENSE | Copyright © JustUndertaker", 40 | // 侧边栏 41 | sidebar: [ 42 | { 43 | text: '开始', 44 | icon: 'icons8:idea', 45 | link: '/guide/', 46 | }, 47 | { 48 | text: '消息段', 49 | icon: 'mdi:message-processing-outline', 50 | link: '/message/', 51 | }, 52 | { 53 | text: '事件', 54 | icon: 'mdi:event-clock', 55 | collapsible: true, 56 | link: '/event/meta.md', 57 | children: [{ 58 | text: '元事件', 59 | icon: 'ph:meta-logo', 60 | link: '/event/meta.md', 61 | }, 62 | { 63 | text: '消息事件', 64 | icon: 'mdi:message-processing-outline', 65 | link: '/event/message.md', 66 | }, 67 | { 68 | text: '请求事件', 69 | icon: 'ph:git-pull-request', 70 | link: '/event/request.md', 71 | }, 72 | { 73 | text: '通知事件', 74 | icon: 'fe:notice-active', 75 | link: '/event/notice.md', 76 | } 77 | ], 78 | }, 79 | { 80 | text: '动作', 81 | icon: 'material-symbols:call-to-action-outline', 82 | collapsible: true, 83 | link: '/action/meta.md', 84 | children: [ 85 | { 86 | text: '元动作', 87 | icon: 'ph:meta-logo', 88 | link: '/action/meta.md', 89 | }, 90 | { 91 | text: '消息动作', 92 | icon: 'mdi:message-processing-outline', 93 | link: '/action/message.md' 94 | }, 95 | { 96 | text: '个人动作', 97 | icon: 'fluent:inprivate-account-16-filled', 98 | link: '/action/private.md', 99 | }, 100 | { 101 | text: '群组动作', 102 | icon: 'material-symbols:group', 103 | link: '/action/group.md', 104 | }, 105 | { 106 | text: '文件动作', 107 | icon: 'ic:outline-insert-drive-file', 108 | link: '/action/file.md' 109 | }, 110 | { 111 | text: '拓展动作', 112 | icon: 'fluent:extension-16-filled', 113 | link: '/action/expand.md', 114 | } 115 | ] 116 | } 117 | ], 118 | plugins: { 119 | // md插件 120 | mdEnhance: { 121 | tasklist: true, 122 | tabs: true, 123 | }, 124 | // 默认代码高亮 125 | prismjs: false, 126 | } 127 | }), 128 | plugins: [ 129 | // 搜索插件 130 | searchProPlugin({ 131 | // 索引全部内容 132 | indexContent: true, 133 | }), 134 | // shiki代码高亮 135 | shikiPlugin({ 136 | theme: "one-dark-pro", 137 | }) 138 | ], 139 | }) 140 | 141 | -------------------------------------------------------------------------------- /docs/.vuepress/public/image/logo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustUndertaker/ComWeChatBotClient/091ce4dd047715cd96a4af1a6d3f9e4128f8aa6e/docs/.vuepress/public/image/logo.gif -------------------------------------------------------------------------------- /docs/.vuepress/public/image/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JustUndertaker/ComWeChatBotClient/091ce4dd047715cd96a4af1a6d3f9e4128f8aa6e/docs/.vuepress/public/image/logo.png -------------------------------------------------------------------------------- /docs/.vuepress/styles/config.scss: -------------------------------------------------------------------------------- 1 | $code-color: ( 2 | light: #383a42, 3 | dark: #abb2bf, 4 | ); 5 | $code-bg-color: ( 6 | light: #f7f5f5, 7 | dark: #282c34, 8 | ); 9 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | --- 2 | home: true 3 | heroImage: /image/logo.gif 4 | heroText: ComWeChat Client 5 | tagline: PC hook微信的协议端 6 | actions: 7 | - text: 快速开始 💡 8 | link: /guide/ 9 | type: primary 10 | features: 11 | - title: ComWechatRobot 12 | details: 上游依赖ComWeChatRobot 13 | link: https://github.com/ljc545w/ComWeChatRobot 14 | - title: Nonebot2 15 | details: 对nonebot2支持 16 | link: https://v2.nonebot.dev/ 17 | - title: Onebot12 18 | details: 本项目支持onebot12协议 19 | link: https://12.onebot.dev/ 20 | footer: AGPL-3.0 LICENSE | Copyright © JustUndertaker 21 | --- 22 | -------------------------------------------------------------------------------- /docs/action/README.md: -------------------------------------------------------------------------------- 1 | # 动作 2 | -------------------------------------------------------------------------------- /docs/action/file.md: -------------------------------------------------------------------------------- 1 | # 文件动作 2 | 3 | :::tip bytes数据类型 4 | 根据 [Onebot12 - 基本数据类型](https://12.onebot.dev/connect/data-protocol/basic-types/): 5 | 6 | > 在 JSON 中表示为 Base64 编码的字符串,MessagePack 中表示为 bin 格式的字节数组。 7 | 8 | ::: 9 | 10 | ## 上传文件 11 | action: `upload_file` 12 | 13 | :::tabs 14 | 15 | @tab 请求参数 16 | | 字段名 | 数据类型 | 说明 | 17 | | :-------: | :------: | :--------: | 18 | | `type` | string | 上传文件的方式,为 `url`、`path`、`data` 之一 | 19 | | `name` | string | 文件名 | 20 | | `url` | string | 文件的url,当 `type` 为 `url` 时必填 | 21 | | `headers` | map[string]string | `可选:`下载文件时的请求头 | 22 | | `path` | string | 文件的路径,当 `type` 为 `path` 时必填 | 23 | | `data` | bytes | 文件数据,当 `type` 为 `data` 时必填 | 24 | 25 | @tab 响应数据 26 | | 字段名 | 数据类型 | 说明 | 27 | | :-------: | :------: | :--------: | 28 | | `file_id` | string | 文件 ID,可供以后使用 | 29 | 30 | @tab 请求示例 31 | ```json 32 | { 33 | "action": "upload_file", 34 | "params": { 35 | "type": "url", 36 | "name": "test.png", 37 | "url": "https://example.com/test.png" 38 | } 39 | } 40 | ``` 41 | 42 | @tab 响应示例 43 | ```json 44 | { 45 | "status": "ok", 46 | "retcode": 0, 47 | "data": { 48 | "file_id": "e30f9684-3d54-4f65-b2da-db291a477f16" 49 | }, 50 | "message": "" 51 | } 52 | ``` 53 | 54 | @tab 在nb2使用 55 | ```python 56 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 57 | from nonebot import get_bot 58 | 59 | async def test(): 60 | bot = get_bot() 61 | file_id = await bot.upload_file(type="url", 62 | name="test.png", 63 | url="https://example.com/test.png" 64 | ) 65 | 66 | ``` 67 | 68 | ::: 69 | 70 | ## 分片上传文件 71 | action: `upload_file_fragmented` 72 | 73 | :::danger Wechat 74 | 暂未实现 75 | ::: 76 | 77 | ## 获取文件 78 | action: `get_file` 79 | 80 | :::tabs 81 | 82 | @tab 请求参数 83 | | 字段名 | 数据类型 | 说明 | 84 | | :-------: | :------: | :--------: | 85 | | `file_id` | string | 文件 ID | 86 | | `type` | string | 获取文件的方式,为 `url`、`path`、`data` 之一 | 87 | 88 | @tab 响应数据 89 | | 字段名 | 数据类型 | 说明 | 90 | | :-------: | :------: | :--------: | 91 | | `name` | string | 文件名 | 92 | | `url` | string | 文件的url,当 `type` 为 `url` 时返回 | 93 | | `headers` | map[string]string | 为空 | 94 | | `path` | string | 文件的路径,当 `type` 为 `path` 时返回 | 95 | | `data` | bytes | 文件数据,当 `type` 为 `data` 时返回 | 96 | 97 | @tab 请求示例 98 | ```json 99 | { 100 | "action": "get_file", 101 | "params": { 102 | "file_id": "e30f9684-3d54-4f65-b2da-db291a477f16", 103 | "type": "url" 104 | } 105 | } 106 | ``` 107 | 108 | @tab 响应示例 109 | ```json 110 | { 111 | "status": "ok", 112 | "retcode": 0, 113 | "data": { 114 | "name": "test.png", 115 | "url": "https://example.com/test.png" 116 | }, 117 | "message": "" 118 | } 119 | ``` 120 | 121 | @tab 在nb2使用 122 | ```python 123 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 124 | from nonebot import get_bot 125 | 126 | async def test(): 127 | bot = get_bot() 128 | file_url = await bot.get_file(file_id="e30f9684-3d54-4f65-b2da-db291a477f16",type="url") 129 | 130 | ``` 131 | 132 | ::: 133 | 134 | ## 分片获取文件 135 | action: `get_file_fragmented` 136 | 137 | :::danger Wechat 138 | 暂未实现 139 | ::: 140 | -------------------------------------------------------------------------------- /docs/action/group.md: -------------------------------------------------------------------------------- 1 | # 群组动作 2 | 3 | ## 获取群信息 4 | action: `get_group_info` 5 | 6 | :::tabs 7 | 8 | @tab 请求参数 9 | | 字段名 | 数据类型 | 说明 | 10 | | :-------: | :------: | :--------: | 11 | | `group_id` | string | 群 ID | 12 | 13 | @tab 响应数据 14 | | 字段名 | 数据类型 | 说明 | 15 | | :-------: | :------: | :--------: | 16 | | `group_id` | string | 群 ID | 17 | | `group_name` | string | 群名称 | 18 | | `wx.avatar` | string | `拓展字段:`群头像url | 19 | 20 | @tab 请求示例 21 | ```json 22 | { 23 | "action": "get_group_info", 24 | "params": { 25 | "group_id": "1234567" 26 | } 27 | } 28 | ``` 29 | 30 | @tab 响应示例 31 | ```json 32 | { 33 | "status": "ok", 34 | "retcode": 0, 35 | "data": { 36 | "group_id": "1234567", 37 | "group_name": "nb2", 38 | "wx.avatar": "https://wx.qlogo.cn/mmhead/ver_1/xxx/0" 39 | }, 40 | "message": "" 41 | } 42 | ``` 43 | 44 | @tab 在nb2使用 45 | ```python 46 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 47 | from nonebot import get_bot 48 | 49 | async def test(): 50 | bot = get_bot() 51 | group_info = await bot.get_group_info(group_id="1234567") 52 | 53 | ``` 54 | 55 | ::: 56 | 57 | ## 获取群列表 58 | action: `get_group_list` 59 | 60 | :::tabs 61 | 62 | @tab 请求参数 63 | 无 64 | 65 | @tab 响应数据 66 | 群信息列表,数据类型为 list[resp[`get_group_info`]]。 67 | 68 | @tab 请求示例 69 | ```json 70 | { 71 | "action": "get_group_list", 72 | "params": {} 73 | } 74 | ``` 75 | 76 | @tab 响应示例 77 | ```json 78 | { 79 | "status": "ok", 80 | "retcode": 0, 81 | "data": [ 82 | { 83 | "group_id": "1234567", 84 | "group_name": "nb2", 85 | }, 86 | { 87 | "group_id": "1234568", 88 | "group_name": "nb2", 89 | } 90 | ], 91 | "message": "" 92 | } 93 | ``` 94 | 95 | @tab 在nb2使用 96 | ```python 97 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 98 | from nonebot import get_bot 99 | 100 | async def test(): 101 | bot = get_bot() 102 | group_list = await bot.get_group_list() 103 | 104 | ``` 105 | 106 | ::: 107 | 108 | ## 获取群成员信息 109 | action: `get_group_member_info` 110 | 111 | :::tabs 112 | 113 | @tab 请求参数 114 | | 字段名 | 数据类型 | 说明 | 115 | | :-------: | :------: | :--------: | 116 | | `group_id` | string | 群 ID | 117 | | `user_id` | string | 用户 ID | 118 | 119 | @tab 响应数据 120 | | 字段名 | 数据类型 | 说明 | 121 | | :-------: | :------: | :--------: | 122 | | `user_id` | string | 用户 ID | 123 | | `user_name` | string | 昵称 | 124 | | `user_displayname` | string | 为空 | 125 | | `wx.avatar` | string | `拓展字段:`头像url | 126 | | `wx.wx_number` | string | `拓展字段:`微信号 | 127 | | `wx.nation` | string | `拓展字段:`国家 | 128 | | `wx.province` | string | `拓展字段:`省份 | 129 | | `wx.city` | string | `拓展字段:`城市 | 130 | 131 | @tab 请求示例 132 | ```json 133 | { 134 | "action": "get_group_member_info", 135 | "params": { 136 | "group_id": "1234567", 137 | "user_id": "1234567" 138 | } 139 | } 140 | ``` 141 | 142 | @tab 响应示例 143 | ```json 144 | { 145 | "status": "ok", 146 | "retcode": 0, 147 | "data": { 148 | "user_id": "1234567", 149 | "user_name": "nb2", 150 | "user_displayname": "", 151 | "wx.avatar": "https://wx.qlogo.cn/mmhead/ver_1/xxx/0", 152 | "wx.wx_number": "nb2", 153 | "wx.nation": "中国", 154 | "wx.province": "广东", 155 | "wx.city": "深圳" 156 | }, 157 | "message": "" 158 | } 159 | ``` 160 | 161 | @tab 在nb2使用 162 | ```python 163 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 164 | from nonebot import get_bot 165 | 166 | async def test(): 167 | bot = get_bot() 168 | group_member_info = await bot.get_group_member_info(group_id="1234567", user_id="1234567") 169 | 170 | ``` 171 | 172 | ::: 173 | 174 | ## 获取群成员列表 175 | action: `get_group_member_list` 176 | 177 | :::tabs 178 | 179 | @tab 请求参数 180 | | 字段名 | 数据类型 | 说明 | 181 | | :-------: | :------: | :--------: | 182 | | `group_id` | string | 群 ID | 183 | 184 | @tab 响应数据 185 | 群成员信息列表,数据类型为 list[resp[`get_group_member_info`]]。 186 | 187 | @tab 请求示例 188 | ```json 189 | { 190 | "action": "get_group_member_list", 191 | "params": { 192 | "group_id": "1234567" 193 | } 194 | } 195 | ``` 196 | 197 | @tab 响应示例 198 | ```json 199 | { 200 | "status": "ok", 201 | "retcode": 0, 202 | "data": [ 203 | { 204 | "user_id": "1234567", 205 | "user_name": "nb2", 206 | "user_displayname": "", 207 | "wx.avatar": "https://wx.qlogo.cn/mmhead/ver_1/xxx/0", 208 | "wx.wx_number": "nb2", 209 | "wx.nation": "中国", 210 | "wx.province": "广东", 211 | "wx.city": "深圳" 212 | }, 213 | { 214 | "user_id": "1234568", 215 | "user_name": "nb2", 216 | "user_displayname": "", 217 | "wx.avatar": "https://wx.qlogo.cn/mmhead/ver_1/xxx/0", 218 | "wx.wx_number": "nb2", 219 | "wx.nation": "中国", 220 | "wx.province": "广东", 221 | "wx.city": "深圳" 222 | } 223 | ], 224 | "message": "" 225 | } 226 | ``` 227 | 228 | @tab 在nb2使用 229 | ```python 230 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 231 | from nonebot import get_bot 232 | 233 | async def test(): 234 | bot = get_bot() 235 | group_member_list = await bot.get_group_member_list(group_id="1234567") 236 | 237 | ``` 238 | 239 | ::: 240 | 241 | ## 设置群名称 242 | action: `set_group_name` 243 | 244 | :::tabs 245 | 246 | @tab 请求参数 247 | | 字段名 | 数据类型 | 说明 | 248 | | :-------: | :------: | :--------: | 249 | | `group_id` | string | 群 ID | 250 | | `group_name` | string | 群名称 | 251 | 252 | @tab 响应数据 253 | 无 254 | 255 | @tab 请求示例 256 | ```json 257 | { 258 | "action": "set_group_name", 259 | "params": { 260 | "group_id": "1234567", 261 | "group_name": "nb2" 262 | } 263 | } 264 | ``` 265 | 266 | @tab 响应示例 267 | ```json 268 | { 269 | "status": "ok", 270 | "retcode": 0, 271 | "data": null, 272 | "message": "" 273 | } 274 | ``` 275 | 276 | @tab 在nb2使用 277 | ```python 278 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 279 | from nonebot import get_bot 280 | 281 | async def test(): 282 | bot = get_bot() 283 | await bot.set_group_name(group_id="1234567", group_name="nb2") 284 | 285 | ``` 286 | 287 | ::: 288 | 289 | ## 退出群 290 | action: `leave_group` 291 | 292 | :::danger Wechat 293 | 未实现 294 | ::: 295 | -------------------------------------------------------------------------------- /docs/action/message.md: -------------------------------------------------------------------------------- 1 | # 消息动作 2 | 3 | 4 | ## 发送消息 5 | action: `send_message` 6 | 7 | :::warning Wechat 8 | 由于 wechat 的特性,该接口有以下限制: 9 | - `message_type` 只能为 `private` 或 `group` 10 | - `message` 中的每个消息段都将作为一条消息发送出去(除了`mention`) 11 | - `mention` 和 `mention_all` 只支持群聊 12 | ::: 13 | 14 | :::tabs 15 | 16 | @tab 请求参数 17 | | 字段名 | 数据类型 | 说明 | 18 | | :-------: | :------: | :--------: | 19 | | `message_type` | string | 消息类型,`private` 或 `group` | 20 | | `user_id` | string | 用户 ID,当 `detail_type` 为 `private` 时必须传入 | 21 | | `group_id` | string | 群 ID,当 `detail_type` 为 `group` 时必须传入 | 22 | | `message` | message | 消息内容,为消息段列表,详见 [消息段](/message/README.md) | 23 | 24 | @tab 响应数据 25 | 在 `Onebot12` 标准中,原则上应该返回一个 `message_id`,但是由于hook的限制,目前只能返回一个 `bool`,用来判断消息是否发送成功。 26 | 27 | @tab 请求示例 28 | ```json 29 | { 30 | "action": "send_message", 31 | "params": { 32 | "detail_type": "group", 33 | "group_id": "12467", 34 | "message": [ 35 | { 36 | "type": "text", 37 | "data": { 38 | "text": "我是文字巴拉巴拉巴拉" 39 | } 40 | } 41 | ] 42 | } 43 | } 44 | ``` 45 | 46 | @tab 响应示例 47 | ```json 48 | { 49 | "status": "ok", 50 | "retcode": 0, 51 | "data": true, 52 | "message": "" 53 | } 54 | ``` 55 | 56 | @tab 在nb2使用 57 | ```python 58 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 59 | from nonebot import get_bot 60 | 61 | async def test(): 62 | bot = get_bot() 63 | message = MessageSegment.text("我是文字巴拉巴拉巴拉") + 64 | MessageSegment.image(file_id="asd-asd-asd-ads") 65 | await bot.send_message(detail_type="group",group_id="12467",message=message) 66 | 67 | ``` 68 | 69 | ::: 70 | 71 | ## 撤回消息 72 | action: `delete_message` 73 | :::danger Wechat 74 | 未实现该动作。 75 | ::: 76 | -------------------------------------------------------------------------------- /docs/action/meta.md: -------------------------------------------------------------------------------- 1 | # 元动作 2 | :::tip Onebot12 3 | 元动作是用于对 OneBot 实现进行控制、检查等的动作,例如获取版本信息等,仅与 OneBot 本身交互,与实现对应的机器人平台无关。 4 | ::: 5 | 6 | ## 获取最新事件列表 7 | action: `get_latest_events` 8 | 9 | 仅 HTTP 通信方式支持,用于轮询获取事件。 10 | :::tabs 11 | 12 | @tab 请求参数 13 | | 字段名 | 数据类型 | 默认值 | 说明 | 14 | | :-------: | :------: | :------: | :--------: | 15 | | `limit` | int64 | 0 | 获取的事件数量上限,0 表示不限制 | 16 | | `timeout` | int64 | 0 | 没有事件时最多等待的秒数,0 表示使用短轮询,不等待 | 17 | 18 | @tab 响应数据 19 | 除元事件外的事件列表,从旧到新排序。 20 | 21 | @tab 请求示例 22 | ```json 23 | { 24 | "action": "get_latest_events", 25 | "params": { 26 | "limit": 100, 27 | "timeout": 0 28 | } 29 | } 30 | ``` 31 | 32 | @tab 响应示例 33 | ```json 34 | { 35 | "status": "ok", 36 | "retcode": 0, 37 | "data": [ 38 | { 39 | "id": "b6e65187-5ac0-489c-b431-53078e9d2bbb", 40 | "self": { 41 | "platform": "wechat", 42 | "user_id": "123234" 43 | }, 44 | "time": 1632847927.599013, 45 | "type": "message", 46 | "detail_type": "private", 47 | "sub_type": "", 48 | "message_id": "6283", 49 | "message": [ 50 | { 51 | "type": "text", 52 | "data": { 53 | "text": "OneBot is not a bot" 54 | } 55 | }, 56 | { 57 | "type": "image", 58 | "data": { 59 | "file_id": "e30f9684-3d54-4f65-b2da-db291a477f16" 60 | } 61 | } 62 | ], 63 | "alt_message": "OneBot is not a bot[图片]", 64 | "user_id": "123456788" 65 | }, 66 | { 67 | "id": "b6e65187-5ac0-489c-b431-53078e9d2bbb", 68 | "self": { 69 | "platform": "qq", 70 | "user_id": "123234" 71 | }, 72 | "time": 1632847927.599013, 73 | "type": "notice", 74 | "detail_type": "group_member_increase", 75 | "sub_type": "join", 76 | "user_id": "123456788", 77 | "group_id": "87654321", 78 | "operator_id": "1234567" 79 | } 80 | ], 81 | "message": "" 82 | } 83 | ``` 84 | 85 | @tab 在nb2使用 86 | ```python 87 | from nonebot.adapters.onebot.v12 import Bot 88 | from nonebot import get_bot 89 | 90 | async def test(): 91 | bot = get_bot() 92 | latest_events = await bot.get_latest_events() 93 | 94 | ``` 95 | ::: 96 | 97 | ## 获取支持的动作列表 98 | action: `get_supported_actions` 99 | :::tabs 100 | 101 | @tab 请求参数 102 | 无. 103 | 104 | @tab 响应数据 105 | 支持的动作名称列表,不包括 `get_latest_events`。 106 | 107 | @tab 请求示例 108 | ```json 109 | { 110 | "action": "get_supported_actions", 111 | "params": {} 112 | } 113 | ``` 114 | 115 | @tab 响应示例 116 | ```json 117 | { 118 | "status": "ok", 119 | "retcode": 0, 120 | "data": [ 121 | "get_supported_actions", 122 | "get_status", 123 | "get_version", 124 | "send_message", 125 | "get_self_info", 126 | "get_user_info", 127 | "get_friend_list", 128 | "get_group_info", 129 | "get_group_list", 130 | "get_group_member_info", 131 | "get_group_member_list", 132 | "set_group_name", 133 | "upload_file", 134 | "get_file", 135 | "wx.get_public_account_list", 136 | "wx.follow_public_number", 137 | "wx.search_friend_by_remark", 138 | "wx.search_friend_by_wxnumber", 139 | "wx.search_friend_by_nickname", 140 | "wx.check_friend_status", 141 | "wx.get_db_handles", 142 | "wx.execute_sql", 143 | "wx.backup_db", 144 | "wx.verify_friend_apply", 145 | "wx.get_wechat_version", 146 | "wx.change_wechat_version", 147 | "wx.delete_friend", 148 | "wx.edit_remark", 149 | "wx.set_group_announcement", 150 | "wx.set_group_nickname", 151 | "wx.get_groupmember_nickname", 152 | "wx.kick_groupmember", 153 | "wx.invite_groupmember", 154 | "wx.get_history_public_msg", 155 | "wx.send_forward_msg", 156 | "wx.send_xml", 157 | "wx.send_card", 158 | "wx.clean_file_cache" 159 | ], 160 | "message": "" 161 | } 162 | ``` 163 | 164 | @tab 在nb2使用 165 | ```python 166 | from nonebot.adapters.onebot.v12 import Bot 167 | from nonebot import get_bot 168 | 169 | async def test(): 170 | bot = get_bot() 171 | supported_actions = await bot.get_supported_actions() 172 | 173 | ``` 174 | ::: 175 | 176 | ## 获取运行状态 177 | action: `get_status` 178 | :::tabs 179 | 180 | @tab 请求参数 181 | 无 182 | 183 | @tab 响应数据 184 | | 字段名 | 数据类型 | 说明 | 185 | | :-------: | :------: | :--------: | 186 | | `good` | bool | 是否各项状态都符合预期,OneBot 实现各模块均正常 | 187 | | `bots` | list[object] | 当前 OneBot Connect 连接上所有机器人账号的状态列表 | 188 | 189 | 其中,`bots` 的每一个元素具有下面这些字段: 190 | | 字段名 | 数据类型 | 说明 | 191 | | :-------: | :------: | :--------: | 192 | | self | self | 机器人自身标识 | 193 | | online | bool | 机器人账号是否在线(可收发消息等) | 194 | 195 | @tab 请求示例 196 | ```json 197 | { 198 | "action": "get_status", 199 | "params": {} 200 | } 201 | ``` 202 | 203 | @tab 响应示例 204 | ```json 205 | { 206 | "status": "ok", 207 | "retcode": 0, 208 | "data": { 209 | "good": true, 210 | "bots": [ 211 | { 212 | "self": { 213 | "platform": "wechat", 214 | "user_id": "xxxx" 215 | }, 216 | "online": true 217 | } 218 | ] 219 | }, 220 | "message": "" 221 | } 222 | ``` 223 | 224 | @tab 在nb2使用 225 | ```python 226 | from nonebot.adapters.onebot.v12 import Bot 227 | from nonebot import get_bot 228 | 229 | async def test(): 230 | bot = get_bot() 231 | status = await bot.get_status() 232 | 233 | ``` 234 | ::: 235 | 236 | ## 获取版本信息 237 | action: `get_version` 238 | :::tabs 239 | 240 | @tab 请求参数 241 | 无 242 | 243 | @tab 响应数据 244 | | 字段名 | 数据类型 | 说明 | 245 | | :-------: | :------: | :--------: | 246 | | `impl` | string | 实现名称,`ComWechat` | 247 | | `version` | string | 版本号 | 248 | | `onebot_version` | string | OneBot 标准版本号 | 249 | 250 | @tab 请求示例 251 | ```json 252 | { 253 | "action": "get_version", 254 | "params": {} 255 | } 256 | ``` 257 | 258 | @tab 响应示例 259 | ```json 260 | { 261 | "status": "ok", 262 | "retcode": 0, 263 | "data": { 264 | "impl": "ComWechat", 265 | "version": "v1.0", 266 | "onebot_version": "12" 267 | }, 268 | "message": "" 269 | } 270 | ``` 271 | 272 | @tab 在nb2使用 273 | ```python 274 | from nonebot.adapters.onebot.v12 import Bot 275 | from nonebot import get_bot 276 | 277 | async def test(): 278 | bot = get_bot() 279 | version = await bot.get_version() 280 | 281 | ``` 282 | ::: 283 | -------------------------------------------------------------------------------- /docs/action/private.md: -------------------------------------------------------------------------------- 1 | # 个人动作 2 | 3 | ## 获取机器人自身信息 4 | action: `get_self_info` 5 | 6 | :::tabs 7 | 8 | @tab 请求参数 9 | 无 10 | 11 | @tab 响应数据 12 | | 字段名 | 数据类型 | 说明 | 13 | | :-------: | :------: | :--------: | 14 | | `user_id` | string | 机器人用户 ID | 15 | | `user_name` | string | 机器人昵称 | 16 | | `user_displayname` | string | 为空 | 17 | 18 | @tab 请求示例 19 | ```json 20 | { 21 | "action": "get_self_info", 22 | "params": {} 23 | } 24 | ``` 25 | 26 | @tab 响应示例 27 | ```json 28 | { 29 | "status": "ok", 30 | "retcode": 0, 31 | "data": { 32 | "user_id": "1234567", 33 | "user_name": "nb2", 34 | "user_displayname": "" 35 | }, 36 | "message": "" 37 | } 38 | ``` 39 | 40 | @tab 在nb2使用 41 | ```python 42 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 43 | from nonebot import get_bot 44 | 45 | async def test(): 46 | bot = get_bot() 47 | self_info = await bot.get_self_info() 48 | 49 | ``` 50 | 51 | ::: 52 | 53 | ## 获取用户信息 54 | action: `get_user_info` 55 | 56 | :::tabs 57 | 58 | @tab 请求参数 59 | | 字段名 | 数据类型 | 说明 | 60 | | :-------: | :------: | :--------: | 61 | | `user_id` | string | 用户 ID | 62 | 63 | @tab 响应数据 64 | | 字段名 | 数据类型 | 说明 | 65 | | :-------: | :------: | :--------: | 66 | | `user_id` | string | 用户 ID | 67 | | `user_name` | string | 用户昵称 | 68 | | `user_displayname` | string | 为空 | 69 | | `user_remark` | string | 备注 | 70 | | `wx.avatar` | string | `拓展字段:` 头像链接 | 71 | | `wx.wx_number` | string | `拓展字段:` 微信号 | 72 | | `wx.nation` | string | `拓展字段:` 国家 | 73 | | `wx.province` | string | `拓展字段:` 省份 | 74 | | `wx.city` | string | `拓展字段:` 城市 | 75 | 76 | @tab 请求示例 77 | ```json 78 | { 79 | "action": "get_user_info", 80 | "params": { 81 | "user_id": "1234567" 82 | } 83 | } 84 | ``` 85 | 86 | @tab 响应示例 87 | ```json 88 | { 89 | "status": "ok", 90 | "retcode": 0, 91 | "data": { 92 | "user_id": "1234567", 93 | "user_name": "nb2", 94 | "user_displayname": "", 95 | "user_remark": "", 96 | "wx.avatar": "https://wx.qlogo.cn/mmhead/ver_1/xxx/0", 97 | "wx.wx_number": "nb2", 98 | "wx.nation": "中国", 99 | "wx.province": "广东", 100 | "wx.city": "深圳" 101 | }, 102 | "message": "" 103 | } 104 | ``` 105 | 106 | @tab 在nb2使用 107 | ```python 108 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 109 | from nonebot import get_bot 110 | 111 | async def test(): 112 | bot = get_bot() 113 | user_info = await bot.get_user_info(user_id="1234567") 114 | ``` 115 | ::: 116 | 117 | ## 获取好友列表 118 | action: `get_friend_list` 119 | 120 | :::tabs 121 | 122 | @tab 请求参数 123 | 无 124 | 125 | @tab 响应数据 126 | 好友信息列表,数据类型为 list[resp[`get_user_info`]]。 127 | 128 | @tab 请求示例 129 | ```json 130 | { 131 | "action": "get_friend_list", 132 | "params": {} 133 | } 134 | ``` 135 | 136 | @tab 响应示例 137 | ```json 138 | { 139 | "status": "ok", 140 | "retcode": 0, 141 | "data": [ 142 | { 143 | "user_id": "1234567", 144 | "user_name": "nb2", 145 | "user_displayname": "", 146 | "user_remark": "", 147 | "wx.verify_flag": "0" 148 | }, 149 | { 150 | "user_id": "7654321", 151 | "user_name": "nb1", 152 | "user_displayname": "", 153 | "user_remark": "", 154 | "wx.verify_flag": "0", 155 | } 156 | ], 157 | "message": "" 158 | } 159 | ``` 160 | `wx.verify_flag` 为好友验证标识。 161 | 162 | @tab 在nb2使用 163 | ```python 164 | from nonebot.adapters.onebot.v12 import Bot, MessageSegment 165 | from nonebot import get_bot 166 | 167 | async def test(): 168 | bot = get_bot() 169 | friend_list = await bot.get_friend_list() 170 | 171 | ``` 172 | 173 | ::: 174 | -------------------------------------------------------------------------------- /docs/event/README.md: -------------------------------------------------------------------------------- 1 | # 事件 2 | -------------------------------------------------------------------------------- /docs/event/message.md: -------------------------------------------------------------------------------- 1 | # 消息事件 2 | :::tip Onebot12 3 | 本页所定义的事件均基于 [OneBot Connect - 事件](https://12.onebot.dev/connect/data-protocol/event/),其中 type 字段值应为 `message`。 4 | ::: 5 | 6 | ## 私聊消息 7 | 8 | | 字段名 | 数据类型 | 说明 | 9 | | :----------: | :------: | :-----------------: | 10 | | `detail_type` | string | `private` | 11 | | `message_id` | string | 消息唯一 ID | 12 | | `message` | message | 消息内容,消息段列表 | 13 | | `alt_message` | string | 消息内容的替代表示 | 14 | | `user_id` | string | 发送方`wxid` | 15 | 16 | ## 群消息 17 | 18 | | 字段名 | 数据类型 | 说明 | 19 | | :----------: | :------: | :-----------------: | 20 | | `detail_type` | string | `group` | 21 | | `message_id` | string | 消息唯一 ID | 22 | | `message` | message | 消息内容,消息段列表 | 23 | | `alt_message` | string | 消息内容的替代表示 | 24 | | `group_id` | string | 群id,以`@chatroom`结尾 | 25 | | `user_id` | string | 发送方`wxid` | 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/event/meta.md: -------------------------------------------------------------------------------- 1 | # 元事件 2 | ::: tip Onebot12 3 | Onebot 实现内部自发产生的一类事件,例如心跳等,与 OneBot 本身的运行状态有关,与实现对应的机器人平台无关。 4 | ::: 5 | 本项目实现了以下的元事件。 6 | ## 连接 7 | 对于正向 WebSocket 和反向 WebSocket 通信方式,在连接建立后会推送给应用端 8 | 的第一个事件;HTTP 和 HTTP Webhook 通信方式不会产生连接事件。 9 | 10 | ::: tabs 11 | 12 | @tab 字段 13 | 14 | | 字段名 | 数据类型 | 说明 | 15 | | :----------: | :------: | :-----------------: | 16 | | `detail_type` | string | `connect` | 17 | | `version` | resp[get_version] | OneBot 实现端版本信息,与 get_version 动作响应数据一致| 18 | 19 | @tab 示例 20 | ```json 21 | { 22 | "id": "b6e65187-5ac0-489c-b431-53078e9d2bbb", 23 | "time": 1632847927.599013, 24 | "type": "meta", 25 | "detail_type": "connect", 26 | "sub_type": "", 27 | "version": { 28 | "impl": "ComWechat", 29 | "version": "1.2.0", 30 | "onebot_version": "12" 31 | } 32 | } 33 | ``` 34 | 35 | ::: 36 | 37 | ## 心跳 38 | 当 enabled 配置为 true 时,每隔 interval 产生一个心跳事件。 39 | :::tip 配置 40 | 间隔 `interval` 对应 `.env` 配置中的 `heartbeat_interval`,单位是ms 41 | ::: 42 | 43 | ::: tabs 44 | 45 | @tab 字段 46 | 47 | | 字段名 | 数据类型 | 说明 | 48 | | :----------: | :------: | :-----------------: | 49 | | `detail_type` | string | `heartbeat` | 50 | | `interval` | int64 | 到下次心跳的间隔,单位:毫秒 | 51 | 52 | @tab 示例 53 | 54 | ```json 55 | { 56 | "id": "b6e65187-5ac0-489c-b431-53078e9d2bbb", 57 | "time": 1632847927.599013, 58 | "type": "meta", 59 | "detail_type": "heartbeat", 60 | "sub_type": "", 61 | "interval": 5000 62 | } 63 | ``` 64 | 65 | ::: 66 | 67 | ## 状态更新 68 | 连接方式为正向ws或反向ws时,在发送`connect`事件后会发送`status`事件,表示连接状态。 69 | ::: tabs 70 | 71 | @tab 字段 72 | 73 | | 字段名 | 数据类型 | 说明 | 74 | | :----------: | :------: | :-----------------: | 75 | | `detail_type` | string | `status_update` | 76 | | `status_update` | resp[get_status] | 与`get_status`动作响应数据一致 | 77 | 78 | @tab 示例 79 | 80 | ```json 81 | { 82 | "id": "b6e65187-5ac0-489c-b431-53078e9d2bbb", 83 | "time": 1632847927.599013, 84 | "type": "meta", 85 | "detail_type": "status_update", 86 | "sub_type": "", 87 | "status": { 88 | "good": true, 89 | "bots": [ 90 | { 91 | "self": { 92 | "platform": "qq", 93 | "user_id": "1234567" 94 | }, 95 | "online": true, 96 | } 97 | ] 98 | } 99 | } 100 | 101 | ``` 102 | 103 | ::: 104 | -------------------------------------------------------------------------------- /docs/event/notice.md: -------------------------------------------------------------------------------- 1 | # 通知事件 2 | :::tip Onebot12 3 | 本页所定义的事件均基于[OneBot Connect - 事件](https://12.onebot.dev/connect/data-protocol/event/),其中 type 字段值应为 `notice`。 4 | ::: 5 | 6 | ## 好友增加 7 | 本事件在好友增加时触发 8 | :::danger Wechat 9 | wechat 在添加好友时使用发送消息:"我通过了你的朋友验证请求,现在我们可以开始聊天了"等,故这边不好实现 10 | ::: 11 | 12 | ## 好友减少 13 | 本事件在好友减少时触发 14 | :::danger Wechat 15 | 由于 `wechat` 的单向好友特性,删除好友时并不会提醒他人,故无法实现 16 | ::: 17 | 18 | ## 私聊消息删除 19 | 本事件在私聊消息被删除时触发 20 | | 字段名 | 数据类型 | 说明 | 21 | | :----------: | :------: | :-----------------: | 22 | | `detail_type` | string | `private_message_delete` | 23 | | `message_id` | string | 撤回消息ID | 24 | | `user_id` | string | 消息发送者 ID | 25 | 26 | ## 群成员增加 27 | 本事件应在群成员(包括机器人自身)申请加群通过、被邀请进群或其它方式进群时触发 28 | :::danger Wechat 29 | 暂未实现 30 | ::: 31 | 32 | ## 群成员减少 33 | 本事件应在群成员(包括机器人自身)主动退出、被踢出或其它方式退出时触发 34 | :::danger Wechat 35 | 暂未实现 36 | ::: 37 | 38 | ## 群消息删除 39 | 本事件应在群消息被撤回或被管理员删除时触发 40 | | 字段名 | 数据类型 | 说明 | 41 | | :----------: | :------: | :-----------------: | 42 | | `detail_type` | string | `group_message_delete` | 43 | | `sub_type` | string | 为 `delete` | 44 | | `group_id` | string | 群 ID | 45 | | `message_id` | string | 消息 ID | 46 | | `user_id` | string | 消息发送者 ID | 47 | | `operator_id` | string | 为空 | 48 | 49 | ## 私聊接收文件通知 50 | 在接收到文件时会发送通知(此时文件还未下载) 51 | | 字段名 | 数据类型 | 说明 | 52 | | :----------: | :------: | :-----------------: | 53 | | `detail_type` | string | `wx.get_private_file` | 54 | | `file_name` | string | 文件名 | 55 | | `file_length` | int | 文件长度 | 56 | | `md5` | string | 文件md5值 | 57 | | `user_id` | string | 发送方 ID | 58 | 59 | ## 群聊接收文件通知 60 | 在接收到文件时会发送通知(此时文件还未下载) 61 | | 字段名 | 数据类型 | 说明 | 62 | | :----------: | :------: | :-----------------: | 63 | | `detail_type` | string | `wx.get_private_file` | 64 | | `file_name` | string | 文件名 | 65 | | `file_length` | int | 文件长度 | 66 | | `md5` | string | 文件md5值 | 67 | | `group_id` | string | 群 ID | 68 | | `user_id` | string | 发送方 ID | 69 | 70 | ## 私聊收到红包通知 71 | 私聊收到红包时的通知 72 | | 字段名 | 数据类型 | 说明 | 73 | | :----------: | :------: | :-----------------: | 74 | | `detail_type` | string | `wx.get_private_redbag` | 75 | | `user_id` | string | 发送方 ID | 76 | 77 | ## 群聊收到红包通知 78 | 群聊收到红包时的通知 79 | | 字段名 | 数据类型 | 说明 | 80 | | :----------: | :------: | :-----------------: | 81 | | `detail_type` | string | `wx.get_group_redbag` | 82 | | `group_id` | string | 群 ID | 83 | | `user_id` | string | 发送方 ID | 84 | 85 | ## 私聊拍一拍通知 86 | 私聊拍一拍时通知 87 | | 字段名 | 数据类型 | 说明 | 88 | | :----------: | :------: | :-----------------: | 89 | | `detail_type` | string | `wx.get_private_poke` | 90 | | `user_id` | string | 接收方 ID | 91 | | `from_user_id` | string | 发送方 ID | 92 | 93 | ## 群聊拍一拍通知 94 | 群聊拍一拍时通知 95 | | 字段名 | 数据类型 | 说明 | 96 | | :----------: | :------: | :-----------------: | 97 | | `detail_type` | string | `wx.get_group_poke` | 98 | | `group_id` | string | 群 ID | 99 | | `user_id` | string | 接收方 ID | 100 | | `from_user_id` | string | 发送方 ID | 101 | 102 | ## 私聊获取名片通知 103 | 私聊收到名片时通知 104 | | 字段名 | 数据类型 | 说明 | 105 | | :----------: | :------: | :-----------------: | 106 | | `detail_type` | string | `wx.get_private_card` | 107 | | `user_id` | string | 发送方 ID | 108 | | `v3` | string | 名片v3信息 | 109 | | `v4` | string | 名片v4信息 | 110 | | `nickname` | string | 名片昵称 | 111 | | `head_url` | string | 头像url | 112 | | `province` | string | 省 | 113 | | `city` | string | 市 | 114 | | `sex` | string | 性别 | 115 | 116 | ## 群聊获取名片通知 117 | 群聊收到名片时通知 118 | | 字段名 | 数据类型 | 说明 | 119 | | :----------: | :------: | :-----------------: | 120 | | `detail_type` | string | `wx.get_group_card` | 121 | | `group_id` | string | 群 ID | 122 | | `user_id` | string | 发送方 ID | 123 | | `v3` | string | 名片v3信息 | 124 | | `v4` | string | 名片v4信息 | 125 | | `nickname` | string | 名片昵称 | 126 | | `head_url` | string | 头像url | 127 | | `province` | string | 省 | 128 | | `city` | string | 市 | 129 | | `sex` | string | 性别 | 130 | -------------------------------------------------------------------------------- /docs/event/request.md: -------------------------------------------------------------------------------- 1 | # 请求事件 2 | :::tip Onebot12 3 | 本页所定义的事件均基于 [OneBot Connect - 事件](https://12.onebot.dev/connect/data-protocol/event/),其中 type 字段值应为 `request`。 4 | ::: 5 | 6 | ## 添加好友请求 7 | 他人请求添加好友时上报 8 | 9 | | 字段名 | 数据类型 | 说明 | 10 | | :----------: | :------: | :-----------------: | 11 | | `detail_type` | string | `wx.friend_request` | 12 | | `user_id` | string | 发送方的 `wxid` | 13 | | `v3` | string | 事件请求v3,接收请求时使用 | 14 | | `v4` | string | 事件请求v4,接收请求时使用 | 15 | | `nickname` | string | 请求人昵称 | 16 | | `content` | string | 附加的话 | 17 | | `country` | string | 国家 | 18 | | `province` | string | 省份 | 19 | | `city` | string | 城市 | 20 | -------------------------------------------------------------------------------- /docs/guide/README.md: -------------------------------------------------------------------------------- 1 | # 开始 2 | 此文档将引导你使用本项目。 3 | ## 许可证 4 | 本项目采用 [AGPLv3](https://github.com/JustUndertaker/ComWeChatBotClient/blob/main/LICENSE) 许可证 5 | ::: danger AGPLv3 6 | 本项目不带有主动添加,涉及金钱接口,不鼓励、不支持一切商业使用。 7 | ::: 8 | ## 上游依赖 9 | 本项目依赖上游:[ComWeChatRobot](https://github.com/ljc545w/ComWeChatRobot)。 10 | ::: tip ComWeChatRobot 11 | PC微信机器人,实现获取通讯录,发送文本、图片、文件等消息,封装COM接口供Python、C#调用 12 | ::: 13 | ## 微信版本 14 | 本项目使用的微信版本为:3.7.0.30,请安装此版本微信,否则无法使用,下载地址:[传送门](https://github.com/tom-snow/wechat-windows-versions/releases/download/v3.7.0.30/WeChatSetup-3.7.0.30.exe) 15 | 16 | 安装后想办法禁用升级。 17 | ## 安装环境 18 | 本项目为PC Hook,并且使用Com接口调用,故只支持Windows系统。为了使用Com接口,在启动本项目时,应首先注册Com服务: 19 | ::: tip 以管理员权限执行以下命令 20 | ```bat 21 | # 安装 22 | CWeChatRobot.exe /regserver 23 | # 卸载 24 | CWeChatRobot.exe /unregserver 25 | ``` 26 | ::: 27 | 或者使用文件下的 `install.bat`、`uninstall.bat`来安装与卸载。 28 | ## Onebot12 29 | 本项目使用 [Onebot12](https://12.onebot.dev/) 作为协议进行传输数据 30 | ::: tip Onebot12 31 | OneBot 是一个聊天机器人应用接口标准,旨在统一不同聊天平台上的机器人应用开发接口,使开发者只需编写一次业务逻辑代码即可应用到多种机器人平台。 32 | ::: 33 | 目前支持的通信方式: 34 | - [x] HTTP 35 | - [X] HTTP Webhook 36 | - [x] 正向 WebSocket 37 | - [x] 反向 WebSocket 38 | 39 | ## 配置 40 | 本项目下的 `.env` 文件为项目配置文件,下面讲解配置文件项目。 41 | 42 | ### `host` 43 | 服务Host 44 | - **类型:** `IPvAnyAddress` 45 | - **默认值:** `127.0.0.1` 46 | 47 | 在使用 `http` 和 `正向 websocket` 方式时会监听此host 48 | 49 | ### `port` 50 | 服务端口 51 | - **类型:** `int`(1~65535) 52 | - **默认值:** `8000` 53 | 54 | 在使用 `http` 和 `正向 websocket` 方式时会监听此端口,注意不要和其他端口冲突! 55 | 56 | ### `access_token` 57 | 访问令牌 58 | - **类型:** `str` 59 | - **默认值:** `""` 60 | 61 | 配置了访问令牌后,与本服务通信的另一端也要配置同样的token,否则会连接失败。 62 | 63 | ### `heartbeat_enabled` 64 | 心跳事件 65 | - **类型:** `bool` 66 | - **默认值:** `false` 67 | 68 | 开启心跳后,将周期向连接端发送心跳事件。 69 | 70 | ### `heartbeat_interval` 71 | 心跳间隔 72 | - **类型:** `int`(1~65535) 73 | - **默认值:** `5000` 74 | 75 | 开启心跳后有用,单位毫秒,必须大于0 76 | 77 | ### `enable_http_api` 78 | 开启http访问 79 | - **类型:** `bool` 80 | - **默认值:** `true` 81 | 82 | 是否开启http访问功能。 83 | 84 | ### `event_enabled` 85 | 启用get_latest_events 86 | - **类型:** `bool` 87 | - **默认值:** `false` 88 | 89 | 开启http时有效,是否启用 `get_latest_events` 原动作 90 | 91 | ### `event_buffer_size` 92 | 缓冲区大小 93 | - **类型:** `int` 94 | - **默认值:** `0` 95 | 96 | `get_latest_events` 存储的事件缓冲区大小,超过该大小将会丢弃最旧的事件,0 表示不限大小 97 | 98 | ### `enable_http_webhook` 99 | 启用http webhook 100 | - **类型:** `bool` 101 | - **默认值:** `false` 102 | 103 | 是否启用http webhook。 104 | 105 | ### `webhook_url` 106 | 上报地址 107 | - **类型:** `Set(URL)` 108 | - **默认值:** `["http://127.0.0.1:8080/onebot/v12/http/"]` 109 | 110 | 启用webhook生效,webhook 上报地址,需要以`http://`开头,多个地址用`,`分隔。 111 | 112 | ### `webhook_timeout` 113 | 上报请求超时时间 114 | - **类型:** `int` 115 | - **默认值:** `5000` 116 | 117 | 启用webhook生效,单位:毫秒,0 表示不超时 118 | 119 | ### `websocekt_type` 120 | websocket连接方式 121 | - **类型:** `str` 122 | - **默认值:** `Unable` 123 | 124 | 只能是以下值: 125 | - `Unable` : 不开启websocket连接 126 | - `Forward` : 正向websocket连接 127 | - `Backward` : 反向websocket连接 128 | 129 | ### `websocket_url` 130 | 连接地址 131 | - **类型:** `Set(URL)` 132 | - **默认值:** `["ws://127.0.0.1:8080/onebot/v12/ws/"]` 133 | 134 | 反向websocket连接时生效,反向 WebSocket 连接地址,需要以`ws://`或`wss://`开头,多个地址用`,`分隔。 135 | 136 | ### `reconnect_interval` 137 | 重连间隔 138 | - **类型:** `int` 139 | - **默认值:** `5000` 140 | 141 | 反向websocket连接时生效,反向 WebSocket 重连间隔,单位:毫秒,必须大于 0 142 | 143 | ### `websocket_buffer_size` 144 | 缓冲区大小 145 | - **类型** `int` 146 | - **默认值** `4` 147 | 148 | 反向websocket连接时生效,反向 WebSocket 缓冲区大小,单位:mb,必须大于 0 149 | 150 | ### `log_level` 151 | 日志等级 152 | - **类型:** `str` 153 | - **默认值:** `INFO` 154 | 155 | 一般为以下值: 156 | - `INFO` : 正常使用 157 | - `DEBUG` : debug下使用 158 | 159 | ### `log_days` 160 | 保存天数 161 | - **类型:** `int` 162 | - **默认值:** `10` 163 | 164 | 日志保存天数。 165 | 166 | ### `cache_days` 167 | 缓存天数 168 | - **类型:** `int` 169 | - **默认值:** `0` 170 | 171 | 临时文件缓存天数,为0则不清理缓存 172 | 173 | ## 使用 Nonebot2 174 | 本项目支持与 [Nonebot2](https://v2.nonebot.dev/) 进行通信,使用时请注意: 175 | 1. 建议使用反向websocket通信; 176 | 2. nonebot2需要安装onebot适配器,并使用12版本; 177 | 3. 需要安装插件补丁,注册拓展事件(还没写完); 178 | -------------------------------------------------------------------------------- /docs/message/README.md: -------------------------------------------------------------------------------- 1 | # 消息段 2 | 本项目目前实现了部分标准消息段及拓展消息段 3 | :::tip onebot12 4 | `消息段:` 表示聊天消息的一个部分,在一些平台上,聊天消息支持图文混排,其中就会有多个消息段,分别表示每个图片和每段文字。 5 | ::: 6 | 7 | ## 纯文本 8 | type: `text` 9 | 10 | 表示一段纯文本。 11 | - [x] 可以接收 12 | - [x] 可以发送 13 | 14 | ::: tabs 15 | @tab 参数 16 | | 字段名 | 数据类型 | 说明 | 17 | | :-------: | :------: | :--------: | 18 | | `text` | string | 纯文本内容 | 19 | 20 | @tab 示例 21 | ```json 22 | { 23 | "type": "text", 24 | "data": { 25 | "text": "这是一个纯文本" 26 | } 27 | } 28 | ``` 29 | 30 | @tab nb2使用 31 | ``` python 32 | from nonebot.adapters.onebot.v12 import MessageSegment 33 | 34 | message = MessageSegment.text("这是一个纯文本") 35 | ``` 36 | ::: 37 | ## 提及(即 @) 38 | type: `mention` 39 | 40 | 表示at某人。 41 | 42 | ::: warning 注意场景 43 | 44 | 此消息段只有在群聊时才可使用 45 | 46 | ::: 47 | 48 | - [x] 可以接收 49 | - [x] 可以发送 50 | 51 | ::: tabs 52 | 53 | @tab 参数 54 | 55 | | 字段名 | 数据类型 | 说明 | 56 | | :-------: | :------: | :----------: | 57 | | `user_id` | string | 提及用户的id | 58 | 59 | @tab 示例 60 | 61 | ```json 62 | { 63 | "type": "mention", 64 | "data": { 65 | "user_id": "1234567" 66 | } 67 | } 68 | ``` 69 | 70 | @tab nb2使用 71 | ```python 72 | from nonebot.adapters.onebot.v12 import MessageSegment 73 | 74 | message = MessageSegment.mention(user_id="123456") 75 | ``` 76 | 77 | ::: 78 | 79 | ## 提及所有人 80 | type: `mention_all` 81 | 82 | 表示at所有人。 83 | 84 | ::: danger 注意权限 85 | 86 | 没有at所有人权限,请不要尝试发送此消息段 87 | 88 | ::: 89 | 90 | - [x] 可以接收 91 | - [x] 可以发送 92 | 93 | ::: tabs 94 | 95 | @tab 参数 96 | 97 | 无。 98 | 99 | @tab 示例 100 | 101 | ```json 102 | { 103 | "type": "mention_all", 104 | "data": {} 105 | } 106 | ``` 107 | 108 | @tab nb2使用 109 | ```python 110 | from nonebot.adapters.onebot.v12 import MessageSegment 111 | 112 | message = MessageSegment.mention_all() 113 | ``` 114 | 115 | ::: 116 | 117 | ## 图片 118 | type:`image` 119 | 120 | 表示一张图片。 121 | 122 | - [x] 可以接收 123 | - [x] 可以发送 124 | 125 | ::: tabs 126 | 127 | @tab 参数 128 | 129 | | 字段名 | 数据类型 | 说明 | 130 | | :-------: | :------: | :---------: | 131 | | `file_id` | string | 图片文件 ID | 132 | 133 | @tab 示例 134 | 135 | ```json 136 | { 137 | "type": "image", 138 | "data": { 139 | "file_id": "e30f9684-3d54-4f65-b2da-db291a477f16" 140 | } 141 | } 142 | ``` 143 | 144 | @tab nb2使用 145 | ```python 146 | from nonebot.adapters.onebot.v12 import MessageSegment 147 | 148 | message = MessageSegment.image(file_id="e30f9684-3d54-4f65-b2da-db291a477f16") 149 | ``` 150 | 151 | ::: 152 | 153 | ## 语音 154 | type: `voice` 155 | 156 | 表示一段语音消息。 157 | 158 | - [x] 可以接收 159 | - [ ] 可以发送 160 | 161 | ::: tabs 162 | 163 | @tab 参数 164 | 165 | | 字段名 | 数据类型 | 说明 | 166 | | :-------: | :------: | :---------: | 167 | | `file_id` | string | 语音文件 ID | 168 | 169 | @tab 示例 170 | 171 | ```json 172 | { 173 | "type": "voice", 174 | "data": { 175 | "file_id": "e30f9684-3d54-4f65-b2da-db291a477f16" 176 | } 177 | } 178 | ``` 179 | 180 | 181 | 182 | ::: 183 | 184 | ## 音频 185 | type: `audio` 186 | 187 | 音频文件。 188 | 189 | ::: warning 未实现 190 | 191 | 本应用未实现此字段 192 | 193 | ::: 194 | 195 | ## 视频 196 | type: `video` 197 | 198 | 视频消息 199 | 200 | - [x] 可以接收 201 | - [ ] 可以发送 202 | 203 | :::tabs 204 | 205 | @tab 参数 206 | 207 | | 字段名 | 数据类型 | 说明 | 208 | | :-------: | :------: | :---------: | 209 | | `file_id` | string | 视频文件 ID | 210 | 211 | @tab 示例 212 | 213 | ```json 214 | { 215 | "type": "video", 216 | "data": { 217 | "file_id": "e30f9684-3d54-4f65-b2da-db291a477f16" 218 | } 219 | } 220 | ``` 221 | 222 | 223 | 224 | ::: 225 | 226 | ## 文件 227 | type: `file` 228 | 229 | 文件消息 230 | 231 | - [x] 可以接收 232 | - [x] 可以发送 233 | 234 | :::tabs 235 | 236 | @tab 参数 237 | 238 | | 字段名 | 数据类型 | 说明 | 239 | | :-------: | :------: | :---------: | 240 | | `file_id` | string | 文件 ID | 241 | 242 | @tab 示例 243 | 244 | ```json 245 | { 246 | "type": "file", 247 | "data": { 248 | "file_id": "e30f9684-3d54-4f65-b2da-db291a477f16" 249 | } 250 | } 251 | ``` 252 | 253 | @tab nb2使用 254 | ```python 255 | from nonebot.adapters.onebot.v12 import MessageSegment 256 | 257 | message = MessageSegment.file(file_id="e30f9684-3d54-4f65-b2da-db291a477f16") 258 | ``` 259 | 260 | ::: 261 | 262 | ## 位置 263 | type: `location` 264 | 265 | 位置消息。 266 | 267 | - [x] 可以接收 268 | - [ ] 可以发送 269 | 270 | ::: tabs 271 | 272 | @tab 参数 273 | 274 | | 字段名 | 数据类型 | 说明 | 275 | | :---------: | :------: | :------: | 276 | | `latitude` | float64 | 纬度 | 277 | | `longitude` | float64 | 经度 | 278 | | `title` | string | 标题 | 279 | | `content` | string | 地址内容 | 280 | 281 | @tab 示例 282 | 283 | ```json 284 | { 285 | "type": "location", 286 | "data": { 287 | "latitude": 31.032315, 288 | "longitude": 121.447127, 289 | "title": "上海交通大学闵行校区", 290 | "content": "中国上海市闵行区东川路800号" 291 | } 292 | } 293 | ``` 294 | 295 | ::: 296 | 297 | 298 | 299 | ## 回复 300 | type: `reply` 301 | 302 | 回复消息。 303 | 304 | :::info wechat 305 | 306 | 在微信里,为引用消息 307 | 308 | ::: 309 | 310 | - [x] 可以接收 311 | - [ ] 可以发送 312 | 313 | ::: tabs 314 | 315 | @tab 参数 316 | 317 | | 字段名 | 数据类型 | 说明 | 318 | | :----------: | :------: | :-----------------: | 319 | | `message_id` | string | 回复的消息 ID | 320 | | `user_id` | string | 回复的消息发送者 ID | 321 | 322 | @tab 示例 323 | 324 | ```json 325 | { 326 | "type": "reply", 327 | "data": { 328 | "message_id": "6283", 329 | "user_id": "1234567" 330 | } 331 | } 332 | ``` 333 | 334 | 335 | 336 | ::: 337 | 338 | ## 表情 339 | type: `wx.emoji` 340 | 341 | 表示表情消息 342 | 343 | ::: warning 与图片区别 344 | 在微信里,图片消息指直接发送图片;而表情为动态gif表情包等 345 | ::: 346 | - [x] 可以接收 347 | - [x] 可以发送 348 | ::: tabs 349 | 350 | @tab 参数 351 | 352 | | 字段名 | 数据类型 | 说明 | 353 | | :-------: | :------: | :---------: | 354 | | `file_id` | string | 图片文件 ID | 355 | 356 | @tab 示例 357 | 358 | ```json 359 | { 360 | "type": "wx.emoji", 361 | "data": { 362 | "file_id": "e30f9684-3d54-4f65-b2da-db291a477f16" 363 | } 364 | } 365 | ``` 366 | 367 | @tab nb2使用 368 | ```python 369 | from nonebot.adapters.onebot.v12 import MessageSegment 370 | 371 | message = MessageSegment("wx.emoji",{"file_id":"e30f9684-3d54-4f65-b2da-db291a477f16"}) 372 | ``` 373 | 374 | ::: 375 | 376 | ## 链接 377 | type: `wx.link` 378 | 379 | 文章链接消息 380 | - [x] 可以接收 381 | - [x] 可以发送 382 | ::: tabs 383 | 384 | @tab 参数 385 | 386 | | 字段名 | 数据类型 | 说明 | 387 | | :-------: | :------: | :---------: | 388 | | `title` | string | 文章标题 | 389 | | `des` | string | 消息卡片摘要 | 390 | | `url` | string | 文章链接 | 391 | | `file_id` | 可选,string | 消息图片id | 392 | 393 | @tab 示例 394 | 395 | ```json 396 | { 397 | "type": "wx.link", 398 | "data": { 399 | "title": "发一篇文章", 400 | "des": "你干嘛,哎哟", 401 | "url": "http://www.baidu.com", 402 | "file_id": "e30f9684-3d54-4f65-b2da-db291a477f16" 403 | } 404 | } 405 | ``` 406 | 407 | @tab nb2使用 408 | ```python 409 | from nonebot.adapters.onebot.v12 import MessageSegment 410 | 411 | message = MessageSegment("wx.link",{ 412 | "title": "发一篇文章", 413 | "des": "你干嘛,哎哟", 414 | "url": "http://www.baidu.com", 415 | "file_id": "e30f9684-3d54-4f65-b2da-db291a477f16" 416 | }) 417 | ``` 418 | 419 | ::: 420 | ## 小程序 421 | type: `wx.app` 422 | 423 | 小程序消息 424 | - [x] 可以接收 425 | - [ ] 可以发送 426 | 427 | ::: tabs 428 | 429 | @tab 参数 430 | 431 | | 字段名 | 数据类型 | 说明 | 432 | | :----------: | :------: | :-----------------: | 433 | | `appid` | string | 小程序id | 434 | | `title` | string | 消息标题 | 435 | | `url` | string | 链接地址 | 436 | 437 | @tab 示例 438 | 439 | ```json 440 | { 441 | "type": "wx.app", 442 | "data": { 443 | "appid": "abcd", 444 | "title": "肯德基疯狂星期四", 445 | "url": "http://www.baidu.com" 446 | } 447 | } 448 | ``` 449 | 450 | 451 | 452 | ::: 453 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import wechatbot_client 2 | 3 | wechatbot_client.init() 4 | 5 | wechatbot_client.load("wechatbot_client.startup") 6 | 7 | 8 | if __name__ == "__main__": 9 | wechatbot_client.run() 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "comwechatbotclient", 3 | "version": "1.0.0", 4 | "description": "

ComWeChat Client

", 5 | "main": "index.js", 6 | "directories": { 7 | "doc": "docs" 8 | }, 9 | "scripts": { 10 | "docs:dev": "vuepress dev docs", 11 | "docs:build": "vuepress build docs" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/JustUndertaker/ComWeChatBotClient.git" 16 | }, 17 | "author": "", 18 | "license": "AGPLv3", 19 | "bugs": { 20 | "url": "https://github.com/JustUndertaker/ComWeChatBotClient/issues" 21 | }, 22 | "homepage": "https://github.com/JustUndertaker/ComWeChatBotClient#readme", 23 | "devDependencies": { 24 | "@vuepress/plugin-search": "^2.0.0-beta.61", 25 | "@vuepress/plugin-shiki": "^2.0.0-beta.61", 26 | "vuepress": "^2.0.0-beta.61", 27 | "vuepress-plugin-md-enhance": "^2.0.0-beta.193", 28 | "vuepress-plugin-search-pro": "^2.0.0-beta.193", 29 | "vuepress-theme-hope": "^2.0.0-beta.193" 30 | }, 31 | "dependencies": { 32 | "abortcontroller-polyfill": "^1.7.5", 33 | "acorn": "^8.8.2", 34 | "ajv": "^8.12.0", 35 | "ansi-regex": "^6.0.1", 36 | "ansi-styles": "^4.3.0", 37 | "anymatch": "^3.1.3", 38 | "arg": "^5.0.2", 39 | "argparse": "^1.0.10", 40 | "array-buffer-byte-length": "^1.0.0", 41 | "artalk": "^2.5.1", 42 | "artplayer": "^4.6.2", 43 | "assignment": "^2.0.0", 44 | "async": "^3.2.4", 45 | "at-least-node": "^1.0.0", 46 | "autoprefixer": "^10.4.14", 47 | "autosize": "^6.0.1", 48 | "available-typed-arrays": "^1.0.5", 49 | "babel-plugin-polyfill-corejs2": "^0.3.3", 50 | "babel-plugin-polyfill-corejs3": "^0.6.0", 51 | "babel-plugin-polyfill-regenerator": "^0.4.1", 52 | "balanced-match": "^1.0.2", 53 | "balloon-css": "^1.2.0", 54 | "base64-js": "^1.5.1", 55 | "bcp-47": "^1.0.8", 56 | "bcp-47-match": "^1.0.3", 57 | "bcp-47-normalize": "^1.1.1", 58 | "bcrypt-ts": "^3.0.0", 59 | "binary-extensions": "^2.2.0", 60 | "bl": "^5.1.0", 61 | "boolbase": "^1.0.0", 62 | "brace-expansion": "^1.1.11", 63 | "braces": "^3.0.2", 64 | "browserslist": "^4.21.5", 65 | "buffer": "^6.0.3", 66 | "buffer-from": "^1.1.2", 67 | "builtin-modules": "^3.3.0", 68 | "cac": "^6.7.14", 69 | "call-bind": "^1.0.2", 70 | "camelcase": "^5.3.1", 71 | "caniuse-lite": "^1.0.30001466", 72 | "chalk": "^5.2.0", 73 | "chart.js": "^4.2.1", 74 | "cheerio": "^1.0.0-rc.12", 75 | "cheerio-select": "^2.1.0", 76 | "chokidar": "^3.5.3", 77 | "cli-cursor": "^4.0.0", 78 | "cli-spinners": "^2.7.0", 79 | "cliui": "^6.0.0", 80 | "clone": "^1.0.4", 81 | "codem-isoboxer": "^0.3.6", 82 | "color-convert": "^2.0.1", 83 | "color-name": "^1.1.4", 84 | "commander": "^8.3.0", 85 | "comment-regex": "^1.0.1", 86 | "common-tags": "^1.8.2", 87 | "concat-map": "^0.0.1", 88 | "connect-history-api-fallback": "^2.0.0", 89 | "convert-source-map": "^1.9.0", 90 | "core-js": "^3.29.1", 91 | "core-js-compat": "^3.29.1", 92 | "cose-base": "^1.0.3", 93 | "cross-spawn": "^7.0.3", 94 | "crypto-random-string": "^2.0.0", 95 | "css-select": "^5.1.0", 96 | "css-what": "^6.1.0", 97 | "csstype": "^2.6.21", 98 | "custom-event-polyfill": "^1.0.7", 99 | "cytoscape": "^3.23.0", 100 | "cytoscape-cose-bilkent": "^4.1.0", 101 | "cytoscape-fcose": "^2.2.0", 102 | "d3": "^7.8.2", 103 | "d3-array": "^3.2.2", 104 | "d3-axis": "^3.0.0", 105 | "d3-brush": "^3.0.0", 106 | "d3-chord": "^3.0.1", 107 | "d3-color": "^3.1.0", 108 | "d3-contour": "^4.0.2", 109 | "d3-delaunay": "^6.0.2", 110 | "d3-dispatch": "^3.0.1", 111 | "d3-drag": "^3.0.0", 112 | "d3-dsv": "^3.0.1", 113 | "d3-ease": "^3.0.1", 114 | "d3-fetch": "^3.0.1", 115 | "d3-force": "^3.0.0", 116 | "d3-format": "^3.1.0", 117 | "d3-geo": "^3.1.0", 118 | "d3-hierarchy": "^3.1.2", 119 | "d3-interpolate": "^3.0.1", 120 | "d3-path": "^3.1.0", 121 | "d3-polygon": "^3.0.1", 122 | "d3-quadtree": "^3.0.1", 123 | "d3-random": "^3.0.1", 124 | "d3-scale": "^4.0.2", 125 | "d3-scale-chromatic": "^3.0.0", 126 | "d3-selection": "^3.0.0", 127 | "d3-shape": "^3.2.0", 128 | "d3-time": "^3.1.0", 129 | "d3-time-format": "^4.1.0", 130 | "d3-timer": "^3.0.1", 131 | "d3-transition": "^3.0.1", 132 | "d3-zoom": "^3.0.0", 133 | "dagre-d3-es": "^7.0.9", 134 | "dashjs": "^4.6.0", 135 | "dayjs": "^1.11.7", 136 | "debug": "^4.3.4", 137 | "decamelize": "^1.2.0", 138 | "deepmerge": "^4.3.0", 139 | "defaults": "^1.0.4", 140 | "define-properties": "^1.2.0", 141 | "delaunator": "^5.0.0", 142 | "dijkstrajs": "^1.0.2", 143 | "dir-glob": "^3.0.1", 144 | "dom-serializer": "^2.0.0", 145 | "domelementtype": "^2.3.0", 146 | "domhandler": "^5.0.3", 147 | "dompurify": "^2.4.3", 148 | "domutils": "^3.0.1", 149 | "echarts": "^5.4.1", 150 | "ejs": "^3.1.9", 151 | "electron-to-chromium": "^1.4.331", 152 | "elkjs": "^0.8.2", 153 | "emoji-regex": "^8.0.0", 154 | "encode-utf8": "^1.0.3", 155 | "entities": "^3.0.1", 156 | "envinfo": "^7.8.1", 157 | "es-abstract": "^1.21.2", 158 | "es-set-tostringtag": "^2.0.1", 159 | "es-to-primitive": "^1.2.1", 160 | "es6-promise": "^4.2.8", 161 | "esbuild": "^0.16.17", 162 | "escalade": "^3.1.1", 163 | "escape-string-regexp": "^1.0.5", 164 | "esm": "^3.2.25", 165 | "esprima": "^4.0.1", 166 | "estree-walker": "^2.0.2", 167 | "esutils": "^2.0.3", 168 | "eve-raphael": "^0.5.0", 169 | "execa": "^7.1.1", 170 | "extend-shallow": "^2.0.1", 171 | "fast-deep-equal": "^2.0.1", 172 | "fast-glob": "^3.2.12", 173 | "fast-json-stable-stringify": "^2.1.0", 174 | "fastq": "^1.15.0", 175 | "fflate": "^0.7.4", 176 | "filelist": "^1.0.4", 177 | "fill-range": "^7.0.1", 178 | "find-up": "^4.1.0", 179 | "flowchart.ts": "^0.1.2", 180 | "for-each": "^0.3.3", 181 | "fraction.js": "^4.2.0", 182 | "fs-extra": "^11.1.0", 183 | "fs.realpath": "^1.0.0", 184 | "function-bind": "^1.1.1", 185 | "function.prototype.name": "^1.1.5", 186 | "functions-have-names": "^1.2.3", 187 | "gensync": "^1.0.0-beta.2", 188 | "get-caller-file": "^2.0.5", 189 | "get-intrinsic": "^1.2.0", 190 | "get-own-enumerable-property-symbols": "^3.0.2", 191 | "get-stream": "^6.0.1", 192 | "get-symbol-description": "^1.0.0", 193 | "giscus": "^1.2.8", 194 | "glob": "^7.2.3", 195 | "glob-parent": "^5.1.2", 196 | "globals": "^11.12.0", 197 | "globalthis": "^1.0.3", 198 | "globby": "^13.1.3", 199 | "gopd": "^1.0.1", 200 | "graceful-fs": "^4.2.10", 201 | "gray-matter": "^4.0.3", 202 | "hanabi": "^0.4.0", 203 | "has": "^1.0.3", 204 | "has-bigints": "^1.0.2", 205 | "has-flag": "^3.0.0", 206 | "has-property-descriptors": "^1.0.0", 207 | "has-proto": "^1.0.1", 208 | "has-symbols": "^1.0.3", 209 | "has-tostringtag": "^1.0.0", 210 | "hash-sum": "^2.0.0", 211 | "he": "^0.5.0", 212 | "heap": "^0.2.7", 213 | "hls.js": "^1.3.4", 214 | "html-entities": "^1.4.0", 215 | "htmlparser2": "^8.0.1", 216 | "human-signals": "^4.3.0", 217 | "iconv-lite": "^0.6.3", 218 | "idb": "^7.1.1", 219 | "ieee754": "^1.2.1", 220 | "ignore": "^5.2.4", 221 | "immediate": "^3.0.6", 222 | "immutable": "^4.3.0", 223 | "imsc": "^1.1.3", 224 | "inflight": "^1.0.6", 225 | "inherits": "^2.0.4", 226 | "insane": "^2.6.2", 227 | "internal-slot": "^1.0.5", 228 | "internmap": "^2.0.3", 229 | "is-alphabetical": "^1.0.4", 230 | "is-alphanumerical": "^1.0.4", 231 | "is-array-buffer": "^3.0.2", 232 | "is-bigint": "^1.0.4", 233 | "is-binary-path": "^2.1.0", 234 | "is-boolean-object": "^1.1.2", 235 | "is-callable": "^1.2.7", 236 | "is-core-module": "^2.11.0", 237 | "is-date-object": "^1.0.5", 238 | "is-decimal": "^1.0.4", 239 | "is-extendable": "^0.1.1", 240 | "is-extglob": "^2.1.1", 241 | "is-fullwidth-code-point": "^3.0.0", 242 | "is-glob": "^4.0.3", 243 | "is-interactive": "^2.0.0", 244 | "is-module": "^1.0.0", 245 | "is-negative-zero": "^2.0.2", 246 | "is-number": "^7.0.0", 247 | "is-number-object": "^1.0.7", 248 | "is-obj": "^1.0.1", 249 | "is-regex": "^1.1.4", 250 | "is-regexp": "^1.0.0", 251 | "is-shared-array-buffer": "^1.0.2", 252 | "is-stream": "^3.0.0", 253 | "is-string": "^1.0.7", 254 | "is-symbol": "^1.0.4", 255 | "is-typed-array": "^1.1.10", 256 | "is-unicode-supported": "^1.3.0", 257 | "is-weakref": "^1.0.2", 258 | "isexe": "^2.0.0", 259 | "jake": "^10.8.5", 260 | "jest-worker": "^26.6.2", 261 | "js-tokens": "^4.0.0", 262 | "js-yaml": "^3.14.1", 263 | "jsesc": "^2.5.2", 264 | "json-schema": "^0.4.0", 265 | "json-schema-traverse": "^1.0.0", 266 | "json5": "^2.2.3", 267 | "jsonfile": "^6.1.0", 268 | "jsonpointer": "^5.0.1", 269 | "katex": "^0.16.4", 270 | "khroma": "^2.0.0", 271 | "kind-of": "^6.0.3", 272 | "layout-base": "^1.0.2", 273 | "leven": "^3.1.0", 274 | "lie": "^3.1.1", 275 | "lilconfig": "^2.1.0", 276 | "linkify-it": "^4.0.1", 277 | "lit": "^2.6.1", 278 | "lit-element": "^3.2.2", 279 | "lit-html": "^2.6.1", 280 | "loadjs": "^4.2.0", 281 | "localforage": "^1.10.0", 282 | "locate-path": "^5.0.0", 283 | "lodash": "^4.17.21", 284 | "lodash-es": "^4.17.21", 285 | "lodash.debounce": "^4.0.8", 286 | "lodash.sortby": "^4.7.0", 287 | "log-symbols": "^5.1.0", 288 | "lru-cache": "^5.1.1", 289 | "magic-string": "^0.25.9", 290 | "markdown-it": "^13.0.1", 291 | "markdown-it-anchor": "^8.6.7", 292 | "markdown-it-container": "^3.0.0", 293 | "markdown-it-emoji": "^2.0.2", 294 | "marked": "^4.2.12", 295 | "mathjax-full": "^3.2.2", 296 | "mdurl": "^1.0.1", 297 | "medium-zoom": "^1.0.8", 298 | "merge-stream": "^2.0.0", 299 | "merge2": "^1.4.1", 300 | "mermaid": "^10.0.2", 301 | "mhchemparser": "^4.1.1", 302 | "micromatch": "^4.0.5", 303 | "mimic-fn": "^4.0.0", 304 | "minimatch": "^3.1.2", 305 | "mitt": "^3.0.0", 306 | "mj-context-menu": "^0.6.1", 307 | "mpegts.js": "^1.7.2", 308 | "ms": "^2.1.2", 309 | "nanoid": "^3.3.4", 310 | "node-releases": "^2.0.10", 311 | "non-layered-tidy-tree-layout": "^2.0.2", 312 | "normalize-path": "^3.0.0", 313 | "normalize-range": "^0.1.2", 314 | "npm-run-path": "^5.1.0", 315 | "nth-check": "^2.1.1", 316 | "object-inspect": "^1.12.3", 317 | "object-keys": "^1.1.1", 318 | "object.assign": "^4.1.4", 319 | "once": "^1.4.0", 320 | "onetime": "^6.0.0", 321 | "option-validator": "^2.0.6", 322 | "ora": "^6.1.2", 323 | "p-limit": "^2.3.0", 324 | "p-locate": "^4.1.0", 325 | "p-try": "^2.2.0", 326 | "parse5": "^7.1.2", 327 | "parse5-htmlparser2-tree-adapter": "^7.0.0", 328 | "path-browserify": "^1.0.1", 329 | "path-exists": "^4.0.0", 330 | "path-is-absolute": "^1.0.1", 331 | "path-key": "^3.1.1", 332 | "path-parse": "^1.0.7", 333 | "path-type": "^4.0.0", 334 | "photoswipe": "^5.3.6", 335 | "picocolors": "^1.0.0", 336 | "picomatch": "^2.3.1", 337 | "plyr": "^3.7.7", 338 | "pngjs": "^5.0.0", 339 | "postcss": "^8.4.21", 340 | "postcss-load-config": "^4.0.1", 341 | "postcss-value-parser": "^4.2.0", 342 | "pretty-bytes": "^5.6.0", 343 | "prismjs": "^1.29.0", 344 | "punycode": "^2.3.0", 345 | "qrcode": "^1.5.1", 346 | "queue-microtask": "^1.2.3", 347 | "randombytes": "^2.1.0", 348 | "rangetouch": "^2.0.1", 349 | "raphael": "^2.3.0", 350 | "readable-stream": "^3.6.2", 351 | "readdirp": "^3.6.0", 352 | "regenerate": "^1.4.2", 353 | "regenerate-unicode-properties": "^10.1.0", 354 | "regenerator-runtime": "^0.13.11", 355 | "regenerator-transform": "^0.15.1", 356 | "regexp.prototype.flags": "^1.4.3", 357 | "regexpu-core": "^5.3.2", 358 | "register-service-worker": "^1.7.2", 359 | "regjsparser": "^0.9.1", 360 | "require-directory": "^2.1.1", 361 | "require-from-string": "^2.0.2", 362 | "require-main-filename": "^2.0.0", 363 | "resolve": "^1.22.1", 364 | "restore-cursor": "^4.0.0", 365 | "reusify": "^1.0.4", 366 | "reveal.js": "^4.4.0", 367 | "robust-predicates": "^3.0.1", 368 | "rollup": "^3.19.1", 369 | "run-parallel": "^1.2.0", 370 | "rw": "^1.3.3", 371 | "safe-buffer": "^5.2.1", 372 | "safe-regex-test": "^1.0.0", 373 | "safer-buffer": "^2.1.2", 374 | "sass": "^1.59.3", 375 | "sax": "^1.2.1", 376 | "section-matter": "^1.0.0", 377 | "semver": "^6.3.0", 378 | "serialize-javascript": "^4.0.0", 379 | "set-blocking": "^2.0.0", 380 | "shebang-command": "^2.0.0", 381 | "shebang-regex": "^3.0.0", 382 | "side-channel": "^1.0.4", 383 | "signal-exit": "^3.0.7", 384 | "sitemap": "^7.1.1", 385 | "slash": "^4.0.0", 386 | "source-map": "^0.6.1", 387 | "source-map-js": "^1.0.2", 388 | "source-map-support": "^0.5.21", 389 | "sourcemap-codec": "^1.4.8", 390 | "speech-rule-engine": "^4.0.7", 391 | "sprintf-js": "^1.0.3", 392 | "string_decoder": "^1.3.0", 393 | "string-width": "^4.2.3", 394 | "string.prototype.matchall": "^4.0.8", 395 | "string.prototype.trim": "^1.2.7", 396 | "string.prototype.trimend": "^1.0.6", 397 | "string.prototype.trimstart": "^1.0.6", 398 | "stringify-object": "^3.3.0", 399 | "strip-ansi": "^7.0.1", 400 | "strip-bom-string": "^1.0.0", 401 | "strip-comments": "^2.0.1", 402 | "strip-final-newline": "^3.0.0", 403 | "striptags": "^3.2.0", 404 | "stylis": "^4.1.3", 405 | "supports-color": "^5.5.0", 406 | "supports-preserve-symlinks-flag": "^1.0.0", 407 | "temp-dir": "^2.0.0", 408 | "tempy": "^0.6.0", 409 | "terser": "^5.16.6", 410 | "to-fast-properties": "^2.0.0", 411 | "to-regex-range": "^5.0.1", 412 | "tr46": "^1.0.1", 413 | "ts-debounce": "^4.0.0", 414 | "ts-dedent": "^2.2.0", 415 | "tslib": "^2.3.0", 416 | "twikoo": "^1.6.10", 417 | "type-fest": "^0.16.0", 418 | "typed-array-length": "^1.0.4", 419 | "ua-parser-js": "^1.0.34", 420 | "uc.micro": "^1.0.6", 421 | "unbox-primitive": "^1.0.2", 422 | "unicode-canonical-property-names-ecmascript": "^2.0.0", 423 | "unicode-match-property-ecmascript": "^2.0.0", 424 | "unicode-match-property-value-ecmascript": "^2.1.0", 425 | "unicode-property-aliases-ecmascript": "^2.1.0", 426 | "unique-string": "^2.0.0", 427 | "universalify": "^2.0.0", 428 | "upath": "^2.0.1", 429 | "update-browserslist-db": "^1.0.10", 430 | "uri-js": "^4.4.1", 431 | "url-polyfill": "^1.1.12", 432 | "util-deprecate": "^1.0.2", 433 | "uuid": "^9.0.0", 434 | "vite": "^4.1.4", 435 | "vue": "^3.2.47", 436 | "vue-router": "^4.1.6", 437 | "vuepress-plugin-auto-catalog": "^2.0.0-beta.193", 438 | "vuepress-plugin-blog2": "^2.0.0-beta.193", 439 | "vuepress-plugin-comment2": "^2.0.0-beta.193", 440 | "vuepress-plugin-components": "^2.0.0-beta.193", 441 | "vuepress-plugin-copy-code2": "^2.0.0-beta.193", 442 | "vuepress-plugin-copyright2": "^2.0.0-beta.193", 443 | "vuepress-plugin-feed2": "^2.0.0-beta.193", 444 | "vuepress-plugin-photo-swipe": "^2.0.0-beta.193", 445 | "vuepress-plugin-pwa2": "^2.0.0-beta.193", 446 | "vuepress-plugin-reading-time2": "^2.0.0-beta.193", 447 | "vuepress-plugin-rtl": "^2.0.0-beta.193", 448 | "vuepress-plugin-sass-palette": "^2.0.0-beta.193", 449 | "vuepress-plugin-seo2": "^2.0.0-beta.193", 450 | "vuepress-plugin-sitemap2": "^2.0.0-beta.193", 451 | "vuepress-shared": "^2.0.0-beta.193", 452 | "vuepress-vite": "^2.0.0-beta.61", 453 | "wcwidth": "^1.0.1", 454 | "web-worker": "^1.2.0", 455 | "webidl-conversions": "^4.0.2", 456 | "webworkify-webpack": "^2.1.5", 457 | "whatwg-url": "^7.1.0", 458 | "which": "^2.0.2", 459 | "which-boxed-primitive": "^1.0.2", 460 | "which-module": "^2.0.0", 461 | "which-typed-array": "^1.1.9", 462 | "wicked-good-xpath": "^1.3.0", 463 | "workbox-background-sync": "^6.5.4", 464 | "workbox-broadcast-update": "^6.5.4", 465 | "workbox-build": "^6.5.4", 466 | "workbox-cacheable-response": "^6.5.4", 467 | "workbox-core": "^6.5.4", 468 | "workbox-expiration": "^6.5.4", 469 | "workbox-google-analytics": "^6.5.4", 470 | "workbox-navigation-preload": "^6.5.4", 471 | "workbox-precaching": "^6.5.4", 472 | "workbox-range-requests": "^6.5.4", 473 | "workbox-recipes": "^6.5.4", 474 | "workbox-routing": "^6.5.4", 475 | "workbox-strategies": "^6.5.4", 476 | "workbox-streams": "^6.5.4", 477 | "workbox-sw": "^6.5.4", 478 | "workbox-window": "^6.5.4", 479 | "wrap-ansi": "^6.2.0", 480 | "wrappy": "^1.0.2", 481 | "xml-js": "^1.6.11", 482 | "xmldom-sre": "^0.1.31", 483 | "y18n": "^4.0.3", 484 | "yallist": "^3.1.1", 485 | "yaml": "^2.2.1", 486 | "yargs": "^15.4.1", 487 | "yargs-parser": "^18.1.3", 488 | "zrender": "^5.4.1" 489 | } 490 | } 491 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiosqlite==0.17.0 2 | anyio==3.6.2 3 | APScheduler==3.10.1 4 | certifi==2022.12.7 5 | click==8.1.3 6 | colorama==0.4.6 7 | comtypes==1.1.14 8 | fastapi==0.93.0 9 | flake8==6.0.0 10 | h11==0.14.0 11 | httpcore==0.16.3 12 | httpx==0.23.3 13 | idna==3.4 14 | iso8601==1.1.0 15 | loguru==0.6.0 16 | mccabe==0.7.0 17 | msgpack==1.0.5 18 | multidict==6.0.4 19 | pathspec==0.10.3 20 | pip==23.0.1 21 | platformdirs==2.5.2 22 | psutil==5.9.4 23 | pycodestyle==2.10.0 24 | pydantic==1.10.6 25 | pyflakes==3.0.1 26 | pypika-tortoise==0.1.6 27 | python-dotenv==1.0.0 28 | pytz==2022.7.1 29 | pytz-deprecation-shim==0.1.0.post0 30 | rfc3986==1.5.0 31 | setuptools==65.6.3 32 | six==1.16.0 33 | sniffio==1.3.0 34 | starlette==0.25.0 35 | tomli==2.0.1 36 | tortoise-orm==0.19.3 37 | typing_extensions==4.5.0 38 | tzdata==2022.7 39 | tzlocal==4.2 40 | uvicorn==0.20.0 41 | websockets==10.4 42 | wheel==0.38.4 43 | win32-setctime==1.1.0 44 | wincertstore==0.2 45 | yarl==1.8.2 46 | -------------------------------------------------------------------------------- /wechatbot_client/__init__.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | from fastapi import FastAPI 4 | 5 | from wechatbot_client.config import Config, Env 6 | from wechatbot_client.driver import Driver 7 | from wechatbot_client.log import default_filter, log_init, logger 8 | from wechatbot_client.wechat import WeChatManager 9 | 10 | _WeChat: WeChatManager = None 11 | """微信管理器""" 12 | 13 | 14 | def init() -> None: 15 | """ 16 | 初始化client 17 | """ 18 | global _WeChat 19 | env = Env() 20 | config = Config(_common_config=env.dict()) 21 | default_filter.level = config.log_level 22 | log_init(config.log_days) 23 | logger.info(f"Current Env: {env.environment}") 24 | logger.debug(f"Loaded Config: {str(config.dict())}") 25 | 26 | _WeChat = WeChatManager(config) 27 | _WeChat.init() 28 | 29 | 30 | def run() -> None: 31 | """ 32 | 启动 33 | """ 34 | driver = get_driver() 35 | driver.run() 36 | 37 | 38 | def get_driver() -> Driver: 39 | """ 40 | 获取后端驱动器 41 | """ 42 | wechat = get_wechat() 43 | return wechat.driver 44 | 45 | 46 | def get_wechat() -> WeChatManager: 47 | """ 48 | 获取wechat管理器 49 | """ 50 | if _WeChat is None: 51 | raise ValueError("wechat管理端尚未初始化...") 52 | return _WeChat 53 | 54 | 55 | def get_app() -> FastAPI: 56 | """获取 Server App 对象。 57 | 58 | 返回: 59 | Server App 对象 60 | 61 | 异常: 62 | ValueError: 全局 `Driver` 对象尚未初始化 (`wechatferry_client.init` 尚未调用) 63 | """ 64 | driver = get_driver() 65 | return driver.server_app 66 | 67 | 68 | def load(name: str) -> None: 69 | """ 70 | 加载指定的模块 71 | """ 72 | importlib.import_module(name) 73 | logger.success(f"加载[{name}]成功...") 74 | -------------------------------------------------------------------------------- /wechatbot_client/action_manager/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | action的实现,同时与com进行交互 3 | """ 4 | from .check import check_action_params as check_action_params 5 | from .check import expand_action as expand_action 6 | from .check import standard_action as standard_action 7 | from .file_router import router as router 8 | from .manager import ActionManager as ActionManager 9 | from .model import ActionRequest as ActionRequest 10 | from .model import ActionResponse as ActionResponse 11 | from .model import WsActionRequest as WsActionRequest 12 | from .model import WsActionResponse as WsActionResponse 13 | -------------------------------------------------------------------------------- /wechatbot_client/action_manager/check.py: -------------------------------------------------------------------------------- 1 | """ 2 | 用来检测action参数的模块 3 | """ 4 | 5 | from inspect import Parameter 6 | from typing import Callable, ParamSpec, Type, TypeVar 7 | 8 | from pydantic import BaseConfig, BaseModel, Extra, ValidationError, create_model 9 | 10 | from wechatbot_client.consts import PREFIX 11 | from wechatbot_client.utils import get_typed_signature, logger_wrapper 12 | 13 | from .model import ActionRequest 14 | 15 | ACTION_DICT: dict[str, Type[BaseModel]] = {} 16 | """标准action模型字典""" 17 | 18 | P = ParamSpec("P") 19 | R = TypeVar("R") 20 | log = logger_wrapper("Action Manager") 21 | 22 | 23 | class ModelConfig(BaseConfig): 24 | """action模型config""" 25 | 26 | extra = Extra.forbid 27 | 28 | 29 | def check_action_params(request: ActionRequest) -> tuple[str, BaseModel]: 30 | """ 31 | 说明: 32 | 检测action的参数合法性,会检测`action`是否存在,同时param类型是否符合 33 | 34 | 参数: 35 | * `request`:action请求 36 | 37 | 返回: 38 | * `str`: action的函数名 39 | * `BaseModel`: action的参数模型 40 | 41 | 错误: 42 | * `TypeError`: 未实现action 43 | * `ValueError`: 参数错误 44 | """ 45 | if request.params is None: 46 | request.params = {} 47 | 48 | action_model = ACTION_DICT.get(request.action) 49 | if action_model is None: 50 | log("ERROR", f"未实现的action:{request.action}") 51 | raise TypeError(f"未实现的action:{request.action}") 52 | 53 | try: 54 | model = action_model.parse_obj(request.params) 55 | except ValidationError as e: 56 | log("ERROR", f"action参数错误:{e}") 57 | raise ValueError("请求参数错误...") 58 | return action_model.__name__, model 59 | 60 | 61 | def standard_action(func: Callable[P, R]) -> Callable[P, R]: 62 | """ 63 | 说明: 64 | 使用此装饰器表示将此函数加入标准action字典中,会生成验证模型,注意参数的类型标注 65 | """ 66 | 67 | global ACTION_DICT 68 | signature = get_typed_signature(func) 69 | field = {} 70 | for parameter in signature.parameters.values(): 71 | name = parameter.name 72 | annotation = parameter.annotation 73 | default = parameter.default 74 | if name != "self": 75 | if default == Parameter.empty: 76 | field[name] = (annotation, ...) 77 | else: 78 | field[name] = (annotation, default) 79 | action_type = create_model(func.__name__, __config__=ModelConfig, **field) 80 | ACTION_DICT[func.__name__] = action_type 81 | return func 82 | 83 | 84 | def expand_action(func: Callable[P, R]) -> Callable[P, R]: 85 | """ 86 | 说明: 87 | 使用此装饰器表示将此函数加入拓展action字典中,会生成验证模型,注意参数的类型标注 88 | 拓展action会使用.action 89 | """ 90 | 91 | global ACTION_DICT 92 | signature = get_typed_signature(func) 93 | field = {} 94 | for parameter in signature.parameters.values(): 95 | name = parameter.name 96 | annotation = parameter.annotation 97 | default = parameter.default 98 | if name != "self": 99 | if default == Parameter.empty: 100 | field[name] = (annotation, ...) 101 | else: 102 | field[name] = (annotation, default) 103 | action_type = create_model(func.__name__, __config__=ModelConfig, **field) 104 | action_name = f"{PREFIX}.{func.__name__}" 105 | ACTION_DICT[action_name] = action_type 106 | return func 107 | 108 | 109 | def get_supported_actions() -> list[str]: 110 | """ 111 | 获取支持的动作列表 112 | """ 113 | return list(ACTION_DICT.keys()) 114 | -------------------------------------------------------------------------------- /wechatbot_client/action_manager/file_router.py: -------------------------------------------------------------------------------- 1 | """ 2 | 文件路由器,此文件实现了`get_file`的通过`url`获取文件的功能 3 | """ 4 | from fastapi import APIRouter 5 | from fastapi.responses import FileResponse, Response 6 | 7 | from wechatbot_client.file_manager import FileCache 8 | 9 | router = APIRouter() 10 | 11 | 12 | @router.get("/get_file/{file_id}") 13 | async def get_file_get(file_id: str) -> FileResponse: 14 | """ 15 | 通过url获取文件 16 | """ 17 | file_path, file_name = await FileCache.get_file(file_id) 18 | if file_path is None: 19 | return Response(status_code=404, content="File not found") 20 | return FileResponse(path=file_path, filename=file_name) 21 | 22 | 23 | @router.post("/get_file/{file_id}") 24 | async def get_file_post(file_id: str) -> FileResponse: 25 | """ 26 | 通过url获取文件 27 | """ 28 | file_path, file_name = await FileCache.get_file(file_id) 29 | if file_path is None: 30 | return Response(status_code=404, content="File not found") 31 | return FileResponse(path=file_path, filename=file_name) 32 | -------------------------------------------------------------------------------- /wechatbot_client/action_manager/model.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Literal, Optional 2 | 3 | from pydantic import BaseModel, Extra 4 | 5 | from wechatbot_client.consts import PLATFORM 6 | 7 | 8 | class BotSelf(BaseModel): 9 | """机器人自身""" 10 | 11 | platform: str = PLATFORM 12 | """消息平台""" 13 | user_id: str 14 | """机器人用户 ID""" 15 | 16 | 17 | class ActionRequest(BaseModel, extra=Extra.forbid): 18 | """动作请求""" 19 | 20 | action: str 21 | """请求方法""" 22 | params: dict 23 | """请求参数""" 24 | self: Optional[BotSelf] 25 | """self参数""" 26 | 27 | 28 | class WsActionRequest(ActionRequest): 29 | """ws请求""" 30 | 31 | echo: str 32 | """ws请求echo""" 33 | 34 | 35 | class ActionResponse(BaseModel, extra=Extra.forbid): 36 | """action回复""" 37 | 38 | status: Literal["ok", "failed"] 39 | """执行状态(成功与否)""" 40 | retcode: int 41 | """返回码""" 42 | data: Any 43 | """响应数据""" 44 | message: str = "" 45 | """错误信息""" 46 | 47 | 48 | class WsActionResponse(ActionResponse): 49 | """ws的response""" 50 | 51 | echo: str 52 | """echo值""" 53 | -------------------------------------------------------------------------------- /wechatbot_client/com_wechat/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 与微信通信底层 3 | 注入步骤: 4 | 创建com对象 -> 检测微信进程 -> 注入 5 | 创建com对象 -> 未检测到微信进程 -> 创建微信进程 -> 注入 6 | 7 | 调用消息: 8 | http/ws -> wechat -> comprocess -> com 9 | 10 | 上报消息: 11 | com -> msgreporter -> wechat -> http/ws 12 | """ 13 | 14 | from .com_wechat import ComWechatApi as ComWechatApi 15 | from .message import MessageHandler as MessageHandler 16 | from .model import Message as Message 17 | -------------------------------------------------------------------------------- /wechatbot_client/com_wechat/model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from pydantic import BaseModel 5 | 6 | from .type import WxType 7 | 8 | 9 | class Message(BaseModel): 10 | """接收的消息""" 11 | 12 | extrainfo: str 13 | """额外信息""" 14 | filepath: Optional[str] 15 | """文件路径""" 16 | isSendByPhone: Optional[bool] 17 | """是否为手机发送,自己手动操作机器人发送时有效""" 18 | isSendMsg: bool 19 | """是否为自身发送""" 20 | message: str 21 | """消息内容""" 22 | msgid: int 23 | """消息id""" 24 | pid: int 25 | """进程pid""" 26 | self: str 27 | """自身id""" 28 | sender: str 29 | """发送方id""" 30 | sign: str 31 | """sign值""" 32 | thumb_path: str 33 | """缩略图位置""" 34 | time: datetime 35 | """发送时间""" 36 | timestamp: int 37 | """时间戳""" 38 | type: WxType 39 | """消息类型""" 40 | wxid: str 41 | """wxid""" 42 | -------------------------------------------------------------------------------- /wechatbot_client/com_wechat/type.py: -------------------------------------------------------------------------------- 1 | """ 2 | 消息tpye分类 3 | """ 4 | from enum import Enum, IntEnum 5 | 6 | 7 | class WxType(IntEnum): 8 | """ 9 | 微信消息type 10 | """ 11 | 12 | TEXT_MSG = 1 13 | """文本消息""" 14 | IMAGE_MSG = 3 15 | """图片消息""" 16 | VOICE_MSG = 34 17 | """语音消息""" 18 | FRIEND_REQUEST = 37 19 | """加好友请求""" 20 | CARD_MSG = 42 21 | """名片消息""" 22 | VIDEO_MSG = 43 23 | """视频消息""" 24 | EMOJI_MSG = 47 25 | """表情消息""" 26 | LOCATION_MSG = 48 27 | """位置消息""" 28 | APP_MSG = 49 29 | """应用消息""" 30 | CREATE_ROOM = 51 31 | """创建房间""" 32 | SYSTEM_NOTICE = 10000 33 | """个人系统消息""" 34 | SYSTEM_MSG = 10002 35 | """群系统消息""" 36 | 37 | 38 | class AppType(IntEnum): 39 | """ 40 | App消息类型 41 | """ 42 | 43 | APP_LINK = 4 44 | """其他应用分享的链接""" 45 | LINK_MSG = 5 46 | """链接消息""" 47 | FILE_NOTICE = 6 48 | """文件消息,包含提示和下载完成""" 49 | QUOTE = 57 50 | """引用消息""" 51 | APP = 33 52 | """小程序""" 53 | GROUP_ANNOUNCEMENT = 87 54 | """群公告""" 55 | TRANSFER = 2000 56 | """转账""" 57 | 58 | 59 | class SysmsgType(str, Enum): 60 | """ 61 | 10002系统消息类型 62 | """ 63 | 64 | REVOKE = "revokemsg" 65 | """撤回消息""" 66 | ROOMTOOL = "roomtoolstips" 67 | """群提示""" 68 | FUNCTIONMSG = "functionmsg" 69 | """函数消息""" 70 | PAT = "pat" 71 | """拍一拍消息""" 72 | -------------------------------------------------------------------------------- /wechatbot_client/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | 配置模块 3 | """ 4 | import os 5 | from enum import Enum 6 | from ipaddress import IPv4Address 7 | from typing import TYPE_CHECKING, Any, Mapping, Optional, Tuple, Union 8 | 9 | from pydantic import AnyUrl, BaseSettings, Extra, Field, IPvAnyAddress 10 | from pydantic.env_settings import ( 11 | DotenvType, 12 | EnvSettingsSource, 13 | InitSettingsSource, 14 | SettingsError, 15 | SettingsSourceCallable, 16 | ) 17 | from pydantic.utils import deep_update 18 | 19 | from .log import logger 20 | 21 | 22 | class CustomEnvSettings(EnvSettingsSource): 23 | def __call__(self, settings: BaseSettings) -> dict[str, Any]: 24 | """ 25 | Build environment variables suitable for passing to the Model. 26 | """ 27 | d: dict[str, Any] = {} 28 | 29 | if settings.__config__.case_sensitive: 30 | env_vars: Mapping[str, Optional[str]] = os.environ # pragma: no cover 31 | else: 32 | env_vars = {k.lower(): v for k, v in os.environ.items()} 33 | 34 | env_file_vars = self._read_env_files(settings.__config__.case_sensitive) 35 | env_vars = {**env_file_vars, **env_vars} 36 | 37 | for field in settings.__fields__.values(): 38 | env_val: Optional[str] = None 39 | for env_name in field.field_info.extra["env_names"]: 40 | env_val = env_vars.get(env_name) 41 | if env_name in env_file_vars: 42 | del env_file_vars[env_name] 43 | if env_val is not None: 44 | break 45 | 46 | is_complex, allow_parse_failure = self.field_is_complex(field) 47 | if is_complex: 48 | if env_val is None: 49 | if env_val_built := self.explode_env_vars(field, env_vars): 50 | d[field.alias] = env_val_built 51 | else: 52 | # field is complex and there's a value, decode that as JSON, then add explode_env_vars 53 | try: 54 | env_val = settings.__config__.parse_env_var(field.name, env_val) 55 | except ValueError as e: 56 | if not allow_parse_failure: 57 | raise SettingsError( 58 | f'error parsing env var "{env_name}"' # type: ignore 59 | ) from e 60 | 61 | if isinstance(env_val, dict): 62 | d[field.alias] = deep_update( 63 | env_val, self.explode_env_vars(field, env_vars) 64 | ) 65 | else: 66 | d[field.alias] = env_val 67 | elif env_val is not None: 68 | # simplest case, field is not complex, we only need to add the value if it was found 69 | d[field.alias] = env_val 70 | 71 | # remain user custom config 72 | for env_name in env_file_vars: 73 | env_val = env_vars[env_name] 74 | if env_val and (val_striped := env_val.strip()): 75 | # there's a value, decode that as JSON 76 | try: 77 | env_val = settings.__config__.parse_env_var(env_name, val_striped) 78 | except ValueError: 79 | logger.trace( 80 | "Error while parsing JSON for " 81 | f"{env_name!r}={val_striped!r}. " 82 | "Assumed as string." 83 | ) 84 | 85 | # explode value when it's a nested dict 86 | env_name, *nested_keys = env_name.split(self.env_nested_delimiter) 87 | if nested_keys and (env_name not in d or isinstance(d[env_name], dict)): 88 | result = {} 89 | *keys, last_key = nested_keys 90 | _tmp = result 91 | for key in keys: 92 | _tmp = _tmp.setdefault(key, {}) 93 | _tmp[last_key] = env_val 94 | d[env_name] = deep_update(d.get(env_name, {}), result) 95 | elif not nested_keys: 96 | d[env_name] = env_val 97 | 98 | return d 99 | 100 | 101 | class BaseConfig(BaseSettings): 102 | if TYPE_CHECKING: 103 | # dummy getattr for pylance checking, actually not used 104 | def __getattr__(self, name: str) -> Any: # pragma: no cover 105 | return self.__dict__.get(name) 106 | 107 | class Config: 108 | extra = Extra.allow 109 | env_nested_delimiter = "__" 110 | 111 | @classmethod 112 | def customise_sources( 113 | cls, 114 | init_settings: InitSettingsSource, 115 | env_settings: EnvSettingsSource, 116 | file_secret_settings: SettingsSourceCallable, 117 | ) -> Tuple[SettingsSourceCallable, ...]: 118 | common_config = init_settings.init_kwargs.pop("_common_config", {}) 119 | return ( 120 | init_settings, 121 | CustomEnvSettings( 122 | env_settings.env_file, 123 | env_settings.env_file_encoding, 124 | env_settings.env_nested_delimiter, 125 | env_settings.env_prefix_len, 126 | ), 127 | InitSettingsSource(common_config), 128 | file_secret_settings, 129 | ) 130 | 131 | 132 | class Env(BaseConfig): 133 | """运行环境配置。大小写不敏感。 134 | 135 | 将会从 `环境变量` > `.env 环境配置文件` 的优先级读取环境信息。 136 | """ 137 | 138 | environment: str = "prod" 139 | """当前环境名。 140 | 141 | 将从 `.env.{environment}` 文件中加载配置。 142 | """ 143 | 144 | class Config: 145 | env_file = ".env" 146 | 147 | 148 | class WebsocketType(str, Enum): 149 | """websocket连接枚举""" 150 | 151 | Unable = "Unable" 152 | """不开启ws""" 153 | Forward = "Forward" 154 | """正向ws""" 155 | Backward = "Backward" 156 | """反向ws""" 157 | 158 | 159 | class WSUrl(AnyUrl): 160 | """ws或wss url""" 161 | 162 | allow_schemes = {"ws", "wss"} 163 | 164 | 165 | class Config(BaseConfig): 166 | """主要配置""" 167 | 168 | _env_file: DotenvType = ".env", ".env.prod" 169 | host: IPvAnyAddress = IPv4Address("127.0.0.1") 170 | """服务host""" 171 | port: int = Field(default=8080, ge=1, le=65535) 172 | """服务端口""" 173 | access_token: str = "" 174 | """访问令牌""" 175 | heartbeat_enabled: bool = False 176 | """是否开启心跳""" 177 | heartbeat_interval: int = 5000 178 | """心跳间隔""" 179 | enable_http_api: bool = False 180 | """是否开启http api""" 181 | event_enabled: bool = False 182 | """是否启用 get_latest_events 元动作""" 183 | event_buffer_size: int = 0 184 | """事件缓冲区大小,超过该大小将会丢弃最旧的事件,0 表示不限大小""" 185 | enable_http_webhook: bool = False 186 | """是否启用http webhook""" 187 | webhook_url: set[AnyUrl] = Field(default_factory=set) 188 | """webhook 上报地址""" 189 | webhook_timeout: int = 5000 190 | """上报请求超时时间,单位:毫秒,0 表示不超时""" 191 | websocekt_type: WebsocketType = WebsocketType.Backward 192 | """websocket连接方式""" 193 | websocket_url: set[WSUrl] = Field(default_factory=set) 194 | """反向 WebSocket 连接地址""" 195 | websocket_buffer_size: int = 4 196 | """反向 WebSocket 的缓冲区大小,单位(Mb)""" 197 | reconnect_interval: int = 5000 198 | """反向 WebSocket 重连间隔""" 199 | log_level: Union[int, str] = "INFO" 200 | """默认日志等级""" 201 | log_days: int = 10 202 | """日志保存天数""" 203 | cache_days: int = 3 204 | """文件缓存天数""" 205 | 206 | class Config: 207 | extra = "allow" 208 | -------------------------------------------------------------------------------- /wechatbot_client/consts.py: -------------------------------------------------------------------------------- 1 | """ 2 | 定义的全局变量 3 | """ 4 | PLATFORM = "wechat" 5 | """平台名称""" 6 | IMPL = "ComWechat" 7 | """实现端名称""" 8 | VERSION = "v1.0" 9 | """版本""" 10 | ONEBOT = "OneBot" 11 | """onebot名称""" 12 | ONEBOT_VERSION = "12" 13 | """onebot版本""" 14 | USER_AGENT = "OneBot/12(ComWeChat)" 15 | """协议头ua""" 16 | PREFIX = "wx" 17 | """拓展前缀""" 18 | 19 | FILE_CACHE = "file_cache" 20 | """保存文件缓存目录,为了防止意外,不要和其他目录放在一起""" 21 | LOG_PATH = "log" 22 | """日志存放目录""" 23 | DATABASE_PATH = "data" 24 | """数据库存放目录""" 25 | DOWNLOAD_TIMEOUT = 10 26 | """下载超时时间""" 27 | -------------------------------------------------------------------------------- /wechatbot_client/driver/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 后端驱动框架,用来管理数据收发 3 | 同时使用uvicorn管理各种任务 4 | """ 5 | 6 | from .driver import BackwardWebSocket as BackwardWebSocket 7 | from .driver import Driver as Driver 8 | from .driver import FastAPIWebSocket as FastAPIWebSocket 9 | from .model import URL as URL 10 | from .model import ContentTypes as ContentTypes 11 | from .model import Cookies as Cookies 12 | from .model import CookieTypes as CookieTypes 13 | from .model import DataTypes as DataTypes 14 | from .model import FileContent as FileContent 15 | from .model import FilesTypes as FilesTypes 16 | from .model import FileType as FileType 17 | from .model import FileTypes as FileTypes 18 | from .model import HeaderTypes as HeaderTypes 19 | from .model import HTTPServerSetup as HTTPServerSetup 20 | from .model import HTTPVersion as HTTPVersion 21 | from .model import QueryTypes as QueryTypes 22 | from .model import QueryVariable as QueryVariable 23 | from .model import RawURL as RawURL 24 | from .model import Request as Request 25 | from .model import Response as Response 26 | from .model import SimpleQuery as SimpleQuery 27 | from .model import WebSocket as WebSocket 28 | from .model import WebSocketServerSetup as WebSocketServerSetup 29 | -------------------------------------------------------------------------------- /wechatbot_client/driver/base.py: -------------------------------------------------------------------------------- 1 | """ 2 | 将后端与前端ws混入 3 | """ 4 | from functools import wraps 5 | from typing import Union 6 | 7 | from fastapi import status 8 | from starlette.websockets import WebSocket, WebSocketDisconnect, WebSocketState 9 | from websockets.exceptions import ConnectionClosed 10 | from websockets.legacy.client import WebSocketClientProtocol 11 | 12 | from wechatbot_client.exception import WebSocketClosed 13 | from wechatbot_client.typing import overrides 14 | 15 | from .model import Request as BaseRequest 16 | from .model import WebSocket as BaseWebSocket 17 | 18 | 19 | def fastapi_catch_closed(func): 20 | """fastapi 的ws 关闭""" 21 | 22 | @wraps(func) 23 | async def decorator(*args, **kwargs): 24 | try: 25 | return await func(*args, **kwargs) 26 | except WebSocketDisconnect as e: 27 | raise WebSocketClosed(e.code) 28 | except KeyError: 29 | raise TypeError("WebSocket received unexpected frame type") 30 | 31 | return decorator 32 | 33 | 34 | def websockets_catch_closed(func): 35 | """websockets 的 ws 关闭""" 36 | 37 | @wraps(func) 38 | async def decorator(*args, **kwargs): 39 | try: 40 | return await func(*args, **kwargs) 41 | except ConnectionClosed as e: 42 | if e.rcvd_then_sent: 43 | raise WebSocketClosed(e.rcvd.code, e.rcvd.reason) # type: ignore 44 | else: 45 | raise WebSocketClosed(e.sent.code, e.sent.reason) # type: ignore 46 | 47 | return decorator 48 | 49 | 50 | class FastAPIWebSocket(BaseWebSocket): 51 | """FastAPI WebSocket Wrapper""" 52 | 53 | @overrides(BaseWebSocket) 54 | def __init__(self, *, request: BaseRequest, websocket: WebSocket) -> None: 55 | super().__init__(request=request) 56 | self.websocket = websocket 57 | 58 | @property 59 | @overrides(BaseWebSocket) 60 | def closed(self) -> bool: 61 | return ( 62 | self.websocket.client_state == WebSocketState.DISCONNECTED 63 | or self.websocket.application_state == WebSocketState.DISCONNECTED 64 | ) 65 | 66 | @overrides(BaseWebSocket) 67 | async def accept(self) -> None: 68 | await self.websocket.accept() 69 | 70 | @overrides(BaseWebSocket) 71 | async def close( 72 | self, code: int = status.WS_1000_NORMAL_CLOSURE, reason: str = "" 73 | ) -> None: 74 | await self.websocket.close(code, reason) 75 | 76 | @overrides(BaseWebSocket) 77 | async def receive(self) -> Union[str, bytes]: 78 | # assert self.websocket.application_state == WebSocketState.CONNECTED 79 | msg = await self.websocket.receive() 80 | if msg["type"] == "websocket.disconnect": 81 | raise WebSocketClosed(msg["code"]) 82 | return msg["text"] if "text" in msg else msg["bytes"] 83 | 84 | @overrides(BaseWebSocket) 85 | @fastapi_catch_closed 86 | async def receive_text(self) -> str: 87 | return await self.websocket.receive_text() 88 | 89 | @overrides(BaseWebSocket) 90 | @fastapi_catch_closed 91 | async def receive_bytes(self) -> bytes: 92 | return await self.websocket.receive_bytes() 93 | 94 | @overrides(BaseWebSocket) 95 | async def send_text(self, data: str) -> None: 96 | await self.websocket.send({"type": "websocket.send", "text": data}) 97 | 98 | @overrides(BaseWebSocket) 99 | async def send_bytes(self, data: bytes) -> None: 100 | await self.websocket.send({"type": "websocket.send", "bytes": data}) 101 | 102 | 103 | class BackwardWebSocket(BaseWebSocket): 104 | """Websockets WebSocket Wrapper""" 105 | 106 | @overrides(BaseWebSocket) 107 | def __init__(self, *, request: BaseRequest, websocket: WebSocketClientProtocol): 108 | super().__init__(request=request) 109 | self.websocket = websocket 110 | 111 | @property 112 | @overrides(BaseWebSocket) 113 | def closed(self) -> bool: 114 | return self.websocket.closed 115 | 116 | @overrides(BaseWebSocket) 117 | async def accept(self): 118 | raise NotImplementedError 119 | 120 | @overrides(BaseWebSocket) 121 | async def close(self, code: int = 1000, reason: str = ""): 122 | await self.websocket.close(code, reason) 123 | 124 | @overrides(BaseWebSocket) 125 | @websockets_catch_closed 126 | async def receive(self) -> Union[str, bytes]: 127 | return await self.websocket.recv() 128 | 129 | @overrides(BaseWebSocket) 130 | @websockets_catch_closed 131 | async def receive_text(self) -> str: 132 | msg = await self.websocket.recv() 133 | if isinstance(msg, bytes): 134 | raise TypeError("WebSocket received unexpected frame type: bytes") 135 | return msg 136 | 137 | @overrides(BaseWebSocket) 138 | @websockets_catch_closed 139 | async def receive_bytes(self) -> bytes: 140 | msg = await self.websocket.recv() 141 | if isinstance(msg, str): 142 | raise TypeError("WebSocket received unexpected frame type: str") 143 | return msg 144 | 145 | @overrides(BaseWebSocket) 146 | async def send_text(self, data: str) -> None: 147 | await self.websocket.send(data) 148 | 149 | @overrides(BaseWebSocket) 150 | async def send_bytes(self, data: bytes) -> None: 151 | await self.websocket.send(data) 152 | -------------------------------------------------------------------------------- /wechatbot_client/driver/driver.py: -------------------------------------------------------------------------------- 1 | """ 2 | 后端驱动driver 3 | """ 4 | import contextlib 5 | import logging 6 | import sys 7 | from typing import Any, AsyncGenerator, Callable, Optional, Tuple, Union 8 | 9 | import httpx 10 | import uvicorn 11 | from fastapi import FastAPI, Request, UploadFile 12 | from fastapi.responses import Response 13 | from pydantic import BaseSettings 14 | from starlette.websockets import WebSocket 15 | from websockets.legacy.client import Connect 16 | 17 | from wechatbot_client.config import Config as BaseConfig 18 | from wechatbot_client.consts import IMPL, ONEBOT_VERSION 19 | 20 | from .base import BackwardWebSocket, FastAPIWebSocket 21 | from .model import FileTypes, HTTPServerSetup, HTTPVersion 22 | from .model import Request as BaseRequest 23 | from .model import Response as BaseResponse 24 | from .model import WebSocketServerSetup 25 | 26 | 27 | class Config(BaseSettings): 28 | """FastAPI 驱动框架设置,详情参考 FastAPI 文档""" 29 | 30 | fastapi_openapi_url: Optional[str] = None 31 | """`openapi.json` 地址,默认为 `None` 即关闭""" 32 | fastapi_docs_url: Optional[str] = None 33 | """`swagger` 地址,默认为 `None` 即关闭""" 34 | fastapi_redoc_url: Optional[str] = None 35 | """`redoc` 地址,默认为 `None` 即关闭""" 36 | fastapi_include_adapter_schema: bool = True 37 | """是否包含适配器路由的 schema,默认为 `True`""" 38 | fastapi_reload: bool = False 39 | """开启/关闭冷重载""" 40 | fastapi_reload_dirs: Optional[list[str]] = None 41 | """重载监控文件夹列表,默认为 uvicorn 默认值""" 42 | fastapi_reload_delay: float = 0.25 43 | """重载延迟,默认为 uvicorn 默认值""" 44 | fastapi_reload_includes: Optional[list[str]] = None 45 | """要监听的文件列表,支持 glob pattern,默认为 uvicorn 默认值""" 46 | fastapi_reload_excludes: Optional[list[str]] = None 47 | """不要监听的文件列表,支持 glob pattern,默认为 uvicorn 默认值""" 48 | fastapi_extra: dict[str, Any] = {} 49 | """传递给 `FastAPI` 的其他参数。""" 50 | 51 | class Config: 52 | extra = "ignore" 53 | 54 | 55 | class Driver: 56 | """FastAPI 驱动框架。""" 57 | 58 | connects: dict[int, Union[FastAPIWebSocket, BackwardWebSocket]] 59 | """维护的连接字典""" 60 | _seq: int 61 | 62 | def __init__(self, config: BaseConfig) -> None: 63 | self._seq = 0 64 | self.connects = {} 65 | self.config = config 66 | self.fastapi_config: Config = Config(**config.dict()) 67 | 68 | self._server_app = FastAPI( 69 | openapi_url=self.fastapi_config.fastapi_openapi_url, 70 | docs_url=self.fastapi_config.fastapi_docs_url, 71 | redoc_url=self.fastapi_config.fastapi_redoc_url, 72 | **self.fastapi_config.fastapi_extra, 73 | ) 74 | 75 | def get_seq(self) -> int: 76 | """获取一个seq,用来维护ws连接""" 77 | s = self._seq 78 | self._seq = (self._seq + 1) % sys.maxsize 79 | return s 80 | 81 | def type(self) -> str: 82 | """驱动名称: `fastapi`""" 83 | return "fastapi" 84 | 85 | @property 86 | def server_app(self) -> FastAPI: 87 | """`FastAPI APP` 对象""" 88 | return self._server_app 89 | 90 | @property 91 | def asgi(self) -> FastAPI: 92 | """`FastAPI APP` 对象""" 93 | return self._server_app 94 | 95 | @property 96 | def logger(self) -> logging.Logger: 97 | """fastapi 使用的 logger""" 98 | return logging.getLogger("fastapi") 99 | 100 | def setup_http_server(self, setup: HTTPServerSetup) -> None: 101 | """设置一个 HTTP 服务器路由配置""" 102 | 103 | async def _handle(request: Request) -> Response: 104 | return await self._handle_http(request, setup) 105 | 106 | self._server_app.add_api_route( 107 | setup.path.path, 108 | _handle, 109 | name=setup.name, 110 | methods=[setup.method], 111 | include_in_schema=self.fastapi_config.fastapi_include_adapter_schema, 112 | ) 113 | 114 | def setup_websocket_server(self, setup: WebSocketServerSetup) -> None: 115 | """设置一个 WebSocket 服务器路由配置""" 116 | 117 | async def _handle(websocket: WebSocket) -> None: 118 | await self._handle_ws(websocket, setup) 119 | 120 | self._server_app.add_api_websocket_route( 121 | setup.path.path, 122 | _handle, 123 | name=setup.name, 124 | ) 125 | 126 | def on_startup(self, func: Callable) -> Callable: 127 | """注册一个在驱动器启动时执行的函数,参考文档: [Events](https://fastapi.tiangolo.com/advanced/events/#startup-event)""" 128 | return self.server_app.on_event("startup")(func) 129 | 130 | def on_shutdown(self, func: Callable) -> Callable: 131 | """注册一个在驱动器停止时执行的函数,参考文档: [Events](https://fastapi.tiangolo.com/advanced/events/#shutdown-event)""" 132 | return self.server_app.on_event("shutdown")(func) 133 | 134 | def run( 135 | self, 136 | host: Optional[str] = None, 137 | port: Optional[int] = None, 138 | *, 139 | app: Optional[str] = None, 140 | **kwargs, 141 | ) -> None: 142 | """使用 `uvicorn` 启动 FastAPI""" 143 | LOGGING_CONFIG = { 144 | "version": 1, 145 | "disable_existing_loggers": False, 146 | "handlers": { 147 | "default": { 148 | "class": "wechatbot_client.log.LoguruHandler", 149 | }, 150 | }, 151 | "loggers": { 152 | "uvicorn.error": {"handlers": ["default"], "level": "INFO"}, 153 | "uvicorn.access": { 154 | "handlers": ["default"], 155 | "level": "INFO", 156 | }, 157 | }, 158 | } 159 | uvicorn.run( 160 | app or self.server_app, # type: ignore 161 | host=host or str(self.config.host), 162 | port=port or self.config.port, 163 | reload=self.fastapi_config.fastapi_reload, 164 | reload_dirs=self.fastapi_config.fastapi_reload_dirs, 165 | reload_delay=self.fastapi_config.fastapi_reload_delay, 166 | reload_includes=self.fastapi_config.fastapi_reload_includes, 167 | reload_excludes=self.fastapi_config.fastapi_reload_excludes, 168 | log_config=LOGGING_CONFIG, 169 | **kwargs, 170 | ) 171 | 172 | async def _handle_http( 173 | self, 174 | request: Request, 175 | setup: HTTPServerSetup, 176 | ) -> Response: 177 | json: Any = None 178 | with contextlib.suppress(Exception): 179 | json = await request.json() 180 | 181 | data: Optional[dict] = None 182 | files: Optional[list[Tuple[str, FileTypes]]] = None 183 | with contextlib.suppress(Exception): 184 | form = await request.form() 185 | data = {} 186 | files = [] 187 | for key, value in form.multi_items(): 188 | if isinstance(value, UploadFile): 189 | files.append( 190 | (key, (value.filename, value.file, value.content_type)) 191 | ) 192 | else: 193 | data[key] = value 194 | 195 | http_request = BaseRequest( 196 | request.method, 197 | str(request.url), 198 | headers=request.headers.items(), 199 | cookies=request.cookies, 200 | content=await request.body(), 201 | data=data, 202 | json=json, 203 | files=files, 204 | version=request.scope["http_version"], 205 | ) 206 | 207 | response = await setup.handle_func(http_request) 208 | return Response( 209 | response.content, response.status_code, dict(response.headers.items()) 210 | ) 211 | 212 | async def _handle_ws( 213 | self, websocket: WebSocket, setup: WebSocketServerSetup 214 | ) -> None: 215 | request = BaseRequest( 216 | "GET", 217 | str(websocket.url), 218 | headers=websocket.headers.items(), 219 | cookies=websocket.cookies, 220 | version=websocket.scope.get("http_version", "1.1"), 221 | ) 222 | ws = FastAPIWebSocket( 223 | request=request, 224 | websocket=websocket, 225 | ) 226 | 227 | await setup.handle_func(ws) 228 | 229 | async def request(self, setup: BaseRequest) -> BaseResponse: 230 | """ 231 | 发起一个http请求 232 | """ 233 | async with httpx.AsyncClient( 234 | cookies=setup.cookies.jar, 235 | http2=setup.version == HTTPVersion.H2, 236 | proxies=setup.proxy, 237 | follow_redirects=True, 238 | ) as client: 239 | response = await client.request( 240 | setup.method, 241 | str(setup.url), 242 | content=setup.content, 243 | data=setup.data, 244 | json=setup.json, 245 | files=setup.files, 246 | headers=tuple(setup.headers.items()), 247 | timeout=setup.timeout, 248 | ) 249 | return Response( 250 | response.status_code, 251 | headers=response.headers.multi_items(), 252 | content=response.content, 253 | request=setup, 254 | ) 255 | 256 | @contextlib.asynccontextmanager 257 | async def start_websocket( 258 | self, setup: BaseRequest 259 | ) -> AsyncGenerator["BackwardWebSocket", None]: 260 | """创建一个websocket连接请求""" 261 | connection = Connect( 262 | str(setup.url), 263 | subprotocols=[f"{ONEBOT_VERSION}.{IMPL}"], 264 | extra_headers={**setup.headers, **setup.cookies.as_header(setup)}, 265 | open_timeout=setup.timeout, 266 | max_size=(2**20) * self.config.websocket_buffer_size, 267 | ) 268 | async with connection as ws: 269 | yield BackwardWebSocket(request=setup, websocket=ws) 270 | 271 | def ws_connect(self, websocket: Union[FastAPIWebSocket, BackwardWebSocket]) -> int: 272 | """ 273 | 添加websoket连接,返回一个seq 274 | """ 275 | seq = self.get_seq() 276 | self.connects[seq] = websocket 277 | return seq 278 | 279 | def ws_disconnect(self, seq: int) -> None: 280 | """ws断开连接""" 281 | self.connects.pop(seq) 282 | 283 | def check_websocket_in( 284 | self, websocket: Union[FastAPIWebSocket, BackwardWebSocket] 285 | ) -> bool: 286 | """ 287 | 检测websocket是否在维护字典中 288 | """ 289 | return websocket in self.connects.keys() 290 | -------------------------------------------------------------------------------- /wechatbot_client/driver/model.py: -------------------------------------------------------------------------------- 1 | """ 2 | 通用请求模型 3 | """ 4 | import abc 5 | import urllib.request 6 | from dataclasses import dataclass 7 | from enum import Enum 8 | from http.cookiejar import Cookie, CookieJar 9 | from typing import ( 10 | IO, 11 | Any, 12 | Awaitable, 13 | Callable, 14 | Dict, 15 | Iterator, 16 | List, 17 | Mapping, 18 | MutableMapping, 19 | Optional, 20 | Tuple, 21 | Union, 22 | ) 23 | 24 | from multidict import CIMultiDict 25 | from yarl import URL as URL 26 | 27 | RawURL = Tuple[bytes, bytes, Optional[int], bytes] 28 | 29 | SimpleQuery = Union[str, int, float] 30 | QueryVariable = Union[SimpleQuery, List[SimpleQuery]] 31 | QueryTypes = Union[ 32 | None, str, Mapping[str, QueryVariable], List[Tuple[str, QueryVariable]] 33 | ] 34 | 35 | HeaderTypes = Union[ 36 | None, 37 | CIMultiDict[str], 38 | Dict[str, str], 39 | List[Tuple[str, str]], 40 | ] 41 | 42 | CookieTypes = Union[None, "Cookies", CookieJar, Dict[str, str], List[Tuple[str, str]]] 43 | 44 | ContentTypes = Union[str, bytes, None] 45 | DataTypes = Union[dict, None] 46 | FileContent = Union[IO[bytes], bytes] 47 | FileType = Tuple[Optional[str], FileContent, Optional[str]] 48 | FileTypes = Union[ 49 | # file (or bytes) 50 | FileContent, 51 | # (filename, file (or bytes)) 52 | Tuple[Optional[str], FileContent], 53 | # (filename, file (or bytes), content_type) 54 | FileType, 55 | ] 56 | FilesTypes = Union[Dict[str, FileTypes], List[Tuple[str, FileTypes]], None] 57 | 58 | 59 | class HTTPVersion(Enum): 60 | H10 = "1.0" 61 | H11 = "1.1" 62 | H2 = "2" 63 | 64 | 65 | class Request: 66 | def __init__( 67 | self, 68 | method: Union[str, bytes], 69 | url: Union["URL", str, RawURL], 70 | *, 71 | params: QueryTypes = None, 72 | headers: HeaderTypes = None, 73 | cookies: CookieTypes = None, 74 | content: ContentTypes = None, 75 | data: DataTypes = None, 76 | json: Any = None, 77 | files: FilesTypes = None, 78 | version: Union[str, HTTPVersion] = HTTPVersion.H11, 79 | timeout: Optional[float] = None, 80 | proxy: Optional[str] = None, 81 | ): 82 | # method 83 | self.method: str = ( 84 | method.decode("ascii").upper() 85 | if isinstance(method, bytes) 86 | else method.upper() 87 | ) 88 | # http version 89 | self.version: HTTPVersion = HTTPVersion(version) 90 | # timeout 91 | self.timeout: Optional[float] = timeout 92 | # proxy 93 | self.proxy: Optional[str] = proxy 94 | 95 | # url 96 | if isinstance(url, tuple): 97 | scheme, host, port, path = url 98 | url = URL.build( 99 | scheme=scheme.decode("ascii"), 100 | host=host.decode("ascii"), 101 | port=port, 102 | path=path.decode("ascii"), 103 | ) 104 | else: 105 | url = URL(url) 106 | 107 | if params is not None: 108 | url = url.update_query(params) 109 | self.url: URL = url 110 | 111 | # headers 112 | self.headers: CIMultiDict[str] = ( 113 | CIMultiDict(headers) if headers is not None else CIMultiDict() 114 | ) 115 | # cookies 116 | self.cookies = Cookies(cookies) 117 | 118 | # body 119 | self.content: ContentTypes = content 120 | self.data: DataTypes = data 121 | self.json: Any = json 122 | self.files: Optional[List[Tuple[str, FileType]]] = None 123 | if files: 124 | self.files = [] 125 | files_ = files.items() if isinstance(files, dict) else files 126 | for name, file_info in files_: 127 | if not isinstance(file_info, tuple): 128 | self.files.append((name, (None, file_info, None))) 129 | elif len(file_info) == 2: 130 | self.files.append((name, (file_info[0], file_info[1], None))) 131 | else: 132 | self.files.append((name, file_info)) # type: ignore 133 | 134 | def __repr__(self) -> str: 135 | return f"{self.__class__.__name__}(method={self.method!r}, url='{self.url!s}')" 136 | 137 | 138 | class Response: 139 | def __init__( 140 | self, 141 | status_code: int, 142 | *, 143 | headers: HeaderTypes = None, 144 | content: ContentTypes = None, 145 | request: Optional[Request] = None, 146 | ): 147 | # status code 148 | self.status_code: int = status_code 149 | 150 | # headers 151 | self.headers: CIMultiDict[str] = ( 152 | CIMultiDict(headers) if headers is not None else CIMultiDict() 153 | ) 154 | # body 155 | self.content: ContentTypes = content 156 | 157 | # request 158 | self.request: Optional[Request] = request 159 | 160 | def __repr__(self) -> str: 161 | return f"{self.__class__.__name__}(status_code={self.status_code!r})" 162 | 163 | 164 | class WebSocket(abc.ABC): 165 | def __init__(self, *, request: Request): 166 | # request 167 | self.request: Request = request 168 | 169 | def __repr__(self) -> str: 170 | return f"{self.__class__.__name__}('{self.request.url!s}')" 171 | 172 | @property 173 | @abc.abstractmethod 174 | def closed(self) -> bool: 175 | """ 176 | 连接是否已经关闭 177 | """ 178 | raise NotImplementedError 179 | 180 | @abc.abstractmethod 181 | async def accept(self) -> None: 182 | """接受 WebSocket 连接请求""" 183 | raise NotImplementedError 184 | 185 | @abc.abstractmethod 186 | async def close(self, code: int = 1000, reason: str = "") -> None: 187 | """关闭 WebSocket 连接请求""" 188 | raise NotImplementedError 189 | 190 | @abc.abstractmethod 191 | async def receive(self) -> Union[str, bytes]: 192 | """接收一条 WebSocket text/bytes 信息""" 193 | raise NotImplementedError 194 | 195 | @abc.abstractmethod 196 | async def receive_text(self) -> str: 197 | """接收一条 WebSocket text 信息""" 198 | raise NotImplementedError 199 | 200 | @abc.abstractmethod 201 | async def receive_bytes(self) -> bytes: 202 | """接收一条 WebSocket binary 信息""" 203 | raise NotImplementedError 204 | 205 | async def send(self, data: Union[str, bytes]) -> None: 206 | """发送一条 WebSocket text/bytes 信息""" 207 | if isinstance(data, str): 208 | await self.send_text(data) 209 | elif isinstance(data, bytes): 210 | await self.send_bytes(data) 211 | else: 212 | raise TypeError("WebSocker send method expects str or bytes!") 213 | 214 | @abc.abstractmethod 215 | async def send_text(self, data: str) -> None: 216 | """发送一条 WebSocket text 信息""" 217 | raise NotImplementedError 218 | 219 | @abc.abstractmethod 220 | async def send_bytes(self, data: bytes) -> None: 221 | """发送一条 WebSocket binary 信息""" 222 | raise NotImplementedError 223 | 224 | 225 | class Cookies(MutableMapping): 226 | def __init__(self, cookies: CookieTypes = None) -> None: 227 | self.jar: CookieJar = cookies if isinstance(cookies, CookieJar) else CookieJar() 228 | if cookies is not None and not isinstance(cookies, CookieJar): 229 | if isinstance(cookies, dict): 230 | for key, value in cookies.items(): 231 | self.set(key, value) 232 | elif isinstance(cookies, list): 233 | for key, value in cookies: 234 | self.set(key, value) 235 | elif isinstance(cookies, Cookies): 236 | for cookie in cookies.jar: 237 | self.jar.set_cookie(cookie) 238 | else: 239 | raise TypeError(f"Cookies must be dict or list, not {type(cookies)}") 240 | 241 | def set(self, name: str, value: str, domain: str = "", path: str = "/") -> None: 242 | cookie = Cookie( 243 | version=0, 244 | name=name, 245 | value=value, 246 | port=None, 247 | port_specified=False, 248 | domain=domain, 249 | domain_specified=bool(domain), 250 | domain_initial_dot=domain.startswith("."), 251 | path=path, 252 | path_specified=bool(path), 253 | secure=False, 254 | expires=None, 255 | discard=True, 256 | comment=None, 257 | comment_url=None, 258 | rest={}, 259 | rfc2109=False, 260 | ) 261 | self.jar.set_cookie(cookie) 262 | 263 | def get( 264 | self, 265 | name: str, 266 | default: Optional[str] = None, 267 | domain: Optional[str] = None, 268 | path: Optional[str] = None, 269 | ) -> Optional[str]: 270 | value: Optional[str] = None 271 | for cookie in self.jar: 272 | if ( 273 | cookie.name == name 274 | and (domain is None or cookie.domain == domain) 275 | and (path is None or cookie.path == path) 276 | ): 277 | if value is not None: 278 | message = f"Multiple cookies exist with name={name}" 279 | raise ValueError(message) 280 | value = cookie.value 281 | 282 | return default if value is None else value 283 | 284 | def delete( 285 | self, name: str, domain: Optional[str] = None, path: Optional[str] = None 286 | ) -> None: 287 | if domain is not None and path is not None: 288 | return self.jar.clear(domain, path, name) 289 | 290 | remove = [ 291 | cookie 292 | for cookie in self.jar 293 | if cookie.name == name 294 | and (domain is None or cookie.domain == domain) 295 | and (path is None or cookie.path == path) 296 | ] 297 | 298 | for cookie in remove: 299 | self.jar.clear(cookie.domain, cookie.path, cookie.name) 300 | 301 | def clear(self, domain: Optional[str] = None, path: Optional[str] = None) -> None: 302 | self.jar.clear(domain, path) 303 | 304 | def update(self, cookies: CookieTypes = None) -> None: 305 | cookies = Cookies(cookies) 306 | for cookie in cookies.jar: 307 | self.jar.set_cookie(cookie) 308 | 309 | def as_header(self, request: Request) -> Dict[str, str]: 310 | urllib_request = self._CookieCompatRequest(request) 311 | self.jar.add_cookie_header(urllib_request) 312 | return urllib_request.added_headers 313 | 314 | def __setitem__(self, name: str, value: str) -> None: 315 | return self.set(name, value) 316 | 317 | def __getitem__(self, name: str) -> str: 318 | value = self.get(name) 319 | if value is None: 320 | raise KeyError(name) 321 | return value 322 | 323 | def __delitem__(self, name: str) -> None: 324 | return self.delete(name) 325 | 326 | def __len__(self) -> int: 327 | return len(self.jar) 328 | 329 | def __iter__(self) -> Iterator[Cookie]: 330 | return iter(self.jar) 331 | 332 | def __repr__(self) -> str: 333 | cookies_repr = ", ".join( 334 | f"Cookie({cookie.name}={cookie.value} for {cookie.domain})" 335 | for cookie in self.jar 336 | ) 337 | return f"{self.__class__.__name__}({cookies_repr})" 338 | 339 | class _CookieCompatRequest(urllib.request.Request): 340 | def __init__(self, request: Request) -> None: 341 | super().__init__( 342 | url=str(request.url), 343 | headers=dict(request.headers), 344 | method=request.method, 345 | ) 346 | self.request = request 347 | self.added_headers: Dict[str, str] = {} 348 | 349 | def add_unredirected_header(self, key: str, value: str) -> None: 350 | super().add_unredirected_header(key, value) 351 | self.added_headers[key] = value 352 | 353 | 354 | @dataclass 355 | class HTTPServerSetup: 356 | """HTTP 服务器路由配置。""" 357 | 358 | path: URL # path should not be absolute, check it by URL.is_absolute() == False 359 | method: str 360 | name: str 361 | handle_func: Callable[[Request], Awaitable[Response]] 362 | 363 | 364 | @dataclass 365 | class WebSocketServerSetup: 366 | """WebSocket 服务器路由配置。""" 367 | 368 | path: URL # path should not be absolute, check it by URL.is_absolute() == False 369 | name: str 370 | handle_func: Callable[[WebSocket], Awaitable[Any]] 371 | -------------------------------------------------------------------------------- /wechatbot_client/exception.py: -------------------------------------------------------------------------------- 1 | """ 2 | 异常模块,这里定义所有异常 3 | """ 4 | 5 | 6 | from typing import Optional 7 | 8 | 9 | class BaseException(Exception): 10 | """异常基类""" 11 | 12 | def __str__(self) -> str: 13 | return self.__repr__() 14 | 15 | 16 | class MessageException(BaseException): 17 | """消息异常""" 18 | 19 | ... 20 | 21 | 22 | class NoThisUserInGroup(MessageException): 23 | """群聊中查无此人""" 24 | 25 | group_id: str 26 | user_id: str 27 | 28 | def __init__(self, group_id: str, user_id: str) -> None: 29 | self.group_id = group_id 30 | self.user_id = user_id 31 | 32 | def __repr__(self) -> str: 33 | return f"在[{self.group_id}]查无此人:{self.user_id}" 34 | 35 | 36 | class FileNotFound(MessageException): 37 | """文件未找到""" 38 | 39 | file_id: str 40 | 41 | def __init__(self, file_id: str) -> None: 42 | self.file_id = file_id 43 | 44 | def __repr__(self) -> str: 45 | return f"未找到文件:{self.file_id}" 46 | 47 | 48 | class WebSocketClosed(BaseException): 49 | """WebSocket 连接已关闭""" 50 | 51 | def __init__(self, code: int, reason: Optional[str] = None) -> None: 52 | self.code = code 53 | self.reason = reason 54 | 55 | def __repr__(self) -> str: 56 | return ( 57 | f"WebSocketClosed(code={self.code}" 58 | + (f", reason={self.reason!r}" if self.reason else "") 59 | + ")" 60 | ) 61 | -------------------------------------------------------------------------------- /wechatbot_client/file_manager/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 文件管理类,用来映射文件id与path 3 | 为了防止文件名冲突,数据库只做映射相关 4 | 使用了sqlite数据库 5 | """ 6 | 7 | from .manager import FileManager as FileManager 8 | from .model import FileCache as FileCache 9 | from .model import database_close as database_close 10 | from .model import database_init as database_init 11 | -------------------------------------------------------------------------------- /wechatbot_client/file_manager/manager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | from shutil import copyfile 4 | from typing import Optional, Tuple 5 | from uuid import uuid4 6 | 7 | from httpx import URL, AsyncClient 8 | 9 | from wechatbot_client.consts import DOWNLOAD_TIMEOUT, FILE_CACHE 10 | from wechatbot_client.utils import logger_wrapper, run_sync 11 | 12 | from .model import FileCache 13 | 14 | log = logger_wrapper("File Manager") 15 | 16 | 17 | class FileManager: 18 | """ 19 | 文件管理模块 20 | """ 21 | 22 | file_path: Path 23 | """文件缓存地址""" 24 | 25 | def __init__(self) -> None: 26 | self.file_path = Path(f"./{FILE_CACHE}/temp") 27 | self.file_path.mkdir(parents=True, exist_ok=True) 28 | 29 | def get_file_name(self, file_path: Path) -> Path: 30 | """ 31 | 说明: 32 | 对于保存在Temp的文件,为了防止重名,需要更改文件名 33 | 34 | 参数: 35 | * `file_path`: 文件路径 36 | 37 | 返回: 38 | * `Path`: 更改后的文件路径 39 | """ 40 | count = 0 41 | original_file_path = file_path 42 | while file_path.exists(): 43 | count += 1 44 | file_name = f"{original_file_path.stem}({count}){original_file_path.suffix}" 45 | file_path = original_file_path.parent / file_name 46 | return file_path 47 | 48 | async def cache_file_id_from_url( 49 | self, url: str, name: str, headers: dict = None 50 | ) -> Optional[str]: 51 | """ 52 | 说明: 53 | 下载文件并缓存 54 | 55 | 参数: 56 | * `url`: 文件url地址 57 | * `name`: 文件名 58 | * `headers`: 下载添加的header 59 | 60 | 返回: 61 | * `str | None`: 文件id,下载失败为None 62 | """ 63 | file_url = URL(url) 64 | if headers is None: 65 | headers = {} 66 | async with AsyncClient(headers=headers) as client: 67 | try: 68 | res = await client.get(file_url) 69 | data = res.content 70 | file_id = str(uuid4()) 71 | file_path = self.file_path / name 72 | file_path = self.get_file_name(file_path) 73 | with open(file_path, mode="wb") as f: 74 | f.write(data) 75 | except Exception as e: 76 | log("ERROR", f"文件下载失败:{e}") 77 | return None 78 | 79 | await FileCache.create_file_cache( 80 | file_id=file_id, file_path=str(file_path.absolute()), file_name=name 81 | ) 82 | return file_id 83 | 84 | async def cache_file_id_from_path( 85 | self, get_path: Path, name: str, copy: bool = True 86 | ) -> Optional[str]: 87 | """ 88 | 说明: 89 | 从路径缓存一个文件 90 | 91 | 参数: 92 | * `path`: 文件路径 93 | * `name`: 文件名 94 | * `copy`: 是否复制到temp下 95 | 96 | 返回: 97 | * `str | None`: 文件id,文件不存在时为None 98 | """ 99 | if not get_path.exists(): 100 | log("ERROR", "缓存的文件不存在") 101 | return None 102 | file_id = str(uuid4()) 103 | if copy: 104 | file_path = self.file_path / name 105 | file_path = self.get_file_name(file_path) 106 | copyfile(get_path, file_path) 107 | else: 108 | file_path = get_path 109 | await FileCache.create_file_cache( 110 | file_id=file_id, 111 | file_path=str(file_path.absolute()), 112 | file_name=name, 113 | file_temp=copy, 114 | ) 115 | return file_id 116 | 117 | async def cache_file_id_from_data(self, data: bytes, name: str) -> str: 118 | """ 119 | 说明: 120 | 从data缓存文件 121 | 122 | 参数: 123 | * `data`: 文件数据 124 | * `name`: 文件名 125 | 126 | 返回: 127 | * `str | None` 128 | """ 129 | file_id = str(uuid4()) 130 | file_path = self.file_path / name 131 | file_path = self.get_file_name(file_path) 132 | with open(file_path, mode="wb") as f: 133 | f.write(data) 134 | await FileCache.create_file_cache( 135 | file_id=file_id, file_path=str(file_path.absolute()), file_name=name 136 | ) 137 | return file_id 138 | 139 | async def get_file(self, file_id: str) -> Optional[Tuple[str, str]]: 140 | """ 141 | 通过file_id获取文件 142 | """ 143 | return await FileCache.get_file(file_id) 144 | 145 | @run_sync 146 | def clean_tempfile(self, file_paths: list[str]) -> int: 147 | """ 148 | 清理临时文件 149 | """ 150 | count = 0 151 | for file_path in file_paths: 152 | file = Path(file_path) 153 | if file.exists(): 154 | file.unlink() 155 | count += 1 156 | return count 157 | 158 | async def clean_cache(self, days: int = 3) -> int: 159 | """ 160 | 说明: 161 | 清理缓存 162 | 163 | 参数: 164 | * `days`: 清理多少天前的缓存 165 | 166 | 返回: 167 | * `int`: 清理的文件数量 168 | """ 169 | file_paths = await FileCache.clean_file(days) 170 | count = await self.clean_tempfile(file_paths) 171 | log("SUCCESS", f"清理缓存成功,共清理: {count} 个文件...") 172 | return count 173 | 174 | async def reset_cache(self) -> None: 175 | """ 176 | 重置缓存 177 | """ 178 | await FileCache.reset() 179 | for file in self.file_path.iterdir(): 180 | file.unlink() 181 | log("SUCCESS", "重置文件缓存...") 182 | 183 | async def wait_image_task(self, image_path: str, future: asyncio.Future) -> None: 184 | """ 185 | 等待图片任务 186 | """ 187 | jpg = Path(f"{image_path}.jpg") 188 | png = Path(f"{image_path}.png") 189 | gif = Path(f"{image_path}.gif") 190 | while True: 191 | if future.cancelled(): 192 | return 193 | if jpg.exists(): 194 | future.set_result(jpg) 195 | return 196 | if png.exists(): 197 | future.set_result(png) 198 | return 199 | if gif.exists(): 200 | future.set_result(gif) 201 | return 202 | await asyncio.sleep(0.5) 203 | 204 | async def wait_for_image(self, image_path: str) -> Optional[Path]: 205 | """ 206 | 说明: 207 | 等待图片下载成功 208 | """ 209 | loop = asyncio.get_event_loop() 210 | future = loop.create_future() 211 | loop.create_task(self.wait_image_task(image_path, future)) 212 | try: 213 | await asyncio.wait_for(future, DOWNLOAD_TIMEOUT) 214 | except asyncio.TimeoutError: 215 | log("ERROR", "图片下载超时...") 216 | future.cancel() 217 | return None 218 | return future.result() 219 | 220 | async def wait_file_task(self, file: Path, future: asyncio.Future) -> None: 221 | """ 222 | 等待文件任务 223 | """ 224 | while True: 225 | if future.cancelled(): 226 | return 227 | if file.exists(): 228 | future.set_result(file) 229 | return 230 | await asyncio.sleep(0.5) 231 | 232 | async def wait_for_file(self, file: Path) -> Optional[Path]: 233 | """ 234 | 说明: 235 | 等待文件下载成功 236 | """ 237 | loop = asyncio.get_event_loop() 238 | future = loop.create_future() 239 | loop.create_task(self.wait_file_task(file, future)) 240 | try: 241 | await asyncio.wait_for(future, DOWNLOAD_TIMEOUT) 242 | except asyncio.TimeoutError: 243 | log("ERROR", "文件下载超时...") 244 | future.cancel() 245 | return None 246 | return future.result() 247 | -------------------------------------------------------------------------------- /wechatbot_client/file_manager/model.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from pathlib import Path 3 | from typing import Optional, Tuple 4 | 5 | from tortoise import Tortoise, fields 6 | from tortoise.models import Model 7 | 8 | from wechatbot_client.consts import DATABASE_PATH 9 | from wechatbot_client.log import logger 10 | 11 | 12 | class FileCache(Model): 13 | """文件缓存表""" 14 | 15 | id = fields.IntField(pk=True, generated=True) 16 | file_id = fields.CharField(max_length=255) 17 | """文件id""" 18 | create_time = fields.DatetimeField() 19 | """创建时间""" 20 | file_name = fields.CharField(max_length=255) 21 | """文件名""" 22 | file_path = fields.CharField(max_length=255) 23 | """文件路径""" 24 | file_temp = fields.BooleanField() 25 | """是否为临时文件""" 26 | 27 | class Meta: 28 | table = "file_cache" 29 | table_description = "文件缓存" 30 | 31 | @classmethod 32 | async def create_file_cache( 33 | cls, file_id: str, file_path: str, file_name: str, file_temp: bool = True 34 | ) -> bool: 35 | """ 36 | 说明: 37 | 创建一个文件缓存 38 | 39 | 参数: 40 | * `file_id`: 文件名 41 | * `file_path`: 文件路径 42 | * `file_name`: 文件名 43 | * `file_temp`: 是否为临时文件 44 | 45 | 返回: 46 | * `bool`: 缓存是否成功 47 | """ 48 | time = datetime.now() 49 | await cls.create( 50 | file_id=file_id, 51 | file_path=file_path, 52 | file_name=file_name, 53 | create_time=time, 54 | file_temp=file_temp, 55 | ) 56 | return True 57 | 58 | @classmethod 59 | async def get_file(cls, file_id: str) -> Optional[Tuple[str, str]]: 60 | """ 61 | 说明: 62 | 根据id查找文件名 63 | 64 | 参数: 65 | * `file_id`: 文件id 66 | 67 | 返回: 68 | * `str | None`: 找到的文件路径 69 | * `str`: 文件名 70 | """ 71 | model = await cls.filter(file_id=file_id).first() 72 | if model: 73 | return model.file_path, model.file_name 74 | return None 75 | 76 | @classmethod 77 | async def clean_file(cls, days: int = 3) -> list[str]: 78 | """ 79 | 说明: 80 | 清理超过`days`天的数据库缓存 81 | 82 | 参数: 83 | * `days`: 天数 84 | """ 85 | time = datetime.now() - timedelta(days=days) 86 | files = await cls.filter(create_time__lte=time) 87 | file_paths = [] 88 | for file in files: 89 | if file.file_temp: 90 | file_paths.append(file.file_path) 91 | await file.delete() 92 | return file_paths 93 | 94 | @classmethod 95 | async def reset(cls) -> None: 96 | """ 97 | 重置数据库,将所有记录删除 98 | """ 99 | await cls.all().delete() 100 | 101 | 102 | async def database_init() -> None: 103 | """ 104 | 数据库初始化 105 | """ 106 | logger.debug("正在注册数据库...") 107 | Path(f"./{DATABASE_PATH}").mkdir(exist_ok=True) 108 | database_path = f"./{DATABASE_PATH}/data.db" 109 | db_url = f"sqlite://{database_path}" 110 | # 这里填要加载的表 111 | models = [ 112 | "wechatbot_client.file_manager.model", 113 | ] 114 | modules = {"models": models} 115 | await Tortoise.init(db_url=db_url, modules=modules) 116 | await Tortoise.generate_schemas() 117 | logger.info("数据库初始化成功...") 118 | 119 | 120 | async def database_close() -> None: 121 | """ 122 | 关闭数据库 123 | """ 124 | await Tortoise.close_connections() 125 | -------------------------------------------------------------------------------- /wechatbot_client/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | 日志模块 3 | """ 4 | import logging 5 | import sys 6 | from pathlib import Path 7 | from typing import Union 8 | 9 | from loguru._logger import Core, Logger 10 | 11 | from wechatbot_client.consts import LOG_PATH 12 | 13 | logger = Logger( 14 | core=Core(), 15 | exception=None, 16 | depth=0, 17 | record=False, 18 | lazy=False, 19 | colors=True, 20 | raw=False, 21 | capture=True, 22 | patcher=None, 23 | extra={}, 24 | ) 25 | """logger对象""" 26 | 27 | 28 | class Filter: 29 | """过滤器类""" 30 | 31 | def __init__(self) -> None: 32 | self.level: Union[int, str] = "INFO" 33 | 34 | def __call__(self, record): 35 | module_name: str = record["name"] 36 | record["name"] = module_name.split(".")[0] 37 | levelno = ( 38 | logger.level(self.level).no if isinstance(self.level, str) else self.level 39 | ) 40 | return record["level"].no >= levelno 41 | 42 | 43 | default_format: str = ( 44 | "{time:MM-DD HH:mm:ss} " 45 | "[{level:^7}] " 46 | "{name:16} | " 47 | # "{function}:{line}| " 48 | "{message}" 49 | ) 50 | """默认日志格式""" 51 | 52 | 53 | class LoguruHandler(logging.Handler): # pragma: no cover 54 | def emit(self, record) -> None: 55 | try: 56 | level = logger.level(record.levelname).name 57 | except ValueError: 58 | level = record.levelno 59 | 60 | frame, depth = logging.currentframe(), 2 61 | while frame and frame.f_code.co_filename == logging.__file__: 62 | frame = frame.f_back 63 | depth += 1 64 | 65 | logger.opt(depth=depth, exception=record.exc_info).log( 66 | level, record.getMessage() 67 | ) 68 | 69 | 70 | default_filter = Filter() 71 | logger_id = logger.add( 72 | sys.stdout, 73 | level=0, 74 | diagnose=False, 75 | filter=default_filter, 76 | format=default_format, 77 | ) 78 | 79 | 80 | def log_init(log_days: int) -> None: 81 | """日志初始化""" 82 | cwd = Path(".") / LOG_PATH 83 | info_path = cwd / "info" 84 | debug_path = cwd / "debug" 85 | error_path = cwd / "error" 86 | info_path.mkdir(parents=True, exist_ok=True) 87 | debug_path.mkdir(parents=True, exist_ok=True) 88 | error_path.mkdir(parents=True, exist_ok=True) 89 | # 日志文件记录格式 90 | file_format = ( 91 | "{time:MM-DD HH:mm:ss} " 92 | "[{level}] " 93 | "{name} | " 94 | "{message}" 95 | ) 96 | 97 | # 错误日志文件记录格式 98 | error_format = ( 99 | "{time:MM-DD HH:mm:ss} " 100 | "[{level}] " 101 | "[{name}] | " 102 | "{function}:{line}| " 103 | "{message}" 104 | ) 105 | logger.add( 106 | f"./{LOG_PATH}/info/" + "{time:YYYY-MM-DD}.log", 107 | rotation="00:00", 108 | retention=f"{log_days} days", 109 | level="INFO", 110 | format=file_format, 111 | filter=default_filter, 112 | encoding="utf-8", 113 | ) 114 | logger.add( 115 | f"./{LOG_PATH}/debug/" + "{time:YYYY-MM-DD}.log", 116 | rotation="00:00", 117 | retention=f"{log_days} days", 118 | level="DEBUG", 119 | format=file_format, 120 | filter=default_filter, 121 | encoding="utf-8", 122 | ) 123 | logger.add( 124 | f"./{LOG_PATH}/error/" + "{time:YYYY-MM-DD}.log", 125 | rotation="00:00", 126 | retention=f"{log_days} days", 127 | level="ERROR", 128 | format=error_format, 129 | filter=default_filter, 130 | encoding="utf-8", 131 | ) 132 | -------------------------------------------------------------------------------- /wechatbot_client/onebot12/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | onebot12的消息与事件 3 | """ 4 | from .event import * 5 | from .message import Message as Message 6 | from .message import MessageSegment as MessageSegment 7 | 8 | __all__ = [ 9 | "Message", 10 | "MessageSegment", 11 | "Event", 12 | "PrivateMessageEvent", 13 | "GroupMessageEvent", 14 | "FriendIncreaseEvent", 15 | "FriendDecreaseEvent", 16 | "PrivateMessageDeleteEvent", 17 | "GroupMemberIncreaseEvent", 18 | "GroupMemberDecreaseEvent", 19 | "GroupAdminSetEvent", 20 | "GroupAdminUnsetEvent", 21 | "GroupMessageDeleteEvent", 22 | "GetPrivateFileNotice", 23 | "GetGroupFileNotice", 24 | "HeartbeatMetaEvent", 25 | ] 26 | -------------------------------------------------------------------------------- /wechatbot_client/onebot12/base_message.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from copy import deepcopy 3 | from dataclasses import asdict, dataclass, field 4 | from typing import ( 5 | Any, 6 | Dict, 7 | Generic, 8 | Iterable, 9 | List, 10 | Optional, 11 | Tuple, 12 | Type, 13 | TypeVar, 14 | Union, 15 | overload, 16 | ) 17 | 18 | from pydantic import parse_obj_as 19 | 20 | T = TypeVar("T") 21 | TMS = TypeVar("TMS", bound="MessageSegment") 22 | TM = TypeVar("TM", bound="Message") 23 | 24 | 25 | @dataclass 26 | class MessageSegment(abc.ABC, Generic[TM]): 27 | """消息段基类""" 28 | 29 | type: str 30 | """消息段类型""" 31 | data: Dict[str, Any] = field(default_factory=dict) 32 | """消息段数据""" 33 | 34 | @classmethod 35 | @abc.abstractmethod 36 | def get_message_class(cls) -> Type[TM]: 37 | """获取消息数组类型""" 38 | raise NotImplementedError 39 | 40 | @abc.abstractmethod 41 | def __str__(self) -> str: 42 | """该消息段所代表的 str,在命令匹配部分使用""" 43 | raise NotImplementedError 44 | 45 | def __len__(self) -> int: 46 | return len(str(self)) 47 | 48 | def __ne__(self: T, other: T) -> bool: 49 | return not self == other 50 | 51 | def __add__(self: TMS, other: Union[str, TMS, Iterable[TMS]]) -> TM: 52 | return self.get_message_class()(self) + other 53 | 54 | def __radd__(self: TMS, other: Union[str, TMS, Iterable[TMS]]) -> TM: 55 | return self.get_message_class()(other) + self 56 | 57 | @classmethod 58 | def __get_validators__(cls): 59 | yield cls._validate 60 | 61 | @classmethod 62 | def _validate(cls, value): 63 | if isinstance(value, cls): 64 | return value 65 | if not isinstance(value, dict): 66 | raise ValueError(f"Expected dict for MessageSegment, got {type(value)}") 67 | if "type" not in value: 68 | raise ValueError( 69 | f"Expected dict with 'type' for MessageSegment, got {value}" 70 | ) 71 | return cls(type=value["type"], data=value.get("data", {})) 72 | 73 | def get(self, key: str, default: Any = None): 74 | return asdict(self).get(key, default) 75 | 76 | def keys(self): 77 | return asdict(self).keys() 78 | 79 | def values(self): 80 | return asdict(self).values() 81 | 82 | def items(self): 83 | return asdict(self).items() 84 | 85 | def copy(self: T) -> T: 86 | return deepcopy(self) 87 | 88 | @abc.abstractmethod 89 | def is_text(self) -> bool: 90 | """当前消息段是否为纯文本""" 91 | raise NotImplementedError 92 | 93 | 94 | class Message(List[TMS], abc.ABC): 95 | """消息数组 96 | 97 | 参数: 98 | message: 消息内容 99 | """ 100 | 101 | def __init__( 102 | self, 103 | message: Union[str, None, Iterable[TMS], TMS] = None, 104 | ): 105 | super().__init__() 106 | if message is None: 107 | return 108 | elif isinstance(message, str): 109 | self.extend(self._construct(message)) 110 | elif isinstance(message, MessageSegment): 111 | self.append(message) 112 | elif isinstance(message, Iterable): 113 | self.extend(message) 114 | else: 115 | self.extend(self._construct(message)) # pragma: no cover 116 | 117 | @classmethod 118 | @abc.abstractmethod 119 | def get_segment_class(cls) -> Type[TMS]: 120 | """获取消息段类型""" 121 | raise NotImplementedError 122 | 123 | def __str__(self) -> str: 124 | return "".join(str(seg) for seg in self) 125 | 126 | @classmethod 127 | def __get_validators__(cls): 128 | yield cls._validate 129 | 130 | @classmethod 131 | def _validate(cls, value): 132 | if isinstance(value, cls): 133 | return value 134 | elif isinstance(value, Message): 135 | raise ValueError(f"Type {type(value)} can not be converted to {cls}") 136 | elif isinstance(value, str): 137 | pass 138 | elif isinstance(value, dict): 139 | value = parse_obj_as(cls.get_segment_class(), value) 140 | elif isinstance(value, Iterable): 141 | value = [parse_obj_as(cls.get_segment_class(), v) for v in value] 142 | else: 143 | raise ValueError( 144 | f"Expected str, dict or iterable for Message, got {type(value)}" 145 | ) 146 | return cls(value) 147 | 148 | @staticmethod 149 | @abc.abstractmethod 150 | def _construct(msg: str) -> Iterable[TMS]: 151 | """构造消息数组""" 152 | raise NotImplementedError 153 | 154 | def __add__(self: TM, other: Union[str, TMS, Iterable[TMS]]) -> TM: 155 | result = self.copy() 156 | result += other 157 | return result 158 | 159 | def __radd__(self: TM, other: Union[str, TMS, Iterable[TMS]]) -> TM: 160 | result = self.__class__(other) 161 | return result + self 162 | 163 | def __iadd__(self: TM, other: Union[str, TMS, Iterable[TMS]]) -> TM: 164 | if isinstance(other, str): 165 | self.extend(self._construct(other)) 166 | elif isinstance(other, MessageSegment): 167 | self.append(other) 168 | elif isinstance(other, Iterable): 169 | self.extend(other) 170 | else: 171 | raise TypeError(f"Unsupported type {type(other)!r}") 172 | return self 173 | 174 | @overload 175 | def __getitem__(self: TM, __args: str) -> TM: 176 | """ 177 | 参数: 178 | __args: 消息段类型 179 | 180 | 返回: 181 | 所有类型为 `__args` 的消息段 182 | """ 183 | 184 | @overload 185 | def __getitem__(self, __args: Tuple[str, int]) -> TMS: 186 | """ 187 | 参数: 188 | __args: 消息段类型和索引 189 | 190 | 返回: 191 | 类型为 `__args[0]` 的消息段第 `__args[1]` 个 192 | """ 193 | 194 | @overload 195 | def __getitem__(self: TM, __args: Tuple[str, slice]) -> TM: 196 | """ 197 | 参数: 198 | __args: 消息段类型和切片 199 | 200 | 返回: 201 | 类型为 `__args[0]` 的消息段切片 `__args[1]` 202 | """ 203 | 204 | @overload 205 | def __getitem__(self, __args: int) -> TMS: 206 | """ 207 | 参数: 208 | __args: 索引 209 | 210 | 返回: 211 | 第 `__args` 个消息段 212 | """ 213 | 214 | @overload 215 | def __getitem__(self: TM, __args: slice) -> TM: 216 | """ 217 | 参数: 218 | __args: 切片 219 | 220 | 返回: 221 | 消息切片 `__args` 222 | """ 223 | 224 | def __getitem__( 225 | self: TM, 226 | args: Union[ 227 | str, 228 | Tuple[str, int], 229 | Tuple[str, slice], 230 | int, 231 | slice, 232 | ], 233 | ) -> Union[TMS, TM]: 234 | arg1, arg2 = args if isinstance(args, tuple) else (args, None) 235 | if isinstance(arg1, int) and arg2 is None: 236 | return super().__getitem__(arg1) 237 | elif isinstance(arg1, slice) and arg2 is None: 238 | return self.__class__(super().__getitem__(arg1)) 239 | elif isinstance(arg1, str) and arg2 is None: 240 | return self.__class__(seg for seg in self if seg.type == arg1) 241 | elif isinstance(arg1, str) and isinstance(arg2, int): 242 | return [seg for seg in self if seg.type == arg1][arg2] 243 | elif isinstance(arg1, str) and isinstance(arg2, slice): 244 | return self.__class__([seg for seg in self if seg.type == arg1][arg2]) 245 | else: 246 | raise ValueError("Incorrect arguments to slice") # pragma: no cover 247 | 248 | def index(self, value: Union[TMS, str], *args) -> int: 249 | if isinstance(value, str): 250 | first_segment = next((seg for seg in self if seg.type == value), None) 251 | if first_segment is None: 252 | raise ValueError(f"Segment with type {value} is not in message") 253 | return super().index(first_segment, *args) 254 | return super().index(value, *args) 255 | 256 | def get(self: TM, type_: str, count: Optional[int] = None) -> TM: 257 | if count is None: 258 | return self[type_] 259 | 260 | iterator, filtered = ( 261 | seg for seg in self if seg.type == type_ 262 | ), self.__class__() 263 | for _ in range(count): 264 | seg = next(iterator, None) 265 | if seg is None: 266 | break 267 | filtered.append(seg) 268 | return filtered 269 | 270 | def count(self, value: Union[TMS, str]) -> int: 271 | return len(self[value]) if isinstance(value, str) else super().count(value) 272 | 273 | def append(self: TM, obj: Union[str, TMS]) -> TM: 274 | """添加一个消息段到消息数组末尾。 275 | 276 | 参数: 277 | obj: 要添加的消息段 278 | """ 279 | if isinstance(obj, MessageSegment): 280 | super().append(obj) 281 | elif isinstance(obj, str): 282 | self.extend(self._construct(obj)) 283 | else: 284 | raise ValueError(f"Unexpected type: {type(obj)} {obj}") # pragma: no cover 285 | return self 286 | 287 | def extend(self: TM, obj: Union[TM, Iterable[TMS]]) -> TM: 288 | """拼接一个消息数组或多个消息段到消息数组末尾。 289 | 290 | 参数: 291 | obj: 要添加的消息数组 292 | """ 293 | for segment in obj: 294 | self.append(segment) 295 | return self 296 | 297 | def copy(self: TM) -> TM: 298 | return deepcopy(self) 299 | 300 | def extract_plain_text(self) -> str: 301 | """提取消息内纯文本消息""" 302 | 303 | return "".join(str(seg) for seg in self if seg.is_text()) 304 | -------------------------------------------------------------------------------- /wechatbot_client/onebot12/event.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import BaseModel, Extra 4 | 5 | from wechatbot_client.consts import PLATFORM, PREFIX 6 | 7 | from .message import Message 8 | 9 | 10 | class BotSelf(BaseModel): 11 | """机器人自身""" 12 | 13 | platform: str = PLATFORM 14 | """消息平台""" 15 | user_id: str 16 | """机器人用户 ID""" 17 | 18 | 19 | class BaseEvent(BaseModel, extra=Extra.allow): 20 | """ 21 | 基础事件 22 | """ 23 | 24 | id: str 25 | """事件id""" 26 | time: float 27 | """时间""" 28 | type: Literal["message", "notice", "request", "meta"] 29 | """类型""" 30 | detail_type: str 31 | """细节类型""" 32 | sub_type: str = "" 33 | """子类型""" 34 | 35 | 36 | class Event(BaseEvent, extra=Extra.allow): 37 | """OneBot V12 协议事件,字段与 OneBot 一致 38 | 39 | 参考文档:[OneBot 文档](https://12.1bot.dev) 40 | """ 41 | 42 | self: BotSelf 43 | """自身标识""" 44 | 45 | 46 | class MessageEvent(Event): 47 | """ 48 | 消息事件 49 | """ 50 | 51 | type: Literal["message"] = "message" 52 | """事件类型""" 53 | message_id: str 54 | """消息id""" 55 | message: Message 56 | """消息""" 57 | alt_message: str 58 | """消息替代表示""" 59 | user_id: str 60 | """用户id""" 61 | 62 | 63 | class PrivateMessageEvent(MessageEvent): 64 | """私聊消息""" 65 | 66 | detail_type: Literal["private"] = "private" 67 | 68 | 69 | class GroupMessageEvent(MessageEvent): 70 | """群消息""" 71 | 72 | detail_type: Literal["group"] = "group" 73 | group_id: str 74 | """群聊id""" 75 | 76 | 77 | class NoticeEvent(Event): 78 | """通知事件""" 79 | 80 | type: Literal["notice"] = "notice" 81 | 82 | 83 | class FriendIncreaseEvent(NoticeEvent): 84 | """好友增加事件""" 85 | 86 | detail_type: Literal["friend_increase"] = "friend_increase" 87 | user_id: str 88 | """添加id""" 89 | 90 | 91 | # 无通知 92 | class FriendDecreaseEvent(NoticeEvent): 93 | """好友减少事件""" 94 | 95 | detail_type: Literal["friend_decrease"] = "friend_decrease" 96 | user_id: str 97 | 98 | 99 | class PrivateMessageDeleteEvent(NoticeEvent): 100 | """私聊消息删除""" 101 | 102 | detail_type: Literal["private_message_delete"] = "private_message_delete" 103 | message_id: str 104 | user_id: str 105 | 106 | 107 | class GroupMemberIncreaseEvent(NoticeEvent): 108 | """群成员增加事件""" 109 | 110 | detail_type: Literal["group_member_increase"] = "group_member_increase" 111 | group_id: str 112 | """群id""" 113 | user_id: str 114 | """群成员id""" 115 | operator_id: str 116 | """操作者id""" 117 | 118 | 119 | class GroupMemberDecreaseEvent(NoticeEvent): 120 | """群成员减少事件""" 121 | 122 | detail_type: Literal["group_member_decrease"] = "group_member_decrease" 123 | group_id: str 124 | """群id""" 125 | user_id: str 126 | """被踢id""" 127 | operator_id: str 128 | """操作者id""" 129 | 130 | 131 | class GroupAdminSetEvent(NoticeEvent): 132 | """群管理员设置事件""" 133 | 134 | detail_type: Literal["group_admin_set"] = "group_admin_set" 135 | group_id: str 136 | """群id""" 137 | user_id: str 138 | """用户id""" 139 | operator_id: str 140 | """操作者id""" 141 | 142 | 143 | class GroupAdminUnsetEvent(NoticeEvent): 144 | """群管理员取消设置事件""" 145 | 146 | detail_type: Literal["group_admin_unset"] = "group_admin_unset" 147 | group_id: str 148 | """群id""" 149 | user_id: str 150 | """用户id""" 151 | operator_id: str 152 | """操作者id""" 153 | 154 | 155 | class GroupMessageDeleteEvent(NoticeEvent): 156 | """群消息删除事件""" 157 | 158 | detail_type: Literal["group_message_delete"] = "group_message_delete" 159 | sub_type: Literal["delete"] = "delete" 160 | group_id: str 161 | """群id""" 162 | message_id: str 163 | """消息id""" 164 | user_id: str 165 | """用户id""" 166 | operator_id: str 167 | """操作者id""" 168 | 169 | 170 | class GetPrivateFileNotice(NoticeEvent): 171 | """ 172 | 私聊接收文件通知,在接收到文件时会发送通知(此时文件还未下载) 173 | """ 174 | 175 | detail_type = f"{PREFIX}.get_private_file" 176 | 177 | file_name: str 178 | """文件名""" 179 | file_length: int 180 | """文件长度""" 181 | md5: str 182 | """文件md5""" 183 | user_id: str 184 | """发送方id""" 185 | 186 | 187 | class GetGroupFileNotice(NoticeEvent): 188 | """ 189 | 群聊接收文件通知,在接收到文件时会发送通知(此时文件还未下载) 190 | """ 191 | 192 | detail_type = f"{PREFIX}.get_group_file" 193 | 194 | file_name: str 195 | """文件名""" 196 | file_length: int 197 | """文件长度""" 198 | md5: str 199 | """文件md5""" 200 | user_id: str 201 | """发送方id""" 202 | group_id: str 203 | """群聊id""" 204 | 205 | 206 | class GetPrivateRedBagNotice(NoticeEvent): 207 | """ 208 | 私聊获取红包提示 209 | """ 210 | 211 | detail_type = f"{PREFIX}.get_private_redbag" 212 | user_id: str 213 | """发送方id""" 214 | 215 | 216 | class GetGroupRedBagNotice(NoticeEvent): 217 | """ 218 | 群聊获取红包提示 219 | """ 220 | 221 | detail_type = f"{PREFIX}.get_group_redbag" 222 | group_id: str 223 | """群id""" 224 | user_id: str 225 | """发送方""" 226 | 227 | 228 | class GetPrivatePokeNotice(NoticeEvent): 229 | """ 230 | 私聊拍一拍 231 | """ 232 | 233 | detail_type = f"{PREFIX}.get_private_poke" 234 | user_id: str 235 | """接收方id""" 236 | from_user_id: str 237 | """发送方id""" 238 | 239 | 240 | class GetGroupPokeNotice(NoticeEvent): 241 | """ 242 | 群聊拍一拍 243 | """ 244 | 245 | detail_type = f"{PREFIX}.get_group_poke" 246 | group_id: str 247 | """群id""" 248 | user_id: str 249 | """接收方id""" 250 | from_user_id: str 251 | """发送方id""" 252 | 253 | 254 | class GetGroupAnnouncementNotice(NoticeEvent): 255 | """ 256 | 群公告 257 | """ 258 | 259 | detail_type = f"{PREFIX}.get_group_announcement" 260 | group_id: str 261 | """群id""" 262 | user_id: str 263 | """操作者""" 264 | text: str 265 | """公告内容""" 266 | 267 | 268 | class GetPrivateCardNotice(NoticeEvent): 269 | """ 270 | 私聊获取名片 271 | """ 272 | 273 | detail_type = f"{PREFIX}.get_private_card" 274 | user_id: str 275 | """发送方id""" 276 | v3: str 277 | """名片v3信息""" 278 | v4: str 279 | """名片v4信息""" 280 | nickname: str 281 | """名片nickname""" 282 | head_url: str 283 | """头像url""" 284 | province: str 285 | """省""" 286 | city: str 287 | """市""" 288 | sex: str 289 | """性别""" 290 | 291 | 292 | class GetGroupCardNotice(NoticeEvent): 293 | """群聊获取名片""" 294 | 295 | detail_type = f"{PREFIX}.get_group_card" 296 | group_id: str 297 | """群聊id""" 298 | user_id: str 299 | """发送方id""" 300 | v3: str 301 | """名片v3信息""" 302 | v4: str 303 | """名片v4信息""" 304 | nickname: str 305 | """名片nickname""" 306 | head_url: str 307 | """头像url""" 308 | province: str 309 | """省""" 310 | city: str 311 | """市""" 312 | sex: str 313 | """性别""" 314 | 315 | 316 | class RequestEvent(Event): 317 | """请求事件""" 318 | 319 | type: Literal["request"] = "request" 320 | 321 | 322 | class FriendRequestEvent(RequestEvent): 323 | """ 324 | 好友请求 325 | """ 326 | 327 | detail_type: str = f"{PREFIX}.friend_request" 328 | user_id: str 329 | """添加id""" 330 | v3: str 331 | """v3信息""" 332 | v4: str 333 | """v4信息""" 334 | nickname: str 335 | """昵称""" 336 | content: str 337 | """附加的话""" 338 | country: str 339 | """国家""" 340 | province: str 341 | """省份""" 342 | city: str 343 | """城市""" 344 | 345 | 346 | class MetaEvent(BaseEvent): 347 | """元事件""" 348 | 349 | type: Literal["meta"] = "meta" 350 | 351 | 352 | class HeartbeatMetaEvent(MetaEvent): 353 | """心跳事件""" 354 | 355 | detail_type: Literal["heartbeat"] = "heartbeat" 356 | interval: int 357 | # status: Status 358 | 359 | 360 | class ConnectEvent(MetaEvent): 361 | """连接事件""" 362 | 363 | detail_type: Literal["connect"] = "connect" 364 | version: dict[str, str] 365 | """版本""" 366 | 367 | 368 | class BotStatus(BaseModel): 369 | self: BotSelf 370 | online: bool 371 | 372 | 373 | class Status(BaseModel): 374 | good: bool 375 | bots: list[BotStatus] 376 | 377 | 378 | class StatusUpdateEvent(MetaEvent): 379 | """状态更新事件""" 380 | 381 | detail_type: Literal["status_update"] = "status_update" 382 | status: Status 383 | -------------------------------------------------------------------------------- /wechatbot_client/onebot12/face.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Face(str, Enum): 5 | """表情""" 6 | 7 | Smile = "[微笑]" 8 | Grimace = "[撇嘴]" 9 | Drool = "[色]" 10 | Scowl = "[发呆]" 11 | CoolGuy = "[得意]" 12 | Sob = "[流泪]" 13 | Shy = "[害羞]" 14 | Silent = "[闭嘴]" 15 | Sleep = "[睡]" 16 | Cry = "[大哭]" 17 | Awkward = "[尴尬]" 18 | Angry = "[发怒]" 19 | Tongue = "[调皮]" 20 | Grin = "[呲牙]" 21 | Surprise = "[惊讶]" 22 | Frown = "[难过]" 23 | Ruthless = "[酷]" 24 | Blush = "[冷汗]" 25 | Scream = "[抓狂]" 26 | Puke = "[吐]" 27 | Chuckle = "[偷笑]" 28 | Joyful = "[愉快]" 29 | Slight = "[白眼]" 30 | Smug = "[傲慢]" 31 | Hungry = "[饥饿]" 32 | Drowsy = "[困]" 33 | Panic = "[惊恐]" 34 | Sweat = "[流汗]" 35 | Laugh = "[憨笑]" 36 | Commando = "[悠闲]" 37 | Determined = "[奋斗]" 38 | Scold = "[咒骂]" 39 | Shocked = "[疑问]" 40 | Shhh = "[嘘]" 41 | Dizzy = "[晕]" 42 | Tormented = "[疯了]" 43 | Toasted = "[衰]" 44 | Skull = "[骷髅]" 45 | Hammer = "[敲打]" 46 | Wave = "[再见]" 47 | Speechless = "[擦汗]" 48 | NosePick = "[抠鼻]" 49 | Clap = "[鼓掌]" 50 | Shame = "[糗大了]" 51 | Trick = "[坏笑]" 52 | BahL = "[左哼哼]" 53 | BahR = "[右哼哼]" 54 | Yawn = "[哈欠]" 55 | PoohPooh = "[鄙视]" 56 | Shrunken = "[委屈]" 57 | TearingUp = "[快哭了]" 58 | Sly = "[阴险]" 59 | Kiss = "[亲亲]" 60 | Wrath = "[吓]" 61 | Whimper = "[可怜]" 62 | Cleaver = "[菜刀]" 63 | Watermelon = "[西瓜]" 64 | Beer = "[啤酒]" 65 | Basketball = "[篮球]" 66 | PingPong = "[乒乓]" 67 | Coffee = "[咖啡]" 68 | Rice = "[饭]" 69 | Pig = "[猪头]" 70 | Rose = "[玫瑰]" 71 | Wilt = "[凋谢]" 72 | Lips = "[嘴唇]" 73 | Heart = "[爱心]" 74 | BrokenHeart = "[心碎]" 75 | Cake = "[蛋糕]" 76 | Lightning = "[闪电]" 77 | Bomb = "[炸弹]" 78 | Dagger = "[刀]" 79 | Soccer = "[足球]" 80 | Ladybug = "[瓢虫]" 81 | Poop = "[便便]" 82 | Moon = "[月亮]" 83 | Sun = "[太阳]" 84 | Gift = "[礼物]" 85 | Hug = "[拥抱]" 86 | ThumbsUp = "[强]" 87 | ThumbsDown = "[弱]" 88 | Shake = "[握手]" 89 | Peace = "[胜利]" 90 | Fight = "[抱拳]" 91 | Beckon = "[勾引]" 92 | Fist = "[拳头]" 93 | Pinky = "[差劲]" 94 | RockOn = "[爱你]" 95 | Nuhuh = "[NO]" 96 | OK = "[OK]" 97 | InLove = "[爱情]" 98 | Blowkiss = "[飞吻]" 99 | Waddle = "[跳跳]" 100 | Tremble = "[发抖]" 101 | Aaagh = "[怄火]" 102 | Twirl = "[转圈]" 103 | Kotow = "[磕头]" 104 | Dramatic = "[回头]" 105 | JumpRope = "[跳绳]" 106 | Surrender = "[投降]" 107 | Hooray = "[激动]" 108 | Meditate = "[乱舞]" 109 | Smooch = "[献吻]" 110 | TaiChiL = "[左太极]" 111 | TaiChiR = "[右太极]" 112 | Hey = "[嘿哈]" 113 | Facepalm = "[捂脸]" 114 | Smirk = "[奸笑]" 115 | Smart = "[机智]" 116 | Moue = "[皱眉]" 117 | Yeah = "[耶]" 118 | Tea = "[茶]" 119 | Packet = "[红包]" 120 | Candle = "[蜡烛]" 121 | Blessing = "[福]" 122 | Chick = "[鸡]" 123 | Onlooker = "[吃瓜]" 124 | GoForIt = "[加油]" 125 | Sweats = "[汗]" 126 | OMG = "[天啊]" 127 | Emm = "[Emm]" 128 | Respect = "[社会社会]" 129 | Doge = "[旺柴]" 130 | NoProb = "[好的]" 131 | MyBad = "[打脸]" 132 | KeepFighting = "[加油加油]" 133 | Wow = "[哇]" 134 | Rich = "[發]" 135 | Broken = "[裂开]" 136 | Hurt = "[苦涩]" 137 | Sigh = "[叹气]" 138 | LetMeSee = "[让我看看]" 139 | Awesome = "[666]" 140 | Boring = "[翻白眼]" 141 | -------------------------------------------------------------------------------- /wechatbot_client/onebot12/message.py: -------------------------------------------------------------------------------- 1 | """ 2 | onebot12消息实现,直接搬运adapter-onebot12 3 | """ 4 | from typing import Iterable, Type 5 | 6 | from wechatbot_client.consts import PREFIX 7 | from wechatbot_client.typing import overrides 8 | 9 | from .base_message import Message as BaseMessage 10 | from .base_message import MessageSegment as BaseMessageSegment 11 | 12 | 13 | class MessageSegment(BaseMessageSegment["Message"]): 14 | """OneBot v12 协议 MessageSegment 适配。具体方法参考协议消息段类型或源码。""" 15 | 16 | @classmethod 17 | @overrides(BaseMessageSegment) 18 | def get_message_class(cls) -> Type["Message"]: 19 | return Message 20 | 21 | @overrides(BaseMessageSegment) 22 | def __str__(self) -> str: 23 | match self.type: 24 | case "text": 25 | return self.data.get("text", "") 26 | case "mention": 27 | return f"@{self.data['user_id']} " 28 | case "mention_all": 29 | return "notify@all" 30 | case "image": 31 | return "[图片]" 32 | case "voice": 33 | return "[语音]" 34 | case "video": 35 | return "[视频]" 36 | case "file": 37 | return "[文件]" 38 | case "location": 39 | return f"[位置]:{self.data['title']} " 40 | case "card": 41 | return f"[名片]:{self.data['nickname']} " 42 | case "link": 43 | return f"[链接]:{self.data['tittle']} " 44 | case _: 45 | return "" 46 | 47 | @overrides(BaseMessageSegment) 48 | def is_text(self) -> bool: 49 | return self.type == "text" or self.type == "quote" 50 | 51 | @staticmethod 52 | def text(text: str) -> "MessageSegment": 53 | """文本消息""" 54 | return MessageSegment("text", {"text": text}) 55 | 56 | @staticmethod 57 | def mention(user_id: str) -> "MessageSegment": 58 | """at消息""" 59 | return MessageSegment("mention", {"user_id": user_id}) 60 | 61 | @staticmethod 62 | def mention_all() -> "MessageSegment": 63 | """全体at消息""" 64 | return MessageSegment("mention_all", {}) 65 | 66 | @staticmethod 67 | def image(file_id: str) -> "MessageSegment": 68 | """图片消息""" 69 | return MessageSegment("image", {"file_id": file_id}) 70 | 71 | @staticmethod 72 | def voice(file_id: str) -> "MessageSegment": 73 | """语音消息""" 74 | return MessageSegment("voice", {"file_id": file_id}) 75 | 76 | @staticmethod 77 | def video(file_id: str) -> "MessageSegment": 78 | """视频消息""" 79 | return MessageSegment("video", {"file_id": file_id}) 80 | 81 | @staticmethod 82 | def file(file_id: str) -> "MessageSegment": 83 | """文件消息""" 84 | return MessageSegment("file", {"file_id": file_id}) 85 | 86 | @staticmethod 87 | def location( 88 | latitude: float, # 维度 89 | longitude: float, # 经度 90 | title: str, # 标题 91 | content: str, # 描述 92 | ) -> "MessageSegment": 93 | """位置消息""" 94 | return MessageSegment( 95 | "location", 96 | { 97 | "latitude": latitude, 98 | "longitude": longitude, 99 | "title": title, 100 | "content": content, 101 | }, 102 | ) 103 | 104 | @staticmethod 105 | def reply(message_id: str, user_id: str) -> "MessageSegment": 106 | """引用消息""" 107 | return MessageSegment("reply", {"message_id": message_id, "user_id": user_id}) 108 | 109 | @staticmethod 110 | def emoji(file_id: str) -> "MessageSegment": 111 | """gif表情""" 112 | return MessageSegment(f"{PREFIX}.emoji", {"file_id": file_id}) 113 | 114 | @staticmethod 115 | def face(dec: str) -> "MessageSegment": 116 | """表情""" 117 | return MessageSegment(f"{PREFIX}.face", {"dec": dec}) 118 | 119 | @staticmethod 120 | def link( 121 | title: str, des: str, url: str, file_id: str # 标题 # 描述 # 链接url # 大图位置 122 | ) -> "MessageSegment": 123 | """链接消息""" 124 | return MessageSegment( 125 | f"{PREFIX}.link", 126 | {"title": title, "des": des, "url": url, "file_id": file_id}, 127 | ) 128 | 129 | @staticmethod 130 | def app( 131 | appid: str, 132 | title: str, 133 | url: str, 134 | ) -> "MessageSegment": 135 | """ 136 | app消息 137 | """ 138 | return MessageSegment( 139 | f"{PREFIX}.app", {"appid": appid, "title": title, "url": url} 140 | ) 141 | 142 | 143 | class Message(BaseMessage[MessageSegment]): 144 | @classmethod 145 | @overrides(BaseMessage) 146 | def get_segment_class(cls) -> Type[MessageSegment]: 147 | return MessageSegment 148 | 149 | @staticmethod 150 | @overrides(BaseMessage) 151 | def _construct(msg: str) -> Iterable[MessageSegment]: 152 | yield MessageSegment.text(msg) 153 | 154 | @overrides(BaseMessage) 155 | def extract_plain_text(self) -> str: 156 | return "".join(seg.data["text"] for seg in self if seg.is_text()) 157 | 158 | def ruduce(self) -> None: 159 | index = 1 160 | while index < len(self): 161 | if self[index - 1].type == "text" and self[index].type == "text": 162 | self[index - 1].data["text"] += self[index].data["text"] 163 | del self[index] 164 | else: 165 | index += 1 166 | -------------------------------------------------------------------------------- /wechatbot_client/scheduler.py: -------------------------------------------------------------------------------- 1 | """ 2 | 定时器模块 3 | """ 4 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 5 | 6 | from wechatbot_client.utils import logger_wrapper 7 | 8 | log = logger_wrapper("scheduler") 9 | scheduler = AsyncIOScheduler(timezone="Asia/Shanghai") 10 | 11 | 12 | def scheduler_init() -> None: 13 | """定时器初始化""" 14 | global scheduler 15 | if not scheduler.running: 16 | scheduler.start() 17 | log("SUCCESS", "定时器模块已开启...") 18 | 19 | 20 | def scheduler_shutdown() -> None: 21 | """定时器关闭""" 22 | log("INFO", "正在关闭定时器...") 23 | if scheduler.running: 24 | scheduler.shutdown(wait=False) 25 | log("SUCCESS", "定时器模块已关闭...") 26 | -------------------------------------------------------------------------------- /wechatbot_client/startup.py: -------------------------------------------------------------------------------- 1 | """ 2 | 启动行为管理,将各类业务剥离开 3 | """ 4 | import asyncio 5 | import time 6 | from functools import partial 7 | from signal import SIGINT, raise_signal 8 | from uuid import uuid4 9 | 10 | from comtypes.client import PumpEvents 11 | 12 | from wechatbot_client import get_driver, get_wechat 13 | from wechatbot_client.action_manager import router 14 | from wechatbot_client.config import Config, WebsocketType 15 | from wechatbot_client.consts import FILE_CACHE 16 | from wechatbot_client.driver import URL, HTTPServerSetup, WebSocketServerSetup 17 | from wechatbot_client.file_manager import database_close, database_init 18 | from wechatbot_client.log import logger 19 | from wechatbot_client.onebot12 import HeartbeatMetaEvent 20 | from wechatbot_client.scheduler import scheduler, scheduler_init, scheduler_shutdown 21 | 22 | driver = get_driver() 23 | wechat = get_wechat() 24 | pump_event_task: asyncio.Task = None 25 | 26 | 27 | @driver.on_startup 28 | async def start_up() -> None: 29 | """ 30 | 启动行为管理 31 | """ 32 | global pump_event_task 33 | config: Config = wechat.config 34 | # 开启定时器 35 | scheduler_init() 36 | # 开启心跳事件 37 | if config.heartbeat_enabled: 38 | logger.debug(f"开启心跳事件,间隔 {config.heartbeat_interval} ms") 39 | scheduler.add_job( 40 | func=partial(heartbeat_event, config.heartbeat_interval), 41 | trigger="interval", 42 | seconds=int(config.heartbeat_interval / 1000), 43 | ) 44 | # 开启自动清理缓存 45 | if config.cache_days > 0: 46 | logger.debug(f"开启自动清理缓存,间隔 {config.cache_days} 天") 47 | scheduler.add_job( 48 | func=partial(clean_filecache, config.cache_days), 49 | trigger="cron", 50 | hour=0, 51 | minute=0, 52 | second=0, 53 | ) 54 | # 开启数据库 55 | await database_init() 56 | # 注册消息事件 57 | wechat.open_recv_msg(f"./{FILE_CACHE}") 58 | # 开始监听event 59 | pump_event_task = asyncio.create_task(pump_event()) 60 | # 开启http路由 61 | if config.enable_http_api: 62 | wechat.setup_http_server( 63 | HTTPServerSetup(URL("/"), "POST", "onebot", wechat.handle_http) 64 | ) 65 | # 开启ws连接任务 66 | if config.websocekt_type == WebsocketType.Forward: 67 | # 正向ws,建立监听 68 | wechat.setup_websocket_server( 69 | WebSocketServerSetup(URL("/"), "onebot", wechat.handle_ws) 70 | ) 71 | elif config.websocekt_type == WebsocketType.Backward: 72 | # 反向ws,连接应用端 73 | await wechat.start_backward() 74 | # 添加get_file路由 75 | driver.server_app.include_router(router) 76 | 77 | 78 | @driver.on_shutdown 79 | async def shutdown() -> None: 80 | """ 81 | 关闭行为管理 82 | """ 83 | # 关闭定时器 84 | scheduler_shutdown() 85 | # 关闭数据库 86 | await database_close() 87 | if pump_event_task: 88 | if not pump_event_task.done(): 89 | pump_event_task.cancel() 90 | await wechat.stop_backward() 91 | wechat.close() 92 | 93 | 94 | async def pump_event() -> None: 95 | """接收event循环""" 96 | while True: 97 | try: 98 | await asyncio.sleep(0) 99 | PumpEvents(0.01) 100 | except KeyboardInterrupt: 101 | raise_signal(SIGINT) 102 | return 103 | 104 | 105 | async def heartbeat_event(interval: int) -> None: 106 | """ 107 | 心跳事件 108 | """ 109 | event_id = str(uuid4()) 110 | event = HeartbeatMetaEvent( 111 | id=event_id, 112 | time=time.time(), 113 | interval=interval, 114 | ) 115 | await wechat.handle_event(event) 116 | 117 | 118 | async def clean_filecache(days: int) -> None: 119 | """ 120 | 自动清理缓存 121 | """ 122 | logger.info("开始清理文件缓存任务...") 123 | nums = await wechat.file_manager.clean_cache(days) 124 | logger.success(f"清理缓存完成,共清理 {nums} 个文件...") 125 | -------------------------------------------------------------------------------- /wechatbot_client/typing.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, TypeVar 2 | 3 | T_Wrapped = TypeVar("T_Wrapped", bound=Callable) 4 | 5 | 6 | def overrides(InterfaceClass: object) -> Callable[[T_Wrapped], T_Wrapped]: 7 | """标记一个方法为父类 interface 的 implement""" 8 | 9 | def overrider(func: T_Wrapped) -> T_Wrapped: 10 | assert func.__name__ in dir(InterfaceClass), f"Error method: {func.__name__}" 11 | return func 12 | 13 | return overrider 14 | -------------------------------------------------------------------------------- /wechatbot_client/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | 工具模块,所有的工具 3 | """ 4 | import asyncio 5 | import dataclasses 6 | import inspect 7 | import json 8 | import re 9 | from base64 import b64encode 10 | from functools import partial, wraps 11 | from typing import Any, Callable, Coroutine, ForwardRef, Optional, ParamSpec, TypeVar 12 | 13 | from pydantic.typing import evaluate_forwardref 14 | 15 | from wechatbot_client.log import logger 16 | from wechatbot_client.typing import overrides 17 | 18 | P = ParamSpec("P") 19 | R = TypeVar("R") 20 | 21 | 22 | def escape_tag(s: str) -> str: 23 | """用于记录带颜色日志时转义 `` 类型特殊标签 24 | 25 | 参考: [loguru color 标签](https://loguru.readthedocs.io/en/stable/api/logger.html#color) 26 | 27 | 参数: 28 | s: 需要转义的字符串 29 | """ 30 | return re.sub(r"\s]*)>", r"\\\g<0>", s) 31 | 32 | 33 | def run_sync(call: Callable[P, R]) -> Callable[P, Coroutine[None, None, R]]: 34 | """一个用于包装 sync function 为 async function 的装饰器 35 | 36 | 参数: 37 | call: 被装饰的同步函数 38 | """ 39 | 40 | @wraps(call) 41 | async def _wrapper(*args: P.args, **kwargs: P.kwargs) -> R: 42 | loop = asyncio.get_running_loop() 43 | pfunc = partial(call, *args, **kwargs) 44 | result = await loop.run_in_executor(None, pfunc) 45 | return result 46 | 47 | return _wrapper 48 | 49 | 50 | def get_typed_signature(call: Callable[..., Any]) -> inspect.Signature: 51 | """获取可调用对象签名""" 52 | signature = inspect.signature(call) 53 | globalns = getattr(call, "__globals__", {}) 54 | typed_params = [ 55 | inspect.Parameter( 56 | name=param.name, 57 | kind=param.kind, 58 | default=param.default, 59 | annotation=get_typed_annotation(param, globalns), 60 | ) 61 | for param in signature.parameters.values() 62 | ] 63 | return inspect.Signature(typed_params) 64 | 65 | 66 | def get_typed_annotation(param: inspect.Parameter, globalns: dict[str, Any]) -> Any: 67 | """获取参数的类型注解""" 68 | annotation = param.annotation 69 | if isinstance(annotation, str): 70 | annotation = ForwardRef(annotation) 71 | try: 72 | annotation = evaluate_forwardref(annotation, globalns, globalns) 73 | except Exception as e: 74 | logger.opt(colors=True, exception=e).warning( 75 | f'Unknown ForwardRef["{param.annotation}"] for parameter {param.name}' 76 | ) 77 | return inspect.Parameter.empty 78 | return annotation 79 | 80 | 81 | def logger_wrapper(logger_name: str): 82 | """用于打印 adapter 的日志。 83 | 84 | 参数: 85 | logger_name: adapter 的名称 86 | 87 | 返回: 88 | 日志记录函数 89 | 90 | - level: 日志等级 91 | - message: 日志信息 92 | - exception: 异常信息 93 | """ 94 | 95 | def log(level: str, message: str, exception: Optional[Exception] = None): 96 | logger.opt(colors=True, exception=exception).log( 97 | level, f"{escape_tag(logger_name)} | {message}" 98 | ) 99 | 100 | return log 101 | 102 | 103 | class DataclassEncoder(json.JSONEncoder): 104 | """在JSON序列化 `Message` (List[Dataclass]) 时使用的 `JSONEncoder`""" 105 | 106 | @overrides(json.JSONEncoder) 107 | def default(self, o): 108 | if isinstance(o, bytes): 109 | return b64encode(o).decode() 110 | if dataclasses.is_dataclass(o): 111 | return {f.name: getattr(o, f.name) for f in dataclasses.fields(o)} 112 | return super().default(o) 113 | -------------------------------------------------------------------------------- /wechatbot_client/wechat/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | 微信客户端抽象,整合各种请求需求: 3 | - 处理`driver`: 上下发送请求 4 | - 处理`com_wechat`: 维护与comwechat的连接 5 | - 处理`api`: 处理api调用 6 | """ 7 | from .wechat import WeChatManager as WeChatManager 8 | -------------------------------------------------------------------------------- /wechatbot_client/wechat/adapter.py: -------------------------------------------------------------------------------- 1 | """ 2 | adapter,用来管理driver和websocket 3 | """ 4 | import asyncio 5 | import contextlib 6 | import json 7 | import time 8 | from abc import abstractmethod 9 | from typing import Any, AsyncGenerator, Optional, Union, cast 10 | from uuid import uuid4 11 | 12 | import msgpack 13 | from pydantic import ValidationError 14 | 15 | from wechatbot_client.action_manager import ( 16 | ActionRequest, 17 | ActionResponse, 18 | WsActionRequest, 19 | WsActionResponse, 20 | ) 21 | from wechatbot_client.config import Config, WebsocketType 22 | from wechatbot_client.consts import IMPL, ONEBOT_VERSION, USER_AGENT, VERSION 23 | from wechatbot_client.driver import ( 24 | URL, 25 | BackwardWebSocket, 26 | Driver, 27 | FastAPIWebSocket, 28 | HTTPServerSetup, 29 | Request, 30 | Response, 31 | WebSocket, 32 | WebSocketServerSetup, 33 | ) 34 | from wechatbot_client.exception import WebSocketClosed 35 | from wechatbot_client.onebot12 import ConnectEvent, Event, StatusUpdateEvent 36 | from wechatbot_client.utils import DataclassEncoder, escape_tag, logger_wrapper 37 | 38 | from .utils import get_auth_bearer 39 | 40 | log = logger_wrapper("OneBot V12") 41 | 42 | HTTP_EVENT_LIST: list[Event] = [] 43 | """get_latest_events的event储存""" 44 | 45 | 46 | def get_connet_event() -> ConnectEvent: 47 | """ 48 | 生成连接事件 49 | """ 50 | event_id = str(uuid4()) 51 | data = { 52 | "impl": IMPL, 53 | "version": VERSION, 54 | "onebot_version": ONEBOT_VERSION, 55 | } 56 | return ConnectEvent(id=event_id, time=time.time(), version=data) 57 | 58 | 59 | class Adapter: 60 | """ 61 | 适配器,用来处理websocket连接 62 | """ 63 | 64 | config: Config 65 | """应用设置""" 66 | event_models: dict 67 | """事件模型映射""" 68 | tasks: list[asyncio.Task] 69 | """反向连接ws任务列表""" 70 | driver: Driver 71 | """后端驱动""" 72 | 73 | def __init__(self, config: Config) -> None: 74 | self.config = config 75 | self.driver = Driver(config) 76 | self.tasks = [] 77 | 78 | def setup_http_server(self, setup: HTTPServerSetup) -> None: 79 | """设置一个 HTTP 服务器路由配置""" 80 | self.driver.setup_http_server(setup) 81 | 82 | def setup_websocket_server(self, setup: WebSocketServerSetup) -> None: 83 | """设置一个 WebSocket 服务器路由配置""" 84 | self.driver.setup_websocket_server(setup) 85 | 86 | async def request(self, setup: Request) -> Response: 87 | """进行一个 HTTP 客户端请求""" 88 | return await self.driver.request(setup) 89 | 90 | @contextlib.asynccontextmanager 91 | async def start_websocket( 92 | self, setup: Request 93 | ) -> AsyncGenerator[BackwardWebSocket, None]: 94 | """建立一个 WebSocket 客户端连接请求""" 95 | async with self.driver.start_websocket(setup) as ws: 96 | yield ws 97 | 98 | def _check_access_token(self, request: Request) -> Optional[Response]: 99 | """ 100 | 检测access_token 101 | """ 102 | token = get_auth_bearer(request.headers.get("Authorization")) 103 | 104 | access_token = self.config.access_token 105 | if access_token != "" and access_token != token: 106 | msg = ( 107 | "Authorization Header is invalid" 108 | if token 109 | else "Missing Authorization Header" 110 | ) 111 | log("WARNING", msg) 112 | return Response(403, content=msg) 113 | 114 | async def handle_ws(self, websocket: WebSocket) -> None: 115 | """ 116 | 当有新的ws连接时的任务 117 | """ 118 | 119 | # check access_token 120 | response = self._check_access_token(websocket.request) 121 | if response is not None: 122 | content = cast(str, response.content) 123 | await websocket.close(1008, content) 124 | return 125 | 126 | # 后续处理代码 127 | await websocket.accept() 128 | seq = self.driver.ws_connect(websocket) 129 | log("SUCCESS", f"新的websocket连接,编号为: {seq}...") 130 | # 发送元事件 131 | event = get_connet_event() 132 | try: 133 | await websocket.send(event.json(ensure_ascii=False)) 134 | except Exception as e: 135 | log("ERROR", f"发送connect事件失败:{e}") 136 | # 发送update事件 137 | event = self.get_status_update_event() 138 | try: 139 | await websocket.send(event.json(ensure_ascii=False, cls=DataclassEncoder)) 140 | except Exception as e: 141 | log("ERROR", f"发送status_update事件失败:{e}") 142 | try: 143 | while True: 144 | data = await websocket.receive() 145 | raw_data = ( 146 | json.loads(data) if isinstance(data, str) else msgpack.unpackb(data) 147 | ) 148 | if action := self.json_to_ws_action(raw_data): 149 | response = await self.action_ws_request(action) 150 | await websocket.send( 151 | response.json(ensure_ascii=False, cls=DataclassEncoder) 152 | ) 153 | except WebSocketClosed: 154 | log( 155 | "WARNING", 156 | f"编号为: {seq} 的websocket被远程关闭了...", 157 | ) 158 | except Exception as e: 159 | log( 160 | "ERROR", 161 | f"处理来自 websocket 的数据时出错 :{e} " 162 | f"- 编号: {seq}.", 163 | ) 164 | 165 | finally: 166 | with contextlib.suppress(Exception): 167 | await websocket.close() 168 | self.driver.ws_disconnect(seq) 169 | 170 | async def handle_http(self, request: Request) -> Response: 171 | """处理http任务""" 172 | 173 | # check access_token 174 | response = self._check_access_token(request) 175 | if response is not None: 176 | return response 177 | 178 | data = request.content 179 | if data is not None: 180 | json_data = json.loads(data) 181 | if action := self.json_to_action(json_data): 182 | # get_latest_events处理 183 | if action.action == "get_latest_events": 184 | if not self.config.event_enabled: 185 | response = ActionResponse( 186 | status="failed", 187 | retcode=10002, 188 | data=None, 189 | message="未开启该action", 190 | ) 191 | else: 192 | data = HTTP_EVENT_LIST.copy() 193 | HTTP_EVENT_LIST.clear() 194 | response = ActionResponse(status="ok", retcode=0, data=data) 195 | else: 196 | response = await self.action_request(action) 197 | headers = { 198 | "Content-Type": "application/json", 199 | "User-Agent": USER_AGENT, 200 | "X-Impl": IMPL, 201 | "X-OneBot-Version": f"{ONEBOT_VERSION}", 202 | } 203 | if self.config.access_token != "": 204 | headers["Authorization"] = f"Bearer {self.config.access_token}" 205 | return Response( 206 | 200, 207 | headers=headers, 208 | content=response.json( 209 | by_alias=True, ensure_ascii=False, cls=DataclassEncoder 210 | ), 211 | ) 212 | return Response(204) 213 | 214 | async def start_backward(self) -> None: 215 | """ 216 | 开启反向ws连接应用端 217 | """ 218 | for url in self.config.websocket_url: 219 | try: 220 | ws_url = URL(url) 221 | self.tasks.append(asyncio.create_task(self._backward_ws(ws_url))) 222 | except Exception as e: 223 | log( 224 | "ERROR", 225 | f"Bad url {escape_tag(url)} " 226 | "in websocket_url config", 227 | e, 228 | ) 229 | 230 | async def _backward_ws(self, url: URL) -> None: 231 | """ 232 | 反向ws连接任务 233 | """ 234 | headers = { 235 | "User-Agent": USER_AGENT, 236 | } 237 | if self.config.access_token != "": 238 | headers["Authorization"] = f"Bearer {self.config.access_token}" 239 | setup = Request("GET", url, headers=headers, timeout=5.0) 240 | log("DEBUG", f"正在连接到url: {url}") 241 | while True: 242 | try: 243 | async with self.start_websocket(setup) as websocket: 244 | log( 245 | "SUCCESS", 246 | f"WebSocket Connection to {escape_tag(str(url))} established", 247 | ) 248 | seq = self.driver.ws_connect(websocket) 249 | log("SUCCESS", f"新的websocket连接,编号为: {seq}...") 250 | # 发送connect事件 251 | event = get_connet_event() 252 | try: 253 | await websocket.send( 254 | event.json(ensure_ascii=False, cls=DataclassEncoder) 255 | ) 256 | except Exception as e: 257 | log("ERROR", f"发送connect事件失败:{e}") 258 | # 发送update事件 259 | event = self.get_status_update_event() 260 | try: 261 | await websocket.send( 262 | event.json(ensure_ascii=False, cls=DataclassEncoder) 263 | ) 264 | except Exception as e: 265 | log("ERROR", f"发送status_update事件失败:{e}") 266 | try: 267 | while True: 268 | data = await websocket.receive() 269 | raw_data = ( 270 | json.loads(data) 271 | if isinstance(data, str) 272 | else msgpack.unpackb(data) 273 | ) 274 | if action := self.json_to_ws_action(raw_data): 275 | response = await self.action_ws_request(action) 276 | await websocket.send( 277 | response.json( 278 | ensure_ascii=False, cls=DataclassEncoder 279 | ) 280 | ) 281 | except WebSocketClosed as e: 282 | log( 283 | "ERROR", 284 | f"WebSocket 关闭了...: {e}", 285 | ) 286 | except Exception as e: 287 | log( 288 | "ERROR", 289 | f"处理来自 websocket 的数据时出错: {e}" 290 | f"{escape_tag(str(url))} 正在尝试重连...", 291 | ) 292 | finally: 293 | self.driver.ws_disconnect(seq) 294 | 295 | except Exception as e: 296 | log( 297 | "ERROR", 298 | "连接到 " 299 | f"{escape_tag(str(url))} 时出错{e} 正在尝试重连...", 300 | ) 301 | 302 | await asyncio.sleep(self.config.reconnect_interval / 1000) 303 | 304 | async def stop_backward(self) -> None: 305 | """关闭反向ws连接任务""" 306 | for task in self.tasks: 307 | if not task.done(): 308 | task.cancel() 309 | 310 | @classmethod 311 | def json_to_action(cls, json_data: Any) -> Optional[ActionRequest]: 312 | """ 313 | json转换为action 314 | """ 315 | if not isinstance(json_data, dict): 316 | return None 317 | try: 318 | action = ActionRequest.parse_obj(json_data) 319 | except ValidationError: 320 | log("ERROR", f"action请求错误: {json_data}") 321 | return None 322 | logstring = str(action.dict()) 323 | if len(logstring) > 200: 324 | logstring = logstring[:200] + "..." 325 | log("SUCCESS", f"收到action请求: {logstring}") 326 | return action 327 | 328 | @classmethod 329 | def json_to_ws_action(cls, json_data: Any) -> Optional[WsActionRequest]: 330 | """json转换为wsaction""" 331 | if not isinstance(json_data, dict): 332 | return None 333 | try: 334 | echo = json_data.pop("echo") 335 | except Exception: 336 | return None 337 | action = cls.json_to_action(json_data) 338 | if action is None: 339 | return None 340 | return WsActionRequest(echo=echo, **action.dict()) 341 | 342 | @abstractmethod 343 | def get_status_update_event(slef) -> StatusUpdateEvent: 344 | """ 345 | 获取状态更新事件 346 | """ 347 | raise NotImplementedError 348 | 349 | @abstractmethod 350 | async def action_request(self, request: ActionRequest) -> ActionResponse: 351 | """ 352 | 处理action的方法 353 | """ 354 | raise NotImplementedError 355 | 356 | @abstractmethod 357 | async def action_ws_request(self, request: WsActionRequest) -> WsActionResponse: 358 | """ 359 | 处理wsaction的方法 360 | """ 361 | raise NotImplementedError 362 | 363 | async def http_event(self, event: Event) -> None: 364 | """ 365 | http处理event 366 | """ 367 | global HTTP_EVENT_LIST 368 | if self.config.event_enabled: 369 | # 开启 get_latest_events 370 | if ( 371 | self.config.event_buffer_size != 0 372 | and len(HTTP_EVENT_LIST) == self.config.event_buffer_size 373 | ): 374 | HTTP_EVENT_LIST.pop(0) 375 | HTTP_EVENT_LIST.append(event) 376 | 377 | async def webhook_event(self, event: Event) -> None: 378 | """ 379 | 处理webhook 380 | """ 381 | log("DEBUG", "发送webhook...") 382 | 383 | headers = { 384 | "User-Agent": USER_AGENT, 385 | "Content-Type": "application/json", 386 | "X-OneBot-Version": ONEBOT_VERSION, 387 | "X-Impl": IMPL, 388 | } 389 | if self.config.access_token != "": 390 | headers["Authorization"] = f"Bearer {self.config.access_token}" 391 | for url in self.config.webhook_url: 392 | try: 393 | post_url = URL(url) 394 | setup = Request( 395 | method="POST", 396 | url=post_url, 397 | headers=headers, 398 | json=event.json( 399 | by_alias=True, ensure_ascii=False, cls=DataclassEncoder 400 | ), 401 | timeout=self.config.webhook_timeout / 1000, 402 | ) 403 | await self.driver.request(setup) 404 | except Exception as e: 405 | log("ERROR", f"发送webhook出现错误:{e}") 406 | 407 | async def _send_ws( 408 | self, ws: Union[FastAPIWebSocket, BackwardWebSocket], event: Event 409 | ) -> None: 410 | """ 411 | 发送ws消息 412 | """ 413 | await ws.send( 414 | event.json(by_alias=True, ensure_ascii=False, cls=DataclassEncoder) 415 | ) 416 | 417 | async def websocket_event(self, event: Event) -> None: 418 | """ 419 | 处理websocket发送事件 420 | """ 421 | task = [self._send_ws(one, event) for one in self.driver.connects.values()] 422 | try: 423 | asyncio.gather(*task) 424 | except Exception as e: 425 | log("ERROR", f"发送ws消息出错:{e}") 426 | 427 | async def handle_event(self, event: Event) -> None: 428 | """ 429 | 处理event 430 | """ 431 | if self.config.enable_http_api: 432 | asyncio.create_task(self.http_event(event)) 433 | if self.config.enable_http_webhook: 434 | asyncio.create_task(self.webhook_event(event)) 435 | if self.config.websocekt_type != WebsocketType.Unable: 436 | asyncio.create_task(self.websocket_event(event)) 437 | -------------------------------------------------------------------------------- /wechatbot_client/wechat/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, TypeVar 2 | 3 | T = TypeVar("T") 4 | 5 | 6 | def get_auth_bearer(access_token: Optional[str] = None) -> Optional[str]: 7 | if not access_token: 8 | return None 9 | scheme, _, param = access_token.partition(" ") 10 | return param if scheme.lower() in ["bearer", "token"] else None 11 | 12 | 13 | def flattened_to_nested(data: T) -> T: 14 | """将扁平键值转为嵌套字典。""" 15 | if isinstance(data, dict): 16 | pairs = [ 17 | ( 18 | key.split(".") if isinstance(key, str) else key, 19 | flattened_to_nested(value), 20 | ) 21 | for key, value in data.items() 22 | ] 23 | result = {} 24 | for key_list, value in pairs: 25 | target = result 26 | for key in key_list[:-1]: 27 | target = target.setdefault(key, {}) 28 | target[key_list[-1]] = value 29 | return result # type: ignore 30 | elif isinstance(data, list): 31 | return [flattened_to_nested(item) for item in data] # type: ignore 32 | return data 33 | -------------------------------------------------------------------------------- /wechatbot_client/wechat/wechat.py: -------------------------------------------------------------------------------- 1 | import time 2 | from pathlib import Path 3 | from uuid import uuid4 4 | 5 | from pydantic import ValidationError 6 | 7 | from wechatbot_client.action_manager import ( 8 | ActionManager, 9 | ActionRequest, 10 | ActionResponse, 11 | WsActionRequest, 12 | WsActionResponse, 13 | check_action_params, 14 | ) 15 | from wechatbot_client.com_wechat import Message, MessageHandler 16 | from wechatbot_client.config import Config 17 | from wechatbot_client.consts import FILE_CACHE 18 | from wechatbot_client.file_manager import FileManager 19 | from wechatbot_client.onebot12 import ( 20 | BotSelf, 21 | BotStatus, 22 | Event, 23 | Status, 24 | StatusUpdateEvent, 25 | ) 26 | from wechatbot_client.typing import overrides 27 | from wechatbot_client.utils import logger_wrapper 28 | 29 | from .adapter import Adapter 30 | 31 | log = logger_wrapper("WeChat Manager") 32 | 33 | 34 | class WeChatManager(Adapter): 35 | """ 36 | 微信客户端行为管理 37 | """ 38 | 39 | self_id: str 40 | """自身微信id""" 41 | file_manager: FileManager 42 | """文件管理模块""" 43 | action_manager: ActionManager 44 | """api管理模块""" 45 | message_handler: MessageHandler 46 | """消息处理器""" 47 | 48 | def __init__(self, config: Config) -> None: 49 | super().__init__(config) 50 | self.self_id = None 51 | self.message_handler = None 52 | self.action_manager = ActionManager() 53 | self.file_manager = FileManager() 54 | 55 | def init(self) -> None: 56 | """ 57 | 初始化wechat管理端 58 | """ 59 | self.action_manager.init(self.file_manager, self.config) 60 | 61 | log("DEBUG", "开始获取wxid...") 62 | info = self.action_manager.get_info() 63 | self.self_id = info["wxId"] 64 | video_path = Path(info["wxFilePath"]).parent 65 | cache_path = Path(f"./{FILE_CACHE}") 66 | image_path = cache_path / "image" / self.self_id 67 | voice_path = cache_path / "voice" / self.self_id 68 | self.message_handler = MessageHandler( 69 | image_path, voice_path, video_path, self.file_manager 70 | ) 71 | self.action_manager.register_message_handler(self.handle_msg) 72 | log("DEBUG", "微信id获取成功...") 73 | log("INFO", "初始化完成,启动uvicorn...") 74 | 75 | def open_recv_msg(self, file_path: str) -> None: 76 | """ 77 | 开始接收消息 78 | """ 79 | self.action_manager.open_recv_msg(file_path) 80 | 81 | def close(self) -> None: 82 | """ 83 | 管理微信管理模块 84 | """ 85 | self.action_manager.close() 86 | 87 | @overrides(Adapter) 88 | async def action_request(self, request: ActionRequest) -> ActionResponse: 89 | """ 90 | 发起action请求 91 | """ 92 | # 验证action 93 | try: 94 | action_name, action_model = check_action_params(request) 95 | except TypeError: 96 | return ActionResponse( 97 | status="failed", 98 | retcode=10002, 99 | data=None, 100 | message=f"未实现的action: {request.action}", 101 | ) 102 | except ValueError: 103 | return ActionResponse( 104 | status="failed", 105 | retcode=10003, 106 | data=None, 107 | message="Param参数错误", 108 | ) 109 | # 调用api 110 | return await self.action_manager.request(action_name, action_model) 111 | 112 | @overrides(Adapter) 113 | def get_status_update_event(slef) -> StatusUpdateEvent: 114 | """ 115 | 获取状态更新事件 116 | """ 117 | event_id = str(uuid4()) 118 | botself = BotSelf(user_id=slef.self_id) 119 | botstatus = BotStatus(self=botself, online=True) 120 | return StatusUpdateEvent( 121 | id=event_id, time=time.time(), status=Status(good=True, bots=[botstatus]) 122 | ) 123 | 124 | @overrides(Adapter) 125 | async def action_ws_request(self, request: WsActionRequest) -> WsActionResponse: 126 | """ 127 | 处理ws请求 128 | """ 129 | echo = request.echo 130 | response = await self.action_request( 131 | ActionRequest(action=request.action, params=request.params) 132 | ) 133 | return WsActionResponse(echo=echo, **response.dict()) 134 | 135 | async def handle_msg(self, msg: str) -> None: 136 | """ 137 | 消息处理函数 138 | """ 139 | try: 140 | message = Message.parse_raw(msg) 141 | except ValidationError as e: 142 | log("ERROR", f"微信消息实例化失败:{e}") 143 | return 144 | if message.isSendMsg: 145 | await self.handle_self_msg(message) 146 | else: 147 | await self.handle_evnt_msg(message) 148 | 149 | async def handle_self_msg(self, msg: Message) -> None: 150 | """ 151 | 处理自身发送消息 152 | """ 153 | # 先确认是否为自己手动发送 154 | if msg.isSendByPhone is not None: 155 | return 156 | pass 157 | 158 | async def handle_evnt_msg(self, msg: Message) -> None: 159 | """ 160 | 处理event消息 161 | """ 162 | try: 163 | event: Event = await self.message_handler.message_to_event(msg) 164 | except Exception as e: 165 | log("ERROR", f"生成事件出错:{e}") 166 | return 167 | if event is None: 168 | log("DEBUG", "未生成合适事件") 169 | return 170 | log("SUCCESS", f"生成事件[{event.__repr_name__()}]:{event.dict()}") 171 | await self.handle_event(event) 172 | --------------------------------------------------------------------------------