From 043cb7f85475393402323c6da5f0904a97cd53fd Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sun, 24 Mar 2024 00:29:54 +0200 Subject: [PATCH] Remove everything and add stub Go module --- .dockerignore | 2 - .editorconfig | 11 +- .github/workflows/go.yml | 35 + .github/workflows/python-lint.yml | 26 - .gitignore | 29 +- .gitlab-ci.yml | 5 +- .idea/icon.svg | 1 + .pre-commit-config.yaml | 24 +- Dockerfile | 68 +- Dockerfile.ci | 14 + LICENSE.exceptions | 12 + MANIFEST.in | 5 - README.md | 29 +- ROADMAP.md | 94 +- build.sh | 4 + dev-requirements.txt | 3 - docker-run.sh | 36 +- ...example-config.yaml => example-config.yaml | 0 go.mod | 3 + main.go | 4 + mautrix_telegram/__init__.py | 2 - mautrix_telegram/__main__.py | 145 - mautrix_telegram/abstract_user.py | 782 ---- mautrix_telegram/bot.py | 457 -- mautrix_telegram/commands/__init__.py | 26 - mautrix_telegram/commands/handler.py | 196 - mautrix_telegram/commands/matrix_auth.py | 85 - mautrix_telegram/commands/portal/__init__.py | 1 - mautrix_telegram/commands/portal/admin.py | 77 - mautrix_telegram/commands/portal/bridge.py | 261 -- mautrix_telegram/commands/portal/config.py | 162 - .../commands/portal/create_chat.py | 75 - mautrix_telegram/commands/portal/filter.py | 105 - mautrix_telegram/commands/portal/misc.py | 347 -- mautrix_telegram/commands/portal/unbridge.py | 118 - mautrix_telegram/commands/portal/util.py | 72 - .../commands/telegram/__init__.py | 1 - mautrix_telegram/commands/telegram/account.py | 173 - mautrix_telegram/commands/telegram/auth.py | 388 -- mautrix_telegram/commands/telegram/misc.py | 516 --- mautrix_telegram/config.py | 311 -- mautrix_telegram/db/__init__.py | 60 - mautrix_telegram/db/backfill_queue.py | 235 - mautrix_telegram/db/bot_chat.py | 55 - mautrix_telegram/db/disappearing_message.py | 78 - mautrix_telegram/db/message.py | 226 - mautrix_telegram/db/portal.py | 192 - mautrix_telegram/db/puppet.py | 144 - mautrix_telegram/db/reaction.py | 100 - mautrix_telegram/db/telegram_file.py | 111 - mautrix_telegram/db/telethon_session.py | 266 -- mautrix_telegram/db/upgrade/__init__.py | 24 - .../db/upgrade/v00_latest_revision.py | 242 - .../db/upgrade/v01_initial_revision.py | 181 - .../db/upgrade/v02_sponsored_events.py | 25 - mautrix_telegram/db/upgrade/v03_reactions.py | 39 - .../db/upgrade/v04_disappearing_messages.py | 32 - .../db/upgrade/v05_channel_ghosts.py | 25 - .../db/upgrade/v06_puppet_avatar_url.py | 31 - .../db/upgrade/v07_puppet_phone_number.py | 23 - .../db/upgrade/v08_portal_first_event.py | 24 - .../db/upgrade/v09_puppet_username_index.py | 23 - .../db/upgrade/v10_more_backfill_fields.py | 23 - .../db/upgrade/v11_backfill_queue.py | 45 - .../db/upgrade/v12_message_sender.py | 24 - .../db/upgrade/v13_multiple_reactions.py | 54 - .../upgrade/v14_puppet_custom_mxid_index.py | 23 - .../db/upgrade/v15_backfill_anchor_id.py | 23 - .../db/upgrade/v16_backfill_type.py | 28 - .../db/upgrade/v17_message_find_recent.py | 25 - .../db/upgrade/v18_puppet_contact_info_set.py | 25 - mautrix_telegram/db/user.py | 158 - mautrix_telegram/formatter/__init__.py | 2 - .../formatter/from_matrix/__init__.py | 110 - .../formatter/from_matrix/parser.py | 92 - .../formatter/from_matrix/telegram_message.py | 122 - mautrix_telegram/formatter/from_telegram.py | 437 -- mautrix_telegram/get_version.py | 49 - mautrix_telegram/matrix.py | 422 -- mautrix_telegram/portal.py | 4079 ----------------- mautrix_telegram/portal_util/__init__.py | 6 - mautrix_telegram/portal_util/deduplication.py | 157 - .../portal_util/message_convert.py | 1040 ----- mautrix_telegram/portal_util/participants.py | 109 - mautrix_telegram/portal_util/power_levels.py | 158 - mautrix_telegram/portal_util/send_lock.py | 57 - .../portal_util/sponsored_message.py | 97 - mautrix_telegram/puppet.py | 601 --- mautrix_telegram/scripts/__init__.py | 0 .../scripts/unicodemojipack/__init__.py | 0 .../scripts/unicodemojipack/__main__.py | 397 -- mautrix_telegram/tgclient.py | 77 - mautrix_telegram/types.py | 3 - mautrix_telegram/unicodemojipack.json | 1 - mautrix_telegram/unicodemojipack.pickle | Bin 82379 -> 0 bytes mautrix_telegram/user.py | 1128 ----- mautrix_telegram/util/__init__.py | 12 - mautrix_telegram/util/color_log.py | 36 - mautrix_telegram/util/file_transfer.py | 427 -- .../util/parallel_file_transfer.py | 400 -- mautrix_telegram/util/recursive_dict.py | 56 - mautrix_telegram/util/sane_mimetypes.py | 37 - mautrix_telegram/util/tgs_converter.py | 169 - mautrix_telegram/util/tl_json.py | 39 - mautrix_telegram/util/webm_converter.py | 52 - mautrix_telegram/version.py | 1 - mautrix_telegram/web/__init__.py | 2 - mautrix_telegram/web/common/__init__.py | 1 - mautrix_telegram/web/common/auth_api.py | 395 -- mautrix_telegram/web/provisioning/__init__.py | 794 ---- mautrix_telegram/web/public/__init__.py | 235 - mautrix_telegram/web/public/favicon.png | Bin 43371 -> 0 bytes mautrix_telegram/web/public/login.css | 99 - mautrix_telegram/web/public/login.html.mako | 129 - .../web/public/matrix-login.html.mako | 79 - optional-requirements.txt | 28 - preview.png | Bin 511960 -> 0 bytes .../spec.yaml => provisioning-spec.yaml | 0 pyproject.toml | 12 - requirements.txt | 10 - setup.py | 74 - 121 files changed, 192 insertions(+), 19308 deletions(-) create mode 100644 .github/workflows/go.yml delete mode 100644 .github/workflows/python-lint.yml create mode 100644 .idea/icon.svg create mode 100644 Dockerfile.ci create mode 100644 LICENSE.exceptions delete mode 100644 MANIFEST.in create mode 100755 build.sh delete mode 100644 dev-requirements.txt rename mautrix_telegram/example-config.yaml => example-config.yaml (100%) create mode 100644 go.mod create mode 100644 main.go delete mode 100644 mautrix_telegram/__init__.py delete mode 100644 mautrix_telegram/__main__.py delete mode 100644 mautrix_telegram/abstract_user.py delete mode 100644 mautrix_telegram/bot.py delete mode 100644 mautrix_telegram/commands/__init__.py delete mode 100644 mautrix_telegram/commands/handler.py delete mode 100644 mautrix_telegram/commands/matrix_auth.py delete mode 100644 mautrix_telegram/commands/portal/__init__.py delete mode 100644 mautrix_telegram/commands/portal/admin.py delete mode 100644 mautrix_telegram/commands/portal/bridge.py delete mode 100644 mautrix_telegram/commands/portal/config.py delete mode 100644 mautrix_telegram/commands/portal/create_chat.py delete mode 100644 mautrix_telegram/commands/portal/filter.py delete mode 100644 mautrix_telegram/commands/portal/misc.py delete mode 100644 mautrix_telegram/commands/portal/unbridge.py delete mode 100644 mautrix_telegram/commands/portal/util.py delete mode 100644 mautrix_telegram/commands/telegram/__init__.py delete mode 100644 mautrix_telegram/commands/telegram/account.py delete mode 100644 mautrix_telegram/commands/telegram/auth.py delete mode 100644 mautrix_telegram/commands/telegram/misc.py delete mode 100644 mautrix_telegram/config.py delete mode 100644 mautrix_telegram/db/__init__.py delete mode 100644 mautrix_telegram/db/backfill_queue.py delete mode 100644 mautrix_telegram/db/bot_chat.py delete mode 100644 mautrix_telegram/db/disappearing_message.py delete mode 100644 mautrix_telegram/db/message.py delete mode 100644 mautrix_telegram/db/portal.py delete mode 100644 mautrix_telegram/db/puppet.py delete mode 100644 mautrix_telegram/db/reaction.py delete mode 100644 mautrix_telegram/db/telegram_file.py delete mode 100644 mautrix_telegram/db/telethon_session.py delete mode 100644 mautrix_telegram/db/upgrade/__init__.py delete mode 100644 mautrix_telegram/db/upgrade/v00_latest_revision.py delete mode 100644 mautrix_telegram/db/upgrade/v01_initial_revision.py delete mode 100644 mautrix_telegram/db/upgrade/v02_sponsored_events.py delete mode 100644 mautrix_telegram/db/upgrade/v03_reactions.py delete mode 100644 mautrix_telegram/db/upgrade/v04_disappearing_messages.py delete mode 100644 mautrix_telegram/db/upgrade/v05_channel_ghosts.py delete mode 100644 mautrix_telegram/db/upgrade/v06_puppet_avatar_url.py delete mode 100644 mautrix_telegram/db/upgrade/v07_puppet_phone_number.py delete mode 100644 mautrix_telegram/db/upgrade/v08_portal_first_event.py delete mode 100644 mautrix_telegram/db/upgrade/v09_puppet_username_index.py delete mode 100644 mautrix_telegram/db/upgrade/v10_more_backfill_fields.py delete mode 100644 mautrix_telegram/db/upgrade/v11_backfill_queue.py delete mode 100644 mautrix_telegram/db/upgrade/v12_message_sender.py delete mode 100644 mautrix_telegram/db/upgrade/v13_multiple_reactions.py delete mode 100644 mautrix_telegram/db/upgrade/v14_puppet_custom_mxid_index.py delete mode 100644 mautrix_telegram/db/upgrade/v15_backfill_anchor_id.py delete mode 100644 mautrix_telegram/db/upgrade/v16_backfill_type.py delete mode 100644 mautrix_telegram/db/upgrade/v17_message_find_recent.py delete mode 100644 mautrix_telegram/db/upgrade/v18_puppet_contact_info_set.py delete mode 100644 mautrix_telegram/db/user.py delete mode 100644 mautrix_telegram/formatter/__init__.py delete mode 100644 mautrix_telegram/formatter/from_matrix/__init__.py delete mode 100644 mautrix_telegram/formatter/from_matrix/parser.py delete mode 100644 mautrix_telegram/formatter/from_matrix/telegram_message.py delete mode 100644 mautrix_telegram/formatter/from_telegram.py delete mode 100644 mautrix_telegram/get_version.py delete mode 100644 mautrix_telegram/matrix.py delete mode 100644 mautrix_telegram/portal.py delete mode 100644 mautrix_telegram/portal_util/__init__.py delete mode 100644 mautrix_telegram/portal_util/deduplication.py delete mode 100644 mautrix_telegram/portal_util/message_convert.py delete mode 100644 mautrix_telegram/portal_util/participants.py delete mode 100644 mautrix_telegram/portal_util/power_levels.py delete mode 100644 mautrix_telegram/portal_util/send_lock.py delete mode 100644 mautrix_telegram/portal_util/sponsored_message.py delete mode 100644 mautrix_telegram/puppet.py delete mode 100644 mautrix_telegram/scripts/__init__.py delete mode 100644 mautrix_telegram/scripts/unicodemojipack/__init__.py delete mode 100644 mautrix_telegram/scripts/unicodemojipack/__main__.py delete mode 100644 mautrix_telegram/tgclient.py delete mode 100644 mautrix_telegram/types.py delete mode 100644 mautrix_telegram/unicodemojipack.json delete mode 100644 mautrix_telegram/unicodemojipack.pickle delete mode 100644 mautrix_telegram/user.py delete mode 100644 mautrix_telegram/util/__init__.py delete mode 100644 mautrix_telegram/util/color_log.py delete mode 100644 mautrix_telegram/util/file_transfer.py delete mode 100644 mautrix_telegram/util/parallel_file_transfer.py delete mode 100644 mautrix_telegram/util/recursive_dict.py delete mode 100644 mautrix_telegram/util/sane_mimetypes.py delete mode 100644 mautrix_telegram/util/tgs_converter.py delete mode 100644 mautrix_telegram/util/tl_json.py delete mode 100644 mautrix_telegram/util/webm_converter.py delete mode 100644 mautrix_telegram/version.py delete mode 100644 mautrix_telegram/web/__init__.py delete mode 100644 mautrix_telegram/web/common/__init__.py delete mode 100644 mautrix_telegram/web/common/auth_api.py delete mode 100644 mautrix_telegram/web/provisioning/__init__.py delete mode 100644 mautrix_telegram/web/public/__init__.py delete mode 100644 mautrix_telegram/web/public/favicon.png delete mode 100644 mautrix_telegram/web/public/login.css delete mode 100644 mautrix_telegram/web/public/login.html.mako delete mode 100644 mautrix_telegram/web/public/matrix-login.html.mako delete mode 100644 optional-requirements.txt delete mode 100644 preview.png rename mautrix_telegram/web/provisioning/spec.yaml => provisioning-spec.yaml (100%) delete mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.py diff --git a/.dockerignore b/.dockerignore index d7755966..5cbed522 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,9 +1,7 @@ .editorconfig -.codeclimate.yml *.png *.md logs -.venv start config.yaml registration.yaml diff --git a/.editorconfig b/.editorconfig index e6e19967..f268152b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,12 +10,11 @@ insert_final_newline = true [*.md] trim_trailing_whitespace = false - -[*.py] -max_line_length = 99 - -[*.{yaml,yml,py}] +indent_size = 2 indent_style = space -[{.gitlab-ci.yml,.pre-commit-config.yaml,mautrix_telegram/web/provisioning/spec.yaml}] +[*.{yaml,yml,sql}] +indent_style = space + +[{.gitlab-ci.yml,.pre-commit-config.yaml,provisioning-spec.yaml,.github/workflows/*.yml}] indent_size = 2 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 00000000..3d392af3 --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,35 @@ +name: Go + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + go-version: ["1.21", "1.22"] + name: Lint ${{ matrix.go-version == '1.22' && '(latest)' || '(old)' }} + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Install libolm + run: sudo apt-get install libolm-dev libolm3 + + - name: Install goimports + run: | + go install golang.org/x/tools/cmd/goimports@latest + export PATH="$HOME/go/bin:$PATH" + + - name: Install pre-commit + run: pip install pre-commit + + - name: Lint + run: pre-commit run -a diff --git a/.github/workflows/python-lint.yml b/.github/workflows/python-lint.yml deleted file mode 100644 index 56d74f15..00000000 --- a/.github/workflows/python-lint.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Python lint - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - uses: isort/isort-action@master - with: - sortPaths: "./mautrix_telegram" - - uses: psf/black@stable - with: - src: "./mautrix_telegram" - version: "24.1.1" - - name: pre-commit - run: | - pip install pre-commit - pre-commit run -av trailing-whitespace - pre-commit run -av end-of-file-fixer - pre-commit run -av check-yaml - pre-commit run -av check-added-large-files diff --git a/.gitignore b/.gitignore index 457a1004..59b2f68e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,15 @@ -/.idea/ - -/.venv -/env/ -pip-selfcheck.json -*.pyc -__pycache__ -/build -/dist -/*.egg-info -/.eggs +.idea *.yaml !.pre-commit-config.yaml !example-config.yaml -!/mautrix_telegram/web/provisioning/spec.yaml -!/.github/workflows/*.yaml +!provisioning-spec.yaml -/start -/mautrix -/telethon - -*.log* +*.json *.db -*.db-* -/*.pickle +*.log *.bak -/*.json + +/mautrix-telegram +/mautrix-telegramgo +/start diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7259931d..29886dd2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,3 +1,6 @@ include: - project: 'mautrix/ci' - file: '/python.yml' + file: '/go.yml' + +variables: + BINARY_NAME: mautrix-telegram diff --git a/.idea/icon.svg b/.idea/icon.svg new file mode 100644 index 00000000..59db3dfe --- /dev/null +++ b/.idea/icon.svg @@ -0,0 +1 @@ +Telegram_logo diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 71a1cbbe..dc2a6d05 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,24 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace exclude_types: [markdown] - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - - repo: https://github.com/psf/black - rev: 24.1.1 + + - repo: https://github.com/tekwizely/pre-commit-golang + rev: v1.0.0-rc.1 hooks: - - id: black - language_version: python3 - files: ^mautrix_telegram/.*\.pyi?$ - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 + - id: go-imports + exclude: "pb\\.go$" + - id: go-vet-mod + #- id: go-staticcheck-repo-mod + # TODO: reenable this and fix all the problems + + - repo: https://github.com/beeper/pre-commit-go + rev: v0.3.1 hooks: - - id: isort - files: ^mautrix_telegram/.*\.pyi?$ + - id: zerolog-ban-msgf + - id: zerolog-use-stringer diff --git a/Dockerfile b/Dockerfile index 865bb22b..be715456 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,59 +1,21 @@ -FROM dock.mau.dev/tulir/lottieconverter:alpine-3.19 +FROM golang:1-alpine3.19 AS builder -RUN apk add --no-cache \ - python3 py3-pip py3-setuptools py3-wheel \ - py3-pillow \ - py3-aiohttp \ - py3-asyncpg \ - py3-aiosqlite \ - py3-magic \ - py3-ruamel.yaml \ - py3-commonmark \ - py3-phonenumbers \ - py3-mako \ - #py3-prometheus-client \ (pulls in twisted unnecessarily) - # Indirect dependencies - py3-idna \ - py3-rsa \ - #py3-telethon \ (outdated) - py3-pyaes \ - py3-aiodns \ - py3-python-socks \ - # cryptg - py3-cffi \ - py3-qrcode \ - py3-brotli \ - # Other dependencies - ffmpeg \ - ca-certificates \ - su-exec \ - netcat-openbsd \ - # encryption - py3-olm \ - py3-pycryptodome \ - py3-unpaddedbase64 \ - py3-future \ - bash \ - curl \ - jq \ - yq +RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev -COPY requirements.txt /opt/mautrix-telegram/requirements.txt -COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt -WORKDIR /opt/mautrix-telegram -RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \ - && pip3 install --break-system-packages /cryptg-*.whl \ - && pip3 install --break-system-packages --no-cache-dir -r requirements.txt -r optional-requirements.txt \ - && apk del .build-deps \ - && rm -f /cryptg-*.whl +COPY . /build +WORKDIR /build +RUN go build -o /usr/bin/mautrix-telegram -COPY . /opt/mautrix-telegram -RUN apk add git && pip3 install --break-system-packages --no-cache-dir .[all] && apk del git \ - # This doesn't make the image smaller, but it's needed so that the `version` command works properly - && cp mautrix_telegram/example-config.yaml . && rm -rf mautrix_telegram .git build +FROM alpine:3.19 +ENV UID=1337 \ + GID=1337 + +RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl + +COPY --from=builder /usr/bin/mautrix-telegram /usr/bin/mautrix-telegram +COPY --from=builder /build/example-config.yaml /opt/mautrix-telegram/example-config.yaml +COPY --from=builder /build/docker-run.sh /docker-run.sh VOLUME /data -ENV UID=1337 GID=1337 \ - FFMPEG_BINARY=/usr/bin/ffmpeg -CMD ["/opt/mautrix-telegram/docker-run.sh"] +CMD ["/docker-run.sh"] diff --git a/Dockerfile.ci b/Dockerfile.ci new file mode 100644 index 00000000..ba34e1f9 --- /dev/null +++ b/Dockerfile.ci @@ -0,0 +1,14 @@ +FROM alpine:3.19 + +ENV UID=1337 \ + GID=1337 + +RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq + +ARG EXECUTABLE=./mautrix-telegram +COPY $EXECUTABLE /usr/bin/mautrix-telegram +COPY ./example-config.yaml /opt/mautrix-telegram/example-config.yaml +COPY ./docker-run.sh /docker-run.sh +VOLUME /data + +CMD ["/docker-run.sh"] diff --git a/LICENSE.exceptions b/LICENSE.exceptions new file mode 100644 index 00000000..2307ff6d --- /dev/null +++ b/LICENSE.exceptions @@ -0,0 +1,12 @@ +The mautrix-telegram developers grant the following special exceptions: + +* to Beeper the right to embed the program in the Beeper clients and servers, + and use and distribute the collective work without applying the license to + the whole. +* to Element the right to distribute compiled binaries of the program as a part + of the Element Server Suite and other server bundles without applying the + license. + +All exceptions are only valid under the condition that any modifications to +the source code of mautrix-telegram remain publicly available under the terms +of the GNU AGPL version 3 or later. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index d8889bcc..00000000 --- a/MANIFEST.in +++ /dev/null @@ -1,5 +0,0 @@ -include README.md -include CHANGELOG.md -include LICENSE -include requirements.txt -include optional-requirements.txt diff --git a/README.md b/README.md index 0db9a6d7..8f936c0e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ # mautrix-telegram -![Languages](https://img.shields.io/github/languages/top/mautrix/telegram.svg) -[![License](https://img.shields.io/github/license/mautrix/telegram.svg)](LICENSE) -[![Release](https://img.shields.io/github/release/mautrix/telegram/all.svg)](https://github.com/mautrix/telegram/releases) -[![GitLab CI](https://mau.dev/mautrix/telegram/badges/master/pipeline.svg)](https://mau.dev/mautrix/telegram/container_registry) -[![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) -[![Imports](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/) +![Languages](https://img.shields.io/github/languages/top/mautrix/telegramgo.svg) +[![License](https://img.shields.io/github/license/mautrix/telegramgo.svg)](LICENSE) +[![Release](https://img.shields.io/github/release/mautrix/telegramgo/all.svg)](https://github.com/mautrix/telegramgo/releases) +[![GitLab CI](https://mau.dev/mautrix/telegramgo/badges/master/pipeline.svg)](https://mau.dev/mautrix/telegramgo/container_registry) A Matrix-Telegram hybrid puppeting/relaybot bridge. @@ -12,19 +10,22 @@ A Matrix-Telegram hybrid puppeting/relaybot bridge. * [Joel Lehtonen / Zouppen](https://github.com/zouppen) ## Documentation +The rewrite doesn't exist yet, so there's no documentation. + + ### Features & Roadmap -[ROADMAP.md](https://github.com/mautrix/telegram/blob/master/ROADMAP.md) -contains a general overview of what is supported by the bridge. +[ROADMAP.md](ROADMAP.md) contains a general overview of what is supported by the bridge. ## Discussion Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net) diff --git a/ROADMAP.md b/ROADMAP.md index 1bf6ba27..52e2d322 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,66 +1,66 @@ # Features & roadmap * Matrix → Telegram - * [x] Message content (text, formatting, files, etc..) - * [x] Message redactions - * [x] Message reactions - * [x] Message edits + * [ ] Message content (text, formatting, files, etc..) + * [ ] Message redactions + * [ ] Message reactions + * [ ] Message edits * [ ] ‡ Message history - * [x] Presence - * [x] Typing notifications - * [x] Read receipts - * [x] Pinning messages - * [x] Power level - * [x] Normal chats + * [ ] Presence + * [ ] Typing notifications + * [ ] Read receipts + * [ ] Pinning messages + * [ ] Power level + * [ ] Normal chats * [ ] Non-hardcoded PL requirements - * [x] Supergroups/channels + * [ ] Supergroups/channels * [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..) - * [x] Membership actions (invite/kick/join/leave) - * [x] Room metadata changes (name, topic, avatar) - * [x] Initial room metadata + * [ ] Membership actions (invite/kick/join/leave) + * [ ] Room metadata changes (name, topic, avatar) + * [ ] Initial room metadata * [ ] User metadata * [ ] Initial displayname/username/avatar at register * [ ] ‡ Changes to displayname/avatar * Telegram → Matrix - * [x] Message content (text, formatting, files, etc..) + * [ ] Message content (text, formatting, files, etc..) * [ ] Advanced message content/media - * [x] Custom emojis - * [x] Polls - * [x] Games - * [ ] Buttons - * [x] Message deletions - * [x] Message reactions - * [x] Message edits - * [x] Message history - * [x] Manually (`!tg backfill`) - * [x] Automatically when creating portal - * [x] Automatically for missed messages - * [x] Avatars - * [x] Presence - * [x] Typing notifications - * [x] Read receipts (private chat only) - * [x] Pinning messages - * [x] Admin/chat creator status + * [ ] Custom emojis + * [ ] Polls + * [ ] Games + * [ ] Buttons + * [ ] Message deletions + * [ ] Message reactions + * [ ] Message edits + * [ ] Message history + * [ ] Manually (`!tg backfill`) + * [ ] Automatically when creating portal + * [ ] Automatically for missed messages + * [ ] Avatars + * [ ] Presence + * [ ] Typing notifications + * [ ] Read receipts (private chat only) + * [ ] Pinning messages + * [ ] Admin/chat creator status * [ ] Supergroup/channel permissions (precise per-user permissions not supported in Matrix) - * [x] Membership actions (invite/kick/join/leave) + * [ ] Membership actions (invite/kick/join/leave) * [ ] Chat metadata changes - * [x] Title - * [x] Avatar + * [ ] Title + * [ ] Avatar * [ ] † About text * [ ] † Public channel username - * [x] Initial chat metadata (about text missing) - * [x] User metadata (displayname/avatar) - * [x] Supergroup upgrade + * [ ] Initial chat metadata (about text missing) + * [ ] User metadata (displayname/avatar) + * [ ] Supergroup upgrade * Misc - * [x] Automatic portal creation - * [x] At startup - * [x] When receiving invite or message - * [x] Portal creation by inviting Matrix puppet of Telegram user to new room - * [x] Option to use bot to relay messages for unauthenticated Matrix users (relaybot) - * [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting) - * [ ] ‡ Calls (hard, not yet supported by Telethon) - * [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram) - * [x] End-to-bridge encryption in Matrix rooms (see [docs](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html)) + * [ ] Automatic portal creation + * [ ] At startup + * [ ] When receiving invite or message + * [ ] Portal creation by inviting Matrix puppet of Telegram user to new room + * [ ] Option to use bot to relay messages for unauthenticated Matrix users (relaybot) + * [ ] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting) + * [ ] ‡ Calls + * [ ] ‡ Secret chats (i.e. end-to-bridge encryption on Telegram) + * [ ] End-to-bridge encryption in Matrix rooms (see [docs](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html)) † Information not automatically sent from source, i.e. implementation may not be possible ‡ Maybe, i.e. this feature may or may not be implemented at some point diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..a8901bc5 --- /dev/null +++ b/build.sh @@ -0,0 +1,4 @@ +#!/bin/sh +export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }') +export GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'" +go build -ldflags "$GO_LDFLAGS" -o mautrix-telegram "$@" diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index bb8c2a0a..00000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -pre-commit>=2.10.1,<3 -isort>=5.10.1,<6 -black>=24,<25 diff --git a/docker-run.sh b/docker-run.sh index 9e207ff7..e7682441 100755 --- a/docker-run.sh +++ b/docker-run.sh @@ -1,17 +1,7 @@ #!/bin/sh -if [ ! -z "$MAUTRIX_DIRECT_STARTUP" ]; then - if [ $(id -u) == 0 ]; then - echo "|------------------------------------------|" - echo "| Warning: running bridge unsafely as root |" - echo "|------------------------------------------|" - fi - exec python3 -m mautrix_telegram -c /data/config.yaml -elif [ $(id -u) != 0 ]; then - echo "The startup script must run as root. It will use su-exec to drop permissions before running the bridge." - echo "To bypass the startup script, either set the `MAUTRIX_DIRECT_STARTUP` environment variable," - echo "or just use `python3 -m mautrix_telegram -c /data/config.yaml` as the run command." - echo "Note that the config and registration will not be auto-generated when bypassing the startup script." - exit 1 + +if [[ -z "$GID" ]]; then + GID="$UID" fi # Define functions. @@ -19,32 +9,28 @@ function fixperms { chown -R $UID:$GID /data # /opt/mautrix-telegram is read-only, so disable file logging if it's pointing there. - if [[ "$(yq e '.logging.handlers.file.filename' /data/config.yaml)" == "./mautrix-telegram.log" ]]; then - yq -I4 e -i 'del(.logging.root.handlers[] | select(. == "file"))' /data/config.yaml - yq -I4 e -i 'del(.logging.handlers.file)' /data/config.yaml + if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/mautrix-telegram.log" ]]; then + yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml fi } -cd /opt/mautrix-telegram - -if [ ! -f /data/config.yaml ]; then - cp example-config.yaml /data/config.yaml +if [[ ! -f /data/config.yaml ]]; then + cp /opt/mautrix-telegram/example-config.yaml /data/config.yaml echo "Didn't find a config file." echo "Copied default config file to /data/config.yaml" echo "Modify that config file to your liking." echo "Start the container again after that to generate the registration file." - fixperms exit fi -if [ ! -f /data/registration.yaml ]; then - python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml || exit $? +if [[ ! -f /data/registration.yaml ]]; then + /usr/bin/mautrix-telegram -g -c /data/config.yaml -r /data/registration.yaml || exit $? echo "Didn't find a registration file." echo "Generated one for you." echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." - fixperms exit fi +cd /data fixperms -exec su-exec $UID:$GID python3 -m mautrix_telegram -c /data/config.yaml +exec su-exec $UID:$GID /usr/bin/mautrix-telegram diff --git a/mautrix_telegram/example-config.yaml b/example-config.yaml similarity index 100% rename from mautrix_telegram/example-config.yaml rename to example-config.yaml diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..5d8a1377 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module go.mau.fi/mautrix-telegram + +go 1.21 diff --git a/main.go b/main.go new file mode 100644 index 00000000..da29a2ca --- /dev/null +++ b/main.go @@ -0,0 +1,4 @@ +package main + +func main() { +} diff --git a/mautrix_telegram/__init__.py b/mautrix_telegram/__init__.py deleted file mode 100644 index 142ae072..00000000 --- a/mautrix_telegram/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = "0.15.1" -__author__ = "Tulir Asokan " diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py deleted file mode 100644 index 3279763c..00000000 --- a/mautrix_telegram/__main__.py +++ /dev/null @@ -1,145 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import Any - -from telethon import __version__ as __telethon_version__ - -from mautrix.bridge import Bridge -from mautrix.types import RoomID, UserID - -from .bot import Bot -from .config import Config -from .db import init as init_db, upgrade_table -from .matrix import MatrixHandler -from .portal import Portal -from .puppet import Puppet -from .user import User -from .version import linkified_version, version -from .web.provisioning import ProvisioningAPI -from .web.public import PublicBridgeWebsite - -from .abstract_user import AbstractUser # isort: skip - - -class TelegramBridge(Bridge): - module = "mautrix_telegram" - name = "mautrix-telegram" - beeper_service_name = "telegram" - beeper_network_name = "telegram" - command = "python -m mautrix-telegram" - description = "A Matrix-Telegram puppeting bridge." - repo_url = "https://github.com/mautrix/telegram" - version = version - markdown_version = linkified_version - config_class = Config - matrix_class = MatrixHandler - upgrade_table = upgrade_table - - config: Config - bot: Bot | None - matrix: MatrixHandler - public_website: PublicBridgeWebsite | None - provisioning_api: ProvisioningAPI | None - - def prepare_db(self) -> None: - super().prepare_db() - init_db(self.db) - - def _prepare_website(self) -> None: - if self.config["appservice.provisioning.enabled"]: - self.provisioning_api = ProvisioningAPI(self) - self.az.app.add_subapp( - self.config["appservice.provisioning.prefix"], self.provisioning_api.app - ) - else: - self.provisioning_api = None - - if self.config["appservice.public.enabled"]: - self.public_website = PublicBridgeWebsite(self.loop) - self.az.app.add_subapp( - self.config["appservice.public.prefix"], self.public_website.app - ) - else: - self.public_website = None - - def prepare_bridge(self) -> None: - self._prepare_website() - AbstractUser.init_cls(self) - bot_token: str = self.config["telegram.bot_token"] - if bot_token and not bot_token.lower().startswith("disable"): - self.bot = AbstractUser.relaybot = Bot(bot_token) - else: - self.bot = AbstractUser.relaybot = None - self.matrix = MatrixHandler(self) - Portal.init_cls(self) - self.add_startup_actions(Puppet.init_cls(self)) - self.add_startup_actions(User.init_cls(self)) - self.add_startup_actions(Portal.restart_scheduled_disappearing()) - if self.bot: - self.add_startup_actions(self.bot.start()) - if self.config["bridge.resend_bridge_info"]: - self.add_startup_actions(self.resend_bridge_info()) - - async def resend_bridge_info(self) -> None: - self.config["bridge.resend_bridge_info"] = False - self.config.save() - self.log.info("Re-sending bridge info state event to all portals") - async for portal in Portal.all(): - await portal.update_bridge_info() - self.log.info("Finished re-sending bridge info state events") - - def prepare_stop(self) -> None: - self.add_shutdown_actions(user.stop() for user in User.by_tgid.values()) - if self.bot: - self.add_shutdown_actions(self.bot.stop()) - - async def get_user(self, user_id: UserID, create: bool = True) -> User | None: - user = await User.get_by_mxid(user_id, create=create) - if user: - await user.ensure_started() - return user - - async def get_portal(self, room_id: RoomID) -> Portal | None: - return await Portal.get_by_mxid(room_id) - - async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet | None: - return await Puppet.get_by_mxid(user_id, create=create) - - async def get_double_puppet(self, user_id: UserID) -> Puppet | None: - return await Puppet.get_by_custom_mxid(user_id) - - def is_bridge_ghost(self, user_id: UserID) -> bool: - return bool(Puppet.get_id_from_mxid(user_id)) - - async def count_logged_in_users(self) -> int: - return len([user for user in User.by_tgid.values() if user.tgid]) - - async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]: - return { - **await super().manhole_global_namespace(user_id), - "User": User, - "Portal": Portal, - "Puppet": Puppet, - } - - @property - def manhole_banner_program_version(self) -> str: - return f"{super().manhole_banner_program_version} and Telethon {__telethon_version__}" - - -TelegramBridge().run() diff --git a/mautrix_telegram/abstract_user.py b/mautrix_telegram/abstract_user.py deleted file mode 100644 index 2eb96260..00000000 --- a/mautrix_telegram/abstract_user.py +++ /dev/null @@ -1,782 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2022 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Union -from abc import ABC, abstractmethod -import asyncio -import logging -import platform -import time - -from telethon.errors import AuthKeyError, UnauthorizedError -from telethon.network import ( - Connection, - ConnectionTcpFull, - ConnectionTcpMTProxyRandomizedIntermediate, -) -from telethon.sessions import Session -from telethon.tl.patched import Message, MessageService -from telethon.tl.types import ( - Channel, - Chat, - MessageActionChannelMigrateFrom, - MessageEmpty, - PeerChannel, - PeerChat, - PeerUser, - PhoneCallRequested, - TypeUpdate, - UpdateBotMessageReaction, - UpdateChannel, - UpdateChannelUserTyping, - UpdateChatDefaultBannedRights, - UpdateChatParticipantAdmin, - UpdateChatParticipants, - UpdateChatUserTyping, - UpdateDeleteChannelMessages, - UpdateDeleteMessages, - UpdateEditChannelMessage, - UpdateEditMessage, - UpdateFolderPeers, - UpdateMessageReactions, - UpdateNewChannelMessage, - UpdateNewMessage, - UpdateNotifySettings, - UpdatePhoneCall, - UpdatePinnedChannelMessages, - UpdatePinnedDialogs, - UpdatePinnedMessages, - UpdateReadChannelInbox, - UpdateReadHistoryInbox, - UpdateReadHistoryOutbox, - UpdateShort, - UpdateShortChatMessage, - UpdateShortMessage, - UpdateUser, - UpdateUserName, - UpdateUserStatus, - UpdateUserTyping, - User, - UserStatusOffline, - UserStatusOnline, -) - -from mautrix.appservice import AppService -from mautrix.errors import MatrixError -from mautrix.types import PresenceState, UserID -from mautrix.util import background_task -from mautrix.util.logging import TraceLogger -from mautrix.util.opt_prometheus import Counter, Histogram - -from . import __version__, portal as po, puppet as pu -from .config import Config -from .db import Message as DBMessage, PgSession -from .tgclient import MautrixTelegramClient -from .types import TelegramID - -if TYPE_CHECKING: - from .__main__ import TelegramBridge - from .bot import Bot - -UpdateMessage = Union[ - UpdateShortChatMessage, - UpdateShortMessage, - UpdateNewChannelMessage, - UpdateNewMessage, - UpdateEditMessage, - UpdateEditChannelMessage, -] -UpdateMessageContent = Union[ - UpdateShortMessage, UpdateShortChatMessage, Message, MessageService, MessageEmpty -] - -UPDATE_TIME = Histogram( - name="bridge_telegram_update", - documentation="Time spent processing Telegram updates", - labelnames=("update_type",), -) -UPDATE_ERRORS = Counter( - name="bridge_telegram_update_error", - documentation="Number of fatal errors while handling Telegram updates", - labelnames=("update_type",), -) - - -class AbstractUser(ABC): - loop: asyncio.AbstractEventLoop = None - log: TraceLogger - az: AppService - bridge: "TelegramBridge" - config: Config - relaybot: "Bot" - ignore_incoming_bot_events: bool = True - max_deletions: int = 10 - - client: MautrixTelegramClient | None - mxid: UserID | None - - tgid: TelegramID | None - username: str | None - is_bot: bool - - is_relaybot: bool - - puppet_whitelisted: bool - whitelisted: bool - relaybot_whitelisted: bool - matrix_puppet_whitelisted: bool - is_admin: bool - - def __init__(self) -> None: - self.is_admin = False - self.matrix_puppet_whitelisted = False - self.puppet_whitelisted = False - self.whitelisted = False - self.relaybot_whitelisted = False - self.client = None - self.is_relaybot = False - self.is_bot = False - - @property - def connected(self) -> bool: - return self.client and self.client.is_connected() - - @property - def _proxy_settings(self) -> tuple[type[Connection], tuple[Any, ...] | None]: - proxy_type = self.config["telegram.proxy.type"].lower() - connection = ConnectionTcpFull - connection_data = ( - self.config["telegram.proxy.address"], - self.config["telegram.proxy.port"], - self.config["telegram.proxy.rdns"], - self.config["telegram.proxy.username"], - self.config["telegram.proxy.password"], - ) - if proxy_type == "disabled": - connection_data = None - elif proxy_type == "socks4": - connection_data = (1,) + connection_data - elif proxy_type == "socks5": - connection_data = (2,) + connection_data - elif proxy_type == "http": - connection_data = (3,) + connection_data - elif proxy_type == "mtproxy": - connection = ConnectionTcpMTProxyRandomizedIntermediate - connection_data = (connection_data[0], connection_data[1], connection_data[4]) - - return connection, connection_data - - @classmethod - def init_cls(cls, bridge: "TelegramBridge") -> None: - cls.bridge = bridge - cls.config = bridge.config - cls.loop = bridge.loop - cls.az = bridge.az - cls.ignore_incoming_bot_events = cls.config["bridge.relaybot.ignore_own_incoming_events"] - cls.max_deletions = cls.config["bridge.max_telegram_delete"] - - async def _init_client(self) -> None: - self.log.debug(f"Initializing client for {self.name}") - - session = await PgSession.get(self.name) - if self.config["telegram.server.enabled"]: - session.set_dc( - self.config["telegram.server.dc"], - self.config["telegram.server.ip"], - self.config["telegram.server.port"], - ) - - if self.is_relaybot: - base_logger = logging.getLogger("telethon.relaybot") - else: - base_logger = logging.getLogger(f"telethon.{self.tgid or -hash(self.mxid)}") - - device = self.config["telegram.device_info.device_model"] - sysversion = self.config["telegram.device_info.system_version"] - appversion = self.config["telegram.device_info.app_version"] - connection, proxy = self._proxy_settings - if proxy: - self.log.debug(f"Using proxy setting: {proxy}") - - assert isinstance(session, Session) - - self.client = MautrixTelegramClient( - session=session, - api_id=self.config["telegram.api_id"], - api_hash=self.config["telegram.api_hash"], - app_version=__version__ if appversion == "auto" else appversion, - system_version=( - MautrixTelegramClient.__version__ if sysversion == "auto" else sysversion - ), - device_model=( - f"{platform.system()} {platform.release()}" if device == "auto" else device - ), - timeout=self.config["telegram.connection.timeout"], - connection_retries=self.config["telegram.connection.retries"], - retry_delay=self.config["telegram.connection.retry_delay"], - flood_sleep_threshold=self.config["telegram.connection.flood_sleep_threshold"], - request_retries=self.config["telegram.connection.request_retries"], - connection=connection, - proxy=proxy, - raise_last_call_error=True, - catch_up=self.config["telegram.catch_up"], - sequential_updates=self.config["telegram.sequential_updates"], - loop=self.loop, - base_logger=base_logger, - update_error_callback=self._telethon_update_error_callback, - use_ipv6=self.config["telegram.connection.use_ipv6"], - ) - self.client.add_event_handler(self._update_catch) - - @abstractmethod - async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None: - pass - - async def _telethon_update_error_callback(self, err: Exception) -> None: - if isinstance(err, (UnauthorizedError, AuthKeyError)): - background_task.create(self.on_signed_out(err)) - return - if self.config["telegram.exit_on_update_error"]: - self.log.critical(f"Stopping due to update handling error {type(err).__name__}") - self.bridge.manual_stop(50) - else: - self.log.info("Recreating Telethon connection in 60 seconds") - await asyncio.sleep(60) - self.log.debug("Now recreating Telethon connection") - await self.stop() - await self.start() - - @abstractmethod - async def update(self, update: TypeUpdate) -> bool: - return False - - @abstractmethod - async def post_login(self) -> None: - raise NotImplementedError() - - @abstractmethod - async def register_portal(self, portal: po.Portal) -> None: - raise NotImplementedError() - - @abstractmethod - async def unregister_portal(self, tgid: int, tg_receiver: int) -> None: - raise NotImplementedError() - - async def _update_catch(self, update: TypeUpdate) -> None: - start_time = time.time() - update_type = type(update).__name__ - try: - if not await self.update(update): - await self._update(update) - except Exception: - self.log.exception("Failed to handle Telegram update") - UPDATE_ERRORS.labels(update_type=update_type).inc() - UPDATE_TIME.labels(update_type=update_type).observe(time.time() - start_time) - - @property - @abstractmethod - def name(self) -> str: - raise NotImplementedError() - - async def is_logged_in(self) -> bool: - return ( - self.client and self.client.is_connected() and await self.client.is_user_authorized() - ) - - async def has_full_access(self, allow_bot: bool = False) -> bool: - return ( - self.puppet_whitelisted - and (not self.is_bot or allow_bot) - and await self.is_logged_in() - ) - - async def start(self, delete_unless_authenticated: bool = False) -> AbstractUser: - if not self.client: - await self._init_client() - attempts = 1 - while True: - try: - await self.client.connect() - except Exception: - attempts += 1 - if attempts > 10: - raise - self.log.exception("Exception connecting to Telegram, retrying in 5s...") - await asyncio.sleep(5) - else: - break - self.log.debug(f"{'Bot' if self.is_relaybot else self.mxid} connected: {self.connected}") - return self - - async def ensure_started(self, even_if_no_session=False) -> AbstractUser: - if self.connected: - return self - session_exists = await PgSession.has(self.mxid) - if even_if_no_session or session_exists: - self.log.debug( - f"Starting client due to ensure_started({even_if_no_session=}, {session_exists=})" - ) - await self.start(delete_unless_authenticated=not even_if_no_session) - return self - - async def stop(self) -> None: - if self.client: - await self.client.disconnect() - self.client = None - - # region Telegram update handling - - async def _update(self, update: TypeUpdate) -> None: - if isinstance(update, UpdateShort): - update = update.update - background_task.create(self._handle_entity_updates(getattr(update, "_entities", {}))) - if isinstance( - update, - ( - UpdateShortChatMessage, - UpdateShortMessage, - UpdateNewChannelMessage, - UpdateNewMessage, - UpdateEditMessage, - UpdateEditChannelMessage, - ), - ): - await self.update_message(update) - elif isinstance(update, UpdateDeleteMessages): - await self.delete_message(update) - elif isinstance(update, UpdateDeleteChannelMessages): - await self.delete_channel_message(update) - elif isinstance(update, UpdatePhoneCall): - await self.update_phone_call(update) - elif isinstance(update, UpdateMessageReactions): - await self.update_reactions(update) - elif isinstance(update, UpdateBotMessageReaction): - await self.update_bot_reactions(update) - elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)): - await self.update_typing(update) - elif isinstance(update, UpdateUserStatus): - await self.update_status(update) - elif isinstance(update, UpdateChatParticipantAdmin): - await self.update_admin(update) - elif isinstance(update, UpdateChatParticipants): - await self.update_participants(update) - elif isinstance(update, UpdateChatDefaultBannedRights): - await self.update_default_banned_rights(update) - elif isinstance(update, (UpdatePinnedMessages, UpdatePinnedChannelMessages)): - await self.update_pinned_messages(update) - elif isinstance(update, (UpdateUserName, UpdateUser)): - await self.update_others_info(update) - elif isinstance(update, UpdateReadHistoryOutbox): - await self.update_read_receipt(update) - elif isinstance(update, (UpdateReadHistoryInbox, UpdateReadChannelInbox)): - await self.update_own_read_receipt(update) - elif isinstance(update, UpdateFolderPeers): - await self.update_folder_peers(update) - elif isinstance(update, UpdatePinnedDialogs): - await self.update_pinned_dialogs(update) - elif isinstance(update, UpdateNotifySettings): - await self.update_notify_settings(update) - elif isinstance(update, UpdateChannel): - await self.update_channel(update) - else: - self.log.trace("Unhandled update: %s", update) - - async def update_folder_peers(self, update: UpdateFolderPeers) -> None: - pass - - async def update_pinned_dialogs(self, update: UpdatePinnedDialogs) -> None: - pass - - async def update_notify_settings(self, update: UpdateNotifySettings) -> None: - pass - - async def update_pinned_messages( - self, update: UpdatePinnedMessages | UpdatePinnedChannelMessages - ) -> None: - if isinstance(update, UpdatePinnedMessages): - portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid) - else: - portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id)) - if portal and portal.mxid: - await portal.receive_telegram_pin_ids( - update.messages, self.tgid, remove=not update.pinned - ) - - @staticmethod - async def update_participants(update: UpdateChatParticipants) -> None: - portal = await po.Portal.get_by_tgid(TelegramID(update.participants.chat_id)) - if portal and portal.mxid: - await portal.update_power_levels(update.participants.participants) - - @staticmethod - async def update_default_banned_rights(update: UpdateChatDefaultBannedRights) -> None: - portal = await po.Portal.get_by_entity(update.peer) - if portal and portal.mxid: - await portal.update_default_banned_rights(update.default_banned_rights) - - async def update_read_receipt(self, update: UpdateReadHistoryOutbox) -> None: - if not isinstance(update.peer, PeerUser): - self.log.debug("Unexpected read receipt peer: %s", update.peer) - return - - portal = await po.Portal.get_by_tgid( - TelegramID(update.peer.user_id), tg_receiver=self.tgid - ) - if not portal or not portal.mxid: - return - - # We check that these are user read receipts, so tg_space is always the user ID. - message = await DBMessage.get_one_by_tgid( - TelegramID(update.max_id), self.tgid, edit_index=-1 - ) - if not message: - return - - puppet = await pu.Puppet.get_by_peer(update.peer) - await puppet.intent.mark_read(portal.mxid, message.mxid) - - async def update_own_read_receipt( - self, update: UpdateReadHistoryInbox | UpdateReadChannelInbox - ) -> None: - puppet = await pu.Puppet.get_by_tgid(self.tgid) - if not puppet.is_real_user: - return - - self.log.debug("Handling own read receipt: %s", update) - if isinstance(update, UpdateReadChannelInbox): - portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id)) - elif isinstance(update.peer, PeerChat): - portal = await po.Portal.get_by_tgid(TelegramID(update.peer.chat_id)) - elif isinstance(update.peer, PeerUser): - portal = await po.Portal.get_by_tgid( - TelegramID(update.peer.user_id), tg_receiver=self.tgid - ) - else: - self.log.debug("Unexpected own read receipt peer: %s", update.peer) - return - - if not portal or not portal.mxid: - # TODO This explodes on channels because the field is channel_id - self.log.debug(f"Dropping own read receipt in unknown chat ({update.peer})") - return - - tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid - message = await DBMessage.get_one_by_tgid( - TelegramID(update.max_id), tg_space, edit_index=-1 - ) - if not message: - self.log.debug( - f"Dropping own read receipt: unknown message {update.max_id}@{tg_space}" - ) - return - - await puppet.intent.mark_read(portal.mxid, message.mxid) - - async def update_admin(self, update: UpdateChatParticipantAdmin) -> None: - # TODO duplication not checked - portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id)) - if not portal or not portal.mxid: - return - - await portal.set_telegram_admin(TelegramID(update.user_id)) - - async def update_typing( - self, update: UpdateUserTyping | UpdateChatUserTyping | UpdateChannelUserTyping - ) -> None: - sender = None - if isinstance(update, UpdateUserTyping): - portal = await po.Portal.get_by_tgid( - TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user" - ) - sender = await pu.Puppet.get_by_tgid(TelegramID(update.user_id)) - elif isinstance(update, UpdateChannelUserTyping): - portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id)) - elif isinstance(update, UpdateChatUserTyping): - portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id)) - else: - return - - if isinstance(update, (UpdateChannelUserTyping, UpdateChatUserTyping)): - sender = await pu.Puppet.get_by_peer(update.from_id) - - if not sender or not portal or not portal.mxid: - return - - await portal.handle_telegram_typing(sender, update) - - async def _handle_entity_updates(self, entities: dict[int, User | Chat | Channel]) -> None: - try: - users = (entity for entity in entities.values() if isinstance(entity, (User, Channel))) - puppets = ((await pu.Puppet.get_by_peer(user), user) for user in users) - await asyncio.gather( - *[puppet.try_update_info(self, info) async for puppet, info in puppets if puppet] - ) - except Exception: - self.log.exception("Failed to handle entity updates") - - async def update_others_info(self, update: UpdateUserName | UpdateUser) -> None: - # TODO duplication not checked - puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id)) - if isinstance(update, UpdateUserName): - if len(update.usernames) > 1: - self.log.warning( - "Got update with multiple usernames (%s) for %s, only saving first one", - update.usernames, - update.user_id, - ) - puppet.username = update.usernames[0].username if update.usernames else None - if await puppet.update_displayname(self, update): - await puppet.save() - await puppet.update_portals_meta() - elif isinstance(update, UpdateUser): - info = await self.client.get_entity(puppet.peer) - await puppet.update_info(self, info) - else: - self.log.warning(f"Unexpected other user info update: {type(update)}") - - async def update_status(self, update: UpdateUserStatus) -> None: - puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id)) - if isinstance(update.status, UserStatusOnline): - await puppet.default_mxid_intent.set_presence(PresenceState.ONLINE) - elif isinstance(update.status, UserStatusOffline): - await puppet.default_mxid_intent.set_presence(PresenceState.OFFLINE) - else: - self.log.warning(f"Unexpected user status update: type({update})") - return - - async def get_message_details( - self, update: UpdateMessage - ) -> tuple[UpdateMessageContent, pu.Puppet | None, po.Portal | None]: - if isinstance(update, UpdateShortChatMessage): - portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat") - sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id)) - elif isinstance(update, UpdateShortMessage): - portal = await po.Portal.get_by_tgid( - TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user" - ) - sender = await pu.Puppet.get_by_tgid(self.tgid if update.out else update.user_id) - elif isinstance( - update, - ( - UpdateNewMessage, - UpdateNewChannelMessage, - UpdateEditMessage, - UpdateEditChannelMessage, - ), - ): - update = update.message - if isinstance(update, MessageEmpty): - return update, None, None - portal = await po.Portal.get_by_entity(update.peer_id, tg_receiver=self.tgid) - if update.out: - sender = await pu.Puppet.get_by_tgid(self.tgid) - elif isinstance(update.from_id, (PeerUser, PeerChannel)): - sender = await pu.Puppet.get_by_peer(update.from_id) - elif isinstance(update.peer_id, PeerUser): - sender = await pu.Puppet.get_by_peer(update.peer_id) - else: - sender = None - else: - self.log.warning( - f"Unexpected message type in User#get_message_details: {type(update)}" - ) - return update, None, None - return update, sender, portal - - @staticmethod - async def _try_redact(message: DBMessage) -> None: - portal = await po.Portal.get_by_mxid(message.mx_room) - if not portal: - return - try: - await portal.main_intent.redact(message.mx_room, message.mxid) - except MatrixError: - pass - - async def delete_message(self, update: UpdateDeleteMessages) -> None: - if len(update.messages) > self.max_deletions: - return - - for message_id in update.messages: - for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid): - if message.redacted: - continue - await message.delete() - number_left = await DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room) - if number_left == 0: - await self._try_redact(message) - - async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None: - if len(update.messages) > self.max_deletions: - return - - channel_id = TelegramID(update.channel_id) - - for message_id in update.messages: - for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id): - if message.redacted: - continue - await message.delete() - await self._try_redact(message) - - async def update_reactions(self, update: UpdateMessageReactions) -> None: - portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid) - if not portal or not portal.mxid or not portal.allow_bridging: - return - await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions) - - async def update_bot_reactions(self, update: UpdateBotMessageReaction) -> None: - portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid) - if not portal or not portal.mxid or not portal.allow_bridging: - return - await portal.handle_telegram_bot_reactions(self, update) - - async def update_phone_call(self, update: UpdatePhoneCall) -> None: - self.log.debug("Phone call update %s", update) - if not isinstance(update.phone_call, PhoneCallRequested): - return - tgid = TelegramID(update.phone_call.participant_id) - if tgid == self.tgid: - tgid = update.phone_call.admin_id - portal = await po.Portal.get_by_tgid(tgid, tg_receiver=self.tgid, peer_type="user") - if not portal or not portal.mxid or not portal.allow_bridging: - return - sender = await pu.Puppet.get_by_tgid(TelegramID(update.phone_call.admin_id)) - await portal.handle_telegram_direct_call(self, sender, update) - - async def update_channel(self, update: UpdateChannel) -> None: - portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id)) - if not portal: - return - if getattr(update, "mau_telethon_is_leave", False): - self.log.debug("UpdateChannel has mau_telethon_is_leave, leaving portal") - await portal.delete_telegram_user(self.tgid, sender=None) - elif chan := getattr(update, "mau_channel", None): - if not portal.mxid: - if ( - not self.is_relaybot - or not self.config["bridge.relaybot.ignore_unbridged_group_chat"] - ): - background_task.create(self._delayed_create_channel(chan)) - else: - self.log.debug("Updating channel info with data fetched by Telethon") - await portal.update_info(self, chan) - await portal.invite_to_matrix(self.mxid) - - async def _delayed_create_channel(self, chan: Channel) -> None: - self.log.debug( - f"Waiting 5 seconds before handling UpdateChannel for non-existent portal {chan.id}" - ) - await asyncio.sleep(5) - portal = await po.Portal.get_by_tgid(TelegramID(chan.id)) - if portal.mxid: - self.log.debug( - "Portal started existing after waiting 5 seconds, " - f"dropping UpdateChannel for {portal.tgid}" - ) - return - else: - self.log.info( - f"Creating Matrix room for {portal.tgid}" - " with data fetched by Telethon due to UpdateChannel" - ) - await portal.create_matrix_room(self, chan, invites=[self.mxid]) - - async def _check_server_notice_edit(self, message: Message) -> None: - pass - - async def update_message(self, original_update: UpdateMessage) -> None: - update, sender, portal = await self.get_message_details(original_update) - if not portal: - return - elif portal and not portal.allow_bridging: - self.log.debug( - f"Ignoring message {update.id} in portal {portal.tgid_log} (bridging disallowed)" - ) - return - - if not portal.mxid and getattr(original_update, "mau_left_channel", False): - self.log.debug( - f"Ignoring message {update.id} in portal {portal.tgid_log} because user isn't in the chat" - ) - return - - if self.is_relaybot: - if update.is_private: - if not self.config["bridge.relaybot.private_chat.invite"]: - if sender: - self.log.debug(f"Ignoring private message to bot from {sender.id}") - return - elif not portal.mxid and self.config["bridge.relaybot.ignore_unbridged_group_chat"]: - self.log.debug( - f"Ignoring message received by bot in unbridged chat {portal.tgid_log}" - ) - return - - if ( - self.ignore_incoming_bot_events - and self.relaybot - and sender - and sender.id == self.relaybot.tgid - ): - self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log) - return - - task = self._call_portal_message_handler(update, original_update, portal, sender) - if portal.backfill_lock.locked: - self.log.debug( - f"{portal.tgid_log} is backfill locked, moving incoming message to async task" - ) - background_task.create(task) - else: - await task - - async def _call_portal_message_handler( - self, - update: UpdateMessageContent, - original_update: UpdateMessage, - portal: po.Portal, - sender: pu.Puppet, - ) -> None: - await portal.backfill_lock.wait(f"update {update.id}") - - if isinstance(update, MessageService): - if isinstance(update.action, MessageActionChannelMigrateFrom): - self.log.debug( - "Received %s in %s by %d, unregistering portal...", - update.action, - portal.tgid_log, - sender.id, - ) - await self.unregister_portal(update.action.chat_id, update.action.chat_id) - await self.register_portal(portal) - return - self.log.debug( - "Handling action %s to %s by %d", - update.action, - portal.tgid_log, - (sender.id if sender else 0), - ) - return await portal.handle_telegram_action(self, sender, update) - - if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)): - if sender and sender.tgid == 777000: - await self._check_server_notice_edit(update) - return await portal.handle_telegram_edit(self, sender, update) - return await portal.handle_telegram_message(self, sender, update) - - # endregion diff --git a/mautrix_telegram/bot.py b/mautrix_telegram/bot.py deleted file mode 100644 index 7e65048f..00000000 --- a/mautrix_telegram/bot.py +++ /dev/null @@ -1,457 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2022 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, Awaitable, Callable, Literal -import logging -import time - -from telethon.errors import ( - AuthKeyError, - ChannelInvalidError, - ChannelPrivateError, - UnauthorizedError, -) -from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest -from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest -from telethon.tl.patched import Message, MessageService -from telethon.tl.types import ( - ChannelParticipantAdmin, - ChannelParticipantCreator, - ChatForbidden, - ChatParticipantAdmin, - ChatParticipantCreator, - ChatParticipantsForbidden, - InputChannel, - InputUser, - MessageActionChatAddUser, - MessageActionChatDeleteUser, - MessageActionChatMigrateTo, - MessageEntityBotCommand, - PeerChannel, - PeerChat, - PeerUser, - TypeChannelParticipant, - TypeChatParticipant, - TypeInputPeer, - TypePeer, - UpdateNewChannelMessage, - UpdateNewMessage, - User, -) -from telethon.utils import add_surrogate, del_surrogate - -from mautrix.errors import MBadState, MForbidden -from mautrix.types import RoomID, UserID - -from . import portal as po, puppet as pu, user as u -from .abstract_user import AbstractUser -from .db import BotChat, Message as DBMessage -from .types import TelegramID - -if TYPE_CHECKING: - from asyncio import Future - -ReplyFunc = Callable[[str], Awaitable[Message]] -BanFunc = Callable[[RoomID, UserID, str], Awaitable[None]] -TelegramAdminPermission = Literal[ - "change_info", - "post_messages", - "edit_messages", - "delete_messages", - "ban_users", - "invite_users", - "pin_messages", - "add_admins", - "anonymous", - "manage_call", - "other", -] - - -class Bot(AbstractUser): - log: logging.Logger = logging.getLogger("mau.user.bot") - - token: str - chats: dict[int, str] - tg_whitelist: list[int] - whitelist_group_admins: bool - _me_info: User | None - _me_mxid: UserID | None - _admin_cache: dict[ - tuple[int, int], - tuple[ChatParticipantAdmin | ChatParticipantCreator | None, float], - ] - _login_wait_fut: Future | None - required_permissions: dict[str, TelegramAdminPermission] = { - "portal": None, - "invite": "invite_users", - "mxban": "ban_users", - "mxkick": "ban_users", - } - - def __init__(self, token: str) -> None: - super().__init__() - self.token = token - self.tgid = None - self.mxid = None - self.puppet_whitelisted = True - self.whitelisted = True - self.relaybot_whitelisted = True - self.tg_username = None - self.is_relaybot = True - self.is_bot = True - self.chats = {} - self._admin_cache = {} - self.tg_whitelist = [] - self.whitelist_group_admins = ( - self.config["bridge.relaybot.whitelist_group_admins"] or False - ) - self._me_info = None - self._me_mxid = None - self._login_wait_fut = self.loop.create_future() - - async def get_me(self, use_cache: bool = True) -> tuple[User, UserID]: - if not use_cache or not self._me_mxid: - self._me_info = await self.client.get_me() - self._me_mxid = pu.Puppet.get_mxid_from_id(TelegramID(self._me_info.id)) - return self._me_info, self._me_mxid - - async def init_permissions(self) -> None: - whitelist = self.config["bridge.relaybot.whitelist"] or [] - for user_id in whitelist: - if isinstance(user_id, str): - entity = await self.client.get_input_entity(user_id) - if isinstance(entity, InputUser): - user_id = entity.user_id - else: - user_id = None - if isinstance(user_id, int): - self.tg_whitelist.append(user_id) - - async def start(self, delete_unless_authenticated: bool = False) -> Bot: - self.chats = {chat.id: chat.type for chat in await BotChat.all()} - await super().start(delete_unless_authenticated) - if not await self.is_logged_in(): - await self.client.sign_in(bot_token=self.token) - await self.post_login() - return self - - async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None: - self.log.fatal("Relay bot got signed out, crashing bridge", exc_info=err) - self.bridge.manual_stop(51) - - async def post_login(self) -> None: - await self.init_permissions() - info = await self.client.get_me() - self.tgid = TelegramID(info.id) - self.tg_username = info.username - self.mxid = pu.Puppet.get_mxid_from_id(self.tgid) - if self._login_wait_fut: - self._login_wait_fut.set_result(None) - self._login_wait_fut = None - - chat_ids = [chat_id for chat_id, chat_type in self.chats.items() if chat_type == "chat"] - response = await self.client(GetChatsRequest(chat_ids)) - for chat in response.chats: - if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated: - await self.remove_chat(TelegramID(chat.id)) - - channel_ids = [ - InputChannel(chat_id, 0) - for chat_id, chat_type in self.chats.items() - if chat_type == "channel" - ] - for channel_id in channel_ids: - try: - await self.client(GetChannelsRequest([channel_id])) - except (ChannelPrivateError, ChannelInvalidError): - await self.remove_chat(TelegramID(channel_id.channel_id)) - - async def register_portal(self, portal: po.Portal) -> None: - await self.add_chat(portal.tgid, portal.peer_type) - - async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None: - await self.remove_chat(tgid) - - async def add_chat(self, chat_id: TelegramID, chat_type: str) -> None: - if chat_id not in self.chats: - self.chats[chat_id] = chat_type - await BotChat(id=chat_id, type=chat_type).insert() - - async def remove_chat(self, chat_id: TelegramID) -> None: - try: - del self.chats[chat_id] - except KeyError: - pass - await BotChat.delete_by_id(chat_id) - - async def _get_admin_participant( - self, chat: TypePeer | TypeInputPeer, tgid: TelegramID - ) -> TypeChatParticipant | TypeChannelParticipant | None: - chan_id = chat.channel_id if isinstance(chat, PeerChannel) else chat.chat_id - try: - cached, created = self._admin_cache[chan_id, tgid] - if created + 60 < time.time(): - return cached - except KeyError: - pass - if isinstance(chat, PeerChannel): - p = await self.client(GetParticipantRequest(chat, tgid)) - pcp = p.participant - self._admin_cache[chat.channel_id, tgid] = (pcp, time.time()) - return pcp - elif isinstance(chat, PeerChat): - chat = await self.client(GetFullChatRequest(chat.chat_id)) - if isinstance(chat.full_chat.participants, ChatParticipantsForbidden): - return None - participants = chat.full_chat.participants.participants - for p in participants: - self._admin_cache[chat.channel_id, tgid] = (p, time.time()) - if p.user_id == tgid: - return p - return None - - @staticmethod - def _has_participant_permission( - pcp: TypeChatParticipant | TypeChannelParticipant | None, - permission: TelegramAdminPermission | None, - ) -> bool: - if isinstance(pcp, (ChannelParticipantCreator, ChannelParticipantAdmin)): - return permission is None or getattr(pcp.admin_rights, permission, False) - elif isinstance(pcp, (ChatParticipantCreator, ChatParticipantAdmin)): - return True - return False - - async def _can_use_commands( - self, chat: TypePeer, tgid: TelegramID, permission: TelegramAdminPermission | None = None - ) -> bool: - if tgid in self.tg_whitelist: - return True - - user = await u.User.get_by_tgid(tgid) - if user and user.is_admin: - self.tg_whitelist.append(user.tgid) - return True - - if self.whitelist_group_admins: - pcp = await self._get_admin_participant(chat, tgid) - return self._has_participant_permission(pcp, permission) - return False - - async def check_can_use_command(self, event: Message, reply: ReplyFunc, command: str) -> bool: - if command not in self.required_permissions: - # Unknown command - return False - elif not isinstance(event.from_id, PeerUser): - await reply("Channels can't use commands") - return False - elif not await self._can_use_commands( - event.to_id, TelegramID(event.from_id.user_id), self.required_permissions[command] - ): - await reply("You do not have the permission to use that command.") - return False - return True - - async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc) -> Message: - if not self.config["bridge.relaybot.authless_portals"]: - return await reply("This bridge doesn't allow portal creation from Telegram.") - - if not portal.allow_bridging: - return await reply("This bridge doesn't allow bridging this chat.") - - await portal.create_matrix_room(self) - if portal.mxid: - if portal.username: - return await reply( - f"Portal is public: [{portal.alias}](https://matrix.to/#/{portal.alias})" - ) - else: - return await reply("Portal is not public. Use `/invite ` to get an invite.") - else: - return await reply("Couldn't create portal room") - - async def handle_command_invite( - self, portal: po.Portal, reply: ReplyFunc, mxid_input: UserID - ) -> Message: - if len(mxid_input) == 0: - return await reply("Usage: `/invite `") - elif not portal.mxid: - return await reply("Portal does not have Matrix room. Create one with /portal first.") - if mxid_input[0] != "@" or mxid_input.find(":") < 2: - return await reply("That doesn't look like a Matrix ID.") - user = await u.User.get_and_start_by_mxid(mxid_input) - if not user.relaybot_whitelisted: - return await reply("That user is not whitelisted to use the bridge.") - elif await user.is_logged_in(): - displayname = f"@{user.tg_username}" if user.tg_username else user.displayname - return await reply( - "That user seems to be logged in. " - f"Just invite [{displayname}](tg://user?id={user.tgid})" - ) - else: - try: - await portal.invite_to_matrix(user.mxid) - except MBadState: - try: - await portal.main_intent.unban_user( - portal.mxid, user.mxid, reason="Invited from Telegram" - ) - except Exception: - return await reply(f"Failed to unban `{user.mxid}` from the portal.") - await portal.invite_to_matrix(user.mxid) - return await reply(f"Unbanned and invited `{user.mxid}` to the portal.") - return await reply(f"Invited `{user.mxid}` to the portal.") - - async def handle_command_ban( - self, - message: Message, - portal: po.Portal, - reply: ReplyFunc, - reason: str, - action: Literal["kick", "ban"] = "ban", - ) -> Message: - if not message.reply_to: - return await reply("You must reply to a relaybot message when using that command") - reply_to_id = TelegramID(message.reply_to.reply_to_msg_id) - tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid - msg = await DBMessage.get_one_by_tgid(reply_to_id, tg_space) - if not msg or msg.sender != self.tgid or not msg.sender_mxid: - return await reply("Target message is not a relayed message") - puppet = await pu.Puppet.get_by_peer(message.from_id) - actioned = "Banned" if action == "ban" else "Kicked" - try: - intent = puppet.intent_for(portal) - func: BanFunc = intent.ban_user if action == "ban" else intent.kick_user - await func(portal.mxid, msg.sender_mxid, reason) - except MForbidden as e: - self.log.warning( - f"Failed to {action} {msg.sender_mxid} from {portal.mxid} as {puppet.mxid}: {e}, " - f"falling back to bridge bot" - ) - reason_prefix = f"{actioned} by {puppet.displayname or puppet.tgid}" - reason = f"{reason_prefix}: {reason}" if reason else reason_prefix - try: - func: BanFunc = ( - self.az.intent.ban_user if action == "ban" else self.az.intent.kick_user - ) - await func(portal.mxid, msg.sender_mxid, reason) - except MForbidden as e: - self.log.warning( - f"Failed to {action} {msg.sender_mxid} from {portal.mxid} as bridge bot: {e}" - ) - return await reply(f"Failed to {action} `{msg.sender_mxid}`") - return await reply(f"Successfully {actioned.lower()} `{msg.sender_mxid}`") - - @staticmethod - def handle_command_id(message: Message, reply: ReplyFunc) -> Awaitable[Message]: - # Provide the prefixed ID to the user so that the user wouldn't need to specify whether the - # chat is a normal group or a supergroup/channel when using the ID. - if isinstance(message.to_id, PeerChannel): - return reply(f"-100{message.to_id.channel_id}") - elif isinstance(message.to_id, PeerChat): - return reply(str(-message.to_id.chat_id)) - elif isinstance(message.to_id, PeerUser): - return reply( - f"Your user ID is {message.to_id.user_id}.\n\n" - f"If you're trying to bridge a group chat to Matrix, you must run the command in " - f"the group, not here. **The ID above will not work** with `!tg bridge`." - ) - else: - return reply("Failed to find chat ID.") - - def parse_command(self, message: Message) -> tuple[str | None, str | None]: - if not message.entities or len(message.entities) < 1 or not message.message: - return None, None - cmd_entity = message.entities[0] - if not isinstance(cmd_entity, MessageEntityBotCommand) or cmd_entity.offset != 0: - return None, None - surrogated_text = add_surrogate(message.message) - command: str = del_surrogate(surrogated_text[: cmd_entity.length]).lower() - rest_of_message: str = "" - if len(surrogated_text) > cmd_entity.length + 1: - rest_of_message: str = del_surrogate(surrogated_text[cmd_entity.length + 1 :]) - command, *target = command.split("@", 1) - if not command.startswith("/"): - return None, None - elif target and target[0] != self.tg_username.lower(): - return None, None - return command[1:], rest_of_message - - async def handle_command(self, message: Message, command: str, args: str) -> None: - def reply(reply_text: str) -> Awaitable[Message]: - return self.client.send_message(message.chat_id, reply_text, reply_to=message.id) - - if command == "start" and message.is_private: - pcm = self.config["bridge.relaybot.private_chat.message"] - if pcm: - await reply(pcm) - elif command == "id": - await self.handle_command_id(message, reply) - elif not message.is_private: - if not await self.check_can_use_command(message, reply, command): - return - portal = await po.Portal.get_by_entity(message.to_id) - if command == "portal": - await self.handle_command_portal(portal, reply) - elif command == "invite": - await self.handle_command_invite(portal, reply, mxid_input=UserID(args)) - elif command == "mxban": - await self.handle_command_ban(message, portal, reply, reason=args) - elif command == "mxkick": - await self.handle_command_ban(message, portal, reply, reason=args, action="kick") - - async def handle_service_message(self, message: MessageService) -> None: - to_peer = message.to_id - if isinstance(to_peer, PeerChannel): - to_id = TelegramID(to_peer.channel_id) - chat_type = "channel" - elif isinstance(to_peer, PeerChat): - to_id = TelegramID(to_peer.chat_id) - chat_type = "chat" - else: - return - - action = message.action - if isinstance(action, MessageActionChatAddUser) and self.tgid in action.users: - await self.add_chat(to_id, chat_type) - elif isinstance(action, MessageActionChatDeleteUser) and action.user_id == self.tgid: - await self.remove_chat(to_id) - elif isinstance(action, MessageActionChatMigrateTo): - await self.remove_chat(to_id) - await self.add_chat(TelegramID(action.channel_id), "channel") - - async def update(self, update) -> bool: - if self._login_wait_fut: - await self._login_wait_fut - if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)): - return False - if isinstance(update.message, MessageService): - await self.handle_service_message(update.message) - return False - - if isinstance(update.message, Message): - command, args = self.parse_command(update.message) - if command: - await self.handle_command(update.message, command, args) - return False - - def is_in_chat(self, peer_id) -> bool: - return peer_id in self.chats - - @property - def name(self) -> str: - return "bot" diff --git a/mautrix_telegram/commands/__init__.py b/mautrix_telegram/commands/__init__.py deleted file mode 100644 index 8182877e..00000000 --- a/mautrix_telegram/commands/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -from .handler import ( - SECTION_ADMIN, - SECTION_AUTH, - SECTION_CREATING_PORTALS, - SECTION_MISC, - SECTION_PORTAL_MANAGEMENT, - CommandEvent, - CommandHandler, - CommandProcessor, - command_handler, -) - -# This has to happen after the handler imports -from . import matrix_auth, portal, telegram # isort: skip - -__all__ = [ - "command_handler", - "CommandHandler", - "CommandProcessor", - "CommandEvent", - "SECTION_AUTH", - "SECTION_MISC", - "SECTION_ADMIN", - "SECTION_CREATING_PORTALS", - "SECTION_PORTAL_MANAGEMENT", -] diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py deleted file mode 100644 index 545ecbea..00000000 --- a/mautrix_telegram/commands/handler.py +++ /dev/null @@ -1,196 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Awaitable, Callable, NamedTuple - -from telethon.errors import FloodWaitError - -from mautrix.bridge.commands import ( - CommandEvent as BaseCommandEvent, - CommandHandler as BaseCommandHandler, - CommandHandlerFunc, - CommandProcessor as BaseCommandProcessor, - HelpSection, - command_handler as base_command_handler, -) -from mautrix.types import EventID, MessageEventContent, RoomID -from mautrix.util.format_duration import format_duration - -from .. import portal as po, user as u - -if TYPE_CHECKING: - from ..__main__ import TelegramBridge - - -class HelpCacheKey(NamedTuple): - is_management: bool - is_portal: bool - puppet_whitelisted: bool - matrix_puppet_whitelisted: bool - is_admin: bool - is_logged_in: bool - - -SECTION_AUTH = HelpSection("Authentication", 10, "") -SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "") -SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "") -SECTION_MISC = HelpSection("Miscellaneous", 40, "") -SECTION_ADMIN = HelpSection("Administration", 50, "") - - -class CommandEvent(BaseCommandEvent): - sender: u.User - portal: po.Portal - - def __init__( - self, - processor: CommandProcessor, - room_id: RoomID, - event_id: EventID, - sender: u.User, - command: str, - args: list[str], - content: MessageEventContent, - portal: po.Portal | None, - is_management: bool, - has_bridge_bot: bool, - ) -> None: - super().__init__( - processor, - room_id, - event_id, - sender, - command, - args, - content, - portal, - is_management, - has_bridge_bot, - ) - self.bridge = processor.bridge - self.tgbot = processor.tgbot - self.config = processor.config - self.public_website = processor.public_website - - @property - def print_error_traceback(self) -> bool: - return self.sender.is_admin - - async def get_help_key(self) -> HelpCacheKey: - return HelpCacheKey( - self.is_management, - self.portal is not None, - self.sender.puppet_whitelisted, - self.sender.matrix_puppet_whitelisted, - self.sender.is_admin, - await self.sender.is_logged_in(), - ) - - -class CommandHandler(BaseCommandHandler): - name: str - - needs_puppeting: bool - needs_matrix_puppeting: bool - - def __init__( - self, - handler: Callable[[CommandEvent], Awaitable[EventID]], - management_only: bool, - name: str, - help_text: str, - help_args: str, - help_section: HelpSection, - needs_auth: bool, - needs_puppeting: bool, - needs_matrix_puppeting: bool, - needs_admin: bool, - **kwargs, - ) -> None: - super().__init__( - handler, - management_only, - name, - help_text, - help_args, - help_section, - needs_auth=needs_auth, - needs_puppeting=needs_puppeting, - needs_matrix_puppeting=needs_matrix_puppeting, - needs_admin=needs_admin, - **kwargs, - ) - - async def get_permission_error(self, evt: CommandEvent) -> str | None: - if self.needs_puppeting and not evt.sender.puppet_whitelisted: - return "That command is limited to users with puppeting privileges." - elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted: - return "That command is limited to users with full puppeting privileges." - return await super().get_permission_error(evt) - - def has_permission(self, key: HelpCacheKey) -> bool: - return ( - super().has_permission(key) - and (not self.needs_puppeting or key.puppet_whitelisted) - and (not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted) - ) - - -def command_handler( - _func: CommandHandlerFunc | None = None, - *, - needs_auth: bool = True, - needs_puppeting: bool = True, - needs_matrix_puppeting: bool = False, - needs_admin: bool = False, - management_only: bool = False, - name: str | None = None, - aliases: list[str] | None = None, - help_text: str = "", - help_args: str = "", - help_section: HelpSection = None, -) -> Callable[[CommandHandlerFunc], CommandHandler]: - return base_command_handler( - _func, - _handler_class=CommandHandler, - name=name, - aliases=aliases, - help_text=help_text, - help_args=help_args, - help_section=help_section, - management_only=management_only, - needs_auth=needs_auth, - needs_admin=needs_admin, - needs_puppeting=needs_puppeting, - needs_matrix_puppeting=needs_matrix_puppeting, - ) - - -class CommandProcessor(BaseCommandProcessor): - def __init__(self, bridge: "TelegramBridge") -> None: - super().__init__(event_class=CommandEvent, bridge=bridge) - self.tgbot = bridge.bot - self.public_website = bridge.public_website - - @staticmethod - async def _run_handler( - handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent - ) -> Any: - try: - return await handler(evt) - except FloodWaitError as e: - return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}") diff --git a/mautrix_telegram/commands/matrix_auth.py b/mautrix_telegram/commands/matrix_auth.py deleted file mode 100644 index 3f0d25e9..00000000 --- a/mautrix_telegram/commands/matrix_auth.py +++ /dev/null @@ -1,85 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf -from mautrix.types import EventID - -from .. import puppet as pu -from . import SECTION_AUTH, CommandEvent, command_handler - - -@command_handler( - needs_auth=True, - management_only=True, - needs_matrix_puppeting=True, - help_section=SECTION_AUTH, - help_text="Replace your Telegram account's Matrix puppet with your own Matrix account.", -) -async def login_matrix(evt: CommandEvent) -> EventID: - puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid) - if puppet.is_real_user: - return await evt.reply( - "You have already logged in with your Matrix account. " - "Log out with `$cmdprefix+sp logout-matrix` first." - ) - allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True) - if allow_matrix_login: - evt.sender.command_status = { - "next": enter_matrix_token, - "action": "Matrix login", - } - if evt.config["appservice.public.enabled"]: - prefix = evt.config["appservice.public.external"] - token = evt.public_website.make_token(evt.sender.mxid, "/matrix-login") - url = f"{prefix}/matrix-login?token={token}" - if allow_matrix_login: - return await evt.reply( - "This bridge instance allows you to log in inside or outside Matrix.\n\n" - "If you would like to log in within Matrix, please send your Matrix access token " - "here.\n" - f"If you would like to log in outside of Matrix, [click here]({url}).\n\n" - "Logging in outside of Matrix is recommended, because in-Matrix login would save " - "your access token in the message history." - ) - return await evt.reply( - "This bridge instance does not allow logging in inside Matrix.\n\n" - f"Please visit [the login page]({url}) to log in." - ) - elif allow_matrix_login: - return await evt.reply( - "This bridge instance does not allow you to log in outside of Matrix.\n\n" - "Please send your Matrix access token here to log in." - ) - return await evt.reply("This bridge instance has been configured to not allow logging in.") - - -async def enter_matrix_token(evt: CommandEvent) -> EventID: - evt.sender.command_status = None - - puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid) - if puppet.is_real_user: - return await evt.reply( - "You have already logged in with your Matrix account. " - "Log out with `$cmdprefix+sp logout-matrix` first." - ) - try: - await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid) - except OnlyLoginSelf: - return await evt.reply("You can only log in as your own Matrix user.") - except InvalidAccessToken: - return await evt.reply("Failed to verify access token.") - return await evt.reply( - f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}." - ) diff --git a/mautrix_telegram/commands/portal/__init__.py b/mautrix_telegram/commands/portal/__init__.py deleted file mode 100644 index 4856d98a..00000000 --- a/mautrix_telegram/commands/portal/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import admin, bridge, config, create_chat, filter, misc, unbridge diff --git a/mautrix_telegram/commands/portal/admin.py b/mautrix_telegram/commands/portal/admin.py deleted file mode 100644 index cb6081d7..00000000 --- a/mautrix_telegram/commands/portal/admin.py +++ /dev/null @@ -1,77 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -import asyncio - -from mautrix.types import EventID - -from ... import portal as po, puppet as pu, user as u -from .. import SECTION_ADMIN, CommandEvent, command_handler - - -@command_handler( - needs_admin=True, - needs_auth=False, - help_section=SECTION_ADMIN, - help_args="<`portal`|`puppet`|`user`>", - help_text="Clear internal bridge caches", -) -async def clear_db_cache(evt: CommandEvent) -> EventID: - try: - section = evt.args[0].lower() - except IndexError: - return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache
`") - if section == "portal": - po.Portal.by_tgid = {} - po.Portal.by_mxid = {} - await evt.reply("Cleared portal cache") - elif section == "puppet": - pu.Puppet.by_tgid = {} - pu.Puppet.by_custom_mxid = {} - await asyncio.gather( - *[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()] - ) - await evt.reply("Cleared puppet cache and restarted custom puppet syncers") - elif section == "user": - u.User.by_mxid = {user.mxid: user for user in u.User.by_tgid.values()} - await evt.reply("Cleared non-logged-in user cache") - else: - return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache
`") - - -@command_handler( - needs_admin=True, - needs_auth=False, - help_section=SECTION_ADMIN, - help_args="[_mxid_]", - help_text="Reload and reconnect a user", -) -async def reload_user(evt: CommandEvent) -> EventID: - if len(evt.args) > 0: - mxid = evt.args[0] - else: - mxid = evt.sender.mxid - user = await u.User.get_by_mxid(mxid, create=False) - if not user: - return await evt.reply("User not found") - puppet = await pu.Puppet.get_by_custom_mxid(mxid) - await user.stop() - del u.User.by_tgid[user.tgid] - del u.User.by_mxid[user.mxid] - user = await u.User.get_by_mxid(mxid) - await user.ensure_started() - if puppet: - await puppet.start() - return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})") diff --git a/mautrix_telegram/commands/portal/bridge.py b/mautrix_telegram/commands/portal/bridge.py deleted file mode 100644 index 2bd7172c..00000000 --- a/mautrix_telegram/commands/portal/bridge.py +++ /dev/null @@ -1,261 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import Awaitable -import asyncio - -from telethon.tl.types import ChannelForbidden, ChatForbidden - -from mautrix.types import EventID, RoomID -from mautrix.util import background_task - -from ... import portal as po -from ...types import TelegramID -from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler -from .util import get_initial_state, user_has_power_level, warn_missing_power - - -@command_handler( - needs_auth=False, - needs_puppeting=False, - help_section=SECTION_CREATING_PORTALS, - help_args="[_id_]", - help_text=( - "Bridge the current Matrix room to the Telegram chat with the given ID. The ID must be " - "the prefixed version that you get with the `/id` command of the Telegram-side bot." - ), -) -async def bridge(evt: CommandEvent) -> EventID: - if len(evt.args) == 0: - return await evt.reply( - "**Usage:** `$cmdprefix+sp bridge [Matrix room ID]`" - ) - force_use_bot = False - if evt.args[0] == "--usebot" and evt.sender.is_admin: - force_use_bot = True - evt.args = evt.args[1:] - room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id - that_this = "This" if room_id == evt.room_id else "That" - - portal = await po.Portal.get_by_mxid(room_id) - if portal: - return await evt.reply(f"{that_this} room is already a portal room.") - - if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"): - return await evt.reply( - f"You do not have the permissions to bridge {that_this.lower()} room." - ) - - # The /id bot command provides the prefixed ID, so we assume - tgid_str = evt.args[0] - tgid = None - try: - if tgid_str.startswith("-100"): - tgid = TelegramID(int(tgid_str[4:])) - peer_type = "channel" - elif tgid_str.startswith("-"): - tgid = TelegramID(-int(tgid_str)) - peer_type = "chat" - except ValueError: - # Invalid integer - pass - if not tgid: - return await evt.reply( - "That doesn't seem like a prefixed Telegram chat ID.\n\n" - "If you did not get the ID using the `/id` bot command, please prefix" - "channel/supergroup IDs with `-100` and non-super group IDs with `-`.\n\n" - "Bridging private chats to existing rooms is not allowed." - ) - - portal = await po.Portal.get_by_tgid(tgid, peer_type=peer_type) - if not portal.allow_bridging: - return await evt.reply( - "This bridge doesn't allow bridging that Telegram chat.\n" - "If you're the bridge admin, try " - "`$cmdprefix+sp filter whitelist ` first." - ) - elif portal.mxid: - has_portal_message = ( - "That Telegram chat already has a portal at " - f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). " - ) - if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"): - return await evt.reply( - f"{has_portal_message}" - "Additionally, you do not have the permissions to unbridge that room." - ) - evt.sender.command_status = { - "next": confirm_bridge, - "action": "Room bridging", - "mxid": portal.mxid, - "bridge_to_mxid": room_id, - "tgid": portal.tgid, - "peer_type": peer_type, - "force_use_bot": force_use_bot, - } - return await evt.reply( - f"{has_portal_message}" - "However, you have the permissions to unbridge that room.\n\n" - "To delete that portal completely and continue bridging, use " - "`$cmdprefix+sp delete-and-continue`. To unbridge the portal " - "without kicking Matrix users, use `$cmdprefix+sp unbridge-and-" - "continue`. To cancel, use `$cmdprefix+sp cancel`" - ) - evt.sender.command_status = { - "next": confirm_bridge, - "action": "Room bridging", - "bridge_to_mxid": room_id, - "tgid": portal.tgid, - "peer_type": peer_type, - "force_use_bot": force_use_bot, - } - return await evt.reply( - "That Telegram chat has no existing portal. To confirm bridging the " - "chat to this room, use `$cmdprefix+sp continue`" - ) - - -async def cleanup_old_portal_while_bridging( - evt: CommandEvent, portal: po.Portal -) -> tuple[bool, Awaitable[None] | None]: - if not portal.mxid: - await evt.reply( - "The portal seems to have lost its Matrix room between you" - "calling `$cmdprefix+sp bridge` and this command.\n\n" - "Continuing without touching previous Matrix room..." - ) - return True, None - elif evt.args[0] == "delete-and-continue": - return True, portal.cleanup_portal("Portal deleted (moving to another room)", delete=False) - elif evt.args[0] == "unbridge-and-continue": - return True, portal.cleanup_portal( - "Room unbridged (portal moving to another room)", puppets_only=True, delete=False - ) - else: - await evt.reply( - "The chat you were trying to bridge already has a Matrix portal room.\n\n" - "Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-" - "continue` to either delete or unbridge the existing room (respectively) and " - "continue with the bridging.\n\n" - "If you changed your mind, use `$cmdprefix+sp cancel` to cancel." - ) - return False, None - - -async def confirm_bridge(evt: CommandEvent) -> EventID | None: - status = evt.sender.command_status - try: - portal = await po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"]) - bridge_to_mxid = status["bridge_to_mxid"] - except KeyError: - evt.sender.command_status = None - return await evt.reply( - "Fatal error: tgid or peer_type missing from command_status. " - "This shouldn't happen unless you're messing with the command handler code." - ) - - is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"] - - if "mxid" in status: - if portal.peer_type != status["peer_type"]: - evt.log.warning( - "Portal %d in database has mismatching peer type %s (expected %s)," - " trusting database as a room already existed", - portal.tgid, - portal.peer_type, - status["peer_type"], - ) - await evt.reply( - "Mismatching peer type in command and portal table, " - "trusting portal as room already existed" - ) - ok, coro = await cleanup_old_portal_while_bridging(evt, portal) - if not ok: - return None - elif coro: - background_task.create(coro) - await evt.reply("Cleaning up previous portal room...") - elif portal.mxid: - evt.sender.command_status = None - return await evt.reply( - "The portal seems to have created a Matrix room between you " - "calling `$cmdprefix+sp bridge` and this command.\n\n" - "Please start over by calling the bridge command again." - ) - elif evt.args[0] != "continue": - return await evt.reply( - "Please use `$cmdprefix+sp continue` to confirm the bridging or " - "`$cmdprefix+sp cancel` to cancel." - ) - elif portal.peer_type != status["peer_type"]: - evt.log.warning( - "Portal %d in database has mismatching peer type %s (expected %s)," - " trusting new peer type as there's no existing room", - portal.tgid, - portal.peer_type, - status["peer_type"], - ) - await evt.reply( - "Mismatching peer type in command and portal table, " - "trusting you as portal room doesn't exist" - ) - portal.peer_type = status["peer_type"] - - evt.sender.command_status = None - async with portal._room_create_lock: - await _locked_confirm_bridge( - evt, portal=portal, room_id=bridge_to_mxid, is_logged_in=is_logged_in - ) - - -async def _locked_confirm_bridge( - evt: CommandEvent, portal: po.Portal, room_id: RoomID, is_logged_in: bool -) -> EventID | None: - user = evt.sender if is_logged_in else evt.tgbot - try: - entity = await user.client.get_entity(portal.peer) - except Exception: - evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer) - if is_logged_in: - return await evt.reply( - "Failed to get info of telegram chat. You are logged in, are you in that chat?" - ) - else: - return await evt.reply( - "Failed to get info of telegram chat. " - "You're not logged in, is the relay bot in the chat?" - ) - if isinstance(entity, (ChatForbidden, ChannelForbidden)): - if is_logged_in: - return await evt.reply("You don't seem to be in that chat.") - else: - return await evt.reply("The bot doesn't seem to be in that chat.") - - portal.mxid = room_id - portal.by_mxid[portal.mxid] = portal - (portal.title, portal.about, levels, portal.encrypted) = await get_initial_state( - evt.az.intent, evt.room_id - ) - portal.photo_id = "" - await portal.save() - await portal.update_bridge_info() - - background_task.create(portal.update_matrix_room(user, entity, levels=levels)) - - await warn_missing_power(levels, evt) - - return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.") diff --git a/mautrix_telegram/commands/portal/config.py b/mautrix_telegram/commands/portal/config.py deleted file mode 100644 index 6c903859..00000000 --- a/mautrix_telegram/commands/portal/config.py +++ /dev/null @@ -1,162 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from typing import Any, Awaitable -from io import StringIO - -from ruamel.yaml import YAMLError - -from mautrix.types import EventID -from mautrix.util.config import yaml - -from ... import portal as po, util -from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler - - -@command_handler( - needs_auth=False, - needs_puppeting=False, - help_section=SECTION_PORTAL_MANAGEMENT, - help_text="View or change per-portal settings.", - help_args="<`help`|_subcommand_> [...]", -) -async def config(evt: CommandEvent) -> None: - cmd = evt.args[0].lower() if len(evt.args) > 0 else "help" - if cmd not in ("view", "defaults", "set", "unset", "add", "del"): - await config_help(evt) - return - elif cmd == "defaults": - await config_defaults(evt) - return - - portal = await po.Portal.get_by_mxid(evt.room_id) - if not portal: - await evt.reply("This is not a portal room.") - return - elif cmd == "view": - await config_view(evt, portal) - return - - if not await portal.can_user_perform(evt.sender, "config"): - await evt.reply("You do not have the permissions to configure this room.") - return - - key = evt.args[1] if len(evt.args) > 1 else None - try: - value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None - except YAMLError as e: - await evt.reply(f"Invalid value provided. Values must be valid YAML.\n{e}") - return - if cmd == "set": - await config_set(evt, portal, key, value) - elif cmd == "unset": - await config_unset(evt, portal, key) - elif cmd == "add" or cmd == "del": - await config_add_del(evt, portal, key, value, cmd) - else: - return - await portal.save() - - -def config_help(evt: CommandEvent) -> Awaitable[EventID]: - return evt.reply( - """**Usage:** `$cmdprefix config [...]`. Subcommands: - -* **help** - View this help text. -* **view** - View the current config data. -* **defaults** - View the default config values. -* **set** <_key_> <_value_> - Set a config value. -* **unset** <_key_> - Remove a config value. -* **add** <_key_> <_value_> - Add a value to an array. -* **del** <_key_> <_value_> - Remove a value from an array. -""" - ) - - -def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[EventID]: - return evt.reply(f"Room-specific config:\n{_str_value(portal.local_config).rstrip()}") - - -def config_defaults(evt: CommandEvent) -> Awaitable[EventID]: - value = _str_value( - { - "bridge_notices": { - "default": evt.config["bridge.bridge_notices.default"], - "exceptions": evt.config["bridge.bridge_notices.exceptions"], - }, - "bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"], - "caption_in_message": evt.config["bridge.caption_in_message"], - "message_formats": evt.config["bridge.message_formats"], - "emote_format": evt.config["bridge.emote_format"], - "state_event_formats": evt.config["bridge.state_event_formats"], - "telegram_link_preview": evt.config["bridge.telegram_link_preview"], - } - ) - return evt.reply(f"Bridge instance wide config:\n{value.rstrip()}") - - -def _str_value(value: Any) -> str: - stream = StringIO() - yaml.dump(value, stream) - value_str = stream.getvalue() - if "\n" in value_str: - return f"\n```yaml\n{value_str}\n```\n" - else: - return f"`{value_str}`" - - -def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: Any) -> Awaitable[EventID]: - if not key or value is None: - return evt.reply(f"**Usage:** `$cmdprefix+sp config set `") - elif util.recursive_set(portal.local_config, key, value): - return evt.reply(f"Successfully set the value of `{key}` to {_str_value(value)}".rstrip()) - else: - return evt.reply(f"Failed to set value of `{key}`. Does the path contain non-map types?") - - -def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[EventID]: - if not key: - return evt.reply(f"**Usage:** `$cmdprefix+sp config unset `") - elif util.recursive_del(portal.local_config, key): - return evt.reply(f"Successfully deleted `{key}` from config.") - else: - return evt.reply(f"`{key}` not found in config.") - - -def config_add_del( - evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str -) -> Awaitable[EventID]: - if not key or value is None: - return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} `") - - arr = util.recursive_get(portal.local_config, key) - if not arr: - return evt.reply( - f"`{key}` not found in config. Maybe do `$cmdprefix+sp config set {key} []` first?" - ) - elif not isinstance(arr, list): - return evt.reply("`{key}` does not seem to be an array.") - elif cmd == "add": - if value in arr: - return evt.reply(f"The array at `{key}` already contains {_str_value(value)}".rstrip()) - arr.append(value) - return evt.reply(f"Successfully added {_str_value(value)} to the array at `{key}`") - else: - if value not in arr: - return evt.reply(f"The array at `{key}` does not contain {_str_value(value)}") - arr.remove(value) - return evt.reply(f"Successfully removed {_str_value(value)} from the array at `{key}`") diff --git a/mautrix_telegram/commands/portal/create_chat.py b/mautrix_telegram/commands/portal/create_chat.py deleted file mode 100644 index 2edf7410..00000000 --- a/mautrix_telegram/commands/portal/create_chat.py +++ /dev/null @@ -1,75 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from mautrix.types import EventID - -from ... import portal as po -from ...types import TelegramID -from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler -from .util import get_initial_state, user_has_power_level, warn_missing_power - - -@command_handler( - help_section=SECTION_CREATING_PORTALS, - help_args="[_type_]", - help_text=( - "Create a Telegram chat of the given type for the current Matrix room. " - "The type is either `group`, `supergroup` or `channel` (defaults to `supergroup`)." - ), -) -async def create(evt: CommandEvent) -> EventID: - type = evt.args[0] if len(evt.args) > 0 else "supergroup" - if type not in ("chat", "group", "supergroup", "channel"): - return await evt.reply( - "**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`" - ) - - if await po.Portal.get_by_mxid(evt.room_id): - return await evt.reply("This is already a portal room.") - - if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"): - return await evt.reply("You do not have the permissions to bridge this room.") - - title, about, levels, encrypted = await get_initial_state(evt.az.intent, evt.room_id) - if not title: - return await evt.reply("Please set a title before creating a Telegram chat.") - - supergroup = type == "supergroup" - type = { - "supergroup": "channel", - "channel": "channel", - "chat": "chat", - "group": "chat", - }[type] - - portal = po.Portal( - tgid=TelegramID(0), - tg_receiver=TelegramID(0), - peer_type=type, - mxid=evt.room_id, - title=title, - about=about, - encrypted=encrypted, - ) - - await warn_missing_power(levels, evt) - - try: - await portal.create_telegram_chat(evt.sender, supergroup=supergroup) - except ValueError as e: - await portal.delete() - return await evt.reply(e.args[0]) diff --git a/mautrix_telegram/commands/portal/filter.py b/mautrix_telegram/commands/portal/filter.py deleted file mode 100644 index 318829cd..00000000 --- a/mautrix_telegram/commands/portal/filter.py +++ /dev/null @@ -1,105 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from mautrix.types import EventID - -from ... import portal as po -from .. import SECTION_ADMIN, CommandEvent, command_handler - - -@command_handler( - needs_admin=True, - help_section=SECTION_ADMIN, - help_args="<`whitelist`|`blacklist`>", - help_text="Change whether the bridge will allow or disallow bridging rooms by default.", -) -async def filter_mode(evt: CommandEvent) -> EventID: - try: - mode = evt.args[0] - if mode not in ("whitelist", "blacklist"): - raise ValueError() - except (IndexError, ValueError): - return await evt.reply("**Usage:** `$cmdprefix+sp filter-mode `") - - evt.config["bridge.filter.mode"] = mode - evt.config.save() - po.Portal.filter_mode = mode - if mode == "whitelist": - return await evt.reply( - "The bridge will now disallow bridging chats by default.\n" - "To allow bridging a specific chat, use" - "`!filter whitelist `." - ) - else: - return await evt.reply( - "The bridge will now allow bridging chats by default.\n" - "To disallow bridging a specific chat, use" - "`!filter blacklist `." - ) - - -@command_handler( - name="filter", - needs_admin=True, - help_section=SECTION_ADMIN, - help_args="<`whitelist`|`blacklist`> <_chat ID_>", - help_text="Allow or disallow bridging a specific chat.", -) -async def edit_filter(evt: CommandEvent) -> EventID: - try: - action = evt.args[0] - if action not in ("whitelist", "blacklist", "add", "remove"): - raise ValueError() - - id_str = evt.args[1] - if id_str.startswith("-100"): - filter_id = int(id_str[4:]) - elif id_str.startswith("-"): - filter_id = int(id_str[1:]) - else: - filter_id = int(id_str) - except (IndexError, ValueError): - return await evt.reply("**Usage:** `$cmdprefix+sp filter `") - - mode = evt.config["bridge.filter.mode"] - if mode not in ("blacklist", "whitelist"): - return await evt.reply(f'Unknown filter mode "{mode}". Please fix the bridge config.') - - filter_id_list = evt.config["bridge.filter.list"] - - if action in ("blacklist", "whitelist"): - action = "add" if mode == action else "remove" - - def save() -> None: - evt.config["bridge.filter.list"] = filter_id_list - evt.config.save() - po.Portal.filter_list = filter_id_list - - if action == "add": - if filter_id in filter_id_list: - return await evt.reply(f"That chat is already {mode}ed.") - filter_id_list.append(filter_id) - save() - return await evt.reply(f"Chat ID added to {mode}.") - elif action == "remove": - if filter_id not in filter_id_list: - return await evt.reply(f"That chat is not {mode}ed.") - filter_id_list.remove(filter_id) - save() - return await evt.reply(f"Chat ID removed from {mode}.") - else: - return await evt.reply("**Usage:** `$cmdprefix+sp filter `") diff --git a/mautrix_telegram/commands/portal/misc.py b/mautrix_telegram/commands/portal/misc.py deleted file mode 100644 index 14e8d3f7..00000000 --- a/mautrix_telegram/commands/portal/misc.py +++ /dev/null @@ -1,347 +0,0 @@ -# mautrix-telegram - A Matrix-Telegram puppeting bridge -# Copyright (C) 2021 Tulir Asokan -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -from __future__ import annotations - -from datetime import datetime, timedelta -import re - -from telethon.errors import ( - ChatAdminRequiredError, - RPCError, - UsernameInvalidError, - UsernameNotModifiedError, - UsernameOccupiedError, -) -from telethon.helpers import add_surrogate -from telethon.tl.functions.channels import GetFullChannelRequest -from telethon.tl.functions.messages import GetExportedChatInvitesRequest, GetFullChatRequest -from telethon.tl.types import ( - ChatInviteExported, - InputMessageEntityMentionName, - InputUserSelf, - MessageEntityMention, - TypeInputPeer, - TypeInputUser, -) -from telethon.tl.types.messages import ExportedChatInvites - -from mautrix.types import EventID - -from ... import formatter as fmt, portal as po, puppet as pu -from .. import SECTION_MISC, SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler -from .util import user_has_power_level - - -@command_handler( - needs_admin=False, - needs_puppeting=False, - needs_auth=False, - help_section=SECTION_MISC, - help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.", -) -async def sync_state(evt: CommandEvent) -> EventID: - portal = await po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - elif not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"): - return await evt.reply(f"You do not have the permissions to synchronize this room.") - - await portal.main_intent.get_joined_members(portal.mxid) - await evt.reply("Synchronization complete") - - -@command_handler( - needs_admin=False, needs_puppeting=False, needs_auth=False, help_section=SECTION_MISC -) -async def sync_full(evt: CommandEvent) -> EventID: - portal = await po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - - if len(evt.args) > 0 and evt.args[0] == "--usebot" and evt.sender.is_admin: - src = evt.tgbot - else: - src = evt.tgbot if await evt.sender.needs_relaybot(portal) else evt.sender - - try: - if portal.peer_type == "channel": - res = await src.client(GetFullChannelRequest(portal.peer)) - elif portal.peer_type == "chat": - res = await src.client(GetFullChatRequest(portal.tgid)) - else: - return await evt.reply("This is not a channel or chat portal.") - except (ValueError, RPCError): - return await evt.reply("Failed to get portal info from Telegram.") - - await portal.update_matrix_room(src, res.full_chat) - return await evt.reply("Portal synced successfully.") - - -@command_handler( - name="id", - needs_admin=False, - needs_puppeting=False, - needs_auth=False, - help_section=SECTION_MISC, - help_text="Get the ID of the Telegram chat where this room is bridged.", -) -async def get_id(evt: CommandEvent) -> EventID: - portal = await po.Portal.get_by_mxid(evt.room_id) - if not portal: - return await evt.reply("This is not a portal room.") - tgid = portal.tgid - if portal.peer_type == "chat": - tgid = -tgid - elif portal.peer_type == "channel": - tgid = f"-100{tgid}" - await evt.reply(f"This room is bridged to Telegram chat ID `{tgid}`.") - - -invite_link_usage = ( - "**Usage:** `$cmdprefix+sp invite-link " - "[--uses=] [--expire=] [--request-needed] -- [title]`" - "\n\n" - "* `--uses`: the number of times the invite link can be used." - " Defaults to unlimited.\n" - "* `--expire`: the duration after which the link will expire." - " A number suffixed with d(ay), h(our), m(inute) or s(econd)\n" - "* `--request-needed`: should the link require admins to approve joins?\n" - "* `title`: a description of the link (only shown to admins)." -) - - -def _parse_flag(args: list[str]) -> tuple[str, str]: - arg = args.pop(0).lower() - if arg == "--": - return "", "" - value = "" - if arg.startswith("--"): - value_start = arg.find("=") - if value_start > 0: - flag = arg[2:value_start] - value = arg[value_start + 1 :] - else: - flag = arg[2:] - if arg not in ("request", "request-needed"): - value = args.pop(0).lower() - elif arg.startswith("-"): - flag = arg[1] - if len(arg) > 3 and arg[2] == "=": - value = arg[3:] - elif arg != "r": - value = args.pop(0).lower() - else: - raise ValueError("invalid flag") - return flag, value - - -delta_regex = re.compile( - "([0-9]+)(w(?:eek)?|d(?:ay)?|h(?:our)?|m(?:in(?:ute)?)?|s(?:ec(?:ond)?)?)" -) - - -def _parse_delta(value: str) -> timedelta | None: - match = delta_regex.fullmatch(value) - if not match: - return None - number = int(match.group(1)) - unit = match.group(2)[0] - if unit == "w": - return timedelta(weeks=number) - elif unit == "d": - return timedelta(days=number) - elif unit == "h": - return timedelta(hours=number) - elif unit == "m": - return timedelta(minutes=number) - elif unit == "s": - return timedelta(seconds=number) - else: - return None - - -@command_handler( - help_section=SECTION_PORTAL_MANAGEMENT, - help_text="Get a Telegram invite link to the current chat.", - help_args="[--uses=] [--expire=