├── .github └── workflows │ └── pythonpublish.yml ├── .gitignore ├── ChangeLog.md ├── Installation ├── Discord.md ├── Line.md ├── Mirai.md ├── QQ.md └── Telegram.md ├── LICENSE ├── README.md ├── config.yaml ├── docs ├── Command.md ├── Driver.md ├── MessageHook.md └── Types.md ├── image ├── qq.png ├── telegram.png ├── tg-discord1.png └── tg-discord2.png ├── requirements.txt ├── setup.py └── unified_message_relay ├── Core ├── UMRAdmin.py ├── UMRCommand.py ├── UMRConfig.py ├── UMRDispatcher.py ├── UMRDriver.py ├── UMRExtension.py ├── UMRFile.py ├── UMRLogging.py ├── UMRManager.py ├── UMRMessageHook.py ├── UMRMessageRelation.py ├── UMRType.py └── __init__.py ├── Lib ├── DaemonClass │ └── __init__.py └── __init__.py ├── Util ├── Helper.py └── __init__.py ├── __init__.py ├── daemon.py └── test └── __init__.py /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | push: 10 | branches: 11 | - master 12 | jobs: 13 | deploy: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@master 19 | - name: Set up Python 20 | uses: actions/setup-python@v1 21 | with: 22 | python-version: '3.7' 23 | arhitecture: 'x64' 24 | 25 | - name: Build Wheel 26 | run: | 27 | python3 setup.py build sdist 28 | - name: Publish package 29 | uses: pypa/gh-action-pypi-publish@master 30 | with: 31 | user: __token__ 32 | password: ${{ secrets.Pypi_APIToken }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | env/ 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 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 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *,cover 49 | .hypothesis/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # dotenv 85 | .env 86 | 87 | # virtualenv 88 | .venv 89 | venv/ 90 | ENV/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # ignore DB file 99 | *.db 100 | 101 | namelist.json 102 | bot.log 103 | bot.log* 104 | nohup.out 105 | special_sticker_list.py 106 | unified_message_relay/nouse.py 107 | 108 | .DS_Store 109 | .idea 110 | .vscode 111 | 112 | submodules -------------------------------------------------------------------------------- /ChangeLog.md: -------------------------------------------------------------------------------- 1 | # ChangeLog 2 | 3 | ## V4.2 4 | 5 | - Rewrite Configuration logic, now supports more comprehensive and flexible validations 6 | - New configuration section: `LogLevel` 7 | - Removed: `Debug` 8 | 9 | - Rewrite Logging logic 10 | 11 | - Redesigned Framework loading logic 12 | 13 | - Redesigned Extension loading logic -------------------------------------------------------------------------------- /Installation/Discord.md: -------------------------------------------------------------------------------- 1 | # Discord Bot Setup 2 | 3 | ## Register your bot and invite to your channel 4 | 5 | [Instructions](https://discordpy.readthedocs.io/en/latest/discord.html) 6 | 7 | Remember to give all of the permissions under text permissions. 8 | 9 | ## Install extension 10 | 11 | ```bash 12 | pip install umr-discord-driver 13 | ``` 14 | 15 | ## Config under Driver section 16 | 17 | ```yaml 18 | Extensions: 19 | - umr_discord_driver 20 | Driver: 21 | Discord: # this name can be change, and the forward list should be using this name 22 | Base: Discord # base driver name, don't change 23 | BotToken: asdsadsddfffsdffsdfsd # the longer token on the developer console (the one says click to reveal) 24 | ClientToken: asdasdsadsfsafsdfsd # the shorter token on the developer console 25 | ``` -------------------------------------------------------------------------------- /Installation/Line.md: -------------------------------------------------------------------------------- 1 | # Line Bot Setup 2 | 3 | ## Sign up line developer console 4 | 5 | Follow [this guide](https://cloud.google.com/dialogflow/docs/integrations/line) until you get the following stuff: 6 | 7 | - Channel id (Under "Basic Settings") 8 | - Bot token (Channel access token (long-lived), need to click claim once under "Messaging API") 9 | - WebHookToken (Channel secret, under "Basic Settings") 10 | 11 | # Set up certificates for HTTPS 12 | 13 | You have to make sure that your running environment has public access, and it is also bound to a domain. 14 | 15 | If you have your own certificate, skip this part. If not, try to use [LetEncrypt](https://github.com/acmesh-official/acme.sh). 16 | 17 | If you are having issue in this part, follow guides on the link above. 18 | 19 | After this part is done, more stuffs are ready: 20 | 21 | - HTTPS CA Cert (ca.cer) 22 | - HTTPS Private Key (example.com.key) 23 | - HTTPS Public Key (example.com.cer) 24 | 25 | ## Install extension 26 | 27 | ```bash 28 | pip install umr-line-driver 29 | ``` 30 | 31 | ## Config under Driver section 32 | 33 | ```yaml 34 | Extensions: 35 | - umr_line_driver 36 | Driver: 37 | Line: # this name can be change, and the forward list should be using this name 38 | Base: Line # do not change this 39 | ChannelID: sdarq3rcar323r2r23r 40 | BotToken: 123dff23rr23r23r23rr 41 | WebHookToken: 43r23r23rf23r23r23r2 42 | WebHookURL: https://example.com # must not include /callback 43 | WebHookPort: 41443 44 | HTTPSCert: /root/.acme.sh/example.com/example.com.cer # all three are full path 45 | HTTPSKey: /root/.acme.sh/example.com/example.com.key 46 | HTTPSCA: /root/.acme.sh/example.com/ca.cer 47 | ``` -------------------------------------------------------------------------------- /Installation/Mirai.md: -------------------------------------------------------------------------------- 1 | # Mirai Bot Setup 2 | 3 | ## Wait for Official release 4 | 5 | The official HTTP API for Mirai is still working in progress. Stay tuned! 6 | 7 | In order to try the latest alpha version, follow their guide (which they might not have right now) 8 | and build [mirai-http-api](https://github.com/mamoe/mirai-api-http) 9 | and [mirai-console](https://github.com/mamoe/mirai-console). 10 | Once you get both jar files, place `httpapi.jar` (name could be different) to plugins folder at the same directory of `console.jar` (name could be different) 11 | (create the dir if not exists). Finally, run with `java -jar console.jar`. 12 | 13 | After launch, shut it down and you shall see new folder named "MiraiAPIHTTP" under plugins. 14 | Edit `setting.yml` to match your config in UMR. There are only two values for now: 15 | 16 | ```yaml 17 | APIKey: abcdefgh 18 | port: 18080 19 | enableWebsocket: true 20 | ``` 21 | 22 | ## Install extension 23 | 24 | ```bash 25 | pip install umr-mirai-driver 26 | ``` 27 | 28 | ## Config under Driver section 29 | 30 | ```yaml 31 | Extensions: 32 | - umr_mirai_driver 33 | Driver: 34 | Mirai: 35 | Base: Mirai 36 | Account: 1213123 37 | Host: 127.0.0.1 38 | Port: 18080 39 | AuthKey: abcdefgh 40 | NameforPrivateChat: yes # if destination chat_id is a private chat, show all attributes (sender name, reply to, forward from) 41 | NameforGroupChat: yes # if destination chat_id is a group/discuss chat, show all attributes (sender name, reply to, forward from) 42 | ``` -------------------------------------------------------------------------------- /Installation/QQ.md: -------------------------------------------------------------------------------- 1 | # QQ Bot Setup 2 | 3 | ## Use Official Docker 4 | 5 | - [coolq/wine-coolq](https://hub.docker.com/r/coolq/wine-coolq/) *Official Coolq Docker* 6 | - [richardchien/cqhttp](https://cqhttp.cc/docs/4.13/#/Docker) *richardchien's Coolq Docker, with Coolq http api* 7 | 8 | The following steps will be based on the *richardchien's Coolq Docker*. Assume `$HOME` is `/root`. Due 9 | to these dockers are still using old Ubuntu image, python 3.7 is not available in docker. Please run docker 10 | and mount data volume, and then run the bot on host os. 11 | 12 | ## Install CoolQ Docker 13 | 14 | ```bash 15 | $ cd 16 | $ docker pull richardchien/cqhttp:latest 17 | $ mkdir coolq 18 | $ docker run -ti --rm --name cqhttp-test \ 19 | -v $HOME/coolq:/home/user/coolq \ # mapping $HOME/coolq to docker's coolq directory 20 | -p 9000:9000 \ # noVNC 21 | -p 127.0.0.1:5700:5700 \ # HTTP API listen 22 | -e VNC_PASSWD=MAX8char \ # vnc password, maximum 8 chars 23 | -e COOLQ_URL=https://dlsec.cqp.me/cqp-tuling \ # Coolq Pro, for Air user, remove this line 24 | -e COOLQ_ACCOUNT=123456 \ # QQ Account 25 | richardchien/cqhttp:latest 26 | ``` 27 | 28 | 29 | - Create Coolq http api config (`$HOME/coolq/app/io.github.richardchien.coolqhttpapi/config.cfg`) 30 | 31 | ```ini 32 | [general] 33 | host=0.0.0.0 34 | port=5700 35 | post_url=http://172.17.0.1:8080 36 | access_token=very 37 | secret=long 38 | post_message_format=array 39 | ``` 40 | 41 | Log in into `http://YOUR_SERVE_IP:9000`, and use the default vnc password `MAX8char` or your own password. You need to 42 | activate Coolq Pro and log in your QQ Account manually. 43 | 44 | ## Install extension 45 | 46 | ```bash 47 | pip install umr-coolq-driver 48 | ``` 49 | 50 | ## Config under Driver section 51 | 52 | ```yaml 53 | Extensions: 54 | - umr_coolq_driver 55 | Driver: 56 | QQ: # this name can be change, and the forward list should be using this name 57 | Base: QQ # base driver name 58 | Account: 643503161 # bot QQ 59 | APIRoot: http://127.0.0.1:5700/ # cq http api listen 60 | ListenIP: 172.17.0.1 # bot listen, for api report 61 | ListenPort: 8080 # listen port 62 | Token: very # cq http api token, same as the config above 63 | Secret: long # cq http api secret, same as the config above 64 | NameforPrivateChat: no # if destination chat_id is a private chat, show all attributes (sender name, reply to, forward from) 65 | NameforGroupChat: yes # if destination chat_id is a group/discuss chat, show all attributes (sender name, reply to, forward from) 66 | ``` -------------------------------------------------------------------------------- /Installation/Telegram.md: -------------------------------------------------------------------------------- 1 | # Telegram Bot Setup 2 | 3 | ## Chat with bot father 4 | 5 | [Offical Instructions](https://core.telegram.org/bots#6-botfather) 6 | 7 | Once you acquired the bot token, remember to turn off the privacy mode. See [here](https://core.telegram.org/bots#privacy-mode) 8 | for details. Go chat with botfather, and there will be one button to toggle privacy mode for your bot. 9 | 10 | ## Install extension 11 | 12 | ```bash 13 | pip install umr-telegram-driver 14 | ``` 15 | 16 | ## Config under Driver section 17 | 18 | ```yaml 19 | Extensions: 20 | - umr_telegram_driver 21 | Driver: 22 | Telegram: # this name can be change, and the forward list should be using this name 23 | Base: Telegram # this is the base driver name, do not change 24 | BotToken: asdasdsadsadsadsad # your bot token 25 | HTTPProxy: http://127.0.0.1:1080 26 | ``` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Curtis Jiang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UnifiedMessageRelay 2 | 3 | ![shields](https://img.shields.io/badge/python-3.7%2B-blue.svg?style=flat-square) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=flat-square)](https://opensource.org/licenses/MIT) 4 | [![Telegram support group](https://img.shields.io/badge/support-telegram-blue)](https://t.me/s/UnifiedMessageRelay) 5 | [![Telegram developer group](https://img.shields.io/badge/developer-telegram-blue)](https://t.me/s/UnifiedMessageRelayDev) 6 | 7 | UnifiedMessageRelay is a framework for the purpose of bringing messages from different chat platform together. With UnifiedMessageRelay, 8 | user no longer need to view messages on different platform, or different groups. UnifiedMessageRelay brings powerful 9 | message forwarding functionality and flexible plugin API to meet your custom need. A driver API specification is also 10 | provided, so one can compose their own backend driver, and the framework will load and utilize the driver automatically. 11 | 12 | 13 | ## Demo 14 | 15 | Telegram <-> QQ: 16 | 17 | Telegram 18 | 19 | QQ 20 | 21 | Telegram <-> Discord: 22 | 23 | Discord 24 | 25 | Telegram 26 | 27 | All four platforms: QQ, Telegram, Line and Discord can forward between each other directly. 28 | 29 | ## Supported platforms 30 | 31 | - QQ API based on [CoolQ HTTP API](https://github.com/richardchien/coolq-http-api) [aiocqhttp](https://github.com/cqmoe/python-aiocqhttp) 32 | - Mirai API based on multiple repos from [Mamoe Technologies](https://github.com/mamoe) 33 | - Telegram API based on [aiogram](https://aiogram.dev) 34 | - Line API based on [linebotx](https://github.com/Shivelight/line-bot-sdk-python-extra) [linebot](https://github.com/line/line-bot-sdk-python) 35 | - Discord API based on [Discord.py](https://github.com/Rapptz/discord.py) 36 | 37 | ## Update 38 | 39 | [ChangeLog.md](ChangeLog.md) 40 | 41 | ## Features 42 | 43 | - Forward text and image between all supported platforms 44 | - Image is converted to supported format automatically 45 | - Reply is preserved with best effort 46 | - Markdown format is preserved for supported platforms 47 | - Command API for customize triggers 48 | - Message Hook API for even more customized needs 49 | 50 | Limited support for Coolq Air. image sending is available for Coolq Pro. 51 | 52 | ## Installation 53 | 54 | ### Framework Setup 55 | ### Install python dependencies on host os 56 | 57 | Make sure Python 3.7+ and `pip` are installed. Run: 58 | 59 | `pip3 install unified-message-relay` 60 | 61 | ### TLDR 62 | 63 | To install every python module in one line: 64 | 65 | `pip3 install -U umr_telegram_driver umr_line_driver umr_discord_driver umr_coolq_driver umr_mirai_driver umr_extensions_demo` 66 | 67 | ### Install other required package on host os 68 | 69 | `apt install libcairo2 ffmpeg libmagickwand-dev` 70 | 71 | ## Configurations 72 | 73 | Create `~/.umr/` 74 | 75 | ```bash 76 | mkdir ~/.umr 77 | ``` 78 | 79 | Copy config.yaml to `~/.umr` 80 | 81 | [Why yaml instead of json?](https://www.quora.com/What-situation-would-you-use-YAML-instead-of-JSON-or-XML) 82 | 83 | [Full Example config](config.yaml) 84 | 85 | The "QQ", "Telegram" or "Line" above are all custom names. Real bot driver should be configure throgh "Driver" list. 86 | 87 | ### Follow the guide for your platform 88 | 89 | [QQ](Installation/QQ.md) 90 | 91 | [Mirai](Installation/Mirai.md) 92 | 93 | [Telegram](Installation/Telegram.md) 94 | 95 | [Line](Installation/Line.md) 96 | 97 | [Discord](Installation/Discord.md) 98 | 99 | ## Start the bot 100 | 101 | ### Viewing CLI Help 102 | 103 | ```shell 104 | unified_message_relay -h 105 | ``` 106 | 107 | ### Background process 108 | 109 | - Start background service 110 | 111 | ```shell 112 | unified_message_relay start 113 | ``` 114 | 115 | or 116 | 117 | ```shell 118 | unified-message-relay restart 119 | ``` 120 | 121 | By default, log will be stored in `/var/log/umr/bot.log`, and cache will be cleared out upon start. 122 | 123 | - Stop the background service 124 | 125 | ```shell 126 | unified_message_relay stop 127 | ``` 128 | 129 | ### Foreground process (for debugging purpose) 130 | 131 | If you need to see the log output for debugging purpose, stop the running daemon first. Then follow this command. 132 | 133 | Remember to enable debug option in config. 134 | 135 | ```shell 136 | unified_message_relay run 137 | ``` 138 | 139 | Hit Ctrl + C to stop. 140 | 141 | ## Extensions and Commands 142 | 143 | Example extensions and commands now require extension `umr-extensions-demo`: 144 | 145 | ```bash 146 | pip install umr-extensions-demo 147 | ``` 148 | 149 | and put `- umr_extensions_demo` under `Extensions` section of `config.yaml`. 150 | 151 | ### Available commands 152 | #### Help 153 | 154 | Send `!!help` to show available commands. 155 | 156 | This command requires no extra module. 157 | 158 | #### Show chat id 159 | 160 | Send `!!id` anywhere to see chat id. 161 | 162 | Reply message with `!!id` to reveal source chat id. 163 | 164 | This command requires `cmd_id.py` under umr_extension_demo. 165 | 166 | #### Delete QQ Message 167 | 168 | Reply to the message you want to delete with `!!del` 169 | 170 | This command requires `QQ_recall.py` under umr_extension_demo and using coolq driver. 171 | Mirai recall is not supported at this time. 172 | 173 | #### Add telegram blocked keyword 174 | 175 | Message containing these keyword will not be forwarded to any other chat 176 | 177 | Send `!!bk` and keywords separated by space 178 | 179 | This command requires `Telegram_watermeter.py` under umr_extension_demo and using telegram driver. 180 | 181 | #### Add telegram blocked channel 182 | 183 | Message originated from these channel will not be forwarded to any other chat 184 | 185 | Reply forwarded channel message with `!!bc` 186 | 187 | This command requires `Telegram_watermeter.py` under umr_extension_demo and using telegram driver. 188 | 189 | To modify saved keywords and channels, edit `ExtensionConfig` section in `config.yaml`. 190 | 191 | ### Available Extensions 192 | 193 | #### Comment filter 194 | 195 | Add `//` at the beginning of the message to avoid forwarding to any other chat. 196 | 197 | # Issue Format 198 | 199 | ## Check these before opening an issue 200 | 201 | 1. Use `unified-message-relay run` to print log to stdout 202 | 2. Check if you are using Python 3.7+ 203 | 3. Check if binary dependencies are installed (search apt in this page) 204 | 4. (If using Coolq) Check if cq-http-api is enabled in Coolq 205 | 5. Check if the log suggests any missing configuration 206 | 6. Check if you are on Dev branch, please switch back to master (dev may be unstable) 207 | 208 | ## Issues must provide 209 | 210 | 1. Descriptions about the issue 211 | 2. Logs of python3 daemon.py run (Desensitization) 212 | 3. Steps to reproduce 213 | 214 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | ForwardList: 2 | Accounts: # Account id for each bot in ForwardList 3 | QQ: 12213312 # your QQ bot account number 4 | Telegram: 12321312 # your telegram bot chat id 5 | Topology: # delete this key if empty 6 | # Topology contains all the point to point forward: 7 | # E.g. From one QQ group to one telegram group, type = OneWay+ 8 | - From: QQ 9 | FromChat: 1123131231 10 | FromChatType: group # group, discuss, private 11 | To: Telegram 12 | ToChat: -31231212344 # telegram chat id, use !!id to show 13 | ToChatType: group 14 | ForwardType: OneWay+ 15 | # OneWay: 16 | # Forward from "FromChat" to "ToChat" 17 | # BiDirection: 18 | # Forward from "FromChat" to "ToChat" and vise versa 19 | # OneWay+: 20 | # Forward from "FromChat" to "ToChat", and vise versa, ignoring backward message without "reply_to" 21 | Default: # delete this key if empty 22 | # Default contains all the platform to point forward: 23 | # E.g. Any message originated from QQ to one telegram group, type = OneWay+ 24 | - From: QQ 25 | To: Telegram 26 | ToChat: 123244234234 27 | ToChatType: group 28 | ForwardType: OneWay+ 29 | # OneWay: 30 | # Forward from "FromChat" to "ToChat" 31 | # OneWay+: 32 | # Forward from "FromChat" to "ToChat", and vise versa, ignoring backward message without "reply_to" 33 | Extensions: # Extensions to load 34 | - umr_telegram_driver 35 | - umr_line_driver 36 | - umr_discord_driver 37 | - umr_coolq_driver 38 | - umr_mirai_driver 39 | - umr_extensions_demo 40 | Driver: 41 | QQ: 42 | Base: QQ 43 | Account: 643503161 44 | APIRoot: http://127.0.0.1:5700/ 45 | ListenIP: 172.17.0.1 46 | ListenPort: 8080 47 | Token: very 48 | Secret: long 49 | NameforPrivateChat: yes # if destination chat_id is a private chat, show all attributes (sender name, reply to, forward from) 50 | NameforGroupChat: yes # if destination chat_id is a group/discuss chat, show all attributes (sender name, reply to, forward from) 51 | Telegram: 52 | Base: Telegram 53 | BotToken: asdasdsadsadsadsad 54 | # HTTPProxy: http://127.0.0.1:1080 # uncomment f proxy is required 55 | Line: 56 | Base: Line 57 | ChannelID: sdarq3rcar323r2r23r 58 | BotToken: 123dff23rr23r23r23rr 59 | WebHookToken: 43r23r23rf23r23r23r2 60 | WebHookURL: https://example.com # must not include /callback 61 | WebHookPort: 41443 62 | HTTPSCert: /root/.acme.sh/example.com/example.com.cer 63 | HTTPSKey: /root/.acme.sh/example.com/example.com.key 64 | HTTPSCA: /root/.acme.sh/example.com/ca.cer 65 | Discord: 66 | Base: Discord 67 | BotToken: asdsadsddfffsdffsdfsd # the longer one 68 | ClientToken: asdasdsadsfsafsdfsd # the shorter one 69 | Mirai: 70 | Base: Mirai 71 | Account: 1213123 72 | Host: 127.0.0.1 73 | Port: 18080 74 | AuthKey: abcdefgh 75 | NameforPrivateChat: yes # if destination chat_id is a private chat, show all attributes (sender name, reply to, forward from) 76 | NameforGroupChat: yes # if destination chat_id is a group/discuss chat, show all attributes (sender name, reply to, forward from) 77 | 78 | DataRoot: /root/coolq/data/image 79 | LogRoot: /var/log/umr 80 | CommandPrefix: "!!" # optional, default "!!" 81 | BotAdmin: # optional, default empty 82 | QQ: 83 | - 123456789 84 | - 987654321 85 | Telegram: 86 | - 213442352354534534 87 | - 345235345345345345 88 | LogLevel: # Optional, default DEBUG 89 | '*': DEBUG # Global level 90 | httpx: INFO # Module level 91 | aiogram: INFO 92 | asyncio: INFO 93 | -------------------------------------------------------------------------------- /docs/Command.md: -------------------------------------------------------------------------------- 1 | # Command 2 | 3 | Command module allows simple interaction between users and plugins. 4 | 5 | ## Example 6 | 7 | ```python 8 | from typing import List 9 | from Core.UMRType import ChatAttribute, ChatType, Privilege 10 | from Core.UMRCommand import register_command, quick_reply 11 | 12 | 13 | @register_command(cmd='echo', description='reply every word you sent') 14 | async def command(chat_attrs: ChatAttribute, args: List): 15 | """ 16 | Prototype of command 17 | :param chat_attrs: 18 | :param args: 19 | :return: 20 | """ 21 | if not args: # args should not be empty 22 | return 23 | 24 | await quick_reply(chat_attrs, ' '.join(args)) 25 | 26 | ``` 27 | 28 | The example above provides basic reply function: it replies whenever you send !!echo with some arguments. 29 | 30 | ## Details 31 | 32 | ### register_command 33 | A complete version of `register_command` 34 | ```python 35 | from Core.UMRType import ChatAttribute, ChatType, Privilege 36 | from Core.UMRCommand import register_command, quick_reply 37 | from typing import List 38 | 39 | @register_command(cmd=['cmd1', 'cmd2', ...], platform=['QQ', 'Telegram', ...], 40 | description='Your description goes here', chat_type=ChatType.PRIVATE, privilege=Privilege.BOT_ADMIN) 41 | async def command(chat_attrs: ChatAttribute, args: List): 42 | pass 43 | ``` 44 | 45 | #### Args: 46 | - cmd: str or List\[str\], depends on how many aliases for the same command. 47 | - platform: str or List\[str\], only command from these platform will be handled. Leave empty for match all. 48 | - description: str, something that will show up in `!!help`. 49 | - chat_type: the chat requirement of this message, possible values listed in `UMRTypes.ChatType` 50 | - privilege: the privilege requirement of this command, possible values listed in `UMRTypes.Privilege` 51 | 52 | ### function prototype 53 | ```python 54 | async def command(chat_attrs: ForwardAttributes, args: List): 55 | pass 56 | ``` 57 | 58 | #### Args: 59 | - chat_attrs: `UMRTypes.ChatAttribute` 60 | - args: list of str, extracted from the rest of the command split by spaces 61 | 62 | #### Return: 63 | 64 | Not required 65 | -------------------------------------------------------------------------------- /docs/Driver.md: -------------------------------------------------------------------------------- 1 | # Driver 2 | 3 | ## API 4 | Driver should implement the following API 5 | 6 | ### Send 7 | ```python 8 | async def send(self, to_chat: int, chat_type: ChatType, messsage: UnifiedMessage): 9 | """ 10 | function prototype for send new message 11 | this function should be implemented in driver, sync or async 12 | send should call set_ingress_message_id to register received message 13 | """ 14 | pass 15 | ``` 16 | 17 | Or the synced version, if async not available: 18 | 19 | ```python 20 | from Core.UMRType import UnifiedMessage 21 | def send(self, to_chat: int, chat_type: ChatType, messsage: UnifiedMessage): 22 | """ 23 | function prototype for send new message 24 | this function should be implemented in driver, sync or async 25 | send should call set_egress_message_id to register message 26 | """ 27 | pass 28 | ``` 29 | 30 | ### IsAdmin 31 | 32 | ```python 33 | async def is_group_admin(self, chat_id: int, chat_type: ChatType, user_id: int) -> bool: 34 | """ 35 | :return if the member is group admin 36 | """ 37 | pass 38 | ``` 39 | ### IsOwner 40 | 41 | ```python 42 | async def is_group_owner(self, chat_id: int, chat_type: ChatType, user_id: int) -> bool: 43 | """ 44 | :return if the member is group owner 45 | """ 46 | if chat_type != ChatType.GROUP: 47 | return False 48 | if chat_id not in self.group_list: 49 | return False 50 | return self.group_list[chat_id][user_id]['role'] == 'owner' 51 | ``` 52 | 53 | ------ 54 | 55 | These functions should be registered to driver's API lookup table, see any existing driver for example. 56 | 57 | Driver must make sure that this function can be called directly from other events loop or threads. 58 | 59 | ## Inbound message 60 | Driver should also implement the following handler, e.g. QQ: 61 | Driver should call `set_ingress_message_id` to register received message 62 | 63 | ```python 64 | async def handle_msg(context): 65 | message_type = context.get("message_type") 66 | chat_id = context.get(f'{message_type}_id') 67 | chat_type = self.chat_type_dict[message_type] 68 | 69 | unified_message_list = await self.dissemble_message(context) 70 | set_ingress_message_id(src_platform=self.name, src_chat_id=chat_id, src_chat_type=chat_type, 71 | src_message_id=context.get('message_id'), user_id=context.get('user_id')) 72 | for message in unified_message_list: 73 | await self.receive(message) 74 | return {} 75 | ``` 76 | 77 | What is important about this code snippet is `await UMRDriver.receive(message)`. The driver should parse the income message 78 | to UnifiedMessage, and then call `UMRDriver.receive(message)`. This is an async function, and driver should use their own 79 | event loop to call this function. 80 | 81 | ## Calling driver 82 | 83 | ```python 84 | from Core.UMRDriver import api_call 85 | result = api_call(chat_attrs.from_platform, 'api name', *args, **kwargs) 86 | ``` 87 | -------------------------------------------------------------------------------- /docs/MessageHook.md: -------------------------------------------------------------------------------- 1 | # Message Hook 2 | 3 | Message hook api provides more flexible message handling 4 | 5 | ## Example 6 | 7 | ```python 8 | from Core.UMRType import UnifiedMessage 9 | from Core.UMRMessageHook import register_hook 10 | @register_hook(src_driver='QQ', src_chat=123123, dst_driver='Telegram', dst_chat=124434) 11 | async def message_hook_func(dst_driver: str, dst_chat: int, 12 | message: UnifiedMessage) -> bool: 13 | """ 14 | Prototype of message hook function with four tuple match 15 | :param dst_driver: driver name 16 | :param dst_chat: chat id 17 | :param message: unified message 18 | :return: True for block message forwarding, False for call next hook 19 | """ 20 | pass 21 | 22 | 23 | @register_hook(src_driver='QQ', src_chat=123123) 24 | async def message_hook_func(message: UnifiedMessage) -> bool: 25 | """ 26 | Prototype of message hook function with two tuple match 27 | :param message: unified message 28 | :return: True for block message forwarding, False for call next hook 29 | """ 30 | pass 31 | ``` 32 | 33 | #### Args: 34 | - `src_driver`: str or List[str], the source driver 35 | - `src_chat`: int or List[int], the source chat id 36 | - `src_chat_type`: ChatType or List[ChatType], the source chat type 37 | - `dst_driver`: str or List[str], the destination driver 38 | - `dst_chat`: int or List[int], the destination chat id 39 | - `dst_chat_type`: ChatType or List[ChatType], the destination chat type 40 | 41 | WHen dst_\* are empty, the function prototype should be: 42 | 43 | `async def message_hook_func(message: UnifiedMessage) -> bool:` 44 | 45 | Because if dst_\* are specified, the message will be matched on each ForwardAction, 46 | that could be multiple matches per forwarded message. 47 | If not, it will be matched base on source. 48 | -------------------------------------------------------------------------------- /docs/Types.md: -------------------------------------------------------------------------------- 1 | # Types 2 | 3 | See [`src/core/UMRType.py`](//Core/UMRType.py) for complete inline documentation. -------------------------------------------------------------------------------- /image/qq.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JQ-Networks/UnifiedMessageRelay/164af3929dd2f2df6debd72df9cf4be5af005004/image/qq.png -------------------------------------------------------------------------------- /image/telegram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JQ-Networks/UnifiedMessageRelay/164af3929dd2f2df6debd72df9cf4be5af005004/image/telegram.png -------------------------------------------------------------------------------- /image/tg-discord1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JQ-Networks/UnifiedMessageRelay/164af3929dd2f2df6debd72df9cf4be5af005004/image/tg-discord1.png -------------------------------------------------------------------------------- /image/tg-discord2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JQ-Networks/UnifiedMessageRelay/164af3929dd2f2df6debd72df9cf4be5af005004/image/tg-discord2.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiogram 2 | discord 3 | httpx 4 | aiocqhttp 5 | lottie 6 | imageio 7 | janus 8 | filetype 9 | cairosvg 10 | Pillow 11 | coloredlogs 12 | ffmpy 13 | line-bot-sdk 14 | quart 15 | Wand 16 | python-mirai-core 17 | pyyaml 18 | pydantic -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from setuptools import setup, find_packages 3 | import unified_message_relay 4 | 5 | if sys.version_info < (3, 7): 6 | raise Exception("Python 3.7 or higher is required. Your version is %s." % sys.version) 7 | 8 | long_description = open('README.md', encoding="utf-8").read() 9 | 10 | __version__ = unified_message_relay.__VERSION__ 11 | 12 | setup( 13 | name='unified-message-relay', 14 | packages=find_packages(include=['unified_message_relay', 'unified_message_relay.*']), 15 | version=__version__, 16 | description='Group Message Forward Framework (supports QQ Telegram Line Discord)', 17 | long_description=long_description, 18 | long_description_content_type='text/markdown', 19 | author='Curtis Jiang', 20 | url='https://github.com/jqqqqqqqqqq/UnifiedMessageRelay', 21 | license='MIT', 22 | python_requires='>=3.7', 23 | include_package_data=True, 24 | zip_safe=False, 25 | keywords=['UMR', 'UnifiedMessageRelay', 'Group chat relay', 'IM', 'messaging'], 26 | classifiers=[ 27 | "Development Status :: 4 - Beta", 28 | "License :: OSI Approved :: MIT License", 29 | "Intended Audience :: Developers", 30 | "Intended Audience :: End Users/Desktop", 31 | "Programming Language :: Python :: 3 :: Only", 32 | "Programming Language :: Python :: 3.7", 33 | "Topic :: Communications :: Chat", 34 | "Topic :: Software Development :: Libraries :: Application Frameworks", 35 | "Typing :: Typed" 36 | ], 37 | install_requires=[ 38 | 'lottie', 39 | 'imageio', 40 | 'janus', 41 | 'filetype', 42 | 'cairosvg', 43 | 'Pillow', 44 | 'coloredlogs', 45 | 'ffmpy', 46 | 'Wand', 47 | 'pyyaml', 48 | 'pydantic' 49 | ], 50 | entry_points={ 51 | "console_scripts": [ 52 | 'unified_message_relay = unified_message_relay.daemon:main' 53 | ] 54 | }, 55 | project_urls={ 56 | "Telegram Chat": "https://t.me/s/UnifiedMessageRelayDev", 57 | } 58 | ) 59 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRAdmin.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List 2 | from . import UMRConfig 3 | from . import UMRLogging 4 | from .UMRDriver import api_call 5 | from .UMRType import ChatType 6 | 7 | # Stateless 8 | 9 | logger = UMRLogging.get_logger('Admin') 10 | 11 | 12 | async def is_bot_admin(platform: str, user_id: int) -> bool: 13 | """ 14 | check if user is in bot admin list 15 | :param platform: 16 | :param user_id: 17 | :return: 18 | """ 19 | if platform not in UMRConfig.config.BotAdmin: 20 | return False 21 | return user_id in UMRConfig.config.BotAdmin[platform] 22 | 23 | 24 | async def is_group_owner(platform: str, chat_id: int, chat_type: ChatType, user_id: int): 25 | if chat_id > 0: # private chat 26 | return False 27 | result = await api_call(platform, 'is_group_owner', chat_id, chat_type, user_id) 28 | if result: # result can be None if driver does not have is_group_owner 29 | if isinstance(result, bool): 30 | return result 31 | else: 32 | return result.result() 33 | else: 34 | return False 35 | 36 | 37 | async def is_group_admin(platform: str, chat_id: int, chat_type: ChatType, user_id: int): 38 | if chat_id > 0: # private chat 39 | return False 40 | result = await api_call(platform, 'is_group_admin', chat_id, chat_type, user_id) 41 | if result: # result can be None if driver does not have is_group_admin 42 | if isinstance(result, bool): 43 | return result 44 | else: 45 | return result.result() 46 | else: 47 | return False 48 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRCommand.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Union, Set 2 | from asyncio import iscoroutinefunction 3 | from . import UMRConfig 4 | from . import UMRLogging 5 | from .UMRType import UnifiedMessage, Command, ChatAttribute, MessageEntity, ChatType, Privilege, SendAction, EntityType 6 | from .UMRMessageHook import register_hook 7 | from .UMRDriver import api_call 8 | from .UMRAdmin import is_bot_admin, is_group_admin, is_group_owner 9 | from ..Util.Helper import unparse_entities_to_markdown 10 | 11 | logger = UMRLogging.get_logger('Command') 12 | 13 | command_map: Dict[str, Command] = dict() 14 | command_prefix: str = UMRConfig.config.CommandPrefix 15 | 16 | 17 | async def unauthorized(chat_attrs: ChatAttribute, required_privilege: Privilege): 18 | privilege_names = { 19 | Privilege.GROUP_ADMIN: 'Group Admin', 20 | Privilege.GROUP_OWNER: 'Group Owner', 21 | Privilege.BOT_ADMIN: 'Bot Admin' 22 | } 23 | 24 | error_message = f'Unauthorized command, requires {privilege_names[required_privilege]}' 25 | await quick_reply(chat_attrs, error_message) 26 | 27 | 28 | @register_hook() 29 | async def command_dispatcher(message: UnifiedMessage): 30 | # filter command 31 | if len(message.text) == 0: # command must have some texts 32 | return False 33 | 34 | msg = unparse_entities_to_markdown(message, EntityType.PLAIN) 35 | 36 | if not msg.startswith(command_prefix): # command must start with command_start 37 | return False 38 | 39 | cmd, *args = msg.split(' ') 40 | cmd = cmd[len(command_prefix):] 41 | logger.debug(f'dispatching command: "{cmd}" with args: "{" ".join(args)}"') 42 | if cmd in command_map: 43 | # check if platform matches 44 | if command_map[cmd].platform: 45 | base_platform = UMRConfig.config.Driver[message.chat_attrs.platform].Base 46 | if base_platform not in command_map[cmd].platform: 47 | return False 48 | 49 | # filter chat_type 50 | if command_map[cmd].chat_type: 51 | if message.chat_attrs.chat_id > 0 and command_map[cmd].chat_type == ChatType.GROUP: 52 | return False 53 | if message.chat_attrs.chat_id < 0 and command_map[cmd].chat_type == ChatType.PRIVATE: 54 | return False 55 | 56 | # filter privilege 57 | if command_map[cmd].privilege: 58 | if command_map[cmd].privilege == Privilege.BOT_ADMIN: 59 | if not await is_bot_admin(message.chat_attrs.platform, message.chat_attrs.user_id): 60 | await unauthorized(message.chat_attrs, command_map[cmd].privilege) 61 | return True 62 | elif command_map[cmd].privilege == Privilege.GROUP_OWNER: 63 | if not await is_bot_admin(message.chat_attrs.platform, message.chat_attrs.user_id) or \ 64 | not await is_group_owner(platform=message.chat_attrs.platform, 65 | chat_id=message.chat_attrs.chat_id, 66 | chat_type=message.chat_attrs.chat_type, 67 | user_id=message.chat_attrs.user_id): 68 | await unauthorized(message.chat_attrs, command_map[cmd].privilege) 69 | return True 70 | elif command_map[cmd].privilege == Privilege.GROUP_ADMIN: 71 | if not await is_bot_admin(message.chat_attrs.platform, message.chat_attrs.user_id) or \ 72 | not await is_group_admin(platform=message.chat_attrs.platform, 73 | chat_id=message.chat_attrs.chat_id, 74 | chat_type=message.chat_attrs.chat_type, 75 | user_id=message.chat_attrs.user_id): 76 | await unauthorized(message.chat_attrs, command_map[cmd].privilege) 77 | return True 78 | 79 | await command_map[cmd].command_function(message.chat_attrs, args) 80 | return True 81 | else: 82 | return False 83 | 84 | 85 | def register_command(cmd: Union[str, List[str]] = '', description: str = '', platform: Union[str, List[str]] = '', 86 | chat_type=ChatType.UNSPECIFIED, privilege=''): 87 | """ 88 | register command 89 | :param cmd: command keyword, must not be null 90 | :param description: command description, will show in help command 91 | :param platform: platform name, if specified, only message from that platform will trigger this command 92 | :return: 93 | """ 94 | 95 | def deco(func): 96 | if isinstance(cmd, str): 97 | assert cmd not in command_map, f'Error, "{cmd}" has been registered' 98 | command_map[cmd] = Command(platform=platform, description=description, chat_type=chat_type, 99 | privilege=privilege, command_function=func) 100 | else: 101 | _cmd = Command(platform=platform, description=description, chat_type=chat_type, 102 | privilege=privilege, command_function=func) 103 | for c in cmd: 104 | assert c not in command_map, f'Error, "{c}" has been registered' 105 | command_map[c] = _cmd 106 | return func 107 | 108 | return deco 109 | 110 | 111 | @register_command(cmd='help', description='get list of commands') 112 | async def command(chat_attrs: ChatAttribute, args: List): 113 | """ 114 | Prototype of command 115 | :param chat_attrs: 116 | :param args: 117 | :return: 118 | """ 119 | if args: # args should be empty 120 | return 121 | 122 | message_entities = list() 123 | help_text = 'Available commands in this group: ' 124 | message = help_text 125 | for cmd, cmd_obj in command_map.items(): 126 | if cmd_obj.platform and chat_attrs.platform not in cmd_obj.platform: 127 | continue 128 | message += '\n' 129 | cmd_text = cmd + ': ' 130 | message_entities.append( 131 | MessageEntity(start=len(message), 132 | end=len(message) + len(cmd_text), 133 | entity_type=EntityType.BOLD)) 134 | message += cmd_text 135 | message += cmd_obj.description 136 | 137 | await quick_reply(chat_attrs, message, message_entities) 138 | 139 | 140 | async def quick_reply(chat_attrs: ChatAttribute, text: str, message_entities: List[MessageEntity] = None): 141 | """ 142 | send quick reply for bot commands 143 | :param chat_attrs: 144 | :param text: 145 | :param message_entities: 146 | :return: 147 | """ 148 | 149 | message = UnifiedMessage() 150 | message.text = text 151 | message.text_entities = message_entities 152 | message.send_action = SendAction(message_id=chat_attrs.message_id, user_id=chat_attrs.user_id) 153 | 154 | await api_call(chat_attrs.platform, 'send', chat_attrs.chat_id, chat_attrs.chat_type, message) 155 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRConfig.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | from . import UMRLogging 3 | from .UMRType import ChatType, ForwardTypeEnum, DefaultForwardTypeEnum, LogLevel 4 | from pydantic import BaseModel, validator 5 | from typing import Dict, List, Union, Type, Optional, Generic, AnyStr, DefaultDict 6 | from typing_extensions import Literal 7 | import importlib 8 | import yaml 9 | import json 10 | 11 | # load config from home directory 12 | 13 | __ALL__ = [ 14 | 'config', 15 | 'register_driver_config', 16 | 'register_extension_config', 17 | 'reload_config', 18 | 'save_config', 19 | 'load_extensions' 20 | ] 21 | 22 | logger = UMRLogging.get_logger('Config') 23 | 24 | 25 | def load_extensions(): 26 | """ 27 | Shared logic for loading both drivers and extensions (import and register only) 28 | 29 | """ 30 | ext_names = config.Extensions 31 | if ext_names: 32 | for e in ext_names: 33 | globals()[e] = importlib.import_module(e) 34 | 35 | 36 | class BaseDriverConfig(BaseModel): 37 | Base: str 38 | 39 | 40 | class BaseExtensionConfig(BaseModel): 41 | Extension: str 42 | 43 | 44 | class Default(BaseModel): 45 | From: str 46 | To: str 47 | ToChat: Union[int, str] 48 | ToChatType: ChatType 49 | ForwardType: DefaultForwardTypeEnum 50 | 51 | 52 | class Topology(BaseModel): 53 | From: str 54 | FromChat: Union[int, str] 55 | FromChatType: ChatType 56 | To: str 57 | ToChat: Union[int, str] 58 | ToChatType: ChatType 59 | ForwardType: ForwardTypeEnum 60 | 61 | 62 | class ForwardList(BaseModel): 63 | Topology: Optional[List[Topology]] 64 | Default: Optional[List[Default]] 65 | Accounts: Dict[str, Union[int, str]] = {} 66 | 67 | @validator('Topology') 68 | def generate_empty_list_if_none(cls, v): 69 | if not v: 70 | return [] 71 | else: 72 | return v 73 | 74 | @validator('Default') 75 | def generate_empty_list_if_none2(cls, v): 76 | if not v: 77 | return [] 78 | else: 79 | return v 80 | 81 | 82 | def construct_union(modules: List, names): 83 | eval_string = ', '.join([i.__module__ + '.' + i.__name__ for i in modules]) 84 | if names: 85 | if eval_string: 86 | eval_string += ', ' + names.__name__ 87 | else: 88 | eval_string = names.__name__ 89 | return eval(f'Union[{eval_string}]') 90 | 91 | 92 | class BasicConfig(BaseModel): 93 | DataRoot: str = '/root/coolq/data/image' 94 | LogRoot: str = '/var/log/umr' 95 | CommandPrefix: str = '!!' 96 | Extensions: Optional[List[str]] 97 | BotAdmin: Optional[Dict[str, List[Union[int, str]]]] 98 | LogLevel: Optional[Dict[str, LogLevel]] 99 | 100 | ForwardList: ForwardList 101 | Driver: Optional[Dict[str, BaseDriverConfig]] 102 | 103 | ExtensionConfig: Optional[Dict[str, BaseExtensionConfig]] 104 | 105 | @validator('Extensions', pre=True, always=True) 106 | def generate_empty_list_if_none(cls, v): 107 | return v or [] 108 | 109 | @validator('Driver', pre=True, always=True) 110 | def generate_empty_dict_if_none(cls, v): 111 | return v or {} 112 | 113 | @validator('ExtensionConfig', pre=True, always=True) 114 | def generate_empty_dict_if_none2(cls, v): 115 | return v or {} 116 | 117 | @validator('BotAdmin', pre=True, always=True) 118 | def generate_empty_dict_if_none3(cls, v): 119 | return v or {} 120 | 121 | @validator('LogLevel', pre=True, always=True) 122 | def generate_empty_dict_if_none4(cls, v): 123 | return v or {} 124 | 125 | 126 | home = str(pathlib.Path.home()) 127 | config = BasicConfig(**yaml.load(open(f'{home}/.umr/config.yaml'), yaml.FullLoader)) 128 | 129 | driver_config = [] 130 | extension_config = [] 131 | 132 | 133 | def register_driver_config(custom_config): 134 | driver_config.append(custom_config) 135 | 136 | 137 | def register_extension_config(custom_config): 138 | extension_config.append(custom_config) 139 | 140 | 141 | def reload_config(): 142 | global config 143 | 144 | class FullConfig(BaseModel): 145 | DataRoot: str = '/root/coolq/data/image' 146 | LogRoot: str = '/var/log/umr' 147 | CommandPrefix: str = '!!' 148 | Extensions: Optional[List[str]] 149 | BotAdmin: Optional[Dict[str, List[Union[int, str]]]] 150 | LogLevel: Optional[Dict[str, LogLevel]] 151 | 152 | ForwardList: ForwardList 153 | Driver: Optional[Dict[str, construct_union(driver_config, BaseDriverConfig)]] 154 | 155 | ExtensionConfig: Optional[Dict[str, construct_union(extension_config, BaseExtensionConfig)]] 156 | 157 | @validator('Extensions', pre=True, always=True) 158 | def generate_empty_list_if_none(cls, v): 159 | return v or [] 160 | 161 | @validator('Driver', pre=True, always=True) 162 | def generate_empty_dict_if_none(cls, v): 163 | return v or {} 164 | 165 | @validator('ExtensionConfig', pre=True, always=True) 166 | def generate_empty_dict_if_none2(cls, v): 167 | return v or {} 168 | 169 | @validator('BotAdmin', pre=True, always=True) 170 | def generate_empty_dict_if_none3(cls, v): 171 | return v or {} 172 | 173 | @validator('LogLevel', pre=True, always=True) 174 | def generate_empty_dict_if_none4(cls, v): 175 | return v or {} 176 | 177 | config = FullConfig(**yaml.load(open(f'{home}/.umr/config.yaml'), yaml.FullLoader)) 178 | 179 | 180 | def save_config(): 181 | yaml.dump(json.loads(config.json()), open(f'{home}/.umr/config.yaml', 'w'), default_flow_style=False) 182 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRDispatcher.py: -------------------------------------------------------------------------------- 1 | from typing import Union, List, DefaultDict, Tuple, Any, Union, Dict 2 | import asyncio 3 | from collections import defaultdict 4 | from .UMRType import UnifiedMessage, ForwardAction, ForwardActionType, DefaultForwardAction,\ 5 | DefaultForwardActionType, SendAction, ChatType, GroupID,\ 6 | DestinationMessageID, ForwardTypeEnum, DefaultForwardTypeEnum 7 | from . import UMRLogging 8 | from . import UMRDriver 9 | from .UMRConfig import config 10 | from .UMRMessageRelation import get_message_id 11 | from .UMRMessageHook import dispatch_hook 12 | from .UMRFile import get_image 13 | 14 | """ 15 | 16 | 17 | +-----------+ +-----------+ +-----------+ +-----------+ 18 | | |Yes |Normal | | Default | | | 19 | | Reply? +--->+OneWay+ +---->+ OneWay+ +--->+ Discard | 20 | | | |Bidirection| | | | | 21 | +-----------+ +-----------+ +-----------+ +-----------+ 22 | | 23 | No| 24 | v 25 | +-----------+ +-----------+ +-----------+ 26 | | | | | | | 27 | | Normal +--->+ Default +---->+ Discard | 28 | | | | | | | 29 | +-----------+ +-----------+ +-----------+ 30 | 31 | 32 | 33 | """ 34 | class UMRDispatcher: 35 | def __init__(self): 36 | self.logger = UMRLogging.get_logger('Dispatcher') 37 | 38 | # bot accounts for each platform 39 | self.bot_accounts = config.ForwardList.Accounts 40 | 41 | # forward graph 42 | 43 | self.action_graph: DefaultDict[GroupID, List[ForwardAction]] = defaultdict(lambda: list()) # action graph 44 | 45 | self.default_action_graph: DefaultDict[str, Dict[GroupID, DefaultForwardAction]] = defaultdict( 46 | lambda: dict()) # default action graph 47 | 48 | # initialize action_graph 49 | for i in config.ForwardList.Topology: 50 | 51 | # Add action 52 | # BiDirection = two ALL 53 | # OneWay = one All 54 | # OneWay+ = one All + one Reply 55 | 56 | # ForwardType.All: From one platform to another, forward all message 57 | # ForwardType.Reply: From one platform to another, forward only replied message 58 | 59 | # init forward graph and workers 60 | if i.ForwardType == ForwardTypeEnum.BiDirection: 61 | forward_action_type = ForwardActionType.ForwardAll 62 | backward_action_type = ForwardActionType.ForwardAll 63 | elif i.ForwardType == ForwardTypeEnum.OneWayPlus: 64 | forward_action_type = ForwardActionType.ForwardAll 65 | backward_action_type = ForwardActionType.ReplyOnly 66 | elif i.ForwardType == ForwardTypeEnum.OneWay: 67 | forward_action_type = ForwardActionType.ForwardAll 68 | backward_action_type = ForwardActionType.Block 69 | else: 70 | self.logger.warning(f'Unrecognized ForwardType in config: "{i.ForwardType}", ignoring') 71 | continue 72 | self.action_graph[GroupID(platform=i.From, 73 | chat_id=i.FromChat, 74 | chat_type=i.FromChatType)].append( 75 | ForwardAction(to_platform=i.To, 76 | to_chat=i.ToChat, 77 | chat_type=i.ToChatType, 78 | action_type=forward_action_type)) 79 | self.action_graph[ 80 | GroupID(platform=i.To, 81 | chat_id=i.ToChat, 82 | chat_type=i.ToChatType)].append( 83 | ForwardAction(to_platform=i.From, 84 | to_chat=i.FromChat, 85 | chat_type=i.FromChatType, 86 | action_type=backward_action_type)) 87 | 88 | # initialize default_action_graph 89 | for i in config.ForwardList.Default: 90 | 91 | # Add action 92 | # OneWay = one All 93 | # OneWay+ = one All + one Reply 94 | 95 | # ForwardType.All: From one platform to another, forward all message, accept reply backward 96 | # ForwardType.Reply: From one platform to another, forward all message, reject reply backward 97 | 98 | if i.ForwardType == DefaultForwardTypeEnum.OneWayPlus: 99 | action_type = DefaultForwardActionType.OneWayWithReply 100 | elif i.ForwardType == DefaultForwardTypeEnum.OneWay: 101 | action_type = DefaultForwardActionType.OneWay 102 | else: 103 | self.logger.warning(f'Unrecognized ForwardType in config: "{i.ForwardType}", ignoring') 104 | continue 105 | self.default_action_graph[i.From][ 106 | GroupID(platform=i.To, 107 | chat_id=i.ToChat, 108 | chat_type=i.ToChatType)] = \ 109 | DefaultForwardAction(to_platform=i.To, 110 | to_chat=i.ToChat, 111 | chat_type=i.ToChatType, 112 | action_type=action_type) 113 | 114 | async def send(self, message: UnifiedMessage, platform: str, chat_id: Union[int, str], chat_type: ChatType): 115 | """ 116 | Internal function: dispatch message to destination driver 117 | 118 | :param message: UnifiedMessage 119 | :param platform: dst platform 120 | :param chat_id: dst chat id 121 | :param chat_type: dst type 122 | """ 123 | if await dispatch_hook(message, 124 | dst_driver=platform, 125 | dst_chat=chat_id, 126 | dst_chat_type=chat_type): 127 | self.logger.debug(f'Message to ({platform}, {chat_id}, {chat_type}) is handled by hook') 128 | return 129 | if message.image.startswith('http'): 130 | message.image = await get_image(message.image, message.file_id) 131 | await UMRDriver.api_call(platform, 'send', chat_id, chat_type, message) 132 | self.logger.debug(f'Message to ({platform}, {chat_id}, {chat_type}) is assigned to driver') 133 | 134 | async def dispatch_normal_reply(self, message: UnifiedMessage, reply_message_id: DestinationMessageID): 135 | # normal one way forward, ignore 136 | normal_action = self.action_graph[GroupID(platform=message.chat_attrs.platform, 137 | chat_id=message.chat_attrs.chat_id, 138 | chat_type=message.chat_attrs.chat_type)] 139 | for action in normal_action: # ignore if defined, else treat as OneWay+ 140 | if action.to_platform == reply_message_id.source.platform and action.to_chat == reply_message_id.source.chat_id: 141 | if action.action_type == ForwardActionType.Block: 142 | return True 143 | break 144 | else: 145 | return False 146 | 147 | # if it has any action, then forward 148 | 149 | message.chat_attrs.reply_to = None 150 | message.send_action = SendAction(message_id=reply_message_id.source.message_id, 151 | user_id=reply_message_id.source.user_id) 152 | await self.send(message, 153 | reply_message_id.source.platform, 154 | reply_message_id.source.chat_id, 155 | reply_message_id.source.chat_type) 156 | 157 | return True 158 | 159 | async def dispatch_default_reply(self, message: UnifiedMessage, reply_message_id: DestinationMessageID): 160 | # default one way forward, block 161 | default_action = self.default_action_graph[reply_message_id.source.platform].get( 162 | GroupID(platform=message.chat_attrs.platform, chat_id=message.chat_attrs.chat_id, 163 | chat_type=message.chat_attrs.chat_type)) 164 | if not default_action: 165 | return False 166 | 167 | if default_action.action_type == DefaultForwardActionType.OneWay: # block forwarding 168 | return True 169 | 170 | # OneWayWithReply 171 | 172 | message.chat_attrs.reply_to = None 173 | message.send_action = SendAction(message_id=reply_message_id.source.message_id, 174 | user_id=reply_message_id.source.user_id) 175 | await self.send(message, 176 | reply_message_id.source.platform, 177 | reply_message_id.source.chat_id, 178 | reply_message_id.source.chat_type) 179 | 180 | return True 181 | 182 | async def dispatch_reply(self, message: UnifiedMessage): 183 | """ 184 | dispatch messages that replied messages forwarded by default rule 185 | :param message: 186 | :return: 187 | """ 188 | 189 | # check reply 190 | if not message.chat_attrs.reply_to: 191 | return False 192 | 193 | # if the message is replying to a normal user, let it pass to normal dispatch 194 | if message.chat_attrs.reply_to.user_id != self.bot_accounts.get(message.chat_attrs.platform): 195 | return False 196 | 197 | reply_message_id = get_message_id(src_platform=message.chat_attrs.platform, 198 | src_chat_id=message.chat_attrs.chat_id, 199 | src_chat_type=message.chat_attrs.chat_type, 200 | src_message_id=message.chat_attrs.reply_to.message_id, 201 | dst_platform=message.chat_attrs.platform, 202 | dst_chat_id=message.chat_attrs.chat_id, 203 | dst_chat_type=message.chat_attrs.chat_type) 204 | 205 | # filter no source message (e.g. bot command) 206 | if not reply_message_id or not reply_message_id.source: 207 | return False 208 | 209 | # from same chat, ignore 210 | if reply_message_id.source.platform == message.chat_attrs.platform and \ 211 | reply_message_id.source.chat_id == message.chat_attrs.chat_id: 212 | return False 213 | 214 | if await self.dispatch_normal_reply(message, reply_message_id): 215 | return True 216 | 217 | if await self.dispatch_default_reply(message, reply_message_id): 218 | return True 219 | 220 | return False 221 | 222 | async def dispatch_default(self, message: UnifiedMessage): 223 | if not self.default_action_graph[message.chat_attrs.platform]: 224 | return False 225 | 226 | for _, action in self.default_action_graph[message.chat_attrs.platform].items(): 227 | await self.send(message, action.to_platform, action.to_chat, action.chat_type) 228 | 229 | return True 230 | 231 | async def dispatch_normal(self, message: UnifiedMessage): 232 | actions = self.action_graph[GroupID(platform=message.chat_attrs.platform, 233 | chat_id=message.chat_attrs.chat_id, 234 | chat_type=message.chat_attrs.chat_type)] 235 | if not actions: 236 | return False 237 | 238 | for action in actions: 239 | if action.action_type != ForwardActionType.ForwardAll: # reply is handled in another logic 240 | continue 241 | 242 | await self.send(message, action.to_platform, action.to_chat, action.chat_type) 243 | 244 | return True 245 | 246 | async def dispatch(self, message: UnifiedMessage): 247 | if await dispatch_hook(message): 248 | return 249 | 250 | # check reply 251 | if await self.dispatch_reply(message): 252 | return 253 | 254 | # check normal 255 | if await self.dispatch_normal(message): 256 | return 257 | 258 | # check default 259 | if await self.dispatch_default(message): 260 | return 261 | 262 | def reload(self): 263 | pass 264 | 265 | 266 | dispatcher: UMRDispatcher 267 | 268 | 269 | async def dispatch(message: UnifiedMessage): 270 | """ 271 | Shadow function for dispatch's real dispatch signature 272 | :param message: 273 | :return: 274 | """ 275 | if not dispatcher: 276 | pass 277 | 278 | await dispatcher.dispatch(message) 279 | 280 | 281 | def init_dispatcher(): 282 | global dispatcher 283 | dispatcher = UMRDispatcher() 284 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRDriver.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Union, Any 2 | from threading import Thread 3 | from .UMRType import UnifiedMessage, ChatType 4 | from . import UMRLogging 5 | from . import UMRConfig 6 | from asyncio import iscoroutinefunction 7 | from . import UMRDispatcher 8 | import asyncio 9 | import importlib 10 | 11 | logger = UMRLogging.get_logger('Driver') 12 | 13 | 14 | # region Driver API lookup table 15 | class BaseDriverMixin: 16 | def __init__(self, name): 17 | """ 18 | Create driver instance 19 | 20 | :param name: the name in ForwardList 21 | """ 22 | pass 23 | 24 | async def post_init(self): 25 | pass 26 | 27 | async def send(self, to_chat: Union[int, str], chat_type: ChatType, message: UnifiedMessage): 28 | pass 29 | 30 | async def is_group_admin(self, chat_id: int, chat_type: ChatType, user_id: int) -> bool: 31 | pass 32 | 33 | async def is_group_owner(self, chat_id: int, chat_type: ChatType, user_id: int) -> bool: 34 | pass 35 | 36 | def start(self): 37 | """ 38 | should be non blocking, run everything in new thread and register that thread 39 | """ 40 | pass 41 | 42 | @property 43 | def started(self): 44 | """ 45 | indicator for driver start up progress 46 | :return: False for not ready, True for ready 47 | """ 48 | return True 49 | 50 | async def receive(self, message: UnifiedMessage): 51 | """ 52 | send unified message to dispatch 53 | this function should not be override 54 | :param message: unified message to dispatch 55 | """ 56 | await UMRDispatcher.dispatch(message) 57 | 58 | 59 | driver_class_lookup_table: Dict[str, Any] = dict() # driver prototypes 60 | driver_lookup_table: Dict[str, BaseDriverMixin] = dict() # driver instances 61 | threads: List[Thread] = list() # all threads that drivers created 62 | 63 | 64 | def register_driver(name: str, driver_prototype): 65 | """ 66 | register a driver class 67 | :param name: driver name, should be global unique 68 | :param driver_prototype: driver constructor 69 | """ 70 | driver_class_lookup_table[name] = driver_prototype 71 | 72 | # endregion 73 | 74 | 75 | # region Driver API for other modules 76 | def driver_lookup(platform: str) -> Union[None, BaseDriverMixin]: 77 | """ 78 | get driver instance 79 | :param platform: platform name (from config) 80 | :return: driver instance 81 | """ 82 | if platform not in driver_lookup_table: 83 | logger.error(f'Base driver "{platform}" not found') 84 | return None 85 | else: 86 | return driver_lookup_table[platform] 87 | 88 | 89 | async def api_call(platform: str, api_name: str, *args, **kwargs): 90 | """ 91 | fast api call 92 | :param platform: driver alias 93 | :param api_name: name of the api 94 | :param args: positional args to pass 95 | :param kwargs: keyword args to pass 96 | :return: None for API not found, api result for successful calling 97 | api result can be any or asyncio.Future, for Future type use result.result() to get the actual result 98 | """ 99 | driver = driver_lookup(platform) 100 | if not driver: 101 | logger.error(f'Due to driver "{platform}" not found, "{api_name}" is ignored') 102 | return 103 | 104 | func = getattr(driver, api_name) 105 | if not func: 106 | return 107 | 108 | if iscoroutinefunction(func): 109 | return await func(*args, **kwargs) 110 | else: 111 | return func(*args, **kwargs) 112 | 113 | # endregion 114 | 115 | 116 | async def __post_init(driver_name): 117 | wait_count = 60 118 | while wait_count > 0: 119 | if driver_lookup_table[driver_name].started: 120 | await driver_lookup_table[driver_name].post_init() 121 | break 122 | await asyncio.sleep(1) 123 | wait_count -= 1 124 | logger.error(f'Waiting for {driver_name} to start timed out, unable to execute post-init') 125 | 126 | 127 | async def init_drivers(): 128 | """ 129 | bring up all the drivers 130 | this function should be called by UMRManager 131 | :return: 132 | """ 133 | config = UMRConfig.config.Driver 134 | 135 | for driver_name, driver_config in config.items(): 136 | if driver_config.Base not in driver_class_lookup_table: 137 | logger.error(f'Base driver "{driver_config.Base}" not found') 138 | exit(-1) 139 | driver: BaseDriverMixin = driver_class_lookup_table[driver_config.Base](driver_name) 140 | driver.start() 141 | driver_lookup_table[driver_name] = driver 142 | 143 | loop = asyncio.get_event_loop() 144 | 145 | for driver_name in config.keys(): 146 | asyncio.run_coroutine_threadsafe(__post_init(driver_name), loop) 147 | 148 | 149 | 150 | 151 | 152 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRExtension.py: -------------------------------------------------------------------------------- 1 | from . import UMRLogging 2 | from . import UMRConfig 3 | from typing import List 4 | import asyncio 5 | 6 | __ALL__ = [ 7 | 'BaseExtension', 8 | 'register_extension', 9 | 'post_init' 10 | ] 11 | 12 | logger = UMRLogging.get_logger('Plugin') 13 | 14 | 15 | class BaseExtension: 16 | def __init__(self): 17 | """ 18 | Pre init logic, registering config validator, etc. 19 | During this stage, only basic config is available, every other function is not up yet. 20 | """ 21 | pass 22 | 23 | async def post_init(self): 24 | """ 25 | Real init logic, complete everything else here 26 | During this stage, the dispatcher and drivers are up and running. Any patch should happen here. 27 | :return: 28 | """ 29 | pass 30 | 31 | 32 | extensions: List[BaseExtension] = [] 33 | 34 | 35 | def register_extension(extension): 36 | """ 37 | Register extension 38 | 39 | :param extension: extension class instances 40 | """ 41 | extensions.append(extension) 42 | 43 | 44 | async def post_init(): 45 | """ 46 | Call every post init method 47 | 48 | """ 49 | if extensions: 50 | await asyncio.wait([i.post_init() for i in extensions]) 51 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRFile.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | import aiohttp 3 | from . import UMRConfig 4 | from PIL import Image 5 | from io import BytesIO 6 | from . import UMRLogging 7 | import os 8 | from uuid import uuid4 9 | import filetype 10 | from lottie.parsers.tgs import parse_tgs 11 | import ffmpy 12 | from wand.image import Image as WandImage 13 | from lottie.exporters.cairo import export_png 14 | from lottie.exporters.gif import _png_gif_prepare 15 | 16 | download_dir = UMRConfig.config.DataRoot 17 | 18 | cache: Dict[str, str] = dict() # Dict[url, file_name] 19 | 20 | logger = UMRLogging.get_logger('FileDL') 21 | 22 | # By default, a image downgrading mapping is hardcoded here 23 | # If more platform is being added, might need to add more conversion mapping 24 | default_target_format = { 25 | 'image/jpeg': 'image/jpeg', 26 | 'image/jpx': 'image/jpeg', 27 | 'image/png': 'image/png', 28 | 'image/gif': 'image/gif', 29 | 'image/webp': 'image/png', 30 | 'image/tiff': 'image/jpeg', 31 | 'image/bmp': 'image/bmp', 32 | 'image/heic': 'image/jpeg', 33 | 34 | 'video/mp4': 'image/gif', 35 | 'video/webm': 'image/gif', 36 | 'application/gzip': 'image/gif' # telegram .tgs sticker 37 | 38 | } 39 | 40 | mime_to_extension = { 41 | 'image/jpeg': '.jpg', 42 | 'image/png': '.png', 43 | 'image/gif': '.gif', 44 | 'image/bmp': '.bmp', 45 | 46 | } 47 | 48 | 49 | async def get_image(url, file_id='', target_format=''): 50 | """ 51 | 52 | :param url: file url, cached for traffic saving 53 | :param file_id: unique file id, will replace cache index if specified (for dynamic urls) 54 | :param target_format: override the default target_format 55 | :return: 56 | """ 57 | try: 58 | if file_id: 59 | if file_id in cache: 60 | logger.debug(f'{file_id} found in cache') 61 | return cache[file_id] 62 | else: 63 | logger.debug(f'{file_id} not found in cache, downloading...') 64 | elif url in cache: 65 | logger.debug(f'{url} found in cache') 66 | return cache[url] 67 | else: 68 | logger.debug(f'{url} not found in cache, downloading...') 69 | async with aiohttp.ClientSession() as session: 70 | async with session.get(url) as response: 71 | logger.debug('download finished') 72 | image = BytesIO(await response.read()) 73 | try: 74 | file_mime = filetype.guess_mime(image) 75 | except TypeError: 76 | logger.exception('Unrecognized image format') 77 | return '' 78 | if not file_mime: 79 | logger.debug(f'filetype did not recognize this format') 80 | return '' 81 | if file_mime not in default_target_format: 82 | logger.error(f'{file_mime} is not supported at this time') 83 | 84 | image.seek(0) # restart from head 85 | target_file_name = str(uuid4()) + mime_to_extension[default_target_format[file_mime]] 86 | file_full_path = os.path.join(download_dir, target_file_name) 87 | 88 | if file_mime == 'application/gzip': # tgs file 89 | convert_tgs_to_gif(image, file_full_path) 90 | elif file_mime == 'video/mp4' or file_mime == 'video/webm': # tg animation 91 | convert_mp4_to_gif(image, file_full_path) 92 | elif file_mime == 'image/webp': 93 | convert_webp_to_png(image, file_full_path) 94 | elif default_target_format[file_mime] == file_mime: 95 | open(file_full_path, 'wb').write(image.read()) 96 | else: 97 | img = Image.open(image) 98 | img = img.convert('RGB') 99 | img.save(file_full_path) 100 | 101 | if file_id: 102 | cache[file_id] = file_full_path 103 | logger.debug(f'{file_id} download complete. Saved as {file_full_path}') 104 | else: 105 | cache[url] = file_full_path 106 | logger.debug(f'{url} download complete. Saved as {file_full_path}') 107 | return file_full_path 108 | except: 109 | logger.exception('Unhandled exception, download aborted') 110 | return '' 111 | 112 | 113 | def convert_mp4_to_gif(mp4_file: [str, BytesIO], gif_file: str): 114 | """ 115 | Reference: http://imageio.readthedocs.io/en/latest/examples.html#convert-a-movie 116 | :param mp4_file: full path or BytesIO object 117 | :param gif_file: full output path 118 | """ 119 | if isinstance(mp4_file, str): 120 | input_file = mp4_file 121 | else: 122 | input_file = '/tmp/' + str(uuid4()) + '.mp4' 123 | f = open(input_file, 'wb') 124 | f.write(mp4_file.read()) 125 | f.close() 126 | 127 | tmp_palettegen_path = '/tmp/' + str(uuid4()) + '.png' 128 | 129 | ff = ffmpy.FFmpeg(inputs={input_file: None}, 130 | outputs={tmp_palettegen_path: '-vf palettegen'}, 131 | global_options=('-y')) 132 | ff.run() 133 | ff = ffmpy.FFmpeg(inputs={input_file: None, tmp_palettegen_path: None}, 134 | outputs={gif_file: '-filter_complex paletteuse'}, 135 | global_options=('-y')) 136 | ff.run() 137 | os.remove(input_file) 138 | os.remove(tmp_palettegen_path) 139 | 140 | 141 | def export_gif(animation, fp, dpi=96, skip_frames=5): 142 | """ 143 | Gif export 144 | 145 | Note that it's a bit slow. 146 | """ 147 | start = int(animation.in_point) 148 | end = int(animation.out_point) 149 | frames = [] 150 | for i in range(start, end+1, skip_frames): 151 | file = BytesIO() 152 | export_png(animation, file, i, dpi) 153 | file.seek(0) 154 | frames.append(_png_gif_prepare(Image.open(file))) 155 | 156 | duration = 1000 / animation.frame_rate * (1 + skip_frames) / 2 157 | frames[0].save( 158 | fp, 159 | format='GIF', 160 | append_images=frames[1:], 161 | save_all=True, 162 | duration=duration, 163 | loop=0, 164 | transparency=255, 165 | disposal=2, 166 | ) 167 | 168 | 169 | def convert_tgs_to_gif(tgs_file: [str, BytesIO], gif_file: str) -> bool: 170 | """ 171 | copied from EH Forwarder Bot 172 | :param tgs_file: full path or BytesIO 173 | :param gif_file: full output path 174 | :return: 175 | """ 176 | # require (libcairo2) 177 | 178 | logger.debug(f"converting tgs to {gif_file}") 179 | # noinspection PyBroadException 180 | try: 181 | animation = parse_tgs(tgs_file) 182 | if animation.frame_rate > 30: 183 | skip_frames = 4 184 | elif animation.frame_rate > 15: 185 | skip_frames = 2 186 | else: 187 | skip_frames = 0 188 | 189 | export_gif(animation, gif_file, skip_frames=skip_frames, dpi=48) 190 | return True 191 | except Exception: 192 | logger.exception("Error occurred while converting TGS to GIF.") 193 | return False 194 | 195 | 196 | def convert_webp_to_png(webp_file: [str, BytesIO], png_file: str) -> bool: 197 | """ 198 | copied from EH Forwarder Bot 199 | :param webp_file: full path or BytesIO 200 | :param png_file: full output path 201 | :return: 202 | """ 203 | if isinstance(webp_file, str): 204 | input_file = webp_file 205 | else: 206 | input_file = '/tmp/' + str(uuid4()) + '.webp' 207 | f = open(input_file, 'wb') 208 | f.write(webp_file.read()) 209 | f.close() 210 | 211 | logger.debug(f"converting webp to {png_file}") 212 | 213 | img = WandImage(filename=input_file) 214 | img.save(filename=png_file) 215 | 216 | os.remove(input_file) 217 | 218 | 219 | def empty_cache_dir(): 220 | """ 221 | run on start up 222 | :return: 223 | """ 224 | file_list = [f for f in os.listdir(download_dir)] 225 | for f in file_list: 226 | os.remove(os.path.join(download_dir, f)) 227 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRLogging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import coloredlogs 3 | import traceback 4 | from logging.handlers import RotatingFileHandler 5 | import os 6 | import pathlib 7 | 8 | 9 | # init coloredlogs 10 | __fmt = '[%(name)s][%(levelname)s] (%(filename)s:%(lineno)d):\n%(message)s\n' 11 | 12 | # get and conf root logger 13 | root_logger: logging.Logger = logging.getLogger('UMR') 14 | coloredlogs.install(fmt=__fmt, level='DEBUG') 15 | 16 | 17 | def __log_except_hook(*exc_info): 18 | # Output unhandled exception 19 | ex_hook_logger = root_logger.getChild('UnknownException') 20 | text = "".join(traceback.format_exception(*exc_info)) 21 | ex_hook_logger.error("Unhandled exception: %s", text) 22 | 23 | 24 | def get_logger(suffix): 25 | return root_logger.getChild(suffix) 26 | 27 | 28 | def post_init(): 29 | # Logger for this module 30 | 31 | logger = root_logger.getChild('Logging') 32 | logger.info('Initializing logging') 33 | 34 | from .UMRConfig import config 35 | 36 | # log level 37 | if '*' in config.LogLevel: 38 | root_logger.setLevel(f"{config.LogLevel['*']}") 39 | for logger_name in config.LogLevel: 40 | if logger_name == '*': 41 | continue 42 | logging.getLogger(logger_name).setLevel(f"{config.LogLevel[logger_name]}") 43 | 44 | # log to file 45 | log_path = config.LogRoot 46 | if log_path.startswith('~'): 47 | home = str(pathlib.Path.home()) 48 | log_path = f'{home}/{config.LogRoot[1:]}' 49 | 50 | # set rotate handler 51 | os.makedirs(log_path, exist_ok=True) # create logging folder 52 | rotate_handler = RotatingFileHandler( 53 | os.path.join(log_path, 'bot.log'), maxBytes=1048576, backupCount=1, encoding='utf-8') 54 | standard_formatter = logging.Formatter( 55 | '[%(asctime)s][%(name)s][%(levelname)s] (%(filename)s:%(lineno)d):\n%(message)s\n') 56 | rotate_handler.setFormatter(standard_formatter) 57 | # __root_logger.addHandler(__rotate_handler) 58 | logging.getLogger().addHandler(rotate_handler) 59 | 60 | logger.info('Initialized logging') 61 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRManager.py: -------------------------------------------------------------------------------- 1 | """ 2 | Controller of the whole program 3 | """ 4 | 5 | from . import UMRLogging 6 | from . import UMRConfig 7 | from . import UMRDispatcher 8 | from . import UMRDriver 9 | from . import UMRExtension 10 | import asyncio 11 | 12 | from time import sleep 13 | logger = UMRLogging.get_logger('Manager') 14 | 15 | 16 | class UMRManager: 17 | @staticmethod 18 | def run(): 19 | try: 20 | # init logging to file 21 | UMRLogging.post_init() 22 | 23 | # init message dispatcher 24 | UMRDispatcher.init_dispatcher() 25 | 26 | # init driver and other extensions 27 | UMRConfig.load_extensions() 28 | 29 | # reload config 30 | UMRConfig.reload_config() 31 | 32 | # init drivers for different platform 33 | asyncio.run(UMRDriver.init_drivers()) 34 | 35 | # init extensions after driver is available 36 | asyncio.run(UMRExtension.post_init()) 37 | 38 | # block main thread 39 | for i in UMRDriver.threads: 40 | i.join() 41 | 42 | except KeyboardInterrupt: 43 | logger.info('Terminating') 44 | exit(0) 45 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRMessageHook.py: -------------------------------------------------------------------------------- 1 | from typing import List, Callable, Union 2 | from .UMRType import MessageHook, ChatType, UnifiedMessage 3 | from . import UMRLogging 4 | 5 | logger = UMRLogging.get_logger('MessageHook') 6 | 7 | message_hook_full: List[MessageHook] = list() # src_driver, src_group, dst_driver, dst_group, hook 8 | message_hook_src: List[MessageHook] = list() 9 | 10 | 11 | def register_hook(src_driver: Union[str, List[str]] = '', src_chat: Union[int, str, List[Union[int, str]]] = 0, 12 | src_chat_type: Union[ChatType, List[ChatType]] = ChatType.UNSPECIFIED, 13 | dst_driver: Union[str, List[str]] = '', dst_chat: Union[int, str, List[Union[int, str]]] = 0, 14 | dst_chat_type: Union[ChatType, List[ChatType]] = ChatType.UNSPECIFIED) -> Callable: 15 | """ 16 | message hook registration 17 | 18 | :param src_driver: driver name, not the name of platform that is specified in config.yaml 19 | :param src_chat: chat id 20 | :param src_chat_type: chat type 21 | :param dst_driver: driver name 22 | :param dst_chat: chat id 23 | :param dst_chat_type: chat type 24 | :return: decorator 25 | """ 26 | 27 | def deco(original_func): 28 | if not dst_chat and not dst_driver: 29 | message_hook_src.append(MessageHook(src_driver, src_chat, src_chat_type, dst_driver, dst_chat, dst_chat_type, original_func)) 30 | else: 31 | message_hook_full.append(MessageHook(src_driver, src_chat, src_chat_type, dst_driver, dst_chat, dst_chat_type, original_func)) 32 | return original_func 33 | 34 | return deco 35 | 36 | 37 | async def dispatch_hook(message: UnifiedMessage, 38 | dst_driver: Union[str, List[str]] = '', dst_chat: Union[int, str, List[Union[int, str]]] = 0, 39 | dst_chat_type: Union[ChatType, List[ChatType]] = ChatType.UNSPECIFIED): 40 | 41 | if not dst_driver and not dst_chat and dst_chat_type == ChatType.UNSPECIFIED: 42 | # hook for matching source only 43 | for hook in message_hook_src: 44 | if (not hook.src_driver or message.chat_attrs.platform in hook.src_driver) and \ 45 | (ChatType.UNSPECIFIED in hook.src_chat_type or message.chat_attrs.chat_type in hook.src_chat_type) and \ 46 | (not hook.src_chat or message.chat_attrs.chat_id in hook.src_chat): 47 | if await hook.hook_function(message): 48 | return True 49 | else: 50 | # hook for matching all four attributes 51 | for hook in message_hook_full: 52 | if (not hook.src_driver or message.chat_attrs.platform in hook.src_driver) and \ 53 | (not hook.src_chat or message.chat_attrs.chat_id in hook.src_chat) and \ 54 | (ChatType.UNSPECIFIED in hook.src_chat_type or message.chat_attrs.chat_type in hook.src_chat_type) and \ 55 | (not hook.dst_driver or dst_driver in hook.dst_driver) and \ 56 | (ChatType.UNSPECIFIED in hook.dst_chat_type or dst_chat_type in hook.src_chat_type) and \ 57 | (not hook.dst_chat or dst_chat in hook.dst_chat): 58 | if await hook.hook_function(dst_driver, dst_chat, dst_chat_type, message): 59 | return True 60 | 61 | return False 62 | 63 | # There are two types of hook definitions, see MessageHook.md 64 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRMessageRelation.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | from typing import List, Dict, Union 3 | from . import UMRLogging 4 | from .UMRType import GroupID, MessageID, DestinationMessageID, ChatType 5 | 6 | logger = UMRLogging.get_logger('MessageRelation') 7 | 8 | 9 | class FIFODict(OrderedDict): 10 | def __init__(self, capacity): 11 | super().__init__() 12 | self._capacity = capacity # cache size 13 | 14 | def __setitem__(self, key, value): 15 | contains_key = key in self 16 | if len(self) - int(contains_key) >= self._capacity: 17 | self.popitem(last=False) # pop the first element if full 18 | OrderedDict.__setitem__(self, key, value) 19 | 20 | 21 | message_mapping = FIFODict(4096) # at least twice as large as message_tuple 22 | 23 | 24 | def set_ingress_message_id(src_platform: str, src_chat_id: Union[int, str], src_chat_type: ChatType, src_message_id: int, user_id): 25 | """ 26 | Register related message id (for message received by bot) 27 | :param src_chat_type: 28 | :param src_platform: 29 | :param src_chat_id: 30 | :param src_message_id: 31 | :param user_id: 32 | :return: 33 | """ 34 | saved_msg_id = {GroupID(platform=src_platform, chat_id=src_chat_id, chat_type=src_chat_type): DestinationMessageID( 35 | platform=src_platform, 36 | chat_id=src_chat_id, 37 | chat_type=src_chat_type, 38 | message_id=src_message_id, 39 | user_id=user_id)} 40 | message_mapping[MessageID(platform=src_platform, chat_id=src_chat_id, chat_type=src_chat_type, message_id=src_message_id)] = saved_msg_id 41 | 42 | 43 | def set_egress_message_id(src_platform: str, src_chat_id: Union[int, str], src_message_id: Union[int, str], src_chat_type: ChatType, 44 | dst_platform: str, dst_chat_id: Union[int, str], dst_message_id: int, dst_chat_type: ChatType, user_id: int): 45 | """ 46 | Register related message id (for message sent by bot) 47 | :param src_chat_type: 48 | :param dst_chat_type: 49 | :param src_platform: 50 | :param src_chat_id: 51 | :param src_message_id: 52 | :param dst_platform: 53 | :param dst_chat_id: 54 | :param dst_message_id: 55 | :param user_id: 56 | :return: 57 | """ 58 | saved_msg_id = message_mapping.get(MessageID(platform=src_platform, 59 | chat_id=src_chat_id, chat_type=src_chat_type, message_id=src_message_id), dict()) 60 | 61 | if not saved_msg_id: # message relation not found 62 | return 63 | 64 | source = saved_msg_id.get(GroupID(platform=src_platform, chat_id=src_chat_id, chat_type=src_chat_type)) 65 | 66 | dst_msg_id = DestinationMessageID(platform=dst_platform, chat_id=dst_chat_id, chat_type=dst_chat_type, 67 | message_id=dst_message_id, user_id=user_id, source=source) 68 | saved_msg_id[GroupID(platform=dst_platform, chat_id=dst_chat_id, chat_type=dst_chat_type)] = dst_msg_id 69 | message_mapping[MessageID(platform=dst_platform, chat_id=dst_chat_id, chat_type=dst_chat_type, message_id=dst_message_id)] = saved_msg_id 70 | 71 | 72 | def get_message_id(src_platform: str, src_chat_id: Union[int, str], src_chat_type: ChatType, src_message_id: int, dst_platform: str, dst_chat_id: Union[int, str], dst_chat_type: ChatType) \ 73 | -> DestinationMessageID: 74 | """ 75 | 76 | :param dst_chat_type: 77 | :param src_chat_type: 78 | :param src_platform: 79 | :param src_chat_id: 80 | :param src_message_id: 81 | :param dst_platform: 82 | :param dst_chat_id: 83 | :return: tuple of user_id, message_id 84 | """ 85 | return message_mapping.get(MessageID(platform=src_platform, chat_id=src_chat_id, chat_type=src_chat_type, message_id=src_message_id), 86 | dict()).get(GroupID(platform=dst_platform, chat_id=dst_chat_id, chat_type=dst_chat_type)) 87 | 88 | 89 | def get_relation_dict(src_platform: str, src_chat_id: Union[int, str], src_chat_type: ChatType, message_id: int) -> Dict[MessageID, DestinationMessageID]: 90 | return message_mapping.get(MessageID(platform=src_platform, chat_id=src_chat_id, chat_type=src_chat_type, message_id=message_id), 91 | dict()) 92 | -------------------------------------------------------------------------------- /unified_message_relay/Core/UMRType.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from dataclasses import dataclass 3 | from typing import List, Callable, FrozenSet, Union 4 | from enum import Enum, auto, Flag 5 | 6 | 7 | class LogLevel(str, Enum): 8 | ERROR = 'ERROR' 9 | WARNING = 'WARNING' 10 | INFO = 'INFO' 11 | DEBUG = 'DEBUG' 12 | 13 | 14 | class ChatType(str, Enum): 15 | """ 16 | Command filter option 17 | """ 18 | UNSPECIFIED = 'unspecified' 19 | PRIVATE = 'private' 20 | DISCUSS = 'discuss' 21 | GROUP = 'group' 22 | 23 | 24 | class ForwardTypeEnum(str, Enum): 25 | OneWay = 'OneWay' 26 | OneWayPlus = 'OneWay+' 27 | BiDirection = 'BiDirection' 28 | 29 | 30 | class DefaultForwardTypeEnum(str, Enum): 31 | OneWay = 'OneWay' 32 | OneWayPlus = 'OneWay+' 33 | 34 | 35 | class Privilege(str, Enum): 36 | """ 37 | Command filter option 38 | The privilege of lower number always contain the privilege of the higher number 39 | """ 40 | UNSPECIFIED = 'unspecified' # permit all, available everywhere 41 | GROUP_ADMIN = 'group_admin' # only available in group 42 | GROUP_OWNER = 'group_owner' # only available in group 43 | BOT_ADMIN = 'bot_admin' # only bot admin, available everywhere 44 | 45 | 46 | class ChatAttribute: 47 | """ 48 | Part of UnifiedMessage 49 | Attributes for every received message. Recursive attributes exist for some platform. 50 | """ 51 | def __init__(self, platform: str = '', chat_id: Union[int, str] = 0, chat_type: ChatType = ChatType.UNSPECIFIED, 52 | name: str = '', user_id: Union[int, str] = 0, message_id: int = 0): 53 | self.platform = platform 54 | self.chat_id = chat_id 55 | self.chat_type = chat_type 56 | self.name = name 57 | self.user_id = user_id 58 | self.message_id = message_id 59 | self.forward_from: Union[None, ChatAttribute] = None 60 | self.reply_to: Union[None, ChatAttribute] = None 61 | 62 | def __bool__(self): 63 | return self.platform is not None 64 | 65 | 66 | class EntityType(Flag): 67 | """ 68 | Each message entity in UnifiedMessage should be monolithic 69 | Only EitityType in unparse_* should be multi-valued 70 | """ 71 | PLAIN = auto() 72 | BOLD = auto() 73 | ITALIC = auto() 74 | CODE = auto() 75 | CODE_BLOCK = auto() 76 | UNDERLINE = auto() 77 | STRIKETHROUGH = auto() 78 | QUOTE = auto() 79 | QUOTE_BLOCK = auto() 80 | LINK = auto() 81 | 82 | 83 | @dataclass 84 | class MessageEntity: 85 | """ 86 | Part of UnifiedMessage 87 | Text segments with entity types 88 | """ 89 | start: int 90 | end: int 91 | entity_type: EntityType 92 | link: str 93 | 94 | def __init__(self, start, end, entity_type=EntityType.PLAIN, link=''): 95 | self.start = start 96 | self.end = end 97 | self.entity_type = entity_type 98 | self.link = link 99 | 100 | 101 | @dataclass 102 | class SendAction: 103 | """ 104 | Part of UnifiedMessage 105 | Currently the action only supports reply to a message or user 106 | """ 107 | message_id: int 108 | user_id: int 109 | 110 | 111 | @dataclass 112 | class UnifiedMessage: 113 | """ 114 | message: List of MessageEntity 115 | e.g. 116 | [ 117 | ('this is text', 'bold'), 118 | ('this is another text', 'italic'), 119 | ('this is another text', 'monospace'), 120 | ('this is another text', 'underline'), 121 | ('this is another text', 'strikethrough'), 122 | ('http://..', 'link', 'title of the link (optional)') 123 | 124 | ] 125 | """ 126 | chat_attrs: ChatAttribute 127 | text: str # pure text message 128 | text_entities: List[MessageEntity] 129 | image: str # path of the image or download url 130 | file_id: str # unique file identifier 131 | send_action: SendAction 132 | 133 | def __init__(self, text: str = '', message_entities=None, image='', file_id='', platform='', chat_id=0, chat_type=ChatType.UNSPECIFIED, 134 | name='', user_id=0, message_id: int = 0): 135 | self.send_action = SendAction(0, 0) 136 | self.text = text 137 | if message_entities: 138 | self.text_entities = message_entities 139 | else: 140 | self.text_entities = list() 141 | self.image = image 142 | self.file_id = file_id 143 | self.chat_attrs = ChatAttribute(platform=platform, 144 | chat_id=chat_id, 145 | chat_type=chat_type, 146 | name=name, 147 | user_id=user_id, 148 | message_id=message_id) 149 | 150 | 151 | @dataclass 152 | class PrivilegeAttributes: 153 | """ 154 | Currently not used in any part of the code 155 | """ 156 | is_admin: bool 157 | is_owner: bool 158 | 159 | 160 | @dataclass 161 | class ControlMessage: 162 | """ 163 | Currently not used in any part of the code 164 | """ 165 | prompt: str 166 | answers: List[str] # use empty list for open questions 167 | privilege_attrs: PrivilegeAttributes # privilege required 168 | identifier: int # id to match response with prompt 169 | 170 | def __init__(self, prompt=None, answers=None, is_admin=None, is_owner=False, identifier=-1): 171 | if answers is None: 172 | answers = list() 173 | self.prompt = prompt 174 | self.answers = answers 175 | self.privilege_attrs = PrivilegeAttributes(is_admin, is_owner) 176 | self.identifier = identifier 177 | 178 | 179 | class ForwardActionType(Enum): 180 | """ 181 | Dispatch filter, filters message reply attribute 182 | """ 183 | ForwardAll = 1 # message can go to the other side 184 | ReplyOnly = 2 # message that replies to forwarded message can go to the other side 185 | Block = 3 # message that cannot go to the other side (one way) 186 | 187 | 188 | class DefaultForwardActionType(Enum): 189 | """ 190 | Dispatch filter, filters message reply attribute 191 | """ 192 | OneWay = 1 # aggregate message only, no backward 193 | OneWayWithReply = 2 # aggregate message and allow backward 194 | 195 | 196 | @dataclass 197 | class ForwardAction: 198 | """ 199 | Dispatch action, final action for matching message 200 | """ 201 | to_platform: str 202 | to_chat: Union[int, str] 203 | chat_type: ChatType 204 | action_type: ForwardActionType # All, Reply 205 | 206 | 207 | @dataclass 208 | class DefaultForwardAction: 209 | """ 210 | Dispatch action, final action for matching message 211 | """ 212 | to_platform: str 213 | to_chat: Union[int, str] 214 | chat_type: ChatType 215 | action_type: DefaultForwardActionType # All, Reply 216 | 217 | 218 | @dataclass 219 | class MessageHook: 220 | """ 221 | Message Hook parameters 222 | """ 223 | src_driver: FrozenSet[str] 224 | src_chat: FrozenSet[int] 225 | src_chat_type: FrozenSet[ChatType] 226 | dst_driver: FrozenSet[str] 227 | dst_chat: FrozenSet[int] 228 | dst_chat_type: FrozenSet[ChatType] 229 | hook_function: Callable 230 | 231 | def __init__(self, src_driver: Union[str, List[str]], src_chat: Union[int, List[int]], src_chat_type: Union[ChatType, List[ChatType]], 232 | dst_driver: Union[str, List[str]], dst_chat: Union[int, List[int]], dst_chat_type: Union[ChatType, List[ChatType]], hook_function: Callable): 233 | if isinstance(src_driver, str): 234 | if src_driver: 235 | self.src_driver = frozenset([src_driver]) 236 | else: 237 | self.src_driver = frozenset() 238 | else: 239 | self.src_driver = frozenset(src_driver) 240 | if isinstance(src_chat, int): 241 | if src_chat: 242 | self.src_chat = frozenset([src_chat]) 243 | else: 244 | self.src_chat = frozenset() 245 | else: 246 | self.src_chat = frozenset(src_chat) 247 | if isinstance(src_chat_type, ChatType): 248 | self.src_chat_type = frozenset([src_chat_type]) 249 | else: 250 | self.src_chat_type = frozenset(src_chat_type) 251 | if isinstance(dst_driver, str): 252 | if dst_driver: 253 | self.dst_driver = frozenset([dst_driver]) 254 | else: 255 | self.dst_driver = frozenset() 256 | else: 257 | self.dst_driver = frozenset(dst_driver) 258 | if isinstance(dst_chat, int): 259 | if dst_chat: 260 | self.dst_chat = frozenset([dst_chat]) 261 | else: 262 | self.dst_chat = frozenset() 263 | else: 264 | self.dst_chat = frozenset(dst_chat) 265 | if isinstance(dst_chat_type, ChatType): 266 | self.dst_chat_type = frozenset([dst_chat_type]) 267 | else: 268 | self.dst_chat_type = frozenset(dst_chat_type) 269 | self.hook_function = hook_function 270 | 271 | 272 | @dataclass 273 | class Command: 274 | """ 275 | Command parameters 276 | """ 277 | platform: FrozenSet[str] 278 | description: str 279 | privilege: Privilege 280 | chat_type: ChatType 281 | command_function: Callable 282 | 283 | def __init__(self, platform: Union[str, List[str]] = '', description='', chat_type=ChatType.UNSPECIFIED, 284 | privilege=Privilege.UNSPECIFIED, command_function=None): 285 | if isinstance(platform, str): 286 | if platform: 287 | self.platform = frozenset([platform]) 288 | else: 289 | self.platform = frozenset() 290 | else: 291 | self.platform = frozenset(platform) 292 | self.description = description 293 | self.chat_type = chat_type 294 | self.privilege = privilege 295 | self.command_function = command_function 296 | 297 | 298 | @dataclass(frozen=True) 299 | class GroupID: 300 | """ 301 | Used in MessageRelation 302 | """ 303 | platform: str 304 | chat_type: ChatType 305 | chat_id: Union[int, str] 306 | 307 | 308 | @dataclass(frozen=True) 309 | class MessageID: 310 | """ 311 | Used in MessageRelation 312 | """ 313 | platform: str 314 | chat_id: Union[int, str] 315 | chat_type: ChatType 316 | message_id: int 317 | 318 | 319 | @dataclass 320 | class DestinationMessageID: 321 | """ 322 | Used in MessageRelation 323 | """ 324 | platform: str = '' 325 | chat_id: Union[int, str] = 0 326 | chat_type: ChatType = ChatType.UNSPECIFIED 327 | message_id: int = 0 328 | user_id: int = 0 329 | source: DestinationMessageID = None 330 | 331 | -------------------------------------------------------------------------------- /unified_message_relay/Core/__init__.py: -------------------------------------------------------------------------------- 1 | # leave blank to avoid unexpected dependency loop 2 | -------------------------------------------------------------------------------- /unified_message_relay/Lib/DaemonClass/__init__.py: -------------------------------------------------------------------------------- 1 | """Generic linux daemon base class for python 3.x.""" 2 | """From https://web.archive.org/web/20160305151936/http://www.jejik.com/articles/2007/02/a_simple_unix_linux_daemon_in_python/""" 3 | import sys, os, time, atexit, signal 4 | 5 | 6 | class Daemon: 7 | """A generic daemon class. 8 | 9 | Usage: subclass the daemon class and override the run() method.""" 10 | 11 | def __init__(self, pidfile): 12 | self.pidfile = pidfile 13 | signal.signal(signal.SIGTERM, self.stop) 14 | 15 | def daemonize(self): 16 | """Deamonize class. UNIX double fork mechanism.""" 17 | 18 | try: 19 | pid = os.fork() 20 | if pid > 0: 21 | # exit first parent 22 | sys.exit(0) 23 | except OSError as err: 24 | sys.stderr.write('fork #1 failed: {0}\n'.format(err)) 25 | sys.exit(1) 26 | 27 | # decouple from parent environment 28 | # os.chdir('/') 29 | os.setsid() 30 | os.umask(0) 31 | 32 | # do second fork 33 | try: 34 | pid = os.fork() 35 | if pid > 0: 36 | # exit from second parent 37 | sys.exit(0) 38 | except OSError as err: 39 | sys.stderr.write('fork #2 failed: {0}\n'.format(err)) 40 | sys.exit(1) 41 | 42 | # redirect standard file descriptors 43 | sys.stdout.flush() 44 | sys.stderr.flush() 45 | si = open(os.devnull, 'r') 46 | so = open(os.devnull, 'a+') 47 | se = open(os.devnull, 'a+') 48 | 49 | os.dup2(si.fileno(), sys.stdin.fileno()) 50 | os.dup2(so.fileno(), sys.stdout.fileno()) 51 | os.dup2(se.fileno(), sys.stderr.fileno()) 52 | 53 | # write pidfile 54 | atexit.register(self.delpid) 55 | 56 | pid = str(os.getpid()) 57 | with open(self.pidfile, 'w+') as f: 58 | f.write(pid + '\n') 59 | 60 | def delpid(self): 61 | os.remove(self.pidfile) 62 | 63 | def start(self, *args, **kwargs): 64 | """Start the daemon.""" 65 | 66 | # Check for a pidfile to see if the daemon already runs 67 | try: 68 | with open(self.pidfile, 'r') as pf: 69 | 70 | pid = int(pf.read().strip()) 71 | except IOError: 72 | pid = None 73 | 74 | if pid: 75 | message = "pidfile {0} already exist. " + \ 76 | "Daemon already running?\n" 77 | sys.stderr.write(message.format(self.pidfile)) 78 | sys.exit(1) 79 | 80 | # Start the daemon 81 | self.daemonize() 82 | self.run(*args, **kwargs) 83 | 84 | def stop(self): 85 | """Stop the daemon.""" 86 | 87 | # Get the pid from the pidfile 88 | try: 89 | with open(self.pidfile, 'r') as pf: 90 | pid = int(pf.read().strip()) 91 | except IOError: 92 | pid = None 93 | 94 | if not pid: 95 | message = "pidfile {0} does not exist. " + \ 96 | "Daemon not running?\n" 97 | sys.stderr.write(message.format(self.pidfile)) 98 | return # not an error in a restart 99 | 100 | # Try killing the daemon process 101 | try: 102 | while 1: 103 | os.kill(pid, signal.SIGTERM) 104 | time.sleep(0.1) 105 | except OSError as err: 106 | e = str(err.args) 107 | if e.find("No such process") > 0: 108 | if os.path.exists(self.pidfile): 109 | os.remove(self.pidfile) 110 | else: 111 | print(str(err.args)) 112 | sys.exit(1) 113 | 114 | def restart(self, *args, **kwargs): 115 | """Restart the daemon.""" 116 | self.stop() 117 | self.start(*args, **kwargs) 118 | 119 | def run(self, *args, **kwargs): 120 | """You should override this method when you subclass Daemon. 121 | 122 | It will be called after the process has been daemonized by 123 | start() or restart().""" 124 | -------------------------------------------------------------------------------- /unified_message_relay/Lib/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = ['DaemonClass'] 2 | -------------------------------------------------------------------------------- /unified_message_relay/Util/Helper.py: -------------------------------------------------------------------------------- 1 | from typing import List, Union, Dict, Callable, Any, Tuple 2 | import logging 3 | from janus import Queue 4 | from ..Core.UMRType import UnifiedMessage, EntityType, MessageEntity 5 | from ..Core.UMRLogging import get_logger 6 | from functools import partial 7 | 8 | logger = get_logger('Util.Helper') 9 | 10 | 11 | # async put new task to janus queue 12 | async def janus_queue_put_async(_janus_queue: Queue, func: Callable, *args, **kwargs): 13 | await _janus_queue.async_q.put((func, args, kwargs)) 14 | 15 | 16 | # sync put new task to janus queue 17 | def janus_queue_put_sync(_janus_queue: Queue, func: Callable, *args, **kwargs): 18 | _janus_queue.sync_q.put((func, args, kwargs)) 19 | 20 | 21 | def escape_markdown(text: str): 22 | """ 23 | return text escaped given entity types 24 | :param text: text to escape 25 | :param entity_type: entities to escape 26 | :return: 27 | """ 28 | # de-escape and escape again to avoid double escaping 29 | return text.replace('\\*', '*').replace('\\`', '`').replace('\\_', '_')\ 30 | .replace('\\~', '~').replace('\\>', '>').replace('\\[', '[')\ 31 | .replace('\\]', ']').replace('\\(', '(').replace('\\)', ')')\ 32 | .replace('*', '\\*').replace('`', '\\`').replace('_', '\\_')\ 33 | .replace('~', '\\~').replace('>', '\\>').replace('[', '\\[')\ 34 | .replace(']', '\\]').replace('(', '\\(').replace(')', '\\)') 35 | 36 | 37 | def escape_html(text: str): 38 | """ 39 | return escaped text 40 | :param text: text to escape 41 | :return: 42 | """ 43 | return text.replace('<', '<').replace('>', '>') 44 | 45 | 46 | def unparse_entities(message: UnifiedMessage, support_entities: EntityType, to_type='html'): 47 | """ 48 | parse plain text with entities to html 49 | :param message: 50 | :param support_entities: 51 | :return: html text 52 | """ 53 | if to_type == 'html': 54 | escape_function = escape_html 55 | entity_start = { 56 | EntityType.PLAIN: '', 57 | EntityType.BOLD: '', 58 | EntityType.ITALIC: '', 59 | EntityType.CODE: '', 60 | EntityType.CODE_BLOCK: '
',
 61 |             EntityType.UNDERLINE:     '',
 62 |             EntityType.STRIKETHROUGH: '',
 63 |             EntityType.QUOTE:         '',
 64 |             EntityType.QUOTE_BLOCK:   '',
 65 |             EntityType.LINK:          '',
 66 |         }
 67 | 
 68 |         entity_end = {
 69 |             EntityType.PLAIN:         '',
 70 |             EntityType.BOLD:          '',
 71 |             EntityType.ITALIC:        '',
 72 |             EntityType.CODE:          '',
 73 |             EntityType.CODE_BLOCK:    '
', 74 | EntityType.UNDERLINE: '', 75 | EntityType.STRIKETHROUGH: '', 76 | EntityType.QUOTE: '', 77 | EntityType.QUOTE_BLOCK: '', 78 | EntityType.LINK: '', 79 | } 80 | else: 81 | entity_start = { 82 | EntityType.PLAIN: '', 83 | EntityType.BOLD: '**\u200b', 84 | EntityType.ITALIC: '*\u200b', 85 | EntityType.CODE: '`\u200b', 86 | EntityType.CODE_BLOCK: '```\u200b', 87 | EntityType.UNDERLINE: '__\u200b', 88 | EntityType.STRIKETHROUGH: '~~\u200b', 89 | EntityType.QUOTE: '> \u200b', 90 | EntityType.QUOTE_BLOCK: '>>> \u200b', 91 | EntityType.LINK: '[Link]({}) ', 92 | } 93 | 94 | entity_end = { 95 | EntityType.PLAIN: '', 96 | EntityType.BOLD: '**\u200b', 97 | EntityType.ITALIC: '*\u200b', 98 | EntityType.CODE: '`\u200b', 99 | EntityType.CODE_BLOCK: '```\u200b', 100 | EntityType.UNDERLINE: '__\u200b', 101 | EntityType.STRIKETHROUGH: '~~\u200b', 102 | EntityType.QUOTE: '', 103 | EntityType.QUOTE_BLOCK: '', 104 | EntityType.LINK: '', 105 | } 106 | escape_function = escape_markdown 107 | 108 | if not message.text_entities: 109 | return escape_function(message.text) 110 | 111 | stack: List[MessageEntity] = list() 112 | result = '' 113 | offset = 0 114 | for entity in message.text_entities: 115 | while stack and entity.start > stack[-1].end: 116 | _entity = stack[-1] 117 | if offset < _entity.end: 118 | result += escape_function(message.text[offset:_entity.end]) 119 | if support_entities & _entity.entity_type: 120 | result += entity_end[_entity.entity_type] 121 | offset = _entity.end 122 | stack.pop() 123 | 124 | if entity.start > offset: 125 | result += escape_function(message.text[offset:entity.start]) 126 | offset = entity.start 127 | 128 | if entity.entity_type == EntityType.LINK: 129 | if support_entities & entity.entity_type: 130 | result += entity_start[entity.entity_type].format(entity.link) 131 | else: 132 | result += f'link: {entity.link} title: ' 133 | else: 134 | if support_entities & entity.entity_type: 135 | result += entity_start[entity.entity_type] 136 | stack.append(entity) 137 | 138 | while stack: 139 | _entity = stack[-1] 140 | if offset < _entity.end: 141 | result += escape_function(message.text[offset:_entity.end]) 142 | if support_entities & _entity.entity_type: 143 | result += entity_end[_entity.entity_type] 144 | offset = _entity.end 145 | stack.pop() 146 | 147 | if offset < len(message.text): 148 | result += escape_function(message.text[offset:]) 149 | 150 | return result 151 | 152 | 153 | def unparse_entities_to_html(message: UnifiedMessage, support_entities: EntityType): 154 | """ 155 | parse plain text with entities to html 156 | :param message: 157 | :param support_entities: 158 | :return: 159 | """ 160 | return unparse_entities(message, support_entities, to_type='html') 161 | 162 | 163 | def unparse_entities_to_markdown(message: UnifiedMessage, support_entities: EntityType): 164 | """ 165 | parse plain text with entities to markdown 166 | :param message: 167 | :param support_entities: 168 | :return: 169 | """ 170 | return unparse_entities(message, support_entities, to_type='markdown') 171 | -------------------------------------------------------------------------------- /unified_message_relay/Util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JQ-Networks/UnifiedMessageRelay/164af3929dd2f2df6debd72df9cf4be5af005004/unified_message_relay/Util/__init__.py -------------------------------------------------------------------------------- /unified_message_relay/__init__.py: -------------------------------------------------------------------------------- 1 | from . import Core 2 | from . import Lib 3 | from . import Util 4 | from . import daemon 5 | __VERSION__ = '4.2' 6 | -------------------------------------------------------------------------------- /unified_message_relay/daemon.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import argparse 4 | from .Lib.DaemonClass import Daemon 5 | 6 | 7 | class MainProcess(Daemon): 8 | def run(self, debug_mode): 9 | from .Core.UMRManager import UMRManager 10 | UMRManager.run() 11 | 12 | 13 | def main(): 14 | # ARGS 15 | argP = argparse.ArgumentParser( 16 | description="QQ <-> Telegram Bot Framework & Forwarder", formatter_class=argparse.RawTextHelpFormatter) 17 | cmdHelpStr = """ 18 | start - start bot as a daemon 19 | stop - stop bot 20 | restart - restart bot 21 | run - run as foreground Debug mode. every log will print to screen and log to file. 22 | """ 23 | argP.add_argument("command", type=str, action="store", 24 | choices=['start', 'stop', 'restart', 'run'], help=cmdHelpStr) 25 | daemon = MainProcess('/tmp/coolq-telegram-bot.pid') 26 | args = argP.parse_args() 27 | if args.command == 'start': 28 | daemon.start(debug_mode=True) 29 | elif args.command == 'stop': 30 | daemon.stop() 31 | elif args.command == 'restart': 32 | daemon.restart(debug_mode=True) 33 | elif args.command == 'run': 34 | # Run as foreground mode 35 | daemon.run(debug_mode=True) 36 | 37 | 38 | if __name__ == '__main__': 39 | main() 40 | -------------------------------------------------------------------------------- /unified_message_relay/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JQ-Networks/UnifiedMessageRelay/164af3929dd2f2df6debd72df9cf4be5af005004/unified_message_relay/test/__init__.py --------------------------------------------------------------------------------