Compare commits

...

136 Commits

Author SHA1 Message Date
Tulir Asokan 53bf278f1e Bump version to 0.15.3 2025-07-16 11:50:47 +03:00
Tulir Asokan 35f137ccc1 Hardcode v11 for new rooms
Upcoming breaking changes in room v12 prevent safely using the default
room version and security embargoes prevent fixing them ahead of time.
2025-07-15 14:20:19 +03:00
Tulir Asokan 31846e7a98 Update changelog 2025-06-16 13:33:52 +03:00
Tulir Asokan 9ab2ee2970 Disable reply fallbacks by default 2025-06-16 13:24:17 +03:00
Tulir Asokan c7dd08ecd1 Update dependencies 2025-06-16 13:20:07 +03:00
Tulir Asokan 8fbd723bfa Enable captions by default 2025-05-07 13:40:39 +03:00
Tulir Asokan 530bd9e52e Update telethon 2025-04-19 15:32:39 +03:00
Tulir Asokan 6480e7925e Fix login QR filename 2025-03-19 20:47:08 +02:00
Tulir Asokan c70ab2a12b Update Telethon 2025-03-19 20:47:08 +02:00
Tulir Asokan 070bfd4f55 Update dependencies 2025-03-09 13:10:39 +02:00
Tulir Asokan 88c3a93526 Fix text in poll bridging 2025-03-09 13:07:29 +02:00
Tulir Asokan caefda582b Disable kicking unauthenticated users 2025-01-19 20:38:39 +02:00
Tulir Asokan e1b181ed55 Update mautrix-python to support MSC4190 2025-01-15 18:54:54 +02:00
Tulir Asokan cc6a915ef4 Update dependencies 2025-01-15 17:54:33 +02:00
Tulir Asokan de4df57278 Ignore partial quotes on sticker messages 2025-01-15 17:49:18 +02:00
Tulir Asokan 0068341185 Bump version to 0.15.2 2024-07-16 11:53:19 +03:00
Tulir Asokan efcf1535ff Update mautrix-python 2024-07-12 20:25:57 +03:00
Tulir Asokan 99f633e98d Update telethon and changelog 2024-07-09 12:15:41 +03:00
Tulir Asokan 0137bfcbf6 Update mautrix-python 2024-07-09 12:15:41 +03:00
Javier Cuevas f6cb26f7f5 Merge pull request #964 from mautrix/feature/periodic-refresh
Add periodic connection refresh
2024-05-24 10:19:43 +02:00
Javier Cuevas 6418202118 Update mautrix_telegram/abstract_user.py
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2024-05-24 09:39:20 +02:00
Javier Cuevas 4b25e855e0 Add force_refresh_interval_seconds to config.py 2024-05-24 09:36:09 +02:00
Javier Cuevas a35f6abfd1 Change default for force_refresh_interval_seconds (disabled by default) 2024-05-24 09:36:03 +02:00
Javier Cuevas 716222a671 Format to pass linting 2024-05-23 17:18:06 +02:00
Javier Cuevas 31801a436c Add periodic connection refresh 2024-05-23 17:06:04 +02:00
Tulir Asokan 8bd5a4e367 Update changelog 2024-05-03 11:47:48 +02:00
Tulir Asokan 43d17a335b Fix call end message 2024-04-08 17:47:44 +03:00
Nick Mills-Barrett 84a3fde1ca Implement bot/channel file size limit 2024-03-25 14:36:29 +00:00
Nick Mills-Barrett 05d05e671b Add config to limit size of documents from bots/channels copied to Matrix 2024-03-25 14:36:29 +00:00
Nick Mills-Barrett ab6a6654f7 Pass through is channel to msg conversion 2024-03-25 14:36:29 +00:00
Tulir Asokan dbfbf12862 Fix error handling replies in some cases 2024-03-19 12:02:58 +02:00
Tulir Asokan 6166173376 Fix message in MSS events 2024-03-14 13:08:53 +02:00
Tulir Asokan 2232d9898e Avoid logging RPCErrors twice 2024-03-14 13:07:22 +02:00
Tulir Asokan 3cf279718f Don't send notices for some errors 2024-03-14 13:05:55 +02:00
Tulir Asokan 65ec4491e2 Merge branch 'tulir/bot-reactions' 2024-03-13 15:21:33 +02:00
Tulir Asokan ce43607c56 Update dependencies 2024-03-13 15:20:41 +02:00
Nick Mills-Barrett 150bf5e338 Return if no document contained in media document event 2024-02-14 09:58:24 +00:00
Tulir Asokan 77cbbebfb2 Update Black to 2024 style and Python 3.10 target 2024-01-29 18:52:10 +02:00
Tulir Asokan 511043a720 Add support for bot-specific reaction update 2024-01-13 22:42:42 +02:00
Tulir Asokan 19a4b4374d Update dependencies and drop Python 3.9 support 2024-01-08 17:35:37 +02:00
Tulir Asokan 731d5e028a Bump version to 0.15.1 2023-12-26 17:07:43 +01:00
Tulir Asokan 5ea9e48954 Don't trust member list if source user isn't there 2023-12-26 16:57:43 +01:00
Tulir Asokan 73b26e3fbd Update Telethon 2023-12-26 16:54:18 +01:00
Tulir Asokan 48be895938 Update dependencies 2023-12-15 22:36:46 +02:00
Tulir Asokan 87909d07ec Fix potential issues with ignore_unbridged_group_chat option 2023-12-15 22:28:10 +02:00
Tulir Asokan 3609eb2b70 Update Docker image to Alpine 3.19 2023-12-08 15:39:02 +02:00
Tulir Asokan 562f646fea Bump version to 0.15.0 2023-11-26 20:15:48 +02:00
Nick Mills-Barrett ab3cf5bc5f Add missing break in connect loop 2023-11-13 18:28:23 +00:00
Nick Mills-Barrett 1b2f07dfa2 Add quick retries when connecting to Telegram (#941)
* Add quick 5 retries when connecting to Telegram

* Fix attempt initialisation

Co-authored-by: Tulir Asokan <tulir@maunium.net>

* Log only when retrying

Co-authored-by: Tulir Asokan <tulir@maunium.net>

---------

Co-authored-by: Tulir Asokan <tulir@maunium.net>
2023-11-13 18:21:39 +00:00
Tulir Asokan 2a67c96db3 Update mautrix-python 2023-11-10 22:07:56 +02:00
Tulir Asokan 3fdb789745 Update dependencies 2023-11-10 14:51:02 +02:00
Tulir Asokan e4c239e6bc Update changelog 2023-11-10 14:46:41 +02:00
Tulir Asokan 897a35be5d Add commands to add and delete contacts. Fixes #885 2023-11-10 14:42:06 +02:00
Tulir Asokan d72897dfe8 Remove support for MSC2716 2023-11-01 01:03:45 +02:00
Tulir Asokan 27723f5055 Update Telethon again 2023-10-30 12:14:54 +02:00
Tulir Asokan a84e5ebc6a Remove redundant <br>'s after block tags when converting from Telegram 2023-10-29 12:06:39 +02:00
Tulir Asokan 90a8583ad0 Include partial quote target text in Matrix event 2023-10-29 12:04:46 +02:00
Tulir Asokan bf2cef424b Add support for cross-room replies from Telegram 2023-10-29 02:12:17 +03:00
Tulir Asokan 6809ebcde9 Update Telethon 2023-10-29 02:00:10 +03:00
Tulir Asokan 6fafc533ab Catch AuthKeyNotFound in start 2023-10-22 11:43:56 +03:00
Tulir Asokan 060dd647c3 Add comment 2023-10-22 11:43:56 +03:00
Tulir Asokan 812b4ec8db Adjust kick message when user joins portal with no relaybot
Closes #875
2023-10-16 19:36:16 +03:00
Tulir Asokan 8c1ddec136 Update Telethon again 2023-10-16 18:07:47 +03:00
Tulir Asokan 08db5a687c Improve .gitignore 2023-10-16 12:58:17 +03:00
Tulir Asokan ec298b2b90 Update Telethon and fix handling disappearing media 2023-10-16 12:58:16 +03:00
Tulir Asokan 22f91d51a3 Handle weird missing sizes in stickers 2023-09-19 15:55:43 -04:00
Tulir Asokan d033042ee1 Bump version to 0.14.2 2023-09-19 12:55:37 -04:00
Tulir Asokan 2270f4fe40 Update pillow 2023-09-19 10:59:15 -04:00
Tulir Asokan 6d208b37a5 Stringify sticker pack IDs and include sticker ID 2023-09-14 17:15:17 -04:00
Tulir Asokan 55ebaef6e3 Include sticker pack reference in events 2023-09-14 14:06:19 -04:00
Tulir Asokan 215f077cf0 Make forward backfill timeout configurable 2023-08-29 21:10:37 +03:00
Tulir Asokan 4e4f409f87 Update changelog 2023-08-27 00:52:38 +03:00
Tulir Asokan 4d145f4716 Update mautrix-python 2023-08-27 00:52:21 +03:00
Tulir Asokan b833a41a88 Update Telethon 2023-08-27 00:11:05 +03:00
Tulir Asokan 768d51c4ae Add fallback message for invoices 2023-08-19 12:09:22 +03:00
Tulir Asokan f7db298fda Ignore stories and story replies properly 2023-08-19 12:08:12 +03:00
Tulir Asokan 4f2118c7ee Fix sending media 2023-08-14 20:47:40 +03:00
Tulir Asokan 4f0770b92d Update Telethon 2023-08-13 17:40:39 +03:00
Sumner Evans 1fb8a7a0a5 stickers: passthrough webm and tgs files
I got the mime type of tgs files from here:
https://github.com/tulir/Telethon/blob/main/telethon/utils.py#L54

Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-08-08 13:51:28 -06:00
Tulir Asokan f79ab283f3 Don't clear saved username based on min user object 2023-08-03 20:37:01 +03:00
Tulir Asokan 23ec691128 Handle "inactive" reactions when fetching allowed list 2023-07-21 12:33:28 +03:00
Tulir Asokan 59213ebeae Add warning log if GetAvailableReactions returns unexpected data 2023-07-06 13:26:01 +03:00
Tulir Asokan 36b2f6af2e Fix bridging reactions if server was rebooted less than 12 hours ago
Fixes #915
2023-07-06 13:24:13 +03:00
Tulir Asokan b2249f7756 Add log when message handling finishes 2023-07-06 13:24:10 +03:00
Tulir Asokan 212023d296 Don't send logout bridge state event if the user was already logged out 2023-07-03 19:21:24 +03:00
Tulir Asokan 4b03134620 Log when username changes 2023-07-03 19:20:12 +03:00
Tulir Asokan 806eea53eb Bump version to 0.14.1 2023-06-26 13:11:49 +03:00
Tulir Asokan 4ca3ee58ac Update changelog 2023-06-26 13:08:47 +03:00
Tulir Asokan 8b003f1187 Drop Python 3.8 support 2023-06-26 13:08:38 +03:00
Tulir Asokan c06a2b2473 Update Docker image to Alpine 3.18 2023-06-26 13:08:02 +03:00
Tulir Asokan f2194c6f33 Update mautrix-python 2023-06-25 13:47:01 +03:00
Tulir Asokan b5c294a558 Update dependencies 2023-06-14 16:15:56 +03:00
Tulir Asokan c6b6ec048e Add debug logs and workaround for forward backfill getting stuck 2023-06-14 16:15:56 +03:00
Nick Mills-Barrett fb461109c1 Fix socks proxy (#921)
* Replace pysocks with python-socks

* Log proxy settings on init

* Rename extra requirement group

Co-authored-by: Tulir Asokan <tulir@maunium.net>

---------

Co-authored-by: Tulir Asokan <tulir@maunium.net>
2023-06-09 15:38:32 +01:00
Tulir Asokan 0411affc88 Merge pull request #920 from exciler/support_ipv6
Add support for IPv6-only hosts
2023-06-06 11:48:13 +03:00
Andreas Palm dfe22800dd Add support for IPv6-only hosts 2023-06-05 22:53:37 +02:00
Tulir Asokan 7868b05ed3 Fix typo when reading config option. Fixes #916 2023-05-31 21:42:53 +03:00
Tulir Asokan 0474f81044 Update Telethon 2023-05-26 13:43:19 +03:00
Tulir Asokan ed471a6623 Add db wal files to gitignore 2023-05-26 13:43:14 +03:00
Tulir Asokan 4504973aff Bump version to 0.14.0 2023-05-26 12:24:43 +03:00
Tulir Asokan a5a71edede Add missing word 2023-05-17 19:04:54 +03:00
Tulir Asokan e1c800f3e6 Update mautrix-python 2023-05-16 19:47:01 +03:00
Tulir Asokan 810f86343a Fix group backfill limit copying 2023-05-08 17:56:27 +03:00
Tulir Asokan 5f7d3ac8c1 Split forward backfill limits by chat type 2023-05-08 17:46:09 +03:00
Malte E cb5c51cd27 Add portal to cache when creating chat from Matrix side (#902) 2023-05-07 18:09:20 +03:00
Stefano Pigozzi 759ccf301c Allow filtering direct chats with filter config (#892) 2023-05-07 18:03:48 +03:00
Tulir Asokan 40e4c7e251 Update changelog 2023-05-07 17:57:21 +03:00
Tulir Asokan e12f1784e2 Only handle /start in private chats 2023-05-07 17:39:25 +03:00
Tulir Asokan 6b8e265f8b Fix case of word in error response 2023-04-30 22:20:55 +03:00
Tulir Asokan de33b553be Add messages to MSS events 2023-04-26 15:46:09 +03:00
Tulir Asokan ed24a0b89f Handle flood waits in provisioning API code and password steps 2023-04-25 19:29:25 +03:00
Tulir Asokan e2697e5a17 Update dependencies 2023-04-24 18:42:19 +03:00
Tulir Asokan c4037ccf11 Add option to disable reply fallbacks 2023-04-23 22:47:28 +03:00
Sumner Evans 6c6fe134ba contact info: omit is_bridge_bot, is_bot -> is_network_bot
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-18 10:20:36 -06:00
Sumner Evans e3c45f6f27 puppet/contact info: set is_bot correctly
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-18 09:46:10 -06:00
Tulir Asokan 732258c093 Don't sync dialogs with no real messages 2023-04-18 17:22:57 +03:00
Sumner Evans 8726fa5d74 puppet: add contact info to all member events
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-14 09:36:09 -06:00
Sumner Evans da61ba96f1 db/puppet: add contact_info_set flag
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-14 09:36:06 -06:00
Tulir Asokan 815ce40989 Add option to not set room meta in encrypted rooms 2023-04-14 14:32:55 +03:00
Tulir Asokan 4ff6a62dab Update mautrix-python 2023-04-14 12:16:59 +03:00
Sumner Evans 918582c967 auth: change wording of error when user terminates all sessions
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-14 00:21:59 -06:00
Tulir Asokan 40c584b121 Add options to automatically delete/ratchet megolm sessions 2023-04-13 21:23:44 +03:00
Tulir Asokan f189dc8c88 Update mautrix-python 2023-04-13 11:25:09 +03:00
Sumner Evans b291c246f4 auth: better error when user terminates session
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-12 22:49:08 -06:00
Tulir Asokan 59ab7be283 Add fi.mau.gif flag to gifs and animated stickers 2023-03-28 12:26:17 +03:00
Tulir Asokan 60981386ec Update mautrix-python 2023-03-23 14:06:23 +02:00
Tulir Asokan 436781215f Don't explode if fetching dialog info fails 2023-03-18 12:05:42 +02:00
Tulir Asokan 9c4b24475c Add missing int casts when sending audio/video 2023-03-14 10:45:00 +02:00
Tulir Asokan ff8d1fc9ec Fix variable name. Fixes #898 2023-03-13 17:17:53 +02:00
Tulir Asokan 5f04729ce8 Preserve reaction timestamps if possible 2023-03-13 13:45:32 +02:00
Tulir Asokan 60526f981a Add another warning to double_puppet_backfill option 2023-03-13 13:39:42 +02:00
Tulir Asokan e39d4972fb Update Telethon 2023-03-13 13:39:25 +02:00
Tulir Asokan 233468b37b Sync mute status even if portal is created outside dialog sync
Closes #897
2023-03-10 13:35:26 +02:00
Tulir Asokan 6eda8bd165 Update Telethon
Fixes #896
2023-03-10 13:23:15 +02:00
Tulir Asokan 7372e7cbea Add fallback messages for calls and premium gifts 2023-03-01 14:02:17 +02:00
Tulir Asokan 1fed2201db Update Telethon to fix handling logouts and other update loop errors 2023-02-28 13:49:41 +02:00
41 changed files with 1342 additions and 430 deletions
+4 -4
View File
@@ -6,17 +6,17 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
python-version: "3.12"
- uses: isort/isort-action@master
with:
sortPaths: "./mautrix_telegram"
- uses: psf/black@stable
with:
src: "./mautrix_telegram"
version: "23.1.0"
version: "24.1.1"
- name: pre-commit
run: |
pip install pre-commit
+11 -4
View File
@@ -10,12 +10,19 @@ __pycache__
/*.egg-info
/.eggs
/config.yaml
/registration.yaml
*.yaml
!.pre-commit-config.yaml
!example-config.yaml
!/mautrix_telegram/web/provisioning/spec.yaml
!/.github/workflows/*.yaml
/start
/mautrix
/telethon
*.log*
*.db
*.db-*
/*.pickle
*.bak
/*.session
/*.session-journal
/*.json
+3 -3
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
rev: v4.5.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
@@ -8,13 +8,13 @@ repos:
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/psf/black
rev: 23.1.0
rev: 24.1.1
hooks:
- id: black
language_version: python3
files: ^mautrix_telegram/.*\.pyi?$
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
rev: 5.13.2
hooks:
- id: isort
files: ^mautrix_telegram/.*\.pyi?$
+105
View File
@@ -1,3 +1,108 @@
# 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
+8 -6
View File
@@ -1,9 +1,11 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.19
RUN apk add --no-cache \
python3 py3-pip py3-setuptools py3-wheel \
py3-pillow \
py3-aiohttp \
py3-asyncpg \
py3-aiosqlite \
py3-magic \
py3-ruamel.yaml \
py3-commonmark \
@@ -14,9 +16,9 @@ RUN apk add --no-cache \
py3-idna \
py3-rsa \
#py3-telethon \ (outdated)
# Optional for socks proxies
py3-pysocks \
py3-pyaes \
py3-aiodns \
py3-python-socks \
# cryptg
py3-cffi \
py3-qrcode \
@@ -40,13 +42,13 @@ COPY requirements.txt /opt/mautrix-telegram/requirements.txt
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
WORKDIR /opt/mautrix-telegram
RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \
&& pip3 install /cryptg-*.whl \
&& pip3 install --no-cache-dir -r requirements.txt -r optional-requirements.txt \
&& pip3 install --break-system-packages /cryptg-*.whl \
&& pip3 install --break-system-packages --no-cache-dir -r requirements.txt -r optional-requirements.txt \
&& apk del .build-deps \
&& rm -f /cryptg-*.whl
COPY . /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
&& cp mautrix_telegram/example-config.yaml . && rm -rf mautrix_telegram .git build
+1 -1
View File
@@ -1,3 +1,3 @@
pre-commit>=2.10.1,<3
isort>=5.10.1,<6
black>=23,<24
black>=24,<25
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.13.0"
__version__ = "0.15.3"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+3 -2
View File
@@ -39,6 +39,8 @@ from .abstract_user import AbstractUser # isort: skip
class TelegramBridge(Bridge):
module = "mautrix_telegram"
name = "mautrix-telegram"
beeper_service_name = "telegram"
beeper_network_name = "telegram"
command = "python -m mautrix-telegram"
description = "A Matrix-Telegram puppeting bridge."
repo_url = "https://github.com/mautrix/telegram"
@@ -50,6 +52,7 @@ class TelegramBridge(Bridge):
config: Config
bot: Bot | None
matrix: MatrixHandler
public_website: PublicBridgeWebsite | None
provisioning_api: ProvisioningAPI | None
@@ -101,8 +104,6 @@ class TelegramBridge(Bridge):
self.log.info("Finished re-sending bridge info state events")
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())
if self.bot:
self.add_shutdown_actions(self.bot.stop())
+90 -3
View File
@@ -38,7 +38,9 @@ from telethon.tl.types import (
PeerChannel,
PeerChat,
PeerUser,
PhoneCallRequested,
TypeUpdate,
UpdateBotMessageReaction,
UpdateChannel,
UpdateChannelUserTyping,
UpdateChatDefaultBannedRights,
@@ -54,6 +56,7 @@ from telethon.tl.types import (
UpdateNewChannelMessage,
UpdateNewMessage,
UpdateNotifySettings,
UpdatePhoneCall,
UpdatePinnedChannelMessages,
UpdatePinnedDialogs,
UpdatePinnedMessages,
@@ -206,6 +209,8 @@ class AbstractUser(ABC):
sysversion = self.config["telegram.device_info.system_version"]
appversion = self.config["telegram.device_info.app_version"]
connection, proxy = self._proxy_settings
if proxy:
self.log.debug(f"Using proxy setting: {proxy}")
assert isinstance(session, Session)
@@ -233,8 +238,27 @@ class AbstractUser(ABC):
loop=self.loop,
base_logger=base_logger,
update_error_callback=self._telethon_update_error_callback,
use_ipv6=self.config["telegram.connection.use_ipv6"],
)
self.client.add_event_handler(self._update_catch)
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:
@@ -301,7 +325,18 @@ class AbstractUser(ABC):
async def start(self, delete_unless_authenticated: bool = False) -> AbstractUser:
if not self.client:
await self._init_client()
await self.client.connect()
attempts = 1
while True:
try:
await self.client.connect()
except Exception:
attempts += 1
if attempts > 10:
raise
self.log.exception("Exception connecting to Telegram, retrying in 5s...")
await asyncio.sleep(5)
else:
break
self.log.debug(f"{'Bot' if self.is_relaybot else self.mxid} connected: {self.connected}")
return self
@@ -343,8 +378,12 @@ class AbstractUser(ABC):
await self.delete_message(update)
elif isinstance(update, UpdateDeleteChannelMessages):
await self.delete_channel_message(update)
elif isinstance(update, UpdatePhoneCall):
await self.update_phone_call(update)
elif isinstance(update, UpdateMessageReactions):
await self.update_reactions(update)
elif isinstance(update, UpdateBotMessageReaction):
await self.update_bot_reactions(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
await self.update_typing(update)
elif isinstance(update, UpdateUserStatus):
@@ -449,6 +488,7 @@ class AbstractUser(ABC):
return
if not portal or not portal.mxid:
# TODO This explodes on channels because the field is channel_id
self.log.debug(f"Dropping own read receipt in unknown chat ({update.peer})")
return
@@ -617,6 +657,25 @@ class AbstractUser(ABC):
return
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions)
async def update_bot_reactions(self, update: UpdateBotMessageReaction) -> None:
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
if not portal or not portal.mxid or not portal.allow_bridging:
return
await portal.handle_telegram_bot_reactions(self, update)
async def update_phone_call(self, update: UpdatePhoneCall) -> None:
self.log.debug("Phone call update %s", update)
if not isinstance(update.phone_call, PhoneCallRequested):
return
tgid = TelegramID(update.phone_call.participant_id)
if tgid == self.tgid:
tgid = update.phone_call.admin_id
portal = await po.Portal.get_by_tgid(tgid, tg_receiver=self.tgid, peer_type="user")
if not portal or not portal.mxid or not portal.allow_bridging:
return
sender = await pu.Puppet.get_by_tgid(TelegramID(update.phone_call.admin_id))
await portal.handle_telegram_direct_call(self, sender, update)
async def update_channel(self, update: UpdateChannel) -> None:
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
if not portal:
@@ -626,7 +685,11 @@ class AbstractUser(ABC):
await portal.delete_telegram_user(self.tgid, sender=None)
elif chan := getattr(update, "mau_channel", None):
if not portal.mxid:
background_task.create(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:
self.log.debug("Updating channel info with data fetched by Telethon")
await portal.update_info(self, chan)
@@ -659,7 +722,15 @@ class AbstractUser(ABC):
if not portal:
return
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
if self.is_relaybot:
@@ -683,6 +754,22 @@ class AbstractUser(ABC):
self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
return
task = self._call_portal_message_handler(update, original_update, portal, sender)
if portal.backfill_lock.locked:
self.log.debug(
f"{portal.tgid_log} is backfill locked, moving incoming message to async task"
)
background_task.create(task)
else:
await task
async def _call_portal_message_handler(
self,
update: UpdateMessageContent,
original_update: UpdateMessage,
portal: po.Portal,
sender: pu.Puppet,
) -> None:
await portal.backfill_lock.wait(f"update {update.id}")
if isinstance(update, MessageService):
+1 -1
View File
@@ -395,7 +395,7 @@ class Bot(AbstractUser):
def reply(reply_text: str) -> Awaitable[Message]:
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"]
if pcm:
await reply(pcm)
+2
View File
@@ -159,6 +159,7 @@ def command_handler(
needs_admin: bool = False,
management_only: bool = False,
name: str | None = None,
aliases: list[str] | None = None,
help_text: str = "",
help_args: str = "",
help_section: HelpSection = None,
@@ -167,6 +168,7 @@ def command_handler(
_func,
_handler_class=CommandHandler,
name=name,
aliases=aliases,
help_text=help_text,
help_args=help_args,
help_section=help_section,
@@ -39,8 +39,6 @@ async def clear_db_cache(evt: CommandEvent) -> EventID:
await evt.reply("Cleared portal cache")
elif section == "puppet":
pu.Puppet.by_tgid = {}
for puppet in pu.Puppet.by_custom_mxid.values():
puppet.stop()
pu.Puppet.by_custom_mxid = {}
await asyncio.gather(
*[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:
return await evt.reply("User not found")
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
if puppet:
puppet.stop()
await user.stop()
del u.User.by_tgid[user.tgid]
del u.User.by_mxid[user.mxid]
+3 -1
View File
@@ -56,7 +56,9 @@ async def bridge(evt: CommandEvent) -> EventID:
return await evt.reply(f"{that_this} room is already a portal room.")
if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply(f"You do not have the permissions to bridge {that_this} 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
tgid_str = evt.args[0]
@@ -65,19 +65,11 @@ async def create(evt: CommandEvent) -> EventID:
about=about,
encrypted=encrypted,
)
invites, errors = await portal.get_telegram_users_in_matrix_room(evt.sender, pre_create=True)
if len(errors) > 0:
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
await evt.reply(
f"Failed to add the following users to the chat:\n\n{error_list}\n\n"
"You can try `$cmdprefix+sp search -r <username>` to help the bridge find "
"those users."
)
await warn_missing_power(levels, evt)
try:
await portal.create_telegram_chat(evt.sender, invites=invites, supergroup=supergroup)
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
except ValueError as e:
await portal.delete()
return await evt.reply(e.args[0])
@@ -121,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))
content = MediaMessageEventContent(
body=qr_login.url,
filename="login-qr.png",
url=mxc,
msgtype=MessageType.IMAGE,
info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size),
+76 -13
View File
@@ -18,8 +18,8 @@ from __future__ import annotations
from typing import cast
import base64
import codecs
import math
import re
import shlex
from aiohttp import ClientSession, InvalidURL
from telethon.errors import (
@@ -29,10 +29,10 @@ from telethon.errors import (
InviteHashInvalidError,
InviteRequestSentError,
OptionsTooMuchError,
TakeoutInitDelayError,
UserAlreadyParticipantError,
)
from telethon.tl.functions.channels import JoinChannelRequest
from telethon.tl.functions.contacts import DeleteByPhonesRequest, ImportContactsRequest
from telethon.tl.functions.messages import (
CheckChatInviteRequest,
GetBotCallbackAnswerRequest,
@@ -42,12 +42,14 @@ from telethon.tl.functions.messages import (
from telethon.tl.patched import Message
from telethon.tl.types import (
InputMediaDice,
InputPhoneContact,
MessageMediaGame,
MessageMediaPoll,
TypeInputPeer,
TypeUpdates,
User as TLUser,
)
from telethon.tl.types.contacts import ImportedContacts
from telethon.tl.types.messages import BotCallbackAnswer
from mautrix.types import EventID, Format
@@ -161,6 +163,76 @@ async def pm(evt: CommandEvent) -> EventID:
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(
evt: CommandEvent, identifier: str, link_type: str
) -> tuple[TypeUpdates | None, EventID | None]:
@@ -440,14 +512,5 @@ async def backfill(evt: CommandEvent) -> None:
if not evt.config["bridge.backfill.normal_groups"] and portal.peer_type == "chat":
await evt.reply("Backfilling normal groups is disabled in the bridge config")
return
if portal.backfill_msc2716:
messages_per_batch = evt.config["bridge.backfill.incremental.messages_per_batch"]
batches = math.ceil(limit / messages_per_batch)
rounded = ""
if batches * messages_per_batch != limit:
rounded = f" (rounded message limit to {batches}*{messages_per_batch})"
await portal.enqueue_backfill(evt.sender, priority=0, max_batches=batches)
await evt.reply(f"Backfill queued{rounded}")
else:
output = await portal.forward_backfill(evt.sender, initial=False, override_limit=limit)
await evt.reply(output)
output = await portal.forward_backfill(evt.sender, initial=False, override_limit=limit)
await evt.reply(output)
+36 -5
View File
@@ -136,6 +136,8 @@ class Config(BaseBridgeConfig):
copy("bridge.caption_in_message")
copy("bridge.image_as_file_size")
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.federate_rooms")
copy("bridge.always_custom_emoji_reaction")
@@ -148,7 +150,16 @@ class Config(BaseBridgeConfig):
copy("bridge.animated_emoji.args.width")
copy("bridge.animated_emoji.args.height")
copy("bridge.animated_emoji.args.fps")
copy("bridge.private_chat_portal_meta")
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")
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_error_reports")
copy("bridge.incoming_bridge_error_reports")
@@ -162,12 +173,29 @@ class Config(BaseBridgeConfig):
copy("bridge.kick_on_logout")
copy("bridge.always_read_joined_telegram_notice")
copy("bridge.backfill.enable")
copy("bridge.backfill.msc2716")
copy("bridge.backfill.double_puppet_backfill")
copy("bridge.backfill.normal_groups")
copy("bridge.backfill.unread_hours_threshold")
copy("bridge.backfill.forward.initial_limit")
copy("bridge.backfill.forward.sync_limit")
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")
@@ -197,6 +225,7 @@ class Config(BaseBridgeConfig):
copy("bridge.filter.mode")
copy("bridge.filter.list")
copy("bridge.filter.users")
copy("bridge.command_prefix")
@@ -235,12 +264,14 @@ class Config(BaseBridgeConfig):
copy("telegram.catch_up")
copy("telegram.sequential_updates")
copy("telegram.exit_on_update_error")
copy("telegram.force_refresh_interval_seconds")
copy("telegram.connection.timeout")
copy("telegram.connection.retries")
copy("telegram.connection.retry_delay")
copy("telegram.connection.flood_sleep_threshold")
copy("telegram.connection.request_retries")
copy("telegram.connection.use_ipv6")
copy("telegram.device_info.device_model")
copy("telegram.device_info.system_version")
+9 -6
View File
@@ -48,6 +48,7 @@ class Puppet:
avatar_url: ContentURI | None
name_set: bool
avatar_set: bool
contact_info_set: bool
is_bot: bool | None
is_channel: bool
is_premium: bool
@@ -68,7 +69,7 @@ class Puppet:
columns: ClassVar[str] = (
"id, is_registered, displayname, displayname_source, displayname_contact, "
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
"name_set, avatar_set, is_bot, is_channel, is_premium, "
"name_set, avatar_set, contact_info_set, is_bot, is_channel, is_premium, "
"custom_mxid, access_token, next_batch, base_url"
)
@@ -108,6 +109,7 @@ class Puppet:
self.avatar_url,
self.name_set,
self.avatar_set,
self.contact_info_set,
self.is_bot,
self.is_channel,
self.is_premium,
@@ -122,8 +124,9 @@ class Puppet:
UPDATE puppet
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,
avatar_url=$11, name_set=$12, avatar_set=$13, is_bot=$14, is_channel=$15,
is_premium=$16, custom_mxid=$17, access_token=$18, next_batch=$19, base_url=$20
avatar_url=$11, name_set=$12, avatar_set=$13, contact_info_set=$14, is_bot=$15,
is_channel=$16, is_premium=$17, custom_mxid=$18, access_token=$19, next_batch=$20,
base_url=$21
WHERE id=$1
"""
await self.db.execute(q, *self._values)
@@ -133,9 +136,9 @@ class Puppet:
INSERT INTO puppet (
id, is_registered, displayname, displayname_source, displayname_contact,
displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
avatar_set, is_bot, is_channel, is_premium, 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,
$19, $20)
$19, $20, $21)
"""
await self.db.execute(q, *self._values)
+3 -3
View File
@@ -208,9 +208,9 @@ class PgSession(MemorySession):
await self._locked_process_entities(tlo)
async def _locked_process_entities(self, tlo) -> None:
rows: list[
tuple[str, int, int, str | None, str | None, str | None]
] = self._entities_to_rows(tlo)
rows: list[tuple[str, int, int, str | None, str | None, str | None]] = (
self._entities_to_rows(tlo)
)
if not rows:
return
if self.db.scheme == Scheme.POSTGRES:
+1
View File
@@ -20,4 +20,5 @@ from . import (
v15_backfill_anchor_id,
v16_backfill_type,
v17_message_find_recent,
v18_puppet_contact_info_set,
)
@@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection, Scheme
latest_version = 17
latest_version = 18
async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
@@ -113,6 +113,7 @@ async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
avatar_url TEXT,
name_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_channel BOOLEAN NOT NULL DEFAULT false,
is_premium BOOLEAN NOT NULL DEFAULT false,
@@ -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"
)
+74 -28
View File
@@ -40,7 +40,7 @@ appservice:
# The full URI to the database. SQLite and Postgres are supported.
# Format examples:
# SQLite: sqlite:///filename.db
# SQLite: sqlite:filename.db
# Postgres: postgres://username:password@hostname/dbname
database: postgres://username:password@hostname/dbname
# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
@@ -216,12 +216,16 @@ bridge:
# to resolve redirects in invite links.
invite_link_resolve: false
# 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: false
caption_in_message: true
# Maximum size of image in megabytes before sending to Telegram as a document.
image_as_file_size: 10
# Maximum number of pixels in an image before sending to Telegram as a document. Defaults to 4096x4096 = 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
# streaming from/to Matrix and using many connections for Telegram.
# Note that generating HQ thumbnails for videos is not possible with streamed transfers.
@@ -267,13 +271,41 @@ bridge:
# Default to encryption, force-enable encryption in all portals the bridge creates
# This will cause the bridge bot to be in private chats for the encryption to work properly.
default: false
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
# 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
# Require encryption, drop any unencrypted messages.
require: false
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
# You must use a client that supports requesting keys from other users to use this feature.
allow_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?
#
# Valid levels:
@@ -309,9 +341,20 @@ bridge:
# default.
messages: 100
# Whether or not to explicitly set the avatar and room name for private
# chat portal rooms. This will be implicitly enabled if encryption.default is true.
private_chat_portal_meta: false
# Disable rotating keys when a user's devices change?
# You should not enable this option unless you understand all the implications.
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
# been sent to Telegram.
delivery_receipts: false
@@ -347,20 +390,6 @@ bridge:
backfill:
# Allow backfilling at all?
enable: true
# Use MSC2716 for backfilling?
#
# This requires a server with MSC2716 support, which is currently an experimental feature in Synapse.
# It can be enabled by setting experimental_features -> msc2716_enabled to true in homeserver.yaml.
msc2716: false
# Use double puppets for backfilling?
#
# If using MSC2716, the double puppets must be in the appservice's user ID namespace
# (because the bridge can't use the double puppet access token with batch sending).
#
# Even without MSC2716, bridging old messages with correct timestamps requires the double
# puppets to be in an appservice namespace, or the server to be modified to allow
# overriding timestamps anyway.
double_puppet_backfill: false
# Whether or not to enable backfilling in normal groups.
# Normal groups have numerous technical problems in Telegram, and backfilling normal groups
# will likely cause problems if there are multiple Matrix users in the group.
@@ -370,17 +399,26 @@ bridge:
# Set to -1 to let any chat be unread.
unread_hours_threshold: 720
# Forward backfilling limits. These apply to both MSC2716 and legacy backfill.
# Forward backfilling limits.
#
# Using a negative initial limit is not recommended, as it would try to backfill everything in a single batch.
# MSC2716 and the incremental settings are meant for backfilling everything incrementally rather than at once.
forward:
forward_limits:
# Number of messages to backfill immediately after creating a portal.
initial_limit: 10
initial:
user: 50
normal_group: 100
supergroup: 10
channel: 10
# Number of messages to backfill when syncing chats.
sync_limit: 100
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 when using MSC2716.
# 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
@@ -458,7 +496,6 @@ bridge:
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
# `filter-mode` management commands.
#
# Filters do not affect direct chats.
# An empty blacklist will essentially disable the filter.
filter:
# Filter mode to use. Either "blacklist" or "whitelist".
@@ -467,6 +504,11 @@ bridge:
mode: blacklist
# The list of group/channel IDs to filter.
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.
command_prefix: "!tg"
@@ -545,6 +587,8 @@ telegram:
# Should incoming updates be handled sequentially to make sure order is preserved on Matrix?
sequential_updates: true
exit_on_update_error: false
# Interval to force refresh the connection (full reconnect). 0 disables it.
force_refresh_interval_seconds: 0
# Telethon connection options.
connection:
@@ -567,6 +611,8 @@ telegram:
# is not recommended, since some requests can always trigger a call fail (such as searching
# for messages).
request_retries: 5
# Use IPv6 for Telethon connection
use_ipv6: false
# Device info sent to Telegram.
device_info:
+1 -1
View File
@@ -1,2 +1,2 @@
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
@@ -82,14 +82,6 @@ class MatrixParser(BaseMatrixParser[TelegramMessage]):
prefix = "#" * length + " "
return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD)
async def blockquote_to_fstring(
self, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage:
msg = await self.tag_aware_parse_node(node, ctx)
children = msg.trim().split("\n")
children = [child.prepend("> ") for child in children]
return TelegramMessage.join(children, "\n")
async def color_to_fstring(self, msg: TelegramMessage, color: str) -> TelegramMessage:
return msg
+23 -4
View File
@@ -176,6 +176,21 @@ async def _convert_custom_emoji(
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(
evt: Message | SponsoredMessage,
source: au.AbstractUser,
@@ -192,10 +207,10 @@ async def telegram_to_matrix(
)
entities = override_entities or evt.entities
if entities:
await _convert_custom_emoji(source, entities, client=client)
content.format = Format.HTML
html = await _telegram_entities_to_matrix_catch(add_surrogate(content.body), entities)
content.formatted_body = del_surrogate(html)
content.formatted_body = await telegram_text_to_matrix_html(
source, content.body, entities, client=client
)
if require_html:
content.ensure_has_html()
@@ -333,7 +348,11 @@ async def _telegram_entities_to_matrix(
last_offset = relative_offset + (0 if skip_entity else entity.length)
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:
+3 -29
View File
@@ -61,21 +61,6 @@ class MatrixHandler(BaseMatrixHandler):
self._previously_typing = {}
async def check_versions(self) -> None:
await super().check_versions()
if self.config["bridge.backfill.msc2716"] and not (
support := self.versions.supports("org.matrix.msc2716")
):
self.log.fatal(
"Backfilling with MSC2716 is enabled in bridge config, but "
+ (
"batch sending is not enabled on homeserver"
if support is False
else "homeserver does not support batch sending"
)
)
sys.exit(18)
async def handle_puppet_group_invite(
self,
room_id: RoomID,
@@ -135,20 +120,8 @@ class MatrixHandler(BaseMatrixHandler):
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)
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:
await portal.create_telegram_chat(invited_by, invites=invites, supergroup=True)
await portal.create_telegram_chat(invited_by, supergroup=True)
except ValueError as e:
await portal.delete()
await portal.az.intent.send_notice(room_id, e.args[0])
@@ -186,7 +159,8 @@ class MatrixHandler(BaseMatrixHandler):
await portal.main_intent.kick_user(
room_id,
user.mxid,
"This chat does not have a bot relaying messages for unauthenticated users.",
"This chat does not have a bot on the Telegram side for relaying messages sent by"
" unauthenticated Matrix users.",
)
return
+433 -215
View File
File diff suppressed because it is too large Load Diff
+228 -20
View File
@@ -34,6 +34,8 @@ from telethon.tl.types import (
DocumentAttributeVideo,
Game,
InputPhotoFileLocation,
InputStickerSetID,
InputStickerSetShortName,
Message,
MessageEntityPre,
MessageMediaContact,
@@ -42,12 +44,16 @@ from telethon.tl.types import (
MessageMediaGame,
MessageMediaGeo,
MessageMediaGeoLive,
MessageMediaInvoice,
MessageMediaPhoto,
MessageMediaPoll,
MessageMediaStory,
MessageMediaUnsupported,
MessageMediaVenue,
MessageMediaWebPage,
MessageReplyStoryHeader,
PeerChannel,
PeerChat,
PeerUser,
Photo,
PhotoCachedSize,
@@ -58,6 +64,8 @@ from telethon.tl.types import (
Poll,
TypeDocumentAttribute,
TypePhotoSize,
UpdateShortChatMessage,
UpdateShortMessage,
WebPage,
)
from telethon.utils import decode_waveform
@@ -104,6 +112,7 @@ class DocAttrs(NamedTuple):
mime_type: str | None
is_sticker: bool
sticker_alt: str | None
sticker_pack_ref: dict | None
width: int
height: int
is_gif: bool
@@ -142,6 +151,8 @@ class TelegramMessageConverter:
MessageMediaUnsupported: self._convert_unsupported,
MessageMediaGame: self._convert_game,
MessageMediaContact: self._convert_contact,
MessageMediaStory: self._convert_story,
MessageMediaInvoice: self._convert_invoice,
}
self._allowed_media = tuple(self._media_converters.keys())
@@ -150,6 +161,7 @@ class TelegramMessageConverter:
source: au.AbstractUser,
intent: IntentAPI,
is_bot: bool,
is_channel: bool,
evt: Message,
no_reply_fallback: bool = False,
deterministic_reply_id: bool = False,
@@ -158,8 +170,13 @@ class TelegramMessageConverter:
if not client:
client = source.client
if hasattr(evt, "media") and isinstance(evt.media, self._allowed_media):
convert_media = self._media_converters[type(evt.media)]
converted = await convert_media(source=source, intent=intent, evt=evt, client=client)
if self._should_convert_full_document(evt.media, is_bot, is_channel):
convert_media = self._media_converters[type(evt.media)]
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:
converted = await self._convert_text(source, intent, is_bot, evt, client)
else:
@@ -192,6 +209,16 @@ class TelegramMessageConverter:
)
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
def _caption_to_message(converted: ConvertedMessage) -> None:
content, caption = converted.content, converted.caption
@@ -252,20 +279,56 @@ class TelegramMessageConverter:
) -> None:
if not evt.reply_to:
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 = (
evt.peer_id.channel_id
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
else source.tgid
)
if isinstance(evt, Message):
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)
if not msg or msg.mx_room != self.portal.mxid:
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
elif not isinstance(content, TextMessageEventContent) or no_fallback:
# Not a text message, just set the reply metadata and return
content.set_reply(msg.mxid)
if msg.mx_room != self.portal.mxid:
content.relates_to.in_reply_to["room_id"] = msg.mx_room
return
# Text message, try to fetch original message to generate reply fallback.
@@ -280,6 +343,8 @@ class TelegramMessageConverter:
except Exception:
self.log.exception("Failed to get event to add reply fallback")
content.set_reply(msg.mxid)
if msg.mx_room != self.portal.mxid:
content.relates_to.in_reply_to["room_id"] = msg.mx_room
@staticmethod
def _photo_size_key(photo: TypePhotoSize) -> int:
@@ -428,7 +493,104 @@ class TelegramMessageConverter:
return ConvertedMessage(
content=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(
@@ -440,6 +602,9 @@ class TelegramMessageConverter:
) -> ConvertedMessage | None:
document = evt.media.document
if not document:
return None
attrs = _parse_document_attributes(document.attributes)
if document.size > self.matrix.media_config.upload_size:
@@ -496,6 +661,7 @@ class TelegramMessageConverter:
info["fi.mau.telegram.gif"] = True
else:
info["fi.mau.telegram.animated_sticker"] = True
info["fi.mau.gif"] = True
info["fi.mau.loop"] = True
info["fi.mau.autoplay"] = True
info["fi.mau.hide_controls"] = True
@@ -536,7 +702,7 @@ class TelegramMessageConverter:
type=event_type,
content=content,
caption=caption_content,
disappear_seconds=evt.media.ttl_seconds,
disappear_seconds=self._adjust_ttl(evt.media.ttl_seconds),
)
@staticmethod
@@ -597,18 +763,18 @@ class TelegramMessageConverter:
_n += 1
return _n
text_answers = "\n".join(f"{n()}. {answer.text}" for answer in poll.answers)
html_answers = "\n".join(f"<li>{answer.text}</li>" 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.text}</li>" for answer in poll.answers)
vote_command = f"{self.command_prefix} vote {poll_id}"
content = TextMessageEventContent(
msgtype=MessageType.TEXT,
format=Format.HTML,
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>"
),
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"Vote with <code>{vote_command} &lt;choice number&gt;</code>"
),
@@ -698,10 +864,33 @@ class TelegramMessageConverter:
)
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:
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()
sticker_pack_ref = None
for attr in attributes:
if isinstance(attr, DocumentAttributeFilename):
name = name or attr.file_name
@@ -709,6 +898,13 @@ def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAt
elif isinstance(attr, DocumentAttributeSticker):
is_sticker = True
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):
is_gif = True
elif isinstance(attr, DocumentAttributeVideo):
@@ -722,17 +918,18 @@ def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAt
waveform = decode_waveform(attr.waveform) if attr.waveform else b""
return DocAttrs(
name,
mime_type,
is_sticker,
sticker_alt,
width,
height,
is_gif,
is_audio,
is_voice,
duration,
waveform,
name=name,
mime_type=mime_type,
is_sticker=is_sticker,
sticker_alt=sticker_alt,
sticker_pack_ref=sticker_pack_ref,
width=width,
height=height,
is_gif=is_gif,
is_audio=is_audio,
is_voice=is_voice,
duration=duration,
waveform=waveform,
)
@@ -758,6 +955,13 @@ def _parse_document_meta(
mime_type = file.mime_type or document.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:
file.mime_type = attrs.mime_type or file.mime_type
if file.width and file.height:
@@ -777,6 +981,10 @@ def _parse_document_meta(
size=file.thumbnail.size,
)
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
info.thumbnail_info = ImageInfo.deserialize(info.serialize())
if file.decryption_info:
+5 -3
View File
@@ -83,9 +83,11 @@ def get_base_power_levels(
levels.users_default = overrides.get("users_default", 0)
levels.events_default = overrides.get(
"events_default",
50
if portal.peer_type == "channel" and not portal.megagroup or dbr.send_messages
else 0,
(
50
if portal.peer_type == "channel" and not portal.megagroup or dbr.send_messages
else 0
),
)
for evt_type, value in overrides.get("events", {}).items():
levels.events[EventType.find(evt_type)] = value
@@ -18,7 +18,7 @@ from __future__ import annotations
import base64
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.messages import SponsoredMessages, SponsoredMessagesEmpty
+42 -4
View File
@@ -55,6 +55,7 @@ if TYPE_CHECKING:
class Puppet(DBPuppet, BasePuppet):
bridge: TelegramBridge
config: Config
hs_domain: str
mxid_template: SimpleTemplate[TelegramID]
@@ -78,6 +79,7 @@ class Puppet(DBPuppet, BasePuppet):
avatar_url: ContentURI | None = None,
name_set: bool = False,
avatar_set: bool = False,
contact_info_set: bool = False,
is_bot: bool = False,
is_channel: bool = False,
is_premium: bool = False,
@@ -100,6 +102,7 @@ class Puppet(DBPuppet, BasePuppet):
avatar_url=avatar_url,
name_set=name_set,
avatar_set=avatar_set,
contact_info_set=contact_info_set,
is_bot=is_bot,
is_channel=is_channel,
is_premium=is_premium,
@@ -154,6 +157,7 @@ class Puppet(DBPuppet, BasePuppet):
@classmethod
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
cls.bridge = bridge
cls.config = bridge.config
cls.loop = bridge.loop
cls.mx = bridge.matrix
@@ -265,11 +269,14 @@ class Puppet(DBPuppet, BasePuppet):
is_bot != self.is_bot or is_channel != self.is_channel or is_premium != self.is_premium
)
self.is_bot = is_bot
if is_bot is not None:
self.is_bot = is_bot
self.is_channel = is_channel
self.is_premium = is_premium
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
changed = True
@@ -279,6 +286,8 @@ class Puppet(DBPuppet, BasePuppet):
if not self.disable_updates:
try:
changed = await self._update_contact_info(force=changed) or changed
changed = (
await self.update_displayname(source, info, client_override=client_override)
or changed
@@ -296,8 +305,37 @@ class Puppet(DBPuppet, BasePuppet):
await self.update_portals_meta()
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:
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
async for portal in p.Portal.find_private_chats_with(self.tgid):
await portal.update_info_from_puppet(self)
+6 -1
View File
@@ -22,6 +22,7 @@ from telethon.tl.patched import Message
from telethon.tl.types import (
InputMediaUploadedDocument,
InputMediaUploadedPhoto,
InputReplyToMessage,
TypeDocumentAttribute,
TypeInputMedia,
TypeInputPeer,
@@ -67,6 +68,10 @@ class MautrixTelegramClient(TelegramClient):
entity = await self.get_input_entity(entity)
reply_to = utils.get_message_id(reply_to)
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)
+78 -26
View File
@@ -23,6 +23,7 @@ import time
from telethon.errors import (
AuthKeyDuplicatedError,
AuthKeyError,
AuthKeyNotFound,
RPCError,
TakeoutInitDelayError,
UnauthorizedError,
@@ -39,6 +40,9 @@ from telethon.tl.types import (
ChatForbidden,
InputUserSelf,
Message,
MessageActionContactSignUp,
MessageActionHistoryClear,
MessageService,
NotifyPeer,
PeerUser,
TypeUpdate,
@@ -52,6 +56,7 @@ from telethon.tl.types import (
User as TLUser,
)
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
@@ -106,6 +111,7 @@ class User(DBUser, AbstractUser, BaseUser):
_available_emoji_reactions_fetched: float
_available_emoji_reactions_lock: asyncio.Lock
_app_config: dict[str, Any] | None
_app_config_hash: int
def __init__(
self,
@@ -143,6 +149,7 @@ class User(DBUser, AbstractUser, BaseUser):
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,
@@ -209,7 +216,7 @@ class User(DBUser, AbstractUser, BaseUser):
async with self._ensure_started_lock:
return cast(User, await super().ensure_started(even_if_no_session))
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
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"
@@ -230,8 +237,8 @@ class User(DBUser, AbstractUser, BaseUser):
async def start(self, delete_unless_authenticated: bool = False) -> User:
try:
await super().start()
except AuthKeyDuplicatedError as e:
self.log.warning("Got AuthKeyDuplicatedError in start()")
except (AuthKeyDuplicatedError, AuthKeyNotFound) as e:
self.log.warning(f"Got {type(e).__name__} in start()")
await self.on_signed_out(e)
if not delete_unless_authenticated:
# The caller wants the client to be connected, so restart the connection.
@@ -291,9 +298,11 @@ class User(DBUser, AbstractUser, BaseUser):
self._track_metric(METRIC_CONNECTED, connected)
if connected:
await self.push_bridge_state(
BridgeStateEvent.BACKFILLING
if self._is_backfilling
else BridgeStateEvent.CONNECTED,
(
BridgeStateEvent.BACKFILLING
if self._is_backfilling
else BridgeStateEvent.CONNECTED
),
info=self._bridge_state_info,
)
else:
@@ -487,13 +496,16 @@ class User(DBUser, AbstractUser, BaseUser):
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]
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:
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
await self._post_sync_dialog(portal, puppet, was_created=True, **post_sync_args)
await self.post_sync_dialog(portal, puppet=None, was_created=True, **post_sync_args)
async def update(self, update: TypeUpdate) -> bool:
if not self.is_bot:
@@ -535,7 +547,7 @@ class User(DBUser, AbstractUser, BaseUser):
await self.stop()
return None
async def update_info(self, info: TLUser = None) -> None:
async def update_info(self, info: TLUser | None = None) -> None:
if not info:
info = await self.get_me()
if not info:
@@ -608,8 +620,11 @@ class User(DBUser, AbstractUser, BaseUser):
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?
await self.push_bridge_state(state, error=error, message=message)
if self.tgid or state != BridgeStateEvent.LOGGED_OUT:
await self.push_bridge_state(state, error=error, message=message)
if delete:
await self.delete()
self.by_mxid.pop(self.mxid, None)
@@ -743,12 +758,12 @@ class User(DBUser, AbstractUser, BaseUser):
)
await self._mute_room(puppet, portal, update.notify_settings.mute_until.timestamp())
async def _sync_dialog(
self, portal: po.Portal, dialog: Dialog, should_create: bool, puppet: pu.Puppet | None
) -> None:
was_created = False
post_sync_args = {
"last_message_ts": cast(datetime, dialog.date).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": (
@@ -759,6 +774,24 @@ class User(DBUser, AbstractUser, BaseUser):
"pinned": dialog.pinned,
"archived": dialog.archived,
}
async def _sync_dialog(
self, portal: po.Portal, dialog: Dialog, should_create: bool, puppet: pu.Puppet | 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
post_sync_args = self.dialog_to_sync_args(dialog)
if portal.mxid:
self.log.debug(f"Backfilling and updating {portal.tgid_log} (dialog sync)")
try:
@@ -772,7 +805,9 @@ class User(DBUser, AbstractUser, BaseUser):
elif should_create:
self.log.debug(f"Creating portal for {portal.tgid_log} immediately (dialog sync)")
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
except Exception:
self.log.exception(f"Error while creating {portal.tgid_log}")
@@ -785,7 +820,7 @@ class User(DBUser, AbstractUser, BaseUser):
extra_data=post_sync_args,
)
if portal.mxid and puppet and puppet.is_real_user:
await self._post_sync_dialog(
await self.post_sync_dialog(
portal=portal,
puppet=puppet,
was_created=was_created,
@@ -793,10 +828,10 @@ class User(DBUser, AbstractUser, BaseUser):
)
self.log.debug(f"_sync_dialog finished for {portal.tgid_log}")
async def _post_sync_dialog(
async def post_sync_dialog(
self,
portal: po.Portal,
puppet: pu.Puppet,
puppet: pu.Puppet | None,
was_created: bool,
max_read_id: int,
last_message_ts: float,
@@ -805,6 +840,10 @@ class User(DBUser, AbstractUser, BaseUser):
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=}, "
@@ -941,11 +980,18 @@ class User(DBUser, AbstractUser, BaseUser):
self.log.debug("Contact syncing complete")
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_emoji_reactions_fetched + 12 * 60 * 60 > time.monotonic():
if self._available_reactions_up_to_date:
return self._available_emoji_reactions
async with self._available_emoji_reactions_lock:
if self._available_emoji_reactions_fetched + 12 * 60 * 60 > time.monotonic():
if self._available_reactions_up_to_date:
return self._available_emoji_reactions
self.log.debug("Fetching available emoji reactions")
available_reactions = await self.client(
@@ -955,13 +1001,18 @@ class User(DBUser, AbstractUser, BaseUser):
self._available_emoji_reactions = {
react.reaction
for react in available_reactions.reactions
if self.is_premium or not react.premium
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:
@@ -969,8 +1020,9 @@ class User(DBUser, AbstractUser, BaseUser):
async def get_app_config(self) -> dict[str, Any]:
if not self._app_config:
cfg = await self.client(GetAppConfigRequest())
self._app_config = util.parse_tl_json(cfg)
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:
+1
View File
@@ -4,6 +4,7 @@ from .file_transfer import (
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
+35 -1
View File
@@ -35,6 +35,7 @@ from telethon.errors import (
PhoneNumberInvalidError,
PhoneNumberUnoccupiedError,
SessionPasswordNeededError,
SessionRevokedError,
)
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
@@ -288,6 +289,17 @@ class AuthAPI(abc.ABC):
errcode="phone_number_unoccupied",
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:
if not password_in_data:
if user.command_status and user.command_status["action"] == "Login":
@@ -342,6 +354,28 @@ class AuthAPI(abc.ABC):
errcode="password_invalid",
error="Incorrect password.",
)
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")
if isinstance(e, ValueError) and "You must provide a phone and a code" in str(e):
@@ -357,5 +391,5 @@ class AuthAPI(abc.ABC):
state="password",
status=500,
errcode="unknown_error",
error="Internal server error while sending password.",
error=f"Internal server error while sending password. {e}",
)
@@ -130,9 +130,9 @@ class ProvisioningAPI(AuthAPI):
"about": portal.about,
"username": portal.username,
"megagroup": portal.megagroup,
"can_unbridge": (await portal.can_user_perform(user, "unbridge"))
if user
else False,
"can_unbridge": (
(await portal.can_user_perform(user, "unbridge")) if user else False
),
}
)
@@ -188,9 +188,11 @@ class ProvisioningAPI(AuthAPI):
if force in ("delete", "unbridge"):
delete = force == "delete"
await portal.cleanup_portal(
"Portal deleted (moving to another room)"
if delete
else "Room unbridged (portal moving to another room)",
(
"Portal deleted (moving to another room)"
if delete
else "Room unbridged (portal moving to another room)"
),
puppets_only=not delete,
)
else:
+9 -7
View File
@@ -2,20 +2,19 @@
# Uncommented lines after the group definition insert things into that group.
#/speedups
cryptg>=0.1,<0.5
cryptg>=0.1,<0.6
aiodns
brotli
#/qr_login
pillow>=4,<10
qrcode>=6,<8
pillow>=10.0.1,<12
qrcode>=6,<9
#/formattednumbers
phonenumbers>=8,<9
phonenumbers>=8,<10
#/metrics
prometheus_client>=0.6,<0.17
prometheus_client>=0.6,<0.23
#/e2be
python-olm>=3,<4
@@ -23,4 +22,7 @@ pycryptodome>=3,<4
unpaddedbase64>=1,<3
#/sqlite
aiosqlite>=0.16,<0.19
aiosqlite>=0.16,<0.22
#/proxy
python-socks[asyncio]
+1 -1
View File
@@ -9,4 +9,4 @@ line_length = 99
[tool.black]
line-length = 99
target-version = ["py38"]
target-version = ["py310"]
+4 -5
View File
@@ -1,11 +1,10 @@
ruamel.yaml>=0.15.35,<0.18
ruamel.yaml>=0.15.35,<0.19
python-magic>=0.4,<0.5
commonmark>=0.8,<0.10
aiohttp>=3,<4
yarl>=1,<2
mautrix>=0.19.4,<0.20
#telethon>=1.25.4,<1.27
tulir-telethon==1.28.0a3
asyncpg>=0.20,<0.28
mautrix>=0.20.8,<0.21
tulir-telethon==1.99.0a6
asyncpg>=0.20,<1
mako>=1,<2
setuptools
+4 -3
View File
@@ -51,7 +51,7 @@ setuptools.setup(
install_requires=install_requires,
extras_require=extras_require,
python_requires="~=3.8",
python_requires="~=3.10",
classifiers=[
"Development Status :: 4 - Beta",
@@ -60,9 +60,10 @@ setuptools.setup(
"Framework :: AsyncIO",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
],
package_data={"mautrix_telegram": [
"web/public/*.mako", "web/public/*.png", "web/public/*.css",