Compare commits

..

1082 Commits

Author SHA1 Message Date
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
Tulir Asokan 60b1573386 Bump version to 0.13.0 2023-02-26 17:24:11 +02:00
Tulir Asokan f4695d8395 Update changelog 2023-02-26 15:05:49 +02:00
Tulir Asokan f63c679d3e Catch errors updating initial profile. Fixes #860 2023-02-22 01:31:32 +02:00
Tulir Asokan 4e5305c91b Update Telethon to save update state more actively (ref #894) 2023-02-22 01:02:47 +02:00
Tulir Asokan f30c03a727 Block creating rooms for deactivated chats (ref #894) 2023-02-21 22:34:21 +02:00
Tulir Asokan 354b49d9e5 Remove unnecessary dependencies in dockerfile and update changelog 2023-02-15 23:01:09 +02:00
Tulir Asokan 7b60ee1337 Actually save timestamps for telegram_file 2023-02-15 21:51:49 +02:00
Tulir Asokan ab1d9b246e Replace moviepy with directly using ffmpeg for video thumbnails
Fixes #809
2023-02-15 21:51:44 +02:00
Tulir Asokan f7b694c9e4 Use new wrapper for creating background tasks 2023-02-11 22:41:15 +02:00
Tulir Asokan be6f6bbfac Update linters 2023-02-11 22:40:50 +02:00
Tulir Asokan a32f797b0b Remove support for registering accounts 2023-02-10 21:20:51 +02:00
vurpo f12abbe038 Merge pull request #887 from mautrix/vurpo/qr-websocket
Add websocket for QR login to provisioning API
2023-01-27 18:40:35 +02:00
Max Sandholm ad2b49928a Sort imports 2023-01-27 17:40:12 +02:00
Max Sandholm 67f75796fa Correct retry and timeout for QR websocket 2023-01-27 17:37:48 +02:00
Tulir Asokan c235ced030 Update dependencies 2023-01-27 15:11:15 +02:00
Tulir Asokan d53764fd84 Remove custom TTLs in bridge states 2023-01-27 15:11:15 +02:00
Tulir Asokan 529d8ae3ba Recreate whole connection instead of only update loop on error 2023-01-27 15:11:15 +02:00
Max Sandholm f864f66e62 Add websocket for QR login to provisioning API 2023-01-26 23:43:44 +02:00
Tulir Asokan b1b633bcf9 Add option to notify portal if incoming message bridging fails 2023-01-26 16:01:59 +02:00
Tulir Asokan e655e0a882 Only send marker for backwards backfills on hungryserv 2023-01-18 14:28:12 +02:00
Tulir Asokan db88fbb694 Remove internal ID from pm command help (ref #882) 2023-01-15 19:05:24 +02:00
Tulir Asokan ace3e42281 Update mautrix-python 2023-01-14 14:28:45 +02:00
Tulir Asokan a40000e6b7 Only fill bridge state if tgid is set 2023-01-14 14:28:22 +02:00
Tulir Asokan 21d2d7dfea Update telethon 2023-01-11 12:13:59 +02:00
Tulir Asokan a61731a289 Update changelog 2023-01-10 16:03:50 +02:00
Tulir Asokan c250076032 Update mautrix-python 2023-01-10 16:03:39 +02:00
vurpo c6d35b103a Merge pull request #880 from mautrix/max/bri-5580
Fix remaining reconnect bug in provision API
2023-01-04 18:49:03 +02:00
Max Sandholm 596c9a5055 None check puppet on logout call 2023-01-04 18:21:25 +02:00
Tulir Asokan 9fae4f14d2 Handle getting logged out the same way in all cases 2023-01-03 21:45:25 +02:00
Tulir Asokan f1f0b86696 Fix deleting existing backfill queue items 2023-01-03 20:45:55 +02:00
Tulir Asokan e3d2a1fcef Catch ValueErrors in 2fa login step 2023-01-02 17:46:54 +02:00
Tulir Asokan 2303622475 Update changelog 2023-01-02 17:16:24 +02:00
vurpo 732277be5e Merge pull request #879 from mautrix/stickersets
Add provisioning API function to get list of user's sticker sets
2023-01-02 16:27:40 +02:00
Max Sandholm 28f205057f Lint imports after enabling linting 2023-01-02 15:11:27 +02:00
Max Sandholm 9e32ec3e39 Add provisioning API function to get list of user's sticker sets 2023-01-02 15:04:49 +02:00
Tulir Asokan 1fa86cbb52 Fix handling username updates 2022-12-31 12:24:33 +02:00
Tulir Asokan 9d8a4d4269 Use allow_contact_info flag for names too 2022-12-30 20:29:35 +02:00
Tulir Asokan cb22615bb5 Update Telethon 2022-12-30 20:17:25 +02:00
Tulir Asokan 989dc32481 Don't fail on unnamed files with unknown mime types 2022-12-28 13:15:13 +02:00
Tulir Asokan 02dd44ad63 Update Telethon 2022-12-22 22:50:21 +02:00
Tulir Asokan d6517959d8 Update dependencies 2022-12-21 18:31:18 +02:00
Tulir Asokan d9d539c4b8 Don't fail file transfer entirely if thumbnailing fails 2022-12-21 18:23:21 +02:00
Tulir Asokan 5b18ffb7ec Fix handling UpdateUserName 2022-12-11 13:37:08 +02:00
Tulir Asokan cf70efb6a2 Clear backfill queue when chat is upgraded 2022-12-02 16:53:58 +02:00
Tulir Asokan a42699e1fb Fix cryptg version range 2022-11-28 12:00:03 +02:00
Tulir Asokan 597e82a33b Update Docker image to Alpine 3.17 2022-11-26 22:02:34 +02:00
Tulir Asokan e302143b8a Bump version to 0.12.2 2022-11-26 19:49:45 +02:00
Tulir Asokan e99b6af2c5 Update Telethon again 2022-11-26 19:48:07 +02:00
Tulir Asokan 35a16ac7e0 Update Telethon 2022-11-24 11:04:20 +02:00
Tulir Asokan 0d20d9069a Remove cchardet. Fixes #869 2022-11-24 11:04:15 +02:00
Tulir Asokan 8b1d272827 Remove unused TARGETARCH build arg in dockerfile 2022-11-22 16:33:37 +02:00
Tulir Asokan 24b3384570 Update asyncpg
Fixes #867
2022-11-18 19:30:19 +02:00
Tulir Asokan 4ca5bfb1ab Use deterministic event IDs for backfill on hungryserv 2022-11-18 18:59:38 +02:00
Tulir Asokan 7c8cf3cb50 Always treat UpdateShortChatMessage as minigroup messages 2022-11-18 17:11:45 +02:00
Tulir Asokan 6b55d5bb41 Adjust heading size in readme 2022-11-18 14:46:19 +02:00
Tulir Asokan 5558fc7157 Add more logs for own read receipts 2022-11-08 10:42:42 +02:00
Tulir Asokan 30a7121000 Update Telethon 2022-11-05 22:55:45 +02:00
Tulir Asokan fb1568d019 Update changelog 2022-11-05 19:27:04 +02:00
Tulir Asokan a0dca671d8 Remove regex filters in provisioning API paths
They're broken due to https://github.com/aio-libs/aiohttp/issues/5621
2022-11-05 19:25:47 +02:00
Andrew Ferrazzutti d79870801b Add index to speed up Message.find_recent query (#862) 2022-11-01 21:25:55 +02:00
Tulir Asokan 2a238a95a9 Merge pull request #861 from vector-im/bot-future-type-check
Add type checking & None check on bot login future
2022-10-31 14:36:19 +02:00
Tulir Asokan 4bfcf46e36 Bridge changes to permissions from Telegram 2022-10-31 14:31:55 +02:00
Andrew Ferrazzutti 894316f035 Add type checking & None check on bot login future 2022-10-28 11:50:38 +02:00
Tulir Asokan 1c47924624 Update mautrix-python 2022-10-24 22:02:49 +03:00
Tulir Asokan 2973b0f200 Update dependencies 2022-10-20 15:29:22 +03:00
Tulir Asokan 4fc5751ae1 Add note about timestamp massaging to double_puppet_backfill 2022-10-20 15:29:22 +03:00
Sumner Evans d37ca7eae3 provisioning API: client -> app
Signed-off-by: Sumner Evans <sumner@beeper.com>
2022-10-18 16:14:53 -06:00
Tulir Asokan 7960f22be9 Add some more logs in dialog sync 2022-10-14 16:04:22 +03:00
Tulir Asokan 1b11ec290a Fix inserting backfill queue items 2022-10-14 16:04:11 +03:00
Tulir Asokan 751f1d93f3 Try to improve getting forwarded message source entity 2022-10-14 14:53:54 +03:00
Tulir Asokan f63a7857a6 Reduce takeout loop timeout 2022-10-14 14:38:06 +03:00
Tulir Asokan 017ca24b13 Try to improve handling avatar updates for new users 2022-10-14 14:37:12 +03:00
Tulir Asokan 3c22ab7bd1 Try to automatically detect when data export is accepted 2022-10-14 13:55:43 +03:00
Tulir Asokan 0bbf64d240 Add option to sync portals in backfill queue 2022-10-14 13:55:12 +03:00
Tulir Asokan af2f20f7b2 Add support for sending members in /createRoom 2022-10-13 15:31:22 +03:00
Tulir Asokan fef03ddec0 Maybe actually fix time comparison 2022-10-12 22:09:23 +03:00
Tulir Asokan f2d0489488 Fix another bug 2022-10-12 16:46:42 +03:00
Tulir Asokan f815d5e2fd Fix mistake in legacy backfill 2022-10-12 16:42:41 +03:00
Tulir Asokan c4a5a3eaf7 Cut too long plaintext messages 2022-10-12 16:41:54 +03:00
Tulir Asokan 921cc6ffa9 Update changelog 2022-10-12 11:25:01 +03:00
Tulir Asokan b582e59eee Add option to mark old chats as read even if they're unread on Telegram 2022-10-12 11:24:52 +03:00
Tulir Asokan c9f8b83f62 Set double puppet key in backfill events 2022-10-12 10:56:45 +03:00
Tulir Asokan 8ff99ce916 Improve handling of reaching the start of a chat in backfill 2022-10-11 20:34:19 +03:00
Tulir Asokan 27b23a96b6 Properly use takeout client for backfilling 2022-10-11 17:53:41 +03:00
Tulir Asokan 8ae34223c5 Add timeout for backfill queue waiter to handle retries 2022-10-11 17:32:59 +03:00
Tulir Asokan 699fc9df1f Skip unsupported messages in backfill 2022-10-11 17:28:14 +03:00
Tulir Asokan 951d02bfc3 Don't try to backfill if limit is zero 2022-10-11 16:11:34 +03:00
Tulir Asokan 9b9a3b452d Infinite backfill with MSC2716 (#817)
Disabled by default, with non-infinite fallback mode as the default behavior
2022-10-11 16:03:52 +03:00
Tulir Asokan 02f21a30a8 Update latest revision upgrade 2022-10-11 16:00:04 +03:00
Tulir Asokan e053664c99 Merge remote-tracking branch 'Half-Shot/hs/index-custom-mxid' 2022-10-11 15:59:33 +03:00
Tulir Asokan 949c6a318f Don't remove all reactions when one is redacted 2022-10-01 17:32:35 +03:00
Tulir Asokan f5cb8baf99 Get reaction limit from server app config 2022-10-01 17:27:56 +03:00
Tulir Asokan 025b864bd8 Allow reacting with any unicode emoji using custom pack 2022-10-01 17:17:27 +03:00
Half-Shot b4fcccbe10 fix filename 2022-09-30 10:04:57 +01:00
Half-Shot b9331b5f5a Add index to puppet custom_mxid column 2022-09-30 10:00:16 +01:00
Tulir Asokan 81aa0084e7 Update Telethon 2022-09-27 18:52:02 +03:00
Tulir Asokan 58bc6788aa Bump version to 0.12.1 2022-09-26 21:42:51 +03:00
Tulir Asokan 5a767a2d92 Update Telethon 2022-09-25 17:06:17 +03:00
Tulir Asokan 282ad43180 Update changelog and mautrix-python 2022-09-24 13:58:13 +03:00
Tulir Asokan bcb30ce807 Update Telethon 2022-09-21 15:27:41 +03:00
Tulir Asokan 2d865f006e Don't use row.get to be compatible with sqlite3.Row 2022-09-20 18:43:41 +03:00
Tulir Asokan b2daebead6 Catch errors when updating read status or tags. Fixes #812 2022-09-20 11:11:59 +03:00
Tulir Asokan 4210091e9a Fix some bugs 2022-09-20 01:59:47 +03:00
Tulir Asokan 4db09f2240 Update Telethon 2022-09-20 00:32:47 +03:00
Tulir Asokan e0260eb551 Don't recreate update loop on UnauthorizedErrors 2022-09-20 00:26:42 +03:00
Tulir Asokan ed1e5474bf Update latest revision migration 2022-09-19 19:10:16 +03:00
Tulir Asokan 65bd7fcc49 Use mautrix-python magic wrapper. Fixes #594 2022-09-17 15:00:49 +03:00
Tulir Asokan 80834ccec1 Update changelog 2022-09-17 14:29:50 +03:00
Tulir Asokan 026c39a3de Add support for new reaction stuff
* Custom emojis in reactions
* Premium users can react 3 times to a single message
* Reactions to recent messages are now polled on read receipt
2022-09-17 14:25:06 +03:00
Tulir Asokan 95939dfa02 Update mautrix-python to fix encrypting when a single device is out of OTKs 2022-09-15 21:55:01 +03:00
Tulir Asokan 279da9097c Update mautrix-python 2022-09-15 17:18:35 +03:00
Tulir Asokan 97126332da Add option to bypass startup script. Closes #838 2022-09-15 17:18:35 +03:00
Tulir Asokan 6641b9a16c Save own ID as message sender ID for messages without sender 2022-09-15 17:18:35 +03:00
Tulir Asokan 927c9afa84 Move config env overrides to mautrix-python 2022-09-15 17:18:35 +03:00
Tulir Asokan d41d7ca0a6 Handle ChatParticipantsForbidden 2022-09-15 17:18:35 +03:00
Tulir Asokan ad0c6cfc8d Run connection tracking task if status_endpoint is set 2022-09-13 16:36:38 +03:00
Tulir Asokan 0289f4b524 Bump version to 0.12.0 2022-08-26 16:22:06 +03:00
Malte E 85b8f5def7 Don't check whether User is channel, add peer property to User 2022-08-24 21:13:11 +03:00
Tulir Asokan f012cb790f Update mautrix-python again 2022-08-22 17:48:10 +03:00
Tulir Asokan 05476d7435 Update mautrix-python 2022-08-22 13:00:08 +03:00
Tulir Asokan 583427da05 Enable appservice ephemeral events by default 2022-08-22 12:57:41 +03:00
Tulir Asokan e3a067c27a Update mautrix-python 2022-08-17 15:20:38 +03:00
Tulir Asokan b3ed4cf657 Fix handling messages with no sender 2022-08-17 15:14:07 +03:00
Tulir Asokan 952c81eadc Update mautrix-python 2022-08-15 11:40:28 +03:00
Tulir Asokan cc29ce19ca Add missing parameter when handling Matrix files 2022-08-15 11:09:10 +03:00
Tulir Asokan 941aa5f9d8 Fix mistake in mark_disappearing 2022-08-14 14:28:23 +03:00
Tulir Asokan 15e5cc8da1 Add command to kick relaybot users from Telegram 2022-08-14 14:20:43 +03:00
Tulir Asokan 2cf9205cda Add command to ban relaybot users from Telegram
Fixes #357
Closes #819
2022-08-14 14:07:48 +03:00
Tulir Asokan 2ec89bc57e Add keywords to mark_matrix_handled calls 2022-08-14 13:47:00 +03:00
Tulir Asokan 89294c57d8 Store message sender in database 2022-08-14 13:44:59 +03:00
Tulir Asokan 624c72fa99 Merge remote-tracking branch 'zsinskri/delivery-receipts' 2022-08-14 12:52:33 +03:00
Tulir Asokan 34af580846 Move misc things from infinite backfill PR 2022-08-14 12:50:28 +03:00
Tulir Asokan 910a681f4b Mark key parameters as positional-only in async getter lock methods 2022-08-14 12:49:45 +03:00
Tulir Asokan c4c225343c Add backfill queue table 2022-08-14 12:49:13 +03:00
Tulir Asokan f13a9d0e96 Add support for disappearing messages 2022-08-14 01:49:39 +03:00
Tulir Asokan c54ae9548f Add support for converting video stickers to images 2022-08-14 00:53:21 +03:00
Tulir Asokan 1216607763 Add custom attribute for custom emojis 2022-08-12 22:45:52 +03:00
Tulir Asokan ecd4d5c338 Limit number of custom emoji being transferred simultaneously 2022-08-12 22:14:53 +03:00
Tulir Asokan a5fe05cff2 Add support for converting animated stickers to webp 2022-08-12 22:07:52 +03:00
Tulir Asokan 76eafbf48c Add basic support for bridging custom emojis from Telegram 2022-08-12 21:35:50 +03:00
Tulir Asokan 473ab17fe7 Update Telethon and strip empty entities when sending to Telegram 2022-08-02 13:46:06 +03:00
Tulir Asokan bea9bc4ec0 Mention forwarding limitations in changelog. Closes #818 2022-07-29 12:24:41 +03:00
Tulir Asokan 5df1e84fae Update mautrix-python 2022-07-29 12:23:46 +03:00
Tulir Asokan 8665871502 Fix some issues with auto-creating groups 2022-07-18 13:01:50 +03:00
Zsin Skri ef57f1021c Revert "Don't send delivery receipts to unencrypted private chat portals. Fixes #483"
This reverts commit a4595b427d.

Commit a4595b4 avoids sending delivery receipts to rooms that do not contain the
bridge bot.  That was necessary as trying to send a read marker would
automatically attempt to join the bridge bot to the room.
That join without invite would fail, hence #483.

But since
https://github.com/mautrix/python/commit/f272f16a1d151a1c6612c9349776eda985c8ea3e
we no longer attempt to join the sender of read receipts, fixing #483 without
necessarily sacrificing the delivery receipt functionality.

Thus:
- a4595b4 is no longer necessary, its original purpose is fulfilled by f272f16.
- a4595b4 prevents delivery receipts from working in unencrypted rooms.
- This reverts a4595b4, thus enabling delivery receipts in unencrypted rooms.
2022-07-17 20:57:17 +02:00
Tulir Asokan b6312f306a Move config check when handling ghost invites 2022-07-17 16:09:02 +03:00
Tulir Asokan 70b73868c7 Merge remote-tracking branch 'maltee1/auto_create_group' 2022-07-17 16:04:56 +03:00
Tulir Asokan 0717b4a290 Disable public portals by default 2022-07-17 16:04:20 +03:00
Tulir Asokan a9b6539910 Update changelog 2022-07-13 14:24:13 +03:00
Tulir Asokan 49520bb8a3 Try to avoid race conditions with supergroup upgrades 2022-07-13 14:20:39 +03:00
Tulir Asokan 7abe19aec9 Add another backfill column 2022-07-13 12:17:35 +03:00
Malte E 3dd0c51be7 add config option, update roadmap 2022-07-12 21:37:41 +02:00
Malte E 565bb87470 implement handle_puppet_group_invite to auto-create groups 2022-07-12 21:02:14 +02:00
Tulir Asokan 9188251501 Add status field to message status events 2022-07-12 15:05:06 +03:00
Tulir Asokan cb11e147ce Add support for Matrix -> Telegram captions with MSC2530 2022-07-12 11:35:51 +03:00
Tulir Asokan eb1190359d Update asyncpg 2022-07-10 18:20:53 +03:00
Tulir Asokan cdfc6fd007 Remove noisy error on ignored messages 2022-07-10 18:20:46 +03:00
Tulir Asokan df9b7d343e Add support for forwarding messages 2022-07-07 13:02:01 +03:00
Tulir Asokan f26973f46c Update changelog and mautrix-python 2022-07-06 19:37:29 +03:00
Tulir Asokan 2335431060 Update mautrix-python again 2022-07-05 20:06:29 +03:00
Tulir Asokan 8fd97af0a9 Update mautrix-python 2022-07-05 13:26:03 +03:00
Tulir Asokan 3ea491d379 Fix handling location messages 2022-07-04 10:44:37 +03:00
Tulir Asokan 3bd7d846f4 Update mautrix-python 2022-06-28 19:35:23 +03:00
Tulir Asokan 99344c38a4 Create room on UpdateChannel 2022-06-28 19:20:18 +03:00
Tulir Asokan d917499d1f Fix check for using double puppeted leaves 2022-06-28 19:14:37 +03:00
Tulir Asokan 98da5fecc3 Use wildcard for cryptg wheel name 2022-06-27 21:41:59 +03:00
Tulir Asokan 6b0ece5da1 Update cryptg 2022-06-27 21:39:18 +03:00
Tulir Asokan 448b149e8e Make docker image smaller 2022-06-27 21:14:14 +03:00
Tulir Asokan 120514125f Update changelog 2022-06-27 20:52:47 +03:00
Tulir Asokan cd4b4365bd Update Docker image to Alpine 3.16 2022-06-27 15:59:11 +03:00
Tulir Asokan 8f68801aa9 Maybe improve channel leave handling 2022-06-27 15:59:11 +03:00
Sumner Evans 1d0e8c7e0c Merge pull request #810 from mautrix/mautrix-0.16.10
deps/mautrix: update to v0.16.10
2022-06-24 11:42:23 -06:00
Sumner Evans 3ff43165c2 deps/mautrix: update to v0.16.10
Signed-off-by: Sumner Evans <sumner@beeper.com>
2022-06-24 11:33:49 -06:00
Tulir Asokan 1fdbdb654a Update Telethon for API layer update 2022-06-24 11:17:24 +03:00
Tulir Asokan 0e024b3b7c Fetch participant count if it's not included in the entity 2022-06-23 23:59:44 +03:00
Tulir Asokan e1a5e30a75 Remove legacy crypto database from example config
It doesn't seem to be used anywhere
2022-06-23 09:58:10 +03:00
Sumner Evans 05d4923db9 encryption: add ability to control rotation settings
Signed-off-by: Sumner Evans <sumner@beeper.com>
2022-06-23 09:58:10 +03:00
Tulir Asokan f18713cd5e Update changelog 2022-06-22 13:28:16 +03:00
Tulir Asokan ef05875bfd Remove plaintext_highlights config option
The code using it was removed in v0.11.0, so it hasn't actually worked
for a while now.
2022-06-22 12:40:30 +03:00
Tulir Asokan 59d85a1e16 Fix incorrect method call 2022-06-22 12:35:58 +03:00
Tulir Asokan 7eec0d1ed3 Add index on puppet username. Fixes #799 2022-06-22 12:30:07 +03:00
Tulir Asokan f917ee189d Don't require puppeting for caption and config commands
Fixes #790
2022-06-22 12:30:01 +03:00
Tulir Asokan ab2e38b33b Merge remote-tracking branch 'ofalvai/patch-1' 2022-06-22 12:10:52 +03:00
Tulir Asokan 38af35e776 Merge remote-tracking branch 'cynhr/cmd-prefix' 2022-06-22 12:10:44 +03:00
Tulir Asokan c6adb87aea Set sync_channel_members to false by default 2022-06-22 12:09:38 +03:00
Tulir Asokan e8eef1c31e Add option to not bridge chats with too many members 2022-06-22 12:05:48 +03:00
Tulir Asokan bac3abcb4c Update mautrix-python 2022-06-21 15:42:27 +03:00
Tulir Asokan c682bdc01e Update dependencies 2022-06-19 15:05:59 +03:00
Cy Nhr 50cd878f13 Include command prefix in game and poll messages
Game and poll messages send by the bridge to matrix each include a command the
receiver might want to run (to play the game or to vote in the poll).

But these command suggestions did always include the "!tg" command prefix, even
if the command prefix was changed to a different value in the config.  That
could lead to the bridge ignoring the exact command it suggested earlier.

With this commit, these messages contain the correct command prefix as defined
in the config so that the command suggestions can be executed by the user
without manually correcting the prefix.
2022-06-18 20:23:59 +02:00
Tulir Asokan ea49ba8be2 Move CI script to mautrix/ci repo 2022-06-18 14:23:01 +03:00
Tulir Asokan b60056c560 Add missing prefix to bridge info endpoint 2022-06-18 09:56:54 +03:00
Tulir Asokan 820210dc44 Fix bridging polls from Telegram 2022-06-02 19:40:23 +03:00
Tulir Asokan 7d998dca3f Add support for custom message bridging status events 2022-06-01 15:36:22 +03:00
Tulir Asokan 037d93471d Catch PhoneNumberUnoccupied in /login/send_code provisioning API 2022-05-30 22:18:28 +03:00
Tulir Asokan 5cb2b871cd Fix sticker event type 2022-05-29 00:35:25 +03:00
Tulir Asokan 44f2c648a8 Add config option to exit if telethon update loop fails 2022-05-26 17:37:21 +03:00
Tulir Asokan 0ae8a5877e Rename db upgrade 2022-05-26 17:28:44 +03:00
Tulir Asokan 18f6622340 Separate Telegram message conversion code from Matrix sending 2022-05-26 15:46:20 +03:00
Tulir Asokan 591e79f5a0 Enable catch_up and sequential_updates by default 2022-05-25 16:49:59 +03:00
Tulir Asokan d898486b49 Add first_event_id and next_batch_id columns for portals 2022-05-25 14:56:41 +03:00
Tulir Asokan 74e0aee421 Update Telethon a third time 2022-05-23 17:58:34 +03:00
Tulir Asokan 07f32e1256 Update Telethon again 2022-05-23 14:59:36 +03:00
Tulir Asokan ea680cf871 Update Telethon 2022-05-23 14:22:11 +03:00
Tulir Asokan e89c75c6cd Don't try to stop relaybot if it's not enabled 2022-05-23 10:46:00 +03:00
Tulir Asokan 59d052afd2 Update Telethon 2022-05-20 21:55:22 +03:00
Tulir Asokan 9383249ade Stop relaybot connection cleanly 2022-05-20 18:44:36 +03:00
Tulir Asokan 0a4f30bf02 Update setup doc links 2022-05-20 15:10:30 +03:00
Tulir Asokan 190f452910 Fix some bugs and update Telethon 2022-05-20 14:24:28 +03:00
Tulir Asokan 3c59a1af97 Adjust logs slightly 2022-05-20 12:28:39 +03:00
Tulir Asokan 11ff628ef8 Always check database before handling message 2022-05-20 12:02:32 +03:00
Tulir Asokan 908e600dc9 Switch /resolve_identifier to GET 2022-05-19 18:22:04 +03:00
Tulir Asokan eb43fde3e4 Add provisioning API for resolving identifiers 2022-05-19 13:15:44 +03:00
Tulir Asokan e6ef40e51d Update Telethon 2022-05-19 13:15:39 +03:00
Tulir Asokan 7feea5aa6d Redact QR code after login 2022-05-16 19:13:06 +03:00
Lonami d084cca983 Add get_update_states to telethon_session (#795)
This is needed for an upcoming patch in order to
properly catch up on all channels the client is in.
2022-05-16 19:09:39 +03:00
Tulir Asokan d9018868a1 Use new helper method to redact command 2022-05-10 17:27:03 +03:00
Tulir Asokan 72360457ef Bridge audio and video metadata properly 2022-05-10 17:13:14 +03:00
Tulir Asokan 0e4c1b71e6 Redact 2fa password when using in-Matrix login 2022-05-10 17:04:39 +03:00
Olivér Falvai 575b761f77 Increase image_as_file_pixels default value 2022-05-07 12:23:01 +02:00
Tulir Asokan 68e950a6bc Add issue templates 2022-04-20 14:02:36 +03:00
Sumner Evans ba5bbebb3e Merge pull request #788 from mautrix/dev-update-stable-and-nightly
ci: automatically update both STABLE and NIGHTLY on dev environment
2022-04-19 08:56:58 -06:00
Sumner Evans cb38896593 ci: automatically update both STABLE and NIGHTLY on dev environment 2022-04-18 19:23:04 -06:00
Tulir Asokan 21c6a7d87f Bump version to 0.11.3 2022-04-17 13:30:38 +03:00
Tulir Asokan 7c2a569235 Remove some unused fields 2022-04-13 14:43:53 +03:00
Tulir Asokan 1f5b91cbec Update mautrix-python 2022-04-09 20:52:45 +03:00
Tulir Asokan 937f37eff0 Don't print generated registration message if config is invalid 2022-04-09 20:46:25 +03:00
Tulir Asokan 4f9f74204a Update dependencies 2022-04-08 18:06:24 +03:00
Tulir Asokan ed6735f10f Fix creating new database 2022-04-06 19:04:12 +03:00
Tulir Asokan 5acd3cf007 Move API version number to endpoint definition 2022-04-06 14:33:03 +03:00
Tulir Asokan 279b997bd3 Add contacts and PM endpoints to OpenAPI spec 2022-04-06 14:29:50 +03:00
Tulir Asokan 4eb6095822 Update provisioning API spec to OpenAPI 3.1.0 2022-04-06 14:06:10 +03:00
Tulir Asokan da5b8556f2 Add phone number field for puppets 2022-04-06 12:49:01 +03:00
Tulir Asokan 261f99ac82 Add provisioning API for listing contacts and starting DMs 2022-04-06 12:40:55 +03:00
Tulir Asokan 61f3c39cc2 Mark reactions as read when reading from Matrix 2022-04-01 15:56:16 +03:00
Tulir Asokan 39ab1d0c22 Fix another bug 2022-03-31 01:58:40 +03:00
Tulir Asokan 8abb9c3884 Fix bugs in Telegram entity parser 2022-03-31 01:53:51 +03:00
Tulir Asokan 58f8ee2ee2 Add config option to mark joined Telegram notices as read automatically 2022-03-30 11:58:40 +03:00
Tulir Asokan 474bcc9544 Update and unpin black 2022-03-28 22:29:22 +03:00
Tulir Asokan a3f4e25101 Fix some bugs and add command to list invite links 2022-03-28 15:49:08 +03:00
Tulir Asokan 8befb664b6 Handle accepted into group action messages 2022-03-28 15:06:35 +03:00
Tulir Asokan 819dd1bcff Allow generating invite links that need join approval 2022-03-28 15:03:22 +03:00
Tulir Asokan 2b8b853fec Add proper message when requesting to join via invite link 2022-03-28 15:03:05 +03:00
Tulir Asokan c536c4a265 Update changelog 2022-03-27 23:39:46 +03:00
Tulir Asokan f13acfe825 Clarify that supergroups are channels in !tg bridge 2022-03-27 23:39:46 +03:00
Sumner Evans 8e763ba067 Merge pull request #775 from mautrix/sumner/bri-2582
async media: add ability to upload media asynchronously
2022-03-27 12:31:39 -06:00
Sumner Evans 8d7cfd8e46 parallel transfer: disable async_upload 2022-03-27 12:26:44 -06:00
Sumner Evans 601058d61c async media: add ability to upload media asynchronously
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2022-03-27 12:26:44 -06:00
Tulir Asokan f8596ef368 Use new ensure_has_html method instead of duplicating code 2022-03-23 19:51:01 +02:00
Tulir Asokan 7f0494d52d Merge remote-tracking branch 'origin/sumner/bri-2496' 2022-03-22 16:29:48 +02:00
Sumner Evans 828478514b Merge pull request #772 from mautrix/fix-kick-from-portals
user: fix bug in kick_from_portals
2022-03-22 08:00:02 -06:00
Tulir Asokan 146f5437d1 Drop Python 3.7 support 2022-03-22 13:44:52 +02:00
Tulir Asokan c28760f2a8 Adjust permission error messages 2022-03-22 13:44:52 +02:00
Tulir Asokan 04f30f6f29 Update mautrix-python 2022-03-22 13:44:52 +02:00
Tulir Asokan caa1d3565b Update changelog 2022-03-22 13:44:52 +02:00
Sumner Evans 1a7a020bb2 backfill: set timestamp on backfilled reactions to message timestamp 2022-03-22 00:48:12 -06:00
Sumner Evans 077ab2bb38 user: fix bug in kick_from_portals 2022-03-22 00:46:32 -06:00
Sumner Evans 6f491bf7d1 Merge pull request #771 from ProkopRandacek/master
Add missing f in front of the f-string
2022-03-21 10:51:51 -06:00
Prokop Randacek 9b80c21d0a add missing F 2022-03-21 10:11:45 +01:00
Tulir Asokan e9dc76a860 Fix public channel mentions always using user instead of portal mxid 2022-03-15 16:32:21 +02:00
Tulir Asokan 9e73324a20 Fix bridge_matrix_leave config option 2022-03-14 12:00:14 +02:00
Tulir Asokan 7df93485d8 Remove extra parameter in call 2022-03-11 12:02:02 +02:00
Tulir Asokan 9018cea5ae Update changelog 2022-03-07 18:52:15 +02:00
Tulir Asokan 32e023231d Catch invalid integers passed to !tg create 2022-03-05 20:16:04 +02:00
Tulir Asokan 4766d14359 Move DM creation code to mautrix-python 2022-03-04 16:12:02 +02:00
Tulir Asokan 526b99ec04 Disable file logging in Docker by default
To enable it, use a custom path that points at a writable volume
2022-03-04 10:57:08 +02:00
Nick Mills-Barrett da132438bd Only change the data directory ownership on Docker start 2022-03-03 18:17:39 +02:00
Tulir Asokan 54176ba2db Fix self parameter name in _mute_room. Fixes #764 2022-03-02 14:33:09 +02:00
Tulir Asokan 1eca3c2ffd Check peer_type in database when manually bridging portal 2022-03-02 14:33:06 +02:00
Tulir Asokan 98142f28cd Improve logging of backfill count 2022-02-28 12:36:43 +02:00
Tulir Asokan 2cf7fc7059 Improve backfilling to fetch less redundant messages 2022-02-28 12:26:24 +02:00
Tulir Asokan a34a18c6cc Deduplicate user joined telegram messages 2022-02-28 11:59:44 +02:00
Tulir Asokan fa738fbadf Fix condition 2022-02-26 17:20:22 +02:00
Tulir Asokan 9ea0516166 Log when tagging and muting rooms 2022-02-25 19:35:05 +02:00
Tulir Asokan b760aadb01 Add custom flag for force sending images as document 2022-02-25 12:38:01 +02:00
Tulir Asokan 24162e14ac Remove msgtype in stickers 2022-02-23 14:36:53 +02:00
Tulir Asokan 9ea495324d Don't try to set room state in non-existent portals 2022-02-23 12:46:16 +02:00
Tulir Asokan 437e86a15b Keep newlines as-is in code blocks 2022-02-23 12:44:56 +02:00
Tulir Asokan d9e0b75e9b Update mautrix-python again 2022-02-22 13:53:43 +02:00
Tulir Asokan 9606518ba7 Update mautrix-python again 2022-02-22 12:40:16 +02:00
Tulir Asokan e2774b830f Update mautrix-python version 2022-02-22 11:58:27 +02:00
Tulir Asokan 951d82ad27 Remove max_document_size option and use media repo config directly 2022-02-20 13:47:40 +02:00
Tulir Asokan 4a55cf589c Add initial db upgrade that jumps to latest version 2022-02-19 00:19:49 +02:00
Tulir Asokan b07d80d876 Add support for converting t.me/c/<id>/<msgid> links 2022-02-18 17:22:26 +02:00
Tulir Asokan ff995b2149 Bump version to 0.11.2 2022-02-14 18:19:03 +02:00
Tulir Asokan 2fb08d59c7 Return error if user tries to send empty login code to API 2022-02-09 12:05:16 +02:00
Sumner Evans 7950c5aa61 Merge pull request #754 from mautrix/sumner/bri-1893
link previews: support from Telegram -> Beeper
2022-02-08 11:37:23 -07:00
Sumner Evans bf65824429 link previews: support from Telegram -> Beeper
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2022-02-08 11:35:38 -07:00
Tulir Asokan 4013f822de Remove community_id config option 2022-02-06 17:38:15 +02:00
Tulir Asokan b27519fd88 Add proper error message for syntax errors in !tg login. Fixes #755 2022-02-05 12:27:02 +02:00
Tulir Asokan 22f97756f7 Update CHANGELOG.md 2022-02-03 19:26:11 +02:00
Tulir Asokan da3f4af171 Fix newlines in unformatted channel posts 2022-02-03 17:43:35 +02:00
Tulir Asokan a55d9ae36a Improve profile info syncing 2022-02-01 20:51:55 +02:00
Tulir Asokan ecf3a12bd4 Mark user joined Telegram notice as read if it's backfilled 2022-02-01 17:33:53 +02:00
Tulir Asokan e7248e2418 Fix timestamp of photo has expired messages in backfill 2022-02-01 16:48:51 +02:00
Tulir Asokan fba118f0d9 Send joined telegram message instead of leaving portal empty 2022-02-01 16:44:31 +02:00
Tulir Asokan 100394d161 Add support for relay user distinguishers. Fixes #750 2022-02-01 16:05:56 +02:00
Tulir Asokan a9908781be Add basic support for MSC3488 location descriptions 2022-02-01 15:25:24 +02:00
Tulir Asokan 0f050edcd9 Add proper support for receiving messages sent as a channel. Fixes #740 2022-02-01 15:20:05 +02:00
Tulir Asokan 2182dfc86b Update to Telegram API layer 138 2022-02-01 13:35:27 +02:00
Tulir Asokan 99fa7a57d2 Add config option to set maximum image pixels before sending as document
Fixes #552
2022-01-31 15:57:00 +02:00
Tulir Asokan 6bf3d10e29 Improve handling of disappearing photos and files
Fixes #508
2022-01-31 15:49:39 +02:00
Tulir Asokan ebd2a38e56 Update black and fix version in CI 2022-01-30 12:29:05 +02:00
Tulir Asokan 03b094e4d4 Update mautrix-python 2022-01-30 12:04:21 +02:00
Tulir Asokan 21b509e5a0 Copy animated sticker args explicitly to remove unsupported args 2022-01-29 18:15:54 +02:00
Tulir Asokan 2732a85f9e Update dependencies 2022-01-26 13:41:20 +02:00
Tulir Asokan 033141e435 Add warning for users who don't know what they're doing 2022-01-22 16:31:43 +02:00
Sumner Evans 251458a1d7 Merge pull request #745 from mautrix/pre-commit-config
pre-commit: add configuration
2022-01-21 14:13:44 -07:00
Sumner Evans 7c4f406ac6 ci: add pre-commit-hooks to lint process 2022-01-21 11:15:52 -07:00
Sumner Evans 984c52afc9 dev-requirements: add pre-commit, isort, black 2022-01-21 11:15:21 -07:00
Sumner Evans f664d4ad90 pre-commit: add configuration 2022-01-21 10:07:12 -07:00
Sumner Evans 8f61be76f9 Merge pull request #738 from mautrix/sumner/bri-1583-telegram-has-disconnected-i-woke-up-to
bridge state: use TRANSIENT_DISCONNECT if connection drops and is expected to come back soon
2022-01-13 07:44:34 -07:00
Tulir Asokan 8003b9aa1c Fix bug in !tg create. Fixes #736 2022-01-12 21:52:25 +02:00
Sumner Evans a0fd98b9e2 bridge state: use TRANSIENT_DISCONNECT if connection drops and is expected to come back soon 2022-01-12 08:59:09 -07:00
Scott Weber feac31e841 Very basic support for live location 2022-01-11 13:36:15 +02:00
Tulir Asokan dd83d6278c Add support for t.me/+code invite links 2022-01-10 23:23:16 +02:00
Tulir Asokan 2a6b075ff2 Bump version to 0.11.1 2022-01-10 15:45:30 +02:00
Tulir Asokan e321bc30d0 Update some small things 2022-01-09 00:06:35 +02:00
Tulir Asokan 63fafec1b7 Make telegram blue text more readable on dark themes. Fixes #729 2022-01-08 23:27:57 +02:00
Tulir Asokan 9f48eca5a6 Use min() instead of sorting list 2022-01-05 21:23:58 +02:00
Tulir Asokan 28845b9daf Update dependencies and fix some things in config updater 2022-01-05 21:01:12 +02:00
Tulir Asokan 113f41d1d2 Deduplicate lottieconverter calls in tgs_converter
Also fix finding first frame file

Fixes #690
Closes #728
2022-01-05 21:00:53 +02:00
Tulir Asokan da3180e290 Delete nulls in message table. Fixes #731 2022-01-05 18:53:10 +02:00
Tulir Asokan 1a62463678 Update changelog 2022-01-05 12:30:38 +02:00
Tulir Asokan e584cf534d Merge branch 'sumner/bri-1517-bridge-voice-messages-telegram-matrix' 2022-01-05 12:09:25 +02:00
Tulir Asokan 4c1267cd32 Merge branch 'maybe-fix-corrupted-db-schema'
Closes #719
2022-01-05 12:09:16 +02:00
Tulir Asokan dc8a3d0c2d Don't use parameters for pg_constraint query 2022-01-05 01:53:57 +02:00
Sumner Evans c481ec850d voice messages: bridge from Telegram to native Matrix
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2022-01-04 14:16:57 -07:00
Tulir Asokan a54dd58de7 Send message checkpoints for Matrix edits too 2022-01-04 21:37:41 +02:00
Tulir Asokan b13da92520 Find constraint names dynamically to work around schemas broken by pgloader 2022-01-03 20:12:55 +02:00
Dominik Fuchß 2b6db85e1a Add missing await to get_input_entity in HTML parser (#724) 2021-12-31 11:19:41 +02:00
Tulir Asokan e7a1216ef7 Don't redact reactions in chats with relaybot
There are usually other Matrix users, so redacting reactions only from
logged-in users would be weird.
2021-12-30 23:34:14 +02:00
Tulir Asokan b1da5c7c2c Don't alter columns to not null on sqlite 2021-12-30 19:59:41 +02:00
Tulir Asokan 3b72de34b3 Fix some things in dedup changes 2021-12-30 19:41:45 +02:00
Tulir Asokan af893554cc Add support for Matrix->Telegram reactions 2021-12-30 18:32:10 +02:00
Tulir Asokan d108ac5d94 Add support for Telegram->Matrix reactions 2021-12-30 17:43:45 +02:00
Tulir Asokan e446121192 Fix order of operations when syncing contacts 2021-12-30 12:20:36 +02:00
Tulir Asokan afb73b1d17 Add support for bridging spoilers 2021-12-29 22:11:11 +02:00
Tulir Asokan aae8f78cb4 Try to drop identity in addition to default and id_seq in puppet/bot_chat tables
Closes #720
Closes #721

Co-authored-by: Carl Ambroselli <git@carl-ambroselli.de>
2021-12-29 12:47:32 +02:00
Tulir Asokan 2a1e5c9d1e Bump version to 0.11.0 2021-12-28 12:50:16 +02:00
Tulir Asokan 89a7c4a0f3 Add changelog link to PyPI 2021-12-28 12:49:17 +02:00
Tulir Asokan bdc9de8070 Bump maximum qrcode version to 7.x 2021-12-28 12:36:47 +02:00
Tulir Asokan de4db8c8a6 Fix get_users dropping one user in non-supergroups if member sync isn't limited 2021-12-28 01:24:36 +02:00
Tulir Asokan cb97c127d6 Add full changelog in a file 2021-12-28 01:08:49 +02:00
Tulir Asokan 173b5ec2e7 Register provisioning API route before public routes. Might fix #487 2021-12-26 15:36:10 +02:00
Tulir Asokan ce0c18003b Fix ! -> / conversion in HTML messages. Fixes #630 2021-12-26 15:22:57 +02:00
Tulir Asokan 80082639b5 Ignore color tags when parsing HTML 2021-12-26 14:30:12 +02:00
Tulir Asokan 7e885f1be2 Bump mautrix-python minimum to stable 0.14 2021-12-26 13:30:14 +02:00
Tulir Asokan 9fd506e098 Change example database config to postgres 2021-12-26 13:12:29 +02:00
Tulir Asokan 7e6978bc10 Change phonenumbers optional dependency name to match mautrix-signal 2021-12-26 13:04:09 +02:00
Tulir Asokan 4e571e6b10 Handle some very old membership values in asyncpg migration 2021-12-23 17:10:31 +02:00
Tulir Asokan 0127bb04ae Update mautrix-python 2021-12-23 16:05:10 +02:00
Tulir Asokan 0711cfb5f7 Ignore phone number parse errors when bridging contact messages 2021-12-22 12:46:31 +02:00
Tulir Asokan 8bc361a154 Don't try to get sponsored messages in private channels 2021-12-21 20:23:15 +02:00
Tulir Asokan 50c6f2b009 Add support for sponsored messages. Fixes #699 2021-12-21 19:51:00 +02:00
Tulir Asokan 190064bfff Automatically convert SQLAlchemy pool_size option to new format 2021-12-21 15:49:27 +02:00
Tulir Asokan 9189d917d0 Update some things 2021-12-21 15:35:21 +02:00
Tulir Asokan f55d6606df Update ensure_future/loop.create_task -> asyncio.create_task 2021-12-21 15:30:54 +02:00
Tulir Asokan 2615e11e34 Move some utility methods from portal to separate files 2021-12-21 15:27:10 +02:00
Tulir Asokan 7595b9c015 Remove alembic stuff
Everyone on v0.10.x already has the latest alembic database reversion,
so it's not needed here anymore.
2021-12-21 13:26:23 +02:00
Tulir Asokan dfe9bd94b1 Fix /id command in private chats 2021-12-21 13:03:02 +02:00
Tulir Asokan 737c4a1104 Add missing await. Fixes #710 2021-12-21 11:21:45 +02:00
Tulir Asokan 82024a3250 Update database settings in example config 2021-12-21 02:00:54 +02:00
Tulir Asokan 3447762d91 Fix dependencies and docker image 2021-12-21 01:55:42 +02:00
Tulir Asokan 055034ed67 Remove tests
They're a bit too broken at this point
2021-12-21 01:47:37 +02:00
Tulir Asokan f768254b83 Install all optional dependencies in dockerfile 2021-12-21 01:46:47 +02:00
Tulir Asokan 6d25e9687e Blacken and isort code 2021-12-21 01:36:24 +02:00
Tulir Asokan f2af17d359 Add support for contact messages 2021-12-21 00:44:46 +02:00
Tulir Asokan 89ab29ea5f Switch from SQLAlchemy to asyncpg/aiosqlite 2021-12-21 00:44:42 +02:00
Tulir Asokan f12f3fe007 Update mautrix-python again 2021-12-15 17:36:52 +02:00
Tulir Asokan 9c14c86358 Update mautrix-python 2021-12-15 14:14:42 +02:00
Tulir Asokan 8603c67347 Move format_duration to mautrix-python
Closes #707
2021-12-14 11:21:44 +02:00
Tulir Asokan 74ec8f4fa6 Catch cases where user has tgid but no session stored 2021-12-08 12:35:36 +02:00
Tulir Asokan 3ddd4449b1 Update mautrix-python 2021-12-08 12:17:26 +02:00
Tulir Asokan 782cd426a4 Switch back to upstream Telethon 2021-12-01 20:53:38 +02:00
Tulir Asokan 2744e7a5a0 Update Docker image to Alpine 3.15 2021-11-30 13:12:42 +02:00
Tulir Asokan ae28d125f2 Fix redaction checkpoints 2021-11-30 12:08:38 +02:00
Brad Murray 05cf150982 Check filter list before handling matrix events 2021-11-29 17:21:10 +02:00
Tulir Asokan 6245c4066f Fix filter_list type hint 2021-11-29 13:37:26 +02:00
Tulir Asokan f7ecc3fdfc Update BridgeState import path 2021-11-28 19:51:59 +02:00
Tulir Asokan 292a218a16 Remove legacy helm chart 2021-11-28 19:51:54 +02:00
Tulir Asokan c095498247 Update dependencies 2021-11-19 18:16:51 +02:00
Sumner Evans 8276692ebf Merge pull request #692 from mautrix/sumner/bri-827-add-bridge-and-remote-message-tracking
Use message send checkpoints
2021-11-17 15:23:50 -07:00
Sumner Evans 7e369dabdc portal/matrix: improve logging
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2021-11-17 15:20:48 -07:00
Tulir Asokan 631ed49ec7 Update comments in example config 2021-11-16 01:08:57 +02:00
Tulir Asokan 25761215c3 Move filter_matrix_event logic to mautrix-python 2021-11-16 01:07:28 +02:00
SOT-TECH b4d4f84161 Update tgs_converter to match updated lottieconverter (#694) 2021-11-16 01:07:13 +02:00
Sumner Evans 8e8360a992 portal/matrix: report message send checkpoint on all message types 2021-11-15 14:25:01 -07:00
Sumner Evans a1f389cb73 example config: add message send checkpoint description 2021-11-15 08:17:10 -07:00
Tulir Asokan 2cc439853f Bump version to 0.10.2 2021-11-13 14:40:39 +02:00
Tulir Asokan 76b2937c18 Update mautrix-python and stop supporting pickle for crypto store
SQLite is now supported for the crypto store instead of pickle (via aiosqlite)
2021-11-13 14:15:32 +02:00
Tulir Asokan f2a9f4ab33 Merge pull request #639 from olmari/patch-1
Add linebreak to "legend"
2021-11-12 23:06:26 +02:00
Tulir Asokan ec375e79d7 Merge pull request #680 from tadzik/tadzik/fix-max-initial-sync-for-chats
Make max_initial_member_sync work for Chats as well as Channels
2021-11-12 23:04:22 +02:00
Tulir Asokan 338a4d9761 Pin Pillow version in dockerfile to same as alpine. Fixes #683 2021-11-01 18:56:08 +02:00
Tulir Asokan 83d457f2b3 Ignore ChannelParticipantBanned in participant list. Fixes #635 2021-10-29 20:24:42 +03:00
Sumner Evans 3507095572 Merge pull request #681 from justinbot/justinbot/dont-log-messages
Don't log entire message contents on exception
2021-10-29 11:24:15 -06:00
Justin Carlson 4e7cf481fd Don't log entire messagecontents on exception. 2021-10-29 12:30:49 -04:00
Tadeusz Sośnierz 0915bb9402 Make max_initial_member_sync work for Chats as well as Channels 2021-10-27 14:46:12 +02:00
Sumner Evans 7c5d1c2959 Merge pull request #676 from justinbot/justinbot/welcome-text-config
Add example config for custom welcome messages
2021-10-26 09:51:08 -06:00
Justin Carlson 8aecf1f84b Update example config. 2021-10-23 12:10:36 -04:00
Justin Carlson 2c45d8dd5b Remove send_welcome_message override 2021-10-23 12:09:16 -04:00
Justin Carlson fac337eaf1 Add example config for welcome messages. 2021-10-22 12:17:25 -04:00
Tulir Asokan e7d8948334 Bump Telethon to update to latest version of layer 133 2021-10-20 21:20:05 +03:00
Tulir Asokan 6b8831872c Allow logout even if session isn't authorized 2021-10-20 20:55:11 +03:00
Tulir Asokan 4e8c373d1b Delete session on log_out() even if telegram logout fails 2021-10-20 20:24:47 +03:00
Tulir Asokan 8865dab6b0 Push bad credentials state if session isn't valid in start() 2021-10-20 20:12:23 +03:00
Tulir Asokan e4a2bd2f69 Catch authorization errors in get_me() 2021-10-20 20:02:09 +03:00
Tulir Asokan a132916525 Update Telethon
The upstream dev doesn't want to make new releases anymore before 2.0,
so this is temporarily using a fork. The main change is API layer 133,
which updates all user/chat IDs to be 64-bit.
2021-10-19 12:40:34 +03:00
Tulir Asokan a9dcb34b2d Use existing power levels as base for user levels instead of hardcoded values 2021-10-19 12:40:34 +03:00
Tulir Asokan 74c43355e4 Decrypt fetched messages to generate reply fallback 2021-10-19 12:40:34 +03:00
Sumner Evans 7255e86595 Merge pull request #670 from mautrix/ci-update-container-versions-on-success
ci: only update container versions on success
2021-10-14 12:30:36 -06:00
Sumner Evans e4098a226e ci: only update container versions on success 2021-10-14 09:45:39 -06:00
Sumner Evans 5dea5977ad Merge pull request #662 from mautrix/ci-auto-update-version
ci: deploy to dev stable and internal automatically
2021-09-17 19:04:14 -04:00
Sumner Evans 1c9a30773e ci: deploy to dev stable and internal automatically 2021-09-17 12:19:14 -04:00
Tulir Asokan e276944b40 Implement get_bridge_states 2021-08-25 16:04:50 +03:00
Tulir Asokan 2e14991815 Remove element ios hack from non-sticker documents 2021-08-20 14:00:42 +03:00
Tulir Asokan 3083727aff Add extension to unnamed file names. Fixes #646 2021-08-20 14:00:25 +03:00
Tulir Asokan d778c639dc Bump maximum Telethon version 2021-08-19 15:08:20 +03:00
Tulir Asokan 10de186598 Bump version to 0.10.1 2021-08-19 14:57:33 +03:00
Tulir Asokan 64107fab17 Add video flags for animated stickers 2021-08-19 14:43:57 +03:00
Tulir Asokan 52bfbddcca Add flag to invite events that will be auto-accepted 2021-08-18 20:48:11 +03:00
Tulir Asokan 5d9cc490d7 Fix public_portals setting not being respected on portal creation 2021-08-17 00:17:21 +03:00
Tulir Asokan 13cac8db9a Reset TelegramClient completely on AuthKeyDuplicatedError 2021-08-16 20:54:02 +03:00
Tulir Asokan 3ab5e4d8cc Move remaining manhole stuff to mautrix-python 2021-08-14 16:22:31 +03:00
Tulir Asokan 7e728dd5af Fix incorrect states being sent when stopping client 2021-08-13 16:48:19 +03:00
Tulir Asokan 597d2e3282 Bump asyncpg version 2021-08-13 13:00:07 +03:00
Tulir Asokan 57611a3f30 Catch AuthKeyDuplicatedError in start() 2021-08-13 12:59:24 +03:00
Tulir Asokan ec64c83cb0 Merge pull request #652 from mautrix/new-bridge-state
Upgrade mautrix to 0.10.2 and use new BridgeStateEvents
2021-08-10 19:53:59 +03:00
Tulir Asokan ecdaaea3b9 Don't send connected state when sync is in progress 2021-08-10 18:14:32 +03:00
Tulir Asokan bda41417aa Update repo path 2021-08-06 17:46:24 +03:00
Sumner Evans 5a76b5bcdc Upgrade mautrix to 0.10.2 and use new BridgeStateEvents 2021-08-04 16:49:56 -06:00
Tulir Asokan 4edd8eaa7b Ignore everything after ; in Matrix location events 2021-08-02 12:52:21 +03:00
Tulir Asokan 742a925040 Change some things 2021-08-02 12:52:05 +03:00
Sami Olmari bcede7710f Add linebreak to "legend"
Signed-off-by: Sami Olmari <sami@olmari.fi>
2021-07-06 09:57:33 +03:00
Tulir Asokan c02f67e0d1 Send warning if bridge bot doesn't have redaction permissions 2021-06-23 18:44:32 +03:00
Tulir Asokan 31650aac96 Update Docker image to Alpine 3.14 2021-06-23 17:50:48 +03:00
Tulir Asokan 730f6bab6f Update to Telethon 1.22 2021-06-22 19:42:36 +03:00
Tulir Asokan f923552f86 Update mautrix-python (ref #623) 2021-06-22 19:25:27 +03:00
Tulir Asokan eca1032d16 Ignore typing notifications from double puppeted users. Fixes #631 2021-06-19 22:48:43 +03:00
Tulir Asokan 570372fa83 Bump version to 0.10.0 2021-06-14 19:45:00 +03:00
Tulir Asokan 5ed09ad783 Fix Telegram->Matrix typing notifications 2021-06-10 15:44:12 +03:00
Tulir Asokan c385aa0b8d Add real-time bridge status push option 2021-06-09 20:04:17 +03:00
Tulir Asokan ec152cbd9d Pass through Telegram gif meta as custom fields 2021-05-24 16:04:53 +03:00
Tulir Asokan b36fc35e04 Don't remove zero-width joiners from middle of displaynames 2021-05-23 16:28:26 +03:00
Tulir Asokan 198e77cae9 Remove commented edge things from dockerfile 2021-05-13 14:56:09 +03:00
Tulir Asokan 9c4beb29a5 Send m.bridge data when bridging existing room to Telegram 2021-05-12 19:21:37 +03:00
Tulir Asokan 6accb530c6 Add option to only bridge mute status and tags when creating portal 2021-04-29 12:09:54 +03:00
Tulir Asokan 1a77ba5fcd Add option to bridge archive, pin and mute status from Telegram 2021-04-20 14:52:19 +03:00
Tulir Asokan 7e9dd8b895 Update mautrix-python 2021-04-16 15:27:56 +03:00
Tulir Asokan 78fcacf7aa Bump version to 0.10.0rc1 2021-04-05 12:47:11 +03:00
Tulir Asokan 077f5d588b Update dependencies 2021-04-05 12:28:39 +03:00
Tulir Asokan 8b73c67836 Mark chat as fully read on Telegram if read receipt target is unknown 2021-03-31 16:42:35 +03:00
Tulir Asokan 92fa05cb06 Fix handling forwarded messages from known chats without a cached title 2021-03-25 19:40:33 +02:00
Tulir Asokan 18f5a33279 Add some logs when bridging read receipts 2021-03-25 19:12:33 +02:00
Tulir Asokan f9a6e9c4fb Fix other usages of Puppet.get_displayname 2021-03-23 20:22:05 +02:00
Tulir Asokan abfefab545 Store puppet displayname quality and don't allow it to decrease 2021-03-23 20:13:06 +02:00
Tulir Asokan 79f8c520bd Move RowProxy import into type checking 2021-03-22 13:51:49 +02:00
Tulir Asokan fa35ed1cb6 Sync own read marker to Matrix when backfilling chats 2021-03-22 13:51:22 +02:00
Tulir Asokan 2e8d612078 Merge remote-tracking branch 'MadhuranS/master'
Fixes #375
2021-03-18 20:33:31 +02:00
Madhu Sivapragasam 4801b0f323 Added about section update bot command 2021-03-18 13:52:02 -04:00
Tulir Asokan 783c94dadd Pin SQLAlchemy to <1.4. Fixes #595 2021-03-15 23:23:26 +02:00
Tulir Asokan c8cf662ad0 Catch network errors when setting puppet displayname/avatar 2021-03-14 12:34:31 +02:00
Tulir Asokan cd70e6b836 Switch to BIGINT for Telegram IDs in database 2021-03-09 22:03:23 +02:00
Tulir Asokan 72cfbf71f8 Fix finding largest photo size. Fixes #586 2021-02-28 14:22:17 +02:00
Tulir Asokan cb36800c75 Maybe fix parallel transfer. Fixes #587 2021-02-28 14:13:07 +02:00
Tulir Asokan 559c504e8b Improve formatting of dice messages 2021-02-28 13:53:50 +02:00
Tulir Asokan de3a37f40c Update Telethon and add support for invite link customization 2021-02-28 13:16:07 +02:00
Tulir Asokan 6020cdf8bf Let mautrix-python handle registration generation message 2021-02-21 17:24:35 +02:00
Tulir Asokan 429cb07b79 Handle missing input entities better when creating groups. Fixes #379 2021-02-14 16:36:21 +02:00
Tulir Asokan 2cf93c5765 Replace wiki with docs.mau.fi 2021-02-13 21:27:34 +02:00
Tulir Asokan db41c8d806 Bump maximum Telethon version again 2021-02-06 13:53:47 +02:00
Tulir Asokan 5313369d85 Revert "Bump maximum Telethon version". Fixes #582
This reverts commit c8c17dac01.
2021-02-06 13:00:12 +02:00
Tulir Asokan c8c17dac01 Bump maximum Telethon version 2021-02-05 19:49:12 +02:00
Tulir Asokan bbb864773f Update Docker image to Alpine 3.13 2021-02-05 19:47:26 +02:00
Tulir Asokan 4767fec86e Update mautrix-python 2021-01-23 01:21:32 +02:00
Tulir Asokan 6d57f070f9 Fix updating names of contact users. Fixes #570 2021-01-21 21:24:16 +02:00
Tulir Asokan 97d47d80ee Allow displayname updates if ghost user has no name 2021-01-21 16:34:10 +02:00
Steffen Deusch 35f59b5f95 fix async puppet default leave 2021-01-16 02:42:55 +02:00
Tulir Asokan 697fb06909 Try to fix displayname changing between contact and non-contact name. Fixes #533 2021-01-01 12:02:21 +02:00
Tulir Asokan efd536357c Fix sticker bridging. Fixes #566 2020-12-28 13:06:59 +02:00
Tulir Asokan 2c917a559c Log raw event that caused displayname updates 2020-12-28 13:06:59 +02:00
Rafaeltheraven b97c1a1b59 Allow enabling room encryption with PL 50 if end-to-bridge encryption is enabled (#550) 2020-12-23 13:18:03 +02:00
Tulir Asokan 9237046b96 Install yq from alpine repos 2020-12-19 14:14:46 +02:00
Tulir Asokan 646bbceb99 Remove webp conversion 2020-12-19 14:14:33 +02:00
Tulir Asokan e9e164c679 Stringify URL when following redirects 2020-12-19 13:36:04 +02:00
Tulir Asokan 033c6c698a Rename Riot to Element in comments about how bad they are 2020-12-19 13:28:49 +02:00
Tulir Asokan 3d403c2471 Add option to resolve redirects in invite links. Fixes #559 2020-12-19 13:15:27 +02:00
Tulir Asokan b22e3d2573 Improve invite link regex
Fixes #554
Fixes #555
2020-12-19 13:10:19 +02:00
Tulir Asokan 7d20c5b732 Fix deduplicating forwarded messages. Fixes #549 2020-12-19 12:54:58 +02:00
Tulir Asokan 2ce2337674 Stringify base_url before inserting to db. Fixes #546 2020-12-19 12:52:10 +02:00
Tulir Asokan 3fe26ae4dd Strip spaces around messages when hashing for deduplication. Fixes #553 2020-12-19 12:49:48 +02:00
Tulir Asokan 6f4faf7a58 Store Matrix redaction state and ignore deletions of redacted messages 2020-12-19 12:48:08 +02:00
Tulir Asokan e1dcfb76f4 Update dependencies and python_requires 2020-12-12 14:01:54 +02:00
Tulir Asokan f658f2c5b7 Fix bugs 2020-12-02 12:11:11 +02:00
Tulir Asokan dd7eed834c Update telethon 2020-12-02 12:01:20 +02:00
Tulir Asokan e4f8b22bc6 Merge branch 'telethon-1.18' 2020-12-02 11:59:39 +02:00
Tulir Asokan 0b8fa5ea06 Update mautrix-python. Fixes #472 2020-12-02 00:34:13 +02:00
Tulir Asokan 140fcae403 Fix Matrix->Telegram location message bridging 2020-11-22 13:47:20 +02:00
Tulir Asokan 95920728f4 Bump version to 0.9.0 2020-11-17 18:01:14 +02:00
Tulir Asokan e85be95d2d Fix cleaning unidentified rooms. Fixes #541 2020-11-17 18:01:06 +02:00
Tulir Asokan 3006b3ab3b Update mautrix-python 2020-11-17 17:57:29 +02:00
Tulir Asokan d4d6cfa87d Bump version to 0.9.0rc3 2020-11-12 01:41:44 +02:00
Tulir Asokan b8cfcbe5ee Set nova nightly image hash in CI 2020-11-11 23:19:19 +02:00
Tulir Asokan 9875833c90 Use correct relation type for replies 2020-11-10 12:31:03 +02:00
Tulir Asokan 38d94484bb Use mautrix utility function for file upload retry 2020-11-10 00:21:36 +02:00
Tulir Asokan 0b3014ff88 Retry sending messages if server returns 502 2020-11-09 21:01:30 +02:00
Tulir Asokan 04c64949e7 Update mautrix-python 2020-11-07 16:01:38 +02:00
Tulir Asokan be59d50678 Fix Matrix->Telegram name mentions 2020-11-07 16:01:21 +02:00
Tulir Asokan 04e2497dd3 Bump version to 0.9.0rc2 2020-11-06 21:30:07 +02:00
Tulir Asokan 2e27e85ac5 Add support for multiple pins 2020-11-06 18:57:22 +02:00
Tulir Asokan 2c59cb4871 Fix sending plaintext captions to Telegram 2020-11-06 18:14:20 +02:00
Tulir Asokan 64ddd07171 Update mautrix-python 2020-11-05 22:19:09 +02:00
Tulir Asokan 1b91fbc806 Check room encryption status when bridging portal 2020-10-30 20:16:02 +02:00
Tulir Asokan 2b6cffc8ef Fix bugs in manual bridging that were added by the previous fix 2020-10-30 19:55:43 +02:00
Tulir Asokan 5cc0afef85 Let mautrix-python handle generating namespaces for the registration 2020-10-30 19:46:37 +02:00
Tulir Asokan 52adbb7335 Fix potential bugs in manual bridging 2020-10-30 19:46:02 +02:00
Tulir Asokan dd3bdd2846 Allow unbridging direct chat portals. Fixes #495 2020-10-29 23:02:37 +02:00
Tulir Asokan f088599dec Disconnect from Telegram after logging out 2020-10-29 22:38:54 +02:00
Tulir Asokan fe573865aa Completely delete private chat portals when user logs out
If it just kicks the user, logging in again later would cause the
bridge to think there's a portal, but fail to invite the user again.

Fixes #397
2020-10-29 22:33:22 +02:00
Tulir Asokan 5316ed57af Send link to Telegram ToS when signing up 2020-10-28 18:54:12 +02:00
Tulir Asokan 1567239ae6 Update connection metric after logging in 2020-10-28 18:44:50 +02:00
Tulir Asokan 24c65f8942 Don't set bridge_connected metric for non-logged-in users 2020-10-28 18:14:12 +02:00
Tulir Asokan 213e63830d Update mautrix-python and unpin yarl/aiohttp 2020-10-28 12:34:11 +02:00
Tulir Asokan efe532e4d0 Don't check user database when handling ephemeral events 2020-10-27 16:49:54 +02:00
Tulir Asokan 8392f46db9 Fix bugs in left member check 2020-10-27 15:37:38 +02:00
Tulir Asokan 87cacc9b20 Update mautrix-python 2020-10-27 15:19:19 +02:00
Tulir Asokan d808893274 Move clean-rooms command to mautrix-python 2020-10-26 19:56:20 +02:00
Tulir Asokan ab671ac7eb Bump version to 0.9.0rc1 2020-10-24 21:35:01 +03:00
Tulir Asokan 2343e85f4d Update ROADMAP.md 2020-10-24 21:33:59 +03:00
Tulir Asokan 70a6b847e2 Fix random bugs and update mautrix-python 2020-10-24 21:13:57 +03:00
Tulir Asokan a3f6bc2acb Add config option for receiving ephemeral events with MSC2409 2020-10-24 21:01:34 +03:00
Tulir Asokan 1bce95586b Update mautrix-python 2020-10-24 20:24:06 +03:00
Tulir Asokan 80aa557e0c Fix resolving UpdateNewMessage sender in private chats 2020-10-23 23:42:45 +03:00
Tulir Asokan 686e26a503 Merge branch 'telethon-1.17' 2020-10-22 17:42:25 +03:00
Tulir Asokan a33cdae4c3 Add missing parameter to get_user 2020-10-16 17:11:26 +03:00
Tulir Asokan 258f665338 Update mautrix-python 2020-10-16 15:18:34 +03:00
Tulir Asokan 3b70829d72 Use Gauge instead of Enum to count connected users 2020-10-15 18:35:21 +03:00
Tulir Asokan 524f60ab48 Update to mautrix-python 0.8.0.beta3
* Cross-server double puppeting is now possible
* End-to-bridge encryption no longer requires login_shared_secret,
  but the homeserver must support MSC2778 (Synapse 1.21+)
2020-10-14 18:56:26 +03:00
Tulir Asokan fdc58ce450 Fix bridging non-image files 2020-10-13 13:38:44 +03:00
Tulir Asokan a4595b427d Don't send delivery receipts to unencrypted private chat portals. Fixes #483 2020-10-09 16:50:12 +03:00
Tulir Asokan 522e33be12 Add png thumbnails for webm animated stickers. Fixes #467 2020-10-09 16:47:41 +03:00
Tulir Asokan 146a79b516 Update mautrix-python 2020-10-09 14:50:37 +03:00
Tulir Asokan 4f85cf1723 Update preview and readme 2020-10-05 11:42:14 +03:00
Tulir Asokan d35799e2ce Add some checks 2020-10-04 15:09:07 +03:00
Tulir Asokan a003e2e979 Update for Telethon 1.17 and TL layer 119 2020-10-02 22:05:15 +03:00
Tulir Asokan f4b8e85689 Update mautrix-python 2020-10-02 14:58:51 +03:00
Tulir Asokan 6b94097f29 Bump mautrix-python version 2020-09-25 15:40:19 +03:00
Tulir Asokan 6e1dbf3a8e Update mautrix-python 2020-09-22 13:10:12 +03:00
Tulir Asokan 0dc56aad1c Update prometheus stuff 2020-09-19 01:04:34 +03:00
Tulir Asokan a565853c5e Update things in setup.py 2020-09-18 17:41:49 +03:00
Tulir Asokan ac56ee1553 Bump mautrix-python version 2020-09-18 17:35:14 +03:00
Tulir Asokan 349914f447 Update mautrix-python 2020-09-14 00:41:04 +03:00
Tulir Asokan 2a1bddf5e4 Move prometheus setup to mautrix-python 2020-09-09 14:02:37 +03:00
Tulir Asokan 668dad9c6f Move .github metadata to common repo 2020-09-09 01:19:38 +03:00
Tulir Asokan 2b978be79c Update deps 2020-09-04 16:50:06 +03:00
Tulir Asokan 66917b6db0 Add option to update m.direct with double puppeting 2020-08-21 21:20:49 +03:00
Tulir Asokan 292745866d Improve trust member list check 2020-08-19 00:21:01 +03:00
Tulir Asokan f86fabafbe Trust member list if there are less members than the sync limit 2020-08-19 00:18:28 +03:00
Tulir Asokan 48a624bd07 Re-add custom get_users method to avoid expensive API calls 2020-08-19 00:11:52 +03:00
Tulir Asokan 66c2e779ea Add mutex for backfill method 2020-08-18 23:56:24 +03:00
Tulir Asokan f84dcb64d3 Replace custom get_users with client.get_participants 2020-08-18 23:41:38 +03:00
Tulir Asokan 95bb974ca6 Update handling of deleted members 2020-08-18 20:32:41 +03:00
Tulir Asokan 953ef0e5bc Maybe fix encrypted parallel file transfer 2020-08-18 20:27:40 +03:00
Tulir Asokan 1b2024e456 Update username even if disable_updates is true 2020-08-18 20:27:10 +03:00
Tulir Asokan e961c3a9ed Pass through messages even if they're commands 2020-08-16 18:24:48 +03:00
Tulir Asokan 22d50208d8 Fix checking if message is command 2020-08-16 18:24:48 +03:00
Tulir Asokan b43cc72de2 Merge pull request #518 from kubesail/master
add jq / yq
2020-08-16 00:09:47 +03:00
Dan Pastusek a06691b214 add TARGETARCH as build arg in ci pipeline 2020-08-14 15:39:53 -06:00
Dan Pastusek 3461ee6a72 remove empty line 2020-08-14 15:04:12 -06:00
Dan Pastusek 8662db67b8 add jq / yq 2020-08-14 15:03:15 -06:00
Tulir Asokan 321a7810c4 Catch individual errors when syncing dialogs 2020-08-06 20:42:19 +03:00
Tulir Asokan eae7bba649 Update to mautrix-python v0.7 2020-08-06 20:34:09 +03:00
Tulir Asokan 92c572d761 Maybe fix parallel file transfer 2020-08-04 16:56:59 +03:00
Tulir Asokan 868ebf2025 Improve YAML handling in !tg config. Fixes #377 2020-08-02 21:19:20 +03:00
Tulir Asokan 9f9182c564 Show upgraded rooms separately in clean-rooms list. Fixes #369 2020-08-02 01:00:09 +03:00
Tulir Asokan c62774f1a6 Implement disappearing photos. Fixes #481 2020-08-02 00:54:37 +03:00
Tulir Asokan eace9b4ef6 Unregister old chat when a group is upgraded 2020-08-02 00:54:16 +03:00
Tulir Asokan bc4610af04 Add option to disable backfilling normal groups 2020-08-01 14:11:34 +03:00
Tulir Asokan 729fa8eb46 Update Telethon 2020-07-30 21:26:20 +03:00
Tulir Asokan 8ca78e21b6 Remove incorrect check in own read receipt bridging 2020-07-30 19:22:13 +03:00
Tulir Asokan b17454723e Bridge own read receipts from other Telegram clients with double puppeting 2020-07-30 19:20:39 +03:00
Tulir Asokan 5e8aa8818f Implement disabling notifications while backfilling 2020-07-29 22:47:00 +03:00
Tulir Asokan ffcfd019c2 Fix auto-accepting private chat portals with double puppeting 2020-07-29 22:21:26 +03:00
Tulir Asokan 7298d9dfdc Handle channel messages correctly in backfill 2020-07-29 22:19:21 +03:00
Tulir Asokan be3b135cc7 Merge branch 'automatic-backfill'
Fixes #476
Fixes #477
2020-07-29 22:15:48 +03:00
Tulir Asokan 9848f8b92c Separate dialog syncing and creation limits and fix bugs 2020-07-29 21:55:51 +03:00
Tulir Asokan 59eb7376c9 Add missed message backfilling 2020-07-28 18:32:34 +03:00
Tulir Asokan ea017467fd Add support for football 2020-07-28 18:01:44 +03:00
Tulir Asokan 2c0a2e694b Add option for automatic backfilling when creating portal 2020-07-28 17:28:07 +03:00
Tulir Asokan 993354bce5 Update mautrix-python 2020-07-27 13:28:08 +03:00
Tulir Asokan 8299b68b96 Update wording in roadmap 2020-07-27 12:36:49 +03:00
Tulir Asokan bf9f9e1064 Merge pull request #503 from SharkyRawr/dbms-import-fix
Fixup `mautrix_telegram.scripts.dbms_migrate` imports as they changed upstream
2020-07-26 22:25:56 +03:00
Sophie 'Sharky' Schumann 5cf8a7a8a4 Fixup mautrix_telegram.scripts.dbms_migrate import for RoomState and UserProfile as it changed upstream. 2020-07-26 21:20:49 +02:00
Tulir Asokan da91df5754 Make management API comment more accurate 2020-07-23 20:16:27 +03:00
Tulir Asokan 341b69ed75 Update roadmap 2020-07-16 15:18:12 +03:00
Tulir Asokan a7a3ce4ea1 Update mautrix-python to fix duplicate message indexes in e2be 2020-07-12 22:03:59 +03:00
Tulir Asokan f83d03fb16 Update mautrix-python a third time 2020-07-12 17:23:52 +03:00
Tulir Asokan 34e1935a97 Update mautrix-python again 2020-07-12 16:34:25 +03:00
Tulir Asokan 0080b028bf Update mautrix-python 2020-07-12 15:48:35 +03:00
Tulir Asokan 689d84fa78 Move enable_dm_encryption helper to Portal 2020-07-09 19:45:28 +03:00
Tulir Asokan 64c9759de8 Update mautrix-python again and fix bugs in accepting invites as puppets 2020-07-09 19:05:40 +03:00
Tulir Asokan 31cac3eef3 Update mautrix-python 2020-07-09 16:59:01 +03:00
Tulir Asokan 4e670a8cbe Switch to mautrix-python crypto 2020-07-08 23:05:39 +03:00
Tulir Asokan bbfcc9d7d8 Fix handling messages with PhotoEmpty. Fixes #494 2020-07-06 12:41:04 +03:00
Tulir Asokan 29cc98a7f5 Update Telethon and mautrix-python 2020-07-05 13:47:17 +03:00
Tulir Asokan 8e54d2e253 Add basketball to known dice throw emojis 2020-07-05 13:47:08 +03:00
Tulir Asokan dd69204f5a Move handle_telegram_text log to trace level (ref #321) 2020-07-04 22:01:01 +03:00
Tulir Asokan 44a102c3b1 Automatically accept invitations when using double puppeting 2020-06-24 23:33:22 +03:00
Tulir Asokan f487853954 Fix handling file captions. Fixes #475 2020-06-24 22:32:16 +03:00
Tulir Asokan a29d9cf4ff Add QR login command. Fixes #399
Requires LonamiWebs/Telethon#1494 until it's merged, then requires using
the master branch of Telethon until a release is made.
2020-06-24 15:04:51 +03:00
Tulir Asokan 3fa6ed74e5 Fix sign in location messages 2020-06-22 13:53:00 +03:00
Tulir Asokan d3c1c2be6c Update deps 2020-06-18 10:51:56 +03:00
Tulir Asokan f274fe1cf6 Add FUNDING.yml 2020-06-18 10:48:04 +03:00
Tulir Asokan f358eab214 Don't mutate EventType objects 2020-06-17 16:39:56 +03:00
Tulir Asokan 59d76148dc Don't try to send m.bridge events before portal is created 2020-06-15 16:13:49 +03:00
Tulir Asokan 489e520ddd Add option to resend bridge info to all portals 2020-06-15 15:30:57 +03:00
Tulir Asokan 60ecb03f64 Add external url to bridge info 2020-06-15 15:02:08 +03:00
Tulir Asokan 8a99e67c6d Update bridge info when portal metadata changes 2020-06-15 14:43:38 +03:00
Tulir Asokan 482a52cb5e Fix using edge repos in docker image. Fixes #482 2020-06-11 19:46:29 +03:00
Tulir Asokan ba13c5cae1 Send "delivery" receipt for messages bridged from Telegram 2020-06-11 19:09:01 +03:00
Tulir Asokan 4b57be3917 Bump version to 0.8.1 2020-06-08 17:45:19 +03:00
Tulir Asokan 9383e5eed2 Allow any 0.5.x version of mautrix-python
Fixes #479
2020-06-08 12:36:18 +03:00
Tulir Asokan a3b4a5e30e Update Docker image to Alpine 3.12 2020-06-06 20:10:14 +03:00
Tulir Asokan 72a45d7d80 Bump version to 0.8.0 2020-06-03 15:37:07 +03:00
Tulir Asokan bcf464428a Bump version to 0.8.0rc5 2020-05-30 13:18:58 +03:00
Tulir Asokan f3b9f4bf73 Bump maximum Telethon version 2020-05-29 15:28:53 +03:00
Tulir Asokan 10e54ed789 Add option to send delivery error notices 2020-05-29 15:28:41 +03:00
Tulir Asokan 35da8df526 Add option to disable removing avatars from Telegram ghosts
There's no way to determine whether an avatar is removed or just hidden
from some users, so avatars are not removed by default.
2020-05-29 15:27:38 +03:00
Tulir Asokan fb1ab220ff Update ROADMAP.md 2020-05-28 12:56:56 +03:00
Tulir Asokan 2dd39fddf0 Try to prevent infinite loop of state changes with double puppeting
Fixes #464
2020-05-27 12:36:51 +03:00
Tulir Asokan 7f69e9f329 Bump mautrix-python version 2020-05-25 14:11:03 +03:00
Tulir Asokan 3f6a4237ad Add option to send read receipt on confirmed delivery to Telegram 2020-05-25 13:25:37 +03:00
Tulir Asokan ee04e8c17f Bump mautrix-python req to rc1 2020-05-22 22:19:36 +03:00
Tulir Asokan 7d75c15027 Actually fix branch/tag condition in CI 2020-05-22 22:19:27 +03:00
Tulir Asokan 312a44d361 Add sponsors section to README.md 2020-05-22 22:00:23 +03:00
Tulir Asokan 85d38e3db6 Bump version to 0.8.0rc3 2020-05-22 20:49:47 +03:00
Tulir Asokan 3a25ee2c93 Merge pull request #468 from davidmehren/fix-peerchannel-admin
Fix admin detection in _can_use_commands
2020-05-22 20:02:53 +03:00
Tulir Asokan a4d49a41e0 Maybe fix branch condition in CI 2020-05-21 19:35:04 +03:00
David Mehren 7ba9e10f0f Fix admin detection in _can_use_commands 2020-05-21 09:44:27 +02:00
Tulir Asokan 05e966011e Fix error syncing private chat portals with no avatar 2020-05-20 23:29:36 +03:00
Tulir Asokan 9081f6bce4 Bump mautrix-python requirement 2020-05-20 23:17:42 +03:00
Tulir Asokan c126e8b615 Actually ignore ChatForbidden when syncing. Fixes #446 2020-05-20 22:45:22 +03:00
Tulir Asokan f454803ef7 Move private information to trace log level. Fixes #321 2020-05-20 22:40:20 +03:00
Tulir Asokan 40beb8f752 Add private_chat_portal_meta option and fix bugs
* The new option is implicitly enabled when encryption is default
* Private chat metadata is now updated after creating the room too
* The puppet metadata is updated before creating the room, to make sure their
  name is available locally
2020-05-20 21:19:42 +03:00
Tulir Asokan 4d8d332732 Bump version to 0.8.0rc2 2020-05-20 19:13:54 +03:00
Tulir Asokan 7fb771b992 Fix copying example config on first run of docker image 2020-05-20 19:13:45 +03:00
Tulir Asokan d0900a95a7 Send uk.half-shot.bridge in addition to m.bridge 2020-05-19 11:37:17 +03:00
Tulir Asokan 8552d463a1 Add missing receiver_id when syncing direct chat dialogs (ref #425) 2020-05-19 11:30:45 +03:00
Tulir Asokan 74d130644c Fix tempfile usage 2020-05-17 15:01:03 +03:00
Tulir Asokan 976e0dd2b7 Fix !tg version command for non-release versions in docker 2020-05-13 23:34:43 +03:00
Tulir Asokan 340c25ba0b Use stdlib tempfile for video thumbnail temp files 2020-05-13 23:33:24 +03:00
Tulir Asokan 7e8d4bc9a8 Include readme, license and requirements.txt in PyPI tarballs 2020-05-13 23:33:08 +03:00
Tulir Asokan 429544373a Bump mautrix-python and send m.bridge events 2020-05-05 21:40:57 +03:00
Tulir Asokan 80dd6fa9e1 Fix typo in unbridge permission error 2020-04-27 13:21:49 +03:00
Tulir Asokan 45ac120407 Add error message if backfill is ran in non-portal room 2020-04-25 23:24:39 +03:00
Tulir Asokan 2c100ca1e5 Fix minor mistake in logging 2020-04-25 19:31:12 +03:00
Tulir Asokan c54bd9e1ce Log the source and reason of user displayname changes 2020-04-25 19:29:12 +03:00
Tulir Asokan a2a35e481a Bump version to 0.8.0rc1 2020-04-25 18:34:10 +03:00
Tulir Asokan 84ff0c777d Allow !tg random command with text names instead of emojis 2020-04-25 18:33:34 +03:00
Tulir Asokan 37ecd57a9b Update telethon and add support for darts. Fixes #457 2020-04-25 18:25:00 +03:00
Tulir Asokan 8578a9bd01 Merge pull request #455 from davidmehren/fix-create-matrix-room
Do not crash in _create_matrix_room if `invites` is `None`
2020-04-25 15:26:34 +03:00
Tulir Asokan 6b64f38fa3 Merge pull request #452 from jevolk/master
TLS listener configuration related
2020-04-25 15:25:37 +03:00
Tulir Asokan ea9206f56b Add support for sending and receiving dice 2020-04-21 10:01:33 +03:00
David Mehren 467c0989e1 Do not crash in _create_matrix_room if invites is None 2020-04-17 18:19:44 +02:00
Jason Volk 2a0d44acc5 Ensure config.yaml update order preservation by including tls items in example-conf.yaml 2020-04-08 00:58:53 -07:00
Jason Volk a9b28b54d5 Fix missing config update copy() for tls items. 2020-04-08 00:56:35 -07:00
Tulir Asokan c296a5d4a4 Merge pull request #449 from halkeye/run-db-migration-after-configs
Run migrations after config file is in place, so it can be properly generated
2020-04-06 10:19:54 +03:00
Tulir Asokan 10926a1240 Use chat.id instead of get_peer_id(chat) for Dialog. Fixes #450 2020-04-06 10:17:13 +03:00
Tulir Asokan 992e962df7 Fix async for typo. Fixes #448 2020-04-06 10:06:12 +03:00
Gavin Mogan 7726925771 Run migrations after config file is in place, so it can be properly generated 2020-04-05 23:50:41 -07:00
Tulir Asokan a53b0e9837 Fix potential KeyError in power level syncing 2020-04-04 22:01:59 +03:00
Tulir Asokan 26eb2d4e54 Remove extra COPY statements in dockerfile 2020-04-04 21:48:53 +03:00
Tulir Asokan b53b27cf2d Use separately built image for lottieconverter to improve caching 2020-04-04 21:38:21 +03:00
Tulir Asokan cecda22ec3 Adjust editorconfig for .gitlab-ci.yml 2020-04-04 21:37:58 +03:00
Tulir Asokan dc5fe62e3a Merge branch 'e2be' into master 2020-04-04 20:39:08 +03:00
Tulir Asokan c957989abb Merge branch 'master' into e2be 2020-04-03 22:18:28 +03:00
Tulir Asokan 708fec6886 Add missing check 2020-04-03 22:18:07 +03:00
Tulir Asokan 32db2355a2 Add pysocks to dockerfile
Closes #445
2020-04-03 22:13:02 +03:00
Tulir Asokan c1d4e8e482 Update mautrix-python to use SQLAlchemy for matrix-nio state storage 2020-03-31 22:19:43 +03:00
Tulir Asokan a00c58e521 Decrypt encrypted media from Matrix 2020-03-30 21:47:41 +03:00
Tulir Asokan 698b56afcf Encrypt media being sent to Matrix in encrypted rooms 2020-03-30 21:47:13 +03:00
Tulir Asokan af285c5ffe Allow matrix-nio 0.10 2020-03-30 01:10:13 +03:00
Tulir Asokan 37917c497e Fix encrypting outgoing Matrix events after restart 2020-03-30 01:04:12 +03:00
Tulir Asokan 50ec2551f8 Remove all automatic matrix-nio state receiving
All state is now fed to nio from the appservice state event stream instead of
/sync. This should remove all race conditions of trying to encrypt messages
before nio is synced.
2020-03-29 14:28:22 +03:00
Tulir Asokan 4519c88230 Bump mautrix-python version 2020-03-29 02:12:40 +02:00
Tulir Asokan d84724b8b0 Fix copying example config in docker 2020-03-29 01:58:38 +02:00
Tulir Asokan 56d21bdf59 Add support for enabling encryption by default 2020-03-29 01:37:00 +02:00
Tulir Asokan 260c1612a6 Install matrix-nio dependencies from alpine packages when available 2020-03-28 23:09:08 +02:00
Tulir Asokan 6ab3106b38 Add libolm to docker image 2020-03-28 22:43:28 +02:00
Tulir Asokan c79d442158 Add initial Matrix end-to-bridge encryption support 2020-03-28 22:01:23 +02:00
Tulir Asokan 7a6de144ce Merge pull request #438 from anoadragon453/anoa/group_id_example
Provide an example of the community ID format in the example config
2020-03-25 12:19:27 +02:00
Andrew Morgan 5240999f56 Merge branch 'master' of https://github.com/tulir/mautrix-telegram into anoa/group_id_example
* 'master' of https://github.com/tulir/mautrix-telegram:
  Add hack for Riot iOS being dumb about thumbnails
  Update to mautrix-python 0.5.0
  Optimize dockerfile a bit
  Move dependency versions to requirements.txt
2020-03-25 10:17:56 +00:00
Tulir Asokan 0a94e60e22 Add hack for Riot iOS being dumb about thumbnails 2020-03-24 14:05:54 +02:00
Tulir Asokan c83fdab502 Update to mautrix-python 0.5.0 2020-03-22 00:51:10 +02:00
Andrew Morgan ca0c2fd9e6 Example group id format 2020-03-06 23:11:13 +00:00
Tulir Asokan a0c842acb6 Optimize dockerfile a bit 2020-03-04 23:57:15 +02:00
Tulir Asokan ba17246755 Move dependency versions to requirements.txt 2020-03-04 23:32:14 +02:00
Tulir Asokan af766449d2 Switch default create group type to supergroup 2020-02-29 17:07:06 +02:00
Tulir Asokan 30052b4d74 Fix typo in Puppet.all_with_custom_mxid 2020-02-28 23:00:09 +02:00
Tulir Asokan 9f02b6edb0 Move enabling experimental docker features to before_script 2020-02-25 22:19:14 +02:00
Tulir Asokan 22e24e6e6c Combine amd64 and arm64 docker images into one manifest 2020-02-25 22:00:29 +02:00
Tulir Asokan 48bc1995bb Merge branch 'arm-ci' 2020-02-25 21:28:10 +02:00
Tulir Asokan 854e289bba Merge pull request #420 from n0emis/n0emis-ogg-mimetype
add workaround for application/ogg
2020-02-19 12:14:18 +02:00
Tulir Asokan db9d55a5cc Default to info logs for telethon 2020-02-13 18:49:21 +02:00
n0emis cca0efbd8d add workaround for application/ogg 2020-02-11 00:02:36 +01:00
Serhat Seyren 596446d14b Fix formatted phone number issue for pm command
(cherry picked from commit 5612330e3b)

Fixes #395
Closes #416
2020-02-08 13:18:45 +02:00
Tulir Asokan 578bc7cd5a Only leave group chat portals with default puppet. Fixes #418 2020-02-08 12:50:17 +02:00
Tulir Asokan d58eb52944 Fix ignore_incoming_bot_events check in channels
Fixes #417
2020-02-07 17:36:43 +02:00
Tulir Asokan 906d8322e3 Set version to 0.8.0+dev 2020-02-07 17:36:23 +02:00
Tulir Asokan c2be26adb2 Fix incorrect initial value for Portal.backfilling. Fixes #414 2020-02-05 21:00:28 +02:00
Tulir Asokan cf88823e6f Add support for backfilling private chats 2020-02-04 22:50:58 +02:00
Tulir Asokan 2fbee75453 Add command to backfill room history from Telegram
Currently supports backfilling one room at a time and backfills
everything after the last bridged message.
2020-02-04 22:41:51 +02:00
Tulir Asokan 07edcc4867 Bump version to 0.7.1 2020-02-04 22:31:09 +02:00
Tulir Asokan 65d7934c21 Add missing response to logout provisioning API endpoint 2020-01-28 22:49:48 +02:00
Tulir Asokan 842d98dc1c Bump version to 0.7.1rc2 2020-01-25 23:37:18 +02:00
Tulir Asokan b7e69ddc61 Fix relaybot messages being allowed through with ignore_own_incoming_events set 2020-01-25 23:36:17 +02:00
Tulir Asokan 2dc6041bd7 Add architecture tags 2020-01-20 22:25:20 +02:00
Tulir Asokan b007646d4b Fix syntax 2020-01-20 22:22:47 +02:00
Tulir Asokan 5580f3dc81 Build arm64 docker image and remove separate push step 2020-01-20 22:19:14 +02:00
Tulir Asokan 82f7905367 Add note to Matrix->Telegram EDU bridging 2020-01-13 20:46:00 +02:00
Tulir Asokan 1d8699054c Merge pull request #409 from cubesky/master
Fix mautrix-python import error.
2020-01-12 23:21:18 +02:00
天空/立音 32c521cb79 Fix mautrix-python import error.
Because of mautrix-python library [API Changes](https://github.com/tulir/mautrix-python/commit/04d2ae4c3d4db5f8798f4f844caafb5d00606507). Database migration script is broken.
2020-01-13 02:46:26 +08:00
Tulir Asokan b4cf8cd451 Bump version to 0.7.1rc1 2020-01-11 20:08:47 +02:00
Tulir Asokan 80ff9d0f66 Precalculate list of channel IDs to get info for to fix #393 2020-01-11 20:07:21 +02:00
Tulir Asokan b0e60e60e4 Fix parameter name error in has_power_level call 2020-01-11 19:58:08 +02:00
Tulir Asokan c4b9a76931 Merge pull request #406 from Ma27/fix-tests
Fix several broken tests that were missing some required positional arguments
2019-12-28 14:56:21 +02:00
Maximilian Bosch fe52f0ad10 Fix several broken tests that were missing some required positional arguments 2019-12-28 13:00:39 +01:00
Tulir Asokan a9abf9a1af Bump version to 0.7.0 2019-12-28 01:29:38 +02:00
Tulir Asokan 815f9605f9 Bump version to 0.7.0rc4 2019-12-25 16:31:00 +02:00
Tulir Asokan 9a9d6fc0bb Fix handling m.emotes 2019-12-25 16:29:22 +02:00
Tulir Asokan 2f691bf1b8 Bump version to 0.7.0rc3 2019-12-25 16:01:39 +02:00
Tulir Asokan 50984dab14 Trust displaynames from non-contacts when syncing puppets 2019-12-25 15:49:35 +02:00
Tulir Asokan 6f6ce4bcc7 Try deleting sources in docker image 2019-12-23 19:44:44 +02:00
Tulir Asokan 119729393c Restore git for version info in CI builds 2019-12-23 19:30:55 +02:00
Tulir Asokan 9f3869e878 Try to fix version info in CI builds again 2019-12-23 19:15:36 +02:00
Tulir Asokan 9fb2a73ec5 Update mautrix-python to handle invites separately from leaves. Fixes #402 2019-12-21 21:02:41 +02:00
Tulir Asokan 64b3699b3c Only print stack traces for admins. Fixes #392 2019-12-21 20:46:49 +02:00
Tulir Asokan 76ad31a3bc Update to Alpine 3.11 and fix version info in CI builds 2019-12-21 20:45:02 +02:00
Tulir Asokan 71cdee5a4d Fix crash when login shared secret is not enabled 2019-12-15 19:04:43 +02:00
Tulir Asokan 2ae4b23528 Add option to log in to custom puppet with shared secret 2019-12-15 18:50:07 +02:00
Tulir Asokan 39927ac6c0 Try to fix cleaning up rooms
Not tested at all
2019-12-11 10:03:05 +02:00
Tulir Asokan 3e6e59db29 Add postgres password field to example helm chart values 2019-12-06 15:57:53 +02:00
Tulir Asokan 36e2c6f66f Bump version to 0.7.0rc2 2019-12-01 20:31:25 +02:00
Tulir Asokan 69d56f4632 Disable debug log when creating peer type chat portal. Fixes #389 2019-12-01 20:10:45 +02:00
Tulir Asokan af0f731a8a Ignore ChatForbidden when syncing dialogs. Fixes #390 2019-12-01 20:09:00 +02:00
Tulir Asokan cf8c05e1c5 Replace LEFT with LEAVE in mx_user_profile migration. Fixes #391 2019-12-01 20:07:54 +02:00
Tulir Asokan 7d5e307368 Allow room moderators to set room-specific configs 2019-11-30 21:38:33 +02:00
Tulir Asokan 701b28c33c Fix getting version in docker 2019-11-30 21:32:22 +02:00
Tulir Asokan a239ca439a Load version info from Git. Fixes #387 2019-11-30 20:54:54 +02:00
Tulir Asokan 578af19baa Use new forbidden default value system in mautrix-python. Fixes #388 2019-11-30 19:49:29 +02:00
Tulir Asokan 792ed007b5 Bump version to 0.7.0rc1 2019-11-30 16:26:19 +02:00
Tulir Asokan 539c2338fc Fix typo in lowercase sql func 2019-11-30 16:18:25 +02:00
Tulir Asokan 792694b2d9 Fix typo 2019-11-30 16:09:20 +02:00
Tulir Asokan 8e20d56091 Handle IndexError in !tg set-pl 2019-11-30 16:08:35 +02:00
Tulir Asokan 1986142db3 Remove alias when cleaning up room 2019-11-30 16:01:07 +02:00
Tulir Asokan c52df5dc36 Fix Matrix event handle time metrics (ref #120) 2019-11-30 15:41:47 +02:00
Tulir Asokan 617d44ed75 Unbridge if bridge bot is kicked or banned. Fixes #312 2019-11-30 15:36:58 +02:00
Tulir Asokan 91e6a73f33 Fix incorrectly case sensitive username finding in db. Fixes #384 2019-11-30 15:21:47 +02:00
Tulir Asokan 25d7087d07 Fix bot-received messages not being handled. Fixes #341 2019-11-30 15:17:01 +02:00
Tulir Asokan f72267e81d Possibly fix private chats with bot accounts 2019-11-28 10:31:18 +02:00
Tulir Asokan ab3b0f3c3c Update minimum mautrix-python version 2019-11-21 23:40:49 +02:00
Tulir Asokan 883c4dcf19 Include server name when joining upgraded room 2019-11-21 23:12:11 +02:00
Tulir Asokan a5aa73dea6 Fix Telegram location message handling 2019-11-12 18:18:08 +02:00
Tulir Asokan ed90c2667a Only apply relaybot.group_chat_invite to chats with relaybot 2019-11-08 20:07:20 +02:00
Tulir Asokan 87d9477bc7 Merge pull request #378 from anoadragon453/anoa/confusing_2fa
Make 2FA error message clearer
2019-11-08 15:18:09 +02:00
Andrew Morgan b854119445 Make 2FA error message clearer 2019-11-08 11:51:40 +00:00
Tulir Asokan 0e56ab131e Fix relaybot message formatting 2019-11-07 22:53:45 +02:00
Tulir Asokan e319417fbc Use specific lottieconverter commit for building in dockerfile 2019-11-06 22:56:34 +02:00
Tulir Asokan 9e831689e9 Fix files over relaybot not having message format 2019-11-06 22:49:26 +02:00
Tulir Asokan 0a5f4e6551 Add option to invite specific users to all created group chat portals 2019-11-06 22:37:48 +02:00
Tulir Asokan aaf158cc29 Fix loading TelegramFile thumbnails from the db 2019-11-06 22:37:25 +02:00
Tulir Asokan 2c2dd37275 Change example homeserver values to example.com 2019-11-06 22:36:55 +02:00
Tulir Asokan 4d4a3b6bf6 Fix mistake preventing portal creation 2019-11-05 13:31:53 +02:00
Tulir Asokan b6b1d72ecb Add config option to override default power levels in rooms 2019-10-31 00:51:31 +02:00
Tulir Asokan 6fa44ce5e9 Merge branch 'helm' 2019-10-31 00:06:51 +02:00
Tulir Asokan 90e7a303ab Fix error on private chat portal create if relaybot is not configured 2019-10-29 15:48:49 +02:00
Tulir Asokan 54256be459 Disable bridging pin sender for now as it seems unreliable 2019-10-28 01:22:08 +02:00
Tulir Asokan 1c662c55cc Ignore telegram updates in blacklisted chats 2019-10-28 01:21:36 +02:00
Tulir Asokan abd1adaabf Add logging to find potential peer type mistakes 2019-10-28 01:10:36 +02:00
Tulir Asokan 5411de90fc Update some things 2019-10-28 01:09:37 +02:00
Tulir Asokan f9a692b5ef Telegram now allows custom contact names without knowing the phone number, so stop trusting those names 2019-10-27 18:08:19 +02:00
Tulir Asokan 9205ef8024 Revert "Changed converter build conf"
This reverts commit ef3a60397f.
2019-10-27 16:13:19 +02:00
Tulir Asokan 4260afaa7e Show exit code when lottieconverter errors 2019-10-27 16:13:13 +02:00
Lawrence ef3a60397f Changed converter build conf 2019-10-27 15:53:01 +02:00
Tulir Asokan 8acc51116d Merge pull request #366 from Eramde/rlottie
TGS animation support
2019-10-27 15:41:53 +02:00
Tulir Asokan cbbc5e8500 Remove unused lottie2ffmpeg script 2019-10-27 15:40:35 +02:00
Tulir Asokan 0192fb8308 Fix minor things 2019-10-27 15:37:42 +02:00
Tulir Asokan 3841528f5a Merge branch 'master' into rlottie 2019-10-27 15:37:33 +02:00
Tulir Asokan 91c3825ae3 Bump minimum mautrix-python version 2019-10-27 14:12:16 +02:00
Tulir Asokan 8c26dd8382 Remove debug prints 2019-10-27 14:07:10 +02:00
Tulir Asokan 01b317484f Add support for formatted captions 2019-10-27 13:55:34 +02:00
Tulir Asokan 73a6ad2cf2 Add parallel file upload too 2019-10-27 02:43:29 +03:00
Tulir Asokan 574312d7c5 Add option for parallel streamed file transfer 2019-10-27 01:12:15 +03:00
Tulir Asokan 6cb8e007aa Don't assume peer type is chat anywhere. Fixes #304 2019-10-26 20:40:21 +03:00
Tulir Asokan 22f6a12842 Add command to set caption for telegram files 2019-10-26 19:28:53 +03:00
Tulir Asokan c15508150a Enable readiness/liveness probes 2019-10-23 23:34:01 +03:00
Tulir Asokan a0f12a2c48 Add postgres as an optional dependency 2019-10-23 22:53:48 +03:00
Tulir Asokan c919a1762b Remove unused parts 2019-10-22 02:10:03 +03:00
Tulir Asokan 6dc73bf710 Add somewhat functional helm chart 2019-10-22 02:08:18 +03:00
Andrew Morgan 623b802d56 Add missing space to clean up response 2019-10-17 16:03:34 +03:00
Randall Lawrence 0726289c7a Modified converters to support pngs option of lottieconverter
See https://github.com/Eramde/LottieConverter/commit/37e73d8dc15152e050288ea0a55541546dde84d1
2019-10-05 20:09:14 +03:00
Tulir Asokan d2edf12fdf Fix weird mime type bug in alpine/magic 2019-10-03 10:57:52 +03:00
Tulir Asokan 9694fb901a Add lottieconverter to docker image 2019-10-03 10:15:22 +03:00
Tulir Asokan a8982cf8c7 Remove extension from lottie2ffmpeg and fix crash when lottieconverter not present 2019-10-03 10:14:58 +03:00
Tulir Asokan f430ed7169 Remove slow python converters and use asyncio subprocess 2019-10-03 01:28:47 +03:00
Tulir Asokan 4f5a501be4 Merge branch 'master' into rlottie 2019-10-02 23:30:50 +03:00
Tulir Asokan 6c312efc9a Fix sending relaybot private chat message 2019-10-02 23:24:19 +03:00
Daniele Rogora 1b987be562 Fixes parentheses when checking for bots, which was causing AttributeError 2019-10-02 23:22:50 +03:00
Lawrence c84536fef7 Set licence header
Deleted autogenerated header and set licence
2019-09-29 22:43:32 +03:00
Lawrence 1044298d76 Update mautrix_telegram/portal/telegram.py
Co-Authored-By: Tulir Asokan <tulir@maunium.net>
2019-09-29 22:37:42 +03:00
Lawrence 4e971932d1 Merge branch 'master' into rlottie 2019-09-29 20:21:56 +03:00
Randall Lawrence 4834e2297a Forgot about db fetch... 2019-09-29 20:15:28 +03:00
Randall Lawrence 2a3f70eb4a Migrated to rlottie utility 2019-09-29 19:44:13 +03:00
Tulir Asokan ea633ce3f9 Set title of relaybot private chat portal to other user's displayname 2019-09-29 01:05:38 +03:00
Tulir Asokan f6b64126cf Add support for bridging or responding to private chats with relaybot 2019-09-29 00:47:28 +03:00
Randall Lawrence 9d3c15f284 Added info in example-config how to install library for lottie 2019-09-25 16:10:52 +03:00
Randall Lawrence 7d224ec5ac Switched to puppeter-lottie npm library 2019-09-25 15:34:34 +03:00
Randall Lawrence ed4e34b808 Changed to 30% frame in image convert 2019-09-25 13:31:56 +03:00
Randall Lawrence f5c008c1a7 Added parameter in config for selecting convert type 2019-09-25 13:09:21 +03:00
Randall Lawrence dc71f74c0c Changed default convert type and image size 2019-09-25 12:53:42 +03:00
Tulir Asokan d5470de8fd Bridge bans to Telegram. Fixes #303 2019-09-22 22:51:46 +03:00
Randall Lawrence dff5903c53 Forgot uncomment db fetch 2019-09-22 01:28:49 +03:00
Randall Lawrence fc241b1cdc Moved converters to other file, added methods for video and gif, which supports resize.
XXX: videos don't want to be played by riot, i don't know why...
2019-09-22 01:23:00 +03:00
Randall Lawrence 77ba732eec Added function to convert tgs to png.
XXX: there is the bug in tgs lib, it crashes on some tgs files.
Also cairo svg2png need to be called not from tgs.exporters because there is no option to set image size
2019-09-21 01:45:56 +03:00
Tulir Asokan 835175aa36 Add better m.emote format options for logged in users. Fixes #355 2019-09-10 23:12:54 +03:00
Tulir Asokan 2e2827717d Escape percent sign in alembic options. Fixes #362 2019-09-10 22:54:14 +03:00
Tulir Asokan 209f85c17e Fix pip extras in dockerfile 2019-09-08 13:07:55 +03:00
Tulir Asokan 37c373c51f Add aiohttp speedup libraries as extras 2019-09-08 13:03:16 +03:00
Tulir Asokan 62fe03e8c1 Move back to telethon releases instead of master branch 2019-09-08 12:55:57 +03:00
Tulir Asokan 427c28db7a Remove build deps from final docker image 2019-09-02 23:18:23 +03:00
Tulir Asokan 835b363661 Fix some problems with editing 2019-09-02 22:58:11 +03:00
Tulir Asokan df67ed57ee Don't crash bridge if startup of one user fails. Fixes #292 2019-09-02 22:52:44 +03:00
Tulir Asokan 43b3cc2ca4 Bump mautrix-python requirement 2019-09-02 22:25:42 +03:00
Tulir Asokan 3c2268870b Fix some potential exceptions when asyncio.gathering 2019-09-02 22:21:48 +03:00
Tulir Asokan fbb1267609 Start using new db base functions 2019-09-02 22:02:50 +03:00
Tulir Asokan 2c443a3b93 Add netcat-openbsd to dockerfile for manhole 2019-09-02 22:02:32 +03:00
Tulir Asokan 13fd8db0b7 Restore better reply fallback behavior to avoid mentions 2019-08-22 22:08:03 +03:00
Tulir Asokan cdee0df5ab Update some dependency versions 2019-08-19 21:42:31 +03:00
Tulir Asokan 9e418afe64 Bump minimum mautrix-python version 2019-08-19 21:38:54 +03:00
Tulir Asokan 7d43eb5d2e Add temporary fix for edits being echoed when using matrix puppeting 2019-08-17 18:27:26 +03:00
Tulir Asokan de4c16431d Handle RPCErrors in formatter and update mautrix-python 2019-08-17 13:43:10 +03:00
Tulir Asokan d3e6860b1c Fix sync-full command 2019-08-17 13:43:00 +03:00
Tulir Asokan 6bccf5595b Make custom puppet errors at startup non-fatal 2019-08-15 22:34:16 +03:00
Tulir Asokan 35023efbf2 Update mautrix-python to fix editing replies 2019-08-15 22:33:22 +03:00
Tulir Asokan d33460e3bd Bridge room meta to Matrix as correct ghost user if possible 2019-08-15 22:33:22 +03:00
Tulir Asokan eea059c0d3 Fix bridging room meta to Telegram 2019-08-15 22:33:22 +03:00
Tulir Asokan 2a327cc29e Handle update_info errors inside entity instead of in user 2019-08-13 14:44:24 +03:00
Tulir Asokan 1ac1bf5b60 Add missing return 2019-08-13 14:38:59 +03:00
Tulir Asokan ad5cace75b Fix small mistakes 2019-08-11 15:09:44 +03:00
Tulir Asokan bf49843721 Add support for whitelisting unix users who can connect to the manhole 2019-08-11 15:01:28 +03:00
Tulir Asokan 25d9e3b1ca Merge branch 'manhole' 2019-08-11 13:46:49 +03:00
Tulir Asokan dc07b2bdf4 Fix typo in dockerfile 2019-08-11 12:04:09 +03:00
Tulir Asokan 0093acb578 Move manhole state to main bridge object 2019-08-11 12:01:55 +03:00
Tulir Asokan b89ecf4c03 Add unix socket manhole to access bridge internals at runtime 2019-08-11 02:35:58 +03:00
Tulir Asokan 468412100c Remove broken catch_up option 2019-08-10 19:48:33 +03:00
Tulir Asokan ea7e4b277f Merge pull request #352 from tulir/mautrix-0.4
Move to mautrix-python
2019-08-10 16:21:11 +03:00
Tulir Asokan 60e35c1bb9 Add command to sync specific portal 2019-08-10 14:24:26 +03:00
Tulir Asokan 117bb5bd86 Fix cleaning up whitespace names 2019-08-10 13:53:30 +03:00
Tulir Asokan e8ba274776 Use unicodedata for cleaning up whitespace names 2019-08-10 13:23:44 +03:00
Tulir Asokan 76a1e20f13 Improve whitespace name cleanup 2019-08-10 13:17:15 +03:00
Tulir Asokan 8cab2fdcb6 Move alembic_version table existence check to mautrix-python 2019-08-09 23:30:25 +03:00
Tulir Asokan 354fcdc84b Switch to pypi dev release of mautrix-python and remove future-fstrings from requirements.txt 2019-08-09 23:16:15 +03:00
Tulir Asokan 99e26a5805 Fix warning log calls 2019-08-09 22:58:56 +03:00
Tulir Asokan d354d6e788 Add repr for formatter entities 2019-08-09 22:52:31 +03:00
Tulir Asokan 28bcf479f3 Merge remote-tracking branch 'Eramde/mtproxy' into mautrix-0.4 2019-08-09 19:38:23 +03:00
Tulir Asokan e3f8fc0e01 Ignore incoming messages in private chats sent by the receiver if no matrix puppeting 2019-08-09 00:42:55 +03:00
Tulir Asokan e8184f0248 Require telethon master branch 2019-08-08 23:20:08 +03:00
Tulir Asokan 937de0fa00 Reduce usage of regexes 2019-08-08 23:15:15 +03:00
Tulir Asokan ac24bc86a0 Minor improvements 2019-08-08 22:21:24 +03:00
Tulir Asokan 1338a43c03 Fix transferring documents into Matrix 2019-08-08 21:57:38 +03:00
Tulir Asokan 8889105d5a Add locking to client connect calls 2019-08-08 00:15:58 +03:00
Tulir Asokan 9cbe6b73fc Use ensure_joined for joining puppets 2019-08-07 23:35:53 +03:00
Tulir Asokan ff98fe38c2 Add improvements and logs 2019-08-07 20:38:22 +03:00
Tulir Asokan 9899c15d36 Handle potential error kicking user 2019-08-07 19:49:22 +03:00
Tulir Asokan 601b29c28b Fix redaction bridging 2019-08-07 19:49:09 +03:00
Tulir Asokan 76e16b365d Minor fixes 2019-08-07 19:22:36 +03:00
Tulir Asokan 1021e8bc00 Fix relaybot bridging media 2019-08-07 18:51:52 +03:00
Tulir Asokan 4f740fc9f8 Fix potential errors generating forward headers 2019-08-07 18:41:54 +03:00
Tulir Asokan 75fc5c6e1e Only include specific optionals in pip install in dockerfile 2019-08-07 00:10:02 +03:00
Tulir Asokan 47cf63e0e6 Add psycopg2 as an optional dependency and throw error at startup if db is not initialized 2019-08-07 00:00:59 +03:00
Tulir Asokan b4a1aacd12 Minor code cleanup and fix tests 2019-08-06 23:37:49 +03:00
Tulir Asokan ad499b977e Persist next_batch for custom puppets 2019-08-06 23:16:17 +03:00
Tulir Asokan b5c55f4e65 Remove debug log 2019-08-06 22:11:55 +03:00
Tulir Asokan 65b69829d7 Fix document bridging 2019-08-06 21:55:52 +03:00
Tulir Asokan cf6eb604bd Make displayname max length configurable 2019-08-06 21:37:49 +03:00
Tulir Asokan 8655f5903a Improve things 2019-08-06 21:30:14 +03:00
Tulir Asokan 45f1dddb81 Move stopping into prepare_stop and stop custom puppet syncs 2019-08-06 20:09:29 +03:00
Tulir Asokan 299d20aac9 Remove portal_ prefix from files in portal directory 2019-08-06 20:01:32 +03:00
Tulir Asokan 43d16474c2 Improve logging and make get_dialogs use iterators more 2019-08-06 19:58:15 +03:00
Tulir Asokan ee08458df1 Actually fix image bridging 2019-08-06 14:42:16 +03:00
Tulir Asokan c80958a776 Maybe actually fix image bridging 2019-08-06 14:36:53 +03:00
Tulir Asokan 13d8a8420a Fix more bugs 2019-08-06 14:33:57 +03:00
Lawrence 01a58ad2ed Minor change
changed _proxy_settings return value(s)
2019-08-06 14:33:50 +03:00
Tulir Asokan a4e66e708a Fix bridging images again 2019-08-06 14:29:45 +03:00
Tulir Asokan 66e0698d2f Fix minor bugs 2019-08-06 14:20:48 +03:00
Tulir Asokan 935694cb64 Fix dockerfile 2019-08-06 02:20:23 +03:00
Tulir Asokan e2404f919e Install mautrix-python@bridge-updates in dockerfile 2019-08-06 02:08:03 +03:00
Tulir Asokan c9810dd9eb Fix example log config 2019-08-06 02:03:59 +03:00
Tulir Asokan 6bfd3eada4 Use mautrix-python bridge-updates branch in requirements.txt 2019-08-06 01:47:30 +03:00
Tulir Asokan 6852bae7f9 Move more things to use telethon methods 2019-08-06 01:46:22 +03:00
Tulir Asokan 8536bdd614 Use telethon's send_read_acknowledge instead of raw methods 2019-08-06 01:19:21 +03:00
Tulir Asokan bd13c73f2f Fix bugs 2019-08-06 01:13:27 +03:00
Tulir Asokan 2a9ab569b4 Only load users with a tgid at startup 2019-08-06 00:59:28 +03:00
Tulir Asokan d6ebce0425 Make it work 2019-08-06 00:51:27 +03:00
Tulir Asokan 3af306abe0 Even^4 more migrations to mautrix-python 2019-08-06 00:23:41 +03:00
Tulir Asokan 30563f3648 Even even even more migrations to mautrix-python 2019-08-05 22:10:43 +03:00
Tulir Asokan d6a2e7a9f7 Split portal.py and migrate more stuff to mautrix-0.4 2019-08-05 00:11:21 +03:00
Tulir Asokan 32d686e908 Migrate formatter and utils to mautrix-python 2019-08-04 15:20:14 +03:00
Tulir Asokan 05f906427e Fix command handler return hints 2019-08-04 01:51:13 +03:00
Tulir Asokan d8653961af Even even more migrations to mautrix-python 2019-08-04 01:41:10 +03:00
Tulir Asokan d521bbc0fa Merge branch 'master' into mautrix-0.4 2019-08-03 21:22:38 +03:00
Tulir Asokan 281f7203dc Filter non-nice whitespace out of displaynames 2019-07-29 20:11:35 +03:00
Tulir Asokan dd683af5f5 Add hacky fix for edit reply fallbacks 2019-07-29 19:59:34 +03:00
Tulir Asokan 9a5506d901 Include non-master branches in docker image tagging 2019-07-26 22:13:08 +03:00
Tulir Asokan 5fc2907392 Add .gitlab-ci.yml and badges to README 2019-07-26 22:06:55 +03:00
Tulir Asokan 1443082991 Change default port to 29317. Fixes #345 2019-07-24 02:12:12 +03:00
Tulir Asokan d4e3956941 Even more migrations to mautrix-python 2019-07-19 21:36:21 +03:00
Lawrence e3a457f84c Amend: Changed connection import 2019-07-19 14:41:05 +03:00
Randall Lawrence e40cd9f6a2 Changed connection import 2019-07-19 14:15:20 +03:00
Tulir Asokan eef498d47a More migrations to mautrix-python 2019-07-19 00:17:57 +03:00
Tulir Asokan 8d4a9dc231 Start migrating to mautrix-python 2019-07-18 23:24:25 +03:00
Tulir Asokan e0d3c940f8 Remove more Python 3.5 compatibility 2019-07-18 23:24:25 +03:00
Tulir Asokan be6d395ed6 Remove Python 3.5 compatibility 2019-07-18 23:24:25 +03:00
Tulir Asokan 87aa0b6659 Limit displaynames sent to Matrix to 100 characters 2019-07-18 23:23:15 +03:00
Tulir Asokan bb167b14ef Add/remove reply fallbacks in m.new_content 2019-07-18 23:22:57 +03:00
Sergey Blazhko 351866d9e4 Added option to connect via MTProxy. Proxy secret should be set in proxy.password config parameter. 2019-07-18 12:33:38 +03:00
Tulir Asokan 9a8f8433b0 Bump version to 0.6.0 2019-07-09 19:43:56 +03:00
Tulir Asokan 4942789213 Fix vulnerability in event handling 2019-07-09 19:43:37 +03:00
Tulir Asokan 0741265837 Bump version to 0.6.0rc2 2019-07-06 21:03:59 +03:00
Tulir Asokan 06d4e1703e Restore old blockquote behavior in formatter as telegram's blockquotes don't work yet 2019-07-06 20:53:37 +03:00
Tulir Asokan 41be2a7b78 Merge branch 'native-strike-underline' 2019-07-06 20:50:07 +03:00
Tulir Asokan 610d12283d Update telethon 2019-07-06 20:49:32 +03:00
Tulir Asokan fee8da1613 Fix handling unsupported media 2019-07-06 17:57:28 +03:00
Tulir Asokan 28bed96e40 Fix displayname not updating for some users
Users who the bridge only saw via logged in users with the target user
in their contact lists wouldn't get their displayname updated due to an
invalid condition in the update_displayname function.
2019-07-04 22:32:30 +03:00
Tulir Asokan 050800f5f7 Add missing escape 2019-06-30 19:16:24 +03:00
Tulir Asokan 21fe94b38c Add support for nested formatting coming from Telegram 2019-06-30 19:16:24 +03:00
Tulir Asokan ce639c12d8 Use native strikethrough/underline/blockquote on Telegram 2019-06-30 19:16:24 +03:00
Tulir Asokan 78dd4e0086 Ignore .bak files 2019-06-30 19:08:30 +03:00
Tulir Asokan 0f7eebd683 Add option to set related groups for created rooms 2019-06-30 19:05:17 +03:00
Tulir Asokan 860b635188 Handle FileIdInvalidError in file transfers 2019-06-30 17:30:52 +03:00
Tulir Asokan 0710b4e8a1 Fix metrics config comment 2019-06-22 20:01:22 +03:00
Tulir Asokan 823abc121e Update docker image to Alpine 3.10 and add libffi-dev 2019-06-22 19:16:14 +03:00
Tulir Asokan 3fa6128561 Bump version to 0.6.0rc1 2019-06-22 18:56:14 +03:00
Tulir Asokan ca00e53a40 Update state cache when sending state events (e.g. kicks). Fixes #278 2019-06-20 23:31:32 +03:00
Tulir Asokan 0003d2efd3 Add secret flag for logged in admins to use relaybot when plumbing rooms. Fixes #294 2019-06-20 22:57:47 +03:00
Tulir Asokan 0efe9f05f2 Add option for maximum document size that gets bridged. Fixes #335 2019-06-20 22:41:51 +03:00
Tulir Asokan 88d0c5feb3 Re-add warning about catch_up 2019-06-20 22:23:51 +03:00
Tulir Asokan 912aa38063 Make mime type extension guessing saner 2019-06-20 21:56:35 +03:00
Tulir Asokan 5fba658c66 Update to telethon 1.8. Fixes #334 2019-06-20 21:42:22 +03:00
Tulir Asokan 070601689a Include relaybot pill in !tg create invite suggestion 2019-06-10 00:49:10 +03:00
Tulir Asokan bde177fc34 Fix env config overrides. Fixes #333 2019-06-07 21:30:06 +03:00
Tulir Asokan a593f71901 Merge pull request #332 from pacien/env-override
Allow config key override through env var
2019-06-07 17:10:10 +03:00
pacien 107fc501e4 Allow config key override through env var
Signed-off-by: pacien <pacien.trangirard@pacien.net>
2019-06-06 22:24:34 +02:00
Tulir Asokan cd51fb85cf Make getting started more user-friendly. Fixes #327 2019-06-01 22:38:43 +03:00
Tulir Asokan 9591a05361 Ignore whitespace in web login input 2019-06-01 22:15:49 +03:00
Tulir Asokan ddfffaf6a2 Handle some image send errors by resending as document. Fixes #324 2019-06-01 22:09:05 +03:00
Tulir Asokan baffe1b79e Revert "Add event/update counter to metrics"
This reverts commit 145eb8f611.
2019-06-01 21:18:06 +03:00
Tulir Asokan 145eb8f611 Add event/update counter to metrics 2019-06-01 21:10:01 +03:00
Tulir Asokan a279835cf8 HTML-escape names in telegram forward/reply header 2019-06-01 19:49:25 +03:00
Tulir Asokan 2dc04a8517 Add basic metrics with prometheus (ref #120) 2019-05-31 02:11:36 +03:00
Tulir Asokan 5c076933e7 Apparently session hashes can be negative integers too 2019-05-31 01:24:48 +03:00
Tulir Asokan 417c2e4d1e Add build stuff to .gitignore 2019-05-31 01:18:11 +03:00
Tulir Asokan cbfb4d6d32 Add command to change displayname 2019-05-31 01:18:03 +03:00
Tulir Asokan 99ac768778 Fix relaybot edit deduplication in channels. Fixes #325 2019-05-31 00:30:55 +03:00
Tulir Asokan 7177d0c37e Fix editing messages that went through relaybot 2019-05-29 16:53:29 +03:00
Tulir Asokan ff257fcd77 Fix edit index upgrade on postgres 2019-05-29 16:37:13 +03:00
Tulir Asokan 47243334f4 Add native Matrix edit support
Warning: may break everything and/or edit your cat
2019-05-29 16:20:15 +03:00
Tulir Asokan 1693b643a7 Hacky fix for null m.relates_to's 2019-05-23 02:07:50 +03:00
Tulir Asokan 9790dff27e Use batch_alter_table when adding columns 2019-05-18 01:49:07 +03:00
Tulir Asokan ab1d65e6f0 Trim left spaces when parsing command. Fixes #322 2019-05-15 20:45:16 +03:00
Tulir Asokan 5bbadbbdc8 Fix typo 2019-05-15 20:16:04 +03:00
Tulir Asokan ce92cd31bf Fix updating user info from entities attached to updates
Also made it trust info from users who don't have the puppet's phone number.
2019-05-15 20:05:27 +03:00
Tulir Asokan 8689d0e8b0 Save peer type when upgrading
Might have been the cause of #304
2019-05-15 20:04:26 +03:00
Tulir Asokan f47e548b04 Bump minimum telethon-session-sqlalchemy version. Fixes #314 2019-05-15 15:29:54 +03:00
Tulir Asokan 6fef2a9a87 Update user info from entities attached to updates 2019-05-15 00:49:17 +03:00
Tulir Asokan bc3ceab039 Fix handling of null m.relates_to objects. Fixes #317 2019-05-11 21:55:30 +03:00
Tulir Asokan b9a0e6cbb6 Add external URL for chat and private channel messages. Fixes #308 2019-05-11 21:55:30 +03:00
Tulir Asokan c50fd4b3ac Fix mime type info for converted images. Fixes #307 2019-05-11 21:55:30 +03:00
Tulir Asokan 430f7b7217 Handle void tags correctly in the HTML parser. Fixes #309 2019-05-11 21:55:30 +03:00
Tulir Asokan 72a3cea948 Merge pull request #315 from t2bot/travis/fix-logout
Use empty collections when clearing portals/contacts instead of None
2019-05-07 02:06:15 +03:00
Tulir Asokan fce22b08e9 Check if bot is configured before trying to get username in bridge info provisioning API 2019-04-24 16:42:28 +03:00
Travis Ralston a2e64b4e0b Use empty collections when clearing portals/contacts instead of None
This avoids an error when logging out regarding "NoneType is not iterable".
2019-04-19 23:42:11 -06:00
Tulir Asokan 1df87447bd Set version to 0.6.0+dev 2019-04-08 00:41:01 +03:00
Tulir Asokan 75b2b3b163 Make retry_delay and other TelegramClient constructor fields configurable. Fixes #299 2019-04-03 16:20:19 +03:00
Tulir Asokan 80d90f93cd Fix newlines in unformatted messages going through relaybot. Fixes #306 2019-04-03 15:31:59 +03:00
Tulir Asokan e1ac4233c7 Add hidden way to clear vote and fix voting for first option 2019-04-03 15:26:30 +03:00
Tulir Asokan 46c3bbff3c Simplify voting in polls 2019-04-03 15:11:21 +03:00
155 changed files with 18092 additions and 9441 deletions
-8
View File
@@ -1,8 +0,0 @@
engines:
sonar-python:
enabled: true
checks:
python:S107:
enabled: false
exclude_patterns:
- "alembic/"
+6
View File
@@ -2,3 +2,9 @@
.codeclimate.yml
*.png
*.md
logs
.venv
start
config.yaml
registration.yaml
*.db
+6
View File
@@ -8,8 +8,14 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.py]
max_line_length = 99
[*.{yaml,yml,py}]
indent_style = space
[{.gitlab-ci.yml,.pre-commit-config.yaml,mautrix_telegram/web/provisioning/spec.yaml}]
indent_size = 2
+7
View File
@@ -0,0 +1,7 @@
---
name: Bug report
about: If something is definitely wrong in the bridge (rather than just a setup issue),
file a bug report. Remember to include relevant logs.
labels: bug
---
+7
View File
@@ -0,0 +1,7 @@
contact_links:
- name: Troubleshooting docs & FAQ
url: https://docs.mau.fi/bridges/general/troubleshooting.html
about: Check this first if you're having problems setting up the bridge.
- name: Support room
url: https://matrix.to/#/#telegram:maunium.net
about: For setup issues not answered by the troubleshooting docs, ask in the Matrix room.
+6
View File
@@ -0,0 +1,6 @@
---
name: Enhancement request
about: Submit a feature request or other suggestion
labels: enhancement
---
+26
View File
@@ -0,0 +1,26 @@
name: Python lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: "3.11"
- uses: isort/isort-action@master
with:
sortPaths: "./mautrix_telegram"
- uses: psf/black@stable
with:
src: "./mautrix_telegram"
version: "23.1.0"
- name: pre-commit
run: |
pip install pre-commit
pre-commit run -av trailing-whitespace
pre-commit run -av end-of-file-fixer
pre-commit run -av check-yaml
pre-commit run -av check-added-large-files
+21 -5
View File
@@ -1,12 +1,28 @@
.idea/
/.idea/
.venv
env/
/.venv
/env/
pip-selfcheck.json
*.pyc
__pycache__
/build
/dist
/*.egg-info
/.eggs
*.yaml
!.pre-commit-config.yaml
!example-config.yaml
!/mautrix_telegram/web/provisioning/spec.yaml
!/.github/workflows/*.yaml
/start
/mautrix
/telethon
config.yaml
registration.yaml
*.log*
*.db
*.db-*
/*.pickle
*.bak
/*.json
+3
View File
@@ -0,0 +1,3 @@
include:
- project: 'mautrix/ci'
file: '/python.yml'
+20
View File
@@ -0,0 +1,20 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
- id: black
language_version: python3
files: ^mautrix_telegram/.*\.pyi?$
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
files: ^mautrix_telegram/.*\.pyi?$
+1014
View File
File diff suppressed because it is too large Load Diff
+45 -31
View File
@@ -1,45 +1,59 @@
FROM docker.io/alpine:3.9
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.19
ENV UID=1337 \
GID=1337 \
FFMPEG_BINARY=/usr/bin/ffmpeg
COPY . /opt/mautrix-telegram
WORKDIR /opt/mautrix-telegram
RUN apk add --no-cache \
py3-virtualenv \
python3 py3-pip py3-setuptools py3-wheel \
py3-pillow \
py3-aiohttp \
py3-lxml \
py3-asyncpg \
py3-aiosqlite \
py3-magic \
py3-sqlalchemy \
py3-markdown \
py3-psycopg2 \
# Not yet in stable repos:
#py3-ruamel \
py3-ruamel.yaml \
py3-commonmark \
py3-phonenumbers \
py3-mako \
#py3-prometheus-client \ (pulls in twisted unnecessarily)
# Indirect dependencies
#commonmark
py3-future \
#alembic
py3-mako \
py3-dateutil \
py3-markupsafe \
#moviepy
py3-decorator \
#py3-tqdm \
py3-requests \
#imageio
py3-numpy \
#telethon
py3-rsa \
py3-idna \
py3-rsa \
#py3-telethon \ (outdated)
py3-pyaes \
py3-aiodns \
py3-python-socks \
# cryptg
py3-cffi \
py3-qrcode \
py3-brotli \
# Other dependencies
python3-dev \
build-base \
ffmpeg \
ca-certificates \
su-exec \
&& pip3 install .[all]
netcat-openbsd \
# encryption
py3-olm \
py3-pycryptodome \
py3-unpaddedbase64 \
py3-future \
bash \
curl \
jq \
yq
COPY requirements.txt /opt/mautrix-telegram/requirements.txt
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
WORKDIR /opt/mautrix-telegram
RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \
&& pip3 install --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 --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
VOLUME /data
ENV UID=1337 GID=1337 \
FFMPEG_BINARY=/usr/bin/ffmpeg
CMD ["/opt/mautrix-telegram/docker-run.sh"]
+5
View File
@@ -0,0 +1,5 @@
include README.md
include CHANGELOG.md
include LICENSE
include requirements.txt
include optional-requirements.txt
+24 -3
View File
@@ -1,9 +1,30 @@
# mautrix-telegram
![Languages](https://img.shields.io/github/languages/top/mautrix/telegram.svg)
[![License](https://img.shields.io/github/license/mautrix/telegram.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/mautrix/telegram/all.svg)](https://github.com/mautrix/telegram/releases)
[![GitLab CI](https://mau.dev/mautrix/telegram/badges/master/pipeline.svg)](https://mau.dev/mautrix/telegram/container_registry)
[![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Imports](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
A Matrix-Telegram hybrid puppeting/relaybot bridge.
### [Wiki](https://github.com/tulir/mautrix-telegram/wiki)
## Sponsors
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
### [Features & Roadmap](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md)
## Documentation
All setup and usage instructions are located on
[docs.mau.fi](https://docs.mau.fi/bridges/python/telegram/index.html).
Some quick links:
* [Bridge setup](https://docs.mau.fi/bridges/python/setup.html?bridge=telegram)
(or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=telegram))
* Basic usage: [Authentication](https://docs.mau.fi/bridges/python/telegram/authentication.html),
[Creating chats](https://docs.mau.fi/bridges/python/telegram/creating-and-managing-chats.html),
[Relaybot setup](https://docs.mau.fi/bridges/python/telegram/relay-bot.html)
### Features & Roadmap
[ROADMAP.md](https://github.com/mautrix/telegram/blob/master/ROADMAP.md)
contains a general overview of what is supported by the bridge.
## Discussion
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
@@ -11,4 +32,4 @@ Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net
Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room)
## Preview
![Preview](https://raw.githubusercontent.com/tulir/mautrix-telegram/master/preview.png)
![Preview](preview.png)
+15 -8
View File
@@ -3,6 +3,8 @@
* Matrix → Telegram
* [x] Message content (text, formatting, files, etc..)
* [x] Message redactions
* [x] Message reactions
* [x] Message edits
* [ ] ‡ Message history
* [x] Presence
* [x] Typing notifications
@@ -11,8 +13,8 @@
* [x] Power level
* [x] Normal chats
* [ ] Non-hardcoded PL requirements
* [x] Supergroups/channels
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
* [x] Supergroups/channels
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
* [x] Membership actions (invite/kick/join/leave)
* [x] Room metadata changes (name, topic, avatar)
* [x] Initial room metadata
@@ -22,12 +24,17 @@
* Telegram → Matrix
* [x] Message content (text, formatting, files, etc..)
* [ ] Advanced message content/media
* [x] Custom emojis
* [x] Polls
* [x] Games
* [ ] Buttons
* [x] Message deletions
* [x] Message reactions
* [x] Message edits
* [ ] Message history
* [x] Message history
* [x] Manually (`!tg backfill`)
* [x] Automatically when creating portal
* [x] Automatically for missed messages
* [x] Avatars
* [x] Presence
* [x] Typing notifications
@@ -48,12 +55,12 @@
* [x] Automatic portal creation
* [x] At startup
* [x] When receiving invite or message
* [x] Private chat creation by inviting Matrix puppet of Telegram user to new room
* [x] Option to use bot to relay messages for unauthenticated Matrix users
* [x] Option to use own Matrix account for messages sent from other Telegram clients
* [x] Portal creation by inviting Matrix puppet of Telegram user to new room
* [x] Option to use bot to relay messages for unauthenticated Matrix users (relaybot)
* [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting)
* [ ] ‡ Calls (hard, not yet supported by Telethon)
* [ ] ‡ Secret chats (not yet supported by Telethon)
* [ ] ‡ E2EE in Matrix rooms (not yet supported
* [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram)
* [x] End-to-bridge encryption in Matrix rooms (see [docs](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html))
† Information not automatically sent from source, i.e. implementation may not be possible
‡ Maybe, i.e. this feature may or may not be implemented at some point
-71
View File
@@ -1,71 +0,0 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
#truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
-95
View File
@@ -1,95 +0,0 @@
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import sys
from os.path import abspath, dirname
sys.path.insert(0, dirname(dirname(abspath(__file__))))
from mautrix_telegram.db import Base
from mautrix_telegram.config import Config
from alchemysession import AlchemySessionContainer
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
mxtg_config = Config(mxtg_config_path, None, None)
mxtg_config.load()
config.set_main_option("sqlalchemy.url",
mxtg_config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
class FakeDB:
@staticmethod
def query_property():
return None
AlchemySessionContainer.create_table_classes(FakeDB(), "telethon_", Base)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
-24
View File
@@ -1,24 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
@@ -1,28 +0,0 @@
"""Add TelegramFile table
Revision ID: 1b241f7e8530
Revises: 97d2a942bcf8
Create Date: 2018-02-19 23:52:06.605741
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1b241f7e8530'
down_revision = '97d2a942bcf8'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('telegram_file',
sa.Column('id', sa.String(), nullable=False),
sa.Column('mxc', sa.String(), nullable=True),
sa.Column('mime_type', sa.String(), nullable=True),
sa.Column('was_converted', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'))
def downgrade():
op.drop_table('telegram_file')
@@ -1,25 +0,0 @@
"""Add is_bot field to puppets
Revision ID: 1fa46383a9d3
Revises: 30eca60587f1
Create Date: 2018-04-29 23:44:40.102333
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1fa46383a9d3'
down_revision = '30eca60587f1'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('puppet', sa.Column('is_bot', sa.Boolean(), nullable=True))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column('is_bot')
@@ -1,41 +0,0 @@
"""Add cascade rules to UserPortal
Revision ID: 2228d49c383f
Revises: bcfefa1f1299
Create Date: 2018-05-31 11:11:59.482112
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2228d49c383f'
down_revision = 'bcfefa1f1299'
branch_labels = None
depends_on = None
def upgrade():
try:
with op.batch_alter_table("user_portal") as batch_op:
batch_op.drop_constraint("user_portal_user_fkey", type_="foreignkey")
batch_op.drop_constraint("user_portal_portal_fkey", type_="foreignkey")
batch_op.create_foreign_key("user_portal_user_fkey", "user", ["user"], ["tgid"],
onupdate="CASCADE", ondelete="CASCADE")
batch_op.create_foreign_key("user_portal_portal_fkey", "portal",
["portal", "portal_receiver"], ["tgid", "tg_receiver"],
onupdate="CASCADE", ondelete="CASCADE")
except ValueError:
return
def downgrade():
try:
with op.batch_alter_table("user_portal") as batch_op:
batch_op.drop_constraint("user_portal_user_fkey", type_="foreignkey")
batch_op.drop_constraint("user_portal_portal_fkey", type_="foreignkey")
batch_op.create_foreign_key("user_portal_user_fkey", "portal",
["portal", "portal_receiver"], ["tgid", "tg_receiver"])
batch_op.create_foreign_key("user_portal_portal_fkey", "user", ["user"], ["tgid"])
except ValueError:
return
@@ -1,24 +0,0 @@
"""Add megagroup field to portals
Revision ID: 30eca60587f1
Revises: cfc972368e50
Create Date: 2018-04-29 15:51:04.656605
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '30eca60587f1'
down_revision = 'cfc972368e50'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('portal', sa.Column('megagroup', sa.Boolean()))
def downgrade():
with op.batch_alter_table("portal") as batch_op:
batch_op.drop_column('megagroup')
@@ -1,111 +0,0 @@
"""Move sessions to main database
Revision ID: 501dad2868bc
Revises: 7d47d84380b6
Create Date: 2018-03-02 19:15:53.826985
"""
from alembic import op
import sqlalchemy as sa
import sqlite3
import os
# revision identifiers, used by Alembic.
revision = '501dad2868bc'
down_revision = '7d47d84380b6'
branch_labels = None
depends_on = None
def upgrade():
Session = op.create_table('telethon_sessions',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('dc_id', sa.Integer, nullable=False),
sa.Column('server_address', sa.String, nullable=True),
sa.Column('port', sa.Integer, nullable=True),
sa.Column('auth_key', sa.LargeBinary, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'dc_id'))
SentFile = op.create_table('telethon_sent_files',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('md5_digest', sa.LargeBinary, nullable=False),
sa.Column('file_size', sa.Integer, nullable=False),
sa.Column('type', sa.Integer, nullable=False),
sa.Column('id', sa.BigInteger, nullable=True),
sa.Column('hash', sa.BigInteger, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'md5_digest', 'file_size',
'type'))
Entity = op.create_table('telethon_entities',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('id', sa.Integer, nullable=False),
sa.Column('hash', sa.Integer, nullable=False),
sa.Column('username', sa.String, nullable=True),
sa.Column('phone', sa.Integer, nullable=True),
sa.Column('name', sa.String, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'id'))
Version = op.create_table('telethon_version',
sa.Column('version', sa.Integer, nullable=False),
sa.PrimaryKeyConstraint('version'))
conn = op.get_bind()
sessions = [os.path.basename(f) for f in os.listdir(".") if f.endswith(".session")]
for session in sessions:
session_to_sqlalchemy(conn, session, Session, SentFile, Entity)
def session_to_sqlalchemy(conn, path, Session, SentFile, Entity):
session_conn = sqlite3.connect(path)
session_id = os.path.splitext(path)[0]
c = session_conn.cursor()
auth_data_tuples = c.execute("SELECT * FROM sessions").fetchall()
auth_data_dicts = []
for row in auth_data_tuples:
dc_id, server_address, port, auth_key = row
auth_data_dicts.append({
"session_id": session_id,
"dc_id": dc_id,
"server_address": server_address,
"port": port,
"auth_key": auth_key,
})
if auth_data_dicts:
conn.execute(Session.insert().values(auth_data_dicts))
sent_file_tuples = c.execute("SELECT * FROM sent_files").fetchall()
sent_file_dicts = []
for row in sent_file_tuples:
md5_digest, file_size, type, id, hash = row
sent_file_dicts.append({
"session_id": session_id,
"md5_digest": md5_digest,
"file_size": file_size,
"type": type,
"id": id,
"hash": hash,
})
if sent_file_dicts:
conn.execute(SentFile.insert().values(sent_file_dicts))
entity_tuples = c.execute("SELECT * FROM entities").fetchall()
entity_dicts = []
for row in entity_tuples:
id, hash, username, phone, name = row
entity_dicts.append({
"session_id": session_id,
"id": id,
"hash": hash,
"username": username,
"phone": phone,
"name": name,
})
if entity_dicts:
conn.execute(Entity.insert().values(entity_dicts))
c.close()
session_conn.close()
def downgrade():
op.drop_table('telethon_version')
op.drop_table('telethon_entities')
op.drop_table('telethon_sent_files')
op.drop_table('telethon_sessions')
@@ -1,136 +0,0 @@
"""Move state store to main database
Revision ID: 6ca3d74d51e4
Revises: 2228d49c383f
Create Date: 2018-06-26 21:31:26.911307
"""
from alembic import context, op
import sqlalchemy.orm as orm
import sqlalchemy as sa
import json
import re
from mautrix_telegram.config import Config
from mautrix_telegram.db import Base
# revision identifiers, used by Alembic.
revision = "6ca3d74d51e4"
down_revision = "2228d49c383f"
branch_labels = None
depends_on = None
class RoomState(Base):
query = None
__tablename__ = "mx_room_state"
__table_args__ = {"extend_existing": True}
room_id = sa.Column(sa.String, primary_key=True)
power_levels = sa.Column("power_levels", sa.Text, nullable=True)
class UserProfile(Base):
query = None
__tablename__ = "mx_user_profile"
__table_args__ = {"extend_existing": True}
room_id = sa.Column(sa.String, primary_key=True)
user_id = sa.Column(sa.String, primary_key=True)
membership = sa.Column(sa.String, nullable=False, default="leave")
displayname = sa.Column(sa.String, nullable=True)
avatar_url = sa.Column(sa.String, nullable=True)
class Puppet(Base):
query = None
__tablename__ = "puppet"
__table_args__ = {"extend_existing": True}
id = sa.Column(sa.Integer, primary_key=True)
displayname = sa.Column(sa.String, nullable=True)
displayname_source = sa.Column(sa.Integer, nullable=True)
username = sa.Column(sa.String, nullable=True)
photo_id = sa.Column(sa.String, nullable=True)
is_bot = sa.Column(sa.Boolean, nullable=True)
matrix_registered = sa.Column(sa.Boolean, nullable=False, default=False)
def upgrade():
op.add_column("puppet", sa.Column("matrix_registered", sa.Boolean(), nullable=False,
server_default=sa.sql.expression.false()))
op.create_table("mx_room_state",
sa.Column("room_id", sa.String(), nullable=False),
sa.Column("power_levels", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("room_id"))
op.create_table("mx_user_profile",
sa.Column("room_id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("membership", sa.String(), nullable=False,
default="leave"),
sa.Column("displayname", sa.String(), nullable=True),
sa.Column("avatar_url", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("room_id", "user_id"))
try:
migrate_state_store()
except Exception as e:
print("Failed to migrate state store:", e)
print("Migrating the state store isn't required, but you can retry by alembic downgrading "
"to revision 2228d49c383f and upgrading again.")
def migrate_state_store():
conn = op.get_bind()
session = orm.sessionmaker(bind=conn)() # type: orm.Session
try:
with open("mx-state.json") as file:
data = json.load(file)
except FileNotFoundError:
return
if not data:
return
registrations = data.get("registrations", [])
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
mxtg_config = Config(mxtg_config_path, None, None)
mxtg_config.load()
username_template = mxtg_config.get("bridge.username_template", "telegram_{userid}")
hs_domain = mxtg_config["homeserver.domain"]
localpart = username_template.format(userid="(.+)")
mxid_regex = re.compile("@{}:{}".format(localpart, hs_domain))
for user in registrations:
match = mxid_regex.match(user)
if not match:
continue
puppet = session.query(Puppet).get(match.group(1))
if not puppet:
continue
puppet.matrix_registered = True
session.merge(puppet)
session.commit()
user_profiles = [UserProfile(room_id=room, user_id=user,
membership=member.get("membership", "leave"),
displayname=member.get("displayname", None),
avatar_url=member.get("avatar_url", None))
for room, members in data.get("members", {}).items()
for user, member in members.items()]
session.add_all(user_profiles)
session.commit()
room_state = [RoomState(room_id=room, power_levels=json.dumps(levels))
for room, levels in data.get("power_levels", {}).items()]
session.add_all(room_state)
session.commit()
def downgrade():
op.drop_table("mx_user_profile")
op.drop_table("mx_room_state")
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column("matrix_registered")
@@ -1,26 +0,0 @@
"""Add timestamp to TelegramFile
Revision ID: 7d47d84380b6
Revises: 1b241f7e8530
Create Date: 2018-02-19 23:53:18.050871
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7d47d84380b6'
down_revision = '1b241f7e8530'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('telegram_file',
sa.Column('timestamp', sa.BigInteger(), nullable=True, default=0,
server_default="0"))
def downgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.drop_column('timestamp')
@@ -1,80 +0,0 @@
"""initial revision
Revision ID: 97d2a942bcf8
Revises:
Create Date: 2018-02-11 18:40:55.483842
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '97d2a942bcf8'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table('portal',
sa.Column('tgid', sa.Integer),
sa.Column('tg_receiver', sa.Integer),
sa.Column('peer_type', sa.String, nullable=False, default=""),
sa.Column('mxid', sa.String, nullable=True),
sa.Column('username', sa.String, nullable=True),
sa.Column('title', sa.String, nullable=True),
sa.Column('about', sa.String, nullable=True),
sa.Column('photo_id', sa.String, nullable=True),
sa.PrimaryKeyConstraint('tgid', 'tg_receiver'),
sa.UniqueConstraint('mxid'))
op.create_table('user',
sa.Column('mxid', sa.String),
sa.Column('tgid', sa.Integer, nullable=True, unique=True),
sa.Column('tg_username', sa.String, nullable=True),
sa.Column('saved_contacts', sa.Integer, nullable=False, default=0),
sa.PrimaryKeyConstraint('mxid'))
op.create_table('puppet',
sa.Column('id', sa.Integer),
sa.Column('displayname', sa.String, nullable=True),
sa.Column('username', sa.String, nullable=True),
sa.Column('photo_id', sa.String, nullable=True),
sa.PrimaryKeyConstraint('id'))
op.create_table('contact',
sa.Column('user', sa.Integer),
sa.Column('contact', sa.Integer),
sa.ForeignKeyConstraint(("user",), ("user.tgid",)),
sa.ForeignKeyConstraint(("contact",), ("puppet.id",)),
sa.PrimaryKeyConstraint('user', 'contact'))
op.create_table('user_portal',
sa.Column('user', sa.Integer),
sa.Column('portal', sa.Integer),
sa.Column('portal_receiver', sa.Integer),
sa.PrimaryKeyConstraint('user', 'portal', 'portal_receiver'),
sa.ForeignKeyConstraint(("user",), ("user.tgid",),
name="user_portal_user_fkey",
onupdate="CASCADE", ondelete="CASCADE"),
sa.ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"),
name="user_portal_portal_fkey",
onupdate="CASCADE", ondelete="CASCADE"))
op.create_table('message',
sa.Column('mxid', sa.String),
sa.Column('mx_room', sa.String),
sa.Column('tgid', sa.Integer),
sa.Column('tg_space', sa.Integer),
sa.PrimaryKeyConstraint('tgid', 'tg_space'),
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"))
op.create_table('bot_chat',
sa.Column('id', sa.Integer),
sa.Column('type', sa.String, nullable=False, default=""),
sa.PrimaryKeyConstraint('id'))
def downgrade():
op.drop_table('bot_chat')
op.drop_table('message')
op.drop_table('user_portal')
op.drop_table('contact')
op.drop_table('puppet')
op.drop_table('user')
op.drop_table('portal')
@@ -1,25 +0,0 @@
"""Add phone number field to users
Revision ID: a9119be92164
Revises: b54929c22c86
Create Date: 2018-09-28 02:38:40.626282
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a9119be92164"
down_revision = "b54929c22c86"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("user", sa.Column("tg_phone", sa.String(), nullable=True))
def downgrade():
with op.batch_alter_table("user") as batch_op:
batch_op.drop_column("tg_phone")
@@ -1,25 +0,0 @@
"""Add portal-specific config
Revision ID: b54929c22c86
Revises: d5f7b8b4b456
Create Date: 2018-09-24 23:40:33.528710
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b54929c22c86"
down_revision = "d5f7b8b4b456"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("portal", sa.Column("config", sa.Text(), nullable=True))
def downgrade():
with op.batch_alter_table("portal") as batch_op:
batch_op.drop_column("config")
@@ -1,24 +0,0 @@
"""Add displayname source fields for puppets
Revision ID: bcfefa1f1299
Revises: bdadd173ee02
Create Date: 2018-05-19 17:00:21.078098
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bcfefa1f1299'
down_revision = 'bdadd173ee02'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('puppet', sa.Column('displayname_source', sa.Integer(), nullable=True))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column('displayname_source')
@@ -1,43 +0,0 @@
"""Update telethon update state table
Revision ID: bdadd173ee02
Revises: eeaf0dae87ce
Create Date: 2018-05-13 10:42:59.395597
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bdadd173ee02'
down_revision = 'eeaf0dae87ce'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column("id", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("hash", existing_type=sa.Integer, type_=sa.BigInteger)
with op.batch_alter_table("telethon_update_state") as batch_op:
batch_op.alter_column("entity_id", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("pts", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("qts", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("date", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("seq", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.add_column(sa.Column("unread_count", sa.Integer))
def downgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column("id", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("hash", existing_type=sa.BigInteger, type_=sa.Integer)
with op.batch_alter_table("telethon_update_state") as batch_op:
batch_op.alter_column("entity_id", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("pts", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("qts", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("date", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("seq", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.drop_column("unread_count")
@@ -1,35 +0,0 @@
"""Add metadata to TelegramFile
Revision ID: cfc972368e50
Revises: 501dad2868bc
Create Date: 2018-03-09 16:07:01.236712
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cfc972368e50'
down_revision = '501dad2868bc'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.add_column(sa.Column('size', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('width', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('height', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('thumbnail', sa.String(), nullable=True))
batch_op.create_foreign_key(constraint_name="fk_file_thumbnail",
referent_table="telegram_file",
local_cols=['thumbnail'],
remote_cols=['id'])
def downgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.drop_column('size')
batch_op.drop_column('width')
batch_op.drop_column('height')
batch_op.drop_column('thumbnail')
@@ -1,26 +0,0 @@
"""Add access_token and custom_mxid fields for puppets
Revision ID: d5f7b8b4b456
Revises: 6ca3d74d51e4
Create Date: 2018-07-20 12:09:30.277960
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "d5f7b8b4b456"
down_revision = "6ca3d74d51e4"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("puppet", sa.Column("access_token", sa.String(), nullable=True))
op.add_column("puppet", sa.Column("custom_mxid", sa.String(), nullable=True))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column("custom_mxid")
batch_op.drop_column("access_token")
@@ -1,34 +0,0 @@
"""Add telethon update state table
Revision ID: eeaf0dae87ce
Revises: 1fa46383a9d3
Create Date: 2018-04-30 17:30:59.610885
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'eeaf0dae87ce'
down_revision = '1fa46383a9d3'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column('phone', existing_type=sa.Integer, type_=sa.BigInteger)
op.create_table('telethon_update_state',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('entity_id', sa.Integer, nullable=False),
sa.Column('pts', sa.Integer, nullable=True),
sa.Column('qts', sa.Integer, nullable=True),
sa.Column('date', sa.Integer, nullable=True),
sa.Column('seq', sa.Integer, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'entity_id'))
def downgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column('phone', existing_type=sa.BigInteger, type_=sa.Integer)
op.drop_table('telethon_update_state')
+3
View File
@@ -0,0 +1,3 @@
pre-commit>=2.10.1,<3
isort>=5.10.1,<6
black>=23,<24
+23 -12
View File
@@ -1,21 +1,32 @@
#!/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.
function fixperms {
chown -R $UID:$GID /data /opt/mautrix-telegram
chown -R $UID:$GID /data
# /opt/mautrix-telegram is read-only, so disable file logging if it's pointing there.
if [[ "$(yq e '.logging.handlers.file.filename' /data/config.yaml)" == "./mautrix-telegram.log" ]]; then
yq -I4 e -i 'del(.logging.root.handlers[] | select(. == "file"))' /data/config.yaml
yq -I4 e -i 'del(.logging.handlers.file)' /data/config.yaml
fi
}
cd /opt/mautrix-telegram
# Replace database path in config.
sed -i "s#sqlite:///mautrix-telegram.db#sqlite:////data/mautrix-telegram.db#" /data/config.yaml
if [ -f /data/mx-state.json ]; then
ln -s /data/mx-state.json
fi
# Check that database is in the right state
alembic -x config=/data/config.yaml upgrade head
if [ ! -f /data/config.yaml ]; then
cp example-config.yaml /data/config.yaml
echo "Didn't find a config file."
@@ -27,10 +38,10 @@ if [ ! -f /data/config.yaml ]; then
fi
if [ ! -f /data/registration.yaml ]; then
python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml
python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml || exit $?
echo "Didn't find a registration file."
echo "Generated one for you."
echo "Copy that over to synapses app service directory."
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
fixperms
exit
fi
-307
View File
@@ -1,307 +0,0 @@
# Homeserver details
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://matrix.org
# The domain of the homeserver (for MXIDs, etc).
domain: matrix.org
# Whether or not to verify the SSL certificate of the homeserver.
# Only applies if address starts with https://
verify_ssl: true
# Application service host/registration related details
# Changing these values requires regeneration of the registration.
appservice:
# The address that the homeserver can use to connect to this appservice.
address: http://localhost:8080
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 8080
# The maximum body size of appservice API requests (from the homeserver) in mebibytes
# Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
max_body_size: 1
# The full URI to the database. SQLite and Postgres are fully supported.
# Other DBMSes supported by SQLAlchemy may or may not work.
# Format examples:
# SQLite: sqlite:///filename.db
# Postgres: postgres://username:password@hostname/dbname
database: sqlite:///mautrix-telegram.db
# Public part of web server for out-of-Matrix interaction with the bridge.
# Used for things like login if the user wants to make sure the 2FA password isn't stored in
# the HS database.
public:
# Whether or not the public-facing endpoints should be enabled.
enabled: true
# The prefix to use in the public-facing endpoints.
prefix: /public
# The base URL where the public-facing endpoints are available. The prefix is not added
# implicitly.
external: https://example.com/public
# Provisioning API part of the web server for automated portal creation and fetching information.
# Used by things like Dimension (https://dimension.t2bot.io/).
provisioning:
# Whether or not the provisioning API should be enabled.
enabled: true
# The prefix to use in the provisioning API endpoints.
prefix: /_matrix/provision/v1
# The shared secret to authorize users of the API.
# Set to "generate" to generate and save a new token.
shared_secret: generate
# The unique ID of this appservice.
id: telegram
# Username of the appservice bot.
bot_username: telegrambot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
bot_displayname: Telegram bridge bot
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration"
hs_token: "This value is generated when generating the registration"
# Bridge config
bridge:
# Localpart template of MXIDs for Telegram users.
# {userid} is replaced with the user ID of the Telegram user.
username_template: "telegram_{userid}"
# Localpart template of room aliases for Telegram portal rooms.
# {groupname} is replaced with the name part of the public channel/group invite link ( https://t.me/{} )
alias_template: "telegram_{groupname}"
# Displayname template for Telegram users.
# {displayname} is replaced with the display name of the Telegram user.
displayname_template: "{displayname} (Telegram)"
# Set the preferred order of user identifiers which to use in the Matrix puppet display name.
# In the (hopefully unlikely) scenario that none of the given keys are found, the numeric user
# ID is used.
#
# If the bridge is working properly, a phone number or an username should always be known, but
# the other one can very well be empty.
#
# Valid keys:
# "full name" (First and/or last name)
# "full name reversed" (Last and/or first name)
# "first name"
# "last name"
# "username"
# "phone number"
displayname_preference:
- full name
- username
- phone number
# Maximum number of members to sync per portal when starting up. Other members will be
# synced when they send messages. The maximum is 10000, after which the Telegram server
# will not send any more members.
# Defaults to no local limit (-> limited to 10000 by server)
max_initial_member_sync: -1
# Whether or not to sync the member list in channels.
# If no channel admins have logged into the bridge, the bridge won't be able to sync the member
# list regardless of this setting.
sync_channel_members: true
# Whether or not to skip deleted members when syncing members.
skip_deleted_members: true
# Whether or not to automatically synchronize contacts and chats of Matrix users logged into
# their Telegram account at startup.
startup_sync: true
# Number of most recently active dialogs to check when syncing chats.
# Dialogs include groups and private chats, but only groups are synced.
# Set to 0 to remove limit.
sync_dialog_limit: 30
# The maximum number of simultaneous Telegram deletions to handle.
# A large number of simultaneous redactions could put strain on your homeserver.
max_telegram_delete: 10
# Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames)
# at startup and when creating a bridge.
sync_matrix_state: true
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix
# login website (see appservice.public config section)
allow_matrix_login: true
# Whether or not to bridge plaintext highlights.
# Only enable this if your displayname_template has some static part that the bridge can use to
# reliably identify what is a plaintext highlight.
plaintext_highlights: false
# Show message editing as a reply to the original message.
# If this is false, message edits are not shown at all, as Matrix does not support editing yet.
edits_as_replies: true
# Highlight changed/added parts in edits. Requires lxml.
highlight_edits: false
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
public_portals: true
# Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down.
# Currently only works for private chats and normal groups.
catch_up: false
# Whether or not to use /sync to get presence, read receipts and typing notifications when using
# your own Matrix account as the Matrix puppet for your Telegram account.
sync_with_custom_puppets: true
# Set to false to disable link previews in messages sent to Telegram.
telegram_link_preview: true
# Use inline images instead of a separate message for the caption.
# N.B. Inline images are not supported on all clients (e.g. Riot iOS).
inline_images: false
# Maximum size of image in megabytes before sending to Telegram as a document.
image_as_file_size: 10
# Whether to bridge Telegram bot messages as m.notices or m.texts.
bot_messages_as_notices: true
bridge_notices:
# Whether or not Matrix bot messages (type m.notice) should be bridged.
default: false
# List of user IDs for whom the previous flag is flipped.
# e.g. if bridge_notices.default is false, notices from other users will not be bridged, but
# notices from users listed here will be bridged.
exceptions:
- "@importantbot:example.com"
# Some config options related to Telegram message deduplication.
# The default values are usually fine, but some debug messages/warnings might recommend you
# change these.
deduplication:
# Whether or not to check the database if the message about to be sent is a duplicate.
pre_db_check: false
# The number of latest events to keep when checking for duplicates.
# You might need to increase this on high-traffic bridge instances.
cache_queue_length: 20
# The formats to use when sending messages to Telegram via the relay bot.
#
# Telegram doesn't have built-in emotes, so the m.emote format is also used for non-relaybot users.
#
# Available variables:
# $sender_displayname - The display name of the sender (e.g. Example User)
# $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
# $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
# $message - The message content as HTML
message_formats:
m.text: "<b>$sender_displayname</b>: $message"
m.emote: "* <b>$sender_displayname</b> $message"
m.file: "<b>$sender_displayname</b> sent a file: $message"
m.image: "<b>$sender_displayname</b> sent an image: $message"
m.audio: "<b>$sender_displayname</b> sent an audio file: $message"
m.video: "<b>$sender_displayname</b> sent a video: $message"
m.location: "<b>$sender_displayname</b> sent a location: $message"
# The formats to use when sending state events to Telegram via the relay bot.
#
# Variables from `message_formats` that have the `sender_` prefix are available without the prefix.
# In name_change events, `$prev_displayname` is the previous displayname.
#
# Set format to an empty string to disable the messages for that event.
state_event_formats:
join: "<b>$displayname</b> joined the room."
leave: "<b>$displayname</b> left the room."
name_change: "<b>$prev_displayname</b> changed their name to <b>$displayname</b>"
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
# `filter-mode` management commands.
#
# Filters do not affect direct chats.
# An empty blacklist will essentially disable the filter.
filter:
# Filter mode to use. Either "blacklist" or "whitelist".
# If the mode is "blacklist", the listed chats will never be bridged.
# If the mode is "whitelist", only the listed chats can be bridged.
mode: blacklist
# The list of group/channel IDs to filter.
list: []
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg"
# Permissions for using the bridge.
# Permitted values:
# relaybot - Only use the bridge via the relaybot, no access to commands.
# user - Relaybot level + access to commands to create bridges.
# puppeting - User level + logging in with a Telegram account.
# full - Full access to use the bridge, i.e. previous levels + Matrix login.
# admin - Full access to use the bridge and some extra administration commands.
# Permitted keys:
# * - All Matrix users
# domain - All users on that homeserver
# mxid - Specific user
permissions:
"*": "relaybot"
"public.example.com": "user"
"example.com": "full"
"@admin:example.com": "admin"
# Options related to the message relay Telegram bot.
relaybot:
# Whether or not to allow creating portals from Telegram.
authless_portals: true
# Whether or not to allow Telegram group admins to use the bot commands.
whitelist_group_admins: true
# Whether or not to ignore incoming events sent by the relay bot.
ignore_own_incoming_events: true
# List of usernames/user IDs who are also allowed to use the bot commands.
whitelist:
- myusername
- 12345678
# Telegram config
telegram:
# Get your own API keys at https://my.telegram.org/apps
api_id: 12345
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
# (Optional) Create your own bot at https://t.me/BotFather
bot_token: disabled
# Custom server to connect to.
server:
# Set to true to use these server settings. If false, will automatically
# use production server assigned by Telegram. Set to false in production.
enabled: false
# The DC ID to connect to.
dc: 2
# The IP to connect to.
ip: 149.154.167.40
# The port to connect to. 443 may not work, 80 is better and both are equally secure.
port: 80
# Telethon proxy configuration.
# You must install PySocks from pip for proxies to work.
proxy:
# Allowed types: disabled, socks4, socks5, http
type: disabled
# Proxy IP address and port.
address: 127.0.0.1
port: 1080
# Whether or not to perform DNS resolving remotely.
rdns: true
# Proxy authentication (optional).
username: ""
password: ""
# Python logging configuration.
#
# See section 16.7.2 of the Python documentation for more info:
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
logging:
version: 1
formatters:
precise:
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
handlers:
file:
class: logging.handlers.RotatingFileHandler
formatter: precise
filename: ./mautrix-telegram.log
maxBytes: 10485760
backupCount: 10
console:
class: logging.StreamHandler
formatter: precise
loggers:
mau:
level: DEBUG
telethon:
level: DEBUG
aiohttp:
level: INFO
root:
level: DEBUG
handlers: [file, console]
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.5.1"
__version__ = "0.15.1"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+109 -115
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,138 +13,133 @@
#
# 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 Awaitable, List, Any
from time import time
import argparse
import asyncio
import logging.config
import sys
import copy
import signal
from __future__ import annotations
import sqlalchemy as sql
from typing import Any
from mautrix_appservice import AppService
from alchemysession import AlchemySessionContainer
from telethon import __version__ as __telethon_version__
from mautrix.bridge import Bridge
from mautrix.types import RoomID, UserID
from .bot import Bot
from .config import Config
from .db import init as init_db, upgrade_table
from .matrix import MatrixHandler
from .portal import Portal
from .puppet import Puppet
from .user import User
from .version import linkified_version, version
from .web.provisioning import ProvisioningAPI
from .web.public import PublicBridgeWebsite
from .abstract_user import init as init_abstract_user
from .bot import init as init_bot
from .config import Config
from .context import Context
from .db import Base, init as init_db
from .formatter import init as init_formatter
from .matrix import MatrixHandler
from .portal import init as init_portal
from .puppet import init as init_puppet
from .sqlstatestore import SQLStateStore
from .user import User, init as init_user
from . import __version__
parser = argparse.ArgumentParser(
description="A Matrix-Telegram puppeting bridge.",
prog="python -m mautrix-telegram")
parser.add_argument("-c", "--config", type=str, default="config.yaml",
metavar="<path>", help="the path to your config file")
parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml",
metavar="<path>", help="the path to the example config "
"(for automatic config updates)")
parser.add_argument("-g", "--generate-registration", action="store_true",
help="generate registration and quit")
parser.add_argument("-r", "--registration", type=str, default="registration.yaml",
metavar="<path>", help="the path to save the generated registration to")
args = parser.parse_args()
from .abstract_user import AbstractUser # isort: skip
config = Config(args.config, args.registration, args.base_config)
config.load()
config.update()
if args.generate_registration:
config.generate_registration()
config.save()
print(f"Registration generated and saved to {config.registration_path}")
sys.exit(0)
class TelegramBridge(Bridge):
module = "mautrix_telegram"
name = "mautrix-telegram"
beeper_service_name = "telegram"
beeper_network_name = "telegram"
command = "python -m mautrix-telegram"
description = "A Matrix-Telegram puppeting bridge."
repo_url = "https://github.com/mautrix/telegram"
version = version
markdown_version = linkified_version
config_class = Config
matrix_class = MatrixHandler
upgrade_table = upgrade_table
logging.config.dictConfig(copy.deepcopy(config["logging"]))
log = logging.getLogger("mau.init") # type: logging.Logger
log.debug(f"Initializing mautrix-telegram {__version__}")
config: Config
bot: Bot | None
matrix: MatrixHandler
public_website: PublicBridgeWebsite | None
provisioning_api: ProvisioningAPI | None
db_engine = sql.create_engine(config["appservice.database"] or "sqlite:///mautrix-telegram.db")
Base.metadata.bind = db_engine
def prepare_db(self) -> None:
super().prepare_db()
init_db(self.db)
session_container = AlchemySessionContainer(engine=db_engine, table_base=Base, session=False,
table_prefix="telethon_", manage_tables=False)
session_container.core_mode = True
def _prepare_website(self) -> None:
if self.config["appservice.provisioning.enabled"]:
self.provisioning_api = ProvisioningAPI(self)
self.az.app.add_subapp(
self.config["appservice.provisioning.prefix"], self.provisioning_api.app
)
else:
self.provisioning_api = None
try:
import uvloop
if self.config["appservice.public.enabled"]:
self.public_website = PublicBridgeWebsite(self.loop)
self.az.app.add_subapp(
self.config["appservice.public.prefix"], self.public_website.app
)
else:
self.public_website = None
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
log.debug("Using uvloop for asyncio")
except ImportError:
pass
def prepare_bridge(self) -> None:
self._prepare_website()
AbstractUser.init_cls(self)
bot_token: str = self.config["telegram.bot_token"]
if bot_token and not bot_token.lower().startswith("disable"):
self.bot = AbstractUser.relaybot = Bot(bot_token)
else:
self.bot = AbstractUser.relaybot = None
self.matrix = MatrixHandler(self)
Portal.init_cls(self)
self.add_startup_actions(Puppet.init_cls(self))
self.add_startup_actions(User.init_cls(self))
self.add_startup_actions(Portal.restart_scheduled_disappearing())
if self.bot:
self.add_startup_actions(self.bot.start())
if self.config["bridge.resend_bridge_info"]:
self.add_startup_actions(self.resend_bridge_info())
loop = asyncio.get_event_loop() # type: asyncio.AbstractEventLoop
async def resend_bridge_info(self) -> None:
self.config["bridge.resend_bridge_info"] = False
self.config.save()
self.log.info("Re-sending bridge info state event to all portals")
async for portal in Portal.all():
await portal.update_bridge_info()
self.log.info("Finished re-sending bridge info state events")
state_store = SQLStateStore()
mebibyte = 1024 ** 2
appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
config["appservice.as_token"], config["appservice.hs_token"],
config["appservice.bot_username"], log="mau.as", loop=loop,
verify_ssl=config["homeserver.verify_ssl"], state_store=state_store,
real_user_content_key="net.maunium.telegram.puppet",
aiohttp_params={
"client_max_size": config["appservice.max_body_size"] * mebibyte
})
bot = init_bot(config)
context = Context(appserv, config, loop, session_container, bot)
def prepare_stop(self) -> None:
self.add_shutdown_actions(user.stop() for user in User.by_tgid.values())
if self.bot:
self.add_shutdown_actions(self.bot.stop())
if config["appservice.public.enabled"]:
public_website = PublicBridgeWebsite(loop)
appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app)
context.public_website = public_website
async def get_user(self, user_id: UserID, create: bool = True) -> User | None:
user = await User.get_by_mxid(user_id, create=create)
if user:
await user.ensure_started()
return user
if config["appservice.provisioning.enabled"]:
provisioning_api = ProvisioningAPI(context)
appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning",
provisioning_api.app)
context.provisioning_api = provisioning_api
async def get_portal(self, room_id: RoomID) -> Portal | None:
return await Portal.get_by_mxid(room_id)
context.mx = MatrixHandler(context)
async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet | None:
return await Puppet.get_by_mxid(user_id, create=create)
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
start_ts = time()
init_db(db_engine)
init_abstract_user(context)
init_formatter(context)
init_portal(context)
startup_actions = (init_puppet(context) +
init_user(context) +
[start, context.mx.init_as_bot()]) # type: List[Awaitable[Any]]
async def get_double_puppet(self, user_id: UserID) -> Puppet | None:
return await Puppet.get_by_custom_mxid(user_id)
if context.bot:
startup_actions.append(context.bot.start())
def is_bridge_ghost(self, user_id: UserID) -> bool:
return bool(Puppet.get_id_from_mxid(user_id))
signal.signal(signal.SIGINT, signal.default_int_handler)
signal.signal(signal.SIGTERM, signal.default_int_handler)
async def count_logged_in_users(self) -> int:
return len([user for user in User.by_tgid.values() if user.tgid])
end_ts = time()
try:
log.debug(f"Initialization complete in {round(end_ts - start_ts, 2)} seconds,"
" running startup actions")
start_ts = time()
loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop))
end_ts = time()
log.debug(f"Startup actions complete in {round(end_ts - start_ts, 2)} seconds,"
" now running forever")
loop.run_forever()
except KeyboardInterrupt:
log.debug("Interrupt received, stopping clients")
loop.run_until_complete(
asyncio.gather(*[user.stop() for user in User.by_tgid.values()], loop=loop))
log.debug("Clients stopped, shutting down")
sys.exit(0)
except Exception as e:
log.exception("Unexpected error")
sys.exit(1)
async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]:
return {
**await super().manhole_global_namespace(user_id),
"User": User,
"Portal": Portal,
"Puppet": Puppet,
}
@property
def manhole_banner_program_version(self) -> str:
return f"{super().manhole_banner_program_version} and Telethon {__telethon_version__}"
TelegramBridge().run()
+575 -191
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# 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
@@ -14,110 +13,252 @@
#
# 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 Tuple, Optional, List, Union, TYPE_CHECKING
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Union
from abc import ABC, abstractmethod
import asyncio
import logging
import platform
import time
from telethon.tl.patched import MessageService, Message
from telethon.errors import AuthKeyError, UnauthorizedError
from telethon.network import (
Connection,
ConnectionTcpFull,
ConnectionTcpMTProxyRandomizedIntermediate,
)
from telethon.sessions import Session
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (
Channel, ChannelForbidden, Chat, ChatForbidden, MessageActionChannelMigrateFrom, PeerUser,
TypeUpdate, UpdateChannelPinnedMessage, UpdateChatPinnedMessage, UpdateChatParticipantAdmin,
UpdateChatParticipants, UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateDeleteMessages,
UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateNewMessage,
UpdateReadHistoryOutbox, UpdateShortChatMessage, UpdateShortMessage, UpdateUserName,
UpdateUserPhoto, UpdateUserStatus, UpdateUserTyping, User, UserStatusOffline, UserStatusOnline)
Channel,
Chat,
MessageActionChannelMigrateFrom,
MessageEmpty,
PeerChannel,
PeerChat,
PeerUser,
PhoneCallRequested,
TypeUpdate,
UpdateChannel,
UpdateChannelUserTyping,
UpdateChatDefaultBannedRights,
UpdateChatParticipantAdmin,
UpdateChatParticipants,
UpdateChatUserTyping,
UpdateDeleteChannelMessages,
UpdateDeleteMessages,
UpdateEditChannelMessage,
UpdateEditMessage,
UpdateFolderPeers,
UpdateMessageReactions,
UpdateNewChannelMessage,
UpdateNewMessage,
UpdateNotifySettings,
UpdatePhoneCall,
UpdatePinnedChannelMessages,
UpdatePinnedDialogs,
UpdatePinnedMessages,
UpdateReadChannelInbox,
UpdateReadHistoryInbox,
UpdateReadHistoryOutbox,
UpdateShort,
UpdateShortChatMessage,
UpdateShortMessage,
UpdateUser,
UpdateUserName,
UpdateUserStatus,
UpdateUserTyping,
User,
UserStatusOffline,
UserStatusOnline,
)
from mautrix_appservice import MatrixRequestError, AppService
from alchemysession import AlchemySessionContainer
from mautrix.appservice import AppService
from mautrix.errors import MatrixError
from mautrix.types import PresenceState, UserID
from mautrix.util import background_task
from mautrix.util.logging import TraceLogger
from mautrix.util.opt_prometheus import Counter, Histogram
from . import portal as po, puppet as pu, __version__
from .db import Message as DBMessage
from .types import TelegramID, MatrixUserID
from . import __version__, portal as po, puppet as pu
from .config import Config
from .db import Message as DBMessage, PgSession
from .tgclient import MautrixTelegramClient
from .types import TelegramID
if TYPE_CHECKING:
from .context import Context
from .config import Config
from .__main__ import TelegramBridge
from .bot import Bot
config = None # type: Config
# Value updated from config in init()
MAX_DELETIONS = 10 # type: int
UpdateMessage = Union[
UpdateShortChatMessage,
UpdateShortMessage,
UpdateNewChannelMessage,
UpdateNewMessage,
UpdateEditMessage,
UpdateEditChannelMessage,
]
UpdateMessageContent = Union[
UpdateShortMessage, UpdateShortChatMessage, Message, MessageService, MessageEmpty
]
UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage]
UpdateMessageContent = Union[UpdateShortMessage, UpdateShortChatMessage, Message, MessageService]
UPDATE_TIME = Histogram(
name="bridge_telegram_update",
documentation="Time spent processing Telegram updates",
labelnames=("update_type",),
)
UPDATE_ERRORS = Counter(
name="bridge_telegram_update_error",
documentation="Number of fatal errors while handling Telegram updates",
labelnames=("update_type",),
)
class AbstractUser(ABC):
session_container = None # type: AlchemySessionContainer
loop = None # type: asyncio.AbstractEventLoop
log = None # type: logging.Logger
az = None # type: AppService
bot = None # type: Bot
ignore_incoming_bot_events = True # type: bool
loop: asyncio.AbstractEventLoop = None
log: TraceLogger
az: AppService
bridge: "TelegramBridge"
config: Config
relaybot: "Bot"
ignore_incoming_bot_events: bool = True
max_deletions: int = 10
client: MautrixTelegramClient | None
mxid: UserID | None
tgid: TelegramID | None
username: str | None
is_bot: bool
is_relaybot: bool
puppet_whitelisted: bool
whitelisted: bool
relaybot_whitelisted: bool
matrix_puppet_whitelisted: bool
is_admin: bool
def __init__(self) -> None:
self.is_admin = False # type: bool
self.matrix_puppet_whitelisted = False # type: bool
self.puppet_whitelisted = False # type: bool
self.whitelisted = False # type: bool
self.relaybot_whitelisted = False # type: bool
self.client = None # type: MautrixTelegramClient
self.tgid = None # type: TelegramID
self.mxid = None # type: MatrixUserID
self.is_relaybot = False # type: bool
self.is_bot = False # type: bool
self.relaybot = None # type: Optional[Bot]
self.is_admin = False
self.matrix_puppet_whitelisted = False
self.puppet_whitelisted = False
self.whitelisted = False
self.relaybot_whitelisted = False
self.client = None
self.is_relaybot = False
self.is_bot = False
@property
def connected(self) -> bool:
return self.client and self.client.is_connected()
@property
def _proxy_settings(self) -> Optional[Tuple[int, str, str, str, str, str]]:
proxy_type = config["telegram.proxy.type"].lower()
def _proxy_settings(self) -> tuple[type[Connection], tuple[Any, ...] | None]:
proxy_type = self.config["telegram.proxy.type"].lower()
connection = ConnectionTcpFull
connection_data = (
self.config["telegram.proxy.address"],
self.config["telegram.proxy.port"],
self.config["telegram.proxy.rdns"],
self.config["telegram.proxy.username"],
self.config["telegram.proxy.password"],
)
if proxy_type == "disabled":
return None
connection_data = None
elif proxy_type == "socks4":
proxy_type = 1
connection_data = (1,) + connection_data
elif proxy_type == "socks5":
proxy_type = 2
connection_data = (2,) + connection_data
elif proxy_type == "http":
proxy_type = 3
connection_data = (3,) + connection_data
elif proxy_type == "mtproxy":
connection = ConnectionTcpMTProxyRandomizedIntermediate
connection_data = (connection_data[0], connection_data[1], connection_data[4])
return (proxy_type,
config["telegram.proxy.address"], config["telegram.proxy.port"],
config["telegram.proxy.rdns"],
config["telegram.proxy.username"], config["telegram.proxy.password"])
return connection, connection_data
def _init_client(self) -> None:
@classmethod
def init_cls(cls, bridge: "TelegramBridge") -> None:
cls.bridge = bridge
cls.config = bridge.config
cls.loop = bridge.loop
cls.az = bridge.az
cls.ignore_incoming_bot_events = cls.config["bridge.relaybot.ignore_own_incoming_events"]
cls.max_deletions = cls.config["bridge.max_telegram_delete"]
async def _init_client(self) -> None:
self.log.debug(f"Initializing client for {self.name}")
device = f"{platform.system()} {platform.release()}"
sysversion = MautrixTelegramClient.__version__
self.session = self.session_container.new_session(self.name)
if config["telegram.server.enabled"]:
self.session.set_dc(config["telegram.server.dc"],
config["telegram.server.ip"],
config["telegram.server.port"])
session = await PgSession.get(self.name)
if self.config["telegram.server.enabled"]:
session.set_dc(
self.config["telegram.server.dc"],
self.config["telegram.server.ip"],
self.config["telegram.server.port"],
)
if self.is_relaybot:
base_logger = logging.getLogger("telethon.relaybot")
else:
base_logger = logging.getLogger(f"telethon.{self.tgid or -hash(self.mxid)}")
self.client = MautrixTelegramClient(session=self.session,
api_id=config["telegram.api_id"],
api_hash=config["telegram.api_hash"],
loop=self.loop,
app_version=__version__,
system_version=sysversion,
device_model=device,
timeout=120,
base_logger=base_logger,
proxy=self._proxy_settings)
device = self.config["telegram.device_info.device_model"]
sysversion = self.config["telegram.device_info.system_version"]
appversion = self.config["telegram.device_info.app_version"]
connection, proxy = self._proxy_settings
if proxy:
self.log.debug(f"Using proxy setting: {proxy}")
assert isinstance(session, Session)
self.client = MautrixTelegramClient(
session=session,
api_id=self.config["telegram.api_id"],
api_hash=self.config["telegram.api_hash"],
app_version=__version__ if appversion == "auto" else appversion,
system_version=(
MautrixTelegramClient.__version__ if sysversion == "auto" else sysversion
),
device_model=(
f"{platform.system()} {platform.release()}" if device == "auto" else device
),
timeout=self.config["telegram.connection.timeout"],
connection_retries=self.config["telegram.connection.retries"],
retry_delay=self.config["telegram.connection.retry_delay"],
flood_sleep_threshold=self.config["telegram.connection.flood_sleep_threshold"],
request_retries=self.config["telegram.connection.request_retries"],
connection=connection,
proxy=proxy,
raise_last_call_error=True,
catch_up=self.config["telegram.catch_up"],
sequential_updates=self.config["telegram.sequential_updates"],
loop=self.loop,
base_logger=base_logger,
update_error_callback=self._telethon_update_error_callback,
use_ipv6=self.config["telegram.connection.use_ipv6"],
)
self.client.add_event_handler(self._update_catch)
@abstractmethod
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
pass
async def _telethon_update_error_callback(self, err: Exception) -> None:
if isinstance(err, (UnauthorizedError, AuthKeyError)):
background_task.create(self.on_signed_out(err))
return
if self.config["telegram.exit_on_update_error"]:
self.log.critical(f"Stopping due to update handling error {type(err).__name__}")
self.bridge.manual_stop(50)
else:
self.log.info("Recreating Telethon connection in 60 seconds")
await asyncio.sleep(60)
self.log.debug("Now recreating Telethon connection")
await self.stop()
await self.start()
@abstractmethod
async def update(self, update: TypeUpdate) -> bool:
return False
@@ -127,28 +268,23 @@ class AbstractUser(ABC):
raise NotImplementedError()
@abstractmethod
def register_portal(self, portal: po.Portal) -> None:
async def register_portal(self, portal: po.Portal) -> None:
raise NotImplementedError()
@abstractmethod
def unregister_portal(self, portal: po.Portal) -> None:
async def unregister_portal(self, tgid: int, tg_receiver: int) -> None:
raise NotImplementedError()
async def _update_catch(self, update: TypeUpdate) -> None:
start_time = time.time()
update_type = type(update).__name__
try:
if not await self.update(update):
await self._update(update)
except Exception:
self.log.exception("Failed to handle Telegram update")
async def get_dialogs(self, limit: int = None) -> List[Union[Chat, Channel]]:
if self.is_bot:
return []
dialogs = await self.client.get_dialogs(limit=limit)
return [dialog.entity for dialog in dialogs if (
not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden))
and not (isinstance(dialog.entity, Chat)
and (dialog.entity.deactivated or dialog.entity.left)))]
UPDATE_ERRORS.labels(update_type=update_type).inc()
UPDATE_TIME.labels(update_type=update_type).observe(time.time() - start_time)
@property
@abstractmethod
@@ -156,43 +292,78 @@ class AbstractUser(ABC):
raise NotImplementedError()
async def is_logged_in(self) -> bool:
return self.client and self.client.is_connected() and await self.client.is_user_authorized()
return (
self.client and self.client.is_connected() and await self.client.is_user_authorized()
)
async def has_full_access(self, allow_bot: bool = False) -> bool:
return (self.puppet_whitelisted
and (not self.is_bot or allow_bot)
and await self.is_logged_in())
return (
self.puppet_whitelisted
and (not self.is_bot or allow_bot)
and await self.is_logged_in()
)
async def start(self, delete_unless_authenticated: bool = False) -> 'AbstractUser':
async def start(self, delete_unless_authenticated: bool = False) -> AbstractUser:
if not self.client:
self._init_client()
await self.client.connect()
self.log.debug("%s connected: %s", self.mxid, self.connected)
await self._init_client()
attempts = 1
while True:
try:
await self.client.connect()
except Exception:
attempts += 1
if attempts > 10:
raise
self.log.exception("Exception connecting to Telegram, retrying in 5s...")
await asyncio.sleep(5)
else:
break
self.log.debug(f"{'Bot' if self.is_relaybot else self.mxid} connected: {self.connected}")
return self
async def ensure_started(self, even_if_no_session=False) -> 'AbstractUser':
if not self.puppet_whitelisted or self.connected:
async def ensure_started(self, even_if_no_session=False) -> AbstractUser:
if self.connected:
return self
self.log.debug("ensure_started(%s, even_if_no_session=%s)", self.mxid, even_if_no_session)
if even_if_no_session or self.session_container.has_session(self.mxid):
session_exists = await PgSession.has(self.mxid)
if even_if_no_session or session_exists:
self.log.debug(
f"Starting client due to ensure_started({even_if_no_session=}, {session_exists=})"
)
await self.start(delete_unless_authenticated=not even_if_no_session)
return self
async def stop(self) -> None:
await self.client.disconnect()
self.client = None
if self.client:
await self.client.disconnect()
self.client = None
# region Telegram update handling
async def _update(self, update: TypeUpdate) -> None:
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
if isinstance(update, UpdateShort):
update = update.update
background_task.create(self._handle_entity_updates(getattr(update, "_entities", {})))
if isinstance(
update,
(
UpdateShortChatMessage,
UpdateShortMessage,
UpdateNewChannelMessage,
UpdateNewMessage,
UpdateEditMessage,
UpdateEditChannelMessage,
),
):
await self.update_message(update)
elif isinstance(update, UpdateDeleteMessages):
await self.delete_message(update)
elif isinstance(update, UpdateDeleteChannelMessages):
await self.delete_channel_message(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)):
elif isinstance(update, UpdatePhoneCall):
await self.update_phone_call(update)
elif isinstance(update, UpdateMessageReactions):
await self.update_reactions(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
await self.update_typing(update)
elif isinstance(update, UpdateUserStatus):
await self.update_status(update)
@@ -200,190 +371,403 @@ class AbstractUser(ABC):
await self.update_admin(update)
elif isinstance(update, UpdateChatParticipants):
await self.update_participants(update)
elif isinstance(update, (UpdateChannelPinnedMessage, UpdateChatPinnedMessage)):
elif isinstance(update, UpdateChatDefaultBannedRights):
await self.update_default_banned_rights(update)
elif isinstance(update, (UpdatePinnedMessages, UpdatePinnedChannelMessages)):
await self.update_pinned_messages(update)
elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
elif isinstance(update, (UpdateUserName, UpdateUser)):
await self.update_others_info(update)
elif isinstance(update, UpdateReadHistoryOutbox):
await self.update_read_receipt(update)
elif isinstance(update, (UpdateReadHistoryInbox, UpdateReadChannelInbox)):
await self.update_own_read_receipt(update)
elif isinstance(update, UpdateFolderPeers):
await self.update_folder_peers(update)
elif isinstance(update, UpdatePinnedDialogs):
await self.update_pinned_dialogs(update)
elif isinstance(update, UpdateNotifySettings):
await self.update_notify_settings(update)
elif isinstance(update, UpdateChannel):
await self.update_channel(update)
else:
self.log.debug("Unhandled update: %s", update)
self.log.trace("Unhandled update: %s", update)
async def update_pinned_messages(self, update: Union[UpdateChannelPinnedMessage,
UpdateChatPinnedMessage]) -> None:
if isinstance(update, UpdateChatPinnedMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
async def update_folder_peers(self, update: UpdateFolderPeers) -> None:
pass
async def update_pinned_dialogs(self, update: UpdatePinnedDialogs) -> None:
pass
async def update_notify_settings(self, update: UpdateNotifySettings) -> None:
pass
async def update_pinned_messages(
self, update: UpdatePinnedMessages | UpdatePinnedChannelMessages
) -> None:
if isinstance(update, UpdatePinnedMessages):
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
else:
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
if portal and portal.mxid:
await portal.receive_telegram_pin_id(update.id, self.tgid)
await portal.receive_telegram_pin_ids(
update.messages, self.tgid, remove=not update.pinned
)
@staticmethod
async def update_participants(update: UpdateChatParticipants) -> None:
portal = po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
if portal and portal.mxid:
await portal.update_telegram_participants(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:
if not isinstance(update.peer, PeerUser):
self.log.debug("Unexpected read receipt peer: %s", update.peer)
return
portal = po.Portal.get_by_tgid(TelegramID(update.peer.user_id), self.tgid)
portal = await po.Portal.get_by_tgid(
TelegramID(update.peer.user_id), tg_receiver=self.tgid
)
if not portal or not portal.mxid:
return
# We check that these are user read receipts, so tg_space is always the user ID.
message = DBMessage.get_by_tgid(TelegramID(update.max_id), self.tgid)
message = await DBMessage.get_one_by_tgid(
TelegramID(update.max_id), self.tgid, edit_index=-1
)
if not message:
return
puppet = pu.Puppet.get(TelegramID(update.peer.user_id))
puppet = await pu.Puppet.get_by_peer(update.peer)
await puppet.intent.mark_read(portal.mxid, message.mxid)
async def update_own_read_receipt(
self, update: UpdateReadHistoryInbox | UpdateReadChannelInbox
) -> None:
puppet = await pu.Puppet.get_by_tgid(self.tgid)
if not puppet.is_real_user:
return
self.log.debug("Handling own read receipt: %s", update)
if isinstance(update, UpdateReadChannelInbox):
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
elif isinstance(update.peer, PeerChat):
portal = await po.Portal.get_by_tgid(TelegramID(update.peer.chat_id))
elif isinstance(update.peer, PeerUser):
portal = await po.Portal.get_by_tgid(
TelegramID(update.peer.user_id), tg_receiver=self.tgid
)
else:
self.log.debug("Unexpected own read receipt peer: %s", update.peer)
return
if not portal or not portal.mxid:
# TODO This explodes on channels because the field is channel_id
self.log.debug(f"Dropping own read receipt in unknown chat ({update.peer})")
return
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
message = await DBMessage.get_one_by_tgid(
TelegramID(update.max_id), tg_space, edit_index=-1
)
if not message:
self.log.debug(
f"Dropping own read receipt: unknown message {update.max_id}@{tg_space}"
)
return
await puppet.intent.mark_read(portal.mxid, message.mxid)
async def update_admin(self, update: UpdateChatParticipantAdmin) -> None:
# TODO duplication not checked
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
if not portal or not portal.mxid:
return
await portal.set_telegram_admin(TelegramID(update.user_id))
async def update_typing(self, update: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None:
async def update_typing(
self, update: UpdateUserTyping | UpdateChatUserTyping | UpdateChannelUserTyping
) -> None:
sender = None
if isinstance(update, UpdateUserTyping):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
portal = await po.Portal.get_by_tgid(
TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user"
)
sender = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
elif isinstance(update, UpdateChannelUserTyping):
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
elif isinstance(update, UpdateChatUserTyping):
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
else:
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
if not portal or not portal.mxid:
return
sender = pu.Puppet.get(TelegramID(update.user_id))
if isinstance(update, (UpdateChannelUserTyping, UpdateChatUserTyping)):
sender = await pu.Puppet.get_by_peer(update.from_id)
if not sender or not portal or not portal.mxid:
return
await portal.handle_telegram_typing(sender, update)
async def update_others_info(self, update: Union[UpdateUserName, UpdateUserPhoto]) -> None:
async def _handle_entity_updates(self, entities: dict[int, User | Chat | Channel]) -> None:
try:
users = (entity for entity in entities.values() if isinstance(entity, (User, Channel)))
puppets = ((await pu.Puppet.get_by_peer(user), user) for user in users)
await asyncio.gather(
*[puppet.try_update_info(self, info) async for puppet, info in puppets if puppet]
)
except Exception:
self.log.exception("Failed to handle entity updates")
async def update_others_info(self, update: UpdateUserName | UpdateUser) -> None:
# TODO duplication not checked
puppet = pu.Puppet.get(TelegramID(update.user_id))
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
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):
puppet.save()
elif isinstance(update, UpdateUserPhoto):
if await puppet.update_avatar(self, update.photo.photo_big):
puppet.save()
await puppet.save()
await puppet.update_portals_meta()
elif isinstance(update, UpdateUser):
info = await self.client.get_entity(puppet.peer)
await puppet.update_info(self, info)
else:
self.log.warning("Unexpected other user info update: %s", update)
self.log.warning(f"Unexpected other user info update: {type(update)}")
async def update_status(self, update: UpdateUserStatus) -> None:
puppet = pu.Puppet.get(TelegramID(update.user_id))
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
if isinstance(update.status, UserStatusOnline):
await puppet.default_mxid_intent.set_presence("online")
await puppet.default_mxid_intent.set_presence(PresenceState.ONLINE)
elif isinstance(update.status, UserStatusOffline):
await puppet.default_mxid_intent.set_presence("offline")
await puppet.default_mxid_intent.set_presence(PresenceState.OFFLINE)
else:
self.log.warning("Unexpected user status update: %s", update)
self.log.warning(f"Unexpected user status update: type({update})")
return
def get_message_details(self, update: UpdateMessage) -> Tuple[UpdateMessageContent,
Optional[pu.Puppet],
Optional[po.Portal]]:
async def get_message_details(
self, update: UpdateMessage
) -> tuple[UpdateMessageContent, pu.Puppet | None, po.Portal | None]:
if isinstance(update, UpdateShortChatMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
sender = pu.Puppet.get(TelegramID(update.from_id))
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id))
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
sender = pu.Puppet.get(self.tgid if update.out else update.user_id)
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
UpdateEditMessage, UpdateEditChannelMessage)):
portal = await po.Portal.get_by_tgid(
TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user"
)
sender = await pu.Puppet.get_by_tgid(self.tgid if update.out else update.user_id)
elif isinstance(
update,
(
UpdateNewMessage,
UpdateNewChannelMessage,
UpdateEditMessage,
UpdateEditChannelMessage,
),
):
update = update.message
if isinstance(update.to_id, PeerUser) and not update.out:
portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
tg_receiver=self.tgid)
if isinstance(update, MessageEmpty):
return update, None, None
portal = await po.Portal.get_by_entity(update.peer_id, tg_receiver=self.tgid)
if update.out:
sender = await pu.Puppet.get_by_tgid(self.tgid)
elif isinstance(update.from_id, (PeerUser, PeerChannel)):
sender = await pu.Puppet.get_by_peer(update.from_id)
elif isinstance(update.peer_id, PeerUser):
sender = await pu.Puppet.get_by_peer(update.peer_id)
else:
portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
sender = pu.Puppet.get(update.from_id) if update.from_id else None
sender = None
else:
self.log.warning(
f"Unexpected message type in User#get_message_details: {type(update)}")
f"Unexpected message type in User#get_message_details: {type(update)}"
)
return update, None, None
return update, sender, portal
@staticmethod
async def _try_redact(portal: po.Portal, message: DBMessage) -> None:
async def _try_redact(message: DBMessage) -> None:
portal = await po.Portal.get_by_mxid(message.mx_room)
if not portal:
return
try:
await portal.main_intent.redact(message.mx_room, message.mxid)
except MatrixRequestError:
except MatrixError:
pass
async def delete_message(self, update: UpdateDeleteMessages) -> None:
if len(update.messages) > MAX_DELETIONS:
if len(update.messages) > self.max_deletions:
return
for message in update.messages:
message = DBMessage.get_by_tgid(TelegramID(message), self.tgid)
if not message:
continue
message.delete()
number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
if number_left == 0:
portal = po.Portal.get_by_mxid(message.mx_room)
await self._try_redact(portal, message)
for message_id in update.messages:
for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid):
if message.redacted:
continue
await message.delete()
number_left = await DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
if number_left == 0:
await self._try_redact(message)
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
if len(update.messages) > MAX_DELETIONS:
if len(update.messages) > self.max_deletions:
return
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
channel_id = TelegramID(update.channel_id)
for message_id in update.messages:
for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id):
if message.redacted:
continue
await message.delete()
await self._try_redact(message)
async def update_reactions(self, update: UpdateMessageReactions) -> None:
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
if not portal or not portal.mxid or not portal.allow_bridging:
return
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions)
async def update_phone_call(self, update: UpdatePhoneCall) -> None:
self.log.debug("Phone call update %s", update)
if not isinstance(update.phone_call, PhoneCallRequested):
return
tgid = TelegramID(update.phone_call.participant_id)
if tgid == self.tgid:
tgid = update.phone_call.admin_id
portal = await po.Portal.get_by_tgid(tgid, tg_receiver=self.tgid, peer_type="user")
if not portal or not portal.mxid or not portal.allow_bridging:
return
sender = await pu.Puppet.get_by_tgid(TelegramID(update.phone_call.admin_id))
await portal.handle_telegram_direct_call(self, sender, update)
async def update_channel(self, update: UpdateChannel) -> None:
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
if not portal:
return
if getattr(update, "mau_telethon_is_leave", False):
self.log.debug("UpdateChannel has mau_telethon_is_leave, leaving portal")
await portal.delete_telegram_user(self.tgid, sender=None)
elif chan := getattr(update, "mau_channel", None):
if not portal.mxid:
if (
not self.is_relaybot
or not self.config["bridge.relaybot.ignore_unbridged_group_chat"]
):
background_task.create(self._delayed_create_channel(chan))
else:
self.log.debug("Updating channel info with data fetched by Telethon")
await portal.update_info(self, chan)
await portal.invite_to_matrix(self.mxid)
for message in update.messages:
message = DBMessage.get_by_tgid(TelegramID(message), portal.tgid)
if not message:
continue
message.delete()
await self._try_redact(portal, message)
async def _delayed_create_channel(self, chan: Channel) -> None:
self.log.debug(
f"Waiting 5 seconds before handling UpdateChannel for non-existent portal {chan.id}"
)
await asyncio.sleep(5)
portal = await po.Portal.get_by_tgid(TelegramID(chan.id))
if portal.mxid:
self.log.debug(
"Portal started existing after waiting 5 seconds, "
f"dropping UpdateChannel for {portal.tgid}"
)
return
else:
self.log.info(
f"Creating Matrix room for {portal.tgid}"
" with data fetched by Telethon due to UpdateChannel"
)
await portal.create_matrix_room(self, chan, invites=[self.mxid])
async def _check_server_notice_edit(self, message: Message) -> None:
pass
async def update_message(self, original_update: UpdateMessage) -> None:
update, sender, portal = self.get_message_details(original_update)
if self.is_bot and not portal.mxid:
self.log.debug(f"Ignoring message received by bot in unbridged chat %s",
portal.tgid_log)
update, sender, portal = await self.get_message_details(original_update)
if not portal:
return
elif portal and not portal.allow_bridging:
self.log.debug(
f"Ignoring message {update.id} in portal {portal.tgid_log} (bridging disallowed)"
)
return
if self.ignore_incoming_bot_events and self.bot and sender.id == self.bot.tgid:
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update, portal.tgid_log)
if not portal.mxid and getattr(original_update, "mau_left_channel", False):
self.log.debug(
f"Ignoring message {update.id} in portal {portal.tgid_log} because user isn't in the chat"
)
return
if self.is_relaybot:
if update.is_private:
if not self.config["bridge.relaybot.private_chat.invite"]:
if sender:
self.log.debug(f"Ignoring private message to bot from {sender.id}")
return
elif not portal.mxid and self.config["bridge.relaybot.ignore_unbridged_group_chat"]:
self.log.debug(
f"Ignoring message received by bot in unbridged chat {portal.tgid_log}"
)
return
if (
self.ignore_incoming_bot_events
and self.relaybot
and sender
and sender.id == self.relaybot.tgid
):
self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
return
task = self._call_portal_message_handler(update, original_update, portal, sender)
if portal.backfill_lock.locked:
self.log.debug(
f"{portal.tgid_log} is backfill locked, moving incoming message to async task"
)
background_task.create(task)
else:
await task
async def _call_portal_message_handler(
self,
update: UpdateMessageContent,
original_update: UpdateMessage,
portal: po.Portal,
sender: pu.Puppet,
) -> None:
await portal.backfill_lock.wait(f"update {update.id}")
if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom):
self.log.debug(f"Ignoring action %s to %s by %d", update.action,
portal.tgid_log,
sender.id)
self.log.debug(
"Received %s in %s by %d, unregistering portal...",
update.action,
portal.tgid_log,
sender.id,
)
await self.unregister_portal(update.action.chat_id, update.action.chat_id)
await self.register_portal(portal)
return
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
self.log.debug(
"Handling action %s to %s by %d",
update.action,
portal.tgid_log,
(sender.id if sender else 0),
)
return await portal.handle_telegram_action(self, sender, update)
user = sender.tgid if sender else "admin"
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
if config["bridge.edits_as_replies"]:
self.log.debug("Handling edit %s to %s by %s", update, portal.tgid_log, user)
return await portal.handle_telegram_edit(self, sender, update)
return
self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
if sender and sender.tgid == 777000:
await self._check_server_notice_edit(update)
return await portal.handle_telegram_edit(self, sender, update)
return await portal.handle_telegram_message(self, sender, update)
# endregion
def init(context: "Context") -> None:
global config, MAX_DELETIONS
AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
AbstractUser.session_container = context.session_container
MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10)
+312 -145
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# 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
@@ -14,55 +13,125 @@
#
# 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 Awaitable, Callable, Dict, List, Optional, Pattern, TYPE_CHECKING
import logging
import re
from __future__ import annotations
from typing import TYPE_CHECKING, Awaitable, Callable, Literal
import logging
import time
from telethon.errors import (
AuthKeyError,
ChannelInvalidError,
ChannelPrivateError,
UnauthorizedError,
)
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (
ChannelParticipantAdmin, ChannelParticipantCreator, ChatForbidden, ChatParticipantAdmin,
ChatParticipantCreator, InputChannel, InputUser, MessageActionChatAddUser,
MessageActionChatDeleteUser, MessageEntityBotCommand, PeerChannel, PeerChat, TypePeer,
UpdateNewChannelMessage, UpdateNewMessage, MessageActionChatMigrateTo)
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
from telethon.errors import ChannelInvalidError, ChannelPrivateError
ChannelParticipantAdmin,
ChannelParticipantCreator,
ChatForbidden,
ChatParticipantAdmin,
ChatParticipantCreator,
ChatParticipantsForbidden,
InputChannel,
InputUser,
MessageActionChatAddUser,
MessageActionChatDeleteUser,
MessageActionChatMigrateTo,
MessageEntityBotCommand,
PeerChannel,
PeerChat,
PeerUser,
TypeChannelParticipant,
TypeChatParticipant,
TypeInputPeer,
TypePeer,
UpdateNewChannelMessage,
UpdateNewMessage,
User,
)
from telethon.utils import add_surrogate, del_surrogate
from .types import MatrixUserID
from mautrix.errors import MBadState, MForbidden
from mautrix.types import RoomID, UserID
from . import portal as po, puppet as pu, user as u
from .abstract_user import AbstractUser
from .db import BotChat
from .db import BotChat, Message as DBMessage
from .types import TelegramID
from . import puppet as pu, portal as po, user as u
if TYPE_CHECKING:
from .config import Config
from .context import Context
config = None # type: Config
from asyncio import Future
ReplyFunc = Callable[[str], Awaitable[Message]]
BanFunc = Callable[[RoomID, UserID, str], Awaitable[None]]
TelegramAdminPermission = Literal[
"change_info",
"post_messages",
"edit_messages",
"delete_messages",
"ban_users",
"invite_users",
"pin_messages",
"add_admins",
"anonymous",
"manage_call",
"other",
]
class Bot(AbstractUser):
log = logging.getLogger("mau.bot") # type: logging.Logger
mxid_regex = re.compile("@.+:.+") # type: Pattern
log: logging.Logger = logging.getLogger("mau.user.bot")
token: str
chats: dict[int, str]
tg_whitelist: list[int]
whitelist_group_admins: bool
_me_info: User | None
_me_mxid: UserID | None
_admin_cache: dict[
tuple[int, int],
tuple[ChatParticipantAdmin | ChatParticipantCreator | None, float],
]
_login_wait_fut: Future | None
required_permissions: dict[str, TelegramAdminPermission] = {
"portal": None,
"invite": "invite_users",
"mxban": "ban_users",
"mxkick": "ban_users",
}
def __init__(self, token: str) -> None:
super().__init__()
self.token = token # type: str
self.puppet_whitelisted = True # type: bool
self.whitelisted = True # type: bool
self.relaybot_whitelisted = True # type: bool
self.username = None # type: str
self.is_relaybot = True # type: bool
self.is_bot = True # type: bool
self.chats = {} # type: Dict[int, str]
self.tg_whitelist = [] # type: List[int]
self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"]
or False) # type: bool
self.token = token
self.tgid = None
self.mxid = None
self.puppet_whitelisted = True
self.whitelisted = True
self.relaybot_whitelisted = True
self.tg_username = None
self.is_relaybot = True
self.is_bot = True
self.chats = {}
self._admin_cache = {}
self.tg_whitelist = []
self.whitelist_group_admins = (
self.config["bridge.relaybot.whitelist_group_admins"] or False
)
self._me_info = None
self._me_mxid = None
self._login_wait_fut = self.loop.create_future()
async def get_me(self, use_cache: bool = True) -> tuple[User, UserID]:
if not use_cache or not self._me_mxid:
self._me_info = await self.client.get_me()
self._me_mxid = pu.Puppet.get_mxid_from_id(TelegramID(self._me_info.id))
return self._me_info, self._me_mxid
async def init_permissions(self) -> None:
whitelist = config["bridge.relaybot.whitelist"] or []
whitelist = self.config["bridge.relaybot.whitelist"] or []
for user_id in whitelist:
if isinstance(user_id, str):
entity = await self.client.get_input_entity(user_id)
@@ -73,204 +142,311 @@ class Bot(AbstractUser):
if isinstance(user_id, int):
self.tg_whitelist.append(user_id)
async def start(self, delete_unless_authenticated: bool = False) -> 'Bot':
self.chats = {chat.id: chat.type for chat in BotChat.all()}
async def start(self, delete_unless_authenticated: bool = False) -> Bot:
self.chats = {chat.id: chat.type for chat in await BotChat.all()}
await super().start(delete_unless_authenticated)
if not await self.is_logged_in():
await self.client.sign_in(bot_token=self.token)
await self.post_login()
return self
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
self.log.fatal("Relay bot got signed out, crashing bridge", exc_info=err)
self.bridge.manual_stop(51)
async def post_login(self) -> None:
await self.init_permissions()
info = await self.client.get_me()
self.tgid = info.id
self.username = info.username
self.tgid = TelegramID(info.id)
self.tg_username = info.username
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
if self._login_wait_fut:
self._login_wait_fut.set_result(None)
self._login_wait_fut = None
chat_ids = [chat_id for chat_id, chat_type in self.chats.items() if chat_type == "chat"]
response = await self.client(GetChatsRequest(chat_ids))
for chat in response.chats:
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
self.remove_chat(TelegramID(chat.id))
await self.remove_chat(TelegramID(chat.id))
channel_ids = [InputChannel(chat_id, 0)
for chat_id, chat_type in self.chats.items()
if chat_type == "channel"]
channel_ids = [
InputChannel(chat_id, 0)
for chat_id, chat_type in self.chats.items()
if chat_type == "channel"
]
for channel_id in channel_ids:
try:
await self.client(GetChannelsRequest([channel_id]))
except (ChannelPrivateError, ChannelInvalidError):
self.remove_chat(TelegramID(channel_id.channel_id))
await self.remove_chat(TelegramID(channel_id.channel_id))
if config["bridge.catch_up"]:
try:
await self.client.catch_up()
except Exception:
self.log.exception("Failed to run catch_up() for bot")
async def register_portal(self, portal: po.Portal) -> None:
await self.add_chat(portal.tgid, portal.peer_type)
def register_portal(self, portal: po.Portal) -> None:
self.add_chat(portal.tgid, portal.peer_type)
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
await self.remove_chat(tgid)
def unregister_portal(self, portal: po.Portal) -> None:
self.remove_chat(portal.tgid)
def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
async def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
if chat_id not in self.chats:
self.chats[chat_id] = chat_type
BotChat(id=TelegramID(chat_id), type=chat_type).insert()
await BotChat(id=chat_id, type=chat_type).insert()
def remove_chat(self, chat_id: TelegramID) -> None:
async def remove_chat(self, chat_id: TelegramID) -> None:
try:
del self.chats[chat_id]
except KeyError:
pass
BotChat.delete(chat_id)
await BotChat.delete_by_id(chat_id)
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
async def _get_admin_participant(
self, chat: TypePeer | TypeInputPeer, tgid: TelegramID
) -> TypeChatParticipant | TypeChannelParticipant | None:
chan_id = chat.channel_id if isinstance(chat, PeerChannel) else chat.chat_id
try:
cached, created = self._admin_cache[chan_id, tgid]
if created + 60 < time.time():
return cached
except KeyError:
pass
if isinstance(chat, PeerChannel):
p = await self.client(GetParticipantRequest(chat, tgid))
pcp = p.participant
self._admin_cache[chat.channel_id, tgid] = (pcp, time.time())
return pcp
elif isinstance(chat, PeerChat):
chat = await self.client(GetFullChatRequest(chat.chat_id))
if isinstance(chat.full_chat.participants, ChatParticipantsForbidden):
return None
participants = chat.full_chat.participants.participants
for p in participants:
self._admin_cache[chat.channel_id, tgid] = (p, time.time())
if p.user_id == tgid:
return p
return None
@staticmethod
def _has_participant_permission(
pcp: TypeChatParticipant | TypeChannelParticipant | None,
permission: TelegramAdminPermission | None,
) -> bool:
if isinstance(pcp, (ChannelParticipantCreator, ChannelParticipantAdmin)):
return permission is None or getattr(pcp.admin_rights, permission, False)
elif isinstance(pcp, (ChatParticipantCreator, ChatParticipantAdmin)):
return True
return False
async def _can_use_commands(
self, chat: TypePeer, tgid: TelegramID, permission: TelegramAdminPermission | None = None
) -> bool:
if tgid in self.tg_whitelist:
return True
user = u.User.get_by_tgid(tgid)
user = await u.User.get_by_tgid(tgid)
if user and user.is_admin:
self.tg_whitelist.append(user.tgid)
return True
if self.whitelist_group_admins:
if isinstance(chat, PeerChannel):
p = await self.client(GetParticipantRequest(chat, tgid))
return isinstance(p, (ChannelParticipantCreator, ChannelParticipantAdmin))
elif isinstance(chat, PeerChat):
chat = await self.client(GetFullChatRequest(chat.chat_id))
participants = chat.full_chat.participants.participants
for p in participants:
if p.user_id == tgid:
return isinstance(p, (ChatParticipantCreator, ChatParticipantAdmin))
pcp = await self._get_admin_participant(chat, tgid)
return self._has_participant_permission(pcp, permission)
return False
async def check_can_use_commands(self, event: Message, reply: ReplyFunc) -> bool:
if not await self._can_use_commands(event.to_id, event.from_id):
async def check_can_use_command(self, event: Message, reply: ReplyFunc, command: str) -> bool:
if command not in self.required_permissions:
# Unknown command
return False
elif not isinstance(event.from_id, PeerUser):
await reply("Channels can't use commands")
return False
elif not await self._can_use_commands(
event.to_id, TelegramID(event.from_id.user_id), self.required_permissions[command]
):
await reply("You do not have the permission to use that command.")
return False
return True
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc) -> Message:
if not config["bridge.relaybot.authless_portals"]:
if not self.config["bridge.relaybot.authless_portals"]:
return await reply("This bridge doesn't allow portal creation from Telegram.")
if not portal.allow_bridging():
if not portal.allow_bridging:
return await reply("This bridge doesn't allow bridging this chat.")
await portal.create_matrix_room(self)
if portal.mxid:
if portal.username:
return await reply(
f"Portal is public: [{portal.alias}](https://matrix.to/#/{portal.alias})")
f"Portal is public: [{portal.alias}](https://matrix.to/#/{portal.alias})"
)
else:
return await reply(
"Portal is not public. Use `/invite <mxid>` to get an invite.")
return await reply("Portal is not public. Use `/invite <mxid>` to get an invite.")
else:
return await reply("Couldn't create portal room")
async def handle_command_invite(self, portal: po.Portal, reply: ReplyFunc,
mxid_input: MatrixUserID) -> Message:
async def handle_command_invite(
self, portal: po.Portal, reply: ReplyFunc, mxid_input: UserID
) -> Message:
if len(mxid_input) == 0:
return await reply("Usage: `/invite <mxid>`")
elif not portal.mxid:
return await reply("Portal does not have Matrix room. "
"Create one with /portal first.")
if not self.mxid_regex.match(mxid_input):
return await reply("Portal does not have Matrix room. Create one with /portal first.")
if mxid_input[0] != "@" or mxid_input.find(":") < 2:
return await reply("That doesn't look like a Matrix ID.")
user = await u.User.get_by_mxid(MatrixUserID(mxid_input)).ensure_started()
user = await u.User.get_and_start_by_mxid(mxid_input)
if not user.relaybot_whitelisted:
return await reply("That user is not whitelisted to use the bridge.")
elif await user.is_logged_in():
displayname = f"@{user.username}" if user.username else user.displayname
return await reply("That user seems to be logged in. "
f"Just invite [{displayname}](tg://user?id={user.tgid})")
displayname = f"@{user.tg_username}" if user.tg_username else user.displayname
return await reply(
"That user seems to be logged in. "
f"Just invite [{displayname}](tg://user?id={user.tgid})"
)
else:
await portal.main_intent.invite(portal.mxid, user.mxid)
try:
await portal.invite_to_matrix(user.mxid)
except MBadState:
try:
await portal.main_intent.unban_user(
portal.mxid, user.mxid, reason="Invited from Telegram"
)
except Exception:
return await reply(f"Failed to unban `{user.mxid}` from the portal.")
await portal.invite_to_matrix(user.mxid)
return await reply(f"Unbanned and invited `{user.mxid}` to the portal.")
return await reply(f"Invited `{user.mxid}` to the portal.")
async def handle_command_ban(
self,
message: Message,
portal: po.Portal,
reply: ReplyFunc,
reason: str,
action: Literal["kick", "ban"] = "ban",
) -> Message:
if not message.reply_to:
return await reply("You must reply to a relaybot message when using that command")
reply_to_id = TelegramID(message.reply_to.reply_to_msg_id)
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
msg = await DBMessage.get_one_by_tgid(reply_to_id, tg_space)
if not msg or msg.sender != self.tgid or not msg.sender_mxid:
return await reply("Target message is not a relayed message")
puppet = await pu.Puppet.get_by_peer(message.from_id)
actioned = "Banned" if action == "ban" else "Kicked"
try:
intent = puppet.intent_for(portal)
func: BanFunc = intent.ban_user if action == "ban" else intent.kick_user
await func(portal.mxid, msg.sender_mxid, reason)
except MForbidden as e:
self.log.warning(
f"Failed to {action} {msg.sender_mxid} from {portal.mxid} as {puppet.mxid}: {e}, "
f"falling back to bridge bot"
)
reason_prefix = f"{actioned} by {puppet.displayname or puppet.tgid}"
reason = f"{reason_prefix}: {reason}" if reason else reason_prefix
try:
func: BanFunc = (
self.az.intent.ban_user if action == "ban" else self.az.intent.kick_user
)
await func(portal.mxid, msg.sender_mxid, reason)
except MForbidden as e:
self.log.warning(
f"Failed to {action} {msg.sender_mxid} from {portal.mxid} as bridge bot: {e}"
)
return await reply(f"Failed to {action} `{msg.sender_mxid}`")
return await reply(f"Successfully {actioned.lower()} `{msg.sender_mxid}`")
@staticmethod
def handle_command_id(message: Message, reply: ReplyFunc) -> Awaitable[Message]:
# Provide the prefixed ID to the user so that the user wouldn't need to specify whether the
# chat is a normal group or a supergroup/channel when using the ID.
if isinstance(message.to_id, PeerChannel):
return reply(f"-100{message.to_id.channel_id}")
return reply(str(-message.to_id.chat_id))
elif isinstance(message.to_id, PeerChat):
return reply(str(-message.to_id.chat_id))
elif isinstance(message.to_id, PeerUser):
return reply(
f"Your user ID is {message.to_id.user_id}.\n\n"
f"If you're trying to bridge a group chat to Matrix, you must run the command in "
f"the group, not here. **The ID above will not work** with `!tg bridge`."
)
else:
return reply("Failed to find chat ID.")
def match_command(self, text: str, command: str) -> bool:
text = text.lower()
command = f"/{command.lower()}"
command_targeted = f"{command}@{self.username.lower()}"
def parse_command(self, message: Message) -> tuple[str | None, str | None]:
if not message.entities or len(message.entities) < 1 or not message.message:
return None, None
cmd_entity = message.entities[0]
if not isinstance(cmd_entity, MessageEntityBotCommand) or cmd_entity.offset != 0:
return None, None
surrogated_text = add_surrogate(message.message)
command: str = del_surrogate(surrogated_text[: cmd_entity.length]).lower()
rest_of_message: str = ""
if len(surrogated_text) > cmd_entity.length + 1:
rest_of_message: str = del_surrogate(surrogated_text[cmd_entity.length + 1 :])
command, *target = command.split("@", 1)
if not command.startswith("/"):
return None, None
elif target and target[0] != self.tg_username.lower():
return None, None
return command[1:], rest_of_message
is_plain_command = text == command or text == command_targeted
if is_plain_command:
return True
is_arg_command = text.startswith(command + " ") or text.startswith(command_targeted + " ")
if is_arg_command:
return True
return False
async def handle_command(self, message: Message) -> None:
async def handle_command(self, message: Message, command: str, args: str) -> None:
def reply(reply_text: str) -> Awaitable[Message]:
return self.client.send_message(message.to_id, reply_text, reply_to=message.id)
return self.client.send_message(message.chat_id, reply_text, reply_to=message.id)
text = message.message
if self.match_command(text, "id"):
if command == "start" and message.is_private:
pcm = self.config["bridge.relaybot.private_chat.message"]
if pcm:
await reply(pcm)
elif command == "id":
await self.handle_command_id(message, reply)
return
portal = po.Portal.get_by_entity(message.to_id)
if self.match_command(text, "portal"):
if not await self.check_can_use_commands(message, reply):
elif not message.is_private:
if not await self.check_can_use_command(message, reply, command):
return
await self.handle_command_portal(portal, reply)
elif self.match_command(text, "invite"):
if not await self.check_can_use_commands(message, reply):
return
try:
mxid = text[text.index(" ") + 1:]
except ValueError:
mxid = ""
await self.handle_command_invite(portal, reply, mxid_input=mxid)
portal = await po.Portal.get_by_entity(message.to_id)
if command == "portal":
await self.handle_command_portal(portal, reply)
elif command == "invite":
await self.handle_command_invite(portal, reply, mxid_input=UserID(args))
elif command == "mxban":
await self.handle_command_ban(message, portal, reply, reason=args)
elif command == "mxkick":
await self.handle_command_ban(message, portal, reply, reason=args, action="kick")
def handle_service_message(self, message: MessageService) -> None:
to_id = message.to_id # type: TelegramID
if isinstance(to_id, PeerChannel):
to_id = to_id.channel_id
async def handle_service_message(self, message: MessageService) -> None:
to_peer = message.to_id
if isinstance(to_peer, PeerChannel):
to_id = TelegramID(to_peer.channel_id)
chat_type = "channel"
elif isinstance(to_id, PeerChat):
to_id = to_id.chat_id
elif isinstance(to_peer, PeerChat):
to_id = TelegramID(to_peer.chat_id)
chat_type = "chat"
else:
return
action = message.action
if isinstance(action, MessageActionChatAddUser) and self.tgid in action.users:
self.add_chat(to_id, chat_type)
await self.add_chat(to_id, chat_type)
elif isinstance(action, MessageActionChatDeleteUser) and action.user_id == self.tgid:
self.remove_chat(to_id)
await self.remove_chat(to_id)
elif isinstance(action, MessageActionChatMigrateTo):
self.remove_chat(to_id)
self.add_chat(TelegramID(action.channel_id), "channel")
await self.remove_chat(to_id)
await self.add_chat(TelegramID(action.channel_id), "channel")
async def update(self, update) -> bool:
if self._login_wait_fut:
await self._login_wait_fut
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
return False
if isinstance(update.message, MessageService):
self.handle_service_message(update.message)
await self.handle_service_message(update.message)
return False
is_command = (isinstance(update.message, Message)
and update.message.entities and len(update.message.entities) > 0
and isinstance(update.message.entities[0], MessageEntityBotCommand))
if is_command:
await self.handle_command(update.message)
return True
if isinstance(update.message, Message):
command, args = self.parse_command(update.message)
if command:
await self.handle_command(update.message, command, args)
return False
def is_in_chat(self, peer_id) -> bool:
@@ -279,12 +455,3 @@ class Bot(AbstractUser):
@property
def name(self) -> str:
return "bot"
def init(cfg: 'Config') -> Optional[Bot]:
global config
config = cfg
token = config["telegram.bot_token"]
if token and not token.lower().startswith("disable"):
return Bot(token)
return None
+26 -5
View File
@@ -1,5 +1,26 @@
from .handler import (command_handler, command_handlers as _command_handlers,
CommandHandler, CommandProcessor, CommandEvent,
SECTION_GENERAL, SECTION_AUTH, SECTION_CREATING_PORTALS,
SECTION_PORTAL_MANAGEMENT, SECTION_MISC, SECTION_ADMIN)
from . import portal, telegram, clean_rooms, matrix_auth, meta
from .handler import (
SECTION_ADMIN,
SECTION_AUTH,
SECTION_CREATING_PORTALS,
SECTION_MISC,
SECTION_PORTAL_MANAGEMENT,
CommandEvent,
CommandHandler,
CommandProcessor,
command_handler,
)
# This has to happen after the handler imports
from . import matrix_auth, portal, telegram # isort: skip
__all__ = [
"command_handler",
"CommandHandler",
"CommandProcessor",
"CommandEvent",
"SECTION_AUTH",
"SECTION_MISC",
"SECTION_ADMIN",
"SECTION_CREATING_PORTALS",
"SECTION_PORTAL_MANAGEMENT",
]
-178
View File
@@ -1,178 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, NewType, Optional, Tuple, Union
from mautrix_appservice import MatrixRequestError, IntentAPI
from ..types import MatrixRoomID, MatrixUserID
from . import command_handler, CommandEvent, SECTION_ADMIN
from .. import puppet as pu, portal as po
ManagementRoom = NewType('ManagementRoom', Tuple[MatrixRoomID, MatrixUserID])
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[MatrixRoomID],
List['po.Portal'], List['po.Portal']]:
management_rooms = [] # type: List[ManagementRoom]
unidentified_rooms = [] # type: List[MatrixRoomID]
portals = [] # type: List[po.Portal]
empty_portals = [] # type: List[po.Portal]
rooms = await intent.get_joined_rooms()
for room_str in rooms:
room = MatrixRoomID(room_str)
portal = po.Portal.get_by_mxid(room)
if not portal:
try:
members = await intent.get_room_members(room)
except MatrixRequestError:
members = []
if len(members) == 2:
other_member = MatrixUserID(members[0] if members[0] != intent.mxid else members[1])
if pu.Puppet.get_id_from_mxid(other_member):
unidentified_rooms.append(room)
else:
management_rooms.append(ManagementRoom((room, other_member)))
else:
unidentified_rooms.append(room)
else:
members = await portal.get_authenticated_matrix_users()
if len(members) == 0:
empty_portals.append(portal)
else:
portals.append(portal)
return management_rooms, unidentified_rooms, portals, empty_portals
@command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms",
help_section=SECTION_ADMIN,
help_text="Clean up unused portal/management rooms.")
async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent)
reply = ["#### Management rooms (M)"]
reply += ([f"{n+1}. [M{n+1}](https://matrix.to/#/{room}) (with {other_member}"
for n, (room, other_member) in enumerate(management_rooms)]
or ["No management rooms found."])
reply.append("#### Active portal rooms (A)")
reply += ([f"{n+1}. [A{n+1}](https://matrix.to/#/{portal.mxid}) "
f"(to Telegram chat \"{portal.title}\")"
for n, portal in enumerate(portals)]
or ["No active portal rooms found."])
reply.append("#### Unidentified rooms (U)")
reply += ([f"{n+1}. [U{n+1}](https://matrix.to/#/{room})"
for n, room in enumerate(unidentified_rooms)]
or ["No unidentified rooms found."])
reply.append("#### Inactive portal rooms (I)")
reply += ([f"{n}. [I{n}](https://matrix.to/#/{portal.mxid}) "
f"(to Telegram chat \"{portal.title}\")"
for n, portal in enumerate(empty_portals)]
or ["No inactive portal rooms found."])
reply += ["#### Usage",
("To clean the recommended set of rooms (unidentified & inactive portals), "
"type `$cmdprefix+sp clean-recommended`"),
"",
("To clean other groups of rooms, type `$cmdprefix+sp clean-groups <letters>` "
"where `letters` are the first letters of the group names (M, A, U, I)"),
"",
("To clean specific rooms, type `$cmdprefix+sp clean-range <range>` "
"where `range` is the range (e.g. `5-21`) prefixed with the first letter of"
"the group name. (e.g. `I2-6`)"),
"",
("Please note that you will have to re-run `$cmdprefix+sp clean-rooms` "
"between each use of the commands above.")]
evt.sender.command_status = {
"next": lambda clean_evt: set_rooms_to_clean(clean_evt, management_rooms,
unidentified_rooms, portals, empty_portals),
"action": "Room cleaning",
}
return await evt.reply("\n".join(reply))
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
unidentified_rooms: List[MatrixRoomID], portals: List["po.Portal"],
empty_portals: List["po.Portal"]) -> None:
command = evt.args[0]
rooms_to_clean = [] # type: List[Union[po.Portal, MatrixRoomID]]
if command == "clean-recommended":
rooms_to_clean += empty_portals
rooms_to_clean += unidentified_rooms
elif command == "clean-groups":
if len(evt.args) < 2:
return await evt.reply("**Usage:** `$cmdprefix+sp clean-groups [M][A][U][I]")
groups_to_clean = evt.args[1].upper()
if "M" in groups_to_clean:
rooms_to_clean += [room_id for (room_id, user_id) in management_rooms]
if "A" in groups_to_clean:
rooms_to_clean += portals
if "U" in groups_to_clean:
rooms_to_clean += unidentified_rooms
if "I" in groups_to_clean:
rooms_to_clean += empty_portals
elif command == "clean-range":
try:
clean_range = evt.args[1]
group, clean_range = clean_range[0], clean_range[1:]
start, end = clean_range.split("-")
start, end = int(start), int(end)
if group == "M":
group = [room_id for (room_id, user_id) in management_rooms]
elif group == "A":
group = portals
elif group == "U":
group = unidentified_rooms
elif group == "I":
group = empty_portals
else:
raise ValueError("Unknown group")
rooms_to_clean = group[start - 1:end]
except (KeyError, ValueError):
return await evt.reply(
"**Usage:** `$cmdprefix+sp clean-groups <_M|A|U|I_><range>")
else:
return await evt.reply(f"Unknown room cleaning action `{command}`. "
"Use `$cmdprefix+sp cancel` to cancel room "
"cleaning.")
evt.sender.command_status = {
"next": lambda confirm: execute_room_cleanup(confirm, rooms_to_clean),
"action": "Room cleaning",
}
await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type"
"`$cmdprefix+sp confirm-clean`.")
async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, MatrixRoomID]]) -> None:
if len(evt.args) > 0 and evt.args[0] == "confirm-clean":
await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. "
"This might take a while.")
cleaned = 0
for room in rooms_to_clean:
if isinstance(room, po.Portal):
await room.cleanup_and_delete()
cleaned += 1
elif isinstance(room, str): # str is aliased by MatrixRoomID
await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted")
cleaned += 1
evt.sender.command_status = None
await evt.reply(f"{cleaned} rooms cleaned up successfully.")
else:
await evt.reply("Room cleaning cancelled.")
+156 -325
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,24 +13,38 @@
#
# 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/>.
"""This module contains classes handling commands issued by Matrix users."""
from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional
import logging
import traceback
from __future__ import annotations
import commonmark
from typing import TYPE_CHECKING, Any, Awaitable, Callable, NamedTuple
from telethon.errors import FloodWaitError
from ..types import MatrixRoomID, MatrixEventID
from ..util import format_duration
from .. import user as u, context as c
from mautrix.bridge.commands import (
CommandEvent as BaseCommandEvent,
CommandHandler as BaseCommandHandler,
CommandHandlerFunc,
CommandProcessor as BaseCommandProcessor,
HelpSection,
command_handler as base_command_handler,
)
from mautrix.types import EventID, MessageEventContent, RoomID
from mautrix.util.format_duration import format_duration
command_handlers = {} # type: Dict[str, CommandHandler]
from .. import portal as po, user as u
if TYPE_CHECKING:
from ..__main__ import TelegramBridge
class HelpCacheKey(NamedTuple):
is_management: bool
is_portal: bool
puppet_whitelisted: bool
matrix_puppet_whitelisted: bool
is_admin: bool
is_logged_in: bool
HelpSection = NamedTuple('HelpSection', [('name', str), ('order', int), ('description', str)])
SECTION_GENERAL = HelpSection("General", 0, "")
SECTION_AUTH = HelpSection("Authentication", 10, "")
SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "")
SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "")
@@ -39,327 +52,145 @@ SECTION_MISC = HelpSection("Miscellaneous", 40, "")
SECTION_ADMIN = HelpSection("Administration", 50, "")
class HtmlEscapingRenderer(commonmark.HtmlRenderer):
def __init__(self, allow_html: bool = False):
super().__init__()
self.allow_html = allow_html
class CommandEvent(BaseCommandEvent):
sender: u.User
portal: po.Portal
def lit(self, s):
if self.allow_html:
return super().lit(s)
return super().lit(s.replace("<", "&lt;").replace(">", "&gt;"))
def image(self, node, entering):
prev = self.allow_html
self.allow_html = True
super().image(node, entering)
self.allow_html = prev
md_parser = commonmark.Parser()
md_renderer = HtmlEscapingRenderer()
def ensure_trailing_newline(s: str) -> str:
"""Returns the passed string, but with a guaranteed trailing newline."""
return s + ("" if s[-1] == "\n" else "\n")
class CommandEvent:
"""Holds information about a command issued in a Matrix room.
When a Matrix command was issued to the bot, CommandEvent will hold
information regarding the event.
Attributes:
room_id: The id of the Matrix room in which the command was issued.
event_id: The id of the matrix event which contained the command.
sender: The user who issued the command.
command: The issued command.
args: Arguments given with the issued command.
is_management: Determines whether the room in which the command wa
issued is a management room.
is_portal: Determines whether the room in which the command was issued
is a portal.
"""
def __init__(self, processor: 'CommandProcessor', room: MatrixRoomID, event: MatrixEventID,
sender: u.User, command: str, args: List[str], is_management: bool,
is_portal: bool) -> None:
self.az = processor.az
self.log = processor.log
self.loop = processor.loop
def __init__(
self,
processor: CommandProcessor,
room_id: RoomID,
event_id: EventID,
sender: u.User,
command: str,
args: list[str],
content: MessageEventContent,
portal: po.Portal | None,
is_management: bool,
has_bridge_bot: bool,
) -> None:
super().__init__(
processor,
room_id,
event_id,
sender,
command,
args,
content,
portal,
is_management,
has_bridge_bot,
)
self.bridge = processor.bridge
self.tgbot = processor.tgbot
self.config = processor.config
self.public_website = processor.public_website
self.command_prefix = processor.command_prefix
self.room_id = room
self.event_id = event
self.sender = sender
self.command = command
self.args = args
self.is_management = is_management
self.is_portal = is_portal
def reply(self, message: str, allow_html: bool = False, render_markdown: bool = True
) -> Awaitable[Dict]:
"""Write a reply to the room in which the command was issued.
@property
def print_error_traceback(self) -> bool:
return self.sender.is_admin
Replaces occurences of "$cmdprefix" in the message with the command
prefix and replaces occurences of "$cmdprefix+sp " with the command
prefix if the command was not issued in a management room.
If allow_html and render_markdown are both False, the message will not
be rendered to html and sending of html is disabled.
Args:
message: The message to post in the room.
allow_html: Escape html in the message or don't render html at all
if markdown is disabled.
render_markdown: Use markdown formatting to render the passed
message to html.
Returns:
Handler for the message sending function.
"""
message_cmd = self._replace_command_prefix(message)
html = self._render_message(message_cmd, allow_html=allow_html,
render_markdown=render_markdown)
return self.az.intent.send_notice(self.room_id, message_cmd, html=html)
def mark_read(self) -> Awaitable[Dict]:
"""Marks the command as read by the bot."""
return self.az.intent.mark_read(self.room_id, self.event_id)
def _replace_command_prefix(self, message: str) -> str:
"""Returns the string with the proper command prefix entered."""
message = message.replace(
"$cmdprefix+sp ", "" if self.is_management else f"{self.command_prefix} "
async def get_help_key(self) -> HelpCacheKey:
return HelpCacheKey(
self.is_management,
self.portal is not None,
self.sender.puppet_whitelisted,
self.sender.matrix_puppet_whitelisted,
self.sender.is_admin,
await self.sender.is_logged_in(),
)
return message.replace("$cmdprefix", self.command_prefix)
class CommandHandler(BaseCommandHandler):
name: str
needs_puppeting: bool
needs_matrix_puppeting: bool
def __init__(
self,
handler: Callable[[CommandEvent], Awaitable[EventID]],
management_only: bool,
name: str,
help_text: str,
help_args: str,
help_section: HelpSection,
needs_auth: bool,
needs_puppeting: bool,
needs_matrix_puppeting: bool,
needs_admin: bool,
**kwargs,
) -> None:
super().__init__(
handler,
management_only,
name,
help_text,
help_args,
help_section,
needs_auth=needs_auth,
needs_puppeting=needs_puppeting,
needs_matrix_puppeting=needs_matrix_puppeting,
needs_admin=needs_admin,
**kwargs,
)
async def get_permission_error(self, evt: CommandEvent) -> str | None:
if self.needs_puppeting and not evt.sender.puppet_whitelisted:
return "That command is limited to users with puppeting privileges."
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
return "That command is limited to users with full puppeting privileges."
return await super().get_permission_error(evt)
def has_permission(self, key: HelpCacheKey) -> bool:
return (
super().has_permission(key)
and (not self.needs_puppeting or key.puppet_whitelisted)
and (not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted)
)
def command_handler(
_func: CommandHandlerFunc | None = None,
*,
needs_auth: bool = True,
needs_puppeting: bool = True,
needs_matrix_puppeting: bool = False,
needs_admin: bool = False,
management_only: bool = False,
name: str | None = None,
aliases: list[str] | None = None,
help_text: str = "",
help_args: str = "",
help_section: HelpSection = None,
) -> Callable[[CommandHandlerFunc], CommandHandler]:
return base_command_handler(
_func,
_handler_class=CommandHandler,
name=name,
aliases=aliases,
help_text=help_text,
help_args=help_args,
help_section=help_section,
management_only=management_only,
needs_auth=needs_auth,
needs_admin=needs_admin,
needs_puppeting=needs_puppeting,
needs_matrix_puppeting=needs_matrix_puppeting,
)
class CommandProcessor(BaseCommandProcessor):
def __init__(self, bridge: "TelegramBridge") -> None:
super().__init__(event_class=CommandEvent, bridge=bridge)
self.tgbot = bridge.bot
self.public_website = bridge.public_website
@staticmethod
def _render_message(message: str, allow_html: bool, render_markdown: bool) -> Optional[str]:
"""Renders the message as HTML.
Args:
allow_html: Flag to allow custom HTML in the message.
render_markdown: If true, markdown styling is applied to the message.
Returns:
The message rendered as HTML.
None is returned if no styled output is required.
"""
html = ""
if render_markdown:
md_renderer.allow_html = allow_html
html = md_renderer.render(md_parser.parse(message))
elif allow_html:
html = message
return ensure_trailing_newline(html) if html else None
class CommandHandler:
"""A command which can be executed from a Matrix room.
The command manages its permission and help texts.
When called, it will check the permission of the command event and execute
the command or, in case of error, report back to the user.
Attributes:
needs_auth: Flag indicating if the sender is required to be logged in.
needs_puppeting: Flag indicating if the sender is required to use
Telegram puppeteering for this command.
needs_matrix_puppeting: Flag indicating if the sender is required to use
Matrix pupeteering.
needs_admin: Flag for whether only admin users can issue this command.
management_only: Whether the command can exclusively be issued in a
management room.
name: The name of this command.
help_section: Section of the help in which this command will appear.
"""
def __init__(self, handler: Callable[[CommandEvent], Awaitable[Dict]], needs_auth: bool,
needs_puppeting: bool, needs_matrix_puppeting: bool, needs_admin: bool,
management_only: bool, name: str, help_text: str, help_args: str,
help_section: HelpSection) -> None:
"""
Args:
handler: The function handling the execution of this command.
needs_auth: Flag indicating if the sender is required to be logged in.
needs_puppeting: Flag indicating if the sender is required to use
Telegram puppeteering for this command.
needs_matrix_puppeting: Flag indicating if the sender is required to
use Matrix pupeteering.
needs_admin: Flag for whether only admin users can issue this command.
management_only: Whether the command can exclusively be issued
in a management room.
name: The name of this command.
help_text: The text displayed in the help for this command.
help_args: Help text for the arguments of this command.
help_section: Section of the help in which this command will appear.
"""
self._handler = handler
self.needs_auth = needs_auth
self.needs_puppeting = needs_puppeting
self.needs_matrix_puppeting = needs_matrix_puppeting
self.needs_admin = needs_admin
self.management_only = management_only
self.name = name
self._help_text = help_text
self._help_args = help_args
self.help_section = help_section
async def get_permission_error(self, evt: CommandEvent) -> Optional[str]:
"""Returns the reason why the command could not be issued.
Args:
evt: The event for which to get the error information.
Returns:
A string describing the error or None if there was no error.
"""
if self.management_only and not evt.is_management:
return (f"`{evt.command}` is a restricted command: "
"you may only run it in management rooms.")
elif self.needs_puppeting and not evt.sender.puppet_whitelisted:
return "This command requires puppeting privileges."
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
return "This command requires Matrix puppeting privileges."
elif self.needs_admin and not evt.sender.is_admin:
return "This command requires administrator privileges."
elif self.needs_auth and not await evt.sender.is_logged_in():
return "This command requires you to be logged in."
return None
def has_permission(self, is_management: bool, puppet_whitelisted: bool,
matrix_puppet_whitelisted: bool, is_admin: bool, is_logged_in: bool) -> bool:
"""Checks the permission for this command with the given status.
Args:
is_management: If the room in which the command will be issued is a
management room.
puppet_whitelisted: If the connected Telegram account puppet is
allowed to issue the command.
matrix_puppet_whitelisted: If the connected Matrix account puppet is
allowed to issue the command.
is_admin: If the issuing user is an admin.
is_logged_in: If the issuing user is logged in.
Returns:
True if a user with the given state is allowed to issue the
command.
"""
return ((not self.management_only or is_management) and
(not self.needs_puppeting or puppet_whitelisted) and
(not self.needs_matrix_puppeting or matrix_puppet_whitelisted) and
(not self.needs_admin or is_admin) and
(not self.needs_auth or is_logged_in))
async def __call__(self, evt: CommandEvent) -> Dict:
"""Executes the command if evt was issued with proper rights.
Args:
evt: The CommandEvent for which to check permissions.
Returns:
The result of the command or the error message function.
Raises:
FloodWaitError
"""
error = await self.get_permission_error(evt)
if error is not None:
return await evt.reply(error)
return await self._handler(evt)
@property
def has_help(self) -> bool:
"""Returns true if this command has a help text."""
return bool(self.help_section) and bool(self._help_text)
@property
def help(self) -> str:
"""Returns the help text to this command."""
return f"**{self.name}** {self._help_args} - {self._help_text}"
def command_handler(_func: Optional[Callable[[CommandEvent], Awaitable[Dict]]] = None, *,
needs_auth: bool = True, needs_puppeting: bool = True,
needs_matrix_puppeting: bool = False, needs_admin: bool = False,
management_only: bool = False, name: Optional[str] = None,
help_text: str = "", help_args: str = "", help_section: HelpSection = None
) -> Callable[[Callable[[CommandEvent], Awaitable[Optional[Dict]]]],
CommandHandler]:
def decorator(func: Callable[[CommandEvent], Awaitable[Optional[Dict]]]) -> CommandHandler:
actual_name = name or func.__name__.replace("_", "-")
handler = CommandHandler(func, needs_auth, needs_puppeting, needs_matrix_puppeting,
needs_admin, management_only, actual_name, help_text, help_args,
help_section)
command_handlers[handler.name] = handler
return handler
return decorator if _func is None else decorator(_func)
class CommandProcessor:
"""Handles the raw commands issued by a user to the Matrix bot."""
log = logging.getLogger("mau.commands")
def __init__(self, context: c.Context) -> None:
self.az, self.config, self.loop, self.tgbot = context.core
self.public_website = context.public_website
self.command_prefix = self.config["bridge.command_prefix"]
async def handle(self, room: MatrixRoomID, event_id: MatrixEventID, sender: u.User,
command: str, args: List[str], is_management: bool, is_portal: bool
) -> Optional[Dict]:
"""Handles the raw commands issued by a user to the Matrix bot.
If the command is not known, it might be a followup command and is
delegated to a command handler registered for that purpose in the
senders command_status as "next".
Args:
room: ID of the Matrix room in which the command was issued.
event_id: ID of the event by which the command was issued.
sender: The sender who issued the command.
command: The issued command, case insensitive.
args: Arguments given with the command.
is_management: Whether the room is a management room.
is_portal: Whether the room is a portal.
Returns:
The result of the error message function or None if no error
occured. Unknown and delegated commands do not count as errors.
"""
if not command_handlers or "unknown-command" not in command_handlers:
raise ValueError("command_handlers are not properly initialized.")
evt = CommandEvent(self, room, event_id, sender, command, args, is_management, is_portal)
orig_command = command
command = command.lower()
async def _run_handler(
handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent
) -> Any:
try:
handler = command_handlers[command]
except KeyError:
if sender.command_status and "next" in sender.command_status:
args.insert(0, orig_command)
evt.command = ""
handler = sender.command_status["next"]
else:
handler = command_handlers["unknown-command"]
try:
await handler(evt)
return await handler(evt)
except FloodWaitError as e:
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
except Exception:
self.log.exception("Unhandled error while handling command "
f"{evt.command} {' '.join(args)} from {sender.mxid}")
if evt.sender.is_admin and evt.is_management:
return await evt.reply("Unhandled error while handling command:\n\n"
"```traceback\n"
f"{traceback.format_exc()}"
"```")
return await evt.reply("Unhandled error while handling command. "
"Check logs for more details.")
return None
+37 -53
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,33 +13,27 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
from mautrix.types import EventID
from . import command_handler, CommandEvent, SECTION_AUTH
from .. import puppet as pu
from . import SECTION_AUTH, CommandEvent, command_handler
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Revert your Telegram account's Matrix puppet to use the default Matrix "
"account.")
async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid)
if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.")
await puppet.switch_mxid(None, None)
return await evt.reply("Reverted your Telegram account's Matrix puppet back to the default.")
@command_handler(needs_auth=True, management_only=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Replace your Telegram account's Matrix puppet with your own Matrix "
"account.")
async def login_matrix(evt: CommandEvent) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid)
@command_handler(
needs_auth=True,
management_only=True,
needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Replace your Telegram account's Matrix puppet with your own Matrix account.",
)
async def login_matrix(evt: CommandEvent) -> EventID:
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first.")
return await evt.reply(
"You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first."
)
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
if allow_matrix_login:
evt.sender.command_status = {
@@ -58,44 +51,35 @@ async def login_matrix(evt: CommandEvent) -> Optional[Dict]:
"here.\n"
f"If you would like to log in outside of Matrix, [click here]({url}).\n\n"
"Logging in outside of Matrix is recommended, because in-Matrix login would save "
"your access token in the message history.")
return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n"
f"Please visit [the login page]({url}) to log in.")
"your access token in the message history."
)
return await evt.reply(
"This bridge instance does not allow logging in inside Matrix.\n\n"
f"Please visit [the login page]({url}) to log in."
)
elif allow_matrix_login:
return await evt.reply(
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
"Please send your Matrix access token here to log in.")
"Please send your Matrix access token here to log in."
)
return await evt.reply("This bridge instance has been configured to not allow logging in.")
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Pings the server with the stored matrix authentication.")
async def ping_matrix(evt: CommandEvent) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid)
if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.")
resp = await puppet.init_custom_mxid()
if resp == pu.PuppetError.InvalidAccessToken:
return await evt.reply("Your access token is invalid.")
elif resp == pu.PuppetError.Success:
return await evt.reply("Your Matrix login is working.")
return await evt.reply(f"Unknown response while checking your Matrix login: {resp}.")
async def enter_matrix_token(evt: CommandEvent) -> Dict:
async def enter_matrix_token(evt: CommandEvent) -> EventID:
evt.sender.command_status = None
puppet = pu.Puppet.get(evt.sender.tgid)
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first.")
resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
if resp == pu.PuppetError.OnlyLoginSelf:
return await evt.reply(
"You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first."
)
try:
await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
except OnlyLoginSelf:
return await evt.reply("You can only log in as your own Matrix user.")
elif resp == pu.PuppetError.InvalidAccessToken:
except InvalidAccessToken:
return await evt.reply("Failed to verify access token.")
assert resp == pu.PuppetError.Success, "Encountered an unhandled PuppetError."
return await evt.reply(
f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.")
f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}."
)
-72
View File
@@ -1,72 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple
from . import command_handler, CommandEvent, _command_handlers, SECTION_GENERAL
from .handler import HelpSection
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_GENERAL,
help_text="Cancel an ongoing action (such as login)")
async def cancel(evt: CommandEvent) -> Optional[Dict]:
if evt.sender.command_status:
action = evt.sender.command_status["action"]
evt.sender.command_status = None
return await evt.reply(f"{action} cancelled.")
else:
return await evt.reply("No ongoing command.")
@command_handler(needs_auth=False, needs_puppeting=False)
async def unknown_command(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
help_cache = {} # type: Dict[Tuple[bool, bool, bool, bool, bool], str]
async def _get_help_text(evt: CommandEvent) -> str:
cache_key = (evt.is_management, evt.sender.puppet_whitelisted,
evt.sender.matrix_puppet_whitelisted, evt.sender.is_admin,
await evt.sender.is_logged_in())
if cache_key not in help_cache:
help_sections = {} # type: Dict[HelpSection, List[str]]
for handler in _command_handlers.values():
if handler.has_help and handler.has_permission(*cache_key):
help_sections.setdefault(handler.help_section, [])
help_sections[handler.help_section].append(handler.help + " ")
help_sorted = sorted(help_sections.items(), key=lambda item: item[0].order)
helps = ["#### {}\n{}\n".format(key.name, "\n".join(value)) for key, value in help_sorted]
help_cache[cache_key] = "\n".join(helps)
return help_cache[cache_key]
def _get_management_status(evt: CommandEvent) -> str:
if evt.is_management:
return "This is a management room: prefixing commands with `$cmdprefix` is not required."
elif evt.is_portal:
return ("**This is a portal room**: you must always prefix commands with `$cmdprefix`.\n"
"Management commands will not be sent to Telegram.")
return "**This is not a management room**: you must prefix commands with `$cmdprefix`."
@command_handler(name="help", needs_auth=False, needs_puppeting=False,
help_section=SECTION_GENERAL,
help_text="Show this help message.")
async def help_cmd(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply(_get_management_status(evt) + "\n" + await _get_help_text(evt))
+30 -54
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,42 +13,22 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
import asyncio
from mautrix_appservice import MatrixRequestError
from mautrix.types import EventID
from ... import portal as po, puppet as pu, user as u
from .. import command_handler, CommandEvent, SECTION_ADMIN
from .. import SECTION_ADMIN, CommandEvent, command_handler
@command_handler(needs_admin=True, needs_auth=False, name="set-pl",
help_section=SECTION_ADMIN,
help_args="<_level_> [_mxid_]",
help_text="Set a temporary power level without affecting Telegram.")
async def set_power_level(evt: CommandEvent) -> Dict:
try:
level = int(evt.args[0])
except KeyError:
return await evt.reply("**Usage:** `$cmdprefix+sp set-pl <level> [mxid]`")
except ValueError:
return await evt.reply("The level must be an integer.")
levels = await evt.az.intent.get_power_levels(evt.room_id)
mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid
levels["users"][mxid] = level
try:
await evt.az.intent.set_power_levels(evt.room_id, levels)
except MatrixRequestError:
evt.log.exception("Failed to set power level.")
return await evt.reply("Failed to set power level.")
return {}
@command_handler(needs_admin=True, needs_auth=False,
help_section=SECTION_ADMIN,
help_args="<`portal`|`puppet`|`user`>",
help_text="Clear internal bridge caches")
async def clear_db_cache(evt: CommandEvent) -> Dict:
@command_handler(
needs_admin=True,
needs_auth=False,
help_section=SECTION_ADMIN,
help_args="<`portal`|`puppet`|`user`>",
help_text="Clear internal bridge caches",
)
async def clear_db_cache(evt: CommandEvent) -> EventID:
try:
section = evt.args[0].lower()
except IndexError:
@@ -59,43 +38,40 @@ async def clear_db_cache(evt: CommandEvent) -> Dict:
po.Portal.by_mxid = {}
await evt.reply("Cleared portal cache")
elif section == "puppet":
pu.Puppet.cache = {}
for puppet in pu.Puppet.by_custom_mxid.values():
puppet.sync_task.cancel()
pu.Puppet.by_tgid = {}
pu.Puppet.by_custom_mxid = {}
await asyncio.gather(
*[puppet.init_custom_mxid() for puppet in pu.Puppet.all_with_custom_mxid()],
loop=evt.loop)
*[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()]
)
await evt.reply("Cleared puppet cache and restarted custom puppet syncers")
elif section == "user":
u.User.by_mxid = {
user.mxid: user
for user in u.User.by_tgid.values()
}
u.User.by_mxid = {user.mxid: user for user in u.User.by_tgid.values()}
await evt.reply("Cleared non-logged-in user cache")
else:
return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache <section>`")
@command_handler(needs_admin=True, needs_auth=False,
help_section=SECTION_ADMIN,
help_args="[_mxid_]",
help_text="Reload and reconnect a user")
async def reload_user(evt: CommandEvent) -> Dict:
@command_handler(
needs_admin=True,
needs_auth=False,
help_section=SECTION_ADMIN,
help_args="[_mxid_]",
help_text="Reload and reconnect a user",
)
async def reload_user(evt: CommandEvent) -> EventID:
if len(evt.args) > 0:
mxid = evt.args[0]
else:
mxid = evt.sender.mxid
user = u.User.get_by_mxid(mxid, create=False)
user = await u.User.get_by_mxid(mxid, create=False)
if not user:
return await evt.reply("User not found")
puppet = pu.Puppet.get_by_custom_mxid(mxid)
if puppet:
puppet.sync_task.cancel()
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
await user.stop()
user.delete(delete_db=False)
user = u.User.get_by_mxid(mxid)
del u.User.by_tgid[user.tgid]
del u.User.by_mxid[user.mxid]
user = await u.User.get_by_mxid(mxid)
await user.ensure_started()
if puppet:
await puppet.init_custom_mxid()
await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
await puppet.start()
return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
+166 -86
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,168 +13,249 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional, Tuple, Coroutine
from __future__ import annotations
from typing import Awaitable
import asyncio
from telethon.tl.types import ChatForbidden, ChannelForbidden
from telethon.tl.types import ChannelForbidden, ChatForbidden
from mautrix.types import EventID, RoomID
from mautrix.util import background_task
from ...types import MatrixRoomID, TelegramID
from ...util import ignore_coro
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state
from ...types import TelegramID
from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler
from .util import get_initial_state, user_has_power_level, warn_missing_power
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_CREATING_PORTALS,
help_args="[_id_]",
help_text="Bridge the current Matrix room to the Telegram chat with the given "
"ID. The ID must be the prefixed version that you get with the `/id` "
"command of the Telegram-side bot.")
async def bridge(evt: CommandEvent) -> Dict:
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_CREATING_PORTALS,
help_args="[_id_]",
help_text=(
"Bridge the current Matrix room to the Telegram chat with the given ID. The ID must be "
"the prefixed version that you get with the `/id` command of the Telegram-side bot."
),
)
async def bridge(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** "
"`$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`")
room_id = MatrixRoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
return await evt.reply(
"**Usage:** `$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`"
)
force_use_bot = False
if evt.args[0] == "--usebot" and evt.sender.is_admin:
force_use_bot = True
evt.args = evt.args[1:]
room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
that_this = "This" if room_id == evt.room_id else "That"
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if portal:
return await evt.reply(f"{that_this} room is already a portal room.")
if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply(f"You do not have the permissions to bridge {that_this} 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]
if tgid_str.startswith("-100"):
tgid = TelegramID(int(tgid_str[4:]))
peer_type = "channel"
elif tgid_str.startswith("-"):
tgid = TelegramID(-int(tgid_str))
peer_type = "chat"
else:
return await evt.reply("That doesn't seem like a prefixed Telegram chat ID.\n\n"
"If you did not get the ID using the `/id` bot command, please "
"prefix channel IDs with `-100` and normal group IDs with `-`.\n\n"
"Bridging private chats to existing rooms is not allowed.")
tgid = None
try:
if tgid_str.startswith("-100"):
tgid = TelegramID(int(tgid_str[4:]))
peer_type = "channel"
elif tgid_str.startswith("-"):
tgid = TelegramID(-int(tgid_str))
peer_type = "chat"
except ValueError:
# Invalid integer
pass
if not tgid:
return await evt.reply(
"That doesn't seem like a prefixed Telegram chat ID.\n\n"
"If you did not get the ID using the `/id` bot command, please prefix"
"channel/supergroup IDs with `-100` and non-super group IDs with `-`.\n\n"
"Bridging private chats to existing rooms is not allowed."
)
portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type)
if not portal.allow_bridging():
return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n"
"If you're the bridge admin, try "
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first.")
if portal.mxid:
portal = await po.Portal.get_by_tgid(tgid, peer_type=peer_type)
if not portal.allow_bridging:
return await evt.reply(
"This bridge doesn't allow bridging that Telegram chat.\n"
"If you're the bridge admin, try "
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first."
)
elif portal.mxid:
has_portal_message = (
"That Telegram chat already has a portal at "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). ")
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). "
)
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
return await evt.reply(f"{has_portal_message}"
"Additionally, you do not have the permissions to unbridge "
"that room.")
return await evt.reply(
f"{has_portal_message}"
"Additionally, you do not have the permissions to unbridge that room."
)
evt.sender.command_status = {
"next": confirm_bridge,
"action": "Room bridging",
"mxid": portal.mxid,
"bridge_to_mxid": room_id,
"tgid": portal.tgid,
"peer_type": portal.peer_type,
"peer_type": peer_type,
"force_use_bot": force_use_bot,
}
return await evt.reply(f"{has_portal_message}"
"However, you have the permissions to unbridge that room.\n\n"
"To delete that portal completely and continue bridging, use "
"`$cmdprefix+sp delete-and-continue`. To unbridge the portal "
"without kicking Matrix users, use `$cmdprefix+sp unbridge-and-"
"continue`. To cancel, use `$cmdprefix+sp cancel`")
return await evt.reply(
f"{has_portal_message}"
"However, you have the permissions to unbridge that room.\n\n"
"To delete that portal completely and continue bridging, use "
"`$cmdprefix+sp delete-and-continue`. To unbridge the portal "
"without kicking Matrix users, use `$cmdprefix+sp unbridge-and-"
"continue`. To cancel, use `$cmdprefix+sp cancel`"
)
evt.sender.command_status = {
"next": confirm_bridge,
"action": "Room bridging",
"bridge_to_mxid": room_id,
"tgid": portal.tgid,
"peer_type": portal.peer_type,
"peer_type": peer_type,
"force_use_bot": force_use_bot,
}
return await evt.reply("That Telegram chat has no existing portal. To confirm bridging the "
"chat to this room, use `$cmdprefix+sp continue`")
return await evt.reply(
"That Telegram chat has no existing portal. To confirm bridging the "
"chat to this room, use `$cmdprefix+sp continue`"
)
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
) -> Tuple[bool, Optional[Coroutine[None, None, None]]]:
async def cleanup_old_portal_while_bridging(
evt: CommandEvent, portal: po.Portal
) -> tuple[bool, Awaitable[None] | None]:
if not portal.mxid:
await evt.reply("The portal seems to have lost its Matrix room between you"
"calling `$cmdprefix+sp bridge` and this command.\n\n"
"Continuing without touching previous Matrix room...")
await evt.reply(
"The portal seems to have lost its Matrix room between you"
"calling `$cmdprefix+sp bridge` and this command.\n\n"
"Continuing without touching previous Matrix room..."
)
return True, None
elif evt.args[0] == "delete-and-continue":
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
message="Portal deleted (moving to another room)")
return True, portal.cleanup_portal("Portal deleted (moving to another room)", delete=False)
elif evt.args[0] == "unbridge-and-continue":
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
message="Room unbridged (portal moving to another room)",
puppets_only=True)
return True, portal.cleanup_portal(
"Room unbridged (portal moving to another room)", puppets_only=True, delete=False
)
else:
await evt.reply(
"The chat you were trying to bridge already has a Matrix portal room.\n\n"
"Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-"
"continue` to either delete or unbridge the existing room (respectively) and "
"continue with the bridging.\n\n"
"If you changed your mind, use `$cmdprefix+sp cancel` to cancel.")
"If you changed your mind, use `$cmdprefix+sp cancel` to cancel."
)
return False, None
async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
async def confirm_bridge(evt: CommandEvent) -> EventID | None:
status = evt.sender.command_status
try:
portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
portal = await po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
bridge_to_mxid = status["bridge_to_mxid"]
except KeyError:
evt.sender.command_status = None
return await evt.reply("Fatal error: tgid or peer_type missing from command_status. "
"This shouldn't happen unless you're messing with the command "
"handler code.")
return await evt.reply(
"Fatal error: tgid or peer_type missing from command_status. "
"This shouldn't happen unless you're messing with the command handler code."
)
is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"]
if "mxid" in status:
if portal.peer_type != status["peer_type"]:
evt.log.warning(
"Portal %d in database has mismatching peer type %s (expected %s),"
" trusting database as a room already existed",
portal.tgid,
portal.peer_type,
status["peer_type"],
)
await evt.reply(
"Mismatching peer type in command and portal table, "
"trusting portal as room already existed"
)
ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
if not ok:
return None
elif coro:
ignore_coro(asyncio.ensure_future(coro, loop=evt.loop))
background_task.create(coro)
await evt.reply("Cleaning up previous portal room...")
elif portal.mxid:
evt.sender.command_status = None
return await evt.reply("The portal seems to have created a Matrix room between you "
"calling `$cmdprefix+sp bridge` and this command.\n\n"
"Please start over by calling the bridge command again.")
return await evt.reply(
"The portal seems to have created a Matrix room between you "
"calling `$cmdprefix+sp bridge` and this command.\n\n"
"Please start over by calling the bridge command again."
)
elif evt.args[0] != "continue":
return await evt.reply("Please use `$cmdprefix+sp continue` to confirm the bridging or "
"`$cmdprefix+sp cancel` to cancel.")
return await evt.reply(
"Please use `$cmdprefix+sp continue` to confirm the bridging or "
"`$cmdprefix+sp cancel` to cancel."
)
elif portal.peer_type != status["peer_type"]:
evt.log.warning(
"Portal %d in database has mismatching peer type %s (expected %s),"
" trusting new peer type as there's no existing room",
portal.tgid,
portal.peer_type,
status["peer_type"],
)
await evt.reply(
"Mismatching peer type in command and portal table, "
"trusting you as portal room doesn't exist"
)
portal.peer_type = status["peer_type"]
evt.sender.command_status = None
is_logged_in = await evt.sender.is_logged_in()
async with portal._room_create_lock:
await _locked_confirm_bridge(
evt, portal=portal, room_id=bridge_to_mxid, is_logged_in=is_logged_in
)
async def _locked_confirm_bridge(
evt: CommandEvent, portal: po.Portal, room_id: RoomID, is_logged_in: bool
) -> EventID | None:
user = evt.sender if is_logged_in else evt.tgbot
try:
entity = await user.client.get_entity(portal.peer)
except Exception:
evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer)
if is_logged_in:
return await evt.reply("Failed to get info of telegram chat. "
"You are logged in, are you in that chat?")
return await evt.reply(
"Failed to get info of telegram chat. You are logged in, are you in that chat?"
)
else:
return await evt.reply("Failed to get info of telegram chat. "
"You're not logged in, is the relay bot in the chat?")
return await evt.reply(
"Failed to get info of telegram chat. "
"You're not logged in, is the relay bot in the chat?"
)
if isinstance(entity, (ChatForbidden, ChannelForbidden)):
if is_logged_in:
return await evt.reply("You don't seem to be in that chat.")
else:
return await evt.reply("The bot doesn't seem to be in that chat.")
direct = False
portal.mxid = bridge_to_mxid
portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id)
portal.mxid = room_id
portal.by_mxid[portal.mxid] = portal
(portal.title, portal.about, levels, portal.encrypted) = await get_initial_state(
evt.az.intent, evt.room_id
)
portal.photo_id = ""
portal.save()
await portal.save()
await portal.update_bridge_info()
ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct,
levels=levels),
loop=evt.loop))
background_task.create(portal.update_matrix_room(user, entity, levels=levels))
await warn_missing_power(levels, evt)
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
+76 -47
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,17 +13,27 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Awaitable
from __future__ import annotations
from typing import Any, Awaitable
from io import StringIO
from ...config import yaml
from ruamel.yaml import YAMLError
from mautrix.types import EventID
from mautrix.util.config import yaml
from ... import portal as po, util
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="View or change per-portal settings.",
help_args="<`help`|_subcommand_> [...]")
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="View or change per-portal settings.",
help_args="<`help`|_subcommand_> [...]",
)
async def config(evt: CommandEvent) -> None:
cmd = evt.args[0].lower() if len(evt.args) > 0 else "help"
if cmd not in ("view", "defaults", "set", "unset", "add", "del"):
@@ -34,7 +43,7 @@ async def config(evt: CommandEvent) -> None:
await config_defaults(evt)
return
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
await evt.reply("This is not a portal room.")
return
@@ -42,8 +51,16 @@ async def config(evt: CommandEvent) -> None:
await config_view(evt, portal)
return
if not await portal.can_user_perform(evt.sender, "config"):
await evt.reply("You do not have the permissions to configure this room.")
return
key = evt.args[1] if len(evt.args) > 1 else None
value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None
try:
value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None
except YAMLError as e:
await evt.reply(f"Invalid value provided. Values must be valid YAML.\n{e}")
return
if cmd == "set":
await config_set(evt, portal, key, value)
elif cmd == "unset":
@@ -52,11 +69,12 @@ async def config(evt: CommandEvent) -> None:
await config_add_del(evt, portal, key, value, cmd)
else:
return
portal.save()
await portal.save()
def config_help(evt: CommandEvent) -> Awaitable[Dict]:
return evt.reply("""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
def config_help(evt: CommandEvent) -> Awaitable[EventID]:
return evt.reply(
"""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
* **help** - View this help text.
* **view** - View the current config data.
@@ -65,43 +83,52 @@ def config_help(evt: CommandEvent) -> Awaitable[Dict]:
* **unset** <_key_> - Remove a config value.
* **add** <_key_> <_value_> - Add a value to an array.
* **del** <_key_> <_value_> - Remove a value from an array.
""")
"""
)
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[Dict]:
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[EventID]:
return evt.reply(f"Room-specific config:\n{_str_value(portal.local_config).rstrip()}")
def config_defaults(evt: CommandEvent) -> Awaitable[EventID]:
value = _str_value(
{
"bridge_notices": {
"default": evt.config["bridge.bridge_notices.default"],
"exceptions": evt.config["bridge.bridge_notices.exceptions"],
},
"bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"],
"caption_in_message": evt.config["bridge.caption_in_message"],
"message_formats": evt.config["bridge.message_formats"],
"emote_format": evt.config["bridge.emote_format"],
"state_event_formats": evt.config["bridge.state_event_formats"],
"telegram_link_preview": evt.config["bridge.telegram_link_preview"],
}
)
return evt.reply(f"Bridge instance wide config:\n{value.rstrip()}")
def _str_value(value: Any) -> str:
stream = StringIO()
yaml.dump(portal.local_config, stream)
return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```")
yaml.dump(value, stream)
value_str = stream.getvalue()
if "\n" in value_str:
return f"\n```yaml\n{value_str}\n```\n"
else:
return f"`{value_str}`"
def config_defaults(evt: CommandEvent) -> Awaitable[Dict]:
stream = StringIO()
yaml.dump({
"edits_as_replies": evt.config["bridge.edits_as_replies"],
"bridge_notices": {
"default": evt.config["bridge.bridge_notices.default"],
"exceptions": evt.config["bridge.bridge_notices.exceptions"],
},
"bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"],
"inline_images": evt.config["bridge.inline_images"],
"message_formats": evt.config["bridge.message_formats"],
"state_event_formats": evt.config["bridge.state_event_formats"],
"telegram_link_preview": evt.config["bridge.telegram_link_preview"],
}, stream)
return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```")
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[Dict]:
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: Any) -> Awaitable[EventID]:
if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
elif util.recursive_set(portal.local_config, key, value):
return evt.reply(f"Successfully set the value of `{key}` to `{value}`.")
return evt.reply(f"Successfully set the value of `{key}` to {_str_value(value)}".rstrip())
else:
return evt.reply(f"Failed to set value of `{key}`. "
"Does the path contain non-map types?")
return evt.reply(f"Failed to set value of `{key}`. Does the path contain non-map types?")
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Dict]:
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[EventID]:
if not key:
return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`")
elif util.recursive_del(portal.local_config, key):
@@ -110,24 +137,26 @@ def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Di
return evt.reply(f"`{key}` not found in config.")
def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
) -> Awaitable[Dict]:
def config_add_del(
evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
) -> Awaitable[EventID]:
if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
arr = util.recursive_get(portal.local_config, key)
if not arr:
return evt.reply(f"`{key}` not found in config. "
f"Maybe do `$cmdprefix+sp config set {key} []` first?")
return evt.reply(
f"`{key}` not found in config. Maybe do `$cmdprefix+sp config set {key} []` first?"
)
elif not isinstance(arr, list):
return evt.reply("`{key}` does not seem to be an array.")
elif cmd == "add":
if value in arr:
return evt.reply(f"The array at `{key}` already contains `{value}`.")
return evt.reply(f"The array at `{key}` already contains {_str_value(value)}".rstrip())
arr.append(value)
return evt.reply(f"Successfully added `{value}` to the array at `{key}`")
return evt.reply(f"Successfully added {_str_value(value)} to the array at `{key}`")
else:
if value not in arr:
return evt.reply(f"The array at `{key}` does not contain `{value}`.")
return evt.reply(f"The array at `{key}` does not contain {_str_value(value)}")
arr.remove(value)
return evt.reply(f"Successfully removed `{value}` from the array at `{key}`")
return evt.reply(f"Successfully removed {_str_value(value)} from the array at `{key}`")
+34 -20
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,32 +13,38 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
from __future__ import annotations
from mautrix.types import EventID
from ... import portal as po
from ...types import TelegramID
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state
from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler
from .util import get_initial_state, user_has_power_level, warn_missing_power
@command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="[_type_]",
help_text="Create a Telegram chat of the given type for the current Matrix room. "
"The type is either `group`, `supergroup` or `channel` (defaults to "
"`group`).")
async def create(evt: CommandEvent) -> Dict:
type = evt.args[0] if len(evt.args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}:
@command_handler(
help_section=SECTION_CREATING_PORTALS,
help_args="[_type_]",
help_text=(
"Create a Telegram chat of the given type for the current Matrix room. "
"The type is either `group`, `supergroup` or `channel` (defaults to `supergroup`)."
),
)
async def create(evt: CommandEvent) -> EventID:
type = evt.args[0] if len(evt.args) > 0 else "supergroup"
if type not in ("chat", "group", "supergroup", "channel"):
return await evt.reply(
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`"
)
if po.Portal.get_by_mxid(evt.room_id):
if await po.Portal.get_by_mxid(evt.room_id):
return await evt.reply("This is already a portal room.")
if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply("You do not have the permissions to bridge this room.")
title, about, levels = await get_initial_state(evt.az.intent, evt.room_id)
title, about, levels, encrypted = await get_initial_state(evt.az.intent, evt.room_id)
if not title:
return await evt.reply("Please set a title before creating a Telegram chat.")
@@ -51,11 +56,20 @@ async def create(evt: CommandEvent) -> Dict:
"group": "chat",
}[type]
portal = po.Portal(tgid=TelegramID(0), peer_type=type,
mxid=evt.room_id, title=title, about=about)
portal = po.Portal(
tgid=TelegramID(0),
tg_receiver=TelegramID(0),
peer_type=type,
mxid=evt.room_id,
title=title,
about=about,
encrypted=encrypted,
)
await warn_missing_power(levels, evt)
try:
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
except ValueError as e:
portal.delete()
await portal.delete()
return await evt.reply(e.args[0])
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
+33 -23
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,18 +13,21 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional
from __future__ import annotations
from mautrix.types import EventID
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_ADMIN
from .. import SECTION_ADMIN, CommandEvent, command_handler
@command_handler(needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`>",
help_text="Change whether the bridge will allow or disallow bridging rooms by "
"default.")
async def filter_mode(evt: CommandEvent) -> Dict:
@command_handler(
needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`>",
help_text="Change whether the bridge will allow or disallow bridging rooms by default.",
)
async def filter_mode(evt: CommandEvent) -> EventID:
try:
mode = evt.args[0]
if mode not in ("whitelist", "blacklist"):
@@ -37,20 +39,27 @@ async def filter_mode(evt: CommandEvent) -> Dict:
evt.config.save()
po.Portal.filter_mode = mode
if mode == "whitelist":
return await evt.reply("The bridge will now disallow bridging chats by default.\n"
"To allow bridging a specific chat, use"
"`!filter whitelist <chat ID>`.")
return await evt.reply(
"The bridge will now disallow bridging chats by default.\n"
"To allow bridging a specific chat, use"
"`!filter whitelist <chat ID>`."
)
else:
return await evt.reply("The bridge will now allow bridging chats by default.\n"
"To disallow bridging a specific chat, use"
"`!filter blacklist <chat ID>`.")
return await evt.reply(
"The bridge will now allow bridging chats by default.\n"
"To disallow bridging a specific chat, use"
"`!filter blacklist <chat ID>`."
)
@command_handler(name="filter", needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
help_text="Allow or disallow bridging a specific chat.")
async def edit_filter(evt: CommandEvent) -> Optional[Dict]:
@command_handler(
name="filter",
needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
help_text="Allow or disallow bridging a specific chat.",
)
async def edit_filter(evt: CommandEvent) -> EventID:
try:
action = evt.args[0]
if action not in ("whitelist", "blacklist", "add", "remove"):
@@ -68,7 +77,7 @@ async def edit_filter(evt: CommandEvent) -> Optional[Dict]:
mode = evt.config["bridge.filter.mode"]
if mode not in ("blacklist", "whitelist"):
return await evt.reply(f"Unknown filter mode \"{mode}\". Please fix the bridge config.")
return await evt.reply(f'Unknown filter mode "{mode}". Please fix the bridge config.')
filter_id_list = evt.config["bridge.filter.list"]
@@ -92,4 +101,5 @@ async def edit_filter(evt: CommandEvent) -> Optional[Dict]:
filter_id_list.remove(filter_id)
save()
return await evt.reply(f"Chat ID removed from {mode}.")
return None
else:
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
+259 -39
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,35 +13,93 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
from __future__ import annotations
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
UsernameNotModifiedError, UsernameOccupiedError)
from datetime import datetime, timedelta
import re
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC
from telethon.errors import (
ChatAdminRequiredError,
RPCError,
UsernameInvalidError,
UsernameNotModifiedError,
UsernameOccupiedError,
)
from telethon.helpers import add_surrogate
from telethon.tl.functions.channels import GetFullChannelRequest
from telethon.tl.functions.messages import GetExportedChatInvitesRequest, GetFullChatRequest
from telethon.tl.types import (
ChatInviteExported,
InputMessageEntityMentionName,
InputUserSelf,
MessageEntityMention,
TypeInputPeer,
TypeInputUser,
)
from telethon.tl.types.messages import ExportedChatInvites
from mautrix.types import EventID
from ... import formatter as fmt, portal as po, puppet as pu
from .. import SECTION_MISC, SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
from .util import user_has_power_level
@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False,
help_section=SECTION_MISC,
help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.")
async def sync_state(evt: CommandEvent) -> Dict:
portal = po.Portal.get_by_mxid(evt.room_id)
@command_handler(
needs_admin=False,
needs_puppeting=False,
needs_auth=False,
help_section=SECTION_MISC,
help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.",
)
async def sync_state(evt: CommandEvent) -> EventID:
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply(f"You do not have the permissions to synchronize this room.")
await portal.sync_matrix_members()
await portal.main_intent.get_joined_members(portal.mxid)
await evt.reply("Synchronization complete")
@command_handler(name="id", needs_admin=False, needs_puppeting=False, needs_auth=False,
help_section=SECTION_MISC,
help_text="Get the ID of the Telegram chat where this room is bridged.")
async def get_id(evt: CommandEvent) -> Dict:
portal = po.Portal.get_by_mxid(evt.room_id)
@command_handler(
needs_admin=False, needs_puppeting=False, needs_auth=False, help_section=SECTION_MISC
)
async def sync_full(evt: CommandEvent) -> EventID:
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
if len(evt.args) > 0 and evt.args[0] == "--usebot" and evt.sender.is_admin:
src = evt.tgbot
else:
src = evt.tgbot if await evt.sender.needs_relaybot(portal) else evt.sender
try:
if portal.peer_type == "channel":
res = await src.client(GetFullChannelRequest(portal.peer))
elif portal.peer_type == "chat":
res = await src.client(GetFullChatRequest(portal.tgid))
else:
return await evt.reply("This is not a channel or chat portal.")
except (ValueError, RPCError):
return await evt.reply("Failed to get portal info from Telegram.")
await portal.update_matrix_room(src, res.full_chat)
return await evt.reply("Portal synced successfully.")
@command_handler(
name="id",
needs_admin=False,
needs_puppeting=False,
needs_auth=False,
help_section=SECTION_MISC,
help_text="Get the ID of the Telegram chat where this room is bridged.",
)
async def get_id(evt: CommandEvent) -> EventID:
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
tgid = portal.tgid
@@ -53,29 +110,189 @@ async def get_id(evt: CommandEvent) -> Dict:
await evt.reply(f"This room is bridged to Telegram chat ID `{tgid}`.")
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Get a Telegram invite link to the current chat.")
async def invite_link(evt: CommandEvent) -> Dict:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
invite_link_usage = (
"**Usage:** `$cmdprefix+sp invite-link "
"[--uses=<amount>] [--expire=<delta>] [--request-needed] -- [title]`"
"\n\n"
"* `--uses`: the number of times the invite link can be used."
" Defaults to unlimited.\n"
"* `--expire`: the duration after which the link will expire."
" A number suffixed with d(ay), h(our), m(inute) or s(econd)\n"
"* `--request-needed`: should the link require admins to approve joins?\n"
"* `title`: a description of the link (only shown to admins)."
)
def _parse_flag(args: list[str]) -> tuple[str, str]:
arg = args.pop(0).lower()
if arg == "--":
return "", ""
value = ""
if arg.startswith("--"):
value_start = arg.find("=")
if value_start > 0:
flag = arg[2:value_start]
value = arg[value_start + 1 :]
else:
flag = arg[2:]
if arg not in ("request", "request-needed"):
value = args.pop(0).lower()
elif arg.startswith("-"):
flag = arg[1]
if len(arg) > 3 and arg[2] == "=":
value = arg[3:]
elif arg != "r":
value = args.pop(0).lower()
else:
raise ValueError("invalid flag")
return flag, value
delta_regex = re.compile(
"([0-9]+)(w(?:eek)?|d(?:ay)?|h(?:our)?|m(?:in(?:ute)?)?|s(?:ec(?:ond)?)?)"
)
def _parse_delta(value: str) -> timedelta | None:
match = delta_regex.fullmatch(value)
if not match:
return None
number = int(match.group(1))
unit = match.group(2)[0]
if unit == "w":
return timedelta(weeks=number)
elif unit == "d":
return timedelta(days=number)
elif unit == "h":
return timedelta(hours=number)
elif unit == "m":
return timedelta(minutes=number)
elif unit == "s":
return timedelta(seconds=number)
else:
return None
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Get a Telegram invite link to the current chat.",
help_args="[--uses=<amount>] [--expire=<time delta, e.g. 1d>] [--request-needed] -- [title]",
)
async def invite_link(evt: CommandEvent) -> EventID:
if not evt.is_portal:
return await evt.reply("This is not a portal room.")
if portal.peer_type == "user":
# TODO once we switch to Python 3.9 minimum, use argparse with exit_on_error=False
uses = None
expire = None
request_needed = False
while evt.args:
try:
flag, value = _parse_flag(evt.args)
except (ValueError, IndexError):
return await evt.reply(invite_link_usage)
if not flag:
break
elif flag in ("uses", "u"):
try:
uses = int(value)
except ValueError:
await evt.reply("The number of uses must be an integer")
elif flag in ("expire", "e"):
expire_delta = _parse_delta(value)
if not expire_delta:
await evt.reply("Invalid format for expiry time delta")
expire = datetime.now() + expire_delta
elif flag in ("request", "request-needed", "r"):
request_needed = True
title = " ".join(evt.args)
if evt.portal.peer_type == "user":
return await evt.reply("You can't invite users to private chats.")
try:
link = await portal.get_invite_link(evt.sender)
return await evt.reply(f"Invite link to {portal.title}: {link}")
link = await evt.portal.get_invite_link(
evt.sender, uses=uses, expire=expire, request_needed=request_needed, title=title
)
return await evt.reply(f"Invite link to {evt.portal.title}: {link}")
except ValueError as e:
return await evt.reply(e.args[0])
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to create an invite link.")
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.")
async def upgrade(evt: CommandEvent) -> Dict:
portal = po.Portal.get_by_mxid(evt.room_id)
async def _format_invite_link(link: ChatInviteExported) -> str:
desc = f"* {link.link}"
if link.title:
desc += f" - {link.title}"
if link.expire_date:
desc += f" \n Expires at {link.expire_date.isoformat()}"
if link.usage_limit:
desc += f" \n Used {link.usage or 0} out of {link.usage_limit} times"
elif link.usage:
desc += f" \n Used {link.usage} times"
else:
desc += " \n Never used"
if link.request_needed:
desc += " \n Join requests enabled - using link requires admin approval"
return desc
async def _hacky_find_mention(evt: CommandEvent) -> TypeInputUser | TypeInputPeer | None:
if len(evt.args) == 0:
return None
text, entities = await fmt.matrix_to_telegram(
evt.sender.client, text=evt.content.body, html=evt.content.formatted_body
)
for entity in entities:
if isinstance(entity, MessageEntityMention):
admin_username = add_surrogate(text)[entity.offset + 1 : entity.offset + entity.length]
return await evt.sender.client.get_input_entity(admin_username)
elif isinstance(entity, InputMessageEntityMentionName):
return entity.user_id
return None
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="List existing Telegram invite links to the current chat.",
help_args="[creator]",
)
async def list_invite_links(evt: CommandEvent) -> EventID:
admin_id = InputUserSelf()
try:
admin_id = await _hacky_find_mention(evt) or InputUserSelf()
except Exception:
pass
resp: ExportedChatInvites = await evt.sender.client(
GetExportedChatInvitesRequest(
peer=await evt.portal.get_input_entity(evt.sender),
admin_id=admin_id,
limit=100,
)
)
if resp.count == 0:
if isinstance(admin_id, InputUserSelf):
return await evt.reply("You haven't created any invite links to the current chat")
else:
return await evt.reply("That user hasn't created any invite links to the current chat")
formatted_links = "\n".join([await _format_invite_link(link) for link in resp.invites])
if isinstance(admin_id, InputUserSelf):
await evt.reply(f"Your links to this chat:\n\n{formatted_links}")
else:
puppet = await pu.Puppet.get_by_peer(admin_id)
await evt.reply(
f"[{puppet.displayname}](https://matrix.to/#/{puppet.mxid})'s links to this chat:\n\n"
f"{formatted_links}"
)
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.",
)
async def upgrade(evt: CommandEvent) -> EventID:
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type == "channel":
@@ -92,30 +309,33 @@ async def upgrade(evt: CommandEvent) -> Dict:
return await evt.reply(e.args[0])
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_args="<_name_|`-`>",
help_text="Change the username of a supergroup/channel. "
"To disable, use a dash (`-`) as the name.")
async def group_name(evt: CommandEvent) -> Dict:
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_args="<_name_|`-`>",
help_text=(
"Change the username of a supergroup/channel. To disable, use a dash (`-`) as the name."
),
)
async def group_name(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
portal = po.Portal.get_by_mxid(evt.room_id)
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type != "channel":
return await evt.reply("Only channels and supergroups have usernames.")
try:
await portal.set_telegram_username(evt.sender,
evt.args[0] if evt.args[0] != "-" else "")
await portal.set_telegram_username(evt.sender, evt.args[0] if evt.args[0] != "-" else "")
if portal.username:
return await evt.reply(f"Username of channel changed to {portal.username}.")
else:
return await evt.reply(f"Channel is now private.")
except ChatAdminRequiredError:
return await evt.reply(
"You don't have the permission to set the username of this channel.")
"You don't have the permission to set the username of this channel."
)
except UsernameNotModifiedError:
if portal.username:
return await evt.reply("That is already the username of this channel.")
+66 -45
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,35 +13,42 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Callable, Optional
from __future__ import annotations
from typing import Callable
from mautrix.types import EventID, RoomID
from ...types import MatrixRoomID
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
from .util import user_has_power_level
async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
action: Optional[str] = None
) -> Optional[po.Portal]:
room_id = MatrixRoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
async def _get_portal_and_check_permission(evt: CommandEvent) -> po.Portal | None:
room_id = RoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
portal = po.Portal.get_by_mxid(room_id)
portal = await po.Portal.get_by_mxid(room_id)
if not portal:
that_this = "This" if room_id == evt.room_id else "That"
await evt.reply(f"{that_this} is not a portal room.")
return None
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission):
action = action or f"{permission.replace('_', ' ')}s"
await evt.reply(f"You do not have the permissions to {action} that portal.")
if portal.peer_type == "user":
if portal.tg_receiver != evt.sender.tgid:
await evt.reply("You do not have the permissions to unbridge that portal.")
return None
return portal
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
await evt.reply("You do not have the permissions to unbridge that portal.")
return None
return portal
def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str,
completed_message: str) -> Dict:
async def post_confirm(confirm) -> Optional[Dict]:
def _get_portal_murder_function(
action: str, room_id: str, function: Callable, command: str, completed_message: str
) -> dict:
async def post_confirm(confirm) -> EventID | None:
confirm.sender.command_status = None
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
await function()
@@ -58,40 +64,55 @@ def _get_portal_murder_function(action: str, room_id: str, function: Callable, c
}
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove all users from the current portal room and forget the portal. "
"Only works for group chats; to delete a private chat portal, simply "
"leave the room.")
async def delete_portal(evt: CommandEvent) -> Optional[Dict]:
portal = await _get_portal_and_check_permission(evt, "unbridge")
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text=(
"Remove all users from the current portal room and forget the portal. "
"Only works for group chats; to delete a private chat portal, simply leave the room."
),
)
async def delete_portal(evt: CommandEvent) -> EventID | None:
portal = await _get_portal_and_check_permission(evt)
if not portal:
return None
evt.sender.command_status = _get_portal_murder_function("Portal deletion", portal.mxid,
portal.cleanup_and_delete, "delete",
"Portal successfully deleted.")
return await evt.reply("Please confirm deletion of portal "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
f"to Telegram chat \"{portal.title}\" "
"by typing `$cmdprefix+sp confirm-delete`"
"\n\n"
"**WARNING:** If the bridge bot has the power level to do so, **this "
"will kick ALL users** in the room. If you just want to remove the "
"bridge, use `$cmdprefix+sp unbridge` instead.")
evt.sender.command_status = _get_portal_murder_function(
"Portal deletion",
portal.mxid,
portal.cleanup_and_delete,
"delete",
"Portal successfully deleted.",
)
return await evt.reply(
"Please confirm deletion of portal "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
f'to Telegram chat "{portal.title}" '
"by typing `$cmdprefix+sp confirm-delete`"
"\n\n"
"**WARNING:** If the bridge bot has the power level to do so, **this "
"will kick ALL users** in the room. If you just want to remove the "
"bridge, use `$cmdprefix+sp unbridge` instead."
)
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove puppets from the current portal room and forget the portal.")
async def unbridge(evt: CommandEvent) -> Optional[Dict]:
portal = await _get_portal_and_check_permission(evt, "unbridge")
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove puppets from the current portal room and forget the portal.",
)
async def unbridge(evt: CommandEvent) -> EventID | None:
portal = await _get_portal_and_check_permission(evt)
if not portal:
return None
evt.sender.command_status = _get_portal_murder_function("Room unbridging", portal.mxid,
portal.unbridge, "unbridge",
"Room successfully unbridged.")
return await evt.reply(f"Please confirm unbridging chat \"{portal.title}\" from room "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
"by typing `$cmdprefix+sp confirm-unbridge`")
evt.sender.command_status = _get_portal_murder_function(
"Room unbridging", portal.mxid, portal.unbridge, "unbridge", "Room successfully unbridged."
)
return await evt.reply(
f'Please confirm unbridging chat "{portal.title}" from room '
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
"by typing `$cmdprefix+sp confirm-unbridge`"
)
+40 -24
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,43 +13,60 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Tuple
from __future__ import annotations
from mautrix_appservice import MatrixRequestError, IntentAPI
from mautrix.appservice import IntentAPI
from mautrix.errors import MatrixRequestError
from mautrix.types import EventType, PowerLevelStateEventContent, RoomID
from ... import user as u
from .. import CommandEvent
async def get_initial_state(intent: IntentAPI, room_id: str) -> Tuple[str, str, Dict]:
state = await intent.get_room_state(room_id)
title = None
about = None
levels = None
async def get_initial_state(
intent: IntentAPI, room_id: RoomID
) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool]:
state = await intent.get_state(room_id)
title: str | None = None
about: str | None = None
levels: PowerLevelStateEventContent | None = None
encrypted: bool = False
for event in state:
try:
if event["type"] == "m.room.name":
title = event["content"]["name"]
elif event["type"] == "m.room.topic":
about = event["content"]["topic"]
elif event["type"] == "m.room.power_levels":
levels = event["content"]
elif event["type"] == "m.room.canonical_alias":
title = title or event["content"]["alias"]
if event.type == EventType.ROOM_NAME:
title = event.content.name
elif event.type == EventType.ROOM_TOPIC:
about = event.content.topic
elif event.type == EventType.ROOM_POWER_LEVELS:
levels = event.content
elif event.type == EventType.ROOM_CANONICAL_ALIAS:
title = title or event.content.canonical_alias
elif event.type == EventType.ROOM_ENCRYPTION:
encrypted = True
except KeyError:
# Some state event probably has empty content
pass
return title, about, levels
return title, about, levels, encrypted
async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50
) -> bool:
async def warn_missing_power(levels: PowerLevelStateEventContent, evt: CommandEvent) -> None:
if levels.get_user_level(evt.az.bot_mxid) < levels.redact:
await evt.reply(
"Warning: The bot does not have privileges to redact messages on Matrix. "
"Message deletions from Telegram will not be bridged unless you give "
f"redaction permissions to [{evt.az.bot_mxid}](https://matrix.to/#/{evt.az.bot_mxid})"
)
async def user_has_power_level(
room_id: RoomID, intent: IntentAPI, sender: u.User, event: str
) -> bool:
if sender.is_admin:
return True
# Make sure the state store contains the power levels.
try:
await intent.get_power_levels(room)
await intent.get_power_levels(room_id)
except MatrixRequestError:
return False
return intent.state_store.has_power_level(room, sender.mxid,
event=f"net.maunium.telegram.{event}",
default=default)
event_type = EventType.find(f"fi.mau.telegram.{event}", t_class=EventType.Class.STATE)
return await intent.state_store.has_power_level(room_id, sender.mxid, event_type)
+105 -39
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,22 +13,37 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional
from __future__ import annotations
from telethon.errors import (UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError,
HashInvalidError, AuthKeyError)
from telethon.errors import (
AboutTooLongError,
AuthKeyError,
FirstNameInvalidError,
HashInvalidError,
UsernameInvalidError,
UsernameNotModifiedError,
UsernameOccupiedError,
)
from telethon.tl.functions.account import (
GetAuthorizationsRequest,
ResetAuthorizationRequest,
UpdateProfileRequest,
UpdateUsernameRequest,
)
from telethon.tl.types import Authorization
from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest,
ResetAuthorizationRequest)
from .. import command_handler, CommandEvent, SECTION_AUTH
from mautrix.types import EventID
from .. import SECTION_AUTH, CommandEvent, command_handler
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
help_args="<_new username_>",
help_text="Change your Telegram username.")
async def username(evt: CommandEvent) -> Optional[Dict]:
@command_handler(
needs_auth=True,
help_section=SECTION_AUTH,
help_args="<_new username_>",
help_text="Change your Telegram username.",
)
async def username(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp username <new username>`")
if evt.sender.is_bot:
@@ -40,31 +54,80 @@ async def username(evt: CommandEvent) -> Optional[Dict]:
try:
await evt.sender.client(UpdateUsernameRequest(username=new_name))
except UsernameInvalidError:
return await evt.reply("Invalid username. Usernames must be between 5 and 30 alphanumeric "
"characters.")
return await evt.reply(
"Invalid username. Usernames must be between 5 and 30 alphanumeric characters."
)
except UsernameNotModifiedError:
return await evt.reply("That is your current username.")
except UsernameOccupiedError:
return await evt.reply("That username is already in use.")
await evt.sender.update_info()
if not evt.sender.username:
if not evt.sender.tg_username:
await evt.reply("Username removed")
else:
await evt.reply(f"Username changed to {evt.sender.username}")
await evt.reply(f"Username changed to {evt.sender.tg_username}")
@command_handler(
needs_auth=True,
help_section=SECTION_AUTH,
help_args="<_new about_>",
help_text="Change your Telegram about section.",
)
async def about(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp about <new about>`")
if evt.sender.is_bot:
return await evt.reply("Bots can't set their own about section.")
new_about = " ".join(evt.args)
if new_about == "-":
new_about = ""
try:
await evt.sender.client(UpdateProfileRequest(about=new_about))
except AboutTooLongError:
return await evt.reply("The provided about section is too long")
return await evt.reply("About section updated")
@command_handler(
needs_auth=True,
help_section=SECTION_AUTH,
help_args="<_new displayname_>",
help_text="Change your Telegram displayname.",
)
async def displayname(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp displayname <new displayname>`")
if evt.sender.is_bot:
return await evt.reply("Bots can't set their own displayname.")
first_name, last_name = (
(evt.args[0], "") if len(evt.args) == 1 else (" ".join(evt.args[:-1]), evt.args[-1])
)
try:
await evt.sender.client(UpdateProfileRequest(first_name=first_name, last_name=last_name))
except FirstNameInvalidError:
return await evt.reply("Invalid first name")
await evt.sender.update_info()
return await evt.reply("Displayname updated")
def _format_session(sess: Authorization) -> str:
return (f"**{sess.app_name} {sess.app_version}** \n"
f" **Platform:** {sess.device_model} {sess.platform} {sess.system_version} \n"
f" **Active:** {sess.date_active} (created {sess.date_created}) \n"
f" **From:** {sess.ip} - {sess.region}, {sess.country}")
return (
f"**{sess.app_name} {sess.app_version}** \n"
f" **Platform:** {sess.device_model} {sess.platform} {sess.system_version} \n"
f" **Active:** {sess.date_active} (created {sess.date_created}) \n"
f" **From:** {sess.ip} - {sess.region}, {sess.country}"
)
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
help_args="<`list`|`terminate`> [_hash_]",
help_text="View or delete other Telegram sessions.")
async def session(evt: CommandEvent) -> Optional[Dict]:
@command_handler(
needs_auth=True,
help_section=SECTION_AUTH,
help_args="<`list`|`terminate`> [_hash_]",
help_text="View or delete other Telegram sessions.",
)
async def session(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
elif evt.sender.is_bot:
@@ -75,29 +138,32 @@ async def session(evt: CommandEvent) -> Optional[Dict]:
session_list = res.authorizations
current = [s for s in session_list if s.current][0]
current_text = _format_session(current)
other_text = "\n".join(f"* {_format_session(sess)} \n"
f" **Hash:** {sess.hash}"
for sess in session_list if not sess.current)
return await evt.reply(f"### Current session\n"
f"{current_text}\n"
f"\n"
f"### Other active sessions\n"
f"{other_text}")
other_text = "\n".join(
f"* {_format_session(sess)} \n **Hash:** {sess.hash}"
for sess in session_list
if not sess.current
)
return await evt.reply(
f"### Current session\n"
f"{current_text}\n"
f"\n"
f"### Other active sessions\n"
f"{other_text}"
)
elif cmd == "terminate" and len(evt.args) > 1:
try:
session_hash = int(evt.args[1])
except ValueError:
return await evt.reply("Hash must be a positive integer")
if session_hash <= 0:
return await evt.reply("Hash must be a positive integer")
return await evt.reply("Hash must be an integer")
try:
ok = await evt.sender.client(ResetAuthorizationRequest(hash=session_hash))
except HashInvalidError:
return await evt.reply("Invalid session hash.")
except AuthKeyError as e:
if e.message == "FRESH_RESET_AUTHORISATION_FORBIDDEN":
return await evt.reply("New sessions can't terminate other sessions. "
"Please wait a while.")
return await evt.reply(
"New sessions can't terminate other sessions. Please wait a while."
)
raise
if ok:
return await evt.reply("Session terminated successfully.")
+257 -163
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,108 +13,184 @@
#
# 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, Dict, Optional
from __future__ import annotations
from typing import Any
import asyncio
import io
from telethon.errors import (
AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError,
PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError,
PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError,
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError)
AccessTokenExpiredError,
AccessTokenInvalidError,
FloodWaitError,
PasswordHashInvalidError,
PhoneCodeExpiredError,
PhoneCodeInvalidError,
PhoneNumberAppSignupForbiddenError,
PhoneNumberBannedError,
PhoneNumberFloodError,
PhoneNumberInvalidError,
PhoneNumberUnoccupiedError,
SessionPasswordNeededError,
)
from telethon.tl.types import User
from ... import puppet as pu, user as u
from ...commands import command_handler, CommandEvent, SECTION_AUTH
from ...util import format_duration, ignore_coro
from mautrix.client import Client
from mautrix.types import (
EventID,
ImageInfo,
MediaMessageEventContent,
MessageType,
TextMessageEventContent,
UserID,
)
from mautrix.util import background_task
from mautrix.util.format_duration import format_duration as fmt_duration
from ... import user as u
from ...commands import SECTION_AUTH, CommandEvent, command_handler
from ...types import TelegramID
try:
from telethon.tl.custom import QRLogin
import PIL as _
import qrcode
except ImportError:
qrcode = None
QRLogin = None
@command_handler(needs_auth=False,
help_section=SECTION_AUTH,
help_text="Check if you're logged into Telegram.")
async def ping(evt: CommandEvent) -> Optional[Dict]:
me = await evt.sender.client.get_me() if await evt.sender.is_logged_in() else None
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
if me:
return await evt.reply(f"You're logged in as {human_tg_id}")
@command_handler(
needs_auth=False, help_section=SECTION_AUTH, help_text="Check if you're logged into Telegram."
)
async def ping(evt: CommandEvent) -> EventID:
if await evt.sender.is_logged_in():
me = await evt.sender.get_me()
if me:
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
return await evt.reply(f"You're logged in as {human_tg_id}")
else:
return await evt.reply("You were logged in, but there appears to have been an error.")
else:
return await evt.reply("You're not logged in.")
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_AUTH,
help_text="Get the info of the message relay Telegram bot.")
async def ping_bot(evt: CommandEvent) -> Optional[Dict]:
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_AUTH,
help_text="Get the info of the message relay Telegram bot.",
)
async def ping_bot(evt: CommandEvent) -> EventID:
if not evt.tgbot:
return await evt.reply("Telegram message relay bot not configured.")
bot_info = await evt.tgbot.client.get_me()
mxid = pu.Puppet.get_mxid_from_id(bot_info.id)
displayname = bot_info.first_name
return await evt.reply("Telegram message relay bot is active: "
f"[{displayname}](https://matrix.to/#/{mxid}) (ID {bot_info.id})\n\n"
"To use the bot, simply invite it to a portal room.")
info, mxid = await evt.tgbot.get_me(use_cache=False)
return await evt.reply(
"Telegram message relay bot is active: "
f"[{info.first_name}](https://matrix.to/#/{mxid}) (ID {info.id})\n\n"
"To use the bot, simply invite it to a portal room."
)
@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) -> Optional[Dict]:
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 None
async def enter_code_register(evt: CommandEvent) -> Dict:
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)
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
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(needs_auth=False, management_only=True,
help_section=SECTION_AUTH,
help_text="Get instructions on how to log in.")
async def login(evt: CommandEvent) -> Optional[Dict]:
override_sender = False
@command_handler(
needs_auth=False,
management_only=True,
help_section=SECTION_AUTH,
help_text="Log in by scanning a QR code.",
)
async def login_qr(evt: CommandEvent) -> EventID:
login_as = evt.sender
if len(evt.args) > 0 and evt.sender.is_admin:
evt.sender = await u.User.get_by_mxid(evt.args[0]).ensure_started()
login_as = await u.User.get_by_mxid(UserID(evt.args[0]))
if not qrcode or not QRLogin:
return await evt.reply("This bridge instance does not support logging in with a QR code.")
if await login_as.is_logged_in():
return await evt.reply(f"You are already logged in as {login_as.human_tg_id}.")
await login_as.ensure_started(even_if_no_session=True)
qr_login = QRLogin(login_as.client, ignored_ids=[])
qr_event_id: EventID | None = None
async def upload_qr() -> None:
nonlocal qr_event_id
buffer = io.BytesIO()
image = qrcode.make(qr_login.url)
size = image.pixel_size
image.save(buffer, "PNG")
qr = buffer.getvalue()
mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr))
content = MediaMessageEventContent(
body=qr_login.url,
url=mxc,
msgtype=MessageType.IMAGE,
info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size),
)
if qr_event_id:
content.set_edit(qr_event_id)
await evt.az.intent.send_message(evt.room_id, content)
else:
content.set_reply(evt.event_id)
qr_event_id = await evt.az.intent.send_message(evt.room_id, content)
retries = 4
while retries > 0:
await qr_login.recreate()
await upload_qr()
try:
user = await qr_login.wait()
break
except asyncio.TimeoutError:
retries -= 1
except SessionPasswordNeededError:
evt.sender.command_status = {
"next": enter_password,
"login_as": login_as if login_as != evt.sender else None,
"action": "Login (password entry)",
}
return await evt.reply(
"Your account has two-factor authentication. Please send your password here."
)
try:
await evt.main_intent.redact(evt.room_id, qr_event_id, reason="QR code scanned")
except Exception:
pass
else:
timeout = TextMessageEventContent(body="Login timed out", msgtype=MessageType.TEXT)
timeout.set_edit(qr_event_id)
return await evt.az.intent.send_message(evt.room_id, timeout)
return await _finish_sign_in(evt, user, login_as=login_as)
@command_handler(
needs_auth=False,
management_only=True,
help_section=SECTION_AUTH,
help_text="Get instructions on how to log in.",
)
async def login(evt: CommandEvent) -> EventID:
override_sender = False
if len(evt.args) > 0 and evt.sender.is_admin and evt.args[0]:
override_user_id = UserID(evt.args[0])
try:
Client.parse_user_id(override_user_id)
except ValueError:
return await evt.reply(
f"**Usage:** `$cmdprefix+sp login [override user ID]`\n\n"
f"{override_user_id!r} is not a valid Matrix user ID"
)
orig_user_id = evt.sender.mxid
evt.sender = await u.User.get_and_start_by_mxid(override_user_id)
override_sender = True
if orig_user_id != evt.sender:
await evt.reply(
f"Admin override: logging in as {evt.sender.mxid} instead of {orig_user_id}"
)
if await evt.sender.is_logged_in():
return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.")
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
allow_matrix_login = evt.config["bridge.allow_matrix_login"]
if allow_matrix_login and not override_sender:
evt.sender.command_status = {
"next": enter_phone_or_token,
@@ -126,46 +201,33 @@ async def login(evt: CommandEvent) -> Optional[Dict]:
if evt.config["appservice.public.enabled"]:
prefix = evt.config["appservice.public.external"]
url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}"
if allow_matrix_login:
if override_sender:
return await evt.reply(
"This bridge instance allows you to log in inside or outside of Matrix, but "
"logging in as another user is only possible via the web interface.\n\n"
f"Please visit [the login page]({url}) to log in as "
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid}).\n\n")
return await evt.reply(
"This bridge instance allows you to log in inside or outside Matrix.\n\n"
"If you would like to log in within Matrix, please send your phone number or bot "
"auth token here.\n"
"If you would like to log in outside of Matrix, please visit [the login page]"
f"({url}).\n\n"
"Logging in outside of Matrix is recommended if you have two-factor authentication "
"enabled, because in-Matrix login would save your password in the message history."
f"\n\n{nb}")
if override_sender:
return await evt.reply(
"This bridge instance does not allow logging in inside Matrix, and logging in as "
"another user inside Matrix isn't possible anyway.\n\n"
f"Please visit [the login page]({url}) to log in as "
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid}).")
return await evt.reply(
"This bridge instance does not allow logging in inside Matrix.\n\n"
f"Please visit [the login page]({url}) to log in.\n\n"
f"{nb}")
f"[Click here to log in]({url}) as "
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid})."
)
elif allow_matrix_login:
return await evt.reply(
f"[Click here to log in]({url}). Alternatively, send your phone"
f" number (or bot auth token) here to log in.\n\n{nb}"
)
return await evt.reply(f"[Click here to log in]({url}).\n\n{nb}")
elif allow_matrix_login:
if override_sender:
return await evt.reply(
"This bridge instance does not allow you to log in outside of Matrix. "
"Logging in as another user inside Matrix is not currently possible.")
"Logging in as another user inside Matrix is not currently possible."
)
return await evt.reply(
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
"Please send your phone number or bot auth token here to start the login process.\n\n"
f"{nb}")
"Please send your phone number (or bot auth token) here to start "
f"the login process.\n\n{nb}"
)
return await evt.reply("This bridge instance has been configured to not allow logging in.")
async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[str, Any]
) -> Dict:
async def _request_code(
evt: CommandEvent, phone_number: str, next_status: dict[str, Any]
) -> EventID:
ok = False
try:
await evt.sender.ensure_started(even_if_no_session=True)
@@ -173,36 +235,44 @@ async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[
ok = True
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
except PhoneNumberAppSignupForbiddenError:
return await evt.reply(
"Your phone number does not allow 3rd party apps to sign in.")
return await evt.reply("Your phone number does not allow 3rd party apps to sign in.")
except PhoneNumberFloodError:
return await evt.reply(
"Your phone number has been temporarily blocked for flooding. "
"The ban is usually applied for around a day.")
"The ban is usually applied for around a day."
)
except FloodWaitError as e:
return await evt.reply(
"Your phone number has been temporarily blocked for flooding. "
f"Please wait for {format_duration(e.seconds)} before trying again.")
f"Please wait for {fmt_duration(e.seconds)} before trying again."
)
except PhoneNumberBannedError:
return await evt.reply("Your phone number has been banned from Telegram.")
return await evt.reply("Your phone number has been banned from Telegram.")
except PhoneNumberUnoccupiedError:
return await evt.reply("That phone number has not been registered. "
"Please register with `$cmdprefix+sp register <phone>`.")
return await evt.reply(
"That phone number has not been registered. "
"Please sign up to Telegram using an official mobile client first."
)
except PhoneNumberInvalidError:
return await evt.reply("That phone number is not valid.")
except Exception:
evt.log.exception("Error requesting phone code")
return await evt.reply("Unhandled exception while requesting code. "
"Check console for more details.")
return await evt.reply(
"Unhandled exception while requesting code. Check console for more details."
)
finally:
evt.sender.command_status = next_status if ok else None
@command_handler(needs_auth=False)
async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
async def enter_phone_or_token(evt: CommandEvent) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`")
elif not evt.config.get("bridge.allow_matrix_login", True):
return await evt.reply("This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions")
return await evt.reply(
"This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions"
)
# phone numbers don't contain colons but telegram bot auth tokens do
if evt.args[0].find(":") > 0:
@@ -210,66 +280,67 @@ async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
await _sign_in(evt, bot_token=evt.args[0])
except Exception:
evt.log.exception("Error sending auth token")
return await evt.reply("Unhandled exception while sending auth token. "
"Check console for more details.")
return await evt.reply(
"Unhandled exception while sending auth token. Check console for more details."
)
else:
await _request_code(evt, evt.args[0], {
"next": enter_code,
"action": "Login",
})
await _request_code(evt, evt.args[0], {"next": enter_code, "action": "Login"})
return None
@command_handler(needs_auth=False)
async def enter_code(evt: CommandEvent) -> Optional[Dict]:
async def enter_code(evt: CommandEvent) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
elif not evt.config.get("bridge.allow_matrix_login", True):
return await evt.reply("This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions")
return await evt.reply(
"This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions"
)
try:
await _sign_in(evt, code=evt.args[0])
except Exception:
evt.log.exception("Error sending phone code")
return await evt.reply("Unhandled exception while sending code. "
"Check console for more details.")
return await evt.reply(
"Unhandled exception while sending code. Check console for more details."
)
return None
@command_handler(needs_auth=False)
async def enter_password(evt: CommandEvent) -> Optional[Dict]:
async def enter_password(evt: CommandEvent) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
elif not evt.config.get("bridge.allow_matrix_login", True):
return await evt.reply("This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions")
return await evt.reply(
"This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions"
)
await evt.redact()
try:
await _sign_in(evt, password=" ".join(evt.args))
await _sign_in(
evt,
login_as=evt.sender.command_status.get("login_as", None),
password=" ".join(evt.args),
)
except AccessTokenInvalidError:
return await evt.reply("That bot token is not valid.")
except AccessTokenExpiredError:
return await evt.reply("That bot token has expired.")
except Exception:
evt.log.exception("Error sending password")
return await evt.reply("Unhandled exception while sending password. "
"Check console for more details.")
return await evt.reply(
"Unhandled exception while sending password. Check console for more details."
)
return None
async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
async def _sign_in(evt: CommandEvent, login_as: u.User = None, **sign_in_info) -> EventID:
login_as = login_as or evt.sender
try:
await evt.sender.ensure_started(even_if_no_session=True)
user = await evt.sender.client.sign_in(**sign_in_info)
existing_user = u.User.get_by_tgid(user.id)
if existing_user and existing_user != evt.sender:
await existing_user.log_out()
await evt.reply(f"[{existing_user.displayname}]"
f"(https://matrix.to/#/{existing_user.mxid})"
" was logged out from the account.")
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
evt.sender.command_status = None
name = f"@{user.username}" if user.username else f"+{user.phone}"
return await evt.reply(f"Successfully logged in as {name}")
await login_as.ensure_started(even_if_no_session=True)
user = await login_as.client.sign_in(**sign_in_info)
await _finish_sign_in(evt, user)
except PhoneCodeExpiredError:
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
except PhoneCodeInvalidError:
@@ -281,14 +352,37 @@ async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
"next": enter_password,
"action": "Login (password entry)",
}
return await evt.reply("Your account has two-factor authentication. "
"Please send your password here.")
return await evt.reply(
"Your account has two-factor authentication. Please send your password here."
)
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
help_text="Log out from Telegram.")
async def logout(evt: CommandEvent) -> Optional[Dict]:
async def _finish_sign_in(evt: CommandEvent, user: User, login_as: u.User = None) -> EventID:
login_as = login_as or evt.sender
existing_user = await u.User.get_by_tgid(TelegramID(user.id))
if existing_user and existing_user != login_as:
await existing_user.log_out()
await evt.reply(
f"[{existing_user.displayname}] (https://matrix.to/#/{existing_user.mxid})"
" was logged out from the account."
)
background_task.create(login_as.post_login(user, first_login=True))
evt.sender.command_status = None
name = f"@{user.username}" if user.username else f"+{user.phone}"
if login_as != evt.sender:
msg = (
f"Successfully logged in [{login_as.mxid}](https://matrix.to/#/{login_as.mxid})"
f" as {name}"
)
else:
msg = f"Successfully logged in as {name}"
return await evt.reply(msg)
@command_handler(needs_auth=False, help_section=SECTION_AUTH, help_text="Log out from Telegram.")
async def logout(evt: CommandEvent) -> EventID:
if not evt.sender.tgid:
return await evt.reply("You're not logged in")
if await evt.sender.log_out():
return await evt.reply("Logged out successfully.")
return await evt.reply("Failed to log out.")
+329 -78
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,32 +13,88 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple
import codecs
from __future__ import annotations
from typing import cast
import base64
import codecs
import re
import shlex
from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError,
UserAlreadyParticipantError)
from telethon.tl.patched import Message
from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll,
TypePeer)
from telethon.tl.types.messages import BotCallbackAnswer
from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatInviteRequest,
GetBotCallbackAnswerRequest, SendVoteRequest)
from aiohttp import ClientSession, InvalidURL
from telethon.errors import (
ChatIdInvalidError,
EmoticonInvalidError,
InviteHashExpiredError,
InviteHashInvalidError,
InviteRequestSentError,
OptionsTooMuchError,
UserAlreadyParticipantError,
)
from telethon.tl.functions.channels import JoinChannelRequest
from telethon.tl.functions.contacts import DeleteByPhonesRequest, ImportContactsRequest
from telethon.tl.functions.messages import (
CheckChatInviteRequest,
GetBotCallbackAnswerRequest,
ImportChatInviteRequest,
SendVoteRequest,
)
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 ... import puppet as pu, portal as po
from mautrix.types import EventID, Format
from ... import portal as po, puppet as pu
from ...abstract_user import AbstractUser
from ...commands import (
SECTION_CREATING_PORTALS,
SECTION_MISC,
SECTION_PORTAL_MANAGEMENT,
CommandEvent,
command_handler,
)
from ...db import Message as DBMessage
from ...types import TelegramID
from ...commands import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS
@command_handler(help_section=SECTION_MISC,
help_args="[_-r|--remote_] <_query_>",
help_text="Search your contacts or the Telegram servers for users.")
async def search(evt: CommandEvent) -> Optional[Dict]:
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_MISC,
help_args="<_caption_>",
help_text="Set a caption for the next image you send",
)
async def caption(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp caption <caption>`")
prefix = f"{evt.command_prefix} caption "
if evt.content.format == Format.HTML:
evt.content.formatted_body = evt.content.formatted_body.replace(prefix, "", 1)
evt.content.body = evt.content.body.replace(prefix, "", 1)
evt.sender.command_status = {"caption": evt.content, "action": "Caption"}
return await evt.reply(
"Your next image or file will be sent with that caption. "
"Use `$cmdprefix+sp cancel` to cancel the caption."
)
@command_handler(
help_section=SECTION_MISC,
help_args="[_-r|--remote_] <_query_>",
help_text="Search your contacts or the Telegram servers for users.",
)
async def search(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
@@ -56,36 +111,45 @@ async def search(evt: CommandEvent) -> Optional[Dict]:
if not results:
if len(query) < 5 and remote:
return await evt.reply("No local results. "
"Minimum length of remote query is 5 characters.")
return await evt.reply(
"No local results. Minimum length of remote query is 5 characters."
)
return await evt.reply("No results 3:")
reply = [] # type: List[str]
reply: list[str] = []
if remote:
reply += ["**Results from Telegram server:**", ""]
else:
reply += ["**Results in contacts:**", ""]
reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
f"{puppet.id} ({similarity}% match)")
for puppet, similarity in results]
reply += [
(
f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
f"{puppet.id} ({similarity}% match)"
)
for puppet, similarity in results
]
# TODO somehow show remote channel results when joining by alias is possible?
return await evt.reply("\n".join(reply))
@command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="<_identifier_>",
help_text="Open a private chat with the given Telegram user. The identifier is "
"either the internal user ID, the username or the phone number. "
"**N.B.** The phone numbers you start chats with must already be in "
"your contacts.")
async def pm(evt: CommandEvent) -> Optional[Dict]:
@command_handler(
help_section=SECTION_CREATING_PORTALS,
help_args="<_username_>",
help_text=(
"Open a private chat with the given Telegram user. You can also use a "
"phone number instead of username, but you must have the number in "
"your Telegram contacts for that to work."
),
)
async def pm(evt: CommandEvent) -> EventID:
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:
user = await evt.sender.client.get_entity(evt.args[0])
id = "".join(evt.args).translate({ord(c): None for c in "+()- "})
user = await evt.sender.client.get_entity(id)
except ValueError:
return await evt.reply("Invalid user identifier or user not found.")
@@ -93,64 +157,170 @@ async def pm(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply("User not found.")
elif not isinstance(user, TLUser):
return await evt.reply("That doesn't seem to be a user.")
portal = po.Portal.get_by_entity(user, evt.sender.tgid)
portal = await po.Portal.get_by_entity(user, tg_receiver=evt.sender.tgid)
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
return await evt.reply("Created private chat room with "
f"{pu.Puppet.get_displayname(user, False)}")
displayname, _ = pu.Puppet.get_displayname(user, False)
return await evt.reply(f"Created private chat room with {displayname}")
async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[Dict]]:
if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):]
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]:
if link_type == "joinchat":
try:
await evt.sender.client(CheckChatInviteRequest(invite_hash))
await evt.sender.client(CheckChatInviteRequest(identifier))
except InviteHashInvalidError:
return None, await evt.reply("Invalid invite link.")
except InviteHashExpiredError:
return None, await evt.reply("Invite link expired.")
try:
return (await evt.sender.client(ImportChatInviteRequest(invite_hash))), None
return (await evt.sender.client(ImportChatInviteRequest(identifier))), None
except UserAlreadyParticipantError:
return None, await evt.reply("You are already in that chat.")
except InviteRequestSentError:
return None, await evt.reply("Invite request sent successfully.")
else:
channel = await evt.sender.client.get_entity(arg)
channel = await evt.sender.client.get_entity(identifier)
if not channel:
return None, await evt.reply("Channel/supergroup not found.")
return await evt.sender.client(JoinChannelRequest(channel)), None
@command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="<_link_>",
help_text="Join a chat with an invite link.")
async def join(evt: CommandEvent) -> Optional[Dict]:
@command_handler(
help_section=SECTION_CREATING_PORTALS,
help_args="<_link_>",
help_text="Join a chat with an invite link.",
)
async def join(evt: CommandEvent) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
arg = regex.match(evt.args[0])
url = evt.args[0]
if evt.config["bridge.invite_link_resolve"]:
try:
async with ClientSession() as sess, sess.get(url) as resp:
url = str(resp.url)
except InvalidURL:
return await evt.reply("That doesn't look like a Telegram invite link.")
regex = re.compile(
r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:/(?P<type>joinchat|s))?/(?P<id>[^/]+)/?",
flags=re.IGNORECASE,
)
arg = regex.match(url)
if not arg:
return await evt.reply("That doesn't look like a Telegram invite link.")
updates, _ = await _join(evt, arg.group(1))
data = arg.groupdict()
identifier = data["id"]
link_type = data["type"]
if link_type:
link_type = link_type.lower()
elif identifier.startswith("+"):
link_type = "joinchat"
identifier = identifier[1:]
updates, _ = await _join(evt, identifier, link_type)
if not updates:
return None
for chat in updates.chats:
portal = po.Portal.get_by_entity(chat)
portal = await po.Portal.get_by_entity(chat)
if portal.mxid:
await portal.invite_to_matrix([evt.sender.mxid])
return await evt.reply(f"Invited you to portal of {portal.title}")
else:
await evt.reply(f"Creating room for {chat.title}... This might take a while.")
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
return await evt.reply(f"Created room for {portal.title}")
try:
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
except ChatIdInvalidError as e:
evt.log.trace(
"ChatIdInvalidError while creating portal from !tg join command: %s",
updates.stringify(),
)
raise e
if portal.mxid:
return await evt.reply(f"Created room for {portal.title}")
else:
return await evt.reply(f"Couldn't create room for {portal.title}")
return None
@command_handler(help_section=SECTION_MISC,
help_args="[`chats`|`contacts`|`me`]",
help_text="Synchronize your chat portals, contacts and/or own info.")
async def sync(evt: CommandEvent) -> Optional[Dict]:
@command_handler(
help_section=SECTION_MISC,
help_args="[`chats`|`contacts`|`me`]",
help_text="Synchronize your chat portals, contacts and/or own info.",
)
async def sync(evt: CommandEvent) -> EventID:
if len(evt.args) > 0:
sync_only = evt.args[0]
if sync_only not in ("chats", "contacts", "me"):
@@ -159,8 +329,10 @@ async def sync(evt: CommandEvent) -> Optional[Dict]:
sync_only = None
if not sync_only or sync_only == "chats":
await evt.sender.sync_dialogs(synchronous_create=True)
await evt.reply("Synchronizing chats...")
await evt.sender.sync_dialogs()
if not sync_only or sync_only == "contacts":
await evt.reply("Synchronizing contacts...")
await evt.sender.sync_contacts()
if not sync_only or sync_only == "me":
await evt.sender.update_info()
@@ -176,8 +348,9 @@ class MessageIDError(ValueError):
self.message = message
async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
) -> Tuple[TypePeer, Message]:
async def _parse_encoded_msgid(
user: AbstractUser, enc_id: str, type_name: str
) -> tuple[TypeInputPeer, Message]:
try:
enc_id += (4 - len(enc_id) % 4) * "="
enc_id = base64.b64decode(enc_id)
@@ -191,10 +364,10 @@ async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
raise MessageIDError(f"Invalid {type_name} ID (format)") from e
if peer_type == PEER_TYPE_CHAT:
orig_msg = DBMessage.get_by_tgid(msg_id, space)
orig_msg = await DBMessage.get_one_by_tgid(msg_id, space)
if not orig_msg:
raise MessageIDError(f"Invalid {type_name} ID (original message not found in db)")
new_msg = DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
new_msg = await DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
if not new_msg:
raise MessageIDError(f"Invalid {type_name} ID (your copy of message not found in db)")
msg_id = new_msg.tgid
@@ -206,13 +379,13 @@ async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
msg = await user.client.get_messages(entity=peer, ids=msg_id)
if not msg:
raise MessageIDError(f"Invalid {type_name} ID (message not found)")
return peer, msg
return peer, cast(Message, msg)
@command_handler(help_section=SECTION_MISC,
help_args="<_play ID_>",
help_text="Play a Telegram game.")
async def play(evt: CommandEvent) -> Optional[Dict]:
@command_handler(
help_section=SECTION_MISC, help_args="<_play ID_>", help_text="Play a Telegram game."
)
async def play(evt: CommandEvent) -> EventID:
if len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp play <play ID>`")
elif not await evt.sender.is_logged_in():
@@ -228,20 +401,26 @@ async def play(evt: CommandEvent) -> Optional[Dict]:
if not isinstance(msg.media, MessageMediaGame):
return await evt.reply("Invalid play ID (message doesn't look like a game)")
game = await evt.sender.client(GetBotCallbackAnswerRequest(peer=peer, msg_id=msg.id, game=True))
game = await evt.sender.client(
GetBotCallbackAnswerRequest(peer=peer, msg_id=msg.id, game=True)
)
if not isinstance(game, BotCallbackAnswer):
return await evt.reply("Game request response invalid")
await evt.reply(f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
f"{msg.media.game.description}")
return await evt.reply(
f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
f"{msg.media.game.description}"
)
@command_handler(help_section=SECTION_MISC,
help_args="<_poll ID_> <_choice ID_>",
help_text="Vote in a Telegram poll.")
async def vote(evt: CommandEvent) -> Optional[Dict]:
if len(evt.args) < 2:
return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice ID>`")
@command_handler(
help_section=SECTION_MISC,
help_args="<_poll ID_> <_choice number_>",
help_text="Vote in a Telegram poll.",
)
async def vote(evt: CommandEvent) -> EventID | None:
if len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice number>`")
elif not await evt.sender.is_logged_in():
return await evt.reply("You must be logged in with a real account to vote in polls.")
elif evt.sender.is_bot:
@@ -255,11 +434,83 @@ async def vote(evt: CommandEvent) -> Optional[Dict]:
if not isinstance(msg.media, MessageMediaPoll):
return await evt.reply("Invalid poll ID (message doesn't look like a poll)")
options = [base64.b64decode(option + (3 - (len(option) + 3) % 4) * "=")
for option in evt.args[1:]]
options = []
for option in evt.args[1:]:
try:
if len(option) > 10:
raise ValueError("option index too long")
option_index = int(option) - 1
except ValueError:
option_index = None
if option_index is None:
return await evt.reply(
f'Invalid option number "{option}"', render_markdown=False, allow_html=False
)
elif option_index < 0:
return await evt.reply(
f"Invalid option number {option}. Option numbers must be positive."
)
elif option_index >= len(msg.media.poll.answers):
return await evt.reply(
f"Invalid option number {option}. "
f"The poll only has {len(msg.media.poll.answers)} options."
)
options.append(msg.media.poll.answers[option_index].option)
options = [msg.media.poll.answers[int(option) - 1].option for option in evt.args[1:]]
try:
resp = await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
except OptionsTooMuchError:
return await evt.reply("You passed too many options.")
# TODO use response
return await evt.mark_read()
@command_handler(
help_section=SECTION_MISC,
help_args="<_emoji_>",
help_text="Roll a dice (\U0001F3B2), kick a football (\u26BD\uFE0F) or throw a "
"dart (\U0001F3AF) or basketball (\U0001F3C0) on the Telegram servers.",
)
async def random(evt: CommandEvent) -> EventID:
if not evt.is_portal:
return await evt.reply("You can only randomize values in portal rooms")
portal = await po.Portal.get_by_mxid(evt.room_id)
arg = evt.args[0] if len(evt.args) > 0 else "dice"
emoticon = {
"dart": "\U0001F3AF",
"dice": "\U0001F3B2",
"ball": "\U0001F3C0",
"basketball": "\U0001F3C0",
"football": "\u26BD",
"soccer": "\u26BD",
}.get(arg, arg)
try:
await evt.sender.client.send_media(
await portal.get_input_entity(evt.sender), InputMediaDice(emoticon)
)
except EmoticonInvalidError:
return await evt.reply("Invalid emoji for randomization")
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_args="[_limit_]",
help_text="Backfill messages from Telegram history.",
)
async def backfill(evt: CommandEvent) -> None:
if not evt.is_portal:
await evt.reply("You can only use backfill in portal rooms")
return
elif not evt.config["bridge.backfill.enable"]:
await evt.reply("Backfilling is disabled in the bridge config")
return
try:
limit = int(evt.args[0])
except (ValueError, IndexError):
limit = -1
portal = await po.Portal.get_by_mxid(evt.room_id)
if not evt.config["bridge.backfill.normal_groups"] and portal.peer_type == "chat":
await evt.reply("Backfilling normal groups is disabled in the bridge config")
return
output = await portal.forward_backfill(evt.sender, initial=False, override_limit=limit)
await evt.reply(output)
+194 -215
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,157 +13,62 @@
#
# 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, Dict, Optional, Tuple
from ruamel.yaml import YAML
from typing import Any, List, NamedTuple
import os
from ruamel.yaml.comments import CommentedMap
import random
import string
yaml = YAML() # type: YAML
yaml.indent(4)
from mautrix.bridge.config import BaseBridgeConfig
from mautrix.client import Client
from mautrix.types import UserID
from mautrix.util.config import ConfigUpdateHelper, ForbiddenDefault, ForbiddenKey
Permissions = NamedTuple(
"Permissions",
relaybot=bool,
user=bool,
puppeting=bool,
matrix_puppeting=bool,
admin=bool,
level=str,
)
class DictWithRecursion:
def __init__(self, data: Optional[CommentedMap] = None) -> None:
self._data = data or CommentedMap() # type: CommentedMap
class Config(BaseBridgeConfig):
@property
def forbidden_defaults(self) -> List[ForbiddenDefault]:
return [
*super().forbidden_defaults,
ForbiddenDefault(
"appservice.database",
"postgres://username:password@hostname/dbname",
),
ForbiddenDefault(
"appservice.public.external",
"https://example.com/public",
condition="appservice.public.enabled",
),
ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
ForbiddenDefault("telegram.api_id", 12345),
ForbiddenDefault("telegram.api_hash", "tjyd5yge35lbodk1xwzw2jstp90k55qz"),
]
@staticmethod
def _parse_key(key: str) -> Tuple[str, Optional[str]]:
if '.' not in key:
return key, None
key, next_key = key.split('.', 1)
if len(key) > 0 and key[0] == "[":
end_index = next_key.index("]")
key = key[1:] + "." + next_key[:end_index]
next_key = next_key[end_index + 2:] if len(next_key) > end_index + 1 else None
return key, next_key
def _recursive_get(self, data: CommentedMap, key: str, default_value: Any) -> Any:
key, next_key = self._parse_key(key)
if next_key is not None:
next_data = data.get(key, CommentedMap())
return self._recursive_get(next_data, next_key, default_value)
return data.get(key, default_value)
def get(self, key: str, default_value: Any, allow_recursion: bool = True) -> Any:
if allow_recursion and '.' in key:
return self._recursive_get(self._data, key, default_value)
return self._data.get(key, default_value)
def __getitem__(self, key: str) -> Any:
return self.get(key, None)
def __contains__(self, key: str) -> bool:
return self[key] is not None
def _recursive_set(self, data: CommentedMap, key: str, value: Any) -> None:
key, next_key = self._parse_key(key)
if next_key is not None:
if key not in data:
data[key] = CommentedMap()
next_data = data.get(key, CommentedMap())
return self._recursive_set(next_data, next_key, value)
data[key] = value
def set(self, key: str, value: Any, allow_recursion: bool = True) -> None:
if allow_recursion and '.' in key:
self._recursive_set(self._data, key, value)
return
self._data[key] = value
def __setitem__(self, key: str, value: Any) -> None:
self.set(key, value)
def _recursive_del(self, data: CommentedMap, key: str) -> None:
key, next_key = self._parse_key(key)
if next_key is not None:
if key not in data:
return
next_data = data[key]
return self._recursive_del(next_data, next_key)
try:
del data[key]
del data.ca.items[key]
except KeyError:
pass
def delete(self, key: str, allow_recursion: bool = True) -> None:
if allow_recursion and '.' in key:
self._recursive_del(self._data, key)
return
try:
del self._data[key]
del self._data.ca.items[key]
except KeyError:
pass
def __delitem__(self, key: str) -> None:
self.delete(key)
class Config(DictWithRecursion):
def __init__(self, path: str, registration_path: str, base_path: str) -> None:
super().__init__()
self.path = path # type: str
self.registration_path = registration_path # type: str
self.base_path = base_path # type: str
self._registration = None # type: Optional[Dict]
def load(self) -> None:
with open(self.path, 'r') as stream:
self._data = yaml.load(stream)
def load_base(self) -> Optional[DictWithRecursion]:
try:
with open(self.base_path, 'r') as stream:
return DictWithRecursion(yaml.load(stream))
except OSError:
pass
return None
def save(self) -> None:
with open(self.path, 'w') as stream:
yaml.dump(self._data, stream)
if self._registration and self.registration_path:
with open(self.registration_path, 'w') as stream:
yaml.dump(self._registration, stream)
@staticmethod
def _new_token() -> str:
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
def update(self) -> None:
base = self.load_base()
if not base:
return
def copy(from_path, to_path=None) -> None:
if from_path in self:
base[to_path or from_path] = self[from_path]
def copy_dict(from_path, to_path=None, override_existing_map=True) -> None:
if from_path in self:
to_path = to_path or from_path
if override_existing_map or to_path not in base:
base[to_path] = CommentedMap()
for key, value in self[from_path].items():
base[to_path][key] = value
copy("homeserver.address")
copy("homeserver.domain")
copy("homeserver.verify_ssl")
def do_update(self, helper: ConfigUpdateHelper) -> None:
super().do_update(helper)
copy, copy_dict, base = helper
if "appservice.protocol" in self and "appservice.address" not in self:
protocol, hostname, port = (self["appservice.protocol"], self["appservice.hostname"],
self["appservice.port"])
protocol, hostname, port = (
self["appservice.protocol"],
self["appservice.hostname"],
self["appservice.port"],
)
base["appservice.address"] = f"{protocol}://{hostname}:{port}"
else:
copy("appservice.address")
copy("appservice.hostname")
copy("appservice.port")
copy("appservice.max_body_size")
copy("appservice.database")
if "appservice.debug" in self and "logging" not in self:
level = "DEBUG" if self["appservice.debug"] else "INFO"
base["logging.root.level"] = level
base["logging.loggers.mau.level"] = level
base["logging.loggers.telethon.level"] = level
copy("appservice.public.enabled")
copy("appservice.public.prefix")
@@ -172,57 +76,146 @@ class Config(DictWithRecursion):
copy("appservice.provisioning.enabled")
copy("appservice.provisioning.prefix")
if base["appservice.provisioning.prefix"].endswith("/v1"):
base["appservice.provisioning.prefix"] = base["appservice.provisioning.prefix"][
: -len("/v1")
]
copy("appservice.provisioning.shared_secret")
if base["appservice.provisioning.shared_secret"] == "generate":
base["appservice.provisioning.shared_secret"] = self._new_token()
copy("appservice.id")
copy("appservice.bot_username")
copy("appservice.bot_displayname")
copy("appservice.bot_avatar")
if "pool_size" in base["appservice.database_opts"]:
pool_size = base["appservice.database_opts"].pop("pool_size")
base["appservice.database_opts.min_size"] = pool_size
base["appservice.database_opts.max_size"] = pool_size
if "pool_pre_ping" in base["appservice.database_opts"]:
del base["appservice.database_opts.pool_pre_ping"]
copy("appservice.as_token")
copy("appservice.hs_token")
copy("metrics.enabled")
copy("metrics.listen_port")
copy("bridge.username_template")
copy("bridge.alias_template")
copy("bridge.displayname_template")
copy("bridge.displayname_preference")
copy("bridge.displayname_max_length")
copy("bridge.allow_avatar_remove")
copy("bridge.allow_contact_info")
copy("bridge.max_initial_member_sync")
copy("bridge.max_member_count")
copy("bridge.sync_channel_members")
copy("bridge.skip_deleted_members")
copy("bridge.startup_sync")
copy("bridge.sync_dialog_limit")
if "bridge.sync_dialog_limit" in self:
base["bridge.sync_create_limit"] = self["bridge.sync_dialog_limit"]
base["bridge.sync_update_limit"] = self["bridge.sync_dialog_limit"]
else:
copy("bridge.sync_update_limit")
copy("bridge.sync_create_limit")
copy("bridge.sync_deferred_create_all")
copy("bridge.sync_direct_chats")
copy("bridge.max_telegram_delete")
copy("bridge.sync_matrix_state")
copy("bridge.allow_matrix_login")
copy("bridge.plaintext_highlights")
copy("bridge.edits_as_replies")
copy("bridge.highlight_edits")
copy("bridge.public_portals")
copy("bridge.catch_up")
copy("bridge.sync_with_custom_puppets")
copy("bridge.sync_direct_chat_list")
copy("bridge.double_puppet_server_map")
copy("bridge.double_puppet_allow_discovery")
copy("bridge.create_group_on_invite")
if "bridge.login_shared_secret" in self:
base["bridge.login_shared_secret_map"] = {
base["homeserver.domain"]: self["bridge.login_shared_secret"]
}
else:
copy("bridge.login_shared_secret_map")
copy("bridge.telegram_link_preview")
copy("bridge.inline_images")
copy("bridge.invite_link_resolve")
copy("bridge.caption_in_message")
copy("bridge.image_as_file_size")
copy("bridge.image_as_file_pixels")
copy("bridge.parallel_file_transfer")
copy("bridge.federate_rooms")
copy("bridge.always_custom_emoji_reaction")
copy("bridge.animated_sticker.target")
copy("bridge.animated_sticker.convert_from_webm")
copy("bridge.animated_sticker.args.width")
copy("bridge.animated_sticker.args.height")
copy("bridge.animated_sticker.args.fps")
copy("bridge.animated_emoji.target")
copy("bridge.animated_emoji.args.width")
copy("bridge.animated_emoji.args.height")
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")
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")
copy("bridge.message_status_events")
copy("bridge.resend_bridge_info")
copy("bridge.mute_bridging")
copy("bridge.pinned_tag")
copy("bridge.archive_tag")
copy("bridge.tag_only_on_create")
copy("bridge.bridge_matrix_leave")
copy("bridge.kick_on_logout")
copy("bridge.always_read_joined_telegram_notice")
copy("bridge.backfill.enable")
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.user")
copy("bridge.bot_messages_as_notices")
if isinstance(self["bridge.bridge_notices"], bool):
base["bridge.bridge_notices"] = {
"default": self["bridge.bridge_notices"],
"exceptions": ["@importantbot:example.com"],
}
base["bridge.bridge_notices"]["default"] = self["bridge.bridge_notices"]
else:
copy("bridge.bridge_notices")
copy("bridge.deduplication.pre_db_check")
copy("bridge.deduplication.cache_queue_length")
copy("bridge.bridge_notices.default")
copy("bridge.bridge_notices.exceptions")
if "bridge.message_formats.m_text" in self:
del self["bridge.message_formats"]
copy_dict("bridge.message_formats", override_existing_map=False)
copy("bridge.emote_format")
copy("bridge.relay_user_distinguishers")
copy("bridge.state_event_formats.join")
copy("bridge.state_event_formats.leave")
@@ -230,12 +223,15 @@ class Config(DictWithRecursion):
copy("bridge.filter.mode")
copy("bridge.filter.list")
copy("bridge.filter.users")
copy("bridge.command_prefix")
migrate_permissions = ("bridge.permissions" not in self
or "bridge.whitelist" in self
or "bridge.admins" in self)
migrate_permissions = (
"bridge.permissions" not in self
or "bridge.whitelist" in self
or "bridge.admins" in self
)
if migrate_permissions:
permissions = self["bridge.permissions"] or CommentedMap()
for entry in self["bridge.whitelist"] or []:
@@ -244,11 +240,16 @@ class Config(DictWithRecursion):
permissions[entry] = "admin"
base["bridge.permissions"] = permissions
else:
copy_dict("bridge.permissions")
copy_dict("bridge.permissions", override_existing_map=True)
if "bridge.relaybot" not in self:
copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals")
else:
copy("bridge.relaybot.private_chat.invite")
copy("bridge.relaybot.private_chat.state_changes")
copy("bridge.relaybot.private_chat.message")
copy("bridge.relaybot.group_chat_invite")
copy("bridge.relaybot.ignore_unbridged_group_chat")
copy("bridge.relaybot.authless_portals")
copy("bridge.relaybot.whitelist_group_admins")
copy("bridge.relaybot.whitelist")
@@ -257,10 +258,29 @@ class Config(DictWithRecursion):
copy("telegram.api_id")
copy("telegram.api_hash")
copy("telegram.bot_token")
copy("telegram.catch_up")
copy("telegram.sequential_updates")
copy("telegram.exit_on_update_error")
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")
copy("telegram.device_info.app_version")
copy("telegram.device_info.lang_code")
copy("telegram.device_info.system_lang_code")
copy("telegram.server.enabled")
copy("telegram.server.dc")
copy("telegram.server.ip")
copy("telegram.server.port")
copy("telegram.proxy.type")
copy("telegram.proxy.address")
copy("telegram.proxy.port")
@@ -268,63 +288,22 @@ class Config(DictWithRecursion):
copy("telegram.proxy.username")
copy("telegram.proxy.password")
if "appservice.debug" in self and "logging" not in self:
level = "DEBUG" if self["appservice.debug"] else "INFO"
base["logging.root.level"] = level
base["logging.loggers.mau.level"] = level
base["logging.loggers.telethon.level"] = level
else:
copy("logging")
self._data = base._data
self.save()
def _get_permissions(self, key: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
def _get_permissions(self, key: str) -> Permissions:
level = self["bridge.permissions"].get(key, "")
admin = level == "admin"
matrix_puppeting = level == "full" or admin
puppeting = level == "puppeting" or matrix_puppeting
user = level == "user" or puppeting
relaybot = level == "relaybot" or user
return relaybot, user, puppeting, matrix_puppeting, admin, level
return Permissions(relaybot, user, puppeting, matrix_puppeting, admin, level)
def get_permissions(self, mxid: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
permissions = self["bridge.permissions"] or {}
def get_permissions(self, mxid: UserID) -> Permissions:
permissions = self["bridge.permissions"]
if mxid in permissions:
return self._get_permissions(mxid)
homeserver = mxid[mxid.index(":") + 1:]
_, homeserver = Client.parse_user_id(mxid)
if homeserver in permissions:
return self._get_permissions(homeserver)
return self._get_permissions("*")
def generate_registration(self) -> None:
homeserver = self["homeserver.domain"]
username_format = self.get("bridge.username_template", "telegram_{userid}") \
.format(userid=".+")
alias_format = self.get("bridge.alias_template", "telegram_{groupname}") \
.format(groupname=".+")
self.set("appservice.as_token", self._new_token())
self.set("appservice.hs_token", self._new_token())
self._registration = {
"id": self["appservice.id"] or "telegram",
"as_token": self["appservice.as_token"],
"hs_token": self["appservice.hs_token"],
"namespaces": {
"users": [{
"exclusive": True,
"regex": f"@{username_format}:{homeserver}"
}],
"aliases": [{
"exclusive": True,
"regex": f"#{alias_format}:{homeserver}"
}]
},
"url": self["appservice.address"],
"sender_localpart": self["appservice.bot_username"],
"rate_limited": False
}
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
import asyncio
from alchemysession import AlchemySessionContainer
from mautrix_appservice import AppService
from .web import PublicBridgeWebsite, ProvisioningAPI
from .config import Config
from .bot import Bot
from .matrix import MatrixHandler
class Context:
def __init__(self, az: 'AppService', config: 'Config', loop: 'asyncio.AbstractEventLoop',
session_container: 'AlchemySessionContainer', bot: Optional['Bot']) -> None:
self.az = az # type: AppService
self.config = config # type: Config
self.loop = loop # type: asyncio.AbstractEventLoop
self.bot = bot # type: Optional[Bot]
self.mx = None # type: Optional[MatrixHandler]
self.session_container = session_container # type: AlchemySessionContainer
self.public_website = None # type: Optional[PublicBridgeWebsite]
self.provisioning_api = None # type: Optional[ProvisioningAPI]
@property
def core(self) -> Tuple['AppService', 'Config', 'asyncio.AbstractEventLoop', Optional['Bot']]:
return self.az, self.config, self.loop, self.bot
+39 -12
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,20 +13,48 @@
#
# 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 .base import Base
from mautrix.util.async_db import Database
from .backfill_queue import Backfill, BackfillType
from .bot_chat import BotChat
from .disappearing_message import DisappearingMessage
from .message import Message
from .portal import Portal
from .puppet import Puppet
from .room_state import RoomState
from .reaction import Reaction
from .telegram_file import TelegramFile
from .user import User, UserPortal, Contact
from .user_profile import UserProfile
from .telethon_session import PgSession
from .upgrade import upgrade_table
from .user import User
def init(db_engine) -> None:
for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile,
RoomState, BotChat):
table.db = db_engine
table.t = table.__table__
table.c = table.t.c
def init(db: Database) -> None:
for table in (
Portal,
Message,
Reaction,
User,
Puppet,
TelegramFile,
BotChat,
PgSession,
DisappearingMessage,
Backfill,
):
table.db = db
__all__ = [
"upgrade_table",
"init",
"Portal",
"Message",
"Reaction",
"User",
"Puppet",
"TelegramFile",
"BotChat",
"PgSession",
"DisappearingMessage",
"Backfill",
]
+235
View File
@@ -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)
-58
View File
@@ -1,58 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from abc import abstractmethod
from sqlalchemy import Table
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.ext.declarative import declarative_base
class BaseBase:
db = None # type: Engine
t = None # type: Table
__table__ = None # type: Table
c = None # type: ImmutableColumnCollection
@classmethod
@abstractmethod
def _one_or_none(cls, rows: RowProxy):
pass
@classmethod
def _select_one_or_none(cls, *args):
return cls._one_or_none(cls.db.execute(cls.t.select().where(*args)))
@property
@abstractmethod
def _edit_identity(self):
pass
def update(self, **values) -> None:
with self.db.begin() as conn:
conn.execute(self.t.update()
.where(self._edit_identity)
.values(**values))
for key, value in values.items():
setattr(self, key, value)
def delete(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.delete().where(self._edit_identity))
Base = declarative_base(cls=BaseBase)
-26
View File
@@ -1,26 +0,0 @@
from abc import abstractmethod
from sqlalchemy import Table
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.ext.declarative import declarative_base
class Base(declarative_base):
db: Engine
t: Table
__table__: Table
c: ImmutableColumnCollection
@classmethod
@abstractmethod
def _one_or_none(cls, rows: RowProxy): ...
@classmethod
def _select_one_or_none(cls, *args): ...
def _edit_identity(self): ...
def update(self, **values) -> None: ...
def delete(self) -> None: ...
+30 -20
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,32 +13,43 @@
#
# 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 Iterable
from __future__ import annotations
from sqlalchemy import Column, Integer, String
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
from mautrix.util.async_db import Database
from ..types import TelegramID
from .base import Base
fake_db = Database.create("") if TYPE_CHECKING else None
# Fucking Telegram not telling bots what chats they are in 3:<
class BotChat(Base):
__tablename__ = "bot_chat"
id = Column(Integer, primary_key=True) # type: TelegramID
type = Column(String, nullable=False)
@dataclass
class BotChat:
db: ClassVar[Database] = fake_db
id: TelegramID
type: str
@classmethod
def delete(cls, chat_id: TelegramID) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.id == chat_id))
def _from_row(cls, row: Record | None) -> BotChat | None:
if row is None:
return None
return cls(**row)
@classmethod
def all(cls) -> Iterable['BotChat']:
rows = cls.db.execute(cls.t.select())
for row in rows:
chat_id, chat_type = row
yield cls(id=chat_id, type=chat_type)
async def delete_by_id(cls, chat_id: TelegramID) -> None:
await cls.db.execute("DELETE FROM bot_chat WHERE id=$1", chat_id)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(id=self.id, type=self.type))
@classmethod
async def all(cls) -> list[BotChat]:
rows = await cls.db.fetch("SELECT id, type FROM bot_chat")
return [cls._from_row(row) for row in rows]
async def insert(self) -> None:
q = "INSERT INTO bot_chat (id, type) VALUES ($1, $2)"
await self.db.execute(q, self.id, self.type)
@@ -0,0 +1,78 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Sumner Evans
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import asyncpg
from mautrix.bridge import AbstractDisappearingMessage
from mautrix.types import EventID, RoomID
from mautrix.util.async_db import Database
fake_db = Database.create("") if TYPE_CHECKING else None
class DisappearingMessage(AbstractDisappearingMessage):
db: ClassVar[Database] = fake_db
async def insert(self) -> None:
q = """
INSERT INTO disappearing_message (room_id, event_id, expiration_seconds, expiration_ts)
VALUES ($1, $2, $3, $4)
"""
await self.db.execute(
q, self.room_id, self.event_id, self.expiration_seconds, self.expiration_ts
)
async def update(self) -> None:
q = "UPDATE disappearing_message SET expiration_ts=$3 WHERE room_id=$1 AND event_id=$2"
await self.db.execute(q, self.room_id, self.event_id, self.expiration_ts)
async def delete(self) -> None:
q = "DELETE from disappearing_message WHERE room_id=$1 AND event_id=$2"
await self.db.execute(q, self.room_id, self.event_id)
@classmethod
def _from_row(cls, row: asyncpg.Record) -> DisappearingMessage:
return cls(**row)
@classmethod
async def get(cls, room_id: RoomID, event_id: EventID) -> DisappearingMessage | None:
q = """
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
WHERE room_id=$1 AND mxid=$2
"""
try:
return cls._from_row(await cls.db.fetchrow(q, room_id, event_id))
except Exception:
return None
@classmethod
async def get_all_scheduled(cls) -> list[DisappearingMessage]:
q = """
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
WHERE expiration_ts IS NOT NULL
"""
return [cls._from_row(r) for r in await cls.db.fetch(q)]
@classmethod
async def get_unscheduled_for_room(cls, room_id: RoomID) -> list[DisappearingMessage]:
q = """
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
WHERE room_id = $1 AND expiration_ts IS NULL
"""
return [cls._from_row(r) for r in await cls.db.fetch(q, room_id)]
+191 -55
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,77 +13,214 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, select
from sqlalchemy.engine.result import RowProxy
from typing import Optional, List
from __future__ import annotations
from ..types import MatrixRoomID, MatrixEventID, TelegramID
from .base import Base
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
import attr
from mautrix.types import EventID, RoomID, UserID
from mautrix.util.async_db import Database, Scheme
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
class Message(Base):
__tablename__ = "message"
@dataclass
class Message:
db: ClassVar[Database] = fake_db
mxid = Column(String) # type: MatrixEventID
mx_room = Column(String) # type: MatrixRoomID
tgid = Column(Integer, primary_key=True) # type: TelegramID
tg_space = Column(Integer, primary_key=True) # type: TelegramID
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
mxid: EventID
mx_room: RoomID
tgid: TelegramID
tg_space: TelegramID
edit_index: int
redacted: bool = False
content_hash: bytes | None = None
sender_mxid: UserID | None = None
sender: TelegramID | None = None
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Message']:
try:
mxid, mx_room, tgid, tg_space = next(rows)
return cls(mxid=mxid, mx_room=mx_room, tgid=tgid, tg_space=tg_space)
except StopIteration:
def _from_row(cls, row: Record | None) -> Message | None:
if row is None:
return None
return cls(**row)
@staticmethod
def _all(rows: RowProxy) -> List['Message']:
return [Message(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3])
for row in rows]
columns: ClassVar[str] = ", ".join(
(
"mxid",
"mx_room",
"tgid",
"tg_space",
"edit_index",
"redacted",
"content_hash",
"sender_mxid",
"sender",
)
)
@classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> Optional['Message']:
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space))
async def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> list[Message]:
q = f"SELECT {cls.columns} FROM message WHERE tgid=$1 AND tg_space=$2"
rows = await cls.db.fetch(q, tgid, tg_space)
return [cls._from_row(row) for row in rows]
@classmethod
def count_spaces_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID) -> int:
rows = cls.db.execute(select([func.count(cls.c.tg_space)])
.where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room)))
try:
count, = next(rows)
return count
except StopIteration:
return 0
async def get_one_by_tgid(
cls, tgid: TelegramID, tg_space: TelegramID, edit_index: int = 0
) -> Message | None:
if edit_index < 0:
q = (
f"SELECT {cls.columns} FROM message WHERE tgid=$1 AND tg_space=$2 "
f"ORDER BY edit_index DESC LIMIT 1 OFFSET {-edit_index - 1}"
)
row = await cls.db.fetchrow(q, tgid, tg_space)
else:
q = (
f"SELECT {cls.columns} FROM message"
" WHERE tgid=$1 AND tg_space=$2 AND edit_index=$3"
)
row = await cls.db.fetchrow(q, tgid, tg_space, edit_index)
return cls._from_row(row)
@classmethod
def get_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID, tg_space: TelegramID
) -> Optional['Message']:
return cls._select_one_or_none(and_(cls.c.mxid == mxid,
cls.c.mx_room == mx_room,
cls.c.tg_space == tg_space))
async def get_first_by_tgids(
cls, tgids: list[TelegramID], tg_space: TelegramID
) -> list[Message]:
if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
q = (
f"SELECT {cls.columns} FROM message"
" WHERE tgid=ANY($1) AND tg_space=$2 AND edit_index=0"
)
rows = await cls.db.fetch(q, tgids, tg_space)
else:
tgid_placeholders = ("?," * len(tgids)).rstrip(",")
q = (
f"SELECT {cls.columns} FROM message "
f"WHERE tg_space=? AND edit_index=0 AND tgid IN ({tgid_placeholders})"
)
rows = await cls.db.fetch(q, tg_space, *tgids)
return [cls._from_row(row) for row in rows]
@classmethod
def update_by_tgid(cls, s_tgid: TelegramID, s_tg_space: TelegramID, **values) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.update()
.where(and_(cls.c.tgid == s_tgid, cls.c.tg_space == s_tg_space))
.values(**values))
async def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> int:
return (
await cls.db.fetchval(
"SELECT COUNT(tg_space) FROM message WHERE mxid=$1 AND mx_room=$2", mxid, mx_room
)
or 0
)
@classmethod
def update_by_mxid(cls, s_mxid: MatrixEventID, s_mx_room: MatrixRoomID, **values) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.update()
.where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room))
.values(**values))
async def find_last(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 DESC LIMIT 1"
)
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
async def delete_all(cls, mx_room: RoomID) -> None:
await cls.db.execute("DELETE FROM message WHERE mx_room=$1", mx_room)
@classmethod
async def get_by_mxid(
cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
) -> Message | None:
q = f"SELECT {cls.columns} FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room, tg_space))
@classmethod
async def get_by_mxids(
cls, mxids: list[EventID], mx_room: RoomID, tg_space: TelegramID
) -> list[Message]:
if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
q = (
f"SELECT {cls.columns} FROM message"
" WHERE mxid=ANY($1) AND mx_room=$2 AND tg_space=$3"
)
rows = await cls.db.fetch(q, mxids, mx_room, tg_space)
else:
mxid_placeholders = ("?," * len(mxids)).rstrip(",")
q = (
f"SELECT {cls.columns} FROM message "
f"WHERE mx_room=? AND tg_space=? AND mxid IN ({mxid_placeholders})"
)
rows = await cls.db.fetch(q, mx_room, tg_space, *mxids)
return [cls._from_row(row) for row in rows]
@classmethod
async def 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
async def replace_temp_mxid(cls, temp_mxid: str, mx_room: RoomID, real_mxid: EventID) -> None:
q = "UPDATE message SET mxid=$1 WHERE mxid=$2 AND mx_room=$3"
await cls.db.execute(q, real_mxid, temp_mxid, mx_room)
@classmethod
async def delete_temp_mxid(cls, temp_mxid: str, mx_room: RoomID) -> None:
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2"
await cls.db.execute(q, temp_mxid, mx_room)
@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
def _edit_identity(self):
return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space)
def _values(self):
return (
self.mxid,
self.mx_room,
self.tgid,
self.tg_space,
self.edit_index,
self.redacted,
self.content_hash,
self.sender_mxid,
self.sender,
)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(mxid=self.mxid, mx_room=self.mx_room,
tgid=self.tgid, tg_space=self.tg_space))
async def insert(self) -> None:
await self.db.execute(self._insert_query, *self._values)
async def delete(self) -> None:
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
await self.db.execute(q, self.mxid, self.mx_room, self.tg_space)
async def mark_redacted(self) -> None:
self.redacted = True
q = "UPDATE message SET redacted=true WHERE mxid=$1 AND mx_room=$2"
await self.db.execute(q, self.mxid, self.mx_room)
+156 -45
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,68 +13,180 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String, Boolean, Text, and_
from sqlalchemy.engine.result import RowProxy
from typing import Optional
from __future__ import annotations
from ..types import MatrixRoomID, TelegramID
from .base import Base
from typing import TYPE_CHECKING, Any, ClassVar
import json
from asyncpg import Record
from attr import dataclass
import attr
from mautrix.types import BatchID, ContentURI, EventID, RoomID
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
class Portal(Base):
__tablename__ = "portal"
@dataclass
class Portal:
db: ClassVar[Database] = fake_db
# Telegram chat information
tgid = Column(Integer, primary_key=True) # type: TelegramID
tg_receiver = Column(Integer, primary_key=True) # type: TelegramID
peer_type = Column(String, nullable=False)
megagroup = Column(Boolean)
tgid: TelegramID
tg_receiver: TelegramID
peer_type: str
megagroup: bool
# Matrix portal information
mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID]
mxid: RoomID | None
avatar_url: ContentURI | None
encrypted: bool
first_event_id: EventID | None
next_batch_id: BatchID | None
base_insertion_id: EventID | None
config = Column(Text, nullable=True)
sponsored_event_id: EventID | None
sponsored_event_ts: int | None
sponsored_msg_random_id: bytes | None
# Telegram chat metadata
username = Column(String, nullable=True)
title = Column(String, nullable=True)
about = Column(String, nullable=True)
photo_id = Column(String, nullable=True)
username: str | None
title: str | None
about: str | None
photo_id: str | None
name_set: bool
avatar_set: bool
local_config: dict[str, Any] = attr.ib(factory=lambda: {})
@classmethod
def scan(cls, row) -> Optional['Portal']:
(tgid, tg_receiver, peer_type, megagroup, mxid, config, username, title, about,
photo_id) = row
return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup,
mxid=mxid, config=config, username=username, title=title, about=about,
photo_id=photo_id)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Portal']:
try:
return cls.scan(next(rows))
except StopIteration:
def _from_row(cls, row: Record | None) -> Portal | None:
if row is None:
return None
data = {**row}
data["local_config"] = json.loads(data.pop("config", None) or "{}")
return cls(**data)
columns: ClassVar[str] = ", ".join(
(
"tgid",
"tg_receiver",
"peer_type",
"megagroup",
"mxid",
"avatar_url",
"encrypted",
"first_event_id",
"next_batch_id",
"base_insertion_id",
"sponsored_event_id",
"sponsored_event_ts",
"sponsored_msg_random_id",
"username",
"title",
"about",
"photo_id",
"name_set",
"avatar_set",
"config",
)
)
@classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']:
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver))
async def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Portal | None:
q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND tg_receiver=$2"
return cls._from_row(await cls.db.fetchrow(q, tgid, tg_receiver))
@classmethod
def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.mxid == mxid)
async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
q = f"SELECT {cls.columns} FROM portal WHERE mxid=$1"
return cls._from_row(await cls.db.fetchrow(q, mxid))
@classmethod
def get_by_username(cls, username: str) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.username == username)
async def find_by_username(cls, username: str) -> Portal | None:
q = f"SELECT {cls.columns} FROM portal WHERE lower(username)=$1"
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
@classmethod
async def find_private_chats_of(cls, tg_receiver: TelegramID) -> list[Portal]:
q = f"SELECT {cls.columns} FROM portal WHERE tg_receiver=$1 AND peer_type='user'"
return [cls._from_row(row) for row in await cls.db.fetch(q, tg_receiver)]
@classmethod
async def find_private_chats_with(cls, tgid: TelegramID) -> list[Portal]:
q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND peer_type='user'"
return [cls._from_row(row) for row in await cls.db.fetch(q, tgid)]
@classmethod
async def all(cls) -> list[Portal]:
rows = await cls.db.fetch(f"SELECT {cls.columns} FROM portal")
return [cls._from_row(row) for row in rows]
@property
def _edit_identity(self):
return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver)
def _values(self):
return (
self.tgid,
self.tg_receiver,
self.peer_type,
self.mxid,
self.avatar_url,
self.encrypted,
self.first_event_id,
self.next_batch_id,
self.base_insertion_id,
self.sponsored_event_id,
self.sponsored_event_ts,
self.sponsored_msg_random_id,
self.username,
self.title,
self.about,
self.photo_id,
self.name_set,
self.avatar_set,
self.megagroup,
json.dumps(self.local_config) if self.local_config else None,
)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
megagroup=self.megagroup, mxid=self.mxid, config=self.config,
username=self.username, title=self.title, about=self.about, photo_id=self.photo_id))
async def save(self) -> None:
q = """
UPDATE portal
SET mxid=$4, avatar_url=$5, encrypted=$6,
first_event_id=$7, next_batch_id=$8, base_insertion_id=$9,
sponsored_event_id=$10, sponsored_event_ts=$11, sponsored_msg_random_id=$12,
username=$13, title=$14, about=$15, photo_id=$16, name_set=$17, avatar_set=$18,
megagroup=$19, config=$20
WHERE tgid=$1 AND tg_receiver=$2 AND (peer_type=$3 OR true)
"""
await self.db.execute(q, *self._values)
async def update_id(self, id: TelegramID, peer_type: str) -> None:
q = (
"UPDATE portal SET tgid=$1, tg_receiver=$1, peer_type=$2 "
"WHERE tgid=$3 AND tg_receiver=$3"
)
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.tg_receiver = id
self.peer_type = peer_type
async def insert(self) -> None:
q = """
INSERT INTO portal (
tgid, tg_receiver, peer_type, mxid, avatar_url, encrypted,
first_event_id, base_insertion_id, next_batch_id,
sponsored_event_id, sponsored_event_ts, sponsored_msg_random_id,
username, title, about, photo_id, name_set, avatar_set, megagroup, config
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19, $20)
"""
await self.db.execute(q, *self._values)
async def delete(self) -> None:
q = "DELETE FROM portal WHERE tgid=$1 AND tg_receiver=$2"
await self.db.execute(q, self.tgid, self.tg_receiver)
+112 -55
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# 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
@@ -14,74 +13,132 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql import expression
from typing import Optional, Iterable
from __future__ import annotations
from ..types import MatrixUserID, MatrixRoomID, TelegramID
from .base import Base
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
from yarl import URL
from mautrix.types import ContentURI, SyncToken, UserID
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
class Puppet(Base):
__tablename__ = "puppet"
@dataclass
class Puppet:
db: ClassVar[Database] = fake_db
id = Column(Integer, primary_key=True) # type: TelegramID
custom_mxid = Column(String, nullable=True) # type: Optional[MatrixUserID]
access_token = Column(String, nullable=True)
displayname = Column(String, nullable=True)
displayname_source = Column(Integer, nullable=True) # type: Optional[TelegramID]
username = Column(String, nullable=True)
photo_id = Column(String, nullable=True)
is_bot = Column(Boolean, nullable=True)
matrix_registered = Column(Boolean, nullable=False, server_default=expression.false())
id: TelegramID
is_registered: bool
displayname: str | None
displayname_source: TelegramID | None
displayname_contact: bool
displayname_quality: int
disable_updates: bool
username: str | None
phone: str | None
photo_id: str | None
avatar_url: ContentURI | None
name_set: bool
avatar_set: bool
contact_info_set: bool
is_bot: bool | None
is_channel: bool
is_premium: bool
custom_mxid: UserID | None
access_token: str | None
next_batch: SyncToken | None
base_url: URL | None
@classmethod
def scan(cls, row) -> Optional['Puppet']:
(id, custom_mxid, access_token, displayname, displayname_source, username, photo_id,
is_bot, matrix_registered) = row
return cls(id=id, custom_mxid=custom_mxid, access_token=access_token,
displayname=displayname, displayname_source=displayname_source,
username=username, photo_id=photo_id, is_bot=is_bot,
matrix_registered=matrix_registered)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Puppet']:
try:
return cls.scan(next(rows))
except StopIteration:
def _from_row(cls, row: Record | None) -> Puppet | None:
if row is None:
return None
data = {**row}
base_url = data.pop("base_url", None)
return cls(**data, base_url=URL(base_url) if base_url else None)
columns: ClassVar[str] = (
"id, is_registered, displayname, displayname_source, displayname_contact, "
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
"name_set, avatar_set, contact_info_set, is_bot, is_channel, is_premium, "
"custom_mxid, access_token, next_batch, base_url"
)
@classmethod
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
rows = cls.db.execute(cls.t.select().where(cls.c.custom_mxid != None))
for row in rows:
yield cls.scan(row)
async def all_with_custom_mxid(cls) -> list[Puppet]:
q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid<>''"
return [cls._from_row(row) for row in await cls.db.fetch(q)]
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.id == tgid)
async def get_by_tgid(cls, tgid: TelegramID) -> Puppet | None:
q = f"SELECT {cls.columns} FROM puppet WHERE id=$1"
return cls._from_row(await cls.db.fetchrow(q, tgid))
@classmethod
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.custom_mxid == mxid)
async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid=$1"
return cls._from_row(await cls.db.fetchrow(q, mxid))
@classmethod
def get_by_username(cls, username: str) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.username == username)
@classmethod
def get_by_displayname(cls, displayname: str) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.displayname == displayname)
async def find_by_username(cls, username: str) -> Puppet | None:
q = f"SELECT {cls.columns} FROM puppet WHERE lower(username)=$1"
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
@property
def _edit_identity(self):
return self.c.id == self.id
def _values(self):
return (
self.id,
self.is_registered,
self.displayname,
self.displayname_source,
self.displayname_contact,
self.displayname_quality,
self.disable_updates,
self.username,
self.phone,
self.photo_id,
self.avatar_url,
self.name_set,
self.avatar_set,
self.contact_info_set,
self.is_bot,
self.is_channel,
self.is_premium,
self.custom_mxid,
self.access_token,
self.next_batch,
str(self.base_url) if self.base_url else None,
)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
id=self.id, custom_mxid=self.custom_mxid, access_token=self.access_token,
displayname=self.displayname, displayname_source=self.displayname_source,
username=self.username, photo_id=self.photo_id, is_bot=self.is_bot,
matrix_registered=self.matrix_registered))
async def save(self) -> None:
q = """
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, 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)
async def insert(self) -> None:
q = """
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, 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, $21)
"""
await self.db.execute(q, *self._values)
+100
View File
@@ -0,0 +1,100 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
from telethon.tl.types import ReactionCustomEmoji, ReactionEmoji, TypeReaction
from mautrix.types import EventID, RoomID
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
@dataclass
class Reaction:
db: ClassVar[Database] = fake_db
mxid: EventID
mx_room: RoomID
msg_mxid: EventID
tg_sender: TelegramID
reaction: str
@classmethod
def _from_row(cls, row: Record | None) -> Reaction | None:
if row is None:
return None
return cls(**row)
columns: ClassVar[str] = "mxid, mx_room, msg_mxid, tg_sender, reaction"
@classmethod
async def delete_all(cls, mx_room: RoomID) -> None:
await cls.db.execute("DELETE FROM reaction WHERE mx_room=$1", mx_room)
@classmethod
async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None:
q = f"SELECT {cls.columns} FROM reaction WHERE mxid=$1 AND mx_room=$2"
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room))
@classmethod
async def get_by_sender(
cls, mxid: EventID, mx_room: RoomID, tg_sender: TelegramID
) -> list[Reaction]:
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3"
rows = await cls.db.fetch(q, mxid, mx_room, tg_sender)
return [cls._from_row(row) for row in rows]
@classmethod
async def get_all_by_message(cls, mxid: EventID, mx_room: RoomID) -> list[Reaction]:
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2"
rows = await cls.db.fetch(q, mxid, mx_room)
return [cls._from_row(row) for row in rows]
@property
def telegram(self) -> TypeReaction:
if self.reaction.isdecimal():
return ReactionCustomEmoji(document_id=int(self.reaction))
else:
return ReactionEmoji(emoticon=self.reaction)
@property
def _values(self):
return (
self.mxid,
self.mx_room,
self.msg_mxid,
self.tg_sender,
self.reaction,
)
async def save(self) -> None:
q = """
INSERT INTO reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (msg_mxid, mx_room, tg_sender, reaction)
DO UPDATE SET mxid=excluded.mxid
"""
await self.db.execute(q, *self._values)
async def delete(self) -> None:
q = "DELETE FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3 AND reaction=$4"
await self.db.execute(q, self.msg_mxid, self.mx_room, self.tg_sender, self.reaction)
-62
View File
@@ -1,62 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, String, Text
from typing import Dict, Optional
import json
from ..types import MatrixRoomID
from .base import Base
class RoomState(Base):
__tablename__ = "mx_room_state"
room_id = Column(String, primary_key=True) # type: MatrixRoomID
power_levels = Column("power_levels", Text, nullable=True) # type: Optional[Dict]
@property
def _power_levels_text(self) -> Optional[str]:
return json.dumps(self.power_levels) if self.power_levels else None
@property
def has_power_levels(self) -> bool:
return bool(self.power_levels)
@classmethod
def get(cls, room_id: MatrixRoomID) -> Optional['RoomState']:
rows = cls.db.execute(cls.t.select().where(cls.c.room_id == room_id))
try:
room_id, power_levels_text = next(rows)
return cls(room_id=room_id, power_levels=(json.loads(power_levels_text)
if power_levels_text else None))
except StopIteration:
return None
def update(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.update()
.where(self.c.room_id == self.room_id)
.values(power_levels=self._power_levels_text))
@property
def _edit_identity(self):
return self.c.room_id == self.room_id
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(room_id=self.room_id,
power_levels=self._power_levels_text))
+89 -34
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,43 +13,99 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
from typing import Optional
from __future__ import annotations
from .base import Base
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
from mautrix.types import ContentURI, EncryptedFile
from mautrix.util.async_db import Database, Scheme
fake_db = Database.create("") if TYPE_CHECKING else None
class TelegramFile(Base):
__tablename__ = "telegram_file"
@dataclass
class TelegramFile:
db: ClassVar[Database] = fake_db
id = Column(String, primary_key=True)
mxc = Column(String)
mime_type = Column(String)
was_converted = Column(Boolean)
timestamp = Column(BigInteger)
size = Column(Integer, nullable=True)
width = Column(Integer, nullable=True)
height = Column(Integer, nullable=True)
thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
thumbnail = None # type: Optional[TelegramFile]
id: str
mxc: ContentURI
mime_type: str
was_converted: bool
timestamp: int
size: int | None
width: int | None
height: int | None
decryption_info: EncryptedFile | None
thumbnail: TelegramFile | None = None
columns: ClassVar[str] = (
"id, mxc, mime_type, was_converted, timestamp, size, width, height, thumbnail, "
"decryption_info"
)
@classmethod
def get(cls, loc_id: str) -> Optional['TelegramFile']:
rows = cls.db.execute(cls.t.select().where(cls.c.id == loc_id))
try:
loc_id, mxc, mime, conv, ts, s, w, h, thumb_id = next(rows)
thumb = None
if thumb_id:
thumb = cls.get(thumb_id)
return cls(id=loc_id, mxc=mxc, mime_type=mime, was_converted=conv, timestamp=ts,
size=s, width=w, height=h, thumbnail_id=thumb_id, thumbnail=thumb)
except StopIteration:
def _from_row(cls, row: Record | None) -> TelegramFile | None:
if row is None:
return None
data = {**row}
data.pop("thumbnail", None)
decryption_info = data.pop("decryption_info", None)
return cls(
**data,
thumbnail=None,
decryption_info=EncryptedFile.parse_json(decryption_info) if decryption_info else None,
)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
id=self.id, mxc=self.mxc, mime_type=self.mime_type,
was_converted=self.was_converted, timestamp=self.timestamp, size=self.size,
width=self.width, height=self.height,
thumbnail=self.thumbnail.id if self.thumbnail else self.thumbnail_id))
@classmethod
async def get_many(cls, loc_ids: list[str]) -> list[TelegramFile]:
if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
q = f"SELECT {cls.columns} FROM telegram_file WHERE id=ANY($1)"
rows = await cls.db.fetch(q, loc_ids)
else:
tgid_placeholders = ("?," * len(loc_ids)).rstrip(",")
q = f"SELECT {cls.columns} FROM telegram_file WHERE id IN ({tgid_placeholders})"
rows = await cls.db.fetch(q, *loc_ids)
return [cls._from_row(row) for row in rows]
@classmethod
async def get(cls, loc_id: str, *, _thumbnail: bool = False) -> TelegramFile | None:
q = f"SELECT {cls.columns} FROM telegram_file WHERE id=$1"
row = await cls.db.fetchrow(q, loc_id)
file = cls._from_row(row)
if file is None:
return None
try:
thumbnail_id = row["thumbnail"]
except KeyError:
thumbnail_id = None
if thumbnail_id and not _thumbnail:
file.thumbnail = await cls.get(thumbnail_id, _thumbnail=True)
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:
q = (
"INSERT INTO telegram_file (id, mxc, mime_type, was_converted, timestamp,"
" size, width, height, thumbnail, decryption_info) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"
)
await self.db.execute(
q,
self.id,
self.mxc,
self.mime_type,
self.was_converted,
self.timestamp,
self.size,
self.width,
self.height,
self.thumbnail.id if self.thumbnail else None,
self.decryption_info.json() if self.decryption_info else None,
)
+266
View File
@@ -0,0 +1,266 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, Iterable
import asyncio
import datetime
from telethon import utils
from telethon.crypto import AuthKey
from telethon.sessions import MemorySession
from telethon.tl.types import PeerChannel, PeerChat, PeerUser, updates
from mautrix.util.async_db import Database, Scheme
fake_db = Database.create("") if TYPE_CHECKING else None
class PgSession(MemorySession):
db: ClassVar[Database] = fake_db
session_id: str
_dc_id: int
_server_address: str | None
_port: int | None
_auth_key: AuthKey | None
_takeout_id: int | None
_process_entities_lock: asyncio.Lock
def __init__(
self,
session_id: str,
dc_id: int = 0,
server_address: str | None = None,
port: int | None = None,
auth_key: AuthKey | None = None,
takeout_id: int | None = None,
) -> None:
super().__init__()
self.session_id = session_id
self._dc_id = dc_id
self._server_address = server_address
self._port = port
self._auth_key = auth_key
self._takeout_id = takeout_id
self._process_entities_lock = asyncio.Lock()
def clone(self, to_instance=None) -> MemorySession:
# We don't want to store data of clones
# (which are used for temporarily connecting to different DCs)
return super().clone(MemorySession())
@property
def auth_key_bytes(self) -> bytes | None:
return self._auth_key.key if self._auth_key else None
@classmethod
async def get(cls, session_id: str) -> PgSession:
q = (
"SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions "
"WHERE session_id=$1"
)
row = await cls.db.fetchrow(q, session_id)
if row is None:
return cls(session_id)
data = {**row}
auth_key = AuthKey(data.pop("auth_key", None))
return cls(**data, auth_key=auth_key)
@classmethod
async def has(cls, session_id: str) -> bool:
q = "SELECT COUNT(*) FROM telethon_sessions WHERE session_id=$1"
count = await cls.db.fetchval(q, session_id)
return count > 0
async def save(self) -> None:
q = (
"INSERT INTO telethon_sessions (session_id, dc_id, server_address, port, auth_key) "
"VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_id) "
"DO UPDATE SET dc_id=$2, server_address=$3, port=$4, auth_key=$5"
)
await self.db.execute(
q, self.session_id, self.dc_id, self.server_address, self.port, self.auth_key_bytes
)
_tables: ClassVar[tuple[str, ...]] = (
"telethon_sessions",
"telethon_entities",
"telethon_sent_files",
"telethon_update_state",
)
async def delete(self) -> None:
async with self.db.acquire() as conn, conn.transaction():
for table in self._tables:
await conn.execute(f"DELETE FROM {table} WHERE session_id=$1", self.session_id)
async def close(self) -> None:
# Nothing to do here, DB connection is global
pass
async def get_update_state(self, entity_id: int) -> updates.State | None:
q = (
"SELECT pts, qts, date, seq, unread_count FROM telethon_update_state "
"WHERE session_id=$1 AND entity_id=$2"
)
row = await self.db.fetchrow(q, self.session_id, entity_id)
if row is None:
return None
date = datetime.datetime.utcfromtimestamp(row["date"])
return updates.State(row["pts"], row["qts"], date, row["seq"], row["unread_count"])
_set_update_state_q = """
INSERT INTO telethon_update_state (session_id, entity_id, pts, qts, date, seq, unread_count)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (session_id, entity_id) DO UPDATE SET
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
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()
await self.db.execute(
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:
q = "DELETE FROM telethon_update_state WHERE session_id=$1 AND entity_id=$2"
await self.db.execute(q, self.session_id, entity_id)
async def get_update_states(self) -> Iterable[tuple[int, updates.State], ...]:
q = (
"SELECT entity_id, pts, qts, date, seq, unread_count FROM telethon_update_state "
"WHERE session_id=$1"
)
rows = await self.db.fetch(q, self.session_id)
return (
(
row["entity_id"],
updates.State(
row["pts"],
row["qts"],
datetime.datetime.utcfromtimestamp(row["date"]),
row["seq"],
row["unread_count"],
),
)
for row in rows
)
def _entity_values_to_row(
self, id: int, hash: int, username: str | None, phone: str | int | None, name: str | None
) -> tuple[str, int, int, str | None, str | None, str | None]:
return self.session_id, id, hash, username, str(phone) if phone else None, name
async def process_entities(self, tlo) -> None:
# Postgres likes to deadlock on simultaneous upserts, so just lock the whole thing here
# TODO: make sure postgres doesn't deadlock on upserts when session_id is different
async with self._process_entities_lock:
await self._locked_process_entities(tlo)
async def _locked_process_entities(self, tlo) -> None:
rows: list[
tuple[str, int, int, str | None, str | None, str | None]
] = self._entities_to_rows(tlo)
if not rows:
return
if self.db.scheme == Scheme.POSTGRES:
q = (
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
"VALUES ($1, unnest($2::bigint[]), unnest($3::bigint[]), "
" unnest($4::text[]), unnest($5::text[]), unnest($6::text[])) "
"ON CONFLICT (session_id, id) DO UPDATE"
" SET hash=excluded.hash, username=excluded.username,"
" phone=excluded.phone, name=excluded.name"
)
_, ids, hashes, usernames, phones, names = zip(*rows)
await self.db.execute(q, self.session_id, ids, hashes, usernames, phones, names)
else:
q = (
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
"VALUES ($1, $2, $3, $4, $5, $6) "
"ON CONFLICT (session_id, id) DO UPDATE "
" SET hash=$3, username=$4, phone=$5, name=$6"
)
await self.db.executemany(q, rows)
async def _select_entity(
self, constraint: str, *args: str | int | tuple[int, ...]
) -> tuple[int, int] | None:
q = f"SELECT id, hash FROM telethon_entities WHERE session_id=$1 AND {constraint}"
row = await self.db.fetchrow(q, self.session_id, *args)
if row is None:
return None
return row["id"], row["hash"]
async def get_entity_rows_by_phone(self, key: str | int) -> tuple[int, int] | None:
return await self._select_entity("phone=$2", str(key))
async def get_entity_rows_by_username(self, key: str) -> tuple[int, int] | None:
return await self._select_entity("username=$2", key)
async def get_entity_rows_by_name(self, key: str) -> tuple[int, int] | None:
return await self._select_entity("name=$2", key)
async def get_entity_rows_by_id(self, key: int, exact: bool = True) -> tuple[int, int] | None:
if exact:
return await self._select_entity("id=$2", key)
ids = (
utils.get_peer_id(PeerUser(key)),
utils.get_peer_id(PeerChat(key)),
utils.get_peer_id(PeerChannel(key)),
)
if self.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
return await self._select_entity("id=ANY($2)", ids)
else:
return await self._select_entity(f"id IN ($2, $3, $4)", *ids)
+24
View File
@@ -0,0 +1,24 @@
from mautrix.util.async_db import UpgradeTable
upgrade_table = UpgradeTable()
from . import (
v01_initial_revision,
v02_sponsored_events,
v03_reactions,
v04_disappearing_messages,
v05_channel_ghosts,
v06_puppet_avatar_url,
v07_puppet_phone_number,
v08_portal_first_event,
v09_puppet_username_index,
v10_more_backfill_fields,
v11_backfill_queue,
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,
)
@@ -0,0 +1,242 @@
# 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
latest_version = 18
async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
await conn.execute(
"""CREATE TABLE "user" (
mxid TEXT PRIMARY KEY,
tgid BIGINT UNIQUE,
tg_username TEXT,
tg_phone TEXT,
is_bot BOOLEAN NOT NULL DEFAULT false,
is_premium BOOLEAN NOT NULL DEFAULT false,
saved_contacts INTEGER NOT NULL DEFAULT 0
)"""
)
await conn.execute(
"""CREATE TABLE portal (
tgid BIGINT,
tg_receiver BIGINT,
peer_type TEXT NOT NULL,
mxid TEXT UNIQUE,
avatar_url TEXT,
encrypted BOOLEAN NOT NULL DEFAULT false,
username TEXT,
title TEXT,
about TEXT,
photo_id TEXT,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
megagroup BOOLEAN,
config jsonb,
first_event_id TEXT,
next_batch_id TEXT,
base_insertion_id TEXT,
sponsored_event_id TEXT,
sponsored_event_ts BIGINT,
sponsored_msg_random_id bytea,
PRIMARY KEY (tgid, tg_receiver)
)"""
)
await conn.execute(
"""CREATE TABLE message (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
tgid BIGINT,
tg_space BIGINT,
edit_index INTEGER,
redacted BOOLEAN NOT NULL DEFAULT false,
content_hash bytea,
sender_mxid TEXT,
sender BIGINT,
PRIMARY KEY (tgid, tg_space, edit_index),
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(
"""CREATE TABLE reaction (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
msg_mxid TEXT NOT NULL,
tg_sender BIGINT,
reaction TEXT NOT NULL,
PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction),
UNIQUE (mxid, mx_room)
)"""
)
await conn.execute(
"""CREATE TABLE disappearing_message (
room_id TEXT,
event_id TEXT,
expiration_seconds BIGINT,
expiration_ts BIGINT,
PRIMARY KEY (room_id, event_id)
)"""
)
await conn.execute(
"""CREATE TABLE puppet (
id BIGINT PRIMARY KEY,
is_registered BOOLEAN NOT NULL DEFAULT false,
displayname TEXT,
displayname_source BIGINT,
displayname_contact BOOLEAN NOT NULL DEFAULT true,
displayname_quality INTEGER NOT NULL DEFAULT 0,
disable_updates BOOLEAN NOT NULL DEFAULT false,
username TEXT,
phone TEXT,
photo_id TEXT,
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,
access_token TEXT,
custom_mxid TEXT,
next_batch TEXT,
base_url TEXT
)"""
)
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(
"""CREATE TABLE telegram_file (
id TEXT PRIMARY KEY,
mxc TEXT NOT NULL,
mime_type TEXT,
was_converted BOOLEAN NOT NULL DEFAULT false,
timestamp BIGINT NOT NULL DEFAULT 0,
size BIGINT,
width INTEGER,
height INTEGER,
thumbnail TEXT,
decryption_info jsonb,
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
ON UPDATE CASCADE ON DELETE SET NULL
)"""
)
await conn.execute("CREATE INDEX telegram_file_mxc_idx ON telegram_file(mxc)")
await conn.execute(
"""CREATE TABLE bot_chat (
id BIGINT PRIMARY KEY,
type TEXT NOT NULL
)"""
)
await conn.execute(
"""CREATE TABLE user_portal (
"user" BIGINT,
portal BIGINT,
portal_receiver BIGINT,
PRIMARY KEY ("user", portal, portal_receiver),
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal, portal_receiver) REFERENCES portal(tgid, tg_receiver)
ON DELETE CASCADE ON UPDATE CASCADE
)"""
)
await conn.execute(
"""CREATE TABLE contact (
"user" BIGINT,
contact BIGINT,
PRIMARY KEY ("user", contact),
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (contact) REFERENCES puppet(id) ON DELETE CASCADE ON UPDATE CASCADE
)"""
)
await conn.execute(
"""CREATE TABLE telethon_sessions (
session_id TEXT PRIMARY KEY,
dc_id INTEGER,
server_address TEXT,
port INTEGER,
auth_key bytea
)"""
)
await conn.execute(
"""CREATE TABLE telethon_entities (
session_id TEXT,
id BIGINT,
hash BIGINT NOT NULL,
username TEXT,
phone TEXT,
name TEXT,
PRIMARY KEY (session_id, id)
)"""
)
await conn.execute(
"""CREATE TABLE telethon_sent_files (
session_id TEXT,
md5_digest bytea,
file_size INTEGER,
type INTEGER,
id BIGINT,
hash BIGINT,
PRIMARY KEY (session_id, md5_digest, file_size, type)
)"""
)
await conn.execute(
"""CREATE TABLE telethon_update_state (
session_id TEXT,
entity_id BIGINT,
pts BIGINT,
qts BIGINT,
date BIGINT,
seq BIGINT,
unread_count INTEGER,
PRIMARY KEY (session_id, entity_id)
)"""
)
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
@@ -0,0 +1,181 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from mautrix.util.async_db import Connection, Scheme
from . import upgrade_table
from .v00_latest_revision import create_latest_tables, latest_version
legacy_version_query = "SELECT version_num FROM alembic_version"
last_legacy_version = "bfc0a39bfe02"
async def first_upgrade_target(conn: Connection, scheme: Scheme) -> int:
is_legacy = await conn.table_exists("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 new db, we'll create the latest tables directly (see create_latest_tables call).
return 1 if is_legacy else latest_version
@upgrade_table.register(description="Initial asyncpg revision", upgrades_to=first_upgrade_target)
async def upgrade_v1(conn: Connection, scheme: Scheme) -> int:
is_legacy = await conn.table_exists("alembic_version")
if is_legacy:
await migrate_legacy_to_v1(conn, scheme)
return 1
else:
return await create_latest_tables(conn, scheme)
async def drop_constraints(conn: Connection, table: str, contype: str) -> None:
q = (
"SELECT conname FROM pg_constraint con INNER JOIN pg_class rel ON rel.oid=con.conrelid "
f"WHERE rel.relname='{table}' AND contype='{contype}'"
)
names = [row["conname"] for row in await conn.fetch(q)]
drops = ", ".join(f"DROP CONSTRAINT {name}" for name in names)
await conn.execute(f"ALTER TABLE {table} {drops}")
async def migrate_legacy_to_v1(conn: Connection, scheme: Scheme) -> None:
legacy_version = await conn.fetchval(legacy_version_query)
if legacy_version != last_legacy_version:
raise RuntimeError(
"Legacy database is not on last version. "
"Please upgrade the old database with alembic or drop it completely first."
)
if scheme != Scheme.SQLITE:
await drop_constraints(conn, "contact", contype="f")
await conn.execute(
"""
ALTER TABLE contact
ADD CONSTRAINT contact_user_fkey FOREIGN KEY (contact) REFERENCES puppet(id)
ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT contact_contact_fkey FOREIGN KEY ("user") REFERENCES "user"(tgid)
ON DELETE CASCADE ON UPDATE CASCADE
"""
)
await drop_constraints(conn, "telethon_sessions", contype="p")
await conn.execute(
"""
ALTER TABLE telethon_sessions
ADD CONSTRAINT telethon_sessions_pkey PRIMARY KEY (session_id)
"""
)
await drop_constraints(conn, "telegram_file", contype="f")
await conn.execute(
"""
ALTER TABLE telegram_file
ADD CONSTRAINT fk_file_thumbnail
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
ON UPDATE CASCADE ON DELETE SET NULL
"""
)
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP IDENTITY IF EXISTS")
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP DEFAULT")
await conn.execute("DROP SEQUENCE IF EXISTS puppet_id_seq")
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP IDENTITY IF EXISTS")
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP DEFAULT")
await conn.execute("DROP SEQUENCE IF EXISTS bot_chat_id_seq")
await conn.execute("ALTER TABLE portal ALTER COLUMN config TYPE jsonb USING config::jsonb")
await conn.execute(
"ALTER TABLE telegram_file ALTER COLUMN decryption_info TYPE jsonb "
"USING decryption_info::jsonb"
)
await varchar_to_text(conn)
else:
await conn.execute(
"""CREATE TABLE telethon_sessions_new (
session_id TEXT PRIMARY KEY,
dc_id INTEGER,
server_address TEXT,
port INTEGER,
auth_key bytea
)"""
)
await conn.execute(
"""
INSERT INTO telethon_sessions_new (session_id, dc_id, server_address, port, auth_key)
SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions
"""
)
await conn.execute("DROP TABLE telethon_sessions")
await conn.execute("ALTER TABLE telethon_sessions_new RENAME TO telethon_sessions")
await update_state_store(conn, scheme)
await conn.execute('ALTER TABLE "user" ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false')
await conn.execute("ALTER TABLE puppet RENAME COLUMN matrix_registered TO is_registered")
await conn.execute("DROP TABLE telethon_version")
await conn.execute("DROP TABLE alembic_version")
async def update_state_store(conn: Connection, scheme: Scheme) -> None:
# The Matrix state store already has more or less the correct schema, so set the version
await conn.execute("CREATE TABLE mx_version (version INTEGER PRIMARY KEY)")
await conn.execute("INSERT INTO mx_version (version) VALUES (2)")
await conn.execute("UPDATE mx_user_profile SET membership='LEAVE' WHERE membership='LEFT'")
if scheme != Scheme.SQLITE:
# Also add the membership type on postgres
await conn.execute(
"CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock')"
)
await conn.execute(
"ALTER TABLE mx_user_profile ALTER COLUMN membership TYPE membership "
"USING LOWER(membership)::membership"
)
else:
# On SQLite there's no custom type, but we still want to lowercase everything
await conn.execute("UPDATE mx_user_profile SET membership=LOWER(membership)")
async def varchar_to_text(conn: Connection) -> None:
columns_to_adjust = {
"user": ("mxid", "tg_username", "tg_phone"),
"portal": (
"peer_type",
"mxid",
"username",
"title",
"about",
"photo_id",
"avatar_url",
"config",
),
"message": ("mxid", "mx_room"),
"puppet": (
"displayname",
"username",
"photo_id",
"access_token",
"custom_mxid",
"next_batch",
"base_url",
),
"bot_chat": ("type",),
"telegram_file": ("id", "mxc", "mime_type", "thumbnail"),
# Phone is a bigint in the old schema, which is safe, but we don't do math on it,
# so let's change it to a string
"telethon_entities": ("session_id", "username", "name", "phone"),
"telethon_sent_files": ("session_id",),
"telethon_sessions": ("session_id", "server_address"),
"telethon_update_state": ("session_id",),
"mx_room_state": ("room_id",),
"mx_user_profile": ("room_id", "user_id", "displayname", "avatar_url"),
}
for table, columns in columns_to_adjust.items():
for column in columns:
await conn.execute(f'ALTER TABLE "{table}" ALTER COLUMN {column} TYPE TEXT')
@@ -0,0 +1,25 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add column to store sponsored message event ID in channels")
async def upgrade_v2(conn: Connection) -> None:
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_event_id TEXT")
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_event_ts BIGINT")
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_msg_random_id bytea")
@@ -0,0 +1,39 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add support for reactions")
async def upgrade_v3(conn: Connection, scheme: str) -> None:
await conn.execute(
"""CREATE TABLE reaction (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
msg_mxid TEXT NOT NULL,
tg_sender BIGINT,
reaction TEXT NOT NULL,
PRIMARY KEY (msg_mxid, mx_room, tg_sender),
UNIQUE (mxid, mx_room)
)"""
)
if scheme != "sqlite":
await conn.execute("DELETE FROM message WHERE mxid IS NULL OR mx_room IS NULL")
await conn.execute("ALTER TABLE message ALTER COLUMN mxid SET NOT NULL")
await conn.execute("ALTER TABLE message ALTER COLUMN mx_room SET NOT NULL")
await conn.execute("ALTER TABLE message ADD COLUMN content_hash bytea")
@@ -0,0 +1,32 @@
# 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 support for disappearing messages")
async def upgrade_v4(conn: Connection) -> None:
await conn.execute(
"""CREATE TABLE disappearing_message (
room_id TEXT,
event_id TEXT,
expiration_seconds BIGINT,
expiration_ts BIGINT,
PRIMARY KEY (room_id, event_id)
)"""
)
@@ -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, Scheme
from . import upgrade_table
@upgrade_table.register(description="Add separate ghost users for channel senders")
async def upgrade_v5(conn: Connection, scheme: str) -> None:
await conn.execute("ALTER TABLE puppet ADD COLUMN is_channel BOOLEAN NOT NULL DEFAULT false")
if scheme == Scheme.POSTGRES:
await conn.execute("ALTER TABLE puppet ALTER COLUMN is_channel DROP DEFAULT")
@@ -0,0 +1,31 @@
# 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 avatar mxc URI in puppet table")
async def upgrade_v6(conn: Connection) -> None:
await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_url TEXT")
await conn.execute("ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("UPDATE puppet SET name_set=true WHERE displayname<>''")
await conn.execute("UPDATE puppet SET avatar_set=true WHERE photo_id<>''")
await conn.execute("ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("UPDATE portal SET name_set=true WHERE title<>'' AND mxid<>''")
await conn.execute("UPDATE portal SET avatar_set=true WHERE photo_id<>'' AND mxid<>''")
@@ -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 phone number in puppet table")
async def upgrade_v7(conn: Connection) -> None:
await conn.execute("ALTER TABLE puppet ADD COLUMN phone TEXT")
@@ -0,0 +1,24 @@
# 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="Track first event ID in portals for infinite backfilling")
async def upgrade_v8(conn: Connection) -> None:
await conn.execute("ALTER TABLE portal ADD COLUMN first_event_id TEXT")
await conn.execute("ALTER TABLE portal ADD COLUMN next_batch_id TEXT")
@@ -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 username column")
async def upgrade_v9(conn: Connection) -> None:
await conn.execute("CREATE INDEX puppet_username_idx ON puppet(LOWER(username))")
@@ -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 more portal columns related to infinite backfill")
async def upgrade_v10(conn: Connection) -> None:
await conn.execute("ALTER TABLE portal ADD COLUMN base_insertion_id TEXT")
@@ -0,0 +1,45 @@
# 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 mautrix.util.async_db import Connection, Scheme
from . import upgrade_table
@upgrade_table.register(description="Add the backfill queue table")
async def upgrade_v11(conn: Connection, scheme: Scheme) -> None:
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,
portal_tgid BIGINT,
portal_tg_receiver BIGINT,
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
)
"""
)
@@ -0,0 +1,24 @@
# 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 sender in message table")
async def upgrade_v12(conn: Connection) -> None:
await conn.execute("ALTER TABLE message ADD COLUMN sender_mxid TEXT")
await conn.execute("ALTER TABLE message ADD COLUMN sender BIGINT")
@@ -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"
)
+119 -91
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,117 +13,146 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
from sqlalchemy.engine.result import RowProxy
from typing import Optional, Iterable, Tuple
from __future__ import annotations
from ..types import MatrixUserID, TelegramID
from .base import Base
from typing import TYPE_CHECKING, ClassVar, Iterable
from asyncpg import Record
from attr import dataclass
from mautrix.types import UserID
from mautrix.util.async_db import Connection, Database, Scheme
from ..types import TelegramID
from .backfill_queue import Backfill
fake_db = Database.create("") if TYPE_CHECKING else None
class User(Base):
__tablename__ = "user"
@dataclass
class User:
db: ClassVar[Database] = fake_db
mxid = Column(String, primary_key=True) # type: MatrixUserID
tgid = Column(Integer, nullable=True, unique=True) # type: Optional[TelegramID]
tg_username = Column(String, nullable=True)
tg_phone = Column(String, nullable=True)
saved_contacts = Column(Integer, default=0, nullable=False)
mxid: UserID
tgid: TelegramID | None
tg_username: str | None
tg_phone: str | None
is_bot: bool
is_premium: bool
saved_contacts: int
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['User']:
try:
mxid, tgid, tg_username, tg_phone, saved_contacts = next(rows)
return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
saved_contacts=saved_contacts)
except StopIteration:
def _from_row(cls, row: Record | None) -> User | None:
if row is None:
return None
return cls(**row)
columns: ClassVar[str] = ", ".join(
("mxid", "tgid", "tg_username", "tg_phone", "is_bot", "is_premium", "saved_contacts")
)
@classmethod
def all(cls) -> Iterable['User']:
rows = cls.db.execute(cls.t.select())
for row in rows:
mxid, tgid, tg_username, tg_phone, saved_contacts = row
yield cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
saved_contacts=saved_contacts)
async def get_by_tgid(cls, tgid: TelegramID) -> User | None:
q = f'SELECT {cls.columns} FROM "user" WHERE tgid=$1'
return cls._from_row(await cls.db.fetchrow(q, tgid))
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
return cls._select_one_or_none(cls.c.tgid == tgid)
async def get_by_mxid(cls, mxid: UserID) -> User | None:
q = f'SELECT {cls.columns} FROM "user" WHERE mxid=$1'
return cls._from_row(await cls.db.fetchrow(q, mxid))
@classmethod
def get_by_mxid(cls, mxid: MatrixUserID) -> Optional['User']:
return cls._select_one_or_none(cls.c.mxid == mxid)
async def find_by_username(cls, username: str) -> User | None:
q = f'SELECT {cls.columns} FROM "user" WHERE lower(tg_username)=$1'
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
@classmethod
def get_by_username(cls, username: str) -> Optional['User']:
return cls._select_one_or_none(cls.c.tg_username == username)
async def all_with_tgid(cls) -> list[User]:
q = f'SELECT {cls.columns} FROM "user" WHERE tgid IS NOT NULL'
return [cls._from_row(row) for row in await cls.db.fetch(q)]
async def delete(self) -> None:
await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid)
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
def _edit_identity(self):
return self.c.mxid == self.mxid
def _values(self):
return (
self.mxid,
self.tgid,
self.tg_username,
self.tg_phone,
self.is_bot,
self.is_premium,
self.saved_contacts,
)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
mxid=self.mxid, tgid=self.tgid, tg_username=self.tg_username,
tg_phone=self.tg_phone, saved_contacts=self.saved_contacts))
async def save(self, conn: Connection | None = None) -> None:
q = """
UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, is_premium=$6,
saved_contacts=$7
WHERE mxid=$1
"""
await (conn or self.db).execute(q, *self._values)
@property
def contacts(self) -> Iterable[TelegramID]:
rows = self.db.execute(Contact.t.select().where(Contact.c.user == self.tgid))
for row in rows:
user, contact = row
yield contact
async def insert(self) -> None:
q = """
INSERT INTO "user" (mxid, tgid, tg_username, tg_phone, is_bot, is_premium, saved_contacts)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"""
await self.db.execute(q, *self._values)
@contacts.setter
def contacts(self, puppets: Iterable[TelegramID]) -> None:
with self.db.begin() as conn:
conn.execute(Contact.t.delete().where(Contact.c.user == self.tgid))
insert_puppets = [{"user": self.tgid, "contact": tgid} for tgid in puppets]
if insert_puppets:
conn.execute(Contact.t.insert(), insert_puppets)
async def get_contacts(self) -> list[TelegramID]:
rows = await self.db.fetch('SELECT contact FROM contact WHERE "user"=$1', self.tgid)
return [TelegramID(row["contact"]) for row in rows]
@property
def portals(self) -> Iterable[Tuple[TelegramID, TelegramID]]:
rows = self.db.execute(UserPortal.t.select().where(UserPortal.c.user == self.tgid))
for row in rows:
user, portal, portal_receiver = row
yield (portal, portal_receiver)
async def set_contacts(self, puppets: Iterable[TelegramID]) -> None:
columns = ["user", "contact"]
records = [(self.tgid, puppet_id) for puppet_id in puppets]
async with self.db.acquire() as conn, conn.transaction():
await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid)
if self.db.scheme == Scheme.POSTGRES:
await conn.copy_records_to_table("contact", records=records, columns=columns)
else:
q = 'INSERT INTO contact ("user", contact) VALUES ($1, $2)'
await conn.executemany(q, records)
@portals.setter
def portals(self, portals: Iterable[Tuple[TelegramID, TelegramID]]) -> None:
with self.db.begin() as conn:
conn.execute(UserPortal.t.delete().where(UserPortal.c.user == self.tgid))
insert_portals = [{
"user": self.tgid,
"portal": tgid,
"portal_receiver": tg_receiver
} for tgid, tg_receiver in portals]
if insert_portals:
conn.execute(UserPortal.t.insert(), insert_portals)
async def get_portals(self) -> list[tuple[TelegramID, TelegramID]]:
q = 'SELECT portal, portal_receiver FROM user_portal WHERE "user"=$1'
rows = await self.db.fetch(q, self.tgid)
return [(TelegramID(row["portal"]), TelegramID(row["portal_receiver"])) for row in rows]
def delete(self) -> None:
super().delete()
self.portals = None
self.contacts = None
async def set_portals(self, portals: Iterable[tuple[TelegramID, TelegramID]]) -> None:
columns = ["user", "portal", "portal_receiver"]
records = [(self.tgid, tgid, tg_receiver) for tgid, tg_receiver in portals]
async with self.db.acquire() as conn, conn.transaction():
await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid)
if self.db.scheme == Scheme.POSTGRES:
await conn.copy_records_to_table("user_portal", records=records, columns=columns)
else:
q = 'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3)'
await conn.executemany(q, records)
async def register_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
q = (
'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3) '
'ON CONFLICT ("user", portal, portal_receiver) DO NOTHING'
)
await self.db.execute(q, self.tgid, tgid, tg_receiver)
class UserPortal(Base):
__tablename__ = "user_portal"
user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"),
primary_key=True) # type: TelegramID
portal = Column(Integer, primary_key=True) # type: TelegramID
portal_receiver = Column(Integer, primary_key=True) # type: TelegramID
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"),
onupdate="CASCADE", ondelete="CASCADE"),)
class Contact(Base):
__tablename__ = "contact"
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) # type: TelegramID
contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True) # type: TelegramID
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
q = 'DELETE FROM user_portal WHERE "user"=$1 AND portal=$2 AND portal_receiver=$3'
await self.db.execute(q, self.tgid, tgid, tg_receiver)
-69
View File
@@ -1,69 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, String, and_
from typing import Dict, Optional
from ..types import MatrixUserID, MatrixRoomID
from .base import Base
class UserProfile(Base):
__tablename__ = "mx_user_profile"
room_id = Column(String, primary_key=True) # type: MatrixRoomID
user_id = Column(String, primary_key=True) # type: MatrixUserID
membership = Column(String, nullable=False, default="leave")
displayname = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
def dict(self) -> Dict[str, str]:
return {
"membership": self.membership,
"displayname": self.displayname,
"avatar_url": self.avatar_url,
}
@classmethod
def get(cls, room_id: MatrixRoomID, user_id: MatrixUserID) -> Optional['UserProfile']:
rows = cls.db.execute(
cls.t.select().where(and_(cls.c.room_id == room_id, cls.c.user_id == user_id)))
try:
room_id, user_id, membership, displayname, avatar_url = next(rows)
return cls(room_id=room_id, user_id=user_id, membership=membership,
displayname=displayname, avatar_url=avatar_url)
except StopIteration:
return None
@classmethod
def delete_all(cls, room_id: MatrixRoomID) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.room_id == room_id))
def update(self) -> None:
super().update(membership=self.membership, displayname=self.displayname,
avatar_url=self.avatar_url)
@property
def _edit_identity(self):
return and_(self.c.room_id == self.room_id, self.c.user_id == self.user_id)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(room_id=self.room_id, user_id=self.user_id,
membership=self.membership,
displayname=self.displayname,
avatar_url=self.avatar_url))
+672
View File
@@ -0,0 +1,672 @@
# Homeserver details
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://example.com
# The domain of the homeserver (for MXIDs, etc).
domain: example.com
# Whether or not to verify the SSL certificate of the homeserver.
# Only applies if address starts with https://
verify_ssl: true
# 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.
http_retry_count: 4
# The URL to push real-time bridge status to.
# If set, the bridge will make POST requests to this URL whenever a user's Telegram connection state changes.
# The bridge will use the appservice as_token to authorize requests.
status_endpoint: null
# Endpoint for reporting per-message status.
message_send_checkpoint_endpoint: null
# Whether asynchronous uploads via MSC2246 should be enabled for media.
# Requires a media repo that supports MSC2246.
async_media: false
# Application service host/registration related details
# Changing these values requires regeneration of the registration.
appservice:
# The address that the homeserver can use to connect to this appservice.
address: http://localhost:29317
# When using https:// the TLS certificate and key files for the address.
tls_cert: false
tls_key: false
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 29317
# The maximum body size of appservice API requests (from the homeserver) in mebibytes
# Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
max_body_size: 1
# The full URI to the database. SQLite and Postgres are supported.
# Format examples:
# SQLite: sqlite:filename.db
# Postgres: postgres://username:password@hostname/dbname
database: postgres://username:password@hostname/dbname
# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
# https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool
# https://docs.python.org/3/library/sqlite3.html#sqlite3.connect
# For sqlite, min_size is used as the connection thread pool size and max_size is ignored.
# Additionally, SQLite supports init_commands as an array of SQL queries to run on connect (e.g. to set PRAGMAs).
database_opts:
min_size: 1
max_size: 10
# Public part of web server for out-of-Matrix interaction with the bridge.
# Used for things like login if the user wants to make sure the 2FA password isn't stored in
# the HS database.
public:
# Whether or not the public-facing endpoints should be enabled.
enabled: false
# The prefix to use in the public-facing endpoints.
prefix: /public
# The base URL where the public-facing endpoints are available. The prefix is not added
# implicitly.
external: https://example.com/public
# Provisioning API part of the web server for automated portal creation and fetching information.
# Used by things like mautrix-manager (https://github.com/tulir/mautrix-manager).
provisioning:
# Whether or not the provisioning API should be enabled.
enabled: true
# The prefix to use in the provisioning API endpoints.
prefix: /_matrix/provision
# The shared secret to authorize users of the API.
# Set to "generate" to generate and save a new token.
shared_secret: generate
# The unique ID of this appservice.
id: telegram
# Username of the appservice bot.
bot_username: telegrambot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
bot_displayname: Telegram bridge bot
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
# Whether or not to receive ephemeral events via appservice transactions.
# Requires MSC2409 support (i.e. Synapse 1.22+).
# You should disable bridge -> sync_with_custom_puppets when this is enabled.
ephemeral_events: true
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration"
hs_token: "This value is generated when generating the registration"
# Prometheus telemetry config. Requires prometheus-client to be installed.
metrics:
enabled: false
listen_port: 8000
# Manhole config.
manhole:
# Whether or not opening the manhole is allowed.
enabled: false
# The path for the unix socket.
path: /var/tmp/mautrix-telegram.manhole
# The list of UIDs who can be added to the whitelist.
# If empty, any UIDs can be specified in the open-manhole command.
whitelist:
- 0
# Bridge config
bridge:
# Localpart template of MXIDs for Telegram users.
# {userid} is replaced with the user ID of the Telegram user.
username_template: "telegram_{userid}"
# Localpart template of room aliases for Telegram portal rooms.
# {groupname} is replaced with the name part of the public channel/group invite link ( https://t.me/{} )
alias_template: "telegram_{groupname}"
# Displayname template for Telegram users.
# {displayname} is replaced with the display name of the Telegram user.
displayname_template: "{displayname} (Telegram)"
# Set the preferred order of user identifiers which to use in the Matrix puppet display name.
# In the (hopefully unlikely) scenario that none of the given keys are found, the numeric user
# ID is used.
#
# If the bridge is working properly, a phone number or an username should always be known, but
# the other one can very well be empty.
#
# Valid keys:
# "full name" (First and/or last name)
# "full name reversed" (Last and/or first name)
# "first name"
# "last name"
# "username"
# "phone number"
displayname_preference:
- full name
- username
- phone number
# Maximum length of displayname
displayname_max_length: 100
# Remove avatars from Telegram ghost users when removed on Telegram. This is disabled by default
# as there's no way to determine whether an avatar is removed or just hidden from some users. If
# you're on a single-user instance, this should be safe to enable.
allow_avatar_remove: false
# 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
# synced when they send messages. The maximum is 10000, after which the Telegram server
# will not send any more members.
# -1 means no limit (which means it's limited to 10000 by the server)
max_initial_member_sync: 100
# Maximum number of participants in chats to bridge. Only applies when the portal is being created.
# If there are more members when trying to create a room, the room creation will be cancelled.
# -1 means no limit (which means all chats can be bridged)
max_member_count: -1
# Whether or not to sync the member list in channels.
# If no channel admins have logged into the bridge, the bridge won't be able to sync the member
# list regardless of this setting.
sync_channel_members: false
# Whether or not to skip deleted members when syncing members.
skip_deleted_members: true
# Whether or not to automatically synchronize contacts and chats of Matrix users logged into
# their Telegram account at startup.
startup_sync: false
# Number of most recently active dialogs to check when syncing chats.
# Set to 0 to remove limit.
sync_update_limit: 0
# Number of most recently active dialogs to create portals for when syncing chats.
# Set to 0 to remove limit.
sync_create_limit: 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.
sync_direct_chats: false
# The maximum number of simultaneous Telegram deletions to handle.
# A large number of simultaneous redactions could put strain on your homeserver.
max_telegram_delete: 10
# Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames)
# at startup and when creating a bridge.
sync_matrix_state: true
# Allow logging in within Matrix. If false, users can only log in using login-qr or the
# out-of-Matrix login website (see appservice.public config section)
allow_matrix_login: true
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
public_portals: false
# Whether or not to use /sync to get presence, read receipts and typing notifications
# when double puppeting is enabled
sync_with_custom_puppets: false
# Whether or not to update the m.direct account data event when double puppeting is enabled.
# Note that updating the m.direct event is not atomic (except with mautrix-asmux)
# and is therefore prone to race conditions.
sync_direct_chat_list: false
# Servers to always allow double puppeting from
double_puppet_server_map:
example.com: https://example.com
# Allow using double puppeting from any server with a valid client .well-known file.
double_puppet_allow_discovery: false
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
#
# If set, custom puppets will be enabled automatically for local users
# instead of users having to find an access token and run `login-matrix`
# manually.
# If using this for other servers than the bridge's server,
# you must also set the URL in the double_puppet_server_map.
login_shared_secret_map:
example.com: foobar
# Set to false to disable link previews in messages sent to Telegram.
telegram_link_preview: true
# Whether or not the !tg join command should do a HTTP request
# to resolve redirects in invite links.
invite_link_resolve: false
# 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
# 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
# Enable experimental parallel file transfer, which makes uploads/downloads much faster by
# streaming from/to Matrix and using many connections for Telegram.
# Note that generating HQ thumbnails for videos is not possible with streamed transfers.
# This option uses internal Telethon implementation details and may break with minor updates.
parallel_file_transfer: false
# Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated.
federate_rooms: true
# 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.
animated_sticker:
# Format to which animated stickers should be converted.
# disable - No conversion, send as-is (gzipped lottie)
# png - converts to non-animated png (fastest),
# gif - converts to animated gif
# webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support
# webp - converts to animated webp, requires ffmpeg executable with webp codec/container support
target: gif
# Should video stickers be converted to the specified format as well?
convert_from_webm: false
# Arguments for converter. All converters take width and height.
args:
width: 256
height: 256
fps: 25 # only for webm, webp and gif (2, 5, 10, 20 or 25 recommended)
# Settings for converting animated emoji.
# Same as animated_sticker, but webm is not supported as the target
# (because inline images can only contain images, not videos).
animated_emoji:
target: webp
args:
width: 64
height: 64
fps: 25
# End-to-bridge encryption support options.
#
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
encryption:
# Allow encryption, work in group chat rooms with e2ee enabled
allow: false
# Default to encryption, force-enable encryption in all portals the bridge creates
# This will cause the bridge bot to be in private chats for the encryption to work properly.
default: false
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
appservice: 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:
# unverified - Send keys to all device in the room.
# cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys.
# cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes).
# cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot.
# Note that creating user signatures from the bridge bot is not currently possible.
# verified - Require manual per-device verification
# (currently only possible by modifying the `trust` column in the `crypto_device` database table).
verification_levels:
# Minimum level for which the bridge should send keys to when bridging messages from Telegram to Matrix.
receive: unverified
# Minimum level that the bridge should accept for incoming Matrix messages.
send: unverified
# Minimum level that the bridge should require for accepting key requests.
share: cross-signed-tofu
# Options for Megolm room key rotation. These options allow you to
# configure the m.room.encryption event content. See:
# https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for
# more information about that event.
rotation:
# Enable custom Megolm room key rotation settings. Note that these
# settings will only apply to rooms created after this option is
# set.
enable_custom: false
# The maximum number of milliseconds a session should be used
# before changing it. The Matrix spec recommends 604800000 (a week)
# as the default.
milliseconds: 604800000
# The maximum number of messages that should be sent with a given a
# session before changing it. The Matrix spec recommends 100 as the
# default.
messages: 100
# 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: false
# 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
# Whether or not delivery errors should be reported as messages in the Matrix room.
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.
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.
# This field will automatically be changed back to false after it,
# except if the config file is not writable.
resend_bridge_info: false
# When using double puppeting, should muted chats be muted in Matrix?
mute_bridging: false
# When using double puppeting, should pinned chats be moved to a specific tag in Matrix?
# The favorites tag is `m.favourite`.
pinned_tag: null
# Same as above for archived chats, the low priority tag is `m.lowpriority`.
archive_tag: null
# Whether or not mute status and tags should only be bridged when the portal room is created.
tag_only_on_create: true
# Should leaving the room on Matrix make the user leave on Telegram?
bridge_matrix_leave: true
# Should the user be kicked out of all portals when logging out of the bridge?
kick_on_logout: true
# Should the "* user joined Telegram" notice always be marked as read automatically?
always_read_joined_telegram_notice: true
# Should the bridge auto-create a group chat on Telegram when a ghost is invited to a room?
# Requires the user to have sufficient power level and double puppeting enabled.
create_group_on_invite: true
# Settings for backfilling messages from Telegram.
backfill:
# Allow backfilling at all?
enable: true
# Whether or not to enable backfilling in normal groups.
# Normal groups have numerous technical problems in Telegram, and backfilling normal groups
# will likely cause problems if there are multiple Matrix users in the group.
normal_groups: false
# 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.
initial_power_level_overrides:
user: {}
group: {}
# Whether to bridge Telegram bot messages as m.notices or m.texts.
bot_messages_as_notices: true
bridge_notices:
# Whether or not Matrix bot messages (type m.notice) should be bridged.
default: false
# List of user IDs for whom the previous flag is flipped.
# e.g. if bridge_notices.default is false, notices from other users will not be bridged, but
# notices from users listed here will be bridged.
exceptions: []
# An array of possible values for the $distinguisher variable in message formats.
# Each user gets one of the values here, based on a hash of their user ID.
# If the array is empty, the $distinguisher variable will also be empty.
relay_user_distinguishers: ["🟦", "🟣", "🟩", "⭕️", "🔶", "⬛️", "🔵", "🟢"]
# The formats to use when sending messages to Telegram via the relay bot.
# Text msgtypes (m.text, m.notice and m.emote) support HTML, media msgtypes don't.
#
# Available variables:
# $sender_displayname - The display name of the sender (e.g. Example User)
# $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
# $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
# $distinguisher - A random string from the options in the relay_user_distinguishers array.
# $message - The message content
message_formats:
m.text: "$distinguisher <b>$sender_displayname</b>: $message"
m.notice: "$distinguisher <b>$sender_displayname</b>: $message"
m.emote: "* $distinguisher <b>$sender_displayname</b> $message"
m.file: "$distinguisher <b>$sender_displayname</b> sent a file: $message"
m.image: "$distinguisher <b>$sender_displayname</b> sent an image: $message"
m.audio: "$distinguisher <b>$sender_displayname</b> sent an audio file: $message"
m.video: "$distinguisher <b>$sender_displayname</b> sent a video: $message"
m.location: "$distinguisher <b>$sender_displayname</b> sent a location: $message"
# Telegram doesn't have built-in emotes, this field specifies how m.emote's from authenticated
# users are sent to telegram. All fields in message_formats are supported. Additionally, the
# Telegram user info is available in the following variables:
# $displayname - Telegram displayname
# $username - Telegram username (may not exist)
# $mention - Telegram @username or displayname mention (depending on which exists)
emote_format: "* $mention $formatted_body"
# The formats to use when sending state events to Telegram via the relay bot.
#
# Variables from `message_formats` that have the `sender_` prefix are available without the prefix.
# In name_change events, `$prev_displayname` is the previous displayname.
#
# Set format to an empty string to disable the messages for that event.
state_event_formats:
join: "$distinguisher <b>$displayname</b> joined the room."
leave: "$distinguisher <b>$displayname</b> left the room."
name_change: "$distinguisher <b>$prev_displayname</b> changed their name to $distinguisher <b>$displayname</b>"
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
# `filter-mode` management commands.
#
# An empty blacklist will essentially disable the filter.
filter:
# Filter mode to use. Either "blacklist" or "whitelist".
# If the mode is "blacklist", the listed chats will never be bridged.
# If the mode is "whitelist", only the listed chats can be bridged.
mode: blacklist
# The list of group/channel IDs to filter.
list: []
# 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"
# Messages sent upon joining a management room.
# Markdown is supported. The defaults are listed below.
management_room_text:
# Sent when joining a room.
welcome: "Hello, I'm a Telegram bridge bot."
# Sent when joining a management room and the user is already logged in.
welcome_connected: "Use `help` for help."
# Sent when joining a management room and the user is not logged in.
welcome_unconnected: "Use `help` for help or `login` to log in."
# Optional extra text sent when joining a management room.
additional_help: ""
# Send each message separately (for readability in some clients)
management_room_multiple_messages: false
# Permissions for using the bridge.
# Permitted values:
# relaybot - Only use the bridge via the relaybot, no access to commands.
# user - Relaybot level + access to commands to create bridges.
# puppeting - User level + logging in with a Telegram account.
# full - Full access to use the bridge, i.e. previous levels + Matrix login.
# admin - Full access to use the bridge and some extra administration commands.
# Permitted keys:
# * - All Matrix users
# domain - All users on that homeserver
# mxid - Specific user
permissions:
"*": "relaybot"
"public.example.com": "user"
"example.com": "full"
"@admin:example.com": "admin"
# Options related to the message relay Telegram bot.
relaybot:
private_chat:
# List of users to invite to the portal when someone starts a private chat with the bot.
# If empty, private chats with the bot won't create a portal.
invite: []
# Whether or not to bridge state change messages in relaybot private chats.
state_changes: true
# When private_chat_invite is empty, this message is sent to users /starting the
# relaybot. Telegram's "markdown" is supported.
message: This is a Matrix bridge relaybot and does not support direct chats
# List of users to invite to all group chat portals created by the bridge.
group_chat_invite: []
# Whether or not the relaybot should not bridge events in unbridged group chats.
# If false, portals will be created when the relaybot receives messages, just like normal
# users. This behavior is usually not desirable, as it interferes with manually bridging
# the chat to another room.
ignore_unbridged_group_chat: true
# Whether or not to allow creating portals from Telegram.
authless_portals: true
# Whether or not to allow Telegram group admins to use the bot commands.
whitelist_group_admins: true
# Whether or not to ignore incoming events sent by the relay bot.
ignore_own_incoming_events: true
# List of usernames/user IDs who are also allowed to use the bot commands.
whitelist:
- myusername
- 12345678
# Telegram config
telegram:
# Get your own API keys at https://my.telegram.org/apps
api_id: 12345
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
# (Optional) Create your own bot at https://t.me/BotFather
bot_token: disabled
# Should the bridge request missed updates from Telegram when restarting?
catch_up: true
# Should incoming updates be handled sequentially to make sure order is preserved on Matrix?
sequential_updates: true
exit_on_update_error: false
# Telethon connection options.
connection:
# The timeout in seconds to be used when connecting.
timeout: 120
# How many times the reconnection should retry, either on the initial connection or when
# Telegram disconnects us. May be set to a negative or null value for infinite retries, but
# this is not recommended, since the program can get stuck in an infinite loop.
retries: 5
# The delay in seconds to sleep between automatic reconnections.
retry_delay: 1
# The threshold below which the library should automatically sleep on flood wait errors
# (inclusive). For instance, if a FloodWaitError for 17s occurs and flood_sleep_threshold
# is 20s, the library will sleep automatically. If the error was for 21s, it would raise
# the error instead. Values larger than a day (86400) will be changed to a day.
flood_sleep_threshold: 60
# How many times a request should be retried. Request are retried when Telegram is having
# internal issues, when there is a FloodWaitError less than flood_sleep_threshold, or when
# there's a migrate error. May take a negative or null value for infinite retries, but this
# is not recommended, since some requests can always trigger a call fail (such as searching
# for messages).
request_retries: 5
# Use IPv6 for Telethon connection
use_ipv6: false
# Device info sent to Telegram.
device_info:
# "auto" = OS name+version.
device_model: mautrix-telegram
# "auto" = Telethon version.
system_version: auto
# "auto" = mautrix-telegram version.
app_version: auto
lang_code: en
system_lang_code: en
# Custom server to connect to.
server:
# Set to true to use these server settings. If false, will automatically
# use production server assigned by Telegram. Set to false in production.
enabled: false
# The DC ID to connect to.
dc: 2
# The IP to connect to.
ip: 149.154.167.40
# The port to connect to. 443 may not work, 80 is better and both are equally secure.
port: 80
# Telethon proxy configuration.
# You must install PySocks from pip for proxies to work.
proxy:
# Allowed types: disabled, socks4, socks5, http, mtproxy
type: disabled
# Proxy IP address and port.
address: 127.0.0.1
port: 1080
# Whether or not to perform DNS resolving remotely. Only for socks/http proxies.
rdns: true
# Proxy authentication (optional). Put MTProxy secret in password field.
username: ""
password: ""
# Python logging configuration.
#
# See section 16.7.2 of the Python documentation for more info:
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
logging:
version: 1
formatters:
colored:
(): mautrix_telegram.util.ColorFormatter
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
normal:
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
handlers:
file:
class: logging.handlers.RotatingFileHandler
formatter: normal
filename: ./mautrix-telegram.log
maxBytes: 10485760
backupCount: 10
console:
class: logging.StreamHandler
formatter: colored
loggers:
mau:
level: DEBUG
telethon:
level: INFO
aiohttp:
level: INFO
root:
level: DEBUG
handlers: [file, console]
+2 -9
View File
@@ -1,9 +1,2 @@
from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram,
init_mx)
from .from_telegram import (telegram_reply_to_matrix, telegram_to_matrix, init_tg)
from .. import context as c
def init(context: c.Context) -> None:
init_mx(context)
init_tg(context)
from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram
from .from_telegram import telegram_text_to_matrix_html, telegram_to_matrix
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,138 +13,98 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING, Dict, Any
from __future__ import annotations
import re
import logging
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
TypeMessageEntity)
from telethon import TelegramClient
from telethon.helpers import add_surrogate, del_surrogate, strip_text
from telethon.tl.types import MessageEntityItalic, TypeMessageEntity
from mautrix.types import MessageEventContent, RoomID
from ... import puppet as pu
from ...types import TelegramID, MatrixRoomID
from ...db import Message as DBMessage
from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text)
from .parser import ParsedMessage, parse_html
from ...types import TelegramID
from .parser import MatrixParser
if TYPE_CHECKING:
from ...context import Context
command_regex = re.compile(r"^!([A-Za-z0-9@]+)")
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)")
log = logging.getLogger("mau.fmt.mx") # type: logging.Logger
should_bridge_plaintext_highlights = False # type: bool
command_regex = re.compile(r"^!([A-Za-z0-9@]+)") # type: Pattern
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)") # type: Pattern
plain_mention_regex = None # type: Optional[Pattern]
def plain_mention_to_html(match: Match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
return (f"{match.group(1)}"
f"<a href='https://matrix.to/#/{puppet.mxid}'>"
f"{puppet.displayname}"
"</a>")
return "".join(match.groups())
def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage:
if len(message) > 4096:
message = message[0:4082] + " [message cut]"
new_entities = []
for entity in entities:
if entity.offset > 4082:
continue
if entity.offset + entity.length > 4082:
entity.length = 4082 - entity.offset
new_entities.append(entity)
new_entities.append(MessageEntityItalic(4082, len(" [message cut]")))
entities = new_entities
return message, entities
MAX_LENGTH = 4096
CUTOFF_TEXT = " [message cut]"
CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT)
class FormatError(Exception):
pass
def matrix_to_telegram(html: str) -> ParsedMessage:
async def matrix_reply_to_telegram(
content: MessageEventContent, tg_space: TelegramID, room_id: RoomID | None = None
) -> TelegramID | None:
event_id = content.get_reply_to()
if not event_id:
return
content.trim_reply_fallback()
message = await DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
return message.tgid
return None
async def matrix_to_telegram(
client: TelegramClient, *, text: str | None = None, html: str | None = None
) -> tuple[str, list[TypeMessageEntity]]:
if html is not None:
return await _matrix_html_to_telegram(client, html)
elif text is not None:
return _matrix_text_to_telegram(text)
else:
raise ValueError("text or html must be provided to convert formatting")
async def _matrix_html_to_telegram(
client: TelegramClient, html: str
) -> tuple[str, list[TypeMessageEntity]]:
try:
html = command_regex.sub(r"<command>\1</command>", html)
html = html.replace("\t", " " * 4)
html = not_command_regex.sub(r"\1", html)
if should_bridge_plaintext_highlights:
html = plain_mention_regex.sub(plain_mention_to_html, html)
text, entities = parse_html(add_surrogates(html))
text = remove_surrogates(text.strip())
text, entities = cut_long_message(text, entities)
parsed = await MatrixParser(client).parse(add_surrogate(html))
text, entities = _cut_long_message(parsed.text, parsed.telegram_entities)
text = del_surrogate(strip_text(text, entities))
return text, entities
except Exception as e:
raise FormatError(f"Failed to convert Matrix format: {html}") from e
def matrix_reply_to_telegram(content: Dict[str, Any], tg_space: TelegramID,
room_id: Optional[MatrixRoomID] = None) -> Optional[TelegramID]:
try:
reply = content.get("m.relates_to", {}).get("m.in_reply_to", {})
if not reply:
return None
room_id = room_id or reply["room_id"]
event_id = reply["event_id"]
try:
if content["format"] == "org.matrix.custom.html":
content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"])
except KeyError:
pass
content["body"] = trim_reply_fallback_text(content["body"])
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
return message.tgid
except KeyError:
pass
return None
def _cut_long_message(
message: str, entities: list[TypeMessageEntity]
) -> tuple[str, list[TypeMessageEntity]]:
if len(message) > MAX_LENGTH:
message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT
new_entities = []
for entity in entities:
if entity.offset > CUT_MAX_LENGTH:
continue
if entity.offset + entity.length > CUT_MAX_LENGTH:
entity.length = CUT_MAX_LENGTH - entity.offset
new_entities.append(entity)
new_entities.append(MessageEntityItalic(CUT_MAX_LENGTH, len(CUTOFF_TEXT)))
entities = new_entities
return message, entities
def matrix_text_to_telegram(text: str) -> ParsedMessage:
def _matrix_text_to_telegram(text: str) -> tuple[str, list[TypeMessageEntity]]:
text = command_regex.sub(r"/\1", text)
text = text.replace("\t", " " * 4)
text = not_command_regex.sub(r"\1", text)
if should_bridge_plaintext_highlights:
entities, pmr_replacer = plain_mention_to_text()
text = plain_mention_regex.sub(pmr_replacer, text)
else:
entities = []
return text, entities
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], str]]:
entities = []
def replacer(match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
offset = match.start()
length = match.end() - offset
if puppet.username:
entity = MessageEntityMention(offset, length)
text = f"@{puppet.username}"
else:
entity = MessageEntityMentionName(offset, length, user_id=puppet.tgid)
text = puppet.displayname
entities.append(entity)
return text
return "".join(match.groups())
return entities, replacer
def init_mx(context: "Context") -> None:
global plain_mention_regex, should_bridge_plaintext_highlights
config = context.config
dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)")
dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+")
plain_mention_regex = re.compile(f"^({dn_template})")
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] or False
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
@@ -1,58 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Tuple
from html.parser import HTMLParser
class HTMLNode(list):
def __init__(self, tag: str, attrs: List[Tuple[str, str]]):
super().__init__()
self.tag = tag # type: str
self.text = "" # type: str
self.tail = "" # type: str
self.attrib = dict(attrs) # type: Dict[str, str]
class NodeifyingParser(HTMLParser):
def __init__(self):
super().__init__()
self.stack = [HTMLNode("html", [])] # type: List[HTMLNode]
def handle_starttag(self, tag, attrs):
node = HTMLNode(tag, attrs)
self.stack[-1].append(node)
self.stack.append(node)
def handle_endtag(self, tag):
if tag == self.stack[-1].tag:
self.stack.pop()
def handle_data(self, data):
if len(self.stack[-1]) > 0:
self.stack[-1][-1].tail += data
else:
self.stack[-1].text += data
def error(self, message):
pass
def read_html(data: str) -> HTMLNode:
parser = NodeifyingParser()
parser.feed(data)
return parser.stack[0]
@@ -1,11 +0,0 @@
from typing import Dict, List
class HTMLNode(List['HTMLNode']):
tag: str
text: str
tail: str
attrib: Dict[str, str]
def read_html(data: str) -> HTMLNode: ...
+61 -218
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@@ -14,236 +13,80 @@
#
# 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 List, Tuple, Pattern
import re
from __future__ import annotations
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
MessageEntityMentionName as MentionName, MessageEntityEmail as Email,
MessageEntityUrl as URL, MessageEntityTextUrl as TextURL,
MessageEntityBold as Bold, MessageEntityItalic as Italic,
MessageEntityCode as Code, MessageEntityPre as Pre,
TypeMessageEntity)
import logging
from ... import user as u, puppet as pu, portal as po
from ...types import MatrixUserID
from ..util import html_to_unicode
from .telegram_message import TelegramMessage, Entity, offset_length_multiply
from telethon import TelegramClient
from .html_reader import HTMLNode, read_html
from mautrix.types import RoomID, UserID
from mautrix.util.formatter import HTMLNode, MatrixParser as BaseMatrixParser, RecursionContext
from mautrix.util.logging import TraceLogger
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
from ... import portal as po, puppet as pu, user as u
from .telegram_message import TelegramEntityType, TelegramMessage
log: TraceLogger = logging.getLogger("mau.fmt.mx")
def parse_html(input_html: str) -> ParsedMessage:
return MatrixParser.parse(input_html)
class MatrixParser(BaseMatrixParser[TelegramMessage]):
e = TelegramEntityType
fs = TelegramMessage
client: TelegramClient
def __init__(self, client: TelegramClient) -> None:
self.client = client
class RecursionContext:
def __init__(self, strip_linebreaks: bool = True, ul_depth: int = 0):
self.strip_linebreaks = strip_linebreaks # type: bool
self.ul_depth = ul_depth # type: int
self._inited = True # type: bool
async def custom_node_to_fstring(
self, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage | None:
if node.tag == "command":
msg = await self.tag_aware_parse_node(node, ctx)
return msg.prepend("/").format(TelegramEntityType.COMMAND)
return None
def __setattr__(self, key, value):
if getattr(self, "_inited", False) is True:
raise TypeError("'RecursionContext' object is immutable")
super(RecursionContext, self).__setattr__(key, value)
def enter_list(self) -> 'RecursionContext':
return RecursionContext(strip_linebreaks=self.strip_linebreaks, ul_depth=self.ul_depth + 1)
def enter_code_block(self) -> 'RecursionContext':
return RecursionContext(strip_linebreaks=False, ul_depth=self.ul_depth)
class MatrixParser:
mention_regex = re.compile("https://matrix.to/#/(@.+:.+)") # type: Pattern
room_regex = re.compile("https://matrix.to/#/(#.+:.+)") # type: Pattern
block_tags = ("p", "pre", "blockquote",
"ol", "ul", "li",
"h1", "h2", "h3", "h4", "h5", "h6",
"div", "hr", "table") # type: Tuple[str, ...]
list_bullets = ("", "", "", "") # type: Tuple[str, ...]
@classmethod
def list_bullet(cls, depth: int) -> str:
return cls.list_bullets[(depth - 1) % len(cls.list_bullets)] + " "
@classmethod
def list_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
ordered = node.tag == "ol"
tagged_children = cls.node_to_tagged_tmessages(node, ctx)
counter = 1
indent_length = 0
if ordered:
async def user_pill_to_fstring(self, msg: TelegramMessage, user_id: UserID) -> TelegramMessage:
user = await pu.Puppet.get_by_mxid(user_id) or await u.User.get_by_mxid(
user_id, create=False
)
if not user:
return msg
if user.tg_username:
return TelegramMessage(f"@{user.tg_username}").format(TelegramEntityType.MENTION)
elif user.tgid:
displayname = user.plain_displayname or msg.text
msg = TelegramMessage(displayname)
try:
counter = int(node.attrib.get("start", "1"))
except ValueError:
counter = 1
longest_index = counter - 1 + len(tagged_children)
indent_length = len(str(longest_index))
indent = (indent_length + 4) * " "
children = [] # type: List[TelegramMessage]
for child, tag in tagged_children:
if tag != "li":
continue
if ordered:
prefix = f"{counter}. "
counter += 1
input_entity = await self.client.get_input_entity(user.tgid)
except (ValueError, TypeError) as e:
log.trace(f"Dropping mention of {user.tgid}: {e}")
else:
prefix = cls.list_bullet(ctx.ul_depth)
child = child.prepend(prefix)
parts = child.split("\n")
parts = parts[:1] + [part.prepend(indent) for part in parts[1:]]
child = TelegramMessage.join(parts, "\n")
children.append(child)
return TelegramMessage.join(children, "\n")
@classmethod
def blockquote_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
children = msg.trim().split("\n")
children = [child.prepend("> ") for child in children]
return TelegramMessage.join(children, "\n")
@classmethod
def header_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
children = cls.node_to_tmessages(node, ctx)
length = int(node.tag[1])
prefix = "#" * length + " "
return TelegramMessage.join(children, "").prepend(prefix).format(Bold)
@classmethod
def basic_format_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
if node.tag in ("b", "strong"):
msg.format(Bold)
elif node.tag in ("i", "em"):
msg.format(Italic)
elif node.tag == "command":
msg.format(Command)
elif node.tag in ("s", "strike", "del"):
msg.text = html_to_unicode(msg.text, "\u0336")
elif node.tag in ("u", "ins"):
msg.text = html_to_unicode(msg.text, "\u0332")
if node.tag in ("s", "strike", "del", "u", "ins"):
msg.entities = Entity.adjust(msg.entities, offset_length_multiply(2))
msg = msg.format(TelegramEntityType.MENTION_NAME, user_id=input_entity)
return msg
@classmethod
def link_to_tstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
href = node.attrib.get("href", "")
if not href:
return msg
async def url_to_fstring(self, msg: TelegramMessage, url: str) -> TelegramMessage:
if url == msg.text:
return msg.format(self.e.URL)
else:
return msg.format(self.e.INLINE_URL, url=url)
if href.startswith("mailto:"):
return TelegramMessage(href[len("mailto:"):]).format(Email)
async def room_pill_to_fstring(self, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage:
username = po.Portal.get_username_from_mx_alias(room_id)
portal = await po.Portal.find_by_username(username)
if portal and portal.username:
return TelegramMessage(f"@{portal.username}").format(TelegramEntityType.MENTION)
mention = cls.mention_regex.match(href)
if mention:
mxid = MatrixUserID(mention.group(1))
user = (pu.Puppet.get_by_mxid(mxid)
or u.User.get_by_mxid(mxid, create=False))
if not user:
return msg
if user.username:
return TelegramMessage(f"@{user.username}").format(Mention)
elif user.tgid:
displayname = user.plain_displayname or msg.text
return TelegramMessage(displayname).format(MentionName, user_id=user.tgid)
return msg
async def header_to_fstring(self, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
children = await self.node_to_fstrings(node, ctx)
length = int(node.tag[1])
prefix = "#" * length + " "
return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD)
room = cls.room_regex.match(href)
if room:
username = po.Portal.get_username_from_mx_alias(room.group(1))
portal = po.Portal.find_by_username(username)
if portal and portal.username:
return TelegramMessage(f"@{portal.username}").format(Mention)
async def color_to_fstring(self, msg: TelegramMessage, color: str) -> TelegramMessage:
return msg
return (msg.format(URL)
if msg.text == href
else msg.format(TextURL, url=href))
@classmethod
def node_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
if node.tag == "blockquote":
return cls.blockquote_to_tmessage(node, ctx)
elif node.tag == "ol":
return cls.list_to_tmessage(node, ctx)
elif node.tag == "ul":
return cls.list_to_tmessage(node, ctx.enter_list())
elif node.tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
return cls.header_to_tmessage(node, ctx)
elif node.tag == "br":
return TelegramMessage("\n")
elif node.tag in ("b", "strong", "i", "em", "s", "del", "u", "ins", "command"):
return cls.basic_format_to_tmessage(node, ctx)
elif node.tag == "a":
return cls.link_to_tstring(node, ctx)
elif node.tag == "p":
return cls.tag_aware_parse_node(node, ctx).append("\n")
elif node.tag == "pre":
lang = ""
try:
if node[0].tag == "code":
node = node[0]
lang = node.attrib["class"][len("language-"):]
except (IndexError, KeyError):
pass
return cls.parse_node(node, ctx.enter_code_block()).format(Pre, language=lang)
elif node.tag == "code":
return cls.parse_node(node, ctx.enter_code_block()).format(Code)
return cls.tag_aware_parse_node(node, ctx)
@staticmethod
def text_to_tmessage(text: str, ctx: RecursionContext) -> TelegramMessage:
if ctx.strip_linebreaks:
text = text.replace("\n", "")
return TelegramMessage(text)
@classmethod
def node_to_tagged_tmessages(cls, node: HTMLNode, ctx: RecursionContext
) -> List[Tuple[TelegramMessage, str]]:
output = []
if node.text:
output.append((cls.text_to_tmessage(node.text, ctx), "text"))
for child in node:
output.append((cls.node_to_tmessage(child, ctx), child.tag))
if child.tail:
output.append((cls.text_to_tmessage(child.tail, ctx), "text"))
return output
@classmethod
def node_to_tmessages(cls, node: HTMLNode, ctx: RecursionContext
) -> List[TelegramMessage]:
return [msg for (msg, tag) in cls.node_to_tagged_tmessages(node, ctx)]
@classmethod
def tag_aware_parse_node(cls, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage:
msgs = cls.node_to_tagged_tmessages(node, ctx)
output = TelegramMessage()
prev_was_block = False
for msg, tag in msgs:
if tag in cls.block_tags:
msg = msg.append("\n")
if not prev_was_block:
msg = msg.prepend("\n")
prev_was_block = True
output = output.append(msg)
return output.trim()
@classmethod
def parse_node(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
return TelegramMessage.join(cls.node_to_tmessages(node, ctx))
@classmethod
def parse(cls, data: str) -> ParsedMessage:
msg = cls.node_to_tmessage(read_html(f"<body>{data}</body>"), RecursionContext())
return msg.text, msg.entities
async def spoiler_to_fstring(self, msg: TelegramMessage, reason: str) -> TelegramMessage:
msg = msg.format(self.e.SPOILER)
if reason:
msg = msg.prepend(f"{reason}: ")
return msg

Some files were not shown because too many files have changed in this diff Show More