├── .dockerignore ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug.md │ ├── config.yml │ └── enhancement.md └── workflows │ └── python-lint.yml ├── .gitignore ├── .gitlab-ci.yml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── ROADMAP.md ├── dev-requirements.txt ├── docker-run.sh ├── mautrix_telegram ├── __init__.py ├── __main__.py ├── abstract_user.py ├── bot.py ├── commands │ ├── __init__.py │ ├── handler.py │ ├── matrix_auth.py │ ├── portal │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── bridge.py │ │ ├── config.py │ │ ├── create_chat.py │ │ ├── filter.py │ │ ├── misc.py │ │ ├── unbridge.py │ │ └── util.py │ └── telegram │ │ ├── __init__.py │ │ ├── account.py │ │ ├── auth.py │ │ └── misc.py ├── config.py ├── db │ ├── __init__.py │ ├── backfill_queue.py │ ├── bot_chat.py │ ├── disappearing_message.py │ ├── message.py │ ├── portal.py │ ├── puppet.py │ ├── reaction.py │ ├── telegram_file.py │ ├── telethon_session.py │ ├── upgrade │ │ ├── __init__.py │ │ ├── v00_latest_revision.py │ │ ├── v01_initial_revision.py │ │ ├── v02_sponsored_events.py │ │ ├── v03_reactions.py │ │ ├── v04_disappearing_messages.py │ │ ├── v05_channel_ghosts.py │ │ ├── v06_puppet_avatar_url.py │ │ ├── v07_puppet_phone_number.py │ │ ├── v08_portal_first_event.py │ │ ├── v09_puppet_username_index.py │ │ ├── v10_more_backfill_fields.py │ │ ├── v11_backfill_queue.py │ │ ├── v12_message_sender.py │ │ ├── v13_multiple_reactions.py │ │ ├── v14_puppet_custom_mxid_index.py │ │ ├── v15_backfill_anchor_id.py │ │ ├── v16_backfill_type.py │ │ ├── v17_message_find_recent.py │ │ └── v18_puppet_contact_info_set.py │ └── user.py ├── example-config.yaml ├── formatter │ ├── __init__.py │ ├── from_matrix │ │ ├── __init__.py │ │ ├── parser.py │ │ └── telegram_message.py │ └── from_telegram.py ├── get_version.py ├── matrix.py ├── portal.py ├── portal_util │ ├── __init__.py │ ├── deduplication.py │ ├── message_convert.py │ ├── participants.py │ ├── power_levels.py │ ├── send_lock.py │ └── sponsored_message.py ├── puppet.py ├── scripts │ ├── __init__.py │ └── unicodemojipack │ │ ├── __init__.py │ │ └── __main__.py ├── tgclient.py ├── types.py ├── unicodemojipack.json ├── unicodemojipack.pickle ├── user.py ├── util │ ├── __init__.py │ ├── color_log.py │ ├── file_transfer.py │ ├── parallel_file_transfer.py │ ├── recursive_dict.py │ ├── sane_mimetypes.py │ ├── tgs_converter.py │ ├── tl_json.py │ └── webm_converter.py ├── version.py └── web │ ├── __init__.py │ ├── common │ ├── __init__.py │ └── auth_api.py │ ├── provisioning │ ├── __init__.py │ └── spec.yaml │ └── public │ ├── __init__.py │ ├── favicon.png │ ├── login.css │ ├── login.html.mako │ └── matrix-login.html.mako ├── optional-requirements.txt ├── preview.png ├── pyproject.toml ├── requirements.txt └── setup.py /.dockerignore: -------------------------------------------------------------------------------- 1 | .editorconfig 2 | .codeclimate.yml 3 | *.png 4 | *.md 5 | logs 6 | .venv 7 | start 8 | config.yaml 9 | registration.yaml 10 | *.db 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.py] 15 | max_line_length = 99 16 | 17 | [*.{yaml,yml,py}] 18 | indent_style = space 19 | 20 | [{.gitlab-ci.yml,.pre-commit-config.yaml,mautrix_telegram/web/provisioning/spec.yaml}] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: If something is definitely wrong in the bridge (rather than just a setup issue), 4 | file a bug report. Remember to include relevant logs. Asking in the Matrix room first 5 | is strongly recommended. 6 | type: Bug 7 | 8 | --- 9 | 10 | 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: Troubleshooting docs & FAQ 3 | url: https://docs.mau.fi/bridges/general/troubleshooting.html 4 | about: Check this first if you're having problems setting up the bridge. 5 | - name: Support room 6 | url: https://matrix.to/#/#telegram:maunium.net 7 | about: For setup issues not answered by the troubleshooting docs, ask in the Matrix room. 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Enhancement request 3 | about: Submit a feature request or other suggestion 4 | type: Feature 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /.github/workflows/python-lint.yml: -------------------------------------------------------------------------------- 1 | name: Python lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-python@v5 11 | with: 12 | python-version: "3.12" 13 | - uses: isort/isort-action@master 14 | with: 15 | sortPaths: "./mautrix_telegram" 16 | - uses: psf/black@stable 17 | with: 18 | src: "./mautrix_telegram" 19 | version: "24.1.1" 20 | - name: pre-commit 21 | run: | 22 | pip install pre-commit 23 | pre-commit run -av trailing-whitespace 24 | pre-commit run -av end-of-file-fixer 25 | pre-commit run -av check-yaml 26 | pre-commit run -av check-added-large-files 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | 3 | /.venv 4 | /env/ 5 | pip-selfcheck.json 6 | *.pyc 7 | __pycache__ 8 | /build 9 | /dist 10 | /*.egg-info 11 | /.eggs 12 | 13 | *.yaml 14 | !.pre-commit-config.yaml 15 | !example-config.yaml 16 | !/mautrix_telegram/web/provisioning/spec.yaml 17 | !/.github/workflows/*.yaml 18 | 19 | /start 20 | /mautrix 21 | /telethon 22 | 23 | *.log* 24 | *.db 25 | *.db-* 26 | /*.pickle 27 | *.bak 28 | /*.json 29 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - project: 'mautrix/ci' 3 | file: '/python.yml' 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude_types: [markdown] 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | - repo: https://github.com/psf/black 11 | rev: 24.1.1 12 | hooks: 13 | - id: black 14 | language_version: python3 15 | files: ^mautrix_telegram/.*\.pyi?$ 16 | - repo: https://github.com/PyCQA/isort 17 | rev: 5.13.2 18 | hooks: 19 | - id: isort 20 | files: ^mautrix_telegram/.*\.pyi?$ 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM dock.mau.dev/tulir/lottieconverter:alpine-3.22 2 | 3 | RUN apk add --no-cache \ 4 | python3 py3-pip py3-setuptools py3-wheel \ 5 | py3-pillow \ 6 | py3-aiohttp \ 7 | py3-asyncpg \ 8 | py3-aiosqlite \ 9 | py3-magic \ 10 | py3-ruamel.yaml \ 11 | py3-commonmark \ 12 | py3-phonenumbers \ 13 | py3-mako \ 14 | #py3-prometheus-client \ (pulls in twisted unnecessarily) 15 | # Indirect dependencies 16 | py3-idna \ 17 | py3-rsa \ 18 | #py3-telethon \ (outdated) 19 | py3-pyaes \ 20 | py3-aiodns \ 21 | py3-python-socks \ 22 | # cryptg 23 | py3-cffi \ 24 | py3-qrcode \ 25 | py3-brotli \ 26 | # Other dependencies 27 | ffmpeg \ 28 | ca-certificates \ 29 | su-exec \ 30 | netcat-openbsd \ 31 | # encryption 32 | py3-olm \ 33 | py3-pycryptodome \ 34 | py3-unpaddedbase64 \ 35 | py3-future \ 36 | bash \ 37 | curl \ 38 | jq \ 39 | yq 40 | 41 | COPY requirements.txt /opt/mautrix-telegram/requirements.txt 42 | COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt 43 | WORKDIR /opt/mautrix-telegram 44 | RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \ 45 | && pip3 install --break-system-packages /cryptg-*.whl \ 46 | && pip3 install --break-system-packages --no-cache-dir -r requirements.txt -r optional-requirements.txt \ 47 | && apk del .build-deps \ 48 | && rm -f /cryptg-*.whl 49 | 50 | COPY . /opt/mautrix-telegram 51 | RUN apk add git && pip3 install --break-system-packages --no-cache-dir .[all] && apk del git \ 52 | # This doesn't make the image smaller, but it's needed so that the `version` command works properly 53 | && cp mautrix_telegram/example-config.yaml . && rm -rf mautrix_telegram .git build 54 | 55 | VOLUME /data 56 | ENV UID=1337 GID=1337 \ 57 | FFMPEG_BINARY=/usr/bin/ffmpeg 58 | 59 | CMD ["/opt/mautrix-telegram/docker-run.sh"] 60 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include CHANGELOG.md 3 | include LICENSE 4 | include requirements.txt 5 | include optional-requirements.txt 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mautrix-telegram 2 | ![Languages](https://img.shields.io/github/languages/top/mautrix/telegram.svg) 3 | [![License](https://img.shields.io/github/license/mautrix/telegram.svg)](LICENSE) 4 | [![Release](https://img.shields.io/github/release/mautrix/telegram/all.svg)](https://github.com/mautrix/telegram/releases) 5 | [![GitLab CI](https://mau.dev/mautrix/telegram/badges/master/pipeline.svg)](https://mau.dev/mautrix/telegram/container_registry) 6 | [![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | [![Imports](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) 8 | 9 | A Matrix-Telegram hybrid puppeting/relaybot bridge. 10 | 11 | ## Sponsors 12 | * [Joel Lehtonen / Zouppen](https://github.com/zouppen) 13 | 14 | ## Documentation 15 | All setup and usage instructions are located on 16 | [docs.mau.fi](https://docs.mau.fi/bridges/python/telegram/index.html). 17 | Some quick links: 18 | 19 | * [Bridge setup](https://docs.mau.fi/bridges/python/setup.html?bridge=telegram) 20 | (or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=telegram)) 21 | * Basic usage: [Authentication](https://docs.mau.fi/bridges/python/telegram/authentication.html), 22 | [Creating chats](https://docs.mau.fi/bridges/python/telegram/creating-and-managing-chats.html), 23 | [Relaybot setup](https://docs.mau.fi/bridges/python/telegram/relay-bot.html) 24 | 25 | ### Features & Roadmap 26 | [ROADMAP.md](https://github.com/mautrix/telegram/blob/master/ROADMAP.md) 27 | contains a general overview of what is supported by the bridge. 28 | 29 | ## Discussion 30 | Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net) 31 | 32 | Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room) 33 | 34 | ## Preview 35 | ![Preview](preview.png) 36 | -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- 1 | # Features & roadmap 2 | 3 | * Matrix → Telegram 4 | * [x] Message content (text, formatting, files, etc..) 5 | * [x] Message redactions 6 | * [x] Message reactions 7 | * [x] Message edits 8 | * [ ] ‡ Message history 9 | * [x] Presence 10 | * [x] Typing notifications 11 | * [x] Read receipts 12 | * [x] Pinning messages 13 | * [x] Power level 14 | * [x] Normal chats 15 | * [ ] Non-hardcoded PL requirements 16 | * [x] Supergroups/channels 17 | * [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..) 18 | * [x] Membership actions (invite/kick/join/leave) 19 | * [x] Room metadata changes (name, topic, avatar) 20 | * [x] Initial room metadata 21 | * [ ] User metadata 22 | * [ ] Initial displayname/username/avatar at register 23 | * [ ] ‡ Changes to displayname/avatar 24 | * Telegram → Matrix 25 | * [x] Message content (text, formatting, files, etc..) 26 | * [ ] Advanced message content/media 27 | * [x] Custom emojis 28 | * [x] Polls 29 | * [x] Games 30 | * [ ] Buttons 31 | * [x] Message deletions 32 | * [x] Message reactions 33 | * [x] Message edits 34 | * [x] Message history 35 | * [x] Manually (`!tg backfill`) 36 | * [x] Automatically when creating portal 37 | * [x] Automatically for missed messages 38 | * [x] Avatars 39 | * [x] Presence 40 | * [x] Typing notifications 41 | * [x] Read receipts (private chat only) 42 | * [x] Pinning messages 43 | * [x] Admin/chat creator status 44 | * [ ] Supergroup/channel permissions (precise per-user permissions not supported in Matrix) 45 | * [x] Membership actions (invite/kick/join/leave) 46 | * [ ] Chat metadata changes 47 | * [x] Title 48 | * [x] Avatar 49 | * [ ] † About text 50 | * [ ] † Public channel username 51 | * [x] Initial chat metadata (about text missing) 52 | * [x] User metadata (displayname/avatar) 53 | * [x] Supergroup upgrade 54 | * Misc 55 | * [x] Automatic portal creation 56 | * [x] At startup 57 | * [x] When receiving invite or message 58 | * [x] Portal creation by inviting Matrix puppet of Telegram user to new room 59 | * [x] Option to use bot to relay messages for unauthenticated Matrix users (relaybot) 60 | * [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting) 61 | * [ ] ‡ Calls (hard, not yet supported by Telethon) 62 | * [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram) 63 | * [x] End-to-bridge encryption in Matrix rooms (see [docs](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html)) 64 | 65 | † Information not automatically sent from source, i.e. implementation may not be possible 66 | ‡ Maybe, i.e. this feature may or may not be implemented at some point 67 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | pre-commit>=2.10.1,<3 2 | isort>=5.10.1,<6 3 | black>=24,<25 4 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | if [ ! -z "$MAUTRIX_DIRECT_STARTUP" ]; then 3 | if [ $(id -u) == 0 ]; then 4 | echo "|------------------------------------------|" 5 | echo "| Warning: running bridge unsafely as root |" 6 | echo "|------------------------------------------|" 7 | fi 8 | exec python3 -m mautrix_telegram -c /data/config.yaml 9 | elif [ $(id -u) != 0 ]; then 10 | echo "The startup script must run as root. It will use su-exec to drop permissions before running the bridge." 11 | echo "To bypass the startup script, either set the `MAUTRIX_DIRECT_STARTUP` environment variable," 12 | echo "or just use `python3 -m mautrix_telegram -c /data/config.yaml` as the run command." 13 | echo "Note that the config and registration will not be auto-generated when bypassing the startup script." 14 | exit 1 15 | fi 16 | 17 | # Define functions. 18 | function fixperms { 19 | chown -R $UID:$GID /data 20 | 21 | # /opt/mautrix-telegram is read-only, so disable file logging if it's pointing there. 22 | if [[ "$(yq e '.logging.handlers.file.filename' /data/config.yaml)" == "./mautrix-telegram.log" ]]; then 23 | yq -I4 e -i 'del(.logging.root.handlers[] | select(. == "file"))' /data/config.yaml 24 | yq -I4 e -i 'del(.logging.handlers.file)' /data/config.yaml 25 | fi 26 | } 27 | 28 | cd /opt/mautrix-telegram 29 | 30 | if [ ! -f /data/config.yaml ]; then 31 | cp example-config.yaml /data/config.yaml 32 | echo "Didn't find a config file." 33 | echo "Copied default config file to /data/config.yaml" 34 | echo "Modify that config file to your liking." 35 | echo "Start the container again after that to generate the registration file." 36 | fixperms 37 | exit 38 | fi 39 | 40 | if [ ! -f /data/registration.yaml ]; then 41 | python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml || exit $? 42 | echo "Didn't find a registration file." 43 | echo "Generated one for you." 44 | echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." 45 | fixperms 46 | exit 47 | fi 48 | 49 | fixperms 50 | exec su-exec $UID:$GID python3 -m mautrix_telegram -c /data/config.yaml 51 | -------------------------------------------------------------------------------- /mautrix_telegram/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.15.3" 2 | __author__ = "Tulir Asokan " 3 | -------------------------------------------------------------------------------- /mautrix_telegram/__main__.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import Any 19 | 20 | from telethon import __version__ as __telethon_version__ 21 | 22 | from mautrix.bridge import Bridge 23 | from mautrix.types import RoomID, UserID 24 | 25 | from .bot import Bot 26 | from .config import Config 27 | from .db import init as init_db, upgrade_table 28 | from .matrix import MatrixHandler 29 | from .portal import Portal 30 | from .puppet import Puppet 31 | from .user import User 32 | from .version import linkified_version, version 33 | from .web.provisioning import ProvisioningAPI 34 | from .web.public import PublicBridgeWebsite 35 | 36 | from .abstract_user import AbstractUser # isort: skip 37 | 38 | 39 | class TelegramBridge(Bridge): 40 | module = "mautrix_telegram" 41 | name = "mautrix-telegram" 42 | beeper_service_name = "telegram" 43 | beeper_network_name = "telegram" 44 | command = "python -m mautrix-telegram" 45 | description = "A Matrix-Telegram puppeting bridge." 46 | repo_url = "https://github.com/mautrix/telegram" 47 | version = version 48 | markdown_version = linkified_version 49 | config_class = Config 50 | matrix_class = MatrixHandler 51 | upgrade_table = upgrade_table 52 | 53 | config: Config 54 | bot: Bot | None 55 | matrix: MatrixHandler 56 | public_website: PublicBridgeWebsite | None 57 | provisioning_api: ProvisioningAPI | None 58 | 59 | def prepare_db(self) -> None: 60 | super().prepare_db() 61 | init_db(self.db) 62 | 63 | def _prepare_website(self) -> None: 64 | if self.config["appservice.provisioning.enabled"]: 65 | self.provisioning_api = ProvisioningAPI(self) 66 | self.az.app.add_subapp( 67 | self.config["appservice.provisioning.prefix"], self.provisioning_api.app 68 | ) 69 | else: 70 | self.provisioning_api = None 71 | 72 | if self.config["appservice.public.enabled"]: 73 | self.public_website = PublicBridgeWebsite(self.loop) 74 | self.az.app.add_subapp( 75 | self.config["appservice.public.prefix"], self.public_website.app 76 | ) 77 | else: 78 | self.public_website = None 79 | 80 | def prepare_bridge(self) -> None: 81 | self._prepare_website() 82 | AbstractUser.init_cls(self) 83 | bot_token: str = self.config["telegram.bot_token"] 84 | if bot_token and not bot_token.lower().startswith("disable"): 85 | self.bot = AbstractUser.relaybot = Bot(bot_token) 86 | else: 87 | self.bot = AbstractUser.relaybot = None 88 | self.matrix = MatrixHandler(self) 89 | Portal.init_cls(self) 90 | self.add_startup_actions(Puppet.init_cls(self)) 91 | self.add_startup_actions(User.init_cls(self)) 92 | self.add_startup_actions(Portal.restart_scheduled_disappearing()) 93 | if self.bot: 94 | self.add_startup_actions(self.bot.start()) 95 | if self.config["bridge.resend_bridge_info"]: 96 | self.add_startup_actions(self.resend_bridge_info()) 97 | 98 | async def resend_bridge_info(self) -> None: 99 | self.config["bridge.resend_bridge_info"] = False 100 | self.config.save() 101 | self.log.info("Re-sending bridge info state event to all portals") 102 | async for portal in Portal.all(): 103 | await portal.update_bridge_info() 104 | self.log.info("Finished re-sending bridge info state events") 105 | 106 | def prepare_stop(self) -> None: 107 | self.add_shutdown_actions(user.stop() for user in User.by_tgid.values()) 108 | if self.bot: 109 | self.add_shutdown_actions(self.bot.stop()) 110 | 111 | async def get_user(self, user_id: UserID, create: bool = True) -> User | None: 112 | user = await User.get_by_mxid(user_id, create=create) 113 | if user: 114 | await user.ensure_started() 115 | return user 116 | 117 | async def get_portal(self, room_id: RoomID) -> Portal | None: 118 | return await Portal.get_by_mxid(room_id) 119 | 120 | async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet | None: 121 | return await Puppet.get_by_mxid(user_id, create=create) 122 | 123 | async def get_double_puppet(self, user_id: UserID) -> Puppet | None: 124 | return await Puppet.get_by_custom_mxid(user_id) 125 | 126 | def is_bridge_ghost(self, user_id: UserID) -> bool: 127 | return bool(Puppet.get_id_from_mxid(user_id)) 128 | 129 | async def count_logged_in_users(self) -> int: 130 | return len([user for user in User.by_tgid.values() if user.tgid]) 131 | 132 | async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]: 133 | return { 134 | **await super().manhole_global_namespace(user_id), 135 | "User": User, 136 | "Portal": Portal, 137 | "Puppet": Puppet, 138 | } 139 | 140 | @property 141 | def manhole_banner_program_version(self) -> str: 142 | return f"{super().manhole_banner_program_version} and Telethon {__telethon_version__}" 143 | 144 | 145 | TelegramBridge().run() 146 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .handler import ( 2 | SECTION_ADMIN, 3 | SECTION_AUTH, 4 | SECTION_CREATING_PORTALS, 5 | SECTION_MISC, 6 | SECTION_PORTAL_MANAGEMENT, 7 | CommandEvent, 8 | CommandHandler, 9 | CommandProcessor, 10 | command_handler, 11 | ) 12 | 13 | # This has to happen after the handler imports 14 | from . import matrix_auth, portal, telegram # isort: skip 15 | 16 | __all__ = [ 17 | "command_handler", 18 | "CommandHandler", 19 | "CommandProcessor", 20 | "CommandEvent", 21 | "SECTION_AUTH", 22 | "SECTION_MISC", 23 | "SECTION_ADMIN", 24 | "SECTION_CREATING_PORTALS", 25 | "SECTION_PORTAL_MANAGEMENT", 26 | ] 27 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/handler.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, Any, Awaitable, Callable, NamedTuple 19 | 20 | from telethon.errors import FloodWaitError 21 | 22 | from mautrix.bridge.commands import ( 23 | CommandEvent as BaseCommandEvent, 24 | CommandHandler as BaseCommandHandler, 25 | CommandHandlerFunc, 26 | CommandProcessor as BaseCommandProcessor, 27 | HelpSection, 28 | command_handler as base_command_handler, 29 | ) 30 | from mautrix.types import EventID, MessageEventContent, RoomID 31 | from mautrix.util.format_duration import format_duration 32 | 33 | from .. import portal as po, user as u 34 | 35 | if TYPE_CHECKING: 36 | from ..__main__ import TelegramBridge 37 | 38 | 39 | class HelpCacheKey(NamedTuple): 40 | is_management: bool 41 | is_portal: bool 42 | puppet_whitelisted: bool 43 | matrix_puppet_whitelisted: bool 44 | is_admin: bool 45 | is_logged_in: bool 46 | 47 | 48 | SECTION_AUTH = HelpSection("Authentication", 10, "") 49 | SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "") 50 | SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "") 51 | SECTION_MISC = HelpSection("Miscellaneous", 40, "") 52 | SECTION_ADMIN = HelpSection("Administration", 50, "") 53 | 54 | 55 | class CommandEvent(BaseCommandEvent): 56 | sender: u.User 57 | portal: po.Portal 58 | 59 | def __init__( 60 | self, 61 | processor: CommandProcessor, 62 | room_id: RoomID, 63 | event_id: EventID, 64 | sender: u.User, 65 | command: str, 66 | args: list[str], 67 | content: MessageEventContent, 68 | portal: po.Portal | None, 69 | is_management: bool, 70 | has_bridge_bot: bool, 71 | ) -> None: 72 | super().__init__( 73 | processor, 74 | room_id, 75 | event_id, 76 | sender, 77 | command, 78 | args, 79 | content, 80 | portal, 81 | is_management, 82 | has_bridge_bot, 83 | ) 84 | self.bridge = processor.bridge 85 | self.tgbot = processor.tgbot 86 | self.config = processor.config 87 | self.public_website = processor.public_website 88 | 89 | @property 90 | def print_error_traceback(self) -> bool: 91 | return self.sender.is_admin 92 | 93 | async def get_help_key(self) -> HelpCacheKey: 94 | return HelpCacheKey( 95 | self.is_management, 96 | self.portal is not None, 97 | self.sender.puppet_whitelisted, 98 | self.sender.matrix_puppet_whitelisted, 99 | self.sender.is_admin, 100 | await self.sender.is_logged_in(), 101 | ) 102 | 103 | 104 | class CommandHandler(BaseCommandHandler): 105 | name: str 106 | 107 | needs_puppeting: bool 108 | needs_matrix_puppeting: bool 109 | 110 | def __init__( 111 | self, 112 | handler: Callable[[CommandEvent], Awaitable[EventID]], 113 | management_only: bool, 114 | name: str, 115 | help_text: str, 116 | help_args: str, 117 | help_section: HelpSection, 118 | needs_auth: bool, 119 | needs_puppeting: bool, 120 | needs_matrix_puppeting: bool, 121 | needs_admin: bool, 122 | **kwargs, 123 | ) -> None: 124 | super().__init__( 125 | handler, 126 | management_only, 127 | name, 128 | help_text, 129 | help_args, 130 | help_section, 131 | needs_auth=needs_auth, 132 | needs_puppeting=needs_puppeting, 133 | needs_matrix_puppeting=needs_matrix_puppeting, 134 | needs_admin=needs_admin, 135 | **kwargs, 136 | ) 137 | 138 | async def get_permission_error(self, evt: CommandEvent) -> str | None: 139 | if self.needs_puppeting and not evt.sender.puppet_whitelisted: 140 | return "That command is limited to users with puppeting privileges." 141 | elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted: 142 | return "That command is limited to users with full puppeting privileges." 143 | return await super().get_permission_error(evt) 144 | 145 | def has_permission(self, key: HelpCacheKey) -> bool: 146 | return ( 147 | super().has_permission(key) 148 | and (not self.needs_puppeting or key.puppet_whitelisted) 149 | and (not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted) 150 | ) 151 | 152 | 153 | def command_handler( 154 | _func: CommandHandlerFunc | None = None, 155 | *, 156 | needs_auth: bool = True, 157 | needs_puppeting: bool = True, 158 | needs_matrix_puppeting: bool = False, 159 | needs_admin: bool = False, 160 | management_only: bool = False, 161 | name: str | None = None, 162 | aliases: list[str] | None = None, 163 | help_text: str = "", 164 | help_args: str = "", 165 | help_section: HelpSection = None, 166 | ) -> Callable[[CommandHandlerFunc], CommandHandler]: 167 | return base_command_handler( 168 | _func, 169 | _handler_class=CommandHandler, 170 | name=name, 171 | aliases=aliases, 172 | help_text=help_text, 173 | help_args=help_args, 174 | help_section=help_section, 175 | management_only=management_only, 176 | needs_auth=needs_auth, 177 | needs_admin=needs_admin, 178 | needs_puppeting=needs_puppeting, 179 | needs_matrix_puppeting=needs_matrix_puppeting, 180 | ) 181 | 182 | 183 | class CommandProcessor(BaseCommandProcessor): 184 | def __init__(self, bridge: "TelegramBridge") -> None: 185 | super().__init__(event_class=CommandEvent, bridge=bridge) 186 | self.tgbot = bridge.bot 187 | self.public_website = bridge.public_website 188 | 189 | @staticmethod 190 | async def _run_handler( 191 | handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent 192 | ) -> Any: 193 | try: 194 | return await handler(evt) 195 | except FloodWaitError as e: 196 | return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}") 197 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/matrix_auth.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf 17 | from mautrix.types import EventID 18 | 19 | from .. import puppet as pu 20 | from . import SECTION_AUTH, CommandEvent, command_handler 21 | 22 | 23 | @command_handler( 24 | needs_auth=True, 25 | management_only=True, 26 | needs_matrix_puppeting=True, 27 | help_section=SECTION_AUTH, 28 | help_text="Replace your Telegram account's Matrix puppet with your own Matrix account.", 29 | ) 30 | async def login_matrix(evt: CommandEvent) -> EventID: 31 | puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid) 32 | if puppet.is_real_user: 33 | return await evt.reply( 34 | "You have already logged in with your Matrix account. " 35 | "Log out with `$cmdprefix+sp logout-matrix` first." 36 | ) 37 | allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True) 38 | if allow_matrix_login: 39 | evt.sender.command_status = { 40 | "next": enter_matrix_token, 41 | "action": "Matrix login", 42 | } 43 | if evt.config["appservice.public.enabled"]: 44 | prefix = evt.config["appservice.public.external"] 45 | token = evt.public_website.make_token(evt.sender.mxid, "/matrix-login") 46 | url = f"{prefix}/matrix-login?token={token}" 47 | if allow_matrix_login: 48 | return await evt.reply( 49 | "This bridge instance allows you to log in inside or outside Matrix.\n\n" 50 | "If you would like to log in within Matrix, please send your Matrix access token " 51 | "here.\n" 52 | f"If you would like to log in outside of Matrix, [click here]({url}).\n\n" 53 | "Logging in outside of Matrix is recommended, because in-Matrix login would save " 54 | "your access token in the message history." 55 | ) 56 | return await evt.reply( 57 | "This bridge instance does not allow logging in inside Matrix.\n\n" 58 | f"Please visit [the login page]({url}) to log in." 59 | ) 60 | elif allow_matrix_login: 61 | return await evt.reply( 62 | "This bridge instance does not allow you to log in outside of Matrix.\n\n" 63 | "Please send your Matrix access token here to log in." 64 | ) 65 | return await evt.reply("This bridge instance has been configured to not allow logging in.") 66 | 67 | 68 | async def enter_matrix_token(evt: CommandEvent) -> EventID: 69 | evt.sender.command_status = None 70 | 71 | puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid) 72 | if puppet.is_real_user: 73 | return await evt.reply( 74 | "You have already logged in with your Matrix account. " 75 | "Log out with `$cmdprefix+sp logout-matrix` first." 76 | ) 77 | try: 78 | await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) 79 | except OnlyLoginSelf: 80 | return await evt.reply("You can only log in as your own Matrix user.") 81 | except InvalidAccessToken: 82 | return await evt.reply("Failed to verify access token.") 83 | return await evt.reply( 84 | f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}." 85 | ) 86 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/portal/__init__.py: -------------------------------------------------------------------------------- 1 | from . import admin, bridge, config, create_chat, filter, misc, unbridge 2 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/portal/admin.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | import asyncio 17 | 18 | from mautrix.types import EventID 19 | 20 | from ... import portal as po, puppet as pu, user as u 21 | from .. import SECTION_ADMIN, CommandEvent, command_handler 22 | 23 | 24 | @command_handler( 25 | needs_admin=True, 26 | needs_auth=False, 27 | help_section=SECTION_ADMIN, 28 | help_args="<`portal`|`puppet`|`user`>", 29 | help_text="Clear internal bridge caches", 30 | ) 31 | async def clear_db_cache(evt: CommandEvent) -> EventID: 32 | try: 33 | section = evt.args[0].lower() 34 | except IndexError: 35 | return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache
`") 36 | if section == "portal": 37 | po.Portal.by_tgid = {} 38 | po.Portal.by_mxid = {} 39 | await evt.reply("Cleared portal cache") 40 | elif section == "puppet": 41 | pu.Puppet.by_tgid = {} 42 | pu.Puppet.by_custom_mxid = {} 43 | await asyncio.gather( 44 | *[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()] 45 | ) 46 | await evt.reply("Cleared puppet cache and restarted custom puppet syncers") 47 | elif section == "user": 48 | u.User.by_mxid = {user.mxid: user for user in u.User.by_tgid.values()} 49 | await evt.reply("Cleared non-logged-in user cache") 50 | else: 51 | return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache
`") 52 | 53 | 54 | @command_handler( 55 | needs_admin=True, 56 | needs_auth=False, 57 | help_section=SECTION_ADMIN, 58 | help_args="[_mxid_]", 59 | help_text="Reload and reconnect a user", 60 | ) 61 | async def reload_user(evt: CommandEvent) -> EventID: 62 | if len(evt.args) > 0: 63 | mxid = evt.args[0] 64 | else: 65 | mxid = evt.sender.mxid 66 | user = await u.User.get_by_mxid(mxid, create=False) 67 | if not user: 68 | return await evt.reply("User not found") 69 | puppet = await pu.Puppet.get_by_custom_mxid(mxid) 70 | await user.stop() 71 | del u.User.by_tgid[user.tgid] 72 | del u.User.by_mxid[user.mxid] 73 | user = await u.User.get_by_mxid(mxid) 74 | await user.ensure_started() 75 | if puppet: 76 | await puppet.start() 77 | return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})") 78 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/portal/config.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import Any, Awaitable 19 | from io import StringIO 20 | 21 | from ruamel.yaml import YAMLError 22 | 23 | from mautrix.types import EventID 24 | from mautrix.util.config import yaml 25 | 26 | from ... import portal as po, util 27 | from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler 28 | 29 | 30 | @command_handler( 31 | needs_auth=False, 32 | needs_puppeting=False, 33 | help_section=SECTION_PORTAL_MANAGEMENT, 34 | help_text="View or change per-portal settings.", 35 | help_args="<`help`|_subcommand_> [...]", 36 | ) 37 | async def config(evt: CommandEvent) -> None: 38 | cmd = evt.args[0].lower() if len(evt.args) > 0 else "help" 39 | if cmd not in ("view", "defaults", "set", "unset", "add", "del"): 40 | await config_help(evt) 41 | return 42 | elif cmd == "defaults": 43 | await config_defaults(evt) 44 | return 45 | 46 | portal = await po.Portal.get_by_mxid(evt.room_id) 47 | if not portal: 48 | await evt.reply("This is not a portal room.") 49 | return 50 | elif cmd == "view": 51 | await config_view(evt, portal) 52 | return 53 | 54 | if not await portal.can_user_perform(evt.sender, "config"): 55 | await evt.reply("You do not have the permissions to configure this room.") 56 | return 57 | 58 | key = evt.args[1] if len(evt.args) > 1 else None 59 | try: 60 | value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None 61 | except YAMLError as e: 62 | await evt.reply(f"Invalid value provided. Values must be valid YAML.\n{e}") 63 | return 64 | if cmd == "set": 65 | await config_set(evt, portal, key, value) 66 | elif cmd == "unset": 67 | await config_unset(evt, portal, key) 68 | elif cmd == "add" or cmd == "del": 69 | await config_add_del(evt, portal, key, value, cmd) 70 | else: 71 | return 72 | await portal.save() 73 | 74 | 75 | def config_help(evt: CommandEvent) -> Awaitable[EventID]: 76 | return evt.reply( 77 | """**Usage:** `$cmdprefix config [...]`. Subcommands: 78 | 79 | * **help** - View this help text. 80 | * **view** - View the current config data. 81 | * **defaults** - View the default config values. 82 | * **set** <_key_> <_value_> - Set a config value. 83 | * **unset** <_key_> - Remove a config value. 84 | * **add** <_key_> <_value_> - Add a value to an array. 85 | * **del** <_key_> <_value_> - Remove a value from an array. 86 | """ 87 | ) 88 | 89 | 90 | def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[EventID]: 91 | return evt.reply(f"Room-specific config:\n{_str_value(portal.local_config).rstrip()}") 92 | 93 | 94 | def config_defaults(evt: CommandEvent) -> Awaitable[EventID]: 95 | value = _str_value( 96 | { 97 | "bridge_notices": { 98 | "default": evt.config["bridge.bridge_notices.default"], 99 | "exceptions": evt.config["bridge.bridge_notices.exceptions"], 100 | }, 101 | "bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"], 102 | "caption_in_message": evt.config["bridge.caption_in_message"], 103 | "message_formats": evt.config["bridge.message_formats"], 104 | "emote_format": evt.config["bridge.emote_format"], 105 | "state_event_formats": evt.config["bridge.state_event_formats"], 106 | "telegram_link_preview": evt.config["bridge.telegram_link_preview"], 107 | } 108 | ) 109 | return evt.reply(f"Bridge instance wide config:\n{value.rstrip()}") 110 | 111 | 112 | def _str_value(value: Any) -> str: 113 | stream = StringIO() 114 | yaml.dump(value, stream) 115 | value_str = stream.getvalue() 116 | if "\n" in value_str: 117 | return f"\n```yaml\n{value_str}\n```\n" 118 | else: 119 | return f"`{value_str}`" 120 | 121 | 122 | def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: Any) -> Awaitable[EventID]: 123 | if not key or value is None: 124 | return evt.reply(f"**Usage:** `$cmdprefix+sp config set `") 125 | elif util.recursive_set(portal.local_config, key, value): 126 | return evt.reply(f"Successfully set the value of `{key}` to {_str_value(value)}".rstrip()) 127 | else: 128 | return evt.reply(f"Failed to set value of `{key}`. Does the path contain non-map types?") 129 | 130 | 131 | def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[EventID]: 132 | if not key: 133 | return evt.reply(f"**Usage:** `$cmdprefix+sp config unset `") 134 | elif util.recursive_del(portal.local_config, key): 135 | return evt.reply(f"Successfully deleted `{key}` from config.") 136 | else: 137 | return evt.reply(f"`{key}` not found in config.") 138 | 139 | 140 | def config_add_del( 141 | evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str 142 | ) -> Awaitable[EventID]: 143 | if not key or value is None: 144 | return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") 145 | 146 | arr = util.recursive_get(portal.local_config, key) 147 | if not arr: 148 | return evt.reply( 149 | f"`{key}` not found in config. Maybe do `$cmdprefix+sp config set {key} []` first?" 150 | ) 151 | elif not isinstance(arr, list): 152 | return evt.reply("`{key}` does not seem to be an array.") 153 | elif cmd == "add": 154 | if value in arr: 155 | return evt.reply(f"The array at `{key}` already contains {_str_value(value)}".rstrip()) 156 | arr.append(value) 157 | return evt.reply(f"Successfully added {_str_value(value)} to the array at `{key}`") 158 | else: 159 | if value not in arr: 160 | return evt.reply(f"The array at `{key}` does not contain {_str_value(value)}") 161 | arr.remove(value) 162 | return evt.reply(f"Successfully removed {_str_value(value)} from the array at `{key}`") 163 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/portal/create_chat.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from mautrix.types import EventID 19 | 20 | from ... import portal as po 21 | from ...types import TelegramID 22 | from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler 23 | from .util import get_initial_state, user_has_power_level, warn_missing_power 24 | 25 | 26 | @command_handler( 27 | help_section=SECTION_CREATING_PORTALS, 28 | help_args="[_type_]", 29 | help_text=( 30 | "Create a Telegram chat of the given type for the current Matrix room. " 31 | "The type is either `group`, `supergroup` or `channel` (defaults to `supergroup`)." 32 | ), 33 | ) 34 | async def create(evt: CommandEvent) -> EventID: 35 | type = evt.args[0] if len(evt.args) > 0 else "supergroup" 36 | if type not in ("chat", "group", "supergroup", "channel"): 37 | return await evt.reply( 38 | "**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`" 39 | ) 40 | 41 | if await po.Portal.get_by_mxid(evt.room_id): 42 | return await evt.reply("This is already a portal room.") 43 | 44 | if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"): 45 | return await evt.reply("You do not have the permissions to bridge this room.") 46 | 47 | title, about, levels, encrypted = await get_initial_state(evt.az.intent, evt.room_id) 48 | if not title: 49 | return await evt.reply("Please set a title before creating a Telegram chat.") 50 | 51 | supergroup = type == "supergroup" 52 | type = { 53 | "supergroup": "channel", 54 | "channel": "channel", 55 | "chat": "chat", 56 | "group": "chat", 57 | }[type] 58 | 59 | portal = po.Portal( 60 | tgid=TelegramID(0), 61 | tg_receiver=TelegramID(0), 62 | peer_type=type, 63 | mxid=evt.room_id, 64 | title=title, 65 | about=about, 66 | encrypted=encrypted, 67 | ) 68 | 69 | await warn_missing_power(levels, evt) 70 | 71 | try: 72 | await portal.create_telegram_chat(evt.sender, supergroup=supergroup) 73 | except ValueError as e: 74 | await portal.delete() 75 | return await evt.reply(e.args[0]) 76 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/portal/filter.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from mautrix.types import EventID 19 | 20 | from ... import portal as po 21 | from .. import SECTION_ADMIN, CommandEvent, command_handler 22 | 23 | 24 | @command_handler( 25 | needs_admin=True, 26 | help_section=SECTION_ADMIN, 27 | help_args="<`whitelist`|`blacklist`>", 28 | help_text="Change whether the bridge will allow or disallow bridging rooms by default.", 29 | ) 30 | async def filter_mode(evt: CommandEvent) -> EventID: 31 | try: 32 | mode = evt.args[0] 33 | if mode not in ("whitelist", "blacklist"): 34 | raise ValueError() 35 | except (IndexError, ValueError): 36 | return await evt.reply("**Usage:** `$cmdprefix+sp filter-mode `") 37 | 38 | evt.config["bridge.filter.mode"] = mode 39 | evt.config.save() 40 | po.Portal.filter_mode = mode 41 | if mode == "whitelist": 42 | return await evt.reply( 43 | "The bridge will now disallow bridging chats by default.\n" 44 | "To allow bridging a specific chat, use" 45 | "`!filter whitelist `." 46 | ) 47 | else: 48 | return await evt.reply( 49 | "The bridge will now allow bridging chats by default.\n" 50 | "To disallow bridging a specific chat, use" 51 | "`!filter blacklist `." 52 | ) 53 | 54 | 55 | @command_handler( 56 | name="filter", 57 | needs_admin=True, 58 | help_section=SECTION_ADMIN, 59 | help_args="<`whitelist`|`blacklist`> <_chat ID_>", 60 | help_text="Allow or disallow bridging a specific chat.", 61 | ) 62 | async def edit_filter(evt: CommandEvent) -> EventID: 63 | try: 64 | action = evt.args[0] 65 | if action not in ("whitelist", "blacklist", "add", "remove"): 66 | raise ValueError() 67 | 68 | id_str = evt.args[1] 69 | if id_str.startswith("-100"): 70 | filter_id = int(id_str[4:]) 71 | elif id_str.startswith("-"): 72 | filter_id = int(id_str[1:]) 73 | else: 74 | filter_id = int(id_str) 75 | except (IndexError, ValueError): 76 | return await evt.reply("**Usage:** `$cmdprefix+sp filter `") 77 | 78 | mode = evt.config["bridge.filter.mode"] 79 | if mode not in ("blacklist", "whitelist"): 80 | return await evt.reply(f'Unknown filter mode "{mode}". Please fix the bridge config.') 81 | 82 | filter_id_list = evt.config["bridge.filter.list"] 83 | 84 | if action in ("blacklist", "whitelist"): 85 | action = "add" if mode == action else "remove" 86 | 87 | def save() -> None: 88 | evt.config["bridge.filter.list"] = filter_id_list 89 | evt.config.save() 90 | po.Portal.filter_list = filter_id_list 91 | 92 | if action == "add": 93 | if filter_id in filter_id_list: 94 | return await evt.reply(f"That chat is already {mode}ed.") 95 | filter_id_list.append(filter_id) 96 | save() 97 | return await evt.reply(f"Chat ID added to {mode}.") 98 | elif action == "remove": 99 | if filter_id not in filter_id_list: 100 | return await evt.reply(f"That chat is not {mode}ed.") 101 | filter_id_list.remove(filter_id) 102 | save() 103 | return await evt.reply(f"Chat ID removed from {mode}.") 104 | else: 105 | return await evt.reply("**Usage:** `$cmdprefix+sp filter `") 106 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/portal/unbridge.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import Callable 19 | 20 | from mautrix.types import EventID, RoomID 21 | 22 | from ... import portal as po 23 | from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler 24 | from .util import user_has_power_level 25 | 26 | 27 | async def _get_portal_and_check_permission(evt: CommandEvent) -> po.Portal | None: 28 | room_id = RoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id 29 | 30 | portal = await po.Portal.get_by_mxid(room_id) 31 | if not portal: 32 | that_this = "This" if room_id == evt.room_id else "That" 33 | await evt.reply(f"{that_this} is not a portal room.") 34 | return None 35 | 36 | if portal.peer_type == "user": 37 | if portal.tg_receiver != evt.sender.tgid: 38 | await evt.reply("You do not have the permissions to unbridge that portal.") 39 | return None 40 | return portal 41 | 42 | if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"): 43 | await evt.reply("You do not have the permissions to unbridge that portal.") 44 | return None 45 | return portal 46 | 47 | 48 | def _get_portal_murder_function( 49 | action: str, room_id: str, function: Callable, command: str, completed_message: str 50 | ) -> dict: 51 | async def post_confirm(confirm) -> EventID | None: 52 | confirm.sender.command_status = None 53 | if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}": 54 | await function() 55 | if confirm.room_id != room_id: 56 | return await confirm.reply(completed_message) 57 | else: 58 | return await confirm.reply(f"{action} cancelled.") 59 | return None 60 | 61 | return { 62 | "next": post_confirm, 63 | "action": action, 64 | } 65 | 66 | 67 | @command_handler( 68 | needs_auth=False, 69 | needs_puppeting=False, 70 | help_section=SECTION_PORTAL_MANAGEMENT, 71 | help_text=( 72 | "Remove all users from the current portal room and forget the portal. " 73 | "Only works for group chats; to delete a private chat portal, simply leave the room." 74 | ), 75 | ) 76 | async def delete_portal(evt: CommandEvent) -> EventID | None: 77 | portal = await _get_portal_and_check_permission(evt) 78 | if not portal: 79 | return None 80 | 81 | evt.sender.command_status = _get_portal_murder_function( 82 | "Portal deletion", 83 | portal.mxid, 84 | portal.cleanup_and_delete, 85 | "delete", 86 | "Portal successfully deleted.", 87 | ) 88 | return await evt.reply( 89 | "Please confirm deletion of portal " 90 | f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) " 91 | f'to Telegram chat "{portal.title}" ' 92 | "by typing `$cmdprefix+sp confirm-delete`" 93 | "\n\n" 94 | "**WARNING:** If the bridge bot has the power level to do so, **this " 95 | "will kick ALL users** in the room. If you just want to remove the " 96 | "bridge, use `$cmdprefix+sp unbridge` instead." 97 | ) 98 | 99 | 100 | @command_handler( 101 | needs_auth=False, 102 | needs_puppeting=False, 103 | help_section=SECTION_PORTAL_MANAGEMENT, 104 | help_text="Remove puppets from the current portal room and forget the portal.", 105 | ) 106 | async def unbridge(evt: CommandEvent) -> EventID | None: 107 | portal = await _get_portal_and_check_permission(evt) 108 | if not portal: 109 | return None 110 | 111 | evt.sender.command_status = _get_portal_murder_function( 112 | "Room unbridging", portal.mxid, portal.unbridge, "unbridge", "Room successfully unbridged." 113 | ) 114 | return await evt.reply( 115 | f'Please confirm unbridging chat "{portal.title}" from room ' 116 | f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) " 117 | "by typing `$cmdprefix+sp confirm-unbridge`" 118 | ) 119 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/portal/util.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from mautrix.appservice import IntentAPI 19 | from mautrix.errors import MatrixRequestError 20 | from mautrix.types import EventType, PowerLevelStateEventContent, RoomID 21 | 22 | from ... import user as u 23 | from .. import CommandEvent 24 | 25 | 26 | async def get_initial_state( 27 | intent: IntentAPI, room_id: RoomID 28 | ) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool]: 29 | state = await intent.get_state(room_id) 30 | title: str | None = None 31 | about: str | None = None 32 | levels: PowerLevelStateEventContent | None = None 33 | encrypted: bool = False 34 | for event in state: 35 | try: 36 | if event.type == EventType.ROOM_NAME: 37 | title = event.content.name 38 | elif event.type == EventType.ROOM_TOPIC: 39 | about = event.content.topic 40 | elif event.type == EventType.ROOM_POWER_LEVELS: 41 | levels = event.content 42 | elif event.type == EventType.ROOM_CANONICAL_ALIAS: 43 | title = title or event.content.canonical_alias 44 | elif event.type == EventType.ROOM_ENCRYPTION: 45 | encrypted = True 46 | except KeyError: 47 | # Some state event probably has empty content 48 | pass 49 | return title, about, levels, encrypted 50 | 51 | 52 | async def warn_missing_power(levels: PowerLevelStateEventContent, evt: CommandEvent) -> None: 53 | if levels.get_user_level(evt.az.bot_mxid) < levels.redact: 54 | await evt.reply( 55 | "Warning: The bot does not have privileges to redact messages on Matrix. " 56 | "Message deletions from Telegram will not be bridged unless you give " 57 | f"redaction permissions to [{evt.az.bot_mxid}](https://matrix.to/#/{evt.az.bot_mxid})" 58 | ) 59 | 60 | 61 | async def user_has_power_level( 62 | room_id: RoomID, intent: IntentAPI, sender: u.User, event: str 63 | ) -> bool: 64 | if sender.is_admin: 65 | return True 66 | # Make sure the state store contains the power levels. 67 | try: 68 | await intent.get_power_levels(room_id) 69 | except MatrixRequestError: 70 | return False 71 | event_type = EventType.find(f"fi.mau.telegram.{event}", t_class=EventType.Class.STATE) 72 | return await intent.state_store.has_power_level(room_id, sender.mxid, event_type) 73 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | from . import account, auth, misc 2 | -------------------------------------------------------------------------------- /mautrix_telegram/commands/telegram/account.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from telethon.errors import ( 19 | AboutTooLongError, 20 | AuthKeyError, 21 | FirstNameInvalidError, 22 | HashInvalidError, 23 | UsernameInvalidError, 24 | UsernameNotModifiedError, 25 | UsernameOccupiedError, 26 | ) 27 | from telethon.tl.functions.account import ( 28 | GetAuthorizationsRequest, 29 | ResetAuthorizationRequest, 30 | UpdateProfileRequest, 31 | UpdateUsernameRequest, 32 | ) 33 | from telethon.tl.types import Authorization 34 | 35 | from mautrix.types import EventID 36 | 37 | from .. import SECTION_AUTH, CommandEvent, command_handler 38 | 39 | 40 | @command_handler( 41 | needs_auth=True, 42 | help_section=SECTION_AUTH, 43 | help_args="<_new username_>", 44 | help_text="Change your Telegram username.", 45 | ) 46 | async def username(evt: CommandEvent) -> EventID: 47 | if len(evt.args) == 0: 48 | return await evt.reply("**Usage:** `$cmdprefix+sp username `") 49 | if evt.sender.is_bot: 50 | return await evt.reply("Bots can't set their own username.") 51 | new_name = evt.args[0] 52 | if new_name == "-": 53 | new_name = "" 54 | try: 55 | await evt.sender.client(UpdateUsernameRequest(username=new_name)) 56 | except UsernameInvalidError: 57 | return await evt.reply( 58 | "Invalid username. Usernames must be between 5 and 30 alphanumeric characters." 59 | ) 60 | except UsernameNotModifiedError: 61 | return await evt.reply("That is your current username.") 62 | except UsernameOccupiedError: 63 | return await evt.reply("That username is already in use.") 64 | await evt.sender.update_info() 65 | if not evt.sender.tg_username: 66 | await evt.reply("Username removed") 67 | else: 68 | await evt.reply(f"Username changed to {evt.sender.tg_username}") 69 | 70 | 71 | @command_handler( 72 | needs_auth=True, 73 | help_section=SECTION_AUTH, 74 | help_args="<_new about_>", 75 | help_text="Change your Telegram about section.", 76 | ) 77 | async def about(evt: CommandEvent) -> EventID: 78 | if len(evt.args) == 0: 79 | return await evt.reply("**Usage:** `$cmdprefix+sp about `") 80 | if evt.sender.is_bot: 81 | return await evt.reply("Bots can't set their own about section.") 82 | new_about = " ".join(evt.args) 83 | if new_about == "-": 84 | new_about = "" 85 | try: 86 | await evt.sender.client(UpdateProfileRequest(about=new_about)) 87 | except AboutTooLongError: 88 | return await evt.reply("The provided about section is too long") 89 | return await evt.reply("About section updated") 90 | 91 | 92 | @command_handler( 93 | needs_auth=True, 94 | help_section=SECTION_AUTH, 95 | help_args="<_new displayname_>", 96 | help_text="Change your Telegram displayname.", 97 | ) 98 | async def displayname(evt: CommandEvent) -> EventID: 99 | if len(evt.args) == 0: 100 | return await evt.reply("**Usage:** `$cmdprefix+sp displayname `") 101 | if evt.sender.is_bot: 102 | return await evt.reply("Bots can't set their own displayname.") 103 | 104 | first_name, last_name = ( 105 | (evt.args[0], "") if len(evt.args) == 1 else (" ".join(evt.args[:-1]), evt.args[-1]) 106 | ) 107 | try: 108 | await evt.sender.client(UpdateProfileRequest(first_name=first_name, last_name=last_name)) 109 | except FirstNameInvalidError: 110 | return await evt.reply("Invalid first name") 111 | await evt.sender.update_info() 112 | return await evt.reply("Displayname updated") 113 | 114 | 115 | def _format_session(sess: Authorization) -> str: 116 | return ( 117 | f"**{sess.app_name} {sess.app_version}** \n" 118 | f" **Platform:** {sess.device_model} {sess.platform} {sess.system_version} \n" 119 | f" **Active:** {sess.date_active} (created {sess.date_created}) \n" 120 | f" **From:** {sess.ip} - {sess.region}, {sess.country}" 121 | ) 122 | 123 | 124 | @command_handler( 125 | needs_auth=True, 126 | help_section=SECTION_AUTH, 127 | help_args="<`list`|`terminate`> [_hash_]", 128 | help_text="View or delete other Telegram sessions.", 129 | ) 130 | async def session(evt: CommandEvent) -> EventID: 131 | if len(evt.args) == 0: 132 | return await evt.reply("**Usage:** `$cmdprefix+sp session [hash]`") 133 | elif evt.sender.is_bot: 134 | return await evt.reply("Bots can't manage their sessions") 135 | cmd = evt.args[0].lower() 136 | if cmd == "list": 137 | res = await evt.sender.client(GetAuthorizationsRequest()) 138 | session_list = res.authorizations 139 | current = [s for s in session_list if s.current][0] 140 | current_text = _format_session(current) 141 | other_text = "\n".join( 142 | f"* {_format_session(sess)} \n **Hash:** {sess.hash}" 143 | for sess in session_list 144 | if not sess.current 145 | ) 146 | return await evt.reply( 147 | f"### Current session\n" 148 | f"{current_text}\n" 149 | f"\n" 150 | f"### Other active sessions\n" 151 | f"{other_text}" 152 | ) 153 | elif cmd == "terminate" and len(evt.args) > 1: 154 | try: 155 | session_hash = int(evt.args[1]) 156 | except ValueError: 157 | return await evt.reply("Hash must be an integer") 158 | try: 159 | ok = await evt.sender.client(ResetAuthorizationRequest(hash=session_hash)) 160 | except HashInvalidError: 161 | return await evt.reply("Invalid session hash.") 162 | except AuthKeyError as e: 163 | if e.message == "FRESH_RESET_AUTHORISATION_FORBIDDEN": 164 | return await evt.reply( 165 | "New sessions can't terminate other sessions. Please wait a while." 166 | ) 167 | raise 168 | if ok: 169 | return await evt.reply("Session terminated successfully.") 170 | else: 171 | return await evt.reply("Session not found.") 172 | else: 173 | return await evt.reply("**Usage:** `$cmdprefix+sp session [hash]`") 174 | -------------------------------------------------------------------------------- /mautrix_telegram/db/__init__.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Database 17 | 18 | from .backfill_queue import Backfill, BackfillType 19 | from .bot_chat import BotChat 20 | from .disappearing_message import DisappearingMessage 21 | from .message import Message 22 | from .portal import Portal 23 | from .puppet import Puppet 24 | from .reaction import Reaction 25 | from .telegram_file import TelegramFile 26 | from .telethon_session import PgSession 27 | from .upgrade import upgrade_table 28 | from .user import User 29 | 30 | 31 | def init(db: Database) -> None: 32 | for table in ( 33 | Portal, 34 | Message, 35 | Reaction, 36 | User, 37 | Puppet, 38 | TelegramFile, 39 | BotChat, 40 | PgSession, 41 | DisappearingMessage, 42 | Backfill, 43 | ): 44 | table.db = db 45 | 46 | 47 | __all__ = [ 48 | "upgrade_table", 49 | "init", 50 | "Portal", 51 | "Message", 52 | "Reaction", 53 | "User", 54 | "Puppet", 55 | "TelegramFile", 56 | "BotChat", 57 | "PgSession", 58 | "DisappearingMessage", 59 | "Backfill", 60 | ] 61 | -------------------------------------------------------------------------------- /mautrix_telegram/db/backfill_queue.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, Any, ClassVar 19 | from datetime import datetime, timedelta 20 | from enum import Enum 21 | import json 22 | 23 | from asyncpg import Record 24 | from attr import dataclass 25 | 26 | from mautrix.types import UserID 27 | from mautrix.util.async_db import Connection, Database 28 | 29 | from ..types import TelegramID 30 | 31 | fake_db = Database.create("") if TYPE_CHECKING else None 32 | 33 | 34 | class BackfillType(Enum): 35 | HISTORICAL = "historical" 36 | SYNC_DIALOG = "sync_dialog" 37 | 38 | 39 | @dataclass 40 | class Backfill: 41 | db: ClassVar[Database] = fake_db 42 | 43 | queue_id: int | None 44 | user_mxid: UserID 45 | priority: int 46 | type: BackfillType 47 | portal_tgid: TelegramID 48 | portal_tg_receiver: TelegramID 49 | anchor_msg_id: TelegramID | None 50 | extra_data: dict[str, Any] 51 | messages_per_batch: int 52 | post_batch_delay: int 53 | max_batches: int 54 | dispatch_time: datetime | None 55 | completed_at: datetime | None 56 | cooldown_timeout: datetime | None 57 | 58 | @staticmethod 59 | def new( 60 | user_mxid: UserID, 61 | priority: int, 62 | type: BackfillType, 63 | portal_tgid: TelegramID, 64 | portal_tg_receiver: TelegramID, 65 | messages_per_batch: int, 66 | anchor_msg_id: TelegramID | None = None, 67 | extra_data: dict[str, Any] | None = None, 68 | post_batch_delay: int = 0, 69 | max_batches: int = -1, 70 | ) -> "Backfill": 71 | return Backfill( 72 | queue_id=None, 73 | user_mxid=user_mxid, 74 | priority=priority, 75 | type=type, 76 | portal_tgid=portal_tgid, 77 | portal_tg_receiver=portal_tg_receiver, 78 | anchor_msg_id=anchor_msg_id, 79 | extra_data=extra_data or {}, 80 | messages_per_batch=messages_per_batch, 81 | post_batch_delay=post_batch_delay, 82 | max_batches=max_batches, 83 | dispatch_time=None, 84 | completed_at=None, 85 | cooldown_timeout=None, 86 | ) 87 | 88 | @classmethod 89 | def _from_row(cls, row: Record | None) -> Backfill | None: 90 | if row is None: 91 | return None 92 | data = {**row} 93 | type = BackfillType(data.pop("type")) 94 | extra_data = json.loads(data.pop("extra_data", None) or "{}") 95 | return cls(**data, type=type, extra_data=extra_data) 96 | 97 | columns = [ 98 | "user_mxid", 99 | "priority", 100 | "type", 101 | "portal_tgid", 102 | "portal_tg_receiver", 103 | "anchor_msg_id", 104 | "extra_data", 105 | "messages_per_batch", 106 | "post_batch_delay", 107 | "max_batches", 108 | "dispatch_time", 109 | "completed_at", 110 | "cooldown_timeout", 111 | ] 112 | columns_str = ",".join(columns) 113 | 114 | @classmethod 115 | async def get_next(cls, user_mxid: UserID) -> Backfill | None: 116 | q = f""" 117 | SELECT queue_id, {cls.columns_str} 118 | FROM backfill_queue 119 | WHERE user_mxid=$1 120 | AND ( 121 | dispatch_time IS NULL 122 | OR ( 123 | dispatch_time < $2 124 | AND completed_at IS NULL 125 | ) 126 | ) 127 | AND ( 128 | cooldown_timeout IS NULL 129 | OR cooldown_timeout < current_timestamp 130 | ) 131 | ORDER BY priority, queue_id 132 | LIMIT 1 133 | """ 134 | return cls._from_row( 135 | await cls.db.fetchrow(q, user_mxid, datetime.now() - timedelta(minutes=15)) 136 | ) 137 | 138 | @classmethod 139 | async def delete_existing( 140 | cls, 141 | user_mxid: UserID, 142 | portal_tgid: int, 143 | portal_tg_receiver: int, 144 | type: BackfillType, 145 | ) -> Backfill | None: 146 | q = f""" 147 | WITH deleted_entries AS ( 148 | DELETE FROM backfill_queue 149 | WHERE user_mxid=$1 150 | AND portal_tgid=$2 151 | AND portal_tg_receiver=$3 152 | AND type=$4 153 | AND dispatch_time IS NULL 154 | AND completed_at IS NULL 155 | RETURNING 1 156 | ) 157 | WITH dispatched_entries AS ( 158 | SELECT 1 FROM backfill_queue 159 | WHERE user_mxid=$1 160 | AND portal_tgid=$2 161 | AND portal_tg_receiver=$3 162 | AND type=$4 163 | AND dispatch_time IS NOT NULL 164 | AND completed_at IS NULL 165 | ) 166 | """ 167 | return cls._from_row( 168 | await cls.db.fetchrow(q, user_mxid, portal_tgid, portal_tg_receiver, type.value) 169 | ) 170 | 171 | @classmethod 172 | async def delete_all(cls, user_mxid: UserID, conn: Connection | None = None) -> None: 173 | await (conn or cls.db).execute("DELETE FROM backfill_queue WHERE user_mxid=$1", user_mxid) 174 | 175 | @classmethod 176 | async def delete_for_portal(cls, tgid: int, tg_receiver: int) -> None: 177 | q = "DELETE FROM backfill_queue WHERE portal_tgid=$1 AND portal_tg_receiver=$2" 178 | await cls.db.execute(q, tgid, tg_receiver) 179 | 180 | async def insert(self) -> list[Backfill]: 181 | delete_q = f""" 182 | DELETE FROM backfill_queue 183 | WHERE user_mxid=$1 184 | AND portal_tgid=$2 185 | AND portal_tg_receiver=$3 186 | AND type=$4 187 | AND dispatch_time IS NULL 188 | AND completed_at IS NULL 189 | RETURNING queue_id, {self.columns_str} 190 | """ 191 | q = f""" 192 | INSERT INTO backfill_queue ({self.columns_str}) 193 | VALUES ({','.join(f'${i+1}' for i in range(len(self.columns)))}) 194 | RETURNING queue_id 195 | """ 196 | async with self.db.acquire() as conn, conn.transaction(): 197 | deleted_rows = await conn.fetch( 198 | delete_q, 199 | self.user_mxid, 200 | self.portal_tgid, 201 | self.portal_tg_receiver, 202 | self.type.value, 203 | ) 204 | self.queue_id = await conn.fetchval( 205 | q, 206 | self.user_mxid, 207 | self.priority, 208 | self.type.value, 209 | self.portal_tgid, 210 | self.portal_tg_receiver, 211 | self.anchor_msg_id, 212 | json.dumps(self.extra_data) if self.extra_data else None, 213 | self.messages_per_batch, 214 | self.post_batch_delay, 215 | self.max_batches, 216 | self.dispatch_time, 217 | self.completed_at, 218 | self.cooldown_timeout, 219 | ) 220 | return [self._from_row(row) for row in deleted_rows] 221 | 222 | async def mark_dispatched(self) -> None: 223 | q = "UPDATE backfill_queue SET dispatch_time=$1 WHERE queue_id=$2" 224 | await self.db.execute(q, datetime.now(), self.queue_id) 225 | 226 | async def mark_done(self) -> None: 227 | q = "UPDATE backfill_queue SET completed_at=$1 WHERE queue_id=$2" 228 | await self.db.execute(q, datetime.now(), self.queue_id) 229 | 230 | async def set_cooldown_timeout(self, timeout: int) -> None: 231 | """ 232 | Set the backfill request to cooldown for ``timeout`` seconds. 233 | """ 234 | q = "UPDATE backfill_queue SET cooldown_timeout=$1 WHERE queue_id=$2" 235 | await self.db.execute(q, datetime.now() + timedelta(seconds=timeout), self.queue_id) 236 | -------------------------------------------------------------------------------- /mautrix_telegram/db/bot_chat.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar 19 | 20 | from asyncpg import Record 21 | from attr import dataclass 22 | 23 | from mautrix.util.async_db import Database 24 | 25 | from ..types import TelegramID 26 | 27 | fake_db = Database.create("") if TYPE_CHECKING else None 28 | 29 | 30 | # Fucking Telegram not telling bots what chats they are in 3:< 31 | @dataclass 32 | class BotChat: 33 | db: ClassVar[Database] = fake_db 34 | 35 | id: TelegramID 36 | type: str 37 | 38 | @classmethod 39 | def _from_row(cls, row: Record | None) -> BotChat | None: 40 | if row is None: 41 | return None 42 | return cls(**row) 43 | 44 | @classmethod 45 | async def delete_by_id(cls, chat_id: TelegramID) -> None: 46 | await cls.db.execute("DELETE FROM bot_chat WHERE id=$1", chat_id) 47 | 48 | @classmethod 49 | async def all(cls) -> list[BotChat]: 50 | rows = await cls.db.fetch("SELECT id, type FROM bot_chat") 51 | return [cls._from_row(row) for row in rows] 52 | 53 | async def insert(self) -> None: 54 | q = "INSERT INTO bot_chat (id, type) VALUES ($1, $2)" 55 | await self.db.execute(q, self.id, self.type) 56 | -------------------------------------------------------------------------------- /mautrix_telegram/db/disappearing_message.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Sumner Evans 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar 19 | 20 | import asyncpg 21 | 22 | from mautrix.bridge import AbstractDisappearingMessage 23 | from mautrix.types import EventID, RoomID 24 | from mautrix.util.async_db import Database 25 | 26 | fake_db = Database.create("") if TYPE_CHECKING else None 27 | 28 | 29 | class DisappearingMessage(AbstractDisappearingMessage): 30 | db: ClassVar[Database] = fake_db 31 | 32 | async def insert(self) -> None: 33 | q = """ 34 | INSERT INTO disappearing_message (room_id, event_id, expiration_seconds, expiration_ts) 35 | VALUES ($1, $2, $3, $4) 36 | """ 37 | await self.db.execute( 38 | q, self.room_id, self.event_id, self.expiration_seconds, self.expiration_ts 39 | ) 40 | 41 | async def update(self) -> None: 42 | q = "UPDATE disappearing_message SET expiration_ts=$3 WHERE room_id=$1 AND event_id=$2" 43 | await self.db.execute(q, self.room_id, self.event_id, self.expiration_ts) 44 | 45 | async def delete(self) -> None: 46 | q = "DELETE from disappearing_message WHERE room_id=$1 AND event_id=$2" 47 | await self.db.execute(q, self.room_id, self.event_id) 48 | 49 | @classmethod 50 | def _from_row(cls, row: asyncpg.Record) -> DisappearingMessage: 51 | return cls(**row) 52 | 53 | @classmethod 54 | async def get(cls, room_id: RoomID, event_id: EventID) -> DisappearingMessage | None: 55 | q = """ 56 | SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message 57 | WHERE room_id=$1 AND mxid=$2 58 | """ 59 | try: 60 | return cls._from_row(await cls.db.fetchrow(q, room_id, event_id)) 61 | except Exception: 62 | return None 63 | 64 | @classmethod 65 | async def get_all_scheduled(cls) -> list[DisappearingMessage]: 66 | q = """ 67 | SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message 68 | WHERE expiration_ts IS NOT NULL 69 | """ 70 | return [cls._from_row(r) for r in await cls.db.fetch(q)] 71 | 72 | @classmethod 73 | async def get_unscheduled_for_room(cls, room_id: RoomID) -> list[DisappearingMessage]: 74 | q = """ 75 | SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message 76 | WHERE room_id = $1 AND expiration_ts IS NULL 77 | """ 78 | return [cls._from_row(r) for r in await cls.db.fetch(q, room_id)] 79 | -------------------------------------------------------------------------------- /mautrix_telegram/db/portal.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, Any, ClassVar 19 | import json 20 | 21 | from asyncpg import Record 22 | from attr import dataclass 23 | import attr 24 | 25 | from mautrix.types import BatchID, ContentURI, EventID, RoomID 26 | from mautrix.util.async_db import Database 27 | 28 | from ..types import TelegramID 29 | 30 | fake_db = Database.create("") if TYPE_CHECKING else None 31 | 32 | 33 | @dataclass 34 | class Portal: 35 | db: ClassVar[Database] = fake_db 36 | 37 | # Telegram chat information 38 | tgid: TelegramID 39 | tg_receiver: TelegramID 40 | peer_type: str 41 | megagroup: bool 42 | 43 | # Matrix portal information 44 | mxid: RoomID | None 45 | avatar_url: ContentURI | None 46 | encrypted: bool 47 | first_event_id: EventID | None 48 | next_batch_id: BatchID | None 49 | base_insertion_id: EventID | None 50 | 51 | sponsored_event_id: EventID | None 52 | sponsored_event_ts: int | None 53 | sponsored_msg_random_id: bytes | None 54 | 55 | # Telegram chat metadata 56 | username: str | None 57 | title: str | None 58 | about: str | None 59 | photo_id: str | None 60 | name_set: bool 61 | avatar_set: bool 62 | 63 | local_config: dict[str, Any] = attr.ib(factory=lambda: {}) 64 | 65 | @classmethod 66 | def _from_row(cls, row: Record | None) -> Portal | None: 67 | if row is None: 68 | return None 69 | data = {**row} 70 | data["local_config"] = json.loads(data.pop("config", None) or "{}") 71 | return cls(**data) 72 | 73 | columns: ClassVar[str] = ", ".join( 74 | ( 75 | "tgid", 76 | "tg_receiver", 77 | "peer_type", 78 | "megagroup", 79 | "mxid", 80 | "avatar_url", 81 | "encrypted", 82 | "first_event_id", 83 | "next_batch_id", 84 | "base_insertion_id", 85 | "sponsored_event_id", 86 | "sponsored_event_ts", 87 | "sponsored_msg_random_id", 88 | "username", 89 | "title", 90 | "about", 91 | "photo_id", 92 | "name_set", 93 | "avatar_set", 94 | "config", 95 | ) 96 | ) 97 | 98 | @classmethod 99 | async def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Portal | None: 100 | q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND tg_receiver=$2" 101 | return cls._from_row(await cls.db.fetchrow(q, tgid, tg_receiver)) 102 | 103 | @classmethod 104 | async def get_by_mxid(cls, mxid: RoomID) -> Portal | None: 105 | q = f"SELECT {cls.columns} FROM portal WHERE mxid=$1" 106 | return cls._from_row(await cls.db.fetchrow(q, mxid)) 107 | 108 | @classmethod 109 | async def find_by_username(cls, username: str) -> Portal | None: 110 | q = f"SELECT {cls.columns} FROM portal WHERE lower(username)=$1" 111 | return cls._from_row(await cls.db.fetchrow(q, username.lower())) 112 | 113 | @classmethod 114 | async def find_private_chats_of(cls, tg_receiver: TelegramID) -> list[Portal]: 115 | q = f"SELECT {cls.columns} FROM portal WHERE tg_receiver=$1 AND peer_type='user'" 116 | return [cls._from_row(row) for row in await cls.db.fetch(q, tg_receiver)] 117 | 118 | @classmethod 119 | async def find_private_chats_with(cls, tgid: TelegramID) -> list[Portal]: 120 | q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND peer_type='user'" 121 | return [cls._from_row(row) for row in await cls.db.fetch(q, tgid)] 122 | 123 | @classmethod 124 | async def all(cls) -> list[Portal]: 125 | rows = await cls.db.fetch(f"SELECT {cls.columns} FROM portal") 126 | return [cls._from_row(row) for row in rows] 127 | 128 | @property 129 | def _values(self): 130 | return ( 131 | self.tgid, 132 | self.tg_receiver, 133 | self.peer_type, 134 | self.mxid, 135 | self.avatar_url, 136 | self.encrypted, 137 | self.first_event_id, 138 | self.next_batch_id, 139 | self.base_insertion_id, 140 | self.sponsored_event_id, 141 | self.sponsored_event_ts, 142 | self.sponsored_msg_random_id, 143 | self.username, 144 | self.title, 145 | self.about, 146 | self.photo_id, 147 | self.name_set, 148 | self.avatar_set, 149 | self.megagroup, 150 | json.dumps(self.local_config) if self.local_config else None, 151 | ) 152 | 153 | async def save(self) -> None: 154 | q = """ 155 | UPDATE portal 156 | SET mxid=$4, avatar_url=$5, encrypted=$6, 157 | first_event_id=$7, next_batch_id=$8, base_insertion_id=$9, 158 | sponsored_event_id=$10, sponsored_event_ts=$11, sponsored_msg_random_id=$12, 159 | username=$13, title=$14, about=$15, photo_id=$16, name_set=$17, avatar_set=$18, 160 | megagroup=$19, config=$20 161 | WHERE tgid=$1 AND tg_receiver=$2 AND (peer_type=$3 OR true) 162 | """ 163 | await self.db.execute(q, *self._values) 164 | 165 | async def update_id(self, id: TelegramID, peer_type: str) -> None: 166 | q = ( 167 | "UPDATE portal SET tgid=$1, tg_receiver=$1, peer_type=$2 " 168 | "WHERE tgid=$3 AND tg_receiver=$3" 169 | ) 170 | clear_queue = "DELETE FROM backfill_queue WHERE portal_tgid=$1 AND portal_tg_receiver=$2" 171 | async with self.db.acquire() as conn, conn.transaction(): 172 | await conn.execute(clear_queue, self.tgid, self.tg_receiver) 173 | await conn.execute(q, id, peer_type, self.tgid) 174 | self.tgid = id 175 | self.tg_receiver = id 176 | self.peer_type = peer_type 177 | 178 | async def insert(self) -> None: 179 | q = """ 180 | INSERT INTO portal ( 181 | tgid, tg_receiver, peer_type, mxid, avatar_url, encrypted, 182 | first_event_id, base_insertion_id, next_batch_id, 183 | sponsored_event_id, sponsored_event_ts, sponsored_msg_random_id, 184 | username, title, about, photo_id, name_set, avatar_set, megagroup, config 185 | ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, 186 | $19, $20) 187 | """ 188 | await self.db.execute(q, *self._values) 189 | 190 | async def delete(self) -> None: 191 | q = "DELETE FROM portal WHERE tgid=$1 AND tg_receiver=$2" 192 | await self.db.execute(q, self.tgid, self.tg_receiver) 193 | -------------------------------------------------------------------------------- /mautrix_telegram/db/puppet.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar 19 | 20 | from asyncpg import Record 21 | from attr import dataclass 22 | from yarl import URL 23 | 24 | from mautrix.types import ContentURI, SyncToken, UserID 25 | from mautrix.util.async_db import Database 26 | 27 | from ..types import TelegramID 28 | 29 | fake_db = Database.create("") if TYPE_CHECKING else None 30 | 31 | 32 | @dataclass 33 | class Puppet: 34 | db: ClassVar[Database] = fake_db 35 | 36 | id: TelegramID 37 | 38 | is_registered: bool 39 | 40 | displayname: str | None 41 | displayname_source: TelegramID | None 42 | displayname_contact: bool 43 | displayname_quality: int 44 | disable_updates: bool 45 | username: str | None 46 | phone: str | None 47 | photo_id: str | None 48 | avatar_url: ContentURI | None 49 | name_set: bool 50 | avatar_set: bool 51 | contact_info_set: bool 52 | is_bot: bool | None 53 | is_channel: bool 54 | is_premium: bool 55 | 56 | custom_mxid: UserID | None 57 | access_token: str | None 58 | next_batch: SyncToken | None 59 | base_url: URL | None 60 | 61 | @classmethod 62 | def _from_row(cls, row: Record | None) -> Puppet | None: 63 | if row is None: 64 | return None 65 | data = {**row} 66 | base_url = data.pop("base_url", None) 67 | return cls(**data, base_url=URL(base_url) if base_url else None) 68 | 69 | columns: ClassVar[str] = ( 70 | "id, is_registered, displayname, displayname_source, displayname_contact, " 71 | "displayname_quality, disable_updates, username, phone, photo_id, avatar_url, " 72 | "name_set, avatar_set, contact_info_set, is_bot, is_channel, is_premium, " 73 | "custom_mxid, access_token, next_batch, base_url" 74 | ) 75 | 76 | @classmethod 77 | async def all_with_custom_mxid(cls) -> list[Puppet]: 78 | q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid<>''" 79 | return [cls._from_row(row) for row in await cls.db.fetch(q)] 80 | 81 | @classmethod 82 | async def get_by_tgid(cls, tgid: TelegramID) -> Puppet | None: 83 | q = f"SELECT {cls.columns} FROM puppet WHERE id=$1" 84 | return cls._from_row(await cls.db.fetchrow(q, tgid)) 85 | 86 | @classmethod 87 | async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None: 88 | q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid=$1" 89 | return cls._from_row(await cls.db.fetchrow(q, mxid)) 90 | 91 | @classmethod 92 | async def find_by_username(cls, username: str) -> Puppet | None: 93 | q = f"SELECT {cls.columns} FROM puppet WHERE lower(username)=$1" 94 | return cls._from_row(await cls.db.fetchrow(q, username.lower())) 95 | 96 | @property 97 | def _values(self): 98 | return ( 99 | self.id, 100 | self.is_registered, 101 | self.displayname, 102 | self.displayname_source, 103 | self.displayname_contact, 104 | self.displayname_quality, 105 | self.disable_updates, 106 | self.username, 107 | self.phone, 108 | self.photo_id, 109 | self.avatar_url, 110 | self.name_set, 111 | self.avatar_set, 112 | self.contact_info_set, 113 | self.is_bot, 114 | self.is_channel, 115 | self.is_premium, 116 | self.custom_mxid, 117 | self.access_token, 118 | self.next_batch, 119 | str(self.base_url) if self.base_url else None, 120 | ) 121 | 122 | async def save(self) -> None: 123 | q = """ 124 | UPDATE puppet 125 | SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5, 126 | displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10, 127 | avatar_url=$11, name_set=$12, avatar_set=$13, contact_info_set=$14, is_bot=$15, 128 | is_channel=$16, is_premium=$17, custom_mxid=$18, access_token=$19, next_batch=$20, 129 | base_url=$21 130 | WHERE id=$1 131 | """ 132 | await self.db.execute(q, *self._values) 133 | 134 | async def insert(self) -> None: 135 | q = """ 136 | INSERT INTO puppet ( 137 | id, is_registered, displayname, displayname_source, displayname_contact, 138 | displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set, 139 | avatar_set, contact_info_set, is_bot, is_channel, is_premium, custom_mxid, 140 | access_token, next_batch, base_url 141 | ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, 142 | $19, $20, $21) 143 | """ 144 | await self.db.execute(q, *self._values) 145 | -------------------------------------------------------------------------------- /mautrix_telegram/db/reaction.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar 19 | 20 | from asyncpg import Record 21 | from attr import dataclass 22 | from telethon.tl.types import ReactionCustomEmoji, ReactionEmoji, TypeReaction 23 | 24 | from mautrix.types import EventID, RoomID 25 | from mautrix.util.async_db import Database 26 | 27 | from ..types import TelegramID 28 | 29 | fake_db = Database.create("") if TYPE_CHECKING else None 30 | 31 | 32 | @dataclass 33 | class Reaction: 34 | db: ClassVar[Database] = fake_db 35 | 36 | mxid: EventID 37 | mx_room: RoomID 38 | msg_mxid: EventID 39 | tg_sender: TelegramID 40 | reaction: str 41 | 42 | @classmethod 43 | def _from_row(cls, row: Record | None) -> Reaction | None: 44 | if row is None: 45 | return None 46 | return cls(**row) 47 | 48 | columns: ClassVar[str] = "mxid, mx_room, msg_mxid, tg_sender, reaction" 49 | 50 | @classmethod 51 | async def delete_all(cls, mx_room: RoomID) -> None: 52 | await cls.db.execute("DELETE FROM reaction WHERE mx_room=$1", mx_room) 53 | 54 | @classmethod 55 | async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None: 56 | q = f"SELECT {cls.columns} FROM reaction WHERE mxid=$1 AND mx_room=$2" 57 | return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room)) 58 | 59 | @classmethod 60 | async def get_by_sender( 61 | cls, mxid: EventID, mx_room: RoomID, tg_sender: TelegramID 62 | ) -> list[Reaction]: 63 | q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3" 64 | rows = await cls.db.fetch(q, mxid, mx_room, tg_sender) 65 | return [cls._from_row(row) for row in rows] 66 | 67 | @classmethod 68 | async def get_all_by_message(cls, mxid: EventID, mx_room: RoomID) -> list[Reaction]: 69 | q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2" 70 | rows = await cls.db.fetch(q, mxid, mx_room) 71 | return [cls._from_row(row) for row in rows] 72 | 73 | @property 74 | def telegram(self) -> TypeReaction: 75 | if self.reaction.isdecimal(): 76 | return ReactionCustomEmoji(document_id=int(self.reaction)) 77 | else: 78 | return ReactionEmoji(emoticon=self.reaction) 79 | 80 | @property 81 | def _values(self): 82 | return ( 83 | self.mxid, 84 | self.mx_room, 85 | self.msg_mxid, 86 | self.tg_sender, 87 | self.reaction, 88 | ) 89 | 90 | async def save(self) -> None: 91 | q = """ 92 | INSERT INTO reaction (mxid, mx_room, msg_mxid, tg_sender, reaction) 93 | VALUES ($1, $2, $3, $4, $5) ON CONFLICT (msg_mxid, mx_room, tg_sender, reaction) 94 | DO UPDATE SET mxid=excluded.mxid 95 | """ 96 | await self.db.execute(q, *self._values) 97 | 98 | async def delete(self) -> None: 99 | q = "DELETE FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3 AND reaction=$4" 100 | await self.db.execute(q, self.msg_mxid, self.mx_room, self.tg_sender, self.reaction) 101 | -------------------------------------------------------------------------------- /mautrix_telegram/db/telegram_file.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar 19 | 20 | from asyncpg import Record 21 | from attr import dataclass 22 | 23 | from mautrix.types import ContentURI, EncryptedFile 24 | from mautrix.util.async_db import Database, Scheme 25 | 26 | fake_db = Database.create("") if TYPE_CHECKING else None 27 | 28 | 29 | @dataclass 30 | class TelegramFile: 31 | db: ClassVar[Database] = fake_db 32 | 33 | id: str 34 | mxc: ContentURI 35 | mime_type: str 36 | was_converted: bool 37 | timestamp: int 38 | size: int | None 39 | width: int | None 40 | height: int | None 41 | decryption_info: EncryptedFile | None 42 | thumbnail: TelegramFile | None = None 43 | 44 | columns: ClassVar[str] = ( 45 | "id, mxc, mime_type, was_converted, timestamp, size, width, height, thumbnail, " 46 | "decryption_info" 47 | ) 48 | 49 | @classmethod 50 | def _from_row(cls, row: Record | None) -> TelegramFile | None: 51 | if row is None: 52 | return None 53 | data = {**row} 54 | data.pop("thumbnail", None) 55 | decryption_info = data.pop("decryption_info", None) 56 | return cls( 57 | **data, 58 | thumbnail=None, 59 | decryption_info=EncryptedFile.parse_json(decryption_info) if decryption_info else None, 60 | ) 61 | 62 | @classmethod 63 | async def get_many(cls, loc_ids: list[str]) -> list[TelegramFile]: 64 | if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH): 65 | q = f"SELECT {cls.columns} FROM telegram_file WHERE id=ANY($1)" 66 | rows = await cls.db.fetch(q, loc_ids) 67 | else: 68 | tgid_placeholders = ("?," * len(loc_ids)).rstrip(",") 69 | q = f"SELECT {cls.columns} FROM telegram_file WHERE id IN ({tgid_placeholders})" 70 | rows = await cls.db.fetch(q, *loc_ids) 71 | return [cls._from_row(row) for row in rows] 72 | 73 | @classmethod 74 | async def get(cls, loc_id: str, *, _thumbnail: bool = False) -> TelegramFile | None: 75 | q = f"SELECT {cls.columns} FROM telegram_file WHERE id=$1" 76 | row = await cls.db.fetchrow(q, loc_id) 77 | file = cls._from_row(row) 78 | if file is None: 79 | return None 80 | try: 81 | thumbnail_id = row["thumbnail"] 82 | except KeyError: 83 | thumbnail_id = None 84 | if thumbnail_id and not _thumbnail: 85 | file.thumbnail = await cls.get(thumbnail_id, _thumbnail=True) 86 | return file 87 | 88 | @classmethod 89 | async def find_by_mxc(cls, mxc: ContentURI) -> TelegramFile | None: 90 | q = f"SELECT {cls.columns} FROM telegram_file WHERE mxc=$1" 91 | return cls._from_row(await cls.db.fetchrow(q, mxc)) 92 | 93 | async def insert(self) -> None: 94 | q = ( 95 | "INSERT INTO telegram_file (id, mxc, mime_type, was_converted, timestamp," 96 | " size, width, height, thumbnail, decryption_info) " 97 | "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)" 98 | ) 99 | await self.db.execute( 100 | q, 101 | self.id, 102 | self.mxc, 103 | self.mime_type, 104 | self.was_converted, 105 | self.timestamp, 106 | self.size, 107 | self.width, 108 | self.height, 109 | self.thumbnail.id if self.thumbnail else None, 110 | self.decryption_info.json() if self.decryption_info else None, 111 | ) 112 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/__init__.py: -------------------------------------------------------------------------------- 1 | from mautrix.util.async_db import UpgradeTable 2 | 3 | upgrade_table = UpgradeTable() 4 | 5 | from . import ( 6 | v01_initial_revision, 7 | v02_sponsored_events, 8 | v03_reactions, 9 | v04_disappearing_messages, 10 | v05_channel_ghosts, 11 | v06_puppet_avatar_url, 12 | v07_puppet_phone_number, 13 | v08_portal_first_event, 14 | v09_puppet_username_index, 15 | v10_more_backfill_fields, 16 | v11_backfill_queue, 17 | v12_message_sender, 18 | v13_multiple_reactions, 19 | v14_puppet_custom_mxid_index, 20 | v15_backfill_anchor_id, 21 | v16_backfill_type, 22 | v17_message_find_recent, 23 | v18_puppet_contact_info_set, 24 | ) 25 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v01_initial_revision.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from mautrix.util.async_db import Connection, Scheme 19 | 20 | from . import upgrade_table 21 | from .v00_latest_revision import create_latest_tables, latest_version 22 | 23 | legacy_version_query = "SELECT version_num FROM alembic_version" 24 | last_legacy_version = "bfc0a39bfe02" 25 | 26 | 27 | async def first_upgrade_target(conn: Connection, scheme: Scheme) -> int: 28 | is_legacy = await conn.table_exists("alembic_version") 29 | # If it's a legacy db, the upgrade process will go to v1 and run each migration up to latest. 30 | # If it's a new db, we'll create the latest tables directly (see create_latest_tables call). 31 | return 1 if is_legacy else latest_version 32 | 33 | 34 | @upgrade_table.register(description="Initial asyncpg revision", upgrades_to=first_upgrade_target) 35 | async def upgrade_v1(conn: Connection, scheme: Scheme) -> int: 36 | is_legacy = await conn.table_exists("alembic_version") 37 | if is_legacy: 38 | await migrate_legacy_to_v1(conn, scheme) 39 | return 1 40 | else: 41 | return await create_latest_tables(conn, scheme) 42 | 43 | 44 | async def drop_constraints(conn: Connection, table: str, contype: str) -> None: 45 | q = ( 46 | "SELECT conname FROM pg_constraint con INNER JOIN pg_class rel ON rel.oid=con.conrelid " 47 | f"WHERE rel.relname='{table}' AND contype='{contype}'" 48 | ) 49 | names = [row["conname"] for row in await conn.fetch(q)] 50 | drops = ", ".join(f"DROP CONSTRAINT {name}" for name in names) 51 | await conn.execute(f"ALTER TABLE {table} {drops}") 52 | 53 | 54 | async def migrate_legacy_to_v1(conn: Connection, scheme: Scheme) -> None: 55 | legacy_version = await conn.fetchval(legacy_version_query) 56 | if legacy_version != last_legacy_version: 57 | raise RuntimeError( 58 | "Legacy database is not on last version. " 59 | "Please upgrade the old database with alembic or drop it completely first." 60 | ) 61 | if scheme != Scheme.SQLITE: 62 | await drop_constraints(conn, "contact", contype="f") 63 | await conn.execute( 64 | """ 65 | ALTER TABLE contact 66 | ADD CONSTRAINT contact_user_fkey FOREIGN KEY (contact) REFERENCES puppet(id) 67 | ON DELETE CASCADE ON UPDATE CASCADE, 68 | ADD CONSTRAINT contact_contact_fkey FOREIGN KEY ("user") REFERENCES "user"(tgid) 69 | ON DELETE CASCADE ON UPDATE CASCADE 70 | """ 71 | ) 72 | await drop_constraints(conn, "telethon_sessions", contype="p") 73 | await conn.execute( 74 | """ 75 | ALTER TABLE telethon_sessions 76 | ADD CONSTRAINT telethon_sessions_pkey PRIMARY KEY (session_id) 77 | """ 78 | ) 79 | await drop_constraints(conn, "telegram_file", contype="f") 80 | await conn.execute( 81 | """ 82 | ALTER TABLE telegram_file 83 | ADD CONSTRAINT fk_file_thumbnail 84 | FOREIGN KEY (thumbnail) REFERENCES telegram_file(id) 85 | ON UPDATE CASCADE ON DELETE SET NULL 86 | """ 87 | ) 88 | await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP IDENTITY IF EXISTS") 89 | await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP DEFAULT") 90 | await conn.execute("DROP SEQUENCE IF EXISTS puppet_id_seq") 91 | await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP IDENTITY IF EXISTS") 92 | await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP DEFAULT") 93 | await conn.execute("DROP SEQUENCE IF EXISTS bot_chat_id_seq") 94 | await conn.execute("ALTER TABLE portal ALTER COLUMN config TYPE jsonb USING config::jsonb") 95 | await conn.execute( 96 | "ALTER TABLE telegram_file ALTER COLUMN decryption_info TYPE jsonb " 97 | "USING decryption_info::jsonb" 98 | ) 99 | await varchar_to_text(conn) 100 | else: 101 | await conn.execute( 102 | """CREATE TABLE telethon_sessions_new ( 103 | session_id TEXT PRIMARY KEY, 104 | dc_id INTEGER, 105 | server_address TEXT, 106 | port INTEGER, 107 | auth_key bytea 108 | )""" 109 | ) 110 | await conn.execute( 111 | """ 112 | INSERT INTO telethon_sessions_new (session_id, dc_id, server_address, port, auth_key) 113 | SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions 114 | """ 115 | ) 116 | await conn.execute("DROP TABLE telethon_sessions") 117 | await conn.execute("ALTER TABLE telethon_sessions_new RENAME TO telethon_sessions") 118 | 119 | await update_state_store(conn, scheme) 120 | await conn.execute('ALTER TABLE "user" ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false') 121 | await conn.execute("ALTER TABLE puppet RENAME COLUMN matrix_registered TO is_registered") 122 | await conn.execute("DROP TABLE telethon_version") 123 | await conn.execute("DROP TABLE alembic_version") 124 | 125 | 126 | async def update_state_store(conn: Connection, scheme: Scheme) -> None: 127 | # The Matrix state store already has more or less the correct schema, so set the version 128 | await conn.execute("CREATE TABLE mx_version (version INTEGER PRIMARY KEY)") 129 | await conn.execute("INSERT INTO mx_version (version) VALUES (2)") 130 | await conn.execute("UPDATE mx_user_profile SET membership='LEAVE' WHERE membership='LEFT'") 131 | if scheme != Scheme.SQLITE: 132 | # Also add the membership type on postgres 133 | await conn.execute( 134 | "CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock')" 135 | ) 136 | await conn.execute( 137 | "ALTER TABLE mx_user_profile ALTER COLUMN membership TYPE membership " 138 | "USING LOWER(membership)::membership" 139 | ) 140 | else: 141 | # On SQLite there's no custom type, but we still want to lowercase everything 142 | await conn.execute("UPDATE mx_user_profile SET membership=LOWER(membership)") 143 | 144 | 145 | async def varchar_to_text(conn: Connection) -> None: 146 | columns_to_adjust = { 147 | "user": ("mxid", "tg_username", "tg_phone"), 148 | "portal": ( 149 | "peer_type", 150 | "mxid", 151 | "username", 152 | "title", 153 | "about", 154 | "photo_id", 155 | "avatar_url", 156 | "config", 157 | ), 158 | "message": ("mxid", "mx_room"), 159 | "puppet": ( 160 | "displayname", 161 | "username", 162 | "photo_id", 163 | "access_token", 164 | "custom_mxid", 165 | "next_batch", 166 | "base_url", 167 | ), 168 | "bot_chat": ("type",), 169 | "telegram_file": ("id", "mxc", "mime_type", "thumbnail"), 170 | # Phone is a bigint in the old schema, which is safe, but we don't do math on it, 171 | # so let's change it to a string 172 | "telethon_entities": ("session_id", "username", "name", "phone"), 173 | "telethon_sent_files": ("session_id",), 174 | "telethon_sessions": ("session_id", "server_address"), 175 | "telethon_update_state": ("session_id",), 176 | "mx_room_state": ("room_id",), 177 | "mx_user_profile": ("room_id", "user_id", "displayname", "avatar_url"), 178 | } 179 | for table, columns in columns_to_adjust.items(): 180 | for column in columns: 181 | await conn.execute(f'ALTER TABLE "{table}" ALTER COLUMN {column} TYPE TEXT') 182 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v02_sponsored_events.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add column to store sponsored message event ID in channels") 22 | async def upgrade_v2(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_event_id TEXT") 24 | await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_event_ts BIGINT") 25 | await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_msg_random_id bytea") 26 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v03_reactions.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add support for reactions") 22 | async def upgrade_v3(conn: Connection, scheme: str) -> None: 23 | await conn.execute( 24 | """CREATE TABLE reaction ( 25 | mxid TEXT NOT NULL, 26 | mx_room TEXT NOT NULL, 27 | msg_mxid TEXT NOT NULL, 28 | tg_sender BIGINT, 29 | reaction TEXT NOT NULL, 30 | 31 | PRIMARY KEY (msg_mxid, mx_room, tg_sender), 32 | UNIQUE (mxid, mx_room) 33 | )""" 34 | ) 35 | if scheme != "sqlite": 36 | await conn.execute("DELETE FROM message WHERE mxid IS NULL OR mx_room IS NULL") 37 | await conn.execute("ALTER TABLE message ALTER COLUMN mxid SET NOT NULL") 38 | await conn.execute("ALTER TABLE message ALTER COLUMN mx_room SET NOT NULL") 39 | await conn.execute("ALTER TABLE message ADD COLUMN content_hash bytea") 40 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v04_disappearing_messages.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add support for disappearing messages") 22 | async def upgrade_v4(conn: Connection) -> None: 23 | await conn.execute( 24 | """CREATE TABLE disappearing_message ( 25 | room_id TEXT, 26 | event_id TEXT, 27 | expiration_seconds BIGINT, 28 | expiration_ts BIGINT, 29 | 30 | PRIMARY KEY (room_id, event_id) 31 | )""" 32 | ) 33 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v05_channel_ghosts.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection, Scheme 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add separate ghost users for channel senders") 22 | async def upgrade_v5(conn: Connection, scheme: str) -> None: 23 | await conn.execute("ALTER TABLE puppet ADD COLUMN is_channel BOOLEAN NOT NULL DEFAULT false") 24 | if scheme == Scheme.POSTGRES: 25 | await conn.execute("ALTER TABLE puppet ALTER COLUMN is_channel DROP DEFAULT") 26 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v06_puppet_avatar_url.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Store avatar mxc URI in puppet table") 22 | async def upgrade_v6(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_url TEXT") 24 | await conn.execute("ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false") 25 | await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false") 26 | await conn.execute("UPDATE puppet SET name_set=true WHERE displayname<>''") 27 | await conn.execute("UPDATE puppet SET avatar_set=true WHERE photo_id<>''") 28 | await conn.execute("ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false") 29 | await conn.execute("ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false") 30 | await conn.execute("UPDATE portal SET name_set=true WHERE title<>'' AND mxid<>''") 31 | await conn.execute("UPDATE portal SET avatar_set=true WHERE photo_id<>'' AND mxid<>''") 32 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v07_puppet_phone_number.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Store phone number in puppet table") 22 | async def upgrade_v7(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE puppet ADD COLUMN phone TEXT") 24 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v08_portal_first_event.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Track first event ID in portals for infinite backfilling") 22 | async def upgrade_v8(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE portal ADD COLUMN first_event_id TEXT") 24 | await conn.execute("ALTER TABLE portal ADD COLUMN next_batch_id TEXT") 25 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v09_puppet_username_index.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add index to puppet username column") 22 | async def upgrade_v9(conn: Connection) -> None: 23 | await conn.execute("CREATE INDEX puppet_username_idx ON puppet(LOWER(username))") 24 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v10_more_backfill_fields.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add more portal columns related to infinite backfill") 22 | async def upgrade_v10(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE portal ADD COLUMN base_insertion_id TEXT") 24 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v11_backfill_queue.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan, Sumner Evans 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection, Scheme 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add the backfill queue table") 22 | async def upgrade_v11(conn: Connection, scheme: Scheme) -> None: 23 | gen = "" 24 | if scheme in (Scheme.POSTGRES, Scheme.COCKROACH): 25 | gen = "GENERATED ALWAYS AS IDENTITY" 26 | await conn.execute( 27 | f""" 28 | CREATE TABLE backfill_queue ( 29 | queue_id INTEGER PRIMARY KEY {gen}, 30 | user_mxid TEXT, 31 | priority INTEGER NOT NULL, 32 | portal_tgid BIGINT, 33 | portal_tg_receiver BIGINT, 34 | messages_per_batch INTEGER NOT NULL, 35 | post_batch_delay INTEGER NOT NULL, 36 | max_batches INTEGER NOT NULL, 37 | dispatch_time TIMESTAMP, 38 | completed_at TIMESTAMP, 39 | cooldown_timeout TIMESTAMP, 40 | FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON DELETE CASCADE ON UPDATE CASCADE, 41 | FOREIGN KEY (portal_tgid, portal_tg_receiver) 42 | REFERENCES portal(tgid, tg_receiver) ON DELETE CASCADE 43 | ) 44 | """ 45 | ) 46 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v12_message_sender.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Store sender in message table") 22 | async def upgrade_v12(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE message ADD COLUMN sender_mxid TEXT") 24 | await conn.execute("ALTER TABLE message ADD COLUMN sender BIGINT") 25 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v13_multiple_reactions.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection, Scheme 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Allow multiple reactions from the same user") 22 | async def upgrade_v13(conn: Connection, scheme: Scheme) -> None: 23 | await conn.execute("CREATE INDEX telegram_file_mxc_idx ON telegram_file(mxc)") 24 | await conn.execute('ALTER TABLE "user" ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false') 25 | await conn.execute("ALTER TABLE puppet ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false") 26 | if scheme == Scheme.POSTGRES: 27 | await conn.execute( 28 | """ 29 | ALTER TABLE reaction 30 | DROP CONSTRAINT reaction_pkey, 31 | ADD CONSTRAINT reaction_pkey PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction) 32 | """ 33 | ) 34 | else: 35 | await conn.execute( 36 | """CREATE TABLE new_reaction ( 37 | mxid TEXT NOT NULL, 38 | mx_room TEXT NOT NULL, 39 | msg_mxid TEXT NOT NULL, 40 | tg_sender BIGINT, 41 | reaction TEXT NOT NULL, 42 | 43 | PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction), 44 | UNIQUE (mxid, mx_room) 45 | )""" 46 | ) 47 | await conn.execute( 48 | """ 49 | INSERT INTO new_reaction (mxid, mx_room, msg_mxid, tg_sender, reaction) 50 | SELECT mxid, mx_room, msg_mxid, tg_sender, reaction FROM reaction 51 | """ 52 | ) 53 | await conn.execute("DROP TABLE reaction") 54 | await conn.execute("ALTER TABLE new_reaction RENAME TO reaction") 55 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v14_puppet_custom_mxid_index.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add index to puppet custom_mxid column") 22 | async def upgrade_v14(conn: Connection) -> None: 23 | await conn.execute("CREATE INDEX IF NOT EXISTS puppet_custom_mxid_idx ON puppet(custom_mxid)") 24 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v15_backfill_anchor_id.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Store lowest message ID in backfill queue") 22 | async def upgrade_v15(conn: Connection) -> None: 23 | await conn.execute("ALTER TABLE backfill_queue ADD COLUMN anchor_msg_id BIGINT") 24 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v16_backfill_type.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection, Scheme 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add type for backfill queue items") 22 | async def upgrade_v16(conn: Connection, scheme: Scheme) -> None: 23 | await conn.execute( 24 | "ALTER TABLE backfill_queue ADD COLUMN type TEXT NOT NULL DEFAULT 'historical'" 25 | ) 26 | await conn.execute("ALTER TABLE backfill_queue ADD COLUMN extra_data jsonb") 27 | if scheme != Scheme.SQLITE: 28 | await conn.execute("ALTER TABLE backfill_queue ALTER COLUMN type DROP DEFAULT") 29 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v17_message_find_recent.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add index for Message.find_recent") 22 | async def upgrade_v17(conn: Connection) -> None: 23 | await conn.execute( 24 | "CREATE INDEX IF NOT EXISTS message_mx_room_and_tgid_idx ON message(mx_room, tgid DESC)" 25 | ) 26 | -------------------------------------------------------------------------------- /mautrix_telegram/db/upgrade/v18_puppet_contact_info_set.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.async_db import Connection 17 | 18 | from . import upgrade_table 19 | 20 | 21 | @upgrade_table.register(description="Add contact_info_set column to puppet table") 22 | async def upgrade_v18(conn: Connection) -> None: 23 | await conn.execute( 24 | "ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false" 25 | ) 26 | -------------------------------------------------------------------------------- /mautrix_telegram/db/user.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import TYPE_CHECKING, ClassVar, Iterable 19 | 20 | from asyncpg import Record 21 | from attr import dataclass 22 | 23 | from mautrix.types import UserID 24 | from mautrix.util.async_db import Connection, Database, Scheme 25 | 26 | from ..types import TelegramID 27 | from .backfill_queue import Backfill 28 | 29 | fake_db = Database.create("") if TYPE_CHECKING else None 30 | 31 | 32 | @dataclass 33 | class User: 34 | db: ClassVar[Database] = fake_db 35 | 36 | mxid: UserID 37 | tgid: TelegramID | None 38 | tg_username: str | None 39 | tg_phone: str | None 40 | is_bot: bool 41 | is_premium: bool 42 | saved_contacts: int 43 | 44 | @classmethod 45 | def _from_row(cls, row: Record | None) -> User | None: 46 | if row is None: 47 | return None 48 | return cls(**row) 49 | 50 | columns: ClassVar[str] = ", ".join( 51 | ("mxid", "tgid", "tg_username", "tg_phone", "is_bot", "is_premium", "saved_contacts") 52 | ) 53 | 54 | @classmethod 55 | async def get_by_tgid(cls, tgid: TelegramID) -> User | None: 56 | q = f'SELECT {cls.columns} FROM "user" WHERE tgid=$1' 57 | return cls._from_row(await cls.db.fetchrow(q, tgid)) 58 | 59 | @classmethod 60 | async def get_by_mxid(cls, mxid: UserID) -> User | None: 61 | q = f'SELECT {cls.columns} FROM "user" WHERE mxid=$1' 62 | return cls._from_row(await cls.db.fetchrow(q, mxid)) 63 | 64 | @classmethod 65 | async def find_by_username(cls, username: str) -> User | None: 66 | q = f'SELECT {cls.columns} FROM "user" WHERE lower(tg_username)=$1' 67 | return cls._from_row(await cls.db.fetchrow(q, username.lower())) 68 | 69 | @classmethod 70 | async def all_with_tgid(cls) -> list[User]: 71 | q = f'SELECT {cls.columns} FROM "user" WHERE tgid IS NOT NULL' 72 | return [cls._from_row(row) for row in await cls.db.fetch(q)] 73 | 74 | async def delete(self) -> None: 75 | await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid) 76 | 77 | async def remove_tgid(self) -> None: 78 | async with self.db.acquire() as conn, conn.transaction(): 79 | if self.tgid: 80 | await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid) 81 | await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid) 82 | await Backfill.delete_all(self.mxid, conn=conn) 83 | self.tgid = None 84 | self.tg_username = None 85 | self.tg_phone = None 86 | self.is_bot = False 87 | self.is_premium = False 88 | self.saved_contacts = 0 89 | await self.save(conn=conn) 90 | 91 | @property 92 | def _values(self): 93 | return ( 94 | self.mxid, 95 | self.tgid, 96 | self.tg_username, 97 | self.tg_phone, 98 | self.is_bot, 99 | self.is_premium, 100 | self.saved_contacts, 101 | ) 102 | 103 | async def save(self, conn: Connection | None = None) -> None: 104 | q = """ 105 | UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, is_premium=$6, 106 | saved_contacts=$7 107 | WHERE mxid=$1 108 | """ 109 | await (conn or self.db).execute(q, *self._values) 110 | 111 | async def insert(self) -> None: 112 | q = """ 113 | INSERT INTO "user" (mxid, tgid, tg_username, tg_phone, is_bot, is_premium, saved_contacts) 114 | VALUES ($1, $2, $3, $4, $5, $6, $7) 115 | """ 116 | await self.db.execute(q, *self._values) 117 | 118 | async def get_contacts(self) -> list[TelegramID]: 119 | rows = await self.db.fetch('SELECT contact FROM contact WHERE "user"=$1', self.tgid) 120 | return [TelegramID(row["contact"]) for row in rows] 121 | 122 | async def set_contacts(self, puppets: Iterable[TelegramID]) -> None: 123 | columns = ["user", "contact"] 124 | records = [(self.tgid, puppet_id) for puppet_id in puppets] 125 | async with self.db.acquire() as conn, conn.transaction(): 126 | await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid) 127 | if self.db.scheme == Scheme.POSTGRES: 128 | await conn.copy_records_to_table("contact", records=records, columns=columns) 129 | else: 130 | q = 'INSERT INTO contact ("user", contact) VALUES ($1, $2)' 131 | await conn.executemany(q, records) 132 | 133 | async def get_portals(self) -> list[tuple[TelegramID, TelegramID]]: 134 | q = 'SELECT portal, portal_receiver FROM user_portal WHERE "user"=$1' 135 | rows = await self.db.fetch(q, self.tgid) 136 | return [(TelegramID(row["portal"]), TelegramID(row["portal_receiver"])) for row in rows] 137 | 138 | async def set_portals(self, portals: Iterable[tuple[TelegramID, TelegramID]]) -> None: 139 | columns = ["user", "portal", "portal_receiver"] 140 | records = [(self.tgid, tgid, tg_receiver) for tgid, tg_receiver in portals] 141 | async with self.db.acquire() as conn, conn.transaction(): 142 | await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid) 143 | if self.db.scheme == Scheme.POSTGRES: 144 | await conn.copy_records_to_table("user_portal", records=records, columns=columns) 145 | else: 146 | q = 'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3)' 147 | await conn.executemany(q, records) 148 | 149 | async def register_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None: 150 | q = ( 151 | 'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3) ' 152 | 'ON CONFLICT ("user", portal, portal_receiver) DO NOTHING' 153 | ) 154 | await self.db.execute(q, self.tgid, tgid, tg_receiver) 155 | 156 | async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None: 157 | q = 'DELETE FROM user_portal WHERE "user"=$1 AND portal=$2 AND portal_receiver=$3' 158 | await self.db.execute(q, self.tgid, tgid, tg_receiver) 159 | -------------------------------------------------------------------------------- /mautrix_telegram/formatter/__init__.py: -------------------------------------------------------------------------------- 1 | from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram 2 | from .from_telegram import telegram_text_to_matrix_html, telegram_to_matrix 3 | -------------------------------------------------------------------------------- /mautrix_telegram/formatter/from_matrix/__init__.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | import re 19 | 20 | from telethon import TelegramClient 21 | from telethon.helpers import add_surrogate, del_surrogate, strip_text 22 | from telethon.tl.types import MessageEntityItalic, TypeMessageEntity 23 | 24 | from mautrix.types import MessageEventContent, RoomID 25 | 26 | from ...db import Message as DBMessage 27 | from ...types import TelegramID 28 | from .parser import MatrixParser 29 | 30 | command_regex = re.compile(r"^!([A-Za-z0-9@]+)") 31 | not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)") 32 | 33 | MAX_LENGTH = 4096 34 | CUTOFF_TEXT = " [message cut]" 35 | CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT) 36 | 37 | 38 | class FormatError(Exception): 39 | pass 40 | 41 | 42 | async def matrix_reply_to_telegram( 43 | content: MessageEventContent, tg_space: TelegramID, room_id: RoomID | None = None 44 | ) -> TelegramID | None: 45 | event_id = content.get_reply_to() 46 | if not event_id: 47 | return 48 | content.trim_reply_fallback() 49 | 50 | message = await DBMessage.get_by_mxid(event_id, room_id, tg_space) 51 | if message: 52 | return message.tgid 53 | return None 54 | 55 | 56 | async def matrix_to_telegram( 57 | client: TelegramClient, *, text: str | None = None, html: str | None = None 58 | ) -> tuple[str, list[TypeMessageEntity]]: 59 | if html is not None: 60 | return await _matrix_html_to_telegram(client, html) 61 | elif text is not None: 62 | return _matrix_text_to_telegram(text) 63 | else: 64 | raise ValueError("text or html must be provided to convert formatting") 65 | 66 | 67 | async def _matrix_html_to_telegram( 68 | client: TelegramClient, html: str 69 | ) -> tuple[str, list[TypeMessageEntity]]: 70 | try: 71 | html = command_regex.sub(r"\1", html) 72 | html = html.replace("\t", " " * 4) 73 | html = not_command_regex.sub(r"\1", html) 74 | 75 | parsed = await MatrixParser(client).parse(add_surrogate(html)) 76 | text, entities = _cut_long_message(parsed.text, parsed.telegram_entities) 77 | text = del_surrogate(strip_text(text, entities)) 78 | 79 | return text, entities 80 | except Exception as e: 81 | raise FormatError(f"Failed to convert Matrix format: {html}") from e 82 | 83 | 84 | def _cut_long_message( 85 | message: str, entities: list[TypeMessageEntity] 86 | ) -> tuple[str, list[TypeMessageEntity]]: 87 | if len(message) > MAX_LENGTH: 88 | message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT 89 | new_entities = [] 90 | for entity in entities: 91 | if entity.offset > CUT_MAX_LENGTH: 92 | continue 93 | if entity.offset + entity.length > CUT_MAX_LENGTH: 94 | entity.length = CUT_MAX_LENGTH - entity.offset 95 | new_entities.append(entity) 96 | new_entities.append(MessageEntityItalic(CUT_MAX_LENGTH, len(CUTOFF_TEXT))) 97 | entities = new_entities 98 | return message, entities 99 | 100 | 101 | def _matrix_text_to_telegram(text: str) -> tuple[str, list[TypeMessageEntity]]: 102 | text = command_regex.sub(r"/\1", text) 103 | text = text.replace("\t", " " * 4) 104 | text = not_command_regex.sub(r"\1", text) 105 | entities = [] 106 | surrogated_text = add_surrogate(text) 107 | if len(surrogated_text) > MAX_LENGTH: 108 | surrogated_text, entities = _cut_long_message(surrogated_text, entities) 109 | text = del_surrogate(surrogated_text) 110 | return text, entities 111 | -------------------------------------------------------------------------------- /mautrix_telegram/formatter/from_matrix/parser.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | import logging 19 | 20 | from telethon import TelegramClient 21 | 22 | from mautrix.types import RoomID, UserID 23 | from mautrix.util.formatter import HTMLNode, MatrixParser as BaseMatrixParser, RecursionContext 24 | from mautrix.util.logging import TraceLogger 25 | 26 | from ... import portal as po, puppet as pu, user as u 27 | from .telegram_message import TelegramEntityType, TelegramMessage 28 | 29 | log: TraceLogger = logging.getLogger("mau.fmt.mx") 30 | 31 | 32 | class MatrixParser(BaseMatrixParser[TelegramMessage]): 33 | e = TelegramEntityType 34 | fs = TelegramMessage 35 | client: TelegramClient 36 | 37 | def __init__(self, client: TelegramClient) -> None: 38 | self.client = client 39 | 40 | async def custom_node_to_fstring( 41 | self, node: HTMLNode, ctx: RecursionContext 42 | ) -> TelegramMessage | None: 43 | if node.tag == "command": 44 | msg = await self.tag_aware_parse_node(node, ctx) 45 | return msg.prepend("/").format(TelegramEntityType.COMMAND) 46 | return None 47 | 48 | async def user_pill_to_fstring(self, msg: TelegramMessage, user_id: UserID) -> TelegramMessage: 49 | user = await pu.Puppet.get_by_mxid(user_id) or await u.User.get_by_mxid( 50 | user_id, create=False 51 | ) 52 | if not user: 53 | return msg 54 | if user.tg_username: 55 | return TelegramMessage(f"@{user.tg_username}").format(TelegramEntityType.MENTION) 56 | elif user.tgid: 57 | displayname = user.plain_displayname or msg.text 58 | msg = TelegramMessage(displayname) 59 | try: 60 | input_entity = await self.client.get_input_entity(user.tgid) 61 | except (ValueError, TypeError) as e: 62 | log.trace(f"Dropping mention of {user.tgid}: {e}") 63 | else: 64 | msg = msg.format(TelegramEntityType.MENTION_NAME, user_id=input_entity) 65 | return msg 66 | 67 | async def url_to_fstring(self, msg: TelegramMessage, url: str) -> TelegramMessage: 68 | if url == msg.text: 69 | return msg.format(self.e.URL) 70 | else: 71 | return msg.format(self.e.INLINE_URL, url=url) 72 | 73 | async def room_pill_to_fstring(self, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage: 74 | username = po.Portal.get_username_from_mx_alias(room_id) 75 | portal = await po.Portal.find_by_username(username) 76 | if portal and portal.username: 77 | return TelegramMessage(f"@{portal.username}").format(TelegramEntityType.MENTION) 78 | 79 | async def header_to_fstring(self, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage: 80 | children = await self.node_to_fstrings(node, ctx) 81 | length = int(node.tag[1]) 82 | prefix = "#" * length + " " 83 | return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD) 84 | 85 | async def color_to_fstring(self, msg: TelegramMessage, color: str) -> TelegramMessage: 86 | return msg 87 | 88 | async def spoiler_to_fstring(self, msg: TelegramMessage, reason: str) -> TelegramMessage: 89 | msg = msg.format(self.e.SPOILER) 90 | if reason: 91 | msg = msg.prepend(f"{reason}: ") 92 | return msg 93 | -------------------------------------------------------------------------------- /mautrix_telegram/formatter/from_matrix/telegram_message.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import Any, Type 19 | from enum import Enum 20 | 21 | from telethon.tl.types import ( 22 | InputMessageEntityMentionName as InputMentionName, 23 | MessageEntityBlockquote as Blockquote, 24 | MessageEntityBold as Bold, 25 | MessageEntityBotCommand as Command, 26 | MessageEntityCode as Code, 27 | MessageEntityEmail as Email, 28 | MessageEntityItalic as Italic, 29 | MessageEntityMention as Mention, 30 | MessageEntityMentionName as MentionName, 31 | MessageEntityPre as Pre, 32 | MessageEntitySpoiler as Spoiler, 33 | MessageEntityStrike as Strike, 34 | MessageEntityTextUrl as TextURL, 35 | MessageEntityUnderline as Underline, 36 | MessageEntityUrl as URL, 37 | TypeMessageEntity, 38 | ) 39 | 40 | from mautrix.util.formatter import EntityString, SemiAbstractEntity 41 | 42 | 43 | class TelegramEntityType(Enum): 44 | """EntityType is a Matrix formatting entity type.""" 45 | 46 | BOLD = Bold 47 | ITALIC = Italic 48 | STRIKETHROUGH = Strike 49 | UNDERLINE = Underline 50 | URL = URL 51 | INLINE_URL = TextURL 52 | EMAIL = Email 53 | PREFORMATTED = Pre 54 | INLINE_CODE = Code 55 | BLOCKQUOTE = Blockquote 56 | MENTION = Mention 57 | MENTION_NAME = InputMentionName 58 | COMMAND = Command 59 | SPOILER = Spoiler 60 | 61 | USER_MENTION = 1 62 | ROOM_MENTION = 2 63 | HEADER = 3 64 | 65 | 66 | class TelegramEntity(SemiAbstractEntity): 67 | internal: TypeMessageEntity 68 | 69 | def __init__( 70 | self, 71 | type: TelegramEntityType | Type[TypeMessageEntity], 72 | offset: int, 73 | length: int, 74 | extra_info: dict[str, Any], 75 | ) -> None: 76 | if isinstance(type, TelegramEntityType): 77 | if isinstance(type.value, int): 78 | raise ValueError(f"Can't create Entity with non-Telegram EntityType {type}") 79 | type = type.value 80 | self.internal = type(offset=offset, length=length, **extra_info) 81 | 82 | def copy(self) -> TelegramEntity: 83 | extra_info = {} 84 | if isinstance(self.internal, Pre): 85 | extra_info["language"] = self.internal.language 86 | elif isinstance(self.internal, TextURL): 87 | extra_info["url"] = self.internal.url 88 | elif isinstance(self.internal, (MentionName, InputMentionName)): 89 | extra_info["user_id"] = self.internal.user_id 90 | return TelegramEntity( 91 | type(self.internal), 92 | offset=self.internal.offset, 93 | length=self.internal.length, 94 | extra_info=extra_info, 95 | ) 96 | 97 | def __repr__(self) -> str: 98 | return str(self.internal) 99 | 100 | @property 101 | def offset(self) -> int: 102 | return self.internal.offset 103 | 104 | @offset.setter 105 | def offset(self, value: int) -> None: 106 | self.internal.offset = value 107 | 108 | @property 109 | def length(self) -> int: 110 | return self.internal.length 111 | 112 | @length.setter 113 | def length(self, value: int) -> None: 114 | self.internal.length = value 115 | 116 | 117 | class TelegramMessage(EntityString[TelegramEntity, TelegramEntityType]): 118 | entity_class = TelegramEntity 119 | 120 | @property 121 | def telegram_entities(self) -> list[TypeMessageEntity]: 122 | return [entity.internal for entity in self.entities] 123 | -------------------------------------------------------------------------------- /mautrix_telegram/get_version.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import subprocess 4 | 5 | from . import __version__ 6 | 7 | cmd_env = { 8 | "PATH": os.environ["PATH"], 9 | "HOME": os.environ["HOME"], 10 | "LANG": "C", 11 | "LC_ALL": "C", 12 | } 13 | 14 | 15 | def run(cmd): 16 | return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env) 17 | 18 | 19 | if os.path.exists(".git") and shutil.which("git"): 20 | try: 21 | git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii") 22 | git_revision_url = f"https://github.com/mautrix/telegram/commit/{git_revision}" 23 | git_revision = git_revision[:8] 24 | except (subprocess.SubprocessError, OSError): 25 | git_revision = "unknown" 26 | git_revision_url = None 27 | 28 | try: 29 | git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii") 30 | except (subprocess.SubprocessError, OSError): 31 | git_tag = None 32 | else: 33 | git_revision = "unknown" 34 | git_revision_url = None 35 | git_tag = None 36 | 37 | git_tag_url = f"https://github.com/mautrix/telegram/releases/tag/{git_tag}" if git_tag else None 38 | 39 | if git_tag and __version__ == git_tag[1:].replace("-", ""): 40 | version = __version__ 41 | linkified_version = f"[{version}]({git_tag_url})" 42 | else: 43 | if not __version__.endswith("+dev"): 44 | __version__ += "+dev" 45 | version = f"{__version__}.{git_revision}" 46 | if git_revision_url: 47 | linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})" 48 | else: 49 | linkified_version = version 50 | -------------------------------------------------------------------------------- /mautrix_telegram/portal_util/__init__.py: -------------------------------------------------------------------------------- 1 | from .deduplication import PortalDedup 2 | from .message_convert import ConvertedMessage, TelegramMessageConverter 3 | from .participants import get_users 4 | from .power_levels import get_base_power_levels, participants_to_power_levels 5 | from .send_lock import PortalReactionLock, PortalSendLock 6 | from .sponsored_message import get_sponsored_message, make_sponsored_message_content 7 | -------------------------------------------------------------------------------- /mautrix_telegram/portal_util/deduplication.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import Any, Generator, Tuple, Union 19 | from collections import deque 20 | import hashlib 21 | 22 | from telethon.tl.patched import Message, MessageService 23 | from telethon.tl.types import ( 24 | Message, 25 | MessageMediaContact, 26 | MessageMediaDice, 27 | MessageMediaDocument, 28 | MessageMediaGame, 29 | MessageMediaGeo, 30 | MessageMediaPhoto, 31 | MessageMediaPoll, 32 | MessageMediaUnsupported, 33 | MessageService, 34 | PeerChannel, 35 | PeerChat, 36 | PeerUser, 37 | TypeUpdates, 38 | UpdateNewChannelMessage, 39 | UpdateNewMessage, 40 | UpdateShortChatMessage, 41 | UpdateShortMessage, 42 | ) 43 | 44 | from mautrix.types import EventID 45 | 46 | from .. import portal as po 47 | from ..types import TelegramID 48 | 49 | DedupMXID = Tuple[EventID, TelegramID] 50 | TypeMessage = Union[Message, MessageService, UpdateShortMessage, UpdateShortChatMessage] 51 | 52 | media_content_table = { 53 | MessageMediaContact: lambda media: [media.user_id], 54 | MessageMediaDocument: lambda media: [media.document.id], 55 | MessageMediaPhoto: lambda media: [media.photo.id if media.photo else 0], 56 | MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat], 57 | MessageMediaGame: lambda media: [media.game.id], 58 | MessageMediaPoll: lambda media: [media.poll.id], 59 | MessageMediaDice: lambda media: [media.value, media.emoticon], 60 | MessageMediaUnsupported: lambda media: ["unsupported media"], 61 | } 62 | 63 | 64 | class PortalDedup: 65 | cache_queue_length: int = 256 66 | 67 | _dedup: deque[bytes | int] 68 | _dedup_mxid: dict[bytes | int, DedupMXID] 69 | _dedup_action: deque[bytes | int] 70 | _portal: po.Portal 71 | 72 | def __init__(self, portal: po.Portal) -> None: 73 | self._dedup = deque() 74 | self._dedup_mxid = {} 75 | self._dedup_action = deque(maxlen=self.cache_queue_length) 76 | self._portal = portal 77 | 78 | @property 79 | def _always_force_hash(self) -> bool: 80 | return self._portal.peer_type == "chat" 81 | 82 | def _hash_content(self, event: TypeMessage) -> Generator[Any, None, None]: 83 | if not self._always_force_hash: 84 | yield event.id 85 | yield int(event.date.timestamp()) 86 | if isinstance(event, MessageService): 87 | yield event.from_id 88 | yield event.action 89 | else: 90 | yield event.message.strip() 91 | if event.fwd_from: 92 | yield event.fwd_from.from_id 93 | if isinstance(event, Message) and event.media: 94 | media_hash_func = media_content_table.get(type(event.media)) or ( 95 | lambda media: ["unknown media"] 96 | ) 97 | yield media_hash_func(event.media) 98 | 99 | def hash_event(self, event: TypeMessage) -> bytes: 100 | return hashlib.sha256( 101 | "-".join(str(a) for a in self._hash_content(event)).encode("utf-8") 102 | ).digest() 103 | 104 | def check_action(self, event: TypeMessage) -> bool: 105 | dedup_id = self.hash_event(event) if self._always_force_hash else event.id 106 | if dedup_id in self._dedup_action: 107 | return True 108 | 109 | self._dedup_action.appendleft(dedup_id) 110 | return False 111 | 112 | def update( 113 | self, 114 | event: TypeMessage, 115 | mxid: DedupMXID = None, 116 | expected_mxid: DedupMXID | None = None, 117 | force_hash: bool = False, 118 | ) -> tuple[bytes, DedupMXID | None]: 119 | evt_hash = self.hash_event(event) 120 | dedup_id = evt_hash if self._always_force_hash or force_hash else event.id 121 | try: 122 | found_mxid = self._dedup_mxid[dedup_id] 123 | except KeyError: 124 | return evt_hash, None 125 | 126 | if found_mxid != expected_mxid: 127 | return evt_hash, found_mxid 128 | self._dedup_mxid[dedup_id] = mxid 129 | if evt_hash != dedup_id: 130 | self._dedup_mxid[evt_hash] = mxid 131 | return evt_hash, None 132 | 133 | def check( 134 | self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False 135 | ) -> tuple[bytes, DedupMXID | None]: 136 | evt_hash = self.hash_event(event) 137 | dedup_id = evt_hash if self._always_force_hash or force_hash else event.id 138 | if dedup_id in self._dedup: 139 | return evt_hash, self._dedup_mxid[dedup_id] 140 | 141 | self._dedup_mxid[dedup_id] = mxid 142 | self._dedup.appendleft(dedup_id) 143 | if evt_hash != dedup_id: 144 | self._dedup_mxid[evt_hash] = mxid 145 | self._dedup.appendleft(evt_hash) 146 | 147 | while len(self._dedup) > self.cache_queue_length: 148 | del self._dedup_mxid[self._dedup.pop()] 149 | return evt_hash, None 150 | 151 | def register_outgoing_actions(self, response: TypeUpdates) -> None: 152 | for update in response.updates: 153 | check_dedup = isinstance( 154 | update, (UpdateNewMessage, UpdateNewChannelMessage) 155 | ) and isinstance(update.message, MessageService) 156 | if check_dedup: 157 | self.check(update.message) 158 | -------------------------------------------------------------------------------- /mautrix_telegram/portal_util/participants.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import Iterable 19 | 20 | from telethon.errors import ChatAdminRequiredError 21 | from telethon.tl.functions.channels import GetParticipantsRequest 22 | from telethon.tl.functions.messages import GetFullChatRequest 23 | from telethon.tl.types import ( 24 | ChannelParticipantBanned, 25 | ChannelParticipantsRecent, 26 | ChannelParticipantsSearch, 27 | ChatParticipantsForbidden, 28 | InputChannel, 29 | InputUser, 30 | TypeChannelParticipant, 31 | TypeChat, 32 | TypeChatParticipant, 33 | TypeInputPeer, 34 | TypeUser, 35 | ) 36 | 37 | from ..tgclient import MautrixTelegramClient 38 | 39 | 40 | def _filter_participants( 41 | users: list[TypeUser], participants: list[TypeChatParticipant | TypeChannelParticipant] 42 | ) -> Iterable[TypeUser]: 43 | participant_map = { 44 | part.user_id: part 45 | for part in participants 46 | if not isinstance(part, ChannelParticipantBanned) 47 | } 48 | for user in users: 49 | try: 50 | user.participant = participant_map[user.id] 51 | except KeyError: 52 | pass 53 | else: 54 | yield user 55 | 56 | 57 | async def _get_channel_users( 58 | client: MautrixTelegramClient, entity: InputChannel, limit: int 59 | ) -> list[TypeUser]: 60 | if 0 < limit <= 200: 61 | response = await client( 62 | GetParticipantsRequest( 63 | entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0 64 | ) 65 | ) 66 | return list(_filter_participants(response.users, response.participants)) 67 | elif limit > 200 or limit == -1: 68 | users: list[TypeUser] = [] 69 | offset = 0 70 | remaining_quota = limit if limit > 0 else 1000000 71 | query = ChannelParticipantsSearch("") if limit == -1 else ChannelParticipantsRecent() 72 | while True: 73 | if remaining_quota <= 0: 74 | break 75 | response = await client( 76 | GetParticipantsRequest( 77 | entity, query, offset=offset, limit=min(remaining_quota, 200), hash=0 78 | ) 79 | ) 80 | if not response.users: 81 | break 82 | users += _filter_participants(response.users, response.participants) 83 | offset += len(response.participants) 84 | remaining_quota -= len(response.participants) 85 | return users 86 | 87 | 88 | async def get_users( 89 | client: MautrixTelegramClient, 90 | tgid: int, 91 | entity: TypeInputPeer | InputUser | TypeChat | TypeUser | InputChannel, 92 | limit: int, 93 | peer_type: str, 94 | ) -> list[TypeUser]: 95 | if peer_type == "chat": 96 | chat = await client(GetFullChatRequest(chat_id=tgid)) 97 | if isinstance(chat.full_chat.participants, ChatParticipantsForbidden): 98 | return [] 99 | users = list(_filter_participants(chat.users, chat.full_chat.participants.participants)) 100 | return users[:limit] if limit > 0 else users 101 | elif peer_type == "channel": 102 | try: 103 | return await _get_channel_users(client, entity, limit) 104 | except ChatAdminRequiredError: 105 | return [] 106 | elif peer_type == "user": 107 | return [entity] 108 | else: 109 | raise RuntimeError(f"Unexpected peer type {peer_type}") 110 | -------------------------------------------------------------------------------- /mautrix_telegram/portal_util/power_levels.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from telethon.tl.types import ( 19 | ChannelParticipantAdmin, 20 | ChannelParticipantCreator, 21 | ChatBannedRights, 22 | ChatParticipantAdmin, 23 | ChatParticipantCreator, 24 | TypeChannelParticipant, 25 | TypeChat, 26 | TypeChatParticipant, 27 | TypeUser, 28 | ) 29 | 30 | from mautrix.types import EventType, PowerLevelStateEventContent as PowerLevelContent, UserID 31 | 32 | from .. import portal as po, puppet as pu, user as u 33 | from ..types import TelegramID 34 | 35 | 36 | def get_base_power_levels( 37 | portal: po.Portal, 38 | levels: PowerLevelContent = None, 39 | entity: TypeChat | None = None, 40 | dbr: ChatBannedRights | None = None, 41 | ) -> PowerLevelContent: 42 | is_initial = not levels 43 | levels = levels or PowerLevelContent() 44 | if portal.peer_type == "user": 45 | overrides = portal.config["bridge.initial_power_level_overrides.user"] 46 | levels.ban = overrides.get("ban", 100) 47 | levels.kick = overrides.get("kick", 100) 48 | levels.invite = overrides.get("invite", 100) 49 | levels.redact = overrides.get("redact", 0) 50 | levels.events[EventType.ROOM_NAME] = 0 51 | levels.events[EventType.ROOM_AVATAR] = 0 52 | levels.events[EventType.ROOM_TOPIC] = 0 53 | levels.state_default = overrides.get("state_default", 0) 54 | levels.users_default = overrides.get("users_default", 0) 55 | levels.events_default = overrides.get("events_default", 0) 56 | else: 57 | overrides = portal.config["bridge.initial_power_level_overrides.group"] 58 | dbr = dbr or entity.default_banned_rights 59 | if not dbr: 60 | portal.log.debug(f"default_banned_rights is None in {entity}") 61 | dbr = ChatBannedRights( 62 | invite_users=True, 63 | change_info=True, 64 | pin_messages=True, 65 | send_stickers=False, 66 | send_messages=False, 67 | until_date=None, 68 | ) 69 | levels.ban = overrides.get("ban", 50) 70 | levels.kick = overrides.get("kick", 50) 71 | levels.redact = overrides.get("redact", 50) 72 | levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0) 73 | levels.events[EventType.ROOM_ENCRYPTION] = 50 if portal.matrix.e2ee else 99 74 | levels.events[EventType.ROOM_TOMBSTONE] = 99 75 | levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0 76 | levels.events[EventType.ROOM_AVATAR] = 50 if dbr.change_info else 0 77 | levels.events[EventType.ROOM_TOPIC] = 50 if dbr.change_info else 0 78 | levels.events[EventType.ROOM_PINNED_EVENTS] = 50 if dbr.pin_messages else 0 79 | levels.events[EventType.ROOM_POWER_LEVELS] = 75 80 | levels.events[EventType.ROOM_HISTORY_VISIBILITY] = 75 81 | levels.events[EventType.STICKER] = 50 if dbr.send_stickers else levels.events_default 82 | levels.state_default = overrides.get("state_default", 50) 83 | levels.users_default = overrides.get("users_default", 0) 84 | levels.events_default = overrides.get( 85 | "events_default", 86 | ( 87 | 50 88 | if portal.peer_type == "channel" and not portal.megagroup or dbr.send_messages 89 | else 0 90 | ), 91 | ) 92 | for evt_type, value in overrides.get("events", {}).items(): 93 | levels.events[EventType.find(evt_type)] = value 94 | userlevel_overrides = overrides.get("users", {}) 95 | if is_initial: 96 | levels.users.update(userlevel_overrides) 97 | if portal.main_intent.mxid not in levels.users: 98 | levels.users[portal.main_intent.mxid] = 100 99 | return levels 100 | 101 | 102 | async def participants_to_power_levels( 103 | portal: po.Portal, 104 | users: list[TypeUser | TypeChatParticipant | TypeChannelParticipant], 105 | levels: PowerLevelContent, 106 | ) -> bool: 107 | bot_level = levels.get_user_level(portal.main_intent.mxid) 108 | if bot_level < levels.get_event_level(EventType.ROOM_POWER_LEVELS): 109 | return False 110 | changed = False 111 | admin_power_level = min(75 if portal.peer_type == "channel" else 50, bot_level) 112 | if levels.get_event_level(EventType.ROOM_POWER_LEVELS) != admin_power_level: 113 | changed = True 114 | levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level 115 | 116 | for user in users: 117 | # The User objects we get from TelegramClient.get_participants have a custom 118 | # participant property 119 | participant = getattr(user, "participant", user) 120 | 121 | puppet = await pu.Puppet.get_by_tgid(TelegramID(participant.user_id)) 122 | user = await u.User.get_by_tgid(TelegramID(participant.user_id)) 123 | new_level = _get_level_from_participant(portal.az.bot_mxid, participant, levels) 124 | 125 | if user: 126 | await user.register_portal(portal) 127 | changed = _participant_to_power_levels(levels, user, new_level, bot_level) or changed 128 | 129 | if puppet: 130 | changed = _participant_to_power_levels(levels, puppet, new_level, bot_level) or changed 131 | return changed 132 | 133 | 134 | def _get_level_from_participant( 135 | bot_mxid: UserID, 136 | participant: TypeUser | TypeChatParticipant | TypeChannelParticipant, 137 | levels: PowerLevelContent, 138 | ) -> int: 139 | # TODO use the power level requirements to get better precision in channels 140 | if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)): 141 | return levels.state_default or 50 142 | elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)): 143 | return levels.get_user_level(bot_mxid) - 5 144 | return levels.users_default or 0 145 | 146 | 147 | def _participant_to_power_levels( 148 | levels: PowerLevelContent, 149 | user: u.User | pu.Puppet, 150 | new_level: int, 151 | bot_level: int, 152 | ) -> bool: 153 | new_level = min(new_level, bot_level) 154 | user_level = levels.get_user_level(user.mxid) 155 | if user_level != new_level and user_level < bot_level: 156 | levels.users[user.mxid] = new_level 157 | return True 158 | return False 159 | -------------------------------------------------------------------------------- /mautrix_telegram/portal_util/send_lock.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from asyncio import Lock 19 | from collections import defaultdict 20 | 21 | from mautrix.types import EventID 22 | 23 | from ..types import TelegramID 24 | 25 | 26 | class FakeLock: 27 | async def __aenter__(self) -> None: 28 | pass 29 | 30 | async def __aexit__(self, exc_type, exc, tb) -> None: 31 | pass 32 | 33 | 34 | class PortalSendLock: 35 | _send_locks: dict[int, Lock] 36 | _noop_lock: Lock = FakeLock() 37 | 38 | def __init__(self) -> None: 39 | self._send_locks = {} 40 | 41 | def __call__(self, user_id: TelegramID, required: bool = True) -> Lock: 42 | if user_id is None and required: 43 | raise ValueError("Required send lock for none id") 44 | try: 45 | return self._send_locks[user_id] 46 | except KeyError: 47 | return self._send_locks.setdefault(user_id, Lock()) if required else self._noop_lock 48 | 49 | 50 | class PortalReactionLock: 51 | _reaction_locks: dict[EventID, Lock] 52 | 53 | def __init__(self) -> None: 54 | self._reaction_locks = defaultdict(lambda: Lock()) 55 | 56 | def __call__(self, mxid: EventID) -> Lock: 57 | return self._reaction_locks[mxid] 58 | -------------------------------------------------------------------------------- /mautrix_telegram/portal_util/sponsored_message.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | import base64 19 | import html 20 | 21 | from telethon.tl.functions.messages import GetSponsoredMessagesRequest 22 | from telethon.tl.types import Channel, InputChannel, PeerChannel, PeerUser, SponsoredMessage, User 23 | from telethon.tl.types.messages import SponsoredMessages, SponsoredMessagesEmpty 24 | 25 | from mautrix.types import MessageType, TextMessageEventContent 26 | 27 | from .. import user as u 28 | from ..formatter import telegram_to_matrix 29 | 30 | 31 | async def get_sponsored_message( 32 | user: u.User, 33 | entity: InputChannel, 34 | ) -> tuple[SponsoredMessage | None, int | None, Channel | User | None]: 35 | resp = await user.client(GetSponsoredMessagesRequest(entity)) 36 | if isinstance(resp, SponsoredMessagesEmpty): 37 | return None, None, None 38 | assert isinstance(resp, SponsoredMessages) 39 | msg = resp.messages[0] 40 | if isinstance(msg.from_id, PeerUser): 41 | entities = resp.users 42 | target_id = msg.from_id.user_id 43 | else: 44 | entities = resp.chats 45 | target_id = msg.from_id.channel_id 46 | try: 47 | entity = next(ent for ent in entities if ent.id == target_id) 48 | except StopIteration: 49 | entity = None 50 | return msg, target_id, entity 51 | 52 | 53 | async def make_sponsored_message_content( 54 | source: u.User, msg: SponsoredMessage, entity: Channel | User 55 | ) -> TextMessageEventContent | None: 56 | content = await telegram_to_matrix(msg, source, require_html=True) 57 | content.external_url = f"https://t.me/{entity.username}" 58 | content.msgtype = MessageType.NOTICE 59 | sponsored_meta = { 60 | "random_id": base64.b64encode(msg.random_id).decode("utf-8"), 61 | } 62 | if isinstance(msg.from_id, PeerChannel): 63 | sponsored_meta["channel_id"] = msg.from_id.channel_id 64 | if getattr(msg, "channel_post", None) is not None: 65 | sponsored_meta["channel_post"] = msg.channel_post 66 | content.external_url += f"/{msg.channel_post}" 67 | action = "View Post" 68 | else: 69 | action = "View Channel" 70 | elif isinstance(msg.from_id, PeerUser): 71 | sponsored_meta["bot_id"] = msg.from_id.user_id 72 | if msg.start_param: 73 | content.external_url += f"?start={msg.start_param}" 74 | action = "View Bot" 75 | else: 76 | return None 77 | 78 | if isinstance(entity, User): 79 | name_parts = [entity.first_name, entity.last_name] 80 | sponsor_name = " ".join(x for x in name_parts if x) 81 | sponsor_name_html = f"{html.escape(sponsor_name)}" 82 | elif isinstance(entity, Channel): 83 | sponsor_name = entity.title 84 | sponsor_name_html = f"{html.escape(sponsor_name)}" 85 | else: 86 | sponsor_name = sponsor_name_html = "unknown entity" 87 | 88 | content["fi.mau.telegram.sponsored"] = sponsored_meta 89 | content.formatted_body += ( 90 | f"

Sponsored message from {sponsor_name_html} " 91 | f"- {action}" 92 | ) 93 | content.body += ( 94 | f"\n\nSponsored message from {sponsor_name} - {action} at {content.external_url}" 95 | ) 96 | 97 | return content 98 | -------------------------------------------------------------------------------- /mautrix_telegram/scripts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mautrix/telegram/280c74e9cdbb7f057f5778bb31843647cf39925d/mautrix_telegram/scripts/__init__.py -------------------------------------------------------------------------------- /mautrix_telegram/scripts/unicodemojipack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mautrix/telegram/280c74e9cdbb7f057f5778bb31843647cf39925d/mautrix_telegram/scripts/unicodemojipack/__init__.py -------------------------------------------------------------------------------- /mautrix_telegram/tgclient.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from typing import List, Optional, Union 17 | 18 | from telethon import TelegramClient, utils 19 | from telethon.sessions.abstract import Session 20 | from telethon.tl.functions.messages import SendMediaRequest 21 | from telethon.tl.patched import Message 22 | from telethon.tl.types import ( 23 | InputMediaUploadedDocument, 24 | InputMediaUploadedPhoto, 25 | InputReplyToMessage, 26 | TypeDocumentAttribute, 27 | TypeInputMedia, 28 | TypeInputPeer, 29 | TypeMessageEntity, 30 | TypeMessageMedia, 31 | TypePeer, 32 | ) 33 | 34 | 35 | class MautrixTelegramClient(TelegramClient): 36 | session: Session 37 | 38 | async def upload_file_direct( 39 | self, 40 | file: bytes, 41 | mime_type: str = None, 42 | attributes: List[TypeDocumentAttribute] = None, 43 | file_name: str = None, 44 | max_image_size: float = 10 * 1000**2, 45 | ) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]: 46 | file_handle = await super().upload_file(file, file_name=file_name) 47 | 48 | if (mime_type == "image/png" or mime_type == "image/jpeg") and len(file) < max_image_size: 49 | return InputMediaUploadedPhoto(file_handle) 50 | else: 51 | attributes = attributes or [] 52 | attr_dict = {type(attr): attr for attr in attributes} 53 | 54 | return InputMediaUploadedDocument( 55 | file=file_handle, 56 | mime_type=mime_type or "application/octet-stream", 57 | attributes=list(attr_dict.values()), 58 | ) 59 | 60 | async def send_media( 61 | self, 62 | entity: Union[TypeInputPeer, TypePeer], 63 | media: Union[TypeInputMedia, TypeMessageMedia], 64 | caption: str = None, 65 | entities: List[TypeMessageEntity] = None, 66 | reply_to: int = None, 67 | ) -> Optional[Message]: 68 | entity = await self.get_input_entity(entity) 69 | reply_to = utils.get_message_id(reply_to) 70 | request = SendMediaRequest( 71 | entity, 72 | media, 73 | message=caption or "", 74 | entities=entities or [], 75 | reply_to=InputReplyToMessage(reply_to_msg_id=reply_to) if reply_to else None, 76 | ) 77 | return self._get_response_message(request, await self(request), entity) 78 | -------------------------------------------------------------------------------- /mautrix_telegram/types.py: -------------------------------------------------------------------------------- 1 | from typing import NewType 2 | 3 | TelegramID = NewType("TelegramID", int) 4 | -------------------------------------------------------------------------------- /mautrix_telegram/unicodemojipack.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mautrix/telegram/280c74e9cdbb7f057f5778bb31843647cf39925d/mautrix_telegram/unicodemojipack.pickle -------------------------------------------------------------------------------- /mautrix_telegram/util/__init__.py: -------------------------------------------------------------------------------- 1 | from .color_log import ColorFormatter 2 | from .file_transfer import ( 3 | UnicodeCustomEmoji, 4 | convert_image, 5 | transfer_custom_emojis_to_matrix, 6 | transfer_file_to_matrix, 7 | transfer_thumbnail_to_matrix, 8 | unicode_custom_emoji_map, 9 | ) 10 | from .parallel_file_transfer import parallel_transfer_to_telegram 11 | from .recursive_dict import recursive_del, recursive_get, recursive_set 12 | from .tl_json import parse_tl_json 13 | -------------------------------------------------------------------------------- /mautrix_telegram/util/color_log.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from mautrix.util.logging.color import ( 17 | MXID_COLOR, 18 | PREFIX, 19 | RESET, 20 | ColorFormatter as BaseColorFormatter, 21 | ) 22 | 23 | TELETHON_COLOR = PREFIX + "35;1m" # magenta 24 | TELETHON_MODULE_COLOR = PREFIX + "35m" 25 | 26 | 27 | class ColorFormatter(BaseColorFormatter): 28 | def _color_name(self, module: str) -> str: 29 | if module.startswith("telethon"): 30 | prefix, user_id, module = module.split(".", 2) 31 | return ( 32 | f"{TELETHON_COLOR}{prefix}{RESET}." 33 | f"{MXID_COLOR}{user_id}{RESET}." 34 | f"{TELETHON_MODULE_COLOR}{module}{RESET}" 35 | ) 36 | return super()._color_name(module) 37 | -------------------------------------------------------------------------------- /mautrix_telegram/util/recursive_dict.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2021 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | from typing import Any 19 | 20 | from mautrix.util.config import RecursiveDict 21 | 22 | 23 | def recursive_set(data: dict[str, Any], key: str, value: Any) -> bool: 24 | key, next_key = RecursiveDict.parse_key(key) 25 | if next_key is not None: 26 | if key not in data: 27 | data[key] = {} 28 | next_data = data.get(key, {}) 29 | if not isinstance(next_data, dict): 30 | return False 31 | return recursive_set(next_data, next_key, value) 32 | data[key] = value 33 | return True 34 | 35 | 36 | def recursive_get(data: dict[str, Any], key: str) -> Any: 37 | key, next_key = RecursiveDict.parse_key(key) 38 | if next_key is not None: 39 | next_data = data.get(key, None) 40 | if not next_data: 41 | return None 42 | return recursive_get(next_data, next_key) 43 | return data.get(key, None) 44 | 45 | 46 | def recursive_del(data: dict[str, any], key: str) -> bool: 47 | key, next_key = RecursiveDict.parse_key(key) 48 | if next_key is not None: 49 | if key not in data: 50 | return False 51 | next_data = data.get(key, {}) 52 | return recursive_del(next_data, next_key) 53 | if key in data: 54 | del data[key] 55 | return True 56 | return False 57 | -------------------------------------------------------------------------------- /mautrix_telegram/util/sane_mimetypes.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2019 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | import mimetypes 17 | 18 | mimetypes.init() 19 | 20 | sanity_overrides = { 21 | "image/jpeg": ".jpeg", 22 | "image/tiff": ".tiff", 23 | "text/plain": ".txt", 24 | "text/html": ".html", 25 | "audio/mpeg": ".mp3", 26 | "audio/ogg": ".ogg", 27 | "application/xml": ".xml", 28 | "application/octet-stream": "", 29 | "application/x-msdos-program": ".exe", 30 | } 31 | 32 | 33 | def guess_extension(mime: str) -> str: 34 | try: 35 | return sanity_overrides[mime] 36 | except KeyError: 37 | return mimetypes.guess_extension(mime) 38 | -------------------------------------------------------------------------------- /mautrix_telegram/util/tgs_converter.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Telegram lottie sticker converter 3 | # Copyright (C) 2019 Randall Eramde Lawrence 4 | # 5 | # This program is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with this program. If not, see . 17 | from __future__ import annotations 18 | 19 | from typing import Any, Awaitable, Callable 20 | import asyncio.subprocess 21 | import logging 22 | import os 23 | import os.path 24 | import shutil 25 | import tempfile 26 | 27 | from attr import dataclass 28 | 29 | from mautrix.util import ffmpeg 30 | 31 | log: logging.Logger = logging.getLogger("mau.util.tgs") 32 | 33 | 34 | @dataclass 35 | class ConvertedSticker: 36 | mime: str 37 | data: bytes 38 | thumbnail_mime: str | None = None 39 | thumbnail_data: bytes | None = None 40 | width: int = 0 41 | height: int = 0 42 | 43 | 44 | Converter = Callable[[bytes, int, int, Any], Awaitable[ConvertedSticker]] 45 | converters: dict[str, Converter] = {} 46 | 47 | 48 | def abswhich(program: str | None) -> str | None: 49 | path = shutil.which(program) 50 | return os.path.abspath(path) if path else None 51 | 52 | 53 | lottieconverter = abswhich("lottieconverter") 54 | 55 | 56 | async def _run_lottieconverter(args: tuple[str, ...], input_data: bytes) -> bytes: 57 | proc = await asyncio.create_subprocess_exec( 58 | lottieconverter, 59 | *args, 60 | stdout=asyncio.subprocess.PIPE, 61 | stderr=asyncio.subprocess.PIPE, 62 | stdin=asyncio.subprocess.PIPE, 63 | ) 64 | stdout, stderr = await proc.communicate(input_data) 65 | if proc.returncode == 0: 66 | return stdout 67 | else: 68 | err_text = stderr.decode("utf-8") if stderr else f"unknown ({proc.returncode})" 69 | raise ffmpeg.ConverterError(f"lottieconverter error: {err_text}") 70 | 71 | 72 | if lottieconverter: 73 | 74 | async def tgs_to_png(file: bytes, width: int, height: int, **_: Any) -> ConvertedSticker: 75 | frame = 1 76 | try: 77 | converted_png = await _run_lottieconverter( 78 | args=("-", "-", "png", f"{width}x{height}", str(frame)), 79 | input_data=file, 80 | ) 81 | return ConvertedSticker("image/png", converted_png) 82 | except ffmpeg.ConverterError as e: 83 | log.error(str(e)) 84 | return ConvertedSticker("application/gzip", file) 85 | 86 | async def tgs_to_gif( 87 | file: bytes, width: int, height: int, fps: int = 25, **_: Any 88 | ) -> ConvertedSticker: 89 | try: 90 | converted_gif = await _run_lottieconverter( 91 | args=("-", "-", "gif", f"{width}x{height}", str(fps)), 92 | input_data=file, 93 | ) 94 | return ConvertedSticker("image/gif", converted_gif) 95 | except ffmpeg.ConverterError as e: 96 | log.error(str(e)) 97 | return ConvertedSticker("application/gzip", file) 98 | 99 | converters["png"] = tgs_to_png 100 | converters["gif"] = tgs_to_gif 101 | 102 | if lottieconverter and ffmpeg.ffmpeg_path: 103 | 104 | async def tgs_to_webm( 105 | file: bytes, width: int, height: int, fps: int = 30, **_: Any 106 | ) -> ConvertedSticker: 107 | with tempfile.TemporaryDirectory(prefix="tgs_") as tmpdir: 108 | file_template = tmpdir + "/out_" 109 | try: 110 | await _run_lottieconverter( 111 | args=("-", file_template, "pngs", f"{width}x{height}", str(fps)), 112 | input_data=file, 113 | ) 114 | first_frame_name = min(os.listdir(tmpdir)) 115 | with open(f"{tmpdir}/{first_frame_name}", "rb") as first_frame_file: 116 | first_frame_data = first_frame_file.read() 117 | webm_data = await ffmpeg.convert_path( 118 | input_args=("-framerate", str(fps), "-pattern_type", "glob"), 119 | input_file=f"{file_template}*.png", 120 | output_args=("-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-f", "webm"), 121 | output_path_override="-", 122 | output_extension=None, 123 | ) 124 | return ConvertedSticker("video/webm", webm_data, "image/png", first_frame_data) 125 | except ffmpeg.ConverterError as e: 126 | log.error(str(e)) 127 | return ConvertedSticker("application/gzip", file) 128 | 129 | async def tgs_to_webp( 130 | file: bytes, width: int, height: int, fps: int = 30, **_: Any 131 | ) -> ConvertedSticker: 132 | with tempfile.TemporaryDirectory(prefix="tgs_") as tmpdir: 133 | file_template = tmpdir + "/out_" 134 | try: 135 | await _run_lottieconverter( 136 | args=("-", file_template, "pngs", f"{width}x{height}", str(fps)), 137 | input_data=file, 138 | ) 139 | first_frame_name = min(os.listdir(tmpdir)) 140 | with open(f"{tmpdir}/{first_frame_name}", "rb") as first_frame_file: 141 | first_frame_data = first_frame_file.read() 142 | webp_data = await ffmpeg.convert_path( 143 | input_args=("-framerate", str(fps), "-pattern_type", "glob"), 144 | input_file=f"{file_template}*.png", 145 | output_args=("-c:v", "libwebp_anim", "-pix_fmt", "yuva420p", "-f", "webp"), 146 | output_path_override="-", 147 | output_extension=None, 148 | ) 149 | return ConvertedSticker("image/webp", webp_data, "image/png", first_frame_data) 150 | except ffmpeg.ConverterError as e: 151 | log.error(str(e)) 152 | return ConvertedSticker("application/gzip", file) 153 | 154 | converters["webm"] = tgs_to_webm 155 | converters["webp"] = tgs_to_webp 156 | 157 | 158 | async def convert_tgs_to( 159 | file: bytes, convert_to: str, width: int, height: int, **kwargs: Any 160 | ) -> ConvertedSticker: 161 | if convert_to in converters: 162 | converter = converters[convert_to] 163 | converted = await converter(file, width, height, **kwargs) 164 | converted.width = width 165 | converted.height = height 166 | return converted 167 | elif convert_to != "disable": 168 | log.warning(f"Unable to convert animated sticker, type {convert_to} not supported") 169 | return ConvertedSticker("application/gzip", file) 170 | -------------------------------------------------------------------------------- /mautrix_telegram/util/tl_json.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from telethon.tl.types import ( 17 | JsonArray, 18 | JsonBool, 19 | JsonNull, 20 | JsonNumber, 21 | JsonObject, 22 | JsonObjectValue, 23 | JsonString, 24 | TypeJSONValue, 25 | ) 26 | 27 | from mautrix.types import JSON 28 | 29 | 30 | def parse_tl_json(val: TypeJSONValue) -> JSON: 31 | if isinstance(val, JsonObject): 32 | return {entry.key: parse_tl_json(entry.value) for entry in val.value} 33 | elif isinstance(val, JsonArray): 34 | return [parse_tl_json(item) for item in val.value] 35 | elif isinstance(val, (JsonBool, JsonNumber, JsonString)): 36 | return val.value 37 | elif isinstance(val, JsonNull): 38 | return None 39 | raise ValueError(f"Unsupported type {type(val)} in TL JSON object") 40 | -------------------------------------------------------------------------------- /mautrix_telegram/util/webm_converter.py: -------------------------------------------------------------------------------- 1 | # mautrix-telegram - A Matrix-Telegram puppeting bridge 2 | # Copyright (C) 2022 Tulir Asokan 3 | # 4 | # This program is free software: you can redistribute it and/or modify 5 | # it under the terms of the GNU Affero General Public License as published by 6 | # the Free Software Foundation, either version 3 of the License, or 7 | # (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU Affero General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU Affero General Public License 15 | # along with this program. If not, see . 16 | from __future__ import annotations 17 | 18 | import logging 19 | 20 | from mautrix.util import ffmpeg 21 | 22 | from .tgs_converter import ConvertedSticker 23 | 24 | log: logging.Logger = logging.getLogger("mau.util.webm") 25 | 26 | 27 | converter_args = { 28 | "gif": { 29 | "output_args": ("-vf", "split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse"), 30 | }, 31 | "png": { 32 | "input_args": ("-ss", "0"), 33 | "output_args": ("-frames:v", "1"), 34 | }, 35 | "webp": {}, 36 | } 37 | 38 | 39 | async def convert_webm_to(file: bytes, convert_to: str) -> ConvertedSticker: 40 | if convert_to in ("png", "gif", "webp"): 41 | try: 42 | converted_data = await ffmpeg.convert_bytes( 43 | data=file, 44 | output_extension=f".{convert_to}", 45 | **converter_args[convert_to], 46 | ) 47 | return ConvertedSticker(f"image/{convert_to}", converted_data) 48 | except ffmpeg.ConverterError as e: 49 | log.error(str(e)) 50 | elif convert_to != "disable": 51 | log.warning(f"Unable to convert webm animated sticker, type {convert_to} not supported") 52 | return ConvertedSticker("video/webm", file) 53 | -------------------------------------------------------------------------------- /mautrix_telegram/version.py: -------------------------------------------------------------------------------- 1 | from .get_version import git_revision, git_tag, linkified_version, version 2 | -------------------------------------------------------------------------------- /mautrix_telegram/web/__init__.py: -------------------------------------------------------------------------------- 1 | from .provisioning import ProvisioningAPI 2 | from .public import PublicBridgeWebsite 3 | -------------------------------------------------------------------------------- /mautrix_telegram/web/common/__init__.py: -------------------------------------------------------------------------------- 1 | from .auth_api import AuthAPI 2 | -------------------------------------------------------------------------------- /mautrix_telegram/web/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mautrix/telegram/280c74e9cdbb7f057f5778bb31843647cf39925d/mautrix_telegram/web/public/favicon.png -------------------------------------------------------------------------------- /mautrix_telegram/web/public/login.css: -------------------------------------------------------------------------------- 1 | /* 2 | * mautrix-telegram - A Matrix-Telegram puppeting bridge 3 | * Copyright (C) 2019 Tulir Asokan 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU Affero General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU Affero General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU Affero General Public License 16 | * along with this program. If not, see . 17 | */ 18 | form > div { 19 | display: none; 20 | } 21 | 22 | form[data-status="request"] > div.status-request, 23 | form[data-status="code"] > div.status-code, 24 | form[data-status="password"] > div.status-password { 25 | display: initial; 26 | } 27 | 28 | .container { 29 | margin-top: 3rem; 30 | max-width: 60rem; 31 | } 32 | 33 | .error, .message { 34 | border-radius: .25rem; 35 | padding: .5rem 1rem; 36 | border: 1px solid transparent; 37 | margin: .5rem 0; 38 | } 39 | 40 | .error { 41 | border-color: #f5c6cb; 42 | background-color: #f8d7da; 43 | color: #721c24; 44 | } 45 | 46 | .message { 47 | border-color: #c3e6cb; 48 | background-color: #d4edda; 49 | color: #155724; 50 | } 51 | 52 | [type="checkbox"], [type="radio"] { 53 | position: absolute; 54 | opacity: 0; 55 | } 56 | 57 | [type="checkbox"] + label, [type="radio"] + label { 58 | position: relative; 59 | padding-left: 2.5rem; 60 | cursor: pointer; 61 | display: inline-block; 62 | } 63 | 64 | [type="checkbox"] + label:before, [type="radio"] + label:before { 65 | content: ''; 66 | position: absolute; 67 | left: 0; 68 | top: 0.4rem; 69 | width: 1.8rem; 70 | height: 1.8rem; 71 | border: 0.1rem solid #d1d1d1; 72 | } 73 | 74 | [type="radio"] + label:before, [type="radio"] + label:after { 75 | border-radius: 50%; 76 | } 77 | 78 | [type="checkbox"]:checked + label:after, 79 | [type="radio"]:checked + label:after { 80 | content: ''; 81 | width: 0.8rem; 82 | height: 0.8rem; 83 | background: #9b4dca; 84 | position: absolute; 85 | top: 0.9rem; 86 | left: 0.5rem; 87 | } 88 | 89 | [type="radio"]:disabled + label:before, [type="checkbox"]:disabled + label:before { 90 | background-color: #d1d1d1; 91 | } 92 | 93 | [type="radio"]:disabled + label, [type="checkbox"]:disabled + label { 94 | color: #d1d1d1; 95 | } 96 | 97 | [type="radio"]:disabled:checked + label:after, [type="checkbox"]:disabled:checked + label:after { 98 | background: #606c76; 99 | } 100 | -------------------------------------------------------------------------------- /mautrix_telegram/web/public/login.html.mako: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | Login - Mautrix-Telegram bridge 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 32 | 33 | 34 | 52 | 53 | 54 |
55 | % if human_tg_id: 56 | % if state == "logged-in": 57 |

Logged in successfully!

58 |

59 | Logged in as ${human_tg_id}. 60 | You can now close this page. 61 | You should be invited to Telegram portals on Matrix momentarily. 62 |

63 | % elif state == "bot-logged-in": 64 |

Logged in successfully!

65 |

66 | Logged in as ${human_tg_id}. 67 | You can now close this page. 68 | You should be invited to Telegram portals on Matrix momentarily. 69 |

70 | % else: 71 |

You're already logged in!

72 |

73 | You're logged in as ${human_tg_id}. 74 |

75 |

76 | If you want to log in with another account, log out using the logout 77 | management command first. 78 |

79 | % endif 80 | % elif state == "invalid-token": 81 |

Invalid or expired token

82 |
Please ask the bridge bot for a new login link.
83 | % else: 84 |

Log in to Telegram

85 | % if error: 86 |
${error}
87 | % endif 88 | % if message: 89 |
${message}
90 | % endif 91 |
92 |
93 | 94 | 95 | % if state == "request": 96 | 97 | 98 | 99 | 102 | % elif state == "bot_token": 103 | 104 | 106 | 107 | % elif state == "code": 108 | 109 | 110 | 111 | % elif state == "password": 112 | 113 | 115 | 116 | % endif 117 | % if state != "request": 118 |
119 | 122 |
123 | % endif 124 |
125 |
126 | % endif 127 |
128 | 129 | 130 | -------------------------------------------------------------------------------- /mautrix_telegram/web/public/matrix-login.html.mako: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | 21 | Matrix login - Mautrix-Telegram bridge 22 | 23 | 24 | 25 | 26 | 27 | 28 | 30 | 32 | 33 | 34 | 35 |
36 | % if state == "logged-in": 37 |

Logged in successfully!

38 |

39 | Logged in as ${mxid}. 40 | You can now close this page. 41 |

42 | % elif state == "already-logged-in": 43 |

You're already logged in!

44 |

45 | If you want to log in with another account, log out using the 46 | logout-matrix management command first. 47 |

48 | % elif state == "invalid-token": 49 |

Invalid or expired token

50 |
Please ask the bridge bot for a new login link.
51 | % else: 52 |

Log in to Matrix

53 | % if error: 54 |
${error}
55 | % endif 56 | % if message: 57 |
${message}
58 | % endif 59 |
60 |
61 | 62 | 63 | 64 | 65 |
66 | 67 |
68 | 69 | 70 | 72 | 73 | 74 |
75 |
76 | % endif 77 |
78 | 79 | 80 | -------------------------------------------------------------------------------- /optional-requirements.txt: -------------------------------------------------------------------------------- 1 | # Format: #/name defines a new extras_require group called name 2 | # Uncommented lines after the group definition insert things into that group. 3 | 4 | #/speedups 5 | cryptg>=0.1,<0.6 6 | aiodns 7 | brotli 8 | 9 | #/qr_login 10 | pillow>=10.0.1,<12 11 | qrcode>=6,<9 12 | 13 | #/formattednumbers 14 | phonenumbers>=8,<10 15 | 16 | #/metrics 17 | prometheus_client>=0.6,<0.23 18 | 19 | #/e2be 20 | python-olm>=3,<4 21 | pycryptodome>=3,<4 22 | unpaddedbase64>=1,<3 23 | base58>=2,<3 24 | 25 | #/sqlite 26 | aiosqlite>=0.16,<0.22 27 | 28 | #/proxy 29 | python-socks[asyncio] 30 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mautrix/telegram/280c74e9cdbb7f057f5778bb31843647cf39925d/preview.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.isort] 2 | profile = "black" 3 | force_to_top = "typing" 4 | from_first = true 5 | combine_as_imports = true 6 | known_first_party = "mautrix" 7 | known_third_party = "telethon" 8 | line_length = 99 9 | 10 | [tool.black] 11 | line-length = 99 12 | target-version = ["py310"] 13 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ruamel.yaml>=0.15.35,<0.19 2 | python-magic>=0.4,<0.5 3 | commonmark>=0.8,<0.10 4 | aiohttp>=3,<4 5 | yarl>=1,<2 6 | mautrix>=0.21.0b5,<0.22 7 | tulir-telethon==1.99.0a6 8 | asyncpg>=0.20,<1 9 | mako>=1,<2 10 | setuptools 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | from mautrix_telegram.get_version import git_tag, git_revision, version, linkified_version 4 | 5 | with open("requirements.txt") as reqs: 6 | install_requires = reqs.read().splitlines() 7 | 8 | with open("optional-requirements.txt") as reqs: 9 | extras_require = {} 10 | current = [] 11 | for line in reqs.read().splitlines(): 12 | if line.startswith("#/"): 13 | extras_require[line[2:]] = current = [] 14 | elif not line or line.startswith("#"): 15 | continue 16 | else: 17 | current.append(line) 18 | 19 | extras_require["all"] = list({dep for deps in extras_require.values() for dep in deps}) 20 | 21 | try: 22 | long_desc = open("README.md").read() 23 | except IOError: 24 | long_desc = "Failed to read README.md" 25 | 26 | with open("mautrix_telegram/version.py", "w") as version_file: 27 | version_file.write(f"""# Generated in setup.py 28 | 29 | git_tag = {git_tag!r} 30 | git_revision = {git_revision!r} 31 | version = {version!r} 32 | linkified_version = {linkified_version!r} 33 | """) 34 | 35 | setuptools.setup( 36 | name="mautrix-telegram", 37 | version=version, 38 | url="https://github.com/mautrix/telegram", 39 | project_urls={ 40 | "Changelog": "https://github.com/mautrix/telegram/blob/master/CHANGELOG.md", 41 | }, 42 | 43 | author="Tulir Asokan", 44 | author_email="tulir@maunium.net", 45 | 46 | description="A Matrix-Telegram hybrid puppeting/relaybot bridge.", 47 | long_description=long_desc, 48 | long_description_content_type="text/markdown", 49 | 50 | packages=setuptools.find_packages(), 51 | 52 | install_requires=install_requires, 53 | extras_require=extras_require, 54 | python_requires="~=3.10", 55 | 56 | classifiers=[ 57 | "Development Status :: 4 - Beta", 58 | "License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)", 59 | "Topic :: Communications :: Chat", 60 | "Framework :: AsyncIO", 61 | "Programming Language :: Python", 62 | "Programming Language :: Python :: 3", 63 | "Programming Language :: Python :: 3.10", 64 | "Programming Language :: Python :: 3.11", 65 | "Programming Language :: Python :: 3.12", 66 | "Programming Language :: Python :: 3.13", 67 | ], 68 | package_data={"mautrix_telegram": [ 69 | "web/public/*.mako", "web/public/*.png", "web/public/*.css", 70 | "example-config.yaml", "unicodemojipack.pickle", 71 | ]}, 72 | data_files=[ 73 | (".", ["mautrix_telegram/example-config.yaml"]), 74 | ], 75 | ) 76 | --------------------------------------------------------------------------------