Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92a6afdd2f |
@@ -0,0 +1,8 @@
|
|||||||
|
engines:
|
||||||
|
sonar-python:
|
||||||
|
enabled: true
|
||||||
|
checks:
|
||||||
|
python:S107:
|
||||||
|
enabled: false
|
||||||
|
exclude_patterns:
|
||||||
|
- "alembic/"
|
||||||
@@ -2,9 +2,3 @@
|
|||||||
.codeclimate.yml
|
.codeclimate.yml
|
||||||
*.png
|
*.png
|
||||||
*.md
|
*.md
|
||||||
logs
|
|
||||||
.venv
|
|
||||||
start
|
|
||||||
config.yaml
|
|
||||||
registration.yaml
|
|
||||||
*.db
|
|
||||||
|
|||||||
@@ -8,14 +8,8 @@ charset = utf-8
|
|||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
|
|
||||||
[*.py]
|
[*.py]
|
||||||
max_line_length = 99
|
max_line_length = 99
|
||||||
|
|
||||||
[*.{yaml,yml,py}]
|
[*.{yaml,yml,py}]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
|
|
||||||
[{.gitlab-ci.yml,.pre-commit-config.yaml}]
|
|
||||||
indent_size = 2
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
name: Python lint
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- uses: actions/setup-python@v2
|
|
||||||
with:
|
|
||||||
python-version: "3.10"
|
|
||||||
- uses: isort/isort-action@master
|
|
||||||
with:
|
|
||||||
sortPaths: "./mautrix_telegram"
|
|
||||||
- uses: psf/black@stable
|
|
||||||
with:
|
|
||||||
src: "./mautrix_telegram"
|
|
||||||
version: "22.1.0"
|
|
||||||
- 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
|
|
||||||
+5
-11
@@ -1,18 +1,12 @@
|
|||||||
/.idea/
|
.idea/
|
||||||
|
|
||||||
/.venv
|
.venv
|
||||||
/env/
|
env/
|
||||||
pip-selfcheck.json
|
pip-selfcheck.json
|
||||||
*.pyc
|
*.pyc
|
||||||
__pycache__
|
__pycache__
|
||||||
/build
|
|
||||||
/dist
|
|
||||||
/*.egg-info
|
|
||||||
/.eggs
|
|
||||||
|
|
||||||
/config.yaml
|
config.yaml
|
||||||
/registration.yaml
|
registration.yaml
|
||||||
*.log*
|
*.log*
|
||||||
*.db
|
*.db
|
||||||
*.pickle
|
|
||||||
*.bak
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
image: docker:stable
|
|
||||||
|
|
||||||
stages:
|
|
||||||
- build
|
|
||||||
- manifest
|
|
||||||
|
|
||||||
default:
|
|
||||||
before_script:
|
|
||||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
|
||||||
|
|
||||||
build amd64:
|
|
||||||
stage: build
|
|
||||||
tags:
|
|
||||||
- amd64
|
|
||||||
script:
|
|
||||||
- docker pull $CI_REGISTRY_IMAGE:latest || true
|
|
||||||
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --build-arg TARGETARCH=amd64 --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 .
|
|
||||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
|
||||||
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
|
||||||
after_script:
|
|
||||||
- |
|
|
||||||
if [[ "$CI_COMMIT_BRANCH" == "master" && "$CI_JOB_STATUS" == "success" ]]; then
|
|
||||||
apk add --update curl jq
|
|
||||||
rm -rf /var/cache/apk/*
|
|
||||||
|
|
||||||
jq -n '
|
|
||||||
{
|
|
||||||
password: env.BEEPER_DEV_ADMIN_NIGHTLY_PASS,
|
|
||||||
bridge: env.BEEPER_BRIDGE_TYPE,
|
|
||||||
image: "\(env.CI_REGISTRY_IMAGE):\(env.CI_COMMIT_SHA)-amd64",
|
|
||||||
channel: "STABLE"
|
|
||||||
}
|
|
||||||
' | curl "$BEEPER_DEV_ADMIN_API_URL" -H "Content-Type: application/json" -d @-
|
|
||||||
|
|
||||||
jq -n '
|
|
||||||
{
|
|
||||||
password: env.BEEPER_PROD_ADMIN_NIGHTLY_PASS,
|
|
||||||
bridge: env.BEEPER_BRIDGE_TYPE,
|
|
||||||
image: "\(env.CI_REGISTRY_IMAGE):\(env.CI_COMMIT_SHA)-amd64",
|
|
||||||
channel: "INTERNAL",
|
|
||||||
deployNext: true
|
|
||||||
}
|
|
||||||
' | curl "$BEEPER_PROD_ADMIN_API_URL" -H "Content-Type: application/json" -d @-
|
|
||||||
fi
|
|
||||||
|
|
||||||
build arm64:
|
|
||||||
stage: build
|
|
||||||
tags:
|
|
||||||
- arm64
|
|
||||||
script:
|
|
||||||
- docker pull $CI_REGISTRY_IMAGE:latest || true
|
|
||||||
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --build-arg TARGETARCH=arm64 --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 .
|
|
||||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
|
||||||
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
|
||||||
|
|
||||||
manifest:
|
|
||||||
stage: manifest
|
|
||||||
before_script:
|
|
||||||
- "mkdir -p $HOME/.docker && echo '{\"experimental\": \"enabled\"}' > $HOME/.docker/config.json"
|
|
||||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
|
||||||
script:
|
|
||||||
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
|
||||||
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
|
||||||
- if [ "$CI_COMMIT_BRANCH" = "master" ]; then docker manifest create $CI_REGISTRY_IMAGE:latest $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 && docker manifest push $CI_REGISTRY_IMAGE:latest; fi
|
|
||||||
- if [ "$CI_COMMIT_BRANCH" != "master" ]; then docker manifest create $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 && docker manifest push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME; fi
|
|
||||||
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
repos:
|
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v4.1.0
|
|
||||||
hooks:
|
|
||||||
- id: trailing-whitespace
|
|
||||||
exclude_types: [markdown]
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: check-yaml
|
|
||||||
- id: check-added-large-files
|
|
||||||
# TODO convert to use the upstream psf/black when
|
|
||||||
# https://github.com/psf/black/issues/2493 gets fixed
|
|
||||||
- repo: local
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
name: black
|
|
||||||
entry: black --check
|
|
||||||
language: system
|
|
||||||
files: ^mautrix_telegram/.*\.py$
|
|
||||||
- repo: https://github.com/PyCQA/isort
|
|
||||||
rev: 5.10.1
|
|
||||||
hooks:
|
|
||||||
- id: isort
|
|
||||||
files: ^mautrix_telegram/.*$
|
|
||||||
-745
@@ -1,745 +0,0 @@
|
|||||||
# v0.11.2 (2022-02-14)
|
|
||||||
|
|
||||||
**N.B.** This will be the last release to support Python 3.7. Future versions
|
|
||||||
will require Python 3.8 or higher. In general, the mautrix bridges will only
|
|
||||||
support the lowest Python version in the latest Debian or Ubuntu LTS.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Added simple fallback message for live location and venue messages from Telegram.
|
|
||||||
* Added support for `t.me/+code` style invite links in `!tg join`.
|
|
||||||
* Added support for showing channel profile when users send messages as a channel.
|
|
||||||
* Added "user joined Telegram" message when Telegram auto-creates a DM chat for
|
|
||||||
a new user.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Added option for adding a random prefix to relayed user displaynames to help
|
|
||||||
distinguish them on the Telegram side.
|
|
||||||
* Improved syncing profile info to room info when using encryption and/or the
|
|
||||||
`private_chat_profile_meta` config option.
|
|
||||||
* Removed legacy `community_id` config option.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Fixed newlines disappearing when bridging channel messages with signatures.
|
|
||||||
* Fixed login throwing an error if a previous login code expired.
|
|
||||||
* Fixed bug in v0.11.0 that broke `!tg create`.
|
|
||||||
|
|
||||||
# v0.11.1 (2022-01-10)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Added support for message reactions.
|
|
||||||
* Added support for spoiler text.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Support for voice messages.
|
|
||||||
* Changed color of blue text from Telegram to be more readable on dark themes.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Fixed syncing contacts throwing an error for new accounts.
|
|
||||||
* Fixed migrating pre-v0.11 legacy databases if the database schema had been
|
|
||||||
corrupted (e.g. by using 3rd party tools for SQLite -> Postgres migration).
|
|
||||||
* Fixed converting animated stickers to webm with >33 FPS.
|
|
||||||
* Fixed a bug in v0.11.0 that broke mentioning users in groups
|
|
||||||
(thanks to [@dfuchss] in [#724]).
|
|
||||||
|
|
||||||
[@dfuchss]: https://github.com/dfuchss
|
|
||||||
[#724]: https://github.com/mautrix/telegram/pull/724
|
|
||||||
|
|
||||||
# v0.11.0 (2021-12-28)
|
|
||||||
|
|
||||||
* Switched from SQLAlchemy to asyncpg/aiosqlite.
|
|
||||||
* The default database is now Postgres. If using SQLite, make sure you install
|
|
||||||
the `sqlite` [optional dependency](https://docs.mau.fi/bridges/python/optional-dependencies.html).
|
|
||||||
* **Alembic is no longer used**, schema migrations happen automatically on startup.
|
|
||||||
* **The automatic database migration requires you to be on the latest legacy
|
|
||||||
database version.** If you were running any v0.10.x version, you should be on
|
|
||||||
the latest version already. Otherwise, update to v0.10.2 first, upgrade the
|
|
||||||
database with `alembic`, then upgrade to v0.11.0 (or higher).
|
|
||||||
* Added support for contact messages.
|
|
||||||
* Added support for Telegram sponsored messages in channels.
|
|
||||||
* Only applies to broadcast channels with 1000+ members
|
|
||||||
(as per <https://t.me/durov/172>).
|
|
||||||
* Only applies if you're using puppeting with a normal user account,
|
|
||||||
because bots can't get sponsored messages.
|
|
||||||
* Fixed non-supergroup member sync incorrectly kicking one user from the Matrix
|
|
||||||
side if there was no limit on the number of members to sync (broke in v0.10.2).
|
|
||||||
* Updated animated sticker conversion to support [lottieconverter r0.2]
|
|
||||||
(thanks to [@sot-tech] in [#694]).
|
|
||||||
* Updated Docker image to Alpine 3.15.
|
|
||||||
* Formatted all code using [black](https://github.com/psf/black)
|
|
||||||
and [isort](https://github.com/PyCQA/isort).
|
|
||||||
|
|
||||||
[lottieconverter r0.2]: https://github.com/sot-tech/LottieConverter/releases/tag/r0.2
|
|
||||||
[#694]: https://github.com/mautrix/telegram/pull/694
|
|
||||||
|
|
||||||
# v0.10.2 (2021-11-13)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Added extensions when bridging unnamed files from Telegram.
|
|
||||||
* Added support for custom bridge bot welcome messages
|
|
||||||
(thanks to [@justinbot] in [#676]).
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Improved handling authorization errors if the bridge was logged out remotely.
|
|
||||||
* Updated room syncer to use existing power levels to find appropriate levels
|
|
||||||
for admins and normal users instead of hardcoding 50 and 0.
|
|
||||||
* Updated to Telegram API layer 133 to handle 64-bit user/chat/channel IDs.
|
|
||||||
* Stopped logging message contents when message handling failed
|
|
||||||
(thanks to [@justinbot] in [#681]).
|
|
||||||
* Removed Element iOS compatibility hack from non-sticker files.
|
|
||||||
* Made `max_initial_member_sync` work for non-supergroups too
|
|
||||||
(thanks to [@tadzik] in [#680]).
|
|
||||||
* SQLite is now supported for the crypto database. Pickle is no longer supported.
|
|
||||||
If you were using pickle, the bridge will create a new e2ee session and store
|
|
||||||
the data in SQLite this time.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Fixed generating reply fallbacks to encrypted messages.
|
|
||||||
* Fixed chat sync failing if the member list contained banned users.
|
|
||||||
|
|
||||||
[@justinbot]: https://github.com/justinbot
|
|
||||||
[@tadzik]: https://github.com/tadzik
|
|
||||||
[#676]: https://github.com/mautrix/telegram/pull/676
|
|
||||||
[#680]: https://github.com/mautrix/telegram/pull/680
|
|
||||||
[#681]: https://github.com/mautrix/telegram/pull/681
|
|
||||||
|
|
||||||
# v0.10.1 (2021-08-19)
|
|
||||||
|
|
||||||
**N.B.** Docker images have moved from `dock.mau.dev/tulir/mautrix-telegram`
|
|
||||||
to `dock.mau.dev/mautrix/telegram`. New versions are only available at the new
|
|
||||||
path.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
* Warning when bridging existing room if bridge bot doesn't have redaction
|
|
||||||
permissions.
|
|
||||||
* Custom flag to invite events that will be auto-accepted using double puppeting.
|
|
||||||
* Custom flags for animated stickers (same as what gifs already had).
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Updated to Telethon 1.22.
|
|
||||||
* Updated Docker image to Alpine 3.14.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Fixed Bridging Matrix location messages with additional flags in `geo_uri`.
|
|
||||||
* Editing encrypted messages will no longer add an asterisk on Telegram.
|
|
||||||
* Matrix typing notifications won't be echoed back for double puppeted users anymore.
|
|
||||||
* `AuthKeyDuplicatedError` is now handled properly instead of making the user
|
|
||||||
get stuck.
|
|
||||||
* Fixed `public_portals` setting not being respected on room creation.
|
|
||||||
|
|
||||||
# v0.10.0 (2021-06-14)
|
|
||||||
|
|
||||||
* Added options to bridge archive, pin and mute status from Telegram to Matrix.
|
|
||||||
* Added custom fields in Matrix events indicating Telegram gifs.
|
|
||||||
* Allowed zero-width joiners in displaynames so things like multi-part emoji
|
|
||||||
would work correctly.
|
|
||||||
* Fixed Telegram->Matrix typing notifications.
|
|
||||||
|
|
||||||
## rc1 (2021-04-05)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Support for multiple pins from/to Telegram.
|
|
||||||
* Option to resolve redirects when joining invite links, for people who use
|
|
||||||
custom URLs as invite links.
|
|
||||||
* Command to update about section in Telegram profile info
|
|
||||||
(thanks to [@MadhuranS] in [#599]).
|
|
||||||
* Own read marker/unread status from Telegram is now synced to Matrix after backfilling.
|
|
||||||
* Support for showing the individual slots in 🎰 dice rolls from Telegram.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Improved invite link regex to allow joining with less precise invite links.
|
|
||||||
* Invite links can be customized with the `--uses=<amount>` and
|
|
||||||
`--expire=<delta>` flags for `!tg invite-link`.
|
|
||||||
* Read receipts where the target message is unknown will now cause the chat to
|
|
||||||
be marked as fully read instead of the read receipt event being ignored.
|
|
||||||
* WebP stickers are now sent as-is without converting to png.
|
|
||||||
* Default power levels in rooms now allow enabling encryption with PL 50 if
|
|
||||||
e2be is enabled in config (thanks to [@Rafaeltheraven] in [#550]).
|
|
||||||
* Updated Docker image to Alpine 3.13 and removed all edge repo stuff.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Matrix->Telegram location message bridging no longer flips the coordinates.
|
|
||||||
* Fixed some user displaynames constantly changing between contact/non-contact
|
|
||||||
names and other similar cases.
|
|
||||||
|
|
||||||
[@Rafaeltheraven]: https://github.com/Rafaeltheraven
|
|
||||||
[@MadhuranS]: https://github.com/MadhuranS
|
|
||||||
[#550]: https://github.com/mautrix/telegram/pull/550
|
|
||||||
[#599]: https://github.com/mautrix/telegram/pull/599
|
|
||||||
|
|
||||||
# v0.9.0 (2020-11-17)
|
|
||||||
|
|
||||||
* Fixed cleaning unidentified rooms.
|
|
||||||
|
|
||||||
## rc3 (2020-11-12)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Added retrying message sending if server returns 502.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Fixed Matrix → Telegram name mentions.
|
|
||||||
* Fixed some bugs with replies.
|
|
||||||
|
|
||||||
## rc2 (2020-11-06)
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Ephemeral event handling should be faster by not checking the database for
|
|
||||||
user existence.
|
|
||||||
* Using the register command now sends a link to the Telegram terms of service.
|
|
||||||
* The `bridge_connected` metric is now only set for users who are logged in.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Fixed bug where syncing members sometimes kicked ghosts of users who were
|
|
||||||
actually still in the chat.
|
|
||||||
* Fixed sending captions to Telegram with `!tg caption` (broken in rc1).
|
|
||||||
* Logging out will now delete private chat portals, instead of only kicking the
|
|
||||||
user and leaving the portal in a broken state.
|
|
||||||
* Unbridging direct chat portals is now possible.
|
|
||||||
|
|
||||||
## rc1 (2020-10-24)
|
|
||||||
|
|
||||||
### Breaking changes
|
|
||||||
* Prometheus metric names are now prefixed with `bridge_`.
|
|
||||||
* An entrypoint script is no longer automatically generated. This won't affect
|
|
||||||
most users, as `python -m mautrix_telegram` has been the official way to start
|
|
||||||
the bridge for a long time.
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Support for logging in by scanning a QR code from another Telegram client.
|
|
||||||
* Automatic backfilling of old messages when creating portals.
|
|
||||||
* Automatic backfilling of missed messages when starting bridge.
|
|
||||||
* Option to update `m.direct` list when using double puppeting.
|
|
||||||
* PNG thumbnails for animated stickers when converted to webm.
|
|
||||||
* Support for receiving ephemeral events pushed directly with [MSC2409]
|
|
||||||
(requires Synapse 1.22 or higher).
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Switched end-to-bridge encryption to mautrix-python instead of a hacky
|
|
||||||
matrix-nio solution.
|
|
||||||
* End-to-bridge encryption no longer requires `login_shared_secret`, it uses
|
|
||||||
[MSC2778] instead (requires Synapse 1.21 or higher).
|
|
||||||
* The bridge info state event is now updated whenever the chat name or avatar changes.
|
|
||||||
* Double puppeting is no longer limited to users on the same homeserver as the bridge.
|
|
||||||
* Delivery receipts are no longer sent in unencrypted private chat portals, as
|
|
||||||
the bridge bot is usually not present in them.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* File captions are now sent as a separate message like photo captions.
|
|
||||||
* The relaybot no longer drops Telegram messages with commands.
|
|
||||||
* Bridging events of a user whose power level is malformed (i.e. a string
|
|
||||||
instead of an integer) now works.
|
|
||||||
|
|
||||||
[MSC2409]: https://github.com/matrix-org/matrix-doc/pull/2409
|
|
||||||
[MSC2778]: https://github.com/matrix-org/matrix-doc/pull/2778
|
|
||||||
|
|
||||||
# v0.8.2 (2020-07-27)
|
|
||||||
|
|
||||||
* Fixed deleting messages from Matrix.
|
|
||||||
* Fixed Alpine edge dependencies in Docker image.
|
|
||||||
|
|
||||||
Note: this release is not on PyPI, as the only changes were a mautrix-python
|
|
||||||
update (v0.5.8) and a fix to the Docker image.
|
|
||||||
|
|
||||||
# v0.8.1 (2020-06-08)
|
|
||||||
|
|
||||||
* Fixed starting bridge for the first time failing due to not registering the bridge bot.
|
|
||||||
* Updated Docker image to Alpine 3.12.
|
|
||||||
|
|
||||||
# v0.8.0 (2020-06-03)
|
|
||||||
|
|
||||||
* Updated to mautrix-python 0.5.0 and matrix-nio 0.12.0.
|
|
||||||
|
|
||||||
## rc5 (2020-05-30)
|
|
||||||
|
|
||||||
* Added option to disable removing avatars from Telegram ghosts.
|
|
||||||
* Added option to send delivery error notices.
|
|
||||||
* Added option to send delivery receipts.
|
|
||||||
* Bumped maximum Telethon version to 1.14.
|
|
||||||
* Possibly fixed infinite loop of avatar changes when using double puppeting.
|
|
||||||
|
|
||||||
## rc3 (2020-05-22)
|
|
||||||
|
|
||||||
* Moved private information to trace log level.
|
|
||||||
* Added `private_chat_portal_meta` option. This is implicitly enabled when
|
|
||||||
encryption is enabled, it was only added as an option for instances with
|
|
||||||
encryption disabled.
|
|
||||||
* Removed avatars are now synced properly from Telegram, instead of leaving the
|
|
||||||
last known avatar forever.
|
|
||||||
* Fixed admin detection on Telegram-side relaybot commands
|
|
||||||
(thanks to [@davidmehren] in [#468]).
|
|
||||||
* Fixed bug handling `ChatForbidden` when syncing chats.
|
|
||||||
|
|
||||||
[@davidmehren]: https://github.com/davidmehren
|
|
||||||
[#468]: https://github.com/mautrix/telegram/pull/468
|
|
||||||
|
|
||||||
## rc2 (2020-05-20)
|
|
||||||
|
|
||||||
* Implemented [MSC2346]: Bridge information state event for newly created rooms.
|
|
||||||
* Fixed `sync_direct_chats` option creating non-working portals.
|
|
||||||
* Fixed video thumbnailing sometimes leaving behind downloaded videos in `/tmp`.
|
|
||||||
|
|
||||||
[MSC2346]: https://github.com/matrix-org/matrix-doc/pull/2346
|
|
||||||
|
|
||||||
## rc1 (2020-04-25)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Command for backfilling room history from Telegram.
|
|
||||||
* arm64 support in docker images.
|
|
||||||
* Optional end-to-bridge encryption support.
|
|
||||||
See [docs](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html) for more info.
|
|
||||||
* Bridging for Telegram dice roll messages.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Riot iOS not showing stickers properly.
|
|
||||||
* Updated to Telethon 1.13 to fix bugs like [#443].
|
|
||||||
|
|
||||||
[#443]: https://github.com/mautrix/telegram/issues/443
|
|
||||||
|
|
||||||
# v0.7.2 (2020-04-04)
|
|
||||||
|
|
||||||
* No changes since rc1.
|
|
||||||
|
|
||||||
## rc1 (2020-02-08)
|
|
||||||
|
|
||||||
* Fixed enabling double puppeting causing saved messages to become unusable.
|
|
||||||
* Fixed receiving channel messages when `ignore_own_incoming_events` was enabled.
|
|
||||||
|
|
||||||
# v0.7.1 (2020-02-04)
|
|
||||||
|
|
||||||
* Fixed missing responses in logout provisioning API.
|
|
||||||
|
|
||||||
## rc2 (2020-01-25)
|
|
||||||
|
|
||||||
* Fixed import in database migration script (thanks to [@cubesky] in [#409]).
|
|
||||||
* Fixed relaybot messages being allowed through to Matrix even when
|
|
||||||
`ignore_own_incoming_events` was set to `true`.
|
|
||||||
|
|
||||||
[@cubesky]: https://github.com/cubesky
|
|
||||||
[#409]: https://github.com/mautrix/telegram/pull/409
|
|
||||||
|
|
||||||
## rc1 (2020-01-11)
|
|
||||||
|
|
||||||
* Fixed incorrect parameter name causing `!tg config set` to throw an error.
|
|
||||||
* Fixed potential dictionary size changed during iteration crash.
|
|
||||||
|
|
||||||
# v0.7.0 (2019-12-28)
|
|
||||||
|
|
||||||
* No changes since rc4.
|
|
||||||
|
|
||||||
## rc4 (2019-12-25)
|
|
||||||
|
|
||||||
* Fixed handling of Matrix `m.emote` events.
|
|
||||||
|
|
||||||
## rc3 (2019-12-25)
|
|
||||||
|
|
||||||
* Added option to log in to custom puppet with shared secret
|
|
||||||
(<https://github.com/devture/matrix-synapse-shared-secret-auth>).
|
|
||||||
* Updated Docker image to Alpine 3.11.
|
|
||||||
* Improved displayname syncing by trusting any displayname if user is not a contact.
|
|
||||||
* Fixed error when cleaning up rooms.
|
|
||||||
* Fixed stack traces being printed to non-admin users.
|
|
||||||
* Fixed invite rejections being handles as leaves.
|
|
||||||
* Fixed `version` command output in CI docker builds not showing the correct
|
|
||||||
git commit hash.
|
|
||||||
|
|
||||||
## rc2 (2019-12-01)
|
|
||||||
|
|
||||||
* Added command to get bridge version.
|
|
||||||
* Made bridge refuse to start if config contains example values.
|
|
||||||
* Removed some debug stack traces.
|
|
||||||
* Ignored `ChatForbidden` when syncing chats that was causing the sync to fail.
|
|
||||||
* Fixed DB migration causing some incorrect values to be left behind.
|
|
||||||
|
|
||||||
## rc1 (2019-11-30)
|
|
||||||
|
|
||||||
### Important changes
|
|
||||||
* Dropped Python 3.5 compatibility.
|
|
||||||
* Moved docker registry to [dock.mau.dev](https://mau.dev/tulir/mautrix-telegram/container_registry).
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Support for bridging animated stickers (thanks to [@sot-tech] in [#366]).
|
|
||||||
* Requires [LottieConverter](https://github.com/sot-tech/LottieConverter),
|
|
||||||
which is included in the docker image.
|
|
||||||
* Can be [configured](https://github.com/mautrix/telegram/blob/v0.7.0-rc1/example-config.yaml#L174-L187).
|
|
||||||
* Support for MTProxy (thanks to [@sot-tech] in [#344]).
|
|
||||||
* [Config option](https://github.com/mautrix/telegram/blob/v0.7.0-rc1/example-config.yaml#L117-L118)
|
|
||||||
for max length of displayname, with the default being 100.
|
|
||||||
* Separate [config option](https://github.com/mautrix/telegram/blob/v0.7.0-rc1/example-config.yaml#L232-L238)
|
|
||||||
for `m.emote` formatting of logged in users.
|
|
||||||
* Streamed file transfers and parallel telegram file download/upload.
|
|
||||||
* Files are streamed from telegram servers to the media repo rather than
|
|
||||||
downloading the whole file into memory.
|
|
||||||
* File transfers use multiple connections to telegram servers to transfer faster.
|
|
||||||
* Parallel and streamed file transfers can be enabled in the
|
|
||||||
[config](https://github.com/mautrix/telegram/blob/v0.7.0-rc1/example-config.yaml#L166-L169).
|
|
||||||
* Command to set caption for files and images when sending to telegram.
|
|
||||||
* Bridging bans to telegram.
|
|
||||||
* Helm chart.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Switched from mautrix-appservice-python to [mautrix-python](https://github.com/mautrix/python).
|
|
||||||
* Users with Matrix puppeting can now bridge their "Saved Messages" chat.
|
|
||||||
* The bridge will refuse to start without access to the example config file.
|
|
||||||
* Changed default port to 29317.
|
|
||||||
* Mentions are now marked as read on Telegram when bridging read receipts using
|
|
||||||
double puppeting.
|
|
||||||
* Kicking or banning the bridge bot will now unbridge the room.
|
|
||||||
* Shrinked Docker image from 151mb to 77mb.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* The bridge will no longer crash if one user's startup fails.
|
|
||||||
* (hopefully) Incorrect peer type being saved into database in some cases.
|
|
||||||
* File names when bridging to Telegram.
|
|
||||||
* Alembic config interpolating passwords with `%`.
|
|
||||||
* A single chat failing to sync preventing any chat from syncing.
|
|
||||||
* Users logged in as a bot not receiving any messages.
|
|
||||||
* Username matching being case-sensitive in the database and preventing
|
|
||||||
telegram->matrix pillifying.
|
|
||||||
* IndexError if running `!tg set-pl` with no parameters.
|
|
||||||
|
|
||||||
[@sot-tech]: https://github.com/sot-tech
|
|
||||||
[#344]: https://github.com/mautrix/telegram/pull/344
|
|
||||||
[#366]: https://github.com/mautrix/telegram/pull/366
|
|
||||||
|
|
||||||
# v0.6.0 (2019-07-09)
|
|
||||||
|
|
||||||
* Fixed vulnerability in event handling.
|
|
||||||
|
|
||||||
## rc2 (2019-07-06)
|
|
||||||
|
|
||||||
* Nested formatting is now supported by Telegram, so the bridge also supports it.
|
|
||||||
* Strikethrough and underline are now bridged into native Telegram formatting
|
|
||||||
rather than unicode hacks.
|
|
||||||
* Fixed displayname not updating for users who the bridge only saw via a logged
|
|
||||||
in user who had the problematic user in their contacts.
|
|
||||||
* Fixed handling unsupported media.
|
|
||||||
* Added handling for `FileIdInvalidError` in file transfers that could disrupt
|
|
||||||
`sync`s.
|
|
||||||
|
|
||||||
## rc1 (2019-06-22)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Native Matrix edit support and new fallback format.
|
|
||||||
* Config options for `retry_delay` and other TelegramClient constructor fields.
|
|
||||||
* Config option for maximum document size to let through the bridge.
|
|
||||||
* External URL field for chat and private channel messages.
|
|
||||||
* Telegram user info (puppet displayname & avatar) is now updated every time
|
|
||||||
the user sends a message.
|
|
||||||
* Command to change Telegram displayname.
|
|
||||||
* Possibility to override config fields with environment variables
|
|
||||||
(thanks to [@pacien] in [#332]).
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Simplified bridged poll message.
|
|
||||||
* Telegram user info updates are now accepted from any logged in user as long
|
|
||||||
as the logged in user doesn't see a phone number for the Telegram user.
|
|
||||||
* Some image errors are now handled by resending the image as a document.
|
|
||||||
* Made getting started more user-friendly.
|
|
||||||
* Updated to Telethon 1.8.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Portal peer type not being saved in database after Telegram chat upgrade.
|
|
||||||
* Newlines in unformatted messages not being bridged when using relaybot.
|
|
||||||
* Mime type info field for stickers converted to PNG.
|
|
||||||
* Content after newlines being stripped in messages sent by some clients.
|
|
||||||
* Potential `NoneType is not iterable` exception when logging out
|
|
||||||
(thanks to [@turt2live] in [#315]).
|
|
||||||
* Handling of Matrix messages where `m.relates_to` is null.
|
|
||||||
* Internal server error when logging in with an account on another DC.
|
|
||||||
* Spaces between command and arguments are now trimmed.
|
|
||||||
* Changed migrations to use `batch_alter_table` for adding columns to have less
|
|
||||||
warnings with SQLite.
|
|
||||||
* Error when `ping`ing without being logged in.
|
|
||||||
* Terminating sessions with negative hashes.
|
|
||||||
* State cache not being updated when sending events, causing invalid cache if
|
|
||||||
the server doesn't echo the sent events.
|
|
||||||
|
|
||||||
[@pacien]: https://github.com/pacien
|
|
||||||
[#315]: https://github.com/mautrix/telegram/pull/315
|
|
||||||
[#332]: https://github.com/mautrix/telegram/pull/332
|
|
||||||
|
|
||||||
# v0.5.2 (2019-05-25)
|
|
||||||
|
|
||||||
* Fixed null `m.relates_to`'s that break Synapse 0.99.5.
|
|
||||||
|
|
||||||
# v0.5.1 (2019-03-21)
|
|
||||||
|
|
||||||
* Fixed Python 3.5 compatibility.
|
|
||||||
* Fixed DBMS migration script.
|
|
||||||
|
|
||||||
# v0.5.0 (2019-03-19)
|
|
||||||
|
|
||||||
* Replaced rawgit with cdnjs in public website as rawgit is deprecated.
|
|
||||||
* Fixed login command throwing error when web login is enabled.
|
|
||||||
* Updated telethon-session-sqlalchemy to fix logging into an account on another DC.
|
|
||||||
* Stopped adding reply fallback to caption when sending caption and image as
|
|
||||||
separate messages.
|
|
||||||
|
|
||||||
## rc4 (2019-03-16)
|
|
||||||
|
|
||||||
* Added verbose flag to migration script.
|
|
||||||
* Added pytest setup and some tests (thanks to [@V02460] in [#290]).
|
|
||||||
* Fixed scripts (DBMS migration and Telematrix import) not being included in builds.
|
|
||||||
* Fixed some database problems.
|
|
||||||
* Removed remaining traces of ORM that might have been the causes of some other
|
|
||||||
database problems.
|
|
||||||
* Removed option to use lxml in HTML parsing as it was messing up emoji offset
|
|
||||||
handling. The new HTML parser supports using the default python HTMLParser
|
|
||||||
class since 0.5.0rc1, so lxml wasn't really useful anway.
|
|
||||||
|
|
||||||
[#290]: https://github.com/mautrix/telegram/pull/290
|
|
||||||
|
|
||||||
## rc3 (2019-02-16)
|
|
||||||
|
|
||||||
* Fixed bridging documents without thumbnails to Matrix.
|
|
||||||
* Added option to set maximum size of image to send to Telegram. Images above
|
|
||||||
the size limit will be sent as documents without the compression Telegram
|
|
||||||
applies to images.
|
|
||||||
* Fixed saving user portals and contacts.
|
|
||||||
* Added Telegram -> Matrix poll bridging and a command to vote in polls.
|
|
||||||
|
|
||||||
## rc2 (2019-02-15)
|
|
||||||
|
|
||||||
* Added missing future-fstrings comments that caused the bridge to not start on
|
|
||||||
Python 3.5.
|
|
||||||
* Fixed handling of document thumbnails.
|
|
||||||
* Fixed private chat portals failing to be created.
|
|
||||||
* Made relaybot handle Telegram chat upgrade events.
|
|
||||||
|
|
||||||
## rc1 (2019-02-14)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* More config options
|
|
||||||
* Option to to use Telegram test servers.
|
|
||||||
* Option to disable link previews on Telegram.
|
|
||||||
* Option to disable startup sync.
|
|
||||||
* Option to skip deleted members when syncing member lists.
|
|
||||||
* Option to change number of dialogs to handle in startup sync.
|
|
||||||
* More commands
|
|
||||||
* `username` for setting Telegram username.
|
|
||||||
* `sync-state` for updating Matrix room state cache.
|
|
||||||
* `matrix-ping` for checking Matrix login status (thanks to [@krombel] in [#271]).
|
|
||||||
* `clear-db-cache` for clearing internal database caches.
|
|
||||||
* `reload-user` for reloading and reconnecting a Telegram user.
|
|
||||||
* `session` for listing and terminating other Telegram sessions.
|
|
||||||
* Added argument to `login` to allow admins to log in for other users.
|
|
||||||
* Added warning when logging in that it grants the bridge full access to the
|
|
||||||
telegram account.
|
|
||||||
* Telegram->Matrix bridging:
|
|
||||||
* Telegram games
|
|
||||||
* Message pins in normal groups
|
|
||||||
* Custom message for unsupported media like polls
|
|
||||||
* Added client ID in logs when making requests to telegram.
|
|
||||||
* Added handling for Matrix room upgrades.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Removed lxml dependency from the new HTML parser and removed the old parser
|
|
||||||
completely.
|
|
||||||
* Switched mautrix-appservice-python state store and most mautrix-telegram
|
|
||||||
tables to SQLAlchemy core. This should speed things up and reduce problems
|
|
||||||
with the ORM getting stuck.
|
|
||||||
* `ensure_started` is now only called for logged in users, which should improve
|
|
||||||
performance for large instances.
|
|
||||||
* Displayname template extras (e.g. the `(Telegram)` suffix) are now stripped
|
|
||||||
when mentioning Telegram users with no username.
|
|
||||||
* Updated Telethon.
|
|
||||||
* Switched Dockerfile to use setup.py for dependencies to avoid dependency
|
|
||||||
updates breaking stuff.
|
|
||||||
* The telematrix import script will now warn about and skip over duplicate portals.
|
|
||||||
* Relaybot will now be used for users who have logged in, but are not in the chat.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Bug where stickers with an unidentified emoji failed to bridge.
|
|
||||||
* Invalid letter prefixes in clean-rooms output.
|
|
||||||
* Messages forwarded from channels showing up as "Unknown source".
|
|
||||||
* Matrix->Telegram room avatar bridging.
|
|
||||||
|
|
||||||
[@krombel]: https://github.com/krombel
|
|
||||||
[#271]: https://github.com/mautrix/telegram/pull/271
|
|
||||||
|
|
||||||
# v0.4.0 (2018-11-28)
|
|
||||||
|
|
||||||
* No changes since rc2.
|
|
||||||
|
|
||||||
## rc2 (2018-11-15)
|
|
||||||
|
|
||||||
* Fixed kicking Telegram puppets from Matrix.
|
|
||||||
|
|
||||||
## rc1 (2018-11-15)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Flag to indicate if user can unbridge portal in provisioning API
|
|
||||||
(thanks to [@turt2live] in [#225]).
|
|
||||||
* Option to send captions as second message (replaces option to send caption
|
|
||||||
in `body`.
|
|
||||||
* Room-specific settings.
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* (internal) Added type hints everywhere (mostly thanks to [@V02460] in [#206]).
|
|
||||||
* Telegram->Matrix formatter now uses `<pre>` tags for multiline code even if
|
|
||||||
said code was in the telegram equivalent of inline code tags.
|
|
||||||
* Better bullets and linebreak handling in Matrix->Telegram formatter.
|
|
||||||
* Logging in will now show your phone number instead of `@None` if you don't
|
|
||||||
have a username.
|
|
||||||
* Significantly improved performance on high-load instances (t2bot.io) by
|
|
||||||
moving most used database tables to SQLAlchemy Core.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* Bugs that caused database migrations to fail in some cases.
|
|
||||||
* Editing the config (e.g. whitelisting chats) corrupting the config.
|
|
||||||
* Negative numbers (chat IDs) in `/connect` of the provisioning API
|
|
||||||
(thanks to [@turt2live] in [#223]).
|
|
||||||
* Relaybot creating portals automatically when receiving message.
|
|
||||||
* Not being able to use a bridge bot localpart that would also match the puppet
|
|
||||||
localpart format.
|
|
||||||
* Matrix login sync failing completely if the homeserver stopped during a sync
|
|
||||||
response.
|
|
||||||
* Errors when cleaning rooms.
|
|
||||||
* Bridging code blocks without a language.
|
|
||||||
* Error and lost messages when trying to bridge PM from new users in some cases.
|
|
||||||
* Logging in with an account that someone has already logged in failing
|
|
||||||
silently and then breaking the bridge.
|
|
||||||
* Relaybot message when adding/removing Matrix displaynames.
|
|
||||||
|
|
||||||
[@V02460]: https://github.com/V02460
|
|
||||||
[#206]: https://github.com/mautrix/telegram/pull/206
|
|
||||||
[#223]: https://github.com/mautrix/telegram/pull/223
|
|
||||||
[#225]: https://github.com/mautrix/telegram/pull/225
|
|
||||||
|
|
||||||
# v0.3.0 (2018-08-15)
|
|
||||||
|
|
||||||
* Added database URI format examples.
|
|
||||||
* Bumped maximum Telethon version to 1.2, possibly fixing the catch_up option.
|
|
||||||
|
|
||||||
## rc3 (2018-08-08)
|
|
||||||
|
|
||||||
* Improved Telegram message deduplication options.
|
|
||||||
* Added pre-send message database check for deduplication.
|
|
||||||
* Made dedup cache queue length configurable.
|
|
||||||
|
|
||||||
## rc2 (2018-08-06)
|
|
||||||
|
|
||||||
* Added option to change max body size for AS API.
|
|
||||||
* Fixed a minor error regarding power level changes (thanks to [@turt2live] in [#203]).
|
|
||||||
* Updated minimum mautrix-appservice version to include some recent bugfixes.
|
|
||||||
|
|
||||||
[@turt2live]: https://github.com/turt2live
|
|
||||||
[#203]: https://github.com/mautrix/telegram/pull/203
|
|
||||||
|
|
||||||
## rc1 (2018-08-05)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
* Logging in with a bot
|
|
||||||
(see [docs](https://docs.mau.fi/bridges/python/telegram/authentication.html#bot-token) for usage).
|
|
||||||
* You can log in with a personal Telegram bot to appear almost like a real
|
|
||||||
user without logging in with a real Telegram account.
|
|
||||||
* Replacing your Telegram account's Matrix puppet with your Matrix account
|
|
||||||
(see [docs](https://docs.mau.fi/bridges/general/double-puppeting.html) for usage).
|
|
||||||
* Formatting options for relaybot messages.
|
|
||||||
* Real displaynames are now supported and enabled by default.
|
|
||||||
* State events (join/leave/name change) can be independently disabled by
|
|
||||||
setting the format to a blank string.
|
|
||||||
* New config sections
|
|
||||||
* Proper log config, including logging to file (by default)
|
|
||||||
* Proxy support (requires installing PySocks)
|
|
||||||
* Separate field for appservice address for homeserver
|
|
||||||
(useful if using a reverse proxy).
|
|
||||||
* New permission levels to allow initiating bridges without allowing puppeting
|
|
||||||
and to allow Telegram puppeting without allowing Matrix puppeting.
|
|
||||||
* Telematrix import script (see [docs](https://docs.mau.fi/bridges/python/telegram/migrating-from-telematrix.html) for usage).
|
|
||||||
* Provisioning API (see [docs](https://docs.mau.fi/bridges/python/telegram/provisioning-api.html) for more info).
|
|
||||||
* DBMS migration script (see [docs](https://docs.mau.fi/bridges/python/telegram/dbms-migration.html) for usage).
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
* Tabs are now replaced with 4 spaces so that Telegram servers wouldn't change
|
|
||||||
the message.
|
|
||||||
* Help page now detects your permissions and only shows commands you can use.
|
|
||||||
* Moved Matrix state cache to the main database. This means that the
|
|
||||||
`mx-state.json` file is no longer needed and all non-config data is
|
|
||||||
stored in the main database.
|
|
||||||
* Better lxml-based HTML parser for Matrix->Telegram formatting bridging.
|
|
||||||
lxml is still optional, so the old parser is used as fallback if lxml is not
|
|
||||||
installed.
|
|
||||||
* Disabled Telegram->Matrix bridging of messages sent by the relaybot.
|
|
||||||
Can be re-enabled in config if necessary.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
* A `ValueError` in some cases when syncing power levels.
|
|
||||||
* Telegram connections being created for unauthenticated users possibly
|
|
||||||
triggering spam protection connection delays in the Telegram servers.
|
|
||||||
* Logging out if a portal had been deleted/unbridged.
|
|
||||||
|
|
||||||
# v0.2.0 (2018-06-08)
|
|
||||||
|
|
||||||
* No changes since rc6.
|
|
||||||
|
|
||||||
## rc6 (2018-06-06)
|
|
||||||
|
|
||||||
* Added warning about `delete-portal` kicking all room members.
|
|
||||||
* Fixed error when upgrading/creating SQLite database.
|
|
||||||
|
|
||||||
## rc5 (2018-06-01)
|
|
||||||
|
|
||||||
* Fixed relaybot automatically creating portal rooms when invited to Telegram chat ([#145]).
|
|
||||||
* Fixed kicking Telegram puppets and fix error message when bridging chats you've left.
|
|
||||||
* Fixed integrity error deleting portals from database.
|
|
||||||
|
|
||||||
[#145]: https://github.com/mautrix/telegram/issues/145
|
|
||||||
|
|
||||||
## rc4 (2018-05-29)
|
|
||||||
|
|
||||||
* ~~Fixed~~ Added Postgres compatibility.
|
|
||||||
* Fixed manual bridging (`!tg bridge`) for unauthenticated users.
|
|
||||||
* Fixed inviting unauthenticated Matrix users from Telegram (via `/invite <mxid>`).
|
|
||||||
* Changed Alembic to read database path from the config, so editing `alembic.ini`
|
|
||||||
is no longer necessary. Use `alembic -x config=/path/to/config.yaml ...` to
|
|
||||||
specify the config path.
|
|
||||||
|
|
||||||
## rc3 (2018-05-25)
|
|
||||||
|
|
||||||
* Reworked Dockerfile to remove virtualenv and use Alpine packages (thanks to
|
|
||||||
[@jcgruenhage] in [#142]). This fixes webp->png conversion for stickers.
|
|
||||||
|
|
||||||
[#142]: https://github.com/mautrix/telegram/pull/142
|
|
||||||
|
|
||||||
## rc2 (2018-05-21)
|
|
||||||
|
|
||||||
* Added Dockerfile (thanks to [@jcgruenhage] in [#136]).
|
|
||||||
|
|
||||||
[#136]: https://github.com/mautrix/telegram/pull/136
|
|
||||||
[@jcgruenhage]: https://github.com/jcgruenhage
|
|
||||||
|
|
||||||
## rc1 (2018-05-19)
|
|
||||||
|
|
||||||
* Added
|
|
||||||
* Option to exclude telegram chats from being bridged.
|
|
||||||
* Support for using a relay bot to relay messages for unauthenticated users
|
|
||||||
* Bridging for message pinning and room mentions/pills.
|
|
||||||
* Matrix->Telegram sticker bridging.
|
|
||||||
* `!command` to `/command` conversion at the start of Matrix message text.
|
|
||||||
* Conversion of t.me message links to matrix.to message links
|
|
||||||
* Timestamp massaging (bridge Telegram timestamps to Matrix)
|
|
||||||
* Support for out-of-Matrix login (useful if you don't want your 2FA password to be stored in the homeserver)
|
|
||||||
* Optional HQ gif/video thumbnails using moviepy.
|
|
||||||
* Option to send bot messages as `m.notice`
|
|
||||||
* Improved deduplication
|
|
||||||
* Matrix file uploads are now reused if the same Telegram file (e.g. a sticker) is sent multiple times
|
|
||||||
* Room metadata changes and other non-message actions are now deduplicated
|
|
||||||
* Improved formatting bridging
|
|
||||||
* Improved Telegram user display name handling in cases where one or more users have set custom display names for other users.
|
|
||||||
* Fixed Alembic setup and removed automatic database generation.
|
|
||||||
* Fixed outgoing message deduplication in cases where message is sent to other clients before responding to the sender.
|
|
||||||
* Moved mautrix-appservice-python to separate repository.
|
|
||||||
* Switched to telethon-session-sqlalchemy to have the session databases in the main database.
|
|
||||||
* Switched license from GPLv3 to AGPLv3
|
|
||||||
* Probably a bunch of other stuff I forgot
|
|
||||||
|
|
||||||
# v0.1.1 (2018-02-18)
|
|
||||||
|
|
||||||
Fixed bridging formatted messages from Matrix to Telegram
|
|
||||||
|
|
||||||
# v0.1.0 (2018-02-17)
|
|
||||||
|
|
||||||
First release.
|
|
||||||
|
|
||||||
Things work.
|
|
||||||
+24
-40
@@ -1,61 +1,45 @@
|
|||||||
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.15
|
FROM docker.io/alpine:3.9
|
||||||
|
|
||||||
ARG TARGETARCH=amd64
|
ENV UID=1337 \
|
||||||
|
GID=1337 \
|
||||||
|
FFMPEG_BINARY=/usr/bin/ffmpeg
|
||||||
|
|
||||||
|
COPY . /opt/mautrix-telegram
|
||||||
|
WORKDIR /opt/mautrix-telegram
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
python3 py3-pip py3-setuptools py3-wheel \
|
|
||||||
py3-virtualenv \
|
py3-virtualenv \
|
||||||
py3-pillow \
|
py3-pillow \
|
||||||
py3-aiohttp \
|
py3-aiohttp \
|
||||||
|
py3-lxml \
|
||||||
py3-magic \
|
py3-magic \
|
||||||
py3-ruamel.yaml \
|
py3-sqlalchemy \
|
||||||
py3-commonmark \
|
py3-markdown \
|
||||||
py3-prometheus-client \
|
py3-psycopg2 \
|
||||||
|
# Not yet in stable repos:
|
||||||
|
#py3-ruamel \
|
||||||
# Indirect dependencies
|
# Indirect dependencies
|
||||||
py3-idna \
|
#commonmark
|
||||||
|
py3-future \
|
||||||
|
#alembic
|
||||||
|
py3-mako \
|
||||||
|
py3-dateutil \
|
||||||
|
py3-markupsafe \
|
||||||
#moviepy
|
#moviepy
|
||||||
py3-decorator \
|
py3-decorator \
|
||||||
py3-tqdm \
|
#py3-tqdm \
|
||||||
py3-requests \
|
py3-requests \
|
||||||
#imageio
|
#imageio
|
||||||
py3-numpy \
|
py3-numpy \
|
||||||
#py3-telethon \ (outdated)
|
#telethon
|
||||||
# Optional for socks proxies
|
py3-rsa \
|
||||||
py3-pysocks \
|
|
||||||
py3-pyaes \
|
|
||||||
# cryptg
|
|
||||||
py3-cffi \
|
|
||||||
py3-qrcode \
|
|
||||||
py3-brotli \
|
|
||||||
# Other dependencies
|
# Other dependencies
|
||||||
|
python3-dev \
|
||||||
|
build-base \
|
||||||
ffmpeg \
|
ffmpeg \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
su-exec \
|
su-exec \
|
||||||
netcat-openbsd \
|
&& pip3 install .[all]
|
||||||
# encryption
|
|
||||||
py3-olm \
|
|
||||||
py3-pycryptodome \
|
|
||||||
py3-unpaddedbase64 \
|
|
||||||
py3-future \
|
|
||||||
bash \
|
|
||||||
curl \
|
|
||||||
jq \
|
|
||||||
yq
|
|
||||||
|
|
||||||
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 -r requirements.txt -r optional-requirements.txt \
|
|
||||||
&& apk del .build-deps
|
|
||||||
|
|
||||||
COPY . /opt/mautrix-telegram
|
|
||||||
RUN apk add git && pip3 install .[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
|
|
||||||
|
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
ENV UID=1337 GID=1337 \
|
|
||||||
FFMPEG_BINARY=/usr/bin/ffmpeg
|
|
||||||
|
|
||||||
CMD ["/opt/mautrix-telegram/docker-run.sh"]
|
CMD ["/opt/mautrix-telegram/docker-run.sh"]
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
include README.md
|
|
||||||
include CHANGELOG.md
|
|
||||||
include LICENSE
|
|
||||||
include requirements.txt
|
|
||||||
include optional-requirements.txt
|
|
||||||
@@ -1,30 +1,9 @@
|
|||||||
# mautrix-telegram
|
# mautrix-telegram
|
||||||

|
|
||||||
[](LICENSE)
|
|
||||||
[](https://github.com/mautrix/telegram/releases)
|
|
||||||
[](https://mau.dev/mautrix/telegram/container_registry)
|
|
||||||
[](https://github.com/psf/black)
|
|
||||||
[](https://pycqa.github.io/isort/)
|
|
||||||
|
|
||||||
A Matrix-Telegram hybrid puppeting/relaybot bridge.
|
A Matrix-Telegram hybrid puppeting/relaybot bridge.
|
||||||
|
|
||||||
## Sponsors
|
### [Wiki](https://github.com/tulir/mautrix-telegram/wiki)
|
||||||
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
|
|
||||||
|
|
||||||
### Documentation
|
### [Features & Roadmap](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md)
|
||||||
All setup and usage instructions are located on
|
|
||||||
[docs.mau.fi](https://docs.mau.fi/bridges/python/telegram/index.html).
|
|
||||||
Some quick links:
|
|
||||||
|
|
||||||
* [Bridge setup](https://docs.mau.fi/bridges/python/setup/index.html?bridge=telegram)
|
|
||||||
(or [with Docker](https://docs.mau.fi/bridges/python/setup/docker.html?bridge=telegram))
|
|
||||||
* Basic usage: [Authentication](https://docs.mau.fi/bridges/python/telegram/authentication.html),
|
|
||||||
[Creating chats](https://docs.mau.fi/bridges/python/telegram/creating-and-managing-chats.html),
|
|
||||||
[Relaybot setup](https://docs.mau.fi/bridges/python/telegram/relay-bot.html)
|
|
||||||
|
|
||||||
### Features & Roadmap
|
|
||||||
[ROADMAP.md](https://github.com/mautrix/telegram/blob/master/ROADMAP.md)
|
|
||||||
contains a general overview of what is supported by the bridge.
|
|
||||||
|
|
||||||
## Discussion
|
## Discussion
|
||||||
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
|
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
|
||||||
@@ -32,4 +11,4 @@ Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net
|
|||||||
Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room)
|
Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room)
|
||||||
|
|
||||||
## Preview
|
## Preview
|
||||||

|

|
||||||
|
|||||||
+7
-13
@@ -3,8 +3,6 @@
|
|||||||
* Matrix → Telegram
|
* Matrix → Telegram
|
||||||
* [x] Message content (text, formatting, files, etc..)
|
* [x] Message content (text, formatting, files, etc..)
|
||||||
* [x] Message redactions
|
* [x] Message redactions
|
||||||
* [x] Message reactions
|
|
||||||
* [x] Message edits
|
|
||||||
* [ ] ‡ Message history
|
* [ ] ‡ Message history
|
||||||
* [x] Presence
|
* [x] Presence
|
||||||
* [x] Typing notifications
|
* [x] Typing notifications
|
||||||
@@ -13,8 +11,8 @@
|
|||||||
* [x] Power level
|
* [x] Power level
|
||||||
* [x] Normal chats
|
* [x] Normal chats
|
||||||
* [ ] Non-hardcoded PL requirements
|
* [ ] Non-hardcoded PL requirements
|
||||||
* [x] Supergroups/channels
|
* [x] Supergroups/channels
|
||||||
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
|
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
|
||||||
* [x] Membership actions (invite/kick/join/leave)
|
* [x] Membership actions (invite/kick/join/leave)
|
||||||
* [x] Room metadata changes (name, topic, avatar)
|
* [x] Room metadata changes (name, topic, avatar)
|
||||||
* [x] Initial room metadata
|
* [x] Initial room metadata
|
||||||
@@ -28,12 +26,8 @@
|
|||||||
* [x] Games
|
* [x] Games
|
||||||
* [ ] Buttons
|
* [ ] Buttons
|
||||||
* [x] Message deletions
|
* [x] Message deletions
|
||||||
* [x] Message reactions
|
|
||||||
* [x] Message edits
|
* [x] Message edits
|
||||||
* [x] Message history
|
* [ ] Message history
|
||||||
* [x] Manually (`!tg backfill`)
|
|
||||||
* [x] Automatically when creating portal
|
|
||||||
* [x] Automatically for missed messages
|
|
||||||
* [x] Avatars
|
* [x] Avatars
|
||||||
* [x] Presence
|
* [x] Presence
|
||||||
* [x] Typing notifications
|
* [x] Typing notifications
|
||||||
@@ -55,11 +49,11 @@
|
|||||||
* [x] At startup
|
* [x] At startup
|
||||||
* [x] When receiving invite or message
|
* [x] When receiving invite or message
|
||||||
* [x] Private chat creation by inviting Matrix puppet of Telegram user to new room
|
* [x] Private chat 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 bot to relay messages for unauthenticated Matrix users
|
||||||
* [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting)
|
* [x] Option to use own Matrix account for messages sent from other Telegram clients
|
||||||
* [ ] ‡ Calls (hard, not yet supported by Telethon)
|
* [ ] ‡ Calls (hard, not yet supported by Telethon)
|
||||||
* [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram)
|
* [ ] ‡ Secret chats (not yet supported by Telethon)
|
||||||
* [x] End-to-bridge encryption in Matrix rooms (see [docs](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html))
|
* [ ] ‡ E2EE in Matrix rooms (not yet supported
|
||||||
|
|
||||||
† Information not automatically sent from source, i.e. implementation may not be possible
|
† 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
|
‡ Maybe, i.e. this feature may or may not be implemented at some point
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts
|
||||||
|
script_location = alembic
|
||||||
|
|
||||||
|
# template used to generate migration files
|
||||||
|
# file_template = %%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# timezone to use when rendering the date
|
||||||
|
# within the migration file as well as the filename.
|
||||||
|
# string value is passed to dateutil.tz.gettz()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the
|
||||||
|
# "slug" field
|
||||||
|
#truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; this defaults
|
||||||
|
# to alembic/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path
|
||||||
|
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from os.path import abspath, dirname
|
||||||
|
|
||||||
|
sys.path.insert(0, dirname(dirname(abspath(__file__))))
|
||||||
|
|
||||||
|
from mautrix_telegram.db import Base
|
||||||
|
from mautrix_telegram.config import Config
|
||||||
|
from alchemysession import AlchemySessionContainer
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
|
||||||
|
mxtg_config = Config(mxtg_config_path, None, None)
|
||||||
|
mxtg_config.load()
|
||||||
|
config.set_main_option("sqlalchemy.url",
|
||||||
|
mxtg_config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeDB:
|
||||||
|
@staticmethod
|
||||||
|
def query_property():
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
AlchemySessionContainer.create_table_classes(FakeDB(), "telethon_", Base)
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
# from myapp import mymodel
|
||||||
|
# target_metadata = mymodel.Base.metadata
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url, target_metadata=target_metadata, literal_binds=True)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section),
|
||||||
|
prefix='sqlalchemy.',
|
||||||
|
poolclass=pool.NullPool)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add TelegramFile table
|
||||||
|
|
||||||
|
Revision ID: 1b241f7e8530
|
||||||
|
Revises: 97d2a942bcf8
|
||||||
|
Create Date: 2018-02-19 23:52:06.605741
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1b241f7e8530'
|
||||||
|
down_revision = '97d2a942bcf8'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table('telegram_file',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('mxc', sa.String(), nullable=True),
|
||||||
|
sa.Column('mime_type', sa.String(), nullable=True),
|
||||||
|
sa.Column('was_converted', sa.Boolean(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table('telegram_file')
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Add is_bot field to puppets
|
||||||
|
|
||||||
|
Revision ID: 1fa46383a9d3
|
||||||
|
Revises: 30eca60587f1
|
||||||
|
Create Date: 2018-04-29 23:44:40.102333
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '1fa46383a9d3'
|
||||||
|
down_revision = '30eca60587f1'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('puppet', sa.Column('is_bot', sa.Boolean(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("puppet") as batch_op:
|
||||||
|
batch_op.drop_column('is_bot')
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Add cascade rules to UserPortal
|
||||||
|
|
||||||
|
Revision ID: 2228d49c383f
|
||||||
|
Revises: bcfefa1f1299
|
||||||
|
Create Date: 2018-05-31 11:11:59.482112
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2228d49c383f'
|
||||||
|
down_revision = 'bcfefa1f1299'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
try:
|
||||||
|
with op.batch_alter_table("user_portal") as batch_op:
|
||||||
|
batch_op.drop_constraint("user_portal_user_fkey", type_="foreignkey")
|
||||||
|
batch_op.drop_constraint("user_portal_portal_fkey", type_="foreignkey")
|
||||||
|
batch_op.create_foreign_key("user_portal_user_fkey", "user", ["user"], ["tgid"],
|
||||||
|
onupdate="CASCADE", ondelete="CASCADE")
|
||||||
|
batch_op.create_foreign_key("user_portal_portal_fkey", "portal",
|
||||||
|
["portal", "portal_receiver"], ["tgid", "tg_receiver"],
|
||||||
|
onupdate="CASCADE", ondelete="CASCADE")
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
try:
|
||||||
|
with op.batch_alter_table("user_portal") as batch_op:
|
||||||
|
batch_op.drop_constraint("user_portal_user_fkey", type_="foreignkey")
|
||||||
|
batch_op.drop_constraint("user_portal_portal_fkey", type_="foreignkey")
|
||||||
|
batch_op.create_foreign_key("user_portal_user_fkey", "portal",
|
||||||
|
["portal", "portal_receiver"], ["tgid", "tg_receiver"])
|
||||||
|
batch_op.create_foreign_key("user_portal_portal_fkey", "user", ["user"], ["tgid"])
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Add megagroup field to portals
|
||||||
|
|
||||||
|
Revision ID: 30eca60587f1
|
||||||
|
Revises: cfc972368e50
|
||||||
|
Create Date: 2018-04-29 15:51:04.656605
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '30eca60587f1'
|
||||||
|
down_revision = 'cfc972368e50'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('portal', sa.Column('megagroup', sa.Boolean()))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("portal") as batch_op:
|
||||||
|
batch_op.drop_column('megagroup')
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""Move sessions to main database
|
||||||
|
|
||||||
|
Revision ID: 501dad2868bc
|
||||||
|
Revises: 7d47d84380b6
|
||||||
|
Create Date: 2018-03-02 19:15:53.826985
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '501dad2868bc'
|
||||||
|
down_revision = '7d47d84380b6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
Session = op.create_table('telethon_sessions',
|
||||||
|
sa.Column('session_id', sa.String, nullable=False),
|
||||||
|
sa.Column('dc_id', sa.Integer, nullable=False),
|
||||||
|
sa.Column('server_address', sa.String, nullable=True),
|
||||||
|
sa.Column('port', sa.Integer, nullable=True),
|
||||||
|
sa.Column('auth_key', sa.LargeBinary, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('session_id', 'dc_id'))
|
||||||
|
SentFile = op.create_table('telethon_sent_files',
|
||||||
|
sa.Column('session_id', sa.String, nullable=False),
|
||||||
|
sa.Column('md5_digest', sa.LargeBinary, nullable=False),
|
||||||
|
sa.Column('file_size', sa.Integer, nullable=False),
|
||||||
|
sa.Column('type', sa.Integer, nullable=False),
|
||||||
|
sa.Column('id', sa.BigInteger, nullable=True),
|
||||||
|
sa.Column('hash', sa.BigInteger, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('session_id', 'md5_digest', 'file_size',
|
||||||
|
'type'))
|
||||||
|
Entity = op.create_table('telethon_entities',
|
||||||
|
sa.Column('session_id', sa.String, nullable=False),
|
||||||
|
sa.Column('id', sa.Integer, nullable=False),
|
||||||
|
sa.Column('hash', sa.Integer, nullable=False),
|
||||||
|
sa.Column('username', sa.String, nullable=True),
|
||||||
|
sa.Column('phone', sa.Integer, nullable=True),
|
||||||
|
sa.Column('name', sa.String, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('session_id', 'id'))
|
||||||
|
Version = op.create_table('telethon_version',
|
||||||
|
sa.Column('version', sa.Integer, nullable=False),
|
||||||
|
sa.PrimaryKeyConstraint('version'))
|
||||||
|
conn = op.get_bind()
|
||||||
|
sessions = [os.path.basename(f) for f in os.listdir(".") if f.endswith(".session")]
|
||||||
|
for session in sessions:
|
||||||
|
session_to_sqlalchemy(conn, session, Session, SentFile, Entity)
|
||||||
|
|
||||||
|
|
||||||
|
def session_to_sqlalchemy(conn, path, Session, SentFile, Entity):
|
||||||
|
session_conn = sqlite3.connect(path)
|
||||||
|
session_id = os.path.splitext(path)[0]
|
||||||
|
c = session_conn.cursor()
|
||||||
|
|
||||||
|
auth_data_tuples = c.execute("SELECT * FROM sessions").fetchall()
|
||||||
|
auth_data_dicts = []
|
||||||
|
for row in auth_data_tuples:
|
||||||
|
dc_id, server_address, port, auth_key = row
|
||||||
|
auth_data_dicts.append({
|
||||||
|
"session_id": session_id,
|
||||||
|
"dc_id": dc_id,
|
||||||
|
"server_address": server_address,
|
||||||
|
"port": port,
|
||||||
|
"auth_key": auth_key,
|
||||||
|
})
|
||||||
|
if auth_data_dicts:
|
||||||
|
conn.execute(Session.insert().values(auth_data_dicts))
|
||||||
|
|
||||||
|
sent_file_tuples = c.execute("SELECT * FROM sent_files").fetchall()
|
||||||
|
sent_file_dicts = []
|
||||||
|
for row in sent_file_tuples:
|
||||||
|
md5_digest, file_size, type, id, hash = row
|
||||||
|
sent_file_dicts.append({
|
||||||
|
"session_id": session_id,
|
||||||
|
"md5_digest": md5_digest,
|
||||||
|
"file_size": file_size,
|
||||||
|
"type": type,
|
||||||
|
"id": id,
|
||||||
|
"hash": hash,
|
||||||
|
})
|
||||||
|
if sent_file_dicts:
|
||||||
|
conn.execute(SentFile.insert().values(sent_file_dicts))
|
||||||
|
|
||||||
|
entity_tuples = c.execute("SELECT * FROM entities").fetchall()
|
||||||
|
entity_dicts = []
|
||||||
|
for row in entity_tuples:
|
||||||
|
id, hash, username, phone, name = row
|
||||||
|
entity_dicts.append({
|
||||||
|
"session_id": session_id,
|
||||||
|
"id": id,
|
||||||
|
"hash": hash,
|
||||||
|
"username": username,
|
||||||
|
"phone": phone,
|
||||||
|
"name": name,
|
||||||
|
})
|
||||||
|
if entity_dicts:
|
||||||
|
conn.execute(Entity.insert().values(entity_dicts))
|
||||||
|
|
||||||
|
c.close()
|
||||||
|
session_conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table('telethon_version')
|
||||||
|
op.drop_table('telethon_entities')
|
||||||
|
op.drop_table('telethon_sent_files')
|
||||||
|
op.drop_table('telethon_sessions')
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Move state store to main database
|
||||||
|
|
||||||
|
Revision ID: 6ca3d74d51e4
|
||||||
|
Revises: 2228d49c383f
|
||||||
|
Create Date: 2018-06-26 21:31:26.911307
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import context, op
|
||||||
|
import sqlalchemy.orm as orm
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
from mautrix_telegram.config import Config
|
||||||
|
from mautrix_telegram.db import Base
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "6ca3d74d51e4"
|
||||||
|
down_revision = "2228d49c383f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoomState(Base):
|
||||||
|
query = None
|
||||||
|
__tablename__ = "mx_room_state"
|
||||||
|
__table_args__ = {"extend_existing": True}
|
||||||
|
|
||||||
|
room_id = sa.Column(sa.String, primary_key=True)
|
||||||
|
power_levels = sa.Column("power_levels", sa.Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(Base):
|
||||||
|
query = None
|
||||||
|
__tablename__ = "mx_user_profile"
|
||||||
|
__table_args__ = {"extend_existing": True}
|
||||||
|
|
||||||
|
room_id = sa.Column(sa.String, primary_key=True)
|
||||||
|
user_id = sa.Column(sa.String, primary_key=True)
|
||||||
|
membership = sa.Column(sa.String, nullable=False, default="leave")
|
||||||
|
displayname = sa.Column(sa.String, nullable=True)
|
||||||
|
avatar_url = sa.Column(sa.String, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Puppet(Base):
|
||||||
|
query = None
|
||||||
|
__tablename__ = "puppet"
|
||||||
|
__table_args__ = {"extend_existing": True}
|
||||||
|
|
||||||
|
id = sa.Column(sa.Integer, primary_key=True)
|
||||||
|
displayname = sa.Column(sa.String, nullable=True)
|
||||||
|
displayname_source = sa.Column(sa.Integer, nullable=True)
|
||||||
|
username = sa.Column(sa.String, nullable=True)
|
||||||
|
photo_id = sa.Column(sa.String, nullable=True)
|
||||||
|
is_bot = sa.Column(sa.Boolean, nullable=True)
|
||||||
|
matrix_registered = sa.Column(sa.Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("puppet", sa.Column("matrix_registered", sa.Boolean(), nullable=False,
|
||||||
|
server_default=sa.sql.expression.false()))
|
||||||
|
op.create_table("mx_room_state",
|
||||||
|
sa.Column("room_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("power_levels", sa.Text(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("room_id"))
|
||||||
|
op.create_table("mx_user_profile",
|
||||||
|
sa.Column("room_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(), nullable=False),
|
||||||
|
sa.Column("membership", sa.String(), nullable=False,
|
||||||
|
default="leave"),
|
||||||
|
sa.Column("displayname", sa.String(), nullable=True),
|
||||||
|
sa.Column("avatar_url", sa.String(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("room_id", "user_id"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
migrate_state_store()
|
||||||
|
except Exception as e:
|
||||||
|
print("Failed to migrate state store:", e)
|
||||||
|
print("Migrating the state store isn't required, but you can retry by alembic downgrading "
|
||||||
|
"to revision 2228d49c383f and upgrading again.")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_state_store():
|
||||||
|
conn = op.get_bind()
|
||||||
|
session = orm.sessionmaker(bind=conn)() # type: orm.Session
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open("mx-state.json") as file:
|
||||||
|
data = json.load(file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
registrations = data.get("registrations", [])
|
||||||
|
|
||||||
|
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
|
||||||
|
mxtg_config = Config(mxtg_config_path, None, None)
|
||||||
|
mxtg_config.load()
|
||||||
|
|
||||||
|
username_template = mxtg_config.get("bridge.username_template", "telegram_{userid}")
|
||||||
|
hs_domain = mxtg_config["homeserver.domain"]
|
||||||
|
localpart = username_template.format(userid="(.+)")
|
||||||
|
mxid_regex = re.compile("@{}:{}".format(localpart, hs_domain))
|
||||||
|
for user in registrations:
|
||||||
|
match = mxid_regex.match(user)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
|
||||||
|
puppet = session.query(Puppet).get(match.group(1))
|
||||||
|
if not puppet:
|
||||||
|
continue
|
||||||
|
|
||||||
|
puppet.matrix_registered = True
|
||||||
|
session.merge(puppet)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
user_profiles = [UserProfile(room_id=room, user_id=user,
|
||||||
|
membership=member.get("membership", "leave"),
|
||||||
|
displayname=member.get("displayname", None),
|
||||||
|
avatar_url=member.get("avatar_url", None))
|
||||||
|
for room, members in data.get("members", {}).items()
|
||||||
|
for user, member in members.items()]
|
||||||
|
session.add_all(user_profiles)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
room_state = [RoomState(room_id=room, power_levels=json.dumps(levels))
|
||||||
|
for room, levels in data.get("power_levels", {}).items()]
|
||||||
|
session.add_all(room_state)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("mx_user_profile")
|
||||||
|
op.drop_table("mx_room_state")
|
||||||
|
with op.batch_alter_table("puppet") as batch_op:
|
||||||
|
batch_op.drop_column("matrix_registered")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Add timestamp to TelegramFile
|
||||||
|
|
||||||
|
Revision ID: 7d47d84380b6
|
||||||
|
Revises: 1b241f7e8530
|
||||||
|
Create Date: 2018-02-19 23:53:18.050871
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '7d47d84380b6'
|
||||||
|
down_revision = '1b241f7e8530'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('telegram_file',
|
||||||
|
sa.Column('timestamp', sa.BigInteger(), nullable=True, default=0,
|
||||||
|
server_default="0"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("telegram_file") as batch_op:
|
||||||
|
batch_op.drop_column('timestamp')
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""initial revision
|
||||||
|
|
||||||
|
Revision ID: 97d2a942bcf8
|
||||||
|
Revises:
|
||||||
|
Create Date: 2018-02-11 18:40:55.483842
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '97d2a942bcf8'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table('portal',
|
||||||
|
sa.Column('tgid', sa.Integer),
|
||||||
|
sa.Column('tg_receiver', sa.Integer),
|
||||||
|
sa.Column('peer_type', sa.String, nullable=False, default=""),
|
||||||
|
sa.Column('mxid', sa.String, nullable=True),
|
||||||
|
sa.Column('username', sa.String, nullable=True),
|
||||||
|
sa.Column('title', sa.String, nullable=True),
|
||||||
|
sa.Column('about', sa.String, nullable=True),
|
||||||
|
sa.Column('photo_id', sa.String, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('tgid', 'tg_receiver'),
|
||||||
|
sa.UniqueConstraint('mxid'))
|
||||||
|
op.create_table('user',
|
||||||
|
sa.Column('mxid', sa.String),
|
||||||
|
sa.Column('tgid', sa.Integer, nullable=True, unique=True),
|
||||||
|
sa.Column('tg_username', sa.String, nullable=True),
|
||||||
|
sa.Column('saved_contacts', sa.Integer, nullable=False, default=0),
|
||||||
|
sa.PrimaryKeyConstraint('mxid'))
|
||||||
|
op.create_table('puppet',
|
||||||
|
sa.Column('id', sa.Integer),
|
||||||
|
sa.Column('displayname', sa.String, nullable=True),
|
||||||
|
sa.Column('username', sa.String, nullable=True),
|
||||||
|
sa.Column('photo_id', sa.String, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id'))
|
||||||
|
op.create_table('contact',
|
||||||
|
sa.Column('user', sa.Integer),
|
||||||
|
sa.Column('contact', sa.Integer),
|
||||||
|
sa.ForeignKeyConstraint(("user",), ("user.tgid",)),
|
||||||
|
sa.ForeignKeyConstraint(("contact",), ("puppet.id",)),
|
||||||
|
sa.PrimaryKeyConstraint('user', 'contact'))
|
||||||
|
op.create_table('user_portal',
|
||||||
|
sa.Column('user', sa.Integer),
|
||||||
|
sa.Column('portal', sa.Integer),
|
||||||
|
sa.Column('portal_receiver', sa.Integer),
|
||||||
|
sa.PrimaryKeyConstraint('user', 'portal', 'portal_receiver'),
|
||||||
|
sa.ForeignKeyConstraint(("user",), ("user.tgid",),
|
||||||
|
name="user_portal_user_fkey",
|
||||||
|
onupdate="CASCADE", ondelete="CASCADE"),
|
||||||
|
sa.ForeignKeyConstraint(("portal", "portal_receiver"),
|
||||||
|
("portal.tgid", "portal.tg_receiver"),
|
||||||
|
name="user_portal_portal_fkey",
|
||||||
|
onupdate="CASCADE", ondelete="CASCADE"))
|
||||||
|
op.create_table('message',
|
||||||
|
sa.Column('mxid', sa.String),
|
||||||
|
sa.Column('mx_room', sa.String),
|
||||||
|
sa.Column('tgid', sa.Integer),
|
||||||
|
sa.Column('tg_space', sa.Integer),
|
||||||
|
sa.PrimaryKeyConstraint('tgid', 'tg_space'),
|
||||||
|
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"))
|
||||||
|
op.create_table('bot_chat',
|
||||||
|
sa.Column('id', sa.Integer),
|
||||||
|
sa.Column('type', sa.String, nullable=False, default=""),
|
||||||
|
sa.PrimaryKeyConstraint('id'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table('bot_chat')
|
||||||
|
op.drop_table('message')
|
||||||
|
op.drop_table('user_portal')
|
||||||
|
op.drop_table('contact')
|
||||||
|
op.drop_table('puppet')
|
||||||
|
op.drop_table('user')
|
||||||
|
op.drop_table('portal')
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Add phone number field to users
|
||||||
|
|
||||||
|
Revision ID: a9119be92164
|
||||||
|
Revises: b54929c22c86
|
||||||
|
Create Date: 2018-09-28 02:38:40.626282
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "a9119be92164"
|
||||||
|
down_revision = "b54929c22c86"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("user", sa.Column("tg_phone", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("user") as batch_op:
|
||||||
|
batch_op.drop_column("tg_phone")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Add portal-specific config
|
||||||
|
|
||||||
|
Revision ID: b54929c22c86
|
||||||
|
Revises: d5f7b8b4b456
|
||||||
|
Create Date: 2018-09-24 23:40:33.528710
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "b54929c22c86"
|
||||||
|
down_revision = "d5f7b8b4b456"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("portal", sa.Column("config", sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("portal") as batch_op:
|
||||||
|
batch_op.drop_column("config")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Add displayname source fields for puppets
|
||||||
|
|
||||||
|
Revision ID: bcfefa1f1299
|
||||||
|
Revises: bdadd173ee02
|
||||||
|
Create Date: 2018-05-19 17:00:21.078098
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'bcfefa1f1299'
|
||||||
|
down_revision = 'bdadd173ee02'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column('puppet', sa.Column('displayname_source', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("puppet") as batch_op:
|
||||||
|
batch_op.drop_column('displayname_source')
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Update telethon update state table
|
||||||
|
|
||||||
|
Revision ID: bdadd173ee02
|
||||||
|
Revises: eeaf0dae87ce
|
||||||
|
Create Date: 2018-05-13 10:42:59.395597
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'bdadd173ee02'
|
||||||
|
down_revision = 'eeaf0dae87ce'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
with op.batch_alter_table("telethon_entities") as batch_op:
|
||||||
|
batch_op.alter_column("id", existing_type=sa.Integer, type_=sa.BigInteger)
|
||||||
|
batch_op.alter_column("hash", existing_type=sa.Integer, type_=sa.BigInteger)
|
||||||
|
|
||||||
|
with op.batch_alter_table("telethon_update_state") as batch_op:
|
||||||
|
batch_op.alter_column("entity_id", existing_type=sa.Integer, type_=sa.BigInteger)
|
||||||
|
batch_op.alter_column("pts", existing_type=sa.Integer, type_=sa.BigInteger)
|
||||||
|
batch_op.alter_column("qts", existing_type=sa.Integer, type_=sa.BigInteger)
|
||||||
|
batch_op.alter_column("date", existing_type=sa.Integer, type_=sa.BigInteger)
|
||||||
|
batch_op.alter_column("seq", existing_type=sa.Integer, type_=sa.BigInteger)
|
||||||
|
batch_op.add_column(sa.Column("unread_count", sa.Integer))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("telethon_entities") as batch_op:
|
||||||
|
batch_op.alter_column("id", existing_type=sa.BigInteger, type_=sa.Integer)
|
||||||
|
batch_op.alter_column("hash", existing_type=sa.BigInteger, type_=sa.Integer)
|
||||||
|
|
||||||
|
with op.batch_alter_table("telethon_update_state") as batch_op:
|
||||||
|
batch_op.alter_column("entity_id", existing_type=sa.BigInteger, type_=sa.Integer)
|
||||||
|
batch_op.alter_column("pts", existing_type=sa.BigInteger, type_=sa.Integer)
|
||||||
|
batch_op.alter_column("qts", existing_type=sa.BigInteger, type_=sa.Integer)
|
||||||
|
batch_op.alter_column("date", existing_type=sa.BigInteger, type_=sa.Integer)
|
||||||
|
batch_op.alter_column("seq", existing_type=sa.BigInteger, type_=sa.Integer)
|
||||||
|
batch_op.drop_column("unread_count")
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Add metadata to TelegramFile
|
||||||
|
|
||||||
|
Revision ID: cfc972368e50
|
||||||
|
Revises: 501dad2868bc
|
||||||
|
Create Date: 2018-03-09 16:07:01.236712
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'cfc972368e50'
|
||||||
|
down_revision = '501dad2868bc'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
with op.batch_alter_table("telegram_file") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('size', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('width', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('height', sa.Integer(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('thumbnail', sa.String(), nullable=True))
|
||||||
|
batch_op.create_foreign_key(constraint_name="fk_file_thumbnail",
|
||||||
|
referent_table="telegram_file",
|
||||||
|
local_cols=['thumbnail'],
|
||||||
|
remote_cols=['id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("telegram_file") as batch_op:
|
||||||
|
batch_op.drop_column('size')
|
||||||
|
batch_op.drop_column('width')
|
||||||
|
batch_op.drop_column('height')
|
||||||
|
batch_op.drop_column('thumbnail')
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Add access_token and custom_mxid fields for puppets
|
||||||
|
|
||||||
|
Revision ID: d5f7b8b4b456
|
||||||
|
Revises: 6ca3d74d51e4
|
||||||
|
Create Date: 2018-07-20 12:09:30.277960
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "d5f7b8b4b456"
|
||||||
|
down_revision = "6ca3d74d51e4"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.add_column("puppet", sa.Column("access_token", sa.String(), nullable=True))
|
||||||
|
op.add_column("puppet", sa.Column("custom_mxid", sa.String(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("puppet") as batch_op:
|
||||||
|
batch_op.drop_column("custom_mxid")
|
||||||
|
batch_op.drop_column("access_token")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Add telethon update state table
|
||||||
|
|
||||||
|
Revision ID: eeaf0dae87ce
|
||||||
|
Revises: 1fa46383a9d3
|
||||||
|
Create Date: 2018-04-30 17:30:59.610885
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'eeaf0dae87ce'
|
||||||
|
down_revision = '1fa46383a9d3'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
with op.batch_alter_table("telethon_entities") as batch_op:
|
||||||
|
batch_op.alter_column('phone', existing_type=sa.Integer, type_=sa.BigInteger)
|
||||||
|
op.create_table('telethon_update_state',
|
||||||
|
sa.Column('session_id', sa.String, nullable=False),
|
||||||
|
sa.Column('entity_id', sa.Integer, nullable=False),
|
||||||
|
sa.Column('pts', sa.Integer, nullable=True),
|
||||||
|
sa.Column('qts', sa.Integer, nullable=True),
|
||||||
|
sa.Column('date', sa.Integer, nullable=True),
|
||||||
|
sa.Column('seq', sa.Integer, nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('session_id', 'entity_id'))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
with op.batch_alter_table("telethon_entities") as batch_op:
|
||||||
|
batch_op.alter_column('phone', existing_type=sa.BigInteger, type_=sa.Integer)
|
||||||
|
op.drop_table('telethon_update_state')
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pre-commit>=2.10.1,<3
|
|
||||||
isort>=5.10.1,<6
|
|
||||||
black==22.1.0
|
|
||||||
@@ -7,6 +7,15 @@ function fixperms {
|
|||||||
|
|
||||||
cd /opt/mautrix-telegram
|
cd /opt/mautrix-telegram
|
||||||
|
|
||||||
|
# Replace database path in config.
|
||||||
|
sed -i "s#sqlite:///mautrix-telegram.db#sqlite:////data/mautrix-telegram.db#" /data/config.yaml
|
||||||
|
|
||||||
|
if [ -f /data/mx-state.json ]; then
|
||||||
|
ln -s /data/mx-state.json
|
||||||
|
fi
|
||||||
|
# Check that database is in the right state
|
||||||
|
alembic -x config=/data/config.yaml upgrade head
|
||||||
|
|
||||||
if [ ! -f /data/config.yaml ]; then
|
if [ ! -f /data/config.yaml ]; then
|
||||||
cp example-config.yaml /data/config.yaml
|
cp example-config.yaml /data/config.yaml
|
||||||
echo "Didn't find a config file."
|
echo "Didn't find a config file."
|
||||||
@@ -19,6 +28,9 @@ fi
|
|||||||
|
|
||||||
if [ ! -f /data/registration.yaml ]; then
|
if [ ! -f /data/registration.yaml ]; then
|
||||||
python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml
|
python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml
|
||||||
|
echo "Didn't find a registration file."
|
||||||
|
echo "Generated one for you."
|
||||||
|
echo "Copy that over to synapses app service directory."
|
||||||
fixperms
|
fixperms
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# Homeserver details
|
||||||
|
homeserver:
|
||||||
|
# The address that this appservice can use to connect to the homeserver.
|
||||||
|
address: https://matrix.org
|
||||||
|
# The domain of the homeserver (for MXIDs, etc).
|
||||||
|
domain: matrix.org
|
||||||
|
# Whether or not to verify the SSL certificate of the homeserver.
|
||||||
|
# Only applies if address starts with https://
|
||||||
|
verify_ssl: true
|
||||||
|
|
||||||
|
# Application service host/registration related details
|
||||||
|
# Changing these values requires regeneration of the registration.
|
||||||
|
appservice:
|
||||||
|
# The address that the homeserver can use to connect to this appservice.
|
||||||
|
address: http://localhost:8080
|
||||||
|
|
||||||
|
# The hostname and port where this appservice should listen.
|
||||||
|
hostname: 0.0.0.0
|
||||||
|
port: 8080
|
||||||
|
# The maximum body size of appservice API requests (from the homeserver) in mebibytes
|
||||||
|
# Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
|
||||||
|
max_body_size: 1
|
||||||
|
|
||||||
|
# The full URI to the database. SQLite and Postgres are fully supported.
|
||||||
|
# Other DBMSes supported by SQLAlchemy may or may not work.
|
||||||
|
# Format examples:
|
||||||
|
# SQLite: sqlite:///filename.db
|
||||||
|
# Postgres: postgres://username:password@hostname/dbname
|
||||||
|
database: sqlite:///mautrix-telegram.db
|
||||||
|
|
||||||
|
# Public part of web server for out-of-Matrix interaction with the bridge.
|
||||||
|
# Used for things like login if the user wants to make sure the 2FA password isn't stored in
|
||||||
|
# the HS database.
|
||||||
|
public:
|
||||||
|
# Whether or not the public-facing endpoints should be enabled.
|
||||||
|
enabled: true
|
||||||
|
# The prefix to use in the public-facing endpoints.
|
||||||
|
prefix: /public
|
||||||
|
# The base URL where the public-facing endpoints are available. The prefix is not added
|
||||||
|
# implicitly.
|
||||||
|
external: https://example.com/public
|
||||||
|
|
||||||
|
# Provisioning API part of the web server for automated portal creation and fetching information.
|
||||||
|
# Used by things like Dimension (https://dimension.t2bot.io/).
|
||||||
|
provisioning:
|
||||||
|
# Whether or not the provisioning API should be enabled.
|
||||||
|
enabled: true
|
||||||
|
# The prefix to use in the provisioning API endpoints.
|
||||||
|
prefix: /_matrix/provision/v1
|
||||||
|
# The shared secret to authorize users of the API.
|
||||||
|
# Set to "generate" to generate and save a new token.
|
||||||
|
shared_secret: generate
|
||||||
|
|
||||||
|
# The unique ID of this appservice.
|
||||||
|
id: telegram
|
||||||
|
# Username of the appservice bot.
|
||||||
|
bot_username: telegrambot
|
||||||
|
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
|
||||||
|
# to leave display name/avatar as-is.
|
||||||
|
bot_displayname: Telegram bridge bot
|
||||||
|
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
|
||||||
|
|
||||||
|
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
|
||||||
|
as_token: "This value is generated when generating the registration"
|
||||||
|
hs_token: "This value is generated when generating the registration"
|
||||||
|
|
||||||
|
# Bridge config
|
||||||
|
bridge:
|
||||||
|
# Localpart template of MXIDs for Telegram users.
|
||||||
|
# {userid} is replaced with the user ID of the Telegram user.
|
||||||
|
username_template: "telegram_{userid}"
|
||||||
|
# Localpart template of room aliases for Telegram portal rooms.
|
||||||
|
# {groupname} is replaced with the name part of the public channel/group invite link ( https://t.me/{} )
|
||||||
|
alias_template: "telegram_{groupname}"
|
||||||
|
# Displayname template for Telegram users.
|
||||||
|
# {displayname} is replaced with the display name of the Telegram user.
|
||||||
|
displayname_template: "{displayname} (Telegram)"
|
||||||
|
|
||||||
|
# Set the preferred order of user identifiers which to use in the Matrix puppet display name.
|
||||||
|
# In the (hopefully unlikely) scenario that none of the given keys are found, the numeric user
|
||||||
|
# ID is used.
|
||||||
|
#
|
||||||
|
# If the bridge is working properly, a phone number or an username should always be known, but
|
||||||
|
# the other one can very well be empty.
|
||||||
|
#
|
||||||
|
# Valid keys:
|
||||||
|
# "full name" (First and/or last name)
|
||||||
|
# "full name reversed" (Last and/or first name)
|
||||||
|
# "first name"
|
||||||
|
# "last name"
|
||||||
|
# "username"
|
||||||
|
# "phone number"
|
||||||
|
displayname_preference:
|
||||||
|
- full name
|
||||||
|
- username
|
||||||
|
- phone number
|
||||||
|
|
||||||
|
# Maximum number of members to sync per portal when starting up. Other members will be
|
||||||
|
# synced when they send messages. The maximum is 10000, after which the Telegram server
|
||||||
|
# will not send any more members.
|
||||||
|
# Defaults to no local limit (-> limited to 10000 by server)
|
||||||
|
max_initial_member_sync: -1
|
||||||
|
# Whether or not to sync the member list in channels.
|
||||||
|
# If no channel admins have logged into the bridge, the bridge won't be able to sync the member
|
||||||
|
# list regardless of this setting.
|
||||||
|
sync_channel_members: true
|
||||||
|
# Whether or not to skip deleted members when syncing members.
|
||||||
|
skip_deleted_members: true
|
||||||
|
# Whether or not to automatically synchronize contacts and chats of Matrix users logged into
|
||||||
|
# their Telegram account at startup.
|
||||||
|
startup_sync: true
|
||||||
|
# Number of most recently active dialogs to check when syncing chats.
|
||||||
|
# Dialogs include groups and private chats, but only groups are synced.
|
||||||
|
# Set to 0 to remove limit.
|
||||||
|
sync_dialog_limit: 30
|
||||||
|
# The maximum number of simultaneous Telegram deletions to handle.
|
||||||
|
# A large number of simultaneous redactions could put strain on your homeserver.
|
||||||
|
max_telegram_delete: 10
|
||||||
|
# Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames)
|
||||||
|
# at startup and when creating a bridge.
|
||||||
|
sync_matrix_state: true
|
||||||
|
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix
|
||||||
|
# login website (see appservice.public config section)
|
||||||
|
allow_matrix_login: true
|
||||||
|
# Whether or not to bridge plaintext highlights.
|
||||||
|
# Only enable this if your displayname_template has some static part that the bridge can use to
|
||||||
|
# reliably identify what is a plaintext highlight.
|
||||||
|
plaintext_highlights: false
|
||||||
|
# Show message editing as a reply to the original message.
|
||||||
|
# If this is false, message edits are not shown at all, as Matrix does not support editing yet.
|
||||||
|
edits_as_replies: true
|
||||||
|
# Highlight changed/added parts in edits. Requires lxml.
|
||||||
|
highlight_edits: false
|
||||||
|
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
|
||||||
|
public_portals: true
|
||||||
|
# Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down.
|
||||||
|
# Currently only works for private chats and normal groups.
|
||||||
|
catch_up: false
|
||||||
|
# Whether or not to use /sync to get presence, read receipts and typing notifications when using
|
||||||
|
# your own Matrix account as the Matrix puppet for your Telegram account.
|
||||||
|
sync_with_custom_puppets: true
|
||||||
|
# Set to false to disable link previews in messages sent to Telegram.
|
||||||
|
telegram_link_preview: true
|
||||||
|
# Use inline images instead of a separate message for the caption.
|
||||||
|
# N.B. Inline images are not supported on all clients (e.g. Riot iOS).
|
||||||
|
inline_images: false
|
||||||
|
# Maximum size of image in megabytes before sending to Telegram as a document.
|
||||||
|
image_as_file_size: 10
|
||||||
|
|
||||||
|
# Whether to bridge Telegram bot messages as m.notices or m.texts.
|
||||||
|
bot_messages_as_notices: true
|
||||||
|
bridge_notices:
|
||||||
|
# Whether or not Matrix bot messages (type m.notice) should be bridged.
|
||||||
|
default: false
|
||||||
|
# List of user IDs for whom the previous flag is flipped.
|
||||||
|
# e.g. if bridge_notices.default is false, notices from other users will not be bridged, but
|
||||||
|
# notices from users listed here will be bridged.
|
||||||
|
exceptions:
|
||||||
|
- "@importantbot:example.com"
|
||||||
|
|
||||||
|
# Some config options related to Telegram message deduplication.
|
||||||
|
# The default values are usually fine, but some debug messages/warnings might recommend you
|
||||||
|
# change these.
|
||||||
|
deduplication:
|
||||||
|
# Whether or not to check the database if the message about to be sent is a duplicate.
|
||||||
|
pre_db_check: false
|
||||||
|
# The number of latest events to keep when checking for duplicates.
|
||||||
|
# You might need to increase this on high-traffic bridge instances.
|
||||||
|
cache_queue_length: 20
|
||||||
|
|
||||||
|
|
||||||
|
# The formats to use when sending messages to Telegram via the relay bot.
|
||||||
|
#
|
||||||
|
# Telegram doesn't have built-in emotes, so the m.emote format is also used for non-relaybot users.
|
||||||
|
#
|
||||||
|
# Available variables:
|
||||||
|
# $sender_displayname - The display name of the sender (e.g. Example User)
|
||||||
|
# $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
|
||||||
|
# $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
|
||||||
|
# $message - The message content as HTML
|
||||||
|
message_formats:
|
||||||
|
m.text: "<b>$sender_displayname</b>: $message"
|
||||||
|
m.emote: "* <b>$sender_displayname</b> $message"
|
||||||
|
m.file: "<b>$sender_displayname</b> sent a file: $message"
|
||||||
|
m.image: "<b>$sender_displayname</b> sent an image: $message"
|
||||||
|
m.audio: "<b>$sender_displayname</b> sent an audio file: $message"
|
||||||
|
m.video: "<b>$sender_displayname</b> sent a video: $message"
|
||||||
|
m.location: "<b>$sender_displayname</b> sent a location: $message"
|
||||||
|
|
||||||
|
# The formats to use when sending state events to Telegram via the relay bot.
|
||||||
|
#
|
||||||
|
# Variables from `message_formats` that have the `sender_` prefix are available without the prefix.
|
||||||
|
# In name_change events, `$prev_displayname` is the previous displayname.
|
||||||
|
#
|
||||||
|
# Set format to an empty string to disable the messages for that event.
|
||||||
|
state_event_formats:
|
||||||
|
join: "<b>$displayname</b> joined the room."
|
||||||
|
leave: "<b>$displayname</b> left the room."
|
||||||
|
name_change: "<b>$prev_displayname</b> changed their name to <b>$displayname</b>"
|
||||||
|
|
||||||
|
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
|
||||||
|
# `filter-mode` management commands.
|
||||||
|
#
|
||||||
|
# Filters do not affect direct chats.
|
||||||
|
# An empty blacklist will essentially disable the filter.
|
||||||
|
filter:
|
||||||
|
# Filter mode to use. Either "blacklist" or "whitelist".
|
||||||
|
# If the mode is "blacklist", the listed chats will never be bridged.
|
||||||
|
# If the mode is "whitelist", only the listed chats can be bridged.
|
||||||
|
mode: blacklist
|
||||||
|
# The list of group/channel IDs to filter.
|
||||||
|
list: []
|
||||||
|
|
||||||
|
# The prefix for commands. Only required in non-management rooms.
|
||||||
|
command_prefix: "!tg"
|
||||||
|
|
||||||
|
# Permissions for using the bridge.
|
||||||
|
# Permitted values:
|
||||||
|
# relaybot - Only use the bridge via the relaybot, no access to commands.
|
||||||
|
# user - Relaybot level + access to commands to create bridges.
|
||||||
|
# puppeting - User level + logging in with a Telegram account.
|
||||||
|
# full - Full access to use the bridge, i.e. previous levels + Matrix login.
|
||||||
|
# admin - Full access to use the bridge and some extra administration commands.
|
||||||
|
# Permitted keys:
|
||||||
|
# * - All Matrix users
|
||||||
|
# domain - All users on that homeserver
|
||||||
|
# mxid - Specific user
|
||||||
|
permissions:
|
||||||
|
"*": "relaybot"
|
||||||
|
"public.example.com": "user"
|
||||||
|
"example.com": "full"
|
||||||
|
"@admin:example.com": "admin"
|
||||||
|
|
||||||
|
# Options related to the message relay Telegram bot.
|
||||||
|
relaybot:
|
||||||
|
# Whether or not to allow creating portals from Telegram.
|
||||||
|
authless_portals: true
|
||||||
|
# Whether or not to allow Telegram group admins to use the bot commands.
|
||||||
|
whitelist_group_admins: true
|
||||||
|
# Whether or not to ignore incoming events sent by the relay bot.
|
||||||
|
ignore_own_incoming_events: true
|
||||||
|
# List of usernames/user IDs who are also allowed to use the bot commands.
|
||||||
|
whitelist:
|
||||||
|
- myusername
|
||||||
|
- 12345678
|
||||||
|
|
||||||
|
# Telegram config
|
||||||
|
telegram:
|
||||||
|
# Get your own API keys at https://my.telegram.org/apps
|
||||||
|
api_id: 12345
|
||||||
|
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
|
||||||
|
# (Optional) Create your own bot at https://t.me/BotFather
|
||||||
|
bot_token: disabled
|
||||||
|
# Custom server to connect to.
|
||||||
|
server:
|
||||||
|
# Set to true to use these server settings. If false, will automatically
|
||||||
|
# use production server assigned by Telegram. Set to false in production.
|
||||||
|
enabled: false
|
||||||
|
# The DC ID to connect to.
|
||||||
|
dc: 2
|
||||||
|
# The IP to connect to.
|
||||||
|
ip: 149.154.167.40
|
||||||
|
# The port to connect to. 443 may not work, 80 is better and both are equally secure.
|
||||||
|
port: 80
|
||||||
|
# Telethon proxy configuration.
|
||||||
|
# You must install PySocks from pip for proxies to work.
|
||||||
|
proxy:
|
||||||
|
# Allowed types: disabled, socks4, socks5, http
|
||||||
|
type: disabled
|
||||||
|
# Proxy IP address and port.
|
||||||
|
address: 127.0.0.1
|
||||||
|
port: 1080
|
||||||
|
# Whether or not to perform DNS resolving remotely.
|
||||||
|
rdns: true
|
||||||
|
# Proxy authentication (optional).
|
||||||
|
username: ""
|
||||||
|
password: ""
|
||||||
|
|
||||||
|
# Python logging configuration.
|
||||||
|
#
|
||||||
|
# See section 16.7.2 of the Python documentation for more info:
|
||||||
|
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
|
||||||
|
logging:
|
||||||
|
version: 1
|
||||||
|
formatters:
|
||||||
|
precise:
|
||||||
|
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
||||||
|
handlers:
|
||||||
|
file:
|
||||||
|
class: logging.handlers.RotatingFileHandler
|
||||||
|
formatter: precise
|
||||||
|
filename: ./mautrix-telegram.log
|
||||||
|
maxBytes: 10485760
|
||||||
|
backupCount: 10
|
||||||
|
console:
|
||||||
|
class: logging.StreamHandler
|
||||||
|
formatter: precise
|
||||||
|
loggers:
|
||||||
|
mau:
|
||||||
|
level: DEBUG
|
||||||
|
telethon:
|
||||||
|
level: DEBUG
|
||||||
|
aiohttp:
|
||||||
|
level: INFO
|
||||||
|
root:
|
||||||
|
level: DEBUG
|
||||||
|
handlers: [file, console]
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "0.11.2"
|
__version__ = "0.5.2"
|
||||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
||||||
|
|||||||
+115
-106
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,130 +14,138 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Awaitable, List, Any
|
||||||
|
from time import time
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import logging.config
|
||||||
|
import sys
|
||||||
|
import copy
|
||||||
|
import signal
|
||||||
|
|
||||||
from typing import Any
|
import sqlalchemy as sql
|
||||||
|
|
||||||
from telethon import __version__ as __telethon_version__
|
from mautrix_appservice import AppService
|
||||||
|
from alchemysession import AlchemySessionContainer
|
||||||
|
|
||||||
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.provisioning import ProvisioningAPI
|
||||||
from .web.public import PublicBridgeWebsite
|
from .web.public import PublicBridgeWebsite
|
||||||
|
from .abstract_user import init as init_abstract_user
|
||||||
|
from .bot import init as init_bot
|
||||||
|
from .config import Config
|
||||||
|
from .context import Context
|
||||||
|
from .db import Base, init as init_db
|
||||||
|
from .formatter import init as init_formatter
|
||||||
|
from .matrix import MatrixHandler
|
||||||
|
from .portal import init as init_portal
|
||||||
|
from .puppet import init as init_puppet
|
||||||
|
from .sqlstatestore import SQLStateStore
|
||||||
|
from .user import User, init as init_user
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
from .abstract_user import AbstractUser # isort: skip
|
parser = argparse.ArgumentParser(
|
||||||
|
description="A Matrix-Telegram puppeting bridge.",
|
||||||
|
prog="python -m mautrix-telegram")
|
||||||
|
parser.add_argument("-c", "--config", type=str, default="config.yaml",
|
||||||
|
metavar="<path>", help="the path to your config file")
|
||||||
|
parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml",
|
||||||
|
metavar="<path>", help="the path to the example config "
|
||||||
|
"(for automatic config updates)")
|
||||||
|
parser.add_argument("-g", "--generate-registration", action="store_true",
|
||||||
|
help="generate registration and quit")
|
||||||
|
parser.add_argument("-r", "--registration", type=str, default="registration.yaml",
|
||||||
|
metavar="<path>", help="the path to save the generated registration to")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config = Config(args.config, args.registration, args.base_config)
|
||||||
|
config.load()
|
||||||
|
config.update()
|
||||||
|
|
||||||
class TelegramBridge(Bridge):
|
if args.generate_registration:
|
||||||
module = "mautrix_telegram"
|
config.generate_registration()
|
||||||
name = "mautrix-telegram"
|
config.save()
|
||||||
command = "python -m mautrix-telegram"
|
print(f"Registration generated and saved to {config.registration_path}")
|
||||||
description = "A Matrix-Telegram puppeting bridge."
|
sys.exit(0)
|
||||||
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
|
logging.config.dictConfig(copy.deepcopy(config["logging"]))
|
||||||
bot: Bot | None
|
log = logging.getLogger("mau.init") # type: logging.Logger
|
||||||
public_website: PublicBridgeWebsite | None
|
log.debug(f"Initializing mautrix-telegram {__version__}")
|
||||||
provisioning_api: ProvisioningAPI | None
|
|
||||||
|
|
||||||
def prepare_db(self) -> None:
|
db_engine = sql.create_engine(config["appservice.database"] or "sqlite:///mautrix-telegram.db")
|
||||||
super().prepare_db()
|
Base.metadata.bind = db_engine
|
||||||
init_db(self.db)
|
|
||||||
|
|
||||||
def _prepare_website(self) -> None:
|
session_container = AlchemySessionContainer(engine=db_engine, table_base=Base, session=False,
|
||||||
if self.config["appservice.provisioning.enabled"]:
|
table_prefix="telethon_", manage_tables=False)
|
||||||
self.provisioning_api = ProvisioningAPI(self)
|
session_container.core_mode = True
|
||||||
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"]:
|
try:
|
||||||
self.public_website = PublicBridgeWebsite(self.loop)
|
import uvloop
|
||||||
self.az.app.add_subapp(
|
|
||||||
self.config["appservice.public.prefix"], self.public_website.app
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.public_website = None
|
|
||||||
|
|
||||||
def prepare_bridge(self) -> None:
|
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||||
self._prepare_website()
|
log.debug("Using uvloop for asyncio")
|
||||||
AbstractUser.init_cls(self)
|
except ImportError:
|
||||||
bot_token: str = self.config["telegram.bot_token"]
|
pass
|
||||||
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:
|
loop = asyncio.get_event_loop() # type: asyncio.AbstractEventLoop
|
||||||
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:
|
state_store = SQLStateStore()
|
||||||
for puppet in Puppet.by_custom_mxid.values():
|
mebibyte = 1024 ** 2
|
||||||
puppet.stop()
|
appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
|
||||||
self.shutdown_actions = (user.stop() for user in User.by_tgid.values())
|
config["appservice.as_token"], config["appservice.hs_token"],
|
||||||
|
config["appservice.bot_username"], log="mau.as", loop=loop,
|
||||||
|
verify_ssl=config["homeserver.verify_ssl"], state_store=state_store,
|
||||||
|
real_user_content_key="net.maunium.telegram.puppet",
|
||||||
|
aiohttp_params={
|
||||||
|
"client_max_size": config["appservice.max_body_size"] * mebibyte
|
||||||
|
})
|
||||||
|
bot = init_bot(config)
|
||||||
|
context = Context(appserv, config, loop, session_container, bot)
|
||||||
|
|
||||||
async def get_user(self, user_id: UserID, create: bool = True) -> User | None:
|
if config["appservice.public.enabled"]:
|
||||||
user = await User.get_by_mxid(user_id, create=create)
|
public_website = PublicBridgeWebsite(loop)
|
||||||
if user:
|
appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app)
|
||||||
await user.ensure_started()
|
context.public_website = public_website
|
||||||
return user
|
|
||||||
|
|
||||||
async def get_portal(self, room_id: RoomID) -> Portal | None:
|
if config["appservice.provisioning.enabled"]:
|
||||||
return await Portal.get_by_mxid(room_id)
|
provisioning_api = ProvisioningAPI(context)
|
||||||
|
appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning",
|
||||||
|
provisioning_api.app)
|
||||||
|
context.provisioning_api = provisioning_api
|
||||||
|
|
||||||
async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet | None:
|
context.mx = MatrixHandler(context)
|
||||||
return await Puppet.get_by_mxid(user_id, create=create)
|
|
||||||
|
|
||||||
async def get_double_puppet(self, user_id: UserID) -> Puppet | None:
|
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
|
||||||
return await Puppet.get_by_custom_mxid(user_id)
|
start_ts = time()
|
||||||
|
init_db(db_engine)
|
||||||
|
init_abstract_user(context)
|
||||||
|
init_formatter(context)
|
||||||
|
init_portal(context)
|
||||||
|
startup_actions = (init_puppet(context) +
|
||||||
|
init_user(context) +
|
||||||
|
[start, context.mx.init_as_bot()]) # type: List[Awaitable[Any]]
|
||||||
|
|
||||||
def is_bridge_ghost(self, user_id: UserID) -> bool:
|
if context.bot:
|
||||||
return bool(Puppet.get_id_from_mxid(user_id))
|
startup_actions.append(context.bot.start())
|
||||||
|
|
||||||
async def count_logged_in_users(self) -> int:
|
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||||
return len([user for user in User.by_tgid.values() if user.tgid])
|
signal.signal(signal.SIGTERM, signal.default_int_handler)
|
||||||
|
|
||||||
async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]:
|
end_ts = time()
|
||||||
return {
|
try:
|
||||||
**await super().manhole_global_namespace(user_id),
|
log.debug(f"Initialization complete in {round(end_ts - start_ts, 2)} seconds,"
|
||||||
"User": User,
|
" running startup actions")
|
||||||
"Portal": Portal,
|
start_ts = time()
|
||||||
"Puppet": Puppet,
|
loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop))
|
||||||
}
|
end_ts = time()
|
||||||
|
log.debug(f"Startup actions complete in {round(end_ts - start_ts, 2)} seconds,"
|
||||||
@property
|
" now running forever")
|
||||||
def manhole_banner_program_version(self) -> str:
|
loop.run_forever()
|
||||||
return f"{super().manhole_banner_program_version} and Telethon {__telethon_version__}"
|
except KeyboardInterrupt:
|
||||||
|
log.debug("Interrupt received, stopping clients")
|
||||||
|
loop.run_until_complete(
|
||||||
TelegramBridge().run()
|
asyncio.gather(*[user.stop() for user in User.by_tgid.values()], loop=loop))
|
||||||
|
log.debug("Clients stopped, shutting down")
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Unexpected error")
|
||||||
|
sys.exit(1)
|
||||||
|
|||||||
+188
-424
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,219 +14,108 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Tuple, Optional, List, Union, TYPE_CHECKING
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, Union
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import platform
|
import platform
|
||||||
import time
|
|
||||||
|
|
||||||
from telethon.network import (
|
from telethon.tl.patched import MessageService, Message
|
||||||
Connection,
|
|
||||||
ConnectionTcpFull,
|
|
||||||
ConnectionTcpMTProxyRandomizedIntermediate,
|
|
||||||
)
|
|
||||||
from telethon.sessions import Session
|
|
||||||
from telethon.tl.patched import Message, MessageService
|
|
||||||
from telethon.tl.types import (
|
from telethon.tl.types import (
|
||||||
Channel,
|
Channel, ChannelForbidden, Chat, ChatForbidden, MessageActionChannelMigrateFrom, PeerUser,
|
||||||
Chat,
|
TypeUpdate, UpdateChannelPinnedMessage, UpdateChatPinnedMessage, UpdateChatParticipantAdmin,
|
||||||
MessageActionChannelMigrateFrom,
|
UpdateChatParticipants, UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateDeleteMessages,
|
||||||
MessageEmpty,
|
UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateNewMessage,
|
||||||
PeerChannel,
|
UpdateReadHistoryOutbox, UpdateShortChatMessage, UpdateShortMessage, UpdateUserName,
|
||||||
PeerChat,
|
UpdateUserPhoto, UpdateUserStatus, UpdateUserTyping, User, UserStatusOffline, UserStatusOnline)
|
||||||
PeerUser,
|
|
||||||
TypeUpdate,
|
|
||||||
UpdateChannelUserTyping,
|
|
||||||
UpdateChatParticipantAdmin,
|
|
||||||
UpdateChatParticipants,
|
|
||||||
UpdateChatUserTyping,
|
|
||||||
UpdateDeleteChannelMessages,
|
|
||||||
UpdateDeleteMessages,
|
|
||||||
UpdateEditChannelMessage,
|
|
||||||
UpdateEditMessage,
|
|
||||||
UpdateFolderPeers,
|
|
||||||
UpdateMessageReactions,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage,
|
|
||||||
UpdateNotifySettings,
|
|
||||||
UpdatePinnedChannelMessages,
|
|
||||||
UpdatePinnedDialogs,
|
|
||||||
UpdatePinnedMessages,
|
|
||||||
UpdateReadChannelInbox,
|
|
||||||
UpdateReadHistoryInbox,
|
|
||||||
UpdateReadHistoryOutbox,
|
|
||||||
UpdateShortChatMessage,
|
|
||||||
UpdateShortMessage,
|
|
||||||
UpdateUserName,
|
|
||||||
UpdateUserPhoto,
|
|
||||||
UpdateUserStatus,
|
|
||||||
UpdateUserTyping,
|
|
||||||
User,
|
|
||||||
UserStatusOffline,
|
|
||||||
UserStatusOnline,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.appservice import AppService
|
from mautrix_appservice import MatrixRequestError, AppService
|
||||||
from mautrix.errors import MatrixError
|
from alchemysession import AlchemySessionContainer
|
||||||
from mautrix.types import PresenceState, UserID
|
|
||||||
from mautrix.util.logging import TraceLogger
|
|
||||||
from mautrix.util.opt_prometheus import Counter, Histogram
|
|
||||||
|
|
||||||
from . import __version__, portal as po, puppet as pu
|
from . import portal as po, puppet as pu, __version__
|
||||||
from .config import Config
|
from .db import Message as DBMessage
|
||||||
from .db import Message as DBMessage, PgSession
|
from .types import TelegramID, MatrixUserID
|
||||||
from .tgclient import MautrixTelegramClient
|
from .tgclient import MautrixTelegramClient
|
||||||
from .types import TelegramID
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .__main__ import TelegramBridge
|
from .context import Context
|
||||||
|
from .config import Config
|
||||||
from .bot import Bot
|
from .bot import Bot
|
||||||
|
|
||||||
UpdateMessage = Union[
|
config = None # type: Config
|
||||||
UpdateShortChatMessage,
|
# Value updated from config in init()
|
||||||
UpdateShortMessage,
|
MAX_DELETIONS = 10 # type: int
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage,
|
|
||||||
UpdateEditMessage,
|
|
||||||
UpdateEditChannelMessage,
|
|
||||||
]
|
|
||||||
UpdateMessageContent = Union[
|
|
||||||
UpdateShortMessage, UpdateShortChatMessage, Message, MessageService, MessageEmpty
|
|
||||||
]
|
|
||||||
|
|
||||||
UPDATE_TIME = Histogram(
|
UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
|
||||||
name="bridge_telegram_update",
|
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage]
|
||||||
documentation="Time spent processing Telegram updates",
|
UpdateMessageContent = Union[UpdateShortMessage, UpdateShortChatMessage, Message, MessageService]
|
||||||
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):
|
class AbstractUser(ABC):
|
||||||
loop: asyncio.AbstractEventLoop = None
|
session_container = None # type: AlchemySessionContainer
|
||||||
log: TraceLogger
|
loop = None # type: asyncio.AbstractEventLoop
|
||||||
az: AppService
|
log = None # type: logging.Logger
|
||||||
bridge: "TelegramBridge"
|
az = None # type: AppService
|
||||||
config: Config
|
bot = None # type: Bot
|
||||||
relaybot: "Bot"
|
ignore_incoming_bot_events = True # type: bool
|
||||||
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:
|
def __init__(self) -> None:
|
||||||
self.is_admin = False
|
self.is_admin = False # type: bool
|
||||||
self.matrix_puppet_whitelisted = False
|
self.matrix_puppet_whitelisted = False # type: bool
|
||||||
self.puppet_whitelisted = False
|
self.puppet_whitelisted = False # type: bool
|
||||||
self.whitelisted = False
|
self.whitelisted = False # type: bool
|
||||||
self.relaybot_whitelisted = False
|
self.relaybot_whitelisted = False # type: bool
|
||||||
self.client = None
|
self.client = None # type: MautrixTelegramClient
|
||||||
self.is_relaybot = False
|
self.tgid = None # type: TelegramID
|
||||||
self.is_bot = False
|
self.mxid = None # type: MatrixUserID
|
||||||
|
self.is_relaybot = False # type: bool
|
||||||
|
self.is_bot = False # type: bool
|
||||||
|
self.relaybot = None # type: Optional[Bot]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def connected(self) -> bool:
|
def connected(self) -> bool:
|
||||||
return self.client and self.client.is_connected()
|
return self.client and self.client.is_connected()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _proxy_settings(self) -> tuple[type[Connection], tuple[Any, ...] | None]:
|
def _proxy_settings(self) -> Optional[Tuple[int, str, str, str, str, str]]:
|
||||||
proxy_type = self.config["telegram.proxy.type"].lower()
|
proxy_type = 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":
|
if proxy_type == "disabled":
|
||||||
connection_data = None
|
return None
|
||||||
elif proxy_type == "socks4":
|
elif proxy_type == "socks4":
|
||||||
connection_data = (1,) + connection_data
|
proxy_type = 1
|
||||||
elif proxy_type == "socks5":
|
elif proxy_type == "socks5":
|
||||||
connection_data = (2,) + connection_data
|
proxy_type = 2
|
||||||
elif proxy_type == "http":
|
elif proxy_type == "http":
|
||||||
connection_data = (3,) + connection_data
|
proxy_type = 3
|
||||||
elif proxy_type == "mtproxy":
|
|
||||||
connection = ConnectionTcpMTProxyRandomizedIntermediate
|
|
||||||
connection_data = (connection_data[0], connection_data[1], connection_data[4])
|
|
||||||
|
|
||||||
return connection, connection_data
|
return (proxy_type,
|
||||||
|
config["telegram.proxy.address"], config["telegram.proxy.port"],
|
||||||
|
config["telegram.proxy.rdns"],
|
||||||
|
config["telegram.proxy.username"], config["telegram.proxy.password"])
|
||||||
|
|
||||||
@classmethod
|
def _init_client(self) -> None:
|
||||||
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}")
|
self.log.debug(f"Initializing client for {self.name}")
|
||||||
|
device = f"{platform.system()} {platform.release()}"
|
||||||
session = await PgSession.get(self.name)
|
sysversion = MautrixTelegramClient.__version__
|
||||||
if self.config["telegram.server.enabled"]:
|
self.session = self.session_container.new_session(self.name)
|
||||||
session.set_dc(
|
if config["telegram.server.enabled"]:
|
||||||
self.config["telegram.server.dc"],
|
self.session.set_dc(config["telegram.server.dc"],
|
||||||
self.config["telegram.server.ip"],
|
config["telegram.server.ip"],
|
||||||
self.config["telegram.server.port"],
|
config["telegram.server.port"])
|
||||||
)
|
|
||||||
|
|
||||||
if self.is_relaybot:
|
if self.is_relaybot:
|
||||||
base_logger = logging.getLogger("telethon.relaybot")
|
base_logger = logging.getLogger("telethon.relaybot")
|
||||||
else:
|
else:
|
||||||
base_logger = logging.getLogger(f"telethon.{self.tgid or -hash(self.mxid)}")
|
base_logger = logging.getLogger(f"telethon.{self.tgid or -hash(self.mxid)}")
|
||||||
|
self.client = MautrixTelegramClient(session=self.session,
|
||||||
device = self.config["telegram.device_info.device_model"]
|
api_id=config["telegram.api_id"],
|
||||||
sysversion = self.config["telegram.device_info.system_version"]
|
api_hash=config["telegram.api_hash"],
|
||||||
appversion = self.config["telegram.device_info.app_version"]
|
loop=self.loop,
|
||||||
connection, proxy = self._proxy_settings
|
app_version=__version__,
|
||||||
|
system_version=sysversion,
|
||||||
assert isinstance(session, Session)
|
device_model=device,
|
||||||
|
timeout=120,
|
||||||
self.client = MautrixTelegramClient(
|
base_logger=base_logger,
|
||||||
session=session,
|
proxy=self._proxy_settings)
|
||||||
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,
|
|
||||||
loop=self.loop,
|
|
||||||
base_logger=base_logger,
|
|
||||||
)
|
|
||||||
self.client.add_event_handler(self._update_catch)
|
self.client.add_event_handler(self._update_catch)
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -237,23 +127,28 @@ class AbstractUser(ABC):
|
|||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def register_portal(self, portal: po.Portal) -> None:
|
def register_portal(self, portal: po.Portal) -> None:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def unregister_portal(self, tgid: int, tg_receiver: int) -> None:
|
def unregister_portal(self, portal: po.Portal) -> None:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
async def _update_catch(self, update: TypeUpdate) -> None:
|
async def _update_catch(self, update: TypeUpdate) -> None:
|
||||||
start_time = time.time()
|
|
||||||
update_type = type(update).__name__
|
|
||||||
try:
|
try:
|
||||||
if not await self.update(update):
|
if not await self.update(update):
|
||||||
await self._update(update)
|
await self._update(update)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception("Failed to handle Telegram update")
|
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)
|
async def get_dialogs(self, limit: int = None) -> List[Union[Chat, Channel]]:
|
||||||
|
if self.is_bot:
|
||||||
|
return []
|
||||||
|
dialogs = await self.client.get_dialogs(limit=limit)
|
||||||
|
return [dialog.entity for dialog in dialogs if (
|
||||||
|
not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden))
|
||||||
|
and not (isinstance(dialog.entity, Chat)
|
||||||
|
and (dialog.entity.deactivated or dialog.entity.left)))]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -261,32 +156,25 @@ class AbstractUser(ABC):
|
|||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
async def is_logged_in(self) -> bool:
|
async def is_logged_in(self) -> bool:
|
||||||
return (
|
return self.client and self.client.is_connected() and await self.client.is_user_authorized()
|
||||||
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:
|
async def has_full_access(self, allow_bot: bool = False) -> bool:
|
||||||
return (
|
return (self.puppet_whitelisted
|
||||||
self.puppet_whitelisted
|
and (not self.is_bot or allow_bot)
|
||||||
and (not self.is_bot or allow_bot)
|
and await self.is_logged_in())
|
||||||
and await self.is_logged_in()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def start(self, delete_unless_authenticated: bool = False) -> AbstractUser:
|
async def start(self, delete_unless_authenticated: bool = False) -> 'AbstractUser':
|
||||||
if not self.client:
|
if not self.client:
|
||||||
await self._init_client()
|
self._init_client()
|
||||||
await self.client.connect()
|
await self.client.connect()
|
||||||
self.log.debug(f"{'Bot' if self.is_relaybot else self.mxid} connected: {self.connected}")
|
self.log.debug("%s connected: %s", self.mxid, self.connected)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def ensure_started(self, even_if_no_session=False) -> AbstractUser:
|
async def ensure_started(self, even_if_no_session=False) -> 'AbstractUser':
|
||||||
if self.connected:
|
if not self.puppet_whitelisted or self.connected:
|
||||||
return self
|
return self
|
||||||
if even_if_no_session or await PgSession.has(self.mxid):
|
self.log.debug("ensure_started(%s, even_if_no_session=%s)", self.mxid, even_if_no_session)
|
||||||
self.log.debug(
|
if even_if_no_session or self.session_container.has_session(self.mxid):
|
||||||
"Starting client due to ensure_started"
|
|
||||||
f"(even_if_no_session={even_if_no_session})"
|
|
||||||
)
|
|
||||||
await self.start(delete_unless_authenticated=not even_if_no_session)
|
await self.start(delete_unless_authenticated=not even_if_no_session)
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@@ -297,26 +185,14 @@ class AbstractUser(ABC):
|
|||||||
# region Telegram update handling
|
# region Telegram update handling
|
||||||
|
|
||||||
async def _update(self, update: TypeUpdate) -> None:
|
async def _update(self, update: TypeUpdate) -> None:
|
||||||
asyncio.create_task(self._handle_entity_updates(getattr(update, "_entities", {})))
|
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
|
||||||
if isinstance(
|
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
|
||||||
update,
|
|
||||||
(
|
|
||||||
UpdateShortChatMessage,
|
|
||||||
UpdateShortMessage,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage,
|
|
||||||
UpdateEditMessage,
|
|
||||||
UpdateEditChannelMessage,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
await self.update_message(update)
|
await self.update_message(update)
|
||||||
elif isinstance(update, UpdateDeleteMessages):
|
elif isinstance(update, UpdateDeleteMessages):
|
||||||
await self.delete_message(update)
|
await self.delete_message(update)
|
||||||
elif isinstance(update, UpdateDeleteChannelMessages):
|
elif isinstance(update, UpdateDeleteChannelMessages):
|
||||||
await self.delete_channel_message(update)
|
await self.delete_channel_message(update)
|
||||||
elif isinstance(update, UpdateMessageReactions):
|
elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)):
|
||||||
await self.update_reactions(update)
|
|
||||||
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
|
|
||||||
await self.update_typing(update)
|
await self.update_typing(update)
|
||||||
elif isinstance(update, UpdateUserStatus):
|
elif isinstance(update, UpdateUserStatus):
|
||||||
await self.update_status(update)
|
await self.update_status(update)
|
||||||
@@ -324,302 +200,190 @@ class AbstractUser(ABC):
|
|||||||
await self.update_admin(update)
|
await self.update_admin(update)
|
||||||
elif isinstance(update, UpdateChatParticipants):
|
elif isinstance(update, UpdateChatParticipants):
|
||||||
await self.update_participants(update)
|
await self.update_participants(update)
|
||||||
elif isinstance(update, (UpdatePinnedMessages, UpdatePinnedChannelMessages)):
|
elif isinstance(update, (UpdateChannelPinnedMessage, UpdateChatPinnedMessage)):
|
||||||
await self.update_pinned_messages(update)
|
await self.update_pinned_messages(update)
|
||||||
elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
|
elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
|
||||||
await self.update_others_info(update)
|
await self.update_others_info(update)
|
||||||
elif isinstance(update, UpdateReadHistoryOutbox):
|
elif isinstance(update, UpdateReadHistoryOutbox):
|
||||||
await self.update_read_receipt(update)
|
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)
|
|
||||||
else:
|
else:
|
||||||
self.log.trace("Unhandled update: %s", update)
|
self.log.debug("Unhandled update: %s", update)
|
||||||
|
|
||||||
async def update_folder_peers(self, update: UpdateFolderPeers) -> None:
|
async def update_pinned_messages(self, update: Union[UpdateChannelPinnedMessage,
|
||||||
pass
|
UpdateChatPinnedMessage]) -> None:
|
||||||
|
if isinstance(update, UpdateChatPinnedMessage):
|
||||||
async def update_pinned_dialogs(self, update: UpdatePinnedDialogs) -> None:
|
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
||||||
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:
|
else:
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
||||||
if portal and portal.mxid:
|
if portal and portal.mxid:
|
||||||
await portal.receive_telegram_pin_ids(
|
await portal.receive_telegram_pin_id(update.id, self.tgid)
|
||||||
update.messages, self.tgid, remove=not update.pinned
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def update_participants(update: UpdateChatParticipants) -> None:
|
async def update_participants(update: UpdateChatParticipants) -> None:
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
|
portal = po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
|
||||||
if portal and portal.mxid:
|
if portal and portal.mxid:
|
||||||
await portal.update_power_levels(update.participants.participants)
|
await portal.update_telegram_participants(update.participants.participants)
|
||||||
|
|
||||||
async def update_read_receipt(self, update: UpdateReadHistoryOutbox) -> None:
|
async def update_read_receipt(self, update: UpdateReadHistoryOutbox) -> None:
|
||||||
if not isinstance(update.peer, PeerUser):
|
if not isinstance(update.peer, PeerUser):
|
||||||
self.log.debug("Unexpected read receipt peer: %s", update.peer)
|
self.log.debug("Unexpected read receipt peer: %s", update.peer)
|
||||||
return
|
return
|
||||||
|
|
||||||
portal = await po.Portal.get_by_tgid(
|
portal = po.Portal.get_by_tgid(TelegramID(update.peer.user_id), self.tgid)
|
||||||
TelegramID(update.peer.user_id), tg_receiver=self.tgid
|
|
||||||
)
|
|
||||||
if not portal or not portal.mxid:
|
if not portal or not portal.mxid:
|
||||||
return
|
return
|
||||||
|
|
||||||
# We check that these are user read receipts, so tg_space is always the user ID.
|
# We check that these are user read receipts, so tg_space is always the user ID.
|
||||||
message = await DBMessage.get_one_by_tgid(
|
message = DBMessage.get_by_tgid(TelegramID(update.max_id), self.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
|
|
||||||
|
|
||||||
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:
|
|
||||||
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:
|
if not message:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
puppet = pu.Puppet.get(TelegramID(update.peer.user_id))
|
||||||
await puppet.intent.mark_read(portal.mxid, message.mxid)
|
await puppet.intent.mark_read(portal.mxid, message.mxid)
|
||||||
|
|
||||||
async def update_admin(self, update: UpdateChatParticipantAdmin) -> None:
|
async def update_admin(self, update: UpdateChatParticipantAdmin) -> None:
|
||||||
# TODO duplication not checked
|
# TODO duplication not checked
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
|
||||||
if not portal or not portal.mxid:
|
if not portal or not portal.mxid:
|
||||||
return
|
return
|
||||||
|
|
||||||
await portal.set_telegram_admin(TelegramID(update.user_id))
|
await portal.set_telegram_admin(TelegramID(update.user_id))
|
||||||
|
|
||||||
async def update_typing(
|
async def update_typing(self, update: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None:
|
||||||
self, update: UpdateUserTyping | UpdateChatUserTyping | UpdateChannelUserTyping
|
|
||||||
) -> None:
|
|
||||||
sender = None
|
|
||||||
if isinstance(update, UpdateUserTyping):
|
if isinstance(update, UpdateUserTyping):
|
||||||
portal = await po.Portal.get_by_tgid(
|
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
|
||||||
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:
|
else:
|
||||||
|
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
|
||||||
|
|
||||||
|
if not portal or not portal.mxid:
|
||||||
return
|
return
|
||||||
|
|
||||||
if isinstance(update, (UpdateChannelUserTyping, UpdateChatUserTyping)):
|
sender = pu.Puppet.get(TelegramID(update.user_id))
|
||||||
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)
|
await portal.handle_telegram_typing(sender, update)
|
||||||
|
|
||||||
async def _handle_entity_updates(self, entities: dict[int, User | Chat | Channel]) -> None:
|
async def update_others_info(self, update: Union[UpdateUserName, UpdateUserPhoto]) -> 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 | UpdateUserPhoto) -> None:
|
|
||||||
# TODO duplication not checked
|
# TODO duplication not checked
|
||||||
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
|
puppet = pu.Puppet.get(TelegramID(update.user_id))
|
||||||
if isinstance(update, UpdateUserName):
|
if isinstance(update, UpdateUserName):
|
||||||
puppet.username = update.username
|
puppet.username = update.username
|
||||||
if await puppet.update_displayname(self, update):
|
if await puppet.update_displayname(self, update):
|
||||||
await puppet.save()
|
puppet.save()
|
||||||
await puppet.update_portals_meta()
|
|
||||||
elif isinstance(update, UpdateUserPhoto):
|
elif isinstance(update, UpdateUserPhoto):
|
||||||
if await puppet.update_avatar(self, update.photo):
|
if await puppet.update_avatar(self, update.photo.photo_big):
|
||||||
await puppet.save()
|
puppet.save()
|
||||||
await puppet.update_portals_meta()
|
|
||||||
else:
|
else:
|
||||||
self.log.warning(f"Unexpected other user info update: {type(update)}")
|
self.log.warning("Unexpected other user info update: %s", update)
|
||||||
|
|
||||||
async def update_status(self, update: UpdateUserStatus) -> None:
|
async def update_status(self, update: UpdateUserStatus) -> None:
|
||||||
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
|
puppet = pu.Puppet.get(TelegramID(update.user_id))
|
||||||
if isinstance(update.status, UserStatusOnline):
|
if isinstance(update.status, UserStatusOnline):
|
||||||
await puppet.default_mxid_intent.set_presence(PresenceState.ONLINE)
|
await puppet.default_mxid_intent.set_presence("online")
|
||||||
elif isinstance(update.status, UserStatusOffline):
|
elif isinstance(update.status, UserStatusOffline):
|
||||||
await puppet.default_mxid_intent.set_presence(PresenceState.OFFLINE)
|
await puppet.default_mxid_intent.set_presence("offline")
|
||||||
else:
|
else:
|
||||||
self.log.warning(f"Unexpected user status update: type({update})")
|
self.log.warning("Unexpected user status update: %s", update)
|
||||||
return
|
return
|
||||||
|
|
||||||
async def get_message_details(
|
def get_message_details(self, update: UpdateMessage) -> Tuple[UpdateMessageContent,
|
||||||
self, update: UpdateMessage
|
Optional[pu.Puppet],
|
||||||
) -> tuple[UpdateMessageContent, pu.Puppet | None, po.Portal | None]:
|
Optional[po.Portal]]:
|
||||||
if isinstance(update, UpdateShortChatMessage):
|
if isinstance(update, UpdateShortChatMessage):
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
|
||||||
if not portal:
|
sender = pu.Puppet.get(TelegramID(update.from_id))
|
||||||
self.log.warning(f"Received message in chat with unknown type {update.chat_id}")
|
|
||||||
sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id))
|
|
||||||
elif isinstance(update, UpdateShortMessage):
|
elif isinstance(update, UpdateShortMessage):
|
||||||
portal = await po.Portal.get_by_tgid(
|
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
|
||||||
TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user"
|
sender = pu.Puppet.get(self.tgid if update.out else update.user_id)
|
||||||
)
|
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
|
||||||
sender = await pu.Puppet.get_by_tgid(self.tgid if update.out else update.user_id)
|
UpdateEditMessage, UpdateEditChannelMessage)):
|
||||||
elif isinstance(
|
|
||||||
update,
|
|
||||||
(
|
|
||||||
UpdateNewMessage,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateEditMessage,
|
|
||||||
UpdateEditChannelMessage,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
update = update.message
|
update = update.message
|
||||||
if isinstance(update, MessageEmpty):
|
if isinstance(update.to_id, PeerUser) and not update.out:
|
||||||
return update, None, None
|
portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
|
||||||
portal = await po.Portal.get_by_entity(update.peer_id, tg_receiver=self.tgid)
|
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)
|
|
||||||
else:
|
else:
|
||||||
sender = None
|
portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
|
||||||
|
sender = pu.Puppet.get(update.from_id) if update.from_id else None
|
||||||
else:
|
else:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"Unexpected message type in User#get_message_details: {type(update)}"
|
f"Unexpected message type in User#get_message_details: {type(update)}")
|
||||||
)
|
|
||||||
return update, None, None
|
return update, None, None
|
||||||
return update, sender, portal
|
return update, sender, portal
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _try_redact(message: DBMessage) -> None:
|
async def _try_redact(portal: po.Portal, message: DBMessage) -> None:
|
||||||
portal = await po.Portal.get_by_mxid(message.mx_room)
|
|
||||||
if not portal:
|
if not portal:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await portal.main_intent.redact(message.mx_room, message.mxid)
|
await portal.main_intent.redact(message.mx_room, message.mxid)
|
||||||
except MatrixError:
|
except MatrixRequestError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def delete_message(self, update: UpdateDeleteMessages) -> None:
|
async def delete_message(self, update: UpdateDeleteMessages) -> None:
|
||||||
if len(update.messages) > self.max_deletions:
|
if len(update.messages) > MAX_DELETIONS:
|
||||||
return
|
return
|
||||||
|
|
||||||
for message_id in update.messages:
|
for message in update.messages:
|
||||||
for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid):
|
message = DBMessage.get_by_tgid(TelegramID(message), self.tgid)
|
||||||
if message.redacted:
|
if not message:
|
||||||
continue
|
continue
|
||||||
await message.delete()
|
message.delete()
|
||||||
number_left = await DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
|
number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
|
||||||
if number_left == 0:
|
if number_left == 0:
|
||||||
await self._try_redact(message)
|
portal = po.Portal.get_by_mxid(message.mx_room)
|
||||||
|
await self._try_redact(portal, message)
|
||||||
|
|
||||||
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
|
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
|
||||||
if len(update.messages) > self.max_deletions:
|
if len(update.messages) > MAX_DELETIONS:
|
||||||
return
|
return
|
||||||
|
|
||||||
channel_id = TelegramID(update.channel_id)
|
portal = po.Portal.get_by_tgid(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_message(self, original_update: UpdateMessage) -> None:
|
|
||||||
update, sender, portal = await self.get_message_details(original_update)
|
|
||||||
if not portal:
|
if not portal:
|
||||||
return
|
return
|
||||||
elif portal and not portal.allow_bridging:
|
|
||||||
self.log.debug(f"Ignoring message in portal {portal.tgid_log} (bridging disallowed)")
|
for message in update.messages:
|
||||||
|
message = DBMessage.get_by_tgid(TelegramID(message), portal.tgid)
|
||||||
|
if not message:
|
||||||
|
continue
|
||||||
|
message.delete()
|
||||||
|
await self._try_redact(portal, message)
|
||||||
|
|
||||||
|
async def update_message(self, original_update: UpdateMessage) -> None:
|
||||||
|
update, sender, portal = self.get_message_details(original_update)
|
||||||
|
|
||||||
|
if self.is_bot and not portal.mxid:
|
||||||
|
self.log.debug(f"Ignoring message received by bot in unbridged chat %s",
|
||||||
|
portal.tgid_log)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.is_relaybot:
|
if self.ignore_incoming_bot_events and self.bot and sender.id == self.bot.tgid:
|
||||||
if update.is_private:
|
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update, portal.tgid_log)
|
||||||
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
|
return
|
||||||
|
|
||||||
await portal.backfill_lock.wait(f"update {update.id}")
|
|
||||||
|
|
||||||
if isinstance(update, MessageService):
|
if isinstance(update, MessageService):
|
||||||
if isinstance(update.action, MessageActionChannelMigrateFrom):
|
if isinstance(update.action, MessageActionChannelMigrateFrom):
|
||||||
self.log.trace(
|
self.log.debug(f"Ignoring action %s to %s by %d", update.action,
|
||||||
"Received %s in %s by %d, unregistering portal...",
|
portal.tgid_log,
|
||||||
update.action,
|
sender.id)
|
||||||
portal.tgid_log,
|
|
||||||
sender.id,
|
|
||||||
)
|
|
||||||
await self.unregister_portal(update.action.chat_id, update.action.chat_id)
|
|
||||||
await self.register_portal(portal)
|
|
||||||
return
|
return
|
||||||
self.log.trace(
|
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
|
||||||
"Handling action %s to %s by %d",
|
sender.id)
|
||||||
update.action,
|
|
||||||
portal.tgid_log,
|
|
||||||
(sender.id if sender else 0),
|
|
||||||
)
|
|
||||||
return await portal.handle_telegram_action(self, sender, update)
|
return await portal.handle_telegram_action(self, sender, update)
|
||||||
|
|
||||||
|
user = sender.tgid if sender else "admin"
|
||||||
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
|
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
|
||||||
return await portal.handle_telegram_edit(self, sender, update)
|
if config["bridge.edits_as_replies"]:
|
||||||
|
self.log.debug("Handling edit %s to %s by %s", update, portal.tgid_log, user)
|
||||||
|
return await portal.handle_telegram_edit(self, sender, update)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
|
||||||
return await portal.handle_telegram_message(self, sender, update)
|
return await portal.handle_telegram_message(self, sender, update)
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
|
def init(context: "Context") -> None:
|
||||||
|
global config, MAX_DELETIONS
|
||||||
|
AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core
|
||||||
|
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
|
||||||
|
AbstractUser.session_container = context.session_container
|
||||||
|
MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10)
|
||||||
|
|||||||
+113
-150
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,81 +14,55 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Awaitable, Callable, Dict, List, Optional, Tuple
|
from typing import Awaitable, Callable, Dict, List, Optional, Pattern, TYPE_CHECKING
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from telethon.errors import ChannelInvalidError, ChannelPrivateError
|
|
||||||
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.patched import Message, MessageService
|
||||||
from telethon.tl.types import (
|
from telethon.tl.types import (
|
||||||
ChannelParticipantAdmin,
|
ChannelParticipantAdmin, ChannelParticipantCreator, ChatForbidden, ChatParticipantAdmin,
|
||||||
ChannelParticipantCreator,
|
ChatParticipantCreator, InputChannel, InputUser, MessageActionChatAddUser,
|
||||||
ChatForbidden,
|
MessageActionChatDeleteUser, MessageEntityBotCommand, PeerChannel, PeerChat, TypePeer,
|
||||||
ChatParticipantAdmin,
|
UpdateNewChannelMessage, UpdateNewMessage, MessageActionChatMigrateTo)
|
||||||
ChatParticipantCreator,
|
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
|
||||||
InputChannel,
|
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
|
||||||
InputUser,
|
from telethon.errors import ChannelInvalidError, ChannelPrivateError
|
||||||
MessageActionChatAddUser,
|
|
||||||
MessageActionChatDeleteUser,
|
|
||||||
MessageActionChatMigrateTo,
|
|
||||||
MessageEntityBotCommand,
|
|
||||||
PeerChannel,
|
|
||||||
PeerChat,
|
|
||||||
PeerUser,
|
|
||||||
TypePeer,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.types import UserID
|
from .types import MatrixUserID
|
||||||
|
|
||||||
from . import portal as po, puppet as pu, user as u
|
|
||||||
from .abstract_user import AbstractUser
|
from .abstract_user import AbstractUser
|
||||||
from .db import BotChat
|
from .db import BotChat
|
||||||
from .types import TelegramID
|
from .types import TelegramID
|
||||||
|
from . import puppet as pu, portal as po, user as u
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .config import Config
|
||||||
|
from .context import Context
|
||||||
|
|
||||||
|
config = None # type: Config
|
||||||
|
|
||||||
ReplyFunc = Callable[[str], Awaitable[Message]]
|
ReplyFunc = Callable[[str], Awaitable[Message]]
|
||||||
|
|
||||||
|
|
||||||
class Bot(AbstractUser):
|
class Bot(AbstractUser):
|
||||||
log: logging.Logger = logging.getLogger("mau.user.bot")
|
log = logging.getLogger("mau.bot") # type: logging.Logger
|
||||||
|
mxid_regex = re.compile("@.+:.+") # type: Pattern
|
||||||
token: str
|
|
||||||
chats: Dict[int, str]
|
|
||||||
tg_whitelist: List[int]
|
|
||||||
whitelist_group_admins: bool
|
|
||||||
_me_info: Optional[User]
|
|
||||||
_me_mxid: Optional[UserID]
|
|
||||||
|
|
||||||
def __init__(self, token: str) -> None:
|
def __init__(self, token: str) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.token = token
|
self.token = token # type: str
|
||||||
self.tgid = None
|
self.puppet_whitelisted = True # type: bool
|
||||||
self.mxid = None
|
self.whitelisted = True # type: bool
|
||||||
self.puppet_whitelisted = True
|
self.relaybot_whitelisted = True # type: bool
|
||||||
self.whitelisted = True
|
self.username = None # type: str
|
||||||
self.relaybot_whitelisted = True
|
self.is_relaybot = True # type: bool
|
||||||
self.tg_username = None
|
self.is_bot = True # type: bool
|
||||||
self.is_relaybot = True
|
self.chats = {} # type: Dict[int, str]
|
||||||
self.is_bot = True
|
self.tg_whitelist = [] # type: List[int]
|
||||||
self.chats = {}
|
self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"]
|
||||||
self.tg_whitelist = []
|
or False) # type: bool
|
||||||
self.whitelist_group_admins = (
|
|
||||||
self.config["bridge.relaybot.whitelist_group_admins"] or False
|
|
||||||
)
|
|
||||||
self._me_info = None
|
|
||||||
self._me_mxid = None
|
|
||||||
|
|
||||||
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:
|
async def init_permissions(self) -> None:
|
||||||
whitelist = self.config["bridge.relaybot.whitelist"] or []
|
whitelist = config["bridge.relaybot.whitelist"] or []
|
||||||
for user_id in whitelist:
|
for user_id in whitelist:
|
||||||
if isinstance(user_id, str):
|
if isinstance(user_id, str):
|
||||||
entity = await self.client.get_input_entity(user_id)
|
entity = await self.client.get_input_entity(user_id)
|
||||||
@@ -98,8 +73,8 @@ class Bot(AbstractUser):
|
|||||||
if isinstance(user_id, int):
|
if isinstance(user_id, int):
|
||||||
self.tg_whitelist.append(user_id)
|
self.tg_whitelist.append(user_id)
|
||||||
|
|
||||||
async def start(self, delete_unless_authenticated: bool = False) -> "Bot":
|
async def start(self, delete_unless_authenticated: bool = False) -> 'Bot':
|
||||||
self.chats = {chat.id: chat.type for chat in await BotChat.all()}
|
self.chats = {chat.id: chat.type for chat in BotChat.all()}
|
||||||
await super().start(delete_unless_authenticated)
|
await super().start(delete_unless_authenticated)
|
||||||
if not await self.is_logged_in():
|
if not await self.is_logged_in():
|
||||||
await self.client.sign_in(bot_token=self.token)
|
await self.client.sign_in(bot_token=self.token)
|
||||||
@@ -109,50 +84,54 @@ class Bot(AbstractUser):
|
|||||||
async def post_login(self) -> None:
|
async def post_login(self) -> None:
|
||||||
await self.init_permissions()
|
await self.init_permissions()
|
||||||
info = await self.client.get_me()
|
info = await self.client.get_me()
|
||||||
self.tgid = TelegramID(info.id)
|
self.tgid = info.id
|
||||||
self.tg_username = info.username
|
self.username = info.username
|
||||||
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
|
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
|
||||||
|
|
||||||
chat_ids = [chat_id for chat_id, chat_type in self.chats.items() if chat_type == "chat"]
|
chat_ids = [chat_id for chat_id, chat_type in self.chats.items() if chat_type == "chat"]
|
||||||
response = await self.client(GetChatsRequest(chat_ids))
|
response = await self.client(GetChatsRequest(chat_ids))
|
||||||
for chat in response.chats:
|
for chat in response.chats:
|
||||||
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
|
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
|
||||||
await self.remove_chat(TelegramID(chat.id))
|
self.remove_chat(TelegramID(chat.id))
|
||||||
|
|
||||||
channel_ids = [
|
channel_ids = [InputChannel(chat_id, 0)
|
||||||
InputChannel(chat_id, 0)
|
for chat_id, chat_type in self.chats.items()
|
||||||
for chat_id, chat_type in self.chats.items()
|
if chat_type == "channel"]
|
||||||
if chat_type == "channel"
|
|
||||||
]
|
|
||||||
for channel_id in channel_ids:
|
for channel_id in channel_ids:
|
||||||
try:
|
try:
|
||||||
await self.client(GetChannelsRequest([channel_id]))
|
await self.client(GetChannelsRequest([channel_id]))
|
||||||
except (ChannelPrivateError, ChannelInvalidError):
|
except (ChannelPrivateError, ChannelInvalidError):
|
||||||
await self.remove_chat(TelegramID(channel_id.channel_id))
|
self.remove_chat(TelegramID(channel_id.channel_id))
|
||||||
|
|
||||||
async def register_portal(self, portal: po.Portal) -> None:
|
if config["bridge.catch_up"]:
|
||||||
await self.add_chat(portal.tgid, portal.peer_type)
|
try:
|
||||||
|
await self.client.catch_up()
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Failed to run catch_up() for bot")
|
||||||
|
|
||||||
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
|
def register_portal(self, portal: po.Portal) -> None:
|
||||||
await self.remove_chat(tgid)
|
self.add_chat(portal.tgid, portal.peer_type)
|
||||||
|
|
||||||
async def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
|
def unregister_portal(self, portal: po.Portal) -> None:
|
||||||
|
self.remove_chat(portal.tgid)
|
||||||
|
|
||||||
|
def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
|
||||||
if chat_id not in self.chats:
|
if chat_id not in self.chats:
|
||||||
self.chats[chat_id] = chat_type
|
self.chats[chat_id] = chat_type
|
||||||
await BotChat(id=chat_id, type=chat_type).insert()
|
BotChat(id=TelegramID(chat_id), type=chat_type).insert()
|
||||||
|
|
||||||
async def remove_chat(self, chat_id: TelegramID) -> None:
|
def remove_chat(self, chat_id: TelegramID) -> None:
|
||||||
try:
|
try:
|
||||||
del self.chats[chat_id]
|
del self.chats[chat_id]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
await BotChat.delete_by_id(chat_id)
|
BotChat.delete(chat_id)
|
||||||
|
|
||||||
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
|
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
|
||||||
if tgid in self.tg_whitelist:
|
if tgid in self.tg_whitelist:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
user = await u.User.get_by_tgid(tgid)
|
user = u.User.get_by_tgid(tgid)
|
||||||
if user and user.is_admin:
|
if user and user.is_admin:
|
||||||
self.tg_whitelist.append(user.tgid)
|
self.tg_whitelist.append(user.tgid)
|
||||||
return True
|
return True
|
||||||
@@ -160,9 +139,7 @@ class Bot(AbstractUser):
|
|||||||
if self.whitelist_group_admins:
|
if self.whitelist_group_admins:
|
||||||
if isinstance(chat, PeerChannel):
|
if isinstance(chat, PeerChannel):
|
||||||
p = await self.client(GetParticipantRequest(chat, tgid))
|
p = await self.client(GetParticipantRequest(chat, tgid))
|
||||||
return isinstance(
|
return isinstance(p, (ChannelParticipantCreator, ChannelParticipantAdmin))
|
||||||
p.participant, (ChannelParticipantCreator, ChannelParticipantAdmin)
|
|
||||||
)
|
|
||||||
elif isinstance(chat, PeerChat):
|
elif isinstance(chat, PeerChat):
|
||||||
chat = await self.client(GetFullChatRequest(chat.chat_id))
|
chat = await self.client(GetFullChatRequest(chat.chat_id))
|
||||||
participants = chat.full_chat.participants.participants
|
participants = chat.full_chat.participants.participants
|
||||||
@@ -172,48 +149,45 @@ class Bot(AbstractUser):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def check_can_use_commands(self, event: Message, reply: ReplyFunc) -> bool:
|
async def check_can_use_commands(self, event: Message, reply: ReplyFunc) -> bool:
|
||||||
# FIXME event.from_id is not int
|
if not await self._can_use_commands(event.to_id, event.from_id):
|
||||||
if not await self._can_use_commands(event.to_id, TelegramID(event.from_id)):
|
|
||||||
await reply("You do not have the permission to use that command.")
|
await reply("You do not have the permission to use that command.")
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc) -> Message:
|
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc) -> Message:
|
||||||
if not self.config["bridge.relaybot.authless_portals"]:
|
if not config["bridge.relaybot.authless_portals"]:
|
||||||
return await reply("This bridge doesn't allow portal creation from Telegram.")
|
return await reply("This bridge doesn't allow portal creation from Telegram.")
|
||||||
|
|
||||||
if not portal.allow_bridging:
|
if not portal.allow_bridging():
|
||||||
return await reply("This bridge doesn't allow bridging this chat.")
|
return await reply("This bridge doesn't allow bridging this chat.")
|
||||||
|
|
||||||
await portal.create_matrix_room(self)
|
await portal.create_matrix_room(self)
|
||||||
if portal.mxid:
|
if portal.mxid:
|
||||||
if portal.username:
|
if portal.username:
|
||||||
return await reply(
|
return await reply(
|
||||||
f"Portal is public: [{portal.alias}](https://matrix.to/#/{portal.alias})"
|
f"Portal is public: [{portal.alias}](https://matrix.to/#/{portal.alias})")
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return await reply("Portal is not public. Use `/invite <mxid>` to get an invite.")
|
return await reply(
|
||||||
|
"Portal is not public. Use `/invite <mxid>` to get an invite.")
|
||||||
|
|
||||||
async def handle_command_invite(
|
async def handle_command_invite(self, portal: po.Portal, reply: ReplyFunc,
|
||||||
self, portal: po.Portal, reply: ReplyFunc, mxid_input: UserID
|
mxid_input: MatrixUserID) -> Message:
|
||||||
) -> Message:
|
|
||||||
if len(mxid_input) == 0:
|
if len(mxid_input) == 0:
|
||||||
return await reply("Usage: `/invite <mxid>`")
|
return await reply("Usage: `/invite <mxid>`")
|
||||||
elif not portal.mxid:
|
elif not portal.mxid:
|
||||||
return await reply("Portal does not have Matrix room. Create one with /portal first.")
|
return await reply("Portal does not have Matrix room. "
|
||||||
if mxid_input[0] != "@" or mxid_input.find(":") < 2:
|
"Create one with /portal first.")
|
||||||
|
if not self.mxid_regex.match(mxid_input):
|
||||||
return await reply("That doesn't look like a Matrix ID.")
|
return await reply("That doesn't look like a Matrix ID.")
|
||||||
user = await u.User.get_and_start_by_mxid(mxid_input)
|
user = await u.User.get_by_mxid(MatrixUserID(mxid_input)).ensure_started()
|
||||||
if not user.relaybot_whitelisted:
|
if not user.relaybot_whitelisted:
|
||||||
return await reply("That user is not whitelisted to use the bridge.")
|
return await reply("That user is not whitelisted to use the bridge.")
|
||||||
elif await user.is_logged_in():
|
elif await user.is_logged_in():
|
||||||
displayname = f"@{user.tg_username}" if user.tg_username else user.displayname
|
displayname = f"@{user.username}" if user.username else user.displayname
|
||||||
return await reply(
|
return await reply("That user seems to be logged in. "
|
||||||
"That user seems to be logged in. "
|
f"Just invite [{displayname}](tg://user?id={user.tgid})")
|
||||||
f"Just invite [{displayname}](tg://user?id={user.tgid})"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
await portal.invite_to_matrix(user.mxid)
|
await portal.main_intent.invite(portal.mxid, user.mxid)
|
||||||
return await reply(f"Invited `{user.mxid}` to the portal.")
|
return await reply(f"Invited `{user.mxid}` to the portal.")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -222,21 +196,12 @@ class Bot(AbstractUser):
|
|||||||
# chat is a normal group or a supergroup/channel when using the ID.
|
# chat is a normal group or a supergroup/channel when using the ID.
|
||||||
if isinstance(message.to_id, PeerChannel):
|
if isinstance(message.to_id, PeerChannel):
|
||||||
return reply(f"-100{message.to_id.channel_id}")
|
return reply(f"-100{message.to_id.channel_id}")
|
||||||
elif isinstance(message.to_id, PeerChat):
|
return reply(str(-message.to_id.chat_id))
|
||||||
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 match_command(self, text: str, command: str) -> bool:
|
def match_command(self, text: str, command: str) -> bool:
|
||||||
text = text.lower()
|
text = text.lower()
|
||||||
command = f"/{command.lower()}"
|
command = f"/{command.lower()}"
|
||||||
command_targeted = f"{command}@{self.tg_username.lower()}"
|
command_targeted = f"{command}@{self.username.lower()}"
|
||||||
|
|
||||||
is_plain_command = text == command or text == command_targeted
|
is_plain_command = text == command or text == command_targeted
|
||||||
if is_plain_command:
|
if is_plain_command:
|
||||||
@@ -250,73 +215,62 @@ class Bot(AbstractUser):
|
|||||||
|
|
||||||
async def handle_command(self, message: Message) -> None:
|
async def handle_command(self, message: Message) -> None:
|
||||||
def reply(reply_text: str) -> Awaitable[Message]:
|
def reply(reply_text: str) -> Awaitable[Message]:
|
||||||
return self.client.send_message(message.chat_id, reply_text, reply_to=message.id)
|
return self.client.send_message(message.to_id, reply_text, reply_to=message.id)
|
||||||
|
|
||||||
text = message.message
|
text = message.message
|
||||||
|
|
||||||
if self.match_command(text, "start"):
|
if self.match_command(text, "id"):
|
||||||
pcm = self.config["bridge.relaybot.private_chat.message"]
|
|
||||||
if pcm:
|
|
||||||
await reply(pcm)
|
|
||||||
return
|
|
||||||
elif self.match_command(text, "id"):
|
|
||||||
await self.handle_command_id(message, reply)
|
await self.handle_command_id(message, reply)
|
||||||
return
|
return
|
||||||
elif message.is_private:
|
|
||||||
return
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_entity(message.to_id)
|
portal = po.Portal.get_by_entity(message.to_id)
|
||||||
|
|
||||||
is_portal_cmd = self.match_command(text, "portal")
|
if self.match_command(text, "portal"):
|
||||||
is_invite_cmd = self.match_command(text, "invite")
|
|
||||||
if is_portal_cmd or is_invite_cmd:
|
|
||||||
if not await self.check_can_use_commands(message, reply):
|
if not await self.check_can_use_commands(message, reply):
|
||||||
return
|
return
|
||||||
if is_portal_cmd:
|
await self.handle_command_portal(portal, reply)
|
||||||
await self.handle_command_portal(portal, reply)
|
elif self.match_command(text, "invite"):
|
||||||
elif is_invite_cmd:
|
if not await self.check_can_use_commands(message, reply):
|
||||||
try:
|
return
|
||||||
mxid = text[text.index(" ") + 1 :]
|
try:
|
||||||
except ValueError:
|
mxid = text[text.index(" ") + 1:]
|
||||||
mxid = ""
|
except ValueError:
|
||||||
await self.handle_command_invite(portal, reply, mxid_input=UserID(mxid))
|
mxid = ""
|
||||||
|
await self.handle_command_invite(portal, reply, mxid_input=mxid)
|
||||||
|
|
||||||
async def handle_service_message(self, message: MessageService) -> None:
|
def handle_service_message(self, message: MessageService) -> None:
|
||||||
to_peer = message.to_id
|
to_id = message.to_id # type: TelegramID
|
||||||
if isinstance(to_peer, PeerChannel):
|
if isinstance(to_id, PeerChannel):
|
||||||
to_id = TelegramID(to_peer.channel_id)
|
to_id = to_id.channel_id
|
||||||
chat_type = "channel"
|
chat_type = "channel"
|
||||||
elif isinstance(to_peer, PeerChat):
|
elif isinstance(to_id, PeerChat):
|
||||||
to_id = TelegramID(to_peer.chat_id)
|
to_id = to_id.chat_id
|
||||||
chat_type = "chat"
|
chat_type = "chat"
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
|
|
||||||
action = message.action
|
action = message.action
|
||||||
if isinstance(action, MessageActionChatAddUser) and self.tgid in action.users:
|
if isinstance(action, MessageActionChatAddUser) and self.tgid in action.users:
|
||||||
await self.add_chat(to_id, chat_type)
|
self.add_chat(to_id, chat_type)
|
||||||
elif isinstance(action, MessageActionChatDeleteUser) and action.user_id == self.tgid:
|
elif isinstance(action, MessageActionChatDeleteUser) and action.user_id == self.tgid:
|
||||||
await self.remove_chat(to_id)
|
self.remove_chat(to_id)
|
||||||
elif isinstance(action, MessageActionChatMigrateTo):
|
elif isinstance(action, MessageActionChatMigrateTo):
|
||||||
await self.remove_chat(to_id)
|
self.remove_chat(to_id)
|
||||||
await self.add_chat(TelegramID(action.channel_id), "channel")
|
self.add_chat(TelegramID(action.channel_id), "channel")
|
||||||
|
|
||||||
async def update(self, update) -> bool:
|
async def update(self, update) -> bool:
|
||||||
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
|
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
|
||||||
return False
|
return False
|
||||||
if isinstance(update.message, MessageService):
|
if isinstance(update.message, MessageService):
|
||||||
await self.handle_service_message(update.message)
|
self.handle_service_message(update.message)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
is_command = (
|
is_command = (isinstance(update.message, Message)
|
||||||
isinstance(update.message, Message)
|
and update.message.entities and len(update.message.entities) > 0
|
||||||
and update.message.entities
|
and isinstance(update.message.entities[0], MessageEntityBotCommand))
|
||||||
and len(update.message.entities) > 0
|
|
||||||
and isinstance(update.message.entities[0], MessageEntityBotCommand)
|
|
||||||
and update.message.entities[0].offset == 0
|
|
||||||
)
|
|
||||||
if is_command:
|
if is_command:
|
||||||
await self.handle_command(update.message)
|
await self.handle_command(update.message)
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def is_in_chat(self, peer_id) -> bool:
|
def is_in_chat(self, peer_id) -> bool:
|
||||||
@@ -325,3 +279,12 @@ class Bot(AbstractUser):
|
|||||||
@property
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "bot"
|
return "bot"
|
||||||
|
|
||||||
|
|
||||||
|
def init(cfg: 'Config') -> Optional[Bot]:
|
||||||
|
global config
|
||||||
|
config = cfg
|
||||||
|
token = config["telegram.bot_token"]
|
||||||
|
if token and not token.lower().startswith("disable"):
|
||||||
|
return Bot(token)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,26 +1,5 @@
|
|||||||
from .handler import (
|
from .handler import (command_handler, command_handlers as _command_handlers,
|
||||||
SECTION_ADMIN,
|
CommandHandler, CommandProcessor, CommandEvent,
|
||||||
SECTION_AUTH,
|
SECTION_GENERAL, SECTION_AUTH, SECTION_CREATING_PORTALS,
|
||||||
SECTION_CREATING_PORTALS,
|
SECTION_PORTAL_MANAGEMENT, SECTION_MISC, SECTION_ADMIN)
|
||||||
SECTION_MISC,
|
from . import portal, telegram, clean_rooms, matrix_auth, meta
|
||||||
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",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
|
# Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
|
||||||
|
from typing import Dict, List, NewType, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from mautrix_appservice import MatrixRequestError, IntentAPI
|
||||||
|
|
||||||
|
from ..types import MatrixRoomID, MatrixUserID
|
||||||
|
from . import command_handler, CommandEvent, SECTION_ADMIN
|
||||||
|
from .. import puppet as pu, portal as po
|
||||||
|
|
||||||
|
ManagementRoom = NewType('ManagementRoom', Tuple[MatrixRoomID, MatrixUserID])
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[MatrixRoomID],
|
||||||
|
List['po.Portal'], List['po.Portal']]:
|
||||||
|
management_rooms = [] # type: List[ManagementRoom]
|
||||||
|
unidentified_rooms = [] # type: List[MatrixRoomID]
|
||||||
|
portals = [] # type: List[po.Portal]
|
||||||
|
empty_portals = [] # type: List[po.Portal]
|
||||||
|
|
||||||
|
rooms = await intent.get_joined_rooms()
|
||||||
|
for room_str in rooms:
|
||||||
|
room = MatrixRoomID(room_str)
|
||||||
|
portal = po.Portal.get_by_mxid(room)
|
||||||
|
if not portal:
|
||||||
|
try:
|
||||||
|
members = await intent.get_room_members(room)
|
||||||
|
except MatrixRequestError:
|
||||||
|
members = []
|
||||||
|
if len(members) == 2:
|
||||||
|
other_member = MatrixUserID(members[0] if members[0] != intent.mxid else members[1])
|
||||||
|
if pu.Puppet.get_id_from_mxid(other_member):
|
||||||
|
unidentified_rooms.append(room)
|
||||||
|
else:
|
||||||
|
management_rooms.append(ManagementRoom((room, other_member)))
|
||||||
|
else:
|
||||||
|
unidentified_rooms.append(room)
|
||||||
|
else:
|
||||||
|
members = await portal.get_authenticated_matrix_users()
|
||||||
|
if len(members) == 0:
|
||||||
|
empty_portals.append(portal)
|
||||||
|
else:
|
||||||
|
portals.append(portal)
|
||||||
|
|
||||||
|
return management_rooms, unidentified_rooms, portals, empty_portals
|
||||||
|
|
||||||
|
|
||||||
|
@command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms",
|
||||||
|
help_section=SECTION_ADMIN,
|
||||||
|
help_text="Clean up unused portal/management rooms.")
|
||||||
|
async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
|
||||||
|
management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent)
|
||||||
|
|
||||||
|
reply = ["#### Management rooms (M)"]
|
||||||
|
reply += ([f"{n+1}. [M{n+1}](https://matrix.to/#/{room}) (with {other_member}"
|
||||||
|
for n, (room, other_member) in enumerate(management_rooms)]
|
||||||
|
or ["No management rooms found."])
|
||||||
|
reply.append("#### Active portal rooms (A)")
|
||||||
|
reply += ([f"{n+1}. [A{n+1}](https://matrix.to/#/{portal.mxid}) "
|
||||||
|
f"(to Telegram chat \"{portal.title}\")"
|
||||||
|
for n, portal in enumerate(portals)]
|
||||||
|
or ["No active portal rooms found."])
|
||||||
|
reply.append("#### Unidentified rooms (U)")
|
||||||
|
reply += ([f"{n+1}. [U{n+1}](https://matrix.to/#/{room})"
|
||||||
|
for n, room in enumerate(unidentified_rooms)]
|
||||||
|
or ["No unidentified rooms found."])
|
||||||
|
reply.append("#### Inactive portal rooms (I)")
|
||||||
|
reply += ([f"{n}. [I{n}](https://matrix.to/#/{portal.mxid}) "
|
||||||
|
f"(to Telegram chat \"{portal.title}\")"
|
||||||
|
for n, portal in enumerate(empty_portals)]
|
||||||
|
or ["No inactive portal rooms found."])
|
||||||
|
|
||||||
|
reply += ["#### Usage",
|
||||||
|
("To clean the recommended set of rooms (unidentified & inactive portals), "
|
||||||
|
"type `$cmdprefix+sp clean-recommended`"),
|
||||||
|
"",
|
||||||
|
("To clean other groups of rooms, type `$cmdprefix+sp clean-groups <letters>` "
|
||||||
|
"where `letters` are the first letters of the group names (M, A, U, I)"),
|
||||||
|
"",
|
||||||
|
("To clean specific rooms, type `$cmdprefix+sp clean-range <range>` "
|
||||||
|
"where `range` is the range (e.g. `5-21`) prefixed with the first letter of"
|
||||||
|
"the group name. (e.g. `I2-6`)"),
|
||||||
|
"",
|
||||||
|
("Please note that you will have to re-run `$cmdprefix+sp clean-rooms` "
|
||||||
|
"between each use of the commands above.")]
|
||||||
|
|
||||||
|
evt.sender.command_status = {
|
||||||
|
"next": lambda clean_evt: set_rooms_to_clean(clean_evt, management_rooms,
|
||||||
|
unidentified_rooms, portals, empty_portals),
|
||||||
|
"action": "Room cleaning",
|
||||||
|
}
|
||||||
|
|
||||||
|
return await evt.reply("\n".join(reply))
|
||||||
|
|
||||||
|
|
||||||
|
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
|
||||||
|
unidentified_rooms: List[MatrixRoomID], portals: List["po.Portal"],
|
||||||
|
empty_portals: List["po.Portal"]) -> None:
|
||||||
|
command = evt.args[0]
|
||||||
|
rooms_to_clean = [] # type: List[Union[po.Portal, MatrixRoomID]]
|
||||||
|
if command == "clean-recommended":
|
||||||
|
rooms_to_clean += empty_portals
|
||||||
|
rooms_to_clean += unidentified_rooms
|
||||||
|
elif command == "clean-groups":
|
||||||
|
if len(evt.args) < 2:
|
||||||
|
return await evt.reply("**Usage:** `$cmdprefix+sp clean-groups [M][A][U][I]")
|
||||||
|
groups_to_clean = evt.args[1].upper()
|
||||||
|
if "M" in groups_to_clean:
|
||||||
|
rooms_to_clean += [room_id for (room_id, user_id) in management_rooms]
|
||||||
|
if "A" in groups_to_clean:
|
||||||
|
rooms_to_clean += portals
|
||||||
|
if "U" in groups_to_clean:
|
||||||
|
rooms_to_clean += unidentified_rooms
|
||||||
|
if "I" in groups_to_clean:
|
||||||
|
rooms_to_clean += empty_portals
|
||||||
|
elif command == "clean-range":
|
||||||
|
try:
|
||||||
|
clean_range = evt.args[1]
|
||||||
|
group, clean_range = clean_range[0], clean_range[1:]
|
||||||
|
start, end = clean_range.split("-")
|
||||||
|
start, end = int(start), int(end)
|
||||||
|
if group == "M":
|
||||||
|
group = [room_id for (room_id, user_id) in management_rooms]
|
||||||
|
elif group == "A":
|
||||||
|
group = portals
|
||||||
|
elif group == "U":
|
||||||
|
group = unidentified_rooms
|
||||||
|
elif group == "I":
|
||||||
|
group = empty_portals
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown group")
|
||||||
|
rooms_to_clean = group[start - 1:end]
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return await evt.reply(
|
||||||
|
"**Usage:** `$cmdprefix+sp clean-groups <_M|A|U|I_><range>")
|
||||||
|
else:
|
||||||
|
return await evt.reply(f"Unknown room cleaning action `{command}`. "
|
||||||
|
"Use `$cmdprefix+sp cancel` to cancel room "
|
||||||
|
"cleaning.")
|
||||||
|
|
||||||
|
evt.sender.command_status = {
|
||||||
|
"next": lambda confirm: execute_room_cleanup(confirm, rooms_to_clean),
|
||||||
|
"action": "Room cleaning",
|
||||||
|
}
|
||||||
|
await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type"
|
||||||
|
"`$cmdprefix+sp confirm-clean`.")
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, MatrixRoomID]]) -> None:
|
||||||
|
if len(evt.args) > 0 and evt.args[0] == "confirm-clean":
|
||||||
|
await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. "
|
||||||
|
"This might take a while.")
|
||||||
|
cleaned = 0
|
||||||
|
for room in rooms_to_clean:
|
||||||
|
if isinstance(room, po.Portal):
|
||||||
|
await room.cleanup_and_delete()
|
||||||
|
cleaned += 1
|
||||||
|
elif isinstance(room, str): # str is aliased by MatrixRoomID
|
||||||
|
await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted")
|
||||||
|
cleaned += 1
|
||||||
|
evt.sender.command_status = None
|
||||||
|
await evt.reply(f"{cleaned} rooms cleaned up successfully.")
|
||||||
|
else:
|
||||||
|
await evt.reply("Room cleaning cancelled.")
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,38 +14,24 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
"""This module contains classes handling commands issued by Matrix users."""
|
||||||
|
from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, NamedTuple
|
import commonmark
|
||||||
|
|
||||||
from telethon.errors import FloodWaitError
|
from telethon.errors import FloodWaitError
|
||||||
|
|
||||||
from mautrix.bridge.commands import (
|
from ..types import MatrixRoomID, MatrixEventID
|
||||||
CommandEvent as BaseCommandEvent,
|
from ..util import format_duration
|
||||||
CommandHandler as BaseCommandHandler,
|
from .. import user as u, context as c
|
||||||
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
|
command_handlers = {} # type: Dict[str, CommandHandler]
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
HelpSection = NamedTuple('HelpSection', [('name', str), ('order', int), ('description', str)])
|
||||||
|
|
||||||
|
SECTION_GENERAL = HelpSection("General", 0, "")
|
||||||
SECTION_AUTH = HelpSection("Authentication", 10, "")
|
SECTION_AUTH = HelpSection("Authentication", 10, "")
|
||||||
SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "")
|
SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "")
|
||||||
SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "")
|
SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "")
|
||||||
@@ -52,143 +39,327 @@ SECTION_MISC = HelpSection("Miscellaneous", 40, "")
|
|||||||
SECTION_ADMIN = HelpSection("Administration", 50, "")
|
SECTION_ADMIN = HelpSection("Administration", 50, "")
|
||||||
|
|
||||||
|
|
||||||
class CommandEvent(BaseCommandEvent):
|
class HtmlEscapingRenderer(commonmark.HtmlRenderer):
|
||||||
sender: u.User
|
def __init__(self, allow_html: bool = False):
|
||||||
portal: po.Portal
|
super().__init__()
|
||||||
|
self.allow_html = allow_html
|
||||||
|
|
||||||
def __init__(
|
def lit(self, s):
|
||||||
self,
|
if self.allow_html:
|
||||||
processor: CommandProcessor,
|
return super().lit(s)
|
||||||
room_id: RoomID,
|
return super().lit(s.replace("<", "<").replace(">", ">"))
|
||||||
event_id: EventID,
|
|
||||||
sender: u.User,
|
def image(self, node, entering):
|
||||||
command: str,
|
prev = self.allow_html
|
||||||
args: list[str],
|
self.allow_html = True
|
||||||
content: MessageEventContent,
|
super().image(node, entering)
|
||||||
portal: po.Portal | None,
|
self.allow_html = prev
|
||||||
is_management: bool,
|
|
||||||
has_bridge_bot: bool,
|
|
||||||
) -> None:
|
md_parser = commonmark.Parser()
|
||||||
super().__init__(
|
md_renderer = HtmlEscapingRenderer()
|
||||||
processor,
|
|
||||||
room_id,
|
|
||||||
event_id,
|
def ensure_trailing_newline(s: str) -> str:
|
||||||
sender,
|
"""Returns the passed string, but with a guaranteed trailing newline."""
|
||||||
command,
|
return s + ("" if s[-1] == "\n" else "\n")
|
||||||
args,
|
|
||||||
content,
|
|
||||||
portal,
|
class CommandEvent:
|
||||||
is_management,
|
"""Holds information about a command issued in a Matrix room.
|
||||||
has_bridge_bot,
|
|
||||||
)
|
When a Matrix command was issued to the bot, CommandEvent will hold
|
||||||
self.bridge = processor.bridge
|
information regarding the event.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
room_id: The id of the Matrix room in which the command was issued.
|
||||||
|
event_id: The id of the matrix event which contained the command.
|
||||||
|
sender: The user who issued the command.
|
||||||
|
command: The issued command.
|
||||||
|
args: Arguments given with the issued command.
|
||||||
|
is_management: Determines whether the room in which the command wa
|
||||||
|
issued is a management room.
|
||||||
|
is_portal: Determines whether the room in which the command was issued
|
||||||
|
is a portal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, processor: 'CommandProcessor', room: MatrixRoomID, event: MatrixEventID,
|
||||||
|
sender: u.User, command: str, args: List[str], is_management: bool,
|
||||||
|
is_portal: bool) -> None:
|
||||||
|
self.az = processor.az
|
||||||
|
self.log = processor.log
|
||||||
|
self.loop = processor.loop
|
||||||
self.tgbot = processor.tgbot
|
self.tgbot = processor.tgbot
|
||||||
self.config = processor.config
|
self.config = processor.config
|
||||||
self.public_website = processor.public_website
|
self.public_website = processor.public_website
|
||||||
|
self.command_prefix = processor.command_prefix
|
||||||
|
self.room_id = room
|
||||||
|
self.event_id = event
|
||||||
|
self.sender = sender
|
||||||
|
self.command = command
|
||||||
|
self.args = args
|
||||||
|
self.is_management = is_management
|
||||||
|
self.is_portal = is_portal
|
||||||
|
|
||||||
@property
|
def reply(self, message: str, allow_html: bool = False, render_markdown: bool = True
|
||||||
def print_error_traceback(self) -> bool:
|
) -> Awaitable[Dict]:
|
||||||
return self.sender.is_admin
|
"""Write a reply to the room in which the command was issued.
|
||||||
|
|
||||||
async def get_help_key(self) -> HelpCacheKey:
|
Replaces occurences of "$cmdprefix" in the message with the command
|
||||||
return HelpCacheKey(
|
prefix and replaces occurences of "$cmdprefix+sp " with the command
|
||||||
self.is_management,
|
prefix if the command was not issued in a management room.
|
||||||
self.portal is not None,
|
If allow_html and render_markdown are both False, the message will not
|
||||||
self.sender.puppet_whitelisted,
|
be rendered to html and sending of html is disabled.
|
||||||
self.sender.matrix_puppet_whitelisted,
|
|
||||||
self.sender.is_admin,
|
Args:
|
||||||
await self.sender.is_logged_in(),
|
message: The message to post in the room.
|
||||||
|
allow_html: Escape html in the message or don't render html at all
|
||||||
|
if markdown is disabled.
|
||||||
|
render_markdown: Use markdown formatting to render the passed
|
||||||
|
message to html.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Handler for the message sending function.
|
||||||
|
"""
|
||||||
|
message_cmd = self._replace_command_prefix(message)
|
||||||
|
html = self._render_message(message_cmd, allow_html=allow_html,
|
||||||
|
render_markdown=render_markdown)
|
||||||
|
|
||||||
|
return self.az.intent.send_notice(self.room_id, message_cmd, html=html)
|
||||||
|
|
||||||
|
def mark_read(self) -> Awaitable[Dict]:
|
||||||
|
"""Marks the command as read by the bot."""
|
||||||
|
return self.az.intent.mark_read(self.room_id, self.event_id)
|
||||||
|
|
||||||
|
def _replace_command_prefix(self, message: str) -> str:
|
||||||
|
"""Returns the string with the proper command prefix entered."""
|
||||||
|
message = message.replace(
|
||||||
|
"$cmdprefix+sp ", "" if self.is_management else f"{self.command_prefix} "
|
||||||
)
|
)
|
||||||
|
return message.replace("$cmdprefix", self.command_prefix)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _render_message(message: str, allow_html: bool, render_markdown: bool) -> Optional[str]:
|
||||||
|
"""Renders the message as HTML.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
allow_html: Flag to allow custom HTML in the message.
|
||||||
|
render_markdown: If true, markdown styling is applied to the message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The message rendered as HTML.
|
||||||
|
None is returned if no styled output is required.
|
||||||
|
"""
|
||||||
|
html = ""
|
||||||
|
if render_markdown:
|
||||||
|
md_renderer.allow_html = allow_html
|
||||||
|
html = md_renderer.render(md_parser.parse(message))
|
||||||
|
elif allow_html:
|
||||||
|
html = message
|
||||||
|
return ensure_trailing_newline(html) if html else None
|
||||||
|
|
||||||
|
|
||||||
class CommandHandler(BaseCommandHandler):
|
class CommandHandler:
|
||||||
name: str
|
"""A command which can be executed from a Matrix room.
|
||||||
|
|
||||||
needs_puppeting: bool
|
The command manages its permission and help texts.
|
||||||
needs_matrix_puppeting: bool
|
When called, it will check the permission of the command event and execute
|
||||||
|
the command or, in case of error, report back to the user.
|
||||||
|
|
||||||
def __init__(
|
Attributes:
|
||||||
self,
|
needs_auth: Flag indicating if the sender is required to be logged in.
|
||||||
handler: Callable[[CommandEvent], Awaitable[EventID]],
|
needs_puppeting: Flag indicating if the sender is required to use
|
||||||
management_only: bool,
|
Telegram puppeteering for this command.
|
||||||
name: str,
|
needs_matrix_puppeting: Flag indicating if the sender is required to use
|
||||||
help_text: str,
|
Matrix pupeteering.
|
||||||
help_args: str,
|
needs_admin: Flag for whether only admin users can issue this command.
|
||||||
help_section: HelpSection,
|
management_only: Whether the command can exclusively be issued in a
|
||||||
needs_auth: bool,
|
management room.
|
||||||
needs_puppeting: bool,
|
name: The name of this command.
|
||||||
needs_matrix_puppeting: bool,
|
help_section: Section of the help in which this command will appear.
|
||||||
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:
|
def __init__(self, handler: Callable[[CommandEvent], Awaitable[Dict]], needs_auth: bool,
|
||||||
if self.needs_puppeting and not evt.sender.puppet_whitelisted:
|
needs_puppeting: bool, needs_matrix_puppeting: bool, needs_admin: bool,
|
||||||
|
management_only: bool, name: str, help_text: str, help_args: str,
|
||||||
|
help_section: HelpSection) -> None:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
handler: The function handling the execution of this command.
|
||||||
|
needs_auth: Flag indicating if the sender is required to be logged in.
|
||||||
|
needs_puppeting: Flag indicating if the sender is required to use
|
||||||
|
Telegram puppeteering for this command.
|
||||||
|
needs_matrix_puppeting: Flag indicating if the sender is required to
|
||||||
|
use Matrix pupeteering.
|
||||||
|
needs_admin: Flag for whether only admin users can issue this command.
|
||||||
|
management_only: Whether the command can exclusively be issued
|
||||||
|
in a management room.
|
||||||
|
name: The name of this command.
|
||||||
|
help_text: The text displayed in the help for this command.
|
||||||
|
help_args: Help text for the arguments of this command.
|
||||||
|
help_section: Section of the help in which this command will appear.
|
||||||
|
"""
|
||||||
|
self._handler = handler
|
||||||
|
self.needs_auth = needs_auth
|
||||||
|
self.needs_puppeting = needs_puppeting
|
||||||
|
self.needs_matrix_puppeting = needs_matrix_puppeting
|
||||||
|
self.needs_admin = needs_admin
|
||||||
|
self.management_only = management_only
|
||||||
|
self.name = name
|
||||||
|
self._help_text = help_text
|
||||||
|
self._help_args = help_args
|
||||||
|
self.help_section = help_section
|
||||||
|
|
||||||
|
async def get_permission_error(self, evt: CommandEvent) -> Optional[str]:
|
||||||
|
"""Returns the reason why the command could not be issued.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
evt: The event for which to get the error information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A string describing the error or None if there was no error.
|
||||||
|
"""
|
||||||
|
if self.management_only and not evt.is_management:
|
||||||
|
return (f"`{evt.command}` is a restricted command: "
|
||||||
|
"you may only run it in management rooms.")
|
||||||
|
elif self.needs_puppeting and not evt.sender.puppet_whitelisted:
|
||||||
return "This command requires puppeting privileges."
|
return "This command requires puppeting privileges."
|
||||||
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
|
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
|
||||||
return "This command requires Matrix puppeting privileges."
|
return "This command requires Matrix puppeting privileges."
|
||||||
return await super().get_permission_error(evt)
|
elif self.needs_admin and not evt.sender.is_admin:
|
||||||
|
return "This command requires administrator privileges."
|
||||||
|
elif self.needs_auth and not await evt.sender.is_logged_in():
|
||||||
|
return "This command requires you to be logged in."
|
||||||
|
return None
|
||||||
|
|
||||||
def has_permission(self, key: HelpCacheKey) -> bool:
|
def has_permission(self, is_management: bool, puppet_whitelisted: bool,
|
||||||
return (
|
matrix_puppet_whitelisted: bool, is_admin: bool, is_logged_in: bool) -> bool:
|
||||||
super().has_permission(key)
|
"""Checks the permission for this command with the given status.
|
||||||
and (not self.needs_puppeting or key.puppet_whitelisted)
|
|
||||||
and (not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted)
|
Args:
|
||||||
)
|
is_management: If the room in which the command will be issued is a
|
||||||
|
management room.
|
||||||
|
puppet_whitelisted: If the connected Telegram account puppet is
|
||||||
|
allowed to issue the command.
|
||||||
|
matrix_puppet_whitelisted: If the connected Matrix account puppet is
|
||||||
|
allowed to issue the command.
|
||||||
|
is_admin: If the issuing user is an admin.
|
||||||
|
is_logged_in: If the issuing user is logged in.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a user with the given state is allowed to issue the
|
||||||
|
command.
|
||||||
|
"""
|
||||||
|
return ((not self.management_only or is_management) and
|
||||||
|
(not self.needs_puppeting or puppet_whitelisted) and
|
||||||
|
(not self.needs_matrix_puppeting or matrix_puppet_whitelisted) and
|
||||||
|
(not self.needs_admin or is_admin) and
|
||||||
|
(not self.needs_auth or is_logged_in))
|
||||||
|
|
||||||
|
async def __call__(self, evt: CommandEvent) -> Dict:
|
||||||
|
"""Executes the command if evt was issued with proper rights.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
evt: The CommandEvent for which to check permissions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The result of the command or the error message function.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FloodWaitError
|
||||||
|
"""
|
||||||
|
error = await self.get_permission_error(evt)
|
||||||
|
if error is not None:
|
||||||
|
return await evt.reply(error)
|
||||||
|
return await self._handler(evt)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_help(self) -> bool:
|
||||||
|
"""Returns true if this command has a help text."""
|
||||||
|
return bool(self.help_section) and bool(self._help_text)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def help(self) -> str:
|
||||||
|
"""Returns the help text to this command."""
|
||||||
|
return f"**{self.name}** {self._help_args} - {self._help_text}"
|
||||||
|
|
||||||
|
|
||||||
def command_handler(
|
def command_handler(_func: Optional[Callable[[CommandEvent], Awaitable[Dict]]] = None, *,
|
||||||
_func: CommandHandlerFunc | None = None,
|
needs_auth: bool = True, needs_puppeting: bool = True,
|
||||||
*,
|
needs_matrix_puppeting: bool = False, needs_admin: bool = False,
|
||||||
needs_auth: bool = True,
|
management_only: bool = False, name: Optional[str] = None,
|
||||||
needs_puppeting: bool = True,
|
help_text: str = "", help_args: str = "", help_section: HelpSection = None
|
||||||
needs_matrix_puppeting: bool = False,
|
) -> Callable[[Callable[[CommandEvent], Awaitable[Optional[Dict]]]],
|
||||||
needs_admin: bool = False,
|
CommandHandler]:
|
||||||
management_only: bool = False,
|
def decorator(func: Callable[[CommandEvent], Awaitable[Optional[Dict]]]) -> CommandHandler:
|
||||||
name: str | None = None,
|
actual_name = name or func.__name__.replace("_", "-")
|
||||||
help_text: str = "",
|
handler = CommandHandler(func, needs_auth, needs_puppeting, needs_matrix_puppeting,
|
||||||
help_args: str = "",
|
needs_admin, management_only, actual_name, help_text, help_args,
|
||||||
help_section: HelpSection = None,
|
help_section)
|
||||||
) -> Callable[[CommandHandlerFunc], CommandHandler]:
|
command_handlers[handler.name] = handler
|
||||||
return base_command_handler(
|
return handler
|
||||||
_func,
|
|
||||||
_handler_class=CommandHandler,
|
return decorator if _func is None else decorator(_func)
|
||||||
name=name,
|
|
||||||
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):
|
class CommandProcessor:
|
||||||
def __init__(self, bridge: "TelegramBridge") -> None:
|
"""Handles the raw commands issued by a user to the Matrix bot."""
|
||||||
super().__init__(event_class=CommandEvent, bridge=bridge)
|
log = logging.getLogger("mau.commands")
|
||||||
self.tgbot = bridge.bot
|
|
||||||
self.public_website = bridge.public_website
|
|
||||||
|
|
||||||
@staticmethod
|
def __init__(self, context: c.Context) -> None:
|
||||||
async def _run_handler(
|
self.az, self.config, self.loop, self.tgbot = context.core
|
||||||
handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent
|
self.public_website = context.public_website
|
||||||
) -> Any:
|
self.command_prefix = self.config["bridge.command_prefix"]
|
||||||
|
|
||||||
|
async def handle(self, room: MatrixRoomID, event_id: MatrixEventID, sender: u.User,
|
||||||
|
command: str, args: List[str], is_management: bool, is_portal: bool
|
||||||
|
) -> Optional[Dict]:
|
||||||
|
"""Handles the raw commands issued by a user to the Matrix bot.
|
||||||
|
|
||||||
|
If the command is not known, it might be a followup command and is
|
||||||
|
delegated to a command handler registered for that purpose in the
|
||||||
|
senders command_status as "next".
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room: ID of the Matrix room in which the command was issued.
|
||||||
|
event_id: ID of the event by which the command was issued.
|
||||||
|
sender: The sender who issued the command.
|
||||||
|
command: The issued command, case insensitive.
|
||||||
|
args: Arguments given with the command.
|
||||||
|
is_management: Whether the room is a management room.
|
||||||
|
is_portal: Whether the room is a portal.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The result of the error message function or None if no error
|
||||||
|
occured. Unknown and delegated commands do not count as errors.
|
||||||
|
"""
|
||||||
|
if not command_handlers or "unknown-command" not in command_handlers:
|
||||||
|
raise ValueError("command_handlers are not properly initialized.")
|
||||||
|
|
||||||
|
evt = CommandEvent(self, room, event_id, sender, command, args, is_management, is_portal)
|
||||||
|
orig_command = command
|
||||||
|
command = command.lower()
|
||||||
try:
|
try:
|
||||||
return await handler(evt)
|
handler = command_handlers[command]
|
||||||
|
except KeyError:
|
||||||
|
if sender.command_status and "next" in sender.command_status:
|
||||||
|
args.insert(0, orig_command)
|
||||||
|
evt.command = ""
|
||||||
|
handler = sender.command_status["next"]
|
||||||
|
else:
|
||||||
|
handler = command_handlers["unknown-command"]
|
||||||
|
try:
|
||||||
|
await handler(evt)
|
||||||
except FloodWaitError as e:
|
except FloodWaitError as e:
|
||||||
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
|
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Unhandled error while handling command "
|
||||||
|
f"{evt.command} {' '.join(args)} from {sender.mxid}")
|
||||||
|
if evt.sender.is_admin and evt.is_management:
|
||||||
|
return await evt.reply("Unhandled error while handling command:\n\n"
|
||||||
|
"```traceback\n"
|
||||||
|
f"{traceback.format_exc()}"
|
||||||
|
"```")
|
||||||
|
return await evt.reply("Unhandled error while handling command. "
|
||||||
|
"Check logs for more details.")
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,27 +14,33 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
|
from typing import Dict, Optional
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
|
from . import command_handler, CommandEvent, SECTION_AUTH
|
||||||
from .. import puppet as pu
|
from .. import puppet as pu
|
||||||
from . import SECTION_AUTH, CommandEvent, command_handler
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
|
||||||
needs_auth=True,
|
help_section=SECTION_AUTH,
|
||||||
management_only=True,
|
help_text="Revert your Telegram account's Matrix puppet to use the default Matrix "
|
||||||
needs_matrix_puppeting=True,
|
"account.")
|
||||||
help_section=SECTION_AUTH,
|
async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
|
||||||
help_text="Replace your Telegram account's Matrix puppet with your own Matrix account.",
|
puppet = pu.Puppet.get(evt.sender.tgid)
|
||||||
)
|
if not puppet.is_real_user:
|
||||||
async def login_matrix(evt: CommandEvent) -> EventID:
|
return await evt.reply("You are not logged in with your Matrix account.")
|
||||||
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
|
await puppet.switch_mxid(None, None)
|
||||||
|
return await evt.reply("Reverted your Telegram account's Matrix puppet back to the default.")
|
||||||
|
|
||||||
|
|
||||||
|
@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) -> Optional[Dict]:
|
||||||
|
puppet = pu.Puppet.get(evt.sender.tgid)
|
||||||
if puppet.is_real_user:
|
if puppet.is_real_user:
|
||||||
return await evt.reply(
|
return await evt.reply("You have already logged in with your Matrix account. "
|
||||||
"You have already logged in with your Matrix account. "
|
"Log out with `$cmdprefix+sp logout-matrix` first.")
|
||||||
"Log out with `$cmdprefix+sp logout-matrix` first."
|
|
||||||
)
|
|
||||||
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
|
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
|
||||||
if allow_matrix_login:
|
if allow_matrix_login:
|
||||||
evt.sender.command_status = {
|
evt.sender.command_status = {
|
||||||
@@ -51,35 +58,44 @@ async def login_matrix(evt: CommandEvent) -> EventID:
|
|||||||
"here.\n"
|
"here.\n"
|
||||||
f"If you would like to log in outside of Matrix, [click here]({url}).\n\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 "
|
"Logging in outside of Matrix is recommended, because in-Matrix login would save "
|
||||||
"your access token in the message history."
|
"your access token in the message history.")
|
||||||
)
|
return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n"
|
||||||
return await evt.reply(
|
f"Please visit [the login page]({url}) to log in.")
|
||||||
"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:
|
elif allow_matrix_login:
|
||||||
return await evt.reply(
|
return await evt.reply(
|
||||||
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
|
"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."
|
"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.")
|
return await evt.reply("This bridge instance has been configured to not allow logging in.")
|
||||||
|
|
||||||
|
|
||||||
async def enter_matrix_token(evt: CommandEvent) -> EventID:
|
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
|
||||||
|
help_section=SECTION_AUTH,
|
||||||
|
help_text="Pings the server with the stored matrix authentication.")
|
||||||
|
async def ping_matrix(evt: CommandEvent) -> Optional[Dict]:
|
||||||
|
puppet = pu.Puppet.get(evt.sender.tgid)
|
||||||
|
if not puppet.is_real_user:
|
||||||
|
return await evt.reply("You are not logged in with your Matrix account.")
|
||||||
|
resp = await puppet.init_custom_mxid()
|
||||||
|
if resp == pu.PuppetError.InvalidAccessToken:
|
||||||
|
return await evt.reply("Your access token is invalid.")
|
||||||
|
elif resp == pu.PuppetError.Success:
|
||||||
|
return await evt.reply("Your Matrix login is working.")
|
||||||
|
return await evt.reply(f"Unknown response while checking your Matrix login: {resp}.")
|
||||||
|
|
||||||
|
|
||||||
|
async def enter_matrix_token(evt: CommandEvent) -> Dict:
|
||||||
evt.sender.command_status = None
|
evt.sender.command_status = None
|
||||||
|
|
||||||
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
|
puppet = pu.Puppet.get(evt.sender.tgid)
|
||||||
if puppet.is_real_user:
|
if puppet.is_real_user:
|
||||||
return await evt.reply(
|
return await evt.reply("You have already logged in with your Matrix account. "
|
||||||
"You have already logged in with your Matrix account. "
|
"Log out with `$cmdprefix+sp logout-matrix` first.")
|
||||||
"Log out with `$cmdprefix+sp logout-matrix` first."
|
|
||||||
)
|
resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
|
||||||
try:
|
if resp == pu.PuppetError.OnlyLoginSelf:
|
||||||
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.")
|
return await evt.reply("You can only log in as your own Matrix user.")
|
||||||
except InvalidAccessToken:
|
elif resp == pu.PuppetError.InvalidAccessToken:
|
||||||
return await evt.reply("Failed to verify access token.")
|
return await evt.reply("Failed to verify access token.")
|
||||||
|
assert resp == pu.PuppetError.Success, "Encountered an unhandled PuppetError."
|
||||||
return await evt.reply(
|
return await evt.reply(
|
||||||
"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}."
|
f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.")
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
|
# Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from . import command_handler, CommandEvent, _command_handlers, SECTION_GENERAL
|
||||||
|
from .handler import HelpSection
|
||||||
|
|
||||||
|
|
||||||
|
@command_handler(needs_auth=False, needs_puppeting=False,
|
||||||
|
help_section=SECTION_GENERAL,
|
||||||
|
help_text="Cancel an ongoing action (such as login)")
|
||||||
|
async def cancel(evt: CommandEvent) -> Optional[Dict]:
|
||||||
|
if evt.sender.command_status:
|
||||||
|
action = evt.sender.command_status["action"]
|
||||||
|
evt.sender.command_status = None
|
||||||
|
return await evt.reply(f"{action} cancelled.")
|
||||||
|
else:
|
||||||
|
return await evt.reply("No ongoing command.")
|
||||||
|
|
||||||
|
|
||||||
|
@command_handler(needs_auth=False, needs_puppeting=False)
|
||||||
|
async def unknown_command(evt: CommandEvent) -> Optional[Dict]:
|
||||||
|
return await evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
|
||||||
|
|
||||||
|
|
||||||
|
help_cache = {} # type: Dict[Tuple[bool, bool, bool, bool, bool], str]
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_help_text(evt: CommandEvent) -> str:
|
||||||
|
cache_key = (evt.is_management, evt.sender.puppet_whitelisted,
|
||||||
|
evt.sender.matrix_puppet_whitelisted, evt.sender.is_admin,
|
||||||
|
await evt.sender.is_logged_in())
|
||||||
|
if cache_key not in help_cache:
|
||||||
|
help_sections = {} # type: Dict[HelpSection, List[str]]
|
||||||
|
for handler in _command_handlers.values():
|
||||||
|
if handler.has_help and handler.has_permission(*cache_key):
|
||||||
|
help_sections.setdefault(handler.help_section, [])
|
||||||
|
help_sections[handler.help_section].append(handler.help + " ")
|
||||||
|
help_sorted = sorted(help_sections.items(), key=lambda item: item[0].order)
|
||||||
|
helps = ["#### {}\n{}\n".format(key.name, "\n".join(value)) for key, value in help_sorted]
|
||||||
|
help_cache[cache_key] = "\n".join(helps)
|
||||||
|
return help_cache[cache_key]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_management_status(evt: CommandEvent) -> str:
|
||||||
|
if evt.is_management:
|
||||||
|
return "This is a management room: prefixing commands with `$cmdprefix` is not required."
|
||||||
|
elif evt.is_portal:
|
||||||
|
return ("**This is a portal room**: you must always prefix commands with `$cmdprefix`.\n"
|
||||||
|
"Management commands will not be sent to Telegram.")
|
||||||
|
return "**This is not a management room**: you must prefix commands with `$cmdprefix`."
|
||||||
|
|
||||||
|
|
||||||
|
@command_handler(name="help", needs_auth=False, needs_puppeting=False,
|
||||||
|
help_section=SECTION_GENERAL,
|
||||||
|
help_text="Show this help message.")
|
||||||
|
async def help_cmd(evt: CommandEvent) -> Optional[Dict]:
|
||||||
|
return await evt.reply(_get_management_status(evt) + "\n" + await _get_help_text(evt))
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,22 +14,42 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
from typing import Dict
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from mautrix.types import EventID
|
from mautrix_appservice import MatrixRequestError
|
||||||
|
|
||||||
from ... import portal as po, puppet as pu, user as u
|
from ... import portal as po, puppet as pu, user as u
|
||||||
from .. import SECTION_ADMIN, CommandEvent, command_handler
|
from .. import command_handler, CommandEvent, SECTION_ADMIN
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_admin=True, needs_auth=False, name="set-pl",
|
||||||
needs_admin=True,
|
help_section=SECTION_ADMIN,
|
||||||
needs_auth=False,
|
help_args="<_level_> [_mxid_]",
|
||||||
help_section=SECTION_ADMIN,
|
help_text="Set a temporary power level without affecting Telegram.")
|
||||||
help_args="<`portal`|`puppet`|`user`>",
|
async def set_power_level(evt: CommandEvent) -> Dict:
|
||||||
help_text="Clear internal bridge caches",
|
try:
|
||||||
)
|
level = int(evt.args[0])
|
||||||
async def clear_db_cache(evt: CommandEvent) -> EventID:
|
except KeyError:
|
||||||
|
return await evt.reply("**Usage:** `$cmdprefix+sp set-pl <level> [mxid]`")
|
||||||
|
except ValueError:
|
||||||
|
return await evt.reply("The level must be an integer.")
|
||||||
|
levels = await evt.az.intent.get_power_levels(evt.room_id)
|
||||||
|
mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid
|
||||||
|
levels["users"][mxid] = level
|
||||||
|
try:
|
||||||
|
await evt.az.intent.set_power_levels(evt.room_id, levels)
|
||||||
|
except MatrixRequestError:
|
||||||
|
evt.log.exception("Failed to set power level.")
|
||||||
|
return await evt.reply("Failed to set power level.")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
@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) -> Dict:
|
||||||
try:
|
try:
|
||||||
section = evt.args[0].lower()
|
section = evt.args[0].lower()
|
||||||
except IndexError:
|
except IndexError:
|
||||||
@@ -38,44 +59,43 @@ async def clear_db_cache(evt: CommandEvent) -> EventID:
|
|||||||
po.Portal.by_mxid = {}
|
po.Portal.by_mxid = {}
|
||||||
await evt.reply("Cleared portal cache")
|
await evt.reply("Cleared portal cache")
|
||||||
elif section == "puppet":
|
elif section == "puppet":
|
||||||
pu.Puppet.by_tgid = {}
|
pu.Puppet.cache = {}
|
||||||
for puppet in pu.Puppet.by_custom_mxid.values():
|
for puppet in pu.Puppet.by_custom_mxid.values():
|
||||||
puppet.stop()
|
puppet.sync_task.cancel()
|
||||||
pu.Puppet.by_custom_mxid = {}
|
pu.Puppet.by_custom_mxid = {}
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
*[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()]
|
*[puppet.init_custom_mxid() for puppet in pu.Puppet.all_with_custom_mxid()],
|
||||||
)
|
loop=evt.loop)
|
||||||
await evt.reply("Cleared puppet cache and restarted custom puppet syncers")
|
await evt.reply("Cleared puppet cache and restarted custom puppet syncers")
|
||||||
elif section == "user":
|
elif section == "user":
|
||||||
u.User.by_mxid = {user.mxid: user for user in u.User.by_tgid.values()}
|
u.User.by_mxid = {
|
||||||
|
user.mxid: user
|
||||||
|
for user in u.User.by_tgid.values()
|
||||||
|
}
|
||||||
await evt.reply("Cleared non-logged-in user cache")
|
await evt.reply("Cleared non-logged-in user cache")
|
||||||
else:
|
else:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache <section>`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache <section>`")
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_admin=True, needs_auth=False,
|
||||||
needs_admin=True,
|
help_section=SECTION_ADMIN,
|
||||||
needs_auth=False,
|
help_args="[_mxid_]",
|
||||||
help_section=SECTION_ADMIN,
|
help_text="Reload and reconnect a user")
|
||||||
help_args="[_mxid_]",
|
async def reload_user(evt: CommandEvent) -> Dict:
|
||||||
help_text="Reload and reconnect a user",
|
|
||||||
)
|
|
||||||
async def reload_user(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) > 0:
|
if len(evt.args) > 0:
|
||||||
mxid = evt.args[0]
|
mxid = evt.args[0]
|
||||||
else:
|
else:
|
||||||
mxid = evt.sender.mxid
|
mxid = evt.sender.mxid
|
||||||
user = await u.User.get_by_mxid(mxid, create=False)
|
user = u.User.get_by_mxid(mxid, create=False)
|
||||||
if not user:
|
if not user:
|
||||||
return await evt.reply("User not found")
|
return await evt.reply("User not found")
|
||||||
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
|
puppet = pu.Puppet.get_by_custom_mxid(mxid)
|
||||||
if puppet:
|
if puppet:
|
||||||
puppet.stop()
|
puppet.sync_task.cancel()
|
||||||
await user.stop()
|
await user.stop()
|
||||||
del u.User.by_tgid[user.tgid]
|
user.delete(delete_db=False)
|
||||||
del u.User.by_mxid[user.mxid]
|
user = u.User.get_by_mxid(mxid)
|
||||||
user = await u.User.get_by_mxid(mxid)
|
|
||||||
await user.ensure_started()
|
await user.ensure_started()
|
||||||
if puppet:
|
if puppet:
|
||||||
await puppet.start()
|
await puppet.init_custom_mxid()
|
||||||
return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
|
await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,44 +14,32 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict, Optional, Tuple, Coroutine
|
||||||
|
|
||||||
from typing import Awaitable
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
from telethon.tl.types import ChannelForbidden, ChatForbidden
|
from telethon.tl.types import ChatForbidden, ChannelForbidden
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID
|
|
||||||
|
|
||||||
|
from ...types import MatrixRoomID, TelegramID
|
||||||
|
from ...util import ignore_coro
|
||||||
from ... import portal as po
|
from ... import portal as po
|
||||||
from ...types import TelegramID
|
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
|
||||||
from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler
|
from .util import user_has_power_level, get_initial_state
|
||||||
from .util import get_initial_state, user_has_power_level, warn_missing_power
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_auth=False, needs_puppeting=False,
|
||||||
needs_auth=False,
|
help_section=SECTION_CREATING_PORTALS,
|
||||||
needs_puppeting=False,
|
help_args="[_id_]",
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
help_text="Bridge the current Matrix room to the Telegram chat with the given "
|
||||||
help_args="[_id_]",
|
"ID. The ID must be the prefixed version that you get with the `/id` "
|
||||||
help_text=(
|
"command of the Telegram-side bot.")
|
||||||
"Bridge the current Matrix room to the Telegram chat with the given ID. The ID must be "
|
async def bridge(evt: CommandEvent) -> Dict:
|
||||||
"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:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply(
|
return await evt.reply("**Usage:** "
|
||||||
"**Usage:** `$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`"
|
"`$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`")
|
||||||
)
|
room_id = MatrixRoomID(evt.args[1]) if len(evt.args) > 1 else evt.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"
|
that_this = "This" if room_id == evt.room_id else "That"
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
if portal:
|
if portal:
|
||||||
return await evt.reply(f"{that_this} room is already a portal room.")
|
return await evt.reply(f"{that_this} room is already a portal room.")
|
||||||
|
|
||||||
@@ -66,30 +55,24 @@ async def bridge(evt: CommandEvent) -> EventID:
|
|||||||
tgid = TelegramID(-int(tgid_str))
|
tgid = TelegramID(-int(tgid_str))
|
||||||
peer_type = "chat"
|
peer_type = "chat"
|
||||||
else:
|
else:
|
||||||
return await evt.reply(
|
return await evt.reply("That doesn't seem like a prefixed Telegram chat ID.\n\n"
|
||||||
"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 "
|
||||||
"If you did not get the ID using the `/id` bot command, please "
|
"prefix channel IDs with `-100` and normal group IDs with `-`.\n\n"
|
||||||
"prefix channel IDs with `-100` and normal group IDs with `-`.\n\n"
|
"Bridging private chats to existing rooms is not allowed.")
|
||||||
"Bridging private chats to existing rooms is not allowed."
|
|
||||||
)
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_tgid(tgid, peer_type=peer_type)
|
portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type)
|
||||||
if not portal.allow_bridging:
|
if not portal.allow_bridging():
|
||||||
return await evt.reply(
|
return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n"
|
||||||
"This bridge doesn't allow bridging that Telegram chat.\n"
|
"If you're the bridge admin, try "
|
||||||
"If you're the bridge admin, try "
|
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first.")
|
||||||
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first."
|
|
||||||
)
|
|
||||||
if portal.mxid:
|
if portal.mxid:
|
||||||
has_portal_message = (
|
has_portal_message = (
|
||||||
"That Telegram chat already has a portal at "
|
"That Telegram chat already has a portal at "
|
||||||
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). "
|
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"):
|
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
|
||||||
return await evt.reply(
|
return await evt.reply(f"{has_portal_message}"
|
||||||
f"{has_portal_message}"
|
"Additionally, you do not have the permissions to unbridge "
|
||||||
"Additionally, you do not have the permissions to unbridge that room."
|
"that room.")
|
||||||
)
|
|
||||||
evt.sender.command_status = {
|
evt.sender.command_status = {
|
||||||
"next": confirm_bridge,
|
"next": confirm_bridge,
|
||||||
"action": "Room bridging",
|
"action": "Room bridging",
|
||||||
@@ -97,132 +80,102 @@ async def bridge(evt: CommandEvent) -> EventID:
|
|||||||
"bridge_to_mxid": room_id,
|
"bridge_to_mxid": room_id,
|
||||||
"tgid": portal.tgid,
|
"tgid": portal.tgid,
|
||||||
"peer_type": portal.peer_type,
|
"peer_type": portal.peer_type,
|
||||||
"force_use_bot": force_use_bot,
|
|
||||||
}
|
}
|
||||||
return await evt.reply(
|
return await evt.reply(f"{has_portal_message}"
|
||||||
f"{has_portal_message}"
|
"However, you have the permissions to unbridge that room.\n\n"
|
||||||
"However, you have the permissions to unbridge that room.\n\n"
|
"To delete that portal completely and continue bridging, use "
|
||||||
"To delete that portal completely and continue bridging, use "
|
"`$cmdprefix+sp delete-and-continue`. To unbridge the portal "
|
||||||
"`$cmdprefix+sp delete-and-continue`. To unbridge the portal "
|
"without kicking Matrix users, use `$cmdprefix+sp unbridge-and-"
|
||||||
"without kicking Matrix users, use `$cmdprefix+sp unbridge-and-"
|
"continue`. To cancel, use `$cmdprefix+sp cancel`")
|
||||||
"continue`. To cancel, use `$cmdprefix+sp cancel`"
|
|
||||||
)
|
|
||||||
evt.sender.command_status = {
|
evt.sender.command_status = {
|
||||||
"next": confirm_bridge,
|
"next": confirm_bridge,
|
||||||
"action": "Room bridging",
|
"action": "Room bridging",
|
||||||
"bridge_to_mxid": room_id,
|
"bridge_to_mxid": room_id,
|
||||||
"tgid": portal.tgid,
|
"tgid": portal.tgid,
|
||||||
"peer_type": portal.peer_type,
|
"peer_type": portal.peer_type,
|
||||||
"force_use_bot": force_use_bot,
|
|
||||||
}
|
}
|
||||||
return await evt.reply(
|
return await evt.reply("That Telegram chat has no existing portal. To confirm bridging the "
|
||||||
"That Telegram chat has no existing portal. To confirm bridging the "
|
"chat to this room, use `$cmdprefix+sp continue`")
|
||||||
"chat to this room, use `$cmdprefix+sp continue`"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_old_portal_while_bridging(
|
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
|
||||||
evt: CommandEvent, portal: po.Portal
|
) -> Tuple[bool, Optional[Coroutine[None, None, None]]]:
|
||||||
) -> tuple[bool, Awaitable[None] | None]:
|
|
||||||
if not portal.mxid:
|
if not portal.mxid:
|
||||||
await evt.reply(
|
await evt.reply("The portal seems to have lost its Matrix room between you"
|
||||||
"The portal seems to have lost its Matrix room between you"
|
"calling `$cmdprefix+sp bridge` and this command.\n\n"
|
||||||
"calling `$cmdprefix+sp bridge` and this command.\n\n"
|
"Continuing without touching previous Matrix room...")
|
||||||
"Continuing without touching previous Matrix room..."
|
|
||||||
)
|
|
||||||
return True, None
|
return True, None
|
||||||
elif evt.args[0] == "delete-and-continue":
|
elif evt.args[0] == "delete-and-continue":
|
||||||
return True, portal.cleanup_portal("Portal deleted (moving to another room)", delete=False)
|
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
|
||||||
|
message="Portal deleted (moving to another room)")
|
||||||
elif evt.args[0] == "unbridge-and-continue":
|
elif evt.args[0] == "unbridge-and-continue":
|
||||||
return True, portal.cleanup_portal(
|
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
|
||||||
"Room unbridged (portal moving to another room)", puppets_only=True, delete=False
|
message="Room unbridged (portal moving to another room)",
|
||||||
)
|
puppets_only=True)
|
||||||
else:
|
else:
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
"The chat you were trying to bridge already has a Matrix portal room.\n\n"
|
"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-"
|
"Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-"
|
||||||
"continue` to either delete or unbridge the existing room (respectively) and "
|
"continue` to either delete or unbridge the existing room (respectively) and "
|
||||||
"continue with the bridging.\n\n"
|
"continue with the bridging.\n\n"
|
||||||
"If you changed your mind, use `$cmdprefix+sp cancel` to cancel."
|
"If you changed your mind, use `$cmdprefix+sp cancel` to cancel.")
|
||||||
)
|
|
||||||
return False, None
|
return False, None
|
||||||
|
|
||||||
|
|
||||||
async def confirm_bridge(evt: CommandEvent) -> EventID | None:
|
async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
|
||||||
status = evt.sender.command_status
|
status = evt.sender.command_status
|
||||||
try:
|
try:
|
||||||
portal = await po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
|
portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
|
||||||
bridge_to_mxid = status["bridge_to_mxid"]
|
bridge_to_mxid = status["bridge_to_mxid"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
evt.sender.command_status = None
|
evt.sender.command_status = None
|
||||||
return await evt.reply(
|
return await evt.reply("Fatal error: tgid or peer_type missing from command_status. "
|
||||||
"Fatal error: tgid or peer_type missing from command_status. "
|
"This shouldn't happen unless you're messing with the command "
|
||||||
"This shouldn't happen unless you're messing with the command handler code."
|
"handler code.")
|
||||||
)
|
|
||||||
|
|
||||||
is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"]
|
|
||||||
|
|
||||||
if "mxid" in status:
|
if "mxid" in status:
|
||||||
ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
|
ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
|
||||||
if not ok:
|
if not ok:
|
||||||
return None
|
return None
|
||||||
elif coro:
|
elif coro:
|
||||||
asyncio.create_task(coro)
|
ignore_coro(asyncio.ensure_future(coro, loop=evt.loop))
|
||||||
await evt.reply("Cleaning up previous portal room...")
|
await evt.reply("Cleaning up previous portal room...")
|
||||||
elif portal.mxid:
|
elif portal.mxid:
|
||||||
evt.sender.command_status = None
|
evt.sender.command_status = None
|
||||||
return await evt.reply(
|
return await evt.reply("The portal seems to have created a Matrix room between you "
|
||||||
"The portal seems to have created a Matrix room between you "
|
"calling `$cmdprefix+sp bridge` and this command.\n\n"
|
||||||
"calling `$cmdprefix+sp bridge` and this command.\n\n"
|
"Please start over by calling the bridge command again.")
|
||||||
"Please start over by calling the bridge command again."
|
|
||||||
)
|
|
||||||
elif evt.args[0] != "continue":
|
elif evt.args[0] != "continue":
|
||||||
return await evt.reply(
|
return await evt.reply("Please use `$cmdprefix+sp continue` to confirm the bridging or "
|
||||||
"Please use `$cmdprefix+sp continue` to confirm the bridging or "
|
"`$cmdprefix+sp cancel` to cancel.")
|
||||||
"`$cmdprefix+sp cancel` to cancel."
|
|
||||||
)
|
|
||||||
|
|
||||||
evt.sender.command_status = None
|
evt.sender.command_status = None
|
||||||
async with portal._room_create_lock:
|
is_logged_in = await evt.sender.is_logged_in()
|
||||||
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
|
user = evt.sender if is_logged_in else evt.tgbot
|
||||||
try:
|
try:
|
||||||
entity = await user.client.get_entity(portal.peer)
|
entity = await user.client.get_entity(portal.peer)
|
||||||
except Exception:
|
except Exception:
|
||||||
evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer)
|
evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer)
|
||||||
if is_logged_in:
|
if is_logged_in:
|
||||||
return await evt.reply(
|
return await evt.reply("Failed to get info of telegram chat. "
|
||||||
"Failed to get info of telegram chat. You are logged in, are you in that chat?"
|
"You are logged in, are you in that chat?")
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return await evt.reply(
|
return await evt.reply("Failed to get info of telegram chat. "
|
||||||
"Failed to get info of telegram chat. "
|
"You're not logged in, is the relay bot in the chat?")
|
||||||
"You're not logged in, is the relay bot in the chat?"
|
|
||||||
)
|
|
||||||
if isinstance(entity, (ChatForbidden, ChannelForbidden)):
|
if isinstance(entity, (ChatForbidden, ChannelForbidden)):
|
||||||
if is_logged_in:
|
if is_logged_in:
|
||||||
return await evt.reply("You don't seem to be in that chat.")
|
return await evt.reply("You don't seem to be in that chat.")
|
||||||
else:
|
else:
|
||||||
return await evt.reply("The bot doesn't seem to be in that chat.")
|
return await evt.reply("The bot doesn't seem to be in that chat.")
|
||||||
|
|
||||||
portal.mxid = room_id
|
direct = False
|
||||||
portal.by_mxid[portal.mxid] = portal
|
|
||||||
(portal.title, portal.about, levels, portal.encrypted) = await get_initial_state(
|
portal.mxid = bridge_to_mxid
|
||||||
evt.az.intent, evt.room_id
|
portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id)
|
||||||
)
|
|
||||||
portal.photo_id = ""
|
portal.photo_id = ""
|
||||||
await portal.save()
|
portal.save()
|
||||||
await portal.update_bridge_info()
|
|
||||||
|
|
||||||
asyncio.create_task(portal.update_matrix_room(user, entity, direct=False, levels=levels))
|
ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct,
|
||||||
|
levels=levels),
|
||||||
await warn_missing_power(levels, evt)
|
loop=evt.loop))
|
||||||
|
|
||||||
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
|
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,26 +14,17 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict, Awaitable
|
||||||
|
|
||||||
from typing import Any, Awaitable
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
|
||||||
from ruamel.yaml import YAMLError
|
from ...config import yaml
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
from mautrix.util.config import yaml
|
|
||||||
|
|
||||||
from ... import portal as po, util
|
from ... import portal as po, util
|
||||||
from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
|
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
||||||
needs_auth=False,
|
help_text="View or change per-portal settings.",
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
help_args="<`help`|_subcommand_> [...]")
|
||||||
help_text="View or change per-portal settings.",
|
|
||||||
help_args="<`help`|_subcommand_> [...]",
|
|
||||||
)
|
|
||||||
async def config(evt: CommandEvent) -> None:
|
async def config(evt: CommandEvent) -> None:
|
||||||
cmd = evt.args[0].lower() if len(evt.args) > 0 else "help"
|
cmd = evt.args[0].lower() if len(evt.args) > 0 else "help"
|
||||||
if cmd not in ("view", "defaults", "set", "unset", "add", "del"):
|
if cmd not in ("view", "defaults", "set", "unset", "add", "del"):
|
||||||
@@ -42,7 +34,7 @@ async def config(evt: CommandEvent) -> None:
|
|||||||
await config_defaults(evt)
|
await config_defaults(evt)
|
||||||
return
|
return
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||||
if not portal:
|
if not portal:
|
||||||
await evt.reply("This is not a portal room.")
|
await evt.reply("This is not a portal room.")
|
||||||
return
|
return
|
||||||
@@ -50,16 +42,8 @@ async def config(evt: CommandEvent) -> None:
|
|||||||
await config_view(evt, portal)
|
await config_view(evt, portal)
|
||||||
return
|
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
|
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
|
||||||
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":
|
if cmd == "set":
|
||||||
await config_set(evt, portal, key, value)
|
await config_set(evt, portal, key, value)
|
||||||
elif cmd == "unset":
|
elif cmd == "unset":
|
||||||
@@ -68,12 +52,11 @@ async def config(evt: CommandEvent) -> None:
|
|||||||
await config_add_del(evt, portal, key, value, cmd)
|
await config_add_del(evt, portal, key, value, cmd)
|
||||||
else:
|
else:
|
||||||
return
|
return
|
||||||
await portal.save()
|
portal.save()
|
||||||
|
|
||||||
|
|
||||||
def config_help(evt: CommandEvent) -> Awaitable[EventID]:
|
def config_help(evt: CommandEvent) -> Awaitable[Dict]:
|
||||||
return evt.reply(
|
return evt.reply("""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
|
||||||
"""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
|
|
||||||
|
|
||||||
* **help** - View this help text.
|
* **help** - View this help text.
|
||||||
* **view** - View the current config data.
|
* **view** - View the current config data.
|
||||||
@@ -82,52 +65,43 @@ def config_help(evt: CommandEvent) -> Awaitable[EventID]:
|
|||||||
* **unset** <_key_> - Remove a config value.
|
* **unset** <_key_> - Remove a config value.
|
||||||
* **add** <_key_> <_value_> - Add a value to an array.
|
* **add** <_key_> <_value_> - Add a value to an array.
|
||||||
* **del** <_key_> <_value_> - Remove a value from an array.
|
* **del** <_key_> <_value_> - Remove a value from an array.
|
||||||
"""
|
""")
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[EventID]:
|
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[Dict]:
|
||||||
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"],
|
|
||||||
"inline_images": evt.config["bridge.inline_images"],
|
|
||||||
"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()
|
stream = StringIO()
|
||||||
yaml.dump(value, stream)
|
yaml.dump(portal.local_config, stream)
|
||||||
value_str = stream.getvalue()
|
return evt.reply(f"Room-specific config:\n\n```yaml\n{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]:
|
def config_defaults(evt: CommandEvent) -> Awaitable[Dict]:
|
||||||
|
stream = StringIO()
|
||||||
|
yaml.dump({
|
||||||
|
"edits_as_replies": evt.config["bridge.edits_as_replies"],
|
||||||
|
"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"],
|
||||||
|
"inline_images": evt.config["bridge.inline_images"],
|
||||||
|
"message_formats": evt.config["bridge.message_formats"],
|
||||||
|
"state_event_formats": evt.config["bridge.state_event_formats"],
|
||||||
|
"telegram_link_preview": evt.config["bridge.telegram_link_preview"],
|
||||||
|
}, stream)
|
||||||
|
return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```")
|
||||||
|
|
||||||
|
|
||||||
|
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[Dict]:
|
||||||
if not key or value is None:
|
if not key or value is None:
|
||||||
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
|
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
|
||||||
elif util.recursive_set(portal.local_config, key, value):
|
elif util.recursive_set(portal.local_config, key, value):
|
||||||
return evt.reply(f"Successfully set the value of `{key}` to {_str_value(value)}".rstrip())
|
return evt.reply(f"Successfully set the value of `{key}` to `{value}`.")
|
||||||
else:
|
else:
|
||||||
return evt.reply(f"Failed to set value of `{key}`. Does the path contain non-map types?")
|
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]:
|
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Dict]:
|
||||||
if not key:
|
if not key:
|
||||||
return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`")
|
return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`")
|
||||||
elif util.recursive_del(portal.local_config, key):
|
elif util.recursive_del(portal.local_config, key):
|
||||||
@@ -136,26 +110,24 @@ def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Ev
|
|||||||
return evt.reply(f"`{key}` not found in config.")
|
return evt.reply(f"`{key}` not found in config.")
|
||||||
|
|
||||||
|
|
||||||
def config_add_del(
|
def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
|
||||||
evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
|
) -> Awaitable[Dict]:
|
||||||
) -> Awaitable[EventID]:
|
|
||||||
if not key or value is None:
|
if not key or value is None:
|
||||||
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
|
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
|
||||||
|
|
||||||
arr = util.recursive_get(portal.local_config, key)
|
arr = util.recursive_get(portal.local_config, key)
|
||||||
if not arr:
|
if not arr:
|
||||||
return evt.reply(
|
return evt.reply(f"`{key}` not found in config. "
|
||||||
f"`{key}` not found in config. Maybe do `$cmdprefix+sp config set {key} []` first?"
|
f"Maybe do `$cmdprefix+sp config set {key} []` first?")
|
||||||
)
|
|
||||||
elif not isinstance(arr, list):
|
elif not isinstance(arr, list):
|
||||||
return evt.reply("`{key}` does not seem to be an array.")
|
return evt.reply("`{key}` does not seem to be an array.")
|
||||||
elif cmd == "add":
|
elif cmd == "add":
|
||||||
if value in arr:
|
if value in arr:
|
||||||
return evt.reply(f"The array at `{key}` already contains {_str_value(value)}".rstrip())
|
return evt.reply(f"The array at `{key}` already contains `{value}`.")
|
||||||
arr.append(value)
|
arr.append(value)
|
||||||
return evt.reply(f"Successfully added {_str_value(value)} to the array at `{key}`")
|
return evt.reply(f"Successfully added `{value}` to the array at `{key}`")
|
||||||
else:
|
else:
|
||||||
if value not in arr:
|
if value not in arr:
|
||||||
return evt.reply(f"The array at `{key}` does not contain {_str_value(value)}")
|
return evt.reply(f"The array at `{key}` does not contain `{value}`.")
|
||||||
arr.remove(value)
|
arr.remove(value)
|
||||||
return evt.reply(f"Successfully removed {_str_value(value)} from the array at `{key}`")
|
return evt.reply(f"Successfully removed `{value}` from the array at `{key}`")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,38 +14,32 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from ... import portal as po
|
from ... import portal as po
|
||||||
from ...types import TelegramID
|
from ...types import TelegramID
|
||||||
from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler
|
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
|
||||||
from .util import get_initial_state, user_has_power_level, warn_missing_power
|
from .util import user_has_power_level, get_initial_state
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(help_section=SECTION_CREATING_PORTALS,
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
help_args="[_type_]",
|
||||||
help_args="[_type_]",
|
help_text="Create a Telegram chat of the given type for the current Matrix room. "
|
||||||
help_text=(
|
"The type is either `group`, `supergroup` or `channel` (defaults to "
|
||||||
"Create a Telegram chat of the given type for the current Matrix room. "
|
"`group`).")
|
||||||
"The type is either `group`, `supergroup` or `channel` (defaults to `supergroup`)."
|
async def create(evt: CommandEvent) -> Dict:
|
||||||
),
|
type = evt.args[0] if len(evt.args) > 0 else "group"
|
||||||
)
|
if type not in {"chat", "group", "supergroup", "channel"}:
|
||||||
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(
|
return await evt.reply(
|
||||||
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`"
|
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
|
||||||
)
|
|
||||||
|
|
||||||
if await po.Portal.get_by_mxid(evt.room_id):
|
if po.Portal.get_by_mxid(evt.room_id):
|
||||||
return await evt.reply("This is already a portal room.")
|
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"):
|
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.")
|
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)
|
title, about, levels = await get_initial_state(evt.az.intent, evt.room_id)
|
||||||
if not title:
|
if not title:
|
||||||
return await evt.reply("Please set a title before creating a Telegram chat.")
|
return await evt.reply("Please set a title before creating a Telegram chat.")
|
||||||
|
|
||||||
@@ -56,29 +51,11 @@ async def create(evt: CommandEvent) -> EventID:
|
|||||||
"group": "chat",
|
"group": "chat",
|
||||||
}[type]
|
}[type]
|
||||||
|
|
||||||
portal = po.Portal(
|
portal = po.Portal(tgid=TelegramID(0), peer_type=type,
|
||||||
tgid=TelegramID(0),
|
mxid=evt.room_id, title=title, about=about)
|
||||||
tg_receiver=TelegramID(0),
|
|
||||||
peer_type=type,
|
|
||||||
mxid=evt.room_id,
|
|
||||||
title=title,
|
|
||||||
about=about,
|
|
||||||
encrypted=encrypted,
|
|
||||||
)
|
|
||||||
invites, errors = await portal.get_telegram_users_in_matrix_room(evt.sender, pre_create=True)
|
|
||||||
if len(errors) > 0:
|
|
||||||
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
|
|
||||||
await evt.reply(
|
|
||||||
f"Failed to add the following users to the chat:\n\n{error_list}\n\n"
|
|
||||||
"You can try `$cmdprefix+sp search -r <username>` to help the bridge find "
|
|
||||||
"those users."
|
|
||||||
)
|
|
||||||
|
|
||||||
await warn_missing_power(levels, evt)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await portal.create_telegram_chat(evt.sender, invites=invites, supergroup=supergroup)
|
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
await portal.delete()
|
portal.delete()
|
||||||
return await evt.reply(e.args[0])
|
return await evt.reply(e.args[0])
|
||||||
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
|
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,21 +14,18 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from ... import portal as po
|
from ... import portal as po
|
||||||
from .. import SECTION_ADMIN, CommandEvent, command_handler
|
from .. import command_handler, CommandEvent, SECTION_ADMIN
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_admin=True,
|
||||||
needs_admin=True,
|
help_section=SECTION_ADMIN,
|
||||||
help_section=SECTION_ADMIN,
|
help_args="<`whitelist`|`blacklist`>",
|
||||||
help_args="<`whitelist`|`blacklist`>",
|
help_text="Change whether the bridge will allow or disallow bridging rooms by "
|
||||||
help_text="Change whether the bridge will allow or disallow bridging rooms by default.",
|
"default.")
|
||||||
)
|
async def filter_mode(evt: CommandEvent) -> Dict:
|
||||||
async def filter_mode(evt: CommandEvent) -> EventID:
|
|
||||||
try:
|
try:
|
||||||
mode = evt.args[0]
|
mode = evt.args[0]
|
||||||
if mode not in ("whitelist", "blacklist"):
|
if mode not in ("whitelist", "blacklist"):
|
||||||
@@ -39,27 +37,20 @@ async def filter_mode(evt: CommandEvent) -> EventID:
|
|||||||
evt.config.save()
|
evt.config.save()
|
||||||
po.Portal.filter_mode = mode
|
po.Portal.filter_mode = mode
|
||||||
if mode == "whitelist":
|
if mode == "whitelist":
|
||||||
return await evt.reply(
|
return await evt.reply("The bridge will now disallow bridging chats by default.\n"
|
||||||
"The bridge will now disallow bridging chats by default.\n"
|
"To allow bridging a specific chat, use"
|
||||||
"To allow bridging a specific chat, use"
|
"`!filter whitelist <chat ID>`.")
|
||||||
"`!filter whitelist <chat ID>`."
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
return await evt.reply(
|
return await evt.reply("The bridge will now allow bridging chats by default.\n"
|
||||||
"The bridge will now allow bridging chats by default.\n"
|
"To disallow bridging a specific chat, use"
|
||||||
"To disallow bridging a specific chat, use"
|
"`!filter blacklist <chat ID>`.")
|
||||||
"`!filter blacklist <chat ID>`."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(name="filter", needs_admin=True,
|
||||||
name="filter",
|
help_section=SECTION_ADMIN,
|
||||||
needs_admin=True,
|
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
|
||||||
help_section=SECTION_ADMIN,
|
help_text="Allow or disallow bridging a specific chat.")
|
||||||
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
|
async def edit_filter(evt: CommandEvent) -> Optional[Dict]:
|
||||||
help_text="Allow or disallow bridging a specific chat.",
|
|
||||||
)
|
|
||||||
async def edit_filter(evt: CommandEvent) -> EventID:
|
|
||||||
try:
|
try:
|
||||||
action = evt.args[0]
|
action = evt.args[0]
|
||||||
if action not in ("whitelist", "blacklist", "add", "remove"):
|
if action not in ("whitelist", "blacklist", "add", "remove"):
|
||||||
@@ -77,7 +68,7 @@ async def edit_filter(evt: CommandEvent) -> EventID:
|
|||||||
|
|
||||||
mode = evt.config["bridge.filter.mode"]
|
mode = evt.config["bridge.filter.mode"]
|
||||||
if mode not in ("blacklist", "whitelist"):
|
if mode not in ("blacklist", "whitelist"):
|
||||||
return await evt.reply(f'Unknown filter mode "{mode}". Please fix the bridge config.')
|
return await evt.reply(f"Unknown filter mode \"{mode}\". Please fix the bridge config.")
|
||||||
|
|
||||||
filter_id_list = evt.config["bridge.filter.list"]
|
filter_id_list = evt.config["bridge.filter.list"]
|
||||||
|
|
||||||
@@ -101,5 +92,4 @@ async def edit_filter(evt: CommandEvent) -> EventID:
|
|||||||
filter_id_list.remove(filter_id)
|
filter_id_list.remove(filter_id)
|
||||||
save()
|
save()
|
||||||
return await evt.reply(f"Chat ID removed from {mode}.")
|
return await evt.reply(f"Chat ID removed from {mode}.")
|
||||||
else:
|
return None
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,83 +14,35 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
|
||||||
import re
|
UsernameNotModifiedError, UsernameOccupiedError)
|
||||||
|
|
||||||
from telethon.errors import (
|
|
||||||
ChatAdminRequiredError,
|
|
||||||
RPCError,
|
|
||||||
UsernameInvalidError,
|
|
||||||
UsernameNotModifiedError,
|
|
||||||
UsernameOccupiedError,
|
|
||||||
)
|
|
||||||
from telethon.tl.functions.channels import GetFullChannelRequest
|
|
||||||
from telethon.tl.functions.messages import GetFullChatRequest
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from ... import portal as po
|
from ... import portal as po
|
||||||
from .. import SECTION_MISC, SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
|
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC
|
||||||
from .util import user_has_power_level
|
from .util import user_has_power_level
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False,
|
||||||
needs_admin=False,
|
help_section=SECTION_MISC,
|
||||||
needs_puppeting=False,
|
help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.")
|
||||||
needs_auth=False,
|
async def sync_state(evt: CommandEvent) -> Dict:
|
||||||
help_section=SECTION_MISC,
|
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||||
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:
|
if not portal:
|
||||||
return await evt.reply("This is not a portal room.")
|
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"):
|
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.")
|
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 portal.sync_matrix_members()
|
||||||
await evt.reply("Synchronization complete")
|
await evt.reply("Synchronization complete")
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(name="id", needs_admin=False, needs_puppeting=False, needs_auth=False,
|
||||||
needs_admin=False, needs_puppeting=False, needs_auth=False, help_section=SECTION_MISC
|
help_section=SECTION_MISC,
|
||||||
)
|
help_text="Get the ID of the Telegram chat where this room is bridged.")
|
||||||
async def sync_full(evt: CommandEvent) -> EventID:
|
async def get_id(evt: CommandEvent) -> Dict:
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
portal = 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:
|
if not portal:
|
||||||
return await evt.reply("This is not a portal room.")
|
return await evt.reply("This is not a portal room.")
|
||||||
tgid = portal.tgid
|
tgid = portal.tgid
|
||||||
@@ -100,88 +53,10 @@ async def get_id(evt: CommandEvent) -> EventID:
|
|||||||
await evt.reply(f"This room is bridged to Telegram chat ID `{tgid}`.")
|
await evt.reply(f"This room is bridged to Telegram chat ID `{tgid}`.")
|
||||||
|
|
||||||
|
|
||||||
invite_link_usage = (
|
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
||||||
"**Usage:** `$cmdprefix+sp invite-link [--uses=<amount>] [--expire=<delta>]`"
|
help_text="Get a Telegram invite link to the current chat.")
|
||||||
"\n\n"
|
async def invite_link(evt: CommandEvent) -> Dict:
|
||||||
"* `--uses`: the number of times the invite link can be used."
|
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||||
" 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)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_flag(args: list[str]) -> tuple[str, str]:
|
|
||||||
arg = args.pop(0).lower()
|
|
||||||
if arg.startswith("--"):
|
|
||||||
value_start = arg.index("=")
|
|
||||||
if value_start:
|
|
||||||
flag = arg[2:value_start]
|
|
||||||
value = arg[value_start + 1 :]
|
|
||||||
else:
|
|
||||||
flag = arg[2:]
|
|
||||||
value = args.pop(0).lower()
|
|
||||||
elif arg.startswith("-"):
|
|
||||||
flag = arg[1]
|
|
||||||
if len(arg) > 3 and arg[2] == "=":
|
|
||||||
value = arg[3:]
|
|
||||||
else:
|
|
||||||
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=<amount>] [--expire=<time delta, e.g. 1d>]",
|
|
||||||
)
|
|
||||||
async def invite_link(evt: CommandEvent) -> EventID:
|
|
||||||
# TODO once we switch to Python 3.9 minimum, use argparse with exit_on_error=False
|
|
||||||
uses = None
|
|
||||||
expire = None
|
|
||||||
while evt.args:
|
|
||||||
try:
|
|
||||||
flag, value = _parse_flag(evt.args)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
return await evt.reply(invite_link_usage)
|
|
||||||
if flag in ("uses", "u"):
|
|
||||||
try:
|
|
||||||
uses = int(value)
|
|
||||||
except ValueError:
|
|
||||||
await evt.reply("The number of uses must be an integer")
|
|
||||||
elif flag in ("expire", "e"):
|
|
||||||
expire_delta = _parse_delta(value)
|
|
||||||
if not expire_delta:
|
|
||||||
await evt.reply("Invalid format for expiry time delta")
|
|
||||||
expire = datetime.now() + expire_delta
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
if not portal:
|
||||||
return await evt.reply("This is not a portal room.")
|
return await evt.reply("This is not a portal room.")
|
||||||
|
|
||||||
@@ -189,7 +64,7 @@ async def invite_link(evt: CommandEvent) -> EventID:
|
|||||||
return await evt.reply("You can't invite users to private chats.")
|
return await evt.reply("You can't invite users to private chats.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
link = await portal.get_invite_link(evt.sender, uses=uses, expire=expire)
|
link = await portal.get_invite_link(evt.sender)
|
||||||
return await evt.reply(f"Invite link to {portal.title}: {link}")
|
return await evt.reply(f"Invite link to {portal.title}: {link}")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return await evt.reply(e.args[0])
|
return await evt.reply(e.args[0])
|
||||||
@@ -197,12 +72,10 @@ async def invite_link(evt: CommandEvent) -> EventID:
|
|||||||
return await evt.reply("You don't have the permission to create an invite link.")
|
return await evt.reply("You don't have the permission to create an invite link.")
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
help_text="Upgrade a normal Telegram group to a supergroup.")
|
||||||
help_text="Upgrade a normal Telegram group to a supergroup.",
|
async def upgrade(evt: CommandEvent) -> Dict:
|
||||||
)
|
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||||
async def upgrade(evt: CommandEvent) -> EventID:
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
if not portal:
|
||||||
return await evt.reply("This is not a portal room.")
|
return await evt.reply("This is not a portal room.")
|
||||||
elif portal.peer_type == "channel":
|
elif portal.peer_type == "channel":
|
||||||
@@ -219,33 +92,30 @@ async def upgrade(evt: CommandEvent) -> EventID:
|
|||||||
return await evt.reply(e.args[0])
|
return await evt.reply(e.args[0])
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
help_args="<_name_|`-`>",
|
||||||
help_args="<_name_|`-`>",
|
help_text="Change the username of a supergroup/channel. "
|
||||||
help_text=(
|
"To disable, use a dash (`-`) as the name.")
|
||||||
"Change the username of a supergroup/channel. To disable, use a dash (`-`) as the name."
|
async def group_name(evt: CommandEvent) -> Dict:
|
||||||
),
|
|
||||||
)
|
|
||||||
async def group_name(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||||
if not portal:
|
if not portal:
|
||||||
return await evt.reply("This is not a portal room.")
|
return await evt.reply("This is not a portal room.")
|
||||||
elif portal.peer_type != "channel":
|
elif portal.peer_type != "channel":
|
||||||
return await evt.reply("Only channels and supergroups have usernames.")
|
return await evt.reply("Only channels and supergroups have usernames.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await portal.set_telegram_username(evt.sender, evt.args[0] if evt.args[0] != "-" else "")
|
await portal.set_telegram_username(evt.sender,
|
||||||
|
evt.args[0] if evt.args[0] != "-" else "")
|
||||||
if portal.username:
|
if portal.username:
|
||||||
return await evt.reply(f"Username of channel changed to {portal.username}.")
|
return await evt.reply(f"Username of channel changed to {portal.username}.")
|
||||||
else:
|
else:
|
||||||
return await evt.reply(f"Channel is now private.")
|
return await evt.reply(f"Channel is now private.")
|
||||||
except ChatAdminRequiredError:
|
except ChatAdminRequiredError:
|
||||||
return await evt.reply(
|
return await evt.reply(
|
||||||
"You don't have the permission to set the username of this channel."
|
"You don't have the permission to set the username of this channel.")
|
||||||
)
|
|
||||||
except UsernameNotModifiedError:
|
except UsernameNotModifiedError:
|
||||||
if portal.username:
|
if portal.username:
|
||||||
return await evt.reply("That is already the username of this channel.")
|
return await evt.reply("That is already the username of this channel.")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,42 +14,35 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict, Callable, Optional
|
||||||
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID
|
|
||||||
|
|
||||||
|
from ...types import MatrixRoomID
|
||||||
from ... import portal as po
|
from ... import portal as po
|
||||||
from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
|
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
|
||||||
from .util import user_has_power_level
|
from .util import user_has_power_level
|
||||||
|
|
||||||
|
|
||||||
async def _get_portal_and_check_permission(evt: CommandEvent) -> po.Portal | None:
|
async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
|
||||||
room_id = RoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
|
action: Optional[str] = None
|
||||||
|
) -> Optional[po.Portal]:
|
||||||
|
room_id = MatrixRoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
if not portal:
|
if not portal:
|
||||||
that_this = "This" if room_id == evt.room_id else "That"
|
that_this = "This" if room_id == evt.room_id else "That"
|
||||||
await evt.reply(f"{that_this} is not a portal room.")
|
await evt.reply(f"{that_this} is not a portal room.")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if portal.peer_type == "user":
|
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission):
|
||||||
if portal.tg_receiver != evt.sender.tgid:
|
action = action or f"{permission.replace('_', ' ')}s"
|
||||||
await evt.reply("You do not have the permissions to unbridge that portal.")
|
await evt.reply(f"You do not have the permissions to {action} that portal.")
|
||||||
return None
|
|
||||||
return portal
|
|
||||||
|
|
||||||
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
|
|
||||||
await evt.reply("You do not have the permissions to unbridge that portal.")
|
|
||||||
return None
|
return None
|
||||||
return portal
|
return portal
|
||||||
|
|
||||||
|
|
||||||
def _get_portal_murder_function(
|
def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str,
|
||||||
action: str, room_id: str, function: Callable, command: str, completed_message: str
|
completed_message: str) -> Dict:
|
||||||
) -> dict:
|
async def post_confirm(confirm) -> Optional[Dict]:
|
||||||
async def post_confirm(confirm) -> EventID | None:
|
|
||||||
confirm.sender.command_status = None
|
confirm.sender.command_status = None
|
||||||
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
|
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
|
||||||
await function()
|
await function()
|
||||||
@@ -64,55 +58,40 @@ def _get_portal_murder_function(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_auth=False, needs_puppeting=False,
|
||||||
needs_auth=False,
|
help_section=SECTION_PORTAL_MANAGEMENT,
|
||||||
needs_puppeting=False,
|
help_text="Remove all users from the current portal room and forget the portal. "
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
"Only works for group chats; to delete a private chat portal, simply "
|
||||||
help_text=(
|
"leave the room.")
|
||||||
"Remove all users from the current portal room and forget the portal. "
|
async def delete_portal(evt: CommandEvent) -> Optional[Dict]:
|
||||||
"Only works for group chats; to delete a private chat portal, simply leave the room."
|
portal = await _get_portal_and_check_permission(evt, "unbridge")
|
||||||
),
|
|
||||||
)
|
|
||||||
async def delete_portal(evt: CommandEvent) -> EventID | None:
|
|
||||||
portal = await _get_portal_and_check_permission(evt)
|
|
||||||
if not portal:
|
if not portal:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
evt.sender.command_status = _get_portal_murder_function(
|
evt.sender.command_status = _get_portal_murder_function("Portal deletion", portal.mxid,
|
||||||
"Portal deletion",
|
portal.cleanup_and_delete, "delete",
|
||||||
portal.mxid,
|
"Portal successfully deleted.")
|
||||||
portal.cleanup_and_delete,
|
return await evt.reply("Please confirm deletion of portal "
|
||||||
"delete",
|
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
|
||||||
"Portal successfully deleted.",
|
f"to Telegram chat \"{portal.title}\" "
|
||||||
)
|
"by typing `$cmdprefix+sp confirm-delete`"
|
||||||
return await evt.reply(
|
"\n\n"
|
||||||
"Please confirm deletion of portal "
|
"**WARNING:** If the bridge bot has the power level to do so, **this "
|
||||||
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
|
"will kick ALL users** in the room. If you just want to remove the "
|
||||||
f'to Telegram chat "{portal.title}" '
|
"bridge, use `$cmdprefix+sp unbridge` instead.")
|
||||||
"by typing `$cmdprefix+sp confirm-delete`"
|
|
||||||
"\n\n"
|
|
||||||
"**WARNING:** If the bridge bot has the power level to do so, **this "
|
|
||||||
"will kick ALL users** in the room. If you just want to remove the "
|
|
||||||
"bridge, use `$cmdprefix+sp unbridge` instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_auth=False, needs_puppeting=False,
|
||||||
needs_auth=False,
|
help_section=SECTION_PORTAL_MANAGEMENT,
|
||||||
needs_puppeting=False,
|
help_text="Remove puppets from the current portal room and forget the portal.")
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
async def unbridge(evt: CommandEvent) -> Optional[Dict]:
|
||||||
help_text="Remove puppets from the current portal room and forget the portal.",
|
portal = await _get_portal_and_check_permission(evt, "unbridge")
|
||||||
)
|
|
||||||
async def unbridge(evt: CommandEvent) -> EventID | None:
|
|
||||||
portal = await _get_portal_and_check_permission(evt)
|
|
||||||
if not portal:
|
if not portal:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
evt.sender.command_status = _get_portal_murder_function(
|
evt.sender.command_status = _get_portal_murder_function("Room unbridging", portal.mxid,
|
||||||
"Room unbridging", portal.mxid, portal.unbridge, "unbridge", "Room successfully unbridged."
|
portal.unbridge, "unbridge",
|
||||||
)
|
"Room successfully unbridged.")
|
||||||
return await evt.reply(
|
return await evt.reply(f"Please confirm unbridging chat \"{portal.title}\" from room "
|
||||||
f'Please confirm unbridging chat "{portal.title}" from room '
|
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
|
||||||
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
|
"by typing `$cmdprefix+sp confirm-unbridge`")
|
||||||
"by typing `$cmdprefix+sp confirm-unbridge`"
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,60 +14,43 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
from mautrix.appservice import IntentAPI
|
from mautrix_appservice import MatrixRequestError, IntentAPI
|
||||||
from mautrix.errors import MatrixRequestError
|
|
||||||
from mautrix.types import EventType, PowerLevelStateEventContent, RoomID
|
|
||||||
|
|
||||||
from ... import user as u
|
from ... import user as u
|
||||||
from .. import CommandEvent
|
|
||||||
|
|
||||||
|
|
||||||
async def get_initial_state(
|
async def get_initial_state(intent: IntentAPI, room_id: str) -> Tuple[str, str, Dict]:
|
||||||
intent: IntentAPI, room_id: RoomID
|
state = await intent.get_room_state(room_id)
|
||||||
) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool]:
|
title = None
|
||||||
state = await intent.get_state(room_id)
|
about = None
|
||||||
title: str | None = None
|
levels = None
|
||||||
about: str | None = None
|
|
||||||
levels: PowerLevelStateEventContent | None = None
|
|
||||||
encrypted: bool = False
|
|
||||||
for event in state:
|
for event in state:
|
||||||
try:
|
try:
|
||||||
if event.type == EventType.ROOM_NAME:
|
if event["type"] == "m.room.name":
|
||||||
title = event.content.name
|
title = event["content"]["name"]
|
||||||
elif event.type == EventType.ROOM_TOPIC:
|
elif event["type"] == "m.room.topic":
|
||||||
about = event.content.topic
|
about = event["content"]["topic"]
|
||||||
elif event.type == EventType.ROOM_POWER_LEVELS:
|
elif event["type"] == "m.room.power_levels":
|
||||||
levels = event.content
|
levels = event["content"]
|
||||||
elif event.type == EventType.ROOM_CANONICAL_ALIAS:
|
elif event["type"] == "m.room.canonical_alias":
|
||||||
title = title or event.content.canonical_alias
|
title = title or event["content"]["alias"]
|
||||||
elif event.type == EventType.ROOM_ENCRYPTION:
|
|
||||||
encrypted = True
|
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# Some state event probably has empty content
|
# Some state event probably has empty content
|
||||||
pass
|
pass
|
||||||
return title, about, levels, encrypted
|
return title, about, levels
|
||||||
|
|
||||||
|
|
||||||
async def warn_missing_power(levels: PowerLevelStateEventContent, evt: CommandEvent) -> None:
|
async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50
|
||||||
if levels.get_user_level(evt.az.bot_mxid) < levels.redact:
|
) -> bool:
|
||||||
await evt.reply(
|
|
||||||
"Warning: The bot does not have privileges to redact messages on Matrix. "
|
|
||||||
"Message deletions from Telegram will not be bridged unless you give "
|
|
||||||
f"redaction permissions to [{evt.az.bot_mxid}](https://matrix.to/#/{evt.az.bot_mxid})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def user_has_power_level(
|
|
||||||
room_id: RoomID, intent: IntentAPI, sender: u.User, event: str
|
|
||||||
) -> bool:
|
|
||||||
if sender.is_admin:
|
if sender.is_admin:
|
||||||
return True
|
return True
|
||||||
# Make sure the state store contains the power levels.
|
# Make sure the state store contains the power levels.
|
||||||
try:
|
try:
|
||||||
await intent.get_power_levels(room_id)
|
await intent.get_power_levels(room)
|
||||||
except MatrixRequestError:
|
except MatrixRequestError:
|
||||||
return False
|
return False
|
||||||
event_type = EventType.find(f"net.maunium.telegram.{event}", t_class=EventType.Class.STATE)
|
return intent.state_store.has_power_level(room, sender.mxid,
|
||||||
return await intent.state_store.has_power_level(room_id, sender.mxid, event_type)
|
event=f"net.maunium.telegram.{event}",
|
||||||
|
default=default)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,37 +14,22 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict, Optional
|
||||||
|
|
||||||
from telethon.errors import (
|
from telethon.errors import (UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError,
|
||||||
AboutTooLongError,
|
HashInvalidError, AuthKeyError)
|
||||||
AuthKeyError,
|
|
||||||
FirstNameInvalidError,
|
|
||||||
HashInvalidError,
|
|
||||||
UsernameInvalidError,
|
|
||||||
UsernameNotModifiedError,
|
|
||||||
UsernameOccupiedError,
|
|
||||||
)
|
|
||||||
from telethon.tl.functions.account import (
|
|
||||||
GetAuthorizationsRequest,
|
|
||||||
ResetAuthorizationRequest,
|
|
||||||
UpdateProfileRequest,
|
|
||||||
UpdateUsernameRequest,
|
|
||||||
)
|
|
||||||
from telethon.tl.types import Authorization
|
from telethon.tl.types import Authorization
|
||||||
|
from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest,
|
||||||
|
ResetAuthorizationRequest)
|
||||||
|
|
||||||
from mautrix.types import EventID
|
from .. import command_handler, CommandEvent, SECTION_AUTH
|
||||||
|
|
||||||
from .. import SECTION_AUTH, CommandEvent, command_handler
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_auth=True,
|
||||||
needs_auth=True,
|
help_section=SECTION_AUTH,
|
||||||
help_section=SECTION_AUTH,
|
help_args="<_new username_>",
|
||||||
help_args="<_new username_>",
|
help_text="Change your Telegram username.")
|
||||||
help_text="Change your Telegram username.",
|
async def username(evt: CommandEvent) -> Optional[Dict]:
|
||||||
)
|
|
||||||
async def username(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp username <new username>`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp username <new username>`")
|
||||||
if evt.sender.is_bot:
|
if evt.sender.is_bot:
|
||||||
@@ -54,80 +40,31 @@ async def username(evt: CommandEvent) -> EventID:
|
|||||||
try:
|
try:
|
||||||
await evt.sender.client(UpdateUsernameRequest(username=new_name))
|
await evt.sender.client(UpdateUsernameRequest(username=new_name))
|
||||||
except UsernameInvalidError:
|
except UsernameInvalidError:
|
||||||
return await evt.reply(
|
return await evt.reply("Invalid username. Usernames must be between 5 and 30 alphanumeric "
|
||||||
"Invalid username. Usernames must be between 5 and 30 alphanumeric characters."
|
"characters.")
|
||||||
)
|
|
||||||
except UsernameNotModifiedError:
|
except UsernameNotModifiedError:
|
||||||
return await evt.reply("That is your current username.")
|
return await evt.reply("That is your current username.")
|
||||||
except UsernameOccupiedError:
|
except UsernameOccupiedError:
|
||||||
return await evt.reply("That username is already in use.")
|
return await evt.reply("That username is already in use.")
|
||||||
await evt.sender.update_info()
|
await evt.sender.update_info()
|
||||||
if not evt.sender.tg_username:
|
if not evt.sender.username:
|
||||||
await evt.reply("Username removed")
|
await evt.reply("Username removed")
|
||||||
else:
|
else:
|
||||||
await evt.reply(f"Username changed to {evt.sender.tg_username}")
|
await evt.reply(f"Username changed to {evt.sender.username}")
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_args="<_new about_>",
|
|
||||||
help_text="Change your Telegram about section.",
|
|
||||||
)
|
|
||||||
async def about(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp about <new about>`")
|
|
||||||
if evt.sender.is_bot:
|
|
||||||
return await evt.reply("Bots can't set their own about section.")
|
|
||||||
new_about = " ".join(evt.args)
|
|
||||||
if new_about == "-":
|
|
||||||
new_about = ""
|
|
||||||
try:
|
|
||||||
await evt.sender.client(UpdateProfileRequest(about=new_about))
|
|
||||||
except AboutTooLongError:
|
|
||||||
return await evt.reply("The provided about section is too long")
|
|
||||||
return await evt.reply("About section updated")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_args="<_new displayname_>",
|
|
||||||
help_text="Change your Telegram displayname.",
|
|
||||||
)
|
|
||||||
async def displayname(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp displayname <new displayname>`")
|
|
||||||
if evt.sender.is_bot:
|
|
||||||
return await evt.reply("Bots can't set their own displayname.")
|
|
||||||
|
|
||||||
first_name, last_name = (
|
|
||||||
(evt.args[0], "") if len(evt.args) == 1 else (" ".join(evt.args[:-1]), evt.args[-1])
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await evt.sender.client(UpdateProfileRequest(first_name=first_name, last_name=last_name))
|
|
||||||
except FirstNameInvalidError:
|
|
||||||
return await evt.reply("Invalid first name")
|
|
||||||
await evt.sender.update_info()
|
|
||||||
return await evt.reply("Displayname updated")
|
|
||||||
|
|
||||||
|
|
||||||
def _format_session(sess: Authorization) -> str:
|
def _format_session(sess: Authorization) -> str:
|
||||||
return (
|
return (f"**{sess.app_name} {sess.app_version}** \n"
|
||||||
f"**{sess.app_name} {sess.app_version}** \n"
|
f" **Platform:** {sess.device_model} {sess.platform} {sess.system_version} \n"
|
||||||
f" **Platform:** {sess.device_model} {sess.platform} {sess.system_version} \n"
|
f" **Active:** {sess.date_active} (created {sess.date_created}) \n"
|
||||||
f" **Active:** {sess.date_active} (created {sess.date_created}) \n"
|
f" **From:** {sess.ip} - {sess.region}, {sess.country}")
|
||||||
f" **From:** {sess.ip} - {sess.region}, {sess.country}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_auth=True,
|
||||||
needs_auth=True,
|
help_section=SECTION_AUTH,
|
||||||
help_section=SECTION_AUTH,
|
help_args="<`list`|`terminate`> [_hash_]",
|
||||||
help_args="<`list`|`terminate`> [_hash_]",
|
help_text="View or delete other Telegram sessions.")
|
||||||
help_text="View or delete other Telegram sessions.",
|
async def session(evt: CommandEvent) -> Optional[Dict]:
|
||||||
)
|
|
||||||
async def session(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
|
||||||
elif evt.sender.is_bot:
|
elif evt.sender.is_bot:
|
||||||
@@ -138,32 +75,29 @@ async def session(evt: CommandEvent) -> EventID:
|
|||||||
session_list = res.authorizations
|
session_list = res.authorizations
|
||||||
current = [s for s in session_list if s.current][0]
|
current = [s for s in session_list if s.current][0]
|
||||||
current_text = _format_session(current)
|
current_text = _format_session(current)
|
||||||
other_text = "\n".join(
|
other_text = "\n".join(f"* {_format_session(sess)} \n"
|
||||||
f"* {_format_session(sess)} \n **Hash:** {sess.hash}"
|
f" **Hash:** {sess.hash}"
|
||||||
for sess in session_list
|
for sess in session_list if not sess.current)
|
||||||
if not sess.current
|
return await evt.reply(f"### Current session\n"
|
||||||
)
|
f"{current_text}\n"
|
||||||
return await evt.reply(
|
f"\n"
|
||||||
f"### Current session\n"
|
f"### Other active sessions\n"
|
||||||
f"{current_text}\n"
|
f"{other_text}")
|
||||||
f"\n"
|
|
||||||
f"### Other active sessions\n"
|
|
||||||
f"{other_text}"
|
|
||||||
)
|
|
||||||
elif cmd == "terminate" and len(evt.args) > 1:
|
elif cmd == "terminate" and len(evt.args) > 1:
|
||||||
try:
|
try:
|
||||||
session_hash = int(evt.args[1])
|
session_hash = int(evt.args[1])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return await evt.reply("Hash must be an integer")
|
return await evt.reply("Hash must be a positive integer")
|
||||||
|
if session_hash <= 0:
|
||||||
|
return await evt.reply("Hash must be a positive integer")
|
||||||
try:
|
try:
|
||||||
ok = await evt.sender.client(ResetAuthorizationRequest(hash=session_hash))
|
ok = await evt.sender.client(ResetAuthorizationRequest(hash=session_hash))
|
||||||
except HashInvalidError:
|
except HashInvalidError:
|
||||||
return await evt.reply("Invalid session hash.")
|
return await evt.reply("Invalid session hash.")
|
||||||
except AuthKeyError as e:
|
except AuthKeyError as e:
|
||||||
if e.message == "FRESH_RESET_AUTHORISATION_FORBIDDEN":
|
if e.message == "FRESH_RESET_AUTHORISATION_FORBIDDEN":
|
||||||
return await evt.reply(
|
return await evt.reply("New sessions can't terminate other sessions. "
|
||||||
"New sessions can't terminate other sessions. Please wait a while."
|
"Please wait a while.")
|
||||||
)
|
|
||||||
raise
|
raise
|
||||||
if ok:
|
if ok:
|
||||||
return await evt.reply("Session terminated successfully.")
|
return await evt.reply("Session terminated successfully.")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,94 +14,51 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import io
|
|
||||||
|
|
||||||
from telethon.errors import (
|
from telethon.errors import (
|
||||||
AccessTokenExpiredError,
|
AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError,
|
||||||
AccessTokenInvalidError,
|
PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError,
|
||||||
FirstNameInvalidError,
|
PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError,
|
||||||
FloodWaitError,
|
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError)
|
||||||
PasswordHashInvalidError,
|
|
||||||
PhoneCodeExpiredError,
|
|
||||||
PhoneCodeInvalidError,
|
|
||||||
PhoneNumberAppSignupForbiddenError,
|
|
||||||
PhoneNumberBannedError,
|
|
||||||
PhoneNumberFloodError,
|
|
||||||
PhoneNumberInvalidError,
|
|
||||||
PhoneNumberOccupiedError,
|
|
||||||
PhoneNumberUnoccupiedError,
|
|
||||||
SessionPasswordNeededError,
|
|
||||||
)
|
|
||||||
from telethon.tl.types import User
|
|
||||||
|
|
||||||
from mautrix.client import Client
|
from ... import puppet as pu, user as u
|
||||||
from mautrix.types import (
|
from ...commands import command_handler, CommandEvent, SECTION_AUTH
|
||||||
EventID,
|
from ...util import format_duration, ignore_coro
|
||||||
ImageInfo,
|
|
||||||
MediaMessageEventContent,
|
|
||||||
MessageType,
|
|
||||||
TextMessageEventContent,
|
|
||||||
UserID,
|
|
||||||
)
|
|
||||||
from mautrix.util.format_duration import format_duration as fmt_duration
|
|
||||||
|
|
||||||
from ... import user as u
|
|
||||||
from ...commands import SECTION_AUTH, CommandEvent, command_handler
|
|
||||||
from ...types import TelegramID
|
|
||||||
|
|
||||||
try:
|
|
||||||
from telethon.tl.custom import QRLogin
|
|
||||||
import PIL as _
|
|
||||||
import qrcode
|
|
||||||
except ImportError:
|
|
||||||
qrcode = None
|
|
||||||
QRLogin = None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_auth=False,
|
||||||
needs_auth=False, help_section=SECTION_AUTH, help_text="Check if you're logged into Telegram."
|
help_section=SECTION_AUTH,
|
||||||
)
|
help_text="Check if you're logged into Telegram.")
|
||||||
async def ping(evt: CommandEvent) -> EventID:
|
async def ping(evt: CommandEvent) -> Optional[Dict]:
|
||||||
if await evt.sender.is_logged_in():
|
me = await evt.sender.client.get_me() if await evt.sender.is_logged_in() else None
|
||||||
me = await evt.sender.get_me()
|
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
|
||||||
if me:
|
if me:
|
||||||
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
|
return await evt.reply(f"You're logged in as {human_tg_id}")
|
||||||
return await evt.reply(f"You're logged in as {human_tg_id}")
|
|
||||||
else:
|
|
||||||
return await evt.reply("You were logged in, but there appears to have been an error.")
|
|
||||||
else:
|
else:
|
||||||
return await evt.reply("You're not logged in.")
|
return await evt.reply("You're not logged in.")
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_auth=False, needs_puppeting=False,
|
||||||
needs_auth=False,
|
help_section=SECTION_AUTH,
|
||||||
needs_puppeting=False,
|
help_text="Get the info of the message relay Telegram bot.")
|
||||||
help_section=SECTION_AUTH,
|
async def ping_bot(evt: CommandEvent) -> Optional[Dict]:
|
||||||
help_text="Get the info of the message relay Telegram bot.",
|
|
||||||
)
|
|
||||||
async def ping_bot(evt: CommandEvent) -> EventID:
|
|
||||||
if not evt.tgbot:
|
if not evt.tgbot:
|
||||||
return await evt.reply("Telegram message relay bot not configured.")
|
return await evt.reply("Telegram message relay bot not configured.")
|
||||||
info, mxid = await evt.tgbot.get_me(use_cache=False)
|
bot_info = await evt.tgbot.client.get_me()
|
||||||
return await evt.reply(
|
mxid = pu.Puppet.get_mxid_from_id(bot_info.id)
|
||||||
"Telegram message relay bot is active: "
|
displayname = bot_info.first_name
|
||||||
f"[{info.first_name}](https://matrix.to/#/{mxid}) (ID {info.id})\n\n"
|
return await evt.reply("Telegram message relay bot is active: "
|
||||||
"To use the bot, simply invite it to a portal room."
|
f"[{displayname}](https://matrix.to/#/{mxid}) (ID {bot_info.id})\n\n"
|
||||||
)
|
"To use the bot, simply invite it to a portal room.")
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_auth=False, management_only=True,
|
||||||
needs_auth=False,
|
help_section=SECTION_AUTH,
|
||||||
management_only=True,
|
help_args="<_phone_> <_full name_>",
|
||||||
help_section=SECTION_AUTH,
|
help_text="Register to Telegram")
|
||||||
help_args="<_phone_> <_full name_>",
|
async def register(evt: CommandEvent) -> Optional[Dict]:
|
||||||
help_text="Register to Telegram",
|
|
||||||
)
|
|
||||||
async def register(evt: CommandEvent) -> EventID:
|
|
||||||
if await evt.sender.is_logged_in():
|
if await evt.sender.is_logged_in():
|
||||||
return await evt.reply("You are already logged in.")
|
return await evt.reply("You are already logged in.")
|
||||||
elif len(evt.args) < 1:
|
elif len(evt.args) < 1:
|
||||||
@@ -112,146 +70,52 @@ async def register(evt: CommandEvent) -> EventID:
|
|||||||
else:
|
else:
|
||||||
full_name = " ".join(evt.args[1:-1]), evt.args[-1]
|
full_name = " ".join(evt.args[1:-1]), evt.args[-1]
|
||||||
|
|
||||||
await _request_code(
|
await _request_code(evt, phone_number, {
|
||||||
evt,
|
"next": enter_code_register,
|
||||||
phone_number,
|
"action": "Register",
|
||||||
{
|
"full_name": full_name,
|
||||||
"next": enter_code_register,
|
})
|
||||||
"action": "Register",
|
return None
|
||||||
"full_name": full_name,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return await evt.reply(
|
|
||||||
"By signing up for Telegram, you agree to "
|
|
||||||
"the terms of service: https://telegram.org/tos"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def enter_code_register(evt: CommandEvent) -> EventID:
|
async def enter_code_register(evt: CommandEvent) -> Dict:
|
||||||
if len(evt.args) == 0:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
|
||||||
try:
|
try:
|
||||||
await evt.sender.ensure_started(even_if_no_session=True)
|
await evt.sender.ensure_started(even_if_no_session=True)
|
||||||
first_name, last_name = evt.sender.command_status["full_name"]
|
first_name, last_name = evt.sender.command_status["full_name"]
|
||||||
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name)
|
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name)
|
||||||
asyncio.create_task(evt.sender.post_login(user, first_login=True))
|
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
|
||||||
evt.sender.command_status = None
|
evt.sender.command_status = None
|
||||||
return await evt.reply(f"Successfully registered to Telegram.")
|
return await evt.reply(f"Successfully registered to Telegram.")
|
||||||
except PhoneNumberOccupiedError:
|
except PhoneNumberOccupiedError:
|
||||||
return await evt.reply(
|
return await evt.reply("That phone number has already been registered. "
|
||||||
"That phone number has already been registered. "
|
"You can log in with `$cmdprefix+sp login`.")
|
||||||
"You can log in with `$cmdprefix+sp login`."
|
|
||||||
)
|
|
||||||
except FirstNameInvalidError:
|
except FirstNameInvalidError:
|
||||||
return await evt.reply("Invalid name. Please set a Matrix displayname before registering.")
|
return await evt.reply("Invalid name. Please set a Matrix displayname before registering.")
|
||||||
except PhoneCodeExpiredError:
|
except PhoneCodeExpiredError:
|
||||||
return await evt.reply(
|
return await evt.reply(
|
||||||
"Phone code expired. Try again with `$cmdprefix+sp register <phone>`."
|
"Phone code expired. Try again with `$cmdprefix+sp register <phone>`.")
|
||||||
)
|
|
||||||
except PhoneCodeInvalidError:
|
except PhoneCodeInvalidError:
|
||||||
return await evt.reply("Invalid phone code.")
|
return await evt.reply("Invalid phone code.")
|
||||||
except Exception:
|
except Exception:
|
||||||
evt.log.exception("Error sending phone code")
|
evt.log.exception("Error sending phone code")
|
||||||
return await evt.reply(
|
return await evt.reply("Unhandled exception while sending code. "
|
||||||
"Unhandled exception while sending code. Check console for more details."
|
"Check console for more details.")
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(needs_auth=False, management_only=True,
|
||||||
needs_auth=False,
|
help_section=SECTION_AUTH,
|
||||||
management_only=True,
|
help_text="Get instructions on how to log in.")
|
||||||
help_section=SECTION_AUTH,
|
async def login(evt: CommandEvent) -> Optional[Dict]:
|
||||||
help_text="Log in by scanning a QR code.",
|
|
||||||
)
|
|
||||||
async def login_qr(evt: CommandEvent) -> EventID:
|
|
||||||
login_as = evt.sender
|
|
||||||
if len(evt.args) > 0 and evt.sender.is_admin:
|
|
||||||
login_as = await u.User.get_by_mxid(UserID(evt.args[0]))
|
|
||||||
if not qrcode or not QRLogin:
|
|
||||||
return await evt.reply("This bridge instance does not support logging in with a QR code.")
|
|
||||||
if await login_as.is_logged_in():
|
|
||||||
return await evt.reply(f"You are already logged in as {login_as.human_tg_id}.")
|
|
||||||
|
|
||||||
await login_as.ensure_started(even_if_no_session=True)
|
|
||||||
qr_login = QRLogin(login_as.client, ignored_ids=[])
|
|
||||||
qr_event_id: EventID | None = None
|
|
||||||
|
|
||||||
async def upload_qr() -> None:
|
|
||||||
nonlocal qr_event_id
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
image = qrcode.make(qr_login.url)
|
|
||||||
size = image.pixel_size
|
|
||||||
image.save(buffer, "PNG")
|
|
||||||
qr = buffer.getvalue()
|
|
||||||
mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr))
|
|
||||||
content = MediaMessageEventContent(
|
|
||||||
body=qr_login.url,
|
|
||||||
url=mxc,
|
|
||||||
msgtype=MessageType.IMAGE,
|
|
||||||
info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size),
|
|
||||||
)
|
|
||||||
if qr_event_id:
|
|
||||||
content.set_edit(qr_event_id)
|
|
||||||
await evt.az.intent.send_message(evt.room_id, content)
|
|
||||||
else:
|
|
||||||
content.set_reply(evt.event_id)
|
|
||||||
qr_event_id = await evt.az.intent.send_message(evt.room_id, content)
|
|
||||||
|
|
||||||
retries = 4
|
|
||||||
while retries > 0:
|
|
||||||
await qr_login.recreate()
|
|
||||||
await upload_qr()
|
|
||||||
try:
|
|
||||||
user = await qr_login.wait()
|
|
||||||
break
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
retries -= 1
|
|
||||||
except SessionPasswordNeededError:
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": enter_password,
|
|
||||||
"login_as": login_as if login_as != evt.sender else None,
|
|
||||||
"action": "Login (password entry)",
|
|
||||||
}
|
|
||||||
return await evt.reply(
|
|
||||||
"Your account has two-factor authentication. Please send your password here."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
timeout = TextMessageEventContent(body="Login timed out", msgtype=MessageType.TEXT)
|
|
||||||
timeout.set_edit(qr_event_id)
|
|
||||||
return await evt.az.intent.send_message(evt.room_id, timeout)
|
|
||||||
|
|
||||||
return await _finish_sign_in(evt, user, login_as=login_as)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False,
|
|
||||||
management_only=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Get instructions on how to log in.",
|
|
||||||
)
|
|
||||||
async def login(evt: CommandEvent) -> EventID:
|
|
||||||
override_sender = False
|
override_sender = False
|
||||||
if len(evt.args) > 0 and evt.sender.is_admin and evt.args[0]:
|
if len(evt.args) > 0 and evt.sender.is_admin:
|
||||||
override_user_id = UserID(evt.args[0])
|
evt.sender = await u.User.get_by_mxid(evt.args[0]).ensure_started()
|
||||||
try:
|
|
||||||
Client.parse_user_id(override_user_id)
|
|
||||||
except ValueError:
|
|
||||||
return await evt.reply(
|
|
||||||
f"**Usage:** `$cmdprefix+sp login [override user ID]`\n\n"
|
|
||||||
f"{override_user_id!r} is not a valid Matrix user ID"
|
|
||||||
)
|
|
||||||
orig_user_id = evt.sender.mxid
|
|
||||||
evt.sender = await u.User.get_and_start_by_mxid(override_user_id)
|
|
||||||
override_sender = True
|
override_sender = True
|
||||||
if orig_user_id != evt.sender:
|
|
||||||
await evt.reply(
|
|
||||||
f"Admin override: logging in as {evt.sender.mxid} instead of {orig_user_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if await evt.sender.is_logged_in():
|
if await evt.sender.is_logged_in():
|
||||||
return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.")
|
return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.")
|
||||||
|
|
||||||
allow_matrix_login = evt.config["bridge.allow_matrix_login"]
|
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
|
||||||
if allow_matrix_login and not override_sender:
|
if allow_matrix_login and not override_sender:
|
||||||
evt.sender.command_status = {
|
evt.sender.command_status = {
|
||||||
"next": enter_phone_or_token,
|
"next": enter_phone_or_token,
|
||||||
@@ -262,33 +126,46 @@ async def login(evt: CommandEvent) -> EventID:
|
|||||||
if evt.config["appservice.public.enabled"]:
|
if evt.config["appservice.public.enabled"]:
|
||||||
prefix = evt.config["appservice.public.external"]
|
prefix = evt.config["appservice.public.external"]
|
||||||
url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}"
|
url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}"
|
||||||
|
if allow_matrix_login:
|
||||||
|
if override_sender:
|
||||||
|
return await evt.reply(
|
||||||
|
"This bridge instance allows you to log in inside or outside of Matrix, but "
|
||||||
|
"logging in as another user is only possible via the web interface.\n\n"
|
||||||
|
f"Please visit [the login page]({url}) to log in as "
|
||||||
|
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid}).\n\n")
|
||||||
|
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 phone number or bot "
|
||||||
|
"auth token here.\n"
|
||||||
|
"If you would like to log in outside of Matrix, please visit [the login page]"
|
||||||
|
f"({url}).\n\n"
|
||||||
|
"Logging in outside of Matrix is recommended if you have two-factor authentication "
|
||||||
|
"enabled, because in-Matrix login would save your password in the message history."
|
||||||
|
f"\n\n{nb}")
|
||||||
if override_sender:
|
if override_sender:
|
||||||
return await evt.reply(
|
return await evt.reply(
|
||||||
f"[Click here to log in]({url}) as "
|
"This bridge instance does not allow logging in inside Matrix, and logging in as "
|
||||||
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid})."
|
"another user inside Matrix isn't possible anyway.\n\n"
|
||||||
)
|
f"Please visit [the login page]({url}) to log in as "
|
||||||
elif allow_matrix_login:
|
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid}).")
|
||||||
return await evt.reply(
|
return await evt.reply(
|
||||||
f"[Click here to log in]({url}). Alternatively, send your phone"
|
"This bridge instance does not allow logging in inside Matrix.\n\n"
|
||||||
f" number (or bot auth token) here to log in.\n\n{nb}"
|
f"Please visit [the login page]({url}) to log in.\n\n"
|
||||||
)
|
f"{nb}")
|
||||||
return await evt.reply(f"[Click here to log in]({url}).\n\n{nb}")
|
|
||||||
elif allow_matrix_login:
|
elif allow_matrix_login:
|
||||||
if override_sender:
|
if override_sender:
|
||||||
return await evt.reply(
|
return await evt.reply(
|
||||||
"This bridge instance does not allow you to log in outside of Matrix. "
|
"This bridge instance does not allow you to log in outside of Matrix. "
|
||||||
"Logging in as another user inside Matrix is not currently possible."
|
"Logging in as another user inside Matrix is not currently possible.")
|
||||||
)
|
|
||||||
return await evt.reply(
|
return await evt.reply(
|
||||||
"Please send your phone number (or bot auth token) here to start "
|
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
|
||||||
f"the login process.\n\n{nb}"
|
"Please send your phone number or bot auth token here to start the login process.\n\n"
|
||||||
)
|
f"{nb}")
|
||||||
return await evt.reply("This bridge instance has been configured to not allow logging in.")
|
return await evt.reply("This bridge instance has been configured to not allow logging in.")
|
||||||
|
|
||||||
|
|
||||||
async def _request_code(
|
async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[str, Any]
|
||||||
evt: CommandEvent, phone_number: str, next_status: dict[str, Any]
|
) -> Dict:
|
||||||
) -> EventID:
|
|
||||||
ok = False
|
ok = False
|
||||||
try:
|
try:
|
||||||
await evt.sender.ensure_started(even_if_no_session=True)
|
await evt.sender.ensure_started(even_if_no_session=True)
|
||||||
@@ -296,44 +173,36 @@ async def _request_code(
|
|||||||
ok = True
|
ok = True
|
||||||
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
|
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
|
||||||
except PhoneNumberAppSignupForbiddenError:
|
except PhoneNumberAppSignupForbiddenError:
|
||||||
return await evt.reply("Your phone number does not allow 3rd party apps to sign in.")
|
return await evt.reply(
|
||||||
|
"Your phone number does not allow 3rd party apps to sign in.")
|
||||||
except PhoneNumberFloodError:
|
except PhoneNumberFloodError:
|
||||||
return await evt.reply(
|
return await evt.reply(
|
||||||
"Your phone number has been temporarily blocked for flooding. "
|
"Your phone number has been temporarily blocked for flooding. "
|
||||||
"The ban is usually applied for around a day."
|
"The ban is usually applied for around a day.")
|
||||||
)
|
|
||||||
except FloodWaitError as e:
|
except FloodWaitError as e:
|
||||||
return await evt.reply(
|
return await evt.reply(
|
||||||
"Your phone number has been temporarily blocked for flooding. "
|
"Your phone number has been temporarily blocked for flooding. "
|
||||||
f"Please wait for {fmt_duration(e.seconds)} before trying again."
|
f"Please wait for {format_duration(e.seconds)} before trying again.")
|
||||||
)
|
|
||||||
except PhoneNumberBannedError:
|
except PhoneNumberBannedError:
|
||||||
return await evt.reply("Your phone number has been banned from Telegram.")
|
return await evt.reply("Your phone number has been banned from Telegram.")
|
||||||
except PhoneNumberUnoccupiedError:
|
except PhoneNumberUnoccupiedError:
|
||||||
return await evt.reply(
|
return await evt.reply("That phone number has not been registered. "
|
||||||
"That phone number has not been registered. "
|
"Please register with `$cmdprefix+sp register <phone>`.")
|
||||||
"Please register with `$cmdprefix+sp register <phone>`."
|
|
||||||
)
|
|
||||||
except PhoneNumberInvalidError:
|
|
||||||
return await evt.reply("That phone number is not valid.")
|
|
||||||
except Exception:
|
except Exception:
|
||||||
evt.log.exception("Error requesting phone code")
|
evt.log.exception("Error requesting phone code")
|
||||||
return await evt.reply(
|
return await evt.reply("Unhandled exception while requesting code. "
|
||||||
"Unhandled exception while requesting code. Check console for more details."
|
"Check console for more details.")
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
evt.sender.command_status = next_status if ok else None
|
evt.sender.command_status = next_status if ok else None
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False)
|
@command_handler(needs_auth=False)
|
||||||
async def enter_phone_or_token(evt: CommandEvent) -> EventID | None:
|
async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
|
||||||
if len(evt.args) == 0:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`")
|
||||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
elif not evt.config.get("bridge.allow_matrix_login", True):
|
||||||
return await evt.reply(
|
return await evt.reply("This bridge instance does not allow in-Matrix login. "
|
||||||
"This bridge instance does not allow in-Matrix login. "
|
"Please use `$cmdprefix+sp login` to get login instructions")
|
||||||
"Please use `$cmdprefix+sp login` to get login instructions"
|
|
||||||
)
|
|
||||||
|
|
||||||
# phone numbers don't contain colons but telegram bot auth tokens do
|
# phone numbers don't contain colons but telegram bot auth tokens do
|
||||||
if evt.args[0].find(":") > 0:
|
if evt.args[0].find(":") > 0:
|
||||||
@@ -341,66 +210,66 @@ async def enter_phone_or_token(evt: CommandEvent) -> EventID | None:
|
|||||||
await _sign_in(evt, bot_token=evt.args[0])
|
await _sign_in(evt, bot_token=evt.args[0])
|
||||||
except Exception:
|
except Exception:
|
||||||
evt.log.exception("Error sending auth token")
|
evt.log.exception("Error sending auth token")
|
||||||
return await evt.reply(
|
return await evt.reply("Unhandled exception while sending auth token. "
|
||||||
"Unhandled exception while sending auth token. Check console for more details."
|
"Check console for more details.")
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
await _request_code(evt, evt.args[0], {"next": enter_code, "action": "Login"})
|
await _request_code(evt, evt.args[0], {
|
||||||
|
"next": enter_code,
|
||||||
|
"action": "Login",
|
||||||
|
})
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False)
|
@command_handler(needs_auth=False)
|
||||||
async def enter_code(evt: CommandEvent) -> EventID | None:
|
async def enter_code(evt: CommandEvent) -> Optional[Dict]:
|
||||||
if len(evt.args) == 0:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
|
||||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
elif not evt.config.get("bridge.allow_matrix_login", True):
|
||||||
return await evt.reply(
|
return await evt.reply("This bridge instance does not allow in-Matrix login. "
|
||||||
"This bridge instance does not allow in-Matrix login. "
|
"Please use `$cmdprefix+sp login` to get login instructions")
|
||||||
"Please use `$cmdprefix+sp login` to get login instructions"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await _sign_in(evt, code=evt.args[0])
|
await _sign_in(evt, code=evt.args[0])
|
||||||
except Exception:
|
except Exception:
|
||||||
evt.log.exception("Error sending phone code")
|
evt.log.exception("Error sending phone code")
|
||||||
return await evt.reply(
|
return await evt.reply("Unhandled exception while sending code. "
|
||||||
"Unhandled exception while sending code. Check console for more details."
|
"Check console for more details.")
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False)
|
@command_handler(needs_auth=False)
|
||||||
async def enter_password(evt: CommandEvent) -> EventID | None:
|
async def enter_password(evt: CommandEvent) -> Optional[Dict]:
|
||||||
if len(evt.args) == 0:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
|
||||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
elif not evt.config.get("bridge.allow_matrix_login", True):
|
||||||
return await evt.reply(
|
return await evt.reply("This bridge instance does not allow in-Matrix login. "
|
||||||
"This bridge instance does not allow in-Matrix login. "
|
"Please use `$cmdprefix+sp login` to get login instructions")
|
||||||
"Please use `$cmdprefix+sp login` to get login instructions"
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await _sign_in(
|
await _sign_in(evt, password=" ".join(evt.args))
|
||||||
evt,
|
|
||||||
login_as=evt.sender.command_status.get("login_as", None),
|
|
||||||
password=" ".join(evt.args),
|
|
||||||
)
|
|
||||||
except AccessTokenInvalidError:
|
except AccessTokenInvalidError:
|
||||||
return await evt.reply("That bot token is not valid.")
|
return await evt.reply("That bot token is not valid.")
|
||||||
except AccessTokenExpiredError:
|
except AccessTokenExpiredError:
|
||||||
return await evt.reply("That bot token has expired.")
|
return await evt.reply("That bot token has expired.")
|
||||||
except Exception:
|
except Exception:
|
||||||
evt.log.exception("Error sending password")
|
evt.log.exception("Error sending password")
|
||||||
return await evt.reply(
|
return await evt.reply("Unhandled exception while sending password. "
|
||||||
"Unhandled exception while sending password. Check console for more details."
|
"Check console for more details.")
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _sign_in(evt: CommandEvent, login_as: u.User = None, **sign_in_info) -> EventID:
|
async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
|
||||||
login_as = login_as or evt.sender
|
|
||||||
try:
|
try:
|
||||||
await login_as.ensure_started(even_if_no_session=True)
|
await evt.sender.ensure_started(even_if_no_session=True)
|
||||||
user = await login_as.client.sign_in(**sign_in_info)
|
user = await evt.sender.client.sign_in(**sign_in_info)
|
||||||
await _finish_sign_in(evt, user)
|
existing_user = u.User.get_by_tgid(user.id)
|
||||||
|
if existing_user and existing_user != evt.sender:
|
||||||
|
await existing_user.log_out()
|
||||||
|
await evt.reply(f"[{existing_user.displayname}]"
|
||||||
|
f"(https://matrix.to/#/{existing_user.mxid})"
|
||||||
|
" was logged out from the account.")
|
||||||
|
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
|
||||||
|
evt.sender.command_status = None
|
||||||
|
name = f"@{user.username}" if user.username else f"+{user.phone}"
|
||||||
|
return await evt.reply(f"Successfully logged in as {name}")
|
||||||
except PhoneCodeExpiredError:
|
except PhoneCodeExpiredError:
|
||||||
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
|
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
|
||||||
except PhoneCodeInvalidError:
|
except PhoneCodeInvalidError:
|
||||||
@@ -412,37 +281,14 @@ async def _sign_in(evt: CommandEvent, login_as: u.User = None, **sign_in_info) -
|
|||||||
"next": enter_password,
|
"next": enter_password,
|
||||||
"action": "Login (password entry)",
|
"action": "Login (password entry)",
|
||||||
}
|
}
|
||||||
return await evt.reply(
|
return await evt.reply("Your account has two-factor authentication. "
|
||||||
"Your account has two-factor authentication. Please send your password here."
|
"Please send your password here.")
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _finish_sign_in(evt: CommandEvent, user: User, login_as: u.User = None) -> EventID:
|
@command_handler(needs_auth=True,
|
||||||
login_as = login_as or evt.sender
|
help_section=SECTION_AUTH,
|
||||||
existing_user = await u.User.get_by_tgid(TelegramID(user.id))
|
help_text="Log out from Telegram.")
|
||||||
if existing_user and existing_user != login_as:
|
async def logout(evt: CommandEvent) -> Optional[Dict]:
|
||||||
await existing_user.log_out()
|
|
||||||
await evt.reply(
|
|
||||||
f"[{existing_user.displayname}] (https://matrix.to/#/{existing_user.mxid})"
|
|
||||||
" was logged out from the account."
|
|
||||||
)
|
|
||||||
asyncio.create_task(login_as.post_login(user, first_login=True))
|
|
||||||
evt.sender.command_status = None
|
|
||||||
name = f"@{user.username}" if user.username else f"+{user.phone}"
|
|
||||||
if login_as != evt.sender:
|
|
||||||
msg = (
|
|
||||||
f"Successfully logged in [{login_as.mxid}](https://matrix.to/#/{login_as.mxid})"
|
|
||||||
f" as {name}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg = f"Successfully logged in as {name}"
|
|
||||||
return await evt.reply(msg)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, help_section=SECTION_AUTH, help_text="Log out from Telegram.")
|
|
||||||
async def logout(evt: CommandEvent) -> EventID:
|
|
||||||
if not evt.sender.tgid:
|
|
||||||
return await evt.reply("You're not logged in")
|
|
||||||
if await evt.sender.log_out():
|
if await evt.sender.log_out():
|
||||||
return await evt.reply("Logged out successfully.")
|
return await evt.reply("Logged out successfully.")
|
||||||
return await evt.reply("Failed to log out.")
|
return await evt.reply("Failed to log out.")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,83 +14,32 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from typing import cast
|
|
||||||
import base64
|
|
||||||
import codecs
|
import codecs
|
||||||
|
import base64
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from aiohttp import ClientSession, InvalidURL
|
from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError,
|
||||||
from telethon.errors import (
|
UserAlreadyParticipantError)
|
||||||
ChatIdInvalidError,
|
|
||||||
EmoticonInvalidError,
|
|
||||||
InviteHashExpiredError,
|
|
||||||
InviteHashInvalidError,
|
|
||||||
OptionsTooMuchError,
|
|
||||||
TakeoutInitDelayError,
|
|
||||||
UserAlreadyParticipantError,
|
|
||||||
)
|
|
||||||
from telethon.tl.functions.channels import JoinChannelRequest
|
|
||||||
from telethon.tl.functions.messages import (
|
|
||||||
CheckChatInviteRequest,
|
|
||||||
GetBotCallbackAnswerRequest,
|
|
||||||
ImportChatInviteRequest,
|
|
||||||
SendVoteRequest,
|
|
||||||
)
|
|
||||||
from telethon.tl.patched import Message
|
from telethon.tl.patched import Message
|
||||||
from telethon.tl.types import (
|
from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll,
|
||||||
InputMediaDice,
|
TypePeer)
|
||||||
MessageMediaGame,
|
|
||||||
MessageMediaPoll,
|
|
||||||
TypeInputPeer,
|
|
||||||
TypeUpdates,
|
|
||||||
User as TLUser,
|
|
||||||
)
|
|
||||||
from telethon.tl.types.messages import BotCallbackAnswer
|
from telethon.tl.types.messages import BotCallbackAnswer
|
||||||
|
from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatInviteRequest,
|
||||||
|
GetBotCallbackAnswerRequest, SendVoteRequest)
|
||||||
|
from telethon.tl.functions.channels import JoinChannelRequest
|
||||||
|
|
||||||
from mautrix.types import EventID, Format
|
from ... import puppet as pu, portal as po
|
||||||
|
|
||||||
from ... import portal as po, puppet as pu
|
|
||||||
from ...abstract_user import AbstractUser
|
from ...abstract_user import AbstractUser
|
||||||
from ...commands import (
|
|
||||||
SECTION_CREATING_PORTALS,
|
|
||||||
SECTION_MISC,
|
|
||||||
SECTION_PORTAL_MANAGEMENT,
|
|
||||||
CommandEvent,
|
|
||||||
command_handler,
|
|
||||||
)
|
|
||||||
from ...db import Message as DBMessage
|
from ...db import Message as DBMessage
|
||||||
from ...types import TelegramID
|
from ...types import TelegramID
|
||||||
|
from ...commands import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(help_section=SECTION_MISC,
|
||||||
needs_auth=False,
|
help_args="[_-r|--remote_] <_query_>",
|
||||||
help_section=SECTION_MISC,
|
help_text="Search your contacts or the Telegram servers for users.")
|
||||||
help_args="<_caption_>",
|
async def search(evt: CommandEvent) -> Optional[Dict]:
|
||||||
help_text="Set a caption for the next image you send",
|
|
||||||
)
|
|
||||||
async def caption(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp caption <caption>`")
|
|
||||||
|
|
||||||
prefix = f"{evt.command_prefix} caption "
|
|
||||||
if evt.content.format == Format.HTML:
|
|
||||||
evt.content.formatted_body = evt.content.formatted_body.replace(prefix, "", 1)
|
|
||||||
evt.content.body = evt.content.body.replace(prefix, "", 1)
|
|
||||||
evt.sender.command_status = {"caption": evt.content, "action": "Caption"}
|
|
||||||
return await evt.reply(
|
|
||||||
"Your next image or file will be sent with that caption. "
|
|
||||||
"Use `$cmdprefix+sp cancel` to cancel the caption."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_MISC,
|
|
||||||
help_args="[_-r|--remote_] <_query_>",
|
|
||||||
help_text="Search your contacts or the Telegram servers for users.",
|
|
||||||
)
|
|
||||||
async def search(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
|
||||||
|
|
||||||
@@ -106,44 +56,36 @@ async def search(evt: CommandEvent) -> EventID:
|
|||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
if len(query) < 5 and remote:
|
if len(query) < 5 and remote:
|
||||||
return await evt.reply(
|
return await evt.reply("No local results. "
|
||||||
"No local results. Minimum length of remote query is 5 characters."
|
"Minimum length of remote query is 5 characters.")
|
||||||
)
|
|
||||||
return await evt.reply("No results 3:")
|
return await evt.reply("No results 3:")
|
||||||
|
|
||||||
reply: list[str] = []
|
reply = [] # type: List[str]
|
||||||
if remote:
|
if remote:
|
||||||
reply += ["**Results from Telegram server:**", ""]
|
reply += ["**Results from Telegram server:**", ""]
|
||||||
else:
|
else:
|
||||||
reply += ["**Results in contacts:**", ""]
|
reply += ["**Results in contacts:**", ""]
|
||||||
reply += [
|
reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
|
||||||
(
|
f"{puppet.id} ({similarity}% match)")
|
||||||
f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
|
for puppet, similarity in results]
|
||||||
f"{puppet.id} ({similarity}% match)"
|
|
||||||
)
|
|
||||||
for puppet, similarity in results
|
|
||||||
]
|
|
||||||
|
|
||||||
# TODO somehow show remote channel results when joining by alias is possible?
|
# TODO somehow show remote channel results when joining by alias is possible?
|
||||||
|
|
||||||
return await evt.reply("\n".join(reply))
|
return await evt.reply("\n".join(reply))
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(help_section=SECTION_CREATING_PORTALS,
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
help_args="<_identifier_>",
|
||||||
help_args="<_identifier_>",
|
help_text="Open a private chat with the given Telegram user. The identifier is "
|
||||||
help_text="Open a private chat with the given Telegram user. The identifier is "
|
"either the internal user ID, the username or the phone number. "
|
||||||
"either the internal user ID, the username or the phone number. "
|
"**N.B.** The phone numbers you start chats with must already be in "
|
||||||
"**N.B.** The phone numbers you start chats with must already be in "
|
"your contacts.")
|
||||||
"your contacts.",
|
async def pm(evt: CommandEvent) -> Optional[Dict]:
|
||||||
)
|
|
||||||
async def pm(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
id = "".join(evt.args).translate({ord(c): None for c in "+()- "})
|
user = await evt.sender.client.get_entity(evt.args[0])
|
||||||
user = await evt.sender.client.get_entity(id)
|
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return await evt.reply("Invalid user identifier or user not found.")
|
return await evt.reply("Invalid user identifier or user not found.")
|
||||||
|
|
||||||
@@ -151,95 +93,64 @@ async def pm(evt: CommandEvent) -> EventID:
|
|||||||
return await evt.reply("User not found.")
|
return await evt.reply("User not found.")
|
||||||
elif not isinstance(user, TLUser):
|
elif not isinstance(user, TLUser):
|
||||||
return await evt.reply("That doesn't seem to be a user.")
|
return await evt.reply("That doesn't seem to be a user.")
|
||||||
portal = await po.Portal.get_by_entity(user, tg_receiver=evt.sender.tgid)
|
portal = po.Portal.get_by_entity(user, evt.sender.tgid)
|
||||||
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
|
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
|
||||||
displayname, _ = pu.Puppet.get_displayname(user, False)
|
return await evt.reply("Created private chat room with "
|
||||||
return await evt.reply(f"Created private chat room with {displayname}")
|
f"{pu.Puppet.get_displayname(user, False)}")
|
||||||
|
|
||||||
|
|
||||||
async def _join(
|
async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[Dict]]:
|
||||||
evt: CommandEvent, identifier: str, link_type: str
|
if arg.startswith("joinchat/"):
|
||||||
) -> tuple[TypeUpdates | None, EventID | None]:
|
invite_hash = arg[len("joinchat/"):]
|
||||||
if link_type == "joinchat":
|
|
||||||
try:
|
try:
|
||||||
await evt.sender.client(CheckChatInviteRequest(identifier))
|
await evt.sender.client(CheckChatInviteRequest(invite_hash))
|
||||||
except InviteHashInvalidError:
|
except InviteHashInvalidError:
|
||||||
return None, await evt.reply("Invalid invite link.")
|
return None, await evt.reply("Invalid invite link.")
|
||||||
except InviteHashExpiredError:
|
except InviteHashExpiredError:
|
||||||
return None, await evt.reply("Invite link expired.")
|
return None, await evt.reply("Invite link expired.")
|
||||||
try:
|
try:
|
||||||
return (await evt.sender.client(ImportChatInviteRequest(identifier))), None
|
return (await evt.sender.client(ImportChatInviteRequest(invite_hash))), None
|
||||||
except UserAlreadyParticipantError:
|
except UserAlreadyParticipantError:
|
||||||
return None, await evt.reply("You are already in that chat.")
|
return None, await evt.reply("You are already in that chat.")
|
||||||
else:
|
else:
|
||||||
channel = await evt.sender.client.get_entity(identifier)
|
channel = await evt.sender.client.get_entity(arg)
|
||||||
if not channel:
|
if not channel:
|
||||||
return None, await evt.reply("Channel/supergroup not found.")
|
return None, await evt.reply("Channel/supergroup not found.")
|
||||||
return await evt.sender.client(JoinChannelRequest(channel)), None
|
return await evt.sender.client(JoinChannelRequest(channel)), None
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(help_section=SECTION_CREATING_PORTALS,
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
help_args="<_link_>",
|
||||||
help_args="<_link_>",
|
help_text="Join a chat with an invite link.")
|
||||||
help_text="Join a chat with an invite link.",
|
async def join(evt: CommandEvent) -> Optional[Dict]:
|
||||||
)
|
|
||||||
async def join(evt: CommandEvent) -> EventID | None:
|
|
||||||
if len(evt.args) == 0:
|
if len(evt.args) == 0:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
|
||||||
|
|
||||||
url = evt.args[0]
|
regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
|
||||||
if evt.config["bridge.invite_link_resolve"]:
|
arg = regex.match(evt.args[0])
|
||||||
try:
|
|
||||||
async with ClientSession() as sess, sess.get(url) as resp:
|
|
||||||
url = str(resp.url)
|
|
||||||
except InvalidURL:
|
|
||||||
return await evt.reply("That doesn't look like a Telegram invite link.")
|
|
||||||
|
|
||||||
regex = re.compile(
|
|
||||||
r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:/(?P<type>joinchat|s))?/(?P<id>[^/]+)/?",
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
arg = regex.match(url)
|
|
||||||
if not arg:
|
if not arg:
|
||||||
return await evt.reply("That doesn't look like a Telegram invite link.")
|
return await evt.reply("That doesn't look like a Telegram invite link.")
|
||||||
|
|
||||||
data = arg.groupdict()
|
updates, _ = await _join(evt, arg.group(1))
|
||||||
identifier = data["id"]
|
|
||||||
link_type = data["type"]
|
|
||||||
if link_type:
|
|
||||||
link_type = link_type.lower()
|
|
||||||
elif identifier.startswith("+"):
|
|
||||||
link_type = "joinchat"
|
|
||||||
identifier = identifier[1:]
|
|
||||||
updates, _ = await _join(evt, identifier, link_type)
|
|
||||||
if not updates:
|
if not updates:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for chat in updates.chats:
|
for chat in updates.chats:
|
||||||
portal = await po.Portal.get_by_entity(chat)
|
portal = po.Portal.get_by_entity(chat)
|
||||||
if portal.mxid:
|
if portal.mxid:
|
||||||
await portal.invite_to_matrix([evt.sender.mxid])
|
await portal.invite_to_matrix([evt.sender.mxid])
|
||||||
return await evt.reply(f"Invited you to portal of {portal.title}")
|
return await evt.reply(f"Invited you to portal of {portal.title}")
|
||||||
else:
|
else:
|
||||||
await evt.reply(f"Creating room for {chat.title}... This might take a while.")
|
await evt.reply(f"Creating room for {chat.title}... This might take a while.")
|
||||||
try:
|
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
|
||||||
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
|
|
||||||
except ChatIdInvalidError as e:
|
|
||||||
evt.log.trace(
|
|
||||||
"ChatIdInvalidError while creating portal from !tg join command: %s",
|
|
||||||
updates.stringify(),
|
|
||||||
)
|
|
||||||
raise e
|
|
||||||
return await evt.reply(f"Created room for {portal.title}")
|
return await evt.reply(f"Created room for {portal.title}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(help_section=SECTION_MISC,
|
||||||
help_section=SECTION_MISC,
|
help_args="[`chats`|`contacts`|`me`]",
|
||||||
help_args="[`chats`|`contacts`|`me`]",
|
help_text="Synchronize your chat portals, contacts and/or own info.")
|
||||||
help_text="Synchronize your chat portals, contacts and/or own info.",
|
async def sync(evt: CommandEvent) -> Optional[Dict]:
|
||||||
)
|
|
||||||
async def sync(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) > 0:
|
if len(evt.args) > 0:
|
||||||
sync_only = evt.args[0]
|
sync_only = evt.args[0]
|
||||||
if sync_only not in ("chats", "contacts", "me"):
|
if sync_only not in ("chats", "contacts", "me"):
|
||||||
@@ -248,10 +159,8 @@ async def sync(evt: CommandEvent) -> EventID:
|
|||||||
sync_only = None
|
sync_only = None
|
||||||
|
|
||||||
if not sync_only or sync_only == "chats":
|
if not sync_only or sync_only == "chats":
|
||||||
await evt.reply("Synchronizing chats...")
|
await evt.sender.sync_dialogs(synchronous_create=True)
|
||||||
await evt.sender.sync_dialogs()
|
|
||||||
if not sync_only or sync_only == "contacts":
|
if not sync_only or sync_only == "contacts":
|
||||||
await evt.reply("Synchronizing contacts...")
|
|
||||||
await evt.sender.sync_contacts()
|
await evt.sender.sync_contacts()
|
||||||
if not sync_only or sync_only == "me":
|
if not sync_only or sync_only == "me":
|
||||||
await evt.sender.update_info()
|
await evt.sender.update_info()
|
||||||
@@ -267,9 +176,8 @@ class MessageIDError(ValueError):
|
|||||||
self.message = message
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
async def _parse_encoded_msgid(
|
async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
|
||||||
user: AbstractUser, enc_id: str, type_name: str
|
) -> Tuple[TypePeer, Message]:
|
||||||
) -> tuple[TypeInputPeer, Message]:
|
|
||||||
try:
|
try:
|
||||||
enc_id += (4 - len(enc_id) % 4) * "="
|
enc_id += (4 - len(enc_id) % 4) * "="
|
||||||
enc_id = base64.b64decode(enc_id)
|
enc_id = base64.b64decode(enc_id)
|
||||||
@@ -283,10 +191,10 @@ async def _parse_encoded_msgid(
|
|||||||
raise MessageIDError(f"Invalid {type_name} ID (format)") from e
|
raise MessageIDError(f"Invalid {type_name} ID (format)") from e
|
||||||
|
|
||||||
if peer_type == PEER_TYPE_CHAT:
|
if peer_type == PEER_TYPE_CHAT:
|
||||||
orig_msg = await DBMessage.get_one_by_tgid(msg_id, space)
|
orig_msg = DBMessage.get_by_tgid(msg_id, space)
|
||||||
if not orig_msg:
|
if not orig_msg:
|
||||||
raise MessageIDError(f"Invalid {type_name} ID (original message not found in db)")
|
raise MessageIDError(f"Invalid {type_name} ID (original message not found in db)")
|
||||||
new_msg = await DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
|
new_msg = DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
|
||||||
if not new_msg:
|
if not new_msg:
|
||||||
raise MessageIDError(f"Invalid {type_name} ID (your copy of message not found in db)")
|
raise MessageIDError(f"Invalid {type_name} ID (your copy of message not found in db)")
|
||||||
msg_id = new_msg.tgid
|
msg_id = new_msg.tgid
|
||||||
@@ -298,13 +206,13 @@ async def _parse_encoded_msgid(
|
|||||||
msg = await user.client.get_messages(entity=peer, ids=msg_id)
|
msg = await user.client.get_messages(entity=peer, ids=msg_id)
|
||||||
if not msg:
|
if not msg:
|
||||||
raise MessageIDError(f"Invalid {type_name} ID (message not found)")
|
raise MessageIDError(f"Invalid {type_name} ID (message not found)")
|
||||||
return peer, cast(Message, msg)
|
return peer, msg
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(help_section=SECTION_MISC,
|
||||||
help_section=SECTION_MISC, help_args="<_play ID_>", help_text="Play a Telegram game."
|
help_args="<_play ID_>",
|
||||||
)
|
help_text="Play a Telegram game.")
|
||||||
async def play(evt: CommandEvent) -> EventID:
|
async def play(evt: CommandEvent) -> Optional[Dict]:
|
||||||
if len(evt.args) < 1:
|
if len(evt.args) < 1:
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp play <play ID>`")
|
return await evt.reply("**Usage:** `$cmdprefix+sp play <play ID>`")
|
||||||
elif not await evt.sender.is_logged_in():
|
elif not await evt.sender.is_logged_in():
|
||||||
@@ -320,26 +228,20 @@ async def play(evt: CommandEvent) -> EventID:
|
|||||||
if not isinstance(msg.media, MessageMediaGame):
|
if not isinstance(msg.media, MessageMediaGame):
|
||||||
return await evt.reply("Invalid play ID (message doesn't look like a game)")
|
return await evt.reply("Invalid play ID (message doesn't look like a game)")
|
||||||
|
|
||||||
game = await evt.sender.client(
|
game = await evt.sender.client(GetBotCallbackAnswerRequest(peer=peer, msg_id=msg.id, game=True))
|
||||||
GetBotCallbackAnswerRequest(peer=peer, msg_id=msg.id, game=True)
|
|
||||||
)
|
|
||||||
if not isinstance(game, BotCallbackAnswer):
|
if not isinstance(game, BotCallbackAnswer):
|
||||||
return await evt.reply("Game request response invalid")
|
return await evt.reply("Game request response invalid")
|
||||||
|
|
||||||
return await evt.reply(
|
await evt.reply(f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
|
||||||
f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
|
f"{msg.media.game.description}")
|
||||||
f"{msg.media.game.description}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(help_section=SECTION_MISC,
|
||||||
help_section=SECTION_MISC,
|
help_args="<_poll ID_> <_choice ID_>",
|
||||||
help_args="<_poll ID_> <_choice number_>",
|
help_text="Vote in a Telegram poll.")
|
||||||
help_text="Vote in a Telegram poll.",
|
async def vote(evt: CommandEvent) -> Optional[Dict]:
|
||||||
)
|
if len(evt.args) < 2:
|
||||||
async def vote(evt: CommandEvent) -> EventID | None:
|
return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice ID>`")
|
||||||
if len(evt.args) < 1:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice number>`")
|
|
||||||
elif not await evt.sender.is_logged_in():
|
elif not await evt.sender.is_logged_in():
|
||||||
return await evt.reply("You must be logged in with a real account to vote in polls.")
|
return await evt.reply("You must be logged in with a real account to vote in polls.")
|
||||||
elif evt.sender.is_bot:
|
elif evt.sender.is_bot:
|
||||||
@@ -353,91 +255,11 @@ async def vote(evt: CommandEvent) -> EventID | None:
|
|||||||
if not isinstance(msg.media, MessageMediaPoll):
|
if not isinstance(msg.media, MessageMediaPoll):
|
||||||
return await evt.reply("Invalid poll ID (message doesn't look like a poll)")
|
return await evt.reply("Invalid poll ID (message doesn't look like a poll)")
|
||||||
|
|
||||||
options = []
|
options = [base64.b64decode(option + (3 - (len(option) + 3) % 4) * "=")
|
||||||
for option in evt.args[1:]:
|
for option in evt.args[1:]]
|
||||||
try:
|
|
||||||
if len(option) > 10:
|
|
||||||
raise ValueError("option index too long")
|
|
||||||
option_index = int(option) - 1
|
|
||||||
except ValueError:
|
|
||||||
option_index = None
|
|
||||||
if option_index is None:
|
|
||||||
return await evt.reply(
|
|
||||||
f'Invalid option number "{option}"', render_markdown=False, allow_html=False
|
|
||||||
)
|
|
||||||
elif option_index < 0:
|
|
||||||
return await evt.reply(
|
|
||||||
f"Invalid option number {option}. Option numbers must be positive."
|
|
||||||
)
|
|
||||||
elif option_index >= len(msg.media.poll.answers):
|
|
||||||
return await evt.reply(
|
|
||||||
f"Invalid option number {option}. "
|
|
||||||
f"The poll only has {len(msg.media.poll.answers)} options."
|
|
||||||
)
|
|
||||||
options.append(msg.media.poll.answers[option_index].option)
|
|
||||||
options = [msg.media.poll.answers[int(option) - 1].option for option in evt.args[1:]]
|
|
||||||
try:
|
try:
|
||||||
await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
|
resp = await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
|
||||||
except OptionsTooMuchError:
|
except OptionsTooMuchError:
|
||||||
return await evt.reply("You passed too many options.")
|
return await evt.reply("You passed too many options.")
|
||||||
# TODO use response
|
# TODO use response
|
||||||
return await evt.mark_read()
|
return await evt.mark_read()
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_MISC,
|
|
||||||
help_args="<_emoji_>",
|
|
||||||
help_text="Roll a dice (\U0001F3B2), kick a football (\u26BD\uFE0F) or throw a "
|
|
||||||
"dart (\U0001F3AF) or basketball (\U0001F3C0) on the Telegram servers.",
|
|
||||||
)
|
|
||||||
async def random(evt: CommandEvent) -> EventID:
|
|
||||||
if not evt.is_portal:
|
|
||||||
return await evt.reply("You can only randomize values in portal rooms")
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
arg = evt.args[0] if len(evt.args) > 0 else "dice"
|
|
||||||
emoticon = {
|
|
||||||
"dart": "\U0001F3AF",
|
|
||||||
"dice": "\U0001F3B2",
|
|
||||||
"ball": "\U0001F3C0",
|
|
||||||
"basketball": "\U0001F3C0",
|
|
||||||
"football": "\u26BD",
|
|
||||||
"soccer": "\u26BD",
|
|
||||||
}.get(arg, arg)
|
|
||||||
try:
|
|
||||||
await evt.sender.client.send_media(
|
|
||||||
await portal.get_input_entity(evt.sender), InputMediaDice(emoticon)
|
|
||||||
)
|
|
||||||
except EmoticonInvalidError:
|
|
||||||
return await evt.reply("Invalid emoji for randomization")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_args="[_limit_]",
|
|
||||||
help_text="Backfill messages from Telegram history.",
|
|
||||||
)
|
|
||||||
async def backfill(evt: CommandEvent) -> None:
|
|
||||||
if not evt.is_portal:
|
|
||||||
await evt.reply("You can only use backfill in portal rooms")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
limit = int(evt.args[0])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
limit = -1
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not evt.config["bridge.backfill.normal_groups"] and portal.peer_type == "chat":
|
|
||||||
await evt.reply("Backfilling normal groups is disabled in the bridge config")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await portal.backfill(evt.sender, limit=limit)
|
|
||||||
except TakeoutInitDelayError:
|
|
||||||
msg = (
|
|
||||||
"Please accept the data export request from a mobile device, "
|
|
||||||
"then re-run the backfill command."
|
|
||||||
)
|
|
||||||
if portal.peer_type == "user":
|
|
||||||
from mautrix.appservice import IntentAPI
|
|
||||||
|
|
||||||
await portal.main_intent.send_notice(evt.room_id, msg)
|
|
||||||
else:
|
|
||||||
await evt.reply(msg)
|
|
||||||
|
|||||||
+208
-147
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,70 +14,157 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from typing import Any, List, NamedTuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
import os
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
from ruamel.yaml.comments import CommentedMap
|
from ruamel.yaml.comments import CommentedMap
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
from mautrix.bridge.config import BaseBridgeConfig
|
yaml = YAML() # type: YAML
|
||||||
from mautrix.client import Client
|
yaml.indent(4)
|
||||||
from mautrix.types import UserID
|
|
||||||
from mautrix.util.config import ConfigUpdateHelper, ForbiddenDefault, ForbiddenKey
|
|
||||||
|
|
||||||
Permissions = NamedTuple(
|
|
||||||
"Permissions",
|
|
||||||
relaybot=bool,
|
|
||||||
user=bool,
|
|
||||||
puppeting=bool,
|
|
||||||
matrix_puppeting=bool,
|
|
||||||
admin=bool,
|
|
||||||
level=str,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseBridgeConfig):
|
class DictWithRecursion:
|
||||||
|
def __init__(self, data: Optional[CommentedMap] = None) -> None:
|
||||||
|
self._data = data or CommentedMap() # type: CommentedMap
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_key(key: str) -> Tuple[str, Optional[str]]:
|
||||||
|
if '.' not in key:
|
||||||
|
return key, None
|
||||||
|
key, next_key = key.split('.', 1)
|
||||||
|
if len(key) > 0 and key[0] == "[":
|
||||||
|
end_index = next_key.index("]")
|
||||||
|
key = key[1:] + "." + next_key[:end_index]
|
||||||
|
next_key = next_key[end_index + 2:] if len(next_key) > end_index + 1 else None
|
||||||
|
return key, next_key
|
||||||
|
|
||||||
|
def _recursive_get(self, data: CommentedMap, key: str, default_value: Any) -> Any:
|
||||||
|
key, next_key = self._parse_key(key)
|
||||||
|
if next_key is not None:
|
||||||
|
next_data = data.get(key, CommentedMap())
|
||||||
|
return self._recursive_get(next_data, next_key, default_value)
|
||||||
|
return data.get(key, default_value)
|
||||||
|
|
||||||
|
def get(self, key: str, default_value: Any, allow_recursion: bool = True) -> Any:
|
||||||
|
if allow_recursion and '.' in key:
|
||||||
|
return self._recursive_get(self._data, key, default_value)
|
||||||
|
return self._data.get(key, default_value)
|
||||||
|
|
||||||
def __getitem__(self, key: str) -> Any:
|
def __getitem__(self, key: str) -> Any:
|
||||||
|
return self.get(key, None)
|
||||||
|
|
||||||
|
def __contains__(self, key: str) -> bool:
|
||||||
|
return self[key] is not None
|
||||||
|
|
||||||
|
def _recursive_set(self, data: CommentedMap, key: str, value: Any) -> None:
|
||||||
|
key, next_key = self._parse_key(key)
|
||||||
|
if next_key is not None:
|
||||||
|
if key not in data:
|
||||||
|
data[key] = CommentedMap()
|
||||||
|
next_data = data.get(key, CommentedMap())
|
||||||
|
return self._recursive_set(next_data, next_key, value)
|
||||||
|
data[key] = value
|
||||||
|
|
||||||
|
def set(self, key: str, value: Any, allow_recursion: bool = True) -> None:
|
||||||
|
if allow_recursion and '.' in key:
|
||||||
|
self._recursive_set(self._data, key, value)
|
||||||
|
return
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
def __setitem__(self, key: str, value: Any) -> None:
|
||||||
|
self.set(key, value)
|
||||||
|
|
||||||
|
def _recursive_del(self, data: CommentedMap, key: str) -> None:
|
||||||
|
key, next_key = self._parse_key(key)
|
||||||
|
if next_key is not None:
|
||||||
|
if key not in data:
|
||||||
|
return
|
||||||
|
next_data = data[key]
|
||||||
|
return self._recursive_del(next_data, next_key)
|
||||||
try:
|
try:
|
||||||
return os.environ[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
|
del data[key]
|
||||||
|
del data.ca.items[key]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return super().__getitem__(key)
|
pass
|
||||||
|
|
||||||
@property
|
def delete(self, key: str, allow_recursion: bool = True) -> None:
|
||||||
def forbidden_defaults(self) -> List[ForbiddenDefault]:
|
if allow_recursion and '.' in key:
|
||||||
return [
|
self._recursive_del(self._data, key)
|
||||||
*super().forbidden_defaults,
|
return
|
||||||
ForbiddenDefault(
|
try:
|
||||||
"appservice.database",
|
del self._data[key]
|
||||||
"postgres://username:password@hostname/dbname",
|
del self._data.ca.items[key]
|
||||||
),
|
except KeyError:
|
||||||
ForbiddenDefault(
|
pass
|
||||||
"appservice.public.external",
|
|
||||||
"https://example.com/public",
|
|
||||||
condition="appservice.public.enabled",
|
|
||||||
),
|
|
||||||
ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
|
|
||||||
ForbiddenDefault("telegram.api_id", 12345),
|
|
||||||
ForbiddenDefault("telegram.api_hash", "tjyd5yge35lbodk1xwzw2jstp90k55qz"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
def __delitem__(self, key: str) -> None:
|
||||||
super().do_update(helper)
|
self.delete(key)
|
||||||
copy, copy_dict, base = helper
|
|
||||||
|
|
||||||
copy("homeserver.asmux")
|
|
||||||
|
class Config(DictWithRecursion):
|
||||||
|
def __init__(self, path: str, registration_path: str, base_path: str) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.path = path # type: str
|
||||||
|
self.registration_path = registration_path # type: str
|
||||||
|
self.base_path = base_path # type: str
|
||||||
|
self._registration = None # type: Optional[Dict]
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
with open(self.path, 'r') as stream:
|
||||||
|
self._data = yaml.load(stream)
|
||||||
|
|
||||||
|
def load_base(self) -> Optional[DictWithRecursion]:
|
||||||
|
try:
|
||||||
|
with open(self.base_path, 'r') as stream:
|
||||||
|
return DictWithRecursion(yaml.load(stream))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
with open(self.path, 'w') as stream:
|
||||||
|
yaml.dump(self._data, stream)
|
||||||
|
if self._registration and self.registration_path:
|
||||||
|
with open(self.registration_path, 'w') as stream:
|
||||||
|
yaml.dump(self._registration, stream)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _new_token() -> str:
|
||||||
|
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
base = self.load_base()
|
||||||
|
if not base:
|
||||||
|
return
|
||||||
|
|
||||||
|
def copy(from_path, to_path=None) -> None:
|
||||||
|
if from_path in self:
|
||||||
|
base[to_path or from_path] = self[from_path]
|
||||||
|
|
||||||
|
def copy_dict(from_path, to_path=None, override_existing_map=True) -> None:
|
||||||
|
if from_path in self:
|
||||||
|
to_path = to_path or from_path
|
||||||
|
if override_existing_map or to_path not in base:
|
||||||
|
base[to_path] = CommentedMap()
|
||||||
|
for key, value in self[from_path].items():
|
||||||
|
base[to_path][key] = value
|
||||||
|
|
||||||
|
copy("homeserver.address")
|
||||||
|
copy("homeserver.domain")
|
||||||
|
copy("homeserver.verify_ssl")
|
||||||
|
|
||||||
if "appservice.protocol" in self and "appservice.address" not in self:
|
if "appservice.protocol" in self and "appservice.address" not in self:
|
||||||
protocol, hostname, port = (
|
protocol, hostname, port = (self["appservice.protocol"], self["appservice.hostname"],
|
||||||
self["appservice.protocol"],
|
self["appservice.port"])
|
||||||
self["appservice.hostname"],
|
|
||||||
self["appservice.port"],
|
|
||||||
)
|
|
||||||
base["appservice.address"] = f"{protocol}://{hostname}:{port}"
|
base["appservice.address"] = f"{protocol}://{hostname}:{port}"
|
||||||
if "appservice.debug" in self and "logging" not in self:
|
else:
|
||||||
level = "DEBUG" if self["appservice.debug"] else "INFO"
|
copy("appservice.address")
|
||||||
base["logging.root.level"] = level
|
copy("appservice.hostname")
|
||||||
base["logging.loggers.mau.level"] = level
|
copy("appservice.port")
|
||||||
base["logging.loggers.telethon.level"] = level
|
copy("appservice.max_body_size")
|
||||||
|
|
||||||
|
copy("appservice.database")
|
||||||
|
|
||||||
copy("appservice.public.enabled")
|
copy("appservice.public.enabled")
|
||||||
copy("appservice.public.prefix")
|
copy("appservice.public.prefix")
|
||||||
@@ -88,100 +176,53 @@ class Config(BaseBridgeConfig):
|
|||||||
if base["appservice.provisioning.shared_secret"] == "generate":
|
if base["appservice.provisioning.shared_secret"] == "generate":
|
||||||
base["appservice.provisioning.shared_secret"] = self._new_token()
|
base["appservice.provisioning.shared_secret"] = self._new_token()
|
||||||
|
|
||||||
if "pool_size" in base["appservice.database_opts"]:
|
copy("appservice.id")
|
||||||
pool_size = base["appservice.database_opts"].pop("pool_size")
|
copy("appservice.bot_username")
|
||||||
base["appservice.database_opts.min_size"] = pool_size
|
copy("appservice.bot_displayname")
|
||||||
base["appservice.database_opts.max_size"] = pool_size
|
copy("appservice.bot_avatar")
|
||||||
if "pool_pre_ping" in base["appservice.database_opts"]:
|
|
||||||
del base["appservice.database_opts.pool_pre_ping"]
|
|
||||||
|
|
||||||
copy("metrics.enabled")
|
copy("appservice.as_token")
|
||||||
copy("metrics.listen_port")
|
copy("appservice.hs_token")
|
||||||
|
|
||||||
copy("bridge.username_template")
|
copy("bridge.username_template")
|
||||||
copy("bridge.alias_template")
|
copy("bridge.alias_template")
|
||||||
copy("bridge.displayname_template")
|
copy("bridge.displayname_template")
|
||||||
|
|
||||||
copy("bridge.displayname_preference")
|
copy("bridge.displayname_preference")
|
||||||
copy("bridge.displayname_max_length")
|
|
||||||
copy("bridge.allow_avatar_remove")
|
|
||||||
|
|
||||||
copy("bridge.max_initial_member_sync")
|
copy("bridge.max_initial_member_sync")
|
||||||
copy("bridge.sync_channel_members")
|
copy("bridge.sync_channel_members")
|
||||||
copy("bridge.skip_deleted_members")
|
copy("bridge.skip_deleted_members")
|
||||||
copy("bridge.startup_sync")
|
copy("bridge.startup_sync")
|
||||||
if "bridge.sync_dialog_limit" in self:
|
copy("bridge.sync_dialog_limit")
|
||||||
base["bridge.sync_create_limit"] = self["bridge.sync_dialog_limit"]
|
|
||||||
base["bridge.sync_update_limit"] = self["bridge.sync_dialog_limit"]
|
|
||||||
else:
|
|
||||||
copy("bridge.sync_update_limit")
|
|
||||||
copy("bridge.sync_create_limit")
|
|
||||||
copy("bridge.sync_direct_chats")
|
|
||||||
copy("bridge.max_telegram_delete")
|
copy("bridge.max_telegram_delete")
|
||||||
copy("bridge.sync_matrix_state")
|
copy("bridge.sync_matrix_state")
|
||||||
copy("bridge.allow_matrix_login")
|
copy("bridge.allow_matrix_login")
|
||||||
copy("bridge.plaintext_highlights")
|
copy("bridge.plaintext_highlights")
|
||||||
|
copy("bridge.edits_as_replies")
|
||||||
|
copy("bridge.highlight_edits")
|
||||||
copy("bridge.public_portals")
|
copy("bridge.public_portals")
|
||||||
|
copy("bridge.catch_up")
|
||||||
copy("bridge.sync_with_custom_puppets")
|
copy("bridge.sync_with_custom_puppets")
|
||||||
copy("bridge.sync_direct_chat_list")
|
|
||||||
copy("bridge.double_puppet_server_map")
|
|
||||||
copy("bridge.double_puppet_allow_discovery")
|
|
||||||
if "bridge.login_shared_secret" in self:
|
|
||||||
base["bridge.login_shared_secret_map"] = {
|
|
||||||
base["homeserver.domain"]: self["bridge.login_shared_secret"]
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
copy("bridge.login_shared_secret_map")
|
|
||||||
copy("bridge.telegram_link_preview")
|
copy("bridge.telegram_link_preview")
|
||||||
copy("bridge.invite_link_resolve")
|
|
||||||
copy("bridge.inline_images")
|
copy("bridge.inline_images")
|
||||||
copy("bridge.image_as_file_size")
|
copy("bridge.image_as_file_size")
|
||||||
copy("bridge.image_as_file_pixels")
|
|
||||||
copy("bridge.max_document_size")
|
|
||||||
copy("bridge.parallel_file_transfer")
|
|
||||||
copy("bridge.federate_rooms")
|
|
||||||
copy("bridge.animated_sticker.target")
|
|
||||||
copy("bridge.animated_sticker.args.width")
|
|
||||||
copy("bridge.animated_sticker.args.height")
|
|
||||||
copy("bridge.animated_sticker.args.fps")
|
|
||||||
copy("bridge.encryption.allow")
|
|
||||||
copy("bridge.encryption.default")
|
|
||||||
copy("bridge.encryption.database")
|
|
||||||
copy("bridge.encryption.key_sharing.allow")
|
|
||||||
copy("bridge.encryption.key_sharing.require_cross_signing")
|
|
||||||
copy("bridge.encryption.key_sharing.require_verification")
|
|
||||||
copy("bridge.private_chat_portal_meta")
|
|
||||||
copy("bridge.delivery_receipts")
|
|
||||||
copy("bridge.delivery_error_reports")
|
|
||||||
copy("bridge.resend_bridge_info")
|
|
||||||
copy("bridge.mute_bridging")
|
|
||||||
copy("bridge.pinned_tag")
|
|
||||||
copy("bridge.archive_tag")
|
|
||||||
copy("bridge.tag_only_on_create")
|
|
||||||
copy("bridge.bridge_matrix_leave")
|
|
||||||
copy("bridge.kick_on_logout")
|
|
||||||
copy("bridge.backfill.invite_own_puppet")
|
|
||||||
copy("bridge.backfill.takeout_limit")
|
|
||||||
copy("bridge.backfill.initial_limit")
|
|
||||||
copy("bridge.backfill.missed_limit")
|
|
||||||
copy("bridge.backfill.disable_notifications")
|
|
||||||
copy("bridge.backfill.normal_groups")
|
|
||||||
|
|
||||||
copy("bridge.initial_power_level_overrides.group")
|
|
||||||
copy("bridge.initial_power_level_overrides.user")
|
|
||||||
|
|
||||||
copy("bridge.bot_messages_as_notices")
|
copy("bridge.bot_messages_as_notices")
|
||||||
if isinstance(self["bridge.bridge_notices"], bool):
|
if isinstance(self["bridge.bridge_notices"], bool):
|
||||||
base["bridge.bridge_notices"]["default"] = self["bridge.bridge_notices"]
|
base["bridge.bridge_notices"] = {
|
||||||
|
"default": self["bridge.bridge_notices"],
|
||||||
|
"exceptions": ["@importantbot:example.com"],
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
copy("bridge.bridge_notices.default")
|
copy("bridge.bridge_notices")
|
||||||
copy("bridge.bridge_notices.exceptions")
|
|
||||||
|
copy("bridge.deduplication.pre_db_check")
|
||||||
|
copy("bridge.deduplication.cache_queue_length")
|
||||||
|
|
||||||
if "bridge.message_formats.m_text" in self:
|
if "bridge.message_formats.m_text" in self:
|
||||||
del self["bridge.message_formats"]
|
del self["bridge.message_formats"]
|
||||||
copy_dict("bridge.message_formats", override_existing_map=False)
|
copy_dict("bridge.message_formats", override_existing_map=False)
|
||||||
copy("bridge.emote_format")
|
|
||||||
copy("bridge.relay_user_distinguishers")
|
|
||||||
|
|
||||||
copy("bridge.state_event_formats.join")
|
copy("bridge.state_event_formats.join")
|
||||||
copy("bridge.state_event_formats.leave")
|
copy("bridge.state_event_formats.leave")
|
||||||
@@ -192,11 +233,9 @@ class Config(BaseBridgeConfig):
|
|||||||
|
|
||||||
copy("bridge.command_prefix")
|
copy("bridge.command_prefix")
|
||||||
|
|
||||||
migrate_permissions = (
|
migrate_permissions = ("bridge.permissions" not in self
|
||||||
"bridge.permissions" not in self
|
or "bridge.whitelist" in self
|
||||||
or "bridge.whitelist" in self
|
or "bridge.admins" in self)
|
||||||
or "bridge.admins" in self
|
|
||||||
)
|
|
||||||
if migrate_permissions:
|
if migrate_permissions:
|
||||||
permissions = self["bridge.permissions"] or CommentedMap()
|
permissions = self["bridge.permissions"] or CommentedMap()
|
||||||
for entry in self["bridge.whitelist"] or []:
|
for entry in self["bridge.whitelist"] or []:
|
||||||
@@ -205,16 +244,11 @@ class Config(BaseBridgeConfig):
|
|||||||
permissions[entry] = "admin"
|
permissions[entry] = "admin"
|
||||||
base["bridge.permissions"] = permissions
|
base["bridge.permissions"] = permissions
|
||||||
else:
|
else:
|
||||||
copy_dict("bridge.permissions", override_existing_map=True)
|
copy_dict("bridge.permissions")
|
||||||
|
|
||||||
if "bridge.relaybot" not in self:
|
if "bridge.relaybot" not in self:
|
||||||
copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals")
|
copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals")
|
||||||
else:
|
else:
|
||||||
copy("bridge.relaybot.private_chat.invite")
|
|
||||||
copy("bridge.relaybot.private_chat.state_changes")
|
|
||||||
copy("bridge.relaybot.private_chat.message")
|
|
||||||
copy("bridge.relaybot.group_chat_invite")
|
|
||||||
copy("bridge.relaybot.ignore_unbridged_group_chat")
|
|
||||||
copy("bridge.relaybot.authless_portals")
|
copy("bridge.relaybot.authless_portals")
|
||||||
copy("bridge.relaybot.whitelist_group_admins")
|
copy("bridge.relaybot.whitelist_group_admins")
|
||||||
copy("bridge.relaybot.whitelist")
|
copy("bridge.relaybot.whitelist")
|
||||||
@@ -223,24 +257,10 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("telegram.api_id")
|
copy("telegram.api_id")
|
||||||
copy("telegram.api_hash")
|
copy("telegram.api_hash")
|
||||||
copy("telegram.bot_token")
|
copy("telegram.bot_token")
|
||||||
|
|
||||||
copy("telegram.connection.timeout")
|
|
||||||
copy("telegram.connection.retries")
|
|
||||||
copy("telegram.connection.retry_delay")
|
|
||||||
copy("telegram.connection.flood_sleep_threshold")
|
|
||||||
copy("telegram.connection.request_retries")
|
|
||||||
|
|
||||||
copy("telegram.device_info.device_model")
|
|
||||||
copy("telegram.device_info.system_version")
|
|
||||||
copy("telegram.device_info.app_version")
|
|
||||||
copy("telegram.device_info.lang_code")
|
|
||||||
copy("telegram.device_info.system_lang_code")
|
|
||||||
|
|
||||||
copy("telegram.server.enabled")
|
copy("telegram.server.enabled")
|
||||||
copy("telegram.server.dc")
|
copy("telegram.server.dc")
|
||||||
copy("telegram.server.ip")
|
copy("telegram.server.ip")
|
||||||
copy("telegram.server.port")
|
copy("telegram.server.port")
|
||||||
|
|
||||||
copy("telegram.proxy.type")
|
copy("telegram.proxy.type")
|
||||||
copy("telegram.proxy.address")
|
copy("telegram.proxy.address")
|
||||||
copy("telegram.proxy.port")
|
copy("telegram.proxy.port")
|
||||||
@@ -248,22 +268,63 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("telegram.proxy.username")
|
copy("telegram.proxy.username")
|
||||||
copy("telegram.proxy.password")
|
copy("telegram.proxy.password")
|
||||||
|
|
||||||
def _get_permissions(self, key: str) -> Permissions:
|
if "appservice.debug" in self and "logging" not in self:
|
||||||
|
level = "DEBUG" if self["appservice.debug"] else "INFO"
|
||||||
|
base["logging.root.level"] = level
|
||||||
|
base["logging.loggers.mau.level"] = level
|
||||||
|
base["logging.loggers.telethon.level"] = level
|
||||||
|
else:
|
||||||
|
copy("logging")
|
||||||
|
|
||||||
|
self._data = base._data
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def _get_permissions(self, key: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
|
||||||
level = self["bridge.permissions"].get(key, "")
|
level = self["bridge.permissions"].get(key, "")
|
||||||
admin = level == "admin"
|
admin = level == "admin"
|
||||||
matrix_puppeting = level == "full" or admin
|
matrix_puppeting = level == "full" or admin
|
||||||
puppeting = level == "puppeting" or matrix_puppeting
|
puppeting = level == "puppeting" or matrix_puppeting
|
||||||
user = level == "user" or puppeting
|
user = level == "user" or puppeting
|
||||||
relaybot = level == "relaybot" or user
|
relaybot = level == "relaybot" or user
|
||||||
return Permissions(relaybot, user, puppeting, matrix_puppeting, admin, level)
|
return relaybot, user, puppeting, matrix_puppeting, admin, level
|
||||||
|
|
||||||
def get_permissions(self, mxid: UserID) -> Permissions:
|
def get_permissions(self, mxid: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
|
||||||
permissions = self["bridge.permissions"]
|
permissions = self["bridge.permissions"] or {}
|
||||||
if mxid in permissions:
|
if mxid in permissions:
|
||||||
return self._get_permissions(mxid)
|
return self._get_permissions(mxid)
|
||||||
|
|
||||||
_, homeserver = Client.parse_user_id(mxid)
|
homeserver = mxid[mxid.index(":") + 1:]
|
||||||
if homeserver in permissions:
|
if homeserver in permissions:
|
||||||
return self._get_permissions(homeserver)
|
return self._get_permissions(homeserver)
|
||||||
|
|
||||||
return self._get_permissions("*")
|
return self._get_permissions("*")
|
||||||
|
|
||||||
|
def generate_registration(self) -> None:
|
||||||
|
homeserver = self["homeserver.domain"]
|
||||||
|
|
||||||
|
username_format = self.get("bridge.username_template", "telegram_{userid}") \
|
||||||
|
.format(userid=".+")
|
||||||
|
alias_format = self.get("bridge.alias_template", "telegram_{groupname}") \
|
||||||
|
.format(groupname=".+")
|
||||||
|
|
||||||
|
self.set("appservice.as_token", self._new_token())
|
||||||
|
self.set("appservice.hs_token", self._new_token())
|
||||||
|
|
||||||
|
self._registration = {
|
||||||
|
"id": self["appservice.id"] or "telegram",
|
||||||
|
"as_token": self["appservice.as_token"],
|
||||||
|
"hs_token": self["appservice.hs_token"],
|
||||||
|
"namespaces": {
|
||||||
|
"users": [{
|
||||||
|
"exclusive": True,
|
||||||
|
"regex": f"@{username_format}:{homeserver}"
|
||||||
|
}],
|
||||||
|
"aliases": [{
|
||||||
|
"exclusive": True,
|
||||||
|
"regex": f"#{alias_format}:{homeserver}"
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"url": self["appservice.address"],
|
||||||
|
"sender_localpart": self["appservice.bot_username"],
|
||||||
|
"rate_limited": False
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
|
# Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
|
||||||
|
from typing import Optional, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from alchemysession import AlchemySessionContainer
|
||||||
|
from mautrix_appservice import AppService
|
||||||
|
|
||||||
|
from .web import PublicBridgeWebsite, ProvisioningAPI
|
||||||
|
from .config import Config
|
||||||
|
from .bot import Bot
|
||||||
|
from .matrix import MatrixHandler
|
||||||
|
|
||||||
|
|
||||||
|
class Context:
|
||||||
|
def __init__(self, az: 'AppService', config: 'Config', loop: 'asyncio.AbstractEventLoop',
|
||||||
|
session_container: 'AlchemySessionContainer', bot: Optional['Bot']) -> None:
|
||||||
|
self.az = az # type: AppService
|
||||||
|
self.config = config # type: Config
|
||||||
|
self.loop = loop # type: asyncio.AbstractEventLoop
|
||||||
|
self.bot = bot # type: Optional[Bot]
|
||||||
|
self.mx = None # type: Optional[MatrixHandler]
|
||||||
|
self.session_container = session_container # type: AlchemySessionContainer
|
||||||
|
self.public_website = None # type: Optional[PublicBridgeWebsite]
|
||||||
|
self.provisioning_api = None # type: Optional[ProvisioningAPI]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def core(self) -> Tuple['AppService', 'Config', 'asyncio.AbstractEventLoop', Optional['Bot']]:
|
||||||
|
return self.az, self.config, self.loop, self.bot
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,45 +14,20 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from mautrix.util.async_db import Database
|
from .base import Base
|
||||||
|
|
||||||
from .bot_chat import BotChat
|
from .bot_chat import BotChat
|
||||||
from .disappearing_message import DisappearingMessage
|
|
||||||
from .message import Message
|
from .message import Message
|
||||||
from .portal import Portal
|
from .portal import Portal
|
||||||
from .puppet import Puppet
|
from .puppet import Puppet
|
||||||
from .reaction import Reaction
|
from .room_state import RoomState
|
||||||
from .telegram_file import TelegramFile
|
from .telegram_file import TelegramFile
|
||||||
from .telethon_session import PgSession
|
from .user import User, UserPortal, Contact
|
||||||
from .upgrade import upgrade_table
|
from .user_profile import UserProfile
|
||||||
from .user import User
|
|
||||||
|
|
||||||
|
|
||||||
def init(db: Database) -> None:
|
def init(db_engine) -> None:
|
||||||
for table in (
|
for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile,
|
||||||
Portal,
|
RoomState, BotChat):
|
||||||
Message,
|
table.db = db_engine
|
||||||
Reaction,
|
table.t = table.__table__
|
||||||
User,
|
table.c = table.t.c
|
||||||
Puppet,
|
|
||||||
TelegramFile,
|
|
||||||
BotChat,
|
|
||||||
PgSession,
|
|
||||||
DisappearingMessage,
|
|
||||||
):
|
|
||||||
table.db = db
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"upgrade_table",
|
|
||||||
"init",
|
|
||||||
"Portal",
|
|
||||||
"Message",
|
|
||||||
"Reaction",
|
|
||||||
"User",
|
|
||||||
"Puppet",
|
|
||||||
"TelegramFile",
|
|
||||||
"BotChat",
|
|
||||||
"PgSession",
|
|
||||||
"DisappearingMessage",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
|
# Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
|
||||||
|
from abc import abstractmethod
|
||||||
|
|
||||||
|
from sqlalchemy import Table
|
||||||
|
from sqlalchemy.engine.base import Engine
|
||||||
|
from sqlalchemy.engine.result import RowProxy
|
||||||
|
from sqlalchemy.sql.base import ImmutableColumnCollection
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
|
|
||||||
|
class BaseBase:
|
||||||
|
db = None # type: Engine
|
||||||
|
t = None # type: Table
|
||||||
|
__table__ = None # type: Table
|
||||||
|
c = None # type: ImmutableColumnCollection
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def _one_or_none(cls, rows: RowProxy):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _select_one_or_none(cls, *args):
|
||||||
|
return cls._one_or_none(cls.db.execute(cls.t.select().where(*args)))
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def _edit_identity(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update(self, **values) -> None:
|
||||||
|
with self.db.begin() as conn:
|
||||||
|
conn.execute(self.t.update()
|
||||||
|
.where(self._edit_identity)
|
||||||
|
.values(**values))
|
||||||
|
for key, value in values.items():
|
||||||
|
setattr(self, key, value)
|
||||||
|
|
||||||
|
def delete(self) -> None:
|
||||||
|
with self.db.begin() as conn:
|
||||||
|
conn.execute(self.t.delete().where(self._edit_identity))
|
||||||
|
|
||||||
|
Base = declarative_base(cls=BaseBase)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from abc import abstractmethod
|
||||||
|
|
||||||
|
from sqlalchemy import Table
|
||||||
|
from sqlalchemy.engine.base import Engine
|
||||||
|
from sqlalchemy.engine.result import RowProxy
|
||||||
|
from sqlalchemy.sql.base import ImmutableColumnCollection
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
|
class Base(declarative_base):
|
||||||
|
db: Engine
|
||||||
|
t: Table
|
||||||
|
__table__: Table
|
||||||
|
c: ImmutableColumnCollection
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@abstractmethod
|
||||||
|
def _one_or_none(cls, rows: RowProxy): ...
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _select_one_or_none(cls, *args): ...
|
||||||
|
|
||||||
|
def _edit_identity(self): ...
|
||||||
|
|
||||||
|
def update(self, **values) -> None: ...
|
||||||
|
|
||||||
|
def delete(self) -> None: ...
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,43 +14,32 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Iterable
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
from sqlalchemy import Column, Integer, String
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
from ..types import TelegramID
|
||||||
|
from .base import Base
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
# Fucking Telegram not telling bots what chats they are in 3:<
|
# Fucking Telegram not telling bots what chats they are in 3:<
|
||||||
@dataclass
|
class BotChat(Base):
|
||||||
class BotChat:
|
__tablename__ = "bot_chat"
|
||||||
db: ClassVar[Database] = fake_db
|
id = Column(Integer, primary_key=True) # type: TelegramID
|
||||||
|
type = Column(String, nullable=False)
|
||||||
id: TelegramID
|
|
||||||
type: str
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _from_row(cls, row: Record | None) -> BotChat | None:
|
def delete(cls, chat_id: TelegramID) -> None:
|
||||||
if row is None:
|
with cls.db.begin() as conn:
|
||||||
return None
|
conn.execute(cls.t.delete().where(cls.c.id == chat_id))
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete_by_id(cls, chat_id: TelegramID) -> None:
|
def all(cls) -> Iterable['BotChat']:
|
||||||
await cls.db.execute("DELETE FROM bot_chat WHERE id=$1", chat_id)
|
rows = cls.db.execute(cls.t.select())
|
||||||
|
for row in rows:
|
||||||
|
chat_id, chat_type = row
|
||||||
|
yield cls(id=chat_id, type=chat_type)
|
||||||
|
|
||||||
@classmethod
|
def insert(self) -> None:
|
||||||
async def all(cls) -> list[BotChat]:
|
with self.db.begin() as conn:
|
||||||
rows = await cls.db.fetch("SELECT id, type FROM bot_chat")
|
conn.execute(self.t.insert().values(id=self.id, type=self.type))
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = "INSERT INTO bot_chat (id, type) VALUES ($1, $2)"
|
|
||||||
await self.db.execute(q, self.id, self.type)
|
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Sumner Evans
|
|
||||||
#
|
|
||||||
# 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 <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
from mautrix.bridge import AbstractDisappearingMessage
|
|
||||||
from mautrix.types import EventID, RoomID
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
class DisappearingMessage(AbstractDisappearingMessage):
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = """
|
|
||||||
INSERT INTO disappearing_message (room_id, event_id, expiration_seconds, expiration_ts)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
"""
|
|
||||||
await self.db.execute(
|
|
||||||
q, self.room_id, self.event_id, self.expiration_seconds, self.expiration_ts
|
|
||||||
)
|
|
||||||
|
|
||||||
async def update(self) -> None:
|
|
||||||
q = "UPDATE disappearing_message SET expiration_ts=$3 WHERE room_id=$1 AND event_id=$2"
|
|
||||||
await self.db.execute(q, self.room_id, self.event_id, self.expiration_ts)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
q = "DELETE from disappearing_message WHERE room_id=$1 AND event_id=$2"
|
|
||||||
await self.db.execute(q, self.room_id, self.event_id)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: asyncpg.Record) -> DisappearingMessage:
|
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get(cls, room_id: RoomID, event_id: EventID) -> DisappearingMessage | None:
|
|
||||||
q = """
|
|
||||||
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
|
|
||||||
WHERE room_id=$1 AND mxid=$2
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, room_id, event_id))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_all_scheduled(cls) -> list[DisappearingMessage]:
|
|
||||||
q = """
|
|
||||||
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
|
|
||||||
WHERE expiration_ts IS NOT NULL
|
|
||||||
"""
|
|
||||||
return [cls._from_row(r) for r in await cls.db.fetch(q)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_unscheduled_for_room(cls, room_id: RoomID) -> list[DisappearingMessage]:
|
|
||||||
q = """
|
|
||||||
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
|
|
||||||
WHERE room_id = $1 AND expiration_ts IS NULL
|
|
||||||
"""
|
|
||||||
return [cls._from_row(r) for r in await cls.db.fetch(q, room_id)]
|
|
||||||
+55
-142
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,165 +14,77 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, select
|
||||||
|
from sqlalchemy.engine.result import RowProxy
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
from ..types import MatrixRoomID, MatrixEventID, TelegramID
|
||||||
|
from .base import Base
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class Message(Base):
|
||||||
class Message:
|
__tablename__ = "message"
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
mxid: EventID
|
mxid = Column(String) # type: MatrixEventID
|
||||||
mx_room: RoomID
|
mx_room = Column(String) # type: MatrixRoomID
|
||||||
tgid: TelegramID
|
tgid = Column(Integer, primary_key=True) # type: TelegramID
|
||||||
tg_space: TelegramID
|
tg_space = Column(Integer, primary_key=True) # type: TelegramID
|
||||||
edit_index: int
|
|
||||||
redacted: bool = False
|
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
|
||||||
content_hash: bytes | None = None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _from_row(cls, row: Record | None) -> Message | None:
|
def _one_or_none(cls, rows: RowProxy) -> Optional['Message']:
|
||||||
if row is None:
|
try:
|
||||||
|
mxid, mx_room, tgid, tg_space = next(rows)
|
||||||
|
return cls(mxid=mxid, mx_room=mx_room, tgid=tgid, tg_space=tg_space)
|
||||||
|
except StopIteration:
|
||||||
return None
|
return None
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
columns: ClassVar[str] = "mxid, mx_room, tgid, tg_space, edit_index, redacted, content_hash"
|
@staticmethod
|
||||||
|
def _all(rows: RowProxy) -> List['Message']:
|
||||||
|
return [Message(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3])
|
||||||
|
for row in rows]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> list[Message]:
|
def get_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> Optional['Message']:
|
||||||
q = f"SELECT {cls.columns} FROM message WHERE tgid=$1 AND tg_space=$2"
|
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space))
|
||||||
rows = await cls.db.fetch(q, tgid, tg_space)
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_one_by_tgid(
|
def count_spaces_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID) -> int:
|
||||||
cls, tgid: TelegramID, tg_space: TelegramID, edit_index: int = 0
|
rows = cls.db.execute(select([func.count(cls.c.tg_space)])
|
||||||
) -> Message | None:
|
.where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room)))
|
||||||
if edit_index < 0:
|
try:
|
||||||
q = (
|
count, = next(rows)
|
||||||
f"SELECT {cls.columns} FROM message WHERE tgid=$1 AND tg_space=$2 "
|
return count
|
||||||
f"ORDER BY edit_index DESC LIMIT 1 OFFSET {-edit_index - 1}"
|
except StopIteration:
|
||||||
)
|
return 0
|
||||||
row = await cls.db.fetchrow(q, tgid, tg_space)
|
|
||||||
else:
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message"
|
|
||||||
" WHERE tgid=$1 AND tg_space=$2 AND edit_index=$3"
|
|
||||||
)
|
|
||||||
row = await cls.db.fetchrow(q, tgid, tg_space, edit_index)
|
|
||||||
return cls._from_row(row)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_first_by_tgids(
|
def get_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID, tg_space: TelegramID
|
||||||
cls, tgids: list[TelegramID], tg_space: TelegramID
|
) -> Optional['Message']:
|
||||||
) -> list[Message]:
|
return cls._select_one_or_none(and_(cls.c.mxid == mxid,
|
||||||
if cls.db.scheme == "postgres":
|
cls.c.mx_room == mx_room,
|
||||||
q = (
|
cls.c.tg_space == tg_space))
|
||||||
f"SELECT {cls.columns} FROM message"
|
|
||||||
" WHERE tgid=ANY($1) AND tg_space=$2 AND edit_index=0"
|
|
||||||
)
|
|
||||||
rows = await cls.db.fetch(q, tgids, tg_space)
|
|
||||||
else:
|
|
||||||
tgid_placeholders = ("?," * len(tgids)).rstrip(",")
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message "
|
|
||||||
f"WHERE tg_space=? AND edit_index=0 AND tgid IN ({tgid_placeholders})"
|
|
||||||
)
|
|
||||||
rows = await cls.db.fetch(q, tg_space, *tgids)
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> int:
|
def update_by_tgid(cls, s_tgid: TelegramID, s_tg_space: TelegramID, **values) -> None:
|
||||||
return (
|
with cls.db.begin() as conn:
|
||||||
await cls.db.fetchval(
|
conn.execute(cls.t.update()
|
||||||
"SELECT COUNT(tg_space) FROM message WHERE mxid=$1 AND mx_room=$2", mxid, mx_room
|
.where(and_(cls.c.tgid == s_tgid, cls.c.tg_space == s_tg_space))
|
||||||
)
|
.values(**values))
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def find_last(cls, mx_room: RoomID, tg_space: TelegramID) -> Message | None:
|
def update_by_mxid(cls, s_mxid: MatrixEventID, s_mx_room: MatrixRoomID, **values) -> None:
|
||||||
q = (
|
with cls.db.begin() as conn:
|
||||||
f"SELECT {cls.columns} FROM message WHERE mx_room=$1 AND tg_space=$2 "
|
conn.execute(cls.t.update()
|
||||||
f"ORDER BY tgid DESC LIMIT 1"
|
.where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room))
|
||||||
)
|
.values(**values))
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mx_room, tg_space))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def delete_all(cls, mx_room: RoomID) -> None:
|
|
||||||
await cls.db.execute("DELETE FROM message WHERE mx_room=$1", mx_room)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_mxid(
|
|
||||||
cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
|
|
||||||
) -> Message | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room, tg_space))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_mxids(
|
|
||||||
cls, mxids: list[EventID], mx_room: RoomID, tg_space: TelegramID
|
|
||||||
) -> list[Message]:
|
|
||||||
if cls.db.scheme == "postgres":
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message"
|
|
||||||
" WHERE mxid=ANY($1) AND mx_room=$2 AND tg_space=$3"
|
|
||||||
)
|
|
||||||
rows = await cls.db.fetch(q, mxids, mx_room, tg_space)
|
|
||||||
else:
|
|
||||||
mxid_placeholders = ("?," * len(mxids)).rstrip(",")
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message "
|
|
||||||
f"WHERE mx_room=? AND tg_space=? AND mxid IN ({mxid_placeholders})"
|
|
||||||
)
|
|
||||||
rows = await cls.db.fetch(q, mx_room, tg_space, *mxids)
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def replace_temp_mxid(cls, temp_mxid: str, mx_room: RoomID, real_mxid: EventID) -> None:
|
|
||||||
q = "UPDATE message SET mxid=$1 WHERE mxid=$2 AND mx_room=$3"
|
|
||||||
await cls.db.execute(q, real_mxid, temp_mxid, mx_room)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def delete_temp_mxid(cls, temp_mxid: str, mx_room: RoomID) -> None:
|
|
||||||
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2"
|
|
||||||
await cls.db.execute(q, temp_mxid, mx_room)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _values(self):
|
def _edit_identity(self):
|
||||||
return (
|
return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space)
|
||||||
self.mxid,
|
|
||||||
self.mx_room,
|
|
||||||
self.tgid,
|
|
||||||
self.tg_space,
|
|
||||||
self.edit_index,
|
|
||||||
self.redacted,
|
|
||||||
self.content_hash,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
def insert(self) -> None:
|
||||||
q = """
|
with self.db.begin() as conn:
|
||||||
INSERT INTO message (mxid, mx_room, tgid, tg_space, edit_index, redacted, content_hash)
|
conn.execute(self.t.insert().values(mxid=self.mxid, mx_room=self.mx_room,
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
tgid=self.tgid, tg_space=self.tg_space))
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
|
|
||||||
await self.db.execute(q, self.mxid, self.mx_room, self.tg_space)
|
|
||||||
|
|
||||||
async def mark_redacted(self) -> None:
|
|
||||||
self.redacted = True
|
|
||||||
q = "UPDATE message SET redacted=true WHERE mxid=$1 AND mx_room=$2"
|
|
||||||
await self.db.execute(q, self.mxid, self.mx_room)
|
|
||||||
|
|||||||
+45
-124
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,148 +14,68 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from sqlalchemy import Column, Integer, String, Boolean, Text, and_
|
||||||
|
from sqlalchemy.engine.result import RowProxy
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar
|
from ..types import MatrixRoomID, TelegramID
|
||||||
import json
|
from .base import Base
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
import attr
|
|
||||||
|
|
||||||
from mautrix.types import ContentURI, EventID, RoomID
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class Portal(Base):
|
||||||
class Portal:
|
__tablename__ = "portal"
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
# Telegram chat information
|
# Telegram chat information
|
||||||
tgid: TelegramID
|
tgid = Column(Integer, primary_key=True) # type: TelegramID
|
||||||
tg_receiver: TelegramID
|
tg_receiver = Column(Integer, primary_key=True) # type: TelegramID
|
||||||
peer_type: str
|
peer_type = Column(String, nullable=False)
|
||||||
megagroup: bool
|
megagroup = Column(Boolean)
|
||||||
|
|
||||||
# Matrix portal information
|
# Matrix portal information
|
||||||
mxid: RoomID | None
|
mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID]
|
||||||
avatar_url: ContentURI | None
|
|
||||||
encrypted: bool
|
|
||||||
|
|
||||||
sponsored_event_id: EventID | None
|
config = Column(Text, nullable=True)
|
||||||
sponsored_event_ts: int | None
|
|
||||||
sponsored_msg_random_id: bytes | None
|
|
||||||
|
|
||||||
# Telegram chat metadata
|
# Telegram chat metadata
|
||||||
username: str | None
|
username = Column(String, nullable=True)
|
||||||
title: str | None
|
title = Column(String, nullable=True)
|
||||||
about: str | None
|
about = Column(String, nullable=True)
|
||||||
photo_id: str | None
|
photo_id = Column(String, nullable=True)
|
||||||
name_set: bool
|
|
||||||
avatar_set: bool
|
|
||||||
|
|
||||||
local_config: dict[str, Any] = attr.ib(factory=lambda: {})
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _from_row(cls, row: Record | None) -> Portal | None:
|
def scan(cls, row) -> Optional['Portal']:
|
||||||
if row is None:
|
(tgid, tg_receiver, peer_type, megagroup, mxid, config, username, title, about,
|
||||||
|
photo_id) = row
|
||||||
|
return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup,
|
||||||
|
mxid=mxid, config=config, username=username, title=title, about=about,
|
||||||
|
photo_id=photo_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _one_or_none(cls, rows: RowProxy) -> Optional['Portal']:
|
||||||
|
try:
|
||||||
|
return cls.scan(next(rows))
|
||||||
|
except StopIteration:
|
||||||
return None
|
return None
|
||||||
data = {**row}
|
|
||||||
data["local_config"] = json.loads(data.pop("config", None) or "{}")
|
|
||||||
return cls(**data)
|
|
||||||
|
|
||||||
columns: ClassVar[str] = (
|
|
||||||
"tgid, tg_receiver, peer_type, megagroup, mxid, avatar_url, encrypted, sponsored_event_id,"
|
|
||||||
"sponsored_event_ts, sponsored_msg_random_id, username, title, about, photo_id, "
|
|
||||||
"name_set, avatar_set, config"
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Portal | None:
|
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']:
|
||||||
q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND tg_receiver=$2"
|
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver))
|
||||||
return cls._from_row(await cls.db.fetchrow(q, tgid, tg_receiver))
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
|
def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']:
|
||||||
q = f"SELECT {cls.columns} FROM portal WHERE mxid=$1"
|
return cls._select_one_or_none(cls.c.mxid == mxid)
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid))
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def find_by_username(cls, username: str) -> Portal | None:
|
def get_by_username(cls, username: str) -> Optional['Portal']:
|
||||||
q = f"SELECT {cls.columns} FROM portal WHERE lower(username)=$1"
|
return cls._select_one_or_none(cls.c.username == username)
|
||||||
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_private_chats_of(cls, tg_receiver: TelegramID) -> list[Portal]:
|
|
||||||
q = f"SELECT {cls.columns} FROM portal WHERE tg_receiver=$1 AND peer_type='user'"
|
|
||||||
return [cls._from_row(row) for row in await cls.db.fetch(q, tg_receiver)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_private_chats_with(cls, tgid: TelegramID) -> list[Portal]:
|
|
||||||
q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND peer_type='user'"
|
|
||||||
return [cls._from_row(row) for row in await cls.db.fetch(q, tgid)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def all(cls) -> list[Portal]:
|
|
||||||
rows = await cls.db.fetch(f"SELECT {cls.columns} FROM portal")
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _values(self):
|
def _edit_identity(self):
|
||||||
return (
|
return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver)
|
||||||
self.tgid,
|
|
||||||
self.tg_receiver,
|
|
||||||
self.peer_type,
|
|
||||||
self.mxid,
|
|
||||||
self.avatar_url,
|
|
||||||
self.encrypted,
|
|
||||||
self.sponsored_event_id,
|
|
||||||
self.sponsored_event_ts,
|
|
||||||
self.sponsored_msg_random_id,
|
|
||||||
self.username,
|
|
||||||
self.title,
|
|
||||||
self.about,
|
|
||||||
self.photo_id,
|
|
||||||
self.name_set,
|
|
||||||
self.avatar_set,
|
|
||||||
self.megagroup,
|
|
||||||
json.dumps(self.local_config) if self.local_config else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def save(self) -> None:
|
def insert(self) -> None:
|
||||||
q = """
|
with self.db.begin() as conn:
|
||||||
UPDATE portal
|
conn.execute(self.t.insert().values(
|
||||||
SET mxid=$4, avatar_url=$5, encrypted=$6, sponsored_event_id=$7, sponsored_event_ts=$8,
|
tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
|
||||||
sponsored_msg_random_id=$9, username=$10, title=$11, about=$12, photo_id=$13,
|
megagroup=self.megagroup, mxid=self.mxid, config=self.config,
|
||||||
name_set=$14, avatar_set=$15, megagroup=$16, config=$17
|
username=self.username, title=self.title, about=self.about, photo_id=self.photo_id))
|
||||||
WHERE tgid=$1 AND tg_receiver=$2 AND (peer_type=$3 OR true)
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def update_id(self, id: TelegramID, peer_type: str) -> None:
|
|
||||||
q = (
|
|
||||||
"UPDATE portal SET tgid=$1, tg_receiver=$1, peer_type=$2 "
|
|
||||||
"WHERE tgid=$3 AND tg_receiver=$3"
|
|
||||||
)
|
|
||||||
await self.db.execute(q, id, peer_type, self.tgid)
|
|
||||||
self.tgid = id
|
|
||||||
self.tg_receiver = id
|
|
||||||
self.peer_type = peer_type
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = """
|
|
||||||
INSERT INTO portal (
|
|
||||||
tgid, tg_receiver, peer_type, mxid, avatar_url, encrypted,
|
|
||||||
sponsored_event_id, sponsored_event_ts, sponsored_msg_random_id,
|
|
||||||
username, title, about, photo_id, name_set, avatar_set, megagroup, config
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
q = "DELETE FROM portal WHERE tgid=$1 AND tg_receiver=$2"
|
|
||||||
await self.db.execute(q, self.tgid, self.tg_receiver)
|
|
||||||
|
|||||||
+53
-105
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,127 +14,74 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from sqlalchemy import Column, Integer, String, Boolean
|
||||||
|
from sqlalchemy.engine.result import RowProxy
|
||||||
|
from sqlalchemy.sql import expression
|
||||||
|
from typing import Optional, Iterable
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
from ..types import MatrixUserID, MatrixRoomID, TelegramID
|
||||||
|
from .base import Base
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
from yarl import URL
|
|
||||||
|
|
||||||
from mautrix.types import ContentURI, SyncToken, UserID
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class Puppet(Base):
|
||||||
class Puppet:
|
__tablename__ = "puppet"
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
id: TelegramID
|
id = Column(Integer, primary_key=True) # type: TelegramID
|
||||||
|
custom_mxid = Column(String, nullable=True) # type: Optional[MatrixUserID]
|
||||||
is_registered: bool
|
access_token = Column(String, nullable=True)
|
||||||
|
displayname = Column(String, nullable=True)
|
||||||
displayname: str | None
|
displayname_source = Column(Integer, nullable=True) # type: Optional[TelegramID]
|
||||||
displayname_source: TelegramID | None
|
username = Column(String, nullable=True)
|
||||||
displayname_contact: bool
|
photo_id = Column(String, nullable=True)
|
||||||
displayname_quality: int
|
is_bot = Column(Boolean, nullable=True)
|
||||||
disable_updates: bool
|
matrix_registered = Column(Boolean, nullable=False, server_default=expression.false())
|
||||||
username: str | None
|
|
||||||
photo_id: str | None
|
|
||||||
avatar_url: ContentURI | None
|
|
||||||
name_set: bool
|
|
||||||
avatar_set: bool
|
|
||||||
is_bot: bool | None
|
|
||||||
is_channel: bool
|
|
||||||
|
|
||||||
custom_mxid: UserID | None
|
|
||||||
access_token: str | None
|
|
||||||
next_batch: SyncToken | None
|
|
||||||
base_url: URL | None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _from_row(cls, row: Record | None) -> Puppet | None:
|
def scan(cls, row) -> Optional['Puppet']:
|
||||||
if row is None:
|
(id, custom_mxid, access_token, displayname, displayname_source, username, photo_id,
|
||||||
|
is_bot, matrix_registered) = row
|
||||||
|
return cls(id=id, custom_mxid=custom_mxid, access_token=access_token,
|
||||||
|
displayname=displayname, displayname_source=displayname_source,
|
||||||
|
username=username, photo_id=photo_id, is_bot=is_bot,
|
||||||
|
matrix_registered=matrix_registered)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _one_or_none(cls, rows: RowProxy) -> Optional['Puppet']:
|
||||||
|
try:
|
||||||
|
return cls.scan(next(rows))
|
||||||
|
except StopIteration:
|
||||||
return None
|
return None
|
||||||
data = {**row}
|
|
||||||
base_url = data.pop("base_url", None)
|
|
||||||
return cls(**data, base_url=URL(base_url) if base_url else None)
|
|
||||||
|
|
||||||
columns: ClassVar[str] = (
|
|
||||||
"id, is_registered, displayname, displayname_source, displayname_contact, "
|
|
||||||
"displayname_quality, disable_updates, username, photo_id, avatar_url, "
|
|
||||||
"name_set, avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url"
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def all_with_custom_mxid(cls) -> list[Puppet]:
|
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
|
||||||
q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid<>''"
|
rows = cls.db.execute(cls.t.select().where(cls.c.custom_mxid != None))
|
||||||
return [cls._from_row(row) for row in await cls.db.fetch(q)]
|
for row in rows:
|
||||||
|
yield cls.scan(row)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_tgid(cls, tgid: TelegramID) -> Puppet | None:
|
def get_by_tgid(cls, tgid: TelegramID) -> Optional['Puppet']:
|
||||||
q = f"SELECT {cls.columns} FROM puppet WHERE id=$1"
|
return cls._select_one_or_none(cls.c.id == tgid)
|
||||||
return cls._from_row(await cls.db.fetchrow(q, tgid))
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
|
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
|
||||||
q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid=$1"
|
return cls._select_one_or_none(cls.c.custom_mxid == mxid)
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid))
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def find_by_username(cls, username: str) -> Puppet | None:
|
def get_by_username(cls, username: str) -> Optional['Puppet']:
|
||||||
q = f"SELECT {cls.columns} FROM puppet WHERE lower(username)=$1"
|
return cls._select_one_or_none(cls.c.username == username)
|
||||||
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def find_by_displayname(cls, displayname: str) -> Puppet | None:
|
def get_by_displayname(cls, displayname: str) -> Optional['Puppet']:
|
||||||
q = f"SELECT {cls.columns} FROM puppet WHERE displayname=$1"
|
return cls._select_one_or_none(cls.c.displayname == displayname)
|
||||||
return cls._from_row(await cls.db.fetchrow(q, displayname))
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _values(self):
|
def _edit_identity(self):
|
||||||
return (
|
return self.c.id == self.id
|
||||||
self.id,
|
|
||||||
self.is_registered,
|
|
||||||
self.displayname,
|
|
||||||
self.displayname_source,
|
|
||||||
self.displayname_contact,
|
|
||||||
self.displayname_quality,
|
|
||||||
self.disable_updates,
|
|
||||||
self.username,
|
|
||||||
self.photo_id,
|
|
||||||
self.avatar_url,
|
|
||||||
self.name_set,
|
|
||||||
self.avatar_set,
|
|
||||||
self.is_bot,
|
|
||||||
self.is_channel,
|
|
||||||
self.custom_mxid,
|
|
||||||
self.access_token,
|
|
||||||
self.next_batch,
|
|
||||||
str(self.base_url) if self.base_url else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def save(self) -> None:
|
def insert(self) -> None:
|
||||||
q = """
|
with self.db.begin() as conn:
|
||||||
UPDATE puppet
|
conn.execute(self.t.insert().values(
|
||||||
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
|
id=self.id, custom_mxid=self.custom_mxid, access_token=self.access_token,
|
||||||
displayname_quality=$6, disable_updates=$7, username=$8, photo_id=$9,
|
displayname=self.displayname, displayname_source=self.displayname_source,
|
||||||
avatar_url=$10, name_set=$11, avatar_set=$12, is_bot=$13, is_channel=$14,
|
username=self.username, photo_id=self.photo_id, is_bot=self.is_bot,
|
||||||
custom_mxid=$15, access_token=$16, next_batch=$17, base_url=$18
|
matrix_registered=self.matrix_registered))
|
||||||
WHERE id=$1
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = """
|
|
||||||
INSERT INTO puppet (
|
|
||||||
id, is_registered, displayname, displayname_source, displayname_contact,
|
|
||||||
displayname_quality, disable_updates, username, photo_id, avatar_url, name_set,
|
|
||||||
avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|||||||
@@ -1,91 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Reaction:
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
mxid: EventID
|
|
||||||
mx_room: RoomID
|
|
||||||
msg_mxid: EventID
|
|
||||||
tg_sender: TelegramID
|
|
||||||
reaction: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: Record | None) -> Reaction | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
columns: ClassVar[str] = "mxid, mx_room, msg_mxid, tg_sender, reaction"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def delete_all(cls, mx_room: RoomID) -> None:
|
|
||||||
await cls.db.execute("DELETE FROM reaction WHERE mx_room=$1", mx_room)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM reaction WHERE mxid=$1 AND mx_room=$2"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_sender(
|
|
||||||
cls, mxid: EventID, mx_room: RoomID, tg_sender: TelegramID
|
|
||||||
) -> Reaction | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room, tg_sender))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_all_by_message(cls, mxid: EventID, mx_room: RoomID) -> list[Reaction]:
|
|
||||||
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2"
|
|
||||||
rows = await cls.db.fetch(q, mxid, mx_room)
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _values(self):
|
|
||||||
return (
|
|
||||||
self.mxid,
|
|
||||||
self.mx_room,
|
|
||||||
self.msg_mxid,
|
|
||||||
self.tg_sender,
|
|
||||||
self.reaction,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def save(self) -> None:
|
|
||||||
q = """
|
|
||||||
INSERT INTO reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
|
|
||||||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (msg_mxid, mx_room, tg_sender)
|
|
||||||
DO UPDATE SET mxid=$1, reaction=$5
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
q = "DELETE FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3"
|
|
||||||
await self.db.execute(q, self.msg_mxid, self.mx_room, self.tg_sender)
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
|
# Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
|
||||||
|
from sqlalchemy import Column, String, Text
|
||||||
|
from typing import Dict, Optional
|
||||||
|
import json
|
||||||
|
|
||||||
|
from ..types import MatrixRoomID
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class RoomState(Base):
|
||||||
|
__tablename__ = "mx_room_state"
|
||||||
|
|
||||||
|
room_id = Column(String, primary_key=True) # type: MatrixRoomID
|
||||||
|
power_levels = Column("power_levels", Text, nullable=True) # type: Optional[Dict]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _power_levels_text(self) -> Optional[str]:
|
||||||
|
return json.dumps(self.power_levels) if self.power_levels else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_power_levels(self) -> bool:
|
||||||
|
return bool(self.power_levels)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, room_id: MatrixRoomID) -> Optional['RoomState']:
|
||||||
|
rows = cls.db.execute(cls.t.select().where(cls.c.room_id == room_id))
|
||||||
|
try:
|
||||||
|
room_id, power_levels_text = next(rows)
|
||||||
|
return cls(room_id=room_id, power_levels=(json.loads(power_levels_text)
|
||||||
|
if power_levels_text else None))
|
||||||
|
except StopIteration:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
with self.db.begin() as conn:
|
||||||
|
conn.execute(self.t.update()
|
||||||
|
.where(self.c.room_id == self.room_id)
|
||||||
|
.values(power_levels=self._power_levels_text))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _edit_identity(self):
|
||||||
|
return self.c.room_id == self.room_id
|
||||||
|
|
||||||
|
def insert(self) -> None:
|
||||||
|
with self.db.begin() as conn:
|
||||||
|
conn.execute(self.t.insert().values(room_id=self.room_id,
|
||||||
|
power_levels=self._power_levels_text))
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,70 +14,43 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
from .base import Base
|
||||||
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.types import ContentURI, EncryptedFile
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class TelegramFile(Base):
|
||||||
class TelegramFile:
|
__tablename__ = "telegram_file"
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
id: str
|
id = Column(String, primary_key=True)
|
||||||
mxc: ContentURI
|
mxc = Column(String)
|
||||||
mime_type: str
|
mime_type = Column(String)
|
||||||
was_converted: bool
|
was_converted = Column(Boolean)
|
||||||
timestamp: int
|
timestamp = Column(BigInteger)
|
||||||
size: int | None
|
size = Column(Integer, nullable=True)
|
||||||
width: int | None
|
width = Column(Integer, nullable=True)
|
||||||
height: int | None
|
height = Column(Integer, nullable=True)
|
||||||
decryption_info: EncryptedFile | None
|
thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
|
||||||
thumbnail: TelegramFile | None = None
|
thumbnail = None # type: Optional[TelegramFile]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get(cls, loc_id: str, *, _thumbnail: bool = False) -> TelegramFile | None:
|
def get(cls, loc_id: str) -> Optional['TelegramFile']:
|
||||||
q = (
|
rows = cls.db.execute(cls.t.select().where(cls.c.id == loc_id))
|
||||||
"SELECT id, mxc, mime_type, was_converted, timestamp, size, width, height, thumbnail,"
|
try:
|
||||||
" decryption_info "
|
loc_id, mxc, mime, conv, ts, s, w, h, thumb_id = next(rows)
|
||||||
"FROM telegram_file WHERE id=$1"
|
thumb = None
|
||||||
)
|
if thumb_id:
|
||||||
row = await cls.db.fetchrow(q, loc_id)
|
thumb = cls.get(thumb_id)
|
||||||
if row is None:
|
return cls(id=loc_id, mxc=mxc, mime_type=mime, was_converted=conv, timestamp=ts,
|
||||||
|
size=s, width=w, height=h, thumbnail_id=thumb_id, thumbnail=thumb)
|
||||||
|
except StopIteration:
|
||||||
return None
|
return None
|
||||||
data = {**row}
|
|
||||||
thumbnail_id = data.pop("thumbnail", None)
|
|
||||||
if _thumbnail:
|
|
||||||
# Don't allow more than one level of recursion
|
|
||||||
thumbnail_id = None
|
|
||||||
decryption_info = data.pop("decryption_info", None)
|
|
||||||
return cls(
|
|
||||||
**data,
|
|
||||||
thumbnail=(await cls.get(thumbnail_id, _thumbnail=True)) if thumbnail_id else None,
|
|
||||||
decryption_info=EncryptedFile.parse_json(decryption_info) if decryption_info else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
def insert(self) -> None:
|
||||||
q = (
|
with self.db.begin() as conn:
|
||||||
"INSERT INTO telegram_file (id, mxc, mime_type, was_converted, size, width, height, "
|
conn.execute(self.t.insert().values(
|
||||||
" thumbnail, decryption_info) "
|
id=self.id, mxc=self.mxc, mime_type=self.mime_type,
|
||||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
|
was_converted=self.was_converted, timestamp=self.timestamp, size=self.size,
|
||||||
)
|
width=self.width, height=self.height,
|
||||||
await self.db.execute(
|
thumbnail=self.thumbnail.id if self.thumbnail else self.thumbnail_id))
|
||||||
q,
|
|
||||||
self.id,
|
|
||||||
self.mxc,
|
|
||||||
self.mime_type,
|
|
||||||
self.was_converted,
|
|
||||||
self.size,
|
|
||||||
self.width,
|
|
||||||
self.height,
|
|
||||||
self.thumbnail.id if self.thumbnail else None,
|
|
||||||
self.decryption_info.json() if self.decryption_info else None,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,207 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from telethon import utils
|
|
||||||
from telethon.crypto import AuthKey
|
|
||||||
from telethon.sessions import MemorySession
|
|
||||||
from telethon.tl.types import PeerChannel, PeerChat, PeerUser, updates
|
|
||||||
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
class PgSession(MemorySession):
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
session_id: str
|
|
||||||
_dc_id: int
|
|
||||||
_server_address: str | None
|
|
||||||
_port: int | None
|
|
||||||
_auth_key: AuthKey | None
|
|
||||||
_takeout_id: int | None
|
|
||||||
_process_entities_lock: asyncio.Lock
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session_id: str,
|
|
||||||
dc_id: int = 0,
|
|
||||||
server_address: str | None = None,
|
|
||||||
port: int | None = None,
|
|
||||||
auth_key: AuthKey | None = None,
|
|
||||||
takeout_id: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.session_id = session_id
|
|
||||||
self._dc_id = dc_id
|
|
||||||
self._server_address = server_address
|
|
||||||
self._port = port
|
|
||||||
self._auth_key = auth_key
|
|
||||||
self._takeout_id = takeout_id
|
|
||||||
self._process_entities_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
def clone(self, to_instance=None) -> MemorySession:
|
|
||||||
# We don't want to store data of clones
|
|
||||||
# (which are used for temporarily connecting to different DCs)
|
|
||||||
return super().clone(MemorySession())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def auth_key_bytes(self) -> bytes | None:
|
|
||||||
return self._auth_key.key if self._auth_key else None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get(cls, session_id: str) -> PgSession:
|
|
||||||
q = (
|
|
||||||
"SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions "
|
|
||||||
"WHERE session_id=$1"
|
|
||||||
)
|
|
||||||
row = await cls.db.fetchrow(q, session_id)
|
|
||||||
if row is None:
|
|
||||||
return cls(session_id)
|
|
||||||
data = {**row}
|
|
||||||
auth_key = AuthKey(data.pop("auth_key", None))
|
|
||||||
return cls(**data, auth_key=auth_key)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def has(cls, session_id: str) -> bool:
|
|
||||||
q = "SELECT COUNT(*) FROM telethon_sessions WHERE session_id=$1"
|
|
||||||
count = await cls.db.fetchval(q, session_id)
|
|
||||||
return count > 0
|
|
||||||
|
|
||||||
async def save(self) -> None:
|
|
||||||
q = (
|
|
||||||
"INSERT INTO telethon_sessions (session_id, dc_id, server_address, port, auth_key) "
|
|
||||||
"VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_id) "
|
|
||||||
"DO UPDATE SET dc_id=$2, server_address=$3, port=$4, auth_key=$5"
|
|
||||||
)
|
|
||||||
await self.db.execute(
|
|
||||||
q, self.session_id, self.dc_id, self.server_address, self.port, self.auth_key_bytes
|
|
||||||
)
|
|
||||||
|
|
||||||
_tables: ClassVar[tuple[str, ...]] = (
|
|
||||||
"telethon_sessions",
|
|
||||||
"telethon_entities",
|
|
||||||
"telethon_sent_files",
|
|
||||||
"telethon_update_state",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
|
||||||
for table in self._tables:
|
|
||||||
await conn.execute(f"DELETE FROM {table} WHERE session_id=$1", self.session_id)
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
# Nothing to do here, DB connection is global
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def get_update_state(self, entity_id: int) -> updates.State | None:
|
|
||||||
q = (
|
|
||||||
"SELECT pts, qts, date, seq, unread_count FROM telethon_update_state "
|
|
||||||
"WHERE session_id=$1 AND entity_id=$2"
|
|
||||||
)
|
|
||||||
row = await self.db.fetchrow(q, self.session_id, entity_id)
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
date = datetime.datetime.utcfromtimestamp(row["date"])
|
|
||||||
return updates.State(row["pts"], row["qts"], date, row["seq"], row["unread_count"])
|
|
||||||
|
|
||||||
async def set_update_state(self, entity_id: int, row: updates.State) -> None:
|
|
||||||
q = (
|
|
||||||
"INSERT INTO telethon_update_state"
|
|
||||||
" (session_id, entity_id, pts, qts, date, seq, unread_count) "
|
|
||||||
"VALUES ($1, $2, $3, $4, $5, $6, $7)"
|
|
||||||
"ON CONFLICT (session_id, entity_id) DO UPDATE"
|
|
||||||
" SET pts=$3, qts=$4, date=$5, seq=$6, unread_count=$7"
|
|
||||||
)
|
|
||||||
ts = row.date.timestamp()
|
|
||||||
await self.db.execute(
|
|
||||||
q, self.session_id, entity_id, row.pts, row.qts, ts, row.seq, row.unread_count
|
|
||||||
)
|
|
||||||
|
|
||||||
def _entity_values_to_row(
|
|
||||||
self, id: int, hash: int, username: str | None, phone: str | int | None, name: str | None
|
|
||||||
) -> tuple[str, int, int, str | None, str | None, str | None]:
|
|
||||||
return self.session_id, id, hash, username, str(phone) if phone else None, name
|
|
||||||
|
|
||||||
async def process_entities(self, tlo) -> None:
|
|
||||||
# Postgres likes to deadlock on simultaneous upserts, so just lock the whole thing here
|
|
||||||
# TODO: make sure postgres doesn't deadlock on upserts when session_id is different
|
|
||||||
async with self._process_entities_lock:
|
|
||||||
await self._locked_process_entities(tlo)
|
|
||||||
|
|
||||||
async def _locked_process_entities(self, tlo) -> None:
|
|
||||||
rows: list[
|
|
||||||
tuple[str, int, int, str | None, str | None, str | None]
|
|
||||||
] = self._entities_to_rows(tlo)
|
|
||||||
if not rows:
|
|
||||||
return
|
|
||||||
if self.db.scheme == "postgres":
|
|
||||||
q = (
|
|
||||||
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
|
|
||||||
"VALUES ($1, unnest($2::bigint[]), unnest($3::bigint[]), "
|
|
||||||
" unnest($4::text[]), unnest($5::text[]), unnest($6::text[])) "
|
|
||||||
"ON CONFLICT (session_id, id) DO UPDATE"
|
|
||||||
" SET hash=excluded.hash, username=excluded.username,"
|
|
||||||
" phone=excluded.phone, name=excluded.name"
|
|
||||||
)
|
|
||||||
_, ids, hashes, usernames, phones, names = zip(*rows)
|
|
||||||
await self.db.execute(q, self.session_id, ids, hashes, usernames, phones, names)
|
|
||||||
else:
|
|
||||||
q = (
|
|
||||||
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
|
|
||||||
"VALUES ($1, $2, $3, $4, $5, $6) "
|
|
||||||
"ON CONFLICT (session_id, id) DO UPDATE "
|
|
||||||
" SET hash=$3, username=$4, phone=$5, name=$6"
|
|
||||||
)
|
|
||||||
await self.db.executemany(q, rows)
|
|
||||||
|
|
||||||
async def _select_entity(
|
|
||||||
self, constraint: str, *args: str | int | tuple[int, ...]
|
|
||||||
) -> tuple[int, int] | None:
|
|
||||||
row = await self.db.fetchrow(
|
|
||||||
f"SELECT id, hash FROM telethon_entities WHERE {constraint}", *args
|
|
||||||
)
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return row["id"], row["hash"]
|
|
||||||
|
|
||||||
async def get_entity_rows_by_phone(self, key: str | int) -> tuple[int, int] | None:
|
|
||||||
return await self._select_entity("phone=$1", str(key))
|
|
||||||
|
|
||||||
async def get_entity_rows_by_username(self, key: str) -> tuple[int, int] | None:
|
|
||||||
return await self._select_entity("username=$1", key)
|
|
||||||
|
|
||||||
async def get_entity_rows_by_name(self, key: str) -> tuple[int, int] | None:
|
|
||||||
return await self._select_entity("name=$1", key)
|
|
||||||
|
|
||||||
async def get_entity_rows_by_id(self, key: int, exact: bool = True) -> tuple[int, int] | None:
|
|
||||||
if exact:
|
|
||||||
return await self._select_entity("id=$1", key)
|
|
||||||
|
|
||||||
ids = (
|
|
||||||
utils.get_peer_id(PeerUser(key)),
|
|
||||||
utils.get_peer_id(PeerChat(key)),
|
|
||||||
utils.get_peer_id(PeerChannel(key)),
|
|
||||||
)
|
|
||||||
if self.db.scheme == "postgres":
|
|
||||||
return await self._select_entity("id=ANY($1)", ids)
|
|
||||||
else:
|
|
||||||
return await self._select_entity(f"id IN ($1, $2, $3)", *ids)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
from mautrix.util.async_db import UpgradeTable
|
|
||||||
|
|
||||||
upgrade_table = UpgradeTable()
|
|
||||||
|
|
||||||
from . import (
|
|
||||||
v01_initial_revision,
|
|
||||||
v02_sponsored_events,
|
|
||||||
v03_reactions,
|
|
||||||
v04_disappearing_messages,
|
|
||||||
v05_channel_ghosts,
|
|
||||||
v06_puppet_avatar_url,
|
|
||||||
)
|
|
||||||
@@ -1,328 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from asyncpg import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
legacy_version_query = "SELECT version_num FROM alembic_version"
|
|
||||||
last_legacy_version = "bfc0a39bfe02"
|
|
||||||
|
|
||||||
|
|
||||||
def table_exists(scheme: str, name: str) -> str:
|
|
||||||
if scheme == "sqlite":
|
|
||||||
return f"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='{name}')"
|
|
||||||
elif scheme == "postgres":
|
|
||||||
return f"SELECT EXISTS(SELECT FROM information_schema.tables WHERE table_name='{name}')"
|
|
||||||
raise RuntimeError("unsupported database scheme")
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Initial asyncpg revision")
|
|
||||||
async def upgrade_v1(conn: Connection, scheme: str) -> None:
|
|
||||||
is_legacy = await conn.fetchval(table_exists(scheme, "alembic_version"))
|
|
||||||
if is_legacy:
|
|
||||||
await migrate_legacy_to_v1(conn, scheme)
|
|
||||||
else:
|
|
||||||
await create_v1_tables(conn)
|
|
||||||
|
|
||||||
|
|
||||||
async def drop_constraints(conn: Connection, table: str, contype: str) -> None:
|
|
||||||
q = (
|
|
||||||
"SELECT conname FROM pg_constraint con INNER JOIN pg_class rel ON rel.oid=con.conrelid "
|
|
||||||
f"WHERE rel.relname='{table}' AND contype='{contype}'"
|
|
||||||
)
|
|
||||||
names = [row["conname"] for row in await conn.fetch(q)]
|
|
||||||
drops = ", ".join(f"DROP CONSTRAINT {name}" for name in names)
|
|
||||||
await conn.execute(f"ALTER TABLE {table} {drops}")
|
|
||||||
|
|
||||||
|
|
||||||
async def migrate_legacy_to_v1(conn: Connection, scheme: str) -> None:
|
|
||||||
legacy_version = await conn.fetchval(legacy_version_query)
|
|
||||||
if legacy_version != last_legacy_version:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Legacy database is not on last version. "
|
|
||||||
"Please upgrade the old database with alembic or drop it completely first."
|
|
||||||
)
|
|
||||||
if scheme != "sqlite":
|
|
||||||
await drop_constraints(conn, "contact", contype="f")
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE contact
|
|
||||||
ADD CONSTRAINT contact_user_fkey FOREIGN KEY (contact) REFERENCES puppet(id)
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
ADD CONSTRAINT contact_contact_fkey FOREIGN KEY ("user") REFERENCES "user"(tgid)
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
await drop_constraints(conn, "telethon_sessions", contype="p")
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE telethon_sessions
|
|
||||||
ADD CONSTRAINT telethon_sessions_pkey PRIMARY KEY (session_id)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
await drop_constraints(conn, "telegram_file", contype="f")
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE telegram_file
|
|
||||||
ADD CONSTRAINT fk_file_thumbnail
|
|
||||||
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE SET NULL
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP IDENTITY IF EXISTS")
|
|
||||||
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP DEFAULT")
|
|
||||||
await conn.execute("DROP SEQUENCE IF EXISTS puppet_id_seq")
|
|
||||||
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP IDENTITY IF EXISTS")
|
|
||||||
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP DEFAULT")
|
|
||||||
await conn.execute("DROP SEQUENCE IF EXISTS bot_chat_id_seq")
|
|
||||||
await conn.execute("ALTER TABLE portal ALTER COLUMN config TYPE jsonb USING config::jsonb")
|
|
||||||
await conn.execute(
|
|
||||||
"ALTER TABLE telegram_file ALTER COLUMN decryption_info TYPE jsonb "
|
|
||||||
"USING decryption_info::jsonb"
|
|
||||||
)
|
|
||||||
await varchar_to_text(conn)
|
|
||||||
else:
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telethon_sessions_new (
|
|
||||||
session_id TEXT PRIMARY KEY,
|
|
||||||
dc_id INTEGER,
|
|
||||||
server_address TEXT,
|
|
||||||
port INTEGER,
|
|
||||||
auth_key bytea
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO telethon_sessions_new (session_id, dc_id, server_address, port, auth_key)
|
|
||||||
SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
await conn.execute("DROP TABLE telethon_sessions")
|
|
||||||
await conn.execute("ALTER TABLE telethon_sessions_new RENAME TO telethon_sessions")
|
|
||||||
|
|
||||||
await update_state_store(conn, scheme)
|
|
||||||
await conn.execute('ALTER TABLE "user" ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false')
|
|
||||||
await conn.execute("ALTER TABLE puppet RENAME COLUMN matrix_registered TO is_registered")
|
|
||||||
await conn.execute("DROP TABLE telethon_version")
|
|
||||||
await conn.execute("DROP TABLE alembic_version")
|
|
||||||
|
|
||||||
|
|
||||||
async def update_state_store(conn: Connection, scheme: str) -> None:
|
|
||||||
# The Matrix state store already has more or less the correct schema, so set the version
|
|
||||||
await conn.execute("CREATE TABLE mx_version (version INTEGER PRIMARY KEY)")
|
|
||||||
await conn.execute("INSERT INTO mx_version (version) VALUES (2)")
|
|
||||||
await conn.execute("UPDATE mx_user_profile SET membership='LEAVE' WHERE membership='LEFT'")
|
|
||||||
if scheme != "sqlite":
|
|
||||||
# Also add the membership type on postgres
|
|
||||||
await conn.execute(
|
|
||||||
"CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock')"
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"ALTER TABLE mx_user_profile ALTER COLUMN membership TYPE membership "
|
|
||||||
"USING LOWER(membership)::membership"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# On SQLite there's no custom type, but we still want to lowercase everything
|
|
||||||
await conn.execute("UPDATE mx_user_profile SET membership=LOWER(membership)")
|
|
||||||
|
|
||||||
|
|
||||||
async def varchar_to_text(conn: Connection) -> None:
|
|
||||||
columns_to_adjust = {
|
|
||||||
"user": ("mxid", "tg_username", "tg_phone"),
|
|
||||||
"portal": (
|
|
||||||
"peer_type",
|
|
||||||
"mxid",
|
|
||||||
"username",
|
|
||||||
"title",
|
|
||||||
"about",
|
|
||||||
"photo_id",
|
|
||||||
"avatar_url",
|
|
||||||
"config",
|
|
||||||
),
|
|
||||||
"message": ("mxid", "mx_room"),
|
|
||||||
"puppet": (
|
|
||||||
"displayname",
|
|
||||||
"username",
|
|
||||||
"photo_id",
|
|
||||||
"access_token",
|
|
||||||
"custom_mxid",
|
|
||||||
"next_batch",
|
|
||||||
"base_url",
|
|
||||||
),
|
|
||||||
"bot_chat": ("type",),
|
|
||||||
"telegram_file": ("id", "mxc", "mime_type", "thumbnail"),
|
|
||||||
# Phone is a bigint in the old schema, which is safe, but we don't do math on it,
|
|
||||||
# so let's change it to a string
|
|
||||||
"telethon_entities": ("session_id", "username", "name", "phone"),
|
|
||||||
"telethon_sent_files": ("session_id",),
|
|
||||||
"telethon_sessions": ("session_id", "server_address"),
|
|
||||||
"telethon_update_state": ("session_id",),
|
|
||||||
"mx_room_state": ("room_id",),
|
|
||||||
"mx_user_profile": ("room_id", "user_id", "displayname", "avatar_url"),
|
|
||||||
}
|
|
||||||
for table, columns in columns_to_adjust.items():
|
|
||||||
for column in columns:
|
|
||||||
await conn.execute(f'ALTER TABLE "{table}" ALTER COLUMN {column} TYPE TEXT')
|
|
||||||
|
|
||||||
|
|
||||||
async def create_v1_tables(conn: Connection) -> None:
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE "user" (
|
|
||||||
mxid TEXT PRIMARY KEY,
|
|
||||||
tgid BIGINT UNIQUE,
|
|
||||||
tg_username TEXT,
|
|
||||||
tg_phone TEXT,
|
|
||||||
is_bot BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
saved_contacts INTEGER NOT NULL DEFAULT 0
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE portal (
|
|
||||||
tgid BIGINT,
|
|
||||||
tg_receiver BIGINT,
|
|
||||||
peer_type TEXT NOT NULL,
|
|
||||||
mxid TEXT UNIQUE,
|
|
||||||
avatar_url TEXT,
|
|
||||||
encrypted BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
username TEXT,
|
|
||||||
title TEXT,
|
|
||||||
about TEXT,
|
|
||||||
photo_id TEXT,
|
|
||||||
megagroup BOOLEAN,
|
|
||||||
config jsonb,
|
|
||||||
PRIMARY KEY (tgid, tg_receiver)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE message (
|
|
||||||
mxid TEXT,
|
|
||||||
mx_room TEXT,
|
|
||||||
tgid BIGINT NOT NULL,
|
|
||||||
tg_space BIGINT NOT NULL,
|
|
||||||
edit_index INTEGER NOT NULL,
|
|
||||||
redacted BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
PRIMARY KEY (tgid, tg_space, edit_index),
|
|
||||||
UNIQUE (mxid, mx_room, tg_space)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE puppet (
|
|
||||||
id BIGINT PRIMARY KEY,
|
|
||||||
|
|
||||||
is_registered BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
displayname TEXT,
|
|
||||||
displayname_source BIGINT,
|
|
||||||
displayname_contact BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
displayname_quality INTEGER NOT NULL DEFAULT 0,
|
|
||||||
disable_updates BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
username TEXT,
|
|
||||||
photo_id TEXT,
|
|
||||||
is_bot BOOLEAN,
|
|
||||||
|
|
||||||
access_token TEXT,
|
|
||||||
custom_mxid TEXT,
|
|
||||||
next_batch TEXT,
|
|
||||||
base_url TEXT
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telegram_file (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
mxc TEXT NOT NULL,
|
|
||||||
mime_type TEXT,
|
|
||||||
was_converted BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
timestamp BIGINT NOT NULL DEFAULT 0,
|
|
||||||
size BIGINT,
|
|
||||||
width INTEGER,
|
|
||||||
height INTEGER,
|
|
||||||
thumbnail TEXT,
|
|
||||||
decryption_info jsonb,
|
|
||||||
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE SET NULL
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE bot_chat (
|
|
||||||
id BIGINT PRIMARY KEY,
|
|
||||||
type TEXT NOT NULL
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE user_portal (
|
|
||||||
"user" BIGINT,
|
|
||||||
portal BIGINT,
|
|
||||||
portal_receiver BIGINT,
|
|
||||||
PRIMARY KEY ("user", portal, portal_receiver),
|
|
||||||
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (portal, portal_receiver) REFERENCES portal(tgid, tg_receiver)
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE contact (
|
|
||||||
"user" BIGINT,
|
|
||||||
contact BIGINT,
|
|
||||||
PRIMARY KEY ("user", contact),
|
|
||||||
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (contact) REFERENCES puppet(id) ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telethon_sessions (
|
|
||||||
session_id TEXT PRIMARY KEY,
|
|
||||||
dc_id INTEGER,
|
|
||||||
server_address TEXT,
|
|
||||||
port INTEGER,
|
|
||||||
auth_key bytea
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telethon_entities (
|
|
||||||
session_id TEXT,
|
|
||||||
id BIGINT,
|
|
||||||
hash BIGINT NOT NULL,
|
|
||||||
username TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
name TEXT,
|
|
||||||
PRIMARY KEY (session_id, id)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telethon_sent_files (
|
|
||||||
session_id TEXT,
|
|
||||||
md5_digest bytea,
|
|
||||||
file_size INTEGER,
|
|
||||||
type INTEGER,
|
|
||||||
id BIGINT,
|
|
||||||
hash BIGINT,
|
|
||||||
PRIMARY KEY (session_id, md5_digest, file_size, type)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telethon_update_state (
|
|
||||||
session_id TEXT,
|
|
||||||
entity_id BIGINT,
|
|
||||||
pts BIGINT,
|
|
||||||
qts BIGINT,
|
|
||||||
date BIGINT,
|
|
||||||
seq BIGINT,
|
|
||||||
unread_count INTEGER,
|
|
||||||
PRIMARY KEY (session_id, entity_id)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
@@ -1,25 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from asyncpg import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add column to store sponsored message event ID in channels")
|
|
||||||
async def upgrade_v2(conn: Connection) -> None:
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_event_id TEXT")
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_event_ts BIGINT")
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_msg_random_id bytea")
|
|
||||||
@@ -1,39 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from asyncpg import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add support for reactions")
|
|
||||||
async def upgrade_v3(conn: Connection, scheme: str) -> None:
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE reaction (
|
|
||||||
mxid TEXT NOT NULL,
|
|
||||||
mx_room TEXT NOT NULL,
|
|
||||||
msg_mxid TEXT NOT NULL,
|
|
||||||
tg_sender BIGINT,
|
|
||||||
reaction TEXT NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (msg_mxid, mx_room, tg_sender),
|
|
||||||
UNIQUE (mxid, mx_room)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
if scheme != "sqlite":
|
|
||||||
await conn.execute("DELETE FROM message WHERE mxid IS NULL OR mx_room IS NULL")
|
|
||||||
await conn.execute("ALTER TABLE message ALTER COLUMN mxid SET NOT NULL")
|
|
||||||
await conn.execute("ALTER TABLE message ALTER COLUMN mx_room SET NOT NULL")
|
|
||||||
await conn.execute("ALTER TABLE message ADD COLUMN content_hash bytea")
|
|
||||||
@@ -1,32 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from asyncpg import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add support for disappearing messages")
|
|
||||||
async def upgrade_v4(conn: Connection) -> None:
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE disappearing_message (
|
|
||||||
room_id TEXT,
|
|
||||||
event_id TEXT,
|
|
||||||
expiration_seconds BIGINT,
|
|
||||||
expiration_ts BIGINT,
|
|
||||||
|
|
||||||
PRIMARY KEY (room_id, event_id)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
@@ -1,25 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from asyncpg import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add separate ghost users for channel senders")
|
|
||||||
async def upgrade_v5(conn: Connection, scheme: str) -> None:
|
|
||||||
await conn.execute("ALTER TABLE puppet ADD COLUMN is_channel BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
if scheme == "postgres":
|
|
||||||
await conn.execute("ALTER TABLE puppet ALTER COLUMN is_channel DROP DEFAULT")
|
|
||||||
@@ -1,31 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from asyncpg import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Store avatar mxc URI in puppet table")
|
|
||||||
async def upgrade_v6(conn: Connection) -> None:
|
|
||||||
await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_url TEXT")
|
|
||||||
await conn.execute("ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
await conn.execute("UPDATE puppet SET name_set=true WHERE displayname<>''")
|
|
||||||
await conn.execute("UPDATE puppet SET avatar_set=true WHERE photo_id<>''")
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
await conn.execute("UPDATE portal SET name_set=true WHERE title<>'' AND mxid<>''")
|
|
||||||
await conn.execute("UPDATE portal SET avatar_set=true WHERE photo_id<>'' AND mxid<>''")
|
|
||||||
+91
-99
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,126 +14,117 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
|
||||||
|
from sqlalchemy.engine.result import RowProxy
|
||||||
|
from typing import Optional, Iterable, Tuple
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar, Iterable
|
from ..types import MatrixUserID, TelegramID
|
||||||
|
from .base import Base
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.types import UserID
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
class User(Base):
|
||||||
class User:
|
__tablename__ = "user"
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
mxid: UserID
|
mxid = Column(String, primary_key=True) # type: MatrixUserID
|
||||||
tgid: TelegramID | None
|
tgid = Column(Integer, nullable=True, unique=True) # type: Optional[TelegramID]
|
||||||
tg_username: str | None
|
tg_username = Column(String, nullable=True)
|
||||||
tg_phone: str | None
|
tg_phone = Column(String, nullable=True)
|
||||||
is_bot: bool
|
saved_contacts = Column(Integer, default=0, nullable=False)
|
||||||
saved_contacts: int
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _from_row(cls, row: Record | None) -> User | None:
|
def _one_or_none(cls, rows: RowProxy) -> Optional['User']:
|
||||||
if row is None:
|
try:
|
||||||
|
mxid, tgid, tg_username, tg_phone, saved_contacts = next(rows)
|
||||||
|
return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
|
||||||
|
saved_contacts=saved_contacts)
|
||||||
|
except StopIteration:
|
||||||
return None
|
return None
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
columns: ClassVar[str] = "mxid, tgid, tg_username, tg_phone, is_bot, saved_contacts"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_tgid(cls, tgid: TelegramID) -> User | None:
|
def all(cls) -> Iterable['User']:
|
||||||
q = f'SELECT {cls.columns} FROM "user" WHERE tgid=$1'
|
rows = cls.db.execute(cls.t.select())
|
||||||
return cls._from_row(await cls.db.fetchrow(q, tgid))
|
for row in rows:
|
||||||
|
mxid, tgid, tg_username, tg_phone, saved_contacts = row
|
||||||
|
yield cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
|
||||||
|
saved_contacts=saved_contacts)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_mxid(cls, mxid: UserID) -> User | None:
|
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
|
||||||
q = f'SELECT {cls.columns} FROM "user" WHERE mxid=$1'
|
return cls._select_one_or_none(cls.c.tgid == tgid)
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid))
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def find_by_username(cls, username: str) -> User | None:
|
def get_by_mxid(cls, mxid: MatrixUserID) -> Optional['User']:
|
||||||
q = f'SELECT {cls.columns} FROM "user" WHERE lower(tg_username)=$1'
|
return cls._select_one_or_none(cls.c.mxid == mxid)
|
||||||
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def all_with_tgid(cls) -> list[User]:
|
def get_by_username(cls, username: str) -> Optional['User']:
|
||||||
q = f'SELECT {cls.columns} FROM "user" WHERE tgid IS NOT NULL'
|
return cls._select_one_or_none(cls.c.tg_username == username)
|
||||||
return [cls._from_row(row) for row in await cls.db.fetch(q)]
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _values(self):
|
def _edit_identity(self):
|
||||||
return (
|
return self.c.mxid == self.mxid
|
||||||
self.mxid,
|
|
||||||
self.tgid,
|
|
||||||
self.tg_username,
|
|
||||||
self.tg_phone,
|
|
||||||
self.is_bot,
|
|
||||||
self.saved_contacts,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def save(self) -> None:
|
def insert(self) -> None:
|
||||||
q = (
|
with self.db.begin() as conn:
|
||||||
'UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, saved_contacts=$6 '
|
conn.execute(self.t.insert().values(
|
||||||
"WHERE mxid=$1"
|
mxid=self.mxid, tgid=self.tgid, tg_username=self.tg_username,
|
||||||
)
|
tg_phone=self.tg_phone, saved_contacts=self.saved_contacts))
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
@property
|
||||||
q = (
|
def contacts(self) -> Iterable[TelegramID]:
|
||||||
'INSERT INTO "user" (mxid, tgid, tg_username, tg_phone, is_bot, saved_contacts) '
|
rows = self.db.execute(Contact.t.select().where(Contact.c.user == self.tgid))
|
||||||
"VALUES ($1, $2, $3, $4, $5, $6)"
|
for row in rows:
|
||||||
)
|
user, contact = row
|
||||||
await self.db.execute(q, *self._values)
|
yield contact
|
||||||
|
|
||||||
async def get_contacts(self) -> list[TelegramID]:
|
@contacts.setter
|
||||||
rows = await self.db.fetch('SELECT contact FROM contact WHERE "user"=$1', self.tgid)
|
def contacts(self, puppets: Iterable[TelegramID]) -> None:
|
||||||
return [TelegramID(row["contact"]) for row in rows]
|
with self.db.begin() as conn:
|
||||||
|
conn.execute(Contact.t.delete().where(Contact.c.user == self.tgid))
|
||||||
|
insert_puppets = [{"user": self.tgid, "contact": tgid} for tgid in puppets]
|
||||||
|
if insert_puppets:
|
||||||
|
conn.execute(Contact.t.insert(), insert_puppets)
|
||||||
|
|
||||||
async def set_contacts(self, puppets: Iterable[TelegramID]) -> None:
|
@property
|
||||||
columns = ["user", "contact"]
|
def portals(self) -> Iterable[Tuple[TelegramID, TelegramID]]:
|
||||||
records = [(self.tgid, puppet_id) for puppet_id in puppets]
|
rows = self.db.execute(UserPortal.t.select().where(UserPortal.c.user == self.tgid))
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
for row in rows:
|
||||||
await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid)
|
user, portal, portal_receiver = row
|
||||||
if self.db.scheme == "postgres":
|
yield (portal, portal_receiver)
|
||||||
await conn.copy_records_to_table("contact", records=records, columns=columns)
|
|
||||||
else:
|
|
||||||
q = 'INSERT INTO contact ("user", contact) VALUES ($1, $2)'
|
|
||||||
await conn.executemany(q, records)
|
|
||||||
|
|
||||||
async def get_portals(self) -> list[tuple[TelegramID, TelegramID]]:
|
@portals.setter
|
||||||
q = 'SELECT portal, portal_receiver FROM user_portal WHERE "user"=$1'
|
def portals(self, portals: Iterable[Tuple[TelegramID, TelegramID]]) -> None:
|
||||||
rows = await self.db.fetch(q, self.tgid)
|
with self.db.begin() as conn:
|
||||||
return [(TelegramID(row["portal"]), TelegramID(row["portal_receiver"])) for row in rows]
|
conn.execute(UserPortal.t.delete().where(UserPortal.c.user == self.tgid))
|
||||||
|
insert_portals = [{
|
||||||
|
"user": self.tgid,
|
||||||
|
"portal": tgid,
|
||||||
|
"portal_receiver": tg_receiver
|
||||||
|
} for tgid, tg_receiver in portals]
|
||||||
|
if insert_portals:
|
||||||
|
conn.execute(UserPortal.t.insert(), insert_portals)
|
||||||
|
|
||||||
async def set_portals(self, portals: Iterable[tuple[TelegramID, TelegramID]]) -> None:
|
def delete(self) -> None:
|
||||||
columns = ["user", "portal", "portal_receiver"]
|
super().delete()
|
||||||
records = [(self.tgid, tgid, tg_receiver) for tgid, tg_receiver in portals]
|
self.portals = None
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
self.contacts = None
|
||||||
await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid)
|
|
||||||
if self.db.scheme == "postgres":
|
|
||||||
await conn.copy_records_to_table("user_portal", records=records, columns=columns)
|
|
||||||
else:
|
|
||||||
q = 'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3)'
|
|
||||||
await conn.executemany(q, records)
|
|
||||||
|
|
||||||
async def register_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
|
|
||||||
q = (
|
|
||||||
'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3) '
|
|
||||||
'ON CONFLICT ("user", portal, portal_receiver) DO NOTHING'
|
|
||||||
)
|
|
||||||
await self.db.execute(q, self.tgid, tgid, tg_receiver)
|
|
||||||
|
|
||||||
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
|
class UserPortal(Base):
|
||||||
q = 'DELETE FROM user_portal WHERE "user"=$1 AND portal=$2 AND portal_receiver=$3'
|
__tablename__ = "user_portal"
|
||||||
await self.db.execute(q, self.tgid, tgid, tg_receiver)
|
|
||||||
|
user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"),
|
||||||
|
primary_key=True) # type: TelegramID
|
||||||
|
portal = Column(Integer, primary_key=True) # type: TelegramID
|
||||||
|
portal_receiver = Column(Integer, primary_key=True) # type: TelegramID
|
||||||
|
|
||||||
|
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
|
||||||
|
("portal.tgid", "portal.tg_receiver"),
|
||||||
|
onupdate="CASCADE", ondelete="CASCADE"),)
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(Base):
|
||||||
|
__tablename__ = "contact"
|
||||||
|
|
||||||
|
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) # type: TelegramID
|
||||||
|
contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True) # type: TelegramID
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
|
# Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
|
||||||
|
from sqlalchemy import Column, String, and_
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from ..types import MatrixUserID, MatrixRoomID
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(Base):
|
||||||
|
__tablename__ = "mx_user_profile"
|
||||||
|
|
||||||
|
room_id = Column(String, primary_key=True) # type: MatrixRoomID
|
||||||
|
user_id = Column(String, primary_key=True) # type: MatrixUserID
|
||||||
|
membership = Column(String, nullable=False, default="leave")
|
||||||
|
displayname = Column(String, nullable=True)
|
||||||
|
avatar_url = Column(String, nullable=True)
|
||||||
|
|
||||||
|
def dict(self) -> Dict[str, str]:
|
||||||
|
return {
|
||||||
|
"membership": self.membership,
|
||||||
|
"displayname": self.displayname,
|
||||||
|
"avatar_url": self.avatar_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, room_id: MatrixRoomID, user_id: MatrixUserID) -> Optional['UserProfile']:
|
||||||
|
rows = cls.db.execute(
|
||||||
|
cls.t.select().where(and_(cls.c.room_id == room_id, cls.c.user_id == user_id)))
|
||||||
|
try:
|
||||||
|
room_id, user_id, membership, displayname, avatar_url = next(rows)
|
||||||
|
return cls(room_id=room_id, user_id=user_id, membership=membership,
|
||||||
|
displayname=displayname, avatar_url=avatar_url)
|
||||||
|
except StopIteration:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def delete_all(cls, room_id: MatrixRoomID) -> None:
|
||||||
|
with cls.db.begin() as conn:
|
||||||
|
conn.execute(cls.t.delete().where(cls.c.room_id == room_id))
|
||||||
|
|
||||||
|
def update(self) -> None:
|
||||||
|
super().update(membership=self.membership, displayname=self.displayname,
|
||||||
|
avatar_url=self.avatar_url)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _edit_identity(self):
|
||||||
|
return and_(self.c.room_id == self.room_id, self.c.user_id == self.user_id)
|
||||||
|
|
||||||
|
def insert(self) -> None:
|
||||||
|
with self.db.begin() as conn:
|
||||||
|
conn.execute(self.t.insert().values(room_id=self.room_id, user_id=self.user_id,
|
||||||
|
membership=self.membership,
|
||||||
|
displayname=self.displayname,
|
||||||
|
avatar_url=self.avatar_url))
|
||||||
@@ -1,545 +0,0 @@
|
|||||||
# Homeserver details
|
|
||||||
homeserver:
|
|
||||||
# The address that this appservice can use to connect to the homeserver.
|
|
||||||
address: https://example.com
|
|
||||||
# The domain of the homeserver (for MXIDs, etc).
|
|
||||||
domain: example.com
|
|
||||||
# Whether or not to verify the SSL certificate of the homeserver.
|
|
||||||
# Only applies if address starts with https://
|
|
||||||
verify_ssl: true
|
|
||||||
asmux: false
|
|
||||||
# Number of retries for all HTTP requests if the homeserver isn't reachable.
|
|
||||||
http_retry_count: 4
|
|
||||||
# The URL to push real-time bridge status to.
|
|
||||||
# If set, the bridge will make POST requests to this URL whenever a user's Telegram connection state changes.
|
|
||||||
# The bridge will use the appservice as_token to authorize requests.
|
|
||||||
status_endpoint: null
|
|
||||||
# Endpoint for reporting per-message status.
|
|
||||||
message_send_checkpoint_endpoint: null
|
|
||||||
|
|
||||||
# Application service host/registration related details
|
|
||||||
# Changing these values requires regeneration of the registration.
|
|
||||||
appservice:
|
|
||||||
# The address that the homeserver can use to connect to this appservice.
|
|
||||||
address: http://localhost:29317
|
|
||||||
# When using https:// the TLS certificate and key files for the address.
|
|
||||||
tls_cert: false
|
|
||||||
tls_key: false
|
|
||||||
|
|
||||||
# The hostname and port where this appservice should listen.
|
|
||||||
hostname: 0.0.0.0
|
|
||||||
port: 29317
|
|
||||||
# The maximum body size of appservice API requests (from the homeserver) in mebibytes
|
|
||||||
# Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
|
|
||||||
max_body_size: 1
|
|
||||||
|
|
||||||
# The full URI to the database. SQLite and Postgres are supported.
|
|
||||||
# Format examples:
|
|
||||||
# SQLite: sqlite:///filename.db
|
|
||||||
# Postgres: postgres://username:password@hostname/dbname
|
|
||||||
database: postgres://username:password@hostname/dbname
|
|
||||||
# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
|
|
||||||
# https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool
|
|
||||||
# https://docs.python.org/3/library/sqlite3.html#sqlite3.connect
|
|
||||||
# For sqlite, min_size is used as the connection thread pool size and max_size is ignored.
|
|
||||||
database_opts:
|
|
||||||
min_size: 1
|
|
||||||
max_size: 10
|
|
||||||
|
|
||||||
# Public part of web server for out-of-Matrix interaction with the bridge.
|
|
||||||
# Used for things like login if the user wants to make sure the 2FA password isn't stored in
|
|
||||||
# the HS database.
|
|
||||||
public:
|
|
||||||
# Whether or not the public-facing endpoints should be enabled.
|
|
||||||
enabled: false
|
|
||||||
# The prefix to use in the public-facing endpoints.
|
|
||||||
prefix: /public
|
|
||||||
# The base URL where the public-facing endpoints are available. The prefix is not added
|
|
||||||
# implicitly.
|
|
||||||
external: https://example.com/public
|
|
||||||
|
|
||||||
# Provisioning API part of the web server for automated portal creation and fetching information.
|
|
||||||
# Used by things like mautrix-manager (https://github.com/tulir/mautrix-manager).
|
|
||||||
provisioning:
|
|
||||||
# Whether or not the provisioning API should be enabled.
|
|
||||||
enabled: true
|
|
||||||
# The prefix to use in the provisioning API endpoints.
|
|
||||||
prefix: /_matrix/provision/v1
|
|
||||||
# The shared secret to authorize users of the API.
|
|
||||||
# Set to "generate" to generate and save a new token.
|
|
||||||
shared_secret: generate
|
|
||||||
|
|
||||||
# The unique ID of this appservice.
|
|
||||||
id: telegram
|
|
||||||
# Username of the appservice bot.
|
|
||||||
bot_username: telegrambot
|
|
||||||
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
|
|
||||||
# to leave display name/avatar as-is.
|
|
||||||
bot_displayname: Telegram bridge bot
|
|
||||||
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
|
|
||||||
|
|
||||||
# Whether or not to receive ephemeral events via appservice transactions.
|
|
||||||
# Requires MSC2409 support (i.e. Synapse 1.22+).
|
|
||||||
# You should disable bridge -> sync_with_custom_puppets when this is enabled.
|
|
||||||
ephemeral_events: false
|
|
||||||
|
|
||||||
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
|
|
||||||
as_token: "This value is generated when generating the registration"
|
|
||||||
hs_token: "This value is generated when generating the registration"
|
|
||||||
|
|
||||||
# Prometheus telemetry config. Requires prometheus-client to be installed.
|
|
||||||
metrics:
|
|
||||||
enabled: false
|
|
||||||
listen_port: 8000
|
|
||||||
|
|
||||||
# Manhole config.
|
|
||||||
manhole:
|
|
||||||
# Whether or not opening the manhole is allowed.
|
|
||||||
enabled: false
|
|
||||||
# The path for the unix socket.
|
|
||||||
path: /var/tmp/mautrix-telegram.manhole
|
|
||||||
# The list of UIDs who can be added to the whitelist.
|
|
||||||
# If empty, any UIDs can be specified in the open-manhole command.
|
|
||||||
whitelist:
|
|
||||||
- 0
|
|
||||||
|
|
||||||
# Bridge config
|
|
||||||
bridge:
|
|
||||||
# Localpart template of MXIDs for Telegram users.
|
|
||||||
# {userid} is replaced with the user ID of the Telegram user.
|
|
||||||
username_template: "telegram_{userid}"
|
|
||||||
# Localpart template of room aliases for Telegram portal rooms.
|
|
||||||
# {groupname} is replaced with the name part of the public channel/group invite link ( https://t.me/{} )
|
|
||||||
alias_template: "telegram_{groupname}"
|
|
||||||
# Displayname template for Telegram users.
|
|
||||||
# {displayname} is replaced with the display name of the Telegram user.
|
|
||||||
displayname_template: "{displayname} (Telegram)"
|
|
||||||
|
|
||||||
# Set the preferred order of user identifiers which to use in the Matrix puppet display name.
|
|
||||||
# In the (hopefully unlikely) scenario that none of the given keys are found, the numeric user
|
|
||||||
# ID is used.
|
|
||||||
#
|
|
||||||
# If the bridge is working properly, a phone number or an username should always be known, but
|
|
||||||
# the other one can very well be empty.
|
|
||||||
#
|
|
||||||
# Valid keys:
|
|
||||||
# "full name" (First and/or last name)
|
|
||||||
# "full name reversed" (Last and/or first name)
|
|
||||||
# "first name"
|
|
||||||
# "last name"
|
|
||||||
# "username"
|
|
||||||
# "phone number"
|
|
||||||
displayname_preference:
|
|
||||||
- full name
|
|
||||||
- username
|
|
||||||
- phone number
|
|
||||||
# Maximum length of displayname
|
|
||||||
displayname_max_length: 100
|
|
||||||
# Remove avatars from Telegram ghost users when removed on Telegram. This is disabled by default
|
|
||||||
# as there's no way to determine whether an avatar is removed or just hidden from some users. If
|
|
||||||
# you're on a single-user instance, this should be safe to enable.
|
|
||||||
allow_avatar_remove: false
|
|
||||||
|
|
||||||
# Maximum number of members to sync per portal when starting up. Other members will be
|
|
||||||
# synced when they send messages. The maximum is 10000, after which the Telegram server
|
|
||||||
# will not send any more members.
|
|
||||||
# -1 means no limit (which means it's limited to 10000 by the server)
|
|
||||||
max_initial_member_sync: 100
|
|
||||||
# Whether or not to sync the member list in channels.
|
|
||||||
# If no channel admins have logged into the bridge, the bridge won't be able to sync the member
|
|
||||||
# list regardless of this setting.
|
|
||||||
sync_channel_members: true
|
|
||||||
# Whether or not to skip deleted members when syncing members.
|
|
||||||
skip_deleted_members: true
|
|
||||||
# Whether or not to automatically synchronize contacts and chats of Matrix users logged into
|
|
||||||
# their Telegram account at startup.
|
|
||||||
startup_sync: true
|
|
||||||
# Number of most recently active dialogs to check when syncing chats.
|
|
||||||
# Set to 0 to remove limit.
|
|
||||||
sync_update_limit: 0
|
|
||||||
# Number of most recently active dialogs to create portals for when syncing chats.
|
|
||||||
# Set to 0 to remove limit.
|
|
||||||
sync_create_limit: 30
|
|
||||||
# Whether or not to sync and create portals for direct chats at startup.
|
|
||||||
sync_direct_chats: false
|
|
||||||
# The maximum number of simultaneous Telegram deletions to handle.
|
|
||||||
# A large number of simultaneous redactions could put strain on your homeserver.
|
|
||||||
max_telegram_delete: 10
|
|
||||||
# Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames)
|
|
||||||
# at startup and when creating a bridge.
|
|
||||||
sync_matrix_state: true
|
|
||||||
# Allow logging in within Matrix. If false, users can only log in using login-qr or the
|
|
||||||
# out-of-Matrix login website (see appservice.public config section)
|
|
||||||
allow_matrix_login: true
|
|
||||||
# Whether or not to bridge plaintext highlights.
|
|
||||||
# Only enable this if your displayname_template has some static part that the bridge can use to
|
|
||||||
# reliably identify what is a plaintext highlight.
|
|
||||||
plaintext_highlights: false
|
|
||||||
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
|
|
||||||
public_portals: true
|
|
||||||
# Whether or not to use /sync to get presence, read receipts and typing notifications
|
|
||||||
# when double puppeting is enabled
|
|
||||||
sync_with_custom_puppets: true
|
|
||||||
# Whether or not to update the m.direct account data event when double puppeting is enabled.
|
|
||||||
# Note that updating the m.direct event is not atomic (except with mautrix-asmux)
|
|
||||||
# and is therefore prone to race conditions.
|
|
||||||
sync_direct_chat_list: false
|
|
||||||
# Servers to always allow double puppeting from
|
|
||||||
double_puppet_server_map:
|
|
||||||
example.com: https://example.com
|
|
||||||
# Allow using double puppeting from any server with a valid client .well-known file.
|
|
||||||
double_puppet_allow_discovery: false
|
|
||||||
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
|
|
||||||
#
|
|
||||||
# If set, custom puppets will be enabled automatically for local users
|
|
||||||
# instead of users having to find an access token and run `login-matrix`
|
|
||||||
# manually.
|
|
||||||
# If using this for other servers than the bridge's server,
|
|
||||||
# you must also set the URL in the double_puppet_server_map.
|
|
||||||
login_shared_secret_map:
|
|
||||||
example.com: foobar
|
|
||||||
# Set to false to disable link previews in messages sent to Telegram.
|
|
||||||
telegram_link_preview: true
|
|
||||||
# Whether or not the !tg join command should do a HTTP request
|
|
||||||
# to resolve redirects in invite links.
|
|
||||||
invite_link_resolve: false
|
|
||||||
# Use inline images instead of a separate message for the caption.
|
|
||||||
# N.B. Inline images are not supported on all clients (e.g. Element iOS/Android).
|
|
||||||
inline_images: false
|
|
||||||
# Maximum size of image in megabytes before sending to Telegram as a document.
|
|
||||||
image_as_file_size: 10
|
|
||||||
# Maximum number of pixels in an image before sending to Telegram as a document. Defaults to 1280x1280 = 1638400.
|
|
||||||
image_as_file_pixels: 1638400
|
|
||||||
# Maximum size of Telegram documents in megabytes to bridge.
|
|
||||||
max_document_size: 100
|
|
||||||
# Enable experimental parallel file transfer, which makes uploads/downloads much faster by
|
|
||||||
# streaming from/to Matrix and using many connections for Telegram.
|
|
||||||
# Note that generating HQ thumbnails for videos is not possible with streamed transfers.
|
|
||||||
# This option uses internal Telethon implementation details and may break with minor updates.
|
|
||||||
parallel_file_transfer: false
|
|
||||||
# Whether or not created rooms should have federation enabled.
|
|
||||||
# If false, created portal rooms will never be federated.
|
|
||||||
federate_rooms: true
|
|
||||||
# Settings for converting animated stickers.
|
|
||||||
animated_sticker:
|
|
||||||
# Format to which animated stickers should be converted.
|
|
||||||
# disable - No conversion, send as-is (gzipped lottie)
|
|
||||||
# png - converts to non-animated png (fastest),
|
|
||||||
# gif - converts to animated gif
|
|
||||||
# webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support
|
|
||||||
target: gif
|
|
||||||
# Arguments for converter. All converters take width and height.
|
|
||||||
args:
|
|
||||||
width: 256
|
|
||||||
height: 256
|
|
||||||
fps: 25 # only for webm and gif (2, 5, 10, 20 or 25 recommended)
|
|
||||||
# End-to-bridge encryption support options.
|
|
||||||
#
|
|
||||||
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
|
|
||||||
encryption:
|
|
||||||
# Allow encryption, work in group chat rooms with e2ee enabled
|
|
||||||
allow: false
|
|
||||||
# Default to encryption, force-enable encryption in all portals the bridge creates
|
|
||||||
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
|
||||||
default: false
|
|
||||||
# Database for the encryption data. If set to `default`, will use the appservice database.
|
|
||||||
database: default
|
|
||||||
# Options for automatic key sharing.
|
|
||||||
key_sharing:
|
|
||||||
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
|
|
||||||
# You must use a client that supports requesting keys from other users to use this feature.
|
|
||||||
allow: false
|
|
||||||
# Require the requesting device to have a valid cross-signing signature?
|
|
||||||
# This doesn't require that the bridge has verified the device, only that the user has verified it.
|
|
||||||
# Not yet implemented.
|
|
||||||
require_cross_signing: false
|
|
||||||
# Require devices to be verified by the bridge?
|
|
||||||
# Verification by the bridge is not yet implemented.
|
|
||||||
require_verification: true
|
|
||||||
# Whether or not to explicitly set the avatar and room name for private
|
|
||||||
# chat portal rooms. This will be implicitly enabled if encryption.default is true.
|
|
||||||
private_chat_portal_meta: false
|
|
||||||
# Whether or not the bridge should send a read receipt from the bridge bot when a message has
|
|
||||||
# been sent to Telegram.
|
|
||||||
delivery_receipts: false
|
|
||||||
# Whether or not delivery errors should be reported as messages in the Matrix room.
|
|
||||||
delivery_error_reports: false
|
|
||||||
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
|
|
||||||
# This field will automatically be changed back to false after it,
|
|
||||||
# except if the config file is not writable.
|
|
||||||
resend_bridge_info: false
|
|
||||||
# When using double puppeting, should muted chats be muted in Matrix?
|
|
||||||
mute_bridging: false
|
|
||||||
# When using double puppeting, should pinned chats be moved to a specific tag in Matrix?
|
|
||||||
# The favorites tag is `m.favourite`.
|
|
||||||
pinned_tag: null
|
|
||||||
# Same as above for archived chats, the low priority tag is `m.lowpriority`.
|
|
||||||
archive_tag: null
|
|
||||||
# Whether or not mute status and tags should only be bridged when the portal room is created.
|
|
||||||
tag_only_on_create: true
|
|
||||||
# Should leaving the room on Matrix make the user leave on Telegram?
|
|
||||||
bridge_matrix_leave: true
|
|
||||||
# Should the user be kicked out of all portals when logging out of the bridge?
|
|
||||||
kick_on_logout: true
|
|
||||||
# Settings for backfilling messages from Telegram.
|
|
||||||
backfill:
|
|
||||||
# Whether or not the Telegram ghosts of logged in Matrix users should be
|
|
||||||
# invited to private chats when backfilling history from Telegram. This is
|
|
||||||
# usually needed to prevent rate limits and to allow timestamp massaging.
|
|
||||||
invite_own_puppet: true
|
|
||||||
# Maximum number of messages to backfill without using a takeout.
|
|
||||||
# The first time a takeout is used, the user has to manually approve it from a different
|
|
||||||
# device. If initial_limit or missed_limit are higher than this value, the bridge will ask
|
|
||||||
# the user to accept the takeout after logging in before syncing any chats.
|
|
||||||
takeout_limit: 100
|
|
||||||
# Maximum number of messages to backfill initially.
|
|
||||||
# Set to 0 to disable backfilling when creating portal, or -1 to disable the limit.
|
|
||||||
#
|
|
||||||
# N.B. Initial backfill will only start after member sync. Make sure your
|
|
||||||
# max_initial_member_sync is set to a low enough value so it doesn't take forever.
|
|
||||||
initial_limit: 0
|
|
||||||
# Maximum number of messages to backfill if messages were missed while the bridge was
|
|
||||||
# disconnected. Note that this only works for logged in users and only if the chat isn't
|
|
||||||
# older than sync_update_limit
|
|
||||||
# Set to 0 to disable backfilling missed messages.
|
|
||||||
missed_limit: 50
|
|
||||||
# If using double puppeting, should notifications be disabled
|
|
||||||
# while the initial backfill is in progress?
|
|
||||||
disable_notifications: false
|
|
||||||
# Whether or not to enable backfilling in normal groups.
|
|
||||||
# Normal groups have numerous technical problems in Telegram, and backfilling normal groups
|
|
||||||
# will likely cause problems if there are multiple Matrix users in the group.
|
|
||||||
normal_groups: false
|
|
||||||
|
|
||||||
# Overrides for base power levels.
|
|
||||||
initial_power_level_overrides:
|
|
||||||
user: {}
|
|
||||||
group: {}
|
|
||||||
|
|
||||||
# Whether to bridge Telegram bot messages as m.notices or m.texts.
|
|
||||||
bot_messages_as_notices: true
|
|
||||||
bridge_notices:
|
|
||||||
# Whether or not Matrix bot messages (type m.notice) should be bridged.
|
|
||||||
default: false
|
|
||||||
# List of user IDs for whom the previous flag is flipped.
|
|
||||||
# e.g. if bridge_notices.default is false, notices from other users will not be bridged, but
|
|
||||||
# notices from users listed here will be bridged.
|
|
||||||
exceptions: []
|
|
||||||
|
|
||||||
# An array of possible values for the $distinguisher variable in message formats.
|
|
||||||
# Each user gets one of the values here, based on a hash of their user ID.
|
|
||||||
# If the array is empty, the $distinguisher variable will also be empty.
|
|
||||||
relay_user_distinguishers: ["🟦", "🟣", "🟩", "⭕️", "🔶", "⬛️", "🔵", "🟢"]
|
|
||||||
# The formats to use when sending messages to Telegram via the relay bot.
|
|
||||||
# Text msgtypes (m.text, m.notice and m.emote) support HTML, media msgtypes don't.
|
|
||||||
#
|
|
||||||
# Available variables:
|
|
||||||
# $sender_displayname - The display name of the sender (e.g. Example User)
|
|
||||||
# $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
|
|
||||||
# $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
|
|
||||||
# $distinguisher - A random string from the options in the relay_user_distinguishers array.
|
|
||||||
# $message - The message content
|
|
||||||
message_formats:
|
|
||||||
m.text: "$distinguisher <b>$sender_displayname</b>: $message"
|
|
||||||
m.notice: "$distinguisher <b>$sender_displayname</b>: $message"
|
|
||||||
m.emote: "* $distinguisher <b>$sender_displayname</b> $message"
|
|
||||||
m.file: "$distinguisher <b>$sender_displayname</b> sent a file: $message"
|
|
||||||
m.image: "$distinguisher <b>$sender_displayname</b> sent an image: $message"
|
|
||||||
m.audio: "$distinguisher <b>$sender_displayname</b> sent an audio file: $message"
|
|
||||||
m.video: "$distinguisher <b>$sender_displayname</b> sent a video: $message"
|
|
||||||
m.location: "$distinguisher <b>$sender_displayname</b> sent a location: $message"
|
|
||||||
# Telegram doesn't have built-in emotes, this field specifies how m.emote's from authenticated
|
|
||||||
# users are sent to telegram. All fields in message_formats are supported. Additionally, the
|
|
||||||
# Telegram user info is available in the following variables:
|
|
||||||
# $displayname - Telegram displayname
|
|
||||||
# $username - Telegram username (may not exist)
|
|
||||||
# $mention - Telegram @username or displayname mention (depending on which exists)
|
|
||||||
emote_format: "* $mention $formatted_body"
|
|
||||||
|
|
||||||
# The formats to use when sending state events to Telegram via the relay bot.
|
|
||||||
#
|
|
||||||
# Variables from `message_formats` that have the `sender_` prefix are available without the prefix.
|
|
||||||
# In name_change events, `$prev_displayname` is the previous displayname.
|
|
||||||
#
|
|
||||||
# Set format to an empty string to disable the messages for that event.
|
|
||||||
state_event_formats:
|
|
||||||
join: "$distinguisher <b>$displayname</b> joined the room."
|
|
||||||
leave: "$distinguisher <b>$displayname</b> left the room."
|
|
||||||
name_change: "$distinguisher <b>$prev_displayname</b> changed their name to $distinguisher <b>$displayname</b>"
|
|
||||||
|
|
||||||
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
|
|
||||||
# `filter-mode` management commands.
|
|
||||||
#
|
|
||||||
# Filters do not affect direct chats.
|
|
||||||
# An empty blacklist will essentially disable the filter.
|
|
||||||
filter:
|
|
||||||
# Filter mode to use. Either "blacklist" or "whitelist".
|
|
||||||
# If the mode is "blacklist", the listed chats will never be bridged.
|
|
||||||
# If the mode is "whitelist", only the listed chats can be bridged.
|
|
||||||
mode: blacklist
|
|
||||||
# The list of group/channel IDs to filter.
|
|
||||||
list: []
|
|
||||||
|
|
||||||
# The prefix for commands. Only required in non-management rooms.
|
|
||||||
command_prefix: "!tg"
|
|
||||||
|
|
||||||
# Messages sent upon joining a management room.
|
|
||||||
# Markdown is supported. The defaults are listed below.
|
|
||||||
management_room_text:
|
|
||||||
# Sent when joining a room.
|
|
||||||
welcome: "Hello, I'm a Telegram bridge bot."
|
|
||||||
# Sent when joining a management room and the user is already logged in.
|
|
||||||
welcome_connected: "Use `help` for help."
|
|
||||||
# Sent when joining a management room and the user is not logged in.
|
|
||||||
welcome_unconnected: "Use `help` for help or `login` to log in."
|
|
||||||
# Optional extra text sent when joining a management room.
|
|
||||||
additional_help: ""
|
|
||||||
|
|
||||||
# Send each message separately (for readability in some clients)
|
|
||||||
management_room_multiple_messages: false
|
|
||||||
|
|
||||||
# Permissions for using the bridge.
|
|
||||||
# Permitted values:
|
|
||||||
# relaybot - Only use the bridge via the relaybot, no access to commands.
|
|
||||||
# user - Relaybot level + access to commands to create bridges.
|
|
||||||
# puppeting - User level + logging in with a Telegram account.
|
|
||||||
# full - Full access to use the bridge, i.e. previous levels + Matrix login.
|
|
||||||
# admin - Full access to use the bridge and some extra administration commands.
|
|
||||||
# Permitted keys:
|
|
||||||
# * - All Matrix users
|
|
||||||
# domain - All users on that homeserver
|
|
||||||
# mxid - Specific user
|
|
||||||
permissions:
|
|
||||||
"*": "relaybot"
|
|
||||||
"public.example.com": "user"
|
|
||||||
"example.com": "full"
|
|
||||||
"@admin:example.com": "admin"
|
|
||||||
|
|
||||||
# Options related to the message relay Telegram bot.
|
|
||||||
relaybot:
|
|
||||||
private_chat:
|
|
||||||
# List of users to invite to the portal when someone starts a private chat with the bot.
|
|
||||||
# If empty, private chats with the bot won't create a portal.
|
|
||||||
invite: []
|
|
||||||
# Whether or not to bridge state change messages in relaybot private chats.
|
|
||||||
state_changes: true
|
|
||||||
# When private_chat_invite is empty, this message is sent to users /starting the
|
|
||||||
# relaybot. Telegram's "markdown" is supported.
|
|
||||||
message: This is a Matrix bridge relaybot and does not support direct chats
|
|
||||||
# List of users to invite to all group chat portals created by the bridge.
|
|
||||||
group_chat_invite: []
|
|
||||||
# Whether or not the relaybot should not bridge events in unbridged group chats.
|
|
||||||
# If false, portals will be created when the relaybot receives messages, just like normal
|
|
||||||
# users. This behavior is usually not desirable, as it interferes with manually bridging
|
|
||||||
# the chat to another room.
|
|
||||||
ignore_unbridged_group_chat: true
|
|
||||||
# Whether or not to allow creating portals from Telegram.
|
|
||||||
authless_portals: true
|
|
||||||
# Whether or not to allow Telegram group admins to use the bot commands.
|
|
||||||
whitelist_group_admins: true
|
|
||||||
# Whether or not to ignore incoming events sent by the relay bot.
|
|
||||||
ignore_own_incoming_events: true
|
|
||||||
# List of usernames/user IDs who are also allowed to use the bot commands.
|
|
||||||
whitelist:
|
|
||||||
- myusername
|
|
||||||
- 12345678
|
|
||||||
|
|
||||||
# Telegram config
|
|
||||||
telegram:
|
|
||||||
# Get your own API keys at https://my.telegram.org/apps
|
|
||||||
api_id: 12345
|
|
||||||
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
|
|
||||||
# (Optional) Create your own bot at https://t.me/BotFather
|
|
||||||
bot_token: disabled
|
|
||||||
|
|
||||||
# Telethon connection options.
|
|
||||||
connection:
|
|
||||||
# The timeout in seconds to be used when connecting.
|
|
||||||
timeout: 120
|
|
||||||
# How many times the reconnection should retry, either on the initial connection or when
|
|
||||||
# Telegram disconnects us. May be set to a negative or null value for infinite retries, but
|
|
||||||
# this is not recommended, since the program can get stuck in an infinite loop.
|
|
||||||
retries: 5
|
|
||||||
# The delay in seconds to sleep between automatic reconnections.
|
|
||||||
retry_delay: 1
|
|
||||||
# The threshold below which the library should automatically sleep on flood wait errors
|
|
||||||
# (inclusive). For instance, if a FloodWaitError for 17s occurs and flood_sleep_threshold
|
|
||||||
# is 20s, the library will sleep automatically. If the error was for 21s, it would raise
|
|
||||||
# the error instead. Values larger than a day (86400) will be changed to a day.
|
|
||||||
flood_sleep_threshold: 60
|
|
||||||
# How many times a request should be retried. Request are retried when Telegram is having
|
|
||||||
# internal issues, when there is a FloodWaitError less than flood_sleep_threshold, or when
|
|
||||||
# there's a migrate error. May take a negative or null value for infinite retries, but this
|
|
||||||
# is not recommended, since some requests can always trigger a call fail (such as searching
|
|
||||||
# for messages).
|
|
||||||
request_retries: 5
|
|
||||||
|
|
||||||
# Device info sent to Telegram.
|
|
||||||
device_info:
|
|
||||||
# "auto" = OS name+version.
|
|
||||||
device_model: auto
|
|
||||||
# "auto" = Telethon version.
|
|
||||||
system_version: auto
|
|
||||||
# "auto" = mautrix-telegram version.
|
|
||||||
app_version: auto
|
|
||||||
lang_code: en
|
|
||||||
system_lang_code: en
|
|
||||||
|
|
||||||
# Custom server to connect to.
|
|
||||||
server:
|
|
||||||
# Set to true to use these server settings. If false, will automatically
|
|
||||||
# use production server assigned by Telegram. Set to false in production.
|
|
||||||
enabled: false
|
|
||||||
# The DC ID to connect to.
|
|
||||||
dc: 2
|
|
||||||
# The IP to connect to.
|
|
||||||
ip: 149.154.167.40
|
|
||||||
# The port to connect to. 443 may not work, 80 is better and both are equally secure.
|
|
||||||
port: 80
|
|
||||||
|
|
||||||
# Telethon proxy configuration.
|
|
||||||
# You must install PySocks from pip for proxies to work.
|
|
||||||
proxy:
|
|
||||||
# Allowed types: disabled, socks4, socks5, http, mtproxy
|
|
||||||
type: disabled
|
|
||||||
# Proxy IP address and port.
|
|
||||||
address: 127.0.0.1
|
|
||||||
port: 1080
|
|
||||||
# Whether or not to perform DNS resolving remotely. Only for socks/http proxies.
|
|
||||||
rdns: true
|
|
||||||
# Proxy authentication (optional). Put MTProxy secret in password field.
|
|
||||||
username: ""
|
|
||||||
password: ""
|
|
||||||
|
|
||||||
# Python logging configuration.
|
|
||||||
#
|
|
||||||
# See section 16.7.2 of the Python documentation for more info:
|
|
||||||
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
|
|
||||||
logging:
|
|
||||||
version: 1
|
|
||||||
formatters:
|
|
||||||
colored:
|
|
||||||
(): mautrix_telegram.util.ColorFormatter
|
|
||||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
|
||||||
normal:
|
|
||||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
|
||||||
handlers:
|
|
||||||
file:
|
|
||||||
class: logging.handlers.RotatingFileHandler
|
|
||||||
formatter: normal
|
|
||||||
filename: ./mautrix-telegram.log
|
|
||||||
maxBytes: 10485760
|
|
||||||
backupCount: 10
|
|
||||||
console:
|
|
||||||
class: logging.StreamHandler
|
|
||||||
formatter: colored
|
|
||||||
loggers:
|
|
||||||
mau:
|
|
||||||
level: DEBUG
|
|
||||||
telethon:
|
|
||||||
level: INFO
|
|
||||||
aiohttp:
|
|
||||||
level: INFO
|
|
||||||
root:
|
|
||||||
level: DEBUG
|
|
||||||
handlers: [file, console]
|
|
||||||
@@ -1,2 +1,9 @@
|
|||||||
from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram
|
from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram,
|
||||||
from .from_telegram import telegram_reply_to_matrix, telegram_to_matrix
|
init_mx)
|
||||||
|
from .from_telegram import (telegram_reply_to_matrix, telegram_to_matrix, init_tg)
|
||||||
|
from .. import context as c
|
||||||
|
|
||||||
|
|
||||||
|
def init(context: c.Context) -> None:
|
||||||
|
init_mx(context)
|
||||||
|
init_tg(context)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,93 +14,138 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING, Dict, Any
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import logging
|
||||||
|
|
||||||
from telethon import TelegramClient
|
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
|
||||||
from telethon.helpers import add_surrogate, del_surrogate
|
TypeMessageEntity)
|
||||||
from telethon.tl.types import MessageEntityItalic, TypeMessageEntity
|
|
||||||
|
|
||||||
from mautrix.types import MessageEventContent, RoomID
|
|
||||||
|
|
||||||
|
from ... import puppet as pu
|
||||||
|
from ...types import TelegramID, MatrixRoomID
|
||||||
from ...db import Message as DBMessage
|
from ...db import Message as DBMessage
|
||||||
from ...types import TelegramID
|
from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
|
||||||
from .parser import MatrixParser
|
trim_reply_fallback_text)
|
||||||
|
from .parser import ParsedMessage, parse_html
|
||||||
|
|
||||||
command_regex = re.compile(r"^!([A-Za-z0-9@]+)")
|
if TYPE_CHECKING:
|
||||||
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)")
|
from ...context import Context
|
||||||
|
|
||||||
MAX_LENGTH = 4096
|
log = logging.getLogger("mau.fmt.mx") # type: logging.Logger
|
||||||
CUTOFF_TEXT = " [message cut]"
|
should_bridge_plaintext_highlights = False # type: bool
|
||||||
CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT)
|
|
||||||
|
command_regex = re.compile(r"^!([A-Za-z0-9@]+)") # type: Pattern
|
||||||
|
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)") # type: Pattern
|
||||||
|
plain_mention_regex = None # type: Optional[Pattern]
|
||||||
|
|
||||||
|
|
||||||
|
def plain_mention_to_html(match: Match) -> str:
|
||||||
|
puppet = pu.Puppet.find_by_displayname(match.group(2))
|
||||||
|
if puppet:
|
||||||
|
return (f"{match.group(1)}"
|
||||||
|
f"<a href='https://matrix.to/#/{puppet.mxid}'>"
|
||||||
|
f"{puppet.displayname}"
|
||||||
|
"</a>")
|
||||||
|
return "".join(match.groups())
|
||||||
|
|
||||||
|
|
||||||
|
def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage:
|
||||||
|
if len(message) > 4096:
|
||||||
|
message = message[0:4082] + " [message cut]"
|
||||||
|
new_entities = []
|
||||||
|
for entity in entities:
|
||||||
|
if entity.offset > 4082:
|
||||||
|
continue
|
||||||
|
if entity.offset + entity.length > 4082:
|
||||||
|
entity.length = 4082 - entity.offset
|
||||||
|
new_entities.append(entity)
|
||||||
|
new_entities.append(MessageEntityItalic(4082, len(" [message cut]")))
|
||||||
|
entities = new_entities
|
||||||
|
return message, entities
|
||||||
|
|
||||||
|
|
||||||
class FormatError(Exception):
|
class FormatError(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def matrix_reply_to_telegram(
|
def matrix_to_telegram(html: str) -> ParsedMessage:
|
||||||
content: MessageEventContent, tg_space: TelegramID, room_id: RoomID | None = None
|
|
||||||
) -> TelegramID | None:
|
|
||||||
event_id = content.get_reply_to()
|
|
||||||
if not event_id:
|
|
||||||
return
|
|
||||||
content.trim_reply_fallback()
|
|
||||||
|
|
||||||
message = await DBMessage.get_by_mxid(event_id, room_id, tg_space)
|
|
||||||
if message:
|
|
||||||
return message.tgid
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def matrix_to_telegram(
|
|
||||||
client: TelegramClient, *, text: str | None = None, html: str | None = None
|
|
||||||
) -> tuple[str, list[TypeMessageEntity]]:
|
|
||||||
if html is not None:
|
|
||||||
return await _matrix_html_to_telegram(client, html)
|
|
||||||
elif text is not None:
|
|
||||||
return _matrix_text_to_telegram(text), []
|
|
||||||
else:
|
|
||||||
raise ValueError("text or html must be provided to convert formatting")
|
|
||||||
|
|
||||||
|
|
||||||
async def _matrix_html_to_telegram(
|
|
||||||
client: TelegramClient, html: str
|
|
||||||
) -> tuple[str, list[TypeMessageEntity]]:
|
|
||||||
try:
|
try:
|
||||||
html = command_regex.sub(r"<command>\1</command>", html)
|
html = command_regex.sub(r"<command>\1</command>", html)
|
||||||
html = html.replace("\t", " " * 4)
|
html = html.replace("\t", " " * 4)
|
||||||
html = not_command_regex.sub(r"\1", html)
|
html = not_command_regex.sub(r"\1", html)
|
||||||
|
if should_bridge_plaintext_highlights:
|
||||||
|
html = plain_mention_regex.sub(plain_mention_to_html, html)
|
||||||
|
|
||||||
parsed = await MatrixParser(client).parse(add_surrogate(html))
|
text, entities = parse_html(add_surrogates(html))
|
||||||
text = del_surrogate(parsed.text.strip())
|
text = remove_surrogates(text.strip())
|
||||||
text, entities = _cut_long_message(text, parsed.telegram_entities)
|
text, entities = cut_long_message(text, entities)
|
||||||
|
|
||||||
return text, entities
|
return text, entities
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise FormatError(f"Failed to convert Matrix format: {html}") from e
|
raise FormatError(f"Failed to convert Matrix format: {html}") from e
|
||||||
|
|
||||||
|
|
||||||
def _cut_long_message(
|
def matrix_reply_to_telegram(content: Dict[str, Any], tg_space: TelegramID,
|
||||||
message: str, entities: list[TypeMessageEntity]
|
room_id: Optional[MatrixRoomID] = None) -> Optional[TelegramID]:
|
||||||
) -> tuple[str, list[TypeMessageEntity]]:
|
try:
|
||||||
if len(message) > MAX_LENGTH:
|
reply = content.get("m.relates_to", {}).get("m.in_reply_to", {})
|
||||||
message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT
|
if not reply:
|
||||||
new_entities = []
|
return None
|
||||||
for entity in entities:
|
room_id = room_id or reply["room_id"]
|
||||||
if entity.offset > CUT_MAX_LENGTH:
|
event_id = reply["event_id"]
|
||||||
continue
|
|
||||||
if entity.offset + entity.length > CUT_MAX_LENGTH:
|
try:
|
||||||
entity.length = CUT_MAX_LENGTH - entity.offset
|
if content["format"] == "org.matrix.custom.html":
|
||||||
new_entities.append(entity)
|
content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"])
|
||||||
new_entities.append(MessageEntityItalic(CUT_MAX_LENGTH, len(CUTOFF_TEXT)))
|
except KeyError:
|
||||||
entities = new_entities
|
pass
|
||||||
return message, entities
|
content["body"] = trim_reply_fallback_text(content["body"])
|
||||||
|
|
||||||
|
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
|
||||||
|
if message:
|
||||||
|
return message.tgid
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _matrix_text_to_telegram(text: str) -> str:
|
def matrix_text_to_telegram(text: str) -> ParsedMessage:
|
||||||
text = command_regex.sub(r"/\1", text)
|
text = command_regex.sub(r"/\1", text)
|
||||||
text = text.replace("\t", " " * 4)
|
text = text.replace("\t", " " * 4)
|
||||||
text = not_command_regex.sub(r"\1", text)
|
text = not_command_regex.sub(r"\1", text)
|
||||||
return text
|
if should_bridge_plaintext_highlights:
|
||||||
|
entities, pmr_replacer = plain_mention_to_text()
|
||||||
|
text = plain_mention_regex.sub(pmr_replacer, text)
|
||||||
|
else:
|
||||||
|
entities = []
|
||||||
|
return text, entities
|
||||||
|
|
||||||
|
|
||||||
|
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], str]]:
|
||||||
|
entities = []
|
||||||
|
|
||||||
|
def replacer(match) -> str:
|
||||||
|
puppet = pu.Puppet.find_by_displayname(match.group(2))
|
||||||
|
if puppet:
|
||||||
|
offset = match.start()
|
||||||
|
length = match.end() - offset
|
||||||
|
if puppet.username:
|
||||||
|
entity = MessageEntityMention(offset, length)
|
||||||
|
text = f"@{puppet.username}"
|
||||||
|
else:
|
||||||
|
entity = MessageEntityMentionName(offset, length, user_id=puppet.tgid)
|
||||||
|
text = puppet.displayname
|
||||||
|
entities.append(entity)
|
||||||
|
return text
|
||||||
|
return "".join(match.groups())
|
||||||
|
|
||||||
|
return entities, replacer
|
||||||
|
|
||||||
|
|
||||||
|
def init_mx(context: "Context") -> None:
|
||||||
|
global plain_mention_regex, should_bridge_plaintext_highlights
|
||||||
|
config = context.config
|
||||||
|
dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)")
|
||||||
|
dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+")
|
||||||
|
plain_mention_regex = re.compile(f"^({dn_template})")
|
||||||
|
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] or False
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
|
# Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLNode(list):
|
||||||
|
def __init__(self, tag: str, attrs: List[Tuple[str, str]]):
|
||||||
|
super().__init__()
|
||||||
|
self.tag = tag # type: str
|
||||||
|
self.text = "" # type: str
|
||||||
|
self.tail = "" # type: str
|
||||||
|
self.attrib = dict(attrs) # type: Dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class NodeifyingParser(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.stack = [HTMLNode("html", [])] # type: List[HTMLNode]
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
node = HTMLNode(tag, attrs)
|
||||||
|
self.stack[-1].append(node)
|
||||||
|
self.stack.append(node)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
if tag == self.stack[-1].tag:
|
||||||
|
self.stack.pop()
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
if len(self.stack[-1]) > 0:
|
||||||
|
self.stack[-1][-1].tail += data
|
||||||
|
else:
|
||||||
|
self.stack[-1].text += data
|
||||||
|
|
||||||
|
def error(self, message):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def read_html(data: str) -> HTMLNode:
|
||||||
|
parser = NodeifyingParser()
|
||||||
|
parser.feed(data)
|
||||||
|
return parser.stack[0]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLNode(List['HTMLNode']):
|
||||||
|
tag: str
|
||||||
|
text: str
|
||||||
|
tail: str
|
||||||
|
attrib: Dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
def read_html(data: str) -> HTMLNode: ...
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,88 +14,236 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import List, Tuple, Pattern
|
||||||
|
import re
|
||||||
|
|
||||||
import logging
|
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
|
||||||
|
MessageEntityMentionName as MentionName, MessageEntityEmail as Email,
|
||||||
|
MessageEntityUrl as URL, MessageEntityTextUrl as TextURL,
|
||||||
|
MessageEntityBold as Bold, MessageEntityItalic as Italic,
|
||||||
|
MessageEntityCode as Code, MessageEntityPre as Pre,
|
||||||
|
TypeMessageEntity)
|
||||||
|
|
||||||
from telethon import TelegramClient
|
from ... import user as u, puppet as pu, portal as po
|
||||||
|
from ...types import MatrixUserID
|
||||||
|
from ..util import html_to_unicode
|
||||||
|
from .telegram_message import TelegramMessage, Entity, offset_length_multiply
|
||||||
|
|
||||||
from mautrix.types import RoomID, UserID
|
from .html_reader import HTMLNode, read_html
|
||||||
from mautrix.util.formatter import HTMLNode, MatrixParser as BaseMatrixParser, RecursionContext
|
|
||||||
from mautrix.util.logging import TraceLogger
|
|
||||||
|
|
||||||
from ... import portal as po, puppet as pu, user as u
|
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
|
||||||
from .telegram_message import TelegramEntityType, TelegramMessage
|
|
||||||
|
|
||||||
log: TraceLogger = logging.getLogger("mau.fmt.mx")
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixParser(BaseMatrixParser[TelegramMessage]):
|
def parse_html(input_html: str) -> ParsedMessage:
|
||||||
e = TelegramEntityType
|
return MatrixParser.parse(input_html)
|
||||||
fs = TelegramMessage
|
|
||||||
client: TelegramClient
|
|
||||||
|
|
||||||
def __init__(self, client: TelegramClient) -> None:
|
|
||||||
self.client = client
|
|
||||||
|
|
||||||
async def custom_node_to_fstring(
|
class RecursionContext:
|
||||||
self, node: HTMLNode, ctx: RecursionContext
|
def __init__(self, strip_linebreaks: bool = True, ul_depth: int = 0):
|
||||||
) -> TelegramMessage | None:
|
self.strip_linebreaks = strip_linebreaks # type: bool
|
||||||
if node.tag == "command":
|
self.ul_depth = ul_depth # type: int
|
||||||
msg = await self.tag_aware_parse_node(node, ctx)
|
self._inited = True # type: bool
|
||||||
return msg.prepend("/").format(TelegramEntityType.COMMAND)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def user_pill_to_fstring(self, msg: TelegramMessage, user_id: UserID) -> TelegramMessage:
|
def __setattr__(self, key, value):
|
||||||
user = await pu.Puppet.get_by_mxid(user_id) or await u.User.get_by_mxid(
|
if getattr(self, "_inited", False) is True:
|
||||||
user_id, create=False
|
raise TypeError("'RecursionContext' object is immutable")
|
||||||
)
|
super(RecursionContext, self).__setattr__(key, value)
|
||||||
if not user:
|
|
||||||
return msg
|
def enter_list(self) -> 'RecursionContext':
|
||||||
if user.tg_username:
|
return RecursionContext(strip_linebreaks=self.strip_linebreaks, ul_depth=self.ul_depth + 1)
|
||||||
return TelegramMessage(f"@{user.tg_username}").format(TelegramEntityType.MENTION)
|
|
||||||
elif user.tgid:
|
def enter_code_block(self) -> 'RecursionContext':
|
||||||
displayname = user.plain_displayname or msg.text
|
return RecursionContext(strip_linebreaks=False, ul_depth=self.ul_depth)
|
||||||
msg = TelegramMessage(displayname)
|
|
||||||
|
|
||||||
|
class MatrixParser:
|
||||||
|
mention_regex = re.compile("https://matrix.to/#/(@.+:.+)") # type: Pattern
|
||||||
|
room_regex = re.compile("https://matrix.to/#/(#.+:.+)") # type: Pattern
|
||||||
|
block_tags = ("p", "pre", "blockquote",
|
||||||
|
"ol", "ul", "li",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"div", "hr", "table") # type: Tuple[str, ...]
|
||||||
|
list_bullets = ("●", "○", "■", "‣") # type: Tuple[str, ...]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_bullet(cls, depth: int) -> str:
|
||||||
|
return cls.list_bullets[(depth - 1) % len(cls.list_bullets)] + " "
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||||
|
ordered = node.tag == "ol"
|
||||||
|
tagged_children = cls.node_to_tagged_tmessages(node, ctx)
|
||||||
|
counter = 1
|
||||||
|
indent_length = 0
|
||||||
|
if ordered:
|
||||||
try:
|
try:
|
||||||
input_entity = await self.client.get_input_entity(user.tgid)
|
counter = int(node.attrib.get("start", "1"))
|
||||||
except (ValueError, TypeError) as e:
|
except ValueError:
|
||||||
log.trace(f"Dropping mention of {user.tgid}: {e}")
|
counter = 1
|
||||||
|
|
||||||
|
longest_index = counter - 1 + len(tagged_children)
|
||||||
|
indent_length = len(str(longest_index))
|
||||||
|
indent = (indent_length + 4) * " "
|
||||||
|
children = [] # type: List[TelegramMessage]
|
||||||
|
for child, tag in tagged_children:
|
||||||
|
if tag != "li":
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ordered:
|
||||||
|
prefix = f"{counter}. "
|
||||||
|
counter += 1
|
||||||
else:
|
else:
|
||||||
msg = msg.format(TelegramEntityType.MENTION_NAME, user_id=input_entity)
|
prefix = cls.list_bullet(ctx.ul_depth)
|
||||||
return msg
|
child = child.prepend(prefix)
|
||||||
|
parts = child.split("\n")
|
||||||
|
parts = parts[:1] + [part.prepend(indent) for part in parts[1:]]
|
||||||
|
child = TelegramMessage.join(parts, "\n")
|
||||||
|
children.append(child)
|
||||||
|
return TelegramMessage.join(children, "\n")
|
||||||
|
|
||||||
async def url_to_fstring(self, msg: TelegramMessage, url: str) -> TelegramMessage:
|
@classmethod
|
||||||
if url == msg.text:
|
def blockquote_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||||
return msg.format(self.e.URL)
|
msg = cls.tag_aware_parse_node(node, ctx)
|
||||||
else:
|
|
||||||
return msg.format(self.e.INLINE_URL, url=url)
|
|
||||||
|
|
||||||
async def room_pill_to_fstring(self, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage:
|
|
||||||
username = po.Portal.get_username_from_mx_alias(room_id)
|
|
||||||
portal = await po.Portal.find_by_username(username)
|
|
||||||
if portal and portal.username:
|
|
||||||
return TelegramMessage(f"@{portal.username}").format(TelegramEntityType.MENTION)
|
|
||||||
|
|
||||||
async def header_to_fstring(self, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
|
||||||
children = await self.node_to_fstrings(node, ctx)
|
|
||||||
length = int(node.tag[1])
|
|
||||||
prefix = "#" * length + " "
|
|
||||||
return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD)
|
|
||||||
|
|
||||||
async def blockquote_to_fstring(
|
|
||||||
self, node: HTMLNode, ctx: RecursionContext
|
|
||||||
) -> TelegramMessage:
|
|
||||||
msg = await self.tag_aware_parse_node(node, ctx)
|
|
||||||
children = msg.trim().split("\n")
|
children = msg.trim().split("\n")
|
||||||
children = [child.prepend("> ") for child in children]
|
children = [child.prepend("> ") for child in children]
|
||||||
return TelegramMessage.join(children, "\n")
|
return TelegramMessage.join(children, "\n")
|
||||||
|
|
||||||
async def color_to_fstring(self, msg: TelegramMessage, color: str) -> TelegramMessage:
|
@classmethod
|
||||||
|
def header_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||||
|
children = cls.node_to_tmessages(node, ctx)
|
||||||
|
length = int(node.tag[1])
|
||||||
|
prefix = "#" * length + " "
|
||||||
|
return TelegramMessage.join(children, "").prepend(prefix).format(Bold)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def basic_format_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||||
|
msg = cls.tag_aware_parse_node(node, ctx)
|
||||||
|
if node.tag in ("b", "strong"):
|
||||||
|
msg.format(Bold)
|
||||||
|
elif node.tag in ("i", "em"):
|
||||||
|
msg.format(Italic)
|
||||||
|
elif node.tag == "command":
|
||||||
|
msg.format(Command)
|
||||||
|
elif node.tag in ("s", "strike", "del"):
|
||||||
|
msg.text = html_to_unicode(msg.text, "\u0336")
|
||||||
|
elif node.tag in ("u", "ins"):
|
||||||
|
msg.text = html_to_unicode(msg.text, "\u0332")
|
||||||
|
|
||||||
|
if node.tag in ("s", "strike", "del", "u", "ins"):
|
||||||
|
msg.entities = Entity.adjust(msg.entities, offset_length_multiply(2))
|
||||||
|
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
async def spoiler_to_fstring(self, msg: TelegramMessage, reason: str) -> TelegramMessage:
|
@classmethod
|
||||||
msg = msg.format(self.e.SPOILER)
|
def link_to_tstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||||
if reason:
|
msg = cls.tag_aware_parse_node(node, ctx)
|
||||||
msg = msg.prepend(f"{reason}: ")
|
href = node.attrib.get("href", "")
|
||||||
return msg
|
if not href:
|
||||||
|
return msg
|
||||||
|
|
||||||
|
if href.startswith("mailto:"):
|
||||||
|
return TelegramMessage(href[len("mailto:"):]).format(Email)
|
||||||
|
|
||||||
|
mention = cls.mention_regex.match(href)
|
||||||
|
if mention:
|
||||||
|
mxid = MatrixUserID(mention.group(1))
|
||||||
|
user = (pu.Puppet.get_by_mxid(mxid)
|
||||||
|
or u.User.get_by_mxid(mxid, create=False))
|
||||||
|
if not user:
|
||||||
|
return msg
|
||||||
|
if user.username:
|
||||||
|
return TelegramMessage(f"@{user.username}").format(Mention)
|
||||||
|
elif user.tgid:
|
||||||
|
displayname = user.plain_displayname or msg.text
|
||||||
|
return TelegramMessage(displayname).format(MentionName, user_id=user.tgid)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
room = cls.room_regex.match(href)
|
||||||
|
if room:
|
||||||
|
username = po.Portal.get_username_from_mx_alias(room.group(1))
|
||||||
|
portal = po.Portal.find_by_username(username)
|
||||||
|
if portal and portal.username:
|
||||||
|
return TelegramMessage(f"@{portal.username}").format(Mention)
|
||||||
|
|
||||||
|
return (msg.format(URL)
|
||||||
|
if msg.text == href
|
||||||
|
else msg.format(TextURL, url=href))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def node_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||||
|
if node.tag == "blockquote":
|
||||||
|
return cls.blockquote_to_tmessage(node, ctx)
|
||||||
|
elif node.tag == "ol":
|
||||||
|
return cls.list_to_tmessage(node, ctx)
|
||||||
|
elif node.tag == "ul":
|
||||||
|
return cls.list_to_tmessage(node, ctx.enter_list())
|
||||||
|
elif node.tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||||
|
return cls.header_to_tmessage(node, ctx)
|
||||||
|
elif node.tag == "br":
|
||||||
|
return TelegramMessage("\n")
|
||||||
|
elif node.tag in ("b", "strong", "i", "em", "s", "del", "u", "ins", "command"):
|
||||||
|
return cls.basic_format_to_tmessage(node, ctx)
|
||||||
|
elif node.tag == "a":
|
||||||
|
return cls.link_to_tstring(node, ctx)
|
||||||
|
elif node.tag == "p":
|
||||||
|
return cls.tag_aware_parse_node(node, ctx).append("\n")
|
||||||
|
elif node.tag == "pre":
|
||||||
|
lang = ""
|
||||||
|
try:
|
||||||
|
if node[0].tag == "code":
|
||||||
|
node = node[0]
|
||||||
|
lang = node.attrib["class"][len("language-"):]
|
||||||
|
except (IndexError, KeyError):
|
||||||
|
pass
|
||||||
|
return cls.parse_node(node, ctx.enter_code_block()).format(Pre, language=lang)
|
||||||
|
elif node.tag == "code":
|
||||||
|
return cls.parse_node(node, ctx.enter_code_block()).format(Code)
|
||||||
|
return cls.tag_aware_parse_node(node, ctx)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def text_to_tmessage(text: str, ctx: RecursionContext) -> TelegramMessage:
|
||||||
|
if ctx.strip_linebreaks:
|
||||||
|
text = text.replace("\n", "")
|
||||||
|
return TelegramMessage(text)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def node_to_tagged_tmessages(cls, node: HTMLNode, ctx: RecursionContext
|
||||||
|
) -> List[Tuple[TelegramMessage, str]]:
|
||||||
|
output = []
|
||||||
|
|
||||||
|
if node.text:
|
||||||
|
output.append((cls.text_to_tmessage(node.text, ctx), "text"))
|
||||||
|
for child in node:
|
||||||
|
output.append((cls.node_to_tmessage(child, ctx), child.tag))
|
||||||
|
if child.tail:
|
||||||
|
output.append((cls.text_to_tmessage(child.tail, ctx), "text"))
|
||||||
|
return output
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def node_to_tmessages(cls, node: HTMLNode, ctx: RecursionContext
|
||||||
|
) -> List[TelegramMessage]:
|
||||||
|
return [msg for (msg, tag) in cls.node_to_tagged_tmessages(node, ctx)]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tag_aware_parse_node(cls, node: HTMLNode, ctx: RecursionContext
|
||||||
|
) -> TelegramMessage:
|
||||||
|
msgs = cls.node_to_tagged_tmessages(node, ctx)
|
||||||
|
output = TelegramMessage()
|
||||||
|
prev_was_block = False
|
||||||
|
for msg, tag in msgs:
|
||||||
|
if tag in cls.block_tags:
|
||||||
|
msg = msg.append("\n")
|
||||||
|
if not prev_was_block:
|
||||||
|
msg = msg.prepend("\n")
|
||||||
|
prev_was_block = True
|
||||||
|
output = output.append(msg)
|
||||||
|
return output.trim()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse_node(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||||
|
return TelegramMessage.join(cls.node_to_tmessages(node, ctx))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, data: str) -> ParsedMessage:
|
||||||
|
msg = cls.node_to_tmessage(read_html(f"<body>{data}</body>"), RecursionContext())
|
||||||
|
return msg.text, msg.entities
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,110 +14,145 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Callable, List, Optional, Sequence, Type, Union
|
||||||
|
|
||||||
from typing import Any, Type
|
from telethon.tl.types import (MessageEntityMentionName as MentionName,
|
||||||
from enum import Enum
|
MessageEntityTextUrl as TextURL, MessageEntityPre as Pre,
|
||||||
|
TypeMessageEntity, InputMessageEntityMentionName as InputMentionName)
|
||||||
from telethon.tl.types import (
|
|
||||||
InputMessageEntityMentionName as InputMentionName,
|
|
||||||
MessageEntityBlockquote as Blockquote,
|
|
||||||
MessageEntityBold as Bold,
|
|
||||||
MessageEntityBotCommand as Command,
|
|
||||||
MessageEntityCode as Code,
|
|
||||||
MessageEntityEmail as Email,
|
|
||||||
MessageEntityItalic as Italic,
|
|
||||||
MessageEntityMention as Mention,
|
|
||||||
MessageEntityMentionName as MentionName,
|
|
||||||
MessageEntityPre as Pre,
|
|
||||||
MessageEntitySpoiler as Spoiler,
|
|
||||||
MessageEntityStrike as Strike,
|
|
||||||
MessageEntityTextUrl as TextURL,
|
|
||||||
MessageEntityUnderline as Underline,
|
|
||||||
MessageEntityUrl as URL,
|
|
||||||
TypeMessageEntity,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.util.formatter import EntityString, SemiAbstractEntity
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramEntityType(Enum):
|
class Entity:
|
||||||
"""EntityType is a Matrix formatting entity type."""
|
@staticmethod
|
||||||
|
def copy(entity: TypeMessageEntity) -> Optional[TypeMessageEntity]:
|
||||||
|
if not entity:
|
||||||
|
return None
|
||||||
|
kwargs = {
|
||||||
|
"offset": entity.offset,
|
||||||
|
"length": entity.length,
|
||||||
|
}
|
||||||
|
if isinstance(entity, Pre):
|
||||||
|
kwargs["language"] = entity.language
|
||||||
|
elif isinstance(entity, TextURL):
|
||||||
|
kwargs["url"] = entity.url
|
||||||
|
elif isinstance(entity, (MentionName, InputMentionName)):
|
||||||
|
kwargs["user_id"] = entity.user_id
|
||||||
|
return entity.__class__(**kwargs)
|
||||||
|
|
||||||
BOLD = Bold
|
@classmethod
|
||||||
ITALIC = Italic
|
def adjust(cls, entity: Union[TypeMessageEntity, List[TypeMessageEntity]],
|
||||||
STRIKETHROUGH = Strike
|
func: Callable[[TypeMessageEntity], None]
|
||||||
UNDERLINE = Underline
|
) -> Union[Optional[TypeMessageEntity], List[TypeMessageEntity]]:
|
||||||
URL = URL
|
if isinstance(entity, list):
|
||||||
INLINE_URL = TextURL
|
return [Entity.adjust(element, func) for element in entity if entity]
|
||||||
EMAIL = Email
|
elif not entity:
|
||||||
PREFORMATTED = Pre
|
return None
|
||||||
INLINE_CODE = Code
|
entity = cls.copy(entity)
|
||||||
BLOCKQUOTE = Blockquote
|
func(entity)
|
||||||
MENTION = Mention
|
if entity.offset < 0:
|
||||||
MENTION_NAME = InputMentionName
|
entity.length += entity.offset
|
||||||
COMMAND = Command
|
entity.offset = 0
|
||||||
SPOILER = Spoiler
|
return entity
|
||||||
|
|
||||||
USER_MENTION = 1
|
|
||||||
ROOM_MENTION = 2
|
|
||||||
HEADER = 3
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramEntity(SemiAbstractEntity):
|
def offset_diff(amount: int) -> Callable[[TypeMessageEntity], None]:
|
||||||
internal: TypeMessageEntity
|
def func(entity: TypeMessageEntity) -> None:
|
||||||
|
entity.offset += amount
|
||||||
|
|
||||||
def __init__(
|
return func
|
||||||
self,
|
|
||||||
type: TelegramEntityType | Type[TypeMessageEntity],
|
|
||||||
offset: int,
|
|
||||||
length: int,
|
|
||||||
extra_info: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
if isinstance(type, TelegramEntityType):
|
|
||||||
if isinstance(type.value, int):
|
|
||||||
raise ValueError(f"Can't create Entity with non-Telegram EntityType {type}")
|
|
||||||
type = type.value
|
|
||||||
self.internal = type(offset=offset, length=length, **extra_info)
|
|
||||||
|
|
||||||
def copy(self) -> TelegramEntity:
|
|
||||||
extra_info = {}
|
|
||||||
if isinstance(self.internal, Pre):
|
|
||||||
extra_info["language"] = self.internal.language
|
|
||||||
elif isinstance(self.internal, TextURL):
|
|
||||||
extra_info["url"] = self.internal.url
|
|
||||||
elif isinstance(self.internal, (MentionName, InputMentionName)):
|
|
||||||
extra_info["user_id"] = self.internal.user_id
|
|
||||||
return TelegramEntity(
|
|
||||||
type(self.internal),
|
|
||||||
offset=self.internal.offset,
|
|
||||||
length=self.internal.length,
|
|
||||||
extra_info=extra_info,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return str(self.internal)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def offset(self) -> int:
|
|
||||||
return self.internal.offset
|
|
||||||
|
|
||||||
@offset.setter
|
|
||||||
def offset(self, value: int) -> None:
|
|
||||||
self.internal.offset = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def length(self) -> int:
|
|
||||||
return self.internal.length
|
|
||||||
|
|
||||||
@length.setter
|
|
||||||
def length(self, value: int) -> None:
|
|
||||||
self.internal.length = value
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramMessage(EntityString[TelegramEntity, TelegramEntityType]):
|
def offset_length_multiply(amount: int) -> Callable[[TypeMessageEntity], None]:
|
||||||
entity_class = TelegramEntity
|
def func(entity: TypeMessageEntity) -> None:
|
||||||
|
entity.offset *= amount
|
||||||
|
entity.length *= amount
|
||||||
|
|
||||||
@property
|
return func
|
||||||
def telegram_entities(self) -> list[TypeMessageEntity]:
|
|
||||||
return [entity.internal for entity in self.entities]
|
|
||||||
|
class TelegramMessage:
|
||||||
|
def __init__(self, text: str = "", entities: Optional[List[TypeMessageEntity]] = None) -> None:
|
||||||
|
self.text = text # type: str
|
||||||
|
self.entities = entities or [] # type: List[TypeMessageEntity]
|
||||||
|
|
||||||
|
def offset_entities(self, offset: int) -> 'TelegramMessage':
|
||||||
|
def apply_offset(entity: TypeMessageEntity, inner_offset: int
|
||||||
|
) -> Optional[TypeMessageEntity]:
|
||||||
|
entity = Entity.copy(entity)
|
||||||
|
entity.offset += inner_offset
|
||||||
|
if entity.offset < 0:
|
||||||
|
entity.offset = 0
|
||||||
|
elif entity.offset > len(self.text):
|
||||||
|
return None
|
||||||
|
elif entity.offset + entity.length > len(self.text):
|
||||||
|
entity.length = len(self.text) - entity.offset
|
||||||
|
return entity
|
||||||
|
|
||||||
|
self.entities = [apply_offset(entity, offset) for entity in self.entities if entity]
|
||||||
|
self.entities = [x for x in self.entities if x is not None]
|
||||||
|
return self
|
||||||
|
|
||||||
|
def append(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
|
||||||
|
for msg in args:
|
||||||
|
if isinstance(msg, str):
|
||||||
|
msg = TelegramMessage(text=msg)
|
||||||
|
self.entities += Entity.adjust(msg.entities, offset_diff(len(self.text)))
|
||||||
|
self.text += msg.text
|
||||||
|
return self
|
||||||
|
|
||||||
|
def prepend(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
|
||||||
|
for msg in args:
|
||||||
|
if isinstance(msg, str):
|
||||||
|
msg = TelegramMessage(text=msg)
|
||||||
|
self.entities = msg.entities + Entity.adjust(self.entities, offset_diff(len(msg.text)))
|
||||||
|
self.text = msg.text + self.text
|
||||||
|
return self
|
||||||
|
|
||||||
|
def format(self, entity_type: Type[TypeMessageEntity], offset: int = None, length: int = None,
|
||||||
|
**kwargs) -> 'TelegramMessage':
|
||||||
|
self.entities.append(entity_type(offset=offset or 0,
|
||||||
|
length=length if length is not None else len(self.text),
|
||||||
|
**kwargs))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def concat(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
|
||||||
|
return TelegramMessage().append(self, *args)
|
||||||
|
|
||||||
|
def trim(self) -> 'TelegramMessage':
|
||||||
|
orig_len = len(self.text)
|
||||||
|
self.text = self.text.lstrip()
|
||||||
|
diff = orig_len - len(self.text)
|
||||||
|
self.text = self.text.rstrip()
|
||||||
|
self.offset_entities(-diff)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def split(self, separator, max_items: int = 0) -> List['TelegramMessage']:
|
||||||
|
text_parts = self.text.split(separator, max_items - 1)
|
||||||
|
output = [] # type: List[TelegramMessage]
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
for part in text_parts:
|
||||||
|
msg = TelegramMessage(part)
|
||||||
|
for entity in self.entities:
|
||||||
|
start_in_range = len(part) > entity.offset - offset >= 0
|
||||||
|
end_in_range = len(part) >= entity.offset - offset + entity.length > 0
|
||||||
|
if start_in_range and end_in_range:
|
||||||
|
msg.entities.append(Entity.adjust(entity, offset_diff(-offset)))
|
||||||
|
output.append(msg)
|
||||||
|
|
||||||
|
offset += len(part)
|
||||||
|
offset += len(separator)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def join(items: Sequence[Union[str, 'TelegramMessage']],
|
||||||
|
separator: str = " ") -> 'TelegramMessage':
|
||||||
|
main = TelegramMessage()
|
||||||
|
for msg in items:
|
||||||
|
if isinstance(msg, str):
|
||||||
|
msg = TelegramMessage(text=msg)
|
||||||
|
main.entities += Entity.adjust(msg.entities, offset_diff(len(main.text)))
|
||||||
|
main.text += msg.text + separator
|
||||||
|
if len(separator) > 0:
|
||||||
|
main.text = main.text[:-len(separator)]
|
||||||
|
return main
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,314 +14,293 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||||
|
|
||||||
from html import escape
|
from html import escape
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from telethon.errors import RPCError
|
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityUrl,
|
||||||
from telethon.helpers import add_surrogate, del_surrogate
|
MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold,
|
||||||
from telethon.tl.custom import Message
|
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
|
||||||
from telethon.tl.types import (
|
MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag,
|
||||||
MessageEntityBlockquote,
|
MessageEntityPhone, TypeMessageEntity, Message, PeerChannel,
|
||||||
MessageEntityBold,
|
MessageFwdHeader, PeerUser)
|
||||||
MessageEntityBotCommand,
|
|
||||||
MessageEntityCashtag,
|
|
||||||
MessageEntityCode,
|
|
||||||
MessageEntityEmail,
|
|
||||||
MessageEntityHashtag,
|
|
||||||
MessageEntityItalic,
|
|
||||||
MessageEntityMention,
|
|
||||||
MessageEntityMentionName,
|
|
||||||
MessageEntityPhone,
|
|
||||||
MessageEntityPre,
|
|
||||||
MessageEntitySpoiler,
|
|
||||||
MessageEntityStrike,
|
|
||||||
MessageEntityTextUrl,
|
|
||||||
MessageEntityUnderline,
|
|
||||||
MessageEntityUrl,
|
|
||||||
MessageFwdHeader,
|
|
||||||
PeerChannel,
|
|
||||||
PeerChat,
|
|
||||||
PeerUser,
|
|
||||||
SponsoredMessage,
|
|
||||||
TypeMessageEntity,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.appservice import IntentAPI
|
from mautrix_appservice import MatrixRequestError
|
||||||
from mautrix.types import (
|
from mautrix_appservice.intent_api import IntentAPI
|
||||||
EventType,
|
|
||||||
Format,
|
|
||||||
MessageType,
|
|
||||||
RelatesTo,
|
|
||||||
RelationType,
|
|
||||||
TextMessageEventContent,
|
|
||||||
)
|
|
||||||
|
|
||||||
from .. import abstract_user as au, portal as po, puppet as pu, user as u
|
from .. import user as u, puppet as pu, portal as po
|
||||||
from ..db import Message as DBMessage
|
|
||||||
from ..types import TelegramID
|
from ..types import TelegramID
|
||||||
|
from ..db import Message as DBMessage
|
||||||
|
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
|
||||||
|
trim_reply_fallback_text, unicode_to_html)
|
||||||
|
|
||||||
log: logging.Logger = logging.getLogger("mau.fmt.tg")
|
if TYPE_CHECKING:
|
||||||
|
from ..abstract_user import AbstractUser
|
||||||
|
from ..context import Context
|
||||||
|
|
||||||
|
try:
|
||||||
|
from lxml.html.diff import htmldiff
|
||||||
|
except ImportError:
|
||||||
|
htmldiff = None # type: ignore
|
||||||
|
|
||||||
|
log = logging.getLogger("mau.fmt.tg") # type: logging.Logger
|
||||||
|
should_highlight_edits = False # type: bool
|
||||||
|
|
||||||
|
|
||||||
async def telegram_reply_to_matrix(evt: Message, source: au.AbstractUser) -> RelatesTo | None:
|
def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Dict:
|
||||||
if evt.reply_to:
|
if evt.reply_to_msg_id:
|
||||||
space = (
|
space = (evt.to_id.channel_id
|
||||||
evt.peer_id.channel_id
|
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
|
||||||
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
|
else source.tgid)
|
||||||
else source.tgid
|
msg = DBMessage.get_by_tgid(evt.reply_to_msg_id, space)
|
||||||
)
|
|
||||||
msg = await DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
|
|
||||||
if msg:
|
if msg:
|
||||||
return RelatesTo(rel_type=RelationType.REPLY, event_id=msg.mxid)
|
return {
|
||||||
return None
|
"m.in_reply_to": {
|
||||||
|
"event_id": msg.mxid,
|
||||||
|
"room_id": msg.mx_room,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
async def _add_forward_header(
|
async def _add_forward_header(source, text: str, html: Optional[str],
|
||||||
source: au.AbstractUser, content: TextMessageEventContent, fwd_from: MessageFwdHeader
|
fwd_from: MessageFwdHeader) -> Tuple[str, str]:
|
||||||
) -> None:
|
if not html:
|
||||||
if not content.formatted_body or content.format != Format.HTML:
|
html = escape(text)
|
||||||
content.format = Format.HTML
|
|
||||||
content.formatted_body = escape(content.body)
|
|
||||||
fwd_from_html, fwd_from_text = None, None
|
fwd_from_html, fwd_from_text = None, None
|
||||||
if isinstance(fwd_from.from_id, PeerUser):
|
if fwd_from.from_id:
|
||||||
user = await u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_id))
|
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id))
|
||||||
if user:
|
if user:
|
||||||
fwd_from_text = user.displayname or user.mxid
|
fwd_from_text = user.displayname or user.mxid
|
||||||
fwd_from_html = (
|
fwd_from_html = f"<a href='https://matrix.to/#/{user.mxid}'>{fwd_from_text}</a>"
|
||||||
f"<a href='https://matrix.to/#/{user.mxid}'>{escape(fwd_from_text)}</a>"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not fwd_from_text:
|
if not fwd_from_text:
|
||||||
puppet = await pu.Puppet.get_by_peer(fwd_from.from_id, create=False)
|
puppet = pu.Puppet.get(TelegramID(fwd_from.from_id), create=False)
|
||||||
if puppet and puppet.displayname:
|
if puppet and puppet.displayname:
|
||||||
fwd_from_text = puppet.displayname or puppet.mxid
|
fwd_from_text = puppet.displayname or puppet.mxid
|
||||||
fwd_from_html = (
|
fwd_from_html = f"<a href='https://matrix.to/#/{puppet.mxid}'>{fwd_from_text}</a>"
|
||||||
f"<a href='https://matrix.to/#/{puppet.mxid}'>{escape(fwd_from_text)}</a>"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not fwd_from_text:
|
if not fwd_from_text:
|
||||||
try:
|
user = await source.client.get_entity(PeerUser(fwd_from.from_id))
|
||||||
user = await source.client.get_entity(fwd_from.from_id)
|
if user:
|
||||||
if user:
|
fwd_from_text = pu.Puppet.get_displayname(user, False)
|
||||||
fwd_from_text, _ = pu.Puppet.get_displayname(user, False)
|
fwd_from_html = f"<b>{fwd_from_text}</b>"
|
||||||
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
|
else:
|
||||||
except (ValueError, RPCError):
|
portal = po.Portal.get_by_tgid(TelegramID(fwd_from.channel_id))
|
||||||
fwd_from_text = fwd_from_html = "unknown user"
|
if portal:
|
||||||
elif isinstance(fwd_from.from_id, (PeerChannel, PeerChat)):
|
|
||||||
from_id = (
|
|
||||||
fwd_from.from_id.chat_id
|
|
||||||
if isinstance(fwd_from.from_id, PeerChat)
|
|
||||||
else fwd_from.from_id.channel_id
|
|
||||||
)
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(from_id))
|
|
||||||
if portal and portal.title:
|
|
||||||
fwd_from_text = portal.title
|
fwd_from_text = portal.title
|
||||||
if portal.alias:
|
if portal.alias:
|
||||||
fwd_from_html = (
|
fwd_from_html = f"<a href='https://matrix.to/#/{portal.alias}'>{fwd_from_text}</a>"
|
||||||
f"<a href='https://matrix.to/#/{portal.alias}'>{escape(fwd_from_text)}</a>"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
fwd_from_html = f"channel <b>{escape(fwd_from_text)}</b>"
|
fwd_from_html = f"<b>{fwd_from_text}</b>"
|
||||||
else:
|
else:
|
||||||
try:
|
channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
|
||||||
channel = await source.client.get_entity(fwd_from.from_id)
|
if channel:
|
||||||
if channel:
|
fwd_from_text = channel.title
|
||||||
fwd_from_text = f"channel {channel.title}"
|
fwd_from_html = f"<b>{fwd_from_text}</b>"
|
||||||
fwd_from_html = f"channel <b>{escape(channel.title)}</b>"
|
|
||||||
except (ValueError, RPCError):
|
|
||||||
fwd_from_text = fwd_from_html = "unknown channel"
|
|
||||||
elif fwd_from.from_name:
|
|
||||||
fwd_from_text = fwd_from.from_name
|
|
||||||
fwd_from_html = f"<b>{escape(fwd_from.from_name)}</b>"
|
|
||||||
else:
|
|
||||||
fwd_from_text = "unknown source"
|
|
||||||
fwd_from_html = f"unknown source"
|
|
||||||
|
|
||||||
content.body = "\n".join([f"> {line}" for line in content.body.split("\n")])
|
if not fwd_from_text:
|
||||||
content.body = f"Forwarded from {fwd_from_text}:\n{content.body}"
|
if fwd_from.from_id:
|
||||||
content.formatted_body = (
|
fwd_from_text = "Unknown user"
|
||||||
f"Forwarded message from {fwd_from_html}<br/>"
|
else:
|
||||||
f"<tg-forward><blockquote>{content.formatted_body}</blockquote></tg-forward>"
|
fwd_from_text = "Unknown source"
|
||||||
)
|
fwd_from_html = f"<b>{fwd_from_text}</b>"
|
||||||
|
|
||||||
|
text = "\n".join([f"> {line}" for line in text.split("\n")])
|
||||||
|
text = f"Forwarded from {fwd_from_text}:\n{text}"
|
||||||
|
html = (f"Forwarded message from {fwd_from_html}<br/>"
|
||||||
|
f"<tg-forward><blockquote>{html}</blockquote></tg-forward>")
|
||||||
|
return text, html
|
||||||
|
|
||||||
|
|
||||||
async def _add_reply_header(
|
def highlight_edits(new_html: str, old_html: str) -> str:
|
||||||
source: au.AbstractUser, content: TextMessageEventContent, evt: Message, main_intent: IntentAPI
|
# Don't include `Edit:` text in diff.
|
||||||
) -> None:
|
if old_html.startswith("<u>Edit:</u> "):
|
||||||
space = (
|
old_html = old_html[len("<u>Edit:</u> "):]
|
||||||
evt.peer_id.channel_id
|
|
||||||
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
|
|
||||||
else source.tgid
|
|
||||||
)
|
|
||||||
|
|
||||||
msg = await DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
|
# Generate diff with lxml
|
||||||
|
new_html = htmldiff(old_html, new_html)
|
||||||
|
|
||||||
|
# Replace <ins> with <u> since Riot doesn't allow <ins>
|
||||||
|
new_html = new_html.replace("<ins>", "<u>").replace("</ins>", "</u>")
|
||||||
|
# Remove <del>s since we just want to hide deletions.
|
||||||
|
new_html = re.sub("<del>.+?</del>", "", new_html)
|
||||||
|
return new_html
|
||||||
|
|
||||||
|
|
||||||
|
async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message,
|
||||||
|
relates_to: Dict, main_intent: IntentAPI, is_edit: bool
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
space = (evt.to_id.channel_id
|
||||||
|
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
|
||||||
|
else source.tgid)
|
||||||
|
|
||||||
|
msg = DBMessage.get_by_tgid(evt.reply_to_msg_id, space)
|
||||||
if not msg:
|
if not msg:
|
||||||
return
|
return text, html
|
||||||
|
|
||||||
content.relates_to = RelatesTo(rel_type=RelationType.REPLY, event_id=msg.mxid)
|
relates_to["m.in_reply_to"] = {
|
||||||
|
"event_id": msg.mxid,
|
||||||
|
"room_id": msg.mx_room,
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event = await main_intent.get_event(msg.mx_room, msg.mxid)
|
event = await main_intent.get_event(msg.mx_room, msg.mxid)
|
||||||
if event.type == EventType.ROOM_ENCRYPTED and source.bridge.matrix.e2ee:
|
|
||||||
event = await source.bridge.matrix.e2ee.decrypt(event)
|
content = event["content"]
|
||||||
if isinstance(event.content, TextMessageEventContent):
|
r_sender = event["sender"]
|
||||||
event.content.trim_reply_fallback()
|
|
||||||
puppet = await pu.Puppet.get_by_mxid(event.sender, create=False)
|
r_text_body = trim_reply_fallback_text(content["body"])
|
||||||
content.set_reply(event, displayname=puppet.displayname if puppet else event.sender)
|
r_html_body = trim_reply_fallback_html(content["formatted_body"]
|
||||||
except Exception:
|
if "formatted_body" in content
|
||||||
log.exception("Failed to get event to add reply fallback")
|
else escape(content["body"]))
|
||||||
|
|
||||||
|
puppet = pu.Puppet.get_by_mxid(r_sender, create=False)
|
||||||
|
r_displayname = puppet.displayname if puppet else r_sender
|
||||||
|
r_sender_link = f"<a href='https://matrix.to/#/{r_sender}'>{r_displayname}</a>"
|
||||||
|
|
||||||
|
if is_edit and should_highlight_edits:
|
||||||
|
html = highlight_edits(html or escape(text), r_html_body)
|
||||||
|
except (ValueError, KeyError, MatrixRequestError):
|
||||||
|
r_sender_link = "unknown user"
|
||||||
|
r_displayname = "unknown user"
|
||||||
|
r_text_body = "Failed to fetch message"
|
||||||
|
r_html_body = "<em>Failed to fetch message</em>"
|
||||||
|
|
||||||
|
if is_edit:
|
||||||
|
html = f"<u>Edit:</u> {html or escape(text)}"
|
||||||
|
text = f"Edit: {text}"
|
||||||
|
|
||||||
|
r_keyword = "In reply to" if not is_edit else "Edit to"
|
||||||
|
r_msg_link = f"<a href='https://matrix.to/#/{msg.mx_room}/{msg.mxid}'>{r_keyword}</a>"
|
||||||
|
html = (
|
||||||
|
f"<mx-reply><blockquote>{r_msg_link} {r_sender_link}\n{r_html_body}</blockquote></mx-reply>"
|
||||||
|
+ (html or escape(text)))
|
||||||
|
|
||||||
|
lines = r_text_body.strip().split("\n")
|
||||||
|
text_with_quote = f"> <{r_displayname}> {lines.pop(0)}"
|
||||||
|
for line in lines:
|
||||||
|
if line:
|
||||||
|
text_with_quote += f"\n> {line}"
|
||||||
|
text_with_quote += "\n\n"
|
||||||
|
text_with_quote += text
|
||||||
|
return text_with_quote, html
|
||||||
|
|
||||||
|
|
||||||
async def telegram_to_matrix(
|
async def telegram_to_matrix(evt: Message, source: "AbstractUser",
|
||||||
evt: Message | SponsoredMessage,
|
main_intent: Optional[IntentAPI] = None,
|
||||||
source: au.AbstractUser,
|
is_edit: bool = False, prefix_text: Optional[str] = None,
|
||||||
main_intent: IntentAPI | None = None,
|
prefix_html: Optional[str] = None, override_text: str = None,
|
||||||
prefix_text: str | None = None,
|
override_entities: List[TypeMessageEntity] = None,
|
||||||
prefix_html: str | None = None,
|
no_reply_fallback: bool = False) -> Tuple[str, str, Dict]:
|
||||||
override_text: str = None,
|
text = add_surrogates(override_text or evt.message)
|
||||||
override_entities: list[TypeMessageEntity] = None,
|
|
||||||
no_reply_fallback: bool = False,
|
|
||||||
require_html: bool = False,
|
|
||||||
) -> TextMessageEventContent:
|
|
||||||
content = TextMessageEventContent(
|
|
||||||
msgtype=MessageType.TEXT,
|
|
||||||
body=add_surrogate(override_text or evt.message),
|
|
||||||
)
|
|
||||||
entities = override_entities or evt.entities
|
entities = override_entities or evt.entities
|
||||||
if entities:
|
html = _telegram_entities_to_matrix_catch(text, entities) if entities else None
|
||||||
content.format = Format.HTML
|
relates_to = {} # type: Dict
|
||||||
html = await _telegram_entities_to_matrix_catch(add_surrogate(content.body), entities)
|
|
||||||
content.formatted_body = del_surrogate(html).replace("\n", "<br/>")
|
|
||||||
|
|
||||||
def force_html():
|
|
||||||
if not content.formatted_body:
|
|
||||||
content.format = Format.HTML
|
|
||||||
content.formatted_body = escape(content.body).replace("\n", "<br/>")
|
|
||||||
|
|
||||||
if require_html:
|
|
||||||
force_html()
|
|
||||||
|
|
||||||
if prefix_html:
|
if prefix_html:
|
||||||
force_html()
|
html = prefix_html + (html or escape(text))
|
||||||
content.formatted_body = prefix_html + content.formatted_body
|
|
||||||
if prefix_text:
|
if prefix_text:
|
||||||
content.body = prefix_text + content.body
|
text = prefix_text + text
|
||||||
|
|
||||||
if getattr(evt, "fwd_from", None):
|
if evt.fwd_from:
|
||||||
await _add_forward_header(source, content, evt.fwd_from)
|
text, html = await _add_forward_header(source, text, html, evt.fwd_from)
|
||||||
|
|
||||||
if getattr(evt, "reply_to", None) and not no_reply_fallback:
|
if evt.reply_to_msg_id and not no_reply_fallback:
|
||||||
await _add_reply_header(source, content, evt, main_intent)
|
text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent,
|
||||||
|
is_edit)
|
||||||
|
|
||||||
if isinstance(evt, Message) and evt.post and evt.post_author:
|
if isinstance(evt, Message) and evt.post and evt.post_author:
|
||||||
force_html()
|
if not html:
|
||||||
content.body += f"\n- {evt.post_author}"
|
html = escape(text)
|
||||||
content.formatted_body += f"<br/><i>- <u>{evt.post_author}</u></i>"
|
text += f"\n- {evt.post_author}"
|
||||||
|
html += f"<br/><i>- <u>{evt.post_author}</u></i>"
|
||||||
|
|
||||||
return content
|
html = unicode_to_html(text, html, "\u0336", "del")
|
||||||
|
html = unicode_to_html(text, html, "\u0332", "u")
|
||||||
|
|
||||||
|
if html:
|
||||||
|
html = html.replace("\n", "<br/>")
|
||||||
|
|
||||||
|
return remove_surrogates(text), remove_surrogates(html), relates_to
|
||||||
|
|
||||||
|
|
||||||
async def _telegram_entities_to_matrix_catch(text: str, entities: list[TypeMessageEntity]) -> str:
|
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
|
||||||
try:
|
try:
|
||||||
return await _telegram_entities_to_matrix(text, entities)
|
return _telegram_entities_to_matrix(text, entities)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception(
|
log.exception("Failed to convert Telegram format:\n"
|
||||||
"Failed to convert Telegram format:\nmessage=%s\nentities=%s", text, entities
|
"message=%s\n"
|
||||||
)
|
"entities=%s",
|
||||||
|
text, entities)
|
||||||
return "[failed conversion in _telegram_entities_to_matrix]"
|
return "[failed conversion in _telegram_entities_to_matrix]"
|
||||||
|
|
||||||
|
|
||||||
async def _telegram_entities_to_matrix(
|
def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity]) -> str:
|
||||||
text: str, entities: list[TypeMessageEntity], offset: int = 0, length: int = None
|
|
||||||
) -> str:
|
|
||||||
if not entities:
|
if not entities:
|
||||||
return escape(text)
|
return text
|
||||||
if length is None:
|
|
||||||
length = len(text)
|
|
||||||
html = []
|
html = []
|
||||||
last_offset = 0
|
last_offset = 0
|
||||||
for i, entity in enumerate(entities):
|
for entity in entities:
|
||||||
if entity.offset > offset + length:
|
if entity.offset > last_offset:
|
||||||
break
|
html.append(escape(text[last_offset:entity.offset]))
|
||||||
relative_offset = entity.offset - offset
|
elif entity.offset < last_offset:
|
||||||
if relative_offset > last_offset:
|
|
||||||
html.append(escape(text[last_offset:relative_offset]))
|
|
||||||
elif relative_offset < last_offset:
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
skip_entity = False
|
skip_entity = False
|
||||||
entity_text = await _telegram_entities_to_matrix(
|
entity_text = escape(text[entity.offset:entity.offset + entity.length])
|
||||||
text=text[relative_offset : relative_offset + entity.length],
|
|
||||||
entities=entities[i + 1 :],
|
|
||||||
offset=entity.offset,
|
|
||||||
length=entity.length,
|
|
||||||
)
|
|
||||||
entity_type = type(entity)
|
entity_type = type(entity)
|
||||||
|
|
||||||
if entity_type == MessageEntityBold:
|
if entity_type == MessageEntityBold:
|
||||||
html.append(f"<strong>{entity_text}</strong>")
|
html.append(f"<strong>{entity_text}</strong>")
|
||||||
elif entity_type == MessageEntityItalic:
|
elif entity_type == MessageEntityItalic:
|
||||||
html.append(f"<em>{entity_text}</em>")
|
html.append(f"<em>{entity_text}</em>")
|
||||||
elif entity_type == MessageEntityUnderline:
|
|
||||||
html.append(f"<u>{entity_text}</u>")
|
|
||||||
elif entity_type == MessageEntityStrike:
|
|
||||||
html.append(f"<del>{entity_text}</del>")
|
|
||||||
elif entity_type == MessageEntityBlockquote:
|
|
||||||
html.append(f"<blockquote>{entity_text}</blockquote>")
|
|
||||||
elif entity_type == MessageEntityCode:
|
elif entity_type == MessageEntityCode:
|
||||||
html.append(
|
html.append(("<pre><code>{entity_text}</code></pre>"
|
||||||
f"<pre><code>{entity_text}</code></pre>"
|
if "\n" in entity_text
|
||||||
if "\n" in entity_text
|
else "<code>{entity_text}</code>").format(entity_text=entity_text))
|
||||||
else f"<code>{entity_text}</code>"
|
|
||||||
)
|
|
||||||
elif entity_type == MessageEntityPre:
|
elif entity_type == MessageEntityPre:
|
||||||
skip_entity = _parse_pre(html, entity_text, entity.language)
|
skip_entity = _parse_pre(html, entity_text, entity.language)
|
||||||
elif entity_type == MessageEntityMention:
|
elif entity_type == MessageEntityMention:
|
||||||
skip_entity = await _parse_mention(html, entity_text)
|
skip_entity = _parse_mention(html, entity_text)
|
||||||
elif entity_type == MessageEntityMentionName:
|
elif entity_type == MessageEntityMentionName:
|
||||||
skip_entity = await _parse_name_mention(html, entity_text, TelegramID(entity.user_id))
|
skip_entity = _parse_name_mention(html, entity_text, TelegramID(entity.user_id))
|
||||||
elif entity_type == MessageEntityEmail:
|
elif entity_type == MessageEntityEmail:
|
||||||
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
|
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
|
||||||
elif entity_type in (MessageEntityTextUrl, MessageEntityUrl):
|
elif entity_type in (MessageEntityTextUrl, MessageEntityUrl):
|
||||||
skip_entity = await _parse_url(
|
skip_entity = _parse_url(html, entity_text,
|
||||||
html, entity_text, entity.url if entity_type == MessageEntityTextUrl else None
|
entity.url if entity_type == MessageEntityTextUrl else None)
|
||||||
)
|
elif entity_type == MessageEntityBotCommand:
|
||||||
elif entity_type in (
|
html.append(f"<font color='blue'>!{entity_text[1:]}</font>")
|
||||||
MessageEntityBotCommand,
|
elif entity_type in (MessageEntityHashtag, MessageEntityCashtag, MessageEntityPhone):
|
||||||
MessageEntityHashtag,
|
html.append(f"<font color='blue'>{entity_text}</font>")
|
||||||
MessageEntityCashtag,
|
|
||||||
MessageEntityPhone,
|
|
||||||
):
|
|
||||||
html.append(f"<font color='#3771bb'>{entity_text}</font>")
|
|
||||||
elif entity_type == MessageEntitySpoiler:
|
|
||||||
html.append(f"<span data-mx-spoiler>{entity_text}</span>")
|
|
||||||
else:
|
else:
|
||||||
skip_entity = True
|
skip_entity = True
|
||||||
last_offset = relative_offset + (0 if skip_entity else entity.length)
|
last_offset = entity.offset + (0 if skip_entity else entity.length)
|
||||||
html.append(escape(text[last_offset:]))
|
html.append(text[last_offset:])
|
||||||
|
|
||||||
return "".join(html)
|
return "".join(html)
|
||||||
|
|
||||||
|
|
||||||
def _parse_pre(html: list[str], entity_text: str, language: str) -> bool:
|
def _parse_pre(html: List[str], entity_text: str, language: str) -> bool:
|
||||||
if language:
|
if language:
|
||||||
html.append(f"<pre><code class='language-{language}'>{entity_text}</code></pre>")
|
html.append("<pre>"
|
||||||
|
f"<code class='language-{language}'>{entity_text}</code>"
|
||||||
|
"</pre>")
|
||||||
else:
|
else:
|
||||||
html.append(f"<pre><code>{entity_text}</code></pre>")
|
html.append(f"<pre><code>{entity_text}</code></pre>")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _parse_mention(html: list[str], entity_text: str) -> bool:
|
def _parse_mention(html: List[str], entity_text: str) -> bool:
|
||||||
username = entity_text[1:]
|
username = entity_text[1:]
|
||||||
|
|
||||||
user = await u.User.find_by_username(username) or await pu.Puppet.find_by_username(username)
|
user = u.User.find_by_username(username) or pu.Puppet.find_by_username(username)
|
||||||
if user:
|
if user:
|
||||||
mxid = user.mxid
|
mxid = user.mxid
|
||||||
else:
|
else:
|
||||||
portal = await po.Portal.find_by_username(username)
|
portal = po.Portal.find_by_username(username)
|
||||||
mxid = portal.alias or portal.mxid if portal else None
|
mxid = portal.alias or portal.mxid if portal else None
|
||||||
|
|
||||||
if mxid:
|
if mxid:
|
||||||
@@ -330,12 +310,12 @@ async def _parse_mention(html: list[str], entity_text: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _parse_name_mention(html: list[str], entity_text: str, user_id: TelegramID) -> bool:
|
def _parse_name_mention(html: List[str], entity_text: str, user_id: TelegramID) -> bool:
|
||||||
user = await u.User.get_by_tgid(user_id)
|
user = u.User.get_by_tgid(user_id)
|
||||||
if user:
|
if user:
|
||||||
mxid = user.mxid
|
mxid = user.mxid
|
||||||
else:
|
else:
|
||||||
puppet = await pu.Puppet.get_by_tgid(user_id, create=False)
|
puppet = pu.Puppet.get(user_id, create=False)
|
||||||
mxid = puppet.mxid if puppet else None
|
mxid = puppet.mxid if puppet else None
|
||||||
if mxid:
|
if mxid:
|
||||||
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
|
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
|
||||||
@@ -345,11 +325,10 @@ async def _parse_name_mention(html: list[str], entity_text: str, user_id: Telegr
|
|||||||
|
|
||||||
|
|
||||||
message_link_regex = re.compile(
|
message_link_regex = re.compile(
|
||||||
r"https?://t(?:elegram)?\.(?:me|dog)/([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})"
|
r"https?://t(?:elegram)?\.(?:me|dog)/([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})")
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _parse_url(html: list[str], entity_text: str, url: str) -> bool:
|
def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
|
||||||
url = escape(url) if url else entity_text
|
url = escape(url) if url else entity_text
|
||||||
if not url.startswith(("https://", "http://", "ftp://", "magnet://")):
|
if not url.startswith(("https://", "http://", "ftp://", "magnet://")):
|
||||||
url = "http://" + url
|
url = "http://" + url
|
||||||
@@ -359,11 +338,16 @@ async def _parse_url(html: list[str], entity_text: str, url: str) -> bool:
|
|||||||
group, msgid_str = message_link_match.groups()
|
group, msgid_str = message_link_match.groups()
|
||||||
msgid = int(msgid_str)
|
msgid = int(msgid_str)
|
||||||
|
|
||||||
portal = await po.Portal.find_by_username(group)
|
portal = po.Portal.find_by_username(group)
|
||||||
if portal:
|
if portal:
|
||||||
message = await DBMessage.get_one_by_tgid(TelegramID(msgid), portal.tgid)
|
message = DBMessage.get_by_tgid(TelegramID(msgid), portal.tgid)
|
||||||
if message:
|
if message:
|
||||||
url = f"https://matrix.to/#/{portal.mxid}/{message.mxid}"
|
url = f"https://matrix.to/#/{portal.mxid}/{message.mxid}"
|
||||||
|
|
||||||
html.append(f"<a href='{url}'>{entity_text}</a>")
|
html.append(f"<a href='{url}'>{entity_text}</a>")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def init_tg(context: "Context") -> None:
|
||||||
|
global should_highlight_edits
|
||||||
|
should_highlight_edits = htmldiff and context.config["bridge.highlight_edits"]
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
|
# Copyright (C) 2019 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 <https://www.gnu.org/licenses/>.
|
||||||
|
from typing import Optional, Pattern
|
||||||
|
from html import escape
|
||||||
|
import struct
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def unicode_to_html(text: str, html: str, ctrl: str, tag: str) -> str:
|
||||||
|
if ctrl not in text:
|
||||||
|
return html
|
||||||
|
if not html:
|
||||||
|
html = escape(text)
|
||||||
|
tag_start = f"<{tag}>"
|
||||||
|
tag_end = f"</{tag}>"
|
||||||
|
characters = html.split(ctrl)
|
||||||
|
html = ""
|
||||||
|
in_tag = False
|
||||||
|
for char in characters:
|
||||||
|
if not in_tag:
|
||||||
|
if len(char) > 1:
|
||||||
|
html += char[0:-1]
|
||||||
|
char = char[-1]
|
||||||
|
html += tag_start
|
||||||
|
in_tag = True
|
||||||
|
html += char
|
||||||
|
else:
|
||||||
|
if len(char) > 1:
|
||||||
|
html += tag_end
|
||||||
|
in_tag = False
|
||||||
|
html += char
|
||||||
|
if in_tag:
|
||||||
|
html += tag_end
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
def html_to_unicode(text: str, ctrl: str) -> str:
|
||||||
|
return ctrl.join(text) + ctrl
|
||||||
|
|
||||||
|
|
||||||
|
# add_surrogates and remove_surrogates are unicode surrogate utility functions from Telethon.
|
||||||
|
# Licensed under the MIT license.
|
||||||
|
# https://github.com/LonamiWebs/Telethon/blob/7cce7aa3e4c6c7019a55530391b1761d33e5a04e/telethon/helpers.py
|
||||||
|
def add_surrogates(text: Optional[str]) -> Optional[str]:
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
return "".join("".join(chr(y) for y in struct.unpack("<HH", x.encode("utf-16-le")))
|
||||||
|
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_surrogates(text: Optional[str]) -> Optional[str]:
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
return text.encode("utf-16", "surrogatepass").decode("utf-16")
|
||||||
|
|
||||||
|
|
||||||
|
# trim_reply_fallback_text, html_reply_fallback_regex and trim_reply_fallback_html are Matrix
|
||||||
|
# reply fallback utility functions.
|
||||||
|
# You may copy and use them under any OSI-approved license.
|
||||||
|
def trim_reply_fallback_text(text: str) -> str:
|
||||||
|
if not text.startswith("> ") or "\n" not in text:
|
||||||
|
return text
|
||||||
|
lines = text.split("\n")
|
||||||
|
while len(lines) > 0 and lines[0].startswith("> "):
|
||||||
|
lines.pop(0)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
html_reply_fallback_regex = re.compile("^<mx-reply>"
|
||||||
|
r"[\s\S]+?"
|
||||||
|
"</mx-reply>") # type: Pattern
|
||||||
|
|
||||||
|
|
||||||
|
def trim_reply_fallback_html(html: str) -> str:
|
||||||
|
return html_reply_fallback_regex.sub("", html)
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from . import __version__
|
|
||||||
|
|
||||||
cmd_env = {
|
|
||||||
"PATH": os.environ["PATH"],
|
|
||||||
"HOME": os.environ["HOME"],
|
|
||||||
"LANG": "C",
|
|
||||||
"LC_ALL": "C",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def run(cmd):
|
|
||||||
return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env)
|
|
||||||
|
|
||||||
|
|
||||||
if os.path.exists(".git") and shutil.which("git"):
|
|
||||||
try:
|
|
||||||
git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii")
|
|
||||||
git_revision_url = f"https://github.com/mautrix/telegram/commit/{git_revision}"
|
|
||||||
git_revision = git_revision[:8]
|
|
||||||
except (subprocess.SubprocessError, OSError):
|
|
||||||
git_revision = "unknown"
|
|
||||||
git_revision_url = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii")
|
|
||||||
except (subprocess.SubprocessError, OSError):
|
|
||||||
git_tag = None
|
|
||||||
else:
|
|
||||||
git_revision = "unknown"
|
|
||||||
git_revision_url = None
|
|
||||||
git_tag = None
|
|
||||||
|
|
||||||
git_tag_url = f"https://github.com/mautrix/telegram/releases/tag/{git_tag}" if git_tag else None
|
|
||||||
|
|
||||||
if git_tag and __version__ == git_tag[1:].replace("-", ""):
|
|
||||||
version = __version__
|
|
||||||
linkified_version = f"[{version}]({git_tag_url})"
|
|
||||||
else:
|
|
||||||
if not __version__.endswith("+dev"):
|
|
||||||
__version__ += "+dev"
|
|
||||||
version = f"{__version__}.{git_revision}"
|
|
||||||
if git_revision_url:
|
|
||||||
linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})"
|
|
||||||
else:
|
|
||||||
linkified_version = version
|
|
||||||
+314
-327
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,433 +14,419 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Dict, List, Match, Optional, Set, Tuple, TYPE_CHECKING
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from mautrix_appservice import MatrixRequestError, IntentError
|
||||||
|
|
||||||
from mautrix.bridge import BaseMatrixHandler
|
from .types import MatrixEvent, MatrixEventID, MatrixRoomID, MatrixUserID
|
||||||
from mautrix.errors import MatrixError
|
from . import user as u, portal as po, puppet as pu, commands as com
|
||||||
from mautrix.types import (
|
|
||||||
Event,
|
|
||||||
EventID,
|
|
||||||
EventType,
|
|
||||||
MemberStateEventContent,
|
|
||||||
MessageType,
|
|
||||||
PresenceEvent,
|
|
||||||
PresenceState,
|
|
||||||
ReactionEvent,
|
|
||||||
ReceiptEvent,
|
|
||||||
RedactionEvent,
|
|
||||||
RoomAvatarStateEventContent as AvatarContent,
|
|
||||||
RoomID,
|
|
||||||
RoomNameStateEventContent as NameContent,
|
|
||||||
RoomTopicStateEventContent as TopicContent,
|
|
||||||
SingleReceiptEventContent,
|
|
||||||
StateEvent,
|
|
||||||
TextMessageEventContent,
|
|
||||||
TypingEvent,
|
|
||||||
UserID,
|
|
||||||
)
|
|
||||||
|
|
||||||
from . import commands as com, portal as po, puppet as pu, user as u
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .__main__ import TelegramBridge
|
from .context import Context
|
||||||
|
|
||||||
|
|
||||||
class MatrixHandler(BaseMatrixHandler):
|
class MatrixHandler:
|
||||||
commands: com.CommandProcessor
|
log = logging.getLogger("mau.mx") # type: logging.Logger
|
||||||
_previously_typing: dict[RoomID, set[UserID]]
|
|
||||||
|
|
||||||
def __init__(self, bridge: "TelegramBridge") -> None:
|
def __init__(self, context: 'Context') -> None:
|
||||||
prefix, suffix = bridge.config["bridge.username_template"].format(userid=":").split(":")
|
self.az, self.config, _, self.tgbot = context.core
|
||||||
homeserver = bridge.config["homeserver.domain"]
|
self.commands = com.CommandProcessor(context) # type: com.CommandProcessor
|
||||||
self.user_id_prefix = f"@{prefix}"
|
self.previously_typing = [] # type: List[MatrixUserID]
|
||||||
self.user_id_suffix = f"{suffix}:{homeserver}"
|
|
||||||
|
|
||||||
super().__init__(command_processor=com.CommandProcessor(bridge), bridge=bridge)
|
self.az.matrix_event_handler(self.handle_event)
|
||||||
|
|
||||||
self._previously_typing = {}
|
async def init_as_bot(self) -> None:
|
||||||
|
displayname = self.config["appservice.bot_displayname"]
|
||||||
|
if displayname:
|
||||||
|
try:
|
||||||
|
await self.az.intent.set_display_name(
|
||||||
|
displayname if displayname != "remove" else "")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.log.exception("TimeoutError when trying to set displayname")
|
||||||
|
|
||||||
async def handle_puppet_invite(
|
avatar = self.config["appservice.bot_avatar"]
|
||||||
self, room_id: RoomID, puppet: pu.Puppet, inviter: u.User, event_id: EventID
|
if avatar:
|
||||||
) -> None:
|
try:
|
||||||
|
await self.az.intent.set_avatar(avatar if avatar != "remove" else "")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.log.exception("TimeoutError when trying to set avatar")
|
||||||
|
|
||||||
|
async def handle_puppet_invite(self, room_id: MatrixRoomID, puppet: pu.Puppet, inviter: u.User
|
||||||
|
) -> None:
|
||||||
intent = puppet.default_mxid_intent
|
intent = puppet.default_mxid_intent
|
||||||
self.log.debug(f"{inviter.mxid} invited puppet for {puppet.tgid} to {room_id}")
|
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room_id}")
|
||||||
if puppet.is_channel:
|
|
||||||
self.log.debug(f"Rejecting invite for {puppet.tgid} to {room_id}: puppet is a channel")
|
|
||||||
await intent.leave_room(room_id, reason="Channels can't be invited to chats")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not await inviter.is_logged_in():
|
if not await inviter.is_logged_in():
|
||||||
self.log.debug(f"Rejecting invite for {puppet.tgid} to {room_id}: user not logged in")
|
await intent.error_and_leave(
|
||||||
await intent.leave_room(
|
room_id, text="Please log in before inviting Telegram puppets.")
|
||||||
room_id,
|
|
||||||
reason="Only users who are logged into the bridge can invite Telegram ghosts.",
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if portal:
|
if portal:
|
||||||
if portal.peer_type == "user":
|
if portal.peer_type == "user":
|
||||||
await intent.error_and_leave(
|
await intent.error_and_leave(
|
||||||
room_id, text="You can not invite additional users to private chats."
|
room_id, text="You can not invite additional users to private chats.")
|
||||||
)
|
|
||||||
return
|
return
|
||||||
await portal.invite_telegram(inviter, puppet)
|
await portal.invite_telegram(inviter, puppet)
|
||||||
await intent.join_room(room_id)
|
await intent.join_room(room_id)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
members = await intent.get_room_members(room_id)
|
members = await self.az.intent.get_room_members(room_id)
|
||||||
except MatrixError:
|
except MatrixRequestError:
|
||||||
self.log.exception(f"Failed to get members after joining {room_id} as {intent.mxid}")
|
members = []
|
||||||
return
|
|
||||||
if self.az.bot_mxid not in members:
|
if self.az.bot_mxid not in members:
|
||||||
if len(members) > 2:
|
if len(members) > 1:
|
||||||
await intent.error_and_leave(
|
await intent.error_and_leave(room_id, text=None, html=(
|
||||||
room_id,
|
f"Please invite "
|
||||||
text=None,
|
f"<a href='https://matrix.to/#/{self.az.bot_mxid}'>the bridge bot</a> "
|
||||||
html=(
|
f"first if you want to create a Telegram chat."))
|
||||||
f"Please invite "
|
|
||||||
f"<a href='https://matrix.to/#/{self.az.bot_mxid}'>the bridge bot</a> "
|
|
||||||
f"first if you want to create a Telegram chat."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
await intent.join_room(room_id)
|
await intent.join_room(room_id)
|
||||||
portal = await po.Portal.get_by_tgid(
|
portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
|
||||||
puppet.tgid, tg_receiver=inviter.tgid, peer_type="user"
|
# TODO: if portal is None:
|
||||||
)
|
|
||||||
if portal.mxid:
|
if portal.mxid:
|
||||||
try:
|
try:
|
||||||
await portal.invite_to_matrix(inviter.mxid)
|
await intent.invite(portal.mxid, inviter.mxid)
|
||||||
await intent.send_notice(
|
await intent.send_notice(room_id, text=None, html=(
|
||||||
room_id,
|
"You already have a private chat with me: "
|
||||||
text=f"You already have a private chat with me: {portal.mxid}",
|
f"<a href='https://matrix.to/#/{portal.mxid}'>"
|
||||||
html=(
|
"Link to room"
|
||||||
"You already have a private chat with me: "
|
"</a>"))
|
||||||
f"<a href='https://matrix.to/#/{portal.mxid}'>Link to room</a>"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await intent.leave_room(room_id)
|
await intent.leave_room(room_id)
|
||||||
return
|
return
|
||||||
except MatrixError:
|
except MatrixRequestError:
|
||||||
pass
|
pass
|
||||||
portal.mxid = room_id
|
portal.mxid = room_id
|
||||||
e2be_ok = await portal.check_dm_encryption()
|
portal.save()
|
||||||
await portal.save()
|
inviter.register_portal(portal)
|
||||||
await inviter.register_portal(portal)
|
await intent.send_notice(room_id, "Portal to private chat created.")
|
||||||
if e2be_ok is True:
|
|
||||||
evt_type, content = await self.e2ee.encrypt(
|
|
||||||
room_id,
|
|
||||||
EventType.ROOM_MESSAGE,
|
|
||||||
TextMessageEventContent(
|
|
||||||
msgtype=MessageType.NOTICE,
|
|
||||||
body=(
|
|
||||||
"Portal to private chat created and end-to-bridge encryption enabled."
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
await intent.send_message_event(room_id, evt_type, content)
|
|
||||||
else:
|
|
||||||
message = "Portal to private chat created."
|
|
||||||
if e2be_ok is False:
|
|
||||||
message += "\n\nWarning: Failed to enable end-to-bridge encryption"
|
|
||||||
await intent.send_notice(room_id, message)
|
|
||||||
await portal.update_bridge_info()
|
|
||||||
else:
|
else:
|
||||||
await intent.join_room(room_id)
|
await intent.join_room(room_id)
|
||||||
await intent.send_notice(
|
await intent.send_notice(room_id, "This puppet will remain inactive until a "
|
||||||
room_id,
|
"Telegram chat is created for this room.")
|
||||||
"This puppet will remain inactive until a Telegram chat is created for this room.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_invite(
|
async def accept_bot_invite(self, room_id: MatrixRoomID, inviter: u.User) -> None:
|
||||||
self, room_id: RoomID, user_id: UserID, inviter: u.User, event_id: EventID
|
tries = 0
|
||||||
) -> None:
|
while tries < 5:
|
||||||
user = await u.User.get_by_mxid(user_id, create=False)
|
try:
|
||||||
|
await self.az.intent.join_room(room_id)
|
||||||
|
break
|
||||||
|
except (IntentError, MatrixRequestError):
|
||||||
|
tries += 1
|
||||||
|
wait_for_seconds = (tries + 1) * 10
|
||||||
|
if tries < 5:
|
||||||
|
self.log.exception(f"Failed to join room {room_id} with bridge bot, "
|
||||||
|
f"retrying in {wait_for_seconds} seconds...")
|
||||||
|
await asyncio.sleep(wait_for_seconds)
|
||||||
|
else:
|
||||||
|
self.log.exception("Failed to join room {room}, giving up.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not inviter.whitelisted:
|
||||||
|
await self.az.intent.send_notice(
|
||||||
|
room_id, text="",
|
||||||
|
html="You are not whitelisted to use this bridge.<br/><br/>"
|
||||||
|
"If you are the owner of this bridge, see the "
|
||||||
|
"<code>bridge.permissions</code> section in your config file.")
|
||||||
|
await self.az.intent.leave_room(room_id)
|
||||||
|
|
||||||
|
async def handle_invite(self, room_id: MatrixRoomID, user_id: MatrixUserID,
|
||||||
|
inviter_mxid: MatrixUserID) -> None:
|
||||||
|
self.log.debug(f"{inviter_mxid} invited {user_id} to {room_id}")
|
||||||
|
inviter = u.User.get_by_mxid(inviter_mxid)
|
||||||
|
if inviter is None:
|
||||||
|
self.log.exception("Failed to find user with Matrix ID {inviter_mxid}")
|
||||||
|
await inviter.ensure_started()
|
||||||
|
if user_id == self.az.bot_mxid:
|
||||||
|
return await self.accept_bot_invite(room_id, inviter)
|
||||||
|
elif not inviter.whitelisted:
|
||||||
|
return
|
||||||
|
|
||||||
|
puppet = pu.Puppet.get_by_mxid(user_id)
|
||||||
|
if puppet:
|
||||||
|
await self.handle_puppet_invite(room_id, puppet, inviter)
|
||||||
|
return
|
||||||
|
|
||||||
|
user = u.User.get_by_mxid(user_id, create=False)
|
||||||
if not user:
|
if not user:
|
||||||
return
|
return
|
||||||
await user.ensure_started()
|
await user.ensure_started()
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
if user and await user.has_full_access(allow_bot=True):
|
if user and await user.has_full_access(allow_bot=True) and portal:
|
||||||
if portal and portal.allow_bridging:
|
await portal.invite_telegram(inviter, user)
|
||||||
await portal.invite_telegram(inviter, user)
|
return
|
||||||
|
|
||||||
async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
|
# The rest can probably be ignored
|
||||||
user = await u.User.get_and_start_by_mxid(user_id)
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
async def handle_join(self, room_id: MatrixRoomID, user_id: MatrixUserID,
|
||||||
if not portal or not portal.allow_bridging:
|
event_id: MatrixEventID) -> None:
|
||||||
|
user = await u.User.get_by_mxid(user_id).ensure_started()
|
||||||
|
|
||||||
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
|
if not portal:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not user.relaybot_whitelisted:
|
if not user.relaybot_whitelisted:
|
||||||
await portal.main_intent.kick_user(
|
await portal.main_intent.kick(room_id, user.mxid,
|
||||||
room_id, user.mxid, "You are not whitelisted on this Telegram bridge."
|
"You are not whitelisted on this Telegram bridge.")
|
||||||
)
|
|
||||||
return
|
return
|
||||||
elif not await user.is_logged_in() and not portal.has_bot:
|
elif not await user.is_logged_in() and not portal.has_bot:
|
||||||
await portal.main_intent.kick_user(
|
await portal.main_intent.kick(room_id, user.mxid,
|
||||||
room_id,
|
"This chat does not have a bot relaying "
|
||||||
user.mxid,
|
"messages for unauthenticated users.")
|
||||||
"This chat does not have a bot relaying messages for unauthenticated users.",
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
self.log.debug(f"{user.mxid} joined {room_id}")
|
self.log.debug(f"{user} joined {room_id}")
|
||||||
if await user.is_logged_in() or portal.has_bot:
|
if await user.is_logged_in() or portal.has_bot:
|
||||||
await portal.join_matrix(user, event_id)
|
await portal.join_matrix(user, event_id)
|
||||||
|
|
||||||
async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
|
async def handle_part(self, room_id: MatrixRoomID, user_id: MatrixUserID,
|
||||||
|
sender_mxid: MatrixUserID, event_id: MatrixEventID) -> None:
|
||||||
self.log.debug(f"{user_id} left {room_id}")
|
self.log.debug(f"{user_id} left {room_id}")
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
user = await u.User.get_by_mxid(user_id, create=False)
|
sender = u.User.get_by_mxid(sender_mxid, create=False)
|
||||||
if not user:
|
|
||||||
return
|
|
||||||
await user.ensure_started()
|
|
||||||
await portal.leave_matrix(user, event_id)
|
|
||||||
|
|
||||||
async def handle_kick_ban(
|
|
||||||
self,
|
|
||||||
ban: bool,
|
|
||||||
room_id: RoomID,
|
|
||||||
user_id: UserID,
|
|
||||||
sender: UserID,
|
|
||||||
reason: str,
|
|
||||||
event_id: EventID,
|
|
||||||
) -> None:
|
|
||||||
action = "banned" if ban else "kicked"
|
|
||||||
self.log.debug(f"{user_id} was {action} from {room_id} by {sender} for {reason}")
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
if user_id == self.az.bot_mxid:
|
|
||||||
# Direct chat portal unbridging is handled in portal.kick_matrix
|
|
||||||
if portal.peer_type != "user":
|
|
||||||
await portal.unbridge()
|
|
||||||
return
|
|
||||||
|
|
||||||
sender = await u.User.get_by_mxid(sender, create=False)
|
|
||||||
if not sender:
|
if not sender:
|
||||||
return
|
return
|
||||||
await sender.ensure_started()
|
await sender.ensure_started()
|
||||||
|
|
||||||
puppet = await pu.Puppet.get_by_mxid(user_id)
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
|
if not portal:
|
||||||
|
return
|
||||||
|
|
||||||
|
puppet = pu.Puppet.get_by_mxid(user_id)
|
||||||
if puppet:
|
if puppet:
|
||||||
if ban:
|
if sender:
|
||||||
await portal.ban_matrix(puppet, sender)
|
|
||||||
else:
|
|
||||||
await portal.kick_matrix(puppet, sender)
|
await portal.kick_matrix(puppet, sender)
|
||||||
return
|
return
|
||||||
|
|
||||||
user = await u.User.get_by_mxid(user_id, create=False)
|
user = u.User.get_by_mxid(user_id, create=False)
|
||||||
if not user:
|
if not user:
|
||||||
return
|
return
|
||||||
await user.ensure_started()
|
await user.ensure_started()
|
||||||
if ban:
|
if await user.is_logged_in() or portal.has_bot:
|
||||||
await portal.ban_matrix(user, sender)
|
await portal.leave_matrix(user, sender, event_id)
|
||||||
else:
|
|
||||||
await portal.kick_matrix(user, sender)
|
|
||||||
|
|
||||||
async def handle_kick(
|
def is_command(self, message: Dict) -> Tuple[bool, str]:
|
||||||
self, room_id: RoomID, user_id: UserID, kicked_by: UserID, reason: str, event_id: EventID
|
text = message.get("body", "")
|
||||||
) -> None:
|
prefix = self.config["bridge.command_prefix"]
|
||||||
await self.handle_kick_ban(False, room_id, user_id, kicked_by, reason, event_id)
|
is_command = text.startswith(prefix)
|
||||||
|
if is_command:
|
||||||
|
text = text[len(prefix) + 1:]
|
||||||
|
return is_command, text
|
||||||
|
|
||||||
async def handle_unban(
|
async def handle_message(self, room: MatrixRoomID, sender_id: MatrixUserID, message: Dict,
|
||||||
self, room_id: RoomID, user_id: UserID, unbanned_by: UserID, reason: str, event_id: EventID
|
event_id: MatrixEventID) -> None:
|
||||||
) -> None:
|
is_command, text = self.is_command(message)
|
||||||
# TODO handle unbans properly instead of handling it as a kick
|
sender = await u.User.get_by_mxid(sender_id).ensure_started()
|
||||||
await self.handle_kick_ban(False, room_id, user_id, unbanned_by, reason, event_id)
|
if not sender.relaybot_whitelisted:
|
||||||
|
self.log.debug(f"Ignoring message \"{message}\" from {sender} to {room}:"
|
||||||
|
" User is not whitelisted.")
|
||||||
|
return
|
||||||
|
self.log.debug(f"Received Matrix event \"{message}\" from {sender} in {room}")
|
||||||
|
|
||||||
async def handle_ban(
|
portal = po.Portal.get_by_mxid(room)
|
||||||
self, room_id: RoomID, user_id: UserID, banned_by: UserID, reason: str, event_id: EventID
|
if not is_command and portal and (await sender.is_logged_in() or portal.has_bot):
|
||||||
) -> None:
|
await portal.handle_matrix_message(sender, message, event_id)
|
||||||
await self.handle_kick_ban(True, room_id, user_id, banned_by, reason, event_id)
|
return
|
||||||
|
|
||||||
async def allow_message(self, user: u.User) -> bool:
|
if not sender.whitelisted or message.get("msgtype", "m.unknown") != "m.text":
|
||||||
return user.relaybot_whitelisted
|
return
|
||||||
|
|
||||||
async def allow_command(self, user: u.User) -> bool:
|
try:
|
||||||
return user.whitelisted
|
is_management = len(await self.az.intent.get_room_members(room)) == 2
|
||||||
|
except MatrixRequestError:
|
||||||
|
# The AS bot is not in the room.
|
||||||
|
return
|
||||||
|
|
||||||
|
if is_command or is_management:
|
||||||
|
try:
|
||||||
|
command, arguments = text.split(" ", 1)
|
||||||
|
args = arguments.split(" ")
|
||||||
|
except ValueError:
|
||||||
|
# Not enough values to unpack, i.e. no arguments
|
||||||
|
command = text
|
||||||
|
args = []
|
||||||
|
await self.commands.handle(room, event_id, sender, command, args, is_management,
|
||||||
|
is_portal=portal is not None)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def allow_bridging_message(user: u.User, portal: po.Portal) -> bool:
|
async def handle_redaction(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
|
||||||
return await user.is_logged_in() or portal.has_bot
|
event_id: MatrixEventID) -> None:
|
||||||
|
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
||||||
@staticmethod
|
|
||||||
async def handle_redaction(evt: RedactionEvent) -> None:
|
|
||||||
sender = await u.User.get_and_start_by_mxid(evt.sender)
|
|
||||||
if not sender.relaybot_whitelisted:
|
if not sender.relaybot_whitelisted:
|
||||||
return
|
return
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
if not portal or not portal.allow_bridging:
|
if not portal:
|
||||||
return
|
return
|
||||||
|
|
||||||
await portal.handle_matrix_deletion(sender, evt.redacts, evt.event_id)
|
await portal.handle_matrix_deletion(sender, event_id)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def handle_reaction(evt: ReactionEvent) -> None:
|
async def handle_power_levels(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
|
||||||
sender = await u.User.get_and_start_by_mxid(evt.sender)
|
new: Dict, old: Dict) -> None:
|
||||||
if not await sender.has_full_access():
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
return
|
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
||||||
|
if await sender.has_full_access(allow_bot=True) and portal:
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
await portal.handle_matrix_power_levels(sender, new["users"], old["users"])
|
||||||
if not portal or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
await portal.handle_matrix_reaction(
|
|
||||||
sender, evt.content.relates_to.event_id, evt.content.relates_to.key, evt.event_id
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def handle_power_levels(evt: StateEvent) -> None:
|
async def handle_room_meta(evt_type: str, room_id: MatrixRoomID, sender_mxid: MatrixUserID,
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
content: dict) -> None:
|
||||||
sender = await u.User.get_and_start_by_mxid(evt.sender)
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
|
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
||||||
await portal.handle_matrix_power_levels(
|
if await sender.has_full_access(allow_bot=True) and portal:
|
||||||
sender, evt.content.users, evt.unsigned.prev_content.users, evt.event_id
|
handler, content_key = {
|
||||||
)
|
"m.room.name": (portal.handle_matrix_title, "name"),
|
||||||
|
"m.room.topic": (portal.handle_matrix_about, "topic"),
|
||||||
@staticmethod
|
"m.room.avatar": (portal.handle_matrix_avatar, "url"),
|
||||||
async def handle_room_meta(
|
|
||||||
evt_type: EventType,
|
|
||||||
room_id: RoomID,
|
|
||||||
sender_mxid: UserID,
|
|
||||||
content: NameContent | AvatarContent | TopicContent,
|
|
||||||
event_id: EventID,
|
|
||||||
) -> None:
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
sender = await u.User.get_and_start_by_mxid(sender_mxid)
|
|
||||||
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
|
|
||||||
handler, content_type, content_key = {
|
|
||||||
EventType.ROOM_NAME: (portal.handle_matrix_title, NameContent, "name"),
|
|
||||||
EventType.ROOM_TOPIC: (portal.handle_matrix_about, TopicContent, "topic"),
|
|
||||||
EventType.ROOM_AVATAR: (portal.handle_matrix_avatar, AvatarContent, "url"),
|
|
||||||
}[evt_type]
|
}[evt_type]
|
||||||
if not isinstance(content, content_type):
|
if content_key not in content:
|
||||||
return
|
return
|
||||||
await handler(sender, content[content_key], event_id)
|
await handler(sender, content[content_key])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def handle_room_pin(
|
async def handle_room_pin(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
|
||||||
room_id: RoomID,
|
new_events: Set[str], old_events: Set[str]) -> None:
|
||||||
sender_mxid: UserID,
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
new_events: set[str],
|
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
||||||
old_events: set[str],
|
if await sender.has_full_access(allow_bot=True) and portal:
|
||||||
event_id: EventID,
|
events = new_events - old_events
|
||||||
) -> None:
|
if len(events) > 0:
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
# New event pinned, set that as pinned in Telegram.
|
||||||
sender = await u.User.get_and_start_by_mxid(sender_mxid)
|
await portal.handle_matrix_pin(sender, MatrixEventID(events.pop()))
|
||||||
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
|
elif len(new_events) == 0:
|
||||||
if not new_events:
|
# All pinned events removed, remove pinned event in Telegram.
|
||||||
await portal.handle_matrix_unpin_all(sender, event_id)
|
await portal.handle_matrix_pin(sender, None)
|
||||||
else:
|
|
||||||
changes = {
|
|
||||||
event_id: event_id in new_events for event_id in new_events ^ old_events
|
|
||||||
}
|
|
||||||
await portal.handle_matrix_pin(sender, changes, event_id)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def handle_room_upgrade(
|
async def handle_room_upgrade(room_id: MatrixRoomID, new_room_id: MatrixRoomID) -> None:
|
||||||
room_id: RoomID, sender: UserID, new_room_id: RoomID, event_id: EventID
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
) -> None:
|
if portal:
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
await portal.handle_matrix_upgrade(new_room_id)
|
||||||
if portal and portal.allow_bridging:
|
|
||||||
await portal.handle_matrix_upgrade(sender, new_room_id, event_id)
|
|
||||||
|
|
||||||
async def handle_member_info_change(
|
@staticmethod
|
||||||
self,
|
async def handle_name_change(room_id: MatrixRoomID, user_id: MatrixUserID, displayname: str,
|
||||||
room_id: RoomID,
|
prev_displayname: str, event_id: MatrixEventID) -> None:
|
||||||
user_id: UserID,
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
profile: MemberStateEventContent,
|
if not portal or not portal.has_bot:
|
||||||
prev_profile: MemberStateEventContent,
|
|
||||||
event_id: EventID,
|
|
||||||
) -> None:
|
|
||||||
if profile.displayname == prev_profile.displayname:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
user = await u.User.get_by_mxid(user_id).ensure_started()
|
||||||
if not portal or not portal.has_bot or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
user = await u.User.get_and_start_by_mxid(user_id)
|
|
||||||
if await user.needs_relaybot(portal):
|
if await user.needs_relaybot(portal):
|
||||||
await portal.name_change_matrix(
|
await portal.name_change_matrix(user, displayname, prev_displayname, event_id)
|
||||||
user, profile.displayname, prev_profile.displayname, event_id
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_read_receipt(
|
|
||||||
self, user: u.User, portal: po.Portal, event_id: EventID, data: SingleReceiptEventContent
|
|
||||||
) -> None:
|
|
||||||
if not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
await portal.mark_read(user, event_id, data.get("ts", 0))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def handle_presence(user_id: UserID, presence: PresenceState) -> None:
|
def parse_read_receipts(content: Dict) -> Dict[MatrixUserID, MatrixEventID]:
|
||||||
user = await u.User.get_by_mxid(user_id, check_db=False, create=False)
|
return {user_id: event_id
|
||||||
if user and await user.is_logged_in():
|
for event_id, receipts in content.items()
|
||||||
await user.set_presence(presence == PresenceState.ONLINE)
|
for user_id in receipts.get("m.read", {})}
|
||||||
|
|
||||||
async def handle_typing(self, room_id: RoomID, now_typing: set[UserID]) -> None:
|
@staticmethod
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
async def handle_read_receipts(room_id: MatrixRoomID,
|
||||||
if not portal or not portal.allow_bridging:
|
receipts: Dict[MatrixUserID, MatrixEventID]) -> None:
|
||||||
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
|
if not portal:
|
||||||
return
|
return
|
||||||
|
|
||||||
previously_typing = self._previously_typing.get(room_id, set())
|
for user_id, event_id in receipts.items():
|
||||||
|
user = await u.User.get_by_mxid(user_id).ensure_started()
|
||||||
|
if not await user.is_logged_in():
|
||||||
|
continue
|
||||||
|
await portal.mark_read(user, event_id)
|
||||||
|
|
||||||
for user_id in set(previously_typing | now_typing):
|
@staticmethod
|
||||||
|
async def handle_presence(user_id: MatrixUserID, presence: str) -> None:
|
||||||
|
user = await u.User.get_by_mxid(user_id).ensure_started()
|
||||||
|
if not await user.is_logged_in():
|
||||||
|
return
|
||||||
|
await user.set_presence(presence == "online")
|
||||||
|
|
||||||
|
async def handle_typing(self, room_id: MatrixRoomID, now_typing: List[MatrixUserID]) -> None:
|
||||||
|
portal = po.Portal.get_by_mxid(room_id)
|
||||||
|
if not portal:
|
||||||
|
return
|
||||||
|
|
||||||
|
for user_id in set(self.previously_typing + now_typing):
|
||||||
is_typing = user_id in now_typing
|
is_typing = user_id in now_typing
|
||||||
was_typing = user_id in previously_typing
|
was_typing = user_id in self.previously_typing
|
||||||
if is_typing and was_typing:
|
if is_typing and was_typing:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
user = await u.User.get_by_mxid(user_id, check_db=False, create=False)
|
user = await u.User.get_by_mxid(user_id).ensure_started()
|
||||||
if user and await user.is_logged_in():
|
if not await user.is_logged_in():
|
||||||
await portal.set_typing(user, is_typing)
|
continue
|
||||||
|
|
||||||
self._previously_typing[room_id] = now_typing
|
await portal.set_typing(user, is_typing)
|
||||||
|
|
||||||
async def handle_ephemeral_event(
|
self.previously_typing = now_typing
|
||||||
self, evt: ReceiptEvent | PresenceEvent | TypingEvent
|
|
||||||
) -> None:
|
|
||||||
if evt.type == EventType.RECEIPT:
|
|
||||||
await self.handle_receipt(evt)
|
|
||||||
elif evt.type == EventType.PRESENCE:
|
|
||||||
await self.handle_presence(evt.sender, evt.content.presence)
|
|
||||||
elif evt.type == EventType.TYPING:
|
|
||||||
await self.handle_typing(evt.room_id, set(evt.content.user_ids))
|
|
||||||
|
|
||||||
async def handle_event(self, evt: Event) -> None:
|
def filter_matrix_event(self, event: MatrixEvent) -> bool:
|
||||||
if evt.type == EventType.ROOM_REDACTION:
|
sender = event.get("sender", None)
|
||||||
await self.handle_redaction(evt)
|
if not sender:
|
||||||
elif evt.type == EventType.REACTION:
|
return False
|
||||||
await self.handle_reaction(evt)
|
return (sender == self.az.bot_mxid
|
||||||
|
or pu.Puppet.get_id_from_mxid(sender) is not None)
|
||||||
|
|
||||||
async def handle_state_event(self, evt: StateEvent) -> None:
|
async def try_handle_event(self, evt: MatrixEvent) -> None:
|
||||||
if evt.type == EventType.ROOM_POWER_LEVELS:
|
try:
|
||||||
await self.handle_power_levels(evt)
|
await self.handle_event(evt)
|
||||||
elif evt.type in (EventType.ROOM_NAME, EventType.ROOM_AVATAR, EventType.ROOM_TOPIC):
|
except Exception:
|
||||||
await self.handle_room_meta(
|
self.log.exception("Error handling manually received Matrix event")
|
||||||
evt.type, evt.room_id, evt.sender, evt.content, evt.event_id
|
|
||||||
)
|
async def handle_event(self, evt: MatrixEvent) -> None:
|
||||||
elif evt.type == EventType.ROOM_PINNED_EVENTS:
|
if self.filter_matrix_event(evt):
|
||||||
new_events = set(evt.content.pinned)
|
return
|
||||||
|
self.log.debug("Received event: %s", evt)
|
||||||
|
evt_type = evt.get("type", "m.unknown") # type: str
|
||||||
|
room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID]
|
||||||
|
event_id = evt.get("event_id", None) # type: Optional[MatrixEventID]
|
||||||
|
sender = evt.get("sender", None) # type: Optional[MatrixUserID]
|
||||||
|
content = evt.get("content", {}) # type: Dict
|
||||||
|
if evt_type == "m.room.member":
|
||||||
|
state_key = evt["state_key"] # type: MatrixUserID
|
||||||
|
prev_content = evt.get("unsigned", {}).get("prev_content", {}) # type: Dict
|
||||||
|
membership = content.get("membership", "") # type: str
|
||||||
|
prev_membership = prev_content.get("membership", "leave") # type: str
|
||||||
|
if membership == prev_membership:
|
||||||
|
match = re.compile("@(.+):(.+)").match(state_key) # type: Match
|
||||||
|
mxid = match.group(0) # type: str
|
||||||
|
displayname = content.get("displayname", None) or mxid # type: str
|
||||||
|
prev_displayname = prev_content.get("displayname", None) or mxid # type: str
|
||||||
|
if displayname != prev_displayname:
|
||||||
|
await self.handle_name_change(room_id, state_key, displayname,
|
||||||
|
prev_displayname, event_id)
|
||||||
|
elif membership == "invite":
|
||||||
|
await self.handle_invite(room_id, state_key, sender)
|
||||||
|
elif prev_membership == "join" and membership == "leave":
|
||||||
|
await self.handle_part(room_id, state_key, sender, event_id)
|
||||||
|
elif membership == "join":
|
||||||
|
await self.handle_join(room_id, state_key, event_id)
|
||||||
|
elif evt_type in ("m.room.message", "m.sticker"):
|
||||||
|
if evt_type != "m.room.message":
|
||||||
|
content["msgtype"] = evt_type
|
||||||
|
await self.handle_message(room_id, sender, content, event_id)
|
||||||
|
elif evt_type == "m.room.redaction":
|
||||||
|
await self.handle_redaction(room_id, sender, evt["redacts"])
|
||||||
|
elif evt_type == "m.room.power_levels":
|
||||||
|
prev_content = evt.get("unsigned", {}).get("prev_content", {})
|
||||||
|
await self.handle_power_levels(room_id, sender, evt["content"], prev_content)
|
||||||
|
elif evt_type in ("m.room.name", "m.room.avatar", "m.room.topic"):
|
||||||
|
await self.handle_room_meta(evt_type, room_id, sender, evt["content"])
|
||||||
|
elif evt_type == "m.room.pinned_events":
|
||||||
|
new_events = set(evt["content"]["pinned"])
|
||||||
try:
|
try:
|
||||||
old_events = set(evt.unsigned.prev_content.pinned)
|
old_events = set(evt["unsigned"]["prev_content"]["pinned"])
|
||||||
except (KeyError, ValueError, TypeError, AttributeError):
|
except KeyError:
|
||||||
old_events = set()
|
old_events = set()
|
||||||
await self.handle_room_pin(
|
await self.handle_room_pin(room_id, sender, new_events, old_events)
|
||||||
evt.room_id, evt.sender, new_events, old_events, evt.event_id
|
elif evt_type == "m.room.tombstone":
|
||||||
)
|
await self.handle_room_upgrade(room_id, evt["content"]["replacement_room"])
|
||||||
elif evt.type == EventType.ROOM_TOMBSTONE:
|
elif evt_type == "m.receipt":
|
||||||
await self.handle_room_upgrade(
|
await self.handle_read_receipts(room_id, self.parse_read_receipts(content))
|
||||||
evt.room_id, evt.sender, evt.content.replacement_room, evt.event_id
|
elif evt_type == "m.presence":
|
||||||
)
|
await self.handle_presence(sender, content.get("presence", "offline"))
|
||||||
|
elif evt_type == "m.typing":
|
||||||
|
await self.handle_typing(room_id, content.get("user_ids", []))
|
||||||
|
|||||||
+1699
-3238
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
from .deduplication import PortalDedup
|
|
||||||
from .media_fallback import make_contact_event_content, make_dice_event_content
|
|
||||||
from .participants import get_users
|
|
||||||
from .power_levels import get_base_power_levels, participants_to_power_levels
|
|
||||||
from .send_lock import PortalReactionLock, PortalSendLock
|
|
||||||
from .sponsored_message import get_sponsored_message, make_sponsored_message_content
|
|
||||||
@@ -1,158 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Generator, Tuple, Union
|
|
||||||
from collections import deque
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from telethon.tl.patched import Message, MessageService
|
|
||||||
from telethon.tl.types import (
|
|
||||||
Message,
|
|
||||||
MessageMediaContact,
|
|
||||||
MessageMediaDice,
|
|
||||||
MessageMediaDocument,
|
|
||||||
MessageMediaGame,
|
|
||||||
MessageMediaGeo,
|
|
||||||
MessageMediaPhoto,
|
|
||||||
MessageMediaPoll,
|
|
||||||
MessageMediaUnsupported,
|
|
||||||
MessageService,
|
|
||||||
PeerChannel,
|
|
||||||
PeerChat,
|
|
||||||
PeerUser,
|
|
||||||
TypeUpdates,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage,
|
|
||||||
UpdateShortChatMessage,
|
|
||||||
UpdateShortMessage,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from .. import portal as po
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
DedupMXID = Tuple[EventID, TelegramID]
|
|
||||||
TypeMessage = Union[Message, MessageService, UpdateShortMessage, UpdateShortChatMessage]
|
|
||||||
|
|
||||||
media_content_table = {
|
|
||||||
MessageMediaContact: lambda media: [media.user_id],
|
|
||||||
MessageMediaDocument: lambda media: [media.document.id],
|
|
||||||
MessageMediaPhoto: lambda media: [media.photo.id if media.photo else 0],
|
|
||||||
MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
|
|
||||||
MessageMediaGame: lambda media: [media.game.id],
|
|
||||||
MessageMediaPoll: lambda media: [media.poll.id],
|
|
||||||
MessageMediaDice: lambda media: [media.value, media.emoticon],
|
|
||||||
MessageMediaUnsupported: lambda media: ["unsupported media"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PortalDedup:
|
|
||||||
pre_db_check: bool = False
|
|
||||||
cache_queue_length: int = 256
|
|
||||||
|
|
||||||
_dedup: deque[bytes | int]
|
|
||||||
_dedup_mxid: dict[bytes | int, DedupMXID]
|
|
||||||
_dedup_action: deque[bytes | int]
|
|
||||||
_portal: po.Portal
|
|
||||||
|
|
||||||
def __init__(self, portal: po.Portal) -> None:
|
|
||||||
self._dedup = deque()
|
|
||||||
self._dedup_mxid = {}
|
|
||||||
self._dedup_action = deque(maxlen=self.cache_queue_length)
|
|
||||||
self._portal = portal
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _always_force_hash(self) -> bool:
|
|
||||||
return self._portal.peer_type == "chat"
|
|
||||||
|
|
||||||
def _hash_content(self, event: TypeMessage) -> Generator[Any, None, None]:
|
|
||||||
if not self._always_force_hash:
|
|
||||||
yield event.id
|
|
||||||
yield int(event.date.timestamp())
|
|
||||||
if isinstance(event, MessageService):
|
|
||||||
yield event.from_id
|
|
||||||
yield event.action
|
|
||||||
else:
|
|
||||||
yield event.message.strip()
|
|
||||||
if event.fwd_from:
|
|
||||||
yield event.fwd_from.from_id
|
|
||||||
if isinstance(event, Message) and event.media:
|
|
||||||
media_hash_func = media_content_table.get(type(event.media)) or (
|
|
||||||
lambda media: ["unknown media"]
|
|
||||||
)
|
|
||||||
yield media_hash_func(event.media)
|
|
||||||
|
|
||||||
def _hash_event(self, event: TypeMessage) -> bytes:
|
|
||||||
return hashlib.sha256(
|
|
||||||
"-".join(str(a) for a in self._hash_content(event)).encode("utf-8")
|
|
||||||
).digest()
|
|
||||||
|
|
||||||
def check_action(self, event: TypeMessage) -> bool:
|
|
||||||
dedup_id = self._hash_event(event) if self._always_force_hash else event.id
|
|
||||||
if dedup_id in self._dedup_action:
|
|
||||||
return True
|
|
||||||
|
|
||||||
self._dedup_action.appendleft(dedup_id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
event: TypeMessage,
|
|
||||||
mxid: DedupMXID = None,
|
|
||||||
expected_mxid: DedupMXID | None = None,
|
|
||||||
force_hash: bool = False,
|
|
||||||
) -> tuple[bytes, DedupMXID | None]:
|
|
||||||
evt_hash = self._hash_event(event)
|
|
||||||
dedup_id = evt_hash if self._always_force_hash or force_hash else event.id
|
|
||||||
try:
|
|
||||||
found_mxid = self._dedup_mxid[dedup_id]
|
|
||||||
except KeyError:
|
|
||||||
return evt_hash, None
|
|
||||||
|
|
||||||
if found_mxid != expected_mxid:
|
|
||||||
return evt_hash, found_mxid
|
|
||||||
self._dedup_mxid[dedup_id] = mxid
|
|
||||||
if evt_hash != dedup_id:
|
|
||||||
self._dedup_mxid[evt_hash] = mxid
|
|
||||||
return evt_hash, None
|
|
||||||
|
|
||||||
def check(
|
|
||||||
self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False
|
|
||||||
) -> tuple[bytes, DedupMXID | None]:
|
|
||||||
evt_hash = self._hash_event(event)
|
|
||||||
dedup_id = evt_hash if self._always_force_hash or force_hash else event.id
|
|
||||||
if dedup_id in self._dedup:
|
|
||||||
return evt_hash, self._dedup_mxid[dedup_id]
|
|
||||||
|
|
||||||
self._dedup_mxid[dedup_id] = mxid
|
|
||||||
self._dedup.appendleft(dedup_id)
|
|
||||||
if evt_hash != dedup_id:
|
|
||||||
self._dedup_mxid[evt_hash] = mxid
|
|
||||||
self._dedup.appendleft(evt_hash)
|
|
||||||
|
|
||||||
while len(self._dedup) > self.cache_queue_length:
|
|
||||||
del self._dedup_mxid[self._dedup.pop()]
|
|
||||||
return evt_hash, None
|
|
||||||
|
|
||||||
def register_outgoing_actions(self, response: TypeUpdates) -> None:
|
|
||||||
for update in response.updates:
|
|
||||||
check_dedup = isinstance(
|
|
||||||
update, (UpdateNewMessage, UpdateNewChannelMessage)
|
|
||||||
) and isinstance(update.message, MessageService)
|
|
||||||
if check_dedup:
|
|
||||||
self.check(update.message)
|
|
||||||
@@ -1,133 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import html
|
|
||||||
|
|
||||||
from telethon.tl.types import MessageMediaContact, MessageMediaDice, PeerUser
|
|
||||||
|
|
||||||
from mautrix.types import Format, MessageType, TextMessageEventContent
|
|
||||||
|
|
||||||
from .. import abstract_user as au, puppet as pu
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
try:
|
|
||||||
import phonenumbers
|
|
||||||
except ImportError:
|
|
||||||
phonenumbers = None
|
|
||||||
|
|
||||||
|
|
||||||
def _format_dice(roll: MessageMediaDice) -> str:
|
|
||||||
if roll.emoticon == "\U0001F3B0":
|
|
||||||
emojis = {
|
|
||||||
0: "\U0001F36B", # "🍫",
|
|
||||||
1: "\U0001F352", # "🍒",
|
|
||||||
2: "\U0001F34B", # "🍋",
|
|
||||||
3: "7\ufe0f\u20e3", # "7️⃣",
|
|
||||||
}
|
|
||||||
res = roll.value - 1
|
|
||||||
slot1, slot2, slot3 = emojis[res % 4], emojis[res // 4 % 4], emojis[res // 16]
|
|
||||||
return f"{slot1} {slot2} {slot3} ({roll.value})"
|
|
||||||
elif roll.emoticon == "\u26BD":
|
|
||||||
results = {
|
|
||||||
1: "miss",
|
|
||||||
2: "hit the woodwork",
|
|
||||||
3: "goal", # seems to go in through the center
|
|
||||||
4: "goal",
|
|
||||||
5: "goal 🎉", # seems to go in through the top right corner, includes confetti
|
|
||||||
}
|
|
||||||
elif roll.emoticon == "\U0001F3B3":
|
|
||||||
results = {
|
|
||||||
1: "miss",
|
|
||||||
2: "1 pin down",
|
|
||||||
3: "3 pins down, split",
|
|
||||||
4: "4 pins down, split",
|
|
||||||
5: "5 pins down",
|
|
||||||
6: "strike 🎉",
|
|
||||||
}
|
|
||||||
# elif roll.emoticon == "\U0001F3C0":
|
|
||||||
# results = {
|
|
||||||
# 2: "rolled off",
|
|
||||||
# 3: "stuck",
|
|
||||||
# }
|
|
||||||
# elif roll.emoticon == "\U0001F3AF":
|
|
||||||
# results = {
|
|
||||||
# 1: "bounced off",
|
|
||||||
# 2: "outer rim",
|
|
||||||
#
|
|
||||||
# 6: "bullseye",
|
|
||||||
# }
|
|
||||||
else:
|
|
||||||
return str(roll.value)
|
|
||||||
return f"{results[roll.value]} ({roll.value})"
|
|
||||||
|
|
||||||
|
|
||||||
def make_dice_event_content(roll: MessageMediaDice) -> TextMessageEventContent:
|
|
||||||
emoji_text = {
|
|
||||||
"\U0001F3AF": " Dart throw",
|
|
||||||
"\U0001F3B2": " Dice roll",
|
|
||||||
"\U0001F3C0": " Basketball throw",
|
|
||||||
"\U0001F3B0": " Slot machine",
|
|
||||||
"\U0001F3B3": " Bowling",
|
|
||||||
"\u26BD": " Football kick",
|
|
||||||
}
|
|
||||||
text = f"{roll.emoticon}{emoji_text.get(roll.emoticon, '')} result: {_format_dice(roll)}"
|
|
||||||
content = TextMessageEventContent(
|
|
||||||
msgtype=MessageType.TEXT, format=Format.HTML, body=text, formatted_body=f"<h4>{text}</h4>"
|
|
||||||
)
|
|
||||||
content["net.maunium.telegram.dice"] = {"emoticon": roll.emoticon, "value": roll.value}
|
|
||||||
return content
|
|
||||||
|
|
||||||
|
|
||||||
async def make_contact_event_content(
|
|
||||||
source: au.AbstractUser, contact: MessageMediaContact
|
|
||||||
) -> TextMessageEventContent:
|
|
||||||
name = " ".join(x for x in [contact.first_name, contact.last_name] if x)
|
|
||||||
formatted_phone = f"+{contact.phone_number}"
|
|
||||||
if phonenumbers is not None:
|
|
||||||
try:
|
|
||||||
parsed = phonenumbers.parse(formatted_phone)
|
|
||||||
fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL
|
|
||||||
formatted_phone = phonenumbers.format_number(parsed, fmt)
|
|
||||||
except phonenumbers.NumberParseException:
|
|
||||||
pass
|
|
||||||
content = TextMessageEventContent(
|
|
||||||
msgtype=MessageType.TEXT,
|
|
||||||
body=f"Shared contact info for {name}: {formatted_phone}",
|
|
||||||
)
|
|
||||||
content["net.maunium.telegram.contact"] = {
|
|
||||||
"user_id": contact.user_id,
|
|
||||||
"first_name": contact.first_name,
|
|
||||||
"last_name": contact.last_name,
|
|
||||||
"phone_number": contact.phone_number,
|
|
||||||
"vcard": contact.vcard,
|
|
||||||
}
|
|
||||||
|
|
||||||
puppet = await pu.Puppet.get_by_tgid(TelegramID(contact.user_id))
|
|
||||||
if not puppet.displayname:
|
|
||||||
try:
|
|
||||||
entity = await source.client.get_entity(PeerUser(contact.user_id))
|
|
||||||
await puppet.update_info(source, entity)
|
|
||||||
except Exception as e:
|
|
||||||
source.log.warning(f"Failed to sync puppet info of received contact: {e}")
|
|
||||||
else:
|
|
||||||
content.format = Format.HTML
|
|
||||||
content.formatted_body = (
|
|
||||||
f"Shared contact info for "
|
|
||||||
f"<a href='https://matrix.to/#/{puppet.mxid}'>{html.escape(name)}</a>: "
|
|
||||||
f"{html.escape(formatted_phone)}"
|
|
||||||
)
|
|
||||||
return content
|
|
||||||
@@ -1,106 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from telethon.errors import ChatAdminRequiredError
|
|
||||||
from telethon.tl.functions.channels import GetParticipantsRequest
|
|
||||||
from telethon.tl.functions.messages import GetFullChatRequest
|
|
||||||
from telethon.tl.types import (
|
|
||||||
ChannelParticipantBanned,
|
|
||||||
ChannelParticipantsRecent,
|
|
||||||
ChannelParticipantsSearch,
|
|
||||||
InputChannel,
|
|
||||||
InputUser,
|
|
||||||
TypeChannelParticipant,
|
|
||||||
TypeChat,
|
|
||||||
TypeChatParticipant,
|
|
||||||
TypeInputPeer,
|
|
||||||
TypeUser,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..tgclient import MautrixTelegramClient
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_participants(
|
|
||||||
users: list[TypeUser], participants: list[TypeChatParticipant | TypeChannelParticipant]
|
|
||||||
) -> Iterable[TypeUser]:
|
|
||||||
participant_map = {
|
|
||||||
part.user_id: part
|
|
||||||
for part in participants
|
|
||||||
if not isinstance(part, ChannelParticipantBanned)
|
|
||||||
}
|
|
||||||
for user in users:
|
|
||||||
try:
|
|
||||||
user.participant = participant_map[user.id]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
yield user
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_channel_users(
|
|
||||||
client: MautrixTelegramClient, entity: InputChannel, limit: int
|
|
||||||
) -> list[TypeUser]:
|
|
||||||
if 0 < limit <= 200:
|
|
||||||
response = await client(
|
|
||||||
GetParticipantsRequest(
|
|
||||||
entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return list(_filter_participants(response.users, response.participants))
|
|
||||||
elif limit > 200 or limit == -1:
|
|
||||||
users: list[TypeUser] = []
|
|
||||||
offset = 0
|
|
||||||
remaining_quota = limit if limit > 0 else 1000000
|
|
||||||
query = ChannelParticipantsSearch("") if limit == -1 else ChannelParticipantsRecent()
|
|
||||||
while True:
|
|
||||||
if remaining_quota <= 0:
|
|
||||||
break
|
|
||||||
response = await client(
|
|
||||||
GetParticipantsRequest(
|
|
||||||
entity, query, offset=offset, limit=min(remaining_quota, 200), hash=0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not response.users:
|
|
||||||
break
|
|
||||||
users += _filter_participants(response.users, response.participants)
|
|
||||||
offset += len(response.participants)
|
|
||||||
remaining_quota -= len(response.participants)
|
|
||||||
return users
|
|
||||||
|
|
||||||
|
|
||||||
async def get_users(
|
|
||||||
client: MautrixTelegramClient,
|
|
||||||
tgid: int,
|
|
||||||
entity: TypeInputPeer | InputUser | TypeChat | TypeUser | InputChannel,
|
|
||||||
limit: int,
|
|
||||||
peer_type: str,
|
|
||||||
) -> list[TypeUser]:
|
|
||||||
if peer_type == "chat":
|
|
||||||
chat = await client(GetFullChatRequest(chat_id=tgid))
|
|
||||||
users = list(_filter_participants(chat.users, chat.full_chat.participants.participants))
|
|
||||||
return users[:limit] if limit > 0 else users
|
|
||||||
elif peer_type == "channel":
|
|
||||||
try:
|
|
||||||
return await _get_channel_users(client, entity, limit)
|
|
||||||
except ChatAdminRequiredError:
|
|
||||||
return []
|
|
||||||
elif peer_type == "user":
|
|
||||||
return [entity]
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"Unexpected peer type {peer_type}")
|
|
||||||
@@ -1,154 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from telethon.tl.types import (
|
|
||||||
ChannelParticipantAdmin,
|
|
||||||
ChannelParticipantCreator,
|
|
||||||
ChatBannedRights,
|
|
||||||
ChatParticipantAdmin,
|
|
||||||
ChatParticipantCreator,
|
|
||||||
TypeChannelParticipant,
|
|
||||||
TypeChat,
|
|
||||||
TypeChatParticipant,
|
|
||||||
TypeUser,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.types import EventType, PowerLevelStateEventContent as PowerLevelContent, UserID
|
|
||||||
|
|
||||||
from .. import portal as po, puppet as pu, user as u
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
|
|
||||||
def get_base_power_levels(
|
|
||||||
portal: po.Portal, levels: PowerLevelContent = None, entity: TypeChat = None
|
|
||||||
) -> PowerLevelContent:
|
|
||||||
levels = levels or PowerLevelContent()
|
|
||||||
if portal.peer_type == "user":
|
|
||||||
overrides = portal.config["bridge.initial_power_level_overrides.user"]
|
|
||||||
levels.ban = overrides.get("ban", 100)
|
|
||||||
levels.kick = overrides.get("kick", 100)
|
|
||||||
levels.invite = overrides.get("invite", 100)
|
|
||||||
levels.redact = overrides.get("redact", 0)
|
|
||||||
levels.events[EventType.ROOM_NAME] = 0
|
|
||||||
levels.events[EventType.ROOM_AVATAR] = 0
|
|
||||||
levels.events[EventType.ROOM_TOPIC] = 0
|
|
||||||
levels.state_default = overrides.get("state_default", 0)
|
|
||||||
levels.users_default = overrides.get("users_default", 0)
|
|
||||||
levels.events_default = overrides.get("events_default", 0)
|
|
||||||
else:
|
|
||||||
overrides = portal.config["bridge.initial_power_level_overrides.group"]
|
|
||||||
dbr = entity.default_banned_rights
|
|
||||||
if not dbr:
|
|
||||||
portal.log.debug(f"default_banned_rights is None in {entity}")
|
|
||||||
dbr = ChatBannedRights(
|
|
||||||
invite_users=True,
|
|
||||||
change_info=True,
|
|
||||||
pin_messages=True,
|
|
||||||
send_stickers=False,
|
|
||||||
send_messages=False,
|
|
||||||
until_date=None,
|
|
||||||
)
|
|
||||||
levels.ban = overrides.get("ban", 50)
|
|
||||||
levels.kick = overrides.get("kick", 50)
|
|
||||||
levels.redact = overrides.get("redact", 50)
|
|
||||||
levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0)
|
|
||||||
levels.events[EventType.ROOM_ENCRYPTION] = 50 if portal.matrix.e2ee else 99
|
|
||||||
levels.events[EventType.ROOM_TOMBSTONE] = 99
|
|
||||||
levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0
|
|
||||||
levels.events[EventType.ROOM_AVATAR] = 50 if dbr.change_info else 0
|
|
||||||
levels.events[EventType.ROOM_TOPIC] = 50 if dbr.change_info else 0
|
|
||||||
levels.events[EventType.ROOM_PINNED_EVENTS] = 50 if dbr.pin_messages else 0
|
|
||||||
levels.events[EventType.ROOM_POWER_LEVELS] = 75
|
|
||||||
levels.events[EventType.ROOM_HISTORY_VISIBILITY] = 75
|
|
||||||
levels.events[EventType.STICKER] = 50 if dbr.send_stickers else levels.events_default
|
|
||||||
levels.state_default = overrides.get("state_default", 50)
|
|
||||||
levels.users_default = overrides.get("users_default", 0)
|
|
||||||
levels.events_default = overrides.get(
|
|
||||||
"events_default",
|
|
||||||
50
|
|
||||||
if (
|
|
||||||
portal.peer_type == "channel"
|
|
||||||
and not entity.megagroup
|
|
||||||
or entity.default_banned_rights.send_messages
|
|
||||||
)
|
|
||||||
else 0,
|
|
||||||
)
|
|
||||||
for evt_type, value in overrides.get("events", {}).items():
|
|
||||||
levels.events[EventType.find(evt_type)] = value
|
|
||||||
levels.users = overrides.get("users", {})
|
|
||||||
if portal.main_intent.mxid not in levels.users:
|
|
||||||
levels.users[portal.main_intent.mxid] = 100
|
|
||||||
return levels
|
|
||||||
|
|
||||||
|
|
||||||
async def participants_to_power_levels(
|
|
||||||
portal: po.Portal,
|
|
||||||
users: list[TypeUser | TypeChatParticipant | TypeChannelParticipant],
|
|
||||||
levels: PowerLevelContent,
|
|
||||||
) -> bool:
|
|
||||||
bot_level = levels.get_user_level(portal.main_intent.mxid)
|
|
||||||
if bot_level < levels.get_event_level(EventType.ROOM_POWER_LEVELS):
|
|
||||||
return False
|
|
||||||
changed = False
|
|
||||||
admin_power_level = min(75 if portal.peer_type == "channel" else 50, bot_level)
|
|
||||||
if levels.get_event_level(EventType.ROOM_POWER_LEVELS) != admin_power_level:
|
|
||||||
changed = True
|
|
||||||
levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
|
|
||||||
|
|
||||||
for user in users:
|
|
||||||
# The User objects we get from TelegramClient.get_participants have a custom
|
|
||||||
# participant property
|
|
||||||
participant = getattr(user, "participant", user)
|
|
||||||
|
|
||||||
puppet = await pu.Puppet.get_by_tgid(TelegramID(participant.user_id))
|
|
||||||
user = await u.User.get_by_tgid(TelegramID(participant.user_id))
|
|
||||||
new_level = _get_level_from_participant(portal.az.bot_mxid, participant, levels)
|
|
||||||
|
|
||||||
if user:
|
|
||||||
await user.register_portal(portal)
|
|
||||||
changed = _participant_to_power_levels(levels, user, new_level, bot_level) or changed
|
|
||||||
|
|
||||||
if puppet:
|
|
||||||
changed = _participant_to_power_levels(levels, puppet, new_level, bot_level) or changed
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def _get_level_from_participant(
|
|
||||||
bot_mxid: UserID,
|
|
||||||
participant: TypeUser | TypeChatParticipant | TypeChannelParticipant,
|
|
||||||
levels: PowerLevelContent,
|
|
||||||
) -> int:
|
|
||||||
# TODO use the power level requirements to get better precision in channels
|
|
||||||
if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
|
|
||||||
return levels.state_default or 50
|
|
||||||
elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)):
|
|
||||||
return levels.get_user_level(bot_mxid) - 5
|
|
||||||
return levels.users_default or 0
|
|
||||||
|
|
||||||
|
|
||||||
def _participant_to_power_levels(
|
|
||||||
levels: PowerLevelContent,
|
|
||||||
user: u.User | pu.Puppet,
|
|
||||||
new_level: int,
|
|
||||||
bot_level: int,
|
|
||||||
) -> bool:
|
|
||||||
new_level = min(new_level, bot_level)
|
|
||||||
user_level = levels.get_user_level(user.mxid)
|
|
||||||
if user_level != new_level and user_level < bot_level:
|
|
||||||
levels.users[user.mxid] = new_level
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -1,57 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from asyncio import Lock
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLock:
|
|
||||||
async def __aenter__(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class PortalSendLock:
|
|
||||||
_send_locks: dict[int, Lock]
|
|
||||||
_noop_lock: Lock = FakeLock()
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._send_locks = {}
|
|
||||||
|
|
||||||
def __call__(self, user_id: TelegramID, required: bool = True) -> Lock:
|
|
||||||
if user_id is None and required:
|
|
||||||
raise ValueError("Required send lock for none id")
|
|
||||||
try:
|
|
||||||
return self._send_locks[user_id]
|
|
||||||
except KeyError:
|
|
||||||
return self._send_locks.setdefault(user_id, Lock()) if required else self._noop_lock
|
|
||||||
|
|
||||||
|
|
||||||
class PortalReactionLock:
|
|
||||||
_reaction_locks: dict[EventID, Lock]
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._reaction_locks = defaultdict(lambda: Lock())
|
|
||||||
|
|
||||||
def __call__(self, mxid: EventID) -> Lock:
|
|
||||||
return self._reaction_locks[mxid]
|
|
||||||
@@ -1,95 +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 <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import html
|
|
||||||
|
|
||||||
from telethon.tl.functions.channels import GetSponsoredMessagesRequest
|
|
||||||
from telethon.tl.types import Channel, InputChannel, PeerChannel, PeerUser, SponsoredMessage, User
|
|
||||||
|
|
||||||
from mautrix.types import MessageType, TextMessageEventContent
|
|
||||||
|
|
||||||
from .. import user as u
|
|
||||||
from ..formatter import telegram_to_matrix
|
|
||||||
|
|
||||||
|
|
||||||
async def get_sponsored_message(
|
|
||||||
user: u.User,
|
|
||||||
entity: InputChannel,
|
|
||||||
) -> tuple[SponsoredMessage | None, int | None, Channel | User | None]:
|
|
||||||
resp = await user.client(GetSponsoredMessagesRequest(entity))
|
|
||||||
if len(resp.messages) == 0:
|
|
||||||
return None, None, None
|
|
||||||
msg = resp.messages[0]
|
|
||||||
if isinstance(msg.from_id, PeerUser):
|
|
||||||
entities = resp.users
|
|
||||||
target_id = msg.from_id.user_id
|
|
||||||
else:
|
|
||||||
entities = resp.chats
|
|
||||||
target_id = msg.from_id.channel_id
|
|
||||||
try:
|
|
||||||
entity = next(ent for ent in entities if ent.id == target_id)
|
|
||||||
except StopIteration:
|
|
||||||
entity = None
|
|
||||||
return msg, target_id, entity
|
|
||||||
|
|
||||||
|
|
||||||
async def make_sponsored_message_content(
|
|
||||||
source: u.User, msg: SponsoredMessage, entity: Channel | User
|
|
||||||
) -> TextMessageEventContent | None:
|
|
||||||
content = await telegram_to_matrix(msg, source, require_html=True)
|
|
||||||
content.external_url = f"https://t.me/{entity.username}"
|
|
||||||
content.msgtype = MessageType.NOTICE
|
|
||||||
sponsored_meta = {
|
|
||||||
"random_id": base64.b64encode(msg.random_id).decode("utf-8"),
|
|
||||||
}
|
|
||||||
if isinstance(msg.from_id, PeerChannel):
|
|
||||||
sponsored_meta["channel_id"] = msg.from_id.channel_id
|
|
||||||
if getattr(msg, "channel_post", None) is not None:
|
|
||||||
sponsored_meta["channel_post"] = msg.channel_post
|
|
||||||
content.external_url += f"/{msg.channel_post}"
|
|
||||||
action = "View Post"
|
|
||||||
else:
|
|
||||||
action = "View Channel"
|
|
||||||
elif isinstance(msg.from_id, PeerUser):
|
|
||||||
sponsored_meta["bot_id"] = msg.from_id.user_id
|
|
||||||
if msg.start_param:
|
|
||||||
content.external_url += f"?start={msg.start_param}"
|
|
||||||
action = "View Bot"
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(entity, User):
|
|
||||||
name_parts = [entity.first_name, entity.last_name]
|
|
||||||
sponsor_name = " ".join(x for x in name_parts if x)
|
|
||||||
sponsor_name_html = f"<strong>{html.escape(sponsor_name)}</strong>"
|
|
||||||
elif isinstance(entity, Channel):
|
|
||||||
sponsor_name = entity.title
|
|
||||||
sponsor_name_html = f"<strong>{html.escape(sponsor_name)}</strong>"
|
|
||||||
else:
|
|
||||||
sponsor_name = sponsor_name_html = "unknown entity"
|
|
||||||
|
|
||||||
content["net.maunium.telegram.sponsored"] = sponsored_meta
|
|
||||||
content.formatted_body += (
|
|
||||||
f"<br/><br/>Sponsored message from {sponsor_name_html} "
|
|
||||||
f"- <a href='{content.external_url}'>{action}</a>"
|
|
||||||
)
|
|
||||||
content.body += (
|
|
||||||
f"\n\nSponsored message from {sponsor_name} - {action} at {content.external_url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return content
|
|
||||||
+346
-375
@@ -1,5 +1,6 @@
|
|||||||
|
# -*- coding: future_fstrings -*-
|
||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
# Copyright (C) 2019 Tulir Asokan
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# 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
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -13,504 +14,474 @@
|
|||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from typing import Awaitable, Any, Dict, List, Iterable, Optional, Pattern, Union, TYPE_CHECKING
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, cast
|
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
import unicodedata
|
from enum import Enum
|
||||||
|
from aiohttp import ServerDisconnectedError
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from telethon.tl.types import (
|
from telethon.tl.types import UserProfilePhoto, User, FileLocation, UpdateUserName, PeerUser
|
||||||
Channel,
|
from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError
|
||||||
ChatPhoto,
|
|
||||||
ChatPhotoEmpty,
|
|
||||||
InputPeerPhotoFileLocation,
|
|
||||||
PeerChannel,
|
|
||||||
PeerChat,
|
|
||||||
PeerUser,
|
|
||||||
TypeChatPhoto,
|
|
||||||
TypeInputPeer,
|
|
||||||
TypeInputUser,
|
|
||||||
TypePeer,
|
|
||||||
TypeUserProfilePhoto,
|
|
||||||
UpdateUserName,
|
|
||||||
User,
|
|
||||||
UserProfilePhoto,
|
|
||||||
UserProfilePhotoEmpty,
|
|
||||||
)
|
|
||||||
from yarl import URL
|
|
||||||
|
|
||||||
from mautrix.appservice import IntentAPI
|
from .types import MatrixUserID, TelegramID
|
||||||
from mautrix.bridge import BasePuppet, async_getter_lock
|
|
||||||
from mautrix.types import ContentURI, RoomID, SyncToken, UserID
|
|
||||||
from mautrix.util.simple_template import SimpleTemplate
|
|
||||||
|
|
||||||
from . import abstract_user as au, portal as p, util
|
|
||||||
from .config import Config
|
|
||||||
from .db import Puppet as DBPuppet
|
from .db import Puppet as DBPuppet
|
||||||
from .types import TelegramID
|
from . import util
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .__main__ import TelegramBridge
|
from .matrix import MatrixHandler
|
||||||
|
from .config import Config
|
||||||
|
from .context import Context
|
||||||
|
from .abstract_user import AbstractUser
|
||||||
|
|
||||||
|
PuppetError = Enum('PuppetError', 'Success OnlyLoginSelf InvalidAccessToken')
|
||||||
|
|
||||||
|
config = None # type: Config
|
||||||
|
|
||||||
|
|
||||||
class Puppet(DBPuppet, BasePuppet):
|
class Puppet:
|
||||||
config: Config
|
log = logging.getLogger("mau.puppet") # type: logging.Logger
|
||||||
hs_domain: str
|
az = None # type: AppService
|
||||||
mxid_template: SimpleTemplate[TelegramID]
|
mx = None # type: MatrixHandler
|
||||||
displayname_template: SimpleTemplate[str]
|
loop = None # type: asyncio.AbstractEventLoop
|
||||||
|
mxid_regex = None # type: Pattern
|
||||||
|
username_template = None # type: str
|
||||||
|
hs_domain = None # type: str
|
||||||
|
cache = {} # type: Dict[TelegramID, Puppet]
|
||||||
|
by_custom_mxid = {} # type: Dict[str, Puppet]
|
||||||
|
|
||||||
by_tgid: dict[TelegramID, Puppet] = {}
|
def __init__(self,
|
||||||
by_custom_mxid: dict[UserID, Puppet] = {}
|
id: TelegramID,
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
custom_mxid: Optional[MatrixUserID] = None,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
displayname: Optional[str] = None,
|
||||||
|
displayname_source: Optional[TelegramID] = None,
|
||||||
|
photo_id: Optional[str] = None,
|
||||||
|
is_bot: bool = False,
|
||||||
|
is_registered: bool = False,
|
||||||
|
db_instance: Optional[DBPuppet] = None) -> None:
|
||||||
|
self.id = id # type: TelegramID
|
||||||
|
self.access_token = access_token # type: Optional[str]
|
||||||
|
self.custom_mxid = custom_mxid # type: Optional[MatrixUserID]
|
||||||
|
self.default_mxid = self.get_mxid_from_id(self.id) # type: MatrixUserID
|
||||||
|
|
||||||
def __init__(
|
self.username = username # type: Optional[str]
|
||||||
self,
|
self.displayname = displayname # type: Optional[str]
|
||||||
id: TelegramID,
|
self.displayname_source = displayname_source # type: Optional[TelegramID]
|
||||||
is_registered: bool = False,
|
self.photo_id = photo_id # type: Optional[str]
|
||||||
displayname: str | None = None,
|
self.is_bot = is_bot # type: bool
|
||||||
displayname_source: TelegramID | None = None,
|
self.is_registered = is_registered # type: bool
|
||||||
displayname_contact: bool = True,
|
self._db_instance = db_instance # type: Optional[DBPuppet]
|
||||||
displayname_quality: int = 0,
|
|
||||||
disable_updates: bool = False,
|
|
||||||
username: str | None = None,
|
|
||||||
photo_id: str | None = None,
|
|
||||||
avatar_url: ContentURI | None = None,
|
|
||||||
name_set: bool = False,
|
|
||||||
avatar_set: bool = False,
|
|
||||||
is_bot: bool = False,
|
|
||||||
is_channel: bool = False,
|
|
||||||
custom_mxid: UserID | None = None,
|
|
||||||
access_token: str | None = None,
|
|
||||||
next_batch: SyncToken | None = None,
|
|
||||||
base_url: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
id=id,
|
|
||||||
is_registered=is_registered,
|
|
||||||
displayname=displayname,
|
|
||||||
displayname_source=displayname_source,
|
|
||||||
displayname_contact=displayname_contact,
|
|
||||||
displayname_quality=displayname_quality,
|
|
||||||
disable_updates=disable_updates,
|
|
||||||
username=username,
|
|
||||||
photo_id=photo_id,
|
|
||||||
avatar_url=avatar_url,
|
|
||||||
name_set=name_set,
|
|
||||||
avatar_set=avatar_set,
|
|
||||||
is_bot=is_bot,
|
|
||||||
is_channel=is_channel,
|
|
||||||
custom_mxid=custom_mxid,
|
|
||||||
access_token=access_token,
|
|
||||||
next_batch=next_batch,
|
|
||||||
base_url=base_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.default_mxid = self.get_mxid_from_id(self.id)
|
|
||||||
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
|
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
|
||||||
self.intent = self._fresh_intent()
|
self.intent = self._fresh_intent() # type: IntentAPI
|
||||||
|
self.sync_task = None # type: Optional[asyncio.Future]
|
||||||
|
|
||||||
self.by_tgid[id] = self
|
self.cache[id] = self
|
||||||
if self.custom_mxid:
|
if self.custom_mxid:
|
||||||
self.by_custom_mxid[self.custom_mxid] = self
|
self.by_custom_mxid[self.custom_mxid] = self
|
||||||
|
|
||||||
self.log = self.log.getChild(str(self.id))
|
@property
|
||||||
|
def mxid(self) -> MatrixUserID:
|
||||||
|
return self.custom_mxid or self.default_mxid
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tgid(self) -> TelegramID:
|
def tgid(self) -> TelegramID:
|
||||||
return self.id
|
return self.id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tg_username(self) -> str | None:
|
def is_real_user(self) -> bool:
|
||||||
return self.username
|
""" Is True when the puppet is a real Matrix user. """
|
||||||
|
return bool(self.custom_mxid and self.access_token)
|
||||||
|
|
||||||
@property
|
@staticmethod
|
||||||
def peer(self) -> PeerUser:
|
async def is_logged_in() -> bool:
|
||||||
return (
|
""" Is True if the puppet is logged in. """
|
||||||
PeerChannel(channel_id=self.tgid) if self.is_channel else PeerUser(user_id=self.tgid)
|
return True
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def plain_displayname(self) -> str:
|
def plain_displayname(self) -> str:
|
||||||
return self.displayname_template.parse(self.displayname) or self.displayname
|
tpl = config["bridge.displayname_template"]
|
||||||
|
if tpl == "{displayname}":
|
||||||
|
# Template has no extra stuff, no need to parse.
|
||||||
|
return self.displayname
|
||||||
|
regex = re.compile("^" + re.escape(tpl).replace(re.escape("{displayname}"), "(.+?)") + "$")
|
||||||
|
match = regex.match(self.displayname)
|
||||||
|
return match.group(1) or self.displayname
|
||||||
|
|
||||||
def get_input_entity(self, user: au.AbstractUser) -> Awaitable[TypeInputPeer | TypeInputUser]:
|
# region Custom puppet management
|
||||||
return user.client.get_input_entity(self.peer)
|
def _fresh_intent(self) -> IntentAPI:
|
||||||
|
return (self.az.intent.user(self.custom_mxid, self.access_token)
|
||||||
|
if self.is_real_user else self.default_mxid_intent)
|
||||||
|
|
||||||
def intent_for(self, portal: p.Portal) -> IntentAPI:
|
async def switch_mxid(self, access_token: Optional[str],
|
||||||
if portal.tgid == self.tgid:
|
mxid: Optional[MatrixUserID]) -> PuppetError:
|
||||||
return self.default_mxid_intent
|
prev_mxid = self.custom_mxid
|
||||||
return self.intent
|
self.custom_mxid = mxid
|
||||||
|
self.access_token = access_token
|
||||||
|
self.intent = self._fresh_intent()
|
||||||
|
|
||||||
|
err = await self.init_custom_mxid()
|
||||||
|
if err != PuppetError.Success:
|
||||||
|
return err
|
||||||
|
|
||||||
|
try:
|
||||||
|
del self.by_custom_mxid[prev_mxid] # type: ignore
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
if self.mxid != self.default_mxid:
|
||||||
|
self.by_custom_mxid[self.mxid] = self
|
||||||
|
await self.leave_rooms_with_default_user()
|
||||||
|
self.save()
|
||||||
|
return PuppetError.Success
|
||||||
|
|
||||||
|
async def init_custom_mxid(self) -> PuppetError:
|
||||||
|
if not self.is_real_user:
|
||||||
|
return PuppetError.Success
|
||||||
|
|
||||||
|
mxid = await self.intent.whoami()
|
||||||
|
if not mxid or mxid != self.custom_mxid:
|
||||||
|
self.custom_mxid = None
|
||||||
|
self.access_token = None
|
||||||
|
self.intent = self._fresh_intent()
|
||||||
|
if mxid != self.custom_mxid:
|
||||||
|
return PuppetError.OnlyLoginSelf
|
||||||
|
return PuppetError.InvalidAccessToken
|
||||||
|
if config["bridge.sync_with_custom_puppets"]:
|
||||||
|
self.sync_task = asyncio.ensure_future(self.sync(), loop=self.loop)
|
||||||
|
return PuppetError.Success
|
||||||
|
|
||||||
|
async def leave_rooms_with_default_user(self) -> None:
|
||||||
|
for room_id in await self.default_mxid_intent.get_joined_rooms():
|
||||||
|
try:
|
||||||
|
await self.default_mxid_intent.leave_room(room_id)
|
||||||
|
await self.intent.ensure_joined(room_id)
|
||||||
|
except (IntentError, MatrixRequestError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def create_sync_filter(self) -> Awaitable[str]:
|
||||||
|
return self.intent.client.create_filter(self.custom_mxid, {
|
||||||
|
"room": {
|
||||||
|
"include_leave": False,
|
||||||
|
"state": {
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"timeline": {
|
||||||
|
"types": [],
|
||||||
|
},
|
||||||
|
"ephemeral": {
|
||||||
|
"types": ["m.typing", "m.receipt"],
|
||||||
|
},
|
||||||
|
"account_data": {
|
||||||
|
"types": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"account_data": {
|
||||||
|
"types": [],
|
||||||
|
},
|
||||||
|
"presence": {
|
||||||
|
"types": ["m.presence"],
|
||||||
|
"senders": [self.custom_mxid],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
def filter_events(self, events: List[Dict]) -> List:
|
||||||
|
new_events = []
|
||||||
|
for event in events:
|
||||||
|
evt_type = event.get("type", None)
|
||||||
|
event.setdefault("content", {})
|
||||||
|
if evt_type == "m.typing":
|
||||||
|
is_typing = self.custom_mxid in event["content"].get("user_ids", [])
|
||||||
|
event["content"]["user_ids"] = [self.custom_mxid] if is_typing else []
|
||||||
|
elif evt_type == "m.receipt":
|
||||||
|
val = None
|
||||||
|
evt = None
|
||||||
|
for event_id in event["content"]:
|
||||||
|
try:
|
||||||
|
val = event["content"][event_id]["m.read"][self.custom_mxid]
|
||||||
|
evt = event_id
|
||||||
|
break
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
if val and evt:
|
||||||
|
event["content"] = {evt: {"m.read": {
|
||||||
|
self.custom_mxid: val
|
||||||
|
}}}
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
new_events.append(event)
|
||||||
|
return new_events
|
||||||
|
|
||||||
|
def handle_sync(self, presence: List, ephemeral: Dict) -> None:
|
||||||
|
presence_events = [self.mx.try_handle_event(event) for event in presence]
|
||||||
|
|
||||||
|
for room_id, events in ephemeral.items():
|
||||||
|
for event in events:
|
||||||
|
event["room_id"] = room_id
|
||||||
|
|
||||||
|
ephemeral_events = [self.mx.try_handle_event(event)
|
||||||
|
for events in ephemeral.values()
|
||||||
|
for event in self.filter_events(events)]
|
||||||
|
|
||||||
|
events = ephemeral_events + presence_events # List[Callable[[int], Awaitable[None]]]
|
||||||
|
coro = asyncio.gather(*events, loop=self.loop)
|
||||||
|
asyncio.ensure_future(coro, loop=self.loop)
|
||||||
|
|
||||||
|
async def sync(self) -> None:
|
||||||
|
try:
|
||||||
|
await self._sync()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.log.info("Syncing cancelled")
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Fatal error syncing")
|
||||||
|
|
||||||
|
async def _sync(self) -> None:
|
||||||
|
if not self.is_real_user:
|
||||||
|
self.log.warning("Called sync() for non-custom puppet.")
|
||||||
|
return
|
||||||
|
custom_mxid = self.custom_mxid
|
||||||
|
access_token_at_start = self.access_token
|
||||||
|
errors = 0
|
||||||
|
next_batch = None
|
||||||
|
filter_id = await self.create_sync_filter()
|
||||||
|
self.log.debug(f"Starting syncer for {custom_mxid} with sync filter {filter_id}.")
|
||||||
|
while access_token_at_start == self.access_token:
|
||||||
|
try:
|
||||||
|
sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch,
|
||||||
|
set_presence="offline") # type: Dict
|
||||||
|
errors = 0
|
||||||
|
if next_batch is not None:
|
||||||
|
presence = sync_resp.get("presence", {}).get("events", []) # type: List
|
||||||
|
ephemeral = {room: data.get("ephemeral", {}).get("events", [])
|
||||||
|
for room, data
|
||||||
|
in sync_resp.get("rooms", {}).get("join", {}).items()
|
||||||
|
} # type: Dict
|
||||||
|
self.handle_sync(presence, ephemeral)
|
||||||
|
next_batch = sync_resp.get("next_batch", None)
|
||||||
|
except (MatrixRequestError, ServerDisconnectedError) as e:
|
||||||
|
wait = min(errors, 11) ** 2
|
||||||
|
self.log.warning(f"Syncer for {custom_mxid} errored: {e}. "
|
||||||
|
f"Waiting for {wait} seconds...")
|
||||||
|
errors += 1
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.")
|
||||||
|
|
||||||
|
# endregion
|
||||||
|
# region DB conversion
|
||||||
|
|
||||||
|
@property
|
||||||
|
def db_instance(self) -> DBPuppet:
|
||||||
|
if not self._db_instance:
|
||||||
|
self._db_instance = self.new_db_instance()
|
||||||
|
return self._db_instance
|
||||||
|
|
||||||
|
def new_db_instance(self) -> DBPuppet:
|
||||||
|
return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid,
|
||||||
|
username=self.username, displayname=self.displayname,
|
||||||
|
displayname_source=self.displayname_source, photo_id=self.photo_id,
|
||||||
|
is_bot=self.is_bot, matrix_registered=self.is_registered)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
|
def from_db(cls, db_puppet: DBPuppet) -> 'Puppet':
|
||||||
cls.config = bridge.config
|
return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid,
|
||||||
cls.loop = bridge.loop
|
db_puppet.username, db_puppet.displayname, db_puppet.displayname_source,
|
||||||
cls.mx = bridge.matrix
|
db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered,
|
||||||
cls.az = bridge.az
|
db_instance=db_puppet)
|
||||||
cls.hs_domain = cls.config["homeserver.domain"]
|
|
||||||
mxid_tpl = SimpleTemplate(
|
|
||||||
cls.config["bridge.username_template"],
|
|
||||||
"userid",
|
|
||||||
prefix="@",
|
|
||||||
suffix=f":{Puppet.hs_domain}",
|
|
||||||
type=int,
|
|
||||||
)
|
|
||||||
cls.mxid_template = cast(SimpleTemplate[TelegramID], mxid_tpl)
|
|
||||||
cls.displayname_template = SimpleTemplate(
|
|
||||||
cls.config["bridge.displayname_template"], "displayname"
|
|
||||||
)
|
|
||||||
cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"]
|
|
||||||
cls.homeserver_url_map = {
|
|
||||||
server: URL(url)
|
|
||||||
for server, url in cls.config["bridge.double_puppet_server_map"].items()
|
|
||||||
}
|
|
||||||
cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"]
|
|
||||||
cls.login_shared_secret_map = {
|
|
||||||
server: secret.encode("utf-8")
|
|
||||||
for server, secret in cls.config["bridge.login_shared_secret_map"].items()
|
|
||||||
}
|
|
||||||
cls.login_device_name = "Telegram Bridge"
|
|
||||||
|
|
||||||
return (puppet.try_start() async for puppet in cls.all_with_custom_mxid())
|
def save(self) -> None:
|
||||||
|
self.db_instance.update(access_token=self.access_token, custom_mxid=self.custom_mxid,
|
||||||
|
username=self.username, displayname=self.displayname,
|
||||||
|
displayname_source=self.displayname_source, photo_id=self.photo_id,
|
||||||
|
is_bot=self.is_bot, matrix_registered=self.is_registered)
|
||||||
|
|
||||||
|
# endregion
|
||||||
# region Info updating
|
# region Info updating
|
||||||
|
|
||||||
def similarity(self, query: str) -> int:
|
def similarity(self, query: str) -> int:
|
||||||
username_similarity = (
|
username_similarity = (SequenceMatcher(None, self.username, query).ratio()
|
||||||
SequenceMatcher(None, self.username, query).ratio() if self.username else 0
|
if self.username else 0)
|
||||||
)
|
displayname_similarity = (SequenceMatcher(None, self.displayname, query).ratio()
|
||||||
displayname_similarity = (
|
if self.displayname else 0)
|
||||||
SequenceMatcher(None, self.plain_displayname, query).ratio() if self.displayname else 0
|
|
||||||
)
|
|
||||||
similarity = max(username_similarity, displayname_similarity)
|
similarity = max(username_similarity, displayname_similarity)
|
||||||
return int(round(similarity * 100))
|
return int(round(similarity * 100))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _filter_name(name: str) -> str:
|
def get_displayname(info: User, enable_format: bool = True) -> str:
|
||||||
if not name:
|
|
||||||
return ""
|
|
||||||
whitespace = (
|
|
||||||
"\t\n\r\v\f \u00a0\u034f\u180e\u2063\u202f\u205f\u2800\u3000\u3164\ufeff\u2000\u2001"
|
|
||||||
"\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b\u200c\u200d\u200e\u200f"
|
|
||||||
"\ufe0f"
|
|
||||||
)
|
|
||||||
allowed_other_format = ("\u200d", "\u200c")
|
|
||||||
name = "".join(
|
|
||||||
c
|
|
||||||
for c in name.strip(whitespace)
|
|
||||||
if unicodedata.category(c) != "Cf" or c in allowed_other_format
|
|
||||||
)
|
|
||||||
return name
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_displayname(cls, info: User | Channel, enable_format: bool = True) -> tuple[str, int]:
|
|
||||||
if isinstance(info, Channel):
|
|
||||||
fn, ln = cls._filter_name(info.title), ""
|
|
||||||
else:
|
|
||||||
fn = cls._filter_name(info.first_name)
|
|
||||||
ln = cls._filter_name(info.last_name)
|
|
||||||
data = {
|
data = {
|
||||||
"phone number": info.phone if hasattr(info, "phone") else None,
|
"phone number": info.phone if hasattr(info, "phone") else None,
|
||||||
"username": info.username,
|
"username": info.username,
|
||||||
"full name": " ".join([fn, ln]).strip(),
|
"full name": " ".join([info.first_name or "", info.last_name or ""]).strip(),
|
||||||
"full name reversed": " ".join([ln, fn]).strip(),
|
"full name reversed": " ".join([info.first_name or "", info.last_name or ""]).strip(),
|
||||||
"first name": fn,
|
"first name": info.first_name,
|
||||||
"last name": ln,
|
"last name": info.last_name,
|
||||||
}
|
}
|
||||||
preferences = cls.config["bridge.displayname_preference"]
|
preferences = config["bridge.displayname_preference"]
|
||||||
name = None
|
name = None
|
||||||
quality = 99
|
|
||||||
for preference in preferences:
|
for preference in preferences:
|
||||||
name = data[preference]
|
name = data[preference]
|
||||||
if name:
|
if name:
|
||||||
break
|
break
|
||||||
quality -= 1
|
|
||||||
|
|
||||||
if isinstance(info, User) and info.deleted:
|
if isinstance(info, User) and info.deleted:
|
||||||
name = f"Deleted account {info.id}"
|
name = f"Deleted account {info.id}"
|
||||||
quality = 99
|
|
||||||
elif not name:
|
elif not name:
|
||||||
name = str(info.id)
|
name = info.id
|
||||||
quality = 0
|
|
||||||
|
|
||||||
return (cls.displayname_template.format_full(name) if enable_format else name), quality
|
if not enable_format:
|
||||||
|
return name
|
||||||
async def try_update_info(self, source: au.AbstractUser, info: User | Channel) -> None:
|
return config["bridge.displayname_template"].format(
|
||||||
try:
|
displayname=name)
|
||||||
await self.update_info(source, info)
|
|
||||||
except Exception:
|
|
||||||
source.log.exception(f"Failed to update info of {self.tgid}")
|
|
||||||
|
|
||||||
async def update_info(self, source: au.AbstractUser, info: User | Channel) -> None:
|
|
||||||
is_bot = False if isinstance(info, Channel) else info.bot
|
|
||||||
is_channel = isinstance(info, Channel)
|
|
||||||
changed = is_bot != self.is_bot or is_channel != self.is_channel
|
|
||||||
|
|
||||||
self.is_bot = is_bot
|
|
||||||
self.is_channel = is_channel
|
|
||||||
|
|
||||||
|
async def update_info(self, source: 'AbstractUser', info: User) -> None:
|
||||||
|
changed = False
|
||||||
if self.username != info.username:
|
if self.username != info.username:
|
||||||
self.username = info.username
|
self.username = info.username
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
if not self.disable_updates:
|
changed = await self.update_displayname(source, info) or changed
|
||||||
try:
|
if isinstance(info.photo, UserProfilePhoto):
|
||||||
changed = await self.update_displayname(source, info) or changed
|
changed = await self.update_avatar(source, info.photo.photo_big) or changed
|
||||||
changed = await self.update_avatar(source, info.photo) or changed
|
|
||||||
except Exception:
|
self.is_bot = info.bot
|
||||||
self.log.exception(f"Failed to update info from source {source.tgid}")
|
|
||||||
|
|
||||||
if changed:
|
if changed:
|
||||||
await self.update_portals_meta()
|
self.save()
|
||||||
await self.save()
|
|
||||||
|
|
||||||
async def update_portals_meta(self) -> None:
|
async def update_displayname(self, source: 'AbstractUser', info: Union[User, UpdateUserName]
|
||||||
if not p.Portal.private_chat_portal_meta and not self.mx.e2ee:
|
) -> bool:
|
||||||
return
|
ignore_source = (not source.is_relaybot
|
||||||
async for portal in p.Portal.find_private_chats_with(self.tgid):
|
and self.displayname_source is not None
|
||||||
await portal.update_info_from_puppet(self)
|
and self.displayname_source != source.tgid)
|
||||||
|
if ignore_source:
|
||||||
async def update_displayname(
|
|
||||||
self, source: au.AbstractUser, info: User | Channel | UpdateUserName
|
|
||||||
) -> bool:
|
|
||||||
if self.disable_updates:
|
|
||||||
return False
|
return False
|
||||||
if (
|
|
||||||
self.displayname
|
|
||||||
and self.displayname.startswith("Deleted user ")
|
|
||||||
and not getattr(info, "deleted", False)
|
|
||||||
):
|
|
||||||
allow_because = "target user was previously deleted"
|
|
||||||
self.displayname_quality = 0
|
|
||||||
elif source.is_relaybot or source.is_bot:
|
|
||||||
allow_because = "source user is a bot"
|
|
||||||
elif self.displayname_source == source.tgid:
|
|
||||||
allow_because = "source user is the primary source"
|
|
||||||
elif isinstance(info, Channel):
|
|
||||||
allow_because = "target user is a channel"
|
|
||||||
elif not isinstance(info, UpdateUserName) and not info.contact:
|
|
||||||
allow_because = "target user is not a contact"
|
|
||||||
elif not self.displayname_source:
|
|
||||||
allow_because = "no primary source set"
|
|
||||||
elif not self.displayname:
|
|
||||||
allow_because = "target user has no name"
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if isinstance(info, UpdateUserName):
|
if isinstance(info, UpdateUserName):
|
||||||
info = await source.client.get_entity(self.peer)
|
info = await source.client.get_entity(PeerUser(self.tgid))
|
||||||
if isinstance(info, Channel) or not info.contact:
|
|
||||||
self.displayname_contact = False
|
|
||||||
elif not self.displayname_contact:
|
|
||||||
if not self.displayname:
|
|
||||||
self.displayname_contact = True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
displayname, quality = self.get_displayname(info)
|
displayname = self.get_displayname(info)
|
||||||
needs_reset = displayname != self.displayname or not self.name_set
|
if displayname != self.displayname:
|
||||||
is_high_quality = quality >= self.displayname_quality
|
await self.default_mxid_intent.set_display_name(displayname)
|
||||||
if needs_reset and is_high_quality:
|
|
||||||
allow_because = f"{allow_because} and quality {quality} >= {self.displayname_quality}"
|
|
||||||
self.log.debug(
|
|
||||||
f"Updating displayname of {self.id} (src: {source.tgid}, allowed "
|
|
||||||
f"because {allow_because}) from {self.displayname} to {displayname}"
|
|
||||||
)
|
|
||||||
self.log.trace("Displayname source data: %s", info)
|
|
||||||
self.displayname = displayname
|
self.displayname = displayname
|
||||||
self.displayname_source = source.tgid
|
self.displayname_source = source.tgid
|
||||||
self.displayname_quality = quality
|
|
||||||
try:
|
|
||||||
await self.default_mxid_intent.set_displayname(
|
|
||||||
displayname[: self.config["bridge.displayname_max_length"]]
|
|
||||||
)
|
|
||||||
self.name_set = True
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to set displayname: {e}")
|
|
||||||
self.name_set = False
|
|
||||||
return True
|
return True
|
||||||
elif source.is_relaybot or self.displayname_source is None:
|
elif source.is_relaybot or self.displayname_source is None:
|
||||||
self.displayname_source = source.tgid
|
self.displayname_source = source.tgid
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def update_avatar(
|
async def update_avatar(self, source: 'AbstractUser', photo: FileLocation) -> bool:
|
||||||
self, source: au.AbstractUser, photo: TypeUserProfilePhoto | TypeChatPhoto
|
photo_id = f"{photo.volume_id}-{photo.local_id}"
|
||||||
) -> bool:
|
if self.photo_id != photo_id:
|
||||||
if self.disable_updates:
|
file = await util.transfer_file_to_matrix(source.client, self.default_mxid_intent,
|
||||||
return False
|
photo)
|
||||||
|
if file:
|
||||||
if photo is None or isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty)):
|
await self.default_mxid_intent.set_avatar(file.mxc)
|
||||||
photo_id = ""
|
|
||||||
elif isinstance(photo, (UserProfilePhoto, ChatPhoto)):
|
|
||||||
photo_id = str(photo.photo_id)
|
|
||||||
else:
|
|
||||||
self.log.warning(f"Unknown user profile photo type: {type(photo)}")
|
|
||||||
return False
|
|
||||||
if not photo_id and not self.config["bridge.allow_avatar_remove"]:
|
|
||||||
return False
|
|
||||||
if self.photo_id != photo_id or not self.avatar_set:
|
|
||||||
if not photo_id:
|
|
||||||
self.photo_id = ""
|
|
||||||
self.avatar_url = None
|
|
||||||
elif self.photo_id != photo_id or not self.avatar_url:
|
|
||||||
file = await util.transfer_file_to_matrix(
|
|
||||||
client=source.client,
|
|
||||||
intent=self.default_mxid_intent,
|
|
||||||
location=InputPeerPhotoFileLocation(
|
|
||||||
peer=await self.get_input_entity(source), photo_id=photo.photo_id, big=True
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if not file:
|
|
||||||
return False
|
|
||||||
self.photo_id = photo_id
|
self.photo_id = photo_id
|
||||||
self.avatar_url = file.mxc
|
return True
|
||||||
try:
|
|
||||||
await self.default_mxid_intent.set_avatar_url(self.avatar_url or "")
|
|
||||||
self.avatar_set = True
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to set avatar: {e}")
|
|
||||||
self.avatar_set = False
|
|
||||||
return True
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
|
|
||||||
portal: p.Portal = await p.Portal.get_by_mxid(room_id)
|
|
||||||
return portal and not portal.backfill_lock.locked and portal.peer_type != "user"
|
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
# region Getters
|
# region Getters
|
||||||
|
|
||||||
def _add_to_cache(self) -> None:
|
|
||||||
self.by_tgid[self.id] = self
|
|
||||||
if self.custom_mxid:
|
|
||||||
self.by_custom_mxid[self.custom_mxid] = self
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@async_getter_lock
|
def get(cls, tgid: TelegramID, create: bool = True) -> Optional['Puppet']:
|
||||||
async def get_by_tgid(
|
|
||||||
cls, tgid: TelegramID, *, create: bool = True, is_channel: bool = False
|
|
||||||
) -> Puppet | None:
|
|
||||||
if tgid is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return cls.by_tgid[tgid]
|
return cls.cache[tgid]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
puppet = cast(cls, await super().get_by_tgid(tgid))
|
puppet = DBPuppet.get_by_tgid(tgid)
|
||||||
if puppet:
|
if puppet:
|
||||||
puppet._add_to_cache()
|
return cls.from_db(puppet)
|
||||||
return puppet
|
|
||||||
|
|
||||||
if create:
|
if create:
|
||||||
puppet = cls(tgid, is_channel=is_channel)
|
puppet = cls(tgid)
|
||||||
await puppet.insert()
|
puppet.db_instance.insert()
|
||||||
puppet._add_to_cache()
|
|
||||||
return puppet
|
return puppet
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@classmethod
|
||||||
def get_id_from_peer(peer: TypePeer | User | Channel) -> TelegramID:
|
def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['Puppet']:
|
||||||
if isinstance(peer, PeerUser):
|
tgid = cls.get_id_from_mxid(mxid)
|
||||||
return TelegramID(peer.user_id)
|
if tgid:
|
||||||
elif isinstance(peer, PeerChannel):
|
return cls.get(tgid, create)
|
||||||
return TelegramID(peer.channel_id)
|
|
||||||
elif isinstance(peer, PeerChat):
|
return None
|
||||||
return TelegramID(peer.chat_id)
|
|
||||||
elif isinstance(peer, (User, Channel)):
|
|
||||||
return TelegramID(peer.id)
|
|
||||||
raise TypeError(f"invalid type {type(peer).__name__!r} in _id_from_peer()")
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_peer(
|
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
|
||||||
cls, peer: TypePeer | User | Channel, *, create: bool = True
|
if not mxid:
|
||||||
) -> Puppet | None:
|
raise ValueError("Matrix ID can't be empty")
|
||||||
if isinstance(peer, PeerChat):
|
|
||||||
return None
|
|
||||||
return await cls.get_by_tgid(
|
|
||||||
cls.get_id_from_peer(peer),
|
|
||||||
create=create,
|
|
||||||
is_channel=isinstance(peer, (PeerChannel, Channel)),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Awaitable[Puppet | None]:
|
|
||||||
return cls.get_by_tgid(cls.get_id_from_mxid(mxid), create=create)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@async_getter_lock
|
|
||||||
async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
|
|
||||||
try:
|
try:
|
||||||
return cls.by_custom_mxid[mxid]
|
return cls.by_custom_mxid[mxid]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
puppet = cast(cls, await super().get_by_custom_mxid(mxid))
|
puppet = DBPuppet.get_by_custom_mxid(mxid)
|
||||||
if puppet:
|
if puppet:
|
||||||
puppet._add_to_cache()
|
puppet = cls.from_db(puppet)
|
||||||
return puppet
|
return puppet
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def all_with_custom_mxid(cls) -> AsyncGenerator[Puppet, None]:
|
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
|
||||||
puppets = await super().all_with_custom_mxid()
|
return (cls.by_custom_mxid[puppet.mxid]
|
||||||
puppet: cls
|
if puppet.custom_mxid in cls.by_custom_mxid
|
||||||
for puppet in puppets:
|
else cls.from_db(puppet)
|
||||||
try:
|
for puppet in DBPuppet.all_with_custom_mxid())
|
||||||
yield cls.by_tgid[puppet.tgid]
|
|
||||||
except KeyError:
|
|
||||||
puppet._add_to_cache()
|
|
||||||
yield puppet
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_id_from_mxid(cls, mxid: UserID) -> TelegramID | None:
|
def get_id_from_mxid(cls, mxid: MatrixUserID) -> Optional[TelegramID]:
|
||||||
return cls.mxid_template.parse(mxid)
|
match = cls.mxid_regex.match(mxid)
|
||||||
|
if match:
|
||||||
|
return TelegramID(int(match.group(1)))
|
||||||
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_mxid_from_id(cls, tgid: TelegramID) -> UserID:
|
def get_mxid_from_id(cls, tgid: TelegramID) -> MatrixUserID:
|
||||||
return UserID(cls.mxid_template.format_full(tgid))
|
return MatrixUserID(f"@{cls.username_template.format(userid=tgid)}:{cls.hs_domain}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def find_by_username(cls, username: str) -> Puppet | None:
|
def find_by_username(cls, username: str) -> Optional['Puppet']:
|
||||||
if not username:
|
if not username:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
username = username.lower()
|
for _, puppet in cls.cache.items():
|
||||||
|
if puppet.username and puppet.username.lower() == username.lower():
|
||||||
for _, puppet in cls.by_tgid.items():
|
|
||||||
if puppet.username and puppet.username.lower() == username:
|
|
||||||
return puppet
|
return puppet
|
||||||
|
|
||||||
puppet = cast(cls, await super().find_by_username(username))
|
dbpuppet = DBPuppet.get_by_username(username)
|
||||||
if puppet:
|
if dbpuppet:
|
||||||
try:
|
return cls.from_db(dbpuppet)
|
||||||
return cls.by_tgid[puppet.tgid]
|
|
||||||
except KeyError:
|
|
||||||
puppet._add_to_cache()
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def find_by_displayname(cls, displayname: str) -> Puppet | None:
|
def find_by_displayname(cls, displayname: str) -> Optional['Puppet']:
|
||||||
if not displayname:
|
if not displayname:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
for _, puppet in cls.by_tgid.items():
|
for _, puppet in cls.cache.items():
|
||||||
if puppet.displayname and puppet.displayname == displayname:
|
if puppet.displayname and puppet.displayname == displayname:
|
||||||
return puppet
|
return puppet
|
||||||
|
|
||||||
puppet = cast(cls, await super().find_by_displayname(displayname))
|
dbpuppet = DBPuppet.get_by_displayname(displayname)
|
||||||
if puppet:
|
if dbpuppet:
|
||||||
try:
|
return cls.from_db(dbpuppet)
|
||||||
return cls.by_tgid[puppet.tgid]
|
|
||||||
except KeyError:
|
|
||||||
puppet._add_to_cache()
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
|
|
||||||
|
|
||||||
|
def init(context: 'Context') -> List[Awaitable[Any]]: # [None, None, PuppetError]
|
||||||
|
global config
|
||||||
|
Puppet.az, config, Puppet.loop, _ = context.core
|
||||||
|
Puppet.mx = context.mx
|
||||||
|
Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}")
|
||||||
|
Puppet.hs_domain = config["homeserver"]["domain"]
|
||||||
|
Puppet.mxid_regex = re.compile(
|
||||||
|
f"@{Puppet.username_template.format(userid='([0-9]+)')}:{Puppet.hs_domain}")
|
||||||
|
return [puppet.init_custom_mxid() for puppet in Puppet.all_with_custom_mxid()]
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import argparse
|
||||||
|
import sqlalchemy as sql
|
||||||
|
from sqlalchemy import orm
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
|
from alchemysession import AlchemySessionContainer
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="mautrix-telegram dbms migration script",
|
||||||
|
prog="python -m mautrix_telegram.scripts.dbms_migrate")
|
||||||
|
parser.add_argument("-f", "--from-url", type=str, required=True, metavar="<url>",
|
||||||
|
help="the old database path")
|
||||||
|
parser.add_argument("-t", "--to-url", type=str, required=True, metavar="<url>",
|
||||||
|
help="the new database path")
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logs while migrating")
|
||||||
|
args = parser.parse_args()
|
||||||
|
verbose = args.verbose or False
|
||||||
|
|
||||||
|
|
||||||
|
def log(message, end="\n"):
|
||||||
|
if verbose:
|
||||||
|
print(message, end=end, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def connect(to):
|
||||||
|
import mautrix_telegram.db.base as base
|
||||||
|
base.Base = declarative_base(cls=base.BaseBase)
|
||||||
|
from mautrix_telegram.db import (Portal, Message, UserPortal, User, RoomState, UserProfile,
|
||||||
|
Contact, Puppet, BotChat, TelegramFile)
|
||||||
|
db_engine = sql.create_engine(to)
|
||||||
|
db_factory = orm.sessionmaker(bind=db_engine)
|
||||||
|
db_session = orm.scoped_session(db_factory) # type: orm.Session
|
||||||
|
base.Base.metadata.bind = db_engine
|
||||||
|
session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
|
||||||
|
table_base=base.Base, table_prefix="telethon_",
|
||||||
|
manage_tables=False)
|
||||||
|
|
||||||
|
return db_session, {
|
||||||
|
"Version": session_container.Version,
|
||||||
|
"Session": session_container.Session,
|
||||||
|
"Entity": session_container.Entity,
|
||||||
|
"SentFile": session_container.SentFile,
|
||||||
|
"UpdateState": session_container.UpdateState,
|
||||||
|
"Portal": Portal,
|
||||||
|
"Message": Message,
|
||||||
|
"Puppet": Puppet,
|
||||||
|
"User": User,
|
||||||
|
"UserPortal": UserPortal,
|
||||||
|
"RoomState": RoomState,
|
||||||
|
"UserProfile": UserProfile,
|
||||||
|
"Contact": Contact,
|
||||||
|
"BotChat": BotChat,
|
||||||
|
"TelegramFile": TelegramFile,
|
||||||
|
}
|
||||||
|
|
||||||
|
log("Connecting to old database")
|
||||||
|
session, tables = connect(args.from_url)
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
for name, table in tables.items():
|
||||||
|
log("Reading table {name}...".format(name=name), end=" ")
|
||||||
|
data[name] = session.query(table).all()
|
||||||
|
log("Done!")
|
||||||
|
|
||||||
|
log("Connecting to new database")
|
||||||
|
session, tables = connect(args.to_url)
|
||||||
|
|
||||||
|
for name, table in tables.items():
|
||||||
|
log("Writing table {name}".format(name=name), end="")
|
||||||
|
length = len(data[name])
|
||||||
|
n = 0
|
||||||
|
for row in data[name]:
|
||||||
|
session.merge(row)
|
||||||
|
n += 5
|
||||||
|
if n >= length:
|
||||||
|
log(".", end="")
|
||||||
|
n = 0
|
||||||
|
log(" Done!")
|
||||||
|
|
||||||
|
log("Committing changes to database...", end=" ")
|
||||||
|
session.commit()
|
||||||
|
log("Done!")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user