Compare commits
258 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8114ff5ad | |||
| 280c74e9cd | |||
| 4f1482e7b0 | |||
| 4641215e97 | |||
| 2f34ebfed9 | |||
| b65a1cc60a | |||
| 53bf278f1e | |||
| 35f137ccc1 | |||
| 31846e7a98 | |||
| 9ab2ee2970 | |||
| c7dd08ecd1 | |||
| 8fbd723bfa | |||
| 530bd9e52e | |||
| 6480e7925e | |||
| c70ab2a12b | |||
| 070bfd4f55 | |||
| 88c3a93526 | |||
| caefda582b | |||
| e1b181ed55 | |||
| cc6a915ef4 | |||
| de4df57278 | |||
| 0068341185 | |||
| efcf1535ff | |||
| 99f633e98d | |||
| 0137bfcbf6 | |||
| f6cb26f7f5 | |||
| 6418202118 | |||
| 4b25e855e0 | |||
| a35f6abfd1 | |||
| 716222a671 | |||
| 31801a436c | |||
| 8bd5a4e367 | |||
| 43d17a335b | |||
| 84a3fde1ca | |||
| 05d05e671b | |||
| ab6a6654f7 | |||
| dbfbf12862 | |||
| 6166173376 | |||
| 2232d9898e | |||
| 3cf279718f | |||
| 65ec4491e2 | |||
| ce43607c56 | |||
| 150bf5e338 | |||
| 77cbbebfb2 | |||
| 511043a720 | |||
| 19a4b4374d | |||
| 731d5e028a | |||
| 5ea9e48954 | |||
| 73b26e3fbd | |||
| 48be895938 | |||
| 87909d07ec | |||
| 3609eb2b70 | |||
| 562f646fea | |||
| ab3cf5bc5f | |||
| 1b2f07dfa2 | |||
| 2a67c96db3 | |||
| 3fdb789745 | |||
| e4c239e6bc | |||
| 897a35be5d | |||
| d72897dfe8 | |||
| 27723f5055 | |||
| a84e5ebc6a | |||
| 90a8583ad0 | |||
| bf2cef424b | |||
| 6809ebcde9 | |||
| 6fafc533ab | |||
| 060dd647c3 | |||
| 812b4ec8db | |||
| 8c1ddec136 | |||
| 08db5a687c | |||
| ec298b2b90 | |||
| 22f91d51a3 | |||
| d033042ee1 | |||
| 2270f4fe40 | |||
| 6d208b37a5 | |||
| 55ebaef6e3 | |||
| 215f077cf0 | |||
| 4e4f409f87 | |||
| 4d145f4716 | |||
| b833a41a88 | |||
| 768d51c4ae | |||
| f7db298fda | |||
| 4f2118c7ee | |||
| 4f0770b92d | |||
| 1fb8a7a0a5 | |||
| f79ab283f3 | |||
| 23ec691128 | |||
| 59213ebeae | |||
| 36b2f6af2e | |||
| b2249f7756 | |||
| 212023d296 | |||
| 4b03134620 | |||
| 806eea53eb | |||
| 4ca3ee58ac | |||
| 8b003f1187 | |||
| c06a2b2473 | |||
| f2194c6f33 | |||
| b5c294a558 | |||
| c6b6ec048e | |||
| fb461109c1 | |||
| 0411affc88 | |||
| dfe22800dd | |||
| 7868b05ed3 | |||
| 0474f81044 | |||
| ed471a6623 | |||
| 4504973aff | |||
| a5a71edede | |||
| e1c800f3e6 | |||
| 810f86343a | |||
| 5f7d3ac8c1 | |||
| cb5c51cd27 | |||
| 759ccf301c | |||
| 40e4c7e251 | |||
| e12f1784e2 | |||
| 6b8e265f8b | |||
| de33b553be | |||
| ed24a0b89f | |||
| e2697e5a17 | |||
| c4037ccf11 | |||
| 6c6fe134ba | |||
| e3c45f6f27 | |||
| 732258c093 | |||
| 8726fa5d74 | |||
| da61ba96f1 | |||
| 815ce40989 | |||
| 4ff6a62dab | |||
| 918582c967 | |||
| 40c584b121 | |||
| f189dc8c88 | |||
| b291c246f4 | |||
| 59ab7be283 | |||
| 60981386ec | |||
| 436781215f | |||
| 9c4b24475c | |||
| ff8d1fc9ec | |||
| 5f04729ce8 | |||
| 60526f981a | |||
| e39d4972fb | |||
| 233468b37b | |||
| 6eda8bd165 | |||
| 7372e7cbea | |||
| 1fed2201db | |||
| 60b1573386 | |||
| f4695d8395 | |||
| f63c679d3e | |||
| 4e5305c91b | |||
| f30c03a727 | |||
| 354b49d9e5 | |||
| 7b60ee1337 | |||
| ab1d9b246e | |||
| f7b694c9e4 | |||
| be6f6bbfac | |||
| a32f797b0b | |||
| f12abbe038 | |||
| ad2b49928a | |||
| 67f75796fa | |||
| c235ced030 | |||
| d53764fd84 | |||
| 529d8ae3ba | |||
| f864f66e62 | |||
| b1b633bcf9 | |||
| e655e0a882 | |||
| db88fbb694 | |||
| ace3e42281 | |||
| a40000e6b7 | |||
| 21d2d7dfea | |||
| a61731a289 | |||
| c250076032 | |||
| c6d35b103a | |||
| 596c9a5055 | |||
| 9fae4f14d2 | |||
| f1f0b86696 | |||
| e3d2a1fcef | |||
| 2303622475 | |||
| 732277be5e | |||
| 28f205057f | |||
| 9e32ec3e39 | |||
| 1fa86cbb52 | |||
| 9d8a4d4269 | |||
| cb22615bb5 | |||
| 989dc32481 | |||
| 02dd44ad63 | |||
| d6517959d8 | |||
| d9d539c4b8 | |||
| 5b18ffb7ec | |||
| cf70efb6a2 | |||
| a42699e1fb | |||
| 597e82a33b | |||
| e302143b8a | |||
| e99b6af2c5 | |||
| 35a16ac7e0 | |||
| 0d20d9069a | |||
| 8b1d272827 | |||
| 24b3384570 | |||
| 4ca5bfb1ab | |||
| 7c8cf3cb50 | |||
| 6b55d5bb41 | |||
| 5558fc7157 | |||
| 30a7121000 | |||
| fb1568d019 | |||
| a0dca671d8 | |||
| d79870801b | |||
| 2a238a95a9 | |||
| 4bfcf46e36 | |||
| 894316f035 | |||
| 1c47924624 | |||
| 2973b0f200 | |||
| 4fc5751ae1 | |||
| d37ca7eae3 | |||
| 7960f22be9 | |||
| 1b11ec290a | |||
| 751f1d93f3 | |||
| f63a7857a6 | |||
| 017ca24b13 | |||
| 3c22ab7bd1 | |||
| 0bbf64d240 | |||
| af2f20f7b2 | |||
| fef03ddec0 | |||
| f2d0489488 | |||
| f815d5e2fd | |||
| c4a5a3eaf7 | |||
| 921cc6ffa9 | |||
| b582e59eee | |||
| c9f8b83f62 | |||
| 8ff99ce916 | |||
| 27b23a96b6 | |||
| 8ae34223c5 | |||
| 699fc9df1f | |||
| 951d02bfc3 | |||
| 9b9a3b452d | |||
| 02f21a30a8 | |||
| e053664c99 | |||
| 949c6a318f | |||
| f5cb8baf99 | |||
| 025b864bd8 | |||
| b4fcccbe10 | |||
| b9331b5f5a | |||
| 81aa0084e7 | |||
| 58bc6788aa | |||
| 5a767a2d92 | |||
| 282ad43180 | |||
| bcb30ce807 | |||
| 2d865f006e | |||
| b2daebead6 | |||
| 4210091e9a | |||
| 4db09f2240 | |||
| e0260eb551 | |||
| ed1e5474bf | |||
| 65bd7fcc49 | |||
| 80834ccec1 | |||
| 026c39a3de | |||
| 95939dfa02 | |||
| 279da9097c | |||
| 97126332da | |||
| 6641b9a16c | |||
| 927c9afa84 | |||
| d41d7ca0a6 | |||
| ad0c6cfc8d |
@@ -1,7 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: If something is definitely wrong in the bridge (rather than just a setup issue),
|
about: If something is definitely wrong in the bridge (rather than just a setup issue),
|
||||||
file a bug report. Remember to include relevant logs.
|
file a bug report. Remember to include relevant logs. Asking in the Matrix room first
|
||||||
labels: bug
|
is strongly recommended.
|
||||||
|
type: Bug
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Remember to include relevant logs, the bridge version and any other details.
|
||||||
|
|
||||||
|
It's always best to ask in the Matrix room first, especially if you aren't sure
|
||||||
|
what details are needed. Issues with insufficient detail will likely just be
|
||||||
|
ignored or closed immediately.
|
||||||
|
-->
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Enhancement request
|
name: Enhancement request
|
||||||
about: Submit a feature request or other suggestion
|
about: Submit a feature request or other suggestion
|
||||||
labels: enhancement
|
type: Feature
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -6,17 +6,17 @@ jobs:
|
|||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v2
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.12"
|
||||||
- uses: isort/isort-action@master
|
- uses: isort/isort-action@master
|
||||||
with:
|
with:
|
||||||
sortPaths: "./mautrix_telegram"
|
sortPaths: "./mautrix_telegram"
|
||||||
- uses: psf/black@stable
|
- uses: psf/black@stable
|
||||||
with:
|
with:
|
||||||
src: "./mautrix_telegram"
|
src: "./mautrix_telegram"
|
||||||
version: "22.3.0"
|
version: "24.1.1"
|
||||||
- name: pre-commit
|
- name: pre-commit
|
||||||
run: |
|
run: |
|
||||||
pip install pre-commit
|
pip install pre-commit
|
||||||
|
|||||||
+13
-3
@@ -10,9 +10,19 @@ __pycache__
|
|||||||
/*.egg-info
|
/*.egg-info
|
||||||
/.eggs
|
/.eggs
|
||||||
|
|
||||||
/config.yaml
|
*.yaml
|
||||||
/registration.yaml
|
!.pre-commit-config.yaml
|
||||||
|
!example-config.yaml
|
||||||
|
!/mautrix_telegram/web/provisioning/spec.yaml
|
||||||
|
!/.github/workflows/*.yaml
|
||||||
|
|
||||||
|
/start
|
||||||
|
/mautrix
|
||||||
|
/telethon
|
||||||
|
|
||||||
*.log*
|
*.log*
|
||||||
*.db
|
*.db
|
||||||
*.pickle
|
*.db-*
|
||||||
|
/*.pickle
|
||||||
*.bak
|
*.bak
|
||||||
|
/*.json
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.1.0
|
rev: v4.5.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude_types: [markdown]
|
exclude_types: [markdown]
|
||||||
@@ -8,13 +8,13 @@ repos:
|
|||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 22.3.0
|
rev: 24.1.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python3
|
language_version: python3
|
||||||
files: ^mautrix_telegram/.*\.pyi?$
|
files: ^mautrix_telegram/.*\.pyi?$
|
||||||
- repo: https://github.com/PyCQA/isort
|
- repo: https://github.com/PyCQA/isort
|
||||||
rev: 5.10.1
|
rev: 5.13.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: isort
|
||||||
files: ^mautrix_telegram/.*\.pyi?$
|
files: ^mautrix_telegram/.*\.pyi?$
|
||||||
|
|||||||
+185
@@ -1,3 +1,188 @@
|
|||||||
|
# v0.15.3 (2025-07-16)
|
||||||
|
|
||||||
|
* Updated Telegram API to layer 204.
|
||||||
|
* Added support for MSC4190.
|
||||||
|
* Enabled captions by default, as they are now supported by most clients.
|
||||||
|
* Existing configs will still need to enable `caption_in_message` manually.
|
||||||
|
* Changed new room creation to hardcode room v11 to avoid v12 rooms being
|
||||||
|
created before proper support for them can be added.
|
||||||
|
* Fixed bridging sticker messages with partial quote replies from Telegram.
|
||||||
|
* Fixed text in poll bridging.
|
||||||
|
* Disabled kicking unauthenticated users from portals.
|
||||||
|
|
||||||
|
# v0.15.2 (2024-07-16)
|
||||||
|
|
||||||
|
* Dropped support for Python 3.9.
|
||||||
|
* Updated Telegram API to layer 183.
|
||||||
|
* Added support for authenticated media downloads.
|
||||||
|
* Added support for receiving reactions when using a bot account.
|
||||||
|
* Added option to limit file size by chat type.
|
||||||
|
* Fixed reply bridging breaking in some cases.
|
||||||
|
|
||||||
|
# v0.15.1 (2023-12-26)
|
||||||
|
|
||||||
|
* Updated Telegram API to layer 169.
|
||||||
|
* Updated Docker image to Alpine 3.19.
|
||||||
|
* Fixed some potential cases where a portal room would be created for the
|
||||||
|
relaybot even if `ignore_unbridged_group_chat` was enabled.
|
||||||
|
* Fixed member sync in groups with hidden members causing puppeted Matrix users
|
||||||
|
to be kicked even if they're still in the group.
|
||||||
|
|
||||||
|
# v0.15.0 (2023-11-26)
|
||||||
|
|
||||||
|
* Removed support for MSC2716 backfilling.
|
||||||
|
* Added `add-contact` and `delete-contact` commands.
|
||||||
|
* Updated Telegram API layer to 166.
|
||||||
|
* Includes receiving view-once media, blockquotes, quote replies and other
|
||||||
|
such things
|
||||||
|
* Fixed AuthKeyNotFound errors not being handled and causing users to get stuck
|
||||||
|
in a non-logged-in state.
|
||||||
|
|
||||||
|
# v0.14.2 (2023-09-19)
|
||||||
|
|
||||||
|
* **Security:** Updated Pillow to 10.0.1.
|
||||||
|
* Added support for double puppeting with arbitrary `as_token`s.
|
||||||
|
See [docs](https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method-new) for more info.
|
||||||
|
* Added support for sending webm and tgs files as stickers.
|
||||||
|
* Updated to Telegram API layer 161.
|
||||||
|
* Fixed cached usernames for Telegram users being cleared incorrectly, leading
|
||||||
|
to mentions not being bridged as usernames.
|
||||||
|
* Fixed reaction bridging failing if the server running the bridge was rebooted
|
||||||
|
less than 12 hours ago.
|
||||||
|
|
||||||
|
# v0.14.1 (2023-06-26)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added option to delete megolm sessions that were received before the
|
||||||
|
automatic ratcheting options were introduced.
|
||||||
|
* Added config option to use IPv6 for Telegram connection
|
||||||
|
(thanks to [@exciler] in [#920]).
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
* Dropped support for Python 3.8.
|
||||||
|
* Updated Docker image to Alpine 3.18.
|
||||||
|
* Added timeout for forward backfills to prevent it from getting stuck
|
||||||
|
permanently.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed `bridge.filter.users` config option not being read correctly.
|
||||||
|
* Fixed proxy support to use python-socks instead of pysocks.
|
||||||
|
|
||||||
|
[@exciler]: https://github.com/exciler
|
||||||
|
[#920]: https://github.com/mautrix/telegram/pull/920
|
||||||
|
|
||||||
|
# v0.14.0 (2023-05-26)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added fallback messages for calls and premium gifts.
|
||||||
|
* Added options to automatically ratchet/delete megolm sessions to minimize
|
||||||
|
access to old messages.
|
||||||
|
* Added option to not set room name/avatar even in encrypted rooms.
|
||||||
|
* Implemented appservice pinging using MSC2659.
|
||||||
|
* Added option to disable or filter bridging direct chats
|
||||||
|
(thanks to [@Steffo99] in [#892]).
|
||||||
|
* Added options to specify different limits for forward and catchup backfilling
|
||||||
|
depending on chat type.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
* Improved handling logouts and certain connection errors.
|
||||||
|
* Changed reaction bridging to preserve timestamps.
|
||||||
|
* Disabled creating portals for DMs that don't have any messages when
|
||||||
|
`sync_direct_chats` is enabled.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed syncing mute status when portal is created through incoming message
|
||||||
|
rather than in startup sync.
|
||||||
|
* Fixed bridge incorrectly trusting member list and kicking users when
|
||||||
|
supergroup has member list hidden.
|
||||||
|
* Fixed sending messages after creating groups from Matrix using relaybot
|
||||||
|
instead of puppet (thanks to [@maltee1] in [#902]).
|
||||||
|
|
||||||
|
[@Steffo99]: https://github.com/Steffo99
|
||||||
|
[@maltee1]: https://github.com/maltee1
|
||||||
|
[#892]: https://github.com/mautrix/telegram/pull/892
|
||||||
|
[#902]: https://github.com/mautrix/telegram/pull/902
|
||||||
|
|
||||||
|
# v0.13.0 (2023-02-26)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added `allow_contact_info` config option to specify whether personal names
|
||||||
|
and avatars for other users should be bridged.
|
||||||
|
* The option is only safe to enable on single-user instances, using it
|
||||||
|
anywhere else will cause ghost user profiles to flip back and forth between
|
||||||
|
personal and default ones.
|
||||||
|
* Added config option to notify Matrix room if bridging an incoming message
|
||||||
|
fails.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
* Updated Docker image to Alpine 3.17.
|
||||||
|
* Updated to Telegram API layer 152.
|
||||||
|
* Improved handling users getting logged out.
|
||||||
|
* Removed support for creating accounts, as Telegram only allows requesting SMS
|
||||||
|
login codes on the official mobile clients now.
|
||||||
|
* Replaced moviepy with calling ffmpeg directly for generating video thumbnails.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed handling Telegram chat upgrades when backfilling is enabled.
|
||||||
|
* Fixed file transfers failing if transfering the thumbnail fails.
|
||||||
|
* Fixed bridging unnamed files with unrecognized mime types.
|
||||||
|
* Fixed enqueueing more backfill.
|
||||||
|
* Fixed timestamps not being saved in `telegram_file` table.
|
||||||
|
* Fixed issues with old events being replayed if the bridge was shut down
|
||||||
|
uncleanly.
|
||||||
|
|
||||||
|
# v0.12.2 (2022-11-26)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added built-in custom emoji packs to allow reacting with any standard unicode
|
||||||
|
emoji from Matrix (note that only premium users can use custom emojis).
|
||||||
|
* Added infinite backfill using [MSC2716].
|
||||||
|
* The new system includes a backwards compatibility mechanism which uses the
|
||||||
|
old method of just sending events to the room. By default, MSC2716 is not
|
||||||
|
enabled and the legacy method will be used.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
* Redacting reactions on Matrix no longer removes the user's other reactions to
|
||||||
|
the same message (premium users can have up to 3 reactions per message).
|
||||||
|
* Changes to default user permissions on Telegram are now bridged.
|
||||||
|
* Added database index to make reaction polling more efficient
|
||||||
|
(thanks to [@AndrewFerr] in [#862]).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed provisioning API not working with URL-encoded parameters.
|
||||||
|
|
||||||
|
[MSC2716]: https://github.com/matrix-org/matrix-spec-proposals/pull/2716
|
||||||
|
[@AndrewFerr]: https://github.com/AndrewFerr
|
||||||
|
[#862]: https://github.com/mautrix/telegram/pull/862
|
||||||
|
|
||||||
|
# v0.12.1 (2022-09-26)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Support for custom emojis in reactions.
|
||||||
|
* Like other bridges with custom emoji reactions, they're bridged as `mxc://`
|
||||||
|
URIs, so client support is required to render them properly.
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
* The bridge will now poll for reactions to 20 most recent messages when
|
||||||
|
receiving a read receipt. This works around Telegram's bad protocol that
|
||||||
|
doesn't notify clients on reactions to other users' messages.
|
||||||
|
* The docker image now has an option to bypass the startup script by setting
|
||||||
|
the `MAUTRIX_DIRECT_STARTUP` environment variable. Additionally, it will
|
||||||
|
refuse to run as a non-root user if that variable is not set (and print an
|
||||||
|
error message suggesting to either set the variable or use a custom command).
|
||||||
|
* Moved environment variable overrides for config fields to mautrix-python.
|
||||||
|
The new system also allows loading JSON values to enable overriding maps like
|
||||||
|
`login_shared_secret_map`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* `ChatParticipantsForbidden` is handled properly when syncing non-supergroup
|
||||||
|
info.
|
||||||
|
* Fixed some bugs with file transfers when using SQLite.
|
||||||
|
* Fixed error when attempting to log in again after logging out.
|
||||||
|
* Fixed QR login not working.
|
||||||
|
* Fixed error syncing chats if bridging a message had previously been
|
||||||
|
interrupted.
|
||||||
|
|
||||||
# v0.12.0 (2022-08-26)
|
# v0.12.0 (2022-08-26)
|
||||||
|
|
||||||
**N.B.** This release requires a homeserver with Matrix v1.1 support, which
|
**N.B.** This release requires a homeserver with Matrix v1.1 support, which
|
||||||
|
|||||||
+8
-15
@@ -1,11 +1,11 @@
|
|||||||
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.16
|
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.22
|
||||||
|
|
||||||
ARG TARGETARCH=amd64
|
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
python3 py3-pip py3-setuptools py3-wheel \
|
python3 py3-pip py3-setuptools py3-wheel \
|
||||||
py3-pillow \
|
py3-pillow \
|
||||||
py3-aiohttp \
|
py3-aiohttp \
|
||||||
|
py3-asyncpg \
|
||||||
|
py3-aiosqlite \
|
||||||
py3-magic \
|
py3-magic \
|
||||||
py3-ruamel.yaml \
|
py3-ruamel.yaml \
|
||||||
py3-commonmark \
|
py3-commonmark \
|
||||||
@@ -15,17 +15,10 @@ RUN apk add --no-cache \
|
|||||||
# Indirect dependencies
|
# Indirect dependencies
|
||||||
py3-idna \
|
py3-idna \
|
||||||
py3-rsa \
|
py3-rsa \
|
||||||
#moviepy
|
|
||||||
py3-decorator \
|
|
||||||
py3-tqdm \
|
|
||||||
py3-requests \
|
|
||||||
#py3-proglog \
|
|
||||||
#imageio
|
|
||||||
py3-numpy \
|
|
||||||
#py3-telethon \ (outdated)
|
#py3-telethon \ (outdated)
|
||||||
# Optional for socks proxies
|
|
||||||
py3-pysocks \
|
|
||||||
py3-pyaes \
|
py3-pyaes \
|
||||||
|
py3-aiodns \
|
||||||
|
py3-python-socks \
|
||||||
# cryptg
|
# cryptg
|
||||||
py3-cffi \
|
py3-cffi \
|
||||||
py3-qrcode \
|
py3-qrcode \
|
||||||
@@ -49,13 +42,13 @@ COPY requirements.txt /opt/mautrix-telegram/requirements.txt
|
|||||||
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
|
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
|
||||||
WORKDIR /opt/mautrix-telegram
|
WORKDIR /opt/mautrix-telegram
|
||||||
RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \
|
RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \
|
||||||
&& pip3 install /cryptg-*.whl \
|
&& pip3 install --break-system-packages /cryptg-*.whl \
|
||||||
&& pip3 install --no-cache-dir -r requirements.txt -r optional-requirements.txt \
|
&& pip3 install --break-system-packages --no-cache-dir -r requirements.txt -r optional-requirements.txt \
|
||||||
&& apk del .build-deps \
|
&& apk del .build-deps \
|
||||||
&& rm -f /cryptg-*.whl
|
&& rm -f /cryptg-*.whl
|
||||||
|
|
||||||
COPY . /opt/mautrix-telegram
|
COPY . /opt/mautrix-telegram
|
||||||
RUN apk add git && pip3 install --no-cache-dir .[all] && apk del git \
|
RUN apk add git && pip3 install --break-system-packages --no-cache-dir .[all] && apk del git \
|
||||||
# This doesn't make the image smaller, but it's needed so that the `version` command works properly
|
# This doesn't make the image smaller, but it's needed so that the `version` command works properly
|
||||||
&& cp mautrix_telegram/example-config.yaml . && rm -rf mautrix_telegram .git build
|
&& cp mautrix_telegram/example-config.yaml . && rm -rf mautrix_telegram .git build
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ A Matrix-Telegram hybrid puppeting/relaybot bridge.
|
|||||||
## Sponsors
|
## Sponsors
|
||||||
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
|
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
|
||||||
|
|
||||||
### Documentation
|
## Documentation
|
||||||
All setup and usage instructions are located on
|
All setup and usage instructions are located on
|
||||||
[docs.mau.fi](https://docs.mau.fi/bridges/python/telegram/index.html).
|
[docs.mau.fi](https://docs.mau.fi/bridges/python/telegram/index.html).
|
||||||
Some quick links:
|
Some quick links:
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
pre-commit>=2.10.1,<3
|
pre-commit>=2.10.1,<3
|
||||||
isort>=5.10.1,<6
|
isort>=5.10.1,<6
|
||||||
black>=22.3,<23
|
black>=24,<25
|
||||||
|
|||||||
@@ -1,4 +1,18 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
if [ ! -z "$MAUTRIX_DIRECT_STARTUP" ]; then
|
||||||
|
if [ $(id -u) == 0 ]; then
|
||||||
|
echo "|------------------------------------------|"
|
||||||
|
echo "| Warning: running bridge unsafely as root |"
|
||||||
|
echo "|------------------------------------------|"
|
||||||
|
fi
|
||||||
|
exec python3 -m mautrix_telegram -c /data/config.yaml
|
||||||
|
elif [ $(id -u) != 0 ]; then
|
||||||
|
echo "The startup script must run as root. It will use su-exec to drop permissions before running the bridge."
|
||||||
|
echo "To bypass the startup script, either set the `MAUTRIX_DIRECT_STARTUP` environment variable,"
|
||||||
|
echo "or just use `python3 -m mautrix_telegram -c /data/config.yaml` as the run command."
|
||||||
|
echo "Note that the config and registration will not be auto-generated when bypassing the startup script."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Define functions.
|
# Define functions.
|
||||||
function fixperms {
|
function fixperms {
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "0.12.0"
|
__version__ = "0.15.3"
|
||||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ from .abstract_user import AbstractUser # isort: skip
|
|||||||
class TelegramBridge(Bridge):
|
class TelegramBridge(Bridge):
|
||||||
module = "mautrix_telegram"
|
module = "mautrix_telegram"
|
||||||
name = "mautrix-telegram"
|
name = "mautrix-telegram"
|
||||||
|
beeper_service_name = "telegram"
|
||||||
|
beeper_network_name = "telegram"
|
||||||
command = "python -m mautrix-telegram"
|
command = "python -m mautrix-telegram"
|
||||||
description = "A Matrix-Telegram puppeting bridge."
|
description = "A Matrix-Telegram puppeting bridge."
|
||||||
repo_url = "https://github.com/mautrix/telegram"
|
repo_url = "https://github.com/mautrix/telegram"
|
||||||
@@ -50,6 +52,7 @@ class TelegramBridge(Bridge):
|
|||||||
|
|
||||||
config: Config
|
config: Config
|
||||||
bot: Bot | None
|
bot: Bot | None
|
||||||
|
matrix: MatrixHandler
|
||||||
public_website: PublicBridgeWebsite | None
|
public_website: PublicBridgeWebsite | None
|
||||||
provisioning_api: ProvisioningAPI | None
|
provisioning_api: ProvisioningAPI | None
|
||||||
|
|
||||||
@@ -101,8 +104,6 @@ class TelegramBridge(Bridge):
|
|||||||
self.log.info("Finished re-sending bridge info state events")
|
self.log.info("Finished re-sending bridge info state events")
|
||||||
|
|
||||||
def prepare_stop(self) -> None:
|
def prepare_stop(self) -> None:
|
||||||
for puppet in Puppet.by_custom_mxid.values():
|
|
||||||
puppet.stop()
|
|
||||||
self.add_shutdown_actions(user.stop() for user in User.by_tgid.values())
|
self.add_shutdown_actions(user.stop() for user in User.by_tgid.values())
|
||||||
if self.bot:
|
if self.bot:
|
||||||
self.add_shutdown_actions(self.bot.stop())
|
self.add_shutdown_actions(self.bot.stop())
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import logging
|
|||||||
import platform
|
import platform
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from telethon.errors import AuthKeyError, UnauthorizedError
|
||||||
from telethon.network import (
|
from telethon.network import (
|
||||||
Connection,
|
Connection,
|
||||||
ConnectionTcpFull,
|
ConnectionTcpFull,
|
||||||
@@ -37,9 +38,12 @@ from telethon.tl.types import (
|
|||||||
PeerChannel,
|
PeerChannel,
|
||||||
PeerChat,
|
PeerChat,
|
||||||
PeerUser,
|
PeerUser,
|
||||||
|
PhoneCallRequested,
|
||||||
TypeUpdate,
|
TypeUpdate,
|
||||||
|
UpdateBotMessageReaction,
|
||||||
UpdateChannel,
|
UpdateChannel,
|
||||||
UpdateChannelUserTyping,
|
UpdateChannelUserTyping,
|
||||||
|
UpdateChatDefaultBannedRights,
|
||||||
UpdateChatParticipantAdmin,
|
UpdateChatParticipantAdmin,
|
||||||
UpdateChatParticipants,
|
UpdateChatParticipants,
|
||||||
UpdateChatUserTyping,
|
UpdateChatUserTyping,
|
||||||
@@ -52,6 +56,7 @@ from telethon.tl.types import (
|
|||||||
UpdateNewChannelMessage,
|
UpdateNewChannelMessage,
|
||||||
UpdateNewMessage,
|
UpdateNewMessage,
|
||||||
UpdateNotifySettings,
|
UpdateNotifySettings,
|
||||||
|
UpdatePhoneCall,
|
||||||
UpdatePinnedChannelMessages,
|
UpdatePinnedChannelMessages,
|
||||||
UpdatePinnedDialogs,
|
UpdatePinnedDialogs,
|
||||||
UpdatePinnedMessages,
|
UpdatePinnedMessages,
|
||||||
@@ -61,8 +66,8 @@ from telethon.tl.types import (
|
|||||||
UpdateShort,
|
UpdateShort,
|
||||||
UpdateShortChatMessage,
|
UpdateShortChatMessage,
|
||||||
UpdateShortMessage,
|
UpdateShortMessage,
|
||||||
|
UpdateUser,
|
||||||
UpdateUserName,
|
UpdateUserName,
|
||||||
UpdateUserPhoto,
|
|
||||||
UpdateUserStatus,
|
UpdateUserStatus,
|
||||||
UpdateUserTyping,
|
UpdateUserTyping,
|
||||||
User,
|
User,
|
||||||
@@ -73,6 +78,7 @@ from telethon.tl.types import (
|
|||||||
from mautrix.appservice import AppService
|
from mautrix.appservice import AppService
|
||||||
from mautrix.errors import MatrixError
|
from mautrix.errors import MatrixError
|
||||||
from mautrix.types import PresenceState, UserID
|
from mautrix.types import PresenceState, UserID
|
||||||
|
from mautrix.util import background_task
|
||||||
from mautrix.util.logging import TraceLogger
|
from mautrix.util.logging import TraceLogger
|
||||||
from mautrix.util.opt_prometheus import Counter, Histogram
|
from mautrix.util.opt_prometheus import Counter, Histogram
|
||||||
|
|
||||||
@@ -203,6 +209,8 @@ class AbstractUser(ABC):
|
|||||||
sysversion = self.config["telegram.device_info.system_version"]
|
sysversion = self.config["telegram.device_info.system_version"]
|
||||||
appversion = self.config["telegram.device_info.app_version"]
|
appversion = self.config["telegram.device_info.app_version"]
|
||||||
connection, proxy = self._proxy_settings
|
connection, proxy = self._proxy_settings
|
||||||
|
if proxy:
|
||||||
|
self.log.debug(f"Using proxy setting: {proxy}")
|
||||||
|
|
||||||
assert isinstance(session, Session)
|
assert isinstance(session, Session)
|
||||||
|
|
||||||
@@ -230,18 +238,45 @@ class AbstractUser(ABC):
|
|||||||
loop=self.loop,
|
loop=self.loop,
|
||||||
base_logger=base_logger,
|
base_logger=base_logger,
|
||||||
update_error_callback=self._telethon_update_error_callback,
|
update_error_callback=self._telethon_update_error_callback,
|
||||||
|
use_ipv6=self.config["telegram.connection.use_ipv6"],
|
||||||
)
|
)
|
||||||
self.client.add_event_handler(self._update_catch)
|
self.client.add_event_handler(self._update_catch)
|
||||||
|
self._schedule_reconnect()
|
||||||
|
|
||||||
|
def _schedule_reconnect(self) -> None:
|
||||||
|
reconnect_interval = self.config["telegram.force_refresh_interval_seconds"]
|
||||||
|
if not reconnect_interval or reconnect_interval == 0:
|
||||||
|
return
|
||||||
|
refresh_time = time.time() + reconnect_interval
|
||||||
|
self.log.info(
|
||||||
|
"Scheduling forced reconnect in %d seconds. Connection will be refreshed at %s",
|
||||||
|
reconnect_interval,
|
||||||
|
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(refresh_time)),
|
||||||
|
)
|
||||||
|
self.loop.call_later(reconnect_interval, lambda: background_task.create(self._reconnect()))
|
||||||
|
|
||||||
|
async def _reconnect(self) -> None:
|
||||||
|
self.log.info("Reconnecting to Telegram...")
|
||||||
|
await self.stop()
|
||||||
|
await self.start()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
async def _telethon_update_error_callback(self, err: Exception) -> None:
|
async def _telethon_update_error_callback(self, err: Exception) -> None:
|
||||||
|
if isinstance(err, (UnauthorizedError, AuthKeyError)):
|
||||||
|
background_task.create(self.on_signed_out(err))
|
||||||
|
return
|
||||||
if self.config["telegram.exit_on_update_error"]:
|
if self.config["telegram.exit_on_update_error"]:
|
||||||
self.log.critical(f"Stopping due to update handling error {type(err).__name__}")
|
self.log.critical(f"Stopping due to update handling error {type(err).__name__}")
|
||||||
self.bridge.manual_stop(50)
|
self.bridge.manual_stop(50)
|
||||||
else:
|
else:
|
||||||
self.log.info("Recreating Telethon update loop in 60 seconds")
|
self.log.info("Recreating Telethon connection in 60 seconds")
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(60)
|
||||||
self.log.debug("Now recreating Telethon update loop")
|
self.log.debug("Now recreating Telethon connection")
|
||||||
self.client._updates_handle = self.loop.create_task(self.client._update_loop())
|
await self.stop()
|
||||||
|
await self.start()
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def update(self, update: TypeUpdate) -> bool:
|
async def update(self, update: TypeUpdate) -> bool:
|
||||||
@@ -290,22 +325,34 @@ class AbstractUser(ABC):
|
|||||||
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()
|
await self._init_client()
|
||||||
|
attempts = 1
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
await self.client.connect()
|
await self.client.connect()
|
||||||
|
except Exception:
|
||||||
|
attempts += 1
|
||||||
|
if attempts > 10:
|
||||||
|
raise
|
||||||
|
self.log.exception("Exception connecting to Telegram, retrying in 5s...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
else:
|
||||||
|
break
|
||||||
self.log.debug(f"{'Bot' if self.is_relaybot else self.mxid} connected: {self.connected}")
|
self.log.debug(f"{'Bot' if self.is_relaybot else self.mxid} connected: {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 self.connected:
|
||||||
return self
|
return self
|
||||||
if even_if_no_session or await PgSession.has(self.mxid):
|
session_exists = await PgSession.has(self.mxid)
|
||||||
|
if even_if_no_session or session_exists:
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Starting client due to ensure_started"
|
f"Starting client due to ensure_started({even_if_no_session=}, {session_exists=})"
|
||||||
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
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
|
if self.client:
|
||||||
await self.client.disconnect()
|
await self.client.disconnect()
|
||||||
self.client = None
|
self.client = None
|
||||||
|
|
||||||
@@ -314,7 +361,7 @@ class AbstractUser(ABC):
|
|||||||
async def _update(self, update: TypeUpdate) -> None:
|
async def _update(self, update: TypeUpdate) -> None:
|
||||||
if isinstance(update, UpdateShort):
|
if isinstance(update, UpdateShort):
|
||||||
update = update.update
|
update = update.update
|
||||||
asyncio.create_task(self._handle_entity_updates(getattr(update, "_entities", {})))
|
background_task.create(self._handle_entity_updates(getattr(update, "_entities", {})))
|
||||||
if isinstance(
|
if isinstance(
|
||||||
update,
|
update,
|
||||||
(
|
(
|
||||||
@@ -331,8 +378,12 @@ class AbstractUser(ABC):
|
|||||||
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, UpdatePhoneCall):
|
||||||
|
await self.update_phone_call(update)
|
||||||
elif isinstance(update, UpdateMessageReactions):
|
elif isinstance(update, UpdateMessageReactions):
|
||||||
await self.update_reactions(update)
|
await self.update_reactions(update)
|
||||||
|
elif isinstance(update, UpdateBotMessageReaction):
|
||||||
|
await self.update_bot_reactions(update)
|
||||||
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
|
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
|
||||||
await self.update_typing(update)
|
await self.update_typing(update)
|
||||||
elif isinstance(update, UpdateUserStatus):
|
elif isinstance(update, UpdateUserStatus):
|
||||||
@@ -341,9 +392,11 @@ 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, UpdateChatDefaultBannedRights):
|
||||||
|
await self.update_default_banned_rights(update)
|
||||||
elif isinstance(update, (UpdatePinnedMessages, UpdatePinnedChannelMessages)):
|
elif isinstance(update, (UpdatePinnedMessages, UpdatePinnedChannelMessages)):
|
||||||
await self.update_pinned_messages(update)
|
await self.update_pinned_messages(update)
|
||||||
elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
|
elif isinstance(update, (UpdateUserName, UpdateUser)):
|
||||||
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)
|
||||||
@@ -387,6 +440,12 @@ class AbstractUser(ABC):
|
|||||||
if portal and portal.mxid:
|
if portal and portal.mxid:
|
||||||
await portal.update_power_levels(update.participants.participants)
|
await portal.update_power_levels(update.participants.participants)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_default_banned_rights(update: UpdateChatDefaultBannedRights) -> None:
|
||||||
|
portal = await po.Portal.get_by_entity(update.peer)
|
||||||
|
if portal and portal.mxid:
|
||||||
|
await portal.update_default_banned_rights(update.default_banned_rights)
|
||||||
|
|
||||||
async def update_read_receipt(self, update: UpdateReadHistoryOutbox) -> None:
|
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)
|
||||||
@@ -415,6 +474,7 @@ class AbstractUser(ABC):
|
|||||||
if not puppet.is_real_user:
|
if not puppet.is_real_user:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.log.debug("Handling own read receipt: %s", update)
|
||||||
if isinstance(update, UpdateReadChannelInbox):
|
if isinstance(update, UpdateReadChannelInbox):
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
||||||
elif isinstance(update.peer, PeerChat):
|
elif isinstance(update.peer, PeerChat):
|
||||||
@@ -428,6 +488,8 @@ class AbstractUser(ABC):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not portal or not portal.mxid:
|
if not portal or not portal.mxid:
|
||||||
|
# TODO This explodes on channels because the field is channel_id
|
||||||
|
self.log.debug(f"Dropping own read receipt in unknown chat ({update.peer})")
|
||||||
return
|
return
|
||||||
|
|
||||||
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
|
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
|
||||||
@@ -435,6 +497,9 @@ class AbstractUser(ABC):
|
|||||||
TelegramID(update.max_id), tg_space, edit_index=-1
|
TelegramID(update.max_id), tg_space, edit_index=-1
|
||||||
)
|
)
|
||||||
if not message:
|
if not message:
|
||||||
|
self.log.debug(
|
||||||
|
f"Dropping own read receipt: unknown message {update.max_id}@{tg_space}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await puppet.intent.mark_read(portal.mxid, message.mxid)
|
await puppet.intent.mark_read(portal.mxid, message.mxid)
|
||||||
@@ -481,18 +546,23 @@ class AbstractUser(ABC):
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception("Failed to handle entity updates")
|
self.log.exception("Failed to handle entity updates")
|
||||||
|
|
||||||
async def update_others_info(self, update: UpdateUserName | UpdateUserPhoto) -> None:
|
async def update_others_info(self, update: UpdateUserName | UpdateUser) -> None:
|
||||||
# TODO duplication not checked
|
# TODO duplication not checked
|
||||||
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
|
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
|
||||||
if isinstance(update, UpdateUserName):
|
if isinstance(update, UpdateUserName):
|
||||||
puppet.username = update.username
|
if len(update.usernames) > 1:
|
||||||
|
self.log.warning(
|
||||||
|
"Got update with multiple usernames (%s) for %s, only saving first one",
|
||||||
|
update.usernames,
|
||||||
|
update.user_id,
|
||||||
|
)
|
||||||
|
puppet.username = update.usernames[0].username if update.usernames else None
|
||||||
if await puppet.update_displayname(self, update):
|
if await puppet.update_displayname(self, update):
|
||||||
await puppet.save()
|
await puppet.save()
|
||||||
await puppet.update_portals_meta()
|
await puppet.update_portals_meta()
|
||||||
elif isinstance(update, UpdateUserPhoto):
|
elif isinstance(update, UpdateUser):
|
||||||
if await puppet.update_avatar(self, update.photo):
|
info = await self.client.get_entity(puppet.peer)
|
||||||
await puppet.save()
|
await puppet.update_info(self, info)
|
||||||
await puppet.update_portals_meta()
|
|
||||||
else:
|
else:
|
||||||
self.log.warning(f"Unexpected other user info update: {type(update)}")
|
self.log.warning(f"Unexpected other user info update: {type(update)}")
|
||||||
|
|
||||||
@@ -510,9 +580,7 @@ class AbstractUser(ABC):
|
|||||||
self, update: UpdateMessage
|
self, update: UpdateMessage
|
||||||
) -> tuple[UpdateMessageContent, pu.Puppet | None, po.Portal | None]:
|
) -> tuple[UpdateMessageContent, pu.Puppet | None, po.Portal | None]:
|
||||||
if isinstance(update, UpdateShortChatMessage):
|
if isinstance(update, UpdateShortChatMessage):
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
|
||||||
if not portal:
|
|
||||||
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))
|
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 = await po.Portal.get_by_tgid(
|
||||||
@@ -536,6 +604,8 @@ class AbstractUser(ABC):
|
|||||||
sender = await pu.Puppet.get_by_tgid(self.tgid)
|
sender = await pu.Puppet.get_by_tgid(self.tgid)
|
||||||
elif isinstance(update.from_id, (PeerUser, PeerChannel)):
|
elif isinstance(update.from_id, (PeerUser, PeerChannel)):
|
||||||
sender = await pu.Puppet.get_by_peer(update.from_id)
|
sender = await pu.Puppet.get_by_peer(update.from_id)
|
||||||
|
elif isinstance(update.peer_id, PeerUser):
|
||||||
|
sender = await pu.Puppet.get_by_peer(update.peer_id)
|
||||||
else:
|
else:
|
||||||
sender = None
|
sender = None
|
||||||
else:
|
else:
|
||||||
@@ -587,6 +657,25 @@ class AbstractUser(ABC):
|
|||||||
return
|
return
|
||||||
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions)
|
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions)
|
||||||
|
|
||||||
|
async def update_bot_reactions(self, update: UpdateBotMessageReaction) -> None:
|
||||||
|
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
|
||||||
|
if not portal or not portal.mxid or not portal.allow_bridging:
|
||||||
|
return
|
||||||
|
await portal.handle_telegram_bot_reactions(self, update)
|
||||||
|
|
||||||
|
async def update_phone_call(self, update: UpdatePhoneCall) -> None:
|
||||||
|
self.log.debug("Phone call update %s", update)
|
||||||
|
if not isinstance(update.phone_call, PhoneCallRequested):
|
||||||
|
return
|
||||||
|
tgid = TelegramID(update.phone_call.participant_id)
|
||||||
|
if tgid == self.tgid:
|
||||||
|
tgid = update.phone_call.admin_id
|
||||||
|
portal = await po.Portal.get_by_tgid(tgid, tg_receiver=self.tgid, peer_type="user")
|
||||||
|
if not portal or not portal.mxid or not portal.allow_bridging:
|
||||||
|
return
|
||||||
|
sender = await pu.Puppet.get_by_tgid(TelegramID(update.phone_call.admin_id))
|
||||||
|
await portal.handle_telegram_direct_call(self, sender, update)
|
||||||
|
|
||||||
async def update_channel(self, update: UpdateChannel) -> None:
|
async def update_channel(self, update: UpdateChannel) -> None:
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
||||||
if not portal:
|
if not portal:
|
||||||
@@ -596,33 +685,52 @@ class AbstractUser(ABC):
|
|||||||
await portal.delete_telegram_user(self.tgid, sender=None)
|
await portal.delete_telegram_user(self.tgid, sender=None)
|
||||||
elif chan := getattr(update, "mau_channel", None):
|
elif chan := getattr(update, "mau_channel", None):
|
||||||
if not portal.mxid:
|
if not portal.mxid:
|
||||||
asyncio.create_task(self._delayed_create_channel(chan))
|
if (
|
||||||
|
not self.is_relaybot
|
||||||
|
or not self.config["bridge.relaybot.ignore_unbridged_group_chat"]
|
||||||
|
):
|
||||||
|
background_task.create(self._delayed_create_channel(chan))
|
||||||
else:
|
else:
|
||||||
self.log.debug("Updating channel info with data fetched by Telethon")
|
self.log.debug("Updating channel info with data fetched by Telethon")
|
||||||
await portal.update_info(self, chan)
|
await portal.update_info(self, chan)
|
||||||
await portal.invite_to_matrix(self.mxid)
|
await portal.invite_to_matrix(self.mxid)
|
||||||
|
|
||||||
async def _delayed_create_channel(self, chan: Channel) -> None:
|
async def _delayed_create_channel(self, chan: Channel) -> None:
|
||||||
self.log.debug("Waiting 5 seconds before handling UpdateChannel for non-existent portal")
|
self.log.debug(
|
||||||
|
f"Waiting 5 seconds before handling UpdateChannel for non-existent portal {chan.id}"
|
||||||
|
)
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(chan.id))
|
portal = await po.Portal.get_by_tgid(TelegramID(chan.id))
|
||||||
if portal.mxid:
|
if portal.mxid:
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Portal started existing after waiting 5 seconds, dropping UpdateChannel"
|
"Portal started existing after waiting 5 seconds, "
|
||||||
|
f"dropping UpdateChannel for {portal.tgid}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
self.log.info(
|
self.log.info(
|
||||||
"Creating Matrix room with data fetched by Telethon due to UpdateChannel"
|
f"Creating Matrix room for {portal.tgid}"
|
||||||
|
" with data fetched by Telethon due to UpdateChannel"
|
||||||
)
|
)
|
||||||
await portal.create_matrix_room(self, chan)
|
await portal.create_matrix_room(self, chan, invites=[self.mxid])
|
||||||
|
|
||||||
|
async def _check_server_notice_edit(self, message: Message) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
async def update_message(self, original_update: UpdateMessage) -> None:
|
async def update_message(self, original_update: UpdateMessage) -> None:
|
||||||
update, sender, portal = await self.get_message_details(original_update)
|
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:
|
elif portal and not portal.allow_bridging:
|
||||||
self.log.debug(f"Ignoring message in portal {portal.tgid_log} (bridging disallowed)")
|
self.log.debug(
|
||||||
|
f"Ignoring message {update.id} in portal {portal.tgid_log} (bridging disallowed)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not portal.mxid and getattr(original_update, "mau_left_channel", False):
|
||||||
|
self.log.debug(
|
||||||
|
f"Ignoring message {update.id} in portal {portal.tgid_log} because user isn't in the chat"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self.is_relaybot:
|
if self.is_relaybot:
|
||||||
@@ -646,11 +754,27 @@ class AbstractUser(ABC):
|
|||||||
self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
|
self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
task = self._call_portal_message_handler(update, original_update, portal, sender)
|
||||||
|
if portal.backfill_lock.locked:
|
||||||
|
self.log.debug(
|
||||||
|
f"{portal.tgid_log} is backfill locked, moving incoming message to async task"
|
||||||
|
)
|
||||||
|
background_task.create(task)
|
||||||
|
else:
|
||||||
|
await task
|
||||||
|
|
||||||
|
async def _call_portal_message_handler(
|
||||||
|
self,
|
||||||
|
update: UpdateMessageContent,
|
||||||
|
original_update: UpdateMessage,
|
||||||
|
portal: po.Portal,
|
||||||
|
sender: pu.Puppet,
|
||||||
|
) -> None:
|
||||||
await portal.backfill_lock.wait(f"update {update.id}")
|
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(
|
||||||
"Received %s in %s by %d, unregistering portal...",
|
"Received %s in %s by %d, unregistering portal...",
|
||||||
update.action,
|
update.action,
|
||||||
portal.tgid_log,
|
portal.tgid_log,
|
||||||
@@ -659,7 +783,7 @@ class AbstractUser(ABC):
|
|||||||
await self.unregister_portal(update.action.chat_id, update.action.chat_id)
|
await self.unregister_portal(update.action.chat_id, update.action.chat_id)
|
||||||
await self.register_portal(portal)
|
await self.register_portal(portal)
|
||||||
return
|
return
|
||||||
self.log.trace(
|
self.log.debug(
|
||||||
"Handling action %s to %s by %d",
|
"Handling action %s to %s by %d",
|
||||||
update.action,
|
update.action,
|
||||||
portal.tgid_log,
|
portal.tgid_log,
|
||||||
@@ -668,6 +792,8 @@ class AbstractUser(ABC):
|
|||||||
return await portal.handle_telegram_action(self, sender, update)
|
return await portal.handle_telegram_action(self, sender, update)
|
||||||
|
|
||||||
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
|
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
|
||||||
|
if sender and sender.tgid == 777000:
|
||||||
|
await self._check_server_notice_edit(update)
|
||||||
return await portal.handle_telegram_edit(self, sender, update)
|
return await portal.handle_telegram_edit(self, sender, update)
|
||||||
return await portal.handle_telegram_message(self, sender, update)
|
return await portal.handle_telegram_message(self, sender, update)
|
||||||
|
|
||||||
|
|||||||
+25
-3
@@ -15,11 +15,16 @@
|
|||||||
# 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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Awaitable, Callable, Literal
|
from typing import TYPE_CHECKING, Awaitable, Callable, Literal
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from telethon.errors import ChannelInvalidError, ChannelPrivateError
|
from telethon.errors import (
|
||||||
|
AuthKeyError,
|
||||||
|
ChannelInvalidError,
|
||||||
|
ChannelPrivateError,
|
||||||
|
UnauthorizedError,
|
||||||
|
)
|
||||||
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
|
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
|
||||||
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
|
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
|
||||||
from telethon.tl.patched import Message, MessageService
|
from telethon.tl.patched import Message, MessageService
|
||||||
@@ -29,6 +34,7 @@ from telethon.tl.types import (
|
|||||||
ChatForbidden,
|
ChatForbidden,
|
||||||
ChatParticipantAdmin,
|
ChatParticipantAdmin,
|
||||||
ChatParticipantCreator,
|
ChatParticipantCreator,
|
||||||
|
ChatParticipantsForbidden,
|
||||||
InputChannel,
|
InputChannel,
|
||||||
InputUser,
|
InputUser,
|
||||||
MessageActionChatAddUser,
|
MessageActionChatAddUser,
|
||||||
@@ -56,6 +62,9 @@ from .abstract_user import AbstractUser
|
|||||||
from .db import BotChat, Message as DBMessage
|
from .db import BotChat, Message as DBMessage
|
||||||
from .types import TelegramID
|
from .types import TelegramID
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from asyncio import Future
|
||||||
|
|
||||||
ReplyFunc = Callable[[str], Awaitable[Message]]
|
ReplyFunc = Callable[[str], Awaitable[Message]]
|
||||||
BanFunc = Callable[[RoomID, UserID, str], Awaitable[None]]
|
BanFunc = Callable[[RoomID, UserID, str], Awaitable[None]]
|
||||||
TelegramAdminPermission = Literal[
|
TelegramAdminPermission = Literal[
|
||||||
@@ -86,6 +95,7 @@ class Bot(AbstractUser):
|
|||||||
tuple[int, int],
|
tuple[int, int],
|
||||||
tuple[ChatParticipantAdmin | ChatParticipantCreator | None, float],
|
tuple[ChatParticipantAdmin | ChatParticipantCreator | None, float],
|
||||||
]
|
]
|
||||||
|
_login_wait_fut: Future | None
|
||||||
required_permissions: dict[str, TelegramAdminPermission] = {
|
required_permissions: dict[str, TelegramAdminPermission] = {
|
||||||
"portal": None,
|
"portal": None,
|
||||||
"invite": "invite_users",
|
"invite": "invite_users",
|
||||||
@@ -112,6 +122,7 @@ class Bot(AbstractUser):
|
|||||||
)
|
)
|
||||||
self._me_info = None
|
self._me_info = None
|
||||||
self._me_mxid = None
|
self._me_mxid = None
|
||||||
|
self._login_wait_fut = self.loop.create_future()
|
||||||
|
|
||||||
async def get_me(self, use_cache: bool = True) -> tuple[User, UserID]:
|
async def get_me(self, use_cache: bool = True) -> tuple[User, UserID]:
|
||||||
if not use_cache or not self._me_mxid:
|
if not use_cache or not self._me_mxid:
|
||||||
@@ -139,12 +150,19 @@ class Bot(AbstractUser):
|
|||||||
await self.post_login()
|
await self.post_login()
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
|
||||||
|
self.log.fatal("Relay bot got signed out, crashing bridge", exc_info=err)
|
||||||
|
self.bridge.manual_stop(51)
|
||||||
|
|
||||||
async def post_login(self) -> None:
|
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 = TelegramID(info.id)
|
||||||
self.tg_username = info.username
|
self.tg_username = info.username
|
||||||
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
|
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
|
||||||
|
if self._login_wait_fut:
|
||||||
|
self._login_wait_fut.set_result(None)
|
||||||
|
self._login_wait_fut = None
|
||||||
|
|
||||||
chat_ids = [chat_id for chat_id, chat_type in self.chats.items() if chat_type == "chat"]
|
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))
|
||||||
@@ -198,6 +216,8 @@ class Bot(AbstractUser):
|
|||||||
return pcp
|
return pcp
|
||||||
elif isinstance(chat, PeerChat):
|
elif isinstance(chat, PeerChat):
|
||||||
chat = await self.client(GetFullChatRequest(chat.chat_id))
|
chat = await self.client(GetFullChatRequest(chat.chat_id))
|
||||||
|
if isinstance(chat.full_chat.participants, ChatParticipantsForbidden):
|
||||||
|
return None
|
||||||
participants = chat.full_chat.participants.participants
|
participants = chat.full_chat.participants.participants
|
||||||
for p in participants:
|
for p in participants:
|
||||||
self._admin_cache[chat.channel_id, tgid] = (p, time.time())
|
self._admin_cache[chat.channel_id, tgid] = (p, time.time())
|
||||||
@@ -375,7 +395,7 @@ class Bot(AbstractUser):
|
|||||||
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.chat_id, reply_text, reply_to=message.id)
|
||||||
|
|
||||||
if command == "start":
|
if command == "start" and message.is_private:
|
||||||
pcm = self.config["bridge.relaybot.private_chat.message"]
|
pcm = self.config["bridge.relaybot.private_chat.message"]
|
||||||
if pcm:
|
if pcm:
|
||||||
await reply(pcm)
|
await reply(pcm)
|
||||||
@@ -415,6 +435,8 @@ class Bot(AbstractUser):
|
|||||||
await self.add_chat(TelegramID(action.channel_id), "channel")
|
await self.add_chat(TelegramID(action.channel_id), "channel")
|
||||||
|
|
||||||
async def update(self, update) -> bool:
|
async def update(self, update) -> bool:
|
||||||
|
if self._login_wait_fut:
|
||||||
|
await self._login_wait_fut
|
||||||
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):
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ def command_handler(
|
|||||||
needs_admin: bool = False,
|
needs_admin: bool = False,
|
||||||
management_only: bool = False,
|
management_only: bool = False,
|
||||||
name: str | None = None,
|
name: str | None = None,
|
||||||
|
aliases: list[str] | None = None,
|
||||||
help_text: str = "",
|
help_text: str = "",
|
||||||
help_args: str = "",
|
help_args: str = "",
|
||||||
help_section: HelpSection = None,
|
help_section: HelpSection = None,
|
||||||
@@ -167,6 +168,7 @@ def command_handler(
|
|||||||
_func,
|
_func,
|
||||||
_handler_class=CommandHandler,
|
_handler_class=CommandHandler,
|
||||||
name=name,
|
name=name,
|
||||||
|
aliases=aliases,
|
||||||
help_text=help_text,
|
help_text=help_text,
|
||||||
help_args=help_args,
|
help_args=help_args,
|
||||||
help_section=help_section,
|
help_section=help_section,
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ async def clear_db_cache(evt: CommandEvent) -> EventID:
|
|||||||
await evt.reply("Cleared portal cache")
|
await evt.reply("Cleared portal cache")
|
||||||
elif section == "puppet":
|
elif section == "puppet":
|
||||||
pu.Puppet.by_tgid = {}
|
pu.Puppet.by_tgid = {}
|
||||||
for puppet in pu.Puppet.by_custom_mxid.values():
|
|
||||||
puppet.stop()
|
|
||||||
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.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()]
|
||||||
@@ -69,8 +67,6 @@ async def reload_user(evt: CommandEvent) -> EventID:
|
|||||||
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 = await pu.Puppet.get_by_custom_mxid(mxid)
|
||||||
if puppet:
|
|
||||||
puppet.stop()
|
|
||||||
await user.stop()
|
await user.stop()
|
||||||
del u.User.by_tgid[user.tgid]
|
del u.User.by_tgid[user.tgid]
|
||||||
del u.User.by_mxid[user.mxid]
|
del u.User.by_mxid[user.mxid]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import asyncio
|
|||||||
from telethon.tl.types import ChannelForbidden, ChatForbidden
|
from telethon.tl.types import ChannelForbidden, ChatForbidden
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID
|
from mautrix.types import EventID, RoomID
|
||||||
|
from mautrix.util import background_task
|
||||||
|
|
||||||
from ... import portal as po
|
from ... import portal as po
|
||||||
from ...types import TelegramID
|
from ...types import TelegramID
|
||||||
@@ -55,7 +56,9 @@ async def bridge(evt: CommandEvent) -> EventID:
|
|||||||
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.")
|
||||||
|
|
||||||
if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
|
if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
|
||||||
return await evt.reply(f"You do not have the permissions to bridge {that_this} room.")
|
return await evt.reply(
|
||||||
|
f"You do not have the permissions to bridge {that_this.lower()} room."
|
||||||
|
)
|
||||||
|
|
||||||
# The /id bot command provides the prefixed ID, so we assume
|
# The /id bot command provides the prefixed ID, so we assume
|
||||||
tgid_str = evt.args[0]
|
tgid_str = evt.args[0]
|
||||||
@@ -184,7 +187,7 @@ async def confirm_bridge(evt: CommandEvent) -> EventID | None:
|
|||||||
if not ok:
|
if not ok:
|
||||||
return None
|
return None
|
||||||
elif coro:
|
elif coro:
|
||||||
asyncio.create_task(coro)
|
background_task.create(coro)
|
||||||
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
|
||||||
@@ -251,7 +254,7 @@ async def _locked_confirm_bridge(
|
|||||||
await portal.save()
|
await portal.save()
|
||||||
await portal.update_bridge_info()
|
await portal.update_bridge_info()
|
||||||
|
|
||||||
asyncio.create_task(portal.update_matrix_room(user, entity, levels=levels))
|
background_task.create(portal.update_matrix_room(user, entity, levels=levels))
|
||||||
|
|
||||||
await warn_missing_power(levels, evt)
|
await warn_missing_power(levels, evt)
|
||||||
|
|
||||||
|
|||||||
@@ -65,19 +65,11 @@ async def create(evt: CommandEvent) -> EventID:
|
|||||||
about=about,
|
about=about,
|
||||||
encrypted=encrypted,
|
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)
|
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()
|
await portal.delete()
|
||||||
return await evt.reply(e.args[0])
|
return await evt.reply(e.args[0])
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import io
|
|||||||
from telethon.errors import (
|
from telethon.errors import (
|
||||||
AccessTokenExpiredError,
|
AccessTokenExpiredError,
|
||||||
AccessTokenInvalidError,
|
AccessTokenInvalidError,
|
||||||
FirstNameInvalidError,
|
|
||||||
FloodWaitError,
|
FloodWaitError,
|
||||||
PasswordHashInvalidError,
|
PasswordHashInvalidError,
|
||||||
PhoneCodeExpiredError,
|
PhoneCodeExpiredError,
|
||||||
@@ -31,14 +30,12 @@ from telethon.errors import (
|
|||||||
PhoneNumberBannedError,
|
PhoneNumberBannedError,
|
||||||
PhoneNumberFloodError,
|
PhoneNumberFloodError,
|
||||||
PhoneNumberInvalidError,
|
PhoneNumberInvalidError,
|
||||||
PhoneNumberOccupiedError,
|
|
||||||
PhoneNumberUnoccupiedError,
|
PhoneNumberUnoccupiedError,
|
||||||
SessionPasswordNeededError,
|
SessionPasswordNeededError,
|
||||||
)
|
)
|
||||||
from telethon.tl.types import User
|
from telethon.tl.types import User
|
||||||
|
|
||||||
from mautrix.client import Client
|
from mautrix.client import Client
|
||||||
from mautrix.errors import MForbidden
|
|
||||||
from mautrix.types import (
|
from mautrix.types import (
|
||||||
EventID,
|
EventID,
|
||||||
ImageInfo,
|
ImageInfo,
|
||||||
@@ -47,6 +44,7 @@ from mautrix.types import (
|
|||||||
TextMessageEventContent,
|
TextMessageEventContent,
|
||||||
UserID,
|
UserID,
|
||||||
)
|
)
|
||||||
|
from mautrix.util import background_task
|
||||||
from mautrix.util.format_duration import format_duration as fmt_duration
|
from mautrix.util.format_duration import format_duration as fmt_duration
|
||||||
|
|
||||||
from ... import user as u
|
from ... import user as u
|
||||||
@@ -94,70 +92,6 @@ async def ping_bot(evt: CommandEvent) -> EventID:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False,
|
|
||||||
management_only=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_args="<_phone_> <_full name_>",
|
|
||||||
help_text="Register to Telegram",
|
|
||||||
)
|
|
||||||
async def register(evt: CommandEvent) -> EventID:
|
|
||||||
if await evt.sender.is_logged_in():
|
|
||||||
return await evt.reply("You are already logged in.")
|
|
||||||
elif len(evt.args) < 1:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp register <phone> <full name>`")
|
|
||||||
|
|
||||||
phone_number = evt.args[0]
|
|
||||||
if len(evt.args) == 2:
|
|
||||||
full_name = evt.args[1], ""
|
|
||||||
else:
|
|
||||||
full_name = " ".join(evt.args[1:-1]), evt.args[-1]
|
|
||||||
|
|
||||||
await _request_code(
|
|
||||||
evt,
|
|
||||||
phone_number,
|
|
||||||
{
|
|
||||||
"next": enter_code_register,
|
|
||||||
"action": "Register",
|
|
||||||
"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:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
|
|
||||||
try:
|
|
||||||
await evt.sender.ensure_started(even_if_no_session=True)
|
|
||||||
first_name, last_name = evt.sender.command_status["full_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))
|
|
||||||
evt.sender.command_status = None
|
|
||||||
return await evt.reply(f"Successfully registered to Telegram.")
|
|
||||||
except PhoneNumberOccupiedError:
|
|
||||||
return await evt.reply(
|
|
||||||
"That phone number has already been registered. "
|
|
||||||
"You can log in with `$cmdprefix+sp login`."
|
|
||||||
)
|
|
||||||
except FirstNameInvalidError:
|
|
||||||
return await evt.reply("Invalid name. Please set a Matrix displayname before registering.")
|
|
||||||
except PhoneCodeExpiredError:
|
|
||||||
return await evt.reply(
|
|
||||||
"Phone code expired. Try again with `$cmdprefix+sp register <phone>`."
|
|
||||||
)
|
|
||||||
except PhoneCodeInvalidError:
|
|
||||||
return await evt.reply("Invalid phone code.")
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Error sending phone code")
|
|
||||||
return await evt.reply(
|
|
||||||
"Unhandled exception while sending code. Check console for more details."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
@command_handler(
|
||||||
needs_auth=False,
|
needs_auth=False,
|
||||||
management_only=True,
|
management_only=True,
|
||||||
@@ -187,6 +121,7 @@ async def login_qr(evt: CommandEvent) -> EventID:
|
|||||||
mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr))
|
mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr))
|
||||||
content = MediaMessageEventContent(
|
content = MediaMessageEventContent(
|
||||||
body=qr_login.url,
|
body=qr_login.url,
|
||||||
|
filename="login-qr.png",
|
||||||
url=mxc,
|
url=mxc,
|
||||||
msgtype=MessageType.IMAGE,
|
msgtype=MessageType.IMAGE,
|
||||||
info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size),
|
info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size),
|
||||||
@@ -317,7 +252,7 @@ async def _request_code(
|
|||||||
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 sign up to Telegram using an official mobile client first."
|
||||||
)
|
)
|
||||||
except PhoneNumberInvalidError:
|
except PhoneNumberInvalidError:
|
||||||
return await evt.reply("That phone number is not valid.")
|
return await evt.reply("That phone number is not valid.")
|
||||||
@@ -432,7 +367,7 @@ async def _finish_sign_in(evt: CommandEvent, user: User, login_as: u.User = None
|
|||||||
f"[{existing_user.displayname}] (https://matrix.to/#/{existing_user.mxid})"
|
f"[{existing_user.displayname}] (https://matrix.to/#/{existing_user.mxid})"
|
||||||
" was logged out from the account."
|
" was logged out from the account."
|
||||||
)
|
)
|
||||||
asyncio.create_task(login_as.post_login(user, first_login=True))
|
background_task.create(login_as.post_login(user, first_login=True))
|
||||||
evt.sender.command_status = None
|
evt.sender.command_status = None
|
||||||
name = f"@{user.username}" if user.username else f"+{user.phone}"
|
name = f"@{user.username}" if user.username else f"+{user.phone}"
|
||||||
if login_as != evt.sender:
|
if login_as != evt.sender:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from typing import cast
|
|||||||
import base64
|
import base64
|
||||||
import codecs
|
import codecs
|
||||||
import re
|
import re
|
||||||
|
import shlex
|
||||||
|
|
||||||
from aiohttp import ClientSession, InvalidURL
|
from aiohttp import ClientSession, InvalidURL
|
||||||
from telethon.errors import (
|
from telethon.errors import (
|
||||||
@@ -28,10 +29,10 @@ from telethon.errors import (
|
|||||||
InviteHashInvalidError,
|
InviteHashInvalidError,
|
||||||
InviteRequestSentError,
|
InviteRequestSentError,
|
||||||
OptionsTooMuchError,
|
OptionsTooMuchError,
|
||||||
TakeoutInitDelayError,
|
|
||||||
UserAlreadyParticipantError,
|
UserAlreadyParticipantError,
|
||||||
)
|
)
|
||||||
from telethon.tl.functions.channels import JoinChannelRequest
|
from telethon.tl.functions.channels import JoinChannelRequest
|
||||||
|
from telethon.tl.functions.contacts import DeleteByPhonesRequest, ImportContactsRequest
|
||||||
from telethon.tl.functions.messages import (
|
from telethon.tl.functions.messages import (
|
||||||
CheckChatInviteRequest,
|
CheckChatInviteRequest,
|
||||||
GetBotCallbackAnswerRequest,
|
GetBotCallbackAnswerRequest,
|
||||||
@@ -41,12 +42,14 @@ from telethon.tl.functions.messages import (
|
|||||||
from telethon.tl.patched import Message
|
from telethon.tl.patched import Message
|
||||||
from telethon.tl.types import (
|
from telethon.tl.types import (
|
||||||
InputMediaDice,
|
InputMediaDice,
|
||||||
|
InputPhoneContact,
|
||||||
MessageMediaGame,
|
MessageMediaGame,
|
||||||
MessageMediaPoll,
|
MessageMediaPoll,
|
||||||
TypeInputPeer,
|
TypeInputPeer,
|
||||||
TypeUpdates,
|
TypeUpdates,
|
||||||
User as TLUser,
|
User as TLUser,
|
||||||
)
|
)
|
||||||
|
from telethon.tl.types.contacts import ImportedContacts
|
||||||
from telethon.tl.types.messages import BotCallbackAnswer
|
from telethon.tl.types.messages import BotCallbackAnswer
|
||||||
|
|
||||||
from mautrix.types import EventID, Format
|
from mautrix.types import EventID, Format
|
||||||
@@ -133,15 +136,16 @@ async def search(evt: CommandEvent) -> EventID:
|
|||||||
|
|
||||||
@command_handler(
|
@command_handler(
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
help_section=SECTION_CREATING_PORTALS,
|
||||||
help_args="<_identifier_>",
|
help_args="<_username_>",
|
||||||
help_text="Open a private chat with the given Telegram user. The identifier is "
|
help_text=(
|
||||||
"either the internal user ID, the username or the phone number. "
|
"Open a private chat with the given Telegram user. You can also use a "
|
||||||
"**N.B.** The phone numbers you start chats with must already be in "
|
"phone number instead of username, but you must have the number in "
|
||||||
"your contacts.",
|
"your Telegram contacts for that to work."
|
||||||
|
),
|
||||||
)
|
)
|
||||||
async def pm(evt: CommandEvent) -> EventID:
|
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 <username>`")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
id = "".join(evt.args).translate({ord(c): None for c in "+()- "})
|
id = "".join(evt.args).translate({ord(c): None for c in "+()- "})
|
||||||
@@ -159,6 +163,76 @@ async def pm(evt: CommandEvent) -> EventID:
|
|||||||
return await evt.reply(f"Created private chat room with {displayname}")
|
return await evt.reply(f"Created private chat room with {displayname}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _handle_contact(source: AbstractUser, user: TLUser) -> str:
|
||||||
|
puppet: pu.Puppet = await pu.Puppet.get_by_tgid(user.id)
|
||||||
|
await puppet.update_info(source, user)
|
||||||
|
|
||||||
|
params = []
|
||||||
|
if user.username:
|
||||||
|
params.append(f"[@{user.username}](https://t.me/{user.username})")
|
||||||
|
if user.phone:
|
||||||
|
params.append(f"+{user.phone}")
|
||||||
|
params.append(f"ID `{user.id}`")
|
||||||
|
params_str = " / ".join(params)
|
||||||
|
return f"[{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): {params_str}"
|
||||||
|
|
||||||
|
|
||||||
|
@command_handler(
|
||||||
|
help_section=SECTION_CREATING_PORTALS,
|
||||||
|
help_args="<_phone_> <_first name_> <_last name_>",
|
||||||
|
help_text="Add a phone number to your contacts on Telegram",
|
||||||
|
)
|
||||||
|
async def add_contact(evt: CommandEvent) -> EventID:
|
||||||
|
if len(evt.args) < 3:
|
||||||
|
return await evt.reply(
|
||||||
|
"**Usage:** `$cmdprefix+sp add-contact <phone> <first name> <last name>`"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
names = shlex.split(" ".join(evt.args[1:]))
|
||||||
|
except ValueError as e:
|
||||||
|
return await evt.reply(
|
||||||
|
f"Failed to parse names (use shell quoting for names with spaces): {e}"
|
||||||
|
)
|
||||||
|
if len(names) != 2:
|
||||||
|
return await evt.reply(
|
||||||
|
"Wrong number of names, must have first and last name "
|
||||||
|
"(use shell quoting for names with spaces)"
|
||||||
|
)
|
||||||
|
res: ImportedContacts = await evt.sender.client(
|
||||||
|
ImportContactsRequest(
|
||||||
|
contacts=[
|
||||||
|
InputPhoneContact(
|
||||||
|
client_id=1, phone=evt.args[0], first_name=names[0], last_name=names[1]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if res.retry_contacts:
|
||||||
|
return await evt.reply("Failed to import contacts")
|
||||||
|
elif not res.users:
|
||||||
|
return await evt.reply("Contact imported, but user not found on Telegram")
|
||||||
|
imported_str = "\n".join(
|
||||||
|
[f"* {await _handle_contact(evt.sender, user)}" for user in res.users]
|
||||||
|
)
|
||||||
|
return await evt.reply(f"Imported contacts:\n\n{imported_str}")
|
||||||
|
|
||||||
|
|
||||||
|
@command_handler(
|
||||||
|
help_section=SECTION_CREATING_PORTALS,
|
||||||
|
help_args="<_phones..._>",
|
||||||
|
help_text="Remove phone numbers from your contacts on Telegram.",
|
||||||
|
aliases=["remove-contact", "delete-contacts", "remove-contacts"],
|
||||||
|
)
|
||||||
|
async def delete_contact(evt: CommandEvent) -> EventID:
|
||||||
|
if len(evt.args) == 0:
|
||||||
|
return await evt.reply("**Usage:** `$cmdprefix+sp delete-contact <phones...>`")
|
||||||
|
ok = await evt.sender.client(DeleteByPhonesRequest(phones=evt.args))
|
||||||
|
if ok:
|
||||||
|
return await evt.reply("Contacts deleted")
|
||||||
|
else:
|
||||||
|
return await evt.reply("Contacts not deleted?")
|
||||||
|
|
||||||
|
|
||||||
async def _join(
|
async def _join(
|
||||||
evt: CommandEvent, identifier: str, link_type: str
|
evt: CommandEvent, identifier: str, link_type: str
|
||||||
) -> tuple[TypeUpdates | None, EventID | None]:
|
) -> tuple[TypeUpdates | None, EventID | None]:
|
||||||
@@ -427,6 +501,9 @@ async def backfill(evt: CommandEvent) -> None:
|
|||||||
if not evt.is_portal:
|
if not evt.is_portal:
|
||||||
await evt.reply("You can only use backfill in portal rooms")
|
await evt.reply("You can only use backfill in portal rooms")
|
||||||
return
|
return
|
||||||
|
elif not evt.config["bridge.backfill.enable"]:
|
||||||
|
await evt.reply("Backfilling is disabled in the bridge config")
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
limit = int(evt.args[0])
|
limit = int(evt.args[0])
|
||||||
except (ValueError, IndexError):
|
except (ValueError, IndexError):
|
||||||
@@ -435,16 +512,5 @@ async def backfill(evt: CommandEvent) -> None:
|
|||||||
if not evt.config["bridge.backfill.normal_groups"] and portal.peer_type == "chat":
|
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")
|
await evt.reply("Backfilling normal groups is disabled in the bridge config")
|
||||||
return
|
return
|
||||||
try:
|
output = await portal.forward_backfill(evt.sender, initial=False, override_limit=limit)
|
||||||
await portal.backfill(evt.sender, limit=limit)
|
await evt.reply(output)
|
||||||
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)
|
|
||||||
|
|||||||
+47
-13
@@ -35,12 +35,6 @@ Permissions = NamedTuple(
|
|||||||
|
|
||||||
|
|
||||||
class Config(BaseBridgeConfig):
|
class Config(BaseBridgeConfig):
|
||||||
def __getitem__(self, key: str) -> Any:
|
|
||||||
try:
|
|
||||||
return os.environ[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
|
|
||||||
except KeyError:
|
|
||||||
return super().__getitem__(key)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def forbidden_defaults(self) -> List[ForbiddenDefault]:
|
def forbidden_defaults(self) -> List[ForbiddenDefault]:
|
||||||
return [
|
return [
|
||||||
@@ -63,8 +57,6 @@ class Config(BaseBridgeConfig):
|
|||||||
super().do_update(helper)
|
super().do_update(helper)
|
||||||
copy, copy_dict, base = helper
|
copy, copy_dict, base = helper
|
||||||
|
|
||||||
copy("homeserver.asmux")
|
|
||||||
|
|
||||||
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.protocol"],
|
||||||
@@ -109,6 +101,7 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("bridge.displayname_preference")
|
copy("bridge.displayname_preference")
|
||||||
copy("bridge.displayname_max_length")
|
copy("bridge.displayname_max_length")
|
||||||
copy("bridge.allow_avatar_remove")
|
copy("bridge.allow_avatar_remove")
|
||||||
|
copy("bridge.allow_contact_info")
|
||||||
|
|
||||||
copy("bridge.max_initial_member_sync")
|
copy("bridge.max_initial_member_sync")
|
||||||
copy("bridge.max_member_count")
|
copy("bridge.max_member_count")
|
||||||
@@ -121,6 +114,7 @@ class Config(BaseBridgeConfig):
|
|||||||
else:
|
else:
|
||||||
copy("bridge.sync_update_limit")
|
copy("bridge.sync_update_limit")
|
||||||
copy("bridge.sync_create_limit")
|
copy("bridge.sync_create_limit")
|
||||||
|
copy("bridge.sync_deferred_create_all")
|
||||||
copy("bridge.sync_direct_chats")
|
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")
|
||||||
@@ -142,8 +136,11 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("bridge.caption_in_message")
|
copy("bridge.caption_in_message")
|
||||||
copy("bridge.image_as_file_size")
|
copy("bridge.image_as_file_size")
|
||||||
copy("bridge.image_as_file_pixels")
|
copy("bridge.image_as_file_pixels")
|
||||||
|
copy("bridge.document_as_link_size.bot")
|
||||||
|
copy("bridge.document_as_link_size.channel")
|
||||||
copy("bridge.parallel_file_transfer")
|
copy("bridge.parallel_file_transfer")
|
||||||
copy("bridge.federate_rooms")
|
copy("bridge.federate_rooms")
|
||||||
|
copy("bridge.always_custom_emoji_reaction")
|
||||||
copy("bridge.animated_sticker.target")
|
copy("bridge.animated_sticker.target")
|
||||||
copy("bridge.animated_sticker.convert_from_webm")
|
copy("bridge.animated_sticker.convert_from_webm")
|
||||||
copy("bridge.animated_sticker.args.width")
|
copy("bridge.animated_sticker.args.width")
|
||||||
@@ -153,9 +150,19 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("bridge.animated_emoji.args.width")
|
copy("bridge.animated_emoji.args.width")
|
||||||
copy("bridge.animated_emoji.args.height")
|
copy("bridge.animated_emoji.args.height")
|
||||||
copy("bridge.animated_emoji.args.fps")
|
copy("bridge.animated_emoji.args.fps")
|
||||||
|
if isinstance(self.get("bridge.private_chat_portal_meta", "default"), bool):
|
||||||
|
base["bridge.private_chat_portal_meta"] = (
|
||||||
|
"always" if self["bridge.private_chat_portal_meta"] else "default"
|
||||||
|
)
|
||||||
|
else:
|
||||||
copy("bridge.private_chat_portal_meta")
|
copy("bridge.private_chat_portal_meta")
|
||||||
|
if base["bridge.private_chat_portal_meta"] not in ("default", "always", "never"):
|
||||||
|
base["bridge.private_chat_portal_meta"] = "default"
|
||||||
|
copy("bridge.disable_reply_fallbacks")
|
||||||
|
copy("bridge.cross_room_replies")
|
||||||
copy("bridge.delivery_receipts")
|
copy("bridge.delivery_receipts")
|
||||||
copy("bridge.delivery_error_reports")
|
copy("bridge.delivery_error_reports")
|
||||||
|
copy("bridge.incoming_bridge_error_reports")
|
||||||
copy("bridge.message_status_events")
|
copy("bridge.message_status_events")
|
||||||
copy("bridge.resend_bridge_info")
|
copy("bridge.resend_bridge_info")
|
||||||
copy("bridge.mute_bridging")
|
copy("bridge.mute_bridging")
|
||||||
@@ -165,12 +172,36 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("bridge.bridge_matrix_leave")
|
copy("bridge.bridge_matrix_leave")
|
||||||
copy("bridge.kick_on_logout")
|
copy("bridge.kick_on_logout")
|
||||||
copy("bridge.always_read_joined_telegram_notice")
|
copy("bridge.always_read_joined_telegram_notice")
|
||||||
copy("bridge.backfill.invite_own_puppet")
|
copy("bridge.backfill.enable")
|
||||||
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.backfill.normal_groups")
|
||||||
|
copy("bridge.backfill.unread_hours_threshold")
|
||||||
|
if "bridge.backfill.forward" in self:
|
||||||
|
initial_limit = self.get("bridge.backfill.forward.initial_limit", 10)
|
||||||
|
sync_limit = self.get("bridge.backfill.forward.sync_limit", 100)
|
||||||
|
base["bridge.backfill.forward_limits.initial.user"] = initial_limit
|
||||||
|
base["bridge.backfill.forward_limits.initial.normal_group"] = initial_limit
|
||||||
|
base["bridge.backfill.forward_limits.initial.supergroup"] = initial_limit
|
||||||
|
base["bridge.backfill.forward_limits.initial.channel"] = initial_limit
|
||||||
|
base["bridge.backfill.forward_limits.sync.user"] = sync_limit
|
||||||
|
base["bridge.backfill.forward_limits.sync.normal_group"] = sync_limit
|
||||||
|
base["bridge.backfill.forward_limits.sync.supergroup"] = sync_limit
|
||||||
|
base["bridge.backfill.forward_limits.sync.channel"] = sync_limit
|
||||||
|
else:
|
||||||
|
copy("bridge.backfill.forward_limits.initial.user")
|
||||||
|
copy("bridge.backfill.forward_limits.initial.normal_group")
|
||||||
|
copy("bridge.backfill.forward_limits.initial.supergroup")
|
||||||
|
copy("bridge.backfill.forward_limits.initial.channel")
|
||||||
|
copy("bridge.backfill.forward_limits.sync.user")
|
||||||
|
copy("bridge.backfill.forward_limits.sync.normal_group")
|
||||||
|
copy("bridge.backfill.forward_limits.sync.supergroup")
|
||||||
|
copy("bridge.backfill.forward_limits.sync.channel")
|
||||||
|
copy("bridge.backfill.forward_timeout")
|
||||||
|
copy("bridge.backfill.incremental.messages_per_batch")
|
||||||
|
copy("bridge.backfill.incremental.post_batch_delay")
|
||||||
|
copy("bridge.backfill.incremental.max_batches.user")
|
||||||
|
copy("bridge.backfill.incremental.max_batches.normal_group")
|
||||||
|
copy("bridge.backfill.incremental.max_batches.supergroup")
|
||||||
|
copy("bridge.backfill.incremental.max_batches.channel")
|
||||||
|
|
||||||
copy("bridge.initial_power_level_overrides.group")
|
copy("bridge.initial_power_level_overrides.group")
|
||||||
copy("bridge.initial_power_level_overrides.user")
|
copy("bridge.initial_power_level_overrides.user")
|
||||||
@@ -194,6 +225,7 @@ class Config(BaseBridgeConfig):
|
|||||||
|
|
||||||
copy("bridge.filter.mode")
|
copy("bridge.filter.mode")
|
||||||
copy("bridge.filter.list")
|
copy("bridge.filter.list")
|
||||||
|
copy("bridge.filter.users")
|
||||||
|
|
||||||
copy("bridge.command_prefix")
|
copy("bridge.command_prefix")
|
||||||
|
|
||||||
@@ -232,12 +264,14 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("telegram.catch_up")
|
copy("telegram.catch_up")
|
||||||
copy("telegram.sequential_updates")
|
copy("telegram.sequential_updates")
|
||||||
copy("telegram.exit_on_update_error")
|
copy("telegram.exit_on_update_error")
|
||||||
|
copy("telegram.force_refresh_interval_seconds")
|
||||||
|
|
||||||
copy("telegram.connection.timeout")
|
copy("telegram.connection.timeout")
|
||||||
copy("telegram.connection.retries")
|
copy("telegram.connection.retries")
|
||||||
copy("telegram.connection.retry_delay")
|
copy("telegram.connection.retry_delay")
|
||||||
copy("telegram.connection.flood_sleep_threshold")
|
copy("telegram.connection.flood_sleep_threshold")
|
||||||
copy("telegram.connection.request_retries")
|
copy("telegram.connection.request_retries")
|
||||||
|
copy("telegram.connection.use_ipv6")
|
||||||
|
|
||||||
copy("telegram.device_info.device_model")
|
copy("telegram.device_info.device_model")
|
||||||
copy("telegram.device_info.system_version")
|
copy("telegram.device_info.system_version")
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
# 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 mautrix.util.async_db import Database
|
||||||
|
|
||||||
|
from .backfill_queue import Backfill, BackfillType
|
||||||
from .bot_chat import BotChat
|
from .bot_chat import BotChat
|
||||||
from .disappearing_message import DisappearingMessage
|
from .disappearing_message import DisappearingMessage
|
||||||
from .message import Message
|
from .message import Message
|
||||||
@@ -38,6 +39,7 @@ def init(db: Database) -> None:
|
|||||||
BotChat,
|
BotChat,
|
||||||
PgSession,
|
PgSession,
|
||||||
DisappearingMessage,
|
DisappearingMessage,
|
||||||
|
Backfill,
|
||||||
):
|
):
|
||||||
table.db = db
|
table.db = db
|
||||||
|
|
||||||
@@ -54,4 +56,5 @@ __all__ = [
|
|||||||
"BotChat",
|
"BotChat",
|
||||||
"PgSession",
|
"PgSession",
|
||||||
"DisappearingMessage",
|
"DisappearingMessage",
|
||||||
|
"Backfill",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||||
|
# Copyright (C) 2022 Tulir Asokan, 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, Any, ClassVar
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from enum import Enum
|
||||||
|
import json
|
||||||
|
|
||||||
|
from asyncpg import Record
|
||||||
|
from attr import dataclass
|
||||||
|
|
||||||
|
from mautrix.types import UserID
|
||||||
|
from mautrix.util.async_db import Connection, Database
|
||||||
|
|
||||||
|
from ..types import TelegramID
|
||||||
|
|
||||||
|
fake_db = Database.create("") if TYPE_CHECKING else None
|
||||||
|
|
||||||
|
|
||||||
|
class BackfillType(Enum):
|
||||||
|
HISTORICAL = "historical"
|
||||||
|
SYNC_DIALOG = "sync_dialog"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Backfill:
|
||||||
|
db: ClassVar[Database] = fake_db
|
||||||
|
|
||||||
|
queue_id: int | None
|
||||||
|
user_mxid: UserID
|
||||||
|
priority: int
|
||||||
|
type: BackfillType
|
||||||
|
portal_tgid: TelegramID
|
||||||
|
portal_tg_receiver: TelegramID
|
||||||
|
anchor_msg_id: TelegramID | None
|
||||||
|
extra_data: dict[str, Any]
|
||||||
|
messages_per_batch: int
|
||||||
|
post_batch_delay: int
|
||||||
|
max_batches: int
|
||||||
|
dispatch_time: datetime | None
|
||||||
|
completed_at: datetime | None
|
||||||
|
cooldown_timeout: datetime | None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def new(
|
||||||
|
user_mxid: UserID,
|
||||||
|
priority: int,
|
||||||
|
type: BackfillType,
|
||||||
|
portal_tgid: TelegramID,
|
||||||
|
portal_tg_receiver: TelegramID,
|
||||||
|
messages_per_batch: int,
|
||||||
|
anchor_msg_id: TelegramID | None = None,
|
||||||
|
extra_data: dict[str, Any] | None = None,
|
||||||
|
post_batch_delay: int = 0,
|
||||||
|
max_batches: int = -1,
|
||||||
|
) -> "Backfill":
|
||||||
|
return Backfill(
|
||||||
|
queue_id=None,
|
||||||
|
user_mxid=user_mxid,
|
||||||
|
priority=priority,
|
||||||
|
type=type,
|
||||||
|
portal_tgid=portal_tgid,
|
||||||
|
portal_tg_receiver=portal_tg_receiver,
|
||||||
|
anchor_msg_id=anchor_msg_id,
|
||||||
|
extra_data=extra_data or {},
|
||||||
|
messages_per_batch=messages_per_batch,
|
||||||
|
post_batch_delay=post_batch_delay,
|
||||||
|
max_batches=max_batches,
|
||||||
|
dispatch_time=None,
|
||||||
|
completed_at=None,
|
||||||
|
cooldown_timeout=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _from_row(cls, row: Record | None) -> Backfill | None:
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
data = {**row}
|
||||||
|
type = BackfillType(data.pop("type"))
|
||||||
|
extra_data = json.loads(data.pop("extra_data", None) or "{}")
|
||||||
|
return cls(**data, type=type, extra_data=extra_data)
|
||||||
|
|
||||||
|
columns = [
|
||||||
|
"user_mxid",
|
||||||
|
"priority",
|
||||||
|
"type",
|
||||||
|
"portal_tgid",
|
||||||
|
"portal_tg_receiver",
|
||||||
|
"anchor_msg_id",
|
||||||
|
"extra_data",
|
||||||
|
"messages_per_batch",
|
||||||
|
"post_batch_delay",
|
||||||
|
"max_batches",
|
||||||
|
"dispatch_time",
|
||||||
|
"completed_at",
|
||||||
|
"cooldown_timeout",
|
||||||
|
]
|
||||||
|
columns_str = ",".join(columns)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_next(cls, user_mxid: UserID) -> Backfill | None:
|
||||||
|
q = f"""
|
||||||
|
SELECT queue_id, {cls.columns_str}
|
||||||
|
FROM backfill_queue
|
||||||
|
WHERE user_mxid=$1
|
||||||
|
AND (
|
||||||
|
dispatch_time IS NULL
|
||||||
|
OR (
|
||||||
|
dispatch_time < $2
|
||||||
|
AND completed_at IS NULL
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
cooldown_timeout IS NULL
|
||||||
|
OR cooldown_timeout < current_timestamp
|
||||||
|
)
|
||||||
|
ORDER BY priority, queue_id
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
return cls._from_row(
|
||||||
|
await cls.db.fetchrow(q, user_mxid, datetime.now() - timedelta(minutes=15))
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def delete_existing(
|
||||||
|
cls,
|
||||||
|
user_mxid: UserID,
|
||||||
|
portal_tgid: int,
|
||||||
|
portal_tg_receiver: int,
|
||||||
|
type: BackfillType,
|
||||||
|
) -> Backfill | None:
|
||||||
|
q = f"""
|
||||||
|
WITH deleted_entries AS (
|
||||||
|
DELETE FROM backfill_queue
|
||||||
|
WHERE user_mxid=$1
|
||||||
|
AND portal_tgid=$2
|
||||||
|
AND portal_tg_receiver=$3
|
||||||
|
AND type=$4
|
||||||
|
AND dispatch_time IS NULL
|
||||||
|
AND completed_at IS NULL
|
||||||
|
RETURNING 1
|
||||||
|
)
|
||||||
|
WITH dispatched_entries AS (
|
||||||
|
SELECT 1 FROM backfill_queue
|
||||||
|
WHERE user_mxid=$1
|
||||||
|
AND portal_tgid=$2
|
||||||
|
AND portal_tg_receiver=$3
|
||||||
|
AND type=$4
|
||||||
|
AND dispatch_time IS NOT NULL
|
||||||
|
AND completed_at IS NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
return cls._from_row(
|
||||||
|
await cls.db.fetchrow(q, user_mxid, portal_tgid, portal_tg_receiver, type.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def delete_all(cls, user_mxid: UserID, conn: Connection | None = None) -> None:
|
||||||
|
await (conn or cls.db).execute("DELETE FROM backfill_queue WHERE user_mxid=$1", user_mxid)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def delete_for_portal(cls, tgid: int, tg_receiver: int) -> None:
|
||||||
|
q = "DELETE FROM backfill_queue WHERE portal_tgid=$1 AND portal_tg_receiver=$2"
|
||||||
|
await cls.db.execute(q, tgid, tg_receiver)
|
||||||
|
|
||||||
|
async def insert(self) -> list[Backfill]:
|
||||||
|
delete_q = f"""
|
||||||
|
DELETE FROM backfill_queue
|
||||||
|
WHERE user_mxid=$1
|
||||||
|
AND portal_tgid=$2
|
||||||
|
AND portal_tg_receiver=$3
|
||||||
|
AND type=$4
|
||||||
|
AND dispatch_time IS NULL
|
||||||
|
AND completed_at IS NULL
|
||||||
|
RETURNING queue_id, {self.columns_str}
|
||||||
|
"""
|
||||||
|
q = f"""
|
||||||
|
INSERT INTO backfill_queue ({self.columns_str})
|
||||||
|
VALUES ({','.join(f'${i+1}' for i in range(len(self.columns)))})
|
||||||
|
RETURNING queue_id
|
||||||
|
"""
|
||||||
|
async with self.db.acquire() as conn, conn.transaction():
|
||||||
|
deleted_rows = await conn.fetch(
|
||||||
|
delete_q,
|
||||||
|
self.user_mxid,
|
||||||
|
self.portal_tgid,
|
||||||
|
self.portal_tg_receiver,
|
||||||
|
self.type.value,
|
||||||
|
)
|
||||||
|
self.queue_id = await conn.fetchval(
|
||||||
|
q,
|
||||||
|
self.user_mxid,
|
||||||
|
self.priority,
|
||||||
|
self.type.value,
|
||||||
|
self.portal_tgid,
|
||||||
|
self.portal_tg_receiver,
|
||||||
|
self.anchor_msg_id,
|
||||||
|
json.dumps(self.extra_data) if self.extra_data else None,
|
||||||
|
self.messages_per_batch,
|
||||||
|
self.post_batch_delay,
|
||||||
|
self.max_batches,
|
||||||
|
self.dispatch_time,
|
||||||
|
self.completed_at,
|
||||||
|
self.cooldown_timeout,
|
||||||
|
)
|
||||||
|
return [self._from_row(row) for row in deleted_rows]
|
||||||
|
|
||||||
|
async def mark_dispatched(self) -> None:
|
||||||
|
q = "UPDATE backfill_queue SET dispatch_time=$1 WHERE queue_id=$2"
|
||||||
|
await self.db.execute(q, datetime.now(), self.queue_id)
|
||||||
|
|
||||||
|
async def mark_done(self) -> None:
|
||||||
|
q = "UPDATE backfill_queue SET completed_at=$1 WHERE queue_id=$2"
|
||||||
|
await self.db.execute(q, datetime.now(), self.queue_id)
|
||||||
|
|
||||||
|
async def set_cooldown_timeout(self, timeout: int) -> None:
|
||||||
|
"""
|
||||||
|
Set the backfill request to cooldown for ``timeout`` seconds.
|
||||||
|
"""
|
||||||
|
q = "UPDATE backfill_queue SET cooldown_timeout=$1 WHERE queue_id=$2"
|
||||||
|
await self.db.execute(q, datetime.now() + timedelta(seconds=timeout), self.queue_id)
|
||||||
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, ClassVar
|
|||||||
|
|
||||||
from asyncpg import Record
|
from asyncpg import Record
|
||||||
from attr import dataclass
|
from attr import dataclass
|
||||||
|
import attr
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID, UserID
|
from mautrix.types import EventID, RoomID, UserID
|
||||||
from mautrix.util.async_db import Database, Scheme
|
from mautrix.util.async_db import Database, Scheme
|
||||||
@@ -122,6 +123,14 @@ class Message:
|
|||||||
)
|
)
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mx_room, tg_space))
|
return cls._from_row(await cls.db.fetchrow(q, mx_room, tg_space))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def find_first(cls, mx_room: RoomID, tg_space: TelegramID) -> Message | None:
|
||||||
|
q = (
|
||||||
|
f"SELECT {cls.columns} FROM message WHERE mx_room=$1 AND tg_space=$2 "
|
||||||
|
f"ORDER BY tgid ASC LIMIT 1"
|
||||||
|
)
|
||||||
|
return cls._from_row(await cls.db.fetchrow(q, mx_room, tg_space))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def delete_all(cls, mx_room: RoomID) -> None:
|
async def delete_all(cls, mx_room: RoomID) -> None:
|
||||||
await cls.db.execute("DELETE FROM message WHERE mx_room=$1", mx_room)
|
await cls.db.execute("DELETE FROM message WHERE mx_room=$1", mx_room)
|
||||||
@@ -152,6 +161,17 @@ class Message:
|
|||||||
rows = await cls.db.fetch(q, mx_room, tg_space, *mxids)
|
rows = await cls.db.fetch(q, mx_room, tg_space, *mxids)
|
||||||
return [cls._from_row(row) for row in rows]
|
return [cls._from_row(row) for row in rows]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def find_recent(
|
||||||
|
cls, mx_room: RoomID, not_sender: TelegramID, limit: int = 20
|
||||||
|
) -> list[Message]:
|
||||||
|
q = f"""
|
||||||
|
SELECT {cls.columns} FROM message
|
||||||
|
WHERE mx_room=$1 AND sender<>$2
|
||||||
|
ORDER BY tgid DESC LIMIT $3
|
||||||
|
"""
|
||||||
|
return [cls._from_row(row) for row in await cls.db.fetch(q, mx_room, not_sender, limit)]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def replace_temp_mxid(cls, temp_mxid: str, mx_room: RoomID, real_mxid: EventID) -> None:
|
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"
|
q = "UPDATE message SET mxid=$1 WHERE mxid=$2 AND mx_room=$3"
|
||||||
@@ -162,6 +182,23 @@ class Message:
|
|||||||
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2"
|
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2"
|
||||||
await cls.db.execute(q, temp_mxid, mx_room)
|
await cls.db.execute(q, temp_mxid, mx_room)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def bulk_insert(cls, messages: list[Message]) -> None:
|
||||||
|
columns = cls.columns.split(", ")
|
||||||
|
records = [attr.astuple(message) for message in messages]
|
||||||
|
async with cls.db.acquire() as conn, conn.transaction():
|
||||||
|
if cls.db.scheme == Scheme.POSTGRES:
|
||||||
|
await conn.copy_records_to_table("message", records=records, columns=columns)
|
||||||
|
else:
|
||||||
|
await conn.executemany(cls._insert_query, records)
|
||||||
|
|
||||||
|
_insert_query: ClassVar[
|
||||||
|
str
|
||||||
|
] = """
|
||||||
|
INSERT INTO message (mxid, mx_room, tgid, tg_space, edit_index, redacted, content_hash, sender_mxid, sender)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _values(self):
|
def _values(self):
|
||||||
return (
|
return (
|
||||||
@@ -177,13 +214,7 @@ class Message:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def insert(self) -> None:
|
async def insert(self) -> None:
|
||||||
q = """
|
await self.db.execute(self._insert_query, *self._values)
|
||||||
INSERT INTO message (
|
|
||||||
mxid, mx_room, tgid, tg_space, edit_index, redacted, content_hash,
|
|
||||||
sender_mxid, sender
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
async def delete(self) -> None:
|
||||||
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
|
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
|
||||||
|
|||||||
@@ -167,7 +167,10 @@ class Portal:
|
|||||||
"UPDATE portal SET tgid=$1, tg_receiver=$1, peer_type=$2 "
|
"UPDATE portal SET tgid=$1, tg_receiver=$1, peer_type=$2 "
|
||||||
"WHERE tgid=$3 AND tg_receiver=$3"
|
"WHERE tgid=$3 AND tg_receiver=$3"
|
||||||
)
|
)
|
||||||
await self.db.execute(q, id, peer_type, self.tgid)
|
clear_queue = "DELETE FROM backfill_queue WHERE portal_tgid=$1 AND portal_tg_receiver=$2"
|
||||||
|
async with self.db.acquire() as conn, conn.transaction():
|
||||||
|
await conn.execute(clear_queue, self.tgid, self.tg_receiver)
|
||||||
|
await conn.execute(q, id, peer_type, self.tgid)
|
||||||
self.tgid = id
|
self.tgid = id
|
||||||
self.tg_receiver = id
|
self.tg_receiver = id
|
||||||
self.peer_type = peer_type
|
self.peer_type = peer_type
|
||||||
|
|||||||
@@ -48,8 +48,10 @@ class Puppet:
|
|||||||
avatar_url: ContentURI | None
|
avatar_url: ContentURI | None
|
||||||
name_set: bool
|
name_set: bool
|
||||||
avatar_set: bool
|
avatar_set: bool
|
||||||
|
contact_info_set: bool
|
||||||
is_bot: bool | None
|
is_bot: bool | None
|
||||||
is_channel: bool
|
is_channel: bool
|
||||||
|
is_premium: bool
|
||||||
|
|
||||||
custom_mxid: UserID | None
|
custom_mxid: UserID | None
|
||||||
access_token: str | None
|
access_token: str | None
|
||||||
@@ -67,7 +69,8 @@ class Puppet:
|
|||||||
columns: ClassVar[str] = (
|
columns: ClassVar[str] = (
|
||||||
"id, is_registered, displayname, displayname_source, displayname_contact, "
|
"id, is_registered, displayname, displayname_source, displayname_contact, "
|
||||||
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
|
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
|
||||||
"name_set, avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url"
|
"name_set, avatar_set, contact_info_set, is_bot, is_channel, is_premium, "
|
||||||
|
"custom_mxid, access_token, next_batch, base_url"
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -106,8 +109,10 @@ class Puppet:
|
|||||||
self.avatar_url,
|
self.avatar_url,
|
||||||
self.name_set,
|
self.name_set,
|
||||||
self.avatar_set,
|
self.avatar_set,
|
||||||
|
self.contact_info_set,
|
||||||
self.is_bot,
|
self.is_bot,
|
||||||
self.is_channel,
|
self.is_channel,
|
||||||
|
self.is_premium,
|
||||||
self.custom_mxid,
|
self.custom_mxid,
|
||||||
self.access_token,
|
self.access_token,
|
||||||
self.next_batch,
|
self.next_batch,
|
||||||
@@ -119,8 +124,9 @@ class Puppet:
|
|||||||
UPDATE puppet
|
UPDATE puppet
|
||||||
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
|
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
|
||||||
displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10,
|
displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10,
|
||||||
avatar_url=$11, name_set=$12, avatar_set=$13, is_bot=$14, is_channel=$15,
|
avatar_url=$11, name_set=$12, avatar_set=$13, contact_info_set=$14, is_bot=$15,
|
||||||
custom_mxid=$16, access_token=$17, next_batch=$18, base_url=$19
|
is_channel=$16, is_premium=$17, custom_mxid=$18, access_token=$19, next_batch=$20,
|
||||||
|
base_url=$21
|
||||||
WHERE id=$1
|
WHERE id=$1
|
||||||
"""
|
"""
|
||||||
await self.db.execute(q, *self._values)
|
await self.db.execute(q, *self._values)
|
||||||
@@ -130,8 +136,9 @@ class Puppet:
|
|||||||
INSERT INTO puppet (
|
INSERT INTO puppet (
|
||||||
id, is_registered, displayname, displayname_source, displayname_contact,
|
id, is_registered, displayname, displayname_source, displayname_contact,
|
||||||
displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
|
displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
|
||||||
avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url
|
avatar_set, contact_info_set, is_bot, is_channel, is_premium, 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,
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
|
||||||
$19)
|
$19, $20, $21)
|
||||||
"""
|
"""
|
||||||
await self.db.execute(q, *self._values)
|
await self.db.execute(q, *self._values)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, ClassVar
|
|||||||
|
|
||||||
from asyncpg import Record
|
from asyncpg import Record
|
||||||
from attr import dataclass
|
from attr import dataclass
|
||||||
|
from telethon.tl.types import ReactionCustomEmoji, ReactionEmoji, TypeReaction
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID
|
from mautrix.types import EventID, RoomID
|
||||||
from mautrix.util.async_db import Database
|
from mautrix.util.async_db import Database
|
||||||
@@ -58,9 +59,10 @@ class Reaction:
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_sender(
|
async def get_by_sender(
|
||||||
cls, mxid: EventID, mx_room: RoomID, tg_sender: TelegramID
|
cls, mxid: EventID, mx_room: RoomID, tg_sender: TelegramID
|
||||||
) -> Reaction | None:
|
) -> list[Reaction]:
|
||||||
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3"
|
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))
|
rows = await cls.db.fetch(q, mxid, mx_room, tg_sender)
|
||||||
|
return [cls._from_row(row) for row in rows]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_all_by_message(cls, mxid: EventID, mx_room: RoomID) -> list[Reaction]:
|
async def get_all_by_message(cls, mxid: EventID, mx_room: RoomID) -> list[Reaction]:
|
||||||
@@ -68,6 +70,13 @@ class Reaction:
|
|||||||
rows = await cls.db.fetch(q, mxid, mx_room)
|
rows = await cls.db.fetch(q, mxid, mx_room)
|
||||||
return [cls._from_row(row) for row in rows]
|
return [cls._from_row(row) for row in rows]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telegram(self) -> TypeReaction:
|
||||||
|
if self.reaction.isdecimal():
|
||||||
|
return ReactionCustomEmoji(document_id=int(self.reaction))
|
||||||
|
else:
|
||||||
|
return ReactionEmoji(emoticon=self.reaction)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _values(self):
|
def _values(self):
|
||||||
return (
|
return (
|
||||||
@@ -81,11 +90,11 @@ class Reaction:
|
|||||||
async def save(self) -> None:
|
async def save(self) -> None:
|
||||||
q = """
|
q = """
|
||||||
INSERT INTO reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
|
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)
|
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (msg_mxid, mx_room, tg_sender, reaction)
|
||||||
DO UPDATE SET mxid=$1, reaction=$5
|
DO UPDATE SET mxid=excluded.mxid
|
||||||
"""
|
"""
|
||||||
await self.db.execute(q, *self._values)
|
await self.db.execute(q, *self._values)
|
||||||
|
|
||||||
async def delete(self) -> None:
|
async def delete(self) -> None:
|
||||||
q = "DELETE FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3"
|
q = "DELETE FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3 AND reaction=$4"
|
||||||
await self.db.execute(q, self.msg_mxid, self.mx_room, self.tg_sender)
|
await self.db.execute(q, self.msg_mxid, self.mx_room, self.tg_sender, self.reaction)
|
||||||
|
|||||||
@@ -77,16 +77,24 @@ class TelegramFile:
|
|||||||
file = cls._from_row(row)
|
file = cls._from_row(row)
|
||||||
if file is None:
|
if file is None:
|
||||||
return None
|
return None
|
||||||
thumbnail_id = row.get("thumbnail", None)
|
try:
|
||||||
|
thumbnail_id = row["thumbnail"]
|
||||||
|
except KeyError:
|
||||||
|
thumbnail_id = None
|
||||||
if thumbnail_id and not _thumbnail:
|
if thumbnail_id and not _thumbnail:
|
||||||
file.thumbnail = await cls.get(thumbnail_id, _thumbnail=True)
|
file.thumbnail = await cls.get(thumbnail_id, _thumbnail=True)
|
||||||
return file
|
return file
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def find_by_mxc(cls, mxc: ContentURI) -> TelegramFile | None:
|
||||||
|
q = f"SELECT {cls.columns} FROM telegram_file WHERE mxc=$1"
|
||||||
|
return cls._from_row(await cls.db.fetchrow(q, mxc))
|
||||||
|
|
||||||
async def insert(self) -> None:
|
async def insert(self) -> None:
|
||||||
q = (
|
q = (
|
||||||
"INSERT INTO telegram_file (id, mxc, mime_type, was_converted, size, width, height, "
|
"INSERT INTO telegram_file (id, mxc, mime_type, was_converted, timestamp,"
|
||||||
" thumbnail, decryption_info) "
|
" size, width, height, thumbnail, decryption_info) "
|
||||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
|
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"
|
||||||
)
|
)
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
q,
|
q,
|
||||||
@@ -94,6 +102,7 @@ class TelegramFile:
|
|||||||
self.mxc,
|
self.mxc,
|
||||||
self.mime_type,
|
self.mime_type,
|
||||||
self.was_converted,
|
self.was_converted,
|
||||||
|
self.timestamp,
|
||||||
self.size,
|
self.size,
|
||||||
self.width,
|
self.width,
|
||||||
self.height,
|
self.height,
|
||||||
|
|||||||
@@ -123,19 +123,55 @@ class PgSession(MemorySession):
|
|||||||
date = datetime.datetime.utcfromtimestamp(row["date"])
|
date = datetime.datetime.utcfromtimestamp(row["date"])
|
||||||
return updates.State(row["pts"], row["qts"], date, row["seq"], row["unread_count"])
|
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:
|
_set_update_state_q = """
|
||||||
q = """
|
|
||||||
INSERT INTO telethon_update_state (session_id, entity_id, pts, qts, date, seq, unread_count)
|
INSERT INTO telethon_update_state (session_id, entity_id, pts, qts, date, seq, unread_count)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (session_id, entity_id) DO UPDATE SET
|
ON CONFLICT (session_id, entity_id) DO UPDATE SET
|
||||||
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
|
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
|
||||||
unread_count=excluded.unread_count
|
unread_count=excluded.unread_count
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
async def set_update_state(self, entity_id: int, row: updates.State) -> None:
|
||||||
|
q = self._set_update_state_q
|
||||||
ts = row.date.timestamp()
|
ts = row.date.timestamp()
|
||||||
await self.db.execute(
|
await self.db.execute(
|
||||||
q, self.session_id, entity_id, row.pts, row.qts, ts, row.seq, row.unread_count
|
q, self.session_id, entity_id, row.pts, row.qts, ts, row.seq, row.unread_count
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def set_update_states(self, rows: list[tuple[int, updates.State]]) -> None:
|
||||||
|
rows = [
|
||||||
|
(
|
||||||
|
self.session_id,
|
||||||
|
entity_id,
|
||||||
|
row.pts,
|
||||||
|
row.qts,
|
||||||
|
row.date.timestamp(),
|
||||||
|
row.seq,
|
||||||
|
row.unread_count,
|
||||||
|
)
|
||||||
|
for entity_id, row in rows
|
||||||
|
]
|
||||||
|
if self.db.scheme == Scheme.POSTGRES:
|
||||||
|
q = """
|
||||||
|
INSERT INTO telethon_update_state (
|
||||||
|
session_id, entity_id, pts, qts, date, seq, unread_count
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1,
|
||||||
|
unnest($2::bigint[]), unnest($3::bigint[]), unnest($4::bigint[]),
|
||||||
|
unnest($5::bigint[]), unnest($6::bigint[]), unnest($7::integer[])
|
||||||
|
)
|
||||||
|
ON CONFLICT (session_id, entity_id) DO UPDATE SET
|
||||||
|
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
|
||||||
|
unread_count=excluded.unread_count
|
||||||
|
"""
|
||||||
|
_, entity_ids, ptses, qtses, timestamps, seqs, unread_counts = zip(*rows)
|
||||||
|
await self.db.execute(
|
||||||
|
q, self.session_id, entity_ids, ptses, qtses, timestamps, seqs, unread_counts
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.db.executemany(self._set_update_state_q, rows)
|
||||||
|
|
||||||
async def delete_update_state(self, entity_id: int) -> None:
|
async def delete_update_state(self, entity_id: int) -> None:
|
||||||
q = "DELETE FROM telethon_update_state WHERE session_id=$1 AND entity_id=$2"
|
q = "DELETE FROM telethon_update_state WHERE session_id=$1 AND entity_id=$2"
|
||||||
await self.db.execute(q, self.session_id, entity_id)
|
await self.db.execute(q, self.session_id, entity_id)
|
||||||
@@ -172,9 +208,9 @@ class PgSession(MemorySession):
|
|||||||
await self._locked_process_entities(tlo)
|
await self._locked_process_entities(tlo)
|
||||||
|
|
||||||
async def _locked_process_entities(self, tlo) -> None:
|
async def _locked_process_entities(self, tlo) -> None:
|
||||||
rows: list[
|
rows: list[tuple[str, int, int, str | None, str | None, str | None]] = (
|
||||||
tuple[str, int, int, str | None, str | None, str | None]
|
self._entities_to_rows(tlo)
|
||||||
] = self._entities_to_rows(tlo)
|
)
|
||||||
if not rows:
|
if not rows:
|
||||||
return
|
return
|
||||||
if self.db.scheme == Scheme.POSTGRES:
|
if self.db.scheme == Scheme.POSTGRES:
|
||||||
|
|||||||
@@ -15,4 +15,10 @@ from . import (
|
|||||||
v10_more_backfill_fields,
|
v10_more_backfill_fields,
|
||||||
v11_backfill_queue,
|
v11_backfill_queue,
|
||||||
v12_message_sender,
|
v12_message_sender,
|
||||||
|
v13_multiple_reactions,
|
||||||
|
v14_puppet_custom_mxid_index,
|
||||||
|
v15_backfill_anchor_id,
|
||||||
|
v16_backfill_type,
|
||||||
|
v17_message_find_recent,
|
||||||
|
v18_puppet_contact_info_set,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,12 +13,12 @@
|
|||||||
#
|
#
|
||||||
# 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 Connection
|
from mautrix.util.async_db import Connection, Scheme
|
||||||
|
|
||||||
latest_version = 10
|
latest_version = 18
|
||||||
|
|
||||||
|
|
||||||
async def create_latest_tables(conn: Connection) -> int:
|
async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""CREATE TABLE "user" (
|
"""CREATE TABLE "user" (
|
||||||
mxid TEXT PRIMARY KEY,
|
mxid TEXT PRIMARY KEY,
|
||||||
@@ -26,6 +26,7 @@ async def create_latest_tables(conn: Connection) -> int:
|
|||||||
tg_username TEXT,
|
tg_username TEXT,
|
||||||
tg_phone TEXT,
|
tg_phone TEXT,
|
||||||
is_bot BOOLEAN NOT NULL DEFAULT false,
|
is_bot BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_premium BOOLEAN NOT NULL DEFAULT false,
|
||||||
saved_contacts INTEGER NOT NULL DEFAULT 0
|
saved_contacts INTEGER NOT NULL DEFAULT 0
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
@@ -66,10 +67,13 @@ async def create_latest_tables(conn: Connection) -> int:
|
|||||||
edit_index INTEGER,
|
edit_index INTEGER,
|
||||||
redacted BOOLEAN NOT NULL DEFAULT false,
|
redacted BOOLEAN NOT NULL DEFAULT false,
|
||||||
content_hash bytea,
|
content_hash bytea,
|
||||||
|
sender_mxid TEXT,
|
||||||
|
sender BIGINT,
|
||||||
PRIMARY KEY (tgid, tg_space, edit_index),
|
PRIMARY KEY (tgid, tg_space, edit_index),
|
||||||
UNIQUE (mxid, mx_room, tg_space)
|
UNIQUE (mxid, mx_room, tg_space)
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
|
await conn.execute("CREATE INDEX message_mx_room_and_tgid_idx ON message(mx_room, tgid DESC)")
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""CREATE TABLE reaction (
|
"""CREATE TABLE reaction (
|
||||||
mxid TEXT NOT NULL,
|
mxid TEXT NOT NULL,
|
||||||
@@ -78,7 +82,7 @@ async def create_latest_tables(conn: Connection) -> int:
|
|||||||
tg_sender BIGINT,
|
tg_sender BIGINT,
|
||||||
reaction TEXT NOT NULL,
|
reaction TEXT NOT NULL,
|
||||||
|
|
||||||
PRIMARY KEY (msg_mxid, mx_room, tg_sender),
|
PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction),
|
||||||
UNIQUE (mxid, mx_room)
|
UNIQUE (mxid, mx_room)
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
@@ -109,8 +113,10 @@ async def create_latest_tables(conn: Connection) -> int:
|
|||||||
avatar_url TEXT,
|
avatar_url TEXT,
|
||||||
name_set BOOLEAN NOT NULL DEFAULT false,
|
name_set BOOLEAN NOT NULL DEFAULT false,
|
||||||
avatar_set BOOLEAN NOT NULL DEFAULT false,
|
avatar_set BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
contact_info_set BOOLEAN NOT NULL DEFAULT false,
|
||||||
is_bot BOOLEAN,
|
is_bot BOOLEAN,
|
||||||
is_channel BOOLEAN NOT NULL DEFAULT false,
|
is_channel BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
is_premium BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
|
||||||
access_token TEXT,
|
access_token TEXT,
|
||||||
custom_mxid TEXT,
|
custom_mxid TEXT,
|
||||||
@@ -119,6 +125,7 @@ async def create_latest_tables(conn: Connection) -> int:
|
|||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
await conn.execute("CREATE INDEX puppet_username_idx ON puppet(LOWER(username))")
|
await conn.execute("CREATE INDEX puppet_username_idx ON puppet(LOWER(username))")
|
||||||
|
await conn.execute("CREATE INDEX puppet_custom_mxid_idx ON puppet(custom_mxid)")
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""CREATE TABLE telegram_file (
|
"""CREATE TABLE telegram_file (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -135,6 +142,7 @@ async def create_latest_tables(conn: Connection) -> int:
|
|||||||
ON UPDATE CASCADE ON DELETE SET NULL
|
ON UPDATE CASCADE ON DELETE SET NULL
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
|
await conn.execute("CREATE INDEX telegram_file_mxc_idx ON telegram_file(mxc)")
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""CREATE TABLE bot_chat (
|
"""CREATE TABLE bot_chat (
|
||||||
id BIGINT PRIMARY KEY,
|
id BIGINT PRIMARY KEY,
|
||||||
@@ -204,4 +212,31 @@ async def create_latest_tables(conn: Connection) -> int:
|
|||||||
PRIMARY KEY (session_id, entity_id)
|
PRIMARY KEY (session_id, entity_id)
|
||||||
)"""
|
)"""
|
||||||
)
|
)
|
||||||
|
gen = ""
|
||||||
|
if scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
|
||||||
|
gen = "GENERATED ALWAYS AS IDENTITY"
|
||||||
|
await conn.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE backfill_queue (
|
||||||
|
queue_id INTEGER PRIMARY KEY {gen},
|
||||||
|
user_mxid TEXT,
|
||||||
|
priority INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
portal_tgid BIGINT,
|
||||||
|
portal_tg_receiver BIGINT,
|
||||||
|
anchor_msg_id BIGINT,
|
||||||
|
extra_data jsonb,
|
||||||
|
messages_per_batch INTEGER NOT NULL,
|
||||||
|
post_batch_delay INTEGER NOT NULL,
|
||||||
|
max_batches INTEGER NOT NULL,
|
||||||
|
dispatch_time TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
cooldown_timeout TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
FOREIGN KEY (portal_tgid, portal_tg_receiver)
|
||||||
|
REFERENCES portal(tgid, tg_receiver) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
return latest_version
|
return latest_version
|
||||||
|
|||||||
@@ -24,29 +24,21 @@ legacy_version_query = "SELECT version_num FROM alembic_version"
|
|||||||
last_legacy_version = "bfc0a39bfe02"
|
last_legacy_version = "bfc0a39bfe02"
|
||||||
|
|
||||||
|
|
||||||
def table_exists(scheme: str, name: str) -> str:
|
async def first_upgrade_target(conn: Connection, scheme: Scheme) -> int:
|
||||||
if scheme == Scheme.SQLITE:
|
is_legacy = await conn.table_exists("alembic_version")
|
||||||
return f"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='{name}')"
|
|
||||||
elif scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
|
|
||||||
return f"SELECT EXISTS(SELECT 1 FROM information_schema.tables WHERE table_name='{name}')"
|
|
||||||
raise RuntimeError("unsupported database scheme")
|
|
||||||
|
|
||||||
|
|
||||||
async def first_upgrade_target(conn: Connection, scheme: str) -> int:
|
|
||||||
is_legacy = await conn.fetchval(table_exists(scheme, "alembic_version"))
|
|
||||||
# If it's a legacy db, the upgrade process will go to v1 and run each migration up to latest.
|
# If it's a legacy db, the upgrade process will go to v1 and run each migration up to latest.
|
||||||
# If it's a new db, we'll create the latest tables directly (see create_latest_tables call).
|
# If it's a new db, we'll create the latest tables directly (see create_latest_tables call).
|
||||||
return 1 if is_legacy else latest_version
|
return 1 if is_legacy else latest_version
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Initial asyncpg revision", upgrades_to=first_upgrade_target)
|
@upgrade_table.register(description="Initial asyncpg revision", upgrades_to=first_upgrade_target)
|
||||||
async def upgrade_v1(conn: Connection, scheme: str) -> int:
|
async def upgrade_v1(conn: Connection, scheme: Scheme) -> int:
|
||||||
is_legacy = await conn.fetchval(table_exists(scheme, "alembic_version"))
|
is_legacy = await conn.table_exists("alembic_version")
|
||||||
if is_legacy:
|
if is_legacy:
|
||||||
await migrate_legacy_to_v1(conn, scheme)
|
await migrate_legacy_to_v1(conn, scheme)
|
||||||
return 1
|
return 1
|
||||||
else:
|
else:
|
||||||
return await create_latest_tables(conn)
|
return await create_latest_tables(conn, scheme)
|
||||||
|
|
||||||
|
|
||||||
async def drop_constraints(conn: Connection, table: str, contype: str) -> None:
|
async def drop_constraints(conn: Connection, table: str, contype: str) -> None:
|
||||||
@@ -59,14 +51,14 @@ async def drop_constraints(conn: Connection, table: str, contype: str) -> None:
|
|||||||
await conn.execute(f"ALTER TABLE {table} {drops}")
|
await conn.execute(f"ALTER TABLE {table} {drops}")
|
||||||
|
|
||||||
|
|
||||||
async def migrate_legacy_to_v1(conn: Connection, scheme: str) -> None:
|
async def migrate_legacy_to_v1(conn: Connection, scheme: Scheme) -> None:
|
||||||
legacy_version = await conn.fetchval(legacy_version_query)
|
legacy_version = await conn.fetchval(legacy_version_query)
|
||||||
if legacy_version != last_legacy_version:
|
if legacy_version != last_legacy_version:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"Legacy database is not on last version. "
|
"Legacy database is not on last version. "
|
||||||
"Please upgrade the old database with alembic or drop it completely first."
|
"Please upgrade the old database with alembic or drop it completely first."
|
||||||
)
|
)
|
||||||
if scheme != "sqlite":
|
if scheme != Scheme.SQLITE:
|
||||||
await drop_constraints(conn, "contact", contype="f")
|
await drop_constraints(conn, "contact", contype="f")
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
@@ -131,12 +123,12 @@ async def migrate_legacy_to_v1(conn: Connection, scheme: str) -> None:
|
|||||||
await conn.execute("DROP TABLE alembic_version")
|
await conn.execute("DROP TABLE alembic_version")
|
||||||
|
|
||||||
|
|
||||||
async def update_state_store(conn: Connection, scheme: str) -> None:
|
async def update_state_store(conn: Connection, scheme: Scheme) -> None:
|
||||||
# The Matrix state store already has more or less the correct schema, so set the version
|
# 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("CREATE TABLE mx_version (version INTEGER PRIMARY KEY)")
|
||||||
await conn.execute("INSERT INTO mx_version (version) VALUES (2)")
|
await conn.execute("INSERT INTO mx_version (version) VALUES (2)")
|
||||||
await conn.execute("UPDATE mx_user_profile SET membership='LEAVE' WHERE membership='LEFT'")
|
await conn.execute("UPDATE mx_user_profile SET membership='LEAVE' WHERE membership='LEFT'")
|
||||||
if scheme != "sqlite":
|
if scheme != Scheme.SQLITE:
|
||||||
# Also add the membership type on postgres
|
# Also add the membership type on postgres
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock')"
|
"CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock')"
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# 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 mautrix.util.async_db import Connection, Scheme
|
||||||
|
|
||||||
|
from . import upgrade_table
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register(description="Allow multiple reactions from the same user")
|
||||||
|
async def upgrade_v13(conn: Connection, scheme: Scheme) -> None:
|
||||||
|
await conn.execute("CREATE INDEX telegram_file_mxc_idx ON telegram_file(mxc)")
|
||||||
|
await conn.execute('ALTER TABLE "user" ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false')
|
||||||
|
await conn.execute("ALTER TABLE puppet ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false")
|
||||||
|
if scheme == Scheme.POSTGRES:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE reaction
|
||||||
|
DROP CONSTRAINT reaction_pkey,
|
||||||
|
ADD CONSTRAINT reaction_pkey PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await conn.execute(
|
||||||
|
"""CREATE TABLE new_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, reaction),
|
||||||
|
UNIQUE (mxid, mx_room)
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO new_reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
|
||||||
|
SELECT mxid, mx_room, msg_mxid, tg_sender, reaction FROM reaction
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute("DROP TABLE reaction")
|
||||||
|
await conn.execute("ALTER TABLE new_reaction RENAME TO reaction")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 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 mautrix.util.async_db import Connection
|
||||||
|
|
||||||
|
from . import upgrade_table
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register(description="Add index to puppet custom_mxid column")
|
||||||
|
async def upgrade_v14(conn: Connection) -> None:
|
||||||
|
await conn.execute("CREATE INDEX IF NOT EXISTS puppet_custom_mxid_idx ON puppet(custom_mxid)")
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 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 mautrix.util.async_db import Connection
|
||||||
|
|
||||||
|
from . import upgrade_table
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register(description="Store lowest message ID in backfill queue")
|
||||||
|
async def upgrade_v15(conn: Connection) -> None:
|
||||||
|
await conn.execute("ALTER TABLE backfill_queue ADD COLUMN anchor_msg_id BIGINT")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# 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 mautrix.util.async_db import Connection, Scheme
|
||||||
|
|
||||||
|
from . import upgrade_table
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register(description="Add type for backfill queue items")
|
||||||
|
async def upgrade_v16(conn: Connection, scheme: Scheme) -> None:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE backfill_queue ADD COLUMN type TEXT NOT NULL DEFAULT 'historical'"
|
||||||
|
)
|
||||||
|
await conn.execute("ALTER TABLE backfill_queue ADD COLUMN extra_data jsonb")
|
||||||
|
if scheme != Scheme.SQLITE:
|
||||||
|
await conn.execute("ALTER TABLE backfill_queue ALTER COLUMN type DROP DEFAULT")
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 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 mautrix.util.async_db import Connection
|
||||||
|
|
||||||
|
from . import upgrade_table
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register(description="Add index for Message.find_recent")
|
||||||
|
async def upgrade_v17(conn: Connection) -> None:
|
||||||
|
await conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS message_mx_room_and_tgid_idx ON message(mx_room, tgid DESC)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# 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 mautrix.util.async_db import Connection
|
||||||
|
|
||||||
|
from . import upgrade_table
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register(description="Add contact_info_set column to puppet table")
|
||||||
|
async def upgrade_v18(conn: Connection) -> None:
|
||||||
|
await conn.execute(
|
||||||
|
"ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false"
|
||||||
|
)
|
||||||
+32
-12
@@ -21,9 +21,10 @@ from asyncpg import Record
|
|||||||
from attr import dataclass
|
from attr import dataclass
|
||||||
|
|
||||||
from mautrix.types import UserID
|
from mautrix.types import UserID
|
||||||
from mautrix.util.async_db import Database, Scheme
|
from mautrix.util.async_db import Connection, Database, Scheme
|
||||||
|
|
||||||
from ..types import TelegramID
|
from ..types import TelegramID
|
||||||
|
from .backfill_queue import Backfill
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
fake_db = Database.create("") if TYPE_CHECKING else None
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ class User:
|
|||||||
tg_username: str | None
|
tg_username: str | None
|
||||||
tg_phone: str | None
|
tg_phone: str | None
|
||||||
is_bot: bool
|
is_bot: bool
|
||||||
|
is_premium: bool
|
||||||
saved_contacts: int
|
saved_contacts: int
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -45,7 +47,9 @@ class User:
|
|||||||
return None
|
return None
|
||||||
return cls(**row)
|
return cls(**row)
|
||||||
|
|
||||||
columns: ClassVar[str] = "mxid, tgid, tg_username, tg_phone, is_bot, saved_contacts"
|
columns: ClassVar[str] = ", ".join(
|
||||||
|
("mxid", "tgid", "tg_username", "tg_phone", "is_bot", "is_premium", "saved_contacts")
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_tgid(cls, tgid: TelegramID) -> User | None:
|
async def get_by_tgid(cls, tgid: TelegramID) -> User | None:
|
||||||
@@ -70,6 +74,20 @@ class User:
|
|||||||
async def delete(self) -> None:
|
async def delete(self) -> None:
|
||||||
await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid)
|
await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid)
|
||||||
|
|
||||||
|
async def remove_tgid(self) -> None:
|
||||||
|
async with self.db.acquire() as conn, conn.transaction():
|
||||||
|
if self.tgid:
|
||||||
|
await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid)
|
||||||
|
await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid)
|
||||||
|
await Backfill.delete_all(self.mxid, conn=conn)
|
||||||
|
self.tgid = None
|
||||||
|
self.tg_username = None
|
||||||
|
self.tg_phone = None
|
||||||
|
self.is_bot = False
|
||||||
|
self.is_premium = False
|
||||||
|
self.saved_contacts = 0
|
||||||
|
await self.save(conn=conn)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _values(self):
|
def _values(self):
|
||||||
return (
|
return (
|
||||||
@@ -78,21 +96,23 @@ class User:
|
|||||||
self.tg_username,
|
self.tg_username,
|
||||||
self.tg_phone,
|
self.tg_phone,
|
||||||
self.is_bot,
|
self.is_bot,
|
||||||
|
self.is_premium,
|
||||||
self.saved_contacts,
|
self.saved_contacts,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def save(self) -> None:
|
async def save(self, conn: Connection | None = None) -> None:
|
||||||
q = (
|
q = """
|
||||||
'UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, saved_contacts=$6 '
|
UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, is_premium=$6,
|
||||||
"WHERE mxid=$1"
|
saved_contacts=$7
|
||||||
)
|
WHERE mxid=$1
|
||||||
await self.db.execute(q, *self._values)
|
"""
|
||||||
|
await (conn or self.db).execute(q, *self._values)
|
||||||
|
|
||||||
async def insert(self) -> None:
|
async def insert(self) -> None:
|
||||||
q = (
|
q = """
|
||||||
'INSERT INTO "user" (mxid, tgid, tg_username, tg_phone, is_bot, saved_contacts) '
|
INSERT INTO "user" (mxid, tgid, tg_username, tg_phone, is_bot, is_premium, saved_contacts)
|
||||||
"VALUES ($1, $2, $3, $4, $5, $6)"
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
)
|
"""
|
||||||
await self.db.execute(q, *self._values)
|
await self.db.execute(q, *self._values)
|
||||||
|
|
||||||
async def get_contacts(self) -> list[TelegramID]:
|
async def get_contacts(self) -> list[TelegramID]:
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ homeserver:
|
|||||||
# Whether or not to verify the SSL certificate of the homeserver.
|
# Whether or not to verify the SSL certificate of the homeserver.
|
||||||
# Only applies if address starts with https://
|
# Only applies if address starts with https://
|
||||||
verify_ssl: true
|
verify_ssl: true
|
||||||
asmux: false
|
# What software is the homeserver running?
|
||||||
|
# Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
|
||||||
|
software: standard
|
||||||
# Number of retries for all HTTP requests if the homeserver isn't reachable.
|
# Number of retries for all HTTP requests if the homeserver isn't reachable.
|
||||||
http_retry_count: 4
|
http_retry_count: 4
|
||||||
# The URL to push real-time bridge status to.
|
# The URL to push real-time bridge status to.
|
||||||
@@ -38,13 +40,14 @@ appservice:
|
|||||||
|
|
||||||
# The full URI to the database. SQLite and Postgres are supported.
|
# The full URI to the database. SQLite and Postgres are supported.
|
||||||
# Format examples:
|
# Format examples:
|
||||||
# SQLite: sqlite:///filename.db
|
# SQLite: sqlite:filename.db
|
||||||
# Postgres: postgres://username:password@hostname/dbname
|
# Postgres: postgres://username:password@hostname/dbname
|
||||||
database: postgres://username:password@hostname/dbname
|
database: postgres://username:password@hostname/dbname
|
||||||
# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
|
# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
|
||||||
# https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool
|
# https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool
|
||||||
# https://docs.python.org/3/library/sqlite3.html#sqlite3.connect
|
# 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.
|
# For sqlite, min_size is used as the connection thread pool size and max_size is ignored.
|
||||||
|
# Additionally, SQLite supports init_commands as an array of SQL queries to run on connect (e.g. to set PRAGMAs).
|
||||||
database_opts:
|
database_opts:
|
||||||
min_size: 1
|
min_size: 1
|
||||||
max_size: 10
|
max_size: 10
|
||||||
@@ -142,6 +145,9 @@ bridge:
|
|||||||
# as there's no way to determine whether an avatar is removed or just hidden from some users. If
|
# 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.
|
# you're on a single-user instance, this should be safe to enable.
|
||||||
allow_avatar_remove: false
|
allow_avatar_remove: false
|
||||||
|
# Should contact names and profile pictures be allowed?
|
||||||
|
# This is only safe to enable on single-user instances.
|
||||||
|
allow_contact_info: false
|
||||||
|
|
||||||
# Maximum number of members to sync per portal when starting up. Other members will be
|
# 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
|
# synced when they send messages. The maximum is 10000, after which the Telegram server
|
||||||
@@ -166,7 +172,10 @@ bridge:
|
|||||||
sync_update_limit: 0
|
sync_update_limit: 0
|
||||||
# Number of most recently active dialogs to create portals for when syncing chats.
|
# Number of most recently active dialogs to create portals for when syncing chats.
|
||||||
# Set to 0 to remove limit.
|
# Set to 0 to remove limit.
|
||||||
sync_create_limit: 30
|
sync_create_limit: 15
|
||||||
|
# Should all chats be scheduled to be created later?
|
||||||
|
# This is best used in combination with MSC2716 infinite backfill.
|
||||||
|
sync_deferred_create_all: false
|
||||||
# Whether or not to sync and create portals for direct chats at startup.
|
# Whether or not to sync and create portals for direct chats at startup.
|
||||||
sync_direct_chats: false
|
sync_direct_chats: false
|
||||||
# The maximum number of simultaneous Telegram deletions to handle.
|
# The maximum number of simultaneous Telegram deletions to handle.
|
||||||
@@ -207,12 +216,16 @@ bridge:
|
|||||||
# to resolve redirects in invite links.
|
# to resolve redirects in invite links.
|
||||||
invite_link_resolve: false
|
invite_link_resolve: false
|
||||||
# Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552.
|
# Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552.
|
||||||
# This is currently not supported in most clients.
|
caption_in_message: true
|
||||||
caption_in_message: false
|
|
||||||
# Maximum size of image in megabytes before sending to Telegram as a document.
|
# Maximum size of image in megabytes before sending to Telegram as a document.
|
||||||
image_as_file_size: 10
|
image_as_file_size: 10
|
||||||
# Maximum number of pixels in an image before sending to Telegram as a document. Defaults to 4096x4096 = 16777216.
|
# Maximum number of pixels in an image before sending to Telegram as a document. Defaults to 4096x4096 = 16777216.
|
||||||
image_as_file_pixels: 16777216
|
image_as_file_pixels: 16777216
|
||||||
|
# Maximum size of Telegram documents before linking to Telegrm instead of bridge
|
||||||
|
# to Matrix media.
|
||||||
|
document_as_link_size:
|
||||||
|
channel:
|
||||||
|
bot:
|
||||||
# Enable experimental parallel file transfer, which makes uploads/downloads much faster by
|
# Enable experimental parallel file transfer, which makes uploads/downloads much faster by
|
||||||
# streaming from/to Matrix and using many connections for Telegram.
|
# streaming from/to Matrix and using many connections for Telegram.
|
||||||
# Note that generating HQ thumbnails for videos is not possible with streamed transfers.
|
# Note that generating HQ thumbnails for videos is not possible with streamed transfers.
|
||||||
@@ -221,6 +234,9 @@ bridge:
|
|||||||
# Whether or not created rooms should have federation enabled.
|
# Whether or not created rooms should have federation enabled.
|
||||||
# If false, created portal rooms will never be federated.
|
# If false, created portal rooms will never be federated.
|
||||||
federate_rooms: true
|
federate_rooms: true
|
||||||
|
# Should the bridge send all unicode reactions as custom emoji reactions to Telegram?
|
||||||
|
# By default, the bridge only uses custom emojis for unicode emojis that aren't allowed in reactions.
|
||||||
|
always_custom_emoji_reaction: false
|
||||||
# Settings for converting animated stickers.
|
# Settings for converting animated stickers.
|
||||||
animated_sticker:
|
animated_sticker:
|
||||||
# Format to which animated stickers should be converted.
|
# Format to which animated stickers should be converted.
|
||||||
@@ -255,11 +271,45 @@ bridge:
|
|||||||
# Default to encryption, force-enable encryption in all portals the bridge creates
|
# 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.
|
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
||||||
default: false
|
default: false
|
||||||
|
# Whether to use MSC3202/MSC4203 instead of /sync long polling for receiving encryption-related data.
|
||||||
|
# This option is not yet compatible with standard Matrix servers like Synapse and should not be used.
|
||||||
|
# Changing this option requires updating the appservice registration file.
|
||||||
|
appservice: false
|
||||||
|
# Whether to use MSC4190 instead of appservice login to create the bridge bot device.
|
||||||
|
# Requires the homeserver to support MSC4190 and the device masquerading parts of MSC3202.
|
||||||
|
# Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861).
|
||||||
|
# Changing this option requires updating the appservice registration file.
|
||||||
|
msc4190: false
|
||||||
|
# Should the bridge bot generate a recovery key and cross-signing keys and verify itself?
|
||||||
|
# Note that without the latest version of MSC4190, this will fail if you reset the bridge database.
|
||||||
|
# The generated recovery key will be printed in logs.
|
||||||
|
self_sign: false
|
||||||
# Require encryption, drop any unencrypted messages.
|
# Require encryption, drop any unencrypted messages.
|
||||||
require: false
|
require: false
|
||||||
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
|
# 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.
|
# You must use a client that supports requesting keys from other users to use this feature.
|
||||||
allow_key_sharing: false
|
allow_key_sharing: false
|
||||||
|
# Options for deleting megolm sessions from the bridge.
|
||||||
|
delete_keys:
|
||||||
|
# Beeper-specific: delete outbound sessions when hungryserv confirms
|
||||||
|
# that the user has uploaded the key to key backup.
|
||||||
|
delete_outbound_on_ack: false
|
||||||
|
# Don't store outbound sessions in the inbound table.
|
||||||
|
dont_store_outbound: false
|
||||||
|
# Ratchet megolm sessions forward after decrypting messages.
|
||||||
|
ratchet_on_decrypt: false
|
||||||
|
# Delete fully used keys (index >= max_messages) after decrypting messages.
|
||||||
|
delete_fully_used_on_decrypt: false
|
||||||
|
# Delete previous megolm sessions from same device when receiving a new one.
|
||||||
|
delete_prev_on_new_session: false
|
||||||
|
# Delete megolm sessions received from a device when the device is deleted.
|
||||||
|
delete_on_device_delete: false
|
||||||
|
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
|
||||||
|
periodically_delete_expired: false
|
||||||
|
# Delete inbound megolm sessions that don't have the received_at field used for
|
||||||
|
# automatic ratcheting and expired session deletion. This is meant as a migration
|
||||||
|
# to delete old keys prior to the bridge update.
|
||||||
|
delete_outdated_inbound: false
|
||||||
# What level of device verification should be required from users?
|
# What level of device verification should be required from users?
|
||||||
#
|
#
|
||||||
# Valid levels:
|
# Valid levels:
|
||||||
@@ -295,14 +345,27 @@ bridge:
|
|||||||
# default.
|
# default.
|
||||||
messages: 100
|
messages: 100
|
||||||
|
|
||||||
# Whether or not to explicitly set the avatar and room name for private
|
# Disable rotating keys when a user's devices change?
|
||||||
# chat portal rooms. This will be implicitly enabled if encryption.default is true.
|
# You should not enable this option unless you understand all the implications.
|
||||||
private_chat_portal_meta: false
|
disable_device_change_key_rotation: false
|
||||||
|
|
||||||
|
# Whether to explicitly set the avatar and room name for private chat portal rooms.
|
||||||
|
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
|
||||||
|
# If set to `always`, all DM rooms will have explicit names and avatars set.
|
||||||
|
# If set to `never`, DM rooms will never have names and avatars set.
|
||||||
|
private_chat_portal_meta: default
|
||||||
|
# Disable generating reply fallbacks? Some extremely bad clients still rely on them,
|
||||||
|
# but they're being phased out and will be completely removed in the future.
|
||||||
|
disable_reply_fallbacks: true
|
||||||
|
# Should cross-chat replies from Telegram be bridged? Most servers and clients don't support this.
|
||||||
|
cross_room_replies: false
|
||||||
# Whether or not the bridge should send a read receipt from the bridge bot when a message has
|
# Whether or not the bridge should send a read receipt from the bridge bot when a message has
|
||||||
# been sent to Telegram.
|
# been sent to Telegram.
|
||||||
delivery_receipts: false
|
delivery_receipts: false
|
||||||
# Whether or not delivery errors should be reported as messages in the Matrix room.
|
# Whether or not delivery errors should be reported as messages in the Matrix room.
|
||||||
delivery_error_reports: false
|
delivery_error_reports: false
|
||||||
|
# Should errors in incoming message handling send a message to the Matrix room?
|
||||||
|
incoming_bridge_error_reports: false
|
||||||
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
|
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
|
||||||
message_status_events: false
|
message_status_events: false
|
||||||
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
|
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
|
||||||
@@ -329,34 +392,55 @@ bridge:
|
|||||||
create_group_on_invite: true
|
create_group_on_invite: true
|
||||||
# Settings for backfilling messages from Telegram.
|
# Settings for backfilling messages from Telegram.
|
||||||
backfill:
|
backfill:
|
||||||
# Whether or not the Telegram ghosts of logged in Matrix users should be
|
# Allow backfilling at all?
|
||||||
# invited to private chats when backfilling history from Telegram. This is
|
enable: true
|
||||||
# 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.
|
# Whether or not to enable backfilling in normal groups.
|
||||||
# Normal groups have numerous technical problems in Telegram, and backfilling 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.
|
# will likely cause problems if there are multiple Matrix users in the group.
|
||||||
normal_groups: false
|
normal_groups: false
|
||||||
|
|
||||||
|
# If a backfilled chat is older than this number of hours, mark it as read even if it's unread on Telegram.
|
||||||
|
# Set to -1 to let any chat be unread.
|
||||||
|
unread_hours_threshold: 720
|
||||||
|
|
||||||
|
# Forward backfilling limits.
|
||||||
|
#
|
||||||
|
# Using a negative initial limit is not recommended, as it would try to backfill everything in a single batch.
|
||||||
|
forward_limits:
|
||||||
|
# Number of messages to backfill immediately after creating a portal.
|
||||||
|
initial:
|
||||||
|
user: 50
|
||||||
|
normal_group: 100
|
||||||
|
supergroup: 10
|
||||||
|
channel: 10
|
||||||
|
# Number of messages to backfill when syncing chats.
|
||||||
|
sync:
|
||||||
|
user: 100
|
||||||
|
normal_group: 100
|
||||||
|
supergroup: 100
|
||||||
|
channel: 100
|
||||||
|
# Timeout for forward backfills in seconds. If you have a high limit, you'll have to increase this too.
|
||||||
|
forward_timeout: 900
|
||||||
|
|
||||||
|
# Settings for incremental backfill of history. These only apply to Beeper, as upstream abandoned MSC2716.
|
||||||
|
incremental:
|
||||||
|
# Maximum number of messages to backfill per batch.
|
||||||
|
messages_per_batch: 100
|
||||||
|
# The number of seconds to wait after backfilling the batch of messages.
|
||||||
|
post_batch_delay: 20
|
||||||
|
# The maximum number of batches to backfill per portal, split by the chat type.
|
||||||
|
# If set to -1, all messages in the chat will eventually be backfilled.
|
||||||
|
max_batches:
|
||||||
|
# Direct chats
|
||||||
|
user: -1
|
||||||
|
# Normal groups. Note that the normal_groups option above must be enabled
|
||||||
|
# for these to be backfilled.
|
||||||
|
normal_group: -1
|
||||||
|
# Supergroups
|
||||||
|
supergroup: 10
|
||||||
|
# Broadcast channels
|
||||||
|
channel: -1
|
||||||
|
|
||||||
# Overrides for base power levels.
|
# Overrides for base power levels.
|
||||||
initial_power_level_overrides:
|
initial_power_level_overrides:
|
||||||
user: {}
|
user: {}
|
||||||
@@ -416,7 +500,6 @@ bridge:
|
|||||||
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
|
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
|
||||||
# `filter-mode` management commands.
|
# `filter-mode` management commands.
|
||||||
#
|
#
|
||||||
# Filters do not affect direct chats.
|
|
||||||
# An empty blacklist will essentially disable the filter.
|
# An empty blacklist will essentially disable the filter.
|
||||||
filter:
|
filter:
|
||||||
# Filter mode to use. Either "blacklist" or "whitelist".
|
# Filter mode to use. Either "blacklist" or "whitelist".
|
||||||
@@ -425,6 +508,11 @@ bridge:
|
|||||||
mode: blacklist
|
mode: blacklist
|
||||||
# The list of group/channel IDs to filter.
|
# The list of group/channel IDs to filter.
|
||||||
list: []
|
list: []
|
||||||
|
# How to handle direct chats:
|
||||||
|
# If users is "null", direct chats will follow the previous settings.
|
||||||
|
# If users is "true", direct chats will always be bridged.
|
||||||
|
# If users is "false", direct chats will never be bridged.
|
||||||
|
users: true
|
||||||
|
|
||||||
# The prefix for commands. Only required in non-management rooms.
|
# The prefix for commands. Only required in non-management rooms.
|
||||||
command_prefix: "!tg"
|
command_prefix: "!tg"
|
||||||
@@ -503,6 +591,8 @@ telegram:
|
|||||||
# Should incoming updates be handled sequentially to make sure order is preserved on Matrix?
|
# Should incoming updates be handled sequentially to make sure order is preserved on Matrix?
|
||||||
sequential_updates: true
|
sequential_updates: true
|
||||||
exit_on_update_error: false
|
exit_on_update_error: false
|
||||||
|
# Interval to force refresh the connection (full reconnect). 0 disables it.
|
||||||
|
force_refresh_interval_seconds: 0
|
||||||
|
|
||||||
# Telethon connection options.
|
# Telethon connection options.
|
||||||
connection:
|
connection:
|
||||||
@@ -525,6 +615,8 @@ telegram:
|
|||||||
# is not recommended, since some requests can always trigger a call fail (such as searching
|
# is not recommended, since some requests can always trigger a call fail (such as searching
|
||||||
# for messages).
|
# for messages).
|
||||||
request_retries: 5
|
request_retries: 5
|
||||||
|
# Use IPv6 for Telethon connection
|
||||||
|
use_ipv6: false
|
||||||
|
|
||||||
# Device info sent to Telegram.
|
# Device info sent to Telegram.
|
||||||
device_info:
|
device_info:
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram
|
from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram
|
||||||
from .from_telegram import telegram_to_matrix
|
from .from_telegram import telegram_text_to_matrix_html, telegram_to_matrix
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ async def matrix_to_telegram(
|
|||||||
if html is not None:
|
if html is not None:
|
||||||
return await _matrix_html_to_telegram(client, html)
|
return await _matrix_html_to_telegram(client, html)
|
||||||
elif text is not None:
|
elif text is not None:
|
||||||
return _matrix_text_to_telegram(text), []
|
return _matrix_text_to_telegram(text)
|
||||||
else:
|
else:
|
||||||
raise ValueError("text or html must be provided to convert formatting")
|
raise ValueError("text or html must be provided to convert formatting")
|
||||||
|
|
||||||
@@ -98,8 +98,13 @@ def _cut_long_message(
|
|||||||
return message, entities
|
return message, entities
|
||||||
|
|
||||||
|
|
||||||
def _matrix_text_to_telegram(text: str) -> str:
|
def _matrix_text_to_telegram(text: str) -> tuple[str, list[TypeMessageEntity]]:
|
||||||
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
|
entities = []
|
||||||
|
surrogated_text = add_surrogate(text)
|
||||||
|
if len(surrogated_text) > MAX_LENGTH:
|
||||||
|
surrogated_text, entities = _cut_long_message(surrogated_text, entities)
|
||||||
|
text = del_surrogate(surrogated_text)
|
||||||
|
return text, entities
|
||||||
|
|||||||
@@ -82,14 +82,6 @@ class MatrixParser(BaseMatrixParser[TelegramMessage]):
|
|||||||
prefix = "#" * length + " "
|
prefix = "#" * length + " "
|
||||||
return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD)
|
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 = [child.prepend("> ") for child in children]
|
|
||||||
return TelegramMessage.join(children, "\n")
|
|
||||||
|
|
||||||
async def color_to_fstring(self, msg: TelegramMessage, color: str) -> TelegramMessage:
|
async def color_to_fstring(self, msg: TelegramMessage, color: str) -> TelegramMessage:
|
||||||
return msg
|
return msg
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ from telethon.errors import RPCError
|
|||||||
from telethon.helpers import add_surrogate, del_surrogate
|
from telethon.helpers import add_surrogate, del_surrogate
|
||||||
from telethon.tl.custom import Message
|
from telethon.tl.custom import Message
|
||||||
from telethon.tl.types import (
|
from telethon.tl.types import (
|
||||||
|
Channel,
|
||||||
|
InputPeerChannelFromMessage,
|
||||||
|
InputPeerUserFromMessage,
|
||||||
MessageEntityBlockquote,
|
MessageEntityBlockquote,
|
||||||
MessageEntityBold,
|
MessageEntityBold,
|
||||||
MessageEntityBotCommand,
|
MessageEntityBotCommand,
|
||||||
@@ -47,21 +50,47 @@ from telethon.tl.types import (
|
|||||||
PeerUser,
|
PeerUser,
|
||||||
SponsoredMessage,
|
SponsoredMessage,
|
||||||
TypeMessageEntity,
|
TypeMessageEntity,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
|
|
||||||
from mautrix.types import Format, MessageType, TextMessageEventContent
|
from mautrix.types import Format, MessageType, TextMessageEventContent
|
||||||
|
|
||||||
from .. import abstract_user as au, portal as po, puppet as pu, user as u
|
from .. import abstract_user as au, portal as po, puppet as pu, user as u
|
||||||
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
|
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
|
||||||
|
from ..tgclient import MautrixTelegramClient
|
||||||
from ..types import TelegramID
|
from ..types import TelegramID
|
||||||
from ..util.file_transfer import transfer_custom_emojis_to_matrix
|
from ..util.file_transfer import UnicodeCustomEmoji, transfer_custom_emojis_to_matrix
|
||||||
|
|
||||||
log: logging.Logger = logging.getLogger("mau.fmt.tg")
|
log: logging.Logger = logging.getLogger("mau.fmt.tg")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_fwd_entity(client: MautrixTelegramClient, evt: Message) -> Channel | User | None:
|
||||||
|
try:
|
||||||
|
return await client.get_entity(evt.fwd_from.from_id)
|
||||||
|
except (ValueError, RPCError) as e:
|
||||||
|
try:
|
||||||
|
input_peer = await client.get_input_entity(evt.peer_id)
|
||||||
|
if isinstance(evt.fwd_from.from_id, PeerUser):
|
||||||
|
return await client.get_entity(
|
||||||
|
InputPeerUserFromMessage(
|
||||||
|
peer=input_peer, msg_id=evt.id, user_id=evt.fwd_from.from_id.user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif isinstance(evt.fwd_from.from_id, PeerChannel):
|
||||||
|
return await client.get_entity(
|
||||||
|
InputPeerChannelFromMessage(
|
||||||
|
peer=input_peer, msg_id=evt.id, channel_id=evt.fwd_from.from_id.channel_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except (ValueError, RPCError) as e:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _add_forward_header(
|
async def _add_forward_header(
|
||||||
source: au.AbstractUser, content: TextMessageEventContent, fwd_from: MessageFwdHeader
|
client: MautrixTelegramClient, content: TextMessageEventContent, evt: Message
|
||||||
) -> None:
|
) -> None:
|
||||||
|
fwd_from = evt.fwd_from
|
||||||
fwd_from_html, fwd_from_text = None, None
|
fwd_from_html, fwd_from_text = None, None
|
||||||
if isinstance(fwd_from.from_id, PeerUser):
|
if isinstance(fwd_from.from_id, PeerUser):
|
||||||
user = await u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_id))
|
user = await u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_id))
|
||||||
@@ -80,12 +109,11 @@ async def _add_forward_header(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not fwd_from_text:
|
if not fwd_from_text:
|
||||||
try:
|
user = await _get_fwd_entity(client, evt)
|
||||||
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>{escape(fwd_from_text)}</b>"
|
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
|
||||||
except (ValueError, RPCError):
|
else:
|
||||||
fwd_from_text = fwd_from_html = "unknown user"
|
fwd_from_text = fwd_from_html = "unknown user"
|
||||||
elif isinstance(fwd_from.from_id, (PeerChannel, PeerChat)):
|
elif isinstance(fwd_from.from_id, (PeerChannel, PeerChat)):
|
||||||
from_id = (
|
from_id = (
|
||||||
@@ -103,12 +131,11 @@ async def _add_forward_header(
|
|||||||
else:
|
else:
|
||||||
fwd_from_html = f"channel <b>{escape(fwd_from_text)}</b>"
|
fwd_from_html = f"channel <b>{escape(fwd_from_text)}</b>"
|
||||||
else:
|
else:
|
||||||
try:
|
channel = await _get_fwd_entity(client, evt)
|
||||||
channel = await source.client.get_entity(fwd_from.from_id)
|
|
||||||
if channel:
|
if channel:
|
||||||
fwd_from_text = f"channel {channel.title}"
|
fwd_from_text = f"channel {channel.title}"
|
||||||
fwd_from_html = f"channel <b>{escape(channel.title)}</b>"
|
fwd_from_html = f"channel <b>{escape(channel.title)}</b>"
|
||||||
except (ValueError, RPCError):
|
else:
|
||||||
fwd_from_text = fwd_from_html = "unknown channel"
|
fwd_from_text = fwd_from_html = "unknown channel"
|
||||||
elif fwd_from.from_name:
|
elif fwd_from.from_name:
|
||||||
fwd_from_text = fwd_from.from_name
|
fwd_from_text = fwd_from.from_name
|
||||||
@@ -135,41 +162,61 @@ class ReuploadedCustomEmoji(MessageEntityCustomEmoji):
|
|||||||
|
|
||||||
|
|
||||||
async def _convert_custom_emoji(
|
async def _convert_custom_emoji(
|
||||||
source: au.AbstractUser, entities: list[TypeMessageEntity]
|
source: au.AbstractUser,
|
||||||
|
entities: list[TypeMessageEntity],
|
||||||
|
client: MautrixTelegramClient | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
emoji_ids = [
|
emoji_ids = [
|
||||||
entity.document_id for entity in entities if isinstance(entity, MessageEntityCustomEmoji)
|
entity.document_id for entity in entities if isinstance(entity, MessageEntityCustomEmoji)
|
||||||
]
|
]
|
||||||
custom_emojis = await transfer_custom_emojis_to_matrix(source, emoji_ids)
|
custom_emojis = await transfer_custom_emojis_to_matrix(source, emoji_ids, client=client)
|
||||||
if len(custom_emojis) > 0:
|
if len(custom_emojis) > 0:
|
||||||
for i, entity in enumerate(entities):
|
for i, entity in enumerate(entities):
|
||||||
if isinstance(entity, MessageEntityCustomEmoji):
|
if isinstance(entity, MessageEntityCustomEmoji):
|
||||||
entities[i] = ReuploadedCustomEmoji(entity, custom_emojis[entity.document_id])
|
entities[i] = ReuploadedCustomEmoji(entity, custom_emojis[entity.document_id])
|
||||||
|
|
||||||
|
|
||||||
|
async def telegram_text_to_matrix_html(
|
||||||
|
source: au.AbstractUser,
|
||||||
|
text: str,
|
||||||
|
entities: list[TypeMessageEntity],
|
||||||
|
client: MautrixTelegramClient | None = None,
|
||||||
|
) -> str:
|
||||||
|
if not entities:
|
||||||
|
return escape(text).replace("\n", "<br/>")
|
||||||
|
await _convert_custom_emoji(source, entities, client=client)
|
||||||
|
text = add_surrogate(text)
|
||||||
|
html = await _telegram_entities_to_matrix_catch(text, entities)
|
||||||
|
html = del_surrogate(html)
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
async def telegram_to_matrix(
|
async def telegram_to_matrix(
|
||||||
evt: Message | SponsoredMessage,
|
evt: Message | SponsoredMessage,
|
||||||
source: au.AbstractUser,
|
source: au.AbstractUser,
|
||||||
|
client: MautrixTelegramClient | None = None,
|
||||||
override_text: str = None,
|
override_text: str = None,
|
||||||
override_entities: list[TypeMessageEntity] = None,
|
override_entities: list[TypeMessageEntity] = None,
|
||||||
require_html: bool = False,
|
require_html: bool = False,
|
||||||
) -> TextMessageEventContent:
|
) -> TextMessageEventContent:
|
||||||
|
if not client:
|
||||||
|
client = source.client
|
||||||
content = TextMessageEventContent(
|
content = TextMessageEventContent(
|
||||||
msgtype=MessageType.TEXT,
|
msgtype=MessageType.TEXT,
|
||||||
body=override_text or evt.message,
|
body=override_text or evt.message,
|
||||||
)
|
)
|
||||||
entities = override_entities or evt.entities
|
entities = override_entities or evt.entities
|
||||||
if entities:
|
if entities:
|
||||||
await _convert_custom_emoji(source, entities)
|
|
||||||
content.format = Format.HTML
|
content.format = Format.HTML
|
||||||
html = await _telegram_entities_to_matrix_catch(add_surrogate(content.body), entities)
|
content.formatted_body = await telegram_text_to_matrix_html(
|
||||||
content.formatted_body = del_surrogate(html)
|
source, content.body, entities, client=client
|
||||||
|
)
|
||||||
|
|
||||||
if require_html:
|
if require_html:
|
||||||
content.ensure_has_html()
|
content.ensure_has_html()
|
||||||
|
|
||||||
if getattr(evt, "fwd_from", None):
|
if getattr(evt, "fwd_from", None):
|
||||||
await _add_forward_header(source, content, evt.fwd_from)
|
await _add_forward_header(client, content, evt)
|
||||||
|
|
||||||
if isinstance(evt, Message) and evt.post and evt.post_author:
|
if isinstance(evt, Message) and evt.post and evt.post_author:
|
||||||
content.ensure_has_html()
|
content.ensure_has_html()
|
||||||
@@ -279,9 +326,13 @@ async def _telegram_entities_to_matrix(
|
|||||||
elif entity_type == MessageEntityCustomEmoji:
|
elif entity_type == MessageEntityCustomEmoji:
|
||||||
html.append(entity_text)
|
html.append(entity_text)
|
||||||
elif entity_type == ReuploadedCustomEmoji:
|
elif entity_type == ReuploadedCustomEmoji:
|
||||||
|
if isinstance(entity.file, UnicodeCustomEmoji):
|
||||||
|
html.append(entity.file.emoji)
|
||||||
|
else:
|
||||||
html.append(
|
html.append(
|
||||||
f'<img data-mx-emoticon data-mau-animated-emoji src="{escape(entity.file.mxc)}" '
|
f"<img data-mx-emoticon data-mau-animated-emoji"
|
||||||
f'height="32" width="32" alt="{entity_text}" title="{entity_text}"/>'
|
f' src="{escape(entity.file.mxc)}" height="32" width="32"'
|
||||||
|
f' alt="{entity_text}" title="{entity_text}"/>'
|
||||||
)
|
)
|
||||||
elif entity_type in (
|
elif entity_type in (
|
||||||
MessageEntityBotCommand,
|
MessageEntityBotCommand,
|
||||||
@@ -297,7 +348,11 @@ async def _telegram_entities_to_matrix(
|
|||||||
last_offset = relative_offset + (0 if skip_entity else entity.length)
|
last_offset = relative_offset + (0 if skip_entity else entity.length)
|
||||||
html.append(text_to_html(text[last_offset:]))
|
html.append(text_to_html(text[last_offset:]))
|
||||||
|
|
||||||
return "".join(html)
|
html_string = "".join(html)
|
||||||
|
# Remove redundant <br>'s after block tags
|
||||||
|
html_string = html_string.replace("</blockquote><br/>", "</blockquote>")
|
||||||
|
html_string = html_string.replace("</pre><br/>", "</pre>")
|
||||||
|
return html_string
|
||||||
|
|
||||||
|
|
||||||
def _parse_pre(html: list[str], entity_text: str, language: str) -> bool:
|
def _parse_pre(html: list[str], entity_text: str, language: str) -> bool:
|
||||||
|
|||||||
@@ -16,15 +16,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
import sys
|
||||||
|
|
||||||
from mautrix.bridge import BaseMatrixHandler
|
from mautrix.bridge import BaseMatrixHandler
|
||||||
from mautrix.errors import MatrixError
|
|
||||||
from mautrix.types import (
|
from mautrix.types import (
|
||||||
Event,
|
Event,
|
||||||
EventID,
|
EventID,
|
||||||
EventType,
|
EventType,
|
||||||
MemberStateEventContent,
|
MemberStateEventContent,
|
||||||
MessageType,
|
|
||||||
PresenceEvent,
|
PresenceEvent,
|
||||||
PresenceState,
|
PresenceState,
|
||||||
ReactionEvent,
|
ReactionEvent,
|
||||||
@@ -36,7 +35,6 @@ from mautrix.types import (
|
|||||||
RoomTopicStateEventContent as TopicContent,
|
RoomTopicStateEventContent as TopicContent,
|
||||||
SingleReceiptEventContent,
|
SingleReceiptEventContent,
|
||||||
StateEvent,
|
StateEvent,
|
||||||
TextMessageEventContent,
|
|
||||||
TypingEvent,
|
TypingEvent,
|
||||||
UserID,
|
UserID,
|
||||||
)
|
)
|
||||||
@@ -122,20 +120,8 @@ class MatrixHandler(BaseMatrixHandler):
|
|||||||
levels.users[self.az.bot_mxid] = 100 if invited_by_level >= 100 else invited_by_level
|
levels.users[self.az.bot_mxid] = 100 if invited_by_level >= 100 else invited_by_level
|
||||||
await double_puppet.intent.set_power_levels(room_id, levels)
|
await double_puppet.intent.set_power_levels(room_id, levels)
|
||||||
|
|
||||||
invites, errors = await portal.get_telegram_users_in_matrix_room(
|
|
||||||
invited_by, pre_create=True
|
|
||||||
)
|
|
||||||
if len(errors) > 0:
|
|
||||||
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
|
|
||||||
await portal.az.intent.send_notice(
|
|
||||||
room_id,
|
|
||||||
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.",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await portal.create_telegram_chat(invited_by, invites=invites, supergroup=True)
|
await portal.create_telegram_chat(invited_by, supergroup=True)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
await portal.delete()
|
await portal.delete()
|
||||||
await portal.az.intent.send_notice(room_id, e.args[0])
|
await portal.az.intent.send_notice(room_id, e.args[0])
|
||||||
@@ -169,13 +155,6 @@ class MatrixHandler(BaseMatrixHandler):
|
|||||||
room_id, user.mxid, "You are not whitelisted on this Telegram bridge."
|
room_id, user.mxid, "You are not whitelisted on this Telegram bridge."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
elif not await user.is_logged_in() and not portal.has_bot:
|
|
||||||
await portal.main_intent.kick_user(
|
|
||||||
room_id,
|
|
||||||
user.mxid,
|
|
||||||
"This chat does not have a bot relaying messages for unauthenticated users.",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.log.debug(f"{user.mxid} joined {room_id}")
|
self.log.debug(f"{user.mxid} joined {room_id}")
|
||||||
if await user.is_logged_in() or portal.has_bot:
|
if await user.is_logged_in() or portal.has_bot:
|
||||||
|
|||||||
+1037
-343
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|||||||
from typing import Any, NamedTuple
|
from typing import Any, NamedTuple
|
||||||
import base64
|
import base64
|
||||||
import codecs
|
import codecs
|
||||||
|
import hashlib
|
||||||
import html
|
import html
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import unicodedata
|
import unicodedata
|
||||||
@@ -33,6 +34,8 @@ from telethon.tl.types import (
|
|||||||
DocumentAttributeVideo,
|
DocumentAttributeVideo,
|
||||||
Game,
|
Game,
|
||||||
InputPhotoFileLocation,
|
InputPhotoFileLocation,
|
||||||
|
InputStickerSetID,
|
||||||
|
InputStickerSetShortName,
|
||||||
Message,
|
Message,
|
||||||
MessageEntityPre,
|
MessageEntityPre,
|
||||||
MessageMediaContact,
|
MessageMediaContact,
|
||||||
@@ -41,12 +44,16 @@ from telethon.tl.types import (
|
|||||||
MessageMediaGame,
|
MessageMediaGame,
|
||||||
MessageMediaGeo,
|
MessageMediaGeo,
|
||||||
MessageMediaGeoLive,
|
MessageMediaGeoLive,
|
||||||
|
MessageMediaInvoice,
|
||||||
MessageMediaPhoto,
|
MessageMediaPhoto,
|
||||||
MessageMediaPoll,
|
MessageMediaPoll,
|
||||||
|
MessageMediaStory,
|
||||||
MessageMediaUnsupported,
|
MessageMediaUnsupported,
|
||||||
MessageMediaVenue,
|
MessageMediaVenue,
|
||||||
MessageMediaWebPage,
|
MessageMediaWebPage,
|
||||||
|
MessageReplyStoryHeader,
|
||||||
PeerChannel,
|
PeerChannel,
|
||||||
|
PeerChat,
|
||||||
PeerUser,
|
PeerUser,
|
||||||
Photo,
|
Photo,
|
||||||
PhotoCachedSize,
|
PhotoCachedSize,
|
||||||
@@ -57,12 +64,15 @@ from telethon.tl.types import (
|
|||||||
Poll,
|
Poll,
|
||||||
TypeDocumentAttribute,
|
TypeDocumentAttribute,
|
||||||
TypePhotoSize,
|
TypePhotoSize,
|
||||||
|
UpdateShortChatMessage,
|
||||||
|
UpdateShortMessage,
|
||||||
WebPage,
|
WebPage,
|
||||||
)
|
)
|
||||||
from telethon.utils import decode_waveform
|
from telethon.utils import decode_waveform
|
||||||
|
|
||||||
from mautrix.appservice import IntentAPI
|
from mautrix.appservice import IntentAPI
|
||||||
from mautrix.types import (
|
from mautrix.types import (
|
||||||
|
EventID,
|
||||||
EventType,
|
EventType,
|
||||||
Format,
|
Format,
|
||||||
ImageInfo,
|
ImageInfo,
|
||||||
@@ -78,6 +88,7 @@ from mautrix.util.logging import TraceLogger
|
|||||||
from .. import abstract_user as au, formatter, matrix as m, portal as po, puppet as pu, util
|
from .. import abstract_user as au, formatter, matrix as m, portal as po, puppet as pu, util
|
||||||
from ..config import Config
|
from ..config import Config
|
||||||
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
|
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
|
||||||
|
from ..tgclient import MautrixTelegramClient
|
||||||
from ..types import TelegramID
|
from ..types import TelegramID
|
||||||
from ..util import sane_mimetypes
|
from ..util import sane_mimetypes
|
||||||
|
|
||||||
@@ -101,6 +112,7 @@ class DocAttrs(NamedTuple):
|
|||||||
mime_type: str | None
|
mime_type: str | None
|
||||||
is_sticker: bool
|
is_sticker: bool
|
||||||
sticker_alt: str | None
|
sticker_alt: str | None
|
||||||
|
sticker_pack_ref: dict | None
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
is_gif: bool
|
is_gif: bool
|
||||||
@@ -139,6 +151,8 @@ class TelegramMessageConverter:
|
|||||||
MessageMediaUnsupported: self._convert_unsupported,
|
MessageMediaUnsupported: self._convert_unsupported,
|
||||||
MessageMediaGame: self._convert_game,
|
MessageMediaGame: self._convert_game,
|
||||||
MessageMediaContact: self._convert_contact,
|
MessageMediaContact: self._convert_contact,
|
||||||
|
MessageMediaStory: self._convert_story,
|
||||||
|
MessageMediaInvoice: self._convert_invoice,
|
||||||
}
|
}
|
||||||
self._allowed_media = tuple(self._media_converters.keys())
|
self._allowed_media = tuple(self._media_converters.keys())
|
||||||
|
|
||||||
@@ -147,14 +161,24 @@ class TelegramMessageConverter:
|
|||||||
source: au.AbstractUser,
|
source: au.AbstractUser,
|
||||||
intent: IntentAPI,
|
intent: IntentAPI,
|
||||||
is_bot: bool,
|
is_bot: bool,
|
||||||
|
is_channel: bool,
|
||||||
evt: Message,
|
evt: Message,
|
||||||
no_reply_fallback: bool = False,
|
no_reply_fallback: bool = False,
|
||||||
|
deterministic_reply_id: bool = False,
|
||||||
|
client: MautrixTelegramClient | None = None,
|
||||||
) -> ConvertedMessage | None:
|
) -> ConvertedMessage | None:
|
||||||
|
if not client:
|
||||||
|
client = source.client
|
||||||
if hasattr(evt, "media") and isinstance(evt.media, self._allowed_media):
|
if hasattr(evt, "media") and isinstance(evt.media, self._allowed_media):
|
||||||
|
if self._should_convert_full_document(evt.media, is_bot, is_channel):
|
||||||
convert_media = self._media_converters[type(evt.media)]
|
convert_media = self._media_converters[type(evt.media)]
|
||||||
converted = await convert_media(source=source, intent=intent, evt=evt)
|
converted = await convert_media(
|
||||||
|
source=source, intent=intent, evt=evt, client=client
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
converted = await self._convert_document_thumb_only(source, intent, evt, client)
|
||||||
elif evt.message:
|
elif evt.message:
|
||||||
converted = await self._convert_text(source, intent, is_bot, evt)
|
converted = await self._convert_text(source, intent, is_bot, evt, client)
|
||||||
else:
|
else:
|
||||||
self.log.debug("Unhandled Telegram message %d", evt.id)
|
self.log.debug("Unhandled Telegram message %d", evt.id)
|
||||||
return
|
return
|
||||||
@@ -176,9 +200,25 @@ class TelegramMessageConverter:
|
|||||||
converted.caption.external_url = converted.content.external_url
|
converted.caption.external_url = converted.content.external_url
|
||||||
if self.portal.get_config("caption_in_message"):
|
if self.portal.get_config("caption_in_message"):
|
||||||
self._caption_to_message(converted)
|
self._caption_to_message(converted)
|
||||||
await self._set_reply(source, evt, converted.content, no_fallback=no_reply_fallback)
|
await self._set_reply(
|
||||||
|
source,
|
||||||
|
evt,
|
||||||
|
converted.content,
|
||||||
|
no_fallback=no_reply_fallback,
|
||||||
|
deterministic_id=deterministic_reply_id,
|
||||||
|
)
|
||||||
return converted
|
return converted
|
||||||
|
|
||||||
|
def _should_convert_full_document(self, media, is_bot: bool, is_channel: bool) -> bool:
|
||||||
|
if not isinstance(media, MessageMediaDocument):
|
||||||
|
return True
|
||||||
|
size = media.document.size
|
||||||
|
if is_bot and self.config["bridge.document_as_link_size.bot"]:
|
||||||
|
return size < self.config["bridge.document_as_link_size.bot"] * 1000**2
|
||||||
|
if is_channel and self.config["bridge.document_as_link_size.channel"]:
|
||||||
|
return size < self.config["bridge.document_as_link_size.channel"] * 1000**2
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _caption_to_message(converted: ConvertedMessage) -> None:
|
def _caption_to_message(converted: ConvertedMessage) -> None:
|
||||||
content, caption = converted.content, converted.caption
|
content, caption = converted.content, converted.caption
|
||||||
@@ -223,26 +263,72 @@ class TelegramMessageConverter:
|
|||||||
raise ValueError("Portal has invalid peer type")
|
raise ValueError("Portal has invalid peer type")
|
||||||
return base64.b64encode(play_id).decode("utf-8").rstrip("=")
|
return base64.b64encode(play_id).decode("utf-8").rstrip("=")
|
||||||
|
|
||||||
|
def deterministic_event_id(self, space: TelegramID, msg_id: TelegramID) -> EventID:
|
||||||
|
hash_content = f"{self.portal.mxid}/telegram/{space}/{msg_id}"
|
||||||
|
hashed = hashlib.sha256(hash_content.encode("utf-8")).digest()
|
||||||
|
b64hash = base64.urlsafe_b64encode(hashed).decode("utf-8").rstrip("=")
|
||||||
|
return EventID(f"${b64hash}:telegram.org")
|
||||||
|
|
||||||
async def _set_reply(
|
async def _set_reply(
|
||||||
self,
|
self,
|
||||||
source: au.AbstractUser,
|
source: au.AbstractUser,
|
||||||
evt: Message,
|
evt: Message,
|
||||||
content: MessageEventContent,
|
content: MessageEventContent,
|
||||||
no_fallback: bool = False,
|
no_fallback: bool = False,
|
||||||
|
deterministic_id: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not evt.reply_to:
|
if not evt.reply_to:
|
||||||
return
|
return
|
||||||
|
elif isinstance(evt.reply_to, MessageReplyStoryHeader):
|
||||||
|
return
|
||||||
|
|
||||||
|
if evt.reply_to.quote and content.msgtype and content.msgtype.is_text:
|
||||||
|
content.ensure_has_html()
|
||||||
|
quote_html = await formatter.telegram_text_to_matrix_html(
|
||||||
|
source, evt.reply_to.quote_text, evt.reply_to.quote_entities
|
||||||
|
)
|
||||||
|
content.formatted_body = (
|
||||||
|
f"<blockquote data-telegram-partial-reply>{quote_html}</blockquote>"
|
||||||
|
f"{content.formatted_body}"
|
||||||
|
)
|
||||||
|
|
||||||
space = (
|
space = (
|
||||||
evt.peer_id.channel_id
|
evt.peer_id.channel_id
|
||||||
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
|
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
|
||||||
else source.tgid
|
else source.tgid
|
||||||
)
|
)
|
||||||
msg = await DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
|
if isinstance(evt, Message):
|
||||||
if not msg or msg.mx_room != self.portal.mxid:
|
evt_peer_id = evt.peer_id
|
||||||
|
elif isinstance(evt, UpdateShortMessage):
|
||||||
|
evt_peer_id = PeerUser(evt.user_id)
|
||||||
|
elif isinstance(evt, UpdateShortChatMessage):
|
||||||
|
evt_peer_id = PeerChat(evt.chat_id)
|
||||||
|
else:
|
||||||
|
evt_peer_id = None
|
||||||
|
if evt.reply_to.reply_to_peer_id and evt.reply_to.reply_to_peer_id != evt_peer_id:
|
||||||
|
if not self.config["bridge.cross_room_replies"]:
|
||||||
|
return
|
||||||
|
space = (
|
||||||
|
evt.reply_to.reply_to_peer_id.channel_id
|
||||||
|
if isinstance(evt.reply_to.reply_to_peer_id, PeerChannel)
|
||||||
|
else source.tgid
|
||||||
|
)
|
||||||
|
|
||||||
|
reply_to_id = TelegramID(evt.reply_to.reply_to_msg_id)
|
||||||
|
msg = await DBMessage.get_one_by_tgid(reply_to_id, space)
|
||||||
|
no_fallback = no_fallback or self.config["bridge.disable_reply_fallbacks"]
|
||||||
|
if not msg:
|
||||||
|
# TODO try to find room ID when generating deterministic ID for cross-room reply
|
||||||
|
if deterministic_id:
|
||||||
|
content.set_reply(self.deterministic_event_id(space, reply_to_id))
|
||||||
|
return
|
||||||
|
elif msg.mx_room != self.portal.mxid and not self.config["bridge.cross_room_replies"]:
|
||||||
return
|
return
|
||||||
elif not isinstance(content, TextMessageEventContent) or no_fallback:
|
elif not isinstance(content, TextMessageEventContent) or no_fallback:
|
||||||
# Not a text message, just set the reply metadata and return
|
# Not a text message, just set the reply metadata and return
|
||||||
content.set_reply(msg.mxid)
|
content.set_reply(msg.mxid)
|
||||||
|
if msg.mx_room != self.portal.mxid:
|
||||||
|
content.relates_to.in_reply_to["room_id"] = msg.mx_room
|
||||||
return
|
return
|
||||||
|
|
||||||
# Text message, try to fetch original message to generate reply fallback.
|
# Text message, try to fetch original message to generate reply fallback.
|
||||||
@@ -257,6 +343,8 @@ class TelegramMessageConverter:
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception("Failed to get event to add reply fallback")
|
self.log.exception("Failed to get event to add reply fallback")
|
||||||
content.set_reply(msg.mxid)
|
content.set_reply(msg.mxid)
|
||||||
|
if msg.mx_room != self.portal.mxid:
|
||||||
|
content.relates_to.in_reply_to["room_id"] = msg.mx_room
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _photo_size_key(photo: TypePhotoSize) -> int:
|
def _photo_size_key(photo: TypePhotoSize) -> int:
|
||||||
@@ -326,9 +414,14 @@ class TelegramMessageConverter:
|
|||||||
return beeper_link_preview
|
return beeper_link_preview
|
||||||
|
|
||||||
async def _convert_text(
|
async def _convert_text(
|
||||||
self, source: au.AbstractUser, intent: IntentAPI, is_bot: bool, evt: Message
|
self,
|
||||||
|
source: au.AbstractUser,
|
||||||
|
intent: IntentAPI,
|
||||||
|
is_bot: bool,
|
||||||
|
evt: Message,
|
||||||
|
client: MautrixTelegramClient,
|
||||||
) -> ConvertedMessage:
|
) -> ConvertedMessage:
|
||||||
content = await formatter.telegram_to_matrix(evt, source)
|
content = await formatter.telegram_to_matrix(evt, source, client)
|
||||||
if is_bot and self.portal.get_config("bot_messages_as_notices"):
|
if is_bot and self.portal.get_config("bot_messages_as_notices"):
|
||||||
content.msgtype = MessageType.NOTICE
|
content.msgtype = MessageType.NOTICE
|
||||||
|
|
||||||
@@ -344,7 +437,11 @@ class TelegramMessageConverter:
|
|||||||
return ConvertedMessage(content=content)
|
return ConvertedMessage(content=content)
|
||||||
|
|
||||||
async def _convert_photo(
|
async def _convert_photo(
|
||||||
self, source: au.AbstractUser, intent: IntentAPI, evt: Message
|
self,
|
||||||
|
source: au.AbstractUser,
|
||||||
|
intent: IntentAPI,
|
||||||
|
evt: Message,
|
||||||
|
client: MautrixTelegramClient,
|
||||||
) -> ConvertedMessage | None:
|
) -> ConvertedMessage | None:
|
||||||
media: MessageMediaPhoto = evt.media
|
media: MessageMediaPhoto = evt.media
|
||||||
if media.photo is None and media.ttl_seconds:
|
if media.photo is None and media.ttl_seconds:
|
||||||
@@ -362,7 +459,7 @@ class TelegramMessageConverter:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
file = await util.transfer_file_to_matrix(
|
file = await util.transfer_file_to_matrix(
|
||||||
source.client,
|
client,
|
||||||
intent,
|
intent,
|
||||||
loc,
|
loc,
|
||||||
encrypt=self.portal.encrypted,
|
encrypt=self.portal.encrypted,
|
||||||
@@ -377,6 +474,8 @@ class TelegramMessageConverter:
|
|||||||
mimetype=file.mime_type,
|
mimetype=file.mime_type,
|
||||||
size=self._photo_size_key(largest_size),
|
size=self._photo_size_key(largest_size),
|
||||||
)
|
)
|
||||||
|
if media.spoiler:
|
||||||
|
info["fi.mau.telegram.spoiler"] = True
|
||||||
ext = sane_mimetypes.guess_extension(file.mime_type)
|
ext = sane_mimetypes.guess_extension(file.mime_type)
|
||||||
name = f"disappearing_image{ext}" if media.ttl_seconds else f"image{ext}"
|
name = f"disappearing_image{ext}" if media.ttl_seconds else f"image{ext}"
|
||||||
content = MediaMessageEventContent(
|
content = MediaMessageEventContent(
|
||||||
@@ -388,18 +487,124 @@ class TelegramMessageConverter:
|
|||||||
content.file = file.decryption_info
|
content.file = file.decryption_info
|
||||||
else:
|
else:
|
||||||
content.url = file.mxc
|
content.url = file.mxc
|
||||||
caption_content = await formatter.telegram_to_matrix(evt, source) if evt.message else None
|
caption_content = (
|
||||||
|
await formatter.telegram_to_matrix(evt, source, client) if evt.message else None
|
||||||
|
)
|
||||||
return ConvertedMessage(
|
return ConvertedMessage(
|
||||||
content=content,
|
content=content,
|
||||||
caption=caption_content,
|
caption=caption_content,
|
||||||
disappear_seconds=media.ttl_seconds,
|
disappear_seconds=self._adjust_ttl(media.ttl_seconds),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _adjust_ttl(ttl: int | None) -> int | None:
|
||||||
|
if not ttl:
|
||||||
|
return None
|
||||||
|
elif ttl == 2147483647:
|
||||||
|
# View-once media, set low TTL
|
||||||
|
return 15
|
||||||
|
else:
|
||||||
|
# Increase media TTL because it's supposed to be counted from opening the media,
|
||||||
|
# but we can only count it from read receipt.
|
||||||
|
return ttl * 5
|
||||||
|
|
||||||
|
async def _convert_document_thumb_only(
|
||||||
|
self,
|
||||||
|
source: au.AbstractUser,
|
||||||
|
intent: IntentAPI,
|
||||||
|
evt: Message,
|
||||||
|
client: MautrixTelegramClient,
|
||||||
|
) -> ConvertedMessage | None:
|
||||||
|
document = evt.media.document
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
return None
|
||||||
|
|
||||||
|
external_link_content = "Unsupported file, please access directly on Telegram"
|
||||||
|
|
||||||
|
external_url = self._get_external_url(evt)
|
||||||
|
# We don't generate external URLs for bot users so only set if known
|
||||||
|
if external_url is not None:
|
||||||
|
external_link_content = (
|
||||||
|
f"Unsupported file, please access directly on Telegram here: {external_url}"
|
||||||
|
)
|
||||||
|
|
||||||
|
attrs = _parse_document_attributes(document.attributes)
|
||||||
|
file = None
|
||||||
|
|
||||||
|
thumb_loc, thumb_size = self.get_largest_photo_size(document)
|
||||||
|
if thumb_size and not isinstance(thumb_size, (PhotoSize, PhotoCachedSize)):
|
||||||
|
self.log.debug(f"Unsupported thumbnail type {type(thumb_size)}")
|
||||||
|
thumb_loc = None
|
||||||
|
thumb_size = None
|
||||||
|
if thumb_loc:
|
||||||
|
try:
|
||||||
|
file = await util.transfer_thumbnail_to_matrix(
|
||||||
|
client,
|
||||||
|
intent,
|
||||||
|
thumb_loc,
|
||||||
|
video=None,
|
||||||
|
mime_type=document.mime_type,
|
||||||
|
encrypt=self.portal.encrypted,
|
||||||
|
async_upload=self.config["homeserver.async_media"],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Failed to transfer thumbnail")
|
||||||
|
if not file:
|
||||||
|
name = attrs.name or ""
|
||||||
|
caption = f"\n{evt.message}" if evt.message else ""
|
||||||
|
return ConvertedMessage(
|
||||||
|
content=TextMessageEventContent(
|
||||||
|
msgtype=MessageType.NOTICE,
|
||||||
|
body=f"{name}{caption}\n{external_link_content}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
info, name = _parse_document_meta(evt, file, attrs, thumb_size)
|
||||||
|
|
||||||
|
event_type = EventType.ROOM_MESSAGE
|
||||||
|
if not name:
|
||||||
|
ext = sane_mimetypes.guess_extension(file.mime_type) or ""
|
||||||
|
name = "unnamed_file" + ext
|
||||||
|
|
||||||
|
content = MediaMessageEventContent(
|
||||||
|
body=name,
|
||||||
|
info=info,
|
||||||
|
msgtype={
|
||||||
|
"video/": MessageType.VIDEO,
|
||||||
|
"audio/": MessageType.AUDIO,
|
||||||
|
"image/": MessageType.IMAGE,
|
||||||
|
}.get(info.mimetype[:6], MessageType.FILE),
|
||||||
|
)
|
||||||
|
if file.decryption_info:
|
||||||
|
content.file = file.decryption_info
|
||||||
|
else:
|
||||||
|
content.url = file.mxc
|
||||||
|
|
||||||
|
caption_content = (
|
||||||
|
await formatter.telegram_to_matrix(evt, source, client) if evt.message else None
|
||||||
|
)
|
||||||
|
caption_content = f"{caption_content}\n{external_link_content}"
|
||||||
|
|
||||||
|
return ConvertedMessage(
|
||||||
|
type=event_type,
|
||||||
|
content=content,
|
||||||
|
caption=caption_content,
|
||||||
|
disappear_seconds=self._adjust_ttl(evt.media.ttl_seconds),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _convert_document(
|
async def _convert_document(
|
||||||
self, source: au.AbstractUser, intent: IntentAPI, evt: Message
|
self,
|
||||||
|
source: au.AbstractUser,
|
||||||
|
intent: IntentAPI,
|
||||||
|
evt: Message,
|
||||||
|
client: MautrixTelegramClient,
|
||||||
) -> ConvertedMessage | None:
|
) -> ConvertedMessage | None:
|
||||||
document = evt.media.document
|
document = evt.media.document
|
||||||
|
|
||||||
|
if not document:
|
||||||
|
return None
|
||||||
|
|
||||||
attrs = _parse_document_attributes(document.attributes)
|
attrs = _parse_document_attributes(document.attributes)
|
||||||
|
|
||||||
if document.size > self.matrix.media_config.upload_size:
|
if document.size > self.matrix.media_config.upload_size:
|
||||||
@@ -419,7 +624,7 @@ class TelegramMessageConverter:
|
|||||||
parallel_id = source.tgid if self.config["bridge.parallel_file_transfer"] else None
|
parallel_id = source.tgid if self.config["bridge.parallel_file_transfer"] else None
|
||||||
tgs_convert = self.config["bridge.animated_sticker"]
|
tgs_convert = self.config["bridge.animated_sticker"]
|
||||||
file = await util.transfer_file_to_matrix(
|
file = await util.transfer_file_to_matrix(
|
||||||
source.client,
|
client,
|
||||||
intent,
|
intent,
|
||||||
document,
|
document,
|
||||||
thumb_loc,
|
thumb_loc,
|
||||||
@@ -456,12 +661,15 @@ class TelegramMessageConverter:
|
|||||||
info["fi.mau.telegram.gif"] = True
|
info["fi.mau.telegram.gif"] = True
|
||||||
else:
|
else:
|
||||||
info["fi.mau.telegram.animated_sticker"] = True
|
info["fi.mau.telegram.animated_sticker"] = True
|
||||||
|
info["fi.mau.gif"] = True
|
||||||
info["fi.mau.loop"] = True
|
info["fi.mau.loop"] = True
|
||||||
info["fi.mau.autoplay"] = True
|
info["fi.mau.autoplay"] = True
|
||||||
info["fi.mau.hide_controls"] = True
|
info["fi.mau.hide_controls"] = True
|
||||||
info["fi.mau.no_audio"] = True
|
info["fi.mau.no_audio"] = True
|
||||||
|
if evt.media.spoiler:
|
||||||
|
info["fi.mau.telegram.spoiler"] = True
|
||||||
if not name:
|
if not name:
|
||||||
ext = sane_mimetypes.guess_extension(file.mime_type)
|
ext = sane_mimetypes.guess_extension(file.mime_type) or ""
|
||||||
name = "unnamed_file" + ext
|
name = "unnamed_file" + ext
|
||||||
|
|
||||||
content = MediaMessageEventContent(
|
content = MediaMessageEventContent(
|
||||||
@@ -486,13 +694,15 @@ class TelegramMessageConverter:
|
|||||||
else:
|
else:
|
||||||
content.url = file.mxc
|
content.url = file.mxc
|
||||||
|
|
||||||
caption_content = await formatter.telegram_to_matrix(evt, source) if evt.message else None
|
caption_content = (
|
||||||
|
await formatter.telegram_to_matrix(evt, source, client) if evt.message else None
|
||||||
|
)
|
||||||
|
|
||||||
return ConvertedMessage(
|
return ConvertedMessage(
|
||||||
type=event_type,
|
type=event_type,
|
||||||
content=content,
|
content=content,
|
||||||
caption=caption_content,
|
caption=caption_content,
|
||||||
disappear_seconds=evt.media.ttl_seconds,
|
disappear_seconds=self._adjust_ttl(evt.media.ttl_seconds),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -527,13 +737,17 @@ class TelegramMessageConverter:
|
|||||||
return ConvertedMessage(content=content)
|
return ConvertedMessage(content=content)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _convert_unsupported(source: au.AbstractUser, evt: Message, **_) -> ConvertedMessage:
|
async def _convert_unsupported(
|
||||||
|
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
|
||||||
|
) -> ConvertedMessage:
|
||||||
override_text = (
|
override_text = (
|
||||||
"This message is not supported on your version of Mautrix-Telegram. "
|
"This message is not supported on your version of Mautrix-Telegram. "
|
||||||
"Please check https://github.com/mautrix/telegram or ask your "
|
"Please check https://github.com/mautrix/telegram or ask your "
|
||||||
"bridge administrator about possible updates."
|
"bridge administrator about possible updates."
|
||||||
)
|
)
|
||||||
content = await formatter.telegram_to_matrix(evt, source, override_text=override_text)
|
content = await formatter.telegram_to_matrix(
|
||||||
|
evt, source, client, override_text=override_text
|
||||||
|
)
|
||||||
content.msgtype = MessageType.NOTICE
|
content.msgtype = MessageType.NOTICE
|
||||||
content["fi.mau.telegram.unsupported"] = True
|
content["fi.mau.telegram.unsupported"] = True
|
||||||
return ConvertedMessage(content=content)
|
return ConvertedMessage(content=content)
|
||||||
@@ -549,18 +763,18 @@ class TelegramMessageConverter:
|
|||||||
_n += 1
|
_n += 1
|
||||||
return _n
|
return _n
|
||||||
|
|
||||||
text_answers = "\n".join(f"{n()}. {answer.text}" for answer in poll.answers)
|
text_answers = "\n".join(f"{n()}. {answer.text.text}" for answer in poll.answers)
|
||||||
html_answers = "\n".join(f"<li>{answer.text}</li>" for answer in poll.answers)
|
html_answers = "\n".join(f"<li>{answer.text.text}</li>" for answer in poll.answers)
|
||||||
vote_command = f"{self.command_prefix} vote {poll_id}"
|
vote_command = f"{self.command_prefix} vote {poll_id}"
|
||||||
content = TextMessageEventContent(
|
content = TextMessageEventContent(
|
||||||
msgtype=MessageType.TEXT,
|
msgtype=MessageType.TEXT,
|
||||||
format=Format.HTML,
|
format=Format.HTML,
|
||||||
body=(
|
body=(
|
||||||
f"Poll: {poll.question}\n{text_answers}\n"
|
f"Poll: {poll.question.text}\n{text_answers}\n"
|
||||||
f"Vote with {vote_command} <choice number>"
|
f"Vote with {vote_command} <choice number>"
|
||||||
),
|
),
|
||||||
formatted_body=(
|
formatted_body=(
|
||||||
f"<strong>Poll</strong>: {poll.question}<br/>\n"
|
f"<strong>Poll</strong>: {poll.question.text}<br/>\n"
|
||||||
f"<ol>{html_answers}</ol>\n"
|
f"<ol>{html_answers}</ol>\n"
|
||||||
f"Vote with <code>{vote_command} <choice number></code>"
|
f"Vote with <code>{vote_command} <choice number></code>"
|
||||||
),
|
),
|
||||||
@@ -589,7 +803,9 @@ class TelegramMessageConverter:
|
|||||||
content["fi.mau.telegram.dice"] = {"emoticon": roll.emoticon, "value": roll.value}
|
content["fi.mau.telegram.dice"] = {"emoticon": roll.emoticon, "value": roll.value}
|
||||||
return ConvertedMessage(content=content)
|
return ConvertedMessage(content=content)
|
||||||
|
|
||||||
async def _convert_game(self, source: au.AbstractUser, evt: Message, **_) -> ConvertedMessage:
|
async def _convert_game(
|
||||||
|
self, source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
|
||||||
|
) -> ConvertedMessage:
|
||||||
game: Game = evt.media.game
|
game: Game = evt.media.game
|
||||||
play_id = self._encode_msgid(source, evt)
|
play_id = self._encode_msgid(source, evt)
|
||||||
command = f"{self.command_prefix} play {play_id}"
|
command = f"{self.command_prefix} play {play_id}"
|
||||||
@@ -599,7 +815,7 @@ class TelegramMessageConverter:
|
|||||||
]
|
]
|
||||||
|
|
||||||
content = await formatter.telegram_to_matrix(
|
content = await formatter.telegram_to_matrix(
|
||||||
evt, source, override_text=override_text, override_entities=override_entities
|
evt, source, client, override_text=override_text, override_entities=override_entities
|
||||||
)
|
)
|
||||||
content.msgtype = MessageType.NOTICE
|
content.msgtype = MessageType.NOTICE
|
||||||
content["fi.mau.telegram.game"] = play_id
|
content["fi.mau.telegram.game"] = play_id
|
||||||
@@ -607,7 +823,9 @@ class TelegramMessageConverter:
|
|||||||
return ConvertedMessage(content=content)
|
return ConvertedMessage(content=content)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _convert_contact(source: au.AbstractUser, evt: Message, **_) -> ConvertedMessage:
|
async def _convert_contact(
|
||||||
|
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
|
||||||
|
) -> ConvertedMessage:
|
||||||
contact: MessageMediaContact = evt.media
|
contact: MessageMediaContact = evt.media
|
||||||
name = " ".join(x for x in [contact.first_name, contact.last_name] if x)
|
name = " ".join(x for x in [contact.first_name, contact.last_name] if x)
|
||||||
formatted_phone = f"+{contact.phone_number}"
|
formatted_phone = f"+{contact.phone_number}"
|
||||||
@@ -633,8 +851,8 @@ class TelegramMessageConverter:
|
|||||||
puppet = await pu.Puppet.get_by_tgid(TelegramID(contact.user_id))
|
puppet = await pu.Puppet.get_by_tgid(TelegramID(contact.user_id))
|
||||||
if not puppet.displayname:
|
if not puppet.displayname:
|
||||||
try:
|
try:
|
||||||
entity = await source.client.get_entity(PeerUser(contact.user_id))
|
entity = await client.get_entity(PeerUser(contact.user_id))
|
||||||
await puppet.update_info(source, entity)
|
await puppet.update_info(source, entity, client_override=client)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
source.log.warning(f"Failed to sync puppet info of received contact: {e}")
|
source.log.warning(f"Failed to sync puppet info of received contact: {e}")
|
||||||
else:
|
else:
|
||||||
@@ -646,10 +864,33 @@ class TelegramMessageConverter:
|
|||||||
)
|
)
|
||||||
return ConvertedMessage(content=content)
|
return ConvertedMessage(content=content)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _convert_story(
|
||||||
|
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
|
||||||
|
) -> ConvertedMessage:
|
||||||
|
content = await formatter.telegram_to_matrix(
|
||||||
|
evt, source, client, override_text="Stories are not yet supported"
|
||||||
|
)
|
||||||
|
content.msgtype = MessageType.NOTICE
|
||||||
|
content["fi.mau.telegram.unsupported"] = True
|
||||||
|
return ConvertedMessage(content=content)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _convert_invoice(
|
||||||
|
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
|
||||||
|
) -> ConvertedMessage:
|
||||||
|
content = await formatter.telegram_to_matrix(
|
||||||
|
evt, source, client, override_text="Invoices are not yet supported"
|
||||||
|
)
|
||||||
|
content.msgtype = MessageType.NOTICE
|
||||||
|
content["fi.mau.telegram.unsupported"] = True
|
||||||
|
return ConvertedMessage(content=content)
|
||||||
|
|
||||||
|
|
||||||
def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAttrs:
|
def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAttrs:
|
||||||
name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0
|
name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0
|
||||||
is_gif, is_audio, is_voice, duration, waveform = False, False, False, 0, bytes()
|
is_gif, is_audio, is_voice, duration, waveform = False, False, False, 0, bytes()
|
||||||
|
sticker_pack_ref = None
|
||||||
for attr in attributes:
|
for attr in attributes:
|
||||||
if isinstance(attr, DocumentAttributeFilename):
|
if isinstance(attr, DocumentAttributeFilename):
|
||||||
name = name or attr.file_name
|
name = name or attr.file_name
|
||||||
@@ -657,6 +898,13 @@ def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAt
|
|||||||
elif isinstance(attr, DocumentAttributeSticker):
|
elif isinstance(attr, DocumentAttributeSticker):
|
||||||
is_sticker = True
|
is_sticker = True
|
||||||
sticker_alt = attr.alt
|
sticker_alt = attr.alt
|
||||||
|
if isinstance(attr.stickerset, InputStickerSetID):
|
||||||
|
sticker_pack_ref = {
|
||||||
|
"id": str(attr.stickerset.id),
|
||||||
|
"access_hash": str(attr.stickerset.access_hash),
|
||||||
|
}
|
||||||
|
elif isinstance(attr.stickerset, InputStickerSetShortName):
|
||||||
|
sticker_pack_ref = {"short_name": attr.stickerset.short_name}
|
||||||
elif isinstance(attr, DocumentAttributeAnimated):
|
elif isinstance(attr, DocumentAttributeAnimated):
|
||||||
is_gif = True
|
is_gif = True
|
||||||
elif isinstance(attr, DocumentAttributeVideo):
|
elif isinstance(attr, DocumentAttributeVideo):
|
||||||
@@ -670,17 +918,18 @@ def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAt
|
|||||||
waveform = decode_waveform(attr.waveform) if attr.waveform else b""
|
waveform = decode_waveform(attr.waveform) if attr.waveform else b""
|
||||||
|
|
||||||
return DocAttrs(
|
return DocAttrs(
|
||||||
name,
|
name=name,
|
||||||
mime_type,
|
mime_type=mime_type,
|
||||||
is_sticker,
|
is_sticker=is_sticker,
|
||||||
sticker_alt,
|
sticker_alt=sticker_alt,
|
||||||
width,
|
sticker_pack_ref=sticker_pack_ref,
|
||||||
height,
|
width=width,
|
||||||
is_gif,
|
height=height,
|
||||||
is_audio,
|
is_gif=is_gif,
|
||||||
is_voice,
|
is_audio=is_audio,
|
||||||
duration,
|
is_voice=is_voice,
|
||||||
waveform,
|
duration=duration,
|
||||||
|
waveform=waveform,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -706,6 +955,13 @@ def _parse_document_meta(
|
|||||||
mime_type = file.mime_type or document.mime_type
|
mime_type = file.mime_type or document.mime_type
|
||||||
info = ImageInfo(size=file.size, mimetype=mime_type)
|
info = ImageInfo(size=file.size, mimetype=mime_type)
|
||||||
|
|
||||||
|
if attrs.is_sticker:
|
||||||
|
info["fi.mau.telegram.sticker"] = {
|
||||||
|
"alt": attrs.sticker_alt,
|
||||||
|
"id": str(document.id),
|
||||||
|
"pack": attrs.sticker_pack_ref,
|
||||||
|
}
|
||||||
|
|
||||||
if attrs.mime_type and not file.was_converted:
|
if attrs.mime_type and not file.was_converted:
|
||||||
file.mime_type = attrs.mime_type or file.mime_type
|
file.mime_type = attrs.mime_type or file.mime_type
|
||||||
if file.width and file.height:
|
if file.width and file.height:
|
||||||
@@ -725,6 +981,10 @@ def _parse_document_meta(
|
|||||||
size=file.thumbnail.size,
|
size=file.thumbnail.size,
|
||||||
)
|
)
|
||||||
elif attrs.is_sticker:
|
elif attrs.is_sticker:
|
||||||
|
if not info.width or not info.height:
|
||||||
|
info.width = 256
|
||||||
|
info.height = 256
|
||||||
|
|
||||||
# This is a hack for bad clients like Element iOS that require a thumbnail
|
# This is a hack for bad clients like Element iOS that require a thumbnail
|
||||||
info.thumbnail_info = ImageInfo.deserialize(info.serialize())
|
info.thumbnail_info = ImageInfo.deserialize(info.serialize())
|
||||||
if file.decryption_info:
|
if file.decryption_info:
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from telethon.tl.types import (
|
|||||||
ChannelParticipantBanned,
|
ChannelParticipantBanned,
|
||||||
ChannelParticipantsRecent,
|
ChannelParticipantsRecent,
|
||||||
ChannelParticipantsSearch,
|
ChannelParticipantsSearch,
|
||||||
|
ChatParticipantsForbidden,
|
||||||
InputChannel,
|
InputChannel,
|
||||||
InputUser,
|
InputUser,
|
||||||
TypeChannelParticipant,
|
TypeChannelParticipant,
|
||||||
@@ -93,6 +94,8 @@ async def get_users(
|
|||||||
) -> list[TypeUser]:
|
) -> list[TypeUser]:
|
||||||
if peer_type == "chat":
|
if peer_type == "chat":
|
||||||
chat = await client(GetFullChatRequest(chat_id=tgid))
|
chat = await client(GetFullChatRequest(chat_id=tgid))
|
||||||
|
if isinstance(chat.full_chat.participants, ChatParticipantsForbidden):
|
||||||
|
return []
|
||||||
users = list(_filter_participants(chat.users, chat.full_chat.participants.participants))
|
users = list(_filter_participants(chat.users, chat.full_chat.participants.participants))
|
||||||
return users[:limit] if limit > 0 else users
|
return users[:limit] if limit > 0 else users
|
||||||
elif peer_type == "channel":
|
elif peer_type == "channel":
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ from ..types import TelegramID
|
|||||||
|
|
||||||
|
|
||||||
def get_base_power_levels(
|
def get_base_power_levels(
|
||||||
portal: po.Portal, levels: PowerLevelContent = None, entity: TypeChat = None
|
portal: po.Portal,
|
||||||
|
levels: PowerLevelContent = None,
|
||||||
|
entity: TypeChat | None = None,
|
||||||
|
dbr: ChatBannedRights | None = None,
|
||||||
) -> PowerLevelContent:
|
) -> PowerLevelContent:
|
||||||
|
is_initial = not levels
|
||||||
levels = levels or PowerLevelContent()
|
levels = levels or PowerLevelContent()
|
||||||
if portal.peer_type == "user":
|
if portal.peer_type == "user":
|
||||||
overrides = portal.config["bridge.initial_power_level_overrides.user"]
|
overrides = portal.config["bridge.initial_power_level_overrides.user"]
|
||||||
@@ -51,7 +55,7 @@ def get_base_power_levels(
|
|||||||
levels.events_default = overrides.get("events_default", 0)
|
levels.events_default = overrides.get("events_default", 0)
|
||||||
else:
|
else:
|
||||||
overrides = portal.config["bridge.initial_power_level_overrides.group"]
|
overrides = portal.config["bridge.initial_power_level_overrides.group"]
|
||||||
dbr = entity.default_banned_rights
|
dbr = dbr or entity.default_banned_rights
|
||||||
if not dbr:
|
if not dbr:
|
||||||
portal.log.debug(f"default_banned_rights is None in {entity}")
|
portal.log.debug(f"default_banned_rights is None in {entity}")
|
||||||
dbr = ChatBannedRights(
|
dbr = ChatBannedRights(
|
||||||
@@ -79,17 +83,17 @@ def get_base_power_levels(
|
|||||||
levels.users_default = overrides.get("users_default", 0)
|
levels.users_default = overrides.get("users_default", 0)
|
||||||
levels.events_default = overrides.get(
|
levels.events_default = overrides.get(
|
||||||
"events_default",
|
"events_default",
|
||||||
|
(
|
||||||
50
|
50
|
||||||
if portal.peer_type == "channel" and not entity.megagroup or dbr.send_messages
|
if portal.peer_type == "channel" and not portal.megagroup or dbr.send_messages
|
||||||
else 0,
|
else 0
|
||||||
|
),
|
||||||
)
|
)
|
||||||
for evt_type, value in overrides.get("events", {}).items():
|
for evt_type, value in overrides.get("events", {}).items():
|
||||||
levels.events[EventType.find(evt_type)] = value
|
levels.events[EventType.find(evt_type)] = value
|
||||||
userlevel_overrides = overrides.get("users", {})
|
userlevel_overrides = overrides.get("users", {})
|
||||||
bot_level = levels.get_user_level(portal.main_intent.mxid)
|
if is_initial:
|
||||||
for user, user_level in levels.users.items():
|
levels.users.update(userlevel_overrides)
|
||||||
if user_level < bot_level:
|
|
||||||
levels.users[user] = userlevel_overrides.get(user, 0)
|
|
||||||
if portal.main_intent.mxid not in levels.users:
|
if portal.main_intent.mxid not in levels.users:
|
||||||
levels.users[portal.main_intent.mxid] = 100
|
levels.users[portal.main_intent.mxid] = 100
|
||||||
return levels
|
return levels
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import html
|
import html
|
||||||
|
|
||||||
from telethon.tl.functions.channels import GetSponsoredMessagesRequest
|
from telethon.tl.functions.messages import GetSponsoredMessagesRequest
|
||||||
from telethon.tl.types import Channel, InputChannel, PeerChannel, PeerUser, SponsoredMessage, User
|
from telethon.tl.types import Channel, InputChannel, PeerChannel, PeerUser, SponsoredMessage, User
|
||||||
|
from telethon.tl.types.messages import SponsoredMessages, SponsoredMessagesEmpty
|
||||||
|
|
||||||
from mautrix.types import MessageType, TextMessageEventContent
|
from mautrix.types import MessageType, TextMessageEventContent
|
||||||
|
|
||||||
@@ -32,8 +33,9 @@ async def get_sponsored_message(
|
|||||||
entity: InputChannel,
|
entity: InputChannel,
|
||||||
) -> tuple[SponsoredMessage | None, int | None, Channel | User | None]:
|
) -> tuple[SponsoredMessage | None, int | None, Channel | User | None]:
|
||||||
resp = await user.client(GetSponsoredMessagesRequest(entity))
|
resp = await user.client(GetSponsoredMessagesRequest(entity))
|
||||||
if len(resp.messages) == 0:
|
if isinstance(resp, SponsoredMessagesEmpty):
|
||||||
return None, None, None
|
return None, None, None
|
||||||
|
assert isinstance(resp, SponsoredMessages)
|
||||||
msg = resp.messages[0]
|
msg = resp.messages[0]
|
||||||
if isinstance(msg.from_id, PeerUser):
|
if isinstance(msg.from_id, PeerUser):
|
||||||
entities = resp.users
|
entities = resp.users
|
||||||
|
|||||||
+111
-25
@@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, cast
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
|
from telethon import utils
|
||||||
from telethon.tl.types import (
|
from telethon.tl.types import (
|
||||||
Channel,
|
Channel,
|
||||||
ChatPhoto,
|
ChatPhoto,
|
||||||
@@ -29,8 +30,6 @@ from telethon.tl.types import (
|
|||||||
PeerChat,
|
PeerChat,
|
||||||
PeerUser,
|
PeerUser,
|
||||||
TypeChatPhoto,
|
TypeChatPhoto,
|
||||||
TypeInputPeer,
|
|
||||||
TypeInputUser,
|
|
||||||
TypePeer,
|
TypePeer,
|
||||||
TypeUserProfilePhoto,
|
TypeUserProfilePhoto,
|
||||||
UpdateUserName,
|
UpdateUserName,
|
||||||
@@ -48,6 +47,7 @@ from mautrix.util.simple_template import SimpleTemplate
|
|||||||
from . import abstract_user as au, portal as p, util
|
from . import abstract_user as au, portal as p, util
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .db import Puppet as DBPuppet
|
from .db import Puppet as DBPuppet
|
||||||
|
from .tgclient import MautrixTelegramClient
|
||||||
from .types import TelegramID
|
from .types import TelegramID
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -55,6 +55,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class Puppet(DBPuppet, BasePuppet):
|
class Puppet(DBPuppet, BasePuppet):
|
||||||
|
bridge: TelegramBridge
|
||||||
config: Config
|
config: Config
|
||||||
hs_domain: str
|
hs_domain: str
|
||||||
mxid_template: SimpleTemplate[TelegramID]
|
mxid_template: SimpleTemplate[TelegramID]
|
||||||
@@ -78,8 +79,10 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
avatar_url: ContentURI | None = None,
|
avatar_url: ContentURI | None = None,
|
||||||
name_set: bool = False,
|
name_set: bool = False,
|
||||||
avatar_set: bool = False,
|
avatar_set: bool = False,
|
||||||
|
contact_info_set: bool = False,
|
||||||
is_bot: bool = False,
|
is_bot: bool = False,
|
||||||
is_channel: bool = False,
|
is_channel: bool = False,
|
||||||
|
is_premium: bool = False,
|
||||||
custom_mxid: UserID | None = None,
|
custom_mxid: UserID | None = None,
|
||||||
access_token: str | None = None,
|
access_token: str | None = None,
|
||||||
next_batch: SyncToken | None = None,
|
next_batch: SyncToken | None = None,
|
||||||
@@ -99,8 +102,10 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
avatar_url=avatar_url,
|
avatar_url=avatar_url,
|
||||||
name_set=name_set,
|
name_set=name_set,
|
||||||
avatar_set=avatar_set,
|
avatar_set=avatar_set,
|
||||||
|
contact_info_set=contact_info_set,
|
||||||
is_bot=is_bot,
|
is_bot=is_bot,
|
||||||
is_channel=is_channel,
|
is_channel=is_channel,
|
||||||
|
is_premium=is_premium,
|
||||||
custom_mxid=custom_mxid,
|
custom_mxid=custom_mxid,
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
next_batch=next_batch,
|
next_batch=next_batch,
|
||||||
@@ -145,9 +150,6 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
def plain_displayname(self) -> str:
|
def plain_displayname(self) -> str:
|
||||||
return self.displayname_template.parse(self.displayname) or self.displayname
|
return self.displayname_template.parse(self.displayname) or self.displayname
|
||||||
|
|
||||||
def get_input_entity(self, user: au.AbstractUser) -> Awaitable[TypeInputPeer | TypeInputUser]:
|
|
||||||
return user.client.get_input_entity(self.peer)
|
|
||||||
|
|
||||||
def intent_for(self, portal: p.Portal) -> IntentAPI:
|
def intent_for(self, portal: p.Portal) -> IntentAPI:
|
||||||
if portal.tgid == self.tgid:
|
if portal.tgid == self.tgid:
|
||||||
return self.default_mxid_intent
|
return self.default_mxid_intent
|
||||||
@@ -155,6 +157,7 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
|
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
|
||||||
|
cls.bridge = bridge
|
||||||
cls.config = bridge.config
|
cls.config = bridge.config
|
||||||
cls.loop = bridge.loop
|
cls.loop = bridge.loop
|
||||||
cls.mx = bridge.matrix
|
cls.mx = bridge.matrix
|
||||||
@@ -253,15 +256,27 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
except Exception:
|
except Exception:
|
||||||
source.log.exception(f"Failed to update info of {self.tgid}")
|
source.log.exception(f"Failed to update info of {self.tgid}")
|
||||||
|
|
||||||
async def update_info(self, source: au.AbstractUser, info: User | Channel) -> None:
|
async def update_info(
|
||||||
|
self,
|
||||||
|
source: au.AbstractUser,
|
||||||
|
info: User | Channel,
|
||||||
|
client_override: MautrixTelegramClient | None = None,
|
||||||
|
) -> None:
|
||||||
is_bot = False if isinstance(info, Channel) else info.bot
|
is_bot = False if isinstance(info, Channel) else info.bot
|
||||||
|
is_premium = False if isinstance(info, Channel) else info.premium
|
||||||
is_channel = isinstance(info, Channel)
|
is_channel = isinstance(info, Channel)
|
||||||
changed = is_bot != self.is_bot or is_channel != self.is_channel
|
changed = (
|
||||||
|
is_bot != self.is_bot or is_channel != self.is_channel or is_premium != self.is_premium
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_bot is not None:
|
||||||
self.is_bot = is_bot
|
self.is_bot = is_bot
|
||||||
self.is_channel = is_channel
|
self.is_channel = is_channel
|
||||||
|
if is_premium is not None:
|
||||||
|
self.is_premium = is_premium
|
||||||
|
|
||||||
if self.username != info.username:
|
if self.username != info.username and (info.username or not info.min):
|
||||||
|
self.log.debug(f"Updating username {self.username} -> {info.username}")
|
||||||
self.username = info.username
|
self.username = info.username
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
@@ -271,8 +286,18 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
|
|
||||||
if not self.disable_updates:
|
if not self.disable_updates:
|
||||||
try:
|
try:
|
||||||
changed = await self.update_displayname(source, info) or changed
|
changed = await self._update_contact_info(force=changed) or changed
|
||||||
changed = await self.update_avatar(source, info.photo) or changed
|
|
||||||
|
changed = (
|
||||||
|
await self.update_displayname(source, info, client_override=client_override)
|
||||||
|
or changed
|
||||||
|
)
|
||||||
|
changed = (
|
||||||
|
await self.update_avatar(
|
||||||
|
source, info.photo, entity=info, client_override=client_override
|
||||||
|
)
|
||||||
|
or changed
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception(f"Failed to update info from source {source.tgid}")
|
self.log.exception(f"Failed to update info from source {source.tgid}")
|
||||||
|
|
||||||
@@ -280,14 +305,46 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
await self.update_portals_meta()
|
await self.update_portals_meta()
|
||||||
await self.save()
|
await self.save()
|
||||||
|
|
||||||
|
async def _update_contact_info(self, force: bool = False) -> bool:
|
||||||
|
if not self.bridge.homeserver_software.is_hungry:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.contact_info_set and not force:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
identifiers = []
|
||||||
|
if self.username:
|
||||||
|
identifiers.append(f"telegram:{self.username}")
|
||||||
|
if self.phone:
|
||||||
|
phone = "+" + self.phone.lstrip("+")
|
||||||
|
identifiers.append(f"tel:{phone}")
|
||||||
|
await self.default_mxid_intent.beeper_update_profile(
|
||||||
|
{
|
||||||
|
"com.beeper.bridge.identifiers": identifiers,
|
||||||
|
"com.beeper.bridge.remote_id": str(self.tgid),
|
||||||
|
"com.beeper.bridge.service": "telegram",
|
||||||
|
"com.beeper.bridge.network": "telegram",
|
||||||
|
"com.beeper.bridge.is_network_bot": self.is_bot,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.contact_info_set = True
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Error updating contact info")
|
||||||
|
self.contact_info_set = False
|
||||||
|
return True
|
||||||
|
|
||||||
async def update_portals_meta(self) -> None:
|
async def update_portals_meta(self) -> None:
|
||||||
if not p.Portal.private_chat_portal_meta and not self.mx.e2ee:
|
if p.Portal.private_chat_portal_meta != "always" and not self.mx.e2ee:
|
||||||
return
|
return
|
||||||
async for portal in p.Portal.find_private_chats_with(self.tgid):
|
async for portal in p.Portal.find_private_chats_with(self.tgid):
|
||||||
await portal.update_info_from_puppet(self)
|
await portal.update_info_from_puppet(self)
|
||||||
|
|
||||||
async def update_displayname(
|
async def update_displayname(
|
||||||
self, source: au.AbstractUser, info: User | Channel | UpdateUserName
|
self,
|
||||||
|
source: au.AbstractUser,
|
||||||
|
info: User | Channel | UpdateUserName,
|
||||||
|
client_override: MautrixTelegramClient | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if self.disable_updates:
|
if self.disable_updates:
|
||||||
return False
|
return False
|
||||||
@@ -314,13 +371,15 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if isinstance(info, UpdateUserName):
|
if isinstance(info, UpdateUserName):
|
||||||
info = await source.client.get_entity(self.peer)
|
info = await (client_override or source.client).get_entity(self.peer)
|
||||||
if isinstance(info, Channel) or not info.contact:
|
is_contact_name = not isinstance(info, Channel) and info.contact
|
||||||
self.displayname_contact = False
|
# Reject name change if the contact status is moving in an unwanted direction,
|
||||||
elif not self.displayname_contact:
|
# and we already have a name for the ghost.
|
||||||
if not self.displayname:
|
if (
|
||||||
self.displayname_contact = True
|
is_contact_name != self.displayname_contact
|
||||||
else:
|
and is_contact_name != self.config["bridge.allow_contact_info"]
|
||||||
|
and self.displayname
|
||||||
|
):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
displayname, quality = self.get_displayname(info)
|
displayname, quality = self.get_displayname(info)
|
||||||
@@ -329,12 +388,14 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
if needs_reset and is_high_quality:
|
if needs_reset and is_high_quality:
|
||||||
allow_because = f"{allow_because} and quality {quality} >= {self.displayname_quality}"
|
allow_because = f"{allow_because} and quality {quality} >= {self.displayname_quality}"
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
f"Updating displayname of {self.id} (src: {source.tgid}, allowed "
|
f"Updating displayname of {self.id} (src: {source.tgid}, "
|
||||||
f"because {allow_because}) from {self.displayname} to {displayname}"
|
f"contact: {is_contact_name}, allowed because {allow_because}) "
|
||||||
|
f"from {self.displayname} to {displayname}"
|
||||||
)
|
)
|
||||||
self.log.trace("Displayname source data: %s", info)
|
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_contact = is_contact_name
|
||||||
self.displayname_quality = quality
|
self.displayname_quality = quality
|
||||||
try:
|
try:
|
||||||
await self.default_mxid_intent.set_displayname(
|
await self.default_mxid_intent.set_displayname(
|
||||||
@@ -351,10 +412,24 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def update_avatar(
|
async def update_avatar(
|
||||||
self, source: au.AbstractUser, photo: TypeUserProfilePhoto | TypeChatPhoto
|
self,
|
||||||
|
source: au.AbstractUser,
|
||||||
|
photo: TypeUserProfilePhoto | TypeChatPhoto,
|
||||||
|
entity: User | None = None,
|
||||||
|
client_override: MautrixTelegramClient | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if self.disable_updates:
|
if self.disable_updates:
|
||||||
return False
|
return False
|
||||||
|
if (
|
||||||
|
isinstance(photo, UserProfilePhoto)
|
||||||
|
and photo.personal
|
||||||
|
and not self.config["bridge.allow_contact_info"]
|
||||||
|
):
|
||||||
|
self.log.trace(
|
||||||
|
"Dropping user avatar as it's personal "
|
||||||
|
"and contact info is disabled in bridge config"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
if photo is None or isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty)):
|
if photo is None or isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty)):
|
||||||
photo_id = ""
|
photo_id = ""
|
||||||
@@ -370,11 +445,22 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
self.photo_id = ""
|
self.photo_id = ""
|
||||||
self.avatar_url = None
|
self.avatar_url = None
|
||||||
elif self.photo_id != photo_id or not self.avatar_url:
|
elif self.photo_id != photo_id or not self.avatar_url:
|
||||||
|
client = client_override or source.client
|
||||||
|
try:
|
||||||
|
peer = await client.get_input_entity(entity or self.peer)
|
||||||
|
except ValueError:
|
||||||
|
if entity:
|
||||||
|
peer = utils.get_input_peer(entity, check_hash=False)
|
||||||
|
else:
|
||||||
|
self.log.warning(f"Couldn't get input entity to update avatar")
|
||||||
|
return False
|
||||||
file = await util.transfer_file_to_matrix(
|
file = await util.transfer_file_to_matrix(
|
||||||
client=source.client,
|
client=client,
|
||||||
intent=self.default_mxid_intent,
|
intent=self.default_mxid_intent,
|
||||||
location=InputPeerPhotoFileLocation(
|
location=InputPeerPhotoFileLocation(
|
||||||
peer=await self.get_input_entity(source), photo_id=photo.photo_id, big=True
|
peer=peer,
|
||||||
|
photo_id=photo.photo_id,
|
||||||
|
big=True,
|
||||||
),
|
),
|
||||||
async_upload=self.config["homeserver.async_media"],
|
async_upload=self.config["homeserver.async_media"],
|
||||||
)
|
)
|
||||||
@@ -393,7 +479,7 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
|
|
||||||
async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
|
async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
|
||||||
portal: p.Portal = await p.Portal.get_by_mxid(room_id)
|
portal: p.Portal = await p.Portal.get_by_mxid(room_id)
|
||||||
return portal and not portal.backfill_lock.locked and portal.peer_type != "user"
|
return portal and portal.peer_type != "user"
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
# region Getters
|
# region Getters
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
# 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 typing import Any, Literal, TypedDict
|
||||||
|
from pathlib import Path
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import mimetypes
|
||||||
|
import pickle
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
|
||||||
|
from lottie.exporters import export_tgs
|
||||||
|
from lottie.exporters.cairo import export_png
|
||||||
|
from lottie.exporters.tgs_validator import Severity, TgsValidator
|
||||||
|
from lottie.importers.svg import import_svg
|
||||||
|
from lottie.objects import Animation
|
||||||
|
from lottie.utils.stripper import float_strip
|
||||||
|
from PIL import Image
|
||||||
|
from telethon import TelegramClient
|
||||||
|
from telethon.custom import Conversation, Message
|
||||||
|
from telethon.tl.functions.messages import GetStickerSetRequest
|
||||||
|
from telethon.tl.types import (
|
||||||
|
Document,
|
||||||
|
DocumentAttributeCustomEmoji,
|
||||||
|
DocumentAttributeFilename,
|
||||||
|
DocumentAttributeImageSize,
|
||||||
|
InputMediaUploadedDocument,
|
||||||
|
InputStickerSetShortName,
|
||||||
|
)
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
mimetypes.add_type("image/webp", ".webp")
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="mautrix-telegram unicode emoji packer")
|
||||||
|
parser.add_argument(
|
||||||
|
"-i", "--api-id", type=int, required=True, metavar="<api id>", help="Telegram API ID"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-a", "--api-hash", type=str, required=True, metavar="<api hash>", help="Telegram API hash"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-s",
|
||||||
|
"--session",
|
||||||
|
type=str,
|
||||||
|
default="unicodemojipacker.session",
|
||||||
|
metavar="<file name>",
|
||||||
|
help="Telethon session name",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
type=str,
|
||||||
|
default="mautrix_telegram/unicodemojipack.json",
|
||||||
|
metavar="<file name>",
|
||||||
|
help="Path to save created emoji pack document IDs",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-f",
|
||||||
|
"--font-directory",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
metavar="<directory path>",
|
||||||
|
help="Path to the Noto color emoji files",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-m",
|
||||||
|
"--media-directory",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
metavar="<directory path>",
|
||||||
|
help="Path to save converted tgs and webp emoji files",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
font_dir: Path = args.font_directory
|
||||||
|
media_dir: Path = args.media_directory
|
||||||
|
|
||||||
|
EMOJI_DATA_URL = "https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json"
|
||||||
|
|
||||||
|
|
||||||
|
def unified_to_unicode(unified: str) -> str:
|
||||||
|
return (
|
||||||
|
"".join(rf"\U{chunk:0>8}" for chunk in unified.split("-"))
|
||||||
|
.encode("ascii")
|
||||||
|
.decode("unicode_escape")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tag_to_str(unified: str) -> str:
|
||||||
|
return "".join(chr(int(x.removeprefix("E00"), 16)) for x in unified.split("-"))
|
||||||
|
|
||||||
|
|
||||||
|
EmojiType = Literal["webp", "tgs"]
|
||||||
|
PackType = Literal["Animated emoji", "Static emoji"]
|
||||||
|
|
||||||
|
|
||||||
|
class Emoji(TypedDict):
|
||||||
|
hex: str
|
||||||
|
emoji: str
|
||||||
|
type: EmojiType
|
||||||
|
filename: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmojiData(TypedDict):
|
||||||
|
tgs: list[Emoji]
|
||||||
|
webp: list[Emoji]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_emoji_data(tone: dict[str, Any], emoji: dict[str, Any]) -> Emoji:
|
||||||
|
hex = (tone["non_qualified"] or tone["unified"]).replace("-FE0F", "")
|
||||||
|
filename_hex = hex.replace("-", "_").lower()
|
||||||
|
filename = f"svg/emoji_u{filename_hex}.svg"
|
||||||
|
if emoji["category"] == "Flags" and emoji["subcategory"] in (
|
||||||
|
"country-flag",
|
||||||
|
"subdivision-flag",
|
||||||
|
):
|
||||||
|
filename = f"third_party/region-flags/waved-svg/emoji_u{filename_hex}.svg"
|
||||||
|
|
||||||
|
with (font_dir / filename).open() as f:
|
||||||
|
lot: Animation = import_svg(f)
|
||||||
|
float_strip(lot)
|
||||||
|
lot.tgs_sanitize()
|
||||||
|
|
||||||
|
output = io.BytesIO()
|
||||||
|
export_tgs(lot, output)
|
||||||
|
|
||||||
|
validator = TgsValidator()
|
||||||
|
validator(lot)
|
||||||
|
validator.check_size(len(output.getvalue()))
|
||||||
|
errors = [err for err in validator.errors if err.severity != Severity.Note]
|
||||||
|
if errors or ("region-flags" in filename and len(output.getvalue()) > 32768):
|
||||||
|
lot.scale(100, 100)
|
||||||
|
|
||||||
|
png_out = io.BytesIO()
|
||||||
|
export_png(lot, png_out)
|
||||||
|
img = Image.open(png_out)
|
||||||
|
output = io.BytesIO()
|
||||||
|
output.name = "image.webp"
|
||||||
|
img.save(output, "webp")
|
||||||
|
|
||||||
|
media_type: EmojiType = "webp"
|
||||||
|
else:
|
||||||
|
media_type: EmojiType = "tgs"
|
||||||
|
path = media_dir / f"{filename_hex}.{media_type}"
|
||||||
|
with path.open("wb") as f:
|
||||||
|
f.write(output.getvalue())
|
||||||
|
print(
|
||||||
|
"Converted", filename, "->", path.name, "//" if errors else "", "\n".join(map(str, errors))
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hex": hex,
|
||||||
|
"emoji": unified_to_unicode(tone["unified"]),
|
||||||
|
"type": media_type,
|
||||||
|
"filename": path.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def load_emoji_data() -> EmojiData:
|
||||||
|
cache_path = media_dir / "conversion-cache.json"
|
||||||
|
try:
|
||||||
|
with cache_path.open() as f:
|
||||||
|
return json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
async with aiohttp.ClientSession() as sess, sess.get(EMOJI_DATA_URL) as resp:
|
||||||
|
raw_emoji_data = sorted(
|
||||||
|
await resp.json(content_type=None),
|
||||||
|
key=lambda dat: dat["sort_order"],
|
||||||
|
)
|
||||||
|
tgs_emoji = []
|
||||||
|
webp_emoji = []
|
||||||
|
for emoji in raw_emoji_data:
|
||||||
|
for tone in (emoji, *emoji.get("skin_variations", {}).values()):
|
||||||
|
parsed_emoji = parse_emoji_data(tone, emoji)
|
||||||
|
if parsed_emoji["type"] == "tgs":
|
||||||
|
tgs_emoji.append(parsed_emoji)
|
||||||
|
else:
|
||||||
|
webp_emoji.append(parsed_emoji)
|
||||||
|
full_data = {"tgs": tgs_emoji, "webp": webp_emoji}
|
||||||
|
with cache_path.open("w") as f:
|
||||||
|
json.dump(full_data, f, ensure_ascii=False)
|
||||||
|
return full_data
|
||||||
|
|
||||||
|
|
||||||
|
async def create_pack(conv: Conversation, name: str, pack_type: str) -> None:
|
||||||
|
await conv.send_message("/newemojipack")
|
||||||
|
resp: Message = await conv.get_response()
|
||||||
|
assert "A new set of custom emoji" in resp.raw_text
|
||||||
|
assert "Please choose the type" in resp.raw_text
|
||||||
|
await conv.send_message(pack_type)
|
||||||
|
resp = await conv.get_response()
|
||||||
|
if pack_type == "Animated emoji":
|
||||||
|
assert "When ready to upload, tell me the name of your set." in resp.raw_text
|
||||||
|
else:
|
||||||
|
assert "Now choose a name for your set." in resp.raw_text
|
||||||
|
await conv.send_message(name)
|
||||||
|
resp = await conv.get_response()
|
||||||
|
if pack_type == "Animated emoji":
|
||||||
|
assert "Now send me the first animated emoji" in resp.raw_text
|
||||||
|
else:
|
||||||
|
assert "Now send me the custom emoji" in resp.raw_text
|
||||||
|
|
||||||
|
|
||||||
|
async def publish_pack(conv: Conversation, shortname: str) -> None:
|
||||||
|
await conv.send_message("/publish")
|
||||||
|
|
||||||
|
resp: Message = await conv.get_response()
|
||||||
|
assert "You can send me a custom emoji from your emoji set" in resp.raw_text
|
||||||
|
await conv.send_message("/skip")
|
||||||
|
|
||||||
|
resp = await conv.get_response()
|
||||||
|
assert "Please provide a short name for your emoji set" in resp.raw_text
|
||||||
|
await conv.send_message(shortname)
|
||||||
|
|
||||||
|
resp = await conv.get_response()
|
||||||
|
assert "I've just published your emoji set" in resp.raw_text
|
||||||
|
|
||||||
|
|
||||||
|
async def send_emoji(
|
||||||
|
conv: Conversation, file: bytes | Path | InputMediaUploadedDocument, emoji: str
|
||||||
|
) -> None:
|
||||||
|
await conv.send_file(file)
|
||||||
|
resp: Message = await conv.get_response()
|
||||||
|
assert "Send me a replacement emoji that corresponds to your custom emoji" in resp.raw_text
|
||||||
|
await conv.send_message(emoji)
|
||||||
|
resp = await conv.get_response()
|
||||||
|
if "Sorry, too many attempts" in resp.raw_text:
|
||||||
|
print(resp.raw_text)
|
||||||
|
input("Press enter to continue")
|
||||||
|
await conv.send_message(emoji)
|
||||||
|
resp = await conv.get_response()
|
||||||
|
while "Please send an emoji that best describes your custom emoji." in resp.raw_text:
|
||||||
|
emoji = input(f"{emoji} was rejected, provide replacement: ")
|
||||||
|
await conv.send_message(emoji)
|
||||||
|
resp = await conv.get_response()
|
||||||
|
assert "Congratulations" in resp.raw_text
|
||||||
|
|
||||||
|
|
||||||
|
class CachedPack(TypedDict):
|
||||||
|
name: str
|
||||||
|
short_name: str
|
||||||
|
part: int
|
||||||
|
type: PackType
|
||||||
|
published: bool
|
||||||
|
collected: bool
|
||||||
|
emojis: list[Emoji]
|
||||||
|
|
||||||
|
|
||||||
|
class CachedData(TypedDict):
|
||||||
|
packs: list[CachedPack]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_packs_int(
|
||||||
|
emoji_list: list[Emoji], pack_type: PackType, current_part: int, total_parts: int
|
||||||
|
) -> tuple[list[CachedPack], int]:
|
||||||
|
packs = []
|
||||||
|
current_pack: CachedPack | None = None
|
||||||
|
for i, emoji in enumerate(emoji_list):
|
||||||
|
if i % 200 == 0:
|
||||||
|
current_part += 1
|
||||||
|
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||||
|
short_name = f"mxtg_unicodemoji_{random_id}"
|
||||||
|
name = f"mautrix-telegram unicodemoji ({current_part}/{total_parts})"
|
||||||
|
current_pack = {
|
||||||
|
"type": pack_type,
|
||||||
|
"short_name": short_name,
|
||||||
|
"part": current_part,
|
||||||
|
"name": name,
|
||||||
|
"published": False,
|
||||||
|
"collected": False,
|
||||||
|
"emojis": [],
|
||||||
|
}
|
||||||
|
packs.append(current_pack)
|
||||||
|
current_pack["emojis"].append(emoji)
|
||||||
|
return packs, current_part
|
||||||
|
|
||||||
|
|
||||||
|
def split_packs(emoji_data: EmojiData) -> list[CachedPack]:
|
||||||
|
total_parts = math.ceil(len(emoji_data["tgs"]) / 200) + math.ceil(
|
||||||
|
len(emoji_data["webp"]) / 200
|
||||||
|
)
|
||||||
|
current_part = 0
|
||||||
|
animated_packs, current_part = _split_packs_int(
|
||||||
|
emoji_data["tgs"], "Animated emoji", current_part, total_parts
|
||||||
|
)
|
||||||
|
static_packs, current_part = _split_packs_int(
|
||||||
|
emoji_data["webp"], "Static emoji", current_part, total_parts
|
||||||
|
)
|
||||||
|
return animated_packs + static_packs
|
||||||
|
|
||||||
|
|
||||||
|
async def create_and_fill_pack(
|
||||||
|
client: TelegramClient, conv: Conversation, pack: CachedPack
|
||||||
|
) -> None:
|
||||||
|
if pack["short_name"] == "mxtg_unicodemoji_xvzs6743":
|
||||||
|
print("Continuing pack", pack["name"])
|
||||||
|
else:
|
||||||
|
print("Creating pack", pack["name"])
|
||||||
|
await create_pack(conv, pack["name"], pack["type"])
|
||||||
|
total = len(pack["emojis"])
|
||||||
|
for i, emoji in enumerate(pack["emojis"]):
|
||||||
|
if pack["short_name"] == "mxtg_unicodemoji_xvzs6743" and i < 87:
|
||||||
|
continue
|
||||||
|
print(f"Adding emoji {i+1}/{total}", emoji["hex"], emoji["emoji"])
|
||||||
|
emoji_file = media_dir / emoji["filename"]
|
||||||
|
if emoji["type"] == "webp":
|
||||||
|
attrs = [
|
||||||
|
DocumentAttributeImageSize(w=100, h=100),
|
||||||
|
DocumentAttributeFilename(file_name="image.webp"),
|
||||||
|
]
|
||||||
|
with emoji_file.open("rb") as f:
|
||||||
|
file_handle = await client.upload_file(f, file_name="emoji.webp")
|
||||||
|
emoji_file = InputMediaUploadedDocument(
|
||||||
|
file_handle, mime_type="image/webp", attributes=attrs
|
||||||
|
)
|
||||||
|
await send_emoji(conv, emoji_file, emoji["emoji"])
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
print("Publishing pack", pack["short_name"])
|
||||||
|
await publish_pack(conv, pack["short_name"])
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
emoji_data = await load_emoji_data()
|
||||||
|
|
||||||
|
split_cache = media_dir / "split-cache.json"
|
||||||
|
try:
|
||||||
|
with split_cache.open() as f:
|
||||||
|
packs: list[CachedPack] = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
packs = split_packs(emoji_data)
|
||||||
|
with split_cache.open("w") as f:
|
||||||
|
json.dump(packs, f)
|
||||||
|
|
||||||
|
doc_id_file = Path(args.output)
|
||||||
|
try:
|
||||||
|
with doc_id_file.open() as f:
|
||||||
|
doc_ids = json.load(f)
|
||||||
|
except FileNotFoundError:
|
||||||
|
doc_ids = {}
|
||||||
|
|
||||||
|
client = TelegramClient(args.session, args.api_id, args.api_hash, flood_sleep_threshold=3600)
|
||||||
|
await client.start()
|
||||||
|
async with client.conversation("Stickers", max_messages=20000) as conv:
|
||||||
|
for pack in packs:
|
||||||
|
if not pack["published"]:
|
||||||
|
await create_and_fill_pack(client, conv, pack)
|
||||||
|
pack["published"] = True
|
||||||
|
with split_cache.open("w") as f:
|
||||||
|
json.dump(packs, f, ensure_ascii=False)
|
||||||
|
if not pack["collected"] or True:
|
||||||
|
print("Collecting document IDs from pack", pack["short_name"])
|
||||||
|
stickers = await client(
|
||||||
|
GetStickerSetRequest(InputStickerSetShortName(pack["short_name"]), 0)
|
||||||
|
)
|
||||||
|
doc: Document
|
||||||
|
for i, doc in enumerate(stickers.documents):
|
||||||
|
attr = next(
|
||||||
|
attr
|
||||||
|
for attr in doc.attributes
|
||||||
|
if isinstance(attr, DocumentAttributeCustomEmoji)
|
||||||
|
)
|
||||||
|
base_emoji = attr.alt.replace("\ufe0f", "")
|
||||||
|
emoji = pack["emojis"][i]["emoji"].replace("\ufe0f", "")
|
||||||
|
doc_ids[emoji] = doc.id
|
||||||
|
print(f"Mapped {emoji} (fallback: {base_emoji}) -> {doc_ids[emoji]}")
|
||||||
|
pack["collected"] = True
|
||||||
|
with split_cache.open("w") as f:
|
||||||
|
json.dump(packs, f, ensure_ascii=False)
|
||||||
|
with doc_id_file.open("w") as f:
|
||||||
|
json.dump(doc_ids, f, ensure_ascii=False)
|
||||||
|
print("Pack completed")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
with open(args.output.replace(".json", ".pickle"), "wb") as f:
|
||||||
|
pickle.dump(doc_ids, f)
|
||||||
|
print("Wrote pickle")
|
||||||
|
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -22,6 +22,7 @@ from telethon.tl.patched import Message
|
|||||||
from telethon.tl.types import (
|
from telethon.tl.types import (
|
||||||
InputMediaUploadedDocument,
|
InputMediaUploadedDocument,
|
||||||
InputMediaUploadedPhoto,
|
InputMediaUploadedPhoto,
|
||||||
|
InputReplyToMessage,
|
||||||
TypeDocumentAttribute,
|
TypeDocumentAttribute,
|
||||||
TypeInputMedia,
|
TypeInputMedia,
|
||||||
TypeInputPeer,
|
TypeInputPeer,
|
||||||
@@ -67,6 +68,10 @@ class MautrixTelegramClient(TelegramClient):
|
|||||||
entity = await self.get_input_entity(entity)
|
entity = await self.get_input_entity(entity)
|
||||||
reply_to = utils.get_message_id(reply_to)
|
reply_to = utils.get_message_id(reply_to)
|
||||||
request = SendMediaRequest(
|
request = SendMediaRequest(
|
||||||
entity, media, message=caption or "", entities=entities or [], reply_to_msg_id=reply_to
|
entity,
|
||||||
|
media,
|
||||||
|
message=caption or "",
|
||||||
|
entities=entities or [],
|
||||||
|
reply_to=InputReplyToMessage(reply_to_msg_id=reply_to) if reply_to else None,
|
||||||
)
|
)
|
||||||
return self._get_response_message(request, await self(request), entity)
|
return self._get_response_message(request, await self(request), entity)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
+405
-58
@@ -15,21 +15,34 @@
|
|||||||
# 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 __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, NamedTuple, cast
|
from typing import TYPE_CHECKING, Any, AsyncGenerator, AsyncIterable, Awaitable, NamedTuple, cast
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
|
|
||||||
from telethon.errors import AuthKeyDuplicatedError, RPCError, UnauthorizedError
|
from telethon.errors import (
|
||||||
|
AuthKeyDuplicatedError,
|
||||||
|
AuthKeyError,
|
||||||
|
AuthKeyNotFound,
|
||||||
|
RPCError,
|
||||||
|
TakeoutInitDelayError,
|
||||||
|
UnauthorizedError,
|
||||||
|
)
|
||||||
from telethon.tl.custom import Dialog
|
from telethon.tl.custom import Dialog
|
||||||
from telethon.tl.functions.account import UpdateStatusRequest
|
from telethon.tl.functions.account import UpdateStatusRequest
|
||||||
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
|
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
|
||||||
|
from telethon.tl.functions.help import GetAppConfigRequest
|
||||||
|
from telethon.tl.functions.messages import GetAvailableReactionsRequest
|
||||||
from telethon.tl.functions.updates import GetStateRequest
|
from telethon.tl.functions.updates import GetStateRequest
|
||||||
from telethon.tl.functions.users import GetUsersRequest
|
from telethon.tl.functions.users import GetUsersRequest
|
||||||
from telethon.tl.types import (
|
from telethon.tl.types import (
|
||||||
Channel,
|
|
||||||
Chat,
|
Chat,
|
||||||
ChatForbidden,
|
ChatForbidden,
|
||||||
InputUserSelf,
|
InputUserSelf,
|
||||||
|
Message,
|
||||||
|
MessageActionContactSignUp,
|
||||||
|
MessageActionHistoryClear,
|
||||||
|
MessageService,
|
||||||
NotifyPeer,
|
NotifyPeer,
|
||||||
PeerUser,
|
PeerUser,
|
||||||
TypeUpdate,
|
TypeUpdate,
|
||||||
@@ -43,18 +56,22 @@ from telethon.tl.types import (
|
|||||||
User as TLUser,
|
User as TLUser,
|
||||||
)
|
)
|
||||||
from telethon.tl.types.contacts import ContactsNotModified
|
from telethon.tl.types.contacts import ContactsNotModified
|
||||||
|
from telethon.tl.types.help import AppConfig
|
||||||
|
from telethon.tl.types.messages import AvailableReactions
|
||||||
|
|
||||||
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY
|
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY
|
||||||
from mautrix.bridge import BaseUser, async_getter_lock
|
from mautrix.bridge import BaseUser, async_getter_lock
|
||||||
from mautrix.client import Client
|
from mautrix.client import Client
|
||||||
from mautrix.errors import MatrixRequestError, MNotFound
|
from mautrix.errors import MatrixRequestError, MNotFound
|
||||||
from mautrix.types import PushActionType, PushRuleKind, PushRuleScope, RoomID, RoomTagInfo, UserID
|
from mautrix.types import PushActionType, PushRuleKind, PushRuleScope, RoomID, RoomTagInfo, UserID
|
||||||
|
from mautrix.util import background_task
|
||||||
from mautrix.util.bridge_state import BridgeState, BridgeStateEvent
|
from mautrix.util.bridge_state import BridgeState, BridgeStateEvent
|
||||||
from mautrix.util.opt_prometheus import Gauge
|
from mautrix.util.opt_prometheus import Gauge
|
||||||
|
|
||||||
from . import portal as po, puppet as pu
|
from . import portal as po, puppet as pu, util
|
||||||
from .abstract_user import AbstractUser
|
from .abstract_user import AbstractUser
|
||||||
from .db import Message as DBMessage, PgSession, User as DBUser
|
from .db import Backfill, BackfillType, Message as DBMessage, PgSession, User as DBUser
|
||||||
|
from .tgclient import MautrixTelegramClient
|
||||||
from .types import TelegramID
|
from .types import TelegramID
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -83,7 +100,18 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
|
|
||||||
_ensure_started_lock: asyncio.Lock
|
_ensure_started_lock: asyncio.Lock
|
||||||
_track_connection_task: asyncio.Task | None
|
_track_connection_task: asyncio.Task | None
|
||||||
|
_backfill_task: asyncio.Task | None
|
||||||
|
wakeup_backfill_task: asyncio.Event
|
||||||
_is_backfilling: bool
|
_is_backfilling: bool
|
||||||
|
takeout_retry_immediate: asyncio.Event
|
||||||
|
takeout_requested: bool
|
||||||
|
|
||||||
|
_available_emoji_reactions: set[str] | None
|
||||||
|
_available_emoji_reactions_hash: int | None
|
||||||
|
_available_emoji_reactions_fetched: float
|
||||||
|
_available_emoji_reactions_lock: asyncio.Lock
|
||||||
|
_app_config: dict[str, Any] | None
|
||||||
|
_app_config_hash: int
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -92,6 +120,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
tg_username: str | None = None,
|
tg_username: str | None = None,
|
||||||
tg_phone: str | None = None,
|
tg_phone: str | None = None,
|
||||||
is_bot: bool = False,
|
is_bot: bool = False,
|
||||||
|
is_premium: bool = False,
|
||||||
saved_contacts: int = 0,
|
saved_contacts: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
@@ -100,6 +129,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
tg_username=tg_username,
|
tg_username=tg_username,
|
||||||
tg_phone=tg_phone,
|
tg_phone=tg_phone,
|
||||||
is_bot=is_bot,
|
is_bot=is_bot,
|
||||||
|
is_premium=is_premium,
|
||||||
saved_contacts=saved_contacts,
|
saved_contacts=saved_contacts,
|
||||||
)
|
)
|
||||||
AbstractUser.__init__(self)
|
AbstractUser.__init__(self)
|
||||||
@@ -109,6 +139,18 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
self._is_backfilling = False
|
self._is_backfilling = False
|
||||||
self._portals_cache = None
|
self._portals_cache = None
|
||||||
|
|
||||||
|
self._backfill_task = None
|
||||||
|
self.wakeup_backfill_task = asyncio.Event()
|
||||||
|
self.takeout_retry_immediate = asyncio.Event()
|
||||||
|
self.takeout_requested = False
|
||||||
|
|
||||||
|
self._available_emoji_reactions = None
|
||||||
|
self._available_emoji_reactions_hash = None
|
||||||
|
self._available_emoji_reactions_fetched = 0
|
||||||
|
self._available_emoji_reactions_lock = asyncio.Lock()
|
||||||
|
self._app_config = None
|
||||||
|
self._app_config_hash = 0
|
||||||
|
|
||||||
(
|
(
|
||||||
self.relaybot_whitelisted,
|
self.relaybot_whitelisted,
|
||||||
self.whitelisted,
|
self.whitelisted,
|
||||||
@@ -174,17 +216,30 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
async with self._ensure_started_lock:
|
async with self._ensure_started_lock:
|
||||||
return cast(User, await super().ensure_started(even_if_no_session))
|
return cast(User, await super().ensure_started(even_if_no_session))
|
||||||
|
|
||||||
|
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError | AuthKeyNotFound) -> None:
|
||||||
|
error_code = "tg-auth-error"
|
||||||
|
if isinstance(err, AuthKeyDuplicatedError):
|
||||||
|
error_code = "tg-auth-key-duplicated"
|
||||||
|
message = None
|
||||||
|
else:
|
||||||
|
message = str(err)
|
||||||
|
self.log.warning(f"User got signed out with {err}, deleting data...")
|
||||||
|
try:
|
||||||
|
await self.log_out(
|
||||||
|
state=BridgeStateEvent.BAD_CREDENTIALS,
|
||||||
|
error=error_code,
|
||||||
|
message=message,
|
||||||
|
delete=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Error handling external logout")
|
||||||
|
|
||||||
async def start(self, delete_unless_authenticated: bool = False) -> User:
|
async def start(self, delete_unless_authenticated: bool = False) -> User:
|
||||||
try:
|
try:
|
||||||
await super().start()
|
await super().start()
|
||||||
except AuthKeyDuplicatedError:
|
except (AuthKeyDuplicatedError, AuthKeyNotFound) as e:
|
||||||
self.log.warning("Got AuthKeyDuplicatedError in start()")
|
self.log.warning(f"Got {type(e).__name__} in start()")
|
||||||
await self.push_bridge_state(
|
await self.on_signed_out(e)
|
||||||
BridgeStateEvent.BAD_CREDENTIALS, error="tg-auth-key-duplicated"
|
|
||||||
)
|
|
||||||
await self.client.disconnect()
|
|
||||||
await self.client.session.delete()
|
|
||||||
self.client = None
|
|
||||||
if not delete_unless_authenticated:
|
if not delete_unless_authenticated:
|
||||||
# The caller wants the client to be connected, so restart the connection.
|
# The caller wants the client to be connected, so restart the connection.
|
||||||
await super().start()
|
await super().start()
|
||||||
@@ -204,12 +259,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
if delete_unless_authenticated or self.tgid:
|
if delete_unless_authenticated or self.tgid:
|
||||||
self.log.error(f"Authorization error in start(): {type(e)}: {e}")
|
self.log.error(f"Authorization error in start(): {type(e)}: {e}")
|
||||||
if self.tgid:
|
if self.tgid:
|
||||||
await self.push_bridge_state(
|
await self.on_signed_out(e)
|
||||||
BridgeStateEvent.BAD_CREDENTIALS,
|
|
||||||
error="tg-auth-error",
|
|
||||||
message=str(e),
|
|
||||||
ttl=3600,
|
|
||||||
)
|
|
||||||
except RPCError as e:
|
except RPCError as e:
|
||||||
self.log.error(f"Unknown RPC error in start(): {type(e)}: {e}")
|
self.log.error(f"Unknown RPC error in start(): {type(e)}: {e}")
|
||||||
if self.tgid:
|
if self.tgid:
|
||||||
@@ -217,10 +267,10 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
else:
|
else:
|
||||||
# Authenticated, run post login
|
# Authenticated, run post login
|
||||||
self.log.debug(f"Ensuring post_login() for {self.name}")
|
self.log.debug(f"Ensuring post_login() for {self.name}")
|
||||||
asyncio.create_task(self.post_login())
|
background_task.create(self.post_login())
|
||||||
return self
|
return self
|
||||||
# Not authenticated, delete data if necessary
|
# Not authenticated, delete data if necessary
|
||||||
if delete_unless_authenticated:
|
if delete_unless_authenticated and self.client is not None:
|
||||||
self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting session...")
|
self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting session...")
|
||||||
await self.client.disconnect()
|
await self.client.disconnect()
|
||||||
await self.client.session.delete()
|
await self.client.session.delete()
|
||||||
@@ -232,6 +282,14 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
self.client and self.client._sender and self.client._sender._transport_connected()
|
self.client and self.client._sender and self.client._sender._transport_connected()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _bridge_state_info(self) -> dict[str, Any]:
|
||||||
|
if self.takeout_requested:
|
||||||
|
return {
|
||||||
|
"takeout_requested": True,
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
|
||||||
async def _track_connection(self) -> None:
|
async def _track_connection(self) -> None:
|
||||||
self.log.debug("Starting loop to track connection state")
|
self.log.debug("Starting loop to track connection state")
|
||||||
while True:
|
while True:
|
||||||
@@ -240,18 +298,21 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
self._track_metric(METRIC_CONNECTED, connected)
|
self._track_metric(METRIC_CONNECTED, connected)
|
||||||
if connected:
|
if connected:
|
||||||
await self.push_bridge_state(
|
await self.push_bridge_state(
|
||||||
|
(
|
||||||
BridgeStateEvent.BACKFILLING
|
BridgeStateEvent.BACKFILLING
|
||||||
if self._is_backfilling
|
if self._is_backfilling
|
||||||
else BridgeStateEvent.CONNECTED,
|
else BridgeStateEvent.CONNECTED
|
||||||
ttl=3600,
|
),
|
||||||
|
info=self._bridge_state_info,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await self.push_bridge_state(
|
await self.push_bridge_state(
|
||||||
BridgeStateEvent.TRANSIENT_DISCONNECT, ttl=240, error="tg-not-connected"
|
BridgeStateEvent.TRANSIENT_DISCONNECT, error="tg-not-connected"
|
||||||
)
|
)
|
||||||
|
|
||||||
async def fill_bridge_state(self, state: BridgeState) -> None:
|
async def fill_bridge_state(self, state: BridgeState) -> None:
|
||||||
await super().fill_bridge_state(state)
|
await super().fill_bridge_state(state)
|
||||||
|
if self.tgid:
|
||||||
state.remote_id = str(self.tgid)
|
state.remote_id = str(self.tgid)
|
||||||
state.remote_name = self.human_tg_id
|
state.remote_name = self.human_tg_id
|
||||||
|
|
||||||
@@ -268,7 +329,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
else:
|
else:
|
||||||
state_event = BridgeStateEvent.UNKNOWN_ERROR
|
state_event = BridgeStateEvent.UNKNOWN_ERROR
|
||||||
ttl = 240
|
ttl = 240
|
||||||
return [BridgeState(state_event=state_event, ttl=ttl)]
|
return [BridgeState(state_event=state_event, ttl=ttl, info=self._bridge_state_info)]
|
||||||
|
|
||||||
async def get_puppet(self) -> pu.Puppet | None:
|
async def get_puppet(self) -> pu.Puppet | None:
|
||||||
if not self.tgid:
|
if not self.tgid:
|
||||||
@@ -286,11 +347,16 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
if self._track_connection_task:
|
if self._track_connection_task:
|
||||||
self._track_connection_task.cancel()
|
self._track_connection_task.cancel()
|
||||||
self._track_connection_task = None
|
self._track_connection_task = None
|
||||||
|
if self._backfill_task:
|
||||||
|
self._backfill_task.cancel()
|
||||||
|
self._backfill_task = None
|
||||||
await super().stop()
|
await super().stop()
|
||||||
self._track_metric(METRIC_CONNECTED, False)
|
self._track_metric(METRIC_CONNECTED, False)
|
||||||
|
|
||||||
async def post_login(self, info: TLUser = None, first_login: bool = False) -> None:
|
async def post_login(self, info: TLUser = None, first_login: bool = False) -> None:
|
||||||
if self.config["metrics.enabled"] and not self._track_connection_task:
|
if (
|
||||||
|
self.config["metrics.enabled"] or self.config["homeserver.status_endpoint"]
|
||||||
|
) and not self._track_connection_task:
|
||||||
self._track_connection_task = asyncio.create_task(self._track_connection())
|
self._track_connection_task = asyncio.create_task(self._track_connection())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -300,6 +366,8 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
return
|
return
|
||||||
|
|
||||||
self._track_metric(METRIC_LOGGED_IN, True)
|
self._track_metric(METRIC_LOGGED_IN, True)
|
||||||
|
if not self._backfill_task or self._backfill_task.done():
|
||||||
|
self._backfill_task = asyncio.create_task(self._try_handle_backfill_requests_loop())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
puppet = await pu.Puppet.get_by_tgid(self.tgid)
|
puppet = await pu.Puppet.get_by_tgid(self.tgid)
|
||||||
@@ -309,7 +377,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception("Failed to automatically enable custom puppet")
|
self.log.exception("Failed to automatically enable custom puppet")
|
||||||
|
|
||||||
if not self.is_bot and self.config["bridge.startup_sync"]:
|
if not self.is_bot and (self.config["bridge.startup_sync"] or first_login):
|
||||||
try:
|
try:
|
||||||
self._is_backfilling = True
|
self._is_backfilling = True
|
||||||
await self.sync_dialogs()
|
await self.sync_dialogs()
|
||||||
@@ -319,6 +387,126 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
finally:
|
finally:
|
||||||
self._is_backfilling = False
|
self._is_backfilling = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _takeout_options(self) -> dict[str, bool | int]:
|
||||||
|
return {
|
||||||
|
"users": True,
|
||||||
|
"chats": self.config["bridge.backfill.normal_groups"],
|
||||||
|
"megagroups": True,
|
||||||
|
"channels": True,
|
||||||
|
"files": True,
|
||||||
|
"max_file_size": min(self.bridge.matrix.media_config.upload_size, 2000 * 1024 * 1024),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _try_handle_backfill_requests_loop(self) -> None:
|
||||||
|
if not self.config["bridge.backfill.enable"]:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await self._handle_backfill_requests_loop()
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Fatal error in backfill request loop")
|
||||||
|
|
||||||
|
async def _handle_backfill_requests_loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
req = await Backfill.get_next(self.mxid)
|
||||||
|
if not req:
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.wakeup_backfill_task.wait(), timeout=300)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
self.wakeup_backfill_task.clear()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await self._takeout_and_backfill(req)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Error in takeout backfill loop, retrying in an hour")
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
async def _check_server_notice_edit(self, message: Message) -> None:
|
||||||
|
if "Data export request" in message.message and "Accepted" in message.message:
|
||||||
|
self.log.debug(
|
||||||
|
f"Received an edit to message {message.id} that looks like the data export"
|
||||||
|
" was accepted, marking takeout as retriable"
|
||||||
|
)
|
||||||
|
self.takeout_retry_immediate.set()
|
||||||
|
|
||||||
|
async def _takeout_and_backfill(self, first_req: Backfill, first_attempt: bool = True) -> None:
|
||||||
|
self.takeout_retry_immediate.clear()
|
||||||
|
self.takeout_requested = True
|
||||||
|
try:
|
||||||
|
async with self.client.takeout(**self._takeout_options) as takeout_client:
|
||||||
|
self.takeout_requested = False
|
||||||
|
self.log.info("Acquired takeout client successfully")
|
||||||
|
await self._backfill_loop_with_client(takeout_client, first_req)
|
||||||
|
self.log.info("Backfills finished, exiting takeout")
|
||||||
|
except TakeoutInitDelayError as e:
|
||||||
|
if first_attempt:
|
||||||
|
self.log.info(
|
||||||
|
f"Takeout requested, will wait for retry request or {e.seconds} seconds"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.log.warning(
|
||||||
|
f"Got takeout init delay again after retry, waiting for {e.seconds} seconds"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.takeout_retry_immediate.wait(), timeout=e.seconds)
|
||||||
|
self.log.info("Retrying takeout")
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self.log.info("Takeout timeout expired")
|
||||||
|
await self._takeout_and_backfill(first_req, first_attempt=False)
|
||||||
|
|
||||||
|
async def _backfill_loop_with_client(
|
||||||
|
self, client: MautrixTelegramClient, first_req: Backfill
|
||||||
|
) -> None:
|
||||||
|
missed_reqs = 0
|
||||||
|
while missed_reqs < 10:
|
||||||
|
req = first_req or await Backfill.get_next(self.mxid)
|
||||||
|
first_req = None
|
||||||
|
if not req:
|
||||||
|
missed_reqs += 1
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(self.wakeup_backfill_task.wait(), timeout=30)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
self.wakeup_backfill_task.clear()
|
||||||
|
continue
|
||||||
|
missed_reqs = 0
|
||||||
|
self.log.info("Backfill request %s", req)
|
||||||
|
try:
|
||||||
|
portal = await po.Portal.get_by_tgid(
|
||||||
|
TelegramID(req.portal_tgid), tg_receiver=TelegramID(req.portal_tg_receiver)
|
||||||
|
)
|
||||||
|
await req.mark_dispatched()
|
||||||
|
if req.type == BackfillType.HISTORICAL:
|
||||||
|
await portal.backfill(self, client, req=req)
|
||||||
|
elif req.type == BackfillType.SYNC_DIALOG:
|
||||||
|
await self._backfill_sync_dialog(portal, client, req.extra_data)
|
||||||
|
await req.mark_done()
|
||||||
|
await asyncio.sleep(req.post_batch_delay)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception("Error handling backfill request for %s", req.portal_tgid)
|
||||||
|
await req.set_cooldown_timeout(1800)
|
||||||
|
|
||||||
|
async def _backfill_sync_dialog(
|
||||||
|
self, portal: po.Portal, client: MautrixTelegramClient, post_sync_args: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
if portal.mxid:
|
||||||
|
self.log.debug("Portal already exists, skipping dialog sync backfill queue item")
|
||||||
|
return
|
||||||
|
self.log.info(f"Creating portal for {portal.tgid_log} as part of backfill loop")
|
||||||
|
try:
|
||||||
|
await portal.create_matrix_room(
|
||||||
|
self,
|
||||||
|
client=client,
|
||||||
|
update_if_exists=False,
|
||||||
|
invites=[self.mxid],
|
||||||
|
from_dialog_sync=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self.log.exception(f"Error while creating {portal.tgid_log}")
|
||||||
|
else:
|
||||||
|
await self.post_sync_dialog(portal, puppet=None, was_created=True, **post_sync_args)
|
||||||
|
|
||||||
async def update(self, update: TypeUpdate) -> bool:
|
async def update(self, update: TypeUpdate) -> bool:
|
||||||
if not self.is_bot:
|
if not self.is_bot:
|
||||||
return False
|
return False
|
||||||
@@ -359,7 +547,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
await self.stop()
|
await self.stop()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def update_info(self, info: TLUser = None) -> None:
|
async def update_info(self, info: TLUser | None = None) -> None:
|
||||||
if not info:
|
if not info:
|
||||||
info = await self.get_me()
|
info = await self.get_me()
|
||||||
if not info:
|
if not info:
|
||||||
@@ -369,6 +557,9 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
if self.is_bot != info.bot:
|
if self.is_bot != info.bot:
|
||||||
self.is_bot = info.bot
|
self.is_bot = info.bot
|
||||||
changed = True
|
changed = True
|
||||||
|
if self.is_premium != info.premium:
|
||||||
|
self.is_premium = info.premium
|
||||||
|
changed = True
|
||||||
if self.tg_username != info.username:
|
if self.tg_username != info.username:
|
||||||
self.tg_username = info.username
|
self.tg_username = info.username
|
||||||
changed = True
|
changed = True
|
||||||
@@ -398,26 +589,49 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
except MatrixRequestError:
|
except MatrixRequestError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async def log_out(self) -> bool:
|
async def log_out(
|
||||||
|
self,
|
||||||
|
delete: bool = True,
|
||||||
|
do_logout: bool = True,
|
||||||
|
state: BridgeStateEvent = BridgeStateEvent.LOGGED_OUT,
|
||||||
|
error: str | None = None,
|
||||||
|
message: str | None = None,
|
||||||
|
) -> bool:
|
||||||
puppet = await pu.Puppet.get_by_tgid(self.tgid)
|
puppet = await pu.Puppet.get_by_tgid(self.tgid)
|
||||||
if puppet.is_real_user:
|
if puppet is not None and puppet.is_real_user:
|
||||||
await puppet.switch_mxid(None, None)
|
await puppet.switch_mxid(None, None)
|
||||||
try:
|
try:
|
||||||
await self.kick_from_portals()
|
await self.kick_from_portals()
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception("Failed to kick user from portals on logout")
|
self.log.exception("Failed to kick user from portals on logout")
|
||||||
await self.push_bridge_state(BridgeStateEvent.LOGGED_OUT)
|
|
||||||
if self.tgid:
|
if self.tgid:
|
||||||
try:
|
try:
|
||||||
del self.by_tgid[self.tgid]
|
del self.by_tgid[self.tgid]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
pass
|
pass
|
||||||
self.tgid = None
|
ok = False
|
||||||
|
if self.client is not None:
|
||||||
|
sess = self.client.session
|
||||||
|
# Try to send a logout request. If it succeeds, this also disconnects the client and
|
||||||
|
# deletes the session, but we do those again later just to be safe.
|
||||||
|
if do_logout:
|
||||||
ok = await self.client.log_out()
|
ok = await self.client.log_out()
|
||||||
await self.client.session.delete()
|
# Force-disconnect the client and set it to None
|
||||||
|
await self.stop()
|
||||||
|
await sess.delete()
|
||||||
|
|
||||||
|
# Drop LOGGED_OUT states if the user was already logged out previously
|
||||||
|
# and doesn't have a remote ID anymore
|
||||||
|
# TODO send a management room notice for non-manual logouts?
|
||||||
|
if self.tgid or state != BridgeStateEvent.LOGGED_OUT:
|
||||||
|
await self.push_bridge_state(state, error=error, message=message)
|
||||||
|
if delete:
|
||||||
await self.delete()
|
await self.delete()
|
||||||
self.by_mxid.pop(self.mxid, None)
|
self.by_mxid.pop(self.mxid, None)
|
||||||
await self.stop()
|
self.log.info("User deleted")
|
||||||
|
else:
|
||||||
|
await self.remove_tgid()
|
||||||
|
self.log.info("User telegram ID cleared")
|
||||||
self._track_metric(METRIC_LOGGED_IN, False)
|
self._track_metric(METRIC_LOGGED_IN, False)
|
||||||
return ok
|
return ok
|
||||||
|
|
||||||
@@ -475,19 +689,18 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
if active and tag_info is None:
|
if active and tag_info is None:
|
||||||
tag_info = RoomTagInfo(order=0.5)
|
tag_info = RoomTagInfo(order=0.5)
|
||||||
tag_info[DOUBLE_PUPPET_SOURCE_KEY] = self.bridge.name
|
tag_info[DOUBLE_PUPPET_SOURCE_KEY] = self.bridge.name
|
||||||
self.log.debug("Adding tag {tag} to {portal.mxid}/{portal.tgid}")
|
self.log.debug(f"Adding tag {tag} to {portal.mxid}/{portal.tgid}")
|
||||||
await puppet.intent.set_room_tag(portal.mxid, tag, tag_info)
|
await puppet.intent.set_room_tag(portal.mxid, tag, tag_info)
|
||||||
elif (
|
elif (
|
||||||
not active and tag_info and tag_info.get(DOUBLE_PUPPET_SOURCE_KEY) == self.bridge.name
|
not active and tag_info and tag_info.get(DOUBLE_PUPPET_SOURCE_KEY) == self.bridge.name
|
||||||
):
|
):
|
||||||
self.log.debug("Removing tag {tag} from {portal.mxid}/{portal.tgid}")
|
self.log.debug(f"Removing tag {tag} from {portal.mxid}/{portal.tgid}")
|
||||||
await puppet.intent.remove_room_tag(portal.mxid, tag)
|
await puppet.intent.remove_room_tag(portal.mxid, tag)
|
||||||
|
|
||||||
async def _mute_room(self, puppet: pu.Puppet, portal: po.Portal, mute_until: datetime) -> None:
|
async def _mute_room(self, puppet: pu.Puppet, portal: po.Portal, mute_until: float) -> None:
|
||||||
if not self.config["bridge.mute_bridging"] or not portal or not portal.mxid:
|
if not self.config["bridge.mute_bridging"] or not portal or not portal.mxid:
|
||||||
return
|
return
|
||||||
now = datetime.utcnow().replace(tzinfo=timezone.utc)
|
if mute_until is not None and mute_until > time.time():
|
||||||
if mute_until is not None and mute_until > now:
|
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
f"Muting {portal.mxid}/{portal.tgid} (muted until {mute_until} on Telegram)"
|
f"Muting {portal.mxid}/{portal.tgid} (muted until {mute_until} on Telegram)"
|
||||||
)
|
)
|
||||||
@@ -543,15 +756,46 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
portal = await po.Portal.get_by_entity(
|
portal = await po.Portal.get_by_entity(
|
||||||
update.peer.peer, tg_receiver=self.tgid, create=False
|
update.peer.peer, tg_receiver=self.tgid, create=False
|
||||||
)
|
)
|
||||||
await self._mute_room(puppet, portal, update.notify_settings.mute_until)
|
await self._mute_room(puppet, portal, update.notify_settings.mute_until.timestamp())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dialog_to_sync_args(dialog: Dialog) -> dict:
|
||||||
|
return {
|
||||||
|
"last_message_ts": (
|
||||||
|
cast(datetime, dialog.date).timestamp() if dialog.date else time.time()
|
||||||
|
),
|
||||||
|
"unread_count": dialog.unread_count,
|
||||||
|
"max_read_id": dialog.dialog.read_inbox_max_id,
|
||||||
|
"mute_until": (
|
||||||
|
dialog.dialog.notify_settings.mute_until.timestamp()
|
||||||
|
if dialog.dialog.notify_settings.mute_until
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"pinned": dialog.pinned,
|
||||||
|
"archived": dialog.archived,
|
||||||
|
}
|
||||||
|
|
||||||
async def _sync_dialog(
|
async def _sync_dialog(
|
||||||
self, portal: po.Portal, dialog: Dialog, should_create: bool, puppet: pu.Puppet | None
|
self, portal: po.Portal, dialog: Dialog, should_create: bool, puppet: pu.Puppet | None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
if (
|
||||||
|
not portal.mxid
|
||||||
|
and isinstance(dialog.message, MessageService)
|
||||||
|
and isinstance(
|
||||||
|
dialog.message.action, (MessageActionContactSignUp, MessageActionHistoryClear)
|
||||||
|
)
|
||||||
|
):
|
||||||
|
self.log.debug(
|
||||||
|
f"Not syncing {portal.tgid_log} "
|
||||||
|
f"(last message is a {type(dialog.message.action).__name__})"
|
||||||
|
)
|
||||||
|
return
|
||||||
was_created = False
|
was_created = False
|
||||||
|
post_sync_args = self.dialog_to_sync_args(dialog)
|
||||||
if portal.mxid:
|
if portal.mxid:
|
||||||
|
self.log.debug(f"Backfilling and updating {portal.tgid_log} (dialog sync)")
|
||||||
try:
|
try:
|
||||||
await portal.backfill(self, last_id=dialog.message.id)
|
await portal.forward_backfill(self, initial=False, last_tgid=dialog.message.id)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception(f"Error while backfilling {portal.tgid_log}")
|
self.log.exception(f"Error while backfilling {portal.tgid_log}")
|
||||||
try:
|
try:
|
||||||
@@ -559,31 +803,79 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception(f"Error while updating {portal.tgid_log}")
|
self.log.exception(f"Error while updating {portal.tgid_log}")
|
||||||
elif should_create:
|
elif should_create:
|
||||||
|
self.log.debug(f"Creating portal for {portal.tgid_log} immediately (dialog sync)")
|
||||||
try:
|
try:
|
||||||
await portal.create_matrix_room(self, dialog.entity, invites=[self.mxid])
|
await portal.create_matrix_room(
|
||||||
|
self, dialog.entity, invites=[self.mxid], from_dialog_sync=True
|
||||||
|
)
|
||||||
was_created = True
|
was_created = True
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception(f"Error while creating {portal.tgid_log}")
|
self.log.exception(f"Error while creating {portal.tgid_log}")
|
||||||
|
elif self.config["bridge.sync_deferred_create_all"]:
|
||||||
|
self.log.debug(f"Enqueuing deferred dialog sync for {portal.tgid_log}")
|
||||||
|
await portal.enqueue_backfill(
|
||||||
|
self,
|
||||||
|
priority=40,
|
||||||
|
type=BackfillType.SYNC_DIALOG,
|
||||||
|
extra_data=post_sync_args,
|
||||||
|
)
|
||||||
if portal.mxid and puppet and puppet.is_real_user:
|
if portal.mxid and puppet and puppet.is_real_user:
|
||||||
|
await self.post_sync_dialog(
|
||||||
|
portal=portal,
|
||||||
|
puppet=puppet,
|
||||||
|
was_created=was_created,
|
||||||
|
**post_sync_args,
|
||||||
|
)
|
||||||
|
self.log.debug(f"_sync_dialog finished for {portal.tgid_log}")
|
||||||
|
|
||||||
|
async def post_sync_dialog(
|
||||||
|
self,
|
||||||
|
portal: po.Portal,
|
||||||
|
puppet: pu.Puppet | None,
|
||||||
|
was_created: bool,
|
||||||
|
max_read_id: int,
|
||||||
|
last_message_ts: float,
|
||||||
|
unread_count: int,
|
||||||
|
mute_until: float,
|
||||||
|
pinned: bool,
|
||||||
|
archived: bool,
|
||||||
|
) -> None:
|
||||||
|
if puppet is None:
|
||||||
|
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
|
||||||
|
if not puppet or not puppet.is_real_user:
|
||||||
|
return
|
||||||
|
self.log.debug(
|
||||||
|
f"Running dialog post-sync for {portal.tgid_log} with args "
|
||||||
|
f"{was_created=}, {max_read_id=}, {last_message_ts=}, {unread_count=}, "
|
||||||
|
f"{mute_until=}, {pinned=}, {archived=}"
|
||||||
|
)
|
||||||
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
|
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
|
||||||
if dialog.unread_count == 0:
|
unread_threshold_hours = self.config["bridge.backfill.unread_hours_threshold"]
|
||||||
|
force_read = (
|
||||||
|
was_created
|
||||||
|
and unread_threshold_hours >= 0
|
||||||
|
and last_message_ts + (unread_threshold_hours * 60 * 60) < time.time()
|
||||||
|
)
|
||||||
|
if unread_count == 0 or force_read:
|
||||||
# This is usually more reliable than finding a specific message
|
# This is usually more reliable than finding a specific message
|
||||||
# e.g. if the last read message is a service message that isn't in the message db
|
# e.g. if the last read message is a service message that isn't in the message db
|
||||||
last_read = await DBMessage.find_last(portal.mxid, tg_space)
|
last_read = await DBMessage.find_last(portal.mxid, tg_space)
|
||||||
else:
|
if force_read:
|
||||||
last_read = await DBMessage.get_one_by_tgid(
|
self.log.debug(
|
||||||
portal.tgid, tg_space, dialog.dialog.read_inbox_max_id
|
f"Marking {portal.tgid_log} as read because the last message is from "
|
||||||
|
f"{last_message_ts} (unread threshold is {unread_threshold_hours} hours)"
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
last_read = await DBMessage.get_one_by_tgid(portal.tgid, tg_space, max_read_id)
|
||||||
|
try:
|
||||||
if last_read:
|
if last_read:
|
||||||
await puppet.intent.mark_read(last_read.mx_room, last_read.mxid)
|
await puppet.intent.mark_read(last_read.mx_room, last_read.mxid)
|
||||||
if was_created or not self.config["bridge.tag_only_on_create"]:
|
if was_created or not self.config["bridge.tag_only_on_create"]:
|
||||||
await self._mute_room(puppet, portal, dialog.dialog.notify_settings.mute_until)
|
await self._mute_room(puppet, portal, mute_until)
|
||||||
await self._tag_room(
|
await self._tag_room(puppet, portal, self.config["bridge.pinned_tag"], pinned)
|
||||||
puppet, portal, self.config["bridge.pinned_tag"], dialog.pinned
|
await self._tag_room(puppet, portal, self.config["bridge.archive_tag"], archived)
|
||||||
)
|
except Exception:
|
||||||
await self._tag_room(
|
self.log.exception(f"Error updating read status and tags for {portal.tgid_log}")
|
||||||
puppet, portal, self.config["bridge.archive_tag"], dialog.archived
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_cached_portals(self) -> dict[tuple[TelegramID, TelegramID], po.Portal]:
|
async def get_cached_portals(self) -> dict[tuple[TelegramID, TelegramID], po.Portal]:
|
||||||
if self._portals_cache is None:
|
if self._portals_cache is None:
|
||||||
@@ -600,9 +892,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
update_limit = self.config["bridge.sync_update_limit"] or None
|
update_limit = self.config["bridge.sync_update_limit"] or None
|
||||||
create_limit = self.config["bridge.sync_create_limit"]
|
create_limit = self.config["bridge.sync_create_limit"]
|
||||||
index = 0
|
index = 0
|
||||||
self.log.debug(
|
self.log.debug(f"Syncing dialogs ({update_limit=}, {create_limit=})")
|
||||||
f"Syncing dialogs (update_limit={update_limit}, create_limit={create_limit})"
|
|
||||||
)
|
|
||||||
await self.push_bridge_state(BridgeStateEvent.BACKFILLING)
|
await self.push_bridge_state(BridgeStateEvent.BACKFILLING)
|
||||||
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
|
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
|
||||||
dialog: Dialog
|
dialog: Dialog
|
||||||
@@ -623,11 +913,12 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
continue
|
continue
|
||||||
portal = await po.Portal.get_by_entity(entity, tg_receiver=self.tgid)
|
portal = await po.Portal.get_by_entity(entity, tg_receiver=self.tgid)
|
||||||
new_portal_cache[portal.tgid_full] = portal
|
new_portal_cache[portal.tgid_full] = portal
|
||||||
|
should_create = not create_limit or index < create_limit
|
||||||
coro = self._sync_dialog(
|
coro = self._sync_dialog(
|
||||||
portal=portal,
|
portal=portal,
|
||||||
dialog=dialog,
|
dialog=dialog,
|
||||||
puppet=puppet,
|
puppet=puppet,
|
||||||
should_create=not create_limit or index < create_limit,
|
should_create=should_create,
|
||||||
)
|
)
|
||||||
creators.append(asyncio.create_task(coro))
|
creators.append(asyncio.create_task(coro))
|
||||||
index += 1
|
index += 1
|
||||||
@@ -686,8 +977,64 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
await puppet.update_info(self, user)
|
await puppet.update_info(self, user)
|
||||||
contacts[user.id] = puppet.contact_info
|
contacts[user.id] = puppet.contact_info
|
||||||
await self.set_contacts(contacts.keys())
|
await self.set_contacts(contacts.keys())
|
||||||
|
self.log.debug("Contact syncing complete")
|
||||||
return contacts
|
return contacts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _available_reactions_up_to_date(self) -> bool:
|
||||||
|
return (
|
||||||
|
bool(self._available_emoji_reactions)
|
||||||
|
and self._available_emoji_reactions_fetched + 12 * 60 * 60 > time.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_available_reactions(self) -> set[str]:
|
||||||
|
if self._available_reactions_up_to_date:
|
||||||
|
return self._available_emoji_reactions
|
||||||
|
async with self._available_emoji_reactions_lock:
|
||||||
|
if self._available_reactions_up_to_date:
|
||||||
|
return self._available_emoji_reactions
|
||||||
|
self.log.debug("Fetching available emoji reactions")
|
||||||
|
available_reactions = await self.client(
|
||||||
|
GetAvailableReactionsRequest(hash=self._available_emoji_reactions_hash or 0)
|
||||||
|
)
|
||||||
|
if isinstance(available_reactions, AvailableReactions):
|
||||||
|
self._available_emoji_reactions = {
|
||||||
|
react.reaction
|
||||||
|
for react in available_reactions.reactions
|
||||||
|
if not react.inactive and (self.is_premium or not react.premium)
|
||||||
|
}
|
||||||
|
self._available_emoji_reactions_hash = available_reactions.hash
|
||||||
|
self._available_emoji_reactions_fetched = time.monotonic()
|
||||||
|
self.log.debug(
|
||||||
|
"Got available emoji reactions: %s", self._available_emoji_reactions
|
||||||
|
)
|
||||||
|
elif self._available_emoji_reactions is None:
|
||||||
|
self.log.warning(
|
||||||
|
f"Got {available_reactions} in response to available reactions request"
|
||||||
|
" even though nothing is cached"
|
||||||
|
)
|
||||||
|
return self._available_emoji_reactions
|
||||||
|
|
||||||
|
def tl_to_json(self) -> Any:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def get_app_config(self) -> dict[str, Any]:
|
||||||
|
if not self._app_config:
|
||||||
|
cfg: AppConfig = await self.client(GetAppConfigRequest(hash=self._app_config_hash))
|
||||||
|
self._app_config = util.parse_tl_json(cfg.config)
|
||||||
|
self._app_config_hash = cfg.hash
|
||||||
|
return self._app_config
|
||||||
|
|
||||||
|
async def get_max_reactions(self, is_premium: bool | None = None) -> int:
|
||||||
|
if is_premium is None:
|
||||||
|
is_premium = self.is_premium
|
||||||
|
cfg = await self.get_app_config()
|
||||||
|
return (
|
||||||
|
cfg.get("reactions_user_max_premium", 3)
|
||||||
|
if is_premium
|
||||||
|
else cfg.get("reactions_user_max_default", 1)
|
||||||
|
)
|
||||||
|
|
||||||
# endregion
|
# endregion
|
||||||
# region Class instance lookup
|
# region Class instance lookup
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
from .color_log import ColorFormatter
|
from .color_log import ColorFormatter
|
||||||
from .file_transfer import convert_image, transfer_file_to_matrix
|
from .file_transfer import (
|
||||||
|
UnicodeCustomEmoji,
|
||||||
|
convert_image,
|
||||||
|
transfer_custom_emojis_to_matrix,
|
||||||
|
transfer_file_to_matrix,
|
||||||
|
transfer_thumbnail_to_matrix,
|
||||||
|
unicode_custom_emoji_map,
|
||||||
|
)
|
||||||
from .parallel_file_transfer import parallel_transfer_to_telegram
|
from .parallel_file_transfer import parallel_transfer_to_telegram
|
||||||
from .recursive_dict import recursive_del, recursive_get, recursive_set
|
from .recursive_dict import recursive_del, recursive_get, recursive_set
|
||||||
|
from .tl_json import parse_tl_json
|
||||||
|
|||||||
@@ -15,12 +15,13 @@
|
|||||||
# 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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional, Union
|
from typing import NamedTuple, Optional, Union
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from sqlite3 import IntegrityError
|
from sqlite3 import IntegrityError
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import tempfile
|
import pickle
|
||||||
|
import pkgutil
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from asyncpg import UniqueViolationError
|
from asyncpg import UniqueViolationError
|
||||||
@@ -42,9 +43,9 @@ from telethon.tl.types import (
|
|||||||
PhotoSize,
|
PhotoSize,
|
||||||
TypePhotoSize,
|
TypePhotoSize,
|
||||||
)
|
)
|
||||||
import magic
|
|
||||||
|
|
||||||
from mautrix.appservice import IntentAPI
|
from mautrix.appservice import IntentAPI
|
||||||
|
from mautrix.util import ffmpeg, magic, variation_selector
|
||||||
|
|
||||||
from .. import abstract_user as au
|
from .. import abstract_user as au
|
||||||
from ..db import TelegramFile as DBTelegramFile
|
from ..db import TelegramFile as DBTelegramFile
|
||||||
@@ -59,11 +60,6 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
Image = None
|
Image = None
|
||||||
|
|
||||||
try:
|
|
||||||
from moviepy.editor import VideoFileClip
|
|
||||||
except ImportError:
|
|
||||||
VideoFileClip = None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from mautrix.crypto.attachments import encrypt_attachment
|
from mautrix.crypto.attachments import encrypt_attachment
|
||||||
except ImportError:
|
except ImportError:
|
||||||
@@ -101,29 +97,16 @@ def convert_image(
|
|||||||
return source_mime, file, None, None
|
return source_mime, file, None, None
|
||||||
|
|
||||||
|
|
||||||
def _read_video_thumbnail(
|
async def _read_video_thumbnail(data: bytes, mime_type: str) -> tuple[bytes, int, int]:
|
||||||
data: bytes,
|
first_frame = await ffmpeg.convert_bytes(
|
||||||
video_ext: str = "mp4",
|
data,
|
||||||
frame_ext: str = "png",
|
output_extension=".png",
|
||||||
max_size: tuple[int, int] = (1024, 720),
|
output_args=("-update", "1", "-frames:v", "1"),
|
||||||
) -> tuple[bytes, int, int]:
|
input_mime=mime_type,
|
||||||
with tempfile.NamedTemporaryFile(prefix="mxtg_video_", suffix=f".{video_ext}") as file:
|
logger=log,
|
||||||
# We don't have any way to read the video from memory, so save it to disk.
|
)
|
||||||
file.write(data)
|
width, height = Image.open(BytesIO(first_frame)).size
|
||||||
|
return first_frame, width, height
|
||||||
# Read temp file and get frame
|
|
||||||
frame = VideoFileClip(file.name).get_frame(0)
|
|
||||||
|
|
||||||
# Convert to png and save to BytesIO
|
|
||||||
image = Image.fromarray(frame).convert("RGBA")
|
|
||||||
|
|
||||||
thumbnail_file = BytesIO()
|
|
||||||
if max_size:
|
|
||||||
image.thumbnail(max_size, Image.ANTIALIAS)
|
|
||||||
image.save(thumbnail_file, frame_ext)
|
|
||||||
|
|
||||||
w, h = image.size
|
|
||||||
return thumbnail_file.getvalue(), w, h
|
|
||||||
|
|
||||||
|
|
||||||
def _location_to_id(location: TypeLocation) -> str:
|
def _location_to_id(location: TypeLocation) -> str:
|
||||||
@@ -149,7 +132,7 @@ async def transfer_thumbnail_to_matrix(
|
|||||||
height: int | None = None,
|
height: int | None = None,
|
||||||
async_upload: bool = False,
|
async_upload: bool = False,
|
||||||
) -> DBTelegramFile | None:
|
) -> DBTelegramFile | None:
|
||||||
if not Image or not VideoFileClip:
|
if not Image or not ffmpeg.ffmpeg_path:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
loc_id = _location_to_id(thumbnail_loc)
|
loc_id = _location_to_id(thumbnail_loc)
|
||||||
@@ -168,16 +151,18 @@ async def transfer_thumbnail_to_matrix(
|
|||||||
video_ext = sane_mimetypes.guess_extension(mime_type)
|
video_ext = sane_mimetypes.guess_extension(mime_type)
|
||||||
if custom_data:
|
if custom_data:
|
||||||
file = custom_data
|
file = custom_data
|
||||||
elif VideoFileClip and video_ext and video:
|
elif video_ext and video:
|
||||||
|
log.debug(f"Generating thumbnail for video {loc_id} with ffmpeg")
|
||||||
try:
|
try:
|
||||||
file, width, height = _read_video_thumbnail(video, video_ext, frame_ext="png")
|
file, width, height = await _read_video_thumbnail(video, mime_type=mime_type)
|
||||||
except OSError:
|
except Exception:
|
||||||
|
log.warning(f"Failed to generate thumbnail for {loc_id}", exc_info=True)
|
||||||
return None
|
return None
|
||||||
mime_type = "image/png"
|
mime_type = "image/png"
|
||||||
else:
|
else:
|
||||||
file = await client.download_file(thumbnail_loc)
|
file = await client.download_file(thumbnail_loc)
|
||||||
width, height = None, None
|
width, height = None, None
|
||||||
mime_type = magic.from_buffer(file, mime=True)
|
mime_type = magic.mimetype(file)
|
||||||
|
|
||||||
decryption_info = None
|
decryption_info = None
|
||||||
upload_mime_type = mime_type
|
upload_mime_type = mime_type
|
||||||
@@ -212,20 +197,44 @@ async def transfer_thumbnail_to_matrix(
|
|||||||
|
|
||||||
transfer_locks: dict[str, asyncio.Lock] = {}
|
transfer_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
unicode_custom_emoji_map = pickle.loads(
|
||||||
|
pkgutil.get_data("mautrix_telegram", "unicodemojipack.pickle")
|
||||||
|
)
|
||||||
|
reverse_unicode_custom_emoji_map = {
|
||||||
|
doc_id: emoji for emoji, doc_id in unicode_custom_emoji_map.items()
|
||||||
|
}
|
||||||
|
|
||||||
TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]]
|
TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]]
|
||||||
|
|
||||||
|
|
||||||
|
class UnicodeCustomEmoji(NamedTuple):
|
||||||
|
emoji: str
|
||||||
|
|
||||||
|
|
||||||
async def transfer_custom_emojis_to_matrix(
|
async def transfer_custom_emojis_to_matrix(
|
||||||
source: au.AbstractUser, emoji_ids: list[int]
|
source: au.AbstractUser, emoji_ids: list[int], client: MautrixTelegramClient | None = None
|
||||||
) -> dict[int, DBTelegramFile]:
|
) -> dict[int, DBTelegramFile | UnicodeCustomEmoji]:
|
||||||
|
if not client:
|
||||||
|
client = source.client
|
||||||
emoji_ids = set(emoji_ids)
|
emoji_ids = set(emoji_ids)
|
||||||
|
existing_unicode = {}
|
||||||
|
for emoji_id in emoji_ids:
|
||||||
|
try:
|
||||||
|
existing_unicode[emoji_id] = UnicodeCustomEmoji(
|
||||||
|
variation_selector.add(reverse_unicode_custom_emoji_map[emoji_id])
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
emoji_ids -= existing_unicode.keys()
|
||||||
|
if not emoji_ids:
|
||||||
|
return existing_unicode
|
||||||
existing = await DBTelegramFile.get_many([str(id) for id in emoji_ids])
|
existing = await DBTelegramFile.get_many([str(id) for id in emoji_ids])
|
||||||
file_map = {int(file.id): file for file in existing}
|
file_map = {int(file.id): file for file in existing} | existing_unicode
|
||||||
not_existing_ids = list(emoji_ids - file_map.keys())
|
not_existing_ids = list(emoji_ids - file_map.keys())
|
||||||
if not_existing_ids:
|
if not_existing_ids:
|
||||||
log.debug(f"Transferring custom emojis through {source.mxid}: {not_existing_ids}")
|
log.debug(f"Transferring custom emojis through {source.mxid}: {not_existing_ids}")
|
||||||
|
|
||||||
documents: list[Document] = await source.client(
|
documents: list[Document] = await client(
|
||||||
GetCustomEmojiDocumentsRequest(document_id=not_existing_ids)
|
GetCustomEmojiDocumentsRequest(document_id=not_existing_ids)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -237,7 +246,7 @@ async def transfer_custom_emojis_to_matrix(
|
|||||||
async def transfer(document: Document) -> None:
|
async def transfer(document: Document) -> None:
|
||||||
async with transfer_sema:
|
async with transfer_sema:
|
||||||
file_map[document.id] = await transfer_file_to_matrix(
|
file_map[document.id] = await transfer_file_to_matrix(
|
||||||
source.client,
|
client,
|
||||||
source.bridge.az.intent,
|
source.bridge.az.intent,
|
||||||
document,
|
document,
|
||||||
is_sticker=True,
|
is_sticker=True,
|
||||||
@@ -324,10 +333,10 @@ async def _unlocked_transfer_file_to_matrix(
|
|||||||
client, intent, loc_id, location, filename, encrypt, parallel_id
|
client, intent, loc_id, location, filename, encrypt, parallel_id
|
||||||
)
|
)
|
||||||
mime_type = location.mime_type
|
mime_type = location.mime_type
|
||||||
file = None
|
unencrypted_file = None
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
file = await client.download_file(location)
|
unencrypted_file = file = await client.download_file(location)
|
||||||
except (LocationInvalidError, FileIdInvalidError):
|
except (LocationInvalidError, FileIdInvalidError):
|
||||||
return None
|
return None
|
||||||
except (AuthBytesInvalidError, AuthKeyInvalidError, SecurityError) as e:
|
except (AuthBytesInvalidError, AuthKeyInvalidError, SecurityError) as e:
|
||||||
@@ -335,13 +344,10 @@ async def _unlocked_transfer_file_to_matrix(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
width, height = None, None
|
width, height = None, None
|
||||||
mime_type = magic.from_buffer(file, mime=True)
|
mime_type = magic.mimetype(file)
|
||||||
|
|
||||||
image_converted = False
|
image_converted = False
|
||||||
# A weird bug in alpine/magic makes it return application/octet-stream for gzips...
|
is_tgs = mime_type == "application/gzip"
|
||||||
is_tgs = mime_type == "application/gzip" or (
|
|
||||||
mime_type == "application/octet-stream" and magic.from_buffer(file).startswith("gzip")
|
|
||||||
)
|
|
||||||
if is_sticker and tgs_convert and is_tgs:
|
if is_sticker and tgs_convert and is_tgs:
|
||||||
converted_anim = await convert_tgs_to(
|
converted_anim = await convert_tgs_to(
|
||||||
file, tgs_convert["target"], **tgs_convert["args"]
|
file, tgs_convert["target"], **tgs_convert["args"]
|
||||||
@@ -378,6 +384,7 @@ async def _unlocked_transfer_file_to_matrix(
|
|||||||
width=width,
|
width=width,
|
||||||
height=height,
|
height=height,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"):
|
if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"):
|
||||||
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
|
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
|
||||||
thumbnail = thumbnail.location
|
thumbnail = thumbnail.location
|
||||||
@@ -386,13 +393,13 @@ async def _unlocked_transfer_file_to_matrix(
|
|||||||
client,
|
client,
|
||||||
intent,
|
intent,
|
||||||
thumbnail,
|
thumbnail,
|
||||||
video=file,
|
video=unencrypted_file,
|
||||||
mime_type=mime_type,
|
mime_type=mime_type,
|
||||||
encrypt=encrypt,
|
encrypt=encrypt,
|
||||||
async_upload=async_upload,
|
async_upload=async_upload,
|
||||||
)
|
)
|
||||||
except FileIdInvalidError:
|
except FileIdInvalidError:
|
||||||
log.warning(f"Failed to transfer thumbnail for {thumbnail!s}", exc_info=True)
|
log.warning(f"Failed to transfer thumbnail {thumbnail!s}", exc_info=True)
|
||||||
elif converted_anim and converted_anim.thumbnail_data:
|
elif converted_anim and converted_anim.thumbnail_data:
|
||||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(
|
db_file.thumbnail = await transfer_thumbnail_to_matrix(
|
||||||
client,
|
client,
|
||||||
@@ -406,6 +413,8 @@ async def _unlocked_transfer_file_to_matrix(
|
|||||||
height=converted_anim.height,
|
height=converted_anim.height,
|
||||||
async_upload=async_upload,
|
async_upload=async_upload,
|
||||||
)
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception(f"Failed to transfer thumbnail for {loc_id}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await db_file.insert()
|
await db_file.insert()
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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 telethon.tl.types import (
|
||||||
|
JsonArray,
|
||||||
|
JsonBool,
|
||||||
|
JsonNull,
|
||||||
|
JsonNumber,
|
||||||
|
JsonObject,
|
||||||
|
JsonObjectValue,
|
||||||
|
JsonString,
|
||||||
|
TypeJSONValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
from mautrix.types import JSON
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tl_json(val: TypeJSONValue) -> JSON:
|
||||||
|
if isinstance(val, JsonObject):
|
||||||
|
return {entry.key: parse_tl_json(entry.value) for entry in val.value}
|
||||||
|
elif isinstance(val, JsonArray):
|
||||||
|
return [parse_tl_json(item) for item in val.value]
|
||||||
|
elif isinstance(val, (JsonBool, JsonNumber, JsonString)):
|
||||||
|
return val.value
|
||||||
|
elif isinstance(val, JsonNull):
|
||||||
|
return None
|
||||||
|
raise ValueError(f"Unsupported type {type(val)} in TL JSON object")
|
||||||
@@ -35,9 +35,11 @@ from telethon.errors import (
|
|||||||
PhoneNumberInvalidError,
|
PhoneNumberInvalidError,
|
||||||
PhoneNumberUnoccupiedError,
|
PhoneNumberUnoccupiedError,
|
||||||
SessionPasswordNeededError,
|
SessionPasswordNeededError,
|
||||||
|
SessionRevokedError,
|
||||||
)
|
)
|
||||||
|
|
||||||
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
|
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
|
||||||
|
from mautrix.util import background_task
|
||||||
from mautrix.util.format_duration import format_duration
|
from mautrix.util.format_duration import format_duration
|
||||||
|
|
||||||
from ...commands.telegram.auth import enter_password
|
from ...commands.telegram.auth import enter_password
|
||||||
@@ -128,7 +130,7 @@ class AuthAPI(abc.ABC):
|
|||||||
status=200,
|
status=200,
|
||||||
message=(
|
message=(
|
||||||
"Code requested successfully. Check your SMS "
|
"Code requested successfully. Check your SMS "
|
||||||
"or Telegram client and enter the code below."
|
"or Telegram app and enter the code below."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except PhoneNumberInvalidError:
|
except PhoneNumberInvalidError:
|
||||||
@@ -199,7 +201,7 @@ class AuthAPI(abc.ABC):
|
|||||||
existing_user = await User.get_by_tgid(user_info.id)
|
existing_user = await User.get_by_tgid(user_info.id)
|
||||||
if existing_user and existing_user != user:
|
if existing_user and existing_user != user:
|
||||||
await existing_user.log_out()
|
await existing_user.log_out()
|
||||||
asyncio.create_task(user.post_login(user_info, first_login=True))
|
background_task.create(user.post_login(user_info, first_login=True))
|
||||||
if user.command_status and user.command_status["action"] == "Login":
|
if user.command_status and user.command_status["action"] == "Login":
|
||||||
user.command_status = None
|
user.command_status = None
|
||||||
|
|
||||||
@@ -287,6 +289,17 @@ class AuthAPI(abc.ABC):
|
|||||||
errcode="phone_number_unoccupied",
|
errcode="phone_number_unoccupied",
|
||||||
error="That phone number has not been registered.",
|
error="That phone number has not been registered.",
|
||||||
)
|
)
|
||||||
|
except FloodWaitError as e:
|
||||||
|
return self.get_login_response(
|
||||||
|
mxid=user.mxid,
|
||||||
|
state="code",
|
||||||
|
status=429,
|
||||||
|
errcode="flood_wait",
|
||||||
|
error=(
|
||||||
|
"You tried to enter your phone code too many times. "
|
||||||
|
f"Please wait for {format_duration(e.seconds)} before trying again."
|
||||||
|
),
|
||||||
|
)
|
||||||
except SessionPasswordNeededError:
|
except SessionPasswordNeededError:
|
||||||
if not password_in_data:
|
if not password_in_data:
|
||||||
if user.command_status and user.command_status["action"] == "Login":
|
if user.command_status and user.command_status["action"] == "Login":
|
||||||
@@ -341,12 +354,42 @@ class AuthAPI(abc.ABC):
|
|||||||
errcode="password_invalid",
|
errcode="password_invalid",
|
||||||
error="Incorrect password.",
|
error="Incorrect password.",
|
||||||
)
|
)
|
||||||
except Exception:
|
except SessionRevokedError:
|
||||||
|
return self.get_login_response(
|
||||||
|
mxid=user.mxid,
|
||||||
|
state="request",
|
||||||
|
status=401,
|
||||||
|
errcode="session_revoked",
|
||||||
|
error=(
|
||||||
|
"Please try again. Login cancelled because your other sessions were "
|
||||||
|
"terminated via the Telegram app."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except FloodWaitError as e:
|
||||||
|
return self.get_login_response(
|
||||||
|
mxid=user.mxid,
|
||||||
|
state="password",
|
||||||
|
status=429,
|
||||||
|
errcode="flood_wait",
|
||||||
|
error=(
|
||||||
|
"You tried to enter your password too many times. "
|
||||||
|
f"Please wait for {format_duration(e.seconds)} before trying again."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
self.log.exception("Error sending password")
|
self.log.exception("Error sending password")
|
||||||
|
if isinstance(e, ValueError) and "You must provide a phone and a code" in str(e):
|
||||||
|
return self.get_login_response(
|
||||||
|
mxid=user.mxid,
|
||||||
|
state="request",
|
||||||
|
status=400,
|
||||||
|
errcode="phone_code_not_entered",
|
||||||
|
error="Please request a new phone code and enter it first.",
|
||||||
|
)
|
||||||
return self.get_login_response(
|
return self.get_login_response(
|
||||||
mxid=user.mxid,
|
mxid=user.mxid,
|
||||||
state="password",
|
state="password",
|
||||||
status=500,
|
status=500,
|
||||||
errcode="unknown_error",
|
errcode="unknown_error",
|
||||||
error="Internal server error while sending password.",
|
error=f"Internal server error while sending password. {e}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,16 +17,22 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Awaitable, Callable
|
from typing import TYPE_CHECKING, Awaitable, Callable
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
from telethon.errors import SessionPasswordNeededError
|
||||||
|
from telethon.tl.custom import QRLogin
|
||||||
|
from telethon.tl.functions.messages import GetAllStickersRequest
|
||||||
from telethon.tl.types import ChannelForbidden, ChatForbidden, TypeChat, User as TLUser
|
from telethon.tl.types import ChannelForbidden, ChatForbidden, TypeChat, User as TLUser
|
||||||
from telethon.utils import get_peer_id, resolve_id
|
from telethon.utils import get_peer_id, resolve_id
|
||||||
|
|
||||||
from mautrix.appservice import AppService
|
from mautrix.appservice import AppService
|
||||||
|
from mautrix.client import Client
|
||||||
from mautrix.errors import IntentError, MatrixRequestError
|
from mautrix.errors import IntentError, MatrixRequestError
|
||||||
from mautrix.types import UserID
|
from mautrix.types import UserID
|
||||||
|
from mautrix.util import background_task
|
||||||
|
|
||||||
from ...commands.portal.util import get_initial_state, user_has_power_level
|
from ...commands.portal.util import get_initial_state, user_has_power_level
|
||||||
from ...portal import Portal
|
from ...portal import Portal
|
||||||
@@ -53,7 +59,7 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
|
|
||||||
self.app = web.Application(loop=bridge.loop, middlewares=[self.error_middleware])
|
self.app = web.Application(loop=bridge.loop, middlewares=[self.error_middleware])
|
||||||
|
|
||||||
portal_prefix = "/v1/portal/{mxid:![^/]+}"
|
portal_prefix = "/v1/portal/{mxid}"
|
||||||
self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid)
|
self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid)
|
||||||
self.app.router.add_route("GET", "/v1/portal/{tgid:-[0-9]+}", self.get_portal_by_tgid)
|
self.app.router.add_route("GET", "/v1/portal/{tgid:-[0-9]+}", self.get_portal_by_tgid)
|
||||||
self.app.router.add_route(
|
self.app.router.add_route(
|
||||||
@@ -62,7 +68,7 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
self.app.router.add_route("POST", f"{portal_prefix}/create", self.create_chat)
|
self.app.router.add_route("POST", f"{portal_prefix}/create", self.create_chat)
|
||||||
self.app.router.add_route("POST", f"{portal_prefix}/disconnect", self.disconnect_chat)
|
self.app.router.add_route("POST", f"{portal_prefix}/disconnect", self.disconnect_chat)
|
||||||
|
|
||||||
user_prefix = "/v1/user/{mxid:@[^:]*:[^/]+}"
|
user_prefix = "/v1/user/{mxid}"
|
||||||
self.app.router.add_route("GET", f"{user_prefix}", self.get_user_info)
|
self.app.router.add_route("GET", f"{user_prefix}", self.get_user_info)
|
||||||
self.app.router.add_route("GET", f"{user_prefix}/chats", self.get_chats)
|
self.app.router.add_route("GET", f"{user_prefix}/chats", self.get_chats)
|
||||||
self.app.router.add_route("GET", f"{user_prefix}/contacts", self.get_contacts)
|
self.app.router.add_route("GET", f"{user_prefix}/contacts", self.get_contacts)
|
||||||
@@ -71,7 +77,12 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
)
|
)
|
||||||
self.app.router.add_route("POST", f"{user_prefix}/pm/{{identifier}}", self.start_dm)
|
self.app.router.add_route("POST", f"{user_prefix}/pm/{{identifier}}", self.start_dm)
|
||||||
|
|
||||||
|
self.app.router.add_route("GET", f"{user_prefix}/stickersets", self.get_stickersets)
|
||||||
|
|
||||||
|
self.app.router.add_route("POST", f"{user_prefix}/retry_takeout", self.retry_takeout)
|
||||||
|
|
||||||
self.app.router.add_route("POST", f"{user_prefix}/logout", self.logout)
|
self.app.router.add_route("POST", f"{user_prefix}/logout", self.logout)
|
||||||
|
self.app.router.add_route("GET", f"{user_prefix}/login/qr", self.login_qr)
|
||||||
self.app.router.add_route("POST", f"{user_prefix}/login/bot_token", self.send_bot_token)
|
self.app.router.add_route("POST", f"{user_prefix}/login/bot_token", self.send_bot_token)
|
||||||
self.app.router.add_route("POST", f"{user_prefix}/login/request_code", self.request_code)
|
self.app.router.add_route("POST", f"{user_prefix}/login/request_code", self.request_code)
|
||||||
self.app.router.add_route("POST", f"{user_prefix}/login/send_code", self.send_code)
|
self.app.router.add_route("POST", f"{user_prefix}/login/send_code", self.send_code)
|
||||||
@@ -119,9 +130,9 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
"about": portal.about,
|
"about": portal.about,
|
||||||
"username": portal.username,
|
"username": portal.username,
|
||||||
"megagroup": portal.megagroup,
|
"megagroup": portal.megagroup,
|
||||||
"can_unbridge": (await portal.can_user_perform(user, "unbridge"))
|
"can_unbridge": (
|
||||||
if user
|
(await portal.can_user_perform(user, "unbridge")) if user else False
|
||||||
else False,
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,9 +188,11 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
if force in ("delete", "unbridge"):
|
if force in ("delete", "unbridge"):
|
||||||
delete = force == "delete"
|
delete = force == "delete"
|
||||||
await portal.cleanup_portal(
|
await portal.cleanup_portal(
|
||||||
|
(
|
||||||
"Portal deleted (moving to another room)"
|
"Portal deleted (moving to another room)"
|
||||||
if delete
|
if delete
|
||||||
else "Room unbridged (portal moving to another room)",
|
else "Room unbridged (portal moving to another room)"
|
||||||
|
),
|
||||||
puppets_only=not delete,
|
puppets_only=not delete,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -217,7 +230,7 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
portal.photo_id = ""
|
portal.photo_id = ""
|
||||||
await portal.save()
|
await portal.save()
|
||||||
|
|
||||||
asyncio.create_task(portal.update_matrix_room(user, entity, levels=levels))
|
background_task.create(portal.update_matrix_room(user, entity, levels=levels))
|
||||||
|
|
||||||
return web.Response(status=202, body="{}")
|
return web.Response(status=202, body="{}")
|
||||||
|
|
||||||
@@ -338,7 +351,7 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
self.log.exception("Failed to disconnect chat")
|
self.log.exception("Failed to disconnect chat")
|
||||||
return self.get_error_response(500, "exception", "Failed to disconnect chat")
|
return self.get_error_response(500, "exception", "Failed to disconnect chat")
|
||||||
else:
|
else:
|
||||||
asyncio.create_task(coro)
|
background_task.create(coro)
|
||||||
return web.json_response({}, status=200 if sync else 202)
|
return web.json_response({}, status=200 if sync else 202)
|
||||||
|
|
||||||
async def get_user_info(self, request: web.Request) -> web.Response:
|
async def get_user_info(self, request: web.Request) -> web.Response:
|
||||||
@@ -494,6 +507,78 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
status=201 if just_created else 200,
|
status=201 if just_created else 200,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_stickersets(self, request: web.Request) -> web.Response:
|
||||||
|
_, user, err = await self.get_user_request_info(
|
||||||
|
request, expect_logged_in=True, want_data=False
|
||||||
|
)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
result = await user.client(GetAllStickersRequest(0))
|
||||||
|
resp = []
|
||||||
|
for stickerset in result.sets:
|
||||||
|
resp.append(stickerset.short_name)
|
||||||
|
return web.json_response(resp, status=200)
|
||||||
|
|
||||||
|
async def retry_takeout(self, request: web.Request) -> web.Response:
|
||||||
|
data, user, err = await self.get_user_request_info(
|
||||||
|
request, expect_logged_in=True, want_data=False
|
||||||
|
)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
if not user.takeout_requested:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"error": "There was no takeout requested",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
user.takeout_retry_immediate.set()
|
||||||
|
return web.json_response({}, status=200)
|
||||||
|
|
||||||
|
async def login_qr(self, request: web.Request) -> web.Response:
|
||||||
|
_, user, err = await self.get_user_request_info(request, websocket=True)
|
||||||
|
if err is not None:
|
||||||
|
return err
|
||||||
|
|
||||||
|
await user.ensure_started(even_if_no_session=True)
|
||||||
|
qr_login = QRLogin(user.client, ignored_ids=[])
|
||||||
|
|
||||||
|
ws = web.WebSocketResponse(protocols=["net.maunium.telegram.login"])
|
||||||
|
await ws.prepare(request)
|
||||||
|
|
||||||
|
retries = 0
|
||||||
|
user_info = None
|
||||||
|
while retries < 4:
|
||||||
|
try:
|
||||||
|
await qr_login.recreate()
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"code": qr_login.url,
|
||||||
|
"timeout": int(
|
||||||
|
(
|
||||||
|
qr_login.expires - datetime.datetime.now(tz=datetime.timezone.utc)
|
||||||
|
).total_seconds()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user_info = await qr_login.wait()
|
||||||
|
break
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
retries += 1
|
||||||
|
except SessionPasswordNeededError:
|
||||||
|
await ws.send_json({"success": False, "error": "password-needed"})
|
||||||
|
await ws.close()
|
||||||
|
return ws
|
||||||
|
else:
|
||||||
|
await ws.send_json({"success": False, "error": "timeout"})
|
||||||
|
await ws.close()
|
||||||
|
return ws
|
||||||
|
|
||||||
|
await self.postprocess_login(user, user_info)
|
||||||
|
await ws.send_json({"success": True})
|
||||||
|
await ws.close()
|
||||||
|
return ws
|
||||||
|
|
||||||
async def send_bot_token(self, request: web.Request) -> web.Response:
|
async def send_bot_token(self, request: web.Request) -> web.Response:
|
||||||
data, user, err = await self.get_user_request_info(request)
|
data, user, err = await self.get_user_request_info(request)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
@@ -619,6 +704,15 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def check_websocket_authorization(self, request: web.Request) -> web.Response | None:
|
||||||
|
auth_parts = request.headers.get("Sec-WebSocket-Protocol").split(",")
|
||||||
|
for part in auth_parts:
|
||||||
|
if part.strip() == f"net.maunium.telegram.auth-{self.secret}":
|
||||||
|
return None
|
||||||
|
return self.get_error_response(
|
||||||
|
error="Shared secret is not valid.", errcode="shared_secret_invalid", status=401
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_data(request: web.Request) -> dict | None:
|
async def get_data(request: web.Request) -> dict | None:
|
||||||
try:
|
try:
|
||||||
@@ -639,6 +733,12 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
return None, self.get_login_response(
|
return None, self.get_login_response(
|
||||||
error="User ID not given.", errcode="mxid_empty", status=400
|
error="User ID not given.", errcode="mxid_empty", status=400
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
Client.parse_user_id(mxid)
|
||||||
|
except ValueError:
|
||||||
|
return None, self.get_login_response(
|
||||||
|
error="Invalid user ID", errcode="mxid_invalid", status=400
|
||||||
|
)
|
||||||
|
|
||||||
user = await User.get_and_start_by_mxid(mxid, even_if_no_session=True)
|
user = await User.get_and_start_by_mxid(mxid, even_if_no_session=True)
|
||||||
if require_puppeting and not user.puppet_whitelisted:
|
if require_puppeting and not user.puppet_whitelisted:
|
||||||
@@ -667,8 +767,12 @@ class ProvisioningAPI(AuthAPI):
|
|||||||
expect_logged_in: bool | None = False,
|
expect_logged_in: bool | None = False,
|
||||||
require_puppeting: bool = False,
|
require_puppeting: bool = False,
|
||||||
want_data: bool = True,
|
want_data: bool = True,
|
||||||
|
websocket: bool = False,
|
||||||
) -> tuple[dict | None, User | None, web.Response | None]:
|
) -> tuple[dict | None, User | None, web.Response | None]:
|
||||||
|
if not websocket:
|
||||||
err = self.check_authorization(request)
|
err = self.check_authorization(request)
|
||||||
|
else:
|
||||||
|
err = self.check_websocket_authorization(request)
|
||||||
if err is not None:
|
if err is not None:
|
||||||
return None, None, err
|
return None, None, err
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -2,28 +2,28 @@
|
|||||||
# Uncommented lines after the group definition insert things into that group.
|
# Uncommented lines after the group definition insert things into that group.
|
||||||
|
|
||||||
#/speedups
|
#/speedups
|
||||||
cryptg>=0.1,<0.4
|
cryptg>=0.1,<0.6
|
||||||
cchardet
|
|
||||||
aiodns
|
aiodns
|
||||||
brotli
|
brotli
|
||||||
|
|
||||||
#/qr_login
|
#/qr_login
|
||||||
pillow>=4,<10
|
pillow>=10.0.1,<12
|
||||||
qrcode>=6,<8
|
qrcode>=6,<9
|
||||||
|
|
||||||
#/hq_thumbnails
|
|
||||||
moviepy>=1,<2
|
|
||||||
|
|
||||||
#/formattednumbers
|
#/formattednumbers
|
||||||
phonenumbers>=8,<9
|
phonenumbers>=8,<10
|
||||||
|
|
||||||
#/metrics
|
#/metrics
|
||||||
prometheus_client>=0.6,<0.15
|
prometheus_client>=0.6,<0.23
|
||||||
|
|
||||||
#/e2be
|
#/e2be
|
||||||
python-olm>=3,<4
|
python-olm>=3,<4
|
||||||
pycryptodome>=3,<4
|
pycryptodome>=3,<4
|
||||||
unpaddedbase64>=1,<3
|
unpaddedbase64>=1,<3
|
||||||
|
base58>=2,<3
|
||||||
|
|
||||||
#/sqlite
|
#/sqlite
|
||||||
aiosqlite>=0.16,<0.18
|
aiosqlite>=0.16,<0.22
|
||||||
|
|
||||||
|
#/proxy
|
||||||
|
python-socks[asyncio]
|
||||||
|
|||||||
+1
-1
@@ -9,4 +9,4 @@ line_length = 99
|
|||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 99
|
line-length = 99
|
||||||
target-version = ["py38"]
|
target-version = ["py310"]
|
||||||
|
|||||||
+5
-6
@@ -1,11 +1,10 @@
|
|||||||
ruamel.yaml>=0.15.35,<0.18
|
ruamel.yaml>=0.15.35,<0.19
|
||||||
python-magic>=0.4,<0.5
|
python-magic>=0.4,<0.5
|
||||||
commonmark>=0.8,<0.10
|
commonmark>=0.8,<0.10
|
||||||
aiohttp>=3,<4
|
aiohttp>=3,<4
|
||||||
yarl>=1,<2
|
yarl>=1,<2
|
||||||
mautrix>=0.17.8,<0.18
|
mautrix>=0.21.0b5,<0.22
|
||||||
#telethon>=1.24,<1.25
|
tulir-telethon==1.99.0a6
|
||||||
tulir-telethon==1.25.0a20
|
asyncpg>=0.20,<1
|
||||||
asyncpg>=0.20,<0.27
|
|
||||||
mako>=1,<2
|
mako>=1,<2
|
||||||
setuptools
|
setuptools<82
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ setuptools.setup(
|
|||||||
|
|
||||||
install_requires=install_requires,
|
install_requires=install_requires,
|
||||||
extras_require=extras_require,
|
extras_require=extras_require,
|
||||||
python_requires="~=3.8",
|
python_requires="~=3.10",
|
||||||
|
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
@@ -60,13 +60,14 @@ setuptools.setup(
|
|||||||
"Framework :: AsyncIO",
|
"Framework :: AsyncIO",
|
||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
],
|
],
|
||||||
package_data={"mautrix_telegram": [
|
package_data={"mautrix_telegram": [
|
||||||
"web/public/*.mako", "web/public/*.png", "web/public/*.css",
|
"web/public/*.mako", "web/public/*.png", "web/public/*.css",
|
||||||
"example-config.yaml",
|
"example-config.yaml", "unicodemojipack.pickle",
|
||||||
]},
|
]},
|
||||||
data_files=[
|
data_files=[
|
||||||
(".", ["mautrix_telegram/example-config.yaml"]),
|
(".", ["mautrix_telegram/example-config.yaml"]),
|
||||||
|
|||||||
Reference in New Issue
Block a user