├── .env ├── .gitignore ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bot.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── src ├── __init__.py ├── plugins │ ├── __init__.py │ ├── chat_history.py │ ├── chat_ui │ │ ├── plugin.go │ │ ├── plugin.py │ │ ├── plugin.ts │ │ └── ui │ │ │ └── index.html │ ├── conv2conv.py │ ├── counter.py │ ├── ding_dong.py │ ├── github_message_forwarder.py │ ├── health_checking.py │ ├── repeater.py │ ├── send_message.py │ ├── uie.py │ └── views │ │ ├── send_message.html │ │ ├── table.jinja2 │ │ └── vue.html ├── schema.py ├── ui_plugin.py └── utils.py ├── start_gateway_docker.sh └── watchtower.py /.env: -------------------------------------------------------------------------------- 1 | token={{your-custom-id}} -------------------------------------------------------------------------------- /.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 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .vscode/ 131 | node_modules/ 132 | .env -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9 2 | 3 | WORKDIR /bot 4 | 5 | COPY requirements.txt requirements.txt 6 | 7 | COPY Makefile Makefile 8 | 9 | RUN make install 10 | 11 | COPY . . 12 | 13 | CMD [ "make", "run"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | P=$(shell pwd) 2 | 3 | .PHONY: build 4 | build: 5 | docker build -t py-wechaty-template-bot:latest . 6 | 7 | .PHONY: dockerrun 8 | dockerrun: 9 | docker stop bot && docker rm bot 10 | docker run -it -d -v $(P):/bot --name bot -p 8004:8004 py-wechaty-template-bot:latest 11 | 12 | .PHONY: bot 13 | bot: 14 | make build 15 | make dockerrun 16 | 17 | .PHONY: install 18 | install: 19 | pip install -r requirements.txt 20 | 21 | .PHONY: run 22 | run: 23 | python bot.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-wechaty-template 2 | 3 | Python Wechaty Project Template which contains the best practise. 4 | 5 | ## Getting Started 6 | 7 | ### 申请Token 8 | 9 | 如要运行微信机器人,需要申请Token来启动服务,在此推荐申请[Padlocal Token](http://pad-local.com/#/)来快速启动该服务。 10 | 11 | ### 启动Gateway Docker 服务 12 | 运行本项目下的`start_gateway_docker.sh`脚本,并将申请到的Padlocal Token作为脚本参数传入 13 | 14 | ```shell 15 | ./start_gateway_docker.sh 16 | ``` 17 | 18 | ### 运行机器人 19 | 20 | ```shell 21 | make bot 22 | ``` 23 | 24 | 初次登陆时,可能需要多次扫码才会登陆成功。 25 | 扫码登陆成功后,wechaty机器人就算启动成功了,可以通过微信向机器人发送消息`ding`来测试。默认开启DingDong插件,机器人会自动回复`dong`。 26 | 至此,恭喜你,你的第一个微信机器人成功运行了!接下来可以将各种需求和业务逻辑以插件的形式加入到机器人中。 27 | 28 | ## 编写插件 29 | 30 | 目前有很多开发者将所有的业务逻辑都写在一个文件的一个函数(`on_message`)里面,时间长了,业务多了,就导致这里的代码很难管理,故需要从代码文件层面就将业务隔离开,此时我们推荐使用[插件系统](https://wechaty.readthedocs.io/zh_CN/latest/plugins/introduction/)来编写对应业务。 31 | 32 | * 插件示例 33 | 34 | ```python 35 | from wechaty import WechatyPlugin, Message 36 | 37 | class DingDongPlugin(WechatyPlugin): 38 | async def on_message(self, msg: Message) -> None: 39 | if msg.text() == "ding": 40 | await msg.say("dong") 41 | ``` 42 | 43 | * 插件消息传递 44 | 45 | 如果在Bot中use了很多个插件:[A, B, C, D, E, ...],wechaty接收到消息之后会挨个儿将消息传递给插件去处理,如果其中回复了消息之后,想阻止插件继续传递的话,需要添加一个message_controller模块,代码示例如下: 46 | 47 | ```python 48 | from wechaty import WechatyPlugin, Message 49 | from wechaty_plugin_contrib.message_controller import message_controller 50 | 51 | class DingDongPlugin(WechatyPlugin): 52 | 53 | @message_controller 54 | async def on_message(self, msg: Message) -> None: 55 | if msg.text() == "ding": 56 | await msg.say("dong") 57 | message_controller.disable_all_plugins(msg) 58 | ``` 59 | 60 | 对代码的修改只需要三行修改: 61 | * 添加message_controller全局单例对象的导入 62 | * 将其作为装饰器应用在on_message函数上 63 | * 在想阻止消息传递的地方添加一行代码:`message_controller.disable_all_plugins(msg)`,此时需要注意需要将msg对象传递进去。 64 | 65 | ## History 66 | 67 | ### v0.0.2 (Aug 2022) 68 | 69 | add `wechaty-ui` based code. 70 | 71 | ### v0.0.1 (July 2022) 72 | 73 | The `python-wechaty-template` project was created. 74 | 75 | ## Maintainers 76 | 77 | - @wj-Mcat - [wj-Mcat](https://github.com/wj-Mcat), nlp researcher 78 | 79 | ## Copyright & License 80 | 81 | - Code & Docs © 2022 Wechaty Contributors 82 | - Code released under the Apache-2.0 License 83 | - Docs released under Creative Commons 84 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | """template of your bot""" 2 | from __future__ import annotations 3 | import asyncio 4 | import os 5 | from wechaty import Wechaty, WechatyOptions 6 | 7 | from dotenv import load_dotenv 8 | from wechaty_plugin_contrib.contrib.info_logger import InfoLoggerPlugin 9 | from src.plugins.ding_dong import DingDongPlugin 10 | from src.plugins.repeater import RepeaterPlugin 11 | from src.plugins.counter import CounterPlugin, UICounterPlugin 12 | from src.plugins.github_message_forwarder import GithubMessageForwarderPlugin 13 | 14 | 15 | if __name__ == "__main__": 16 | load_dotenv() 17 | options = WechatyOptions( 18 | port=os.environ.get('port', 8004) 19 | ) 20 | bot = Wechaty(options) 21 | bot.use([ 22 | DingDongPlugin(), 23 | RepeaterPlugin(), 24 | InfoLoggerPlugin(), 25 | CounterPlugin(), 26 | UICounterPlugin(), 27 | GithubMessageForwarderPlugin( 28 | endpoint=os.environ.get('github_endpoint', None) or "your-custom-endpoint" 29 | ), 30 | ]) 31 | asyncio.run(bot.start()) 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | # refer to: https://docs.pytest.org/en/stable/mark.html 2 | [tool.pytest.ini_options] 3 | pythonpath = "./" 4 | minversion = "6.0" 5 | # addopts = "--cov-report term-missing --cov-report xml --cov=src/" 6 | testpaths = [ 7 | "tests" 8 | ] -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | mypy 3 | mypy_extensions 4 | pycodestyle 5 | pylint 6 | pylint-quotes 7 | pytest 8 | pytest-asyncio 9 | pytest-cov 10 | pytype 11 | semver 12 | pyee 13 | requests 14 | qrcode 15 | apscheduler 16 | lxml 17 | pre-commit 18 | mkdocs 19 | mkdocs-material 20 | types-requests 21 | mkdocstrings 22 | mkdocstrings-python-legacy -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | wechaty 2 | wechaty-plugin-contrib 3 | quart 4 | python-dotenv 5 | docker 6 | apscheduler 7 | dataclasses-json -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/python-wechaty-template/2648103a1a53ae7496e7f7b13255adad93acdbab/src/__init__.py -------------------------------------------------------------------------------- /src/plugins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/python-wechaty-template/2648103a1a53ae7496e7f7b13255adad93acdbab/src/plugins/__init__.py -------------------------------------------------------------------------------- /src/plugins/chat_history.py: -------------------------------------------------------------------------------- 1 | from quart import Quart, render_template_string, jsonify 2 | from wechaty import WechatyPlugin 3 | 4 | 5 | class ChatHistoryPlugin(WechatyPlugin): 6 | VIEW_URL = '/api/plugins/chat_history/view' 7 | 8 | async def blueprint(self, app: Quart) -> None: 9 | 10 | @app.route('/api/plugins/counter/view') 11 | async def get_counter_view(): 12 | 13 | with open("./src/plugins/views/table.jinja2", 'r', encoding='utf-8') as f: 14 | # with open("./src/plugins/views/vue.html", 'r', encoding='utf-8') as f: 15 | template = f.read() 16 | 17 | self.setting['count'] += 1 18 | 19 | response = await render_template_string(template, count=self.setting['count']) 20 | return response -------------------------------------------------------------------------------- /src/plugins/chat_ui/plugin.go: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/python-wechaty-template/2648103a1a53ae7496e7f7b13255adad93acdbab/src/plugins/chat_ui/plugin.go -------------------------------------------------------------------------------- /src/plugins/chat_ui/plugin.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/python-wechaty-template/2648103a1a53ae7496e7f7b13255adad93acdbab/src/plugins/chat_ui/plugin.py -------------------------------------------------------------------------------- /src/plugins/chat_ui/plugin.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/python-wechaty-template/2648103a1a53ae7496e7f7b13255adad93acdbab/src/plugins/chat_ui/plugin.ts -------------------------------------------------------------------------------- /src/plugins/chat_ui/ui/index.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wechaty/python-wechaty-template/2648103a1a53ae7496e7f7b13255adad93acdbab/src/plugins/chat_ui/ui/index.html -------------------------------------------------------------------------------- /src/plugins/conv2conv.py: -------------------------------------------------------------------------------- 1 | """""" 2 | from collections import defaultdict 3 | import os 4 | import re 5 | from typing import ( 6 | Dict, Optional, List, Set, Tuple, Union 7 | ) 8 | from dataclasses import dataclass 9 | from wechaty import ( 10 | Contact, 11 | FileBox, 12 | MessageType, 13 | WechatyPlugin, 14 | Room, 15 | Message, 16 | WechatyPluginOptions 17 | ) 18 | from wechaty_puppet import get_logger 19 | 20 | from src.utils import remove_at_info 21 | from wechaty_plugin_contrib.message_controller import message_controller 22 | from wechaty_puppet.schemas.base import BaseDataClass 23 | 24 | 25 | @dataclass 26 | class Conversation(BaseDataClass): 27 | """Room or Contact Configuraiton""" 28 | alias: str 29 | id: str 30 | type: str = 'Room' 31 | no: str = '' 32 | 33 | def info(self): 34 | """get the simple info""" 35 | return f'[{self.type}]\t名称:{self.alias}\t\t编号:[{self.id}]' 36 | 37 | 38 | class Conv2ConvsPlugin(WechatyPlugin): 39 | """ 40 | """ 41 | def __init__( 42 | self, 43 | options: Optional[WechatyPluginOptions] = None, 44 | trigger_with_at: bool = True, 45 | with_alias: bool = True 46 | ) -> None: 47 | """init params for conversations to conversations configuration 48 | 49 | Args: 50 | options (Optional[WechatyPluginOptions], optional): default wechaty plugin options. Defaults to None. 51 | config_file (str, optional): _description_. Defaults to .wechaty//config.xlsx. 52 | expire_seconds (int, optional): start to forward. Defaults to 60. 53 | command_prefix (str, optional): . Defaults to ''. 54 | trigger_with_at (bool, optional): _description_. Defaults to True. 55 | """ 56 | super().__init__(options) 57 | 58 | # 3. save the admin status 59 | self.admin_status: Dict[str, List[Conversation]] = defaultdict(list) 60 | self.trigger_with_at = trigger_with_at 61 | self.with_alias = with_alias 62 | 63 | self.talker_desc_cache = {} 64 | 65 | async def get_talker_desc(self, msg: Message): 66 | talker = msg.talker() 67 | room = msg.room() 68 | 69 | union_id = talker.contact_id 70 | if room: 71 | union_id += room.room_id 72 | 73 | if union_id in self.talker_desc_cache: 74 | return self.talker_desc_cache[union_id] 75 | 76 | await talker.ready() 77 | talker_name = talker.name 78 | 79 | if room: 80 | alias = await room.alias(talker) 81 | if alias: 82 | talker_name = alias 83 | 84 | room_name = None 85 | if room.room_id in self.setting: 86 | room_name = self.setting[room.room_id]['alias'] 87 | 88 | if not room_name: 89 | await room.ready() 90 | room_name = await room.topic() 91 | 92 | talker_name = f"{talker_name} @ {room_name}" 93 | 94 | self.talker_desc_cache[union_id] = talker_name 95 | return talker_name 96 | 97 | async def forward_message(self, msg: Message, conversation_id: str): 98 | """forward the message to the target conversations 99 | 100 | Args: 101 | msg (Message): the message to forward 102 | conversation_id (str): the id of conversation 103 | """ 104 | talker_desc = await self.get_talker_desc(msg) 105 | 106 | # 1. get the type of message 107 | conversations = self.admin_status.get(conversation_id, []) 108 | if not conversations: 109 | return 110 | 111 | file_box = None 112 | if msg.type() in [MessageType.MESSAGE_TYPE_IMAGE, MessageType.MESSAGE_TYPE_VIDEO, MessageType.MESSAGE_TYPE_ATTACHMENT]: 113 | file_box = await msg.to_file_box() 114 | file_path = os.path.join(self.cache_dir, "files", file_box.name) 115 | os.makedirs(os.path.dirname(file_path), exist_ok=True) 116 | 117 | await file_box.to_file(file_path, overwrite=True) 118 | file_box = FileBox.from_file(file_path) 119 | 120 | for conversation in conversations: 121 | if conversation.type == 'Room': 122 | forwarder_target = self.bot.Room.load(conversation.id) 123 | elif conversation.type == 'Contact': 124 | forwarder_target = self.bot.Contact.load(conversation.id) 125 | else: 126 | continue 127 | 128 | # TODO: there are some issues in FileBox saying 129 | if file_box: 130 | await forwarder_target.say(file_box) 131 | 132 | # 如果是文本的话,是需要单独来转发 133 | elif msg.type() == MessageType.MESSAGE_TYPE_TEXT: 134 | text = msg.text() 135 | if self.with_alias and conversation.alias: 136 | text = talker_desc + '\n===============\n' + text 137 | await forwarder_target.say(text) 138 | 139 | elif forwarder_target: 140 | await msg.forward(forwarder_target) 141 | 142 | @message_controller.may_disable_message 143 | async def on_message(self, msg: Message) -> None: 144 | talker = msg.talker() 145 | room: Optional[Room] = msg.room() 146 | 147 | conversation_id: str = room.room_id if room else talker.contact_id 148 | 149 | # 2. 判断是否是自己发送的消息 150 | if msg.is_self() or conversation_id not in self.setting: 151 | return 152 | 153 | text = msg.text() 154 | 155 | if conversation_id in self.admin_status: 156 | await self.forward_message(msg, conversation_id=conversation_id) 157 | self.admin_status.pop(conversation_id) 158 | message_controller.disable_all_plugins(msg) 159 | return 160 | 161 | # filter the target conversations 162 | receivers: List[Conversation] = [] 163 | for key, value in self.setting.items(): 164 | value['id'] = key 165 | if key == conversation_id: 166 | continue 167 | receivers.append(Conversation( 168 | **value 169 | )) 170 | 171 | if not receivers: 172 | return 173 | 174 | self.admin_status[conversation_id] = receivers 175 | 176 | if text: 177 | # set the words to the message 178 | msg.payload.text = text 179 | await self.forward_message(msg, conversation_id=conversation_id) 180 | self.admin_status.pop(conversation_id) 181 | 182 | message_controller.disable_all_plugins(msg) -------------------------------------------------------------------------------- /src/plugins/counter.py: -------------------------------------------------------------------------------- 1 | from quart import Quart, render_template_string, jsonify 2 | from wechaty import WechatyPlugin 3 | 4 | class CounterPlugin(WechatyPlugin): 5 | # 需要和blueprint注册的UI入口地址一致 6 | VIEW_URL = '/api/plugins/counter/view' 7 | 8 | async def blueprint(self, app: Quart) -> None: 9 | 10 | @app.route('/api/plugins/counter/view') 11 | async def get_counter_view(): 12 | 13 | with open("./src/plugins/views/table.jinja2", 'r', encoding='utf-8') as f: 14 | # with open("./src/plugins/views/vue.html", 'r', encoding='utf-8') as f: 15 | template = f.read() 16 | 17 | self.setting['count'] += 1 18 | 19 | response = await render_template_string(template, count=self.setting['count']) 20 | return response 21 | 22 | class UICounterPlugin(WechatyPlugin): 23 | # 需要和blueprint注册的UI入口地址一致 24 | VIEW_URL = '/api/plugins/ui_counter/view' 25 | 26 | async def blueprint(self, app: Quart) -> None: 27 | 28 | @app.route('/api/plugins/ui_counter/view') 29 | async def get_ui_counter_view(): 30 | 31 | with open("./src/plugins/views/vue.html", 'r', encoding='utf-8') as f: 32 | template = f.read() 33 | return template 34 | 35 | @app.route('/api/plugins/ui_counter/count') 36 | async def get_ui_count(): 37 | self.setting['count'] += 1 38 | return jsonify({"data": self.setting['count']}) -------------------------------------------------------------------------------- /src/plugins/ding_dong.py: -------------------------------------------------------------------------------- 1 | from wechaty import WechatyPlugin, Message 2 | from quart import Quart, render_template_string 3 | from wechaty_plugin_contrib.message_controller import message_controller 4 | from wechaty import WechatyPlugin 5 | 6 | class DingDongPlugin(WechatyPlugin): 7 | VIEW_URL = '/api/plugins/ding_dong/view' 8 | 9 | @message_controller.may_disable_message 10 | async def on_message(self, msg: Message) -> None: 11 | if msg.text() == "ding": 12 | await msg.say("dong") 13 | await msg.say("I'm alive ...") 14 | message_controller.disable_all_plugins(msg) 15 | 16 | async def blueprint(self, app: Quart) -> None: 17 | 18 | @app.route('/api/plugins/ding_dong/view') 19 | async def get_ding_dong_view(): 20 | 21 | # with open("./src/plugins/views/table.jinja2", 'r', encoding='utf-8') as f: 22 | with open("./src/plugins/views/vue.html", 'r', encoding='utf-8') as f: 23 | template = f.read() 24 | 25 | data = [i for i in range(20)] 26 | response = await render_template_string(template, tables=data) 27 | return response -------------------------------------------------------------------------------- /src/plugins/github_message_forwarder.py: -------------------------------------------------------------------------------- 1 | """Plugins for Github Message forwarder""" 2 | from __future__ import annotations 3 | from dataclasses import dataclass 4 | import os 5 | 6 | from typing import Dict, List, Optional, Union 7 | import requests 8 | 9 | from wechaty import Contact, UrlLink, WechatyPlugin, WechatyPluginOptions, Wechaty, Message 10 | from wechaty_puppet import UrlLinkPayload 11 | from wechaty_plugin_contrib.message_controller import message_controller 12 | from github import Github 13 | 14 | from github.PullRequest import PullRequest 15 | from github.PullRequestComment import PullRequestComment 16 | from github.Issue import Issue 17 | from github.IssueComment import IssueComment 18 | 19 | 20 | GITHUB_URL_LINK_TYPES = ['issue', 'issue-comment', 'pull-request', 'pull-request-comment', 'pull-request'] 21 | 22 | @dataclass 23 | class GithubUrlLinkPayload(UrlLinkPayload): 24 | full_name: Optional[str] = None 25 | type: str = "issue" 26 | 27 | 28 | class GithubAppMessageParser: 29 | def __init__(self, token: str) -> None: 30 | self.github: Github = Github(login_or_token=token) 31 | 32 | def parse(self, message: dict) -> Optional[Union[Issue, 33 | IssueComment, 34 | PullRequest, PullRequestComment 35 | ]]: 36 | event, payload = message['event'], message['payload'] 37 | trigger_name = f"{event}.{payload['action']}" 38 | 39 | if trigger_name == "pull_request.opened": 40 | return self.parse_pull_request_opened(payload) 41 | 42 | if trigger_name == 'issues.opened': 43 | return self.parse_issue_opened(payload) 44 | 45 | if trigger_name == 'issue_comment.created': 46 | return self.parse_issue_comment(payload) 47 | 48 | 49 | def parse_issue_comment(self, message: dict) -> UrlLinkPayload: 50 | full_name = message['repository']['full_name'] 51 | issue_number = message['issue']['number'] 52 | description = message['comment']['body'] 53 | avatar_url = message['comment']['user']['avatar_url'] 54 | title=f"Issue#{issue_number} {message['issue']['title'][:30]} {full_name}" 55 | if "pull_request" in message['issue']: 56 | title=f"PR#{issue_number} {message['issue']['title'][:30]} {full_name}" 57 | 58 | return GithubUrlLinkPayload( 59 | url=message['comment']['html_url'], 60 | title=title[:30], 61 | description=description[:70], 62 | thumbnailUrl=avatar_url, 63 | ) 64 | 65 | def parse_issue_opened(self, message: dict) -> UrlLinkPayload: 66 | """parse open issue message to UrlLinkPayload 67 | 68 | Args: 69 | message (dict): _description_ 70 | 71 | Returns: 72 | UrlLinkPayload: _description_ 73 | """ 74 | full_name = message['repository']['full_name'] 75 | issue_number = message['issue']['number'] 76 | description = message['issue']['body'] 77 | avatar_url = message['issue']['user']['avatar_url'] 78 | title=f"New Issue#{issue_number} {message['issue']['title'][:30]} {full_name}" 79 | 80 | return UrlLinkPayload( 81 | url=message['issue']['html_url'], 82 | title=title[:30], 83 | description=description[:70], 84 | thumbnailUrl=avatar_url, 85 | ) 86 | 87 | def parse_pull_request_opened(self, message: dict) -> UrlLinkPayload: 88 | """parse open issue message to UrlLinkPayload 89 | 90 | Args: 91 | message (dict): _description_ 92 | 93 | Returns: 94 | UrlLinkPayload: _description_ 95 | """ 96 | full_name = message['repository']['full_name'] 97 | pull_request_number = message['pull_request']['number'] 98 | pull_request_body = message['pull_request']['body'] 99 | avatar_url = message['pull_request']['user']['avatar_url'] 100 | title=f"New PR#{pull_request_number} {message['pull_request']['title'][:30]} {full_name}" 101 | 102 | return UrlLinkPayload( 103 | url=message['pull_request']['html_url'], 104 | title=title[:30], 105 | description=pull_request_body[:70], 106 | thumbnailUrl=avatar_url, 107 | ) 108 | 109 | def parse_pull_request_review_submit_opened(self, message: dict) -> UrlLinkPayload: 110 | """parse open issue message to UrlLinkPayload 111 | 112 | Args: 113 | message (dict): _description_ 114 | 115 | Returns: 116 | UrlLinkPayload: _description_ 117 | """ 118 | full_name = message['repository']['full_name'] 119 | pull_request_number = message['pull_request']['number'] 120 | pull_request_body = message['review']['body'] or '' 121 | avatar_url = message['review']['user']['avatar_url'] 122 | title=f"#{pull_request_number} {message['pull_request']['title'][:30]} {full_name}" 123 | 124 | return UrlLinkPayload( 125 | url=message['pull_request']['html_url'], 126 | title=title[:30], 127 | description=pull_request_body[:70], 128 | thumbnailUrl=avatar_url, 129 | ) 130 | 131 | def parse_pull_request_review_comment_opened(self, message: dict) -> UrlLinkPayload: 132 | """parse open issue message to UrlLinkPayload 133 | 134 | Args: 135 | message (dict): _description_ 136 | 137 | Returns: 138 | UrlLinkPayload: _description_ 139 | """ 140 | full_name = message['repository']['full_name'] 141 | pull_request_number = message['pull_request']['number'] 142 | pull_request_body = message['comment']['body'] or '' 143 | avatar_url = message['comment']['user']['avatar_url'] 144 | title=f"#{pull_request_number} {message['pull_request']['title'][:30]} {full_name}" 145 | 146 | return UrlLinkPayload( 147 | url=message['comment']['html_url'], 148 | title=title[:30], 149 | description=pull_request_body[:70], 150 | thumbnailUrl=avatar_url, 151 | ) 152 | 153 | 154 | class GithubMessageForwarderPlugin(WechatyPlugin): 155 | def __init__(self, endpoint: Optional[str] = None, token: Optional[str] = None): 156 | super().__init__() 157 | self.endpoint = endpoint 158 | 159 | token = token or os.environ.get("github_token", None) 160 | self.url_link_parser = GithubAppMessageParser(token) 161 | 162 | async def fetch_url_link(self): 163 | response = requests.get(self.endpoint) 164 | messages = response.json().get('data', []) 165 | 166 | self.logger.info(f"start to fetch github url_link status<{response.status_code}> messages<{len(messages)}> ...") 167 | 168 | for message in messages: 169 | url_link = self.url_link_parser.parse(message) 170 | if not url_link: 171 | continue 172 | 173 | for contact_id in self.setting.get("admins", []): 174 | contact: Contact = self.bot.Contact.load(contact_id) 175 | await contact.say(UrlLink(url_link)) 176 | 177 | return 178 | 179 | async def init_plugin(self, wechaty: Wechaty) -> None: 180 | # await self.fetch_url_link() 181 | self.add_interval_job(minutes=1, handler=self.fetch_url_link, job_id=self.name) 182 | 183 | # @message_controller.may_disable_message 184 | # async def on_message(self, msg: Message) -> None: 185 | # if msg.text() == "testing": 186 | # await self.fetch_url_link() 187 | # message_controller.disable_all_plugins(msg) 188 | -------------------------------------------------------------------------------- /src/plugins/health_checking.py: -------------------------------------------------------------------------------- 1 | """basic ding-dong bot for the wechaty plugin""" 2 | import asyncio 3 | from typing import Optional 4 | from asyncio import Event 5 | from quart import Quart, jsonify 6 | 7 | from wechaty import Message, Wechaty, WechatyPluginOptions 8 | from wechaty.plugin import WechatyPlugin 9 | 10 | from wechaty_plugin_contrib.message_controller import message_controller 11 | 12 | 13 | class HealthCheckingPlugin(WechatyPlugin): 14 | """health checking plugin""" 15 | def __init__(self, options: Optional[WechatyPluginOptions] = None): 16 | super().__init__(options) 17 | self.event = Event() 18 | self.is_init = False 19 | 20 | async def init_plugin(self, wechaty: Wechaty) -> None: 21 | """init the plugin on the dong event""" 22 | wechaty.on('dong', self.on_dong) 23 | 24 | async def on_dong(self, *args, **kwargs) -> None: 25 | """listen dong event""" 26 | if not self.is_init: 27 | return 28 | self.event.set() 29 | 30 | @message_controller.may_disable_message 31 | async def on_message(self, msg: Message) -> None: 32 | """listen message event""" 33 | talker = msg.talker() 34 | text = msg.text() 35 | 36 | if text == 'ding': 37 | message_controller.disable_all_plugins(msg) 38 | if msg.room(): 39 | await msg.room().say('dong', mention_ids=[talker.contact_id]) 40 | else: 41 | await talker.say('dong') 42 | 43 | async def blueprint(self, app: Quart) -> None: 44 | """add blue print to start web service""" 45 | @app.route('/ding') 46 | async def listence_ding(): 47 | if not self.is_init: 48 | self.event._loop = asyncio.get_event_loop() 49 | self.is_init = True 50 | 51 | await self.bot.puppet.ding() 52 | if self.event.is_set(): 53 | self.event.clear() 54 | await self.event.wait() 55 | self.event.clear() 56 | return jsonify(dict(code=200, msg='dong')) 57 | -------------------------------------------------------------------------------- /src/plugins/repeater.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from wechaty import WechatyPlugin, Message, WechatyPluginOptions 3 | from quart import Quart 4 | from wechaty_plugin_contrib.message_controller import message_controller 5 | from src.utils import SettingFileMixin 6 | from src.ui_plugin import WechatyUIPlugin 7 | 8 | 9 | class RepeaterPlugin(WechatyUIPlugin): 10 | 11 | @message_controller.may_disable_message 12 | async def on_message(self, msg: Message) -> None: 13 | talker, room = msg.talker(), msg.room() 14 | setting = self.get_setting() 15 | 16 | conv_id = room.room_id if room else talker.contact_id 17 | 18 | if conv_id not in setting.get('admin_ids', []): 19 | return 20 | 21 | await msg.forward(talker) 22 | message_controller.disable_all_plugins(msg) -------------------------------------------------------------------------------- /src/plugins/send_message.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from wechaty import WechatyPlugin, Message 3 | from quart import Quart, render_template_string, request 4 | from wechaty_plugin_contrib.message_controller import message_controller 5 | from wechaty import WechatyPlugin 6 | from src.utils import success, error 7 | 8 | class SendMessagePlugin(WechatyPlugin): 9 | VIEW_URL = '/api/plugins/send_messages/view' 10 | 11 | def __init__(self, options = None): 12 | super().__init__(options) 13 | 14 | self.sleep_second: int = 1 15 | 16 | async def blueprint(self, app: Quart) -> None: 17 | 18 | @app.route("/api/plugins/send_message/text/") 19 | async def send_text_message_to_admin(content: str): 20 | admin_id = self.setting.get('admin_id', None) 21 | if admin_id is None: 22 | return "not admin id" 23 | contact = self.bot.Contact.load(admin_id) 24 | await contact.say(content) 25 | return "success" 26 | 27 | @app.route("/api/plugins/send_message/room", methods=['POST']) 28 | async def send_text_message_to_rooms(): 29 | data = await request.get_json() 30 | room_ids = data.get("room_ids", []) 31 | text = data.get("text", '') 32 | 33 | if not room_ids or not text: 34 | return error('not valid messages') 35 | 36 | for room_id in room_ids: 37 | room = self.bot.Room.load(room_id) 38 | await room.say(text) 39 | await asyncio.sleep(self.sleep_second) 40 | 41 | return success("success") 42 | 43 | @app.route(SendMessagePlugin.VIEW_URL) 44 | async def send_message_view(): 45 | with open("./src/plugins/views/send_message.html", 'r', encoding='utf-8') as f: 46 | content = f.read() 47 | return content 48 | 49 | @app.route('/api/plugins/send_message/room_select') 50 | async def get_room_select(): 51 | room_select = [] 52 | rooms = await self.bot.Room.find_all() 53 | for room in rooms: 54 | if not room.payload.topic or not room.room_id: 55 | continue 56 | room_select.append(dict( 57 | value=room.room_id, 58 | label=room.payload.topic 59 | )) 60 | 61 | return success(room_select) 62 | -------------------------------------------------------------------------------- /src/plugins/uie.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from wechaty import WechatyPlugin, Message, Room 3 | from quart import Quart, render_template_string 4 | from wechaty_plugin_contrib.message_controller import message_controller 5 | 6 | from wechaty import WechatyPlugin 7 | from src.utils import remove_at_info 8 | from paddlenlp import Taskflow 9 | from pprint import pformat, pprint 10 | 11 | from tap import Tap 12 | 13 | 14 | class UIEParams(Tap): 15 | schema: Optional[str] = None 16 | text: Optional[str] = None 17 | 18 | class Predictor: 19 | 20 | uie_model = None 21 | 22 | @staticmethod 23 | def predict(text) -> str: 24 | if Predictor.uie_model is None: 25 | ie = Taskflow('information_extraction', schema=['时间', '地点', '人物', '公司']) 26 | result = pformat(ie(params.text)) 27 | 28 | 29 | class UIEPlugin(WechatyPlugin): 30 | VIEW_URL = '/api/plugins/uie/view' 31 | 32 | def __init__(self): 33 | super().__init__() 34 | 35 | self.prefix = 'task_flow.uie' 36 | 37 | @message_controller.may_disable_message 38 | async def on_message(self, msg: Message) -> None: 39 | room = msg.room() 40 | text = msg.text() 41 | 42 | if self.prefix not in text: 43 | return 44 | 45 | if room is not None: 46 | if not await msg.mention_self(): 47 | return 48 | text = remove_at_info(text).strip() 49 | 50 | if not text.startswith(self.prefix): 51 | return None 52 | 53 | # parse the arguments 54 | args = text.split() 55 | params: UIEParams = UIEParams().parse_args(args, known_only=True) 56 | 57 | if params.schema is None or params.text is None: 58 | return None 59 | 60 | schema = eval(params.schema) 61 | ie = Taskflow('information_extraction', schema=schema) 62 | 63 | result = pformat(ie(params.text)) 64 | 65 | if room: 66 | await room.say(result, mention_ids=[msg.talker().contact_id]) 67 | else: 68 | await msg.say(str(result)) -------------------------------------------------------------------------------- /src/plugins/views/send_message.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 批量发送消息 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 查询 29 | 30 | 31 | 32 | 33 |
34 | 35 | 71 | 72 | -------------------------------------------------------------------------------- /src/plugins/views/table.jinja2: -------------------------------------------------------------------------------- 1 |

来自于服务器的数据

2 | 此页面已被浏览:{{count}}次 -------------------------------------------------------------------------------- /src/plugins/views/vue.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | Click 13 | Count: {{count}} 14 |
15 | 16 | 43 | -------------------------------------------------------------------------------- /src/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from dataclasses import dataclass 3 | 4 | from dataclasses_json import dataclass_json 5 | 6 | 7 | 8 | @dataclass_json 9 | @dataclass 10 | class NavMetadata: 11 | """nav metadata""" 12 | view_url: Optional[str] = None 13 | author: Optional[str] = None # name of author 14 | avatar: Optional[str] = None # avatar of author 15 | author_link: Optional[str] = None # introduction link of author 16 | icon: Optional[str] = None # avatar of author 17 | -------------------------------------------------------------------------------- /src/ui_plugin.py: -------------------------------------------------------------------------------- 1 | from wechaty import WechatyPlugin 2 | from src.schema import NavMetadata 3 | from src.utils import SettingFileMixin 4 | 5 | class WechatyUIPlugin(WechatyPlugin, SettingFileMixin): 6 | AUTHOR = "wj-mcat" 7 | AVATAR = 'https://avatars.githubusercontent.com/u/10242208?v=4' 8 | AUTHOR_LINK = "https://github.com/wj-Mcat" 9 | ICON = "https://wechaty.js.org/img/wechaty-icon.svg" 10 | VIEW_URL = None 11 | UI_DIR = "ui/dist" 12 | 13 | def metadata(self) -> NavMetadata: 14 | """get the default nav metadata 15 | 16 | Returns: 17 | NavMetadata: the instance of metadata 18 | """ 19 | return NavMetadata( 20 | author=self.AUTHOR, 21 | author_link=self.AUTHOR_LINK, 22 | icon=self.ICON, 23 | avatar=self.AVATAR, 24 | view_url=self.VIEW_URL 25 | ) -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from quart import jsonify 4 | import socket 5 | 6 | 7 | def get_unused_localhost_port(): 8 | sock = socket.socket() 9 | # This tells the OS to give us any free port in the range [1024 - 65535] 10 | sock.bind(("", 0)) 11 | port = sock.getsockname()[1] 12 | sock.close() 13 | return port 14 | 15 | 16 | def remove_at_info(text: str) -> str: 17 | """get the clear message, remove the command prefix and at""" 18 | split_chars = ['\u2005', '\u0020'] 19 | while text.startswith('@'): 20 | text = text.strip() 21 | for char in split_chars: 22 | tokens = text.split(char) 23 | if len(tokens) > 1: 24 | tokens = [token for token in text.split(char) if not token.startswith('@')] 25 | text = char.join(tokens) 26 | else: 27 | text = ''.join(tokens) 28 | return text 29 | 30 | 31 | class SettingFileMixin: 32 | 33 | def get_setting_file(self) -> str: 34 | if hasattr(self, 'setting_file'): 35 | return self.setting_file 36 | self.setting_file = os.path.join(self.cache_dir, 'setting.json') 37 | os.makedirs( 38 | os.path.dirname(self.setting_file), 39 | exist_ok=True 40 | ) 41 | return self.setting_file 42 | 43 | @property 44 | def setting(self) -> dict: 45 | return self.get_setting(force_load=False) 46 | 47 | @setting.setter 48 | def setting(self, value: dict) -> dict: 49 | self.update_setting(value) 50 | 51 | def get_setting(self, force_load: bool = False) -> dict: 52 | """get the setting from the file""" 53 | with open(self.get_setting_file(), 'r', encoding='utf-8') as f: 54 | setting = json.load(f) 55 | return setting 56 | 57 | def update_setting(self, setting: dict) -> None: 58 | with open(self.get_setting_file(), 'w', encoding='utf-8') as f: 59 | json.dump(setting, f, ensure_ascii=True) 60 | return setting 61 | 62 | 63 | def success(data): 64 | return jsonify({ 65 | "data": data, 66 | "code": 200 67 | }) 68 | 69 | def error(msg): 70 | return jsonify({ 71 | "msg": msg, 72 | "code": 500 73 | }) 74 | -------------------------------------------------------------------------------- /start_gateway_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 设置环境变量 4 | if test -z "$1" 5 | then 6 | echo "请输入你申请的 padlocal token 作为参数, 再次运行脚本"&&exit 7 | else 8 | padlocal_prefix="puppet_padlocal_" 9 | if [[ $1 == *$padlocal_prefix* ]] 10 | then 11 | echo "环境变量设置完成, 启动docker" 12 | else 13 | echo -e "参数格式错误! 请输入类似以下格式的token:\n puppet_padlocal_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"&&exit 14 | fi 15 | fi 16 | export WECHATY_LOG="verbose" 17 | export WECHATY_PUPPET="wechaty-puppet-padlocal" 18 | export WECHATY_PUPPET_PADLOCAL_TOKEN=$1 19 | export WECHATY_PUPPET_SERVER_PORT="8080" 20 | export WECHATY_TOKEN=`python3 -c "import uuid;print(uuid.uuid4())"` 21 | sed -i s/token=.*/token=$WECHATY_TOKEN/g .env 22 | 23 | docker run -ti \ 24 | --name wechaty_puppet_service_token_gateway \ 25 | --rm \ 26 | -e WECHATY_LOG \ 27 | -e WECHATY_PUPPET \ 28 | -e WECHATY_PUPPET_PADLOCAL_TOKEN \ 29 | -e WECHATY_PUPPET_SERVER_PORT \ 30 | -e WECHATY_TOKEN \ 31 | -p "$WECHATY_PUPPET_SERVER_PORT:$WECHATY_PUPPET_SERVER_PORT" \ 32 | wechaty/wechaty:0.65 -------------------------------------------------------------------------------- /watchtower.py: -------------------------------------------------------------------------------- 1 | """watch tower for keep the alive of running container""" 2 | from __future__ import annotations 3 | import asyncio 4 | from typing import List, Optional 5 | import requests 6 | import docker 7 | from docker.models.containers import Container 8 | from wechaty_puppet import get_logger 9 | 10 | 11 | logger = get_logger("WatchTower", file='watch-tower.log') 12 | 13 | 14 | class WatchTower: 15 | """watchtower to keep container alive""" 16 | def __init__(self, name_or_id: str, interval_seconds: int = 60, port: int = 8003, try_times: int = 10) -> None: 17 | """init function 18 | 19 | Args: 20 | name_or_id (str): name or id of bot container 21 | interval_seconds (int, optional): . Defaults to 60. 22 | port (int, optional): the target port of bot service. Defaults to 8003. 23 | try_times (int, optional): try to check the alive status of bot container 24 | """ 25 | self.name_or_id = name_or_id 26 | self.interval_seconds = interval_seconds 27 | self.port = port 28 | self.try_times = try_times 29 | 30 | def find_bot_container(self) -> Optional[Container]: 31 | """find the bot container""" 32 | client = docker.from_env() 33 | containers: List[Container] = client.containers.list() or [] 34 | for container in containers: 35 | if container.name == self.name_or_id or container.id == self.name_or_id: 36 | return container 37 | return None 38 | 39 | def check_is_alive(self): 40 | """check if the bot is alive""" 41 | endpoint = f'http://localhost:{self.port}/ding' 42 | for _ in range(self.try_times): 43 | try: 44 | # 如果在60秒之内没有得到回复,可判断机器人的状态不是很良好 45 | requests.get(endpoint, timeout=60) 46 | 47 | # 如果返回了结果,可以判断bot正常运行,于是可返回其正常状态 48 | return True 49 | except: 50 | pass 51 | return False 52 | 53 | async def watch(self): 54 | """the main body of wathing""" 55 | logger.info('staring to watch the bot in docker ...') 56 | while True: 57 | container = self.find_bot_container() 58 | 59 | if container is not None: 60 | if not self.check_is_alive(): 61 | logger.error('===============================================================') 62 | logger.error('the bot is not alive. we are trying to restart the container ...') 63 | logger.error('===============================================================') 64 | container.restart() 65 | else: 66 | logger.info('the bot is alive ...') 67 | else: 68 | logger.error('can not find the container of the bot') 69 | await asyncio.sleep(self.interval_seconds) 70 | 71 | 72 | if __name__ == '__main__': 73 | # name_or_id: container id of your bot 74 | watch_tower = WatchTower(name_or_id='bot', interval_seconds=180) 75 | asyncio.run(watch_tower.watch()) 76 | --------------------------------------------------------------------------------