├── VERSION ├── src └── wechaty_puppet_service │ ├── py.typed │ ├── version.py │ ├── __init__.py │ ├── version_test.py │ ├── config.py │ ├── utils.py │ └── puppet.py ├── requirements.txt ├── .github ├── CODEOWNERS └── workflows │ └── pypi.yml ├── requirements-dev.txt ├── tests ├── smoke_testing_test.py ├── test_token_envrionment.py └── test_utils.py ├── pyproject.toml ├── .editorconfig ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── setup.py ├── README.md ├── .gitignore ├── Makefile ├── LICENSE └── .pylintrc /VERSION: -------------------------------------------------------------------------------- 1 | 0.8.10 2 | -------------------------------------------------------------------------------- /src/wechaty_puppet_service/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyee 2 | requests 3 | wechaty-grpc~=0.20.19 4 | ping3 -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # 2 | # https://help.github.com/articles/about-codeowners/ 3 | # 4 | 5 | * @wechaty/python 6 | -------------------------------------------------------------------------------- /src/wechaty_puppet_service/version.py: -------------------------------------------------------------------------------- 1 | """ 2 | Do not edit this file. 3 | This file will be auto-generated before deploy. 4 | """ 5 | VERSION = '0.0.0' 6 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | mypy 3 | mypy-extensions 4 | pycodestyle 5 | pylint 6 | pylint-quotes 7 | pytest 8 | pytype 9 | semver 10 | grpclib 11 | wechaty-puppet~=0.3dev2 12 | pre-commit -------------------------------------------------------------------------------- /src/wechaty_puppet_service/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | doc 3 | """ 4 | from .puppet import PuppetService 5 | 6 | from .version import VERSION 7 | 8 | 9 | __version__ = VERSION 10 | 11 | __all__ = [ 12 | 'PuppetService', 13 | 14 | '__version__' 15 | ] 16 | -------------------------------------------------------------------------------- /src/wechaty_puppet_service/version_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | version unit test, this file will be updated in deploy stage. 3 | """ 4 | # import pytest 5 | 6 | from .version import VERSION 7 | 8 | 9 | def test_version() -> None: 10 | """ 11 | Unit Test for version file 12 | """ 13 | 14 | assert VERSION == '0.0.0', 'version should be 0.0.0' 15 | -------------------------------------------------------------------------------- /tests/smoke_testing_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Unit Test 3 | """ 4 | # pylint: disable=W0621 5 | 6 | # from typing import ( 7 | # # Any, 8 | # Iterable, 9 | # ) 10 | 11 | import pytest # type: ignore 12 | 13 | # from agent import Agent 14 | 15 | 16 | def test_smoke_testing() -> None: 17 | """ wechaty """ 18 | assert pytest, 'should True' 19 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.mypy] 2 | disallow_untyped_defs = true 3 | warn_unused_ignores = true 4 | ignore_missing_imports = false 5 | 6 | [[tool.mypy.overrides]] 7 | module = [ 8 | "wechaty_puppet.*", 9 | "wechaty_puppet_service.*", 10 | "pytest.*", 11 | "grpclib.*", 12 | "lxml.*", 13 | "ping3.*", 14 | "wechaty_grpc.*", 15 | "pyee.*" 16 | ] 17 | ignore_missing_imports = true -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | max_line_length = 0 14 | indent_style = space 15 | trim_trailing_whitespace = false 16 | 17 | # 4 tab indentation 18 | [Makefile] 19 | indent_style = tab 20 | indent_size = 4 21 | 22 | [*.py] 23 | indent_size = 4 24 | -------------------------------------------------------------------------------- /tests/test_token_envrionment.py: -------------------------------------------------------------------------------- 1 | """unit test for token environemnt""" 2 | from __future__ import annotations 3 | 4 | import os 5 | 6 | from wechaty_puppet_service.config import get_token 7 | 8 | 9 | def test_service_token(): 10 | token = 'your-self-token' 11 | os.environ['WECHATY_PUPPET_SERVICE_TOKEN'] = token 12 | assert get_token() == token 13 | 14 | 15 | def test_upper_token_name(): 16 | token = 'your-self-token' 17 | os.environ['TOKEN'] = token 18 | assert get_token() == token 19 | 20 | 21 | def test_lower_token_name(): 22 | token = 'your-self-token' 23 | os.environ['token'] = token 24 | assert get_token() == token 25 | 26 | 27 | def test_none_token(): 28 | del os.environ['WECHATY_PUPPET_SERVICE_TOKEN'] 29 | del os.environ['TOKEN'] 30 | del os.environ['token'] 31 | assert get_token() is None 32 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | unit test for utils module 3 | """ 4 | from wechaty_puppet_service.utils import ( 5 | extract_host_and_port, 6 | ping_endpoint 7 | ) 8 | 9 | 10 | def test_http_endpoint_extraction(): 11 | endpoint = 'http://www.baidu.com' 12 | url, port = extract_host_and_port(endpoint) 13 | assert url == 'www.baidu.com' 14 | assert port == 80 15 | 16 | endpoint = 'https://www.baidu.com' 17 | url, port = extract_host_and_port(endpoint) 18 | assert port == 443 19 | 20 | 21 | def test_ip_address_endpoint_extraction(): 22 | endpoint = '127.0.0.1:80' 23 | url, port = extract_host_and_port(endpoint) 24 | assert url == '127.0.0.1' 25 | assert port == 80 26 | 27 | endpoint = '10.23.45.66:87' 28 | url, port = extract_host_and_port(endpoint) 29 | assert url == '10.23.45.66' 30 | assert port == 87 31 | 32 | 33 | def test_ping_endpoint(): 34 | valid_endpoint: str = 'https://www.baidu.com' 35 | assert ping_endpoint(valid_endpoint) 36 | 37 | invalid_endpoint: str = 'https://www.baidu.com:76' 38 | assert not ping_endpoint(invalid_endpoint) 39 | 40 | invalid_endpoint: str = 'https://www.abababa111111.com' 41 | assert not ping_endpoint(invalid_endpoint) 42 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # the hook execution directory in under git root directory 2 | repos: 3 | - repo: local 4 | hooks: 5 | 6 | - id: pylint 7 | name: pylint 8 | description: "Pylint: Checks for errors in Python code" 9 | language: system 10 | entry: make pylint 11 | require_serial: true 12 | stages: [push] 13 | types: [python] 14 | 15 | - id: pycodestyle 16 | name: pycodestyle 17 | description: "pycodestyle: Check your Python code against styles conventions in PEP 8" 18 | language: system 19 | entry: make pycodestyle 20 | require_serial: true 21 | stages: [push] 22 | types: [python] 23 | 24 | - id: flake8 25 | name: flake8 26 | description: "flake8: Tool For Style Guide Enforcement" 27 | language: system 28 | entry: make flake8 29 | require_serial: true 30 | stages: [push] 31 | types: [python] 32 | 33 | - id: mypy 34 | name: mypy 35 | description: "mypy: an optional static type checker for Python" 36 | language: system 37 | entry: make mypy 38 | require_serial: true 39 | stages: [push] 40 | types: [python] 41 | 42 | - id: pytest 43 | name: pytest 44 | description: "pytest: run python pytest unit test" 45 | language: system 46 | entry: make pytest 47 | require_serial: true 48 | stages: [push] 49 | types: [python] 50 | 51 | - id: bump-version 52 | name: bump-version 53 | description: "Bumped Version: bump the version when a new commit come in" 54 | language: system 55 | entry: make version 56 | require_serial: true 57 | stages: [push] 58 | types: [python] 59 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "python3", 3 | "python.linting.enabled": true, 4 | 5 | "python.linting.pylintEnabled": true, 6 | "python.linting.pylintArgs": [ 7 | "--load-plugins", 8 | "pylint_quotes" 9 | ], 10 | 11 | "python.linting.mypyEnabled": true, 12 | "python.linting.mypyArgs": [ 13 | "--ignore-missing-imports", 14 | "--follow-imports=silent", 15 | "--python-version=3" 16 | ], 17 | 18 | "python.linting.pycodestyleEnabled": false, 19 | 20 | "python.linting.flake8Enabled": true, 21 | "python.linting.flake8Args": [ 22 | "--ignore=E203,E221,E241,E272,E501,F811," 23 | ], 24 | 25 | "alignment": { 26 | "operatorPadding": "right", 27 | "indentBase": "firstline", 28 | "surroundSpace": { 29 | "colon": [1, 1], // The first number specify how much space to add to the left, can be negative. The second number is how much space to the right, can be negative. 30 | "assignment": [1, 1], // The same as above. 31 | "arrow": [1, 1], // The same as above. 32 | "comment": 2, // Special how much space to add between the trailing comment and the code. 33 | // If this value is negative, it means don't align the trailing comment. 34 | }, 35 | }, 36 | 37 | "files.exclude": { 38 | "build/": true, 39 | "dist/": true, 40 | "*_cache/": true, 41 | ".pytype/": true, 42 | "**/__pycache__": true, 43 | "**/*.egg-info/": true, 44 | }, 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/wechaty_puppet_service/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python Wechaty - https://github.com/wechaty/python-wechaty 3 | 4 | Authors: Huan LI (李卓桓) 5 | Jingjing WU (吴京京) 6 | 7 | 2020-now @ Copyright Wechaty 8 | 9 | Licensed under the Apache License, Version 2.0 (the 'License'); 10 | you may not use this file except in compliance with the License. 11 | You may obtain a copy of the License at 12 | 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an 'AS IS' BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | """ 21 | import os 22 | from typing import Optional 23 | from wechaty_puppet import get_logger 24 | 25 | logger = get_logger('WechatyPuppetServiceConfig') 26 | 27 | # send 1M data in every async request 28 | CHUNK_SIZE = 1024 * 1024 29 | 30 | 31 | def get_token() -> Optional[str]: 32 | """ 33 | get the token from environment variable 34 | """ 35 | return os.environ.get('WECHATY_PUPPET_SERVICE_TOKEN', None) or \ 36 | os.environ.get('TOKEN', None) or \ 37 | os.environ.get('token', None) or None 38 | 39 | 40 | def get_endpoint() -> Optional[str]: 41 | """ 42 | get the endpoint from environment variable 43 | """ 44 | return os.environ.get('WECHATY_PUPPET_SERVICE_ENDPOINT', None) or \ 45 | os.environ.get('ENDPOINT', None) or \ 46 | os.environ.get('endpoint', None) or None 47 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | setup 3 | """ 4 | import semver 5 | import setuptools 6 | 7 | 8 | def versioning(version: str) -> str: 9 | """version to specification""" 10 | sem_ver = semver.parse(version) 11 | 12 | major = sem_ver['major'] 13 | minor = sem_ver['minor'] 14 | patch = str(sem_ver['patch']) 15 | 16 | if minor % 2: 17 | patch = 'dev' + patch 18 | 19 | fin_ver = '%d.%d.%s' % ( 20 | major, 21 | minor, 22 | patch, 23 | ) 24 | 25 | return fin_ver 26 | 27 | 28 | def get_install_requires() -> str: 29 | """get install_requires""" 30 | with open('requirements.txt', 'r') as requirements_fh: 31 | return requirements_fh.read().splitlines() 32 | 33 | 34 | def setup() -> None: 35 | """setup""" 36 | 37 | with open('README.md', 'r') as fh: 38 | long_description = fh.read() 39 | 40 | version = '0.0.0' 41 | with open('VERSION', 'r') as fh: 42 | version = versioning(fh.readline()) 43 | 44 | setuptools.setup( 45 | name='wechaty-puppet-service', 46 | version=version, 47 | author='Huan LI (李卓桓)', 48 | author_email='zixia@zixia.net', 49 | description='Python Service Puppet for Wechaty', 50 | long_description=long_description, 51 | long_description_content_type='text/markdown', 52 | license='Apache-2.0', 53 | url='https://github.com/wechaty/python-wechaty-puppet-service', 54 | packages=setuptools.find_packages('src'), 55 | package_data={"wechaty_puppet_service": ["py.typed"]}, 56 | package_dir={'': 'src'}, 57 | install_requires=get_install_requires(), 58 | classifiers=[ 59 | 'Programming Language :: Python :: 3.7', 60 | 'License :: OSI Approved :: Apache Software License', 61 | 'Operating System :: OS Independent', 62 | ], 63 | ) 64 | 65 | 66 | setup() 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wechaty-puppet-service [![Python 3.7](https://img.shields.io/badge/python-3.7+-blue.svg)](https://www.python.org/downloads/release/python-370/) [![PyPI GitHub Actions](https://github.com/wechaty/python-wechaty-puppet-service/workflows/PyPI/badge.svg)](https://github.com/wechaty/python-wechaty-puppet/actions?query=workflow%3APyPI) 2 | 3 | [![Powered by Wechaty](https://img.shields.io/badge/Powered%20By-Wechaty-brightgreen.svg)](https://github.com/wechaty/wechaty) 4 | 5 | ![Service](https://wechaty.github.io/wechaty-puppet-service/images/hostie.png) 6 | 7 | [![PyPI Version](https://img.shields.io/pypi/v/wechaty-puppet-service?color=blue)](https://pypi.org/project/wechaty-puppet-service) 8 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/wechaty-puppet-service?color=blue) 9 | 10 | Python Service Puppet for Wechaty 11 | 12 | ## Features 13 | 14 | 1. Consume Wechaty Puppet Service 15 | 16 | ## Usage 17 | 18 | ```python 19 | import asyncio 20 | from wechaty import Wechaty 21 | from wechaty_puppet_service import PuppetService 22 | 23 | bot = Wechaty(PuppetService("your-token-here")) 24 | bot.on('message', lambda x: print(x)) 25 | 26 | asyncio.run(bot.start()) 27 | ``` 28 | 29 | ## Environment Variables 30 | 31 | ### 1 `WECHATY_PUPPET_SERVICE_TOKEN` 32 | 33 | The token set to this environment variable will become the default value of `puppetOptions.token` 34 | 35 | ```sh 36 | WECHATY_PUPPET_SERVICE_TOKEN=secret python bot.py 37 | ``` 38 | 39 | or you can use `TOKEN` or `token` environment variable alias name to set **token**, for example: 40 | 41 | ```shell 42 | TOKEN=secret python bot.py 43 | # or 44 | token=secret python bot.py 45 | ``` 46 | 47 | ## History 48 | 49 | ### master 50 | 51 | ### v0.7 (Mar, 2021) 52 | 53 | Rename from `wechaty-puppet-hostie` -> `wechaty-puppet-service` 54 | 55 | ### v0.0.1 (Mar 10, 2020) 56 | 57 | 1. Init Python version 58 | 1. Published to PyPI 59 | 60 | ## Authors 61 | 62 | - [@wj-Mcat](https://github.com/wj-Mcat) - Jingjing WU (吴京京) 63 | - [@huan](https://github.com/huan) - ([李卓桓](http://linkedin.com/in/zixia)) zixia@zixia.net 64 | 65 | ## Copyright & License 66 | 67 | * Code & Docs © 2020-now Huan LI \ 68 | * Code released under the Apache-2.0 License 69 | * Docs released under Creative Commons 70 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | name: Build 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: actions/setup-python@v1 12 | with: 13 | python-version: 3.8 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | make install 18 | - name: Test 19 | run: make test 20 | 21 | pack: 22 | name: Pack 23 | needs: build 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | - uses: actions/setup-python@v1 28 | with: 29 | python-version: 3.8 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | pip install setuptools wheel twine 34 | make install 35 | - name: Pack Testing 36 | run: | 37 | make dist 38 | echo "To be add: pack testing" 39 | 40 | deploy: 41 | if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/heads/v')) 42 | name: Deploy 43 | needs: [build, pack] 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v2 47 | - uses: actions/setup-python@v1 48 | with: 49 | python-version: 3.8 50 | - name: Install dependencies 51 | run: | 52 | python -m pip install --upgrade pip 53 | pip install setuptools wheel twine 54 | make install 55 | 56 | - name: Check Branch 57 | id: check-branch 58 | run: | 59 | if [[ ${{ github.ref }} =~ ^refs/heads/(master|v[0-9]+\.[0-9]+.*)$ ]]; then 60 | echo ::set-output name=match::true 61 | fi # See: https://stackoverflow.com/a/58869470/1123955 62 | 63 | - name: Is A Publish Branch 64 | if: steps.check-branch.outputs.match == 'true' 65 | env: 66 | TWINE_USERNAME: __token__ 67 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 68 | run: | 69 | make deploy-version 70 | python setup.py sdist bdist_wheel 71 | twine upload --skip-existing dist/* 72 | 73 | - name: Is Not A Publish Branch 74 | if: steps.check-branch.outputs.match != 'true' 75 | run: echo 'Not A Publish Branch' 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 | .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 | .pytype/ 131 | .idea/ 132 | **/logs/ 133 | src/wechaty_puppet_service/*.pyi 134 | src/wechaty_puppet_service/**/*.pyi 135 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Python Wechaty 2 | # 3 | # GitHb: https://github.com/wechaty/python-wechaty 4 | # Author: Huan LI git.io/zixia 5 | # 6 | 7 | SOURCE_GLOB=$(wildcard bin/*.py src/**/*.py tests/**/*.py) 8 | 9 | # 10 | # Huan(202003) 11 | # F811: https://github.com/PyCQA/pyflakes/issues/320#issuecomment-469337000 12 | # 13 | IGNORE_PEP=E203,E221,E241,E272,E501,F811 14 | 15 | # help scripts to find the right place of wechaty module 16 | export PYTHONPATH=src/ 17 | 18 | .PHONY: all 19 | all : clean lint 20 | 21 | .PHONY: clean 22 | clean: 23 | rm -fr dist/* 24 | rm -rf src/wechaty_puppet_service/*.pyi src/wechaty_puppet_service/**/*.pyi 25 | 26 | .PHONY: lint 27 | lint: pylint pycodestyle flake8 mypy pytype 28 | 29 | .PHONY: pylint 30 | pylint: 31 | pylint \ 32 | --load-plugins pylint_quotes \ 33 | --disable W0511,C0302,W1203 \ 34 | $(SOURCE_GLOB) 35 | 36 | .PHONY: pycodestyle 37 | pycodestyle: 38 | pycodestyle \ 39 | --statistics \ 40 | --count \ 41 | --ignore="${IGNORE_PEP}" \ 42 | $(SOURCE_GLOB) 43 | 44 | .PHONY: flake8 45 | flake8: 46 | flake8 \ 47 | --ignore="${IGNORE_PEP}" \ 48 | $(SOURCE_GLOB) 49 | 50 | .PHONY: mypy 51 | mypy: 52 | MYPYPATH=stubs/ mypy --install-types --non-interactive \ 53 | $(SOURCE_GLOB) 54 | 55 | .PHONE: pytype 56 | pytype: 57 | pytype --disable=import-error,pyi-error \ 58 | src/ 59 | 60 | .PHONY: uninstall-git-hook 61 | uninstall-git-hook: 62 | pre-commit clean 63 | pre-commit gc 64 | pre-commit uninstall 65 | pre-commit uninstall --hook-type pre-push 66 | 67 | .PHONY: install-git-hook 68 | install-git-hook: 69 | # cleanup existing pre-commit configuration (if any) 70 | pre-commit clean 71 | pre-commit gc 72 | # setup pre-commit 73 | # Ensures pre-commit hooks point to latest versions 74 | pre-commit autoupdate 75 | pre-commit install 76 | pre-commit install --overwrite --hook-type pre-push 77 | 78 | .PHONY: install 79 | install: 80 | pip3 install -r requirements.txt 81 | pip3 install -r requirements-dev.txt 82 | # install pre-commit related hook scripts 83 | $(MAKE) install-git-hook 84 | 85 | .PHONY: pytest 86 | pytest: 87 | pytest src/ tests/ 88 | 89 | .PHONY: test-unit 90 | test-unit: pytest 91 | 92 | .PHONY: test 93 | test: lint pytest 94 | 95 | code: 96 | code . 97 | 98 | .PHONY: run 99 | run: 100 | python3 bin/run.py 101 | 102 | .PHONY: dist 103 | dist: 104 | make clean 105 | python3 setup.py sdist bdist_wheel 106 | 107 | .PHONY: publish 108 | publish: 109 | PATH=~/.local/bin:${PATH} twine upload dist/* 110 | 111 | .PHONY: version 112 | version: 113 | @newVersion=$$(awk -F. '{print $$1"."$$2"."$$3+1}' < VERSION) \ 114 | && echo $${newVersion} > VERSION \ 115 | && git add VERSION \ 116 | && git commit -m "🔥 update version to $${newVersion}" > /dev/null \ 117 | && git tag "v$${newVersion}" \ 118 | && echo "Bumped version to $${newVersion}" 119 | 120 | .PHONY: deploy-version 121 | deploy-version: 122 | echo "VERSION = '$$(cat VERSION)'" > src/wechaty_puppet_service/version.py 123 | -------------------------------------------------------------------------------- /src/wechaty_puppet_service/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python Wechaty - https://github.com/wechaty/python-wechaty 3 | 4 | Authors: Huan LI (李卓桓) 5 | Jingjing WU (吴京京) 6 | 7 | 2020-now @ Copyright Wechaty 8 | 9 | Licensed under the Apache License, Version 2.0 (the 'License'); 10 | you may not use this file except in compliance with the License. 11 | You may obtain a copy of the License at 12 | 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an 'AS IS' BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | """ 21 | from __future__ import annotations 22 | 23 | from telnetlib import Telnet 24 | 25 | import socket 26 | from typing import Tuple 27 | from urllib.parse import urlsplit 28 | from xml.dom import minidom 29 | 30 | from wechaty_puppet import FileBox, WechatyPuppetError 31 | 32 | 33 | def extract_host_and_port(url: str) -> Tuple[str, int]: 34 | """ 35 | It should be : format, but it can be a service name. 36 | If it's in docker cluster network, the port can be None. 37 | 38 | Args: 39 | url (str): the service endpoint or names 40 | 41 | Return: 42 | host (str), port (int) 43 | """ 44 | # 1. be sure that there is schema(http:// or https://) in endpoint 45 | if not url.startswith('http://') and not url.startswith('https://'): 46 | url = f'http://{url}' 47 | 48 | # 2. extract host & port from url 49 | split_result = urlsplit(url) 50 | host = split_result.hostname 51 | if not host: 52 | raise WechatyPuppetError(f'invalid url: {url}') 53 | 54 | default_port = 443 if split_result.scheme == 'https' else 80 55 | port = split_result.port or default_port 56 | 57 | return host, port 58 | 59 | 60 | def ping_endpoint(end_point: str) -> bool: 61 | """ 62 | Check end point is valid 63 | Use different method: 64 | 1. If there is port: telnet 65 | 2. If there is host/domain: ping 66 | 67 | Args: 68 | end_point (str): host and port 69 | 70 | Return: 71 | return True if end point is valid, otherwise False 72 | 73 | Examples: 74 | >>> end_point = '127.0.0.1:80' 75 | >>> assert ping_endpoint(end_point) 76 | 77 | """ 78 | # 1. extract host & port 79 | tn = Telnet() 80 | host, port = extract_host_and_port(end_point) 81 | 82 | # 2. test host:port with socket 83 | res = True 84 | try: 85 | tn.open(host, port=port, timeout=3) 86 | except socket.error: 87 | res = False 88 | 89 | return res 90 | 91 | 92 | async def message_emoticon(message: str) -> FileBox: 93 | """ 94 | emoticon from message 95 | 96 | :param message: 97 | :return: 98 | """ 99 | dom_tree = minidom.parseString(message) 100 | collection = dom_tree.documentElement 101 | file_box = FileBox.from_url( 102 | url=collection.getElementsByTagName('emoji')[0].getAttribute('cdnurl'), 103 | name=collection.getElementsByTagName('emoji')[0].getAttribute('md5') + '.gif' 104 | ) 105 | return file_box 106 | -------------------------------------------------------------------------------- /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 2016-2018 Huan LI (李卓桓) 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 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # A comma-separated list of package or module names from where C extensions may 4 | # be loaded. Extensions are loading into the active Python interpreter and may 5 | # run arbitrary code 6 | # https://github.com/pytorch/pytorch/issues/1942#issuecomment-315681074 7 | # extension-pkg-whitelist=numpy,torch 8 | 9 | # Add files or directories to the blacklist. They should be base names, not 10 | # paths. 11 | ignore=CVS 12 | 13 | # Add files or directories matching the regex patterns to the blacklist. The 14 | # regex matches against base names, not paths. 15 | ignore-patterns= 16 | 17 | # Python code to execute, usually for sys.path manipulation such as 18 | # pygtk.require(). 19 | #init-hook= 20 | 21 | # Use multiple processes to speed up Pylint. 22 | jobs=1 23 | 24 | # List of plugins (as comma separated values of python modules names) to load, 25 | # usually to register additional checkers. 26 | load-plugins= 27 | 28 | # Pickle collected data for later comparisons. 29 | persistent=yes 30 | 31 | # Specify a configuration file. 32 | #rcfile= 33 | 34 | # Allow loading of arbitrary C extensions. Extensions are imported into the 35 | # active Python interpreter and may run arbitrary code. 36 | unsafe-load-any-extension=no 37 | 38 | 39 | [MESSAGES CONTROL] 40 | 41 | # Only show warnings with the listed confidence levels. Leave empty to show 42 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 43 | confidence= 44 | 45 | # Disable the message, report, category or checker with the given id(s). You 46 | # can either give multiple identifiers separated by comma (,) or put this 47 | # option multiple times (only on the command line, not in the configuration 48 | # file where it should appear only once).You can also use "--disable=all" to 49 | # disable everything first and then reenable specific checks. For example, if 50 | # you want to run only the similarities checker, you can use "--disable=all 51 | # --enable=similarities". If you want to run only the classes checker, but have 52 | # no Warning level messages displayed, use"--disable=all --enable=classes 53 | # --disable=W" 54 | disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,long-suffix,old-ne-operator,old-octal-literal,import-star-module-level,raw-checker-failed,bad-inline-option,locally-disabled,locally-enabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,eq-without-hash,div-method,idiv-method,rdiv-method,exception-message-attribute,invalid-str-codec,sys-max-int,bad-python3-import,deprecated-string-function,deprecated-str-translate-call,C0103,W1401,E203,C0326 55 | 56 | # Enable the message, report, category or checker with the given id(s). You can 57 | # either give multiple identifier separated by comma (,) or put this option 58 | # multiple time (only on the command line, not in the configuration file where 59 | # it should appear only once). See also the "--disable" option for examples. 60 | enable= 61 | 62 | 63 | [REPORTS] 64 | 65 | # Python expression which should return a note less than 10 (10 is the highest 66 | # note). You have access to the variables errors warning, statement which 67 | # respectively contain the number of errors / warnings messages and the total 68 | # number of statements analyzed. This is used by the global evaluation report 69 | # (RP0004). 70 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 71 | 72 | # Template used to display messages. This is a python new-style format string 73 | # used to format the message information. See doc for all details 74 | #msg-template= 75 | 76 | # Set the output format. Available formats are text, parseable, colorized, json 77 | # and msvs (visual studio).You can also give a reporter class, eg 78 | # mypackage.mymodule.MyReporterClass. 79 | output-format=text 80 | 81 | # Tells whether to display a full report or only the messages 82 | reports=no 83 | 84 | # Activate the evaluation score. 85 | score=yes 86 | 87 | 88 | [REFACTORING] 89 | 90 | # Maximum number of nested blocks for function / method body 91 | max-nested-blocks=5 92 | 93 | 94 | [LOGGING] 95 | 96 | # Logging modules to check that the string format arguments are in logging 97 | # function parameter format 98 | logging-modules=logging 99 | 100 | 101 | [VARIABLES] 102 | 103 | # List of additional names supposed to be defined in builtins. Remember that 104 | # you should avoid to define new builtins when possible. 105 | additional-builtins= 106 | 107 | # Tells whether unused global variables should be treated as a violation. 108 | allow-global-unused-variables=yes 109 | 110 | # List of strings which can identify a callback function by name. A callback 111 | # name must start or end with one of those strings. 112 | callbacks=cb_,_cb 113 | 114 | # A regular expression matching the name of dummy variables (i.e. expectedly 115 | # not used). 116 | dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ 117 | 118 | # Argument names that match this expression will be ignored. Default to name 119 | # with leading underscore 120 | ignored-argument-names=_.*|^ignored_|^unused_ 121 | 122 | # Tells whether we should check for unused import in __init__ files. 123 | init-import=no 124 | 125 | # List of qualified module names which can have objects that can redefine 126 | # builtins. 127 | redefining-builtins-modules=six.moves,future.builtins 128 | 129 | 130 | [TYPECHECK] 131 | 132 | # List of decorators that produce context managers, such as 133 | # contextlib.contextmanager. Add to this list to register other decorators that 134 | # produce valid context managers. 135 | contextmanager-decorators=contextlib.contextmanager 136 | 137 | # List of members which are set dynamically and missed by pylint inference 138 | # system, and so shouldn't trigger E1101 when accessed. Python regular 139 | # expressions are accepted. 140 | generated-members= 141 | 142 | # Tells whether missing members accessed in mixin class should be ignored. A 143 | # mixin class is detected if its name ends with "mixin" (case insensitive). 144 | ignore-mixin-members=yes 145 | 146 | # This flag controls whether pylint should warn about no-member and similar 147 | # checks whenever an opaque object is returned when inferring. The inference 148 | # can return multiple potential results while evaluating a Python object, but 149 | # some branches might not be evaluated, which results in partial inference. In 150 | # that case, it might be useful to still emit no-member and other checks for 151 | # the rest of the inferred objects. 152 | ignore-on-opaque-inference=yes 153 | 154 | # List of class names for which member attributes should not be checked (useful 155 | # for classes with dynamically set attributes). This supports the use of 156 | # qualified names. 157 | ignored-classes=optparse.Values,thread._local,_thread._local 158 | 159 | # List of module names for which member attributes should not be checked 160 | # (useful for modules/projects where namespaces are manipulated during runtime 161 | # and thus existing member attributes cannot be deduced by static analysis. It 162 | # supports qualified module names, as well as Unix pattern matching. 163 | ignored-modules= 164 | 165 | # Show a hint with possible names when a member name was not found. The aspect 166 | # of finding the hint is based on edit distance. 167 | missing-member-hint=yes 168 | 169 | # The minimum edit distance a name should have in order to be considered a 170 | # similar match for a missing member name. 171 | missing-member-hint-distance=1 172 | 173 | # The total number of similar names that should be taken in consideration when 174 | # showing a hint for a missing member. 175 | missing-member-max-choices=1 176 | 177 | 178 | [FORMAT] 179 | 180 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 181 | expected-line-ending-format= 182 | 183 | # Regexp for a line that is allowed to be longer than the limit. 184 | ignore-long-lines=^\s*(# )??$ 185 | 186 | # Number of spaces of indent required inside a hanging or continued line. 187 | indent-after-paren=4 188 | 189 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 190 | # tab). 191 | indent-string=' ' 192 | 193 | # Maximum number of characters on a single line. 194 | max-line-length=100 195 | 196 | # Maximum number of lines in a module 197 | max-module-lines=1000 198 | 199 | # List of optional constructs for which whitespace checking is disabled. `dict- 200 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 201 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 202 | # `empty-line` allows space-only lines. 203 | no-space-check=trailing-comma,dict-separator 204 | 205 | # Allow the body of a class to be on the same line as the declaration if body 206 | # contains single statement. 207 | single-line-class-stmt=no 208 | 209 | # Allow the body of an if to be on the same line as the test if there is no 210 | # else. 211 | single-line-if-stmt=no 212 | 213 | 214 | [SPELLING] 215 | 216 | # Spelling dictionary name. Available dictionaries: none. To make it working 217 | # install python-enchant package. 218 | spelling-dict= 219 | 220 | # List of comma separated words that should not be checked. 221 | spelling-ignore-words= 222 | 223 | # A path to a file that contains private dictionary; one word per line. 224 | spelling-private-dict-file= 225 | 226 | # Tells whether to store unknown words to indicated private dictionary in 227 | # --spelling-private-dict-file option instead of raising a message. 228 | spelling-store-unknown-words=no 229 | 230 | 231 | [SIMILARITIES] 232 | 233 | # Ignore comments when computing similarities. 234 | ignore-comments=yes 235 | 236 | # Ignore docstrings when computing similarities. 237 | ignore-docstrings=yes 238 | 239 | # Ignore imports when computing similarities. 240 | ignore-imports=no 241 | 242 | # Minimum lines number of a similarity. 243 | min-similarity-lines=4 244 | 245 | 246 | [MISCELLANEOUS] 247 | 248 | # List of note tags to take in consideration, separated by a comma. 249 | notes=FIXME,XXX,TODO 250 | 251 | 252 | [BASIC] 253 | 254 | # Huan(202003) 255 | string-quote=single-avoid-escape 256 | 257 | # Naming hint for argument names 258 | argument-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 259 | 260 | # Regular expression matching correct argument names 261 | argument-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 262 | 263 | # Naming hint for attribute names 264 | attr-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 265 | 266 | # Regular expression matching correct attribute names 267 | attr-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 268 | 269 | # Bad variable names which should always be refused, separated by a comma 270 | bad-names=foo,bar,baz,toto,tutu,tata 271 | 272 | # Naming hint for class attribute names 273 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 274 | 275 | # Regular expression matching correct class attribute names 276 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 277 | 278 | # Naming hint for class names 279 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 280 | 281 | # Regular expression matching correct class names 282 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 283 | 284 | # Naming hint for constant names 285 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 286 | 287 | # Regular expression matching correct constant names 288 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 289 | 290 | # Minimum line length for functions/classes that require docstrings, shorter 291 | # ones are exempt. 292 | docstring-min-length=-1 293 | 294 | # Naming hint for function names 295 | function-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 296 | 297 | # Regular expression matching correct function names 298 | function-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 299 | 300 | # Good variable names which should always be accepted, separated by a comma 301 | good-names=i,j,k,ex,Run,_ 302 | 303 | # Include a hint for the correct naming format with invalid-name 304 | include-naming-hint=no 305 | 306 | # Naming hint for inline iteration names 307 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 308 | 309 | # Regular expression matching correct inline iteration names 310 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 311 | 312 | # Naming hint for method names 313 | method-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 314 | 315 | # Regular expression matching correct method names 316 | method-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 317 | 318 | # Naming hint for module names 319 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 320 | 321 | # Regular expression matching correct module names 322 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 323 | 324 | # Colon-delimited sets of names that determine each other's naming style when 325 | # the name regexes allow several styles. 326 | name-group= 327 | 328 | # Regular expression which should only match function or class names that do 329 | # not require a docstring. 330 | no-docstring-rgx=^_ 331 | 332 | # List of decorators that produce properties, such as abc.abstractproperty. Add 333 | # to this list to register other decorators that produce valid properties. 334 | property-classes=abc.abstractproperty 335 | 336 | # Naming hint for variable names 337 | variable-name-hint=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 338 | 339 | # Regular expression matching correct variable names 340 | variable-rgx=(([a-z][a-z0-9_]{2,30})|(_[a-z0-9_]*))$ 341 | 342 | 343 | [DESIGN] 344 | 345 | # Maximum number of arguments for function / method 346 | max-args=9 347 | 348 | # Maximum number of attributes for a class (see R0902). 349 | max-attributes=7 350 | 351 | # Maximum number of boolean expressions in a if statement 352 | max-bool-expr=5 353 | 354 | # Maximum number of branch for function / method body 355 | max-branches=12 356 | 357 | # Maximum number of locals for function / method body 358 | max-locals=15 359 | 360 | # Maximum number of parents for a class (see R0901). 361 | max-parents=7 362 | 363 | # Maximum number of public methods for a class (see R0904). 364 | max-public-methods=20 365 | 366 | # Maximum number of return / yield for function / method body 367 | max-returns=6 368 | 369 | # Maximum number of statements in function / method body 370 | max-statements=50 371 | 372 | # Minimum number of public methods for a class (see R0903). 373 | min-public-methods=2 374 | 375 | 376 | [IMPORTS] 377 | 378 | # Allow wildcard imports from modules that define __all__. 379 | allow-wildcard-with-all=no 380 | 381 | # Analyse import fallback blocks. This can be used to support both Python 2 and 382 | # 3 compatible code, which means that the block might have code that exists 383 | # only in one or another interpreter, leading to false positives when analysed. 384 | analyse-fallback-blocks=no 385 | 386 | # Deprecated modules which should not be used, separated by a comma 387 | deprecated-modules=optparse,tkinter.tix 388 | 389 | # Create a graph of external dependencies in the given file (report RP0402 must 390 | # not be disabled) 391 | ext-import-graph= 392 | 393 | # Create a graph of every (i.e. internal and external) dependencies in the 394 | # given file (report RP0402 must not be disabled) 395 | import-graph= 396 | 397 | # Create a graph of internal dependencies in the given file (report RP0402 must 398 | # not be disabled) 399 | int-import-graph= 400 | 401 | # Force import order to recognize a module as part of the standard 402 | # compatibility libraries. 403 | known-standard-library= 404 | 405 | # Force import order to recognize a module as part of a third party library. 406 | known-third-party=enchant 407 | 408 | 409 | [CLASSES] 410 | 411 | # List of method names used to declare (i.e. assign) instance attributes. 412 | defining-attr-methods=__init__,__new__,setUp 413 | 414 | # List of member names, which should be excluded from the protected access 415 | # warning. 416 | exclude-protected=_asdict,_fields,_replace,_source,_make 417 | 418 | # List of valid names for the first argument in a class method. 419 | valid-classmethod-first-arg=cls 420 | 421 | # List of valid names for the first argument in a metaclass class method. 422 | valid-metaclass-classmethod-first-arg=mcs 423 | 424 | 425 | [EXCEPTIONS] 426 | 427 | # Exceptions that will emit a warning when being caught. Defaults to 428 | # "Exception" 429 | overgeneral-exceptions=Exception 430 | -------------------------------------------------------------------------------- /src/wechaty_puppet_service/puppet.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python Wechaty - https://github.com/wechaty/python-wechaty 3 | 4 | Authors: Huan LI (李卓桓) 5 | Jingjing WU (吴京京) 6 | 7 | 2020-now @ Copyright Wechaty 8 | 9 | Licensed under the Apache License, Version 2.0 (the 'License'); 10 | you may not use this file except in compliance with the License. 11 | You may obtain a copy of the License at 12 | 13 | http://www.apache.org/licenses/LICENSE-2.0 14 | 15 | Unless required by applicable law or agreed to in writing, software 16 | distributed under the License is distributed on an 'AS IS' BASIS, 17 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | See the License for the specific language governing permissions and 19 | limitations under the License. 20 | """ 21 | from __future__ import annotations 22 | 23 | import json 24 | from typing import Callable, Optional, List 25 | from dataclasses import asdict 26 | import requests 27 | 28 | from wechaty_grpc.wechaty import ( 29 | PuppetStub, 30 | ) 31 | from wechaty_grpc.wechaty.puppet import ( 32 | MessageFileResponse, 33 | MessageImageResponse 34 | ) 35 | # pylint: disable=E0401 36 | from grpclib.client import Channel 37 | # pylint: disable=E0401 38 | from pyee import AsyncIOEventEmitter 39 | from wechaty_puppet.schemas.types import PayloadType 40 | 41 | from wechaty_puppet import ( 42 | EventScanPayload, 43 | ScanStatus, 44 | 45 | EventReadyPayload, 46 | 47 | EventDongPayload, 48 | EventRoomTopicPayload, 49 | EventRoomLeavePayload, 50 | EventRoomJoinPayload, 51 | EventRoomInvitePayload, 52 | 53 | EventMessagePayload, 54 | EventLogoutPayload, 55 | EventLoginPayload, 56 | EventFriendshipPayload, 57 | EventHeartbeatPayload, 58 | EventErrorPayload, 59 | FileBox, RoomMemberPayload, RoomPayload, RoomInvitationPayload, 60 | RoomQueryFilter, FriendshipPayload, ContactPayload, MessagePayload, 61 | MessageQueryFilter, 62 | 63 | ImageType, 64 | EventType, 65 | MessageType, 66 | Puppet, 67 | PuppetOptions, 68 | MiniProgramPayload, 69 | UrlLinkPayload, 70 | 71 | get_logger 72 | ) 73 | 74 | from wechaty_puppet.exceptions import ( 75 | WechatyPuppetConfigurationError, 76 | WechatyPuppetError, 77 | WechatyPuppetGrpcError, 78 | WechatyPuppetOperationError, 79 | WechatyPuppetPayloadError 80 | ) 81 | 82 | from wechaty_puppet_service.config import ( 83 | get_endpoint, 84 | get_token, 85 | ) 86 | from wechaty_puppet_service.utils import ( 87 | extract_host_and_port, 88 | ping_endpoint, 89 | message_emoticon 90 | ) 91 | 92 | log = get_logger('PuppetService') 93 | 94 | 95 | def _map_message_type(message_payload: MessagePayload) -> MessagePayload: 96 | """ 97 | get messageType value which is ts-wechaty-puppet type from service server, 98 | but is MessageType. so we should map it to MessageType from wechaty-grpc 99 | target MessageType Enum: 100 | MESSAGE_TYPE_UNSPECIFIED = 0; 101 | 102 | MESSAGE_TYPE_ATTACHMENT = 1; 103 | MESSAGE_TYPE_AUDIO = 2; 104 | MESSAGE_TYPE_CONTACT = 3; 105 | MESSAGE_TYPE_EMOTICON = 4; 106 | MESSAGE_TYPE_IMAGE = 5; 107 | MESSAGE_TYPE_TEXT = 6; 108 | MESSAGE_TYPE_VIDEO = 7; 109 | MESSAGE_TYPE_CHAT_HISTORY = 8; 110 | MESSAGE_TYPE_LOCATION = 9; 111 | MESSAGE_TYPE_MINI_PROGRAM = 10; 112 | MESSAGE_TYPE_TRANSFER = 11; 113 | MESSAGE_TYPE_RED_ENVELOPE = 12; 114 | MESSAGE_TYPE_RECALLED = 13; 115 | MESSAGE_TYPE_URL = 14; 116 | 117 | source MessageType Enum: 118 | export enum MessageType { 119 | Unknown = 0, 120 | 121 | Attachment=1, // Attach(6), 122 | Audio=2, // Audio(1), Voice(34) 123 | Contact=3, // ShareCard(42) 124 | ChatHistory=4, // ChatHistory(19) 125 | Emoticon=5, // Sticker: Emoticon(15), Emoticon(47) 126 | Image=6, // Img(2), Image(3) 127 | Text=7, // Text(1) 128 | Location=8, // Location(48) 129 | MiniProgram=9, // MiniProgram(33) 130 | GroupNote=10, // GroupNote(53) 131 | Transfer=11, // Transfers(2000) 132 | RedEnvelope=12, // RedEnvelopes(2001) 133 | Recalled=13, // Recalled(10002) 134 | Url=14, // Url(5) 135 | Video=15, // Video(4), Video(43) 136 | } 137 | :return: 138 | 139 | # 140 | """ 141 | if isinstance(message_payload.type, int): 142 | map_container: List[MessageType] = [ 143 | MessageType.MESSAGE_TYPE_UNSPECIFIED, 144 | MessageType.MESSAGE_TYPE_ATTACHMENT, 145 | MessageType.MESSAGE_TYPE_AUDIO, 146 | MessageType.MESSAGE_TYPE_CONTACT, 147 | MessageType.MESSAGE_TYPE_CHAT_HISTORY, 148 | MessageType.MESSAGE_TYPE_EMOTICON, 149 | MessageType.MESSAGE_TYPE_IMAGE, 150 | MessageType.MESSAGE_TYPE_TEXT, 151 | MessageType.MESSAGE_TYPE_LOCATION, 152 | MessageType.MESSAGE_TYPE_MINI_PROGRAM, 153 | MessageType.MESSAGE_TYPE_UNSPECIFIED, 154 | MessageType.MESSAGE_TYPE_TRANSFER, 155 | MessageType.MESSAGE_TYPE_RED_ENVELOPE, 156 | MessageType.MESSAGE_TYPE_RECALLED, 157 | MessageType.MESSAGE_TYPE_URL, 158 | MessageType.MESSAGE_TYPE_VIDEO] 159 | message_payload.type = map_container[message_payload.type] 160 | return message_payload 161 | 162 | 163 | # pylint: disable=R0904 164 | class PuppetService(Puppet): 165 | """ 166 | grpc wechaty puppet implementation 167 | """ 168 | 169 | def __init__(self, options: PuppetOptions, name: str = 'puppet_service'): 170 | """init PuppetService from options or environment 171 | 172 | Args: 173 | options (PuppetOptions): the configuration of PuppetService 174 | name (str, optional): [description]. Defaults to 'service_puppet'. 175 | 176 | Raises: 177 | WechatyPuppetConfigurationError: raise Error when configuration occur error 178 | """ 179 | super().__init__(options, name) 180 | token, endpoint = get_token(), get_endpoint() 181 | 182 | # 1. checking the endpoint & token configuration 183 | if options.end_point and endpoint: 184 | log.warning( 185 | f'there are two endpoints from options<{options.end_point}> ' 186 | f'and environment<{endpoint}>, and the first one is adopted' 187 | ) 188 | 189 | if options.token and token: 190 | log.warning( 191 | f'there are two tokens from options[adopted]<{options.token}> ' 192 | f'and environment<{token}>, and the first one is adopted' 193 | ) 194 | 195 | options.end_point = options.end_point or endpoint 196 | options.token = options.token or token 197 | 198 | if not options.end_point and not options.token: 199 | raise WechatyPuppetConfigurationError( 200 | 'one of and configuration is required, ' 201 | 'you should set environment as token,' 202 | 'or environment as endpoint' 203 | ) 204 | 205 | if options.end_point and options.token: 206 | log.warning(f'there are endpoint<{options.end_point}> and token<{options.token}>, ' 207 | f'and the endpoint will be used for service ...') 208 | 209 | self.channel: Optional[Channel] = None 210 | self._puppet_stub: Optional[PuppetStub] = None 211 | 212 | self._event_stream: AsyncIOEventEmitter = AsyncIOEventEmitter() 213 | 214 | self.login_user_id: Optional[str] = None 215 | 216 | @property 217 | def puppet_stub(self) -> PuppetStub: 218 | """ 219 | get the current PuppetStub instance guaranteed to be not null or raises an error. 220 | :return: 221 | """ 222 | if self._puppet_stub is None: 223 | raise WechatyPuppetError('puppet_stub should not be none') 224 | return self._puppet_stub 225 | 226 | async def room_list(self) -> List[str]: 227 | """ 228 | get all room list 229 | :return: 230 | """ 231 | response = await self.puppet_stub.room_list() 232 | if response is None: 233 | raise WechatyPuppetGrpcError('can"t get room_list response') 234 | return response.ids 235 | 236 | async def message_image(self, message_id: str, image_type: ImageType = 3 237 | ) -> FileBox: 238 | """ 239 | get message image data 240 | :param message_id: 241 | :param image_type: 242 | :return: 243 | """ 244 | response: MessageImageResponse = await self.puppet_stub.message_image( 245 | id=message_id, 246 | type=image_type) 247 | file_box = FileBox.from_json(response.filebox) 248 | return file_box 249 | 250 | def on(self, event_name: str, caller: Callable[..., None]) -> None: 251 | """ 252 | listen event from the wechaty 253 | :param event_name: 254 | :param caller: 255 | :return: 256 | """ 257 | # TODO -> if the event is listened twice, how to handle this problem 258 | self._event_stream.on(event_name, caller) 259 | 260 | def listener_count(self, event_name: str) -> int: 261 | """ 262 | how to get event count 263 | :param event_name: 264 | :return: 265 | """ 266 | listeners = self._event_stream.listeners(event_name) 267 | return len(listeners) 268 | 269 | async def contact_list(self) -> List[str]: 270 | """ 271 | get contact list 272 | :return: 273 | """ 274 | response = await self.puppet_stub.contact_list() 275 | return response.ids 276 | 277 | async def tag_contact_delete(self, tag_id: str) -> None: 278 | """ 279 | delete some tag 280 | :param tag_id: 281 | :return: 282 | """ 283 | await self.puppet_stub.tag_contact_delete(id=tag_id) 284 | return None 285 | 286 | async def tag_favorite_delete(self, tag_id: str) -> None: 287 | """ 288 | delete tag favorite 289 | :param tag_id: 290 | :return: 291 | """ 292 | # wechaty_grpc has not implement this function 293 | return None 294 | 295 | async def tag_contact_add(self, tag_id: str, contact_id: str) -> None: 296 | """ 297 | add a tag to contact 298 | :param tag_id: 299 | :param contact_id: 300 | :return: 301 | """ 302 | await self.puppet_stub.tag_contact_add( 303 | id=tag_id, contact_id=contact_id) 304 | 305 | async def tag_favorite_add(self, tag_id: str, contact_id: str) -> None: 306 | """ 307 | add a tag to favorite 308 | :param tag_id: 309 | :param contact_id: 310 | :return: 311 | """ 312 | # wechaty_grpc has not implement this function 313 | 314 | async def tag_contact_remove(self, tag_id: str, contact_id: str) -> None: 315 | """ 316 | remove a tag from contact 317 | :param tag_id: 318 | :param contact_id: 319 | :return: 320 | """ 321 | await self.puppet_stub.tag_contact_remove( 322 | id=tag_id, 323 | contact_id=contact_id) 324 | 325 | async def tag_contact_list(self, contact_id: Optional[str] = None 326 | ) -> List[str]: 327 | """ 328 | get tag list from a contact 329 | :param contact_id: 330 | :return: 331 | """ 332 | response = await self.puppet_stub.tag_contact_list( 333 | contact_id=contact_id) 334 | return response.ids 335 | 336 | async def message_send_text(self, conversation_id: str, message: str, 337 | mention_ids: Optional[List[str]] = None 338 | ) -> str: 339 | """ 340 | send text message 341 | :param conversation_id: 342 | :param message: 343 | :param mention_ids: 344 | :return: 345 | """ 346 | response = await self.puppet_stub.message_send_text( 347 | conversation_id=conversation_id, 348 | text=message, mentonal_ids=mention_ids) 349 | return response.id 350 | 351 | async def message_send_contact(self, contact_id: str, 352 | conversation_id: str) -> str: 353 | """ 354 | send contact message 355 | :param contact_id: 356 | :param conversation_id: 357 | :return: 358 | """ 359 | response = await self.puppet_stub.message_send_contact( 360 | conversation_id=conversation_id, 361 | contact_id=contact_id 362 | ) 363 | return response.id 364 | 365 | async def message_send_file(self, conversation_id: str, 366 | file: FileBox) -> str: 367 | """ 368 | send file message 369 | :param conversation_id: 370 | :param file: 371 | :return: 372 | """ 373 | response = await self.puppet_stub.message_send_file( 374 | conversation_id=conversation_id, 375 | filebox=file.to_json_str() 376 | ) 377 | return response.id 378 | 379 | async def message_send_url(self, conversation_id: str, url: str) -> str: 380 | """ 381 | send url message 382 | :param conversation_id: 383 | :param url: 384 | :return: 385 | """ 386 | response = await self.puppet_stub.message_send_url( 387 | conversation_id=conversation_id, 388 | url_link=url 389 | ) 390 | return response.id 391 | 392 | async def message_send_mini_program(self, conversation_id: str, 393 | mini_program: MiniProgramPayload 394 | ) -> str: 395 | """ 396 | send mini_program message 397 | :param conversation_id: 398 | :param mini_program: 399 | :return: 400 | """ 401 | response = await self.puppet_stub.message_send_mini_program( 402 | conversation_id=conversation_id, 403 | # TODO -> check mini_program key 404 | mini_program=json.dumps(asdict(mini_program)) 405 | ) 406 | return response.id 407 | 408 | async def message_search(self, query: Optional[MessageQueryFilter] = None 409 | ) -> List[str]: 410 | """ 411 | # TODO -> this function should not be here ? 412 | :param query: 413 | :return: 414 | """ 415 | return [] 416 | 417 | async def message_recall(self, message_id: str) -> bool: 418 | """ 419 | recall the message 420 | :param message_id: 421 | :return: 422 | """ 423 | response = await self.puppet_stub.message_recall(id=message_id) 424 | return response.success 425 | 426 | async def message_payload(self, message_id: str) -> MessagePayload: 427 | """ 428 | get message payload 429 | :param message_id: 430 | :return: 431 | """ 432 | response = await self.puppet_stub.message_payload(id=message_id) 433 | 434 | return _map_message_type(response) 435 | 436 | async def message_forward(self, to_id: str, message_id: str) -> None: 437 | """ 438 | forward the message 439 | :param to_id: 440 | :param message_id: 441 | :return: 442 | """ 443 | payload = await self.message_payload(message_id=message_id) 444 | if payload.type == MessageType.MESSAGE_TYPE_TEXT: 445 | if not payload.text: 446 | raise Exception('no text') 447 | await self.message_send_text(conversation_id=to_id, message=payload.text) 448 | elif payload.type == MessageType.MESSAGE_TYPE_URL: 449 | url_payload = await self.message_url(message_id=message_id) 450 | await self.message_send_url( 451 | conversation_id=to_id, 452 | url=json.dumps(asdict(url_payload)) 453 | ) 454 | elif payload.type == MessageType.MESSAGE_TYPE_MINI_PROGRAM: 455 | mini_program = await self.message_mini_program(message_id=message_id) 456 | await self.message_send_mini_program(conversation_id=to_id, mini_program=mini_program) 457 | elif payload.type == MessageType.MESSAGE_TYPE_EMOTICON: 458 | file_box = await message_emoticon(message=payload.text) 459 | await self.message_send_file(conversation_id=to_id, file=file_box) 460 | elif payload.type == MessageType.MESSAGE_TYPE_AUDIO: 461 | raise WechatyPuppetOperationError('Can not support audio message forward') 462 | # elif payload.type == MessageType.ChatHistory: 463 | elif payload.type == MessageType.MESSAGE_TYPE_IMAGE: 464 | file_box = await self.message_image(message_id=message_id, image_type=3) 465 | await self.message_send_file(conversation_id=to_id, file=file_box) 466 | else: 467 | file_box = await self.message_file(message_id=message_id) 468 | await self.message_send_file(conversation_id=to_id, file=file_box) 469 | 470 | async def message_file(self, message_id: str) -> FileBox: 471 | """ 472 | extract file from message 473 | :param message_id: 474 | :return: 475 | """ 476 | response: MessageFileResponse = await self.puppet_stub.message_file(id=message_id) 477 | file_box = FileBox.from_json(response.filebox) 478 | return file_box 479 | 480 | async def message_contact(self, message_id: str) -> str: 481 | """ 482 | extract 483 | :param message_id: 484 | :return: 485 | """ 486 | response = await self.puppet_stub.message_contact(id=message_id) 487 | return response.id 488 | 489 | async def message_url(self, message_id: str) -> UrlLinkPayload: 490 | """ 491 | extract url_link payload data from response 492 | :param message_id: 493 | :return: 494 | """ 495 | response = await self.puppet_stub.message_url(id=message_id) 496 | # parse url_link data from response 497 | payload_data = json.loads(response.url_link) 498 | return UrlLinkPayload( 499 | url=payload_data.get('url', ''), 500 | title=payload_data.get('title', ''), 501 | description=payload_data.get('description', ''), 502 | thumbnailUrl=payload_data.get('thumbnailUrl', ''), 503 | ) 504 | 505 | async def message_mini_program(self, message_id: str) -> MiniProgramPayload: 506 | """ 507 | extract mini_program from message 508 | :param message_id: 509 | :return: 510 | """ 511 | # TODO -> need to MiniProgram 512 | if self.puppet_stub is None: 513 | raise Exception('puppet_stub should not be none') 514 | 515 | response = await self.puppet_stub.message_mini_program(id=message_id) 516 | response_dict = json.loads(response.mini_program) 517 | try: 518 | mini_program = MiniProgramPayload(**response_dict) 519 | except Exception as e: 520 | raise WechatyPuppetPayloadError(f'can"t init mini-program payload {response_dict}')\ 521 | from e 522 | return mini_program 523 | 524 | async def contact_alias(self, contact_id: str, alias: Optional[str] = None 525 | ) -> str: 526 | """ 527 | get/set contact alias 528 | :param contact_id: 529 | :param alias: 530 | :return: 531 | """ 532 | response = await self.puppet_stub.contact_alias( 533 | id=contact_id, alias=alias) 534 | if response.alias is None and alias is None: 535 | raise WechatyPuppetGrpcError(f'can"t get contact<{contact_id}> alias') 536 | return response.alias 537 | 538 | async def contact_payload_dirty(self, contact_id: str) -> None: 539 | """ 540 | mark the contact payload dirty status, and remove it from the cache 541 | """ 542 | await self.dirty_payload(PayloadType.PAYLOAD_TYPE_CONTACT, contact_id) 543 | 544 | async def contact_payload(self, contact_id: str) -> ContactPayload: 545 | """ 546 | get contact payload 547 | :param contact_id: 548 | :return: 549 | """ 550 | response = await self.puppet_stub.contact_payload(id=contact_id) 551 | return response 552 | 553 | async def contact_avatar(self, contact_id: str, 554 | file_box: Optional[FileBox] = None) -> FileBox: 555 | """ 556 | get/set contact avatar 557 | :param contact_id: 558 | :param file_box: 559 | :return: 560 | """ 561 | response = await self.puppet_stub.contact_avatar( 562 | id=contact_id, filebox=file_box) 563 | return FileBox.from_json(response.filebox) 564 | 565 | async def contact_tag_ids(self, contact_id: str) -> List[str]: 566 | """ 567 | get contact tags 568 | :param contact_id: 569 | :return: 570 | """ 571 | response = await self.puppet_stub.tag_contact_list( 572 | contact_id=contact_id) 573 | return response.ids 574 | 575 | def self_id(self) -> str: 576 | """ 577 | # TODO -> how to get self_id, now wechaty has save login_user 578 | contact_id 579 | :return: 580 | """ 581 | if not self.login_user_id: 582 | raise WechatyPuppetOperationError('can"t call self_id() before logined') 583 | return self.login_user_id 584 | 585 | async def friendship_search(self, weixin: Optional[str] = None, 586 | phone: Optional[str] = None) -> Optional[str]: 587 | """ 588 | search friendship by wexin/phone 589 | :param weixin: 590 | :param phone: 591 | :return: 592 | """ 593 | if weixin is not None: 594 | weixin_response = await self.puppet_stub.friendship_search_weixin( 595 | weixin=weixin 596 | ) 597 | if weixin_response is not None: 598 | return weixin_response.contact_id 599 | if phone is not None: 600 | phone_response = await self.puppet_stub.friendship_search_phone( 601 | phone=phone 602 | ) 603 | if phone is not None: 604 | return phone_response.contact_id 605 | return None 606 | 607 | async def friendship_add(self, contact_id: str, hello: str) -> None: 608 | """ 609 | try to add friendship 610 | :param contact_id: 611 | :param hello: 612 | :return: 613 | """ 614 | await self.puppet_stub.friendship_add( 615 | contact_id=contact_id, 616 | hello=hello 617 | ) 618 | 619 | async def friendship_payload(self, friendship_id: str, 620 | payload: Optional[FriendshipPayload] = None 621 | ) -> FriendshipPayload: 622 | """ 623 | get/set friendship payload 624 | :param friendship_id: 625 | :param payload: 626 | :return: 627 | """ 628 | response = await self.puppet_stub.friendship_payload( 629 | id=friendship_id, payload=json.dumps(payload) 630 | ) 631 | return response 632 | 633 | async def friendship_accept(self, friendship_id: str) -> None: 634 | """ 635 | accept friendship 636 | :param friendship_id: 637 | :return: 638 | """ 639 | await self.puppet_stub.friendship_accept(id=friendship_id) 640 | 641 | async def room_create(self, contact_ids: List[str], 642 | topic: Optional[str] = None 643 | ) -> str: 644 | """ 645 | create room 646 | :param contact_ids: 647 | :param topic: 648 | :return: created room_id 649 | """ 650 | response = await self.puppet_stub.room_create( 651 | contact_ids=contact_ids, 652 | topic=topic 653 | ) 654 | return response.id 655 | 656 | async def room_search(self, 657 | query: Optional[RoomQueryFilter] = None) -> List[str]: 658 | """ 659 | find the room_ids 660 | search room 661 | :param query: 662 | :return: 663 | """ 664 | room_list_response = await self.puppet_stub.room_list() 665 | return room_list_response.ids 666 | 667 | async def room_invitation_payload(self, 668 | room_invitation_id: str, 669 | payload: Optional[RoomInvitationPayload] 670 | = None) -> RoomInvitationPayload: 671 | """ 672 | get room_invitation_payload 673 | """ 674 | response = await self.puppet_stub.room_invitation_payload( 675 | id=room_invitation_id, 676 | payload=payload 677 | ) 678 | return RoomInvitationPayload(**response.to_dict()) 679 | 680 | async def room_invitation_accept(self, room_invitation_id: str) -> None: 681 | """ 682 | accept the room invitation 683 | :param room_invitation_id: 684 | :return: 685 | """ 686 | await self.puppet_stub.room_invitation_accept(id=room_invitation_id) 687 | 688 | async def contact_self_qr_code(self) -> str: 689 | """ 690 | :return: 691 | """ 692 | 693 | response = await self.puppet_stub.contact_self_qr_code() 694 | return response.qrcode 695 | 696 | async def contact_self_name(self, name: str) -> None: 697 | """ 698 | set the name of the contact 699 | :param name: 700 | :return: 701 | """ 702 | await self.puppet_stub.contact_self_name(name=name) 703 | 704 | async def contact_signature(self, signature: str) -> None: 705 | """ 706 | 707 | :param signature: 708 | :return: 709 | """ 710 | 711 | async def room_validate(self, room_id: str) -> bool: 712 | """ 713 | 714 | :param room_id: 715 | :return: 716 | """ 717 | 718 | async def room_payload_dirty(self, room_id: str) -> None: 719 | """ 720 | mark the room payload dirty status, and remove it from the cache 721 | :param room_id: 722 | :return: 723 | """ 724 | await self.dirty_payload( 725 | PayloadType.PAYLOAD_TYPE_ROOM, 726 | room_id 727 | ) 728 | 729 | async def room_member_payload_dirty(self, room_id: str) -> None: 730 | """ 731 | mark the room-member payload dirty status, and remove it from the cache 732 | :param room_id: 733 | :return: 734 | """ 735 | await self.dirty_payload( 736 | PayloadType.PAYLOAD_TYPE_ROOM_MEMBER, 737 | room_id 738 | ) 739 | 740 | async def room_payload(self, room_id: str) -> RoomPayload: 741 | """ 742 | 743 | :param room_id: 744 | :return: 745 | """ 746 | response = await self.puppet_stub.room_payload(id=room_id) 747 | return response 748 | 749 | async def room_members(self, room_id: str) -> List[str]: 750 | """ 751 | 752 | :param room_id: 753 | :return: 754 | """ 755 | response = await self.puppet_stub.room_member_list(id=room_id) 756 | return response.member_ids 757 | 758 | async def room_add(self, room_id: str, contact_id: str) -> None: 759 | """ 760 | add contact to room 761 | :param room_id: 762 | :param contact_id: 763 | :return: 764 | """ 765 | await self.puppet_stub.room_add(id=room_id, contact_id=contact_id) 766 | 767 | async def room_delete(self, room_id: str, contact_id: str) -> None: 768 | """ 769 | delete contact from room 770 | :param room_id: 771 | :param contact_id: 772 | :return: 773 | """ 774 | await self.puppet_stub.room_del(id=room_id, contact_id=contact_id) 775 | 776 | async def room_quit(self, room_id: str) -> None: 777 | """ 778 | quit from room 779 | :param room_id: 780 | :return: 781 | """ 782 | await self.puppet_stub.room_quit(id=room_id) 783 | 784 | async def room_topic(self, room_id: str, new_topic: str) -> None: 785 | """ 786 | set/set topic of the room 787 | :param room_id: 788 | :param new_topic: 789 | :return: 790 | """ 791 | await self.puppet_stub.room_topic(id=room_id, topic=new_topic) 792 | 793 | async def room_announce(self, room_id: str, 794 | announcement: Optional[str] = None) -> str: 795 | """ 796 | get/set announce 797 | :param room_id: 798 | :param announcement: 799 | :return: 800 | """ 801 | room_announce_response = await self.puppet_stub.room_announce( 802 | id=room_id, text=announcement) 803 | if announcement is None and room_announce_response.text is not None: 804 | # get the announcement 805 | return room_announce_response.text 806 | if announcement is not None and room_announce_response.text is None: 807 | return announcement 808 | return '' 809 | 810 | async def room_qr_code(self, room_id: str) -> str: 811 | """ 812 | get room qr_code 813 | :param room_id: 814 | :return: 815 | """ 816 | # TODO -> wechaty-grpc packages has leave out id params 817 | log.warning('room_qr_code() param is missing', room_id) 818 | room_qr_code_response = await \ 819 | self.puppet_stub.room_qr_code() 820 | return room_qr_code_response.qrcode 821 | 822 | async def room_member_payload(self, room_id: str, 823 | contact_id: str) -> RoomMemberPayload: 824 | """ 825 | get room member payload 826 | :param room_id: 827 | :param contact_id: 828 | :return: 829 | """ 830 | member_payload = await self.puppet_stub.room_member_payload( 831 | id=room_id, member_id=contact_id) 832 | return member_payload 833 | 834 | async def room_avatar(self, room_id: str) -> FileBox: 835 | """ 836 | get room avatar 837 | :param room_id: 838 | :return: 839 | """ 840 | room_avatar_response = await self.puppet_stub.room_avatar(id=room_id) 841 | 842 | file_box_data = json.loads(room_avatar_response.filebox) 843 | 844 | if 'remoteUrl' not in file_box_data: 845 | raise WechatyPuppetPayloadError('invalid room avatar response') 846 | 847 | file_box = FileBox.from_url( 848 | url=file_box_data['remoteUrl'], 849 | name=f'avatar-{room_id}.jpeg' 850 | ) 851 | return file_box 852 | 853 | async def dirty_payload(self, payload_type: PayloadType, payload_id: str) -> None: 854 | """ 855 | mark the payload dirty status, and remove it from the cache 856 | """ 857 | await self.puppet_stub.dirty_payload( 858 | type=payload_type.value, 859 | id=payload_id 860 | ) 861 | 862 | def _init_puppet(self) -> None: 863 | """ 864 | start puppet channel contact_self_qr_code 865 | """ 866 | log.info('init puppet ...') 867 | 868 | # 1. if there is no endpoint, it should fetch it from chatie server with token 869 | if not self.options.end_point: 870 | 871 | url = f'https://api.chatie.io/v0/hosties/{self.options.token}' 872 | log.info('fetching endpoint from chatie-server: %s', url) 873 | response = requests.get(url) 874 | 875 | if response.status_code != 200: 876 | raise WechatyPuppetGrpcError( 877 | 'can"t fetch endpoint from chatie server. ' 878 | 'You can try it later, or make sure that your pc can connect to heroku server ' 879 | ) 880 | 881 | data = response.json() 882 | 883 | if 'ip' not in data or data['ip'] == '0.0.0.0': 884 | raise WechatyPuppetGrpcError( 885 | 'Your service token has no available endpoint, is your token correct?' 886 | ) 887 | if 'port' not in data: 888 | raise WechatyPuppetGrpcError("can't find service server port") 889 | 890 | self.options.end_point = f'{data["ip"]}:{data["port"]}' 891 | log.debug('endpoint from chatie-server : <%s>', self.options.end_point) 892 | 893 | if ping_endpoint(self.options.end_point) is False: 894 | raise WechatyPuppetConfigurationError( 895 | f"can't not ping endpoint: {self.options.end_point}" 896 | ) 897 | 898 | host, port = extract_host_and_port(self.options.end_point) 899 | self.channel = Channel(host=host, port=port) 900 | 901 | # pylint: disable=W0212 902 | self.channel._authority = self.options.token 903 | 904 | self._puppet_stub = PuppetStub(self.channel) 905 | 906 | async def start(self) -> None: 907 | """ 908 | start puppet_stub 909 | :return: 910 | """ 911 | self._init_puppet() 912 | 913 | log.info('starting the puppet ...') 914 | 915 | try: 916 | await self.puppet_stub.stop() 917 | finally: 918 | await self.puppet_stub.start() 919 | 920 | log.info('puppet has started ...') 921 | await self._listen_for_event() 922 | return None 923 | 924 | async def stop(self) -> None: 925 | """ 926 | stop the grpc channel connection 927 | """ 928 | log.info('stop()') 929 | self._event_stream.remove_all_listeners() 930 | if self._puppet_stub is not None: 931 | await self._puppet_stub.stop() 932 | self._puppet_stub = None 933 | if self.channel: 934 | self.channel.close() 935 | self.channel = None 936 | 937 | async def logout(self) -> None: 938 | """ 939 | logout the account 940 | :return: 941 | """ 942 | log.info('logout()') 943 | if self.login_user_id is None: 944 | raise WechatyPuppetOperationError('logout before login?') 945 | 946 | try: 947 | await self.puppet_stub.logout() 948 | # pylint: disable=W0703 949 | except Exception as exception: 950 | log.error('logout() rejection %s', exception) 951 | finally: 952 | payload = EventLogoutPayload(contact_id=self.login_user_id, data='logout') 953 | self._event_stream.emit('logout', payload) 954 | self.login_user_id = None 955 | 956 | async def login(self, user_id: str) -> None: 957 | """ 958 | login the account 959 | :return: 960 | """ 961 | self.login_user_id = user_id 962 | payload = EventLoginPayload(contact_id=user_id) 963 | self._event_stream.emit('login', payload) 964 | 965 | async def ding(self, data: Optional[str] = '') -> None: 966 | """ 967 | set the ding event 968 | :param data: 969 | :return: 970 | """ 971 | log.debug('send ding info to service server ...') 972 | 973 | await self.puppet_stub.ding(data=data) 974 | 975 | # pylint: disable=R0912,R0915 976 | async def _listen_for_event(self) -> None: 977 | """ 978 | listen event from service server with heartbeat 979 | """ 980 | # listen event from grpclib 981 | log.info('listening the event from the puppet ...') 982 | 983 | async for response in self.puppet_stub.event(): 984 | if response is not None: 985 | payload_data: dict = json.loads(response.payload) 986 | if response.type == int(EventType.EVENT_TYPE_SCAN): 987 | log.debug('receive scan info <%s>', payload_data) 988 | # create qr_code 989 | payload = EventScanPayload( 990 | status=ScanStatus(payload_data['status']), 991 | qrcode=payload_data.get('qrcode', None), 992 | data=payload_data.get('data', None) 993 | ) 994 | self._event_stream.emit('scan', payload) 995 | 996 | elif response.type == int(EventType.EVENT_TYPE_DONG): 997 | log.debug('receive dong info <%s>', payload_data) 998 | payload = EventDongPayload(**payload_data) 999 | self._event_stream.emit('dong', payload) 1000 | 1001 | elif response.type == int(EventType.EVENT_TYPE_MESSAGE): 1002 | # payload = get_message_payload_from_response(response) 1003 | log.debug('receive message info <%s>', payload_data) 1004 | event_message_payload = EventMessagePayload( 1005 | message_id=payload_data['messageId']) 1006 | self._event_stream.emit('message', event_message_payload) 1007 | 1008 | elif response.type == int(EventType.EVENT_TYPE_HEARTBEAT): 1009 | log.debug('receive heartbeat info <%s>', payload_data) 1010 | # Huan(202005) FIXME: 1011 | # https://github.com/wechaty/python-wechaty-puppet/issues/6 1012 | # Workaround for unexpected server json payload key: timeout 1013 | # if 'timeout' in payload_data: 1014 | # del payload_data['timeout'] 1015 | payload_data = {'data': payload_data['data']} 1016 | payload = EventHeartbeatPayload(**payload_data) 1017 | self._event_stream.emit('heartbeat', payload) 1018 | 1019 | elif response.type == int(EventType.EVENT_TYPE_ERROR): 1020 | log.info('receive error info <%s>', payload_data) 1021 | payload = EventErrorPayload(**payload_data) 1022 | self._event_stream.emit('error', payload) 1023 | 1024 | elif response.type == int(EventType.EVENT_TYPE_FRIENDSHIP): 1025 | log.debug('receive friendship info <%s>', payload_data) 1026 | payload = EventFriendshipPayload( 1027 | friendship_id=payload_data.get('friendshipId') 1028 | ) 1029 | self._event_stream.emit('friendship', payload) 1030 | 1031 | elif response.type == int(EventType.EVENT_TYPE_ROOM_JOIN): 1032 | log.debug('receive room-join info <%s>', payload_data) 1033 | payload = EventRoomJoinPayload( 1034 | invited_ids=payload_data.get('inviteeIdList', []), 1035 | inviter_id=payload_data.get('inviterId'), 1036 | room_id=payload_data.get('roomId'), 1037 | timestamp=payload_data.get('timestamp') 1038 | ) 1039 | self._event_stream.emit('room-join', payload) 1040 | 1041 | elif response.type == int(EventType.EVENT_TYPE_ROOM_INVITE): 1042 | log.debug('receive room-invite info <%s>', payload_data) 1043 | payload = EventRoomInvitePayload( 1044 | room_invitation_id=payload_data.get( 1045 | 'roomInvitationId', None) 1046 | ) 1047 | self._event_stream.emit('room-invite', payload) 1048 | 1049 | elif response.type == int(EventType.EVENT_TYPE_ROOM_LEAVE): 1050 | log.debug('receive room-leave info <%s>', payload_data) 1051 | payload = EventRoomLeavePayload( 1052 | removed_ids=payload_data.get('removeeIdList', []), 1053 | remover_id=payload_data.get('removerId'), 1054 | room_id=payload_data.get('roomId'), 1055 | timestamp=payload_data.get('timestamp') 1056 | ) 1057 | self._event_stream.emit('room-leave', payload) 1058 | 1059 | elif response.type == int(EventType.EVENT_TYPE_ROOM_TOPIC): 1060 | log.debug('receive room-topic info <%s>', payload_data) 1061 | payload = EventRoomTopicPayload( 1062 | changer_id=payload_data.get('changerId'), 1063 | new_topic=payload_data.get('newTopic'), 1064 | old_topic=payload_data.get('oldTopic'), 1065 | room_id=payload_data.get('roomId'), 1066 | timestamp=payload_data.get('timestamp') 1067 | ) 1068 | self._event_stream.emit('room-topic', payload) 1069 | 1070 | elif response.type == int(EventType.EVENT_TYPE_READY): 1071 | log.debug('receive ready info <%s>', payload_data) 1072 | payload = EventReadyPayload(**payload_data) 1073 | self._event_stream.emit('ready', payload) 1074 | 1075 | elif response.type == int(EventType.EVENT_TYPE_LOGIN): 1076 | log.debug('receive login info <%s>', payload_data) 1077 | event_login_payload = EventLoginPayload( 1078 | contact_id=payload_data['contactId']) 1079 | self.login_user_id = payload_data.get('contactId', None) 1080 | self._event_stream.emit('login', event_login_payload) 1081 | 1082 | elif response.type == int(EventType.EVENT_TYPE_LOGOUT): 1083 | log.debug('receive logout info <%s>', payload_data) 1084 | payload = EventLogoutPayload( 1085 | contact_id=payload_data['contactId'], 1086 | data=payload_data.get('data', None) 1087 | ) 1088 | self.login_user_id = None 1089 | self._event_stream.emit('logout', payload) 1090 | 1091 | elif response.type == int(EventType.EVENT_TYPE_UNSPECIFIED): 1092 | pass 1093 | --------------------------------------------------------------------------------