├── .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 | 
2 |
3 |
4 |
5 |
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"?((?:[fb]g\s)?[^<>\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 |
--------------------------------------------------------------------------------