Compare commits

...

196 Commits

Author SHA1 Message Date
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
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
100 changed files with 5513 additions and 5220 deletions
+6
View File
@@ -2,3 +2,9 @@
.codeclimate.yml
*.png
*.md
logs
.venv
start
config.yaml
registration.yaml
*.db
+9 -9
View File
@@ -1,17 +1,17 @@
.idea/
/.idea/
.venv
env/
/.venv
/env/
pip-selfcheck.json
*.pyc
__pycache__
build
dist
*.egg-info
.eggs
/build
/dist
/*.egg-info
/.eggs
config.yaml
registration.yaml
/config.yaml
/registration.yaml
*.log*
*.db
*.bak
+38
View File
@@ -0,0 +1,38 @@
image: docker:stable
stages:
- build
- push
default:
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
build:
stage: build
script:
- docker pull $CI_REGISTRY_IMAGE:latest || true
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
push latest:
stage: push
only:
- master
variables:
GIT_STRATEGY: none
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
- docker push $CI_REGISTRY_IMAGE:latest
push tag:
stage: push
variables:
GIT_STRATEGY: none
except:
- master
script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
+16
View File
@@ -0,0 +1,16 @@
[settings]
line_length=99
indent=4
multi_line_output=5
sections=FUTURE,STDLIB,THIRDPARTY,TELETHON,MAUTRIX,FIRSTPARTY,LOCALFOLDER
no_lines_before=LOCALFOLDER
default_section=FIRSTPARTY
known_thirdparty=aiohttp,sqlalchemy,alembic,commonmark,ruamel.yaml,PIL,moviepy,prometheus_client,yarl,mako,pkg_resources
known_telethon=telethon,alchemysession,cryptg
known_mautrix=mautrix
balanced_wrapping=True
length_sort=True
+41 -8
View File
@@ -1,21 +1,49 @@
FROM docker.io/alpine:3.10
FROM docker.io/alpine:3.10 AS lottieconverter
WORKDIR /build
RUN apk add --no-cache git build-base cmake \
&& git clone https://github.com/Samsung/rlottie.git \
&& cd rlottie \
&& mkdir build \
&& cd build \
&& cmake .. \
&& make -j2 \
&& make install \
&& cd ../..
RUN apk add --no-cache libpng libpng-dev zlib zlib-dev \
&& git clone https://github.com/Eramde/LottieConverter.git \
&& cd LottieConverter \
&& git checkout 543c1d23ac9322f4f03c7fb6612ea7d026d44ac0 \
&& make
FROM docker.io/alpine:3.11
ENV UID=1337 \
GID=1337 \
FFMPEG_BINARY=/usr/bin/ffmpeg
COPY --from=lottieconverter /usr/lib/librlottie* /usr/lib/
COPY --from=lottieconverter /build/LottieConverter/dist/Debug/GNU-Linux/lottieconverter /usr/local/bin/lottieconverter
COPY . /opt/mautrix-telegram
WORKDIR /opt/mautrix-telegram
RUN apk add --no-cache \
RUN apk add --no-cache --virtual .build-deps \
python3-dev \
libffi-dev \
build-base \
git \
&& apk add --no-cache \
py3-virtualenv \
py3-pillow \
py3-aiohttp \
py3-magic \
py3-sqlalchemy \
py3-markdown \
py3-psycopg2 \
py3-ruamel.yaml \
# Indirect dependencies
py3-idna \
#commonmark
py3-future \
#alembic
@@ -24,20 +52,25 @@ RUN apk add --no-cache \
py3-markupsafe \
#moviepy
py3-decorator \
#py3-tqdm \
py3-tqdm \
py3-requests \
#imageio
py3-numpy \
#telethon
py3-rsa \
# cryptg
py3-cffi \
# Other dependencies
python3-dev \
libffi-dev \
build-base \
ffmpeg \
ca-certificates \
su-exec \
&& pip3 install .[all]
netcat-openbsd \
# lottieconverter
zlib libpng \
&& pip3 install .[speedups,hq_thumbnails,metrics] \
# pip installs the sources to /usr/lib/python3.8/site-packages, so we don't need them here
&& rm -rf /opt/mautrix-telegram/mautrix_telegram \
&& apk del .build-deps
VOLUME /data
+6
View File
@@ -1,4 +1,10 @@
# mautrix-telegram
![Languages](https://img.shields.io/github/languages/top/tulir/mautrix-telegram.svg)
[![License](https://img.shields.io/github/license/tulir/mautrix-telegram.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/tulir/mautrix-telegram/all.svg)](https://github.com/tulir/mautrix-telegram/releases)
[![GitLab CI](https://mau.dev/tulir/mautrix-telegram/badges/master/pipeline.svg)](https://mau.dev/tulir/mautrix-telegram/container_registry)
[![Maintainability](https://img.shields.io/codeclimate/maintainability/tulir/mautrix-telegram.svg)](https://codeclimate.com/github/tulir/mautrix-telegram)
A Matrix-Telegram hybrid puppeting/relaybot bridge.
### [Wiki](https://github.com/tulir/mautrix-telegram/wiki)
+4 -10
View File
@@ -7,7 +7,8 @@ from os.path import abspath, dirname
sys.path.insert(0, dirname(dirname(abspath(__file__))))
from mautrix_telegram.db import Base
from mautrix.util.db import Base
import mautrix_telegram.db
from mautrix_telegram.config import Config
from alchemysession import AlchemySessionContainer
@@ -18,17 +19,10 @@ 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"))
config.set_main_option("sqlalchemy.url", mxtg_config["appservice.database"].replace("%", "%%"))
class FakeDB:
@staticmethod
def query_property():
return None
AlchemySessionContainer.create_table_classes(FakeDB(), "telethon_", Base)
AlchemySessionContainer.create_table_classes(None, "telethon_", Base)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
@@ -0,0 +1,26 @@
"""Switch mx_user_profile to native enum
Revision ID: 4f7d7ed5792a
Revises: 9e9c89b0b877
Create Date: 2019-08-04 17:47:36.568120
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '4f7d7ed5792a'
down_revision = '9e9c89b0b877'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
conn.execute("UPDATE mx_user_profile SET membership=UPPER(membership)")
conn.execute("UPDATE mx_user_profile SET membership='LEAVE' WHERE membership='LEFT'")
def downgrade():
conn = op.get_bind()
conn.execute("UPDATE mx_user_profile SET membership=LOWER(membership)")
@@ -5,14 +5,16 @@ 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 alembic import context, op
import sqlalchemy.orm as orm
import sqlalchemy as sa
from mautrix.util.db import Base
from mautrix_telegram.config import Config
from mautrix_telegram.db import Base
# revision identifiers, used by Alembic.
revision = "6ca3d74d51e4"
@@ -22,7 +24,6 @@ depends_on = None
class RoomState(Base):
query = None
__tablename__ = "mx_room_state"
__table_args__ = {"extend_existing": True}
@@ -31,7 +32,6 @@ class RoomState(Base):
class UserProfile(Base):
query = None
__tablename__ = "mx_user_profile"
__table_args__ = {"extend_existing": True}
@@ -43,7 +43,6 @@ class UserProfile(Base):
class Puppet(Base):
query = None
__tablename__ = "puppet"
__table_args__ = {"extend_existing": True}
@@ -83,7 +82,7 @@ def upgrade():
def migrate_state_store():
conn = op.get_bind()
session = orm.sessionmaker(bind=conn)() # type: orm.Session
session: orm.Session = orm.sessionmaker(bind=conn)()
try:
with open("mx-state.json") as file:
@@ -0,0 +1,26 @@
"""Store custom puppet next_batch in database
Revision ID: a7c04a56041b
Revises: 4f7d7ed5792a
Create Date: 2019-08-06 23:08:51.087651
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a7c04a56041b"
down_revision = "4f7d7ed5792a"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.add_column(sa.Column("next_batch", sa.String(), nullable=True))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column("next_batch")
+91 -23
View File
@@ -1,9 +1,9 @@
# Homeserver details
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://matrix.org
address: https://example.com
# The domain of the homeserver (for MXIDs, etc).
domain: matrix.org
domain: example.com
# Whether or not to verify the SSL certificate of the homeserver.
# Only applies if address starts with https://
verify_ssl: true
@@ -12,11 +12,11 @@ homeserver:
# 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
address: http://localhost:29317
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 8080
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
@@ -33,7 +33,7 @@ appservice:
# the HS database.
public:
# Whether or not the public-facing endpoints should be enabled.
enabled: true
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
@@ -73,6 +73,17 @@ 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.
@@ -103,6 +114,8 @@ bridge:
- full name
- username
- phone number
# Maximum length of displayname
displayname_max_length: 100
# 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
@@ -119,9 +132,10 @@ bridge:
# 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
# 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
@@ -137,13 +151,15 @@ bridge:
plaintext_highlights: false
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
public_portals: true
# Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down.
# Currently only works for private chats and normal groups.
# WARNING: This feature seems to be broken in the telegram library.
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
# Shared secret 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.
login_shared_secret: null
# 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.
@@ -153,6 +169,33 @@ bridge:
image_as_file_size: 10
# Maximum size of Telegram documents in megabytes to bridge.
max_document_size: 100
# Enable experimental parallel file transfer, which makes uploads/downloads much faster by
# streaming from/to Matrix and using many connections for Telegram.
# Note that generating HQ thumbnails for videos is not possible with streamed transfers.
parallel_file_transfer: false
# Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated.
federate_rooms: true
# Settings for converting animated stickers.
animated_sticker:
# Format to which animated stickers should be converted.
# disable - No conversion, send as-is (gzipped lottie)
# png - converts to non-animated png (fastest),
# gif - converts to animated gif, but loses transparency
# webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support
target: gif
# Arguments for converter. All converters take width and height.
# GIF converter takes background as a hex color.
args:
width: 256
height: 256
background: "020202" # only for gif
fps: 30 # only for webm
# 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
@@ -175,24 +218,30 @@ bridge:
# 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.
# 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)
# $message - The message content as HTML
# $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
message_formats:
m.text: "<b>$sender_displayname</b>: $message"
m.notice: "<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"
# 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.
#
@@ -240,6 +289,22 @@ bridge:
# 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.
@@ -307,14 +372,14 @@ telegram:
# Telethon proxy configuration.
# You must install PySocks from pip for proxies to work.
proxy:
# Allowed types: disabled, socks4, socks5, http
# 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.
# Whether or not to perform DNS resolving remotely. Only for socks/http proxies.
rdns: true
# Proxy authentication (optional).
# Proxy authentication (optional). Put MTProxy secret in password field.
username: ""
password: ""
@@ -325,18 +390,21 @@ telegram:
logging:
version: 1
formatters:
precise:
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: precise
formatter: normal
filename: ./mautrix-telegram.log
maxBytes: 10485760
backupCount: 10
console:
class: logging.StreamHandler
formatter: precise
formatter: colored
loggers:
mau:
level: DEBUG
+2
View File
@@ -0,0 +1,2 @@
[*.{yaml,yml}]
indent_size = 2
+1
View File
@@ -0,0 +1 @@
charts/*
+22
View File
@@ -0,0 +1,22 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
+14
View File
@@ -0,0 +1,14 @@
apiVersion: v1
name: mautrix-telegram
version: 0.1.0
appVersion: "0.7.0"
description: A Matrix-Telegram hybrid puppeting/relaybot bridge.
keywords:
- matrix
- bridge
- telegram
maintainers:
- name: Tulir Asokan
email: tulir@maunium.net
sources:
- https://github.com/tulir/mautrix-telegram
+6
View File
@@ -0,0 +1,6 @@
dependencies:
- name: postgresql
repository: https://kubernetes-charts.storage.googleapis.com/
version: 6.5.0
digest: sha256:85139e9d4207e49c11c5f84d7920d0135cffd3d427f3f3638d4e51258990de2a
generated: "2019-10-23T22:11:37.005827507+03:00"
+5
View File
@@ -0,0 +1,5 @@
dependencies:
- name: postgresql
version: 6.5.0
repository: https://kubernetes-charts.storage.googleapis.com/
condition: postgresql.enabled
+21
View File
@@ -0,0 +1,21 @@
Your registration file is below. Save it into a YAML file and give the path to that file to synapse:
id: {{ .Values.appservice.id }}
as_token: {{ .Values.appservice.asToken }}
hs_token: {{ .Values.appservice.hsToken }}
namespaces:
users:
- exclusive: true
regex: "@{{ .Values.bridge.username_template | replace "{userid}" ".+"}}:{{ .Values.homeserver.domain }}"
{{- if .Values.appservice.communityID }}
group_id: {{ .Values.appservice.communityID }}
{{- end }}
aliases:
- exclusive: true
regex: "@{{ .Values.bridge.alias_template | replace "{groupname}" ".+"}}:{{ .Values.homeserver.domain }}"
{{- if .Values.appservice.communityID }}
group_id: {{ .Values.appservice.communityID }}
{{- end }}
url: {{ .Values.appservice.address }}
sender_localpart: {{ .Values.appservice.botUsername }}
rate_limited: false
@@ -0,0 +1,55 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "mautrix-telegram.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "mautrix-telegram.fullname" -}}
{{- if .Values.fullnameOverride -}}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- $name := default .Chart.Name .Values.nameOverride -}}
{{- if contains $name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "mautrix-telegram.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{/*
Common labels
*/}}
{{- define "mautrix-telegram.labels" -}}
app.kubernetes.io/name: {{ include "mautrix-telegram.name" . }}
helm.sh/chart: {{ include "mautrix-telegram.chart" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}
{{/*
Create the name of the service account to use
*/}}
{{- define "mautrix-telegram.serviceAccountName" -}}
{{- if .Values.serviceAccount.create -}}
{{ default (include "mautrix-telegram.fullname" .) .Values.serviceAccount.name }}
{{- else -}}
{{ default "default" .Values.serviceAccount.name }}
{{- end -}}
{{- end -}}
@@ -0,0 +1,57 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ template "mautrix-telegram.fullname" . }}
labels:
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/instance: {{ .Release.Name }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
app.kubernetes.io/name: {{ template "mautrix-telegram.name" . }}
data:
config.yaml: |
homeserver:
address: {{ .Values.homeserver.address }}
domain: {{ .Values.homeserver.domain }}
verify_ssl: {{ .Values.homeserver.verifySSL }}
appservice:
address: http://{{ include "mautrix-telegram.fullname" . }}:{{ .Values.service.port }}
hostname: 0.0.0.0
port: {{ .Values.service.port }}
max_body_size: {{ .Values.appservice.maxBodySize }}
{{- if .Values.postgresql.enabled }}
database: "postgres://postgres:{{ .Values.postgresql.postgresqlPassword }}@{{ .Release.Name }}-postgresql/{{ .Values.postgresql.postgresqlDatabase }}"
{{- else }}
database: {{ .Values.appservice.database | quote }}
{{- end }}
public:
{{- toYaml .Values.appservice.public | nindent 8 }}
provisioning:
{{- toYaml .Values.appservice.provisioning | nindent 8 }}
id: {{ .Values.appservice.id }}
bot_username: {{ .Values.appservice.botUsername }}
bot_displayname: {{ .Values.appservice.botDisplayname }}
bot_avatar: {{ .Values.appservice.botAvatar }}
community_id: {{ .Values.appservice.communityID }}
as_token: {{ .Values.appservice.asToken }}
hs_token: {{ .Values.appservice.hsToken }}
metrics:
{{- toYaml .Values.metrics | nindent 6 }}
bridge:
{{- toYaml .Values.bridge | nindent 6 }}
telegram:
{{- toYaml .Values.telegram | nindent 6 }}
logging:
{{- toYaml .Values.logging | nindent 6 }}
registration.yaml: ""
@@ -0,0 +1,69 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mautrix-telegram.fullname" . }}
labels:
{{- include "mautrix-telegram.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: {{ include "mautrix-telegram.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
template:
{{- if .Values.podAnnotations }}
annotations:
{{- toYaml .Values.podAnnotations | nindent 6 }}
{{- end }}
metadata:
labels:
app.kubernetes.io/name: {{ include "mautrix-telegram.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
spec:
serviceAccountName: {{ template "mautrix-telegram.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
volumeMounts:
- mountPath: /data
name: config-volume
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /_matrix/mau/live
port: http
initialDelaySeconds: 60
periodSeconds: 5
readinessProbe:
httpGet:
path: /_matrix/mau/ready
port: http
initialDelaySeconds: 60
periodSeconds: 5
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: config-volume
configMap:
name: {{ template "mautrix-telegram.fullname" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "mautrix-telegram.fullname" . }}
labels:
{{ include "mautrix-telegram.labels" . | indent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
app.kubernetes.io/name: {{ include "mautrix-telegram.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
@@ -0,0 +1,8 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ template "mautrix-telegram.serviceAccountName" . }}
labels:
{{ include "mautrix-telegram.labels" . | indent 4 }}
{{- end -}}
+141
View File
@@ -0,0 +1,141 @@
image:
repository: dock.mau.dev/tulir/mautrix-telegram
tag: latest
pullPolicy: IfNotPresent
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name:
service:
type: ClusterIP
port: 29317
resources: {}
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
nodeSelector: {}
tolerations: []
affinity: {}
# Postgres pod configs
postgresql:
enabled: true
postgresqlDatabase: mxtg
postgresqlPassword: SET TO RANDOM STRING
persistence:
size: 2Gi
resources:
requests:
memory: 256Mi
cpu: 100m
# 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://
verifySSL: true
# Application service host/registration related details
# Changing these values requires regeneration of the registration.
appservice:
# 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
maxBodySize: 1
# 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.
shared_secret: SET TO RANDOM STRING
id: telegram
botUsername: telegrambot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
botDisplayname: Telegram bridge bot
botAvatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
# Community ID for bridged users (changes registration file) and rooms.
# Must be created manually.
communityID: false
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
asToken: SET TO RANDOM STRING
hsToken: SET TO RANDOM STRING
# The keys below can be used to override the configs in the base config:
# https://github.com/tulir/mautrix-telegram/blob/master/example-config.yaml
# Note that the "appservice" and "homeserver" sections are above and slightly different than the base.
# 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}"
# 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"
# Prometheus telemetry config.
metrics:
enabled: false
listen_port: 8000
# 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: 123456789:
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.6.0"
__version__ = "0.7.0rc4"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+68 -118
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,150 +13,101 @@
#
# 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
import os
from typing import Optional
from itertools import chain
import sqlalchemy as sql
from mautrix_appservice import AppService
from alchemysession import AlchemySessionContainer
from mautrix.bridge import Bridge
from mautrix.util.db import Base
from .web.provisioning import ProvisioningAPI
from .web.public import PublicBridgeWebsite
from .commands.manhole import ManholeState
from .abstract_user import init as init_abstract_user
from .bot import init as init_bot
from .bot import Bot, init as init_bot
from .config import Config
from .context import Context
from .db import Base, init as init_db
from .db import 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 .puppet import Puppet, init as init_puppet
from .sqlstatestore import SQLStateStore
from .user import User, init as init_user
from . import __version__
from .version import version, linkified_version
try:
import prometheus_client as prometheus
except ImportError:
prometheus = None
parser = argparse.ArgumentParser(
description="A Matrix-Telegram puppeting bridge.",
prog="python -m mautrix-telegram")
parser.add_argument("-c", "--config", type=str, default="config.yaml",
metavar="<path>", help="the path to your config file")
parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml",
metavar="<path>", help="the path to the example config "
"(for automatic config updates)")
parser.add_argument("-g", "--generate-registration", action="store_true",
help="generate registration and quit")
parser.add_argument("-r", "--registration", type=str, default="registration.yaml",
metavar="<path>", help="the path to save the generated registration to")
args = parser.parse_args()
config = Config(args.config, args.registration, args.base_config, os.environ)
config.load()
config.update()
class TelegramBridge(Bridge):
name = "mautrix-telegram"
command = "python -m mautrix-telegram"
description = "A Matrix-Telegram puppeting bridge."
repo_url = "https://github.com/tulir/mautrix-telegram"
real_user_content_key = "net.maunium.telegram.puppet"
version = version
markdown_version = linkified_version
config_class = Config
matrix_class = MatrixHandler
state_store_class = SQLStateStore
if args.generate_registration:
config.generate_registration()
config.save()
print(f"Registration generated and saved to {config.registration_path}")
sys.exit(0)
config: Config
session_container: AlchemySessionContainer
bot: Bot
manhole: Optional[ManholeState]
logging.config.dictConfig(copy.deepcopy(config["logging"]))
log = logging.getLogger("mau.init") # type: logging.Logger
log.debug(f"Initializing mautrix-telegram {__version__}")
def prepare_db(self) -> None:
super().prepare_db()
init_db(self.db)
self.session_container = AlchemySessionContainer(
engine=self.db, table_base=Base, session=False,
table_prefix="telethon_", manage_tables=False)
db_engine = sql.create_engine(config["appservice.database"] or "sqlite:///mautrix-telegram.db")
Base.metadata.bind = db_engine
def _prepare_website(self, context: Context) -> None:
if self.config["appservice.public.enabled"]:
public_website = PublicBridgeWebsite(self.loop)
self.az.app.add_subapp(self.config["appservice.public.prefix"], public_website.app)
context.public_website = public_website
session_container = AlchemySessionContainer(engine=db_engine, table_base=Base, session=False,
table_prefix="telethon_", manage_tables=False)
session_container.core_mode = True
if self.config["appservice.provisioning.enabled"]:
provisioning_api = ProvisioningAPI(context)
self.az.app.add_subapp(self.config["appservice.provisioning.prefix"],
provisioning_api.app)
context.provisioning_api = provisioning_api
try:
import uvloop
if self.config["metrics.enabled"]:
if prometheus:
prometheus.start_http_server(self.config["metrics.listen_port"])
else:
self.log.warning("Metrics are enabled in the config, "
"but prometheus_client is not installed.")
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
log.debug("Using uvloop for asyncio")
except ImportError:
pass
def prepare_bridge(self) -> None:
self.bot = init_bot(self.config)
context = Context(self.az, self.config, self.loop, self.session_container, self, self.bot)
self._prepare_website(context)
self.matrix = context.mx = MatrixHandler(context)
self.manhole = None
loop = asyncio.get_event_loop() # type: asyncio.AbstractEventLoop
init_abstract_user(context)
init_formatter(context)
init_portal(context)
puppet_startup = init_puppet(context)
user_startup = init_user(context)
bot_startup = [self.bot.start()] if self.bot else []
self.startup_actions = chain(puppet_startup, user_startup, bot_startup)
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:
for puppet in Puppet.by_custom_mxid.values():
puppet.stop()
self.shutdown_actions = (user.stop() for user in User.by_tgid.values())
if self.manhole:
self.manhole.close()
self.manhole = None
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
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
context.mx = MatrixHandler(context)
if config["metrics.enabled"]:
if prometheus:
prometheus.start_http_server(config["metrics.listen_port"])
else:
log.warn("Metrics are enabled in the config, but prometheus-async is not installed.")
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]]
if context.bot:
startup_actions.append(context.bot.start())
signal.signal(signal.SIGINT, signal.default_int_handler)
signal.signal(signal.SIGTERM, signal.default_int_handler)
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)
TelegramBridge().run()
+105 -77
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,28 +13,33 @@
#
# 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, Dict, TYPE_CHECKING
from typing import Tuple, Optional, Union, Dict, Type, Any, TYPE_CHECKING
from abc import ABC, abstractmethod
import asyncio
import logging
import platform
import time
from telethon.sessions import Session
from telethon.network import (ConnectionTcpMTProxyRandomizedIntermediate, ConnectionTcpFull,
Connection)
from telethon.tl.patched import MessageService, Message
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, PeerUser, TypeUpdate, UpdateChatPinnedMessage,
UpdateChannelPinnedMessage, UpdateChatParticipantAdmin, UpdateChatParticipants,
UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateNewMessage, UpdateDeleteMessages,
UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateReadHistoryOutbox,
UpdateShortChatMessage, UpdateShortMessage, UpdateUserName, UpdateUserPhoto, UpdateUserStatus,
UpdateUserTyping, User, UserStatusOffline, UserStatusOnline)
from mautrix_appservice import MatrixRequestError, AppService
from mautrix.types import UserID, PresenceState
from mautrix.errors import MatrixError
from mautrix.appservice import AppService
from alchemysession import AlchemySessionContainer
from . import portal as po, puppet as pu, __version__
from .db import Message as DBMessage
from .types import TelegramID, MatrixUserID
from .types import TelegramID
from .tgclient import MautrixTelegramClient
if TYPE_CHECKING:
@@ -43,9 +47,9 @@ if TYPE_CHECKING:
from .config import Config
from .bot import Bot
config = None # type: Config
config: Optional['Config'] = None
# Value updated from config in init()
MAX_DELETIONS = 10 # type: int
MAX_DELETIONS: int = 10
UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage]
@@ -60,47 +64,67 @@ except ImportError:
Histogram = None
UPDATE_TIME = None
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
session_container: AlchemySessionContainer = None
loop: asyncio.AbstractEventLoop = None
log: logging.Logger
az: AppService
relaybot: Optional['Bot']
ignore_incoming_bot_events: bool = True
client: Optional[MautrixTelegramClient]
mxid: Optional[UserID]
tgid: Optional[TelegramID]
username: Optional['str']
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
self.relaybot = None
@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]]:
def _proxy_settings(self) -> Tuple[Type[Connection], Optional[Tuple[Any, ...]]]:
proxy_type = config["telegram.proxy.type"].lower()
connection = ConnectionTcpFull
connection_data = (config["telegram.proxy.address"],
config["telegram.proxy.port"],
config["telegram.proxy.rdns"],
config["telegram.proxy.username"],
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:
self.log.debug(f"Initializing client for {self.name}")
@@ -119,6 +143,9 @@ class AbstractUser(ABC):
device = config["telegram.device_info.device_model"]
sysversion = config["telegram.device_info.system_version"]
appversion = config["telegram.device_info.app_version"]
connection, proxy = self._proxy_settings
assert isinstance(self.session, Session)
self.client = MautrixTelegramClient(
session=self.session,
@@ -127,16 +154,18 @@ class AbstractUser(ABC):
api_hash=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,
system_version=(MautrixTelegramClient.__version__
if sysversion == "auto" else sysversion),
device_model=(f"{platform.system()} {platform.release()}"
if device == "auto" else device),
timeout=config["telegram.connection.timeout"],
connection_retries=config["telegram.connection.retries"],
retry_delay=config["telegram.connection.retry_delay"],
flood_sleep_threshold=config["telegram.connection.flood_sleep_threshold"],
request_retries=config["telegram.connection.request_retries"],
proxy=self._proxy_settings,
connection=connection,
proxy=proxy,
loop=self.loop,
base_logger=base_logger
@@ -165,26 +194,18 @@ class AbstractUser(ABC):
if not await self.update(update):
await self._update(update)
except Exception:
self.log.exception("Failed to handle Telegram update")
self.log.exception(f"Failed to handle Telegram update {update}")
if UPDATE_TIME:
UPDATE_TIME.labels(update_type=type(update).__name__).observe(time.time() - start_time)
async def get_dialogs(self, limit: int = None) -> List[Union[Chat, Channel]]:
if self.is_bot:
return []
dialogs = await self.client.get_dialogs(limit=limit)
return [dialog.entity for dialog in dialogs if (
not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden))
and not (isinstance(dialog.entity, Chat)
and (dialog.entity.deactivated or dialog.entity.left)))]
@property
@abstractmethod
def name(self) -> str:
raise NotImplementedError()
async def is_logged_in(self) -> bool:
return self.client and self.client.is_connected() and await self.client.is_user_authorized()
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
@@ -195,14 +216,15 @@ class AbstractUser(ABC):
if not self.client:
self._init_client()
await self.client.connect()
self.log.debug("%s connected: %s", self.mxid, self.connected)
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:
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):
self.log.debug("Starting client due to ensure_started"
f"(even_if_no_session={even_if_no_session})")
await self.start(delete_unless_authenticated=not even_if_no_session)
return self
@@ -273,7 +295,7 @@ class AbstractUser(ABC):
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 = po.Portal.get_by_tgid(TelegramID(update.chat_id))
if not portal or not portal.mxid:
return
@@ -283,7 +305,7 @@ class AbstractUser(ABC):
if isinstance(update, UpdateUserTyping):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
else:
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
if not portal or not portal.mxid:
return
@@ -296,7 +318,7 @@ class AbstractUser(ABC):
try:
users = (entity for entity in entities.values() if isinstance(entity, User))
puppets = ((pu.Puppet.get(TelegramID(user.id)), user) for user in users)
await asyncio.gather(*[puppet.update_info(self, info)
await asyncio.gather(*[puppet.try_update_info(self, info)
for puppet, info in puppets if puppet])
except Exception:
self.log.exception("Failed to handle entity updates")
@@ -317,9 +339,9 @@ class AbstractUser(ABC):
async def update_status(self, update: UpdateUserStatus) -> None:
puppet = pu.Puppet.get(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)
return
@@ -328,7 +350,9 @@ class AbstractUser(ABC):
Optional[pu.Puppet],
Optional[po.Portal]]:
if isinstance(update, UpdateShortChatMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
if not portal:
self.log.warning(f"Received message in chat with unknown type {update.chat_id}")
sender = pu.Puppet.get(TelegramID(update.from_id))
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
@@ -355,7 +379,7 @@ class AbstractUser(ABC):
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:
@@ -363,12 +387,10 @@ class AbstractUser(ABC):
return
for message_id in update.messages:
messages = DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid)
for message in messages:
for message in DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid):
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(message)
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
@@ -378,20 +400,29 @@ class AbstractUser(ABC):
channel_id = TelegramID(update.channel_id)
for message_id in update.messages:
messages = DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id)
for message in messages:
for message in DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id):
message.delete()
await self._try_redact(message)
async def update_message(self, original_update: UpdateMessage) -> None:
update, sender, portal = self.get_message_details(original_update)
if self.is_bot and not portal.mxid:
self.log.debug(f"Ignoring message received by bot in unbridged chat %s",
portal.tgid_log)
if not portal:
return
elif portal and not portal.allow_bridging:
self.log.debug(f"Ignoring message in portal {portal.tgid_log} (bridging disallowed)")
return
if self.ignore_incoming_bot_events and self.bot and sender.id == self.bot.tgid:
if self.is_relaybot:
if update.is_private:
if not config["bridge.relaybot.private_chat.invite"]:
self.log.debug(f"Ignoring private message to bot from {sender.id}")
return
elif not portal.mxid and config["bridge.relaybot.ignore_unbridged_group_chat"]:
self.log.debug("Ignoring message received by bot"
f" in unbridged chat {portal.tgid_log}")
return
if self.ignore_incoming_bot_events and self.relaybot and sender.id == self.relaybot.tgid:
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update, portal.tgid_log)
return
@@ -405,17 +436,14 @@ class AbstractUser(ABC):
sender.id)
return await portal.handle_telegram_action(self, sender, update)
user = sender.tgid if sender else "admin"
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
return await portal.handle_telegram_edit(self, sender, update)
self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
return await portal.handle_telegram_message(self, sender, update)
# endregion
def init(context: "Context") -> None:
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"]
+62 -50
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,21 +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 Awaitable, Callable, Dict, List, Optional, Pattern, Tuple, TYPE_CHECKING
from typing import Awaitable, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
import logging
import re
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (
ChannelParticipantAdmin, ChannelParticipantCreator, ChatForbidden, ChatParticipantAdmin,
ChatParticipantCreator, InputChannel, InputUser, MessageActionChatAddUser,
ChatParticipantCreator, InputChannel, InputUser, MessageActionChatAddUser, PeerUser,
MessageActionChatDeleteUser, MessageEntityBotCommand, PeerChannel, PeerChat, TypePeer,
UpdateNewChannelMessage, UpdateNewMessage, MessageActionChatMigrateTo, User)
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
from telethon.errors import ChannelInvalidError, ChannelPrivateError
from .types import MatrixUserID
from mautrix.types import UserID
from .abstract_user import AbstractUser
from .db import BotChat
from .types import TelegramID
@@ -36,34 +35,41 @@ 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
config: Optional['Config'] = None
ReplyFunc = Callable[[str], Awaitable[Message]]
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: Optional[User]
_me_mxid: Optional[UserID]
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.token = token
self.tgid = None
self.mxid = None
self.puppet_whitelisted = True
self.whitelisted = True
self.relaybot_whitelisted = True
self.username = None
self.is_relaybot = True
self.is_bot = True
self.chats = {}
self.tg_whitelist = []
self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"]
or False) # type: bool
self._me_info = None # type: Optional[User]
self._me_mxid = None # type: Optional[MatrixUserID]
or False)
self._me_info = None
self._me_mxid = None
async def get_me(self, use_cache: bool = True) -> Tuple[User, MatrixUserID]:
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))
@@ -92,7 +98,7 @@ class Bot(AbstractUser):
async def post_login(self) -> None:
await self.init_permissions()
info = await self.client.get_me()
self.tgid = info.id
self.tgid = TelegramID(info.id)
self.username = info.username
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
@@ -102,21 +108,15 @@ class Bot(AbstractUser):
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
self.remove_chat(TelegramID(chat.id))
channel_ids = [InputChannel(chat_id, 0)
channel_ids = (InputChannel(chat_id, 0)
for chat_id, chat_type in self.chats.items()
if chat_type == "channel"]
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))
if config["bridge.catch_up"]:
try:
await self.client.catch_up()
except Exception:
self.log.exception("Failed to run catch_up() for bot")
def register_portal(self, portal: po.Portal) -> None:
self.add_chat(portal.tgid, portal.peer_type)
@@ -133,7 +133,7 @@ class Bot(AbstractUser):
del self.chats[chat_id]
except KeyError:
pass
BotChat.delete(chat_id)
BotChat.delete_by_id(chat_id)
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
if tgid in self.tg_whitelist:
@@ -157,7 +157,7 @@ class Bot(AbstractUser):
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):
if not await self._can_use_commands(event.to_id, TelegramID(event.from_id)):
await reply("You do not have the permission to use that command.")
return False
return True
@@ -166,7 +166,7 @@ class Bot(AbstractUser):
if not 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)
@@ -179,15 +179,15 @@ class Bot(AbstractUser):
"Portal is not public. Use `/invite <mxid>` to get an invite.")
async def handle_command_invite(self, portal: po.Portal, reply: ReplyFunc,
mxid_input: MatrixUserID) -> Message:
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):
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_by_mxid(mxid_input).ensure_started()
if not user.relaybot_whitelisted:
return await reply("That user is not whitelisted to use the bridge.")
elif await user.is_logged_in():
@@ -195,7 +195,7 @@ class Bot(AbstractUser):
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)
await portal.main_intent.invite_user(portal.mxid, user.mxid)
return await reply(f"Invited `{user.mxid}` to the portal.")
@staticmethod
@@ -204,7 +204,12 @@ class Bot(AbstractUser):
# 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.from_id}.")
else:
return reply("Failed to find chat ID.")
def match_command(self, text: str, command: str) -> bool:
text = text.lower()
@@ -221,15 +226,23 @@ class Bot(AbstractUser):
return False
async def handle_command(self, message: Message) -> None:
async def handle_command(self, message: Message) -> Optional[bool]:
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 self.match_command(text, "start"):
pcm = config["bridge.relaybot.private_chat.message"]
if not pcm:
return True
await reply(pcm)
return
elif self.match_command(text, "id"):
await self.handle_command_id(message, reply)
return
elif message.is_private:
return
portal = po.Portal.get_by_entity(message.to_id)
@@ -244,15 +257,15 @@ class Bot(AbstractUser):
mxid = text[text.index(" ") + 1:]
except ValueError:
mxid = ""
await self.handle_command_invite(portal, reply, mxid_input=mxid)
await self.handle_command_invite(portal, reply, mxid_input=UserID(mxid))
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
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
@@ -277,8 +290,7 @@ class Bot(AbstractUser):
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
return not await self.handle_command(update.message)
return False
def is_in_chat(self, peer_id) -> bool:
+8 -5
View File
@@ -1,5 +1,8 @@
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 (command_handler, CommandHandler, CommandProcessor, CommandEvent,
SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT,
SECTION_MISC, SECTION_ADMIN)
from . import portal, telegram, clean_rooms, matrix_auth, manhole
__all__ = ["command_handler", "CommandHandler", "CommandProcessor", "CommandEvent",
"SECTION_AUTH", "SECTION_MISC", "SECTION_ADMIN", "SECTION_CREATING_PORTALS",
"SECTION_PORTAL_MANAGEMENT"]
+24 -25
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,41 +13,41 @@
#
# 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 typing import List, NamedTuple, Tuple, Union
from mautrix_appservice import MatrixRequestError, IntentAPI
from mautrix.appservice import IntentAPI
from mautrix.errors import MatrixRequestError
from mautrix.types import RoomID, UserID, EventID
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])
ManagementRoom = NamedTuple('ManagementRoom', room_id=RoomID, user_id=UserID)
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[MatrixRoomID],
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[RoomID],
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]
management_rooms: List[ManagementRoom] = []
unidentified_rooms: List[RoomID] = []
portals: List[po.Portal] = []
empty_portals: 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)
for room_id in rooms:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
try:
members = await intent.get_room_members(room)
members = await intent.get_room_members(room_id)
except MatrixRequestError:
members = []
if len(members) == 2:
other_member = MatrixUserID(members[0] if members[0] != intent.mxid else members[1])
other_member = members[0] if members[0] != intent.mxid else members[1]
if pu.Puppet.get_id_from_mxid(other_member):
unidentified_rooms.append(room)
unidentified_rooms.append(room_id)
else:
management_rooms.append(ManagementRoom((room, other_member)))
management_rooms.append(ManagementRoom(room_id, other_member))
else:
unidentified_rooms.append(room)
unidentified_rooms.append(room_id)
else:
members = await portal.get_authenticated_matrix_users()
if len(members) == 0:
@@ -62,7 +61,7 @@ async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[Mat
@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]:
async def clean_rooms(evt: CommandEvent) -> EventID:
management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent)
reply = ["#### Management rooms (M)"]
@@ -108,10 +107,10 @@ async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
unidentified_rooms: List[MatrixRoomID], portals: List["po.Portal"],
unidentified_rooms: List[RoomID], portals: List["po.Portal"],
empty_portals: List["po.Portal"]) -> None:
command = evt.args[0]
rooms_to_clean = [] # type: List[Union[po.Portal, MatrixRoomID]]
rooms_to_clean: List[Union[po.Portal, RoomID]] = []
if command == "clean-recommended":
rooms_to_clean += empty_portals
rooms_to_clean += unidentified_rooms
@@ -156,11 +155,11 @@ async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
"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"
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:
async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, RoomID]]) -> 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.")
@@ -169,8 +168,8 @@ async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, Matrix
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")
else:
await po.Portal.cleanup_room(evt.az.intent, room, "Room deleted")
cleaned += 1
evt.sender.command_status = None
await evt.reply(f"{cleaned} rooms cleaned up successfully.")
+64 -297
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -15,23 +14,23 @@
# 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
import commonmark
from typing import Awaitable, Callable, List, Optional, NamedTuple, Any
from telethon.errors import FloodWaitError
from ..types import MatrixRoomID, MatrixEventID
from mautrix.types import RoomID, EventID, MessageEventContent
from mautrix.bridge.commands import (HelpSection, CommandEvent as BaseCommandEvent,
CommandHandler as BaseCommandHandler,
CommandProcessor as BaseCommandProcessor,
CommandHandlerFunc, command_handler as base_command_handler)
from ..util import format_duration
from .. import user as u, context as c
command_handlers = {} # type: Dict[str, CommandHandler]
HelpCacheKey = NamedTuple('HelpCacheKey',
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,186 +38,47 @@ 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
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,
is_management: bool, is_portal: bool) -> None:
super().__init__(processor, room_id, event_id, sender, command, args, content,
is_management, is_portal)
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} "
)
return message.replace("$cmdprefix", self.command_prefix)
@staticmethod
def _render_message(message: str, allow_html: bool, render_markdown: bool) -> Optional[str]:
"""Renders the message as HTML.
Args:
allow_html: Flag to allow custom HTML in the message.
render_markdown: If true, markdown styling is applied to the message.
Returns:
The message rendered as HTML.
None is returned if no styled output is required.
"""
html = ""
if render_markdown:
md_renderer.allow_html = allow_html
html = md_renderer.render(md_parser.parse(message))
elif allow_html:
html = message
return ensure_trailing_newline(html) if html else None
async def get_help_key(self) -> HelpCacheKey:
return HelpCacheKey(self.is_management, self.is_portal, self.sender.puppet_whitelisted,
self.sender.matrix_puppet_whitelisted, self.sender.is_admin,
await self.sender.is_logged_in())
class CommandHandler:
"""A command which can be executed from a Matrix room.
class CommandHandler(BaseCommandHandler):
name: str
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.
management_only: bool
needs_auth: bool
needs_puppeting: bool
needs_matrix_puppeting: bool
needs_admin: bool
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,
def __init__(self, handler: Callable[[CommandEvent], Awaitable[EventID]],
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
help_section: HelpSection, needs_auth: bool, needs_puppeting: bool,
needs_matrix_puppeting: bool, needs_admin: bool) -> 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)
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.")
@@ -232,134 +92,41 @@ class CommandHandler:
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 has_permission(self, key: HelpCacheKey) -> bool:
return ((not self.management_only or key.is_management) and
(not self.needs_puppeting or key.puppet_whitelisted) and
(not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted) and
(not self.needs_admin or key.is_admin) and
(not self.needs_auth or key.is_logged_in))
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)
def command_handler(_func: Optional[CommandHandlerFunc] = 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[[CommandHandlerFunc],
CommandHandler]:
return base_command_handler(
_func, _handler_class=CommandHandler, name=name, help_text=help_text, help_args=help_args,
help_section=help_section, management_only=management_only, needs_auth=needs_auth,
needs_admin=needs_admin, needs_puppeting=needs_puppeting,
needs_matrix_puppeting=needs_matrix_puppeting)
class CommandProcessor:
"""Handles the raw commands issued by a user to the Matrix bot."""
log = logging.getLogger("mau.commands")
class CommandProcessor(BaseCommandProcessor):
def __init__(self, context: c.Context) -> None:
super().__init__(az=context.az, config=context.config, event_class=CommandEvent,
loop=context.loop, bridge=context.bridge)
self.tgbot = context.bot
self.bridge = context.bridge
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()
@staticmethod
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
+128
View File
@@ -0,0 +1,128 @@
# 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 Set, Callable
import asyncio
import sys
import os
from attr import dataclass
from telethon import __version__ as __telethon_version__
from mautrix import __version__ as __mautrix_version__
from mautrix.types import UserID
from mautrix.errors import MatrixConnectionError
from mautrix.util.manhole import start_manhole
from .. import __version__
from . import command_handler, CommandEvent, SECTION_ADMIN
@dataclass
class ManholeState:
server: asyncio.AbstractServer
opened_by: UserID
close: Callable[[], None]
whitelist: Set[int]
@command_handler(needs_auth=False, needs_admin=True, help_section=SECTION_ADMIN,
help_text="Open a manhole into the bridge.", help_args="<_uid..._>")
async def open_manhole(evt: CommandEvent) -> None:
if not evt.config["manhole.enabled"]:
await evt.reply("The manhole has been disabled in the config.")
return
elif len(evt.args) == 0:
await evt.reply("**Usage:** `$cmdprefix+sp open-manhole <uid...>`")
return
whitelist = set()
whitelist_whitelist = evt.config["manhole.whitelist"]
for arg in evt.args:
try:
uid = int(arg)
except ValueError:
await evt.reply(f"{arg} is not an integer.")
return
if whitelist_whitelist and uid not in whitelist_whitelist:
await evt.reply(f"{uid} is not in the list of allowed UIDs.")
return
whitelist.add(uid)
if evt.bridge.manhole:
added = [uid for uid in whitelist
if uid not in evt.bridge.manhole.whitelist]
evt.bridge.manhole.whitelist |= set(added)
if len(added) == 0:
await evt.reply(f"There's an existing manhole opened by {evt.bridge.manhole.opened_by}"
" and all the given UIDs are already whitelisted.")
else:
added_str = (f"{', '.join(str(uid) for uid in added[:-1])} and {added[-1]}"
if len(added) > 1 else added[0])
await evt.reply(f"There's an existing manhole opened by {evt.bridge.manhole.opened_by}"
f". Added {added_str} to the whitelist.")
evt.log.info(f"{evt.sender.mxid} added {added_str} to the manhole whitelist.")
return
from ..portal import Portal
from ..puppet import Puppet
from ..user import User
namespace = {
"bridge": evt.bridge,
"User": User,
"Portal": Portal,
"Puppet": Puppet,
}
banner = (f"Python {sys.version} on {sys.platform}\n"
f"mautrix-telegram {__version__} with mautrix-python {__mautrix_version__} "
f"and Telethon {__telethon_version__}\n\nManhole opened by {evt.sender.mxid}\n")
path = evt.config["manhole.path"]
wl_list = list(whitelist)
whitelist_str = (f"{', '.join(str(uid) for uid in wl_list[:-1])} and {wl_list[-1]}"
if len(wl_list) > 1 else wl_list[0])
evt.log.info(f"{evt.sender.mxid} opened a manhole with {whitelist_str} whitelisted.")
server, close = await start_manhole(path=path, banner=banner, namespace=namespace,
loop=evt.loop, whitelist=whitelist)
evt.bridge.manhole = ManholeState(server=server, opened_by=evt.sender.mxid, close=close,
whitelist=whitelist)
plrl = "s" if len(whitelist) != 1 else ""
await evt.reply(f"Opened manhole at unix://{path} with UID{plrl} {whitelist_str} whitelisted")
await server.wait_closed()
evt.bridge.manhole = None
try:
os.unlink(path)
except FileNotFoundError:
pass
evt.log.info(f"{evt.sender.mxid}'s manhole was closed.")
try:
await evt.reply("Your manhole was closed.")
except (AttributeError, MatrixConnectionError) as e:
evt.log.warning(f"Failed to send manhole close notification: {e}")
@command_handler(needs_auth=False, needs_admin=True, help_section=SECTION_ADMIN,
help_text="Close an open manhole.")
async def close_manhole(evt: CommandEvent) -> None:
if not evt.bridge.manhole:
await evt.reply("There is no open manhole.")
return
opened_by = evt.bridge.manhole.opened_by
evt.bridge.manhole.close()
evt.bridge.manhole = None
if opened_by != evt.sender.mxid:
await evt.reply(f"Closed manhole opened by {opened_by}")
+33 -21
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,17 +13,17 @@
#
# 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.types import EventID
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
from . import command_handler, CommandEvent, SECTION_AUTH
from .. import puppet as pu
@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]:
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) -> EventID:
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.")
@@ -36,7 +35,7 @@ async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
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]:
async def login_matrix(evt: CommandEvent) -> EventID:
puppet = pu.Puppet.get(evt.sender.tgid)
if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. "
@@ -71,31 +70,44 @@ async def login_matrix(evt: CommandEvent) -> Optional[Dict]:
@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]:
async def ping_matrix(evt: CommandEvent) -> EventID:
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:
try:
await puppet.start()
except 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}.")
return await evt.reply("Your Matrix login is working.")
async def enter_matrix_token(evt: CommandEvent) -> Dict:
@command_handler(needs_auth=True, needs_matrix_puppeting=True, help_section=SECTION_AUTH,
help_text="Clear the Matrix sync token stored for your custom puppet.")
async def clear_cache_matrix(evt: CommandEvent) -> EventID:
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.")
try:
puppet.stop()
puppet.next_batch = None
await puppet.start()
except InvalidAccessToken:
return await evt.reply("Your access token is invalid.")
return await evt.reply("Cleared cache successfully.")
async def enter_matrix_token(evt: CommandEvent) -> EventID:
evt.sender.command_status = None
puppet = pu.Puppet.get(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:
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}.")
return await evt.reply("Replaced your Telegram account's Matrix puppet "
f"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))
+12 -15
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,10 +13,10 @@
#
# 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.errors 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
@@ -27,29 +26,28 @@ from .. import command_handler, CommandEvent, SECTION_ADMIN
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:
async def set_power_level(evt: CommandEvent) -> EventID:
try:
level = int(evt.args[0])
except KeyError:
except (KeyError, IndexError):
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
levels.users[mxid] = level
try:
await evt.az.intent.set_power_levels(evt.room_id, levels)
return 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:
async def clear_db_cache(evt: CommandEvent) -> EventID:
try:
section = evt.args[0].lower()
except IndexError:
@@ -63,9 +61,8 @@ async def clear_db_cache(evt: CommandEvent) -> Dict:
for puppet in pu.Puppet.by_custom_mxid.values():
puppet.sync_task.cancel()
pu.Puppet.by_custom_mxid = {}
await asyncio.gather(
*[puppet.init_custom_mxid() for puppet in pu.Puppet.all_with_custom_mxid()],
loop=evt.loop)
await asyncio.gather(*[puppet.try_start() for puppet in pu.Puppet.all_with_custom_mxid()],
loop=evt.loop)
await evt.reply("Cleared puppet cache and restarted custom puppet syncers")
elif section == "user":
u.User.by_mxid = {
@@ -81,7 +78,7 @@ async def clear_db_cache(evt: CommandEvent) -> Dict:
help_section=SECTION_ADMIN,
help_args="[_mxid_]",
help_text="Reload and reconnect a user")
async def reload_user(evt: CommandEvent) -> Dict:
async def reload_user(evt: CommandEvent) -> EventID:
if len(evt.args) > 0:
mxid = evt.args[0]
else:
@@ -97,5 +94,5 @@ async def reload_user(evt: CommandEvent) -> Dict:
user = 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})")
+16 -18
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,13 +13,14 @@
#
# 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 typing import Optional, Tuple, Coroutine
import asyncio
from telethon.tl.types import ChatForbidden, ChannelForbidden
from ...types import MatrixRoomID, TelegramID
from ...util import ignore_coro
from mautrix.types import EventID, RoomID
from ...types import TelegramID
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state
@@ -32,7 +32,7 @@ from .util import user_has_power_level, get_initial_state
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:
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]`")
@@ -40,7 +40,7 @@ async def bridge(evt: CommandEvent) -> Dict:
if evt.args[0] == "--usebot" and evt.sender.is_admin:
force_use_bot = True
evt.args = evt.args[1:]
room_id = MatrixRoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
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)
@@ -65,7 +65,7 @@ async def bridge(evt: CommandEvent) -> Dict:
"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():
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.")
@@ -105,19 +105,18 @@ async def bridge(evt: CommandEvent) -> Dict:
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
) -> Tuple[bool, Optional[Coroutine[None, None, None]]]:
) -> Tuple[
bool, Optional[Coroutine[None, None, None]]]:
if not portal.mxid:
await evt.reply("The portal seems to have lost its Matrix room between you"
"calling `$cmdprefix+sp bridge` and this command.\n\n"
"Continuing without touching previous Matrix room...")
return True, None
elif evt.args[0] == "delete-and-continue":
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
message="Portal deleted (moving to another room)")
return True, portal.cleanup_portal("Portal deleted (moving to another room)")
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)
else:
await evt.reply(
"The chat you were trying to bridge already has a Matrix portal room.\n\n"
@@ -128,7 +127,7 @@ async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Porta
return False, None
async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]:
status = evt.sender.command_status
try:
portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
@@ -143,7 +142,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
if not ok:
return None
elif coro:
ignore_coro(asyncio.ensure_future(coro, loop=evt.loop))
asyncio.ensure_future(coro, loop=evt.loop)
await evt.reply("Cleaning up previous portal room...")
elif portal.mxid:
evt.sender.command_status = None
@@ -180,8 +179,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
portal.photo_id = ""
portal.save()
ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct,
levels=levels),
loop=evt.loop))
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
loop=evt.loop)
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
+16 -10
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,15 +13,17 @@
#
# 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 typing import Awaitable
from io import StringIO
from ...config import yaml
from mautrix.util.config import yaml
from mautrix.types import EventID
from ... import portal as po, util
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
@command_handler(needs_auth=False, help_section=SECTION_PORTAL_MANAGEMENT,
help_text="View or change per-portal settings.",
help_args="<`help`|_subcommand_> [...]")
async def config(evt: CommandEvent) -> None:
@@ -42,6 +43,10 @@ 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
if cmd == "set":
@@ -55,7 +60,7 @@ async def config(evt: CommandEvent) -> None:
portal.save()
def config_help(evt: CommandEvent) -> Awaitable[Dict]:
def config_help(evt: CommandEvent) -> Awaitable[EventID]:
return evt.reply("""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
* **help** - View this help text.
@@ -68,13 +73,13 @@ def config_help(evt: CommandEvent) -> Awaitable[Dict]:
""")
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[Dict]:
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[EventID]:
stream = StringIO()
yaml.dump(portal.local_config, stream)
return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```")
def config_defaults(evt: CommandEvent) -> Awaitable[Dict]:
def config_defaults(evt: CommandEvent) -> Awaitable[EventID]:
stream = StringIO()
yaml.dump({
"bridge_notices": {
@@ -84,13 +89,14 @@ def config_defaults(evt: CommandEvent) -> Awaitable[Dict]:
"bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"],
"inline_images": evt.config["bridge.inline_images"],
"message_formats": evt.config["bridge.message_formats"],
"emote_format": evt.config["bridge.emote_format"],
"state_event_formats": evt.config["bridge.state_event_formats"],
"telegram_link_preview": evt.config["bridge.telegram_link_preview"],
}, 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: str) -> 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):
@@ -100,7 +106,7 @@ def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Aw
"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,7 +116,7 @@ def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Di
def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
) -> Awaitable[Dict]:
) -> Awaitable[EventID]:
if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,7 +13,7 @@
#
# 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 mautrix.types import EventID
from ... import portal as po
from ...types import TelegramID
@@ -27,7 +26,7 @@ from .util import user_has_power_level, get_initial_state
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:
async def create(evt: CommandEvent) -> EventID:
type = evt.args[0] if len(evt.args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}:
return await evt.reply(
+5 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,7 +13,7 @@
#
# 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.types import EventID
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_ADMIN
@@ -25,7 +24,7 @@ from .. import command_handler, CommandEvent, 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:
async def filter_mode(evt: CommandEvent) -> EventID:
try:
mode = evt.args[0]
if mode not in ("whitelist", "blacklist"):
@@ -50,7 +49,7 @@ async def filter_mode(evt: CommandEvent) -> Dict:
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]:
async def edit_filter(evt: CommandEvent) -> EventID:
try:
action = evt.args[0]
if action not in ("whitelist", "blacklist", "add", "remove"):
@@ -92,4 +91,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>`")
+36 -9
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,10 +13,12 @@
#
# 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 telethon.tl.functions.channels import GetFullChannelRequest
from telethon.tl.functions.messages import GetFullChatRequest
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
UsernameNotModifiedError, UsernameOccupiedError)
UsernameNotModifiedError, UsernameOccupiedError, RPCError)
from mautrix.types import EventID
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC
@@ -27,7 +28,7 @@ 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:
async def sync_state(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
@@ -38,10 +39,36 @@ async def sync_state(evt: CommandEvent) -> Dict:
await evt.reply("Synchronization complete")
@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False,
help_section=SECTION_MISC)
async def sync_full(evt: CommandEvent) -> EventID:
portal = 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) -> Dict:
async def get_id(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
@@ -55,7 +82,7 @@ async def get_id(evt: CommandEvent) -> Dict:
@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:
async def invite_link(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
@@ -74,7 +101,7 @@ async def invite_link(evt: CommandEvent) -> Dict:
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.")
async def upgrade(evt: CommandEvent) -> Dict:
async def upgrade(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
@@ -96,7 +123,7 @@ async def upgrade(evt: CommandEvent) -> Dict:
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:
async def group_name(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
+6 -6
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -16,7 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Callable, Optional
from ...types import MatrixRoomID
from mautrix.types import RoomID, EventID
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
from .util import user_has_power_level
@@ -25,7 +25,7 @@ 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
room_id = RoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
portal = po.Portal.get_by_mxid(room_id)
if not portal:
@@ -42,7 +42,7 @@ async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
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]:
async def post_confirm(confirm) -> Optional[EventID]:
confirm.sender.command_status = None
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
await function()
@@ -63,7 +63,7 @@ def _get_portal_murder_function(action: str, room_id: str, function: Callable, c
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]:
async def delete_portal(evt: CommandEvent) -> Optional[EventID]:
portal = await _get_portal_and_check_permission(evt, "unbridge")
if not portal:
return None
@@ -84,7 +84,7 @@ async def delete_portal(evt: CommandEvent) -> Optional[Dict]:
@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]:
async def unbridge(evt: CommandEvent) -> Optional[EventID]:
portal = await _get_portal_and_check_permission(evt, "unbridge")
if not portal:
return None
+26 -22
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,43 +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 typing import Dict, Tuple
from typing import Tuple, Optional
from mautrix_appservice import MatrixRequestError, IntentAPI
from mautrix.errors import MatrixRequestError
from mautrix.appservice import IntentAPI
from mautrix.types import RoomID, EventType, PowerLevelStateEventContent
from ... import user as u
OptStr = Optional[str]
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[OptStr, OptStr, Optional[PowerLevelStateEventContent]]:
state = await intent.get_state(room_id)
title: OptStr = None
about: OptStr = None
levels: Optional[PowerLevelStateEventContent] = None
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
except KeyError:
# Some state event probably has empty content
pass
return title, about, levels
async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50
) -> bool:
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"net.maunium.telegram.{event}")
event_type.t_class = EventType.Class.STATE
return intent.state_store.has_power_level(room_id, sender.mxid, event_type)
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,7 +13,7 @@
#
# 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 typing import Optional
from telethon.errors import (UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError,
HashInvalidError, AuthKeyError, FirstNameInvalidError)
@@ -22,6 +21,8 @@ from telethon.tl.types import Authorization
from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest,
ResetAuthorizationRequest, UpdateProfileRequest)
from mautrix.types import EventID
from .. import command_handler, CommandEvent, SECTION_AUTH
@@ -29,7 +30,7 @@ from .. import command_handler, CommandEvent, SECTION_AUTH
help_section=SECTION_AUTH,
help_args="<_new username_>",
help_text="Change your Telegram username.")
async def username(evt: CommandEvent) -> Optional[Dict]:
async def username(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp username <new username>`")
if evt.sender.is_bot:
@@ -55,7 +56,7 @@ async def username(evt: CommandEvent) -> Optional[Dict]:
@command_handler(needs_auth=True, help_section=SECTION_AUTH, help_args="<_new displayname_>",
help_text="Change your Telegram displayname.")
async def displayname(evt: CommandEvent) -> Optional[Dict]:
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:
@@ -69,7 +70,7 @@ async def displayname(evt: CommandEvent) -> Optional[Dict]:
except FirstNameInvalidError:
return await evt.reply("Invalid first name")
await evt.sender.update_info()
await evt.reply("Displayname updated")
return await evt.reply("Displayname updated")
def _format_session(sess: Authorization) -> str:
@@ -83,7 +84,7 @@ def _format_session(sess: Authorization) -> str:
help_section=SECTION_AUTH,
help_args="<`list`|`terminate`> [_hash_]",
help_text="View or delete other Telegram sessions.")
async def session(evt: CommandEvent) -> Optional[Dict]:
async def session(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
elif evt.sender.is_bot:
+22 -18
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -17,21 +16,24 @@
from typing import Any, Dict, Optional
import asyncio
from telethon.errors import (
from telethon.errors import ( # isort: skip
AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError,
PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError,
PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError,
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError)
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError,
PhoneNumberInvalidError)
from ... import puppet as pu, user as u
from mautrix.types import EventID
from ... import user as u
from ...commands import command_handler, CommandEvent, SECTION_AUTH
from ...util import format_duration, ignore_coro
from ...util import format_duration
@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]:
async def ping(evt: CommandEvent) -> EventID:
me = await evt.sender.client.get_me() if await evt.sender.is_logged_in() else None
if me:
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
@@ -43,7 +45,7 @@ async def ping(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) -> Optional[Dict]:
async def ping_bot(evt: CommandEvent) -> EventID:
if not evt.tgbot:
return await evt.reply("Telegram message relay bot not configured.")
info, mxid = await evt.tgbot.get_me(use_cache=False)
@@ -56,7 +58,7 @@ async def ping_bot(evt: CommandEvent) -> Optional[Dict]:
help_section=SECTION_AUTH,
help_args="<_phone_> <_full name_>",
help_text="Register to Telegram")
async def register(evt: CommandEvent) -> Optional[Dict]:
async def register(evt: CommandEvent) -> Optional[EventID]:
if await evt.sender.is_logged_in():
return await evt.reply("You are already logged in.")
elif len(evt.args) < 1:
@@ -76,14 +78,14 @@ async def register(evt: CommandEvent) -> Optional[Dict]:
return None
async def enter_code_register(evt: CommandEvent) -> Dict:
async def enter_code_register(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
try:
await evt.sender.ensure_started(even_if_no_session=True)
first_name, last_name = evt.sender.command_status["full_name"]
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name)
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
asyncio.ensure_future(evt.sender.post_login(user, first_login=True), loop=evt.loop)
evt.sender.command_status = None
return await evt.reply(f"Successfully registered to Telegram.")
except PhoneNumberOccupiedError:
@@ -105,7 +107,7 @@ async def enter_code_register(evt: CommandEvent) -> Dict:
@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]:
async def login(evt: CommandEvent) -> EventID:
override_sender = False
if len(evt.args) > 0 and evt.sender.is_admin:
evt.sender = await u.User.get_by_mxid(evt.args[0]).ensure_started()
@@ -142,7 +144,7 @@ async def login(evt: CommandEvent) -> Optional[Dict]:
async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[str, Any]
) -> Dict:
) -> EventID:
ok = False
try:
await evt.sender.ensure_started(even_if_no_session=True)
@@ -165,6 +167,8 @@ async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[
except PhoneNumberUnoccupiedError:
return await evt.reply("That phone number has not been registered. "
"Please register with `$cmdprefix+sp register <phone>`.")
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. "
@@ -174,7 +178,7 @@ async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[
@command_handler(needs_auth=False)
async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
async def enter_phone_or_token(evt: CommandEvent) -> Optional[EventID]:
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):
@@ -198,7 +202,7 @@ async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
@command_handler(needs_auth=False)
async def enter_code(evt: CommandEvent) -> Optional[Dict]:
async def enter_code(evt: CommandEvent) -> Optional[EventID]:
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):
@@ -214,7 +218,7 @@ async def enter_code(evt: CommandEvent) -> Optional[Dict]:
@command_handler(needs_auth=False)
async def enter_password(evt: CommandEvent) -> Optional[Dict]:
async def enter_password(evt: CommandEvent) -> Optional[EventID]:
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):
@@ -233,7 +237,7 @@ async def enter_password(evt: CommandEvent) -> Optional[Dict]:
return None
async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
async def _sign_in(evt: CommandEvent, **sign_in_info) -> EventID:
try:
await evt.sender.ensure_started(even_if_no_session=True)
user = await evt.sender.client.sign_in(**sign_in_info)
@@ -243,7 +247,7 @@ async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
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))
asyncio.ensure_future(evt.sender.post_login(user, first_login=True), 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}")
@@ -265,7 +269,7 @@ async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
help_text="Log out from Telegram.")
async def logout(evt: CommandEvent) -> Optional[Dict]:
async def logout(evt: CommandEvent) -> EventID:
if await evt.sender.log_out():
return await evt.reply("Logged out successfully.")
return await evt.reply("Failed to log out.")
+42 -20
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,21 +13,24 @@
#
# 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 typing import List, Optional, Tuple, cast
import logging
import codecs
import base64
import re
from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError,
UserAlreadyParticipantError)
UserAlreadyParticipantError, ChatIdInvalidError)
from telethon.tl.patched import Message
from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll,
TypePeer)
TypeInputPeer)
from telethon.tl.types.messages import BotCallbackAnswer
from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatInviteRequest,
GetBotCallbackAnswerRequest, SendVoteRequest)
from telethon.tl.functions.channels import JoinChannelRequest
from mautrix.types import EventID, Format
from ... import puppet as pu, portal as po
from ...abstract_user import AbstractUser
from ...db import Message as DBMessage
@@ -36,10 +38,26 @@ from ...types import TelegramID
from ...commands import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS
@command_handler(needs_auth=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) -> Optional[Dict]:
async def search(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
@@ -60,7 +78,7 @@ async def search(evt: CommandEvent) -> Optional[Dict]:
"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:
@@ -74,13 +92,12 @@ async def search(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply("\n".join(reply))
@command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="<_identifier_>",
@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]:
async def pm(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
@@ -99,7 +116,7 @@ async def pm(evt: CommandEvent) -> Optional[Dict]:
f"{pu.Puppet.get_displayname(user, False)}")
async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[Dict]]:
async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[EventID]]:
if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):]
try:
@@ -122,7 +139,7 @@ async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Opt
@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]:
async def join(evt: CommandEvent) -> Optional[EventID]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
@@ -142,7 +159,11 @@ async def join(evt: CommandEvent) -> Optional[Dict]:
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])
try:
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
except ChatIdInvalidError as e:
logging.getLogger("mau.commands").info(updates.stringify())
raise e
return await evt.reply(f"Created room for {portal.title}")
return None
@@ -150,7 +171,7 @@ async def join(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) -> Optional[Dict]:
async def sync(evt: CommandEvent) -> EventID:
if len(evt.args) > 0:
sync_only = evt.args[0]
if sync_only not in ("chats", "contacts", "me"):
@@ -177,7 +198,7 @@ class MessageIDError(ValueError):
async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
) -> Tuple[TypePeer, Message]:
) -> Tuple[TypeInputPeer, Message]:
try:
enc_id += (4 - len(enc_id) % 4) * "="
enc_id = base64.b64decode(enc_id)
@@ -206,13 +227,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]:
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,18 +249,19 @@ 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 number_>",
help_text="Vote in a Telegram poll.")
async def vote(evt: CommandEvent) -> Optional[Dict]:
async def vote(evt: CommandEvent) -> EventID:
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():
+64 -171
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,149 +13,39 @@
#
# 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, Dict, List, NamedTuple
from ruamel.yaml.comments import CommentedMap
import random
import string
import os
yaml = YAML() # type: YAML
yaml.indent(4)
from mautrix.types import UserID
from mautrix.client import Client
from mautrix.bridge.config import (BaseBridgeConfig, ConfigUpdateHelper, ForbiddenKey,
ForbiddenDefault)
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
@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,
overrides: Dict[str, Any] = None) -> 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]
self._overrides = overrides or {} # type: Dict[str, Any]
class Config(BaseBridgeConfig):
def __getitem__(self, key: str) -> Any:
try:
return self._overrides[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
return os.environ[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
except KeyError:
return super().__getitem__(key)
def load(self) -> None:
with open(self.path, 'r') as stream:
self._data = yaml.load(stream)
@property
def forbidden_defaults(self) -> List[ForbiddenDefault]:
return [
*super().forbidden_defaults,
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"),
]
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
def do_update(self, helper: ConfigUpdateHelper) -> None:
copy, copy_dict, base = helper
copy("homeserver.address")
copy("homeserver.domain")
@@ -197,28 +86,41 @@ class Config(DictWithRecursion):
copy("metrics.enabled")
copy("metrics.listen_port")
copy("manhole.enabled")
copy("manhole.path")
copy("manhole.whitelist")
copy("bridge.username_template")
copy("bridge.alias_template")
copy("bridge.displayname_template")
copy("bridge.displayname_preference")
copy("bridge.displayname_max_length")
copy("bridge.max_initial_member_sync")
copy("bridge.sync_channel_members")
copy("bridge.skip_deleted_members")
copy("bridge.startup_sync")
copy("bridge.sync_dialog_limit")
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.public_portals")
copy("bridge.catch_up")
copy("bridge.sync_with_custom_puppets")
copy("bridge.login_shared_secret")
copy("bridge.telegram_link_preview")
copy("bridge.inline_images")
copy("bridge.image_as_file_size")
copy("bridge.max_document_size")
copy("bridge.parallel_file_transfer")
copy("bridge.federate_rooms")
copy("bridge.animated_sticker.target")
copy("bridge.animated_sticker.args")
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):
@@ -235,6 +137,7 @@ class Config(DictWithRecursion):
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.state_event_formats.join")
copy("bridge.state_event_formats.leave")
@@ -261,6 +164,11 @@ class Config(DictWithRecursion):
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")
@@ -302,58 +210,43 @@ class Config(DictWithRecursion):
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:
@property
def namespaces(self) -> Dict[str, List[Dict[str, Any]]]:
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=".+")
username_format = self["bridge.username_template"].format(userid=".+")
alias_format = self["bridge.alias_template"].format(groupname=".+")
group_id = ({"group_id": self["appservice.community_id"]}
if self["appservice.community_id"] else {})
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
return {
"users": [{
"exclusive": True,
"regex": f"@{username_format}:{homeserver}",
**group_id,
}],
"aliases": [{
"exclusive": True,
"regex": f"#{alias_format}:{homeserver}",
}]
}
if self["appservice.community_id"]:
self._registration["namespaces"]["users"][0]["group_id"] \
= self["appservice.community_id"]
+29 -17
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -15,31 +14,44 @@
# 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
import asyncio
from alchemysession import AlchemySessionContainer
from mautrix.appservice import AppService
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
from .__main__ import TelegramBridge
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]
az: AppService
config: 'Config'
loop: asyncio.AbstractEventLoop
bridge: 'TelegramBridge'
bot: Optional['Bot']
mx: Optional['MatrixHandler']
session_container: AlchemySessionContainer
public_website: Optional['PublicBridgeWebsite']
provisioning_api: Optional['ProvisioningAPI']
def __init__(self, az: AppService, config: 'Config', loop: asyncio.AbstractEventLoop,
session_container: AlchemySessionContainer, bridge: 'TelegramBridge',
bot: Optional['Bot']) -> None:
self.az = az
self.config = config
self.loop = loop
self.bridge = bridge
self.bot = bot
self.mx = None
self.session_container = session_container
self.public_website = None
self.provisioning_api = None
@property
def core(self) -> Tuple['AppService', 'Config', 'asyncio.AbstractEventLoop', Optional['Bot']]:
def core(self) -> Tuple[AppService, 'Config', asyncio.AbstractEventLoop, Optional['Bot']]:
return self.az, self.config, self.loop, self.bot
+6 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,20 +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 .base import Base
from sqlalchemy.engine.base import Engine
from mautrix.bridge.db import UserProfile, RoomState
from .bot_chat import BotChat
from .message import Message
from .portal import Portal
from .puppet import Puppet
from .room_state import RoomState
from .telegram_file import TelegramFile
from .user import User, UserPortal, Contact
from .user_profile import UserProfile
def init(db_engine) -> None:
def init(db_engine: 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
table.column_names = table.c.keys()
-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: ...
+6 -13
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -18,28 +17,22 @@ from typing import Iterable
from sqlalchemy import Column, Integer, String
from mautrix.util.db import Base
from ..types import TelegramID
from .base import Base
# 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)
id: TelegramID = Column(Integer, primary_key=True)
type: str = Column(String, nullable=False)
@classmethod
def delete(cls, chat_id: TelegramID) -> None:
def delete_by_id(cls, chat_id: TelegramID) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.id == chat_id))
@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)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(id=self.id, type=self.type))
return cls._select_all()
+27 -56
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,62 +13,46 @@
#
# 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, desc, select
from sqlalchemy.engine.result import RowProxy
from typing import Optional, List
from typing import Optional, Iterator
from ..types import MatrixRoomID, MatrixEventID, TelegramID
from .base import Base
from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, desc, select
from mautrix.types import RoomID, EventID
from mautrix.util.db import Base
from ..types import TelegramID
class Message(Base):
__tablename__ = "message"
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
edit_index = Column(Integer, primary_key=True) # type: int
mxid: EventID = Column(String)
mx_room: RoomID = Column(String)
tgid: TelegramID = Column(Integer, primary_key=True)
tg_space: TelegramID = Column(Integer, primary_key=True)
edit_index: int = Column(Integer, primary_key=True)
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room_2"),)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Message']:
try:
mxid, mx_room, tgid, tg_space, edit_index = next(rows)
return cls(mxid=mxid, mx_room=mx_room, tgid=tgid, tg_space=tg_space,
edit_index=edit_index)
except StopIteration:
return None
@staticmethod
def _all(rows: RowProxy) -> List['Message']:
return [Message(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3],
edit_index=row[4])
for row in rows]
@classmethod
def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> List['Message']:
return cls._all(cls.db.execute(cls.t.select().where(and_(cls.c.tgid == tgid,
cls.c.tg_space == tg_space))))
def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> Iterator['Message']:
return cls._select_all(cls.c.tgid == tgid, cls.c.tg_space == tg_space)
@classmethod
def get_one_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID, edit_index: int = 0
) -> Optional['Message']:
query = cls.t.select()
if edit_index < 0:
query = (query
.where(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space))
.order_by(desc(cls.c.edit_index))
.limit(1)
.offset(-edit_index - 1))
return cls._one_or_none(cls.db.execute(
cls.t.select()
.where(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space))
.order_by(desc(cls.c.edit_index))
.limit(1).offset(-edit_index - 1)))
else:
query = query.where(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space,
cls.c.edit_index == edit_index))
return cls._one_or_none(cls.db.execute(query))
return cls._select_one_or_none(cls.c.tgid == tgid, cls.c.tg_space == tg_space,
cls.c.edit_index == edit_index)
@classmethod
def count_spaces_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID) -> int:
def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> 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:
@@ -79,11 +62,10 @@ class Message(Base):
return 0
@classmethod
def get_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID, tg_space: TelegramID
def get_by_mxid(cls, mxid: EventID, mx_room: RoomID, 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))
return cls._select_one_or_none(cls.c.mxid == mxid, cls.c.mx_room == mx_room,
cls.c.tg_space == tg_space)
@classmethod
def update_by_tgid(cls, s_tgid: TelegramID, s_tg_space: TelegramID, s_edit_index: int,
@@ -95,19 +77,8 @@ class Message(Base):
.values(**values))
@classmethod
def update_by_mxid(cls, s_mxid: MatrixEventID, s_mx_room: MatrixRoomID, **values) -> None:
def update_by_mxid(cls, s_mxid: EventID, s_mx_room: RoomID, **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))
@property
def _edit_identity(self):
return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space,
self.c.edit_index == self.edit_index)
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,
edit_index=self.edit_index))
+19 -44
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,68 +13,44 @@
#
# 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 ..types import MatrixRoomID, TelegramID
from .base import Base
from sqlalchemy import Column, Integer, String, Boolean, Text, func
from mautrix.types import RoomID
from mautrix.util.db import Base
from ..types import TelegramID
class Portal(Base):
__tablename__ = "portal"
# 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 = Column(Integer, primary_key=True)
tg_receiver: TelegramID = Column(Integer, primary_key=True)
peer_type: str = Column(String, nullable=False)
megagroup: bool = Column(Boolean)
# Matrix portal information
mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID]
mxid: RoomID = Column(String, unique=True, nullable=True)
config = Column(Text, nullable=True)
config: str = Column(Text, nullable=True)
# Telegram chat metadata
username = Column(String, nullable=True)
title = Column(String, nullable=True)
about = Column(String, nullable=True)
photo_id = Column(String, nullable=True)
@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:
return None
username: str = Column(String, nullable=True)
title: str = Column(String, nullable=True)
about: str = Column(String, nullable=True)
photo_id: str = Column(String, nullable=True)
@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))
return cls._select_one_or_none(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver)
@classmethod
def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']:
def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod
def get_by_username(cls, username: str) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.username == username)
@property
def _edit_identity(self):
return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver)
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))
return cls._select_one_or_none(func.lower(cls.c.username) == username)
+21 -49
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,75 +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 sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql import expression
from typing import Optional, Iterable
from ..types import MatrixUserID, MatrixRoomID, TelegramID
from .base import Base
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.sql import expression, func
from mautrix.types import UserID, SyncToken
from mautrix.util.db import Base
from ..types import TelegramID
class Puppet(Base):
__tablename__ = "puppet"
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())
disable_updates = Column(Boolean, nullable=False, server_default=expression.false())
@classmethod
def scan(cls, row) -> Optional['Puppet']:
(id, custom_mxid, access_token, displayname, displayname_source, username, photo_id,
is_bot, matrix_registered, disable_updates) = 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, disable_updates=disable_updates)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Puppet']:
try:
return cls.scan(next(rows))
except StopIteration:
return None
id: TelegramID = Column(Integer, primary_key=True)
custom_mxid: UserID = Column(String, nullable=True)
access_token: str = Column(String, nullable=True)
next_batch: SyncToken = Column(String, nullable=True)
displayname: str = Column(String, nullable=True)
displayname_source: TelegramID = Column(Integer, nullable=True)
username: str = Column(String, nullable=True)
photo_id: str = Column(String, nullable=True)
is_bot: bool = Column(Boolean, nullable=True)
matrix_registered: bool = Column(Boolean, nullable=False, server_default=expression.false())
disable_updates: bool = Column(Boolean, nullable=False, server_default=expression.false())
@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)
yield from cls._select_all(cls.c.custom_mxid != None)
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.id == tgid)
@classmethod
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.custom_mxid == mxid)
@classmethod
def get_by_username(cls, username: str) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.username == username)
return cls._select_one_or_none(func.lower(cls.c.username) == username)
@classmethod
def get_by_displayname(cls, displayname: str) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.displayname == displayname)
@property
def _edit_identity(self):
return self.c.id == self.id
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, disable_updates=self.disable_updates))
-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))
+23 -23
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,38 +13,39 @@
#
# 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 .base import Base
from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
from sqlalchemy.engine.result import RowProxy
from mautrix.types import ContentURI
from mautrix.util.db import Base
class TelegramFile(Base):
__tablename__ = "telegram_file"
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 = Column(String, primary_key=True)
mxc: ContentURI = Column(String)
mime_type: str = Column(String)
was_converted: bool = Column(Boolean)
timestamp: int = Column(BigInteger)
size: Optional[int] = Column(Integer, nullable=True)
width: Optional[int] = Column(Integer, nullable=True)
height: Optional[int] = Column(Integer, nullable=True)
thumbnail_id: str = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
thumbnail: Optional['TelegramFile'] = None
@classmethod
def scan(cls, row: RowProxy) -> 'TelegramFile':
telegram_file: TelegramFile = super().scan(row)
if isinstance(telegram_file.thumbnail, str):
telegram_file.thumbnail = cls.get(telegram_file.thumbnail)
return telegram_file
@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:
return None
return cls._select_one_or_none(cls.c.id == loc_id)
def insert(self) -> None:
with self.db.begin() as conn:
+21 -43
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,61 +13,40 @@
#
# 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 ..types import MatrixUserID, TelegramID
from .base import Base
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String, func
from mautrix.types import UserID
from mautrix.util.db import Base
from ..types import TelegramID
class User(Base):
__tablename__ = "user"
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 = Column(String, primary_key=True)
tgid: Optional[TelegramID] = Column(Integer, nullable=True, unique=True)
tg_username: str = Column(String, nullable=True)
tg_phone: str = Column(String, nullable=True)
saved_contacts: int = Column(Integer, default=0, nullable=False)
@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:
return None
@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)
def all_with_tgid(cls) -> Iterable['User']:
return cls._select_all(cls.c.tgid != None)
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
return cls._select_one_or_none(cls.c.tgid == tgid)
@classmethod
def get_by_mxid(cls, mxid: MatrixUserID) -> Optional['User']:
def get_by_mxid(cls, mxid: UserID) -> Optional['User']:
return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod
def get_by_username(cls, username: str) -> Optional['User']:
return cls._select_one_or_none(cls.c.tg_username == username)
@property
def _edit_identity(self):
return self.c.mxid == self.mxid
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))
return cls._select_one_or_none(func.lower(cls.c.tg_username) == username)
@property
def contacts(self) -> Iterable[TelegramID]:
@@ -113,10 +91,10 @@ class User(Base):
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
user: TelegramID = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE",
ondelete="CASCADE"), primary_key=True)
portal: TelegramID = Column(Integer, primary_key=True)
portal_receiver: TelegramID = Column(Integer, primary_key=True)
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"),
@@ -126,5 +104,5 @@ class UserPortal(Base):
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
user: TelegramID = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
contact: TelegramID = Column(Integer, ForeignKey("puppet.id"), primary_key=True)
-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))
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,29 +13,30 @@
#
# 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 typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING
import re
import logging
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
TypeMessageEntity)
from telethon.helpers import add_surrogate, del_surrogate
from mautrix.types import RoomID, MessageEventContent
from ... import puppet as pu
from ...types import TelegramID, MatrixRoomID
from ...types import TelegramID
from ...db import Message as DBMessage
from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text)
from .parser import ParsedMessage, parse_html
if TYPE_CHECKING:
from ...context import Context
log = logging.getLogger("mau.fmt.mx") # type: logging.Logger
should_bridge_plaintext_highlights = False # type: bool
log: logging.Logger = logging.getLogger("mau.fmt.mx")
should_bridge_plaintext_highlights: bool = False
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]
command_regex: Pattern = re.compile(r"^!([A-Za-z0-9@]+)")
not_command_regex: Pattern = re.compile(r"^\\(![A-Za-z0-9@]+)")
plain_mention_regex: Optional[Pattern] = None
def plain_mention_to_html(match: Match) -> str:
@@ -49,17 +49,22 @@ def plain_mention_to_html(match: Match) -> str:
return "".join(match.groups())
MAX_LENGTH = 4096
CUTOFF_TEXT = " [message cut]"
CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT)
def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage:
if len(message) > 4096:
message = message[0:4082] + " [message cut]"
if len(message) > MAX_LENGTH:
message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT
new_entities = []
for entity in entities:
if entity.offset > 4082:
if entity.offset > CUT_MAX_LENGTH:
continue
if entity.offset + entity.length > 4082:
entity.length = 4082 - entity.offset
if entity.offset + entity.length > CUT_MAX_LENGTH:
entity.length = CUT_MAX_LENGTH - entity.offset
new_entities.append(entity)
new_entities.append(MessageEntityItalic(4082, len(" [message cut]")))
new_entities.append(MessageEntityItalic(CUT_MAX_LENGTH, len(CUTOFF_TEXT)))
entities = new_entities
return message, entities
@@ -76,8 +81,8 @@ def matrix_to_telegram(html: str) -> ParsedMessage:
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 = parse_html(add_surrogate(html))
text = del_surrogate(text.strip())
text, entities = cut_long_message(text, entities)
return text, entities
@@ -85,26 +90,12 @@ def matrix_to_telegram(html: str) -> ParsedMessage:
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]:
relates_to = content.get("m.relates_to", None) or {}
if not relates_to:
return None
reply = (relates_to if relates_to.get("rel_type", None) == "m.reference"
else relates_to.get("m.in_reply_to", None) or {})
if not reply:
return None
room_id = room_id or reply.get("room_id", None)
event_id = reply.get("event_id", None)
def matrix_reply_to_telegram(content: MessageEventContent, tg_space: TelegramID,
room_id: Optional[RoomID] = None) -> Optional[TelegramID]:
event_id = content.get_reply_to()
if not event_id:
return
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"])
content.trim_reply_fallback()
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
@@ -124,10 +115,10 @@ def matrix_text_to_telegram(text: str) -> ParsedMessage:
return text, entities
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], str]]:
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[Match], str]]:
entities = []
def replacer(match) -> str:
def replacer(match: Match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
offset = match.start()
@@ -148,7 +139,7 @@ def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], st
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 = config["bridge.displayname_template"]
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
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"]
@@ -1,66 +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):
# From https://www.w3.org/TR/html5/syntax.html#writing-html-documents-elements
void_tags = ("area", "base", "br", "col", "command", "embed", "hr", "img", "input", "link",
"meta", "param", "source", "track", "wbr")
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)
if tag not in self.void_tags:
self.stack.append(node)
def handle_startendtag(self, tag, attrs):
self.stack[-1].append(HTMLNode(tag, attrs))
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: ...
+50 -212
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,238 +13,77 @@
#
# 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 typing import List, Tuple, Optional
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
MessageEntityMentionName as MentionName, MessageEntityUrl as URL,
MessageEntityEmail as Email, MessageEntityTextUrl as TextURL,
MessageEntityBold as Bold, MessageEntityItalic as Italic,
MessageEntityCode as Code, MessageEntityPre as Pre,
MessageEntityStrike as Strike, MessageEntityUnderline as Underline,
MessageEntityBlockquote as Blockquote, TypeMessageEntity)
from telethon.tl.types import TypeMessageEntity
from mautrix.types import UserID, RoomID
from mautrix.util.formatter import MatrixParser as BaseMatrixParser, RecursionContext
from mautrix.util.formatter.html_reader_htmlparser import read_html, HTMLNode
from ... import user as u, puppet as pu, portal as po
from ...types import MatrixUserID
from .telegram_message import TelegramMessage, Entity, offset_length_multiply
from .telegram_message import TelegramMessage, TelegramEntityType
from .html_reader import HTMLNode, read_html
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
def parse_html(input_html: str) -> ParsedMessage:
return MatrixParser.parse(input_html)
msg = MatrixParser.parse(input_html)
return msg.text, msg.telegram_entities
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
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, ...]
class MatrixParser(BaseMatrixParser[TelegramMessage]):
e = TelegramEntityType
fs = TelegramMessage
read_html = read_html
@classmethod
def list_bullet(cls, depth: int) -> str:
return cls.list_bullets[(depth - 1) % len(cls.list_bullets)] + " "
@classmethod
def list_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
ordered = node.tag == "ol"
tagged_children = cls.node_to_tagged_tmessages(node, ctx)
counter = 1
indent_length = 0
if ordered:
try:
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
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 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:
def custom_node_to_fstring(cls, node: HTMLNode, ctx: RecursionContext
) -> Optional[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 in ("s", "strike", "del"):
msg.format(Strike)
elif node.tag in ("u", "ins"):
msg.format(Underline)
elif node == "blockquote":
msg.format(Blockquote)
elif node.tag == "command":
msg.format(Command)
if node.tag == "command":
msg.format(TelegramEntityType.COMMAND)
return None
@classmethod
def user_pill_to_fstring(cls, msg: TelegramMessage, user_id: UserID) -> TelegramMessage:
user = (pu.Puppet.get_by_mxid(user_id)
or u.User.get_by_mxid(user_id, create=False))
if not user:
return msg
if user.username:
return TelegramMessage(f"@{user.username}").format(TelegramEntityType.MENTION)
elif user.tgid:
displayname = user.plain_displayname or msg.text
return TelegramMessage(displayname).format(TelegramEntityType.MENTION_NAME,
user_id=user.tgid)
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
if href.startswith("mailto:"):
return TelegramMessage(href[len("mailto:"):]).format(Email)
mention = cls.mention_regex.match(href)
if mention:
mxid = MatrixUserID(mention.group(1))
user = (pu.Puppet.get_by_mxid(mxid)
or u.User.get_by_mxid(mxid, create=False))
if not user:
return msg
if user.username:
return TelegramMessage(f"@{user.username}").format(Mention)
elif user.tgid:
displayname = user.plain_displayname or msg.text
return TelegramMessage(displayname).format(MentionName, user_id=user.tgid)
return msg
room = cls.room_regex.match(href)
if room:
username = po.Portal.get_username_from_mx_alias(room.group(1))
portal = po.Portal.find_by_username(username)
if portal and portal.username:
return TelegramMessage(f"@{portal.username}").format(Mention)
return (msg.format(URL)
if msg.text == href
else msg.format(TextURL, url=href))
def url_to_fstring(cls, msg: TelegramMessage, url: str) -> TelegramMessage:
if url == msg.text:
return msg.format(cls.e.URL)
else:
return msg.format(cls.e.INLINE_URL, url=url)
@classmethod
def blockquote_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
def room_pill_to_fstring(cls, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage:
username = po.Portal.get_username_from_mx_alias(room_id)
portal = po.Portal.find_by_username(username)
if portal and portal.username:
return TelegramMessage(f"@{portal.username}").format(TelegramEntityType.MENTION)
@classmethod
def header_to_fstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
children = cls.node_to_fstrings(node, ctx)
length = int(node.tag[1])
prefix = "#" * length + " "
return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD)
@classmethod
def blockquote_to_fstring(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 node_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
if 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 == "blockquote":
# Telegram already has blockquote entities in the protocol schema, but it strips them
# server-side and none of the official clients support them.
# TODO once Telegram changes that, use the above if block for blockquotes too.
return cls.blockquote_to_tmessage(node, ctx)
elif node.tag == "a":
return cls.link_to_tstring(node, ctx)
elif node.tag == "p":
return cls.tag_aware_parse_node(node, ctx).append("\n")
elif node.tag == "pre":
lang = ""
try:
if node[0].tag == "code":
node = node[0]
lang = node.attrib["class"][len("language-"):]
except (IndexError, KeyError):
pass
return cls.parse_node(node, ctx.enter_code_block()).format(Pre, language=lang)
elif node.tag == "code":
return cls.parse_node(node, ctx.enter_code_block()).format(Code)
return cls.tag_aware_parse_node(node, ctx)
@staticmethod
def text_to_tmessage(text: str, ctx: RecursionContext) -> TelegramMessage:
if ctx.strip_linebreaks:
text = text.replace("\n", "")
return TelegramMessage(text)
@classmethod
def node_to_tagged_tmessages(cls, node: HTMLNode, ctx: RecursionContext
) -> List[Tuple[TelegramMessage, str]]:
output = []
if node.text:
output.append((cls.text_to_tmessage(node.text, ctx), "text"))
for child in node:
output.append((cls.node_to_tmessage(child, ctx), child.tag))
if child.tail:
output.append((cls.text_to_tmessage(child.tail, ctx), "text"))
return output
@classmethod
def node_to_tmessages(cls, node: HTMLNode, ctx: RecursionContext
) -> List[TelegramMessage]:
return [msg for (msg, tag) in cls.node_to_tagged_tmessages(node, ctx)]
@classmethod
def tag_aware_parse_node(cls, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage:
msgs = cls.node_to_tagged_tmessages(node, ctx)
output = TelegramMessage()
prev_was_block = False
for msg, tag in msgs:
if tag in cls.block_tags:
msg = msg.append("\n")
if not prev_was_block:
msg = msg.prepend("\n")
prev_was_block = True
output = output.append(msg)
return output.trim()
@classmethod
def parse_node(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
return TelegramMessage.join(cls.node_to_tmessages(node, ctx))
@classmethod
def parse(cls, data: str) -> ParsedMessage:
msg = cls.node_to_tmessage(read_html(f"<body>{data}</body>"), RecursionContext())
return msg.text, msg.entities
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,145 +13,87 @@
#
# 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 Callable, List, Optional, Sequence, Type, Union
from typing import Optional, Union, Any, List, Type, Dict
from enum import Enum
from telethon.tl.types import (MessageEntityMentionName as MentionName,
MessageEntityTextUrl as TextURL, MessageEntityPre as Pre,
TypeMessageEntity, InputMessageEntityMentionName as InputMentionName)
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
MessageEntityMentionName as MentionName, MessageEntityUrl as URL,
MessageEntityEmail as Email, MessageEntityTextUrl as TextURL,
MessageEntityBold as Bold, MessageEntityItalic as Italic,
MessageEntityCode as Code, MessageEntityPre as Pre,
MessageEntityStrike as Strike, MessageEntityUnderline as Underline,
MessageEntityBlockquote as Blockquote, TypeMessageEntity,
InputMessageEntityMentionName as InputMentionName)
from mautrix.util.formatter import EntityString, SemiAbstractEntity
class Entity:
@staticmethod
def copy(entity: TypeMessageEntity) -> Optional[TypeMessageEntity]:
if not entity:
return None
kwargs = {
"offset": entity.offset,
"length": entity.length,
}
if isinstance(entity, Pre):
kwargs["language"] = entity.language
elif isinstance(entity, TextURL):
kwargs["url"] = entity.url
elif isinstance(entity, (MentionName, InputMentionName)):
kwargs["user_id"] = entity.user_id
return entity.__class__(**kwargs)
class TelegramEntityType(Enum):
"""EntityType is a Matrix formatting entity type."""
BOLD = Bold
ITALIC = Italic
STRIKETHROUGH = Strike
UNDERLINE = Underline
URL = URL
INLINE_URL = TextURL
EMAIL = Email
PREFORMATTED = Pre
INLINE_CODE = Code
BLOCKQUOTE = Blockquote
MENTION = Mention
MENTION_NAME = MentionName
COMMAND = Command
@classmethod
def adjust(cls, entity: Union[TypeMessageEntity, List[TypeMessageEntity]],
func: Callable[[TypeMessageEntity], None]
) -> Union[Optional[TypeMessageEntity], List[TypeMessageEntity]]:
if isinstance(entity, list):
return [Entity.adjust(element, func) for element in entity if entity]
elif not entity:
return None
entity = cls.copy(entity)
func(entity)
if entity.offset < 0:
entity.length += entity.offset
entity.offset = 0
return entity
USER_MENTION = 1
ROOM_MENTION = 2
HEADER = 3
def offset_diff(amount: int) -> Callable[[TypeMessageEntity], None]:
def func(entity: TypeMessageEntity) -> None:
entity.offset += amount
class TelegramEntity(SemiAbstractEntity):
internal: TypeMessageEntity
return func
def __init__(self, type: Union[TelegramEntityType, Type[TypeMessageEntity]],
offset: int, length: int, extra_info: Dict[str, Any]) -> None:
if isinstance(type, TelegramEntityType):
if isinstance(type.value, int):
raise ValueError(f"Can't create Entity with non-Telegram EntityType {type}")
type = type.value
self.internal = type(offset=offset, length=length, **extra_info)
def copy(self) -> Optional['TelegramEntity']:
extra_info = {}
if isinstance(self.internal, Pre):
extra_info["language"] = self.internal.language
elif isinstance(self.internal, TextURL):
extra_info["url"] = self.internal.url
elif isinstance(self.internal, (MentionName, InputMentionName)):
extra_info["user_id"] = self.internal.user_id
return TelegramEntity(type(self.internal), offset=self.internal.offset,
length=self.internal.length, extra_info=extra_info)
def __repr__(self) -> str:
return str(self.internal)
@property
def offset(self) -> int:
return self.internal.offset
@offset.setter
def offset(self, value: int) -> None:
self.internal.offset = value
@property
def length(self) -> int:
return self.internal.length
@length.setter
def length(self, value: int) -> None:
self.internal.length = value
def offset_length_multiply(amount: int) -> Callable[[TypeMessageEntity], None]:
def func(entity: TypeMessageEntity) -> None:
entity.offset *= amount
entity.length *= amount
class TelegramMessage(EntityString[TelegramEntity, TelegramEntityType]):
entity_class = TelegramEntity
return func
class TelegramMessage:
def __init__(self, text: str = "", entities: Optional[List[TypeMessageEntity]] = None) -> None:
self.text = text # type: str
self.entities = entities or [] # type: List[TypeMessageEntity]
def offset_entities(self, offset: int) -> 'TelegramMessage':
def apply_offset(entity: TypeMessageEntity, inner_offset: int
) -> Optional[TypeMessageEntity]:
entity = Entity.copy(entity)
entity.offset += inner_offset
if entity.offset < 0:
entity.offset = 0
elif entity.offset > len(self.text):
return None
elif entity.offset + entity.length > len(self.text):
entity.length = len(self.text) - entity.offset
return entity
self.entities = [apply_offset(entity, offset) for entity in self.entities if entity]
self.entities = [x for x in self.entities if x is not None]
return self
def append(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
for msg in args:
if isinstance(msg, str):
msg = TelegramMessage(text=msg)
self.entities += Entity.adjust(msg.entities, offset_diff(len(self.text)))
self.text += msg.text
return self
def prepend(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
for msg in args:
if isinstance(msg, str):
msg = TelegramMessage(text=msg)
self.entities = msg.entities + Entity.adjust(self.entities, offset_diff(len(msg.text)))
self.text = msg.text + self.text
return self
def format(self, entity_type: Type[TypeMessageEntity], offset: int = None, length: int = None,
**kwargs) -> 'TelegramMessage':
self.entities.append(entity_type(offset=offset or 0,
length=length if length is not None else len(self.text),
**kwargs))
return self
def concat(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
return TelegramMessage().append(self, *args)
def trim(self) -> 'TelegramMessage':
orig_len = len(self.text)
self.text = self.text.lstrip()
diff = orig_len - len(self.text)
self.text = self.text.rstrip()
self.offset_entities(-diff)
return self
def split(self, separator, max_items: int = 0) -> List['TelegramMessage']:
text_parts = self.text.split(separator, max_items - 1)
output = [] # type: List[TelegramMessage]
offset = 0
for part in text_parts:
msg = TelegramMessage(part)
for entity in self.entities:
start_in_range = len(part) > entity.offset - offset >= 0
end_in_range = len(part) >= entity.offset - offset + entity.length > 0
if start_in_range and end_in_range:
msg.entities.append(Entity.adjust(entity, offset_diff(-offset)))
output.append(msg)
offset += len(part)
offset += len(separator)
return output
@staticmethod
def join(items: Sequence[Union[str, 'TelegramMessage']],
separator: str = " ") -> 'TelegramMessage':
main = TelegramMessage()
for msg in items:
if isinstance(msg, str):
msg = TelegramMessage(text=msg)
main.entities += Entity.adjust(msg.entities, offset_diff(len(main.text)))
main.text += msg.text + separator
if len(separator) > 0:
main.text = main.text[:-len(separator)]
return main
@property
def telegram_entities(self) -> List[TypeMessageEntity]:
return [entity.internal for entity in self.entities]
+84 -106
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,7 +13,7 @@
#
# 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, TYPE_CHECKING
from typing import List, Optional, TYPE_CHECKING
from html import escape
import logging
import re
@@ -23,48 +22,44 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold,
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag,
MessageEntityPhone, TypeMessageEntity, Message, PeerChannel,
MessageEntityPhone, TypeMessageEntity, PeerChannel,
MessageEntityBlockquote, MessageEntityStrike, MessageFwdHeader,
MessageEntityUnderline, PeerUser)
from telethon.tl.custom import Message
from telethon.errors import RPCError
from telethon.helpers import add_surrogate, del_surrogate
from mautrix_appservice import MatrixRequestError
from mautrix_appservice.intent_api import IntentAPI
from mautrix.errors import MatrixRequestError
from mautrix.appservice import IntentAPI
from mautrix.types import (TextMessageEventContent, RelatesTo, RelationType, Format, MessageType,
MessageEvent)
from .. import user as u, puppet as pu, portal as po
from ..types import TelegramID
from ..db import Message as DBMessage
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text)
if TYPE_CHECKING:
from ..abstract_user import AbstractUser
log = logging.getLogger("mau.fmt.tg") # type: logging.Logger
log: logging.Logger = logging.getLogger("mau.fmt.tg")
def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Dict:
def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Optional[RelatesTo]:
if evt.reply_to_msg_id:
space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid)
msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space)
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
if msg:
return {
"m.in_reply_to": {
"event_id": msg.mxid,
"room_id": msg.mx_room,
},
"rel_type": "m.reference",
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
return {}
return RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
return None
async def _add_forward_header(source, text: str, html: Optional[str],
fwd_from: MessageFwdHeader) -> Tuple[str, str]:
if not html:
html = escape(text)
async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventContent,
fwd_from: MessageFwdHeader) -> None:
if not content.formatted_body or content.format != Format.HTML:
content.format = Format.HTML
content.formatted_body = escape(content.body)
fwd_from_html, fwd_from_text = None, None
if fwd_from.from_id:
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id))
@@ -81,11 +76,14 @@ async def _add_forward_header(source, text: str, html: Optional[str],
f"{escape(fwd_from_text)}</a>")
if not fwd_from_text:
user = await source.client.get_entity(PeerUser(fwd_from.from_id))
if user:
fwd_from_text = pu.Puppet.get_displayname(user, False)
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
else:
try:
user = await source.client.get_entity(PeerUser(fwd_from.from_id))
if user:
fwd_from_text = pu.Puppet.get_displayname(user, False)
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
except (ValueError, RPCError):
fwd_from_text = fwd_from_html = "unknown user"
elif fwd_from.channel_id:
portal = po.Portal.get_by_tgid(TelegramID(fwd_from.channel_id))
if portal:
fwd_from_text = portal.title
@@ -93,78 +91,49 @@ async def _add_forward_header(source, text: str, html: Optional[str],
fwd_from_html = (f"<a href='https://matrix.to/#/{portal.alias}'>"
f"{escape(fwd_from_text)}</a>")
else:
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
fwd_from_html = f"channel <b>{escape(fwd_from_text)}</b>"
else:
channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
if channel:
fwd_from_text = channel.title
fwd_from_html = f"<b>{fwd_from_text}</b>"
try:
channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
if channel:
fwd_from_text = f"channel {channel.title}"
fwd_from_html = f"channel <b>{escape(channel.title)}</b>"
except (ValueError, RPCError):
fwd_from_text = fwd_from_html = "unknown channel"
elif fwd_from.from_name:
fwd_from_text = fwd_from.from_name
fwd_from_html = f"<b>{escape(fwd_from.from_name)}</b>"
else:
fwd_from_text = "unknown source"
fwd_from_html = f"unknown source"
if not fwd_from_text:
if fwd_from.from_id:
fwd_from_text = "Unknown user"
else:
fwd_from_text = "Unknown source"
fwd_from_html = f"<b>{fwd_from_text}</b>"
text = "\n".join([f"> {line}" for line in text.split("\n")])
text = f"Forwarded from {fwd_from_text}:\n{text}"
html = (f"Forwarded message from {fwd_from_html}<br/>"
f"<tg-forward><blockquote>{html}</blockquote></tg-forward>")
return text, html
content.body = "\n".join([f"> {line}" for line in content.body.split("\n")])
content.body = f"Forwarded from {fwd_from_text}:\n{content.body}"
content.formatted_body = (
f"Forwarded message from {fwd_from_html}<br/>"
f"<tg-forward><blockquote>{content.formatted_body}</blockquote></tg-forward>")
async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message,
relates_to: Dict, main_intent: IntentAPI) -> Tuple[str, str]:
async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventContent, evt: Message,
main_intent: IntentAPI):
space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid)
msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space)
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
if not msg:
return text, html
return
relates_to["rel_type"] = "m.reference"
relates_to["event_id"] = msg.mxid
relates_to["room_id"] = msg.mx_room
relates_to["m.in_reply_to"] = {
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
content.relates_to = RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
try:
event = await main_intent.get_event(msg.mx_room, msg.mxid)
content = event["content"]
r_sender = event["sender"]
r_text_body = trim_reply_fallback_text(content["body"])
r_html_body = trim_reply_fallback_html(content["formatted_body"]
if "formatted_body" in content
else escape(content["body"]))
puppet = pu.Puppet.get_by_mxid(r_sender, create=False)
r_displayname = puppet.displayname if puppet else r_sender
r_sender_link = f"<a href='https://matrix.to/#/{r_sender}'>{escape(r_displayname)}</a>"
except (ValueError, KeyError, MatrixRequestError):
r_sender_link = "unknown user"
r_displayname = "unknown user"
r_text_body = "Failed to fetch message"
r_html_body = "<em>Failed to fetch message</em>"
r_msg_link = f"<a href='https://matrix.to/#/{msg.mx_room}/{msg.mxid}'>In reply to</a>"
html = (
f"<mx-reply><blockquote>{r_msg_link} {r_sender_link}\n{r_html_body}</blockquote></mx-reply>"
+ (html or escape(text)))
lines = r_text_body.strip().split("\n")
text_with_quote = f"> <{r_displayname}> {lines.pop(0)}"
for line in lines:
if line:
text_with_quote += f"\n> {line}"
text_with_quote += "\n\n"
text_with_quote += text
return text_with_quote, html
event: MessageEvent = await main_intent.get_event(msg.mx_room, msg.mxid)
if isinstance(event.content, TextMessageEventContent):
event.content.trim_reply_fallback()
puppet = pu.Puppet.get_by_mxid(event.sender, create=False)
content.set_reply(event, displayname=puppet.displayname if puppet else event.sender)
except MatrixRequestError:
log.exception("Failed to get event to add reply fallback")
async def telegram_to_matrix(evt: Message, source: "AbstractUser",
@@ -172,33 +141,42 @@ async def telegram_to_matrix(evt: Message, source: "AbstractUser",
prefix_text: Optional[str] = None, prefix_html: Optional[str] = None,
override_text: str = None,
override_entities: List[TypeMessageEntity] = None,
no_reply_fallback: bool = False) -> Tuple[str, str, Dict]:
text = add_surrogates(override_text or evt.message)
no_reply_fallback: bool = False) -> TextMessageEventContent:
content = TextMessageEventContent(
msgtype=MessageType.TEXT,
body=add_surrogate(override_text or evt.message),
)
entities = override_entities or evt.entities
html = _telegram_entities_to_matrix_catch(text, entities) if entities else None
relates_to = {} # type: Dict
if entities:
content.format = Format.HTML
content.formatted_body = _telegram_entities_to_matrix_catch(content.body, entities)
if prefix_html:
html = prefix_html + (html or escape(text))
if not content.formatted_body:
content.format = Format.HTML
content.formatted_body = escape(content.body)
content.formatted_body = prefix_html + content.formatted_body
if prefix_text:
text = prefix_text + text
content.body = prefix_text + content.body
if evt.fwd_from:
text, html = await _add_forward_header(source, text, html, evt.fwd_from)
await _add_forward_header(source, content, evt.fwd_from)
if evt.reply_to_msg_id and not no_reply_fallback:
text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent)
await _add_reply_header(source, content, evt, main_intent)
if isinstance(evt, Message) and evt.post and evt.post_author:
if not html:
html = escape(text)
text += f"\n- {evt.post_author}"
html += f"<br/><i>- <u>{evt.post_author}</u></i>"
if not content.formatted_body:
content.formatted_body = escape(content.body)
content.body += f"\n- {evt.post_author}"
content.formatted_body += f"<br/><i>- <u>{evt.post_author}</u></i>"
if html:
html = html.replace("\n", "<br/>")
content.body = del_surrogate(content.body)
return remove_surrogates(text), remove_surrogates(html), relates_to
if content.formatted_body:
content.formatted_body = del_surrogate(content.formatted_body.replace("\n", "<br/>"))
return content
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
@@ -313,8 +291,8 @@ def _parse_name_mention(html: List[str], entity_text: str, user_id: TelegramID)
return False
message_link_regex = re.compile(
r"https?://t(?:elegram)?\.(?:me|dog)/([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})")
message_link_regex = re.compile(r"https?://t(?:elegram)?\.(?:me|dog)/"
r"([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})")
def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
-57
View File
@@ -1,57 +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, Pattern
from html import escape
import struct
import re
# add_surrogates and remove_surrogates are unicode surrogate utility functions from Telethon.
# Licensed under the MIT license.
# https://github.com/LonamiWebs/Telethon/blob/7cce7aa3e4c6c7019a55530391b1761d33e5a04e/telethon/helpers.py
def add_surrogates(text: Optional[str]) -> Optional[str]:
if text is None:
return None
return "".join("".join(chr(y) for y in struct.unpack("<HH", x.encode("utf-16-le")))
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text)
def remove_surrogates(text: Optional[str]) -> Optional[str]:
if text is None:
return None
return text.encode("utf-16", "surrogatepass").decode("utf-16")
# trim_reply_fallback_text, html_reply_fallback_regex and trim_reply_fallback_html are Matrix
# reply fallback utility functions.
# You may copy and use them under any OSI-approved license.
def trim_reply_fallback_text(text: str) -> str:
if not text.startswith("> ") or "\n" not in text:
return text
lines = text.split("\n")
while len(lines) > 0 and lines[0].startswith("> "):
lines.pop(0)
return "\n".join(lines)
html_reply_fallback_regex = re.compile("^<mx-reply>"
r"[\s\S]+?"
"</mx-reply>") # type: Pattern
def trim_reply_fallback_html(html: str) -> str:
return html_reply_fallback_regex.sub("", html)
+49
View File
@@ -0,0 +1,49 @@
import subprocess
import shutil
import os
from . import __version__
cmd_env = {
"PATH": os.environ["PATH"],
"HOME": os.environ["HOME"],
"LANG": "C",
"LC_ALL": "C",
}
def run(cmd):
return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env)
if os.path.exists(".git") and shutil.which("git"):
try:
git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii")
git_revision_url = f"https://github.com/tulir/mautrix-telegram/commit/{git_revision}"
git_revision = git_revision[:8]
except (subprocess.SubprocessError, OSError):
git_revision = "unknown"
git_revision_url = None
try:
git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii")
except (subprocess.SubprocessError, OSError):
git_tag = None
else:
git_revision = "unknown"
git_revision_url = None
git_tag = None
git_tag_url = (f"https://github.com/tulir/mautrix-telegram/releases/tag/{git_tag}"
if git_tag else None)
if git_tag and __version__ == git_tag[1:].replace("-", ""):
version = __version__
linkified_version = f"[{version}]({git_tag_url})"
else:
if not __version__.endswith("+dev"):
__version__ += "+dev"
version = f"{__version__}.{git_revision}"
if git_revision_url:
linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})"
else:
linkified_version = version
+184 -268
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,58 +13,56 @@
#
# 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, Match, Optional, Set, Tuple, TYPE_CHECKING
import logging
import asyncio
import time
import re
from typing import Dict, Set, Tuple, Union, Iterable, TYPE_CHECKING
from mautrix_appservice import MatrixRequestError, IntentError
from mautrix.bridge import BaseMatrixHandler
from mautrix.types import (Event, EventType, RoomID, UserID, EventID, ReceiptEvent, ReceiptType,
ReceiptEventContent, PresenceEvent, PresenceState, TypingEvent,
MessageEvent, StateEvent, RedactionEvent, RoomNameStateEventContent,
RoomAvatarStateEventContent, RoomTopicStateEventContent,
MemberStateEventContent)
from mautrix.errors import MatrixError
from .types import MatrixEvent, MatrixEventID, MatrixRoomID, MatrixUserID
from . import user as u, portal as po, puppet as pu, commands as com
if TYPE_CHECKING:
from .context import Context
from .bot import Bot
try:
from prometheus_client import Histogram
EVENT_TIME = Histogram("matrix_event", "Time spent processing Matrix events",
["event_type"])
EVENT_TIME = Histogram("matrix_event", "Time spent processing Matrix events", ["event_type"])
except ImportError:
Histogram = None
EVENT_TIME = None
RoomMetaStateEventContent = Union[RoomNameStateEventContent, RoomAvatarStateEventContent,
RoomTopicStateEventContent]
class MatrixHandler:
log = logging.getLogger("mau.mx") # type: logging.Logger
class MatrixHandler(BaseMatrixHandler):
bot: 'Bot'
commands: 'com.CommandProcessor'
previously_typing: Dict[RoomID, Set[UserID]]
def __init__(self, context: 'Context') -> None:
self.az, self.config, _, self.tgbot = context.core
self.commands = com.CommandProcessor(context) # type: com.CommandProcessor
self.previously_typing = [] # type: List[MatrixUserID]
super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop,
command_processor=com.CommandProcessor(context))
self.bot = context.bot
self.previously_typing = {}
self.az.matrix_event_handler(self.handle_event)
async def get_user(self, user_id: UserID) -> 'u.User':
return await u.User.get_by_mxid(user_id).ensure_started()
async def init_as_bot(self) -> None:
displayname = self.config["appservice.bot_displayname"]
if displayname:
try:
await self.az.intent.set_display_name(
displayname if displayname != "remove" else "")
except asyncio.TimeoutError:
self.log.exception("TimeoutError when trying to set displayname")
async def get_portal(self, room_id: RoomID) -> 'po.Portal':
return po.Portal.get_by_mxid(room_id)
avatar = self.config["appservice.bot_avatar"]
if avatar:
try:
await self.az.intent.set_avatar(avatar if avatar != "remove" else "")
except asyncio.TimeoutError:
self.log.exception("TimeoutError when trying to set avatar")
async def get_puppet(self, user_id: UserID) -> 'pu.Puppet':
return pu.Puppet.get_by_mxid(user_id)
async def handle_puppet_invite(self, room_id: MatrixRoomID, puppet: pu.Puppet, inviter: u.User
) -> None:
async def handle_puppet_invite(self, room_id: RoomID, puppet: pu.Puppet, inviter: u.User,
event_id: EventID) -> None:
intent = puppet.default_mxid_intent
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room_id}")
if not await inviter.is_logged_in():
@@ -83,7 +80,7 @@ class MatrixHandler:
return
try:
members = await self.az.intent.get_room_members(room_id)
except MatrixRequestError:
except MatrixError:
members = []
if self.az.bot_mxid not in members:
if len(members) > 1:
@@ -95,18 +92,16 @@ class MatrixHandler:
await intent.join_room(room_id)
portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
# TODO: if portal is None:
if portal.mxid:
try:
await intent.invite(portal.mxid, inviter.mxid)
await intent.send_notice(room_id, text=None, html=(
"You already have a private chat with me: "
f"<a href='https://matrix.to/#/{portal.mxid}'>"
"Link to room"
"</a>"))
await intent.invite_user(portal.mxid, inviter.mxid)
await intent.send_notice(
room_id, text=f"You already have a private chat with me: {portal.mxid}",
html=("You already have a private chat with me: "
f"<a href='https://matrix.to/#/{portal.mxid}'>Link to room</a>"))
await intent.leave_room(room_id)
return
except MatrixRequestError:
except MatrixError:
pass
portal.mxid = room_id
portal.save()
@@ -117,67 +112,25 @@ class MatrixHandler:
await intent.send_notice(room_id, "This puppet will remain inactive until a "
"Telegram chat is created for this room.")
async def accept_bot_invite(self, room_id: MatrixRoomID, inviter: u.User) -> None:
tries = 0
while tries < 5:
try:
await self.az.intent.join_room(room_id)
break
except (IntentError, MatrixRequestError):
tries += 1
wait_for_seconds = (tries + 1) * 10
if tries < 5:
self.log.exception(f"Failed to join room {room_id} with bridge bot, "
f"retrying in {wait_for_seconds} seconds...")
await asyncio.sleep(wait_for_seconds)
else:
self.log.exception("Failed to join room {room}, giving up.")
return
if not inviter.whitelisted:
await self.az.intent.send_notice(
room_id,
text="You are not whitelisted to use this bridge.\n\n"
"If you are the owner of this bridge, see the "
"`bridge.permissions` section in your config file.",
html="<p>You are not whitelisted to use this bridge.</p>"
"<p>If you are the owner of this bridge, see the "
"<code>bridge.permissions</code> section in your config file.</p>")
await self.az.intent.leave_room(room_id)
async def send_welcome_message(self, room_id: RoomID, inviter: 'u.User') -> None:
try:
is_management = len(await self.az.intent.get_room_members(room_id)) == 2
except MatrixRequestError:
is_management = False
except MatrixError:
# The AS bot is not in the room.
return
cmd_prefix = self.commands.command_prefix
text = html = "Hello, I'm a Telegram bridge bot. "
if is_management and inviter.puppet_whitelisted and not await inviter.is_logged_in():
text += f"Use `{cmd_prefix} help` for help or `{cmd_prefix} login` to log in."
html += (f"Use <code>{cmd_prefix} help</code> for help"
f" or <code>{cmd_prefix} login</code> to log in.")
pass
else:
text += f"Use `{cmd_prefix} help` for help."
html += f"Use <code>{cmd_prefix} help</code> for help."
await self.az.intent.send_notice(room_id, text=text, html=html)
async def handle_invite(self, room_id: MatrixRoomID, user_id: MatrixUserID,
inviter_mxid: MatrixUserID) -> None:
self.log.debug(f"{inviter_mxid} invited {user_id} to {room_id}")
inviter = u.User.get_by_mxid(inviter_mxid)
if inviter is None:
self.log.exception("Failed to find user with Matrix ID {inviter_mxid}")
await inviter.ensure_started()
if user_id == self.az.bot_mxid:
return await self.accept_bot_invite(room_id, inviter)
elif not inviter.whitelisted:
return
puppet = pu.Puppet.get_by_mxid(user_id)
if puppet:
await self.handle_puppet_invite(room_id, puppet, inviter)
return
async def handle_invite(self, room_id: RoomID, user_id: UserID, inviter: 'u.User',
event_id: EventID) -> None:
user = u.User.get_by_mxid(user_id, create=False)
if not user:
return
@@ -185,12 +138,8 @@ class MatrixHandler:
portal = po.Portal.get_by_mxid(room_id)
if user and await user.has_full_access(allow_bot=True) and portal:
await portal.invite_telegram(inviter, user)
return
# The rest can probably be ignored
async def handle_join(self, room_id: MatrixRoomID, user_id: MatrixUserID,
event_id: MatrixEventID) -> None:
async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
user = await u.User.get_by_mxid(user_id).ensure_started()
portal = po.Portal.get_by_mxid(room_id)
@@ -198,35 +147,58 @@ class MatrixHandler:
return
if not user.relaybot_whitelisted:
await portal.main_intent.kick(room_id, user.mxid,
"You are not whitelisted on this Telegram bridge.")
await portal.main_intent.kick_user(room_id, user.mxid,
"You are not whitelisted on this Telegram bridge.")
return
elif not await user.is_logged_in() and not portal.has_bot:
await portal.main_intent.kick(room_id, user.mxid,
"This chat does not have a bot relaying "
"messages for unauthenticated users.")
await portal.main_intent.kick_user(room_id, user.mxid,
"This chat does not have a bot relaying "
"messages for unauthenticated users.")
return
self.log.debug(f"{user} joined {room_id}")
if await user.is_logged_in() or portal.has_bot:
await portal.join_matrix(user, event_id)
async def handle_part(self, room_id: MatrixRoomID, user_id: MatrixUserID,
sender_mxid: MatrixUserID, event_id: MatrixEventID) -> None:
async def get_leave_handle_info(self) -> Tuple[po.Portal, u.User]:
pass
async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
self.log.debug(f"{user_id} left {room_id}")
sender = u.User.get_by_mxid(sender_mxid, create=False)
if not sender:
return
await sender.ensure_started()
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
user = u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
await portal.leave_matrix(user, event_id)
async def handle_kick_ban(self, ban: bool, room_id: RoomID, user_id: UserID, sender: UserID,
reason: str, event_id: EventID) -> None:
action = "banned" if ban else "kicked"
self.log.debug(f"{user_id} was {action} from {room_id} by {sender} for {reason}")
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
if user_id == self.az.bot_mxid:
# Direct chat portal unbridging is handled in portal.kick_matrix
if portal.peer_type != "user":
await portal.unbridge()
return
sender = u.User.get_by_mxid(sender, create=False)
if not sender:
return
await sender.ensure_started()
puppet = pu.Puppet.get_by_mxid(user_id)
if puppet:
if sender:
if ban:
await portal.ban_matrix(puppet, sender)
else:
await portal.kick_matrix(puppet, sender)
return
@@ -234,90 +206,73 @@ class MatrixHandler:
if not user:
return
await user.ensure_started()
if await user.is_logged_in() or portal.has_bot:
await portal.leave_matrix(user, sender, event_id)
if ban:
await portal.ban_matrix(user, sender)
else:
await portal.kick_matrix(user, sender)
def is_command(self, message: Dict) -> Tuple[bool, str]:
text = message.get("body", "")
prefix = self.config["bridge.command_prefix"]
is_command = text.startswith(prefix)
if is_command:
text = text[len(prefix) + 1:].lstrip()
return is_command, text
async def handle_kick(self, room_id: RoomID, user_id: UserID, kicked_by: UserID, reason: str,
event_id: EventID) -> None:
await self.handle_kick_ban(False, room_id, user_id, kicked_by, reason, event_id)
async def handle_message(self, room: MatrixRoomID, sender_id: MatrixUserID, message: Dict,
event_id: MatrixEventID) -> None:
is_command, text = self.is_command(message)
sender = await u.User.get_by_mxid(sender_id).ensure_started()
if not sender.relaybot_whitelisted:
self.log.debug(f"Ignoring message \"{message}\" from {sender} to {room}:"
" User is not whitelisted.")
return
self.log.debug(f"Received Matrix event \"{message}\" from {sender} in {room}")
async def handle_unban(self, room_id: RoomID, user_id: UserID, unbanned_by: UserID,
reason: str, event_id: EventID) -> None:
# TODO handle unbans properly instead of handling it as a kick
await self.handle_kick_ban(False, room_id, user_id, unbanned_by, reason, event_id)
portal = po.Portal.get_by_mxid(room)
if not is_command and portal and (await sender.is_logged_in() or portal.has_bot):
await portal.handle_matrix_message(sender, message, event_id)
return
if not sender.whitelisted or message.get("msgtype", "m.unknown") != "m.text":
return
try:
is_management = len(await self.az.intent.get_room_members(room)) == 2
except MatrixRequestError:
# The AS bot is not in the room.
return
if is_command or is_management:
try:
command, arguments = text.split(" ", 1)
args = arguments.split(" ")
except ValueError:
# Not enough values to unpack, i.e. no arguments
command = text
args = []
await self.commands.handle(room, event_id, sender, command, args, is_management,
is_portal=portal is not None)
async def handle_ban(self, room_id: RoomID, user_id: UserID, banned_by: UserID, reason: str,
event_id: EventID) -> None:
await self.handle_kick_ban(True, room_id, user_id, banned_by, reason, event_id)
@staticmethod
async def handle_redaction(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
event_id: MatrixEventID) -> None:
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
async def allow_message(user: 'u.User') -> bool:
return user.relaybot_whitelisted
@staticmethod
async def allow_command(user: 'u.User') -> bool:
return user.whitelisted
@staticmethod
async def allow_bridging_message(user: 'u.User', portal: 'po.Portal') -> bool:
return await user.is_logged_in() or portal.has_bot
@staticmethod
async def handle_redaction(evt: RedactionEvent) -> None:
sender = await u.User.get_by_mxid(evt.sender).ensure_started()
if not sender.relaybot_whitelisted:
return
portal = po.Portal.get_by_mxid(room_id)
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return
await portal.handle_matrix_deletion(sender, event_id)
await portal.handle_matrix_deletion(sender, evt.redacts)
@staticmethod
async def handle_power_levels(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
new: Dict, old: Dict) -> None:
async def handle_power_levels(evt: StateEvent) -> None:
portal = po.Portal.get_by_mxid(evt.room_id)
sender = await u.User.get_by_mxid(evt.sender).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal:
await portal.handle_matrix_power_levels(sender, evt.content.users,
evt.unsigned.prev_content.users)
@staticmethod
async def handle_room_meta(evt_type: EventType, room_id: RoomID, sender_mxid: UserID,
content: RoomMetaStateEventContent) -> None:
portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal:
await portal.handle_matrix_power_levels(sender, new["users"], old["users"])
@staticmethod
async def handle_room_meta(evt_type: str, room_id: MatrixRoomID, sender_mxid: MatrixUserID,
content: dict) -> None:
portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal:
handler, content_key = {
"m.room.name": (portal.handle_matrix_title, "name"),
"m.room.topic": (portal.handle_matrix_about, "topic"),
"m.room.avatar": (portal.handle_matrix_avatar, "url"),
handler, content_type, content_key = {
EventType.ROOM_NAME: (portal.handle_matrix_title, RoomNameStateEventContent, "name"),
EventType.ROOM_TOPIC: (portal.handle_matrix_about, RoomTopicStateEventContent, "topic"),
EventType.ROOM_AVATAR: (portal.handle_matrix_avatar, RoomAvatarStateEventContent, "url"),
}[evt_type]
if content_key not in content:
if not isinstance(content, content_type):
return
await handler(sender, content[content_key])
@staticmethod
async def handle_room_pin(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
async def handle_room_pin(room_id: RoomID, sender_mxid: UserID,
new_events: Set[str], old_events: Set[str]) -> None:
portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
@@ -325,62 +280,69 @@ class MatrixHandler:
events = new_events - old_events
if len(events) > 0:
# New event pinned, set that as pinned in Telegram.
await portal.handle_matrix_pin(sender, MatrixEventID(events.pop()))
await portal.handle_matrix_pin(sender, EventID(events.pop()))
elif len(new_events) == 0:
# All pinned events removed, remove pinned event in Telegram.
await portal.handle_matrix_pin(sender, None)
@staticmethod
async def handle_room_upgrade(room_id: MatrixRoomID, new_room_id: MatrixRoomID) -> None:
async def handle_room_upgrade(room_id: RoomID, sender: UserID, new_room_id: RoomID) -> None:
portal = po.Portal.get_by_mxid(room_id)
if portal:
await portal.handle_matrix_upgrade(new_room_id)
await portal.handle_matrix_upgrade(sender, new_room_id)
async def handle_member_info_change(self, room_id: RoomID, user_id: UserID,
profile: MemberStateEventContent,
prev_profile: MemberStateEventContent,
event_id: EventID) -> None:
if profile.displayname == prev_profile.displayname:
return
@staticmethod
async def handle_name_change(room_id: MatrixRoomID, user_id: MatrixUserID, displayname: str,
prev_displayname: str, event_id: MatrixEventID) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal or not portal.has_bot:
return
user = await u.User.get_by_mxid(user_id).ensure_started()
if await user.needs_relaybot(portal):
await portal.name_change_matrix(user, displayname, prev_displayname, event_id)
await portal.name_change_matrix(user, profile.displayname, prev_profile.displayname,
event_id)
@staticmethod
def parse_read_receipts(content: Dict) -> Dict[MatrixUserID, MatrixEventID]:
return {user_id: event_id
def parse_read_receipts(content: ReceiptEventContent) -> Iterable[Tuple[UserID, EventID]]:
return ((user_id, event_id)
for event_id, receipts in content.items()
for user_id in receipts.get("m.read", {})}
for user_id in receipts.get(ReceiptType.READ, {}))
@staticmethod
async def handle_read_receipts(room_id: MatrixRoomID,
receipts: Dict[MatrixUserID, MatrixEventID]) -> None:
async def handle_read_receipts(room_id: RoomID, receipts: Iterable[Tuple[UserID, EventID]]
) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
for user_id, event_id in receipts.items():
for user_id, event_id in receipts:
user = await u.User.get_by_mxid(user_id).ensure_started()
if not await user.is_logged_in():
continue
await portal.mark_read(user, event_id)
@staticmethod
async def handle_presence(user_id: MatrixUserID, presence: str) -> None:
async def handle_presence(user_id: UserID, presence: PresenceState) -> None:
user = await u.User.get_by_mxid(user_id).ensure_started()
if not await user.is_logged_in():
return
await user.set_presence(presence == "online")
await user.set_presence(presence == PresenceState.ONLINE)
async def handle_typing(self, room_id: MatrixRoomID, now_typing: List[MatrixUserID]) -> None:
async def handle_typing(self, room_id: RoomID, now_typing: Set[UserID]) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
for user_id in set(self.previously_typing + now_typing):
previously_typing = self.previously_typing.get(room_id, set())
for user_id in set(previously_typing | now_typing):
is_typing = user_id in now_typing
was_typing = user_id in self.previously_typing
was_typing = user_id in previously_typing
if is_typing and was_typing:
continue
@@ -390,88 +352,42 @@ class MatrixHandler:
await portal.set_typing(user, is_typing)
self.previously_typing = now_typing
self.previously_typing[room_id] = now_typing
def filter_matrix_event(self, event: MatrixEvent) -> bool:
sender = event.get("sender", None)
if not sender:
return False
return (sender == self.az.bot_mxid
or pu.Puppet.get_id_from_mxid(sender) is not None)
def filter_matrix_event(self, evt: Event) -> bool:
if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent)):
return True
return evt.sender and (evt.sender == self.az.bot_mxid
or pu.Puppet.get_id_from_mxid(evt.sender) is not None)
async def try_handle_ephemeral_event(self, evt: MatrixEvent) -> None:
try:
await self.handle_ephemeral_event(evt)
except Exception:
self.log.exception("Error handling manually received Matrix event")
async def handle_ephemeral_event(self, evt: Union[ReceiptEvent, PresenceEvent, TypingEvent]
) -> None:
if evt.type == EventType.RECEIPT:
await self.handle_read_receipts(evt.room_id, self.parse_read_receipts(evt.content))
elif evt.type == EventType.PRESENCE:
await self.handle_presence(evt.sender, evt.content.presence)
elif evt.type == EventType.TYPING:
await self.handle_typing(evt.room_id, set(evt.content.user_ids))
async def handle_ephemeral_event(self, evt: MatrixEvent) -> None:
evt_type = evt.get("type", "m.unknown") # type: str
room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID]
sender = evt.get("sender", None) # type: Optional[MatrixUserID]
content = evt.get("content", {}) # type: Dict
if evt_type == "m.receipt":
await self.handle_read_receipts(room_id, self.parse_read_receipts(content))
elif evt_type == "m.presence":
await self.handle_presence(sender, content.get("presence", "offline"))
elif evt_type == "m.typing":
await self.handle_typing(room_id, content.get("user_ids", []))
async def handle_event(self, evt: Event) -> None:
if evt.type == EventType.ROOM_REDACTION:
await self.handle_redaction(evt)
async def handle_event(self, evt: MatrixEvent) -> None:
if self.filter_matrix_event(evt):
return
start_time = time.time()
self.log.debug("Received event: %s", evt)
evt_type = evt.get("type", "m.unknown") # type: str
room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID]
event_id = evt.get("event_id", None) # type: Optional[MatrixEventID]
sender = evt.get("sender", None) # type: Optional[MatrixUserID]
state_key = evt.get("state_key", None)
content = evt.get("content", {}) # type: Dict
if state_key is not None:
if evt_type == "m.room.member":
prev_content = evt.get("unsigned", {}).get("prev_content", {}) # type: Dict
membership = content.get("membership", "") # type: str
prev_membership = prev_content.get("membership", "leave") # type: str
if membership == prev_membership:
match = re.compile("@(.+):(.+)").match(state_key) # type: Match
mxid = match.group(0) # type: str
displayname = content.get("displayname", None) or mxid # type: str
prev_displayname = prev_content.get("displayname", None) or mxid # type: str
if displayname != prev_displayname:
await self.handle_name_change(room_id, state_key, displayname,
prev_displayname, event_id)
elif membership == "invite":
await self.handle_invite(room_id, state_key, sender)
elif prev_membership == "join" and membership == "leave":
await self.handle_part(room_id, state_key, sender, event_id)
elif membership == "join":
await self.handle_join(room_id, state_key, event_id)
elif evt_type == "m.room.power_levels":
prev_content = evt.get("unsigned", {}).get("prev_content", {})
await self.handle_power_levels(room_id, sender, evt["content"], prev_content)
elif evt_type in ("m.room.name", "m.room.avatar", "m.room.topic"):
await self.handle_room_meta(evt_type, room_id, sender, evt["content"])
elif evt_type == "m.room.pinned_events":
new_events = set(evt["content"]["pinned"])
try:
old_events = set(evt["unsigned"]["prev_content"]["pinned"])
except KeyError:
old_events = set()
await self.handle_room_pin(room_id, sender, new_events, old_events)
elif evt_type == "m.room.tombstone":
await self.handle_room_upgrade(room_id, evt["content"]["replacement_room"])
else:
return
else:
if evt_type in ("m.room.message", "m.sticker"):
if evt_type != "m.room.message":
content["msgtype"] = evt_type
await self.handle_message(room_id, sender, content, event_id)
elif evt_type == "m.room.redaction":
await self.handle_redaction(room_id, sender, evt["redacts"])
else:
return
async def handle_state_event(self, evt: StateEvent) -> None:
if evt.type == EventType.ROOM_POWER_LEVELS:
await self.handle_power_levels(evt)
elif evt.type in (EventType.ROOM_NAME, EventType.ROOM_AVATAR, EventType.ROOM_TOPIC):
await self.handle_room_meta(evt.type, evt.room_id, evt.sender, evt.content)
elif evt.type == EventType.ROOM_PINNED_EVENTS:
new_events = set(evt.content.pinned)
try:
old_events = set(evt.unsigned.prev_content.pinned)
except (KeyError, ValueError, TypeError, AttributeError):
old_events = set()
await self.handle_room_pin(evt.room_id, evt.sender, new_events, old_events)
elif evt.type == EventType.ROOM_TOMBSTONE:
await self.handle_room_upgrade(evt.room_id, evt.sender, evt.content.replacement_room)
async def log_event_handle_duration(self, evt: Event, duration: float) -> None:
if EVENT_TIME:
EVENT_TIME.labels(event_type=evt_type).observe(time.time() - start_time)
EVENT_TIME.labels(event_type=str(evt.type)).observe(duration)
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
from .base import BasePortal, init as init_base
from .matrix import PortalMatrix, init as init_matrix
from .metadata import PortalMetadata, init as init_metadata
from .telegram import PortalTelegram, init as init_telegram
from .deduplication import init as init_dedup
from ..context import Context
class Portal(PortalMatrix, PortalTelegram, PortalMetadata):
pass
def init(context: Context) -> None:
init_base(context)
init_dedup(context)
init_metadata(context)
init_telegram(context)
init_matrix(context)
__all__ = ["Portal", "init"]
+15
View File
@@ -0,0 +1,15 @@
from typing import Union
from .base import BasePortal
from .portal_matrix import PortalMatrix
from .portal_metadata import PortalMetadata
from .portal_telegram import PortalTelegram
from ..context import Context
Portal = Union[BasePortal, PortalMatrix, PortalMetadata, PortalTelegram]
def init(context: Context) -> None:
pass
__all__ = ["Portal", "init"]
+508
View File
@@ -0,0 +1,508 @@
# 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 Awaitable, Dict, List, Optional, Tuple, Union, Any, TYPE_CHECKING
from abc import ABC, abstractmethod
import asyncio
import logging
import json
from telethon.tl.functions.messages import ExportChatInviteRequest
from telethon.tl.types import (Channel, ChannelFull, Chat, ChatFull, ChatInviteEmpty, InputChannel,
InputPeerChannel, InputPeerChat, InputPeerUser, InputUser,
PeerChannel, PeerChat, PeerUser, TypeChat, TypeInputPeer, TypePeer,
TypeUser, TypeUserFull, User, UserFull, TypeInputChannel, Photo,
Document, TypePhotoSize, PhotoSize, InputPhotoFileLocation,
TypeChatParticipant, TypeChannelParticipant, PhotoEmpty, ChatPhoto,
ChatPhotoEmpty)
from mautrix.errors import MatrixRequestError, IntentError
from mautrix.appservice import AppService, IntentAPI
from mautrix.types import RoomID, RoomAlias, UserID, EventType, PowerLevelStateEventContent
from mautrix.util.simple_template import SimpleTemplate
from ..types import TelegramID
from ..context import Context
from ..db import Portal as DBPortal
from .. import puppet as p, user as u, util
from .deduplication import PortalDedup
from .send_lock import PortalSendLock
if TYPE_CHECKING:
from ..bot import Bot
from ..abstract_user import AbstractUser
from ..config import Config
from . import Portal
TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
TypeChatPhoto = Union[ChatPhoto, ChatPhotoEmpty, Photo, PhotoEmpty]
InviteList = Union[UserID, List[UserID]]
config: Optional['Config'] = None
class BasePortal(ABC):
base_log: logging.Logger = logging.getLogger("mau.portal")
az: AppService = None
bot: 'Bot' = None
loop: asyncio.AbstractEventLoop = None
# Config cache
filter_mode: str = None
filter_list: List[str] = None
max_initial_member_sync: int = -1
sync_channel_members: bool = True
sync_matrix_state: bool = True
public_portals: bool = False
alias_template: SimpleTemplate[str]
hs_domain: str
# Instance cache
by_mxid: Dict[RoomID, 'Portal'] = {}
by_tgid: Dict[Tuple[TelegramID, TelegramID], 'Portal'] = {}
mxid: Optional[RoomID]
tgid: TelegramID
tg_receiver: TelegramID
peer_type: str
username: str
megagroup: bool
title: Optional[str]
about: Optional[str]
photo_id: Optional[str]
local_config: Dict[str, Any]
deleted: bool
log: logging.Logger
alias: Optional[RoomAlias]
dedup: PortalDedup
send_lock: PortalSendLock
_db_instance: DBPortal
_main_intent: Optional[IntentAPI]
def __init__(self, tgid: TelegramID, peer_type: str, tg_receiver: Optional[TelegramID] = None,
mxid: Optional[RoomID] = None, username: Optional[str] = None,
megagroup: Optional[bool] = False, title: Optional[str] = None,
about: Optional[str] = None, photo_id: Optional[str] = None,
local_config: Optional[str] = None, db_instance: DBPortal = None) -> None:
self.mxid = mxid
self.tgid = tgid
self.tg_receiver = tg_receiver or tgid
self.peer_type = peer_type
self.username = username
self.megagroup = megagroup
self.title = title
self.about = about
self.photo_id = photo_id
self.local_config = json.loads(local_config or "{}")
self._db_instance = db_instance
self._main_intent = None
self.deleted = False
self.log = self.base_log.getChild(self.tgid_log if self.tgid else self.mxid)
self.dedup = PortalDedup(self)
self.send_lock = PortalSendLock()
if tgid:
self.by_tgid[self.tgid_full] = self
if mxid:
self.by_mxid[mxid] = self
# region Propegrties
@property
def tgid_full(self) -> Tuple[TelegramID, TelegramID]:
return self.tgid, self.tg_receiver
@property
def tgid_log(self) -> str:
if self.tgid == self.tg_receiver:
return str(self.tgid)
return f"{self.tg_receiver}<->{self.tgid}"
@property
def alias(self) -> Optional[RoomAlias]:
if not self.username:
return None
return RoomAlias(f"#{self.alias_localpart}:{self.hs_domain}")
@property
def alias_localpart(self) -> Optional[str]:
if not self.username:
return None
return self.alias_template.format(self.username)
@property
def peer(self) -> Union[TypePeer, TypeInputPeer]:
if self.peer_type == "user":
return PeerUser(user_id=self.tgid)
elif self.peer_type == "chat":
return PeerChat(chat_id=self.tgid)
elif self.peer_type == "channel":
return PeerChannel(channel_id=self.tgid)
@property
def has_bot(self) -> bool:
return (bool(self.bot)
and (self.bot.is_in_chat(self.tgid)
or (self.peer_type == "user" and self.tg_receiver == self.bot.tgid)))
@property
def main_intent(self) -> IntentAPI:
if not self._main_intent:
direct = self.peer_type == "user"
puppet = p.Puppet.get(self.tgid) if direct else None
self._main_intent = puppet.intent_for(self) if direct else self.az.intent
return self._main_intent
@property
def allow_bridging(self) -> bool:
if self.peer_type == "user":
return True
elif self.filter_mode == "whitelist":
return self.tgid in self.filter_list
elif self.filter_mode == "blacklist":
return self.tgid not in self.filter_list
return True
# endregion
# region Miscellaneous getters
def get_config(self, key: str) -> Any:
local = util.recursive_get(self.local_config, key)
if local is not None:
return local
return config[f"bridge.{key}"]
@staticmethod
def _get_largest_photo_size(photo: Union[Photo, Document]
) -> Tuple[Optional[InputPhotoFileLocation],
Optional[TypePhotoSize]]:
if not photo:
return None, None
if isinstance(photo, Document) and not photo.thumbs:
return None, None
largest = max(photo.thumbs if isinstance(photo, Document) else photo.sizes,
key=(lambda photo2: (len(photo2.bytes)
if not isinstance(photo2, PhotoSize)
else photo2.size)))
return InputPhotoFileLocation(
id=photo.id,
access_hash=photo.access_hash,
file_reference=photo.file_reference,
thumb_size=largest.type,
), largest
async def can_user_perform(self, user: 'u.User', event: str) -> bool:
if user.is_admin:
return True
if not self.mxid:
# No room for anybody to perform actions in
return False
try:
await self.main_intent.get_power_levels(self.mxid)
except MatrixRequestError:
return False
evt_type = EventType.find(f"net.maunium.telegram.{event}")
evt_type.t_class = EventType.Class.STATE
return self.main_intent.state_store.has_power_level(self.mxid, user.mxid, event=evt_type)
def get_input_entity(self, user: 'AbstractUser'
) -> Awaitable[Union[TypeInputPeer, TypeInputChannel]]:
return user.client.get_input_entity(self.peer)
async def get_entity(self, user: 'AbstractUser') -> TypeChat:
try:
return await user.client.get_entity(self.peer)
except ValueError:
if user.is_bot:
self.log.warning(f"Could not find entity with bot {user.tgid}. "
"Failing...")
raise
self.log.warning(f"Could not find entity with user {user.tgid}. "
"falling back to get_dialogs.")
async for dialog in user.client.iter_dialogs():
if dialog.entity.id == self.tgid:
return dialog.entity
raise
async def get_invite_link(self, user: 'u.User') -> str:
if self.peer_type == "user":
raise ValueError("You can't invite users to private chats.")
if self.username:
return f"https://t.me/{self.username}"
link = await user.client(ExportChatInviteRequest(peer=await self.get_input_entity(user)))
if isinstance(link, ChatInviteEmpty):
raise ValueError("Failed to get invite link.")
return link.link
# endregion
# region Matrix room cleanup
async def get_authenticated_matrix_users(self) -> List['u.User']:
try:
members = await self.main_intent.get_room_members(self.mxid)
except MatrixRequestError:
return []
authenticated: List[u.User] = []
has_bot = self.has_bot
for member_str in members:
member = UserID(member_str)
if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid:
continue
user = await u.User.get_by_mxid(member).ensure_started()
authenticated_through_bot = has_bot and user.relaybot_whitelisted
if authenticated_through_bot or await user.has_full_access(allow_bot=True):
authenticated.append(user)
return authenticated
@staticmethod
async def cleanup_room(intent: IntentAPI, room_id: RoomID, message: str,
puppets_only: bool = False) -> None:
try:
members = await intent.get_room_members(room_id)
except MatrixRequestError:
members = []
for user in members:
puppet = p.Puppet.get_by_mxid(UserID(user), create=False)
if user != intent.mxid and (not puppets_only or puppet):
try:
if puppet:
await puppet.default_mxid_intent.leave_room(room_id)
else:
await intent.kick_user(room_id, user, message)
except (MatrixRequestError, IntentError):
pass
try:
await intent.leave_room(room_id)
except (MatrixRequestError, IntentError):
self.log.warning("Failed to leave room when cleaning up room", exc_info=True)
async def cleanup_portal(self, message: str, puppets_only: bool = False) -> None:
if self.username:
try:
await self.main_intent.remove_room_alias(self.alias_localpart)
except (MatrixRequestError, IntentError):
self.log.warning("Failed to remove alias when cleaning up room", exc_info=True)
await self.cleanup_room(self.main_intent, self.mxid, message, puppets_only)
async def unbridge(self) -> None:
await self.cleanup_portal("Room unbridged", puppets_only=True)
self.delete()
async def cleanup_and_delete(self) -> None:
await self.cleanup_portal("Portal deleted")
self.delete()
# endregion
# region Database conversion
@property
def db_instance(self) -> DBPortal:
if not self._db_instance:
self._db_instance = self.new_db_instance()
return self._db_instance
def new_db_instance(self) -> DBPortal:
return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
mxid=self.mxid, username=self.username, megagroup=self.megagroup,
title=self.title, about=self.about, photo_id=self.photo_id,
config=json.dumps(self.local_config))
def save(self) -> None:
self.db_instance.edit(mxid=self.mxid, username=self.username, title=self.title,
about=self.about, photo_id=self.photo_id, megagroup=self.megagroup,
config=json.dumps(self.local_config))
def delete(self) -> None:
try:
del self.by_tgid[self.tgid_full]
except KeyError:
pass
try:
del self.by_mxid[self.mxid]
except KeyError:
pass
if self._db_instance:
self._db_instance.delete()
self.deleted = True
@classmethod
def from_db(cls, db_portal: DBPortal) -> 'Portal':
return cls(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver,
peer_type=db_portal.peer_type, mxid=db_portal.mxid,
username=db_portal.username, megagroup=db_portal.megagroup,
title=db_portal.title, about=db_portal.about, photo_id=db_portal.photo_id,
local_config=db_portal.config, db_instance=db_portal)
# endregion
# region Class instance lookup
@classmethod
def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
try:
return cls.by_mxid[mxid]
except KeyError:
pass
portal = DBPortal.get_by_mxid(mxid)
if portal:
return cls.from_db(portal)
return None
@classmethod
def get_username_from_mx_alias(cls, alias: str) -> Optional[str]:
return cls.alias_template.parse(alias)
@classmethod
def find_by_username(cls, username: str) -> Optional['Portal']:
if not username:
return None
username = username.lower()
for _, portal in cls.by_tgid.items():
if portal.username and portal.username.lower() == username:
return portal
dbportal = DBPortal.get_by_username(username)
if dbportal:
return cls.from_db(dbportal)
return None
@classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: Optional[TelegramID] = None,
peer_type: str = None) -> Optional['Portal']:
tg_receiver = tg_receiver or tgid
tgid_full = (tgid, tg_receiver)
try:
return cls.by_tgid[tgid_full]
except KeyError:
pass
db_portal = DBPortal.get_by_tgid(tgid, tg_receiver)
if db_portal:
return cls.from_db(db_portal)
if peer_type:
cls.log.info(f"Creating portal for {peer_type} {tgid} (receiver {tg_receiver})")
# TODO enable this for non-release builds
# (or add better wrong peer type error handling)
# if peer_type == "chat":
# import traceback
# cls.log.info("Chat portal stack trace:\n" + "".join(traceback.format_stack()))
portal = cls(tgid, peer_type=peer_type, tg_receiver=tg_receiver)
portal.db_instance.insert()
return portal
return None
@classmethod
def get_by_entity(cls, entity: Union[TypeChat, TypePeer, TypeUser, TypeUserFull,
TypeInputPeer],
receiver_id: Optional[TelegramID] = None, create: bool = True
) -> Optional['Portal']:
entity_type = type(entity)
if entity_type in (Chat, ChatFull):
type_name = "chat"
entity_id = entity.id
elif entity_type in (PeerChat, InputPeerChat):
type_name = "chat"
entity_id = entity.chat_id
elif entity_type in (Channel, ChannelFull):
type_name = "channel"
entity_id = entity.id
elif entity_type in (PeerChannel, InputPeerChannel, InputChannel):
type_name = "channel"
entity_id = entity.channel_id
elif entity_type in (User, UserFull):
type_name = "user"
entity_id = entity.id
elif entity_type in (PeerUser, InputPeerUser, InputUser):
type_name = "user"
entity_id = entity.user_id
else:
raise ValueError(f"Unknown entity type {entity_type.__name__}")
return cls.get_by_tgid(TelegramID(entity_id),
receiver_id if type_name == "user" else entity_id,
type_name if create else None)
# endregion
# region Abstract methods (cross-called in matrix/metadata/telegram classes)
@abstractmethod
async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
direct: bool, puppet: p.Puppet = None,
levels: PowerLevelStateEventContent = None,
users: List[User] = None,
participants: List[TypeParticipant] = None) -> None:
pass
@abstractmethod
async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None,
invites: InviteList = None, update_if_exists: bool = True,
synchronous: bool = False) -> Optional[str]:
pass
@abstractmethod
async def _add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None
) -> None:
pass
@abstractmethod
async def _delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None:
pass
@abstractmethod
async def _update_title(self, title: str, sender: Optional['p.Puppet'] = None,
save: bool = False) -> bool:
pass
@abstractmethod
async def _update_avatar(self, user: 'AbstractUser', photo: Union[TypeChatPhoto],
sender: Optional['p.Puppet'] = None, save: bool = False) -> bool:
pass
@abstractmethod
def _migrate_and_save_telegram(self, new_id: TelegramID) -> None:
pass
@abstractmethod
def handle_matrix_power_levels(self, sender: 'u.User', new_levels: Dict[UserID, int],
old_levels: Dict[UserID, int]) -> Awaitable[None]:
pass
# endregion
def init(context: Context) -> None:
global config
BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core
BasePortal.max_initial_member_sync = config["bridge.max_initial_member_sync"]
BasePortal.sync_channel_members = config["bridge.sync_channel_members"]
BasePortal.sync_matrix_state = config["bridge.sync_matrix_state"]
BasePortal.public_portals = config["bridge.public_portals"]
BasePortal.filter_mode = config["bridge.filter.mode"]
BasePortal.filter_list = config["bridge.filter.list"]
BasePortal.hs_domain = config["homeserver.domain"]
BasePortal.alias_template = SimpleTemplate(config["bridge.alias_template"], "groupname",
prefix="#", suffix=f":{BasePortal.hs_domain}")
+133
View File
@@ -0,0 +1,133 @@
# 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, Deque, Dict, Tuple, TYPE_CHECKING
from collections import deque
import hashlib
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (MessageMediaContact, MessageMediaDocument, MessageMediaGeo,
MessageMediaPhoto, TypeMessage, TypeUpdates, UpdateNewMessage,
UpdateNewChannelMessage)
from mautrix.types import EventID
from ..context import Context
from ..types import TelegramID
if TYPE_CHECKING:
from .base import BasePortal
DedupMXID = Tuple[EventID, TelegramID]
class PortalDedup:
pre_db_check: bool = False
cache_queue_length: int = 20
_dedup: Deque[str]
_dedup_mxid: Dict[str, DedupMXID]
_dedup_action: Deque[str]
_portal: 'BasePortal'
def __init__(self, portal: 'BasePortal') -> None:
self._dedup = deque()
self._dedup_mxid = {}
self._dedup_action = deque()
self._portal = portal
@property
def _always_force_hash(self) -> bool:
return self._portal.peer_type != 'channel'
@staticmethod
def _hash_event(event: TypeMessage) -> str:
# Non-channel messages are unique per-user (wtf telegram), so we have no other choice than
# to deduplicate based on a hash of the message content.
# The timestamp is only accurate to the second, so we can't rely solely on that either.
if isinstance(event, MessageService):
hash_content = [event.date.timestamp(), event.from_id, event.action]
else:
hash_content = [event.date.timestamp(), event.message]
if event.fwd_from:
hash_content += [event.fwd_from.from_id, event.fwd_from.channel_id]
elif isinstance(event, Message) and event.media:
try:
hash_content += {
MessageMediaContact: lambda media: [media.user_id],
MessageMediaDocument: lambda media: [media.document.id],
MessageMediaPhoto: lambda media: [media.photo.id],
MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
}[type(event.media)](event.media)
except KeyError:
pass
return hashlib.md5("-"
.join(str(a) for a in hash_content)
.encode("utf-8")
).hexdigest()
def check_action(self, event: TypeMessage) -> bool:
evt_hash = self._hash_event(event) if self._always_force_hash else event.id
if evt_hash in self._dedup_action:
return True
self._dedup_action.append(evt_hash)
if len(self._dedup_action) > self.cache_queue_length:
self._dedup_action.popleft()
return False
def update(self, event: TypeMessage, mxid: DedupMXID = None,
expected_mxid: Optional[DedupMXID] = None, force_hash: bool = False
) -> Optional[DedupMXID]:
evt_hash = self._hash_event(event) if self._always_force_hash or force_hash else event.id
try:
found_mxid = self._dedup_mxid[evt_hash]
except KeyError:
return EventID("None"), TelegramID(0)
if found_mxid != expected_mxid:
return found_mxid
self._dedup_mxid[evt_hash] = mxid
return None
def check(self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False
) -> Optional[DedupMXID]:
evt_hash = (self._hash_event(event)
if self._always_force_hash or force_hash
else event.id)
if evt_hash in self._dedup:
return self._dedup_mxid[evt_hash]
self._dedup_mxid[evt_hash] = mxid
self._dedup.append(evt_hash)
if len(self._dedup) > self.cache_queue_length:
del self._dedup_mxid[self._dedup.popleft()]
return None
def register_outgoing_actions(self, response: TypeUpdates) -> None:
for update in response.updates:
check_dedup = (isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage))
and isinstance(update.message, MessageService))
if check_dedup:
self.check(update.message)
def init(context: Context) -> None:
cfg = context.config
PortalDedup.dedup_pre_db_check = cfg["bridge.deduplication.pre_db_check"]
PortalDedup.dedup_cache_queue_length = cfg["bridge.deduplication.cache_queue_length"]
+537
View File
@@ -0,0 +1,537 @@
# 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 Awaitable, Dict, List, Optional, Tuple, Union, Any, TYPE_CHECKING
from html import escape as escape_html
from string import Template
from abc import ABC
import magic
from telethon.tl.functions.messages import (EditChatPhotoRequest, EditChatTitleRequest,
UpdatePinnedMessageRequest, SetTypingRequest,
EditChatAboutRequest)
from telethon.tl.functions.channels import EditPhotoRequest, EditTitleRequest, JoinChannelRequest
from telethon.errors import (ChatNotModifiedError, PhotoExtInvalidError,
PhotoInvalidDimensionsError, PhotoSaveFileInvalidError)
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (
DocumentAttributeFilename, DocumentAttributeImageSize, GeoPoint,
InputChatUploadedPhoto, MessageActionChatEditPhoto, MessageMediaGeo,
SendMessageCancelAction, SendMessageTypingAction, TypeInputPeer, TypeMessageEntity,
UpdateNewMessage, InputMediaUploadedDocument, InputMediaUploadedPhoto)
from mautrix.types import (EventID, RoomID, UserID, ContentURI, MessageType, MessageEventContent,
TextMessageEventContent, MediaMessageEventContent, Format,
LocationMessageEventContent)
from mautrix.bridge import BasePortal as MautrixBasePortal
from ..types import TelegramID
from ..db import Message as DBMessage
from ..util import sane_mimetypes, parallel_transfer_to_telegram
from ..context import Context
from .. import puppet as p, user as u, formatter, util
from .base import BasePortal
if TYPE_CHECKING:
from ..abstract_user import AbstractUser
from ..tgclient import MautrixTelegramClient
from ..config import Config
TypeMessage = Union[Message, MessageService]
config: Optional['Config'] = None
class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
async def _get_state_change_message(self, event: str, user: 'u.User', **kwargs: Any
) -> Optional[str]:
tpl = self.get_config(f"state_event_formats.{event}")
if len(tpl) == 0:
# Empty format means they don't want the message
return None
displayname = await self.get_displayname(user)
tpl_args = {
"mxid": user.mxid,
"username": user.mxid_localpart,
"displayname": escape_html(displayname),
**kwargs,
}
return Template(tpl).safe_substitute(tpl_args)
async def _send_state_change_message(self, event: str, user: 'u.User', event_id: EventID,
**kwargs: Any) -> None:
if not self.has_bot:
return
elif self.peer_type == "user" and not config["bridge.relaybot.private_chat.state_changes"]:
return
async with self.send_lock(self.bot.tgid):
message = await self._get_state_change_message(event, user, **kwargs)
if not message:
return
response = await self.bot.client.send_message(
self.peer, message,
parse_mode=self._matrix_event_to_entities)
space = self.tgid if self.peer_type == "channel" else self.bot.tgid
self.dedup.check(response, (event_id, space))
async def name_change_matrix(self, user: 'u.User', displayname: str, prev_displayname: str,
event_id: EventID) -> None:
await self._send_state_change_message("name_change", user, event_id,
displayname=displayname,
prev_displayname=prev_displayname)
async def get_displayname(self, user: 'u.User') -> str:
return await self.main_intent.get_room_displayname(self.mxid, user.mxid) or user.mxid
def set_typing(self, user: 'u.User', typing: bool = True,
action: type = SendMessageTypingAction) -> Awaitable[bool]:
return user.client(SetTypingRequest(
self.peer, action() if typing else SendMessageCancelAction()))
async def mark_read(self, user: 'u.User', event_id: EventID) -> None:
if user.is_bot:
return
space = self.tgid if self.peer_type == "channel" else user.tgid
message = DBMessage.get_by_mxid(event_id, self.mxid, space)
if not message:
return
await user.client.send_read_acknowledge(self.peer, max_id=message.tgid,
clear_mentions=True)
async def _preproc_kick_ban(self, user: Union['u.User', 'p.Puppet'], source: 'u.User'
) -> Optional['AbstractUser']:
if user.tgid == source.tgid:
return None
if self.peer_type == "user" and user.tgid == self.tgid:
self.delete()
return None
if isinstance(user, u.User) and await user.needs_relaybot(self):
if not self.bot:
return None
# TODO kick message
return None
if await source.needs_relaybot(self):
if not self.has_bot:
return None
return self.bot
return source
async def kick_matrix(self, user: Union['u.User', 'p.Puppet'], source: 'u.User') -> None:
source = await self._preproc_kick_ban(user, source)
if source is not None:
await source.client.kick_participant(self.peer, user.peer)
async def ban_matrix(self, user: Union['u.User', 'p.Puppet'], source: 'u.User'):
source = await self._preproc_kick_ban(user, source)
if source is not None:
await source.client.edit_permissions(self.peer, user.peer, view_messages=False)
async def leave_matrix(self, user: 'u.User', event_id: EventID) -> None:
if await user.needs_relaybot(self):
await self._send_state_change_message("leave", user, event_id)
return
if self.peer_type == "user":
await self.main_intent.leave_room(self.mxid)
self.delete()
try:
del self.by_tgid[self.tgid_full]
del self.by_mxid[self.mxid]
except KeyError:
pass
else:
await user.client.delete_dialog(self.peer)
async def join_matrix(self, user: 'u.User', event_id: EventID) -> None:
if await user.needs_relaybot(self):
await self._send_state_change_message("join", user, event_id)
return
if self.peer_type == "channel" and not user.is_bot:
await user.client(JoinChannelRequest(channel=await self.get_input_entity(user)))
else:
# We'll just assume the user is already in the chat.
pass
async def _apply_msg_format(self, sender: 'u.User', content: MessageEventContent
) -> None:
if not isinstance(content, TextMessageEventContent) or content.format != Format.HTML:
content.format = Format.HTML
content.formatted_body = escape_html(content.body).replace("\n", "<br/>")
tpl = (self.get_config(f"message_formats.[{content.msgtype.value}]")
or "<b>$sender_displayname</b>: $message")
displayname = await self.get_displayname(sender)
tpl_args = dict(sender_mxid=sender.mxid,
sender_username=sender.mxid_localpart,
sender_displayname=escape_html(displayname),
message=content.formatted_body,
body=content.body, formatted_body=content.formatted_body)
content.formatted_body = Template(tpl).safe_substitute(tpl_args)
async def _apply_emote_format(self, sender: 'u.User',
content: TextMessageEventContent) -> None:
if content.format != Format.HTML:
content.format = Format.HTML
content.formatted_body = escape_html(content.body).replace("\n", "<br/>")
tpl = self.get_config("emote_format")
puppet = p.Puppet.get(sender.tgid)
content.formatted_body = Template(tpl).safe_substitute(
dict(sender_mxid=sender.mxid,
sender_username=sender.mxid_localpart,
sender_displayname=escape_html(await self.get_displayname(sender)),
mention=f"<a href='https://matrix.to/#/{puppet.mxid}'>{puppet.displayname}</a>",
username=sender.username,
displayname=puppet.displayname,
body=content.body,
formatted_body=content.formatted_body))
content.msgtype = MessageType.TEXT
async def _pre_process_matrix_message(self, sender: 'u.User', use_relaybot: bool,
content: MessageEventContent) -> None:
if use_relaybot:
await self._apply_msg_format(sender, content)
elif content.msgtype == MessageType.EMOTE:
await self._apply_emote_format(sender, content)
@staticmethod
def _matrix_event_to_entities(event: Union[str, MessageEventContent]
) -> Tuple[str, Optional[List[TypeMessageEntity]]]:
try:
if isinstance(event, str):
message, entities = formatter.matrix_to_telegram(event)
elif isinstance(event, TextMessageEventContent) and event.format == Format.HTML:
message, entities = formatter.matrix_to_telegram(event.formatted_body)
else:
message, entities = formatter.matrix_text_to_telegram(event.body)
except KeyError:
message, entities = None, None
return message, entities
async def _handle_matrix_text(self, sender_id: TelegramID, event_id: EventID,
space: TelegramID, client: 'MautrixTelegramClient',
content: TextMessageEventContent, reply_to: TelegramID) -> None:
async with self.send_lock(sender_id):
lp = self.get_config("telegram_link_preview")
if content.get_edit():
orig_msg = DBMessage.get_by_mxid(content.get_edit(), self.mxid, space)
if orig_msg:
response = await client.edit_message(self.peer, orig_msg.tgid, content,
parse_mode=self._matrix_event_to_entities,
link_preview=lp)
self._add_telegram_message_to_db(event_id, space, -1, response)
return
response = await client.send_message(self.peer, content, reply_to=reply_to,
parse_mode=self._matrix_event_to_entities,
link_preview=lp)
self._add_telegram_message_to_db(event_id, space, 0, response)
async def _handle_matrix_file(self, sender_id: TelegramID, event_id: EventID,
space: TelegramID, client: 'MautrixTelegramClient',
content: MediaMessageEventContent, reply_to: TelegramID,
caption: TextMessageEventContent = None) -> None:
mime = content.info.mimetype
w, h = content.info.width, content.info.height
file_name = content["net.maunium.telegram.internal.filename"]
max_image_size = config["bridge.image_as_file_size"] * 1000 ** 2
if config["bridge.parallel_file_transfer"]:
file_handle, file_size = await parallel_transfer_to_telegram(client, self.main_intent,
content.url, sender_id)
else:
file = await self.main_intent.download_media(content.url)
if content.msgtype == MessageType.STICKER:
if mime != "image/gif":
mime, file, w, h = util.convert_image(file, source_mime=mime,
target_type="webp")
else:
# Remove sticker description
file_name = "sticker.gif"
file_handle = await client.upload_file(file)
file_size = len(file)
file_handle.name = file_name
attributes = [DocumentAttributeFilename(file_name=file_name)]
if w and h:
attributes.append(DocumentAttributeImageSize(w, h))
if (mime == "image/png" or mime == "image/jpeg") and file_size < max_image_size:
media = InputMediaUploadedPhoto(file_handle)
else:
media = InputMediaUploadedDocument(file=file_handle, attributes=attributes,
mime_type=mime or "application/octet-stream")
caption, entities = self._matrix_event_to_entities(caption) if caption else (None, None)
async with self.send_lock(sender_id):
if await self._matrix_document_edit(client, content, space, caption, media, event_id):
return
try:
response = await client.send_media(self.peer, media, reply_to=reply_to,
caption=caption, entities=entities)
except (PhotoInvalidDimensionsError, PhotoSaveFileInvalidError, PhotoExtInvalidError):
media = InputMediaUploadedDocument(file=media.file, mime_type=mime,
attributes=attributes)
response = await client.send_media(self.peer, media, reply_to=reply_to,
caption=caption, entities=entities)
self._add_telegram_message_to_db(event_id, space, 0, response)
async def _matrix_document_edit(self, client: 'MautrixTelegramClient',
content: MessageEventContent, space: TelegramID,
caption: str, media: Any, event_id: EventID) -> bool:
if content.get_edit():
orig_msg = DBMessage.get_by_mxid(content.get_edit(), self.mxid, space)
if orig_msg:
response = await client.edit_message(self.peer, orig_msg.tgid,
caption, file=media)
self._add_telegram_message_to_db(event_id, space, -1, response)
return True
return False
async def _handle_matrix_location(self, sender_id: TelegramID, event_id: EventID,
space: TelegramID, client: 'MautrixTelegramClient',
content: LocationMessageEventContent, reply_to: TelegramID
) -> None:
try:
lat, long = content.geo_uri[len("geo:"):].split(",")
lat, long = float(lat), float(long)
except (KeyError, ValueError):
self.log.exception("Failed to parse location")
return None
caption, entities = self._matrix_event_to_entities(content)
media = MessageMediaGeo(geo=GeoPoint(lat, long, access_hash=0))
async with self.send_lock(sender_id):
if await self._matrix_document_edit(client, content, space, caption, media, event_id):
return
response = await client.send_media(self.peer, media, reply_to=reply_to,
caption=caption, entities=entities)
self._add_telegram_message_to_db(event_id, space, 0, response)
def _add_telegram_message_to_db(self, event_id: EventID, space: TelegramID,
edit_index: int, response: TypeMessage) -> None:
self.log.debug("Handled Matrix message: %s", response)
self.dedup.check(response, (event_id, space), force_hash=edit_index != 0)
if edit_index < 0:
prev_edit = DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1)
edit_index = prev_edit.edit_index + 1
DBMessage(
tgid=TelegramID(response.id),
tg_space=space,
mx_room=self.mxid,
mxid=event_id,
edit_index=edit_index).insert()
async def handle_matrix_message(self, sender: 'u.User', content: MessageEventContent,
event_id: EventID) -> None:
if not content.body or not content.msgtype:
self.log.debug(f"Ignoring message {event_id} in {self.mxid} without body or msgtype")
return
puppet = p.Puppet.get_by_custom_mxid(sender.mxid)
if puppet and content.get("net.maunium.telegram.puppet", False):
self.log.debug("Ignoring puppet-sent message by confirmed puppet user %s", sender.mxid)
return
logged_in = not await sender.needs_relaybot(self)
client = sender.client if logged_in else self.bot.client
sender_id = sender.tgid if logged_in else self.bot.tgid
space = (self.tgid if self.peer_type == "channel" # Channels have their own ID space
else (sender.tgid if logged_in else self.bot.tgid))
reply_to = formatter.matrix_reply_to_telegram(content, space, room_id=self.mxid)
media = (MessageType.STICKER, MessageType.IMAGE, MessageType.FILE, MessageType.AUDIO,
MessageType.VIDEO)
if content.msgtype == MessageType.NOTICE:
bridge_notices = self.get_config("bridge_notices.default")
excepted = sender.mxid in self.get_config("bridge_notices.exceptions")
if not bridge_notices and not excepted:
return
if content.msgtype in (MessageType.TEXT, MessageType.EMOTE, MessageType.NOTICE):
await self._pre_process_matrix_message(sender, not logged_in, content)
await self._handle_matrix_text(sender_id, event_id, space, client, content, reply_to)
elif content.msgtype == MessageType.LOCATION:
await self._pre_process_matrix_message(sender, not logged_in, content)
await self._handle_matrix_location(sender_id, event_id, space, client, content,
reply_to)
elif content.msgtype in media:
content["net.maunium.telegram.internal.filename"] = content.body
try:
caption_content: MessageEventContent = sender.command_status["caption"]
reply_to = reply_to or formatter.matrix_reply_to_telegram(caption_content, space,
room_id=self.mxid)
sender.command_status = None
except (KeyError, TypeError):
caption_content = None if logged_in else TextMessageEventContent(body=content.body)
if caption_content:
caption_content.msgtype = content.msgtype
await self._pre_process_matrix_message(sender, not logged_in, caption_content)
await self._handle_matrix_file(sender_id, event_id, space, client, content, reply_to,
caption_content)
else:
self.log.debug(f"Unhandled Matrix event: {content}")
async def handle_matrix_pin(self, sender: 'u.User',
pinned_message: Optional[EventID]) -> None:
if self.peer_type != "chat" and self.peer_type != "channel":
return
try:
if not pinned_message:
await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=0))
else:
tg_space = self.tgid if self.peer_type == "channel" else sender.tgid
message = DBMessage.get_by_mxid(pinned_message, self.mxid, tg_space)
if message is None:
self.log.warning(f"Could not find pinned {pinned_message} in {self.mxid}")
return
await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=message.tgid))
except ChatNotModifiedError:
pass
async def handle_matrix_deletion(self, deleter: 'u.User', event_id: EventID) -> None:
real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot
space = self.tgid if self.peer_type == "channel" else real_deleter.tgid
message = DBMessage.get_by_mxid(event_id, self.mxid, space)
if not message:
return
if message.edit_index == 0:
await real_deleter.client.delete_messages(self.peer, [message.tgid])
else:
self.log.debug(f"Ignoring deletion of edit event {message.mxid} in {message.mx_room}")
async def _update_telegram_power_level(self, sender: 'u.User', user_id: TelegramID,
level: int) -> None:
moderator = level >= 50
admin = level >= 75
await sender.client.edit_admin(self.peer, user_id,
change_info=moderator, post_messages=moderator,
edit_messages=moderator, delete_messages=moderator,
ban_users=moderator, invite_users=moderator,
pin_messages=moderator, add_admins=admin)
async def handle_matrix_power_levels(self, sender: 'u.User', new_users: Dict[UserID, int],
old_users: Dict[UserID, int]) -> None:
# TODO handle all power level changes and bridge exact admin rights to supergroups/channels
for user, level in new_users.items():
if not user or user == self.main_intent.mxid or user == sender.mxid:
continue
user_id = p.Puppet.get_id_from_mxid(user)
if not user_id:
mx_user = u.User.get_by_mxid(user, create=False)
if not mx_user or not mx_user.tgid:
continue
user_id = mx_user.tgid
if not user_id or user_id == sender.tgid:
continue
if user not in old_users or level != old_users[user]:
await self._update_telegram_power_level(sender, user_id, level)
async def handle_matrix_about(self, sender: 'u.User', about: str) -> None:
if self.peer_type not in ("chat", "channel"):
return
peer = await self.get_input_entity(sender)
await sender.client(EditChatAboutRequest(peer=peer, about=about))
self.about = about
self.save()
async def handle_matrix_title(self, sender: 'u.User', title: str) -> None:
if self.peer_type not in ("chat", "channel"):
return
if self.peer_type == "chat":
response = await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title))
else:
channel = await self.get_input_entity(sender)
response = await sender.client(EditTitleRequest(channel=channel, title=title))
self.dedup.register_outgoing_actions(response)
self.title = title
self.save()
async def handle_matrix_avatar(self, sender: 'u.User', url: ContentURI) -> None:
if self.peer_type not in ("chat", "channel"):
# Invalid peer type
return
file = await self.main_intent.download_media(url)
mime = magic.from_buffer(file, mime=True)
ext = sane_mimetypes.guess_extension(mime)
uploaded = await sender.client.upload_file(file, file_name=f"avatar{ext}")
photo = InputChatUploadedPhoto(file=uploaded)
if self.peer_type == "chat":
response = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo))
else:
channel = await self.get_input_entity(sender)
response = await sender.client(EditPhotoRequest(channel=channel, photo=photo))
self.dedup.register_outgoing_actions(response)
for update in response.updates:
is_photo_update = (isinstance(update, UpdateNewMessage)
and isinstance(update.message, MessageService)
and isinstance(update.message.action, MessageActionChatEditPhoto))
if is_photo_update:
loc, size = self._get_largest_photo_size(update.message.action.photo)
self.photo_id = f"{size.location.volume_id}-{size.location.local_id}"
self.save()
break
async def handle_matrix_upgrade(self, sender: UserID, new_room: RoomID) -> None:
_, server = self.main_intent.parse_user_id(sender)
old_room = self.mxid
self.migrate_and_save_matrix(new_room)
await self.main_intent.join_room(new_room, servers=[server])
entity: Optional[TypeInputPeer] = None
user: Optional[AbstractUser] = None
if self.bot and self.has_bot:
user = self.bot
entity = await self.get_input_entity(self.bot)
if not entity:
user_mxids = await self.main_intent.get_room_members(self.mxid)
for user_str in user_mxids:
user_id = UserID(user_str)
if user_id == self.az.bot_mxid:
continue
user = u.User.get_by_mxid(user_id, create=False)
if user and user.tgid:
entity = await self.get_input_entity(user)
if entity:
break
if not entity:
self.log.error("Failed to fully migrate to upgraded Matrix room: "
"no Telegram user found.")
return
await self.update_matrix_room(user, entity, direct=self.peer_type == "user")
self.log.info(f"{sender} upgraded room from {old_room} to {self.mxid}")
def migrate_and_save_matrix(self, new_id: RoomID) -> None:
try:
del self.by_mxid[self.mxid]
except KeyError:
pass
self.mxid = new_id
self.db_instance.edit(mxid=self.mxid)
self.by_mxid[self.mxid] = self
def init(context: Context) -> None:
global config
config = context.config
+708
View File
@@ -0,0 +1,708 @@
# 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 List, Optional, Tuple, Union, Callable, TYPE_CHECKING
from abc import ABC
import asyncio
from telethon.tl.functions.messages import (AddChatUserRequest, CreateChatRequest,
GetFullChatRequest, MigrateChatRequest)
from telethon.tl.functions.channels import (CreateChannelRequest, GetParticipantsRequest,
InviteToChannelRequest, UpdateUsernameRequest)
from telethon.errors import ChatAdminRequiredError
from telethon.tl.types import (
Channel, ChatBannedRights, ChannelParticipantsRecent, ChannelParticipantsSearch, ChatPhoto,
PhotoEmpty, InputChannel, InputUser, ChatPhotoEmpty, PeerUser, Photo, TypeChat, TypeInputPeer,
TypeUser, User, InputPeerPhotoFileLocation, ChatParticipantAdmin, ChannelParticipantAdmin,
ChatParticipantCreator, ChannelParticipantCreator)
from mautrix.errors import MForbidden
from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership, Member,
PowerLevelStateEventContent, RoomAlias)
from mautrix.appservice import IntentAPI
from ..types import TelegramID
from ..context import Context
from .. import puppet as p, user as u, util
from .base import BasePortal, InviteList, TypeParticipant, TypeChatPhoto
if TYPE_CHECKING:
from ..abstract_user import AbstractUser
from ..config import Config
config: Optional['Config'] = None
class PortalMetadata(BasePortal, ABC):
_room_create_lock: asyncio.Lock
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self._room_create_lock = asyncio.Lock()
# region Matrix -> Telegram
async def _get_telegram_users_in_matrix_room(self) -> List[Union[InputUser, PeerUser]]:
user_tgids = set()
user_mxids = await self.main_intent.get_room_members(self.mxid, (Membership.JOIN,
Membership.INVITE))
for user_str in user_mxids:
user = UserID(user_str)
if user == self.az.bot_mxid:
continue
mx_user = u.User.get_by_mxid(user, create=False)
if mx_user and mx_user.tgid:
user_tgids.add(mx_user.tgid)
puppet_id = p.Puppet.get_id_from_mxid(user)
if puppet_id:
user_tgids.add(puppet_id)
return [PeerUser(user_id) for user_id in user_tgids]
async def upgrade_telegram_chat(self, source: 'u.User') -> None:
if self.peer_type != "chat":
raise ValueError("Only normal group chats are upgradable to supergroups.")
response = await source.client(MigrateChatRequest(chat_id=self.tgid))
entity = None
for chat in response.chats:
if isinstance(chat, Channel):
entity = chat
break
if not entity:
raise ValueError("Upgrade may have failed: output channel not found.")
self.peer_type = "channel"
self._migrate_and_save_telegram(TelegramID(entity.id))
await self.update_info(source, entity)
def _migrate_and_save_telegram(self, new_id: TelegramID) -> None:
try:
del self.by_tgid[self.tgid_full]
except KeyError:
pass
try:
existing = self.by_tgid[(new_id, new_id)]
existing.delete()
except KeyError:
pass
self.db_instance.edit(tgid=new_id, tg_receiver=new_id, peer_type=self.peer_type)
old_id = self.tgid
self.tgid = new_id
self.tg_receiver = new_id
self.by_tgid[self.tgid_full] = self
self.log = self.base_log.getChild(self.tgid_log)
self.log.info(f"Telegram chat upgraded from {old_id}")
async def set_telegram_username(self, source: 'u.User', username: str) -> None:
if self.peer_type != "channel":
raise ValueError("Only channels and supergroups have usernames.")
await source.client(
UpdateUsernameRequest(await self.get_input_entity(source), username))
if await self._update_username(username):
self.save()
async def create_telegram_chat(self, source: 'u.User', supergroup: bool = False) -> None:
if not self.mxid:
raise ValueError("Can't create Telegram chat for portal without Matrix room.")
elif self.tgid:
raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.")
invites = await self._get_telegram_users_in_matrix_room()
if len(invites) < 2:
if self.bot is not None:
info, mxid = await self.bot.get_me()
raise ValueError("Not enough Telegram users to create a chat. "
"Invite more Telegram ghost users to the room, such as the "
f"relaybot ([{info.first_name}](https://matrix.to/#/{mxid})).")
raise ValueError("Not enough Telegram users to create a chat. "
"Invite more Telegram ghost users to the room.")
if self.peer_type == "chat":
response = await source.client(CreateChatRequest(title=self.title, users=invites))
entity = response.chats[0]
elif self.peer_type == "channel":
response = await source.client(CreateChannelRequest(title=self.title,
about=self.about or "",
megagroup=supergroup))
entity = response.chats[0]
await source.client(InviteToChannelRequest(
channel=await source.client.get_input_entity(entity),
users=invites))
else:
raise ValueError("Invalid peer type for Telegram chat creation")
self.tgid = entity.id
self.tg_receiver = self.tgid
self.by_tgid[self.tgid_full] = self
await self.update_info(source, entity)
self.db_instance.insert()
self.log = self.base_log.getChild(self.tgid_log)
if self.bot and self.bot.tgid in invites:
self.bot.add_chat(self.tgid, self.peer_type)
levels = await self.main_intent.get_power_levels(self.mxid)
if levels.get_user_level(self.main_intent.mxid) == 100:
levels = self._get_base_power_levels(levels, entity)
await self.main_intent.set_power_levels(self.mxid, levels)
await self.handle_matrix_power_levels(source, levels.users, {})
async def invite_telegram(self, source: 'u.User',
puppet: Union[p.Puppet, 'AbstractUser']) -> None:
if self.peer_type == "chat":
await source.client(
AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0))
elif self.peer_type == "channel":
await source.client(InviteToChannelRequest(channel=self.peer, users=[puppet.tgid]))
# We don't care if there are invites for private chat portals with the relaybot.
elif not self.bot or self.tg_receiver != self.bot.tgid:
raise ValueError("Invalid peer type for Telegram user invite")
async def sync_matrix_members(self) -> None:
resp = await self.main_intent.get_room_joined_memberships(self.mxid)
members = resp["joined"]
for mxid, info in members.items():
member = Member(membership=Membership.JOIN)
if "display_name" in info:
member.displayname = info["display_name"]
if "avatar_url" in info:
member.avatar_url = info["avatar_url"]
self.az.state_store.set_member(self.mxid, mxid, member)
# endregion
# region Telegram -> Matrix
async def invite_to_matrix(self, users: InviteList) -> None:
if isinstance(users, list):
for user in users:
await self.main_intent.invite_user(self.mxid, user, check_cache=True)
else:
await self.main_intent.invite_user(self.mxid, users, check_cache=True)
async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
direct: bool = None, puppet: p.Puppet = None,
levels: PowerLevelStateEventContent = None,
users: List[User] = None,
participants: List[TypeParticipant] = None) -> None:
if direct is None:
direct = self.peer_type == "user"
try:
await self._update_matrix_room(user, entity, direct, puppet, levels, users,
participants)
except Exception:
self.log.exception("Fatal error updating Matrix room")
async def _update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
direct: bool, puppet: p.Puppet = None,
levels: PowerLevelStateEventContent = None,
users: List[User] = None,
participants: List[TypeParticipant] = None) -> None:
if not direct:
await self.update_info(user, entity)
if not users or not participants:
users, participants = await self._get_users(user, entity)
await self._sync_telegram_users(user, users)
await self.update_telegram_participants(participants, levels)
else:
if not puppet:
puppet = p.Puppet.get(self.tgid)
await puppet.update_info(user, entity)
await puppet.intent_for(self).join_room(self.mxid)
if self.sync_matrix_state:
await self.sync_matrix_members()
async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None,
invites: InviteList = None, update_if_exists: bool = True,
synchronous: bool = False) -> Optional[str]:
if self.mxid:
if update_if_exists:
if not entity:
try:
entity = await self.get_entity(user)
except Exception:
self.log.exception(f"Failed to get entity through {user.tgid} for update")
return self.mxid
update = self.update_matrix_room(user, entity, self.peer_type == "user")
if synchronous:
await update
else:
asyncio.ensure_future(update, loop=self.loop)
await self.invite_to_matrix(invites or [])
return self.mxid
async with self._room_create_lock:
try:
return await self._create_matrix_room(user, entity, invites)
except Exception:
self.log.exception("Fatal error creating Matrix room")
async def _create_matrix_room(self, user: 'AbstractUser', entity: TypeChat, invites: InviteList
) -> Optional[RoomID]:
direct = self.peer_type == "user"
if self.mxid:
return self.mxid
if not self.allow_bridging:
return None
if not entity:
entity = await self.get_entity(user)
self.log.debug(f"Fetched data: {entity}")
self.log.debug("Creating room")
try:
self.title = entity.title
except AttributeError:
self.title = None
if direct and self.tgid == user.tgid:
self.title = "Telegram Saved Messages"
self.about = "Your Telegram cloud storage chat"
puppet = p.Puppet.get(self.tgid) if direct else None
self._main_intent = puppet.intent_for(self) if direct else self.az.intent
if self.peer_type == "channel":
self.megagroup = entity.megagroup
if self.peer_type == "channel" and entity.username:
preset = RoomCreatePreset.PUBLIC
self.username = entity.username
alias = self.alias_localpart
else:
preset = RoomCreatePreset.PRIVATE
# TODO invite link alias?
alias = None
if alias:
# TODO? properly handle existing room aliases
await self.main_intent.remove_room_alias(alias)
power_levels = self._get_base_power_levels(entity=entity)
users = participants = None
if not direct:
users, participants = await self._get_users(user, entity)
if self.has_bot:
extra_invites = config["bridge.relaybot.group_chat_invite"]
invites += extra_invites
for invite in extra_invites:
power_levels.users.setdefault(invite, 100)
self._participants_to_power_levels(participants, power_levels)
elif self.bot and self.tg_receiver == self.bot.tgid:
invites = config["bridge.relaybot.private_chat.invite"]
for invite in invites:
power_levels.users.setdefault(invite, 100)
self.title = puppet.displayname
initial_state = [{
"type": EventType.ROOM_POWER_LEVELS.serialize(),
"content": power_levels.serialize(),
}]
if config["appservice.community_id"]:
initial_state.append({
"type": "m.room.related_groups",
"content": {"groups": [config["appservice.community_id"]]},
})
creation_content = {}
if not config["bridge.federate_rooms"]:
creation_content["m.federate"] = False
room_id = await self.main_intent.create_room(alias_localpart=alias, preset=preset,
is_direct=direct, invitees=invites or [],
name=self.title, topic=self.about,
initial_state=initial_state,
creation_content=creation_content)
if not room_id:
raise Exception(f"Failed to create room")
self.mxid = RoomID(room_id)
self.by_mxid[self.mxid] = self
self.save()
self.az.state_store.set_power_levels(self.mxid, power_levels)
user.register_portal(self)
asyncio.ensure_future(self.update_matrix_room(user, entity, direct, puppet,
levels=power_levels, users=users,
participants=participants), loop=self.loop)
return self.mxid
def _get_base_power_levels(self, levels: PowerLevelStateEventContent = None,
entity: TypeChat = None) -> PowerLevelStateEventContent:
levels = levels or PowerLevelStateEventContent()
if self.peer_type == "user":
overrides = config["bridge.initial_power_level_overrides.user"]
levels.ban = overrides.get("ban", 100)
levels.kick = overrides.get("kick", 100)
levels.invite = overrides.get("invite", 100)
levels.redact = overrides.get("redact", 0)
levels.events[EventType.ROOM_NAME] = 0
levels.events[EventType.ROOM_AVATAR] = 0
levels.events[EventType.ROOM_TOPIC] = 0
levels.state_default = overrides.get("state_default", 0)
levels.users_default = overrides.get("users_default", 0)
levels.events_default = overrides.get("events_default", 0)
else:
overrides = config["bridge.initial_power_level_overrides.group"]
dbr = entity.default_banned_rights
if not dbr:
self.log.debug(f"default_banned_rights is None in {entity}")
dbr = ChatBannedRights(invite_users=True, change_info=True, pin_messages=True,
send_stickers=False, send_messages=False, until_date=None)
levels.ban = overrides.get("ban", 50)
levels.kick = overrides.get("kick", 50)
levels.redact = overrides.get("redact", 50)
levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0)
levels.events[EventType.ROOM_ENCRYPTED] = 99
levels.events[EventType.ROOM_TOMBSTONE] = 99
levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0
levels.events[EventType.ROOM_AVATAR] = 50 if dbr.change_info else 0
levels.events[EventType.ROOM_TOPIC] = 50 if dbr.change_info else 0
levels.events[EventType.ROOM_PINNED_EVENTS] = 50 if dbr.pin_messages else 0
levels.events[EventType.ROOM_POWER_LEVELS] = 75
levels.events[EventType.ROOM_HISTORY_VISIBILITY] = 75
levels.events[EventType.STICKER] = 50 if dbr.send_stickers else levels.events_default
levels.state_default = overrides.get("state_default", 50)
levels.users_default = overrides.get("users_default", 0)
levels.events_default = (
overrides.get("events_default",
50 if (self.peer_type == "channel" and not entity.megagroup
or entity.default_banned_rights.send_messages)
else 0))
for evt_type, value in overrides.get("events", {}).items():
levels.events[EventType.find(evt_type)] = value
levels.users = overrides.get("users", {})
if self.main_intent.mxid not in levels.users:
levels.users[self.main_intent.mxid] = 100
return levels
@staticmethod
def _get_level_from_participant(participant: TypeParticipant) -> int:
# TODO use the power level requirements to get better precision in channels
if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
return 50
elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)):
return 95
return 0
@staticmethod
def _participant_to_power_levels(levels: PowerLevelStateEventContent,
user: Union['u.User', p.Puppet], new_level: int,
bot_level: int) -> bool:
new_level = min(new_level, bot_level)
user_level = levels.get_user_level(user.mxid)
if user_level != new_level and user_level < bot_level:
levels.users[user.mxid] = new_level
return True
return False
def _participants_to_power_levels(self, participants: List[TypeParticipant],
levels: PowerLevelStateEventContent) -> bool:
bot_level = levels.get_user_level(self.main_intent.mxid)
if bot_level < levels.get_event_level(EventType.ROOM_POWER_LEVELS):
return False
changed = False
admin_power_level = min(75 if self.peer_type == "channel" else 50, bot_level)
if levels.events[EventType.ROOM_POWER_LEVELS] != admin_power_level:
changed = True
levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
for participant in participants:
puppet = p.Puppet.get(TelegramID(participant.user_id))
user = u.User.get_by_tgid(TelegramID(participant.user_id))
new_level = self._get_level_from_participant(participant)
if user:
user.register_portal(self)
changed = self._participant_to_power_levels(levels, user, new_level,
bot_level) or changed
if puppet:
changed = self._participant_to_power_levels(levels, puppet, new_level,
bot_level) or changed
return changed
async def update_telegram_participants(self, participants: List[TypeParticipant],
levels: PowerLevelStateEventContent = None) -> None:
if not levels:
levels = await self.main_intent.get_power_levels(self.mxid)
if self._participants_to_power_levels(participants, levels):
await self.main_intent.set_power_levels(self.mxid, levels)
def _add_bot_chat(self, bot: User) -> None:
if self.bot and bot.id == self.bot.tgid:
self.bot.add_chat(self.tgid, self.peer_type)
return
user = u.User.get_by_tgid(TelegramID(bot.id))
if user and user.is_bot:
user.register_portal(self)
async def _sync_telegram_users(self, source: 'AbstractUser', users: List[User]) -> None:
allowed_tgids = set()
skip_deleted = config["bridge.skip_deleted_members"]
for entity in users:
if skip_deleted and entity.deleted:
continue
puppet = p.Puppet.get(TelegramID(entity.id))
if entity.bot:
self._add_bot_chat(entity)
allowed_tgids.add(entity.id)
await puppet.intent_for(self).ensure_joined(self.mxid)
await puppet.update_info(source, entity)
user = u.User.get_by_tgid(TelegramID(entity.id))
if user:
await self.invite_to_matrix(user.mxid)
# We can't trust the member list if any of the following cases is true:
# * There are close to 10 000 users, because Telegram might not be sending all members.
# * The member sync count is limited, because then we might ignore some members.
# * It's a channel, because non-admins don't have access to the member list.
trust_member_list = (len(allowed_tgids) < 9900
and self.max_initial_member_sync == -1
and (self.megagroup or self.peer_type != "channel"))
if trust_member_list:
joined_mxids = await self.main_intent.get_room_members(self.mxid)
for user_mxid in joined_mxids:
if user_mxid == self.az.bot_mxid:
continue
puppet_id = p.Puppet.get_id_from_mxid(user_mxid)
if puppet_id and puppet_id not in allowed_tgids:
if self.bot and puppet_id == self.bot.tgid:
self.bot.remove_chat(self.tgid)
try:
await self.main_intent.kick_user(self.mxid, user_mxid,
"User had left this Telegram chat.")
except MForbidden:
pass
continue
mx_user = u.User.get_by_mxid(user_mxid, create=False)
if mx_user and mx_user.is_bot and mx_user.tgid not in allowed_tgids:
mx_user.unregister_portal(self)
if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids:
try:
await self.main_intent.kick_user(self.mxid, mx_user.mxid,
"You had left this Telegram chat.")
except MForbidden:
pass
continue
async def _add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None
) -> None:
puppet = p.Puppet.get(user_id)
if source:
entity: User = await source.client.get_entity(PeerUser(user_id))
await puppet.update_info(source, entity)
await puppet.intent_for(self).ensure_joined(self.mxid)
user = u.User.get_by_tgid(user_id)
if user:
user.register_portal(self)
await self.invite_to_matrix(user.mxid)
async def _delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None:
puppet = p.Puppet.get(user_id)
user = u.User.get_by_tgid(user_id)
kick_message = (f"Kicked by {sender.displayname}"
if sender and sender.tgid != puppet.tgid
else "Left Telegram chat")
if sender.tgid != puppet.tgid:
try:
await sender.intent_for(self).kick_user(self.mxid, puppet.mxid)
except MForbidden:
await self.main_intent.kick_user(self.mxid, puppet.mxid, kick_message)
else:
await puppet.intent_for(self).leave_room(self.mxid)
if user:
user.unregister_portal(self)
if sender.tgid != puppet.tgid:
try:
await sender.intent_for(self).kick_user(self.mxid, puppet.mxid)
return
except MForbidden:
pass
try:
await self.main_intent.kick_user(self.mxid, user.mxid, kick_message)
except MForbidden as e:
self.log.warning(f"Failed to kick {user.mxid}: {e}")
async def update_info(self, user: 'AbstractUser', entity: TypeChat = None) -> None:
if self.peer_type == "user":
self.log.warning("Called update_info() for direct chat portal")
return
self.log.debug("Updating info")
try:
if not entity:
entity = await self.get_entity(user)
self.log.debug(f"Fetched data: {entity}")
changed = False
if self.peer_type == "channel":
changed = self.megagroup != entity.megagroup or changed
self.megagroup = entity.megagroup
changed = await self._update_username(entity.username) or changed
if hasattr(entity, "about"):
changed = self._update_about(entity.about) or changed
changed = await self._update_title(entity.title) or changed
if isinstance(entity.photo, ChatPhoto):
changed = await self._update_avatar(user, entity.photo) or changed
except Exception:
self.log.exception(f"Failed to update info from source {user.tgid}")
if changed:
self.save()
async def _update_username(self, username: str, save: bool = False) -> bool:
if self.username == username:
return False
if self.username:
await self.main_intent.remove_room_alias(self.alias_localpart)
self.username = username or None
if self.username:
await self.main_intent.add_room_alias(self.mxid, self.alias_localpart, override=True)
if self.public_portals:
await self.main_intent.set_join_rule(self.mxid, "public")
else:
await self.main_intent.set_join_rule(self.mxid, "invite")
if save:
self.save()
return True
async def _try_use_intent(self, sender: Optional['p.Puppet'],
action: Callable[[IntentAPI], None]) -> None:
if sender:
try:
await action(sender.intent_for(self))
except MForbidden:
await action(self.main_intent)
else:
await action(self.main_intent)
async def _update_about(self, about: str, sender: Optional['p.Puppet'] = None,
save: bool = False) -> bool:
if self.about == about:
return False
self.about = about
await self._try_use_intent(sender,
lambda intent: intent.set_room_topic(self.mxid, self.about))
if save:
self.save()
return True
async def _update_title(self, title: str, sender: Optional['p.Puppet'] = None,
save: bool = False) -> bool:
if self.title == title:
return False
self.title = title
await self._try_use_intent(sender,
lambda intent: intent.set_room_name(self.mxid, self.title))
if save:
self.save()
return True
async def _update_avatar(self, user: 'AbstractUser', photo: TypeChatPhoto,
sender: Optional['p.Puppet'] = None, save: bool = False) -> bool:
if isinstance(photo, ChatPhoto):
loc = InputPeerPhotoFileLocation(
peer=await self.get_input_entity(user),
local_id=photo.photo_big.local_id,
volume_id=photo.photo_big.volume_id,
big=True
)
photo_id = f"{loc.volume_id}-{loc.local_id}"
elif isinstance(photo, Photo):
loc, largest = self._get_largest_photo_size(photo)
photo_id = f"{largest.location.volume_id}-{largest.location.local_id}"
elif isinstance(photo, (ChatPhotoEmpty, PhotoEmpty)):
photo_id = ""
loc = None
else:
raise ValueError(f"Unknown photo type {type(photo)}")
if self.photo_id != photo_id:
if not photo_id:
await self._try_use_intent(sender,
lambda intent: intent.set_room_avatar(self.mxid, None))
self.photo_id = ""
if save:
self.save()
return True
file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc)
if file:
await self._try_use_intent(sender, lambda intent: intent.set_room_avatar(self.mxid,
file.mxc))
self.photo_id = photo_id
if save:
self.save()
return True
return False
async def _get_users(self, user: 'AbstractUser',
entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser, InputChannel]
) -> Tuple[List[TypeUser], List[TypeParticipant]]:
# TODO replace with client.get_participants
if self.peer_type == "chat":
chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
return chat.users, chat.full_chat.participants.participants
elif self.peer_type == "channel":
if not self.megagroup and not self.sync_channel_members:
return [], []
limit = self.max_initial_member_sync
if limit == 0:
return [], []
try:
if 0 < limit <= 200:
response = await user.client(GetParticipantsRequest(
entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0))
return response.users, response.participants
elif limit > 200 or limit == -1:
users: List[TypeUser] = []
participants: List[TypeParticipant] = []
offset = 0
remaining_quota = limit if limit > 0 else 1000000
query = (ChannelParticipantsSearch("") if limit == -1
else ChannelParticipantsRecent())
while True:
if remaining_quota <= 0:
break
response = await user.client(GetParticipantsRequest(
entity, query, offset=offset, limit=min(remaining_quota, 100), hash=0))
if not response.users:
break
participants += response.participants
users += response.users
offset += len(response.participants)
remaining_quota -= len(response.participants)
return users, participants
except ChatAdminRequiredError:
return [], []
elif self.peer_type == "user":
return [entity], []
return [], []
# endregion
def init(context: Context) -> None:
global config
config = context.config
+44
View File
@@ -0,0 +1,44 @@
# 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
from asyncio import Lock
from ..types import TelegramID
class FakeLock:
async def __aenter__(self) -> None:
pass
async def __aexit__(self, exc_type, exc, tb) -> None:
pass
class PortalSendLock:
_send_locks: Dict[int, Lock]
_noop_lock: Lock = FakeLock()
def __init__(self) -> None:
self._send_locks = {}
def __call__(self, user_id: TelegramID, required: bool = True) -> Lock:
if user_id is None and required:
raise ValueError("Required send lock for none id")
try:
return self._send_locks[user_id]
except KeyError:
return (self._send_locks.setdefault(user_id, Lock())
if required else self._noop_lock)
+523
View File
@@ -0,0 +1,523 @@
# 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 Awaitable, Dict, List, Optional, Tuple, Union, NamedTuple, TYPE_CHECKING
from html import escape as escape_html
from abc import ABC
import random
import mimetypes
import codecs
import unicodedata
import base64
from sqlalchemy.exc import IntegrityError
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (
Poll, DocumentAttributeFilename, DocumentAttributeSticker, DocumentAttributeVideo,
MessageMediaPoll, MessageActionChannelCreate, MessageActionChatAddUser,
MessageActionChatCreate, MessageActionChatDeletePhoto, MessageActionChatDeleteUser,
MessageActionChatEditPhoto, MessageActionChatEditTitle, MessageActionChatJoinedByLink,
MessageActionChatMigrateTo, MessageActionPinMessage, MessageActionGameScore,
MessageMediaDocument, MessageMediaGeo, MessageMediaPhoto, MessageMediaUnsupported,
MessageMediaGame, PeerUser, PhotoCachedSize, TypeChannelParticipant, TypeChatParticipant,
TypeDocumentAttribute, TypeMessageAction, TypePhotoSize, PhotoSize, UpdateChatUserTyping,
UpdateUserTyping, MessageEntityPre, ChatPhotoEmpty)
from mautrix.appservice import IntentAPI
from mautrix.types import (EventID, UserID, ImageInfo, ThumbnailInfo, RelatesTo, MessageType,
EventType, MediaMessageEventContent, TextMessageEventContent,
LocationMessageEventContent, Format)
from ..types import TelegramID
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
from ..util import sane_mimetypes
from ..context import Context
from .. import puppet as p, user as u, formatter, util
from .base import BasePortal
if TYPE_CHECKING:
from ..abstract_user import AbstractUser
from ..config import Config
InviteList = Union[UserID, List[UserID]]
TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
DocAttrs = NamedTuple("DocAttrs", name=Optional[str], mime_type=Optional[str], is_sticker=bool,
sticker_alt=Optional[str], width=int, height=int)
config: Optional['Config'] = None
class PortalTelegram(BasePortal, ABC):
async def handle_telegram_typing(self, user: p.Puppet,
_: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None:
await user.intent_for(self).set_typing(self.mxid, is_typing=True)
def _get_external_url(self, evt: Message) -> Optional[str]:
if self.peer_type == "channel" and self.username is not None:
return f"https://t.me/{self.username}/{evt.id}"
elif self.peer_type != "user":
return f"https://t.me/c/{self.tgid}/{evt.id}"
return None
async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: Dict = None) -> Optional[EventID]:
loc, largest_size = self._get_largest_photo_size(evt.media.photo)
file = await util.transfer_file_to_matrix(source.client, intent, loc)
if not file:
return None
if self.get_config("inline_images") and (evt.message
or evt.fwd_from or evt.reply_to_msg_id):
content = await formatter.telegram_to_matrix(
evt, source, self.main_intent,
prefix_html=f"<img src='{file.mxc}' alt='Inline Telegram photo'/><br/>",
prefix_text="Inline image: ")
content.external_url = self._get_external_url(evt)
await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_message(self.mxid, content, timestamp=evt.date)
info = ImageInfo(
height=largest_size.h, width=largest_size.w, orientation=0, mimetype=file.mime_type,
size=(len(largest_size.bytes) if (isinstance(largest_size, PhotoCachedSize))
else largest_size.size))
name = f"image{sane_mimetypes.guess_extension(file.mime_type)}"
await intent.set_typing(self.mxid, is_typing=False)
content = MediaMessageEventContent(url=file.mxc, msgtype=MessageType.IMAGE, info=info,
body=name, relates_to=relates_to,
external_url=self._get_external_url(evt))
result = await intent.send_message(self.mxid, content, timestamp=evt.date)
if evt.message:
caption_content = await formatter.telegram_to_matrix(evt, source, self.main_intent,
no_reply_fallback=True)
caption_content.external_url = content.external_url
result = await intent.send_message(self.mxid, caption_content, timestamp=evt.date)
return result
@staticmethod
def _parse_telegram_document_attributes(attributes: List[TypeDocumentAttribute]) -> DocAttrs:
name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0
for attr in attributes:
if isinstance(attr, DocumentAttributeFilename):
name = name or attr.file_name
mime_type, _ = mimetypes.guess_type(attr.file_name)
elif isinstance(attr, DocumentAttributeSticker):
is_sticker = True
sticker_alt = attr.alt
elif isinstance(attr, DocumentAttributeVideo):
width, height = attr.w, attr.h
return DocAttrs(name, mime_type, is_sticker, sticker_alt, width, height)
@staticmethod
def _parse_telegram_document_meta(evt: Message, file: DBTelegramFile, attrs: DocAttrs,
thumb_size: TypePhotoSize) -> Tuple[ImageInfo, str]:
document = evt.media.document
name = evt.message or attrs.name
if attrs.is_sticker:
alt = attrs.sticker_alt
if len(alt) > 0:
try:
name = f"{alt} ({unicodedata.name(alt[0]).lower()})"
except ValueError:
name = alt
generic_types = ("text/plain", "application/octet-stream")
if file.mime_type in generic_types and document.mime_type not in generic_types:
mime_type = document.mime_type or file.mime_type
else:
mime_type = file.mime_type or document.mime_type
info = ImageInfo(size=file.size, mimetype=mime_type)
if attrs.mime_type and not file.was_converted:
file.mime_type = attrs.mime_type or file.mime_type
if file.width and file.height:
info.width, info.height = file.width, file.height
elif attrs.width and attrs.height:
info.width, info.height = attrs.width, attrs.height
if file.thumbnail:
info.thumbnail_url = file.thumbnail.mxc
info.thumbnail_info = ThumbnailInfo(mimetype=file.thumbnail.mime_type,
height=file.thumbnail.height or thumb_size.h,
width=file.thumbnail.width or thumb_size.w,
size=file.thumbnail.size)
return info, name
async def handle_telegram_document(self, source: 'AbstractUser', intent: IntentAPI,
evt: Message, relates_to: RelatesTo = None
) -> Optional[EventID]:
document = evt.media.document
attrs = self._parse_telegram_document_attributes(document.attributes)
if document.size > config["bridge.max_document_size"] * 1000 ** 2:
name = attrs.name or ""
caption = f"\n{evt.message}" if evt.message else ""
return await intent.send_notice(self.mxid, f"Too large file {name}{caption}")
thumb_loc, thumb_size = self._get_largest_photo_size(document)
if thumb_size and not isinstance(thumb_size, (PhotoSize, PhotoCachedSize)):
self.log.debug(f"Unsupported thumbnail type {type(thumb_size)}")
thumb_loc = None
thumb_size = None
parallel_id = source.tgid if config["bridge.parallel_file_transfer"] else None
file = await util.transfer_file_to_matrix(source.client, intent, document, thumb_loc,
is_sticker=attrs.is_sticker,
tgs_convert=config["bridge.animated_sticker"],
filename=attrs.name, parallel_id=parallel_id)
if not file:
return None
info, name = self._parse_telegram_document_meta(evt, file, attrs, thumb_size)
await intent.set_typing(self.mxid, is_typing=False)
event_type = EventType.ROOM_MESSAGE
# Riot only supports images as stickers, so send animated webm stickers as m.video
if attrs.is_sticker and file.mime_type.startswith("image/"):
event_type = EventType.STICKER
content = MediaMessageEventContent(
body=name or "unnamed file", info=info, url=file.mxc, relates_to=relates_to,
external_url=self._get_external_url(evt),
msgtype={
"video/": MessageType.VIDEO,
"audio/": MessageType.AUDIO,
"image/": MessageType.IMAGE,
}.get(info.mimetype[:6], MessageType.FILE))
return await intent.send_message_event(self.mxid, event_type, content, timestamp=evt.date)
def handle_telegram_location(self, _: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: dict = None) -> Awaitable[EventID]:
long = evt.media.geo.long
lat = evt.media.geo.lat
long_char = "E" if long > 0 else "W"
lat_char = "N" if lat > 0 else "S"
body = f"{round(lat, 5)}° {lat_char}, {round(long, 5)}° {long_char}"
url = f"https://maps.google.com/?q={lat},{long}"
content = LocationMessageEventContent(
msgtype=MessageType.LOCATION, geo_uri=f"geo:{lat},{long}",
body=f"Location: {body}\n{url}",
relates_to=relates_to, external_url=self._get_external_url(evt))
content["format"] = str(Format.HTML)
content["formatted_body"] = f"Location: <a href='{url}'>{body}</a>"
return intent.send_message(self.mxid, content, timestamp=evt.date)
async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool,
evt: Message) -> EventID:
self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}")
content = await formatter.telegram_to_matrix(evt, source, self.main_intent)
content.external_url = self._get_external_url(evt)
if is_bot and self.get_config("bot_messages_as_notices"):
content.msgtype = MessageType.NOTICE
await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_message(self.mxid, content, timestamp=evt.date)
async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI,
evt: Message, relates_to: dict = None) -> EventID:
override_text = ("This message is not supported on your version of Mautrix-Telegram. "
"Please check https://github.com/tulir/mautrix-telegram or ask your "
"bridge administrator about possible updates.")
content = await formatter.telegram_to_matrix(
evt, source, self.main_intent, override_text=override_text)
content.msgtype = MessageType.NOTICE
content.external_url = self._get_external_url(evt)
content["net.maunium.telegram.unsupported"] = True
await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_message(self.mxid, content, timestamp=evt.date)
async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: RelatesTo) -> EventID:
poll: Poll = evt.media.poll
poll_id = self._encode_msgid(source, evt)
_n = 0
def n() -> int:
nonlocal _n
_n += 1
return _n
text_answers = "\n".join(f"{n()}. {answer.text}" for answer in poll.answers)
html_answers = "\n".join(f"<li>{answer.text}</li>" for answer in poll.answers)
content = TextMessageEventContent(
msgtype=MessageType.TEXT, format=Format.HTML,
body=f"Poll: {poll.question}\n{text_answers}\n"
f"Vote with !tg vote {poll_id} <choice number>",
formatted_body=f"<strong>Poll</strong>: {poll.question}<br/>\n"
f"<ol>{html_answers}</ol>\n"
f"Vote with <code>!tg vote {poll_id} &lt;choice number&gt;</code>",
relates_to=relates_to, external_url=self._get_external_url(evt))
await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_message(self.mxid, content, timestamp=evt.date)
@staticmethod
def _int_to_bytes(i: int) -> bytes:
hex_value = "{0:010x}".format(i)
return codecs.decode(hex_value, "hex_codec")
def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str:
if self.peer_type == "channel":
play_id = (b"c"
+ self._int_to_bytes(self.tgid)
+ self._int_to_bytes(evt.id))
elif self.peer_type == "chat":
play_id = (b"g"
+ self._int_to_bytes(self.tgid)
+ self._int_to_bytes(evt.id)
+ self._int_to_bytes(source.tgid))
elif self.peer_type == "user":
play_id = (b"u"
+ self._int_to_bytes(self.tgid)
+ self._int_to_bytes(evt.id))
else:
raise ValueError("Portal has invalid peer type")
return base64.b64encode(play_id).decode("utf-8").rstrip("=")
async def handle_telegram_game(self, source: 'AbstractUser', intent: IntentAPI,
evt: Message, relates_to: RelatesTo = None) -> EventID:
game = evt.media.game
play_id = self._encode_msgid(source, evt)
command = f"!tg play {play_id}"
override_text = f"Run {command} in your bridge management room to play {game.title}"
override_entities = [
MessageEntityPre(offset=len("Run "), length=len(command), language="")]
content = await formatter.telegram_to_matrix(
evt, source, self.main_intent,
override_text=override_text, override_entities=override_entities)
content.msgtype = MessageType.NOTICE
content.external_url = self._get_external_url(evt)
content["net.maunium.telegram.game"] = play_id
await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_message(self.mxid, content, timestamp=evt.date)
async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet, evt: Message
) -> None:
if not self.mxid:
return
elif hasattr(evt, "media") and isinstance(evt.media, MessageMediaGame):
self.log.debug("Ignoring game message edit event")
return
async with self.send_lock(sender.tgid if sender else None, required=False):
tg_space = self.tgid if self.peer_type == "channel" else source.tgid
temporary_identifier = EventID(
f"${random.randint(1000000000000, 9999999999999)}TGBRIDGEDITEMP")
duplicate_found = self.dedup.check(evt, (temporary_identifier, tg_space),
force_hash=True)
if duplicate_found:
mxid, other_tg_space = duplicate_found
if tg_space != other_tg_space:
prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1)
if not prev_edit_msg:
return
DBMessage(mxid=mxid, mx_room=self.mxid, tg_space=tg_space,
tgid=TelegramID(evt.id), edit_index=prev_edit_msg.edit_index + 1
).insert()
return
content = await formatter.telegram_to_matrix(evt, source, self.main_intent,
no_reply_fallback=True)
editing_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
if not editing_msg:
self.log.info(f"Didn't find edited message {evt.id}@{tg_space} (src {source.tgid}) "
"in database.")
return
content.msgtype = (MessageType.NOTICE if (sender and sender.is_bot
and self.get_config("bot_messages_as_notices"))
else MessageType.TEXT)
content.external_url = self._get_external_url(evt)
content.set_edit(editing_msg.mxid)
intent = sender.intent_for(self) if sender else self.main_intent
await intent.set_typing(self.mxid, is_typing=False)
event_id = await intent.send_message(self.mxid, content)
prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) or editing_msg
DBMessage(mxid=event_id, mx_room=self.mxid, tg_space=tg_space, tgid=TelegramID(evt.id),
edit_index=prev_edit_msg.edit_index + 1).insert()
DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id)
async def handle_telegram_message(self, source: 'AbstractUser', sender: p.Puppet,
evt: Message) -> None:
if not self.mxid:
await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
if (self.peer_type == "user" and sender.tgid == self.tg_receiver
and not sender.is_real_user and not self.az.state_store.is_joined(self.mxid,
sender.mxid)):
self.log.debug(f"Ignoring private chat message {evt.id}@{source.tgid} as receiver does"
" not have matrix puppeting and their default puppet isn't in the room")
return
async with self.send_lock(sender.tgid if sender else None, required=False):
tg_space = self.tgid if self.peer_type == "channel" else source.tgid
temporary_identifier = EventID(
f"${random.randint(1000000000000, 9999999999999)}TGBRIDGETEMP")
duplicate_found = self.dedup.check(evt, (temporary_identifier, tg_space))
if duplicate_found:
mxid, other_tg_space = duplicate_found
self.log.debug(f"Ignoring message {evt.id}@{tg_space} (src {source.tgid}) "
f"as it was already handled (in space {other_tg_space})")
if tg_space != other_tg_space:
DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=mxid,
tg_space=tg_space, edit_index=0).insert()
return
if self.dedup.pre_db_check and self.peer_type == "channel":
msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
if msg:
self.log.debug(f"Ignoring message {evt.id} (src {source.tgid}) as it was already"
f"handled into {msg.mxid}. This duplicate was catched in the db "
"check. If you get this message often, consider increasing"
"bridge.deduplication.cache_queue_length in the config.")
return
if sender and not sender.displayname:
self.log.debug(f"Telegram user {sender.tgid} sent a message, but doesn't have a "
"displayname, updating info...")
entity = await source.client.get_entity(PeerUser(sender.tgid))
await sender.update_info(source, entity)
allowed_media = (MessageMediaPhoto, MessageMediaDocument, MessageMediaGeo,
MessageMediaGame, MessageMediaPoll, MessageMediaUnsupported)
media = evt.media if hasattr(evt, "media") and isinstance(evt.media,
allowed_media) else None
intent = sender.intent_for(self) if sender else self.main_intent
if not media and evt.message:
is_bot = sender.is_bot if sender else False
event_id = await self.handle_telegram_text(source, intent, is_bot, evt)
elif media:
event_id = await {
MessageMediaPhoto: self.handle_telegram_photo,
MessageMediaDocument: self.handle_telegram_document,
MessageMediaGeo: self.handle_telegram_location,
MessageMediaPoll: self.handle_telegram_poll,
MessageMediaUnsupported: self.handle_telegram_unsupported,
MessageMediaGame: self.handle_telegram_game,
}[type(media)](source, intent, evt,
relates_to=formatter.telegram_reply_to_matrix(evt, source))
else:
self.log.debug("Unhandled Telegram message: %s", evt)
return
if not event_id:
return
prev_id = self.dedup.update(evt, (event_id, tg_space), (temporary_identifier, tg_space))
if prev_id:
self.log.debug(f"Sent message {evt.id}@{tg_space} to Matrix as {event_id}. "
f"Temporary dedup identifier was {temporary_identifier}, "
f"but dedup map contained {prev_id[1]} instead! -- "
"This was probably a race condition caused by Telegram sending updates"
"to other clients before responding to the sender. I'll just redact "
"the likely duplicate message now.")
await intent.redact(self.mxid, event_id)
return
self.log.debug("Handled Telegram message: %s", evt)
try:
DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=event_id,
tg_space=tg_space, edit_index=0).insert()
DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id)
except IntegrityError as e:
self.log.exception(f"{e.__class__.__name__} while saving message mapping. "
"This might mean that an update was handled after it left the "
"dedup cache queue. You can try enabling bridge.deduplication."
"pre_db_check in the config.")
await intent.redact(self.mxid, event_id)
async def _create_room_on_action(self, source: 'AbstractUser',
action: TypeMessageAction) -> bool:
if source.is_relaybot and config["bridge.ignore_unbridged_group_chat"]:
return False
create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate)
create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink)
if isinstance(action, create_and_exit) or isinstance(action, create_and_continue):
await self.create_matrix_room(source, invites=[source.mxid],
update_if_exists=isinstance(action, create_and_exit))
if not isinstance(action, create_and_continue):
return False
return True
async def handle_telegram_action(self, source: 'AbstractUser', sender: p.Puppet,
update: MessageService) -> None:
action = update.action
should_ignore = ((not self.mxid and not await self._create_room_on_action(source, action))
or self.dedup.check_action(update))
if should_ignore or not self.mxid:
return
if isinstance(action, MessageActionChatEditTitle):
await self._update_title(action.title, sender=sender, save=True)
elif isinstance(action, MessageActionChatEditPhoto):
await self._update_avatar(source, action.photo, sender=sender, save=True)
elif isinstance(action, MessageActionChatDeletePhoto):
await self._update_avatar(source, ChatPhotoEmpty(), sender=sender, save=True)
elif isinstance(action, MessageActionChatAddUser):
for user_id in action.users:
await self._add_telegram_user(TelegramID(user_id), source)
elif isinstance(action, MessageActionChatJoinedByLink):
await self._add_telegram_user(sender.id, source)
elif isinstance(action, MessageActionChatDeleteUser):
await self._delete_telegram_user(TelegramID(action.user_id), sender)
elif isinstance(action, MessageActionChatMigrateTo):
self.peer_type = "channel"
self._migrate_and_save_telegram(TelegramID(action.channel_id))
await sender.intent_for(self).send_emote(self.mxid,
"upgraded this group to a supergroup.")
elif isinstance(action, MessageActionGameScore):
# TODO handle game score
pass
else:
self.log.debug("Unhandled Telegram action in %s: %s", self.title, action)
async def set_telegram_admin(self, user_id: TelegramID) -> None:
puppet = p.Puppet.get(user_id)
user = u.User.get_by_tgid(user_id)
levels = await self.main_intent.get_power_levels(self.mxid)
if user:
levels.users[user.mxid] = 50
if puppet:
levels.users[puppet.mxid] = 50
await self.main_intent.set_power_levels(self.mxid, levels)
async def receive_telegram_pin_id(self, msg_id: TelegramID, receiver: TelegramID) -> None:
tg_space = receiver if self.peer_type != "channel" else self.tgid
message = DBMessage.get_one_by_tgid(msg_id, tg_space) if msg_id != 0 else None
if message:
await self.main_intent.set_pinned_messages(self.mxid, [message.mxid])
else:
await self.main_intent.set_pinned_messages(self.mxid, [])
async def set_telegram_admins_enabled(self, enabled: bool) -> None:
level = 50 if enabled else 10
levels = await self.main_intent.get_power_levels(self.mxid)
levels.invite = level
levels.events[EventType.ROOM_NAME] = level
levels.events[EventType.ROOM_AVATAR] = level
await self.main_intent.set_power_levels(self.mxid, levels)
def init(context: Context) -> None:
global config
config = context.config
+156 -256
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,21 +13,24 @@
#
# 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, Any, Dict, List, Iterable, Optional, Pattern, Union, TYPE_CHECKING
from typing import Awaitable, Any, Dict, Iterable, Optional, Union, TYPE_CHECKING
from difflib import SequenceMatcher
from enum import Enum
from aiohttp import ServerDisconnectedError
import unicodedata
import asyncio
import logging
import re
from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer,
InputPeerPhotoFileLocation, UserProfilePhotoEmpty)
from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError
InputPeerPhotoFileLocation, UserProfilePhotoEmpty, TypeInputUser)
from .types import MatrixUserID, TelegramID
from mautrix.appservice import AppService, IntentAPI
from mautrix.errors import MatrixRequestError
from mautrix.bridge import CustomPuppetMixin
from mautrix.types import UserID, SyncToken
from mautrix.util.simple_template import SimpleTemplate
from .types import TelegramID
from .db import Puppet as DBPuppet
from . import util
from . import util, portal as p
if TYPE_CHECKING:
from .matrix import MatrixHandler
@@ -36,26 +38,47 @@ if TYPE_CHECKING:
from .context import Context
from .abstract_user import AbstractUser
PuppetError = Enum('PuppetError', 'Success OnlyLoginSelf InvalidAccessToken')
config = None # type: Config
config: Optional['Config'] = None
class Puppet:
log = logging.getLogger("mau.puppet") # type: logging.Logger
az = None # type: AppService
mx = None # type: MatrixHandler
loop = None # type: asyncio.AbstractEventLoop
mxid_regex = None # type: Pattern
username_template = None # type: str
hs_domain = None # type: str
cache = {} # type: Dict[TelegramID, Puppet]
by_custom_mxid = {} # type: Dict[str, Puppet]
class Puppet(CustomPuppetMixin):
log: logging.Logger = logging.getLogger("mau.puppet")
az: AppService
mx: 'MatrixHandler'
loop: asyncio.AbstractEventLoop
hs_domain: str
mxid_template: SimpleTemplate[TelegramID]
displayname_template: SimpleTemplate[str]
cache: Dict[TelegramID, 'Puppet'] = {}
by_custom_mxid: Dict[UserID, 'Puppet'] = {}
id: TelegramID
access_token: Optional[str]
custom_mxid: Optional[UserID]
_next_batch: Optional[SyncToken]
default_mxid: UserID
username: Optional[str]
displayname: Optional[str]
displayname_source: Optional[TelegramID]
photo_id: Optional[str]
is_bot: bool
is_registered: bool
disable_updates: bool
default_mxid_intent: IntentAPI
intent: IntentAPI
sync_task: Optional[asyncio.Future]
_db_instance: Optional[DBPuppet]
def __init__(self,
id: TelegramID,
access_token: Optional[str] = None,
custom_mxid: Optional[MatrixUserID] = None,
custom_mxid: Optional[UserID] = None,
next_batch: Optional[SyncToken] = None,
username: Optional[str] = None,
displayname: Optional[str] = None,
displayname_source: Optional[TelegramID] = None,
@@ -64,40 +87,47 @@ class Puppet:
is_registered: bool = False,
disable_updates: bool = False,
db_instance: Optional[DBPuppet] = None) -> None:
self.id = id # type: TelegramID
self.access_token = access_token # type: Optional[str]
self.custom_mxid = custom_mxid # type: Optional[MatrixUserID]
self.default_mxid = self.get_mxid_from_id(self.id) # type: MatrixUserID
self.id = id
self.access_token = access_token
self.custom_mxid = custom_mxid
self._next_batch = next_batch
self.default_mxid = self.get_mxid_from_id(self.id)
self.username = username # type: Optional[str]
self.displayname = displayname # type: Optional[str]
self.displayname_source = displayname_source # type: Optional[TelegramID]
self.photo_id = photo_id # type: Optional[str]
self.is_bot = is_bot # type: bool
self.is_registered = is_registered # type: bool
self.disable_updates = disable_updates # type: bool
self._db_instance = db_instance # type: Optional[DBPuppet]
self.username = username
self.displayname = displayname
self.displayname_source = displayname_source
self.photo_id = photo_id
self.is_bot = is_bot
self.is_registered = is_registered
self.disable_updates = disable_updates
self._db_instance = db_instance
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
self.intent = self._fresh_intent() # type: IntentAPI
self.sync_task = None # type: Optional[asyncio.Future]
self.intent = self._fresh_intent()
self.sync_task = None
self.cache[id] = self
if self.custom_mxid:
self.by_custom_mxid[self.custom_mxid] = self
@property
def mxid(self) -> MatrixUserID:
return self.custom_mxid or self.default_mxid
self.log = self.log.getChild(str(self.id))
@property
def tgid(self) -> TelegramID:
return self.id
@property
def is_real_user(self) -> bool:
""" Is True when the puppet is a real Matrix user. """
return bool(self.custom_mxid and self.access_token)
def peer(self) -> PeerUser:
return PeerUser(user_id=self.tgid)
@property
def next_batch(self) -> SyncToken:
return self._next_batch
@next_batch.setter
def next_batch(self, value: SyncToken) -> None:
self._next_batch = value
self.db_instance.edit(next_batch=self._next_batch)
@staticmethod
async def is_logged_in() -> bool:
@@ -106,175 +136,17 @@ class Puppet:
@property
def plain_displayname(self) -> str:
tpl = config["bridge.displayname_template"]
if tpl == "{displayname}":
# Template has no extra stuff, no need to parse.
return self.displayname
regex = re.compile("^" + re.escape(tpl).replace(re.escape("{displayname}"), "(.+?)") + "$")
match = regex.match(self.displayname)
return match.group(1) or self.displayname
return self.displayname_template.parse(self.displayname) or self.displayname
def get_input_entity(self, user: 'AbstractUser') -> Awaitable[TypeInputPeer]:
return user.client.get_input_entity(PeerUser(user_id=self.tgid))
def get_input_entity(self, user: 'AbstractUser'
) -> Awaitable[Union[TypeInputPeer, TypeInputUser]]:
return user.client.get_input_entity(self.peer)
# region Custom puppet management
def _fresh_intent(self) -> IntentAPI:
return (self.az.intent.user(self.custom_mxid, self.access_token)
if self.is_real_user else self.default_mxid_intent)
def intent_for(self, portal: 'p.Portal') -> IntentAPI:
if portal.tgid == self.tgid:
return self.default_mxid_intent
return self.intent
async def switch_mxid(self, access_token: Optional[str],
mxid: Optional[MatrixUserID]) -> PuppetError:
prev_mxid = self.custom_mxid
self.custom_mxid = mxid
self.access_token = access_token
self.intent = self._fresh_intent()
err = await self.init_custom_mxid()
if err != PuppetError.Success:
return err
try:
del self.by_custom_mxid[prev_mxid] # type: ignore
except KeyError:
pass
if self.mxid != self.default_mxid:
self.by_custom_mxid[self.mxid] = self
await self.leave_rooms_with_default_user()
self.save()
return PuppetError.Success
async def init_custom_mxid(self) -> PuppetError:
if not self.is_real_user:
return PuppetError.Success
mxid = await self.intent.whoami()
if not mxid or mxid != self.custom_mxid:
self.custom_mxid = None
self.access_token = None
self.intent = self._fresh_intent()
if mxid != self.custom_mxid:
return PuppetError.OnlyLoginSelf
return PuppetError.InvalidAccessToken
if config["bridge.sync_with_custom_puppets"]:
self.sync_task = asyncio.ensure_future(self.sync(), loop=self.loop)
return PuppetError.Success
async def leave_rooms_with_default_user(self) -> None:
for room_id in await self.default_mxid_intent.get_joined_rooms():
try:
await self.default_mxid_intent.leave_room(room_id)
await self.intent.ensure_joined(room_id)
except (IntentError, MatrixRequestError):
pass
def create_sync_filter(self) -> Awaitable[str]:
return self.intent.client.create_filter(self.custom_mxid, {
"room": {
"include_leave": False,
"state": {
"types": []
},
"timeline": {
"types": [],
},
"ephemeral": {
"types": ["m.typing", "m.receipt"],
},
"account_data": {
"types": []
}
},
"account_data": {
"types": [],
},
"presence": {
"types": ["m.presence"],
"senders": [self.custom_mxid],
},
})
def filter_events(self, events: List[Dict]) -> List:
new_events = []
for event in events:
evt_type = event.get("type", None)
event.setdefault("content", {})
if evt_type == "m.typing":
is_typing = self.custom_mxid in event["content"].get("user_ids", [])
event["content"]["user_ids"] = [self.custom_mxid] if is_typing else []
elif evt_type == "m.receipt":
val = None
evt = None
for event_id in event["content"]:
try:
val = event["content"][event_id]["m.read"][self.custom_mxid]
evt = event_id
break
except KeyError:
pass
if val and evt:
event["content"] = {evt: {"m.read": {
self.custom_mxid: val
}}}
else:
continue
new_events.append(event)
return new_events
def handle_sync(self, presence: List, ephemeral: Dict) -> None:
presence_events = [self.mx.try_handle_ephemeral_event(event) for event in presence]
for room_id, events in ephemeral.items():
for event in events:
event["room_id"] = room_id
ephemeral_events = [self.mx.try_handle_ephemeral_event(event)
for events in ephemeral.values()
for event in self.filter_events(events)]
events = ephemeral_events + presence_events # List[Callable[[int], Awaitable[None]]]
coro = asyncio.gather(*events, loop=self.loop)
asyncio.ensure_future(coro, loop=self.loop)
async def sync(self) -> None:
try:
await self._sync()
except asyncio.CancelledError:
self.log.info("Syncing cancelled")
except Exception:
self.log.exception("Fatal error syncing")
async def _sync(self) -> None:
if not self.is_real_user:
self.log.warning("Called sync() for non-custom puppet.")
return
custom_mxid = self.custom_mxid
access_token_at_start = self.access_token
errors = 0
next_batch = None
filter_id = await self.create_sync_filter()
self.log.debug(f"Starting syncer for {custom_mxid} with sync filter {filter_id}.")
while access_token_at_start == self.access_token:
try:
sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch,
set_presence="offline") # type: Dict
errors = 0
if next_batch is not None:
presence = sync_resp.get("presence", {}).get("events", []) # type: List
ephemeral = {room: data.get("ephemeral", {}).get("events", [])
for room, data
in sync_resp.get("rooms", {}).get("join", {}).items()
} # type: Dict
self.handle_sync(presence, ephemeral)
next_batch = sync_resp.get("next_batch", None)
except (MatrixRequestError, ServerDisconnectedError) as e:
wait = min(errors, 11) ** 2
self.log.warning(f"Syncer for {custom_mxid} errored: {e}. "
f"Waiting for {wait} seconds...")
errors += 1
await asyncio.sleep(wait)
self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.")
# endregion
# region DB conversion
@property
@@ -283,26 +155,27 @@ class Puppet:
self._db_instance = self.new_db_instance()
return self._db_instance
@property
def _fields(self) -> Dict[str, Any]:
return dict(access_token=self.access_token, next_batch=self._next_batch,
custom_mxid=self.custom_mxid, username=self.username, is_bot=self.is_bot,
displayname=self.displayname, displayname_source=self.displayname_source,
photo_id=self.photo_id, matrix_registered=self.is_registered,
disable_updates=self.disable_updates)
def new_db_instance(self) -> DBPuppet:
return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid,
username=self.username, displayname=self.displayname,
displayname_source=self.displayname_source, photo_id=self.photo_id,
is_bot=self.is_bot, matrix_registered=self.is_registered,
disable_updates=self.disable_updates)
return DBPuppet(id=self.id, **self._fields)
def save(self) -> None:
self.db_instance.edit(**self._fields)
@classmethod
def from_db(cls, db_puppet: DBPuppet) -> 'Puppet':
return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid,
db_puppet.username, db_puppet.displayname, db_puppet.displayname_source,
db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered,
db_puppet.disable_updates, db_instance=db_puppet)
def save(self) -> None:
self.db_instance.update(access_token=self.access_token, custom_mxid=self.custom_mxid,
username=self.username, displayname=self.displayname,
displayname_source=self.displayname_source, photo_id=self.photo_id,
is_bot=self.is_bot, matrix_registered=self.is_registered,
disable_updates=self.disable_updates)
db_puppet.next_batch, db_puppet.username, db_puppet.displayname,
db_puppet.displayname_source, db_puppet.photo_id, db_puppet.is_bot,
db_puppet.matrix_registered, db_puppet.disable_updates,
db_instance=db_puppet)
# endregion
# region Info updating
@@ -316,14 +189,26 @@ class Puppet:
return int(round(similarity * 100))
@staticmethod
def get_displayname(info: User, enable_format: bool = True) -> str:
def _filter_name(name: str) -> str:
if not name:
return ""
whitespace = ("\t\n\r\v\f \u00a0\u034f\u180e\u2063\u202f\u205f\u2800\u3000\u3164\ufeff"
"\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b"
"\u200c\u200d\u200e\u200f\ufe0f")
name = "".join(c for c in name.strip(whitespace) if unicodedata.category(c) != 'Cf')
return name
@classmethod
def get_displayname(cls, info: User, enable_format: bool = True) -> str:
fn = cls._filter_name(info.first_name)
ln = cls._filter_name(info.last_name)
data = {
"phone number": info.phone if hasattr(info, "phone") else None,
"username": info.username,
"full name": " ".join([info.first_name or "", info.last_name or ""]).strip(),
"full name reversed": " ".join([info.first_name or "", info.last_name or ""]).strip(),
"first name": info.first_name,
"last name": info.last_name,
"full name": " ".join([fn, ln]).strip(),
"full name reversed": " ".join([ln, fn]).strip(),
"first name": fn,
"last name": ln,
}
preferences = config["bridge.displayname_preference"]
name = None
@@ -335,12 +220,17 @@ class Puppet:
if isinstance(info, User) and info.deleted:
name = f"Deleted account {info.id}"
elif not name:
name = info.id
name = str(info.id)
if not enable_format:
return name
return config["bridge.displayname_template"].format(
displayname=name)
return cls.displayname_template.format_full(name)
async def try_update_info(self, source: 'AbstractUser', info: User) -> None:
try:
await self.update_info(source, info)
except Exception:
source.log.exception(f"Failed to update info of {self.tgid}")
async def update_info(self, source: 'AbstractUser', info: User) -> None:
if self.disable_updates:
@@ -350,9 +240,12 @@ class Puppet:
self.username = info.username
changed = True
changed = await self.update_displayname(source, info) or changed
if isinstance(info.photo, UserProfilePhoto):
changed = await self.update_avatar(source, info.photo) or changed
try:
changed = await self.update_displayname(source, info) or changed
if isinstance(info.photo, UserProfilePhoto):
changed = await self.update_avatar(source, info.photo) or changed
except Exception:
self.log.exception(f"Failed to update info from source {source.tgid}")
self.is_bot = info.bot
@@ -365,10 +258,10 @@ class Puppet:
return False
allow_source = (source.is_relaybot
or self.displayname_source == source.tgid
# User is not a contact, so there's no custom name
or not info.contact
# No displayname source, so just trust anything
or self.displayname_source is None
# No phone -> not in contact list -> can't set custom name
or (isinstance(info, User) and info.phone is None))
or self.displayname_source is None)
if not allow_source:
return False
elif isinstance(info, UpdateUserName):
@@ -379,7 +272,8 @@ class Puppet:
self.displayname = displayname
self.displayname_source = source.tgid
try:
await self.default_mxid_intent.set_display_name(displayname[:100])
await self.default_mxid_intent.set_displayname(
displayname[:config["bridge.displayname_max_length"]])
except MatrixRequestError:
self.log.exception("Failed to set displayname")
self.displayname = ""
@@ -403,7 +297,7 @@ class Puppet:
if not photo_id:
self.photo_id = ""
try:
await self.default_mxid_intent.set_avatar("")
await self.default_mxid_intent.set_avatar_url("")
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
@@ -419,7 +313,7 @@ class Puppet:
if file:
self.photo_id = photo_id
try:
await self.default_mxid_intent.set_avatar(file.mxc)
await self.default_mxid_intent.set_avatar_url(file.mxc)
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
@@ -448,7 +342,7 @@ class Puppet:
return None
@classmethod
def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['Puppet']:
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
tgid = cls.get_id_from_mxid(mxid)
if tgid:
return cls.get(tgid, create)
@@ -456,7 +350,7 @@ class Puppet:
return None
@classmethod
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
if not mxid:
raise ValueError("Matrix ID can't be empty")
@@ -480,23 +374,22 @@ class Puppet:
for puppet in DBPuppet.all_with_custom_mxid())
@classmethod
def get_id_from_mxid(cls, mxid: MatrixUserID) -> Optional[TelegramID]:
match = cls.mxid_regex.match(mxid)
if match:
return TelegramID(int(match.group(1)))
return None
def get_id_from_mxid(cls, mxid: UserID) -> Optional[TelegramID]:
return cls.mxid_template.parse(mxid)
@classmethod
def get_mxid_from_id(cls, tgid: TelegramID) -> MatrixUserID:
return MatrixUserID(f"@{cls.username_template.format(userid=tgid)}:{cls.hs_domain}")
def get_mxid_from_id(cls, tgid: TelegramID) -> UserID:
return UserID(cls.mxid_template.format_full(tgid))
@classmethod
def find_by_username(cls, username: str) -> Optional['Puppet']:
if not username:
return None
username = username.lower()
for _, puppet in cls.cache.items():
if puppet.username and puppet.username.lower() == username.lower():
if puppet.username and puppet.username.lower() == username:
return puppet
dbpuppet = DBPuppet.get_by_username(username)
@@ -522,12 +415,19 @@ class Puppet:
# endregion
def init(context: 'Context') -> List[Awaitable[Any]]: # [None, None, PuppetError]
def init(context: 'Context') -> Iterable[Awaitable[Any]]:
global config
Puppet.az, config, Puppet.loop, _ = context.core
Puppet.mx = context.mx
Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}")
Puppet.hs_domain = config["homeserver"]["domain"]
Puppet.mxid_regex = re.compile(
f"@{Puppet.username_template.format(userid='([0-9]+)')}:{Puppet.hs_domain}")
return [puppet.init_custom_mxid() for puppet in Puppet.all_with_custom_mxid()]
Puppet.mxid_template = SimpleTemplate(config["bridge.username_template"], "userid",
prefix="@", suffix=f":{Puppet.hs_domain}", type=int)
Puppet.displayname_template = SimpleTemplate(config["bridge.displayname_template"],
"displayname")
secret = config["bridge.login_shared_secret"]
Puppet.login_shared_secret = secret.encode("utf-8") if secret else None
Puppet.login_device_name = "Telegram Bridge"
return (puppet.try_start() for puppet in Puppet.all_with_custom_mxid())
@@ -1,7 +1,9 @@
from typing import Union
import argparse
import sqlalchemy as sql
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declarative_base
import sqlalchemy as sql
from alchemysession import AlchemySessionContainer
@@ -22,16 +24,19 @@ def log(message, end="\n"):
def connect(to):
import mautrix_telegram.db.base as base
base.Base = declarative_base(cls=base.BaseBase)
from mautrix_telegram.db import (Portal, Message, UserPortal, User, RoomState, UserProfile,
Contact, Puppet, BotChat, TelegramFile)
from mautrix.bridge.db import Base, RoomState, UserProfile
from mautrix_telegram.db import (Portal, Message, UserPortal, User, Contact, Puppet, BotChat,
TelegramFile)
db_engine = sql.create_engine(to)
db_factory = orm.sessionmaker(bind=db_engine)
db_session = orm.scoped_session(db_factory) # type: orm.Session
base.Base.metadata.bind = db_engine
db_session: Union[orm.Session, orm.scoped_session] = orm.scoped_session(db_factory)
Base.metadata.bind = db_engine
new_base = declarative_base()
new_base.metadata.bind = db_engine
session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
table_base=base.Base, table_prefix="telethon_",
table_base=new_base, table_prefix="telethon_",
manage_tables=False)
return db_session, {
@@ -52,6 +57,7 @@ def connect(to):
"TelegramFile": TelegramFile,
}
log("Connecting to old database")
session, tables = connect(args.from_url)
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -15,11 +14,14 @@
# 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 sqlalchemy import orm
import sqlalchemy as sql
import argparse
from mautrix_telegram.db import Base, Portal, Message, Puppet, BotChat
from sqlalchemy import orm
import sqlalchemy as sql
from mautrix.util.db import Base
from mautrix_telegram.db import Portal, Message, Puppet, BotChat
from mautrix_telegram.config import Config
from .models import ChatLink, TgUser, MatrixUser, Message as TMMessage, Base as TelematrixBase
@@ -38,8 +40,7 @@ args = parser.parse_args()
config = Config(args.config, None, None)
config.load()
mxtg_db_engine = sql.create_engine(
config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
mxtg_db_engine = sql.create_engine(config["appservice.database"])
mxtg = orm.sessionmaker(bind=mxtg_db_engine)()
Base.metadata.bind = mxtg_db_engine
@@ -55,18 +56,18 @@ tm_messages = telematrix.query(TMMessage).all()
telematrix.close()
telematrix_db_engine.dispose()
portals_by_tgid = {} # type: Dict[int, Portal]
portals_by_mxid = {} # type: Dict[str, Portal]
chats = {} # type: Dict[int, BotChat]
messages = {} # type: Dict[str, Message]
puppets = {} # type: Dict[int, Puppet]
portals_by_tgid: Dict[int, Portal] = {}
portals_by_mxid: Dict[str, Portal] = {}
chats: Dict[int, BotChat] = {}
messages: Dict[str, Message] = {}
puppets: Dict[int, Puppet] = {}
for chat_link in chat_links:
if type(chat_link.tg_room) is str:
print("Expected tg_room to be a number, got a string. Ignoring %s" % chat_link.tg_room)
print(f"Expected tg_room to be a number, got a string. Ignoring {chat_link.tg_room}")
continue
if chat_link.tg_room >= 0:
print("Unexpected unprefixed telegram chat ID: %s, ignoring..." % chat_link.tg_room)
print(f"Unexpected unprefixed telegram chat ID: {chat_link.tg_room}, ignoring...")
continue
tgid = str(chat_link.tg_room)
if tgid.startswith("-100"):
+15 -96
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,106 +13,26 @@
#
# 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 mautrix.types import UserID
from mautrix.bridge.db import SQLStateStore as BaseSQLStateStore
from mautrix_appservice import StateStore
from .types import MatrixUserID, MatrixRoomID
from . import puppet as pu
from .db import RoomState, UserProfile
class SQLStateStore(StateStore):
def __init__(self) -> None:
super().__init__()
self.profile_cache = {} # type: Dict[Tuple[str, str], UserProfile]
self.room_state_cache = {} # type: Dict[str, RoomState]
class SQLStateStore(BaseSQLStateStore):
def is_registered(self, user_id: UserID) -> bool:
puppet = pu.Puppet.get_by_mxid(user_id, create=False)
if puppet:
return puppet.is_registered
custom_puppet = pu.Puppet.get_by_custom_mxid(user_id)
if custom_puppet:
return True
return super().is_registered(user_id)
@staticmethod
def is_registered(user: MatrixUserID) -> bool:
puppet = pu.Puppet.get_by_mxid(user)
return puppet.is_registered if puppet else False
@staticmethod
def registered(user: MatrixUserID) -> None:
puppet = pu.Puppet.get_by_mxid(user)
def registered(self, user_id: UserID) -> None:
puppet = pu.Puppet.get_by_mxid(user_id, create=True)
if puppet:
puppet.is_registered = True
puppet.save()
def update_state(self, event: Dict) -> None:
event_type = event["type"]
if event_type == "m.room.power_levels":
self.set_power_levels(event["room_id"], event["content"])
elif event_type == "m.room.member":
self.set_member(event["room_id"], event["state_key"], event["content"])
def _get_user_profile(self, room_id: MatrixRoomID, user_id: MatrixUserID, create: bool = True
) -> UserProfile:
key = (room_id, user_id)
try:
return self.profile_cache[key]
except KeyError:
pass
profile = UserProfile.get(*key)
if profile:
self.profile_cache[key] = profile
elif create:
profile = UserProfile(room_id=room_id, user_id=user_id, membership="leave")
profile.insert()
self.profile_cache[key] = profile
return profile
def get_member(self, room: MatrixRoomID, user: MatrixUserID) -> Dict:
return self._get_user_profile(room, user).dict()
def set_member(self, room: MatrixRoomID, user: MatrixUserID, member: Dict) -> None:
profile = self._get_user_profile(room, user)
profile.membership = member.get("membership", profile.membership or "leave")
profile.displayname = member.get("displayname", profile.displayname)
profile.avatar_url = member.get("avatar_url", profile.avatar_url)
profile.update()
def set_membership(self, room: MatrixRoomID, user: MatrixUserID, membership: str) -> None:
self.set_member(room, user, {
"membership": membership,
})
def _get_room_state(self, room_id: MatrixRoomID, create: bool = True) -> RoomState:
try:
return self.room_state_cache[room_id]
except KeyError:
pass
room = RoomState.get(room_id)
if room:
self.room_state_cache[room_id] = room
elif create:
room = RoomState(room_id=room_id)
room.insert()
self.room_state_cache[room_id] = room
return room
def has_power_levels(self, room: MatrixRoomID) -> bool:
return bool(self._get_room_state(room).power_levels)
def get_power_levels(self, room: MatrixRoomID) -> Dict:
return self._get_room_state(room).power_levels
def set_power_level(self, room: MatrixRoomID, user: MatrixUserID, level: int) -> None:
room_state = self._get_room_state(room)
power_levels = room_state.power_levels
if not power_levels:
power_levels = {
"users": {},
"events": {},
}
power_levels[room]["users"][user] = level
room_state.power_levels = power_levels
room_state.update()
def set_power_levels(self, room: MatrixRoomID, content: Dict) -> None:
state = self._get_room_state(room)
state.power_levels = content
state.update()
else:
super().registered(user_id)
+7 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -18,18 +17,21 @@ from typing import List, Union, Optional
from telethon import TelegramClient, utils
from telethon.tl.functions.messages import SendMediaRequest
from telethon.tl.types import (
InputMediaUploadedDocument, InputMediaUploadedPhoto, TypeDocumentAttribute, TypeInputMedia,
TypeInputPeer, TypeMessageEntity, TypeMessageMedia, TypePeer)
from telethon.tl.types import (InputMediaUploadedDocument, InputMediaUploadedPhoto,
TypeDocumentAttribute, TypeInputMedia, TypeInputPeer,
TypeMessageEntity, TypeMessageMedia, TypePeer)
from telethon.tl.patched import Message
from telethon.sessions.abstract import Session
class MautrixTelegramClient(TelegramClient):
session: Session
async def upload_file_direct(self, file: bytes, mime_type: str = None,
attributes: List[TypeDocumentAttribute] = None,
file_name: str = None, max_image_size: float = 10 * 1000 ** 2,
) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]:
file_handle = await super().upload_file(file, file_name=file_name, use_cache=False)
file_handle = await super().upload_file(file, file_name=file_name)
if (mime_type == "image/png" or mime_type == "image/jpeg") and len(file) < max_image_size:
return InputMediaUploadedPhoto(file_handle)
+1 -7
View File
@@ -1,9 +1,3 @@
from typing import Dict, NewType
MatrixUserID = NewType('MatrixUserID', str)
MatrixRoomID = NewType('MatrixRoomID', str)
MatrixEventID = NewType('MatrixEventID', str)
MatrixEvent = NewType('MatrixEvent', Dict)
from typing import NewType
TelegramID = NewType('TelegramID', int)
+102 -48
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,20 +13,24 @@
#
# 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, Dict, List, Iterable, Match, NewType, Optional, Tuple, TYPE_CHECKING
from typing import (Awaitable, Dict, List, Iterable, NewType, Optional, Tuple, Any, cast,
TYPE_CHECKING)
import logging
import asyncio
import re
from telethon.tl.types import (
TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser,
UpdateShortChatMessage, UpdateShortMessage, User as TLUser)
from telethon.tl.types import (TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser,
UpdateShortChatMessage, UpdateShortMessage, User as TLUser, Chat,
ChatForbidden)
from telethon.tl.types.contacts import ContactsNotModified
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
from telethon.tl.functions.account import UpdateStatusRequest
from mautrix_appservice import MatrixRequestError
from .types import MatrixUserID, TelegramID
from mautrix.client import Client
from mautrix.errors import MatrixRequestError
from mautrix.types import UserID
from mautrix.bridge import BaseUser
from .types import TelegramID
from .db import User as DBUser
from .abstract_user import AbstractUser
from . import portal as po, puppet as pu
@@ -36,36 +39,46 @@ if TYPE_CHECKING:
from .config import Config
from .context import Context
config = None # type: Config
config: Optional['Config'] = None
SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int])
class User(AbstractUser):
log = logging.getLogger("mau.user") # type: logging.Logger
by_mxid = {} # type: Dict[str, User]
by_tgid = {} # type: Dict[int, User]
class User(AbstractUser, BaseUser):
log: logging.Logger = logging.getLogger("mau.user")
by_mxid: Dict[str, 'User'] = {}
by_tgid: Dict[int, 'User'] = {}
def __init__(self, mxid: MatrixUserID, tgid: Optional[TelegramID] = None,
phone: Optional[str]
contacts: List['pu.Puppet']
saved_contacts: int
portals: Dict[Tuple[TelegramID, TelegramID], 'po.Portal']
command_status: Optional[Dict[str, Any]]
_db_instance: Optional[DBUser]
_ensure_started_lock: asyncio.Lock
def __init__(self, mxid: UserID, tgid: Optional[TelegramID] = None,
username: Optional[str] = None, phone: Optional[str] = None,
db_contacts: Optional[Iterable[TelegramID]] = None,
saved_contacts: int = 0, is_bot: bool = False,
db_portals: Optional[Iterable[Tuple[TelegramID, TelegramID]]] = None,
db_instance: Optional[DBUser] = None) -> None:
super().__init__()
self.mxid = mxid # type: MatrixUserID
self.tgid = tgid # type: TelegramID
self.is_bot = is_bot # type: bool
self.username = username # type: str
self.phone = phone # type: str
self.contacts = [] # type: List[pu.Puppet]
self.saved_contacts = saved_contacts # type: int
self.mxid = mxid
self.tgid = tgid
self.is_bot = is_bot
self.username = username
self.phone = phone
self.contacts = []
self.saved_contacts = saved_contacts
self.db_contacts = db_contacts
self.portals = {} # type: Dict[Tuple[TelegramID, TelegramID], po.Portal]
self.portals = {}
self.db_portals = db_portals or []
self._db_instance = db_instance # type: Optional[DBUser]
self._db_instance = db_instance
self._ensure_started_lock = asyncio.Lock()
self.command_status = None # type: Optional[Dict]
self.command_status = None
(self.relaybot_whitelisted,
self.whitelisted,
@@ -78,14 +91,16 @@ class User(AbstractUser):
if tgid:
self.by_tgid[tgid] = self
self.log = self.log.getChild(self.mxid)
@property
def name(self) -> str:
return self.mxid
@property
def mxid_localpart(self) -> str:
match = re.compile("@(.+):(.+)").match(self.mxid) # type: Match
return match.group(1)
localpart, server = Client.parse_user_id(self.mxid)
return localpart
@property
def human_tg_id(self) -> str:
@@ -136,8 +151,8 @@ class User(AbstractUser):
saved_contacts=self.saved_contacts, portals=self.db_portals)
def save(self, contacts: bool = False, portals: bool = False) -> None:
self.db_instance.update(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone,
saved_contacts=self.saved_contacts)
self.db_instance.edit(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone,
saved_contacts=self.saved_contacts)
if contacts:
self.db_instance.contacts = self.db_contacts
if portals:
@@ -161,8 +176,17 @@ class User(AbstractUser):
# endregion
# region Telegram connection management
def ensure_started(self, even_if_no_session=False) -> Awaitable['User']:
return super().ensure_started(even_if_no_session)
async def try_ensure_started(self) -> None:
try:
await self.ensure_started()
except Exception:
self.log.exception("Exception in ensure_started")
async def ensure_started(self, even_if_no_session=False) -> 'User':
if not self.puppet_whitelisted or self.connected:
return self
async with self._ensure_started_lock:
return cast(User, await super().ensure_started(even_if_no_session))
async def start(self, delete_unless_authenticated: bool = False) -> 'User':
await super().start()
@@ -175,16 +199,27 @@ class User(AbstractUser):
self.client.session.delete()
return self
async def post_login(self, info: TLUser = None) -> None:
async def post_login(self, info: TLUser = None, first_login: bool = False) -> None:
try:
await self.update_info(info)
if not self.is_bot and config["bridge.startup_sync"]:
except Exception:
self.log.exception("Failed to update telegram account info")
return
try:
puppet = pu.Puppet.get(self.tgid)
if puppet.custom_mxid != self.mxid and puppet.can_auto_login(self.mxid):
self.log.info(f"Automatically enabling custom puppet")
await puppet.switch_mxid(access_token="auto", mxid=self.mxid)
except Exception:
self.log.exception("Failed to automatically enable custom puppet")
if not self.is_bot and config["bridge.startup_sync"]:
try:
await self.sync_dialogs()
await self.sync_contacts()
if config["bridge.catch_up"]:
await self.client.catch_up()
except Exception:
self.log.exception("Failed to run post-login functions for %s", self.mxid)
except Exception:
self.log.exception("Failed to run post-login sync")
async def update(self, update: TypeUpdate) -> bool:
if not self.is_bot:
@@ -198,7 +233,7 @@ class User(AbstractUser):
else:
portal = po.Portal.get_by_entity(message.to_id, receiver_id=self.tgid)
elif isinstance(update, UpdateShortChatMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
else:
@@ -206,7 +241,9 @@ class User(AbstractUser):
if portal:
self.register_portal(portal)
return False
# Don't bother handling the update
return True
# endregion
@@ -229,7 +266,7 @@ class User(AbstractUser):
self.phone = info.phone
changed = True
if self.tgid != info.id:
self.tgid = info.id
self.tgid = TelegramID(info.id)
self.by_tgid[self.tgid] = self
if changed:
self.save()
@@ -242,7 +279,8 @@ class User(AbstractUser):
if not portal or portal.deleted or not portal.mxid or portal.has_bot:
continue
try:
await portal.main_intent.kick(portal.mxid, self.mxid, "Logged out of Telegram.")
await portal.main_intent.kick_user(portal.mxid, self.mxid,
"Logged out of Telegram.")
except MatrixRequestError:
pass
self.portals = {}
@@ -263,7 +301,7 @@ class User(AbstractUser):
def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45
) -> List[SearchResult]:
results = [] # type: List[SearchResult]
results: List[SearchResult] = []
for contact in self.contacts:
similarity = contact.similarity(query)
if similarity >= min_similarity:
@@ -275,7 +313,7 @@ class User(AbstractUser):
if len(query) < 5:
return []
server_results = await self.client(SearchRequest(q=query, limit=max_results))
results = [] # type: List[SearchResult]
results: List[SearchResult] = []
for user in server_results.users:
puppet = pu.Puppet.get(user.id)
await puppet.update_info(self, user)
@@ -295,8 +333,21 @@ class User(AbstractUser):
return await self._search_remote(query), True
async def sync_dialogs(self, synchronous_create: bool = False) -> None:
if self.is_bot:
return
creators = []
for entity in await self.get_dialogs(limit=config["bridge.sync_dialog_limit"] or None):
limit = config["bridge.sync_dialog_limit"] or None
self.log.debug(f"Syncing dialogs (limit={limit}, synchronous_create={synchronous_create})")
async for dialog in self.client.iter_dialogs(limit=limit, ignore_migrated=True,
archived=False):
entity = dialog.entity
if isinstance(entity, ChatForbidden):
self.log.warning(f"Ignoring forbidden chat {entity} while syncing")
elif isinstance(entity, Chat) and (entity.deactivated or entity.left):
self.log.warning(f"Ignoring deactivated or left chat {entity} while syncing")
continue
elif isinstance(entity, TLUser) and not config["bridge.sync_direct_chats"]:
continue
portal = po.Portal.get_by_entity(entity)
self.portals[portal.tgid_full] = portal
creators.append(
@@ -304,6 +355,7 @@ class User(AbstractUser):
synchronous=synchronous_create))
self.save(portals=True)
await asyncio.gather(*creators, loop=self.loop)
self.log.debug("Dialog syncing complete")
def register_portal(self, portal: po.Portal) -> None:
try:
@@ -323,7 +375,7 @@ class User(AbstractUser):
async def needs_relaybot(self, portal: po.Portal) -> bool:
return not await self.is_logged_in() or (
(portal.has_bot or self.bot) and portal.tgid_full not in self.portals)
(portal.has_bot or self.is_bot) and portal.tgid_full not in self.portals)
def _hash_contacts(self) -> int:
acc = 0
@@ -348,7 +400,7 @@ class User(AbstractUser):
# region Class instance lookup
@classmethod
def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['User']:
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['User']:
if not mxid:
raise ValueError("Matrix ID can't be empty")
@@ -388,8 +440,10 @@ class User(AbstractUser):
if not username:
return None
username = username.lower()
for _, user in cls.by_tgid.items():
if user.username and user.username.lower() == username.lower():
if user.username and user.username.lower() == username:
return user
puppet = DBUser.get_by_username(username)
@@ -400,9 +454,9 @@ class User(AbstractUser):
# endregion
def init(context: 'Context') -> List[Awaitable['User']]:
def init(context: 'Context') -> Iterable[Awaitable['User']]:
global config
config = context.config
users = [User.from_db(user) for user in DBUser.all()]
return [user.ensure_started() for user in users if user.tgid]
return (User.from_db(db_user).try_ensure_started()
for db_user in DBUser.all_with_tgid())
+2 -4
View File
@@ -1,7 +1,5 @@
from .file_transfer import transfer_file_to_matrix, convert_image
from .parallel_file_transfer import parallel_transfer_to_telegram
from .format_duration import format_duration
from .signed_token import sign_token, verify_token
from .recursive_dict import recursive_del, recursive_set, recursive_get
def ignore_coro(coro):
pass
from .color_log import ColorFormatter
+29
View File
@@ -0,0 +1,29 @@
# 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 mautrix.util.color_log import ColorFormatter as BaseColorFormatter, PREFIX, MXID_COLOR, RESET
TELETHON_COLOR = PREFIX + "35;1m" # magenta
TELETHON_MODULE_COLOR = PREFIX + "35m"
class ColorFormatter(BaseColorFormatter):
def _color_name(self, module: str) -> str:
if module.startswith("telethon"):
prefix, user_id, module = module.split(".", 2)
return (f"{TELETHON_COLOR}{prefix}{RESET}."
f"{MXID_COLOR}{user_id}{RESET}."
f"{TELETHON_MODULE_COLOR}{module}{RESET}")
return super()._color_name(module)
+60 -33
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -28,16 +27,20 @@ from telethon.tl.types import (Document, InputFileLocation, InputDocumentFileLoc
InputPeerPhotoFileLocation)
from telethon.errors import (AuthBytesInvalidError, AuthKeyInvalidError, LocationInvalidError,
SecurityError, FileIdInvalidError)
from mautrix_appservice import IntentAPI
from mautrix.appservice import IntentAPI
from ..tgclient import MautrixTelegramClient
from ..db import TelegramFile as DBTelegramFile
from ..util import sane_mimetypes
from .parallel_file_transfer import parallel_transfer_to_matrix
try:
from PIL import Image
except ImportError:
Image = None
try:
from moviepy.editor import VideoFileClip
import random
@@ -47,7 +50,9 @@ try:
except ImportError:
VideoFileClip = random = string = os = mimetypes = None
log = logging.getLogger("mau.util") # type: logging.Logger
from .tgs_converter import convert_tgs_to
log: logging.Logger = logging.getLogger("mau.util")
TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation,
InputFileLocation, InputPhotoFileLocation]
@@ -59,7 +64,7 @@ def convert_image(file: bytes, source_mime: str = "image/webp", target_type: str
if not Image:
return source_mime, file, None, None
try:
image = Image.open(BytesIO(file)).convert("RGBA") # type: Image.Image
image: Image.Image = Image.open(BytesIO(file)).convert("RGBA")
if thumbnail_to:
image.thumbnail(thumbnail_to, Image.ANTIALIAS)
new_file = BytesIO()
@@ -102,8 +107,10 @@ def _read_video_thumbnail(data: bytes, video_ext: str = "mp4", frame_ext: str =
def _location_to_id(location: TypeLocation) -> str:
if isinstance(location, (Document, InputDocumentFileLocation, InputPhotoFileLocation)):
if isinstance(location, Document):
return f"{location.id}-{location.access_hash}"
elif isinstance(location, (InputDocumentFileLocation, InputPhotoFileLocation)):
return f"{location.id}-{location.access_hash}-{location.thumb_size}"
elif isinstance(location, (InputFileLocation, InputPeerPhotoFileLocation)):
return f"{location.volume_id}-{location.local_id}"
@@ -123,7 +130,7 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
return db_file
video_ext = sane_mimetypes.guess_extension(mime)
if VideoFileClip and video_ext:
if VideoFileClip and video_ext and video:
try:
file, width, height = _read_video_thumbnail(video, video_ext, frame_ext="png")
except OSError:
@@ -134,7 +141,7 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
width, height = None, None
mime_type = magic.from_buffer(file, mime=True)
content_uri = await intent.upload_file(file, mime_type)
content_uri = await intent.upload_media(file, mime_type)
db_file = DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
was_converted=False, timestamp=int(time.time()), size=len(file),
@@ -148,14 +155,16 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
return db_file
transfer_locks = {} # type: Dict[str, asyncio.Lock]
transfer_locks: Dict[str, asyncio.Lock] = {}
TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]]
async def transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
location: TypeLocation, thumbnail: TypeThumbnail = None,
is_sticker: bool = False) -> Optional[DBTelegramFile]:
is_sticker: bool = False, tgs_convert: Optional[dict] = None,
filename: Optional[str] = None, parallel_id: Optional[int] = None
) -> Optional[DBTelegramFile]:
location_id = _location_to_id(location)
if not location_id:
return None
@@ -171,43 +180,61 @@ async def transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentA
transfer_locks[location_id] = lock
async with lock:
return await _unlocked_transfer_file_to_matrix(client, intent, location_id, location,
thumbnail, is_sticker)
thumbnail, is_sticker, tgs_convert,
filename, parallel_id)
async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
loc_id: str, location: TypeLocation,
thumbnail: TypeThumbnail, is_sticker: bool
thumbnail: TypeThumbnail, is_sticker: bool,
tgs_convert: Optional[dict], filename: Optional[str],
parallel_id: Optional[int]
) -> Optional[DBTelegramFile]:
db_file = DBTelegramFile.get(loc_id)
if db_file:
return db_file
try:
file = await client.download_file(location)
except (LocationInvalidError, FileIdInvalidError):
return None
except (AuthBytesInvalidError, AuthKeyInvalidError, SecurityError) as e:
log.exception(f"{e.__class__.__name__} while downloading a file.")
return None
if parallel_id and isinstance(location, Document) and (not is_sticker or not tgs_convert):
db_file = await parallel_transfer_to_matrix(client, intent, loc_id, location, filename,
parallel_id)
mime_type = location.mime_type
file = None
else:
try:
file = await client.download_file(location)
except (LocationInvalidError, FileIdInvalidError):
return None
except (AuthBytesInvalidError, AuthKeyInvalidError, SecurityError) as e:
log.exception(f"{e.__class__.__name__} while downloading a file.")
return None
width, height = None, None
mime_type = magic.from_buffer(file, mime=True)
width, height = None, None
mime_type = magic.from_buffer(file, mime=True)
image_converted = False
if mime_type == "image/webp":
new_mime_type, file, width, height = convert_image(
file, source_mime="image/webp", target_type="png",
thumbnail_to=(256, 256) if is_sticker else None)
image_converted = new_mime_type != mime_type
mime_type = new_mime_type
thumbnail = None
image_converted = False
# A weird bug in alpine/magic makes it return application/octet-stream for gzips...
if is_sticker and tgs_convert and (mime_type == "application/gzip" or (
mime_type == "application/octet-stream"
and magic.from_buffer(file).startswith("gzip"))):
mime_type, file, width, height = await convert_tgs_to(
file, tgs_convert["target"], **tgs_convert["args"])
thumbnail = None
image_converted = mime_type != "application/gzip"
content_uri = await intent.upload_file(file, mime_type)
if mime_type == "image/webp":
new_mime_type, file, width, height = convert_image(
file, source_mime="image/webp", target_type="png",
thumbnail_to=(256, 256) if is_sticker else None)
image_converted = new_mime_type != mime_type
mime_type = new_mime_type
thumbnail = None
db_file = DBTelegramFile(id=loc_id, mxc=content_uri,
mime_type=mime_type, was_converted=image_converted,
timestamp=int(time.time()), size=len(file),
width=width, height=height)
content_uri = await intent.upload_media(file, mime_type)
db_file = DBTelegramFile(id=loc_id, mxc=content_uri,
mime_type=mime_type, was_converted=image_converted,
timestamp=int(time.time()), size=len(file),
width=width, height=height)
if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"):
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
thumbnail = thumbnail.location
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -0,0 +1,298 @@
# 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, List, AsyncGenerator, Union, Awaitable, DefaultDict, Tuple
from collections import defaultdict
import hashlib
import asyncio
import logging
import time
import math
from aiohttp import ClientResponse
from telethon.tl.types import (Document, InputFileLocation, InputDocumentFileLocation,
InputPhotoFileLocation, InputPeerPhotoFileLocation, TypeInputFile,
InputFileBig, InputFile)
from telethon.tl.functions.auth import ExportAuthorizationRequest, ImportAuthorizationRequest
from telethon.tl.functions.upload import (GetFileRequest, SaveFilePartRequest,
SaveBigFilePartRequest)
from telethon.network import MTProtoSender
from telethon.crypto import AuthKey
from telethon import utils, helpers
from mautrix.appservice import IntentAPI
from mautrix.types import ContentURI
from ..tgclient import MautrixTelegramClient
from ..db import TelegramFile as DBTelegramFile
log: logging.Logger = logging.getLogger("mau.util")
TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation,
InputFileLocation, InputPhotoFileLocation]
class DownloadSender:
sender: MTProtoSender
request: GetFileRequest
remaining: int
stride: int
def __init__(self, sender: MTProtoSender, file: TypeLocation, offset: int, limit: int,
stride: int, count: int) -> None:
self.sender = sender
self.request = GetFileRequest(file, offset=offset, limit=limit)
self.stride = stride
self.remaining = count
async def next(self) -> Optional[bytes]:
if not self.remaining:
return None
result = await self.sender.send(self.request)
self.remaining -= 1
self.request.offset += self.stride
return result.bytes
def disconnect(self) -> Awaitable[None]:
return self.sender.disconnect()
class UploadSender:
sender: MTProtoSender
request: Union[SaveFilePartRequest, SaveBigFilePartRequest]
part_count: int
stride: int
previous: Optional[asyncio.Task]
loop: asyncio.AbstractEventLoop
def __init__(self, sender: MTProtoSender, file_id: int, part_count: int, big: bool, index: int,
stride: int, loop: asyncio.AbstractEventLoop) -> None:
self.sender = sender
self.part_count = part_count
if big:
self.request = SaveBigFilePartRequest(file_id, index, part_count, b"")
else:
self.request = SaveFilePartRequest(file_id, index, b"")
self.stride = stride
self.previous = None
self.loop = loop
async def next(self, data: bytes) -> None:
if self.previous:
await self.previous
self.previous = self.loop.create_task(self._next(data))
async def _next(self, data: bytes) -> None:
self.request.bytes = data
log.debug(f"Sending file part {self.request.file_part}/{self.part_count}"
f" with {len(data)} bytes")
await self.sender.send(self.request)
self.request.file_part += self.stride
async def disconnect(self) -> None:
if self.previous:
await self.previous
return await self.sender.disconnect()
class ParallelTransferrer:
client: MautrixTelegramClient
loop: asyncio.AbstractEventLoop
dc_id: int
senders: Optional[List[Union[DownloadSender, UploadSender]]]
auth_key: AuthKey
upload_ticker: int
def __init__(self, client: MautrixTelegramClient, dc_id: Optional[int] = None) -> None:
self.client = client
self.loop = self.client.loop
self.dc_id = dc_id or self.client.session.dc_id
self.auth_key = (None if dc_id and self.client.session.dc_id != dc_id
else self.client.session.auth_key)
self.senders = None
self.upload_ticker = 0
async def _cleanup(self) -> None:
await asyncio.gather(*[sender.disconnect() for sender in self.senders])
self.senders = None
@staticmethod
def _get_connection_count(file_size: int, max_count: int = 20,
full_size: int = 100 * 1024 * 1024) -> int:
if file_size > full_size:
return max_count
return math.ceil((file_size / full_size) * max_count)
async def _init_download(self, connections: int, file: TypeLocation, part_count: int,
part_size: int) -> None:
minimum, remainder = divmod(part_count, connections)
def get_part_count() -> int:
nonlocal remainder
if remainder > 0:
remainder -= 1
return minimum + 1
return minimum
# The first cross-DC sender will export+import the authorization, so we always create it
# before creating any other senders.
self.senders = [
await self._create_download_sender(file, 0, part_size, connections * part_size,
get_part_count()),
*await asyncio.gather(
*[self._create_download_sender(file, i, part_size, connections * part_size,
get_part_count())
for i in range(1, connections)])
]
async def _create_download_sender(self, file: TypeLocation, index: int, part_size: int,
stride: int,
part_count: int) -> DownloadSender:
return DownloadSender(await self._create_sender(), file, index * part_size, part_size,
stride, part_count)
async def _init_upload(self, connections: int, file_id: int, part_count: int, big: bool
) -> None:
self.senders = [
await self._create_upload_sender(file_id, part_count, big, 0, connections),
*await asyncio.gather(
*[self._create_upload_sender(file_id, part_count, big, i, connections)
for i in range(1, connections)])
]
async def _create_upload_sender(self, file_id: int, part_count: int, big: bool, index: int,
stride: int) -> UploadSender:
return UploadSender(await self._create_sender(), file_id, part_count, big, index, stride,
loop=self.loop)
async def _create_sender(self) -> MTProtoSender:
dc = await self.client._get_dc(self.dc_id)
sender = MTProtoSender(self.auth_key, self.loop, loggers=self.client._log)
await sender.connect(self.client._connection(dc.ip_address, dc.port, dc.id,
loop=self.loop, loggers=self.client._log,
proxy=self.client._proxy))
if not self.auth_key:
log.debug(f"Exporting auth to DC {self.dc_id}")
auth = await self.client(ExportAuthorizationRequest(self.dc_id))
req = self.client._init_with(ImportAuthorizationRequest(
id=auth.id, bytes=auth.bytes
))
await sender.send(req)
self.auth_key = sender.auth_key
return sender
async def init_upload(self, file_id: int, file_size: int, part_size_kb: Optional[float] = None,
connection_count: Optional[int] = None) -> Tuple[int, int, bool]:
connection_count = connection_count or self._get_connection_count(file_size)
part_size = (part_size_kb or utils.get_appropriated_part_size(file_size)) * 1024
part_count = (file_size + part_size - 1) // part_size
is_large = file_size > 10 * 1024 * 1024
await self._init_upload(connection_count, file_id, part_count, is_large)
return part_size, part_count, is_large
async def upload(self, part: bytes) -> None:
await self.senders[self.upload_ticker].next(part)
self.upload_ticker = (self.upload_ticker + 1) % len(self.senders)
async def finish_upload(self) -> None:
await self._cleanup()
async def download(self, file: TypeLocation, file_size: int,
part_size_kb: Optional[float] = None,
connection_count: Optional[int] = None) -> AsyncGenerator[bytes, None]:
connection_count = connection_count or self._get_connection_count(file_size)
part_size = (part_size_kb or utils.get_appropriated_part_size(file_size)) * 1024
part_count = math.ceil(file_size / part_size)
log.debug("Starting parallel download: "
f"{connection_count} {part_size} {part_count} {file!s}")
await self._init_download(connection_count, file, part_count, part_size)
part = 0
while part < part_count:
tasks = []
for sender in self.senders:
tasks.append(self.loop.create_task(sender.next()))
for task in tasks:
data = await task
if not data:
break
yield data
part += 1
log.debug(f"Part {part} downloaded")
log.debug("Parallel download finished, cleaning up connections")
await self._cleanup()
parallel_transfer_locks: DefaultDict[int, asyncio.Lock] = defaultdict(lambda: asyncio.Lock())
async def parallel_transfer_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
loc_id: str, location: TypeLocation, filename: str,
parallel_id: int) -> DBTelegramFile:
size = location.size
mime_type = location.mime_type
dc_id, location = utils.get_input_location(location)
# We lock the transfers because telegram has connection count limits
async with parallel_transfer_locks[parallel_id]:
downloader = ParallelTransferrer(client, dc_id)
content_uri = await intent.upload_media(downloader.download(location, size),
mime_type=mime_type, filename=filename, size=size)
return DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
was_converted=False, timestamp=int(time.time()), size=size,
width=None, height=None)
async def _internal_transfer_to_telegram(client: MautrixTelegramClient, response: ClientResponse
) -> Tuple[TypeInputFile, int]:
file_id = helpers.generate_random_long()
file_size = response.content_length
hash_md5 = hashlib.md5()
uploader = ParallelTransferrer(client)
part_size, part_count, is_large = await uploader.init_upload(file_id, file_size)
buffer = bytearray()
async for data in response.content:
if not is_large:
hash_md5.update(data)
if len(buffer) == 0 and len(data) == part_size:
await uploader.upload(data)
continue
new_len = len(buffer) + len(data)
if new_len >= part_size:
cutoff = part_size - len(buffer)
buffer.extend(data[:cutoff])
await uploader.upload(bytes(buffer))
buffer.clear()
buffer.extend(data[cutoff:])
else:
buffer.extend(data)
if len(buffer) > 0:
await uploader.upload(bytes(buffer))
await uploader.finish_upload()
if is_large:
return InputFileBig(file_id, part_count, "upload"), file_size
else:
return InputFile(file_id, part_count, "upload", hash_md5.hexdigest()), file_size
async def parallel_transfer_to_telegram(client: MautrixTelegramClient, intent: IntentAPI,
uri: ContentURI, parallel_id: int
) -> Tuple[TypeInputFile, int]:
url = intent.api.get_download_url(uri)
async with parallel_transfer_locks[parallel_id]:
async with intent.api.session.get(url) as response:
return await _internal_transfer_to_telegram(client, response)
+5 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -15,11 +14,12 @@
# 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, Any
from ..config import DictWithRecursion
from mautrix.util.config import RecursiveDict
def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool:
key, next_key = DictWithRecursion._parse_key(key)
key, next_key = RecursiveDict.parse_key(key)
if next_key is not None:
if key not in data:
data[key] = {}
@@ -32,7 +32,7 @@ def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool:
def recursive_get(data: Dict[str, Any], key: str) -> Any:
key, next_key = DictWithRecursion._parse_key(key)
key, next_key = RecursiveDict.parse_key(key)
if next_key is not None:
next_data = data.get(key, None)
if not next_data:
@@ -42,7 +42,7 @@ def recursive_get(data: Dict[str, Any], key: str) -> Any:
def recursive_del(data: Dict[str, any], key: str) -> bool:
key, next_key = DictWithRecursion._parse_key(key)
key, next_key = RecursiveDict.parse_key(key)
if next_key is not None:
if key not in data:
return False
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
-53
View File
@@ -1,53 +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, Optional
import json
import base64
import hashlib
def _get_checksum(key: str, payload: bytes) -> str:
hasher = hashlib.sha256()
hasher.update(payload)
hasher.update(key.encode("utf-8"))
checksum = hasher.hexdigest()
return checksum
def sign_token(key: str, payload: Dict) -> str:
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
checksum = _get_checksum(key, payload_b64)
return f"{checksum}:{payload_b64.decode('utf-8')}"
def verify_token(key: str, data: str) -> Optional[Dict]:
if not data:
return None
try:
checksum, payload = data.split(":", 1)
except ValueError:
return None
if checksum != _get_checksum(key, payload.encode("utf-8")):
return None
payload = base64.urlsafe_b64decode(payload).decode("utf-8")
try:
return json.loads(payload)
except json.JSONDecodeError:
return None
+112
View File
@@ -0,0 +1,112 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Telegram lottie sticker converter
# Copyright (C) 2019 Randall Eramde Lawrence
#
# 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, Callable, Awaitable, Optional, Tuple, Any
import asyncio.subprocess
import logging
import shutil
import os.path
import tempfile
log: logging.Logger = logging.getLogger("mau.util.tgs")
converters: Dict[str, Callable[[bytes, int, int, Any], Awaitable[Tuple[str, bytes]]]] = {}
def abswhich(program: Optional[str]) -> Optional[str]:
path = shutil.which(program)
return os.path.abspath(path) if path else None
lottieconverter = abswhich("lottieconverter")
ffmpeg = abswhich("ffmpeg")
if lottieconverter:
async def tgs_to_png(file: bytes, width: int, height: int, **_: Any) -> Tuple[str, bytes]:
frame = 1
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", "-", "png",
f"{width}x{height}", str(frame),
stdout=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate(file)
if proc.returncode == 0:
return "image/png", stdout
else:
log.error("lottieconverter error: " + (stderr.decode("utf-8") if stderr is not None
else f"unknown ({proc.returncode})"))
return "application/gzip", file
async def tgs_to_gif(file: bytes, width: int, height: int, background: str = "202020",
**_: Any) -> Tuple[str, bytes]:
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", "-", "gif",
f"{width}x{height}", f"0x{background}",
stdout=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate(file)
if proc.returncode == 0:
return "image/gif", stdout
else:
log.error("lottieconverter error: " + (stderr.decode("utf-8") if stderr is not None
else f"unknown ({proc.returncode})"))
return "application/gzip", file
converters["png"] = tgs_to_png
converters["gif"] = tgs_to_gif
if lottieconverter and ffmpeg:
async def tgs_to_webm(file: bytes, width: int, height: int, fps: int = 30,
**_: Any) -> Tuple[str, bytes]:
with tempfile.TemporaryDirectory(prefix="tgs_") as tmpdir:
file_template = tmpdir + "/out_"
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", file_template,
"pngs", f"{width}x{height}", str(fps),
stdout=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE)
_, stderr = await proc.communicate(file)
if proc.returncode == 0:
proc = await asyncio.create_subprocess_exec(ffmpeg, "-hide_banner", "-loglevel",
"error", "-framerate", str(fps),
"-pattern_type", "glob", "-i",
file_template + "*.png",
"-c:v", "libvpx-vp9", "-pix_fmt",
"yuva420p", "-f", "webm", "-",
stdout=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
return "video/webm", stdout
else:
log.error("ffmpeg error: " + (stderr.decode("utf-8") if stderr is not None
else f"unknown ({proc.returncode})"))
else:
log.error("lottieconverter error: " + (stderr.decode("utf-8") if stderr is not None
else f"unknown ({proc.returncode})"))
return "application/gzip", file
converters["webm"] = tgs_to_webm
async def convert_tgs_to(file: bytes, convert_to: str, width: int, height: int, **kwargs: Any
) -> Tuple[str, bytes, Optional[int], Optional[int]]:
if convert_to in converters:
converter = converters[convert_to]
mime, out = await converter(file, width, height, **kwargs)
return mime, out, width, height
elif convert_to != "disable":
log.warning(f"Unable to convert animated sticker, type {convert_to} not supported")
return "application/gzip", file, None, None
+1
View File
@@ -0,0 +1 @@
from .get_version import git_tag, git_revision, version, linkified_version
+21 -16
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,27 +13,30 @@
#
# 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 typing import Optional
from aiohttp import web
from abc import abstractmethod
import abc
import asyncio
import logging
from aiohttp import web
from telethon.errors import *
from mautrix.bridge import OnlyLoginSelf, InvalidAccessToken
from ...commands.telegram.auth import enter_password
from ...util import format_duration, ignore_coro
from ...puppet import Puppet, PuppetError
from ...util import format_duration
from ...puppet import Puppet
from ...user import User
class AuthAPI(abc.ABC):
log = logging.getLogger("mau.web.auth") # type: logging.Logger
log: logging.Logger = logging.getLogger("mau.web.auth")
loop: asyncio.AbstractEventLoop
def __init__(self, loop: asyncio.AbstractEventLoop):
self.loop = loop # type: asyncio.AbstractEventLoop
self.loop = loop
@abstractmethod
def get_login_response(self, status: int = 200, state: str = "", username: str = "",
@@ -56,15 +58,14 @@ class AuthAPI(abc.ABC):
error="You have already logged in with your Matrix "
"account.", errcode="already-logged-in")
resp = await puppet.switch_mxid(token.strip(), user.mxid)
if resp == PuppetError.OnlyLoginSelf:
try:
await puppet.switch_mxid(token.strip(), user.mxid)
except OnlyLoginSelf:
return self.get_mx_login_response(status=403, errcode="only-login-self",
error="You can only log in as your own Matrix user.")
elif resp == PuppetError.InvalidAccessToken:
except InvalidAccessToken:
return self.get_mx_login_response(status=401, errcode="invalid-access-token",
error="Failed to verify access token.")
assert resp == PuppetError.Success, "Encountered an unhandled PuppetError."
return self.get_mx_login_response(mxid=user.mxid, status=200, state="logged-in")
async def post_matrix_password(self, user: User, password: str) -> web.Response:
@@ -118,7 +119,7 @@ class AuthAPI(abc.ABC):
existing_user = User.get_by_tgid(user_info.id)
if existing_user and existing_user != user:
await existing_user.log_out()
ignore_coro(asyncio.ensure_future(user.post_login(user_info), loop=self.loop))
asyncio.ensure_future(user.post_login(user_info, first_login=True), loop=self.loop)
if user.command_status and user.command_status["action"] == "Login":
user.command_status = None
@@ -166,9 +167,13 @@ class AuthAPI(abc.ABC):
"next": enter_password,
"action": "Login (password entry)",
}
message = (
"Code accepted, but you have 2-factor "
"authentication enabled. Please enter your password."
)
return self.get_login_response(
mxid=user.mxid, state="password", status=202,
message="Code accepted, but you have 2-factor authentication is enabled.")
mxid=user.mxid, state="password", status=202, message=message
)
return None
except Exception:
self.log.exception("Error sending phone code")
+35 -42
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -14,20 +13,23 @@
#
# 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 aiohttp import web
from typing import Awaitable, Callable, Dict, Optional, Tuple, TYPE_CHECKING
import asyncio
import logging
import json
from aiohttp import web
from telethon.utils import get_peer_id, resolve_id
from telethon.tl.types import ChatForbidden, ChannelForbidden, TypeChat
from mautrix_appservice import AppService, MatrixRequestError, IntentError
from ...types import MatrixUserID, TelegramID
from mautrix.appservice import AppService
from mautrix.errors import MatrixRequestError, IntentError
from mautrix.types import UserID
from ...types import TelegramID
from ...user import User
from ...portal import Portal
from ...util import ignore_coro
from ...commands.portal.util import user_has_power_level, get_initial_state
from ..common import AuthAPI
@@ -36,16 +38,19 @@ if TYPE_CHECKING:
class ProvisioningAPI(AuthAPI):
log = logging.getLogger("mau.web.provisioning") # type: logging.Logger
log: logging.Logger = logging.getLogger("mau.web.provisioning")
secret: str
az: AppService
context: 'Context'
app: web.Application
def __init__(self, context: "Context") -> None:
super().__init__(context.loop)
self.secret = context.config["appservice.provisioning.shared_secret"] # type: str
self.az = context.az # type: AppService
self.context = context # type: Context
self.secret = context.config["appservice.provisioning.shared_secret"]
self.az = context.az
self.context = context
self.app = web.Application(loop=context.loop, middlewares=[self.error_middleware]
) # type: web.Application
self.app = web.Application(loop=context.loop, middlewares=[self.error_middleware])
portal_prefix = "/portal/{mxid:![^/]+}"
self.app.router.add_route("GET", f"{portal_prefix}", self.get_portal_by_mxid)
@@ -77,18 +82,7 @@ class ProvisioningAPI(AuthAPI):
if not portal:
return self.get_error_response(404, "portal_not_found",
"Portal with given Matrix ID not found.")
user, _ = await self.get_user(request.query.get("user_id", None), expect_logged_in=None,
require_puppeting=False)
return web.json_response({
"mxid": portal.mxid,
"chat_id": get_peer_id(portal.peer),
"peer_type": portal.peer_type,
"title": portal.title,
"about": portal.about,
"username": portal.username,
"megagroup": portal.megagroup,
"can_unbridge": (await portal.can_user_perform(user, "unbridge")) if user else False,
})
return await self._get_portal_response(UserID(request.query.get("user_id", "")), portal)
async def get_portal_by_tgid(self, request: web.Request) -> web.Response:
err = self.check_authorization(request)
@@ -104,8 +98,10 @@ class ProvisioningAPI(AuthAPI):
if not portal:
return self.get_error_response(404, "portal_not_found",
"Portal to given Telegram chat not found.")
user, _ = await self.get_user(request.query.get("user_id", None), expect_logged_in=None,
require_puppeting=False)
return await self._get_portal_response(UserID(request.query.get("user_id", "")), portal)
async def _get_portal_response(self, user_id: UserID, portal: Portal) -> web.Response:
user, _ = await self.get_user(user_id, expect_logged_in=None, require_puppeting=False)
return web.json_response({
"mxid": portal.mxid,
"chat_id": get_peer_id(portal.peer),
@@ -153,11 +149,9 @@ class ProvisioningAPI(AuthAPI):
force = request.query.get("force", None)
if force in ("delete", "unbridge"):
delete = force == "delete"
await portal.cleanup_room(portal.main_intent, portal.mxid, puppets_only=not delete,
message=("Portal deleted (moving to another room)"
if delete
else "Room unbridged (portal moving to another "
"room)"))
await portal.cleanup_portal("Portal deleted (moving to another room)" if delete
else "Room unbridged (portal moving to another room)",
puppets_only=not delete)
else:
return self.get_error_response(409, "chat_already_bridged",
"Telegram chat is already bridged to another "
@@ -169,7 +163,7 @@ class ProvisioningAPI(AuthAPI):
return self.get_login_response(status=403, errcode="not_logged_in",
error="You are not logged in and there is no relay bot.")
entity = None # type: Optional[TypeChat]
entity: Optional[TypeChat] = None
try:
entity = await acting_user.client.get_entity(portal.peer)
except Exception:
@@ -191,9 +185,8 @@ class ProvisioningAPI(AuthAPI):
portal.photo_id = ""
portal.save()
ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct,
levels=levels),
loop=self.loop))
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
loop=self.loop)
return web.Response(status=202, body="{}")
@@ -272,7 +265,8 @@ class ProvisioningAPI(AuthAPI):
require_puppeting=False, require_user=False)
if err is not None:
return err
elif user and not await user_has_power_level(portal.mxid, self.az.intent, user, "unbridge"):
elif user and not await user_has_power_level(portal.mxid, self.az.intent, user,
"unbridge"):
return self.get_error_response(403, "not_enough_permissions",
"You do not have the permissions to unbridge that room.")
@@ -287,7 +281,7 @@ class ProvisioningAPI(AuthAPI):
self.log.exception("Failed to disconnect chat")
return self.get_error_response(500, "exception", "Failed to disconnect chat")
else:
ignore_coro(asyncio.ensure_future(coro, loop=self.loop))
asyncio.ensure_future(coro, loop=self.loop)
return web.json_response({}, status=200 if sync else 202)
async def get_user_info(self, request: web.Request) -> web.Response:
@@ -320,11 +314,10 @@ class ProvisioningAPI(AuthAPI):
return err
if not user.is_bot:
chats = await user.get_dialogs()
return web.json_response([{
"id": get_peer_id(chat),
"title": chat.title,
} for chat in chats])
} async for chat in user.client.get_dialogs(ignore_migrated=True, archived=False)])
else:
return web.json_response([{
"id": get_peer_id(chat.peer),
@@ -365,7 +358,8 @@ class ProvisioningAPI(AuthAPI):
async def bridge_info(self, request: web.Request) -> web.Response:
return web.json_response({
"relaybot_username": self.context.bot.username if self.context.bot is not None else None,
"relaybot_username": (self.context.bot.username
if self.context.bot is not None else None),
}, status=200)
@staticmethod
@@ -431,7 +425,7 @@ class ProvisioningAPI(AuthAPI):
except json.JSONDecodeError:
return None
async def get_user(self, mxid: MatrixUserID, expect_logged_in: Optional[bool] = False,
async def get_user(self, mxid: Optional[UserID], expect_logged_in: Optional[bool] = False,
require_puppeting: bool = True, require_user: bool = True
) -> Tuple[Optional[User], Optional[web.Response]]:
if not mxid:
@@ -460,8 +454,7 @@ class ProvisioningAPI(AuthAPI):
expect_logged_in: Optional[bool] = False,
require_puppeting: bool = False,
want_data: bool = True,
) -> (Tuple[Optional[Dict],
Optional[User],
) -> (Tuple[Optional[Dict], Optional[User],
Optional[web.Response]]):
err = self.check_authorization(request)
if err is not None:
+18 -14
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
@@ -15,37 +14,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 Optional
from aiohttp import web
from mako.template import Template
import pkg_resources
import asyncio
import logging
import random
import string
import time
from ...types import MatrixUserID
from ...util import sign_token, verify_token
from mako.template import Template
from aiohttp import web
import pkg_resources
from mautrix.types import UserID
from mautrix.util.signed_token import sign_token, verify_token
from ...user import User
from ...puppet import Puppet
from ..common import AuthAPI
class PublicBridgeWebsite(AuthAPI):
log = logging.getLogger("mau.web.public") # type: logging.Logger
log: logging.Logger = logging.getLogger("mau.web.public")
secret_key: str
login: Template
mx_login: Template
app: web.Application
def __init__(self, loop: asyncio.AbstractEventLoop):
super().__init__(loop)
self.secret_key = "".join(
random.choice(string.ascii_lowercase + string.digits) for _ in range(64)) # type: str
self.secret_key = "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
self.login = Template(pkg_resources.resource_string(
"mautrix_telegram", "web/public/login.html.mako")) # type: Template
"mautrix_telegram", "web/public/login.html.mako"))
self.mx_login = Template(pkg_resources.resource_string(
"mautrix_telegram", "web/public/matrix-login.html.mako")) # type: Template
"mautrix_telegram", "web/public/matrix-login.html.mako"))
self.app = web.Application(loop=loop) # type: web.Application
self.app = web.Application(loop=loop)
self.app.router.add_route("GET", "/login", self.get_login)
self.app.router.add_route("POST", "/login", self.post_login)
self.app.router.add_route("GET", "/matrix-login", self.get_matrix_login)
@@ -60,11 +64,11 @@ class PublicBridgeWebsite(AuthAPI):
"expiry": int(time.time()) + expires_in,
})
def verify_token(self, token: str, endpoint: str = "/login") -> Optional[MatrixUserID]:
def verify_token(self, token: str, endpoint: str = "/login") -> Optional[UserID]:
token = verify_token(self.secret_key, token)
if token and (token.get("expiry", 0) > int(time.time()) and
token.get("endpoint", None) == endpoint):
return MatrixUserID(token.get("mxid", None))
return UserID(token.get("mxid", None))
return None
async def get_login(self, request: web.Request) -> web.Response:
+2 -1
View File
@@ -1,4 +1,5 @@
cryptg
Pillow
moviepy
prometheus-client
prometheus_client
psycopg2-binary
+1 -2
View File
@@ -1,10 +1,9 @@
aiohttp
mautrix-appservice
mautrix
ruamel.yaml
python-magic
SQLAlchemy
alembic
commonmark
future-fstrings
telethon
telethon-session-sqlalchemy
+22 -10
View File
@@ -1,12 +1,14 @@
import setuptools
import glob
import mautrix_telegram
from mautrix_telegram.get_version import git_tag, git_revision, version, linkified_version
extras = {
"fast_crypto": ["cryptg>=0.1,<0.3"],
"speedups": ["cryptg>=0.1,<0.3", "cchardet", "aiodns", "Brotli"],
"webp_convert": ["Pillow>=4.3.0,<7"],
"hq_thumbnails": ["moviepy>=1.0,<2.0"],
"metrics": ["prometheus-client>=0.6.0,<0.8.0"],
"metrics": ["prometheus_client>=0.6.0,<0.8.0"],
"postgres": ["psycopg2-binary>=2,<3"],
}
extras["all"] = list({dep for deps in extras.values() for dep in deps})
@@ -15,9 +17,18 @@ try:
except IOError:
long_desc = "Failed to read README.md"
with open("mautrix_telegram/version.py", "w") as version_file:
version_file.write(f"""# Generated in setup.py
git_tag = {git_tag!r}
git_revision = {git_revision!r}
version = {version!r}
linkified_version = {linkified_version!r}
""")
setuptools.setup(
name="mautrix-telegram",
version=mautrix_telegram.__version__,
version=version,
url="https://github.com/tulir/mautrix-telegram",
author="Tulir Asokan",
@@ -31,17 +42,17 @@ setuptools.setup(
install_requires=[
"aiohttp>=3.0.1,<4",
"mautrix-appservice>=0.3.11,<0.4.0",
"mautrix>=0.4.0rc5,<0.5",
"SQLAlchemy>=1.2.3,<2",
"alembic>=1.0.0,<2",
"commonmark>=0.8.1,<1",
"ruamel.yaml>=0.15.35,<0.16",
"future-fstrings>=0.4.2",
"commonmark>=0.8.1,<0.10",
"ruamel.yaml>=0.15.35,<0.17",
"python-magic>=0.4.15,<0.5",
"telethon>=1.9,<1.10",
"telethon>=1.10,<1.11",
"telethon-session-sqlalchemy>=0.2.14,<0.3",
],
extras_require=extras,
python_requires="~=3.6",
setup_requires=["pytest-runner"],
tests_require=["pytest", "pytest-asyncio", "pytest-mock"],
@@ -53,8 +64,9 @@ setuptools.setup(
"Framework :: AsyncIO",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
],
entry_points="""
[console_scripts]
+51 -48
View File
@@ -5,12 +5,14 @@ import pytest
from _pytest.fixtures import FixtureRequest
from pytest_mock import MockFixture
from mautrix.types import EventID, RoomID, UserID
import mautrix.bridge.commands.handler
import mautrix_telegram.commands.handler
from mautrix_telegram.commands.handler import (CommandEvent, CommandHandler, CommandProcessor,
HelpSection)
HelpSection, HelpCacheKey)
from mautrix_telegram.config import Config
from mautrix_telegram.context import Context
from mautrix_telegram.types import MatrixEventID, MatrixRoomID, MatrixUserID
import mautrix_telegram.user as u
from tests.utils.helpers import AsyncMock, list_true_once_each
@@ -45,9 +47,9 @@ class TestCommandEvent:
evt = CommandEvent(
processor=command_processor,
room=MatrixRoomID("#mock_room:example.org"),
event=MatrixEventID("$H45H:example.org"),
sender=u.User(MatrixUserID("@sender:example.org")),
room_id=RoomID("#mock_room:example.org"),
event_id=EventID("$H45H:example.org"),
sender=u.User(UserID("@sender:example.org")),
command="help",
args=[],
is_management=True,
@@ -61,7 +63,7 @@ class TestCommandEvent:
# html, no markdown
evt.reply(message, allow_html=True, render_markdown=False)
mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"),
RoomID("#mock_room:example.org"),
"**This** <i>was</i><br/><strong>all</strong>fun*!",
html="**This** <i>was</i><br/><strong>all</strong>fun*!\n",
)
@@ -69,7 +71,7 @@ class TestCommandEvent:
# html, markdown (default)
evt.reply(message, allow_html=True, render_markdown=True)
mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"),
RoomID("#mock_room:example.org"),
"**This** <i>was</i><br/><strong>all</strong>fun*!",
html=(
"<p><strong>This</strong> <i>was</i><br/>"
@@ -80,7 +82,7 @@ class TestCommandEvent:
# no html, no markdown
evt.reply(message, allow_html=False, render_markdown=False)
mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"),
RoomID("#mock_room:example.org"),
"**This** <i>was</i><br/><strong>all</strong>fun*!",
html=None,
)
@@ -88,7 +90,7 @@ class TestCommandEvent:
# no html, markdown
evt.reply(message, allow_html=False, render_markdown=True)
mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"),
RoomID("#mock_room:example.org"),
"**This** <i>was</i><br/><strong>all</strong>fun*!",
html="<p><strong>This</strong> &lt;i&gt;was&lt;/i&gt;&lt;br/&gt;"
"&lt;strong&gt;all&lt;/strong&gt;fun*!</p>\n"
@@ -100,9 +102,9 @@ class TestCommandEvent:
evt = CommandEvent(
processor=command_processor,
room=MatrixRoomID("#mock_room:example.org"),
event=MatrixEventID("$H45H:example.org"),
sender=u.User(MatrixUserID("@sender:example.org")),
room_id=RoomID("#mock_room:example.org"),
event_id=EventID("$H45H:example.org"),
sender=u.User(UserID("@sender:example.org")),
command="help",
args=[],
is_management=False,
@@ -115,7 +117,7 @@ class TestCommandEvent:
render_markdown=False)
mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"),
RoomID("#mock_room:example.org"),
"tg ....tg+sp...tg tg",
html=None,
)
@@ -126,9 +128,9 @@ class TestCommandEvent:
evt = CommandEvent(
processor=command_processor,
room=MatrixRoomID("#mock_room:example.org"),
event=MatrixEventID("$H45H:example.org"),
sender=u.User(MatrixUserID("@sender:example.org")),
room_id=RoomID("#mock_room:example.org"),
event_id=EventID("$H45H:example.org"),
sender=u.User(UserID("@sender:example.org")),
command="help",
args=[],
is_management=True,
@@ -144,7 +146,7 @@ class TestCommandEvent:
)
mock_az.intent.send_notice.assert_called_with(
MatrixRoomID("#mock_room:example.org"),
RoomID("#mock_room:example.org"),
"....tg+sp...tg tg",
html="<p>....tg+sp...tg tg</p>\n",
)
@@ -195,15 +197,15 @@ class TestCommandHandler:
help_section=HelpSection("Mock Section", 42, ""),
)
sender = u.User(MatrixUserID("@sender:example.org"))
sender = u.User(UserID("@sender:example.org"))
sender.puppet_whitelisted = False
sender.matrix_puppet_whitelisted = False
sender.is_admin = False
event = CommandEvent(
processor=command_processor,
room=MatrixRoomID("#mock_room:example.org"),
event=MatrixEventID("$H45H:example.org"),
room_id=RoomID("#mock_room:example.org"),
event_id=EventID("$H45H:example.org"),
sender=sender,
command=command,
args=[],
@@ -212,7 +214,8 @@ class TestCommandHandler:
)
assert await command_handler.get_permission_error(event)
assert not command_handler.has_permission(False, False, False, False, False)
assert not command_handler.has_permission(
HelpCacheKey(False, False, False, False, False, False))
@pytest.mark.parametrize(
(
@@ -255,7 +258,7 @@ class TestCommandHandler:
help_section=HelpSection("Mock Section", 42, ""),
)
sender = u.User(MatrixUserID("@sender:example.org"))
sender = u.User(UserID("@sender:example.org"))
sender.puppet_whitelisted = puppet_whitelisted
sender.matrix_puppet_whitelisted = matrix_puppet_whitelisted
sender.is_admin = is_admin
@@ -263,8 +266,8 @@ class TestCommandHandler:
event = CommandEvent(
processor=command_processor,
room=MatrixRoomID("#mock_room:example.org"),
event=MatrixEventID("$H45H:example.org"),
room_id=RoomID("#mock_room:example.org"),
event_id=EventID("$H45H:example.org"),
sender=sender,
command=command,
args=[],
@@ -274,12 +277,12 @@ class TestCommandHandler:
assert not await command_handler.get_permission_error(event)
assert command_handler.has_permission(
is_management=is_management,
puppet_whitelisted=puppet_whitelisted,
matrix_puppet_whitelisted=matrix_puppet_whitelisted,
is_admin=is_admin,
is_logged_in=is_logged_in,
)
HelpCacheKey(is_management=is_management,
puppet_whitelisted=puppet_whitelisted,
matrix_puppet_whitelisted=matrix_puppet_whitelisted,
is_admin=is_admin,
is_logged_in=is_logged_in,
is_portal=boolean))
class TestCommandProcessor:
@@ -292,41 +295,41 @@ class TestCommandProcessor:
mocker: MockFixture) -> None:
mocker.patch('mautrix_telegram.user.config', self.config)
mocker.patch(
'mautrix_telegram.commands.handler.command_handlers',
'mautrix.bridge.commands.handler.command_handlers',
{"help": AsyncMock(), "unknown-command": AsyncMock()}
)
sender = u.User(MatrixUserID("@sender:example.org"))
sender = u.User(UserID("@sender:example.org"))
result = await command_processor.handle(
room=MatrixRoomID("#mock_room:example.org"),
event_id=MatrixEventID("$H45H:example.org"),
room_id=RoomID("#mock_room:example.org"),
event_id=EventID("$H45H:example.org"),
sender=sender,
command="hElp",
args=[],
is_management=boolean2[0],
is_portal=boolean2[1],
)
is_portal=boolean2[1])
assert result is None
command_handlers = mautrix_telegram.commands.handler.command_handlers
command_handlers = mautrix.bridge.commands.handler.command_handlers
command_handlers["help"].mock.assert_called_once() # type: ignore
@pytest.mark.asyncio
async def test_handle_unknown_command(self, command_processor: CommandProcessor,
boolean2: Tuple[bool, bool], mocker: MockFixture) -> None:
boolean2: Tuple[bool, bool],
mocker: MockFixture) -> None:
mocker.patch('mautrix_telegram.user.config', self.config)
mocker.patch(
'mautrix_telegram.commands.handler.command_handlers',
'mautrix.bridge.commands.handler.command_handlers',
{"help": AsyncMock(), "unknown-command": AsyncMock()}
)
sender = u.User(MatrixUserID("@sender:example.org"))
sender = u.User(UserID("@sender:example.org"))
sender.command_status = {}
result = await command_processor.handle(
room=MatrixRoomID("#mock_room:example.org"),
event_id=MatrixEventID("$H45H:example.org"),
room_id=RoomID("#mock_room:example.org"),
event_id=EventID("$H45H:example.org"),
sender=sender,
command="foo",
args=[],
@@ -335,7 +338,7 @@ class TestCommandProcessor:
)
assert result is None
command_handlers = mautrix_telegram.commands.handler.command_handlers
command_handlers = mautrix.bridge.commands.handler.command_handlers
command_handlers["help"].mock.assert_not_called() # type: ignore
command_handlers["unknown-command"].mock.assert_called_once() # type: ignore
@@ -345,16 +348,16 @@ class TestCommandProcessor:
mocker: MockFixture) -> None:
mocker.patch('mautrix_telegram.user.config', self.config)
mocker.patch(
'mautrix_telegram.commands.handler.command_handlers',
'mautrix.bridge.commands.handler.command_handlers',
{"help": AsyncMock(), "unknown-command": AsyncMock()}
)
sender = u.User(MatrixUserID("@sender:example.org"))
sender = u.User(UserID("@sender:example.org"))
sender.command_status = {"foo": AsyncMock(), "next": AsyncMock()}
result = await command_processor.handle(
room=MatrixRoomID("#mock_room:example.org"),
event_id=MatrixEventID("$H45H:example.org"),
room_id=RoomID("#mock_room:example.org"),
event_id=EventID("$H45H:example.org"),
sender=sender, # u.User
command="foo",
args=[],
@@ -363,7 +366,7 @@ class TestCommandProcessor:
)
assert result is None
command_handlers = mautrix_telegram.commands.handler.command_handlers
command_handlers = mautrix.bridge.commands.handler.command_handlers
command_handlers["help"].mock.assert_not_called() # type: ignore
command_handlers["unknown-command"].mock.assert_not_called() # type: ignore
sender.command_status["foo"].mock.assert_not_called() # type: ignore