Compare commits

..

353 Commits

Author SHA1 Message Date
Tulir Asokan 792ed007b5 Bump version to 0.7.0rc1 2019-11-30 16:26:19 +02:00
Tulir Asokan 539c2338fc Fix typo in lowercase sql func 2019-11-30 16:18:25 +02:00
Tulir Asokan 792694b2d9 Fix typo 2019-11-30 16:09:20 +02:00
Tulir Asokan 8e20d56091 Handle IndexError in !tg set-pl 2019-11-30 16:08:35 +02:00
Tulir Asokan 1986142db3 Remove alias when cleaning up room 2019-11-30 16:01:07 +02:00
Tulir Asokan c52df5dc36 Fix Matrix event handle time metrics (ref #120) 2019-11-30 15:41:47 +02:00
Tulir Asokan 617d44ed75 Unbridge if bridge bot is kicked or banned. Fixes #312 2019-11-30 15:36:58 +02:00
Tulir Asokan 91e6a73f33 Fix incorrectly case sensitive username finding in db. Fixes #384 2019-11-30 15:21:47 +02:00
Tulir Asokan 25d7087d07 Fix bot-received messages not being handled. Fixes #341 2019-11-30 15:17:01 +02:00
Tulir Asokan f72267e81d Possibly fix private chats with bot accounts 2019-11-28 10:31:18 +02:00
Tulir Asokan ab3b0f3c3c Update minimum mautrix-python version 2019-11-21 23:40:49 +02:00
Tulir Asokan 883c4dcf19 Include server name when joining upgraded room 2019-11-21 23:12:11 +02:00
Tulir Asokan a5aa73dea6 Fix Telegram location message handling 2019-11-12 18:18:08 +02:00
Tulir Asokan ed90c2667a Only apply relaybot.group_chat_invite to chats with relaybot 2019-11-08 20:07:20 +02:00
Tulir Asokan 87d9477bc7 Merge pull request #378 from anoadragon453/anoa/confusing_2fa
Make 2FA error message clearer
2019-11-08 15:18:09 +02:00
Andrew Morgan b854119445 Make 2FA error message clearer 2019-11-08 11:51:40 +00:00
Tulir Asokan 0e56ab131e Fix relaybot message formatting 2019-11-07 22:53:45 +02:00
Tulir Asokan e319417fbc Use specific lottieconverter commit for building in dockerfile 2019-11-06 22:56:34 +02:00
Tulir Asokan 9e831689e9 Fix files over relaybot not having message format 2019-11-06 22:49:26 +02:00
Tulir Asokan 0a5f4e6551 Add option to invite specific users to all created group chat portals 2019-11-06 22:37:48 +02:00
Tulir Asokan aaf158cc29 Fix loading TelegramFile thumbnails from the db 2019-11-06 22:37:25 +02:00
Tulir Asokan 2c2dd37275 Change example homeserver values to example.com 2019-11-06 22:36:55 +02:00
Tulir Asokan 4d4a3b6bf6 Fix mistake preventing portal creation 2019-11-05 13:31:53 +02:00
Tulir Asokan b6b1d72ecb Add config option to override default power levels in rooms 2019-10-31 00:51:31 +02:00
Tulir Asokan 6fa44ce5e9 Merge branch 'helm' 2019-10-31 00:06:51 +02:00
Tulir Asokan 90e7a303ab Fix error on private chat portal create if relaybot is not configured 2019-10-29 15:48:49 +02:00
Tulir Asokan 54256be459 Disable bridging pin sender for now as it seems unreliable 2019-10-28 01:22:08 +02:00
Tulir Asokan 1c662c55cc Ignore telegram updates in blacklisted chats 2019-10-28 01:21:36 +02:00
Tulir Asokan abd1adaabf Add logging to find potential peer type mistakes 2019-10-28 01:10:36 +02:00
Tulir Asokan 5411de90fc Update some things 2019-10-28 01:09:37 +02:00
Tulir Asokan f9a692b5ef Telegram now allows custom contact names without knowing the phone number, so stop trusting those names 2019-10-27 18:08:19 +02:00
Tulir Asokan 9205ef8024 Revert "Changed converter build conf"
This reverts commit ef3a60397f.
2019-10-27 16:13:19 +02:00
Tulir Asokan 4260afaa7e Show exit code when lottieconverter errors 2019-10-27 16:13:13 +02:00
Lawrence ef3a60397f Changed converter build conf 2019-10-27 15:53:01 +02:00
Tulir Asokan 8acc51116d Merge pull request #366 from Eramde/rlottie
TGS animation support
2019-10-27 15:41:53 +02:00
Tulir Asokan cbbc5e8500 Remove unused lottie2ffmpeg script 2019-10-27 15:40:35 +02:00
Tulir Asokan 0192fb8308 Fix minor things 2019-10-27 15:37:42 +02:00
Tulir Asokan 3841528f5a Merge branch 'master' into rlottie 2019-10-27 15:37:33 +02:00
Tulir Asokan 91c3825ae3 Bump minimum mautrix-python version 2019-10-27 14:12:16 +02:00
Tulir Asokan 8c26dd8382 Remove debug prints 2019-10-27 14:07:10 +02:00
Tulir Asokan 01b317484f Add support for formatted captions 2019-10-27 13:55:34 +02:00
Tulir Asokan 73a6ad2cf2 Add parallel file upload too 2019-10-27 02:43:29 +03:00
Tulir Asokan 574312d7c5 Add option for parallel streamed file transfer 2019-10-27 01:12:15 +03:00
Tulir Asokan 6cb8e007aa Don't assume peer type is chat anywhere. Fixes #304 2019-10-26 20:40:21 +03:00
Tulir Asokan 22f6a12842 Add command to set caption for telegram files 2019-10-26 19:28:53 +03:00
Tulir Asokan c15508150a Enable readiness/liveness probes 2019-10-23 23:34:01 +03:00
Tulir Asokan a0f12a2c48 Add postgres as an optional dependency 2019-10-23 22:53:48 +03:00
Tulir Asokan c919a1762b Remove unused parts 2019-10-22 02:10:03 +03:00
Tulir Asokan 6dc73bf710 Add somewhat functional helm chart 2019-10-22 02:08:18 +03:00
Andrew Morgan 623b802d56 Add missing space to clean up response 2019-10-17 16:03:34 +03:00
Randall Lawrence 0726289c7a Modified converters to support pngs option of lottieconverter
See https://github.com/Eramde/LottieConverter/commit/37e73d8dc15152e050288ea0a55541546dde84d1
2019-10-05 20:09:14 +03:00
Tulir Asokan d2edf12fdf Fix weird mime type bug in alpine/magic 2019-10-03 10:57:52 +03:00
Tulir Asokan 9694fb901a Add lottieconverter to docker image 2019-10-03 10:15:22 +03:00
Tulir Asokan a8982cf8c7 Remove extension from lottie2ffmpeg and fix crash when lottieconverter not present 2019-10-03 10:14:58 +03:00
Tulir Asokan f430ed7169 Remove slow python converters and use asyncio subprocess 2019-10-03 01:28:47 +03:00
Tulir Asokan 4f5a501be4 Merge branch 'master' into rlottie 2019-10-02 23:30:50 +03:00
Tulir Asokan 6c312efc9a Fix sending relaybot private chat message 2019-10-02 23:24:19 +03:00
Daniele Rogora 1b987be562 Fixes parentheses when checking for bots, which was causing AttributeError 2019-10-02 23:22:50 +03:00
Lawrence c84536fef7 Set licence header
Deleted autogenerated header and set licence
2019-09-29 22:43:32 +03:00
Lawrence 1044298d76 Update mautrix_telegram/portal/telegram.py
Co-Authored-By: Tulir Asokan <tulir@maunium.net>
2019-09-29 22:37:42 +03:00
Lawrence 4e971932d1 Merge branch 'master' into rlottie 2019-09-29 20:21:56 +03:00
Randall Lawrence 4834e2297a Forgot about db fetch... 2019-09-29 20:15:28 +03:00
Randall Lawrence 2a3f70eb4a Migrated to rlottie utility 2019-09-29 19:44:13 +03:00
Tulir Asokan ea633ce3f9 Set title of relaybot private chat portal to other user's displayname 2019-09-29 01:05:38 +03:00
Tulir Asokan f6b64126cf Add support for bridging or responding to private chats with relaybot 2019-09-29 00:47:28 +03:00
Randall Lawrence 9d3c15f284 Added info in example-config how to install library for lottie 2019-09-25 16:10:52 +03:00
Randall Lawrence 7d224ec5ac Switched to puppeter-lottie npm library 2019-09-25 15:34:34 +03:00
Randall Lawrence ed4e34b808 Changed to 30% frame in image convert 2019-09-25 13:31:56 +03:00
Randall Lawrence f5c008c1a7 Added parameter in config for selecting convert type 2019-09-25 13:09:21 +03:00
Randall Lawrence dc71f74c0c Changed default convert type and image size 2019-09-25 12:53:42 +03:00
Tulir Asokan d5470de8fd Bridge bans to Telegram. Fixes #303 2019-09-22 22:51:46 +03:00
Randall Lawrence dff5903c53 Forgot uncomment db fetch 2019-09-22 01:28:49 +03:00
Randall Lawrence fc241b1cdc Moved converters to other file, added methods for video and gif, which supports resize.
XXX: videos don't want to be played by riot, i don't know why...
2019-09-22 01:23:00 +03:00
Randall Lawrence 77ba732eec Added function to convert tgs to png.
XXX: there is the bug in tgs lib, it crashes on some tgs files.
Also cairo svg2png need to be called not from tgs.exporters because there is no option to set image size
2019-09-21 01:45:56 +03:00
Tulir Asokan 835175aa36 Add better m.emote format options for logged in users. Fixes #355 2019-09-10 23:12:54 +03:00
Tulir Asokan 2e2827717d Escape percent sign in alembic options. Fixes #362 2019-09-10 22:54:14 +03:00
Tulir Asokan 209f85c17e Fix pip extras in dockerfile 2019-09-08 13:07:55 +03:00
Tulir Asokan 37c373c51f Add aiohttp speedup libraries as extras 2019-09-08 13:03:16 +03:00
Tulir Asokan 62fe03e8c1 Move back to telethon releases instead of master branch 2019-09-08 12:55:57 +03:00
Tulir Asokan 427c28db7a Remove build deps from final docker image 2019-09-02 23:18:23 +03:00
Tulir Asokan 835b363661 Fix some problems with editing 2019-09-02 22:58:11 +03:00
Tulir Asokan df67ed57ee Don't crash bridge if startup of one user fails. Fixes #292 2019-09-02 22:52:44 +03:00
Tulir Asokan 43b3cc2ca4 Bump mautrix-python requirement 2019-09-02 22:25:42 +03:00
Tulir Asokan 3c2268870b Fix some potential exceptions when asyncio.gathering 2019-09-02 22:21:48 +03:00
Tulir Asokan fbb1267609 Start using new db base functions 2019-09-02 22:02:50 +03:00
Tulir Asokan 2c443a3b93 Add netcat-openbsd to dockerfile for manhole 2019-09-02 22:02:32 +03:00
Tulir Asokan 13fd8db0b7 Restore better reply fallback behavior to avoid mentions 2019-08-22 22:08:03 +03:00
Tulir Asokan cdee0df5ab Update some dependency versions 2019-08-19 21:42:31 +03:00
Tulir Asokan 9e418afe64 Bump minimum mautrix-python version 2019-08-19 21:38:54 +03:00
Tulir Asokan 7d43eb5d2e Add temporary fix for edits being echoed when using matrix puppeting 2019-08-17 18:27:26 +03:00
Tulir Asokan de4c16431d Handle RPCErrors in formatter and update mautrix-python 2019-08-17 13:43:10 +03:00
Tulir Asokan d3e6860b1c Fix sync-full command 2019-08-17 13:43:00 +03:00
Tulir Asokan 6bccf5595b Make custom puppet errors at startup non-fatal 2019-08-15 22:34:16 +03:00
Tulir Asokan 35023efbf2 Update mautrix-python to fix editing replies 2019-08-15 22:33:22 +03:00
Tulir Asokan d33460e3bd Bridge room meta to Matrix as correct ghost user if possible 2019-08-15 22:33:22 +03:00
Tulir Asokan eea059c0d3 Fix bridging room meta to Telegram 2019-08-15 22:33:22 +03:00
Tulir Asokan 2a327cc29e Handle update_info errors inside entity instead of in user 2019-08-13 14:44:24 +03:00
Tulir Asokan 1ac1bf5b60 Add missing return 2019-08-13 14:38:59 +03:00
Tulir Asokan ad5cace75b Fix small mistakes 2019-08-11 15:09:44 +03:00
Tulir Asokan bf49843721 Add support for whitelisting unix users who can connect to the manhole 2019-08-11 15:01:28 +03:00
Tulir Asokan 25d9e3b1ca Merge branch 'manhole' 2019-08-11 13:46:49 +03:00
Tulir Asokan dc07b2bdf4 Fix typo in dockerfile 2019-08-11 12:04:09 +03:00
Tulir Asokan 0093acb578 Move manhole state to main bridge object 2019-08-11 12:01:55 +03:00
Tulir Asokan b89ecf4c03 Add unix socket manhole to access bridge internals at runtime 2019-08-11 02:35:58 +03:00
Tulir Asokan 468412100c Remove broken catch_up option 2019-08-10 19:48:33 +03:00
Tulir Asokan ea7e4b277f Merge pull request #352 from tulir/mautrix-0.4
Move to mautrix-python
2019-08-10 16:21:11 +03:00
Tulir Asokan 60e35c1bb9 Add command to sync specific portal 2019-08-10 14:24:26 +03:00
Tulir Asokan 117bb5bd86 Fix cleaning up whitespace names 2019-08-10 13:53:30 +03:00
Tulir Asokan e8ba274776 Use unicodedata for cleaning up whitespace names 2019-08-10 13:23:44 +03:00
Tulir Asokan 76a1e20f13 Improve whitespace name cleanup 2019-08-10 13:17:15 +03:00
Tulir Asokan 8cab2fdcb6 Move alembic_version table existence check to mautrix-python 2019-08-09 23:30:25 +03:00
Tulir Asokan 354fcdc84b Switch to pypi dev release of mautrix-python and remove future-fstrings from requirements.txt 2019-08-09 23:16:15 +03:00
Tulir Asokan 99e26a5805 Fix warning log calls 2019-08-09 22:58:56 +03:00
Tulir Asokan d354d6e788 Add repr for formatter entities 2019-08-09 22:52:31 +03:00
Tulir Asokan 28bcf479f3 Merge remote-tracking branch 'Eramde/mtproxy' into mautrix-0.4 2019-08-09 19:38:23 +03:00
Tulir Asokan e3f8fc0e01 Ignore incoming messages in private chats sent by the receiver if no matrix puppeting 2019-08-09 00:42:55 +03:00
Tulir Asokan e8184f0248 Require telethon master branch 2019-08-08 23:20:08 +03:00
Tulir Asokan 937de0fa00 Reduce usage of regexes 2019-08-08 23:15:15 +03:00
Tulir Asokan ac24bc86a0 Minor improvements 2019-08-08 22:21:24 +03:00
Tulir Asokan 1338a43c03 Fix transferring documents into Matrix 2019-08-08 21:57:38 +03:00
Tulir Asokan 8889105d5a Add locking to client connect calls 2019-08-08 00:15:58 +03:00
Tulir Asokan 9cbe6b73fc Use ensure_joined for joining puppets 2019-08-07 23:35:53 +03:00
Tulir Asokan ff98fe38c2 Add improvements and logs 2019-08-07 20:38:22 +03:00
Tulir Asokan 9899c15d36 Handle potential error kicking user 2019-08-07 19:49:22 +03:00
Tulir Asokan 601b29c28b Fix redaction bridging 2019-08-07 19:49:09 +03:00
Tulir Asokan 76e16b365d Minor fixes 2019-08-07 19:22:36 +03:00
Tulir Asokan 1021e8bc00 Fix relaybot bridging media 2019-08-07 18:51:52 +03:00
Tulir Asokan 4f740fc9f8 Fix potential errors generating forward headers 2019-08-07 18:41:54 +03:00
Tulir Asokan 75fc5c6e1e Only include specific optionals in pip install in dockerfile 2019-08-07 00:10:02 +03:00
Tulir Asokan 47cf63e0e6 Add psycopg2 as an optional dependency and throw error at startup if db is not initialized 2019-08-07 00:00:59 +03:00
Tulir Asokan b4a1aacd12 Minor code cleanup and fix tests 2019-08-06 23:37:49 +03:00
Tulir Asokan ad499b977e Persist next_batch for custom puppets 2019-08-06 23:16:17 +03:00
Tulir Asokan b5c55f4e65 Remove debug log 2019-08-06 22:11:55 +03:00
Tulir Asokan 65b69829d7 Fix document bridging 2019-08-06 21:55:52 +03:00
Tulir Asokan cf6eb604bd Make displayname max length configurable 2019-08-06 21:37:49 +03:00
Tulir Asokan 8655f5903a Improve things 2019-08-06 21:30:14 +03:00
Tulir Asokan 45f1dddb81 Move stopping into prepare_stop and stop custom puppet syncs 2019-08-06 20:09:29 +03:00
Tulir Asokan 299d20aac9 Remove portal_ prefix from files in portal directory 2019-08-06 20:01:32 +03:00
Tulir Asokan 43d16474c2 Improve logging and make get_dialogs use iterators more 2019-08-06 19:58:15 +03:00
Tulir Asokan ee08458df1 Actually fix image bridging 2019-08-06 14:42:16 +03:00
Tulir Asokan c80958a776 Maybe actually fix image bridging 2019-08-06 14:36:53 +03:00
Tulir Asokan 13d8a8420a Fix more bugs 2019-08-06 14:33:57 +03:00
Lawrence 01a58ad2ed Minor change
changed _proxy_settings return value(s)
2019-08-06 14:33:50 +03:00
Tulir Asokan a4e66e708a Fix bridging images again 2019-08-06 14:29:45 +03:00
Tulir Asokan 66e0698d2f Fix minor bugs 2019-08-06 14:20:48 +03:00
Tulir Asokan 935694cb64 Fix dockerfile 2019-08-06 02:20:23 +03:00
Tulir Asokan e2404f919e Install mautrix-python@bridge-updates in dockerfile 2019-08-06 02:08:03 +03:00
Tulir Asokan c9810dd9eb Fix example log config 2019-08-06 02:03:59 +03:00
Tulir Asokan 6bfd3eada4 Use mautrix-python bridge-updates branch in requirements.txt 2019-08-06 01:47:30 +03:00
Tulir Asokan 6852bae7f9 Move more things to use telethon methods 2019-08-06 01:46:22 +03:00
Tulir Asokan 8536bdd614 Use telethon's send_read_acknowledge instead of raw methods 2019-08-06 01:19:21 +03:00
Tulir Asokan bd13c73f2f Fix bugs 2019-08-06 01:13:27 +03:00
Tulir Asokan 2a9ab569b4 Only load users with a tgid at startup 2019-08-06 00:59:28 +03:00
Tulir Asokan d6ebce0425 Make it work 2019-08-06 00:51:27 +03:00
Tulir Asokan 3af306abe0 Even^4 more migrations to mautrix-python 2019-08-06 00:23:41 +03:00
Tulir Asokan 30563f3648 Even even even more migrations to mautrix-python 2019-08-05 22:10:43 +03:00
Tulir Asokan d6a2e7a9f7 Split portal.py and migrate more stuff to mautrix-0.4 2019-08-05 00:11:21 +03:00
Tulir Asokan 32d686e908 Migrate formatter and utils to mautrix-python 2019-08-04 15:20:14 +03:00
Tulir Asokan 05f906427e Fix command handler return hints 2019-08-04 01:51:13 +03:00
Tulir Asokan d8653961af Even even more migrations to mautrix-python 2019-08-04 01:41:10 +03:00
Tulir Asokan d521bbc0fa Merge branch 'master' into mautrix-0.4 2019-08-03 21:22:38 +03:00
Tulir Asokan 281f7203dc Filter non-nice whitespace out of displaynames 2019-07-29 20:11:35 +03:00
Tulir Asokan dd683af5f5 Add hacky fix for edit reply fallbacks 2019-07-29 19:59:34 +03:00
Tulir Asokan 9a5506d901 Include non-master branches in docker image tagging 2019-07-26 22:13:08 +03:00
Tulir Asokan 5fc2907392 Add .gitlab-ci.yml and badges to README 2019-07-26 22:06:55 +03:00
Tulir Asokan 1443082991 Change default port to 29317. Fixes #345 2019-07-24 02:12:12 +03:00
Tulir Asokan d4e3956941 Even more migrations to mautrix-python 2019-07-19 21:36:21 +03:00
Lawrence e3a457f84c Amend: Changed connection import 2019-07-19 14:41:05 +03:00
Randall Lawrence e40cd9f6a2 Changed connection import 2019-07-19 14:15:20 +03:00
Tulir Asokan eef498d47a More migrations to mautrix-python 2019-07-19 00:17:57 +03:00
Tulir Asokan 8d4a9dc231 Start migrating to mautrix-python 2019-07-18 23:24:25 +03:00
Tulir Asokan e0d3c940f8 Remove more Python 3.5 compatibility 2019-07-18 23:24:25 +03:00
Tulir Asokan be6d395ed6 Remove Python 3.5 compatibility 2019-07-18 23:24:25 +03:00
Tulir Asokan 87aa0b6659 Limit displaynames sent to Matrix to 100 characters 2019-07-18 23:23:15 +03:00
Tulir Asokan bb167b14ef Add/remove reply fallbacks in m.new_content 2019-07-18 23:22:57 +03:00
Sergey Blazhko 351866d9e4 Added option to connect via MTProxy. Proxy secret should be set in proxy.password config parameter. 2019-07-18 12:33:38 +03:00
Tulir Asokan 9a8f8433b0 Bump version to 0.6.0 2019-07-09 19:43:56 +03:00
Tulir Asokan 4942789213 Fix vulnerability in event handling 2019-07-09 19:43:37 +03:00
Tulir Asokan 0741265837 Bump version to 0.6.0rc2 2019-07-06 21:03:59 +03:00
Tulir Asokan 06d4e1703e Restore old blockquote behavior in formatter as telegram's blockquotes don't work yet 2019-07-06 20:53:37 +03:00
Tulir Asokan 41be2a7b78 Merge branch 'native-strike-underline' 2019-07-06 20:50:07 +03:00
Tulir Asokan 610d12283d Update telethon 2019-07-06 20:49:32 +03:00
Tulir Asokan fee8da1613 Fix handling unsupported media 2019-07-06 17:57:28 +03:00
Tulir Asokan 28bed96e40 Fix displayname not updating for some users
Users who the bridge only saw via logged in users with the target user
in their contact lists wouldn't get their displayname updated due to an
invalid condition in the update_displayname function.
2019-07-04 22:32:30 +03:00
Tulir Asokan 050800f5f7 Add missing escape 2019-06-30 19:16:24 +03:00
Tulir Asokan 21fe94b38c Add support for nested formatting coming from Telegram 2019-06-30 19:16:24 +03:00
Tulir Asokan ce639c12d8 Use native strikethrough/underline/blockquote on Telegram 2019-06-30 19:16:24 +03:00
Tulir Asokan 78dd4e0086 Ignore .bak files 2019-06-30 19:08:30 +03:00
Tulir Asokan 0f7eebd683 Add option to set related groups for created rooms 2019-06-30 19:05:17 +03:00
Tulir Asokan 860b635188 Handle FileIdInvalidError in file transfers 2019-06-30 17:30:52 +03:00
Tulir Asokan 0710b4e8a1 Fix metrics config comment 2019-06-22 20:01:22 +03:00
Tulir Asokan 823abc121e Update docker image to Alpine 3.10 and add libffi-dev 2019-06-22 19:16:14 +03:00
Tulir Asokan 3fa6128561 Bump version to 0.6.0rc1 2019-06-22 18:56:14 +03:00
Tulir Asokan ca00e53a40 Update state cache when sending state events (e.g. kicks). Fixes #278 2019-06-20 23:31:32 +03:00
Tulir Asokan 0003d2efd3 Add secret flag for logged in admins to use relaybot when plumbing rooms. Fixes #294 2019-06-20 22:57:47 +03:00
Tulir Asokan 0efe9f05f2 Add option for maximum document size that gets bridged. Fixes #335 2019-06-20 22:41:51 +03:00
Tulir Asokan 88d0c5feb3 Re-add warning about catch_up 2019-06-20 22:23:51 +03:00
Tulir Asokan 912aa38063 Make mime type extension guessing saner 2019-06-20 21:56:35 +03:00
Tulir Asokan 5fba658c66 Update to telethon 1.8. Fixes #334 2019-06-20 21:42:22 +03:00
Tulir Asokan 070601689a Include relaybot pill in !tg create invite suggestion 2019-06-10 00:49:10 +03:00
Tulir Asokan bde177fc34 Fix env config overrides. Fixes #333 2019-06-07 21:30:06 +03:00
Tulir Asokan a593f71901 Merge pull request #332 from pacien/env-override
Allow config key override through env var
2019-06-07 17:10:10 +03:00
pacien 107fc501e4 Allow config key override through env var
Signed-off-by: pacien <pacien.trangirard@pacien.net>
2019-06-06 22:24:34 +02:00
Tulir Asokan cd51fb85cf Make getting started more user-friendly. Fixes #327 2019-06-01 22:38:43 +03:00
Tulir Asokan 9591a05361 Ignore whitespace in web login input 2019-06-01 22:15:49 +03:00
Tulir Asokan ddfffaf6a2 Handle some image send errors by resending as document. Fixes #324 2019-06-01 22:09:05 +03:00
Tulir Asokan baffe1b79e Revert "Add event/update counter to metrics"
This reverts commit 145eb8f611.
2019-06-01 21:18:06 +03:00
Tulir Asokan 145eb8f611 Add event/update counter to metrics 2019-06-01 21:10:01 +03:00
Tulir Asokan a279835cf8 HTML-escape names in telegram forward/reply header 2019-06-01 19:49:25 +03:00
Tulir Asokan 2dc04a8517 Add basic metrics with prometheus (ref #120) 2019-05-31 02:11:36 +03:00
Tulir Asokan 5c076933e7 Apparently session hashes can be negative integers too 2019-05-31 01:24:48 +03:00
Tulir Asokan 417c2e4d1e Add build stuff to .gitignore 2019-05-31 01:18:11 +03:00
Tulir Asokan cbfb4d6d32 Add command to change displayname 2019-05-31 01:18:03 +03:00
Tulir Asokan 99ac768778 Fix relaybot edit deduplication in channels. Fixes #325 2019-05-31 00:30:55 +03:00
Tulir Asokan 7177d0c37e Fix editing messages that went through relaybot 2019-05-29 16:53:29 +03:00
Tulir Asokan ff257fcd77 Fix edit index upgrade on postgres 2019-05-29 16:37:13 +03:00
Tulir Asokan 47243334f4 Add native Matrix edit support
Warning: may break everything and/or edit your cat
2019-05-29 16:20:15 +03:00
Tulir Asokan 1693b643a7 Hacky fix for null m.relates_to's 2019-05-23 02:07:50 +03:00
Tulir Asokan 9790dff27e Use batch_alter_table when adding columns 2019-05-18 01:49:07 +03:00
Tulir Asokan ab1d65e6f0 Trim left spaces when parsing command. Fixes #322 2019-05-15 20:45:16 +03:00
Tulir Asokan 5bbadbbdc8 Fix typo 2019-05-15 20:16:04 +03:00
Tulir Asokan ce92cd31bf Fix updating user info from entities attached to updates
Also made it trust info from users who don't have the puppet's phone number.
2019-05-15 20:05:27 +03:00
Tulir Asokan 8689d0e8b0 Save peer type when upgrading
Might have been the cause of #304
2019-05-15 20:04:26 +03:00
Tulir Asokan f47e548b04 Bump minimum telethon-session-sqlalchemy version. Fixes #314 2019-05-15 15:29:54 +03:00
Tulir Asokan 6fef2a9a87 Update user info from entities attached to updates 2019-05-15 00:49:17 +03:00
Tulir Asokan bc3ceab039 Fix handling of null m.relates_to objects. Fixes #317 2019-05-11 21:55:30 +03:00
Tulir Asokan b9a0e6cbb6 Add external URL for chat and private channel messages. Fixes #308 2019-05-11 21:55:30 +03:00
Tulir Asokan c50fd4b3ac Fix mime type info for converted images. Fixes #307 2019-05-11 21:55:30 +03:00
Tulir Asokan 430f7b7217 Handle void tags correctly in the HTML parser. Fixes #309 2019-05-11 21:55:30 +03:00
Tulir Asokan 72a3cea948 Merge pull request #315 from t2bot/travis/fix-logout
Use empty collections when clearing portals/contacts instead of None
2019-05-07 02:06:15 +03:00
Tulir Asokan fce22b08e9 Check if bot is configured before trying to get username in bridge info provisioning API 2019-04-24 16:42:28 +03:00
Travis Ralston a2e64b4e0b Use empty collections when clearing portals/contacts instead of None
This avoids an error when logging out regarding "NoneType is not iterable".
2019-04-19 23:42:11 -06:00
Tulir Asokan 1df87447bd Set version to 0.6.0+dev 2019-04-08 00:41:01 +03:00
Tulir Asokan 75b2b3b163 Make retry_delay and other TelegramClient constructor fields configurable. Fixes #299 2019-04-03 16:20:19 +03:00
Tulir Asokan 80d90f93cd Fix newlines in unformatted messages going through relaybot. Fixes #306 2019-04-03 15:31:59 +03:00
Tulir Asokan e1ac4233c7 Add hidden way to clear vote and fix voting for first option 2019-04-03 15:26:30 +03:00
Tulir Asokan 46c3bbff3c Simplify voting in polls 2019-04-03 15:11:21 +03:00
Tulir Asokan 41b8292f25 Bump version to 0.5.1 2019-03-21 15:32:37 +02:00
Tulir Asokan 366b95c8e8 Fix Python 3.5 compatibility 2019-03-21 14:42:18 +02:00
Tulir Asokan fecf068455 Revert switching to @as_declarative for SQLAlchemy base class
This reverts commit 1da1133934 and a part of 2cf9dcafd9
2019-03-21 13:48:53 +02:00
Tulir Asokan 1da1133934 Fix reference to old BaseBase class in dbms migration script 2019-03-21 12:10:43 +02:00
Tulir Asokan c4ac84c1a1 Bump version to 0.5.0 2019-03-19 20:08:24 +02:00
Tulir Asokan 2cf9dcafd9 Update copyright year and fix minor lint problems 2019-03-19 18:30:36 +02:00
Tulir Asokan 784abcba4e Update native deps in dockerfile and increase minimum alchemysession version 2019-03-19 18:30:36 +02:00
Tulir Asokan aaa44fb7aa Update ROADMAP.md 2019-03-17 15:47:29 +02:00
Tulir Asokan f7a4a23045 Don't add reply fallback to caption when caption is separate event. Fixes #285 2019-03-16 21:59:37 +02:00
Tulir Asokan 7e3c892ff6 Stop using rawgit in public website. Fixes #289 2019-03-16 18:05:12 +02:00
Tulir Asokan 36a654bcfe Bump version to 0.5.0rc4 2019-03-16 17:36:25 +02:00
Tulir Asokan e16182ee6a Fix Context initialization in tests 2019-03-16 17:22:16 +02:00
Tulir Asokan 7c46bf4b9e Remove remaining traces of ORM 2019-03-16 17:13:28 +02:00
Tulir Asokan 7c82580b4b Merge pull request #290 from V02460/tests
Add pytest unit testing framework
2019-03-16 17:13:19 +02:00
Kai A. Hiller 1e1e9b03c0 Revert absolute imports back to relative 2019-03-14 10:33:43 +01:00
Tulir Asokan 0587145145 Always flush stdout when logging in db migrate script 2019-03-13 23:50:40 +02:00
Tulir Asokan 7840da94b5 Fix verbose flag in db migrate script 2019-03-13 23:41:44 +02:00
Tulir Asokan 010866e0d0 Add verbose option to db migration script 2019-03-13 23:28:31 +02:00
Tulir Asokan c54b057d90 Add __init__.py's so scripts would be included in builds 2019-03-13 23:28:31 +02:00
Tulir Asokan b55f3a9c4d Merge pull request #291 from t2bot/travis/error-reporting
Log startup exceptions
2019-03-10 13:08:48 +02:00
Travis Ralston aa09e738e6 Log startup exceptions 2019-03-09 20:19:15 -06:00
Kai A. Hiller 4254b85628 Add pytest unit testing framework 2019-03-08 19:11:02 +01:00
Tulir Asokan 7d5e946067 Fix potential errors caused by deleted portals when logging out (ref #286) 2019-03-02 04:09:39 +02:00
Tulir Asokan 9eda525d2a Fix handling missing argument in clear-db-cache (ref #286) 2019-03-02 04:09:23 +02:00
Tulir Asokan 8ef337f40b Remove lxml HTML parser as it was messing up emoji offset handling 2019-03-01 23:45:30 +02:00
Tulir Asokan f5ac584ed5 Escape HTML in displaynames before putting it in the relaybot format 2019-03-01 23:11:54 +02:00
Tulir Asokan a3534d802a Wrap database-changing statements in db.begin() 2019-02-24 02:53:50 +02:00
Tulir Asokan 92b689255b Bump minimum alchemysession version and fix migrate script imports 2019-02-20 01:46:24 +02:00
Tulir Asokan fb5167963a Fix repadding base64 2019-02-17 16:14:38 +02:00
Tulir Asokan 50ac4b6381 Handle cases where entity.default_banned_rights is None 2019-02-16 23:22:04 +02:00
Tulir Asokan d842fc73cb Handle AuthKeyError when terminating sessions 2019-02-16 23:21:47 +02:00
Tulir Asokan 531d118ed0 Fix saving new users to database. Actually fixes #284 2019-02-16 23:12:39 +02:00
Tulir Asokan cead705c21 Bump version to 0.5.0rc3 2019-02-16 20:04:40 +02:00
Tulir Asokan e5a2afee37 Improve Matrix representation of Telegram polls 2019-02-16 19:55:27 +02:00
Tulir Asokan f2efb235eb Add command to vote in polls. Fixes #257 2019-02-16 19:47:38 +02:00
Tulir Asokan ffc1a5ad8f Show Telegram polls in Matrix (no voting yet. ref #257) 2019-02-16 17:43:23 +02:00
Tulir Asokan 1c3764b099 Fix saving user portals and contacts. Fixes #284 2019-02-16 17:29:14 +02:00
Tulir Asokan 5af045844e Make max photo size before sending as file configurable. Fixes #141 2019-02-16 17:14:02 +02:00
Tulir Asokan be255ec7af Fix bridging large images to Telegram 2019-02-16 17:08:07 +02:00
Tulir Asokan 7f7dec4e80 Fix bridging documents without thumbnails to Matrix 2019-02-16 17:07:58 +02:00
Tulir Asokan 8a6687d00c Use uvloop if installed 2019-02-16 17:07:19 +02:00
Tulir Asokan 1b719027e6 Bump version to 0.5.0rc2 2019-02-15 18:38:07 +02:00
Tulir Asokan d661f7b798 Bump minimum telethon-session-sqlalchemy to avoid SQL errors 2019-02-15 18:38:00 +02:00
Tulir Asokan e437869c13 Handle telegram chat upgrades in relaybot. Fixes #283 2019-02-15 18:35:31 +02:00
Tulir Asokan c979de9387 Fix creating base power levels for private chats. Fixes #282 2019-02-15 18:29:05 +02:00
Tulir Asokan be806949bf Fix handling thumbnails of documents. Fixes #281 2019-02-15 18:18:43 +02:00
Tulir Asokan 1c08725ade Add missing copyright headers and future-fstrings encodings 2019-02-15 17:59:04 +02:00
Tulir Asokan bb939bc4cd Bump version to 0.5.0rc1 2019-02-14 16:06:43 +02:00
Tulir Asokan c88b28606e Code cleanup 2019-02-14 16:05:01 +02:00
Tulir Asokan 172dc91ec1 Add command to list and terminate sessions (ref #249) 2019-02-14 13:28:48 +02:00
Tulir Asokan 3a46bb4920 Update moviepy 2019-02-14 13:28:32 +02:00
Tulir Asokan aba2e6b140 Fix Matrix->Telegram room avatar bridging. Fixes #165 2019-02-14 01:50:24 +02:00
Tulir Asokan d678cdfff4 Fix import in alembic migration 2019-02-14 01:41:45 +02:00
Tulir Asokan 218752bb40 Fix power level cache turning into a string 2019-02-14 01:16:19 +02:00
Tulir Asokan 17b711d097 Add option to skip deleted members when syncing members. Fixes #192 2019-02-14 01:07:50 +02:00
Tulir Asokan 346090f7dc Add config option to change number of dialogs to handle in startup sync 2019-02-14 01:03:50 +02:00
Tulir Asokan 20dd6f8383 Show time startup actions took 2019-02-14 01:00:02 +02:00
Tulir Asokan c31e0a50b5 Add option to disable startup sync. Fixes #176 2019-02-14 00:57:27 +02:00
Tulir Asokan c2172aa562 Set alchemysession core mode on by default
Bump minimum telethon-session-sqlalchemy version for core mode support on non-postgres engines
Fixes #263
2019-02-14 00:52:00 +02:00
Tulir Asokan 9174186442 Stop using SQLAlchemy ORM everywhere 2019-02-14 00:06:45 +02:00
Tulir Asokan 8ef82abe9d Ignore duplicate portals in telematrix import. Fixes #243 2019-02-13 23:56:48 +02:00
Tulir Asokan 9e58b6572e Fix extras all when an extra feature has more than one dependency 2019-02-13 19:49:59 +02:00
Tulir Asokan 311e443d21 Remove bare except in setup.py 2019-02-13 18:19:53 +02:00
Tulir Asokan 6a8fceff5b Update mautrix-appservice to fix generating reply fallbacks for events with slashes in their ID 2019-02-13 18:10:07 +02:00
Tulir Asokan 6ceb7f735c Show channel name or link in forwarded messages. Fixes #107 2019-02-13 00:15:24 +02:00
Tulir Asokan 5c8f2034c3 Fix formatting in command helps 2019-02-13 00:05:17 +02:00
Tulir Asokan f8e429f08a More file splitting and new admin commands 2019-02-12 23:48:08 +02:00
Tulir Asokan e84c793ba6 Fix User.get_by_username() 2019-02-12 21:34:19 +02:00
Tulir Asokan 0812c9a3bc Fix import in alembic 2019-02-12 21:18:27 +02:00
Tulir Asokan 0d0b043bb8 Fix small mistakes 2019-02-12 20:57:14 +02:00
Tulir Asokan 16d3458e5a Include portal chat ID in logs 2019-02-12 15:06:19 +02:00
Tulir Asokan f775e40b16 Move db to own package 2019-02-12 15:05:51 +02:00
Tulir Asokan cf847d3b8e Finish moving portals and users to SQLAlchemy Core 2019-02-12 14:42:03 +02:00
Tulir Asokan 53489e7356 Start moving portals and users to SQLAlchemy Core 2019-02-12 01:19:12 +02:00
Tulir Asokan c028e1befc Add missing await 2019-02-11 23:33:46 +02:00
Tulir Asokan 790bb04ae5 Update dockerfile and handle readme read error in setup.py 2019-02-11 23:08:24 +02:00
Tulir Asokan 165f286bfd Handle Matrix room upgrades. Fixes #277 2019-02-11 22:32:37 +02:00
Tulir Asokan 05dfe8c4a3 Fix letters in clean-rooms and add !tg id command 2019-02-11 22:32:10 +02:00
Tulir Asokan ea37f05c11 Update telethon and downgrade imageio
Fixes #279
Fixes #274
2019-02-11 20:40:47 +02:00
Tulir Asokan 379f428961 Merge pull request #266 from tulir/client-id-in-logs
Add client ID to telethon logs
2019-02-11 09:03:18 +02:00
Tulir Asokan 88ac3051f3 Merge pull request #271 from krombel/add_ping_matrix
add ping to check matrix login
2019-02-11 08:59:57 +02:00
Tulir Asokan 99f4fc8339 Set max telethon version in requirements.txt 2019-02-04 15:28:05 +02:00
Tulir Asokan 2480578bd9 Set max telethon version to 1.5.3 2019-02-04 09:06:58 +02:00
Krombel 5ae143c98e add ping to check matrix login 2019-01-24 15:56:37 +01:00
Tulir Asokan 1473956a8a Add client ID to telethon logs
Depends on LonamiWebs/Telethon#1087
2019-01-11 15:36:30 +02:00
Tulir Asokan 01426308c5 Make automatic full Matrix state syncs optional 2019-01-07 19:58:16 +02:00
Tulir Asokan a090d6de32 Add command to cache Matrix room memberships 2019-01-07 19:54:19 +02:00
Tulir Asokan e9ddd0caa8 Add missing checks and fix file bridging with latest Telegram API layer
Fixes #260
2019-01-01 18:45:59 +02:00
Tulir Asokan a258c59ca3 Bump minimum Telethon version 2018-12-28 16:36:23 +02:00
Tulir Asokan 8021fcc24c Bridge message pins in normal groups. Fixes #259 2018-12-28 16:34:58 +02:00
Tulir Asokan 55f7cbb1bb Include command error traceback for admins 2018-12-23 20:24:05 +02:00
Tulir Asokan dad0ccb3c0 Clean up code 2018-12-23 19:51:02 +02:00
Tulir Asokan 06f1bcfb3f Make play IDs shorter 2018-12-23 17:32:05 +02:00
Tulir Asokan 2e20ae2148 Add support for playing games. Fixes #256 2018-12-23 17:00:19 +02:00
Tulir Asokan 09676f8314 Add custom message for unsupported media. Fixes #258 2018-12-23 14:55:28 +02:00
Tulir Asokan 75b6e4f633 Strip displayname format in Matrix->Telegram non-username mentions. Fixes #138 2018-12-20 16:45:40 +02:00
Tulir Asokan 1bebdcba89 Allow removing username and fix pinging with no username 2018-12-20 16:45:11 +02:00
Tulir Asokan c589f34986 Make telegram_link_preview configurable per-room. Fixes #244 again 2018-12-20 15:31:05 +02:00
Tulir Asokan e970dadb6f Add note that logging in grants the bridge full access to telegram account. Fixes #248 2018-12-20 15:00:06 +02:00
Tulir Asokan 0c0f7905da Add hidden argument for admins to log in as another user. Fixes #251 2018-12-20 14:51:25 +02:00
Tulir Asokan af8bb6aa4d Re-add type hint override for ensure_started 2018-12-20 14:42:01 +02:00
Tulir Asokan ca132a6d18 Add option to disable telegram link previews. Fixes #244 2018-12-20 14:35:30 +02:00
Tulir Asokan f519ea0193 Only call ensure_started for logged in users at startup. Fixes #247 2018-12-20 14:25:06 +02:00
Tulir Asokan 1ae4a63d4e Install indirect dependencies from apk 2018-12-20 00:43:01 +02:00
Tulir Asokan 5c4db8df5b Fix Telegram->Matrix file transfer broken in b2e183e363 2018-12-20 00:32:27 +02:00
Tulir Asokan 85eca1a75e Bump version to 0.5.0+dev 2018-12-20 00:21:34 +02:00
Tulir Asokan c3a21388f4 Remove unnecessary ORM commits 2018-12-20 00:14:38 +02:00
Tulir Asokan 082ef79346 Use only emoji as sticker body if unicodedata doesn't find name. Fixes #252 2018-12-20 00:08:48 +02:00
Tulir Asokan 85dc424ea0 Fix possible duplicate room creation after upgrading group and restarting 2018-12-20 00:07:42 +02:00
Tulir Asokan b2e183e363 Switch TelegramFile to SQLAlchemy core 2018-12-20 00:07:04 +02:00
Tulir Asokan e548836d38 Make clean-groups case-insensitive 2018-12-19 23:32:36 +02:00
Tulir Asokan 4a2bb3d7fc Switch state store to SQLAlchemy core 2018-12-19 23:32:22 +02:00
Tulir Asokan 65e0ebdb37 Add command to set username and fix some bugs 2018-12-19 22:36:51 +02:00
Tulir Asokan d3d02f173a Add option to use telegram test DC 2018-12-19 21:19:53 +02:00
Tulir Asokan c39d24ccdc Add HTMLParser compatibility to recursive Matrix parser and remove old parser 2018-11-28 02:26:01 +02:00
Tulir Asokan 1994ce38eb Bump version to 0.4.0 2018-11-28 02:10:37 +02:00
120 changed files with 8101 additions and 5744 deletions
+6
View File
@@ -2,3 +2,9 @@
.codeclimate.yml
*.png
*.md
logs
.venv
start
config.yaml
registration.yaml
*.db
+10 -4
View File
@@ -1,11 +1,17 @@
.idea/
/.idea/
.venv
/.venv
/env/
pip-selfcheck.json
*.pyc
__pycache__
/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
+52 -7
View File
@@ -1,28 +1,73 @@
FROM docker.io/alpine:3.8
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.10
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 \
&& apk add --no-cache \
py3-virtualenv \
py3-pillow \
py3-aiohttp \
py3-lxml \
py3-magic \
py3-numpy \
py3-asn1crypto \
py3-sqlalchemy \
py3-markdown \
py3-psycopg2 \
py3-ruamel.yaml \
# Indirect dependencies
py3-idna \
#commonmark
py3-future \
#alembic
py3-mako \
py3-dateutil \
py3-markupsafe \
#moviepy
py3-decorator \
#py3-tqdm \
py3-requests \
#imageio
py3-numpy \
#telethon
py3-rsa \
# cryptg
py3-cffi \
# Other dependencies
ffmpeg \
ca-certificates \
su-exec \
&& pip3 install -r requirements.txt -r optional-requirements.txt
netcat-openbsd \
# lottieconverter
zlib libpng \
&& pip3 install .[speedups,hq_thumbnails,metrics] \
&& 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)
+7
View File
@@ -3,6 +3,7 @@
* Matrix → Telegram
* [x] Message content (text, formatting, files, etc..)
* [x] Message redactions
* [x] Message edits
* [ ] ‡ Message history
* [x] Presence
* [x] Typing notifications
@@ -21,6 +22,10 @@
* [ ] ‡ Changes to displayname/avatar
* Telegram → Matrix
* [x] Message content (text, formatting, files, etc..)
* [ ] Advanced message content/media
* [x] Polls
* [x] Games
* [ ] Buttons
* [x] Message deletions
* [x] Message edits
* [ ] Message history
@@ -48,6 +53,8 @@
* [x] Option to use bot to relay messages for unauthenticated Matrix users
* [x] Option to use own Matrix account for messages sent from other Telegram clients
* [ ] ‡ Calls (hard, not yet supported by Telethon)
* [ ] ‡ Secret chats (not yet supported by Telethon)
* [ ] ‡ E2EE in Matrix rooms (not yet supported
† Information not automatically sent from source, i.e. implementation may not be possible
‡ Maybe, i.e. this feature may or may not be implemented at some point
+4 -11
View File
@@ -7,10 +7,10 @@ from os.path import abspath, dirname
sys.path.insert(0, dirname(dirname(abspath(__file__))))
from mautrix_telegram.base import Base
from mautrix.util.db import Base
import mautrix_telegram.db
from mautrix_telegram.config import Config
from alchemysession import AlchemySessionContainer
import mautrix_telegram.db
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -19,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,27 @@
"""Add disable_updates field for puppets
Revision ID: 17574c57f3f8
Revises: a9119be92164
Create Date: 2019-05-15 00:24:46.967529
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '17574c57f3f8'
down_revision = 'a9119be92164'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.add_column(sa.Column("disable_updates", sa.Boolean(), nullable=False,
server_default=sa.sql.expression.false()))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column("disable_updates")
@@ -17,7 +17,8 @@ depends_on = None
def upgrade():
op.add_column('puppet', sa.Column('is_bot', sa.Boolean(), nullable=True))
with op.batch_alter_table("puppet") as batch_op:
batch_op.add_column(sa.Column('is_bot', sa.Boolean(), nullable=True))
def downgrade():
@@ -16,7 +16,8 @@ depends_on = None
def upgrade():
op.add_column('portal', sa.Column('megagroup', sa.Boolean()))
with op.batch_alter_table("portal") as batch_op:
batch_op.add_column(sa.Column('megagroup', sa.Boolean()))
def downgrade():
@@ -0,0 +1,25 @@
"""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)")
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.base 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}
@@ -57,7 +56,8 @@ class Puppet(Base):
def upgrade():
op.add_column("puppet", sa.Column("matrix_registered", sa.Boolean(), nullable=False,
with op.batch_alter_table("puppet") as batch_op:
batch_op.add_column(sa.Column("matrix_registered", sa.Boolean(), nullable=False,
server_default=sa.sql.expression.false()))
op.create_table("mx_room_state",
sa.Column("room_id", sa.String(), nullable=False),
@@ -82,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,48 @@
"""Add edit index to messages
Revision ID: 9e9c89b0b877
Revises: 17574c57f3f8
Create Date: 2019-05-29 15:28:23.128377
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9e9c89b0b877'
down_revision = '17574c57f3f8'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('_message_temp',
sa.Column('mxid', sa.String),
sa.Column('mx_room', sa.String),
sa.Column('tgid', sa.Integer),
sa.Column('tg_space', sa.Integer),
sa.Column('edit_index', sa.Integer),
sa.PrimaryKeyConstraint('tgid', 'tg_space', 'edit_index'),
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room_2"))
c = op.get_bind()
c.execute("INSERT INTO _message_temp (mxid, mx_room, tgid, tg_space, edit_index) "
"SELECT message.mxid, message.mx_room, message.tgid, message.tg_space, 0 "
"FROM message")
c.execute("DROP TABLE message")
c.execute("ALTER TABLE _message_temp RENAME TO message")
def downgrade():
op.create_table('_message_temp',
sa.Column('mxid', sa.String),
sa.Column('mx_room', sa.String),
sa.Column('tgid', sa.Integer),
sa.Column('tg_space', sa.Integer),
sa.PrimaryKeyConstraint('tgid', 'tg_space'),
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"))
c = op.get_bind()
c.execute("INSERT INTO _message_temp (mxid, mx_room, tgid, tg_space) "
"SELECT message.mxid, message.mx_room, message.tgid, message.tg_space "
"FROM message")
c.execute("DROP TABLE message")
c.execute("ALTER TABLE _message_temp RENAME TO message")
@@ -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")
+170 -45
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
@@ -27,9 +27,6 @@ appservice:
# SQLite: sqlite:///filename.db
# Postgres: postgres://username:password@hostname/dbname
database: sqlite:///mautrix-telegram.db
# Whether or not to use SQLAlchemy Core for common database actions. Use if the bridge is
# being bottlenecked on ORM commits. Only supported with PostgreSQL.
sqlalchemy_core_mode: false
# 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
@@ -63,10 +60,30 @@ appservice:
bot_displayname: Telegram bridge bot
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
# Community ID for bridged users (changes registration file) and rooms.
# Must be created manually.
community_id: false
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration"
hs_token: "This value is generated when generating the registration"
# Prometheus telemetry config. Requires prometheus-client to be installed.
metrics:
enabled: false
listen_port: 8000
# Manhole config.
manhole:
# Whether or not opening the manhole is allowed.
enabled: false
# The path for the unix socket.
path: /var/tmp/mautrix-telegram.manhole
# The list of UIDs who can be added to the whitelist.
# If empty, any UIDs can be specified in the open-manhole command.
whitelist:
- 0
# Bridge config
bridge:
# Localpart template of MXIDs for Telegram users.
@@ -97,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
@@ -107,9 +126,22 @@ bridge:
# If no channel admins have logged into the bridge, the bridge won't be able to sync the member
# list regardless of this setting.
sync_channel_members: true
# Whether or not to skip deleted members when syncing members.
skip_deleted_members: true
# Whether or not to automatically synchronize contacts and chats of Matrix users logged into
# their Telegram account at startup.
startup_sync: true
# Number of most recently active dialogs to check when syncing chats.
# Set to 0 to remove limit.
sync_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
# Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames)
# at startup and when creating a bridge.
sync_matrix_state: true
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix
# login website (see appservice.public config section)
allow_matrix_login: true
@@ -117,16 +149,58 @@ bridge:
# Only enable this if your displayname_template has some static part that the bridge can use to
# reliably identify what is a plaintext highlight.
plaintext_highlights: false
# Highlight changed/added parts in edits. Requires lxml.
highlight_edits: false
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
public_portals: true
# Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down.
# Currently only works for private chats and normal groups.
catch_up: false
# Whether or not to use /sync to get presence, read receipts and typing notifications when using
# your own Matrix account as the Matrix puppet for your Telegram account.
sync_with_custom_puppets: true
# Set to false to disable link previews in messages sent to Telegram.
telegram_link_preview: true
# Use inline images instead of a separate message for the caption.
# N.B. Inline images are not supported on all clients (e.g. Riot iOS).
inline_images: false
# Maximum size of image in megabytes before sending to Telegram as a document.
image_as_file_size: 10
# 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
bridge_notices:
# Whether or not Matrix bot messages (type m.notice) should be bridged.
default: false
# List of user IDs for whom the previous flag is flipped.
# e.g. if bridge_notices.default is false, notices from other users will not be bridged, but
# notices from users listed here will be bridged.
exceptions:
- "@importantbot:example.com"
# Some config options related to Telegram message deduplication.
# The default values are usually fine, but some debug messages/warnings might recommend you
@@ -138,44 +212,30 @@ bridge:
# You might need to increase this on high-traffic bridge instances.
cache_queue_length: 20
# Show message editing as a reply to the original message.
# If this is false, message edits are not shown at all, as Matrix does not support editing yet.
edits_as_replies: false
bridge_notices:
# Whether or not Matrix bot messages (type m.notice) should be bridged.
default: false
# List of user IDs for whom the previous flag is flipped.
# e.g. if bridge_notices.default is false, notices from other users will not be bridged, but
# notices from users listed here will be bridged.
exceptions:
- "@importantbot:example.com"
# Whether to bridge Telegram bot messages as m.notices or m.texts.
bot_messages_as_notices: true
# Use inline images instead of a separate message for the caption.
# N.B. Inline images are not supported on all clients (e.g. Riot iOS).
inline_images: false
# Whether to send stickers as the new native m.sticker type or normal m.images.
# Old versions of Riot don't support the new type at all.
# Remember that proper sticker support always requires Pillow to convert webp into png.
native_stickers: true
# 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.
#
@@ -223,6 +283,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.
@@ -241,17 +317,63 @@ telegram:
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
# (Optional) Create your own bot at https://t.me/BotFather
bot_token: disabled
# Telethon connection options.
connection:
# The timeout in seconds to be used when connecting.
timeout: 120
# How many times the reconnection should retry, either on the initial connection or when
# Telegram disconnects us. May be set to a negative or null value for infinite retries, but
# this is not recommended, since the program can get stuck in an infinite loop.
retries: 5
# The delay in seconds to sleep between automatic reconnections.
retry_delay: 1
# The threshold below which the library should automatically sleep on flood wait errors
# (inclusive). For instance, if a FloodWaitError for 17s occurs and flood_sleep_threshold
# is 20s, the library will sleep automatically. If the error was for 21s, it would raise
# the error instead. Values larger than a day (86400) will be changed to a day.
flood_sleep_threshold: 60
# How many times a request should be retried. Request are retried when Telegram is having
# internal issues, when there is a FloodWaitError less than flood_sleep_threshold, or when
# there's a migrate error. May take a negative or null value for infinite retries, but this
# is not recommended, since some requests can always trigger a call fail (such as searching
# for messages).
request_retries: 5
# Device info sent to Telegram.
device_info:
# "auto" = OS name+version.
device_model: auto
# "auto" = Telethon version.
system_version: auto
# "auto" = mautrix-telegram version.
app_version: auto
lang_code: en
system_lang_code: en
# Custom server to connect to.
server:
# Set to true to use these server settings. If false, will automatically
# use production server assigned by Telegram. Set to false in production.
enabled: false
# The DC ID to connect to.
dc: 2
# The IP to connect to.
ip: 149.154.167.40
# The port to connect to. 443 may not work, 80 is better and both are equally secure.
port: 80
# Telethon proxy configuration.
# You must install PySocks from pip for proxies to work.
proxy:
# Allowed types: disabled, socks4, socks5, http
# 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: ""
@@ -262,18 +384,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 -}}
+140
View File
@@ -0,0 +1,140 @@
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
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.4.0rc2"
__version__ = "0.7.0rc1"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+69 -101
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -14,130 +13,99 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Coroutine, List
import argparse
import asyncio
import logging.config
import sys
import copy
import signal
from typing import Optional
from itertools import chain
from sqlalchemy import orm
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 .base import Base
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 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__
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()
try:
import prometheus_client as prometheus
except ImportError:
prometheus = None
config = Config(args.config, args.registration, args.base_config)
config.load()
config.update()
if args.generate_registration:
config.generate_registration()
config.save()
print(f"Registration generated and saved to {config.registration_path}")
sys.exit(0)
class TelegramBridge(Bridge):
name = "mautrix-telegram"
command = "python -m mautrix-telegram"
description = "A Matrix-Telegram puppeting bridge."
real_user_content_key = "net.maunium.telegram.puppet"
version = __version__
config_class = Config
matrix_class = MatrixHandler
state_store_class = SQLStateStore
logging.config.dictConfig(copy.deepcopy(config["logging"]))
log = logging.getLogger("mau.init") # type: logging.Logger
log.debug(f"Initializing mautrix-telegram {__version__}")
config: Config
session_container: AlchemySessionContainer
bot: Bot
manhole: Optional[ManholeState]
db_engine = sql.create_engine(config["appservice.database"] or "sqlite:///mautrix-telegram.db")
db_factory = orm.sessionmaker(bind=db_engine)
db_session = orm.scoping.scoped_session(db_factory)
Base.metadata.bind = db_engine
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)
session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
table_base=Base, table_prefix="telethon_",
manage_tables=False)
if config["appservice.sqlalchemy_core_mode"]:
try:
session_container.core_mode = True
except AttributeError:
log.error("Current version of teleton-session-sqlalchemy does not support core mode")
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
loop = asyncio.get_event_loop() # type: asyncio.AbstractEventLoop
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
state_store = SQLStateStore(db_session)
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
})
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.")
context = Context(appserv, db_session, config, loop, session_container)
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
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
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)
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
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
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
init_db(db_session, db_engine)
init_abstract_user(context)
context.bot = init_bot(context)
context.mx = MatrixHandler(context)
init_formatter(context)
init_portal(context)
startup_actions = (init_puppet(context) +
init_user(context) +
[start,
context.mx.init_as_bot()]) # type: List[Coroutine]
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)
try:
log.debug("Initialization complete, running startup actions")
loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop))
log.debug("Startup actions complete, 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)
TelegramBridge().run()
+191 -132
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -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, 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 sqlalchemy import orm
from telethon.tl.types import Channel, ChannelForbidden, Chat, ChatForbidden, Message, \
MessageActionChannelMigrateFrom, MessageService, PeerUser, TypeUpdate, \
UpdateChannelPinnedMessage, UpdateChatAdmins, UpdateChatParticipantAdmin, \
UpdateChatParticipants, UpdateChatUserTyping, UpdateDeleteChannelMessages, \
UpdateDeleteMessages, UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, \
UpdateNewMessage, UpdateReadHistoryOutbox, UpdateShortChatMessage, UpdateShortMessage, \
UpdateUserName, UpdateUserPhoto, UpdateUserStatus, UpdateUserTyping, User, UserStatusOffline, \
UserStatusOnline
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, 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,72 +47,129 @@ 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]
UpdateMessageContent = Union[UpdateShortMessage, UpdateShortChatMessage, Message, MessageService]
try:
from prometheus_client import Histogram
UPDATE_TIME = Histogram("telegram_update", "Time spent processing Telegram updates",
["update_type"])
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
db = None # type: orm.Session
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}")
device = f"{platform.system()} {platform.release()}"
sysversion = MautrixTelegramClient.__version__
self.session = self.session_container.new_session(self.name)
self.client = MautrixTelegramClient(session=self.session,
api_id=config["telegram.api_id"],
api_hash=config["telegram.api_hash"],
loop=self.loop,
app_version=__version__,
system_version=sysversion,
device_model=device,
timeout=120,
proxy=self._proxy_settings)
if config["telegram.server.enabled"]:
self.session.set_dc(config["telegram.server.dc"],
config["telegram.server.ip"],
config["telegram.server.port"])
if self.is_relaybot:
base_logger = logging.getLogger("telethon.relaybot")
else:
base_logger = logging.getLogger(f"telethon.{self.tgid or -hash(self.mxid)}")
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,
api_id=config["telegram.api_id"],
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),
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"],
connection=connection,
proxy=proxy,
loop=self.loop,
base_logger=base_logger
)
self.client.add_event_handler(self._update_catch)
@abstractmethod
@@ -128,20 +189,14 @@ class AbstractUser(ABC):
raise NotImplementedError()
async def _update_catch(self, update: TypeUpdate) -> None:
start_time = time.time()
try:
if not await self.update(update):
await self._update(update)
except Exception:
self.log.exception("Failed to handle Telegram update")
async def get_dialogs(self, limit: int = None) -> List[Union[Chat, Channel]]:
if self.is_bot:
return []
dialogs = await self.client.get_dialogs(limit=limit)
return [dialog.entity for dialog in dialogs if (
not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden))
and not (isinstance(dialog.entity, Chat)
and (dialog.entity.deactivated or dialog.entity.left)))]
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)
@property
@abstractmethod
@@ -149,7 +204,8 @@ class AbstractUser(ABC):
raise NotImplementedError()
async def is_logged_in(self) -> bool:
return self.client and self.client.is_connected() and await self.client.is_user_authorized()
return (self.client and self.client.is_connected()
and await self.client.is_user_authorized())
async def has_full_access(self, allow_bot: bool = False) -> bool:
return (self.puppet_whitelisted
@@ -160,20 +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:
if self.connected:
return self
self.log.debug("ensure_started(%s, connected=%s, even_if_no_session=%s, session_count=%s)",
self.mxid, self.connected, even_if_no_session,
self.session_container.Session.query.filter(
self.session_container.Session.session_id == self.mxid).count())
should_connect = (even_if_no_session or
self.session_container.Session.query.filter(
self.session_container.Session.session_id == self.mxid).count() > 0)
if not self.connected and should_connect:
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
@@ -184,6 +235,8 @@ class AbstractUser(ABC):
# region Telegram update handling
async def _update(self, update: TypeUpdate) -> None:
asyncio.ensure_future(self._handle_entity_updates(getattr(update, "_entities", {})),
loop=self.loop)
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
await self.update_message(update)
@@ -195,11 +248,11 @@ class AbstractUser(ABC):
await self.update_typing(update)
elif isinstance(update, UpdateUserStatus):
await self.update_status(update)
elif isinstance(update, (UpdateChatAdmins, UpdateChatParticipantAdmin)):
elif isinstance(update, UpdateChatParticipantAdmin):
await self.update_admin(update)
elif isinstance(update, UpdateChatParticipants):
await self.update_participants(update)
elif isinstance(update, UpdateChannelPinnedMessage):
elif isinstance(update, (UpdateChannelPinnedMessage, UpdateChatPinnedMessage)):
await self.update_pinned_messages(update)
elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
await self.update_others_info(update)
@@ -208,11 +261,14 @@ class AbstractUser(ABC):
else:
self.log.debug("Unhandled update: %s", update)
@staticmethod
async def update_pinned_messages(update: UpdateChannelPinnedMessage) -> None:
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
async def update_pinned_messages(self, update: Union[UpdateChannelPinnedMessage,
UpdateChatPinnedMessage]) -> None:
if isinstance(update, UpdateChatPinnedMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
else:
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
if portal and portal.mxid:
await portal.receive_telegram_pin_id(update.id)
await portal.receive_telegram_pin_id(update.id, self.tgid)
@staticmethod
async def update_participants(update: UpdateChatParticipants) -> None:
@@ -230,32 +286,26 @@ class AbstractUser(ABC):
return
# We check that these are user read receipts, so tg_space is always the user ID.
message = DBMessage.get_by_tgid(update.max_id, self.tgid)
message = DBMessage.get_one_by_tgid(TelegramID(update.max_id), self.tgid, edit_index=-1)
if not message:
return
puppet = pu.Puppet.get(TelegramID(update.peer.user_id))
await puppet.intent.mark_read(portal.mxid, message.mxid)
async def update_admin(self,
update: Union[UpdateChatAdmins, UpdateChatParticipantAdmin]) -> None:
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
if isinstance(update, UpdateChatAdmins):
await portal.set_telegram_admins_enabled(update.enabled)
elif isinstance(update, UpdateChatParticipantAdmin):
await portal.set_telegram_admin(TelegramID(update.user_id))
else:
self.log.warning("Unexpected admin status update: %s", update)
await portal.set_telegram_admin(TelegramID(update.user_id))
async def update_typing(self, update: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None:
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
@@ -263,14 +313,25 @@ class AbstractUser(ABC):
sender = pu.Puppet.get(TelegramID(update.user_id))
await portal.handle_telegram_typing(sender, update)
async def _handle_entity_updates(self, entities: Dict[int, Union[User, Chat, Channel]]
) -> None:
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.try_update_info(self, info)
for puppet, info in puppets if puppet])
except Exception:
self.log.exception("Failed to handle entity updates")
async def update_others_info(self, update: Union[UpdateUserName, UpdateUserPhoto]) -> None:
# TODO duplication not checked
puppet = pu.Puppet.get(TelegramID(update.user_id))
if isinstance(update, UpdateUserName):
puppet.username = update.username
if await puppet.update_displayname(self, update):
puppet.save()
elif isinstance(update, UpdateUserPhoto):
if await puppet.update_avatar(self, update.photo.photo_big):
if await puppet.update_avatar(self, update.photo):
puppet.save()
else:
self.log.warning("Unexpected other user info update: %s", update)
@@ -278,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
@@ -289,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")
@@ -310,54 +373,56 @@ class AbstractUser(ABC):
return update, sender, portal
@staticmethod
async def _try_redact(portal: po.Portal, message: DBMessage) -> None:
async def _try_redact(message: DBMessage) -> None:
portal = po.Portal.get_by_mxid(message.mx_room)
if not portal:
return
try:
await portal.main_intent.redact(message.mx_room, message.mxid)
except MatrixRequestError:
except MatrixError:
pass
async def delete_message(self, update: UpdateDeleteMessages) -> None:
if len(update.messages) > MAX_DELETIONS:
return
for message in update.messages:
message = DBMessage.get_by_tgid(TelegramID(message), self.tgid)
if not message:
continue
message.delete()
number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
if number_left == 0:
portal = po.Portal.get_by_mxid(message.mx_room)
await self._try_redact(portal, message)
self.db.commit()
for message_id in update.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:
await self._try_redact(message)
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
if len(update.messages) > MAX_DELETIONS:
return
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
if not portal:
return
channel_id = TelegramID(update.channel_id)
for message in update.messages:
message = DBMessage.get_by_tgid(TelegramID(message), portal.tgid)
if not message:
continue
message.delete()
await self._try_redact(portal, message)
self.db.commit()
for message_id in update.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
@@ -371,22 +436,16 @@ 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)):
if config["bridge.edits_as_replies"]:
self.log.debug("Handling edit %s to %s by %s", update, portal.tgid_log, user)
return await portal.handle_telegram_edit(self, sender, update)
return
self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
return await portal.handle_telegram_edit(self, sender, update)
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, AbstractUser.db, config, AbstractUser.loop, AbstractUser.relaybot = context.core
AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
AbstractUser.session_container = context.session_container
MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10)
-2
View File
@@ -1,2 +0,0 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base() # type: declarative_base
+87 -64
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -14,50 +13,67 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, Callable, Dict, List, Optional, Pattern, TYPE_CHECKING
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, Message, MessageActionChatAddUser,
MessageActionChatDeleteUser, MessageEntityBotCommand, MessageService, PeerChannel, PeerChat,
TypePeer, UpdateNewChannelMessage, UpdateNewMessage)
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
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 = {chat.id: chat.type for chat in BotChat.query.all()} # 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
or False)
self._me_info = None
self._me_mxid = None
async def get_me(self, use_cache: bool = True) -> Tuple[User, UserID]:
if not use_cache or not self._me_mxid:
self._me_info = await self.client.get_me()
self._me_mxid = pu.Puppet.get_mxid_from_id(TelegramID(self._me_info.id))
return self._me_info, self._me_mxid
async def init_permissions(self) -> None:
whitelist = config["bridge.relaybot.whitelist"] or []
@@ -72,6 +88,7 @@ class Bot(AbstractUser):
self.tg_whitelist.append(user_id)
async def start(self, delete_unless_authenticated: bool = False) -> 'Bot':
self.chats = {chat.id: chat.type for chat in BotChat.all()}
await super().start(delete_unless_authenticated)
if not await self.is_logged_in():
await self.client.sign_in(bot_token=self.token)
@@ -81,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)
@@ -89,22 +106,16 @@ class Bot(AbstractUser):
response = await self.client(GetChatsRequest(chat_ids))
for chat in response.chats:
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
self.remove_chat(chat.id)
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(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")
self.remove_chat(TelegramID(channel_id.channel_id))
def register_portal(self, portal: po.Portal) -> None:
self.add_chat(portal.tgid, portal.peer_type)
@@ -112,23 +123,19 @@ class Bot(AbstractUser):
def unregister_portal(self, portal: po.Portal) -> None:
self.remove_chat(portal.tgid)
def add_chat(self, chat_id: int, chat_type: str) -> None:
def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
if chat_id not in self.chats:
self.chats[chat_id] = chat_type
self.db.add(BotChat(id=chat_id, type=chat_type))
self.db.commit()
BotChat(id=TelegramID(chat_id), type=chat_type).insert()
def remove_chat(self, chat_id: int) -> None:
def remove_chat(self, chat_id: TelegramID) -> None:
try:
del self.chats[chat_id]
except KeyError:
pass
existing_chat = BotChat.query.get(chat_id)
if existing_chat:
self.db.delete(existing_chat)
self.db.commit()
BotChat.delete_by_id(chat_id)
async def _can_use_commands(self, chat: TypePeer, tgid: int) -> bool:
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
if tgid in self.tg_whitelist:
return True
@@ -150,16 +157,16 @@ 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
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc) -> None:
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc) -> Message:
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)
@@ -172,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():
@@ -188,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
@@ -197,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()
@@ -214,14 +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"):
return await self.handle_command_id(message, reply)
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)
@@ -236,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
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
@@ -254,6 +275,9 @@ class Bot(AbstractUser):
self.add_chat(to_id, chat_type)
elif isinstance(action, MessageActionChatDeleteUser) and action.user_id == self.tgid:
self.remove_chat(to_id)
elif isinstance(action, MessageActionChatMigrateTo):
self.remove_chat(to_id)
self.add_chat(TelegramID(action.channel_id), "channel")
async def update(self, update) -> bool:
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
@@ -266,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:
@@ -278,9 +301,9 @@ class Bot(AbstractUser):
return "bot"
def init(context: 'Context') -> Optional[Bot]:
def init(cfg: 'Config') -> Optional[Bot]:
global config
config = context.config
config = cfg
token = config["telegram.bot_token"]
if token and not token.lower().startswith("disable"):
return Bot(token)
+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 clean_rooms, auth, meta, telegram, portal
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"]
+28 -29
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -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)"]
@@ -70,7 +69,7 @@ async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
for n, (room, other_member) in enumerate(management_rooms)]
or ["No management rooms found."])
reply.append("#### Active portal rooms (A)")
reply += ([f"{n+1}. [P{n+1}](https://matrix.to/#/{portal.mxid}) "
reply += ([f"{n+1}. [A{n+1}](https://matrix.to/#/{portal.mxid}) "
f"(to Telegram chat \"{portal.title}\")"
for n, portal in enumerate(portals)]
or ["No active portal rooms found."])
@@ -79,7 +78,7 @@ async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
for n, room in enumerate(unidentified_rooms)]
or ["No unidentified rooms found."])
reply.append("#### Inactive portal rooms (I)")
reply += ([f"{n}. [E{n}](https://matrix.to/#/{portal.mxid}) "
reply += ([f"{n}. [I{n}](https://matrix.to/#/{portal.mxid}) "
f"(to Telegram chat \"{portal.title}\")"
for n, portal in enumerate(empty_portals)]
or ["No inactive portal rooms found."])
@@ -93,7 +92,7 @@ async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
"",
("To clean specific rooms, type `$cmdprefix+sp clean-range <range>` "
"where `range` is the range (e.g. `5-21`) prefixed with the first letter of"
"the group name."),
"the group name. (e.g. `I2-6`)"),
"",
("Please note that you will have to re-run `$cmdprefix+sp clean-rooms` "
"between each use of the commands above.")]
@@ -108,17 +107,17 @@ 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
elif command == "clean-groups":
if len(evt.args) < 2:
return await evt.reply("**Usage:** `$cmdprefix+sp clean-groups [M][A][U][I]")
groups_to_clean = evt.args[1]
groups_to_clean = evt.args[1].upper()
if "M" in groups_to_clean:
rooms_to_clean += [room_id for (room_id, user_id) in management_rooms]
if "A" in groups_to_clean:
@@ -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,7 +168,7 @@ 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
else:
await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted")
cleaned += 1
evt.sender.command_status = None
+66 -139
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -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, Callable, Dict, List, NamedTuple, Optional
import commonmark
import logging
"""This module contains classes handling commands issued by Matrix users."""
from typing import Awaitable, Callable, List, Optional, NamedTuple, Any
from telethon.errors import FloodWaitError
from ..types import MatrixRoomID
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, "")
@@ -36,73 +38,41 @@ 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()
class CommandEvent:
def __init__(self, processor: 'CommandProcessor', room: MatrixRoomID, 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.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]:
message = message.replace("$cmdprefix+sp ",
"" if self.is_management else f"{self.command_prefix} ")
message = message.replace("$cmdprefix", self.command_prefix)
html = None
if render_markdown:
md_renderer.allow_html = allow_html
html = md_renderer.render(md_parser.parse(message))
elif allow_html:
html = message
return self.az.intent.send_notice(self.room_id, message, html=html)
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:
def __init__(self, handler: Callable[[CommandEvent], Awaitable[Dict]], needs_auth: bool,
needs_puppeting: bool, needs_matrix_puppeting: bool, needs_admin: bool,
class CommandHandler(BaseCommandHandler):
name: str
management_only: bool
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:
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]:
if self.management_only and not evt.is_management:
@@ -118,84 +88,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:
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:
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:
return bool(self.help_section) and bool(self._help_text)
@property
def help(self) -> str:
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]:
input_name = name
def decorator(func: Callable[[CommandEvent], Awaitable[Optional[Dict]]]) -> CommandHandler:
name = input_name or func.__name__.replace("_", "-")
handler = CommandHandler(func, needs_auth, needs_puppeting, needs_matrix_puppeting,
needs_admin, management_only, 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:
log = logging.getLogger("mau.commands")
class CommandProcessor(BaseCommandProcessor):
def __init__(self, context: c.Context) -> None:
self.az, self.db, self.config, self.loop, self.tgbot = context.core
super().__init__(az=context.az, config=context.config, event_class=CommandEvent,
loop=context.loop)
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, sender: u.User, command: str, args: List[str],
is_management: bool, is_portal: bool) -> Optional[Dict]:
evt = CommandEvent(self, room, 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}")
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}")
+113
View File
@@ -0,0 +1,113 @@
# 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.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) -> 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.")
await puppet.switch_mxid(None, None)
return await evt.reply("Reverted your Telegram account's Matrix puppet back to the default.")
@command_handler(needs_auth=True, management_only=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Replace your Telegram account's Matrix puppet with your own Matrix "
"account.")
async def login_matrix(evt: CommandEvent) -> 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. "
"Log out with `$cmdprefix+sp logout-matrix` first.")
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
if allow_matrix_login:
evt.sender.command_status = {
"next": enter_matrix_token,
"action": "Matrix login",
}
if evt.config["appservice.public.enabled"]:
prefix = evt.config["appservice.public.external"]
token = evt.public_website.make_token(evt.sender.mxid, "/matrix-login")
url = f"{prefix}/matrix-login?token={token}"
if allow_matrix_login:
return await evt.reply(
"This bridge instance allows you to log in inside or outside Matrix.\n\n"
"If you would like to log in within Matrix, please send your Matrix access token "
"here.\n"
f"If you would like to log in outside of Matrix, [click here]({url}).\n\n"
"Logging in outside of Matrix is recommended, because in-Matrix login would save "
"your access token in the message history.")
return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n"
f"Please visit [the login page]({url}) to log in.")
elif allow_matrix_login:
return await evt.reply(
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
"Please send your Matrix access token here to log in.")
return await evt.reply("This bridge instance has been configured to not allow logging in.")
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Pings the server with the stored matrix authentication.")
async def ping_matrix(evt: CommandEvent) -> 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:
await puppet.start()
except InvalidAccessToken:
return await evt.reply("Your access token is invalid.")
return await evt.reply("Your Matrix login is working.")
@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.")
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.")
except InvalidAccessToken:
return await evt.reply("Failed to verify access token.")
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) 2018 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)
help = ["#### {}\n{}\n".format(key.name, "\n".join(value)) for key, value in help_sorted]
help_cache[cache_key] = "\n".join(help)
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(needs_auth=False, needs_puppeting=False,
help_section=SECTION_GENERAL,
help_text="Show this help message.")
async def help(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply(_get_management_status(evt) + "\n" + await _get_help_text(evt))
-614
View File
@@ -1,614 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 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, Callable, Optional, Tuple, Coroutine, Awaitable
from io import StringIO
import asyncio
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
UsernameNotModifiedError, UsernameOccupiedError)
from telethon.tl.types import ChatForbidden, ChannelForbidden
from mautrix_appservice import MatrixRequestError, IntentAPI
from ..types import MatrixRoomID, TelegramID
from ..config import yaml
from .. import portal as po, user as u, util
from . import (command_handler, CommandEvent,
SECTION_ADMIN, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT)
@command_handler(needs_admin=True, needs_auth=False, name="set-pl",
help_section=SECTION_ADMIN,
help_args="<_level_> [_mxid_]",
help_text="Set a temporary power level without affecting Telegram.")
async def set_power_level(evt: CommandEvent) -> Dict:
try:
level = int(evt.args[0])
except KeyError:
return await evt.reply("**Usage:** `$cmdprefix+sp set-power <level> [mxid]`")
except ValueError:
return await evt.reply("The level must be an integer.")
levels = await evt.az.intent.get_power_levels(evt.room_id)
mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid
levels["users"][mxid] = level
try:
await evt.az.intent.set_power_levels(evt.room_id, levels)
except MatrixRequestError:
evt.log.exception("Failed to set power level.")
return await evt.reply("Failed to set power level.")
return {}
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Get a Telegram invite link to the current chat.")
async def invite_link(evt: CommandEvent) -> Dict:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
if portal.peer_type == "user":
return await evt.reply("You can't invite users to private chats.")
try:
link = await portal.get_invite_link(evt.sender)
return await evt.reply(f"Invite link to {portal.title}: {link}")
except ValueError as e:
return await evt.reply(e.args[0])
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to create an invite link.")
async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50
) -> bool:
if sender.is_admin:
return True
# Make sure the state store contains the power levels.
try:
await intent.get_power_levels(room)
except MatrixRequestError:
return False
return intent.state_store.has_power_level(room, sender.mxid,
event=f"net.maunium.telegram.{event}",
default=default)
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
portal = po.Portal.get_by_mxid(room_id)
if not portal:
that_this = "This" if room_id == evt.room_id else "That"
await evt.reply(f"{that_this} is not a portal room.")
return None
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission):
action = action or f"{permission.replace('_', ' ')}s"
await evt.reply(f"You do not have the permissions to {action} that portal.")
return None
return portal
def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str,
completed_message: str) -> Dict:
async def post_confirm(confirm) -> Optional[Dict]:
confirm.sender.command_status = None
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
await function()
if confirm.room_id != room_id:
return await confirm.reply(completed_message)
else:
return await confirm.reply(f"{action} cancelled.")
return None
return {
"next": post_confirm,
"action": action,
}
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove all users from the current portal room and forget the portal. "
"Only works for group chats; to delete a private chat portal, simply "
"leave the room.")
async def delete_portal(evt: CommandEvent) -> Optional[Dict]:
portal = await _get_portal_and_check_permission(evt, "unbridge")
if not portal:
return None
evt.sender.command_status = _get_portal_murder_function("Portal deletion", portal.mxid,
portal.cleanup_and_delete, "delete",
"Portal successfully deleted.")
return await evt.reply("Please confirm deletion of portal "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
f"to Telegram chat \"{portal.title}\" "
"by typing `$cmdprefix+sp confirm-delete`"
"\n\n"
"**WARNING:** If the bridge bot has the power level to do so, **this "
"will kick ALL users** in the room. If you just want to remove the "
"bridge, use `$cmdprefix+sp unbridge` instead.")
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove puppets from the current portal room and forget the portal.")
async def unbridge(evt: CommandEvent) -> Optional[Dict]:
portal = await _get_portal_and_check_permission(evt, "unbridge")
if not portal:
return None
evt.sender.command_status = _get_portal_murder_function("Room unbridging", portal.mxid,
portal.unbridge, "unbridge",
"Room successfully unbridged.")
return await evt.reply(f"Please confirm unbridging chat \"{portal.title}\" from room "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
"by typing `$cmdprefix+sp confirm-unbridge`")
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_args="[_id_]",
help_text="Bridge the current Matrix room to the Telegram chat with the given "
"ID. The ID must be the prefixed version that you get with the `/id` "
"command of the Telegram-side bot.")
async def bridge(evt: CommandEvent) -> Dict:
if len(evt.args) == 0:
return await evt.reply("**Usage:** "
"`$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`")
room_id = MatrixRoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
that_this = "This" if room_id == evt.room_id else "That"
portal = po.Portal.get_by_mxid(room_id)
if portal:
return await evt.reply(f"{that_this} room is already a portal room.")
if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply(f"You do not have the permissions to bridge {that_this} room.")
# The /id bot command provides the prefixed ID, so we assume
tgid_str = evt.args[0]
if tgid_str.startswith("-100"):
tgid = TelegramID(int(tgid_str[4:]))
peer_type = "channel"
elif tgid_str.startswith("-"):
tgid = TelegramID(-int(tgid_str))
peer_type = "chat"
else:
return await evt.reply("That doesn't seem like a prefixed Telegram chat ID.\n\n"
"If you did not get the ID using the `/id` bot command, please "
"prefix channel IDs with `-100` and normal group IDs with `-`.\n\n"
"Bridging private chats to existing rooms is not allowed.")
portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type)
if not portal.allow_bridging():
return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n"
"If you're the bridge admin, try "
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first.")
if portal.mxid:
has_portal_message = (
"That Telegram chat already has a portal at "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). ")
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
return await evt.reply(f"{has_portal_message}"
"Additionally, you do not have the permissions to unbridge "
"that room.")
evt.sender.command_status = {
"next": confirm_bridge,
"action": "Room bridging",
"mxid": portal.mxid,
"bridge_to_mxid": room_id,
"tgid": portal.tgid,
"peer_type": portal.peer_type,
}
return await evt.reply(f"{has_portal_message}"
"However, you have the permissions to unbridge that room.\n\n"
"To delete that portal completely and continue bridging, use "
"`$cmdprefix+sp delete-and-continue`. To unbridge the portal "
"without kicking Matrix users, use `$cmdprefix+sp unbridge-and-"
"continue`. To cancel, use `$cmdprefix+sp cancel`")
evt.sender.command_status = {
"next": confirm_bridge,
"action": "Room bridging",
"bridge_to_mxid": room_id,
"tgid": portal.tgid,
"peer_type": portal.peer_type,
}
return await evt.reply("That Telegram chat has no existing portal. To confirm bridging the "
"chat to this room, use `$cmdprefix+sp continue`")
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
) -> Tuple[bool, Optional[Coroutine[None, None, None]]]:
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)")
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)
else:
await evt.reply(
"The chat you were trying to bridge already has a Matrix portal room.\n\n"
"Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-"
"continue` to either delete or unbridge the existing room (respectively) and "
"continue with the bridging.\n\n"
"If you changed your mind, use `$cmdprefix+sp cancel` to cancel.")
return False, None
async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
status = evt.sender.command_status
try:
portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
bridge_to_mxid = status["bridge_to_mxid"]
except KeyError:
evt.sender.command_status = None
return await evt.reply("Fatal error: tgid or peer_type missing from command_status. "
"This shouldn't happen unless you're messing with the command "
"handler code.")
if "mxid" in status:
ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
if not ok:
return None
elif coro:
asyncio.ensure_future(coro, loop=evt.loop)
await evt.reply("Cleaning up previous portal room...")
elif portal.mxid:
evt.sender.command_status = None
return await evt.reply("The portal seems to have created a Matrix room between you "
"calling `$cmdprefix+sp bridge` and this command.\n\n"
"Please start over by calling the bridge command again.")
elif evt.args[0] != "continue":
return await evt.reply("Please use `$cmdprefix+sp continue` to confirm the bridging or "
"`$cmdprefix+sp cancel` to cancel.")
evt.sender.command_status = None
is_logged_in = await evt.sender.is_logged_in()
user = evt.sender if is_logged_in else evt.tgbot
try:
entity = await user.client.get_entity(portal.peer)
except Exception:
evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer)
if is_logged_in:
return await evt.reply("Failed to get info of telegram chat. "
"You are logged in, are you in that chat?")
else:
return await evt.reply("Failed to get info of telegram chat. "
"You're not logged in, is the relay bot in the chat?")
if isinstance(entity, (ChatForbidden, ChannelForbidden)):
if is_logged_in:
return await evt.reply("You don't seem to be in that chat.")
else:
return await evt.reply("The bot doesn't seem to be in that chat.")
direct = False
portal.mxid = bridge_to_mxid
portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id)
portal.photo_id = ""
portal.save()
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.")
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
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"]
except KeyError:
# Some state event probably has empty content
pass
return title, about, levels
@command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="[_type_]",
help_text="Create a Telegram chat of the given type for the current Matrix room. "
"The type is either `group`, `supergroup` or `channel` (defaults to "
"`group`).")
async def create(evt: CommandEvent) -> Dict:
type = evt.args[0] if len(evt.args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}:
return await evt.reply(
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
if po.Portal.get_by_mxid(evt.room_id):
return await evt.reply("This is already a portal room.")
if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply("You do not have the permissions to bridge this room.")
title, about, levels = await get_initial_state(evt.az.intent, evt.room_id)
if not title:
return await evt.reply("Please set a title before creating a Telegram chat.")
supergroup = type == "supergroup"
type = {
"supergroup": "channel",
"channel": "channel",
"chat": "chat",
"group": "chat",
}[type]
portal = po.Portal(tgid=None, mxid=evt.room_id, title=title, about=about, peer_type=type)
try:
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
except ValueError as e:
portal.delete()
return await evt.reply(e.args[0])
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.")
async def upgrade(evt: CommandEvent) -> Dict:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type == "channel":
return await evt.reply("This is already a supergroup or a channel.")
elif portal.peer_type == "user":
return await evt.reply("You can't upgrade private chats.")
try:
await portal.upgrade_telegram_chat(evt.sender)
return await evt.reply(f"Group upgraded to supergroup. New ID: {portal.tgid}")
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to upgrade this group.")
except ValueError as e:
return await evt.reply(e.args[0])
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="View or change per-portal settings.",
help_args="<`help`|_subcommand_> [...]")
async def config(evt: CommandEvent) -> None:
cmd = evt.args[0].lower() if len(evt.args) > 0 else "help"
if cmd not in ("view", "defaults", "set", "unset", "add", "del"):
await config_help(evt)
return
elif cmd == "defaults":
await config_defaults(evt)
return
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
await evt.reply("This is not a portal room.")
return
elif cmd == "view":
await config_view(evt, portal)
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":
await config_set(evt, portal, key, value)
elif cmd == "unset":
await config_unset(evt, portal, key)
elif cmd == "add" or cmd == "del":
await config_add_del(evt, portal, key, value, cmd)
else:
return
portal.save()
def config_help(evt: CommandEvent) -> Awaitable[Dict]:
return evt.reply("""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
* **help** - View this help text.
* **view** - View the current config data.
* **defaults** - View the default config values.
* **set** <_key_> <_value_> - Set a config value.
* **unset** <_key_> - Remove a config value.
* **add** <_key_> <_value_> - Add a value to an array.
* **del** <_key_> <_value_> - Remove a value from an array.
""")
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[Dict]:
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]:
stream = StringIO()
yaml.dump({
"edits_as_replies": evt.config["bridge.edits_as_replies"],
"bridge_notices": {
"default": evt.config["bridge.bridge_notices.default"],
"exceptions": evt.config["bridge.bridge_notices.exceptions"],
},
"bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"],
"inline_images": evt.config["bridge.inline_images"],
"native_stickers": evt.config["bridge.native_stickers"],
"message_formats": evt.config["bridge.message_formats"],
"state_event_formats": evt.config["bridge.state_event_formats"],
}, stream)
return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```")
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[Dict]:
if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
elif util.recursive_set(portal.local_config, key, value):
return evt.reply(f"Successfully set the value of `{key}` to `{value}`.")
else:
return evt.reply(f"Failed to set value of `{key}`. "
"Does the path contain non-map types?")
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Dict]:
if not key:
return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`")
elif util.recursive_del(portal.local_config, key):
return evt.reply(f"Successfully deleted `{key}` from config.")
else:
return evt.reply(f"`{key}` not found in config.")
def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
) -> Awaitable[Dict]:
if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
arr = util.recursive_get(portal.local_config, key)
if not arr:
return evt.reply(f"`{key}` not found in config. "
f"Maybe do `$cmdprefix+sp config set {key} []` first?")
elif not isinstance(arr, list):
return evt.reply("`{key}` does not seem to be an array.")
elif cmd == "add":
if value in arr:
return evt.reply(f"The array at `{key}` already contains `{value}`.")
arr.append(value)
return evt.reply(f"Successfully added `{value}` to the array at `{key}`")
else:
if value not in arr:
return evt.reply(f"The array at `{key}` does not contain `{value}`.")
arr.remove(value)
return evt.reply(f"Successfully removed `{value}` from the array at `{key}`")
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_args="<_name_|`-`>",
help_text="Change the username of a supergroup/channel. "
"To disable, use a dash (`-`) as the name.")
async def group_name(evt: CommandEvent) -> Dict:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type != "channel":
return await evt.reply("Only channels and supergroups have usernames.")
try:
await portal.set_telegram_username(evt.sender,
evt.args[0] if evt.args[0] != "-" else "")
if portal.username:
return await evt.reply(f"Username of channel changed to {portal.username}.")
else:
return await evt.reply(f"Channel is now private.")
except ChatAdminRequiredError:
return await evt.reply(
"You don't have the permission to set the username of this channel.")
except UsernameNotModifiedError:
if portal.username:
return await evt.reply("That is already the username of this channel.")
else:
return await evt.reply("This channel is already private")
except UsernameOccupiedError:
return await evt.reply("That username is already in use.")
except UsernameInvalidError:
return await evt.reply("Invalid username")
@command_handler(needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`>",
help_text="Change whether the bridge will allow or disallow bridging rooms by "
"default.")
async def filter_mode(evt: CommandEvent) -> Dict:
try:
mode = evt.args[0]
if mode not in ("whitelist", "blacklist"):
raise ValueError()
except (IndexError, ValueError):
return await evt.reply("**Usage:** `$cmdprefix+sp filter-mode <whitelist/blacklist>`")
evt.config["bridge.filter.mode"] = mode
evt.config.save()
po.Portal.filter_mode = mode
if mode == "whitelist":
return await evt.reply("The bridge will now disallow bridging chats by default.\n"
"To allow bridging a specific chat, use"
"`!filter whitelist <chat ID>`.")
else:
return await evt.reply("The bridge will now allow bridging chats by default.\n"
"To disallow bridging a specific chat, use"
"`!filter blacklist <chat ID>`.")
@command_handler(needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
help_text="Allow or disallow bridging a specific chat.")
async def filter(evt: CommandEvent) -> Optional[Dict]:
try:
action = evt.args[0]
if action not in ("whitelist", "blacklist", "add", "remove"):
raise ValueError()
id_str = evt.args[1]
if id_str.startswith("-100"):
id = int(id_str[4:])
elif id_str.startswith("-"):
id = int(id_str[1:])
else:
id = int(id_str)
except (IndexError, ValueError):
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
mode = evt.config["bridge.filter.mode"]
if mode not in ("blacklist", "whitelist"):
return await evt.reply(f"Unknown filter mode \"{mode}\". Please fix the bridge config.")
list = evt.config["bridge.filter.list"]
if action in ("blacklist", "whitelist"):
action = "add" if mode == action else "remove"
def save() -> None:
evt.config["bridge.filter.list"] = list
evt.config.save()
po.Portal.filter_list = list
if action == "add":
if id in list:
return await evt.reply(f"That chat is already {mode}ed.")
list.append(id)
save()
return await evt.reply(f"Chat ID added to {mode}.")
elif action == "remove":
if id not in list:
return await evt.reply(f"That chat is not {mode}ed.")
list.remove(id)
save()
return await evt.reply(f"Chat ID removed from {mode}.")
return None
@@ -0,0 +1 @@
from . import admin, bridge, config, create_chat, filter, misc, unbridge
+98
View File
@@ -0,0 +1,98 @@
# 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/>.
import asyncio
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
@command_handler(needs_admin=True, needs_auth=False, name="set-pl",
help_section=SECTION_ADMIN,
help_args="<_level_> [_mxid_]",
help_text="Set a temporary power level without affecting Telegram.")
async def set_power_level(evt: CommandEvent) -> EventID:
try:
level = int(evt.args[0])
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
try:
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.")
@command_handler(needs_admin=True, needs_auth=False,
help_section=SECTION_ADMIN,
help_args="<`portal`|`puppet`|`user`>",
help_text="Clear internal bridge caches")
async def clear_db_cache(evt: CommandEvent) -> EventID:
try:
section = evt.args[0].lower()
except IndexError:
return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache <section>`")
if section == "portal":
po.Portal.by_tgid = {}
po.Portal.by_mxid = {}
await evt.reply("Cleared portal cache")
elif section == "puppet":
pu.Puppet.cache = {}
for puppet in pu.Puppet.by_custom_mxid.values():
puppet.sync_task.cancel()
pu.Puppet.by_custom_mxid = {}
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 = {
user.mxid: user
for user in u.User.by_tgid.values()
}
await evt.reply("Cleared non-logged-in user cache")
else:
return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache <section>`")
@command_handler(needs_admin=True, needs_auth=False,
help_section=SECTION_ADMIN,
help_args="[_mxid_]",
help_text="Reload and reconnect a user")
async def reload_user(evt: CommandEvent) -> EventID:
if len(evt.args) > 0:
mxid = evt.args[0]
else:
mxid = evt.sender.mxid
user = u.User.get_by_mxid(mxid, create=False)
if not user:
return await evt.reply("User not found")
puppet = pu.Puppet.get_by_custom_mxid(mxid)
if puppet:
puppet.sync_task.cancel()
await user.stop()
user.delete(delete_db=False)
user = u.User.get_by_mxid(mxid)
await user.ensure_started()
if puppet:
await puppet.start()
return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
+187
View File
@@ -0,0 +1,187 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Tuple, Coroutine
import asyncio
from telethon.tl.types import ChatForbidden, ChannelForbidden
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
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_CREATING_PORTALS,
help_args="[_id_]",
help_text="Bridge the current Matrix room to the Telegram chat with the given "
"ID. The ID must be the prefixed version that you get with the `/id` "
"command of the Telegram-side bot.")
async def bridge(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** "
"`$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`")
force_use_bot = False
if evt.args[0] == "--usebot" and evt.sender.is_admin:
force_use_bot = True
evt.args = evt.args[1:]
room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
that_this = "This" if room_id == evt.room_id else "That"
portal = po.Portal.get_by_mxid(room_id)
if portal:
return await evt.reply(f"{that_this} room is already a portal room.")
if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply(f"You do not have the permissions to bridge {that_this} room.")
# The /id bot command provides the prefixed ID, so we assume
tgid_str = evt.args[0]
if tgid_str.startswith("-100"):
tgid = TelegramID(int(tgid_str[4:]))
peer_type = "channel"
elif tgid_str.startswith("-"):
tgid = TelegramID(-int(tgid_str))
peer_type = "chat"
else:
return await evt.reply("That doesn't seem like a prefixed Telegram chat ID.\n\n"
"If you did not get the ID using the `/id` bot command, please "
"prefix channel IDs with `-100` and normal group IDs with `-`.\n\n"
"Bridging private chats to existing rooms is not allowed.")
portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type)
if not portal.allow_bridging:
return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n"
"If you're the bridge admin, try "
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first.")
if portal.mxid:
has_portal_message = (
"That Telegram chat already has a portal at "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). ")
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
return await evt.reply(f"{has_portal_message}"
"Additionally, you do not have the permissions to unbridge "
"that room.")
evt.sender.command_status = {
"next": confirm_bridge,
"action": "Room bridging",
"mxid": portal.mxid,
"bridge_to_mxid": room_id,
"tgid": portal.tgid,
"peer_type": portal.peer_type,
"force_use_bot": force_use_bot,
}
return await evt.reply(f"{has_portal_message}"
"However, you have the permissions to unbridge that room.\n\n"
"To delete that portal completely and continue bridging, use "
"`$cmdprefix+sp delete-and-continue`. To unbridge the portal "
"without kicking Matrix users, use `$cmdprefix+sp unbridge-and-"
"continue`. To cancel, use `$cmdprefix+sp cancel`")
evt.sender.command_status = {
"next": confirm_bridge,
"action": "Room bridging",
"bridge_to_mxid": room_id,
"tgid": portal.tgid,
"peer_type": portal.peer_type,
"force_use_bot": force_use_bot,
}
return await evt.reply("That Telegram chat has no existing portal. To confirm bridging the "
"chat to this room, use `$cmdprefix+sp continue`")
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
) -> 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)")
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)
else:
await evt.reply(
"The chat you were trying to bridge already has a Matrix portal room.\n\n"
"Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-"
"continue` to either delete or unbridge the existing room (respectively) and "
"continue with the bridging.\n\n"
"If you changed your mind, use `$cmdprefix+sp cancel` to cancel.")
return False, None
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"])
bridge_to_mxid = status["bridge_to_mxid"]
except KeyError:
evt.sender.command_status = None
return await evt.reply("Fatal error: tgid or peer_type missing from command_status. "
"This shouldn't happen unless you're messing with the command "
"handler code.")
if "mxid" in status:
ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
if not ok:
return None
elif coro:
asyncio.ensure_future(coro, loop=evt.loop)
await evt.reply("Cleaning up previous portal room...")
elif portal.mxid:
evt.sender.command_status = None
return await evt.reply("The portal seems to have created a Matrix room between you "
"calling `$cmdprefix+sp bridge` and this command.\n\n"
"Please start over by calling the bridge command again.")
elif evt.args[0] != "continue":
return await evt.reply("Please use `$cmdprefix+sp continue` to confirm the bridging or "
"`$cmdprefix+sp cancel` to cancel.")
evt.sender.command_status = None
is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"]
user = evt.sender if is_logged_in else evt.tgbot
try:
entity = await user.client.get_entity(portal.peer)
except Exception:
evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer)
if is_logged_in:
return await evt.reply("Failed to get info of telegram chat. "
"You are logged in, are you in that chat?")
else:
return await evt.reply("Failed to get info of telegram chat. "
"You're not logged in, is the relay bot in the chat?")
if isinstance(entity, (ChatForbidden, ChannelForbidden)):
if is_logged_in:
return await evt.reply("You don't seem to be in that chat.")
else:
return await evt.reply("The bot doesn't seem to be in that chat.")
direct = False
portal.mxid = bridge_to_mxid
portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id)
portal.photo_id = ""
portal.save()
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.")
+134
View File
@@ -0,0 +1,134 @@
# 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
from io import StringIO
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,
help_text="View or change per-portal settings.",
help_args="<`help`|_subcommand_> [...]")
async def config(evt: CommandEvent) -> None:
cmd = evt.args[0].lower() if len(evt.args) > 0 else "help"
if cmd not in ("view", "defaults", "set", "unset", "add", "del"):
await config_help(evt)
return
elif cmd == "defaults":
await config_defaults(evt)
return
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
await evt.reply("This is not a portal room.")
return
elif cmd == "view":
await config_view(evt, portal)
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":
await config_set(evt, portal, key, value)
elif cmd == "unset":
await config_unset(evt, portal, key)
elif cmd == "add" or cmd == "del":
await config_add_del(evt, portal, key, value, cmd)
else:
return
portal.save()
def config_help(evt: CommandEvent) -> Awaitable[EventID]:
return evt.reply("""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
* **help** - View this help text.
* **view** - View the current config data.
* **defaults** - View the default config values.
* **set** <_key_> <_value_> - Set a config value.
* **unset** <_key_> - Remove a config value.
* **add** <_key_> <_value_> - Add a value to an array.
* **del** <_key_> <_value_> - Remove a value from an array.
""")
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[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[EventID]:
stream = StringIO()
yaml.dump({
"bridge_notices": {
"default": evt.config["bridge.bridge_notices.default"],
"exceptions": evt.config["bridge.bridge_notices.exceptions"],
},
"bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"],
"inline_images": evt.config["bridge.inline_images"],
"message_formats": evt.config["bridge.message_formats"],
"emote_format": evt.config["bridge.emote_format"],
"state_event_formats": evt.config["bridge.state_event_formats"],
"telegram_link_preview": evt.config["bridge.telegram_link_preview"],
}, 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[EventID]:
if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
elif util.recursive_set(portal.local_config, key, value):
return evt.reply(f"Successfully set the value of `{key}` to `{value}`.")
else:
return evt.reply(f"Failed to set value of `{key}`. "
"Does the path contain non-map types?")
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[EventID]:
if not key:
return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`")
elif util.recursive_del(portal.local_config, key):
return evt.reply(f"Successfully deleted `{key}` from config.")
else:
return evt.reply(f"`{key}` not found in config.")
def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
) -> Awaitable[EventID]:
if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
arr = util.recursive_get(portal.local_config, key)
if not arr:
return evt.reply(f"`{key}` not found in config. "
f"Maybe do `$cmdprefix+sp config set {key} []` first?")
elif not isinstance(arr, list):
return evt.reply("`{key}` does not seem to be an array.")
elif cmd == "add":
if value in arr:
return evt.reply(f"The array at `{key}` already contains `{value}`.")
arr.append(value)
return evt.reply(f"Successfully added `{value}` to the array at `{key}`")
else:
if value not in arr:
return evt.reply(f"The array at `{key}` does not contain `{value}`.")
arr.remove(value)
return evt.reply(f"Successfully removed `{value}` from the array at `{key}`")
@@ -0,0 +1,60 @@
# 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.types import EventID
from ... import portal as po
from ...types import TelegramID
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state
@command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="[_type_]",
help_text="Create a Telegram chat of the given type for the current Matrix room. "
"The type is either `group`, `supergroup` or `channel` (defaults to "
"`group`).")
async def create(evt: CommandEvent) -> EventID:
type = evt.args[0] if len(evt.args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}:
return await evt.reply(
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
if po.Portal.get_by_mxid(evt.room_id):
return await evt.reply("This is already a portal room.")
if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply("You do not have the permissions to bridge this room.")
title, about, levels = await get_initial_state(evt.az.intent, evt.room_id)
if not title:
return await evt.reply("Please set a title before creating a Telegram chat.")
supergroup = type == "supergroup"
type = {
"supergroup": "channel",
"channel": "channel",
"chat": "chat",
"group": "chat",
}[type]
portal = po.Portal(tgid=TelegramID(0), peer_type=type,
mxid=evt.room_id, title=title, about=about)
try:
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
except ValueError as e:
portal.delete()
return await evt.reply(e.args[0])
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
@@ -0,0 +1,95 @@
# 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.types import EventID
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_ADMIN
@command_handler(needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`>",
help_text="Change whether the bridge will allow or disallow bridging rooms by "
"default.")
async def filter_mode(evt: CommandEvent) -> EventID:
try:
mode = evt.args[0]
if mode not in ("whitelist", "blacklist"):
raise ValueError()
except (IndexError, ValueError):
return await evt.reply("**Usage:** `$cmdprefix+sp filter-mode <whitelist/blacklist>`")
evt.config["bridge.filter.mode"] = mode
evt.config.save()
po.Portal.filter_mode = mode
if mode == "whitelist":
return await evt.reply("The bridge will now disallow bridging chats by default.\n"
"To allow bridging a specific chat, use"
"`!filter whitelist <chat ID>`.")
else:
return await evt.reply("The bridge will now allow bridging chats by default.\n"
"To disallow bridging a specific chat, use"
"`!filter blacklist <chat ID>`.")
@command_handler(name="filter", needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
help_text="Allow or disallow bridging a specific chat.")
async def edit_filter(evt: CommandEvent) -> EventID:
try:
action = evt.args[0]
if action not in ("whitelist", "blacklist", "add", "remove"):
raise ValueError()
id_str = evt.args[1]
if id_str.startswith("-100"):
filter_id = int(id_str[4:])
elif id_str.startswith("-"):
filter_id = int(id_str[1:])
else:
filter_id = int(id_str)
except (IndexError, ValueError):
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
mode = evt.config["bridge.filter.mode"]
if mode not in ("blacklist", "whitelist"):
return await evt.reply(f"Unknown filter mode \"{mode}\". Please fix the bridge config.")
filter_id_list = evt.config["bridge.filter.list"]
if action in ("blacklist", "whitelist"):
action = "add" if mode == action else "remove"
def save() -> None:
evt.config["bridge.filter.list"] = filter_id_list
evt.config.save()
po.Portal.filter_list = filter_id_list
if action == "add":
if filter_id in filter_id_list:
return await evt.reply(f"That chat is already {mode}ed.")
filter_id_list.append(filter_id)
save()
return await evt.reply(f"Chat ID added to {mode}.")
elif action == "remove":
if filter_id not in filter_id_list:
return await evt.reply(f"That chat is not {mode}ed.")
filter_id_list.remove(filter_id)
save()
return await evt.reply(f"Chat ID removed from {mode}.")
else:
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
+154
View File
@@ -0,0 +1,154 @@
# 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 telethon.tl.functions.channels import GetFullChannelRequest
from telethon.tl.functions.messages import GetFullChatRequest
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
UsernameNotModifiedError, UsernameOccupiedError, RPCError)
from mautrix.types import EventID
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC
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) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply(f"You do not have the permissions to synchronize this room.")
await portal.sync_matrix_members()
await 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) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
tgid = portal.tgid
if portal.peer_type == "chat":
tgid = -tgid
elif portal.peer_type == "channel":
tgid = f"-100{tgid}"
await evt.reply(f"This room is bridged to Telegram chat ID `{tgid}`.")
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Get a Telegram invite link to the current chat.")
async def invite_link(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
if portal.peer_type == "user":
return await evt.reply("You can't invite users to private chats.")
try:
link = await portal.get_invite_link(evt.sender)
return await evt.reply(f"Invite link to {portal.title}: {link}")
except ValueError as e:
return await evt.reply(e.args[0])
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to create an invite link.")
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.")
async def upgrade(evt: CommandEvent) -> EventID:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type == "channel":
return await evt.reply("This is already a supergroup or a channel.")
elif portal.peer_type == "user":
return await evt.reply("You can't upgrade private chats.")
try:
await portal.upgrade_telegram_chat(evt.sender)
return await evt.reply(f"Group upgraded to supergroup. New ID: -100{portal.tgid}")
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to upgrade this group.")
except ValueError as e:
return await evt.reply(e.args[0])
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_args="<_name_|`-`>",
help_text="Change the username of a supergroup/channel. "
"To disable, use a dash (`-`) as the name.")
async def group_name(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type != "channel":
return await evt.reply("Only channels and supergroups have usernames.")
try:
await portal.set_telegram_username(evt.sender,
evt.args[0] if evt.args[0] != "-" else "")
if portal.username:
return await evt.reply(f"Username of channel changed to {portal.username}.")
else:
return await evt.reply(f"Channel is now private.")
except ChatAdminRequiredError:
return await evt.reply(
"You don't have the permission to set the username of this channel.")
except UsernameNotModifiedError:
if portal.username:
return await evt.reply("That is already the username of this channel.")
else:
return await evt.reply("This channel is already private")
except UsernameOccupiedError:
return await evt.reply("That username is already in use.")
except UsernameInvalidError:
return await evt.reply("Invalid username")
@@ -0,0 +1,97 @@
# 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, Callable, Optional
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
async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
action: Optional[str] = None
) -> Optional[po.Portal]:
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:
that_this = "This" if room_id == evt.room_id else "That"
await evt.reply(f"{that_this} is not a portal room.")
return None
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission):
action = action or f"{permission.replace('_', ' ')}s"
await evt.reply(f"You do not have the permissions to {action} that portal.")
return None
return portal
def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str,
completed_message: str) -> Dict:
async def post_confirm(confirm) -> Optional[EventID]:
confirm.sender.command_status = None
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
await function()
if confirm.room_id != room_id:
return await confirm.reply(completed_message)
else:
return await confirm.reply(f"{action} cancelled.")
return None
return {
"next": post_confirm,
"action": action,
}
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove all users from the current portal room and forget the portal. "
"Only works for group chats; to delete a private chat portal, simply "
"leave the room.")
async def delete_portal(evt: CommandEvent) -> Optional[EventID]:
portal = await _get_portal_and_check_permission(evt, "unbridge")
if not portal:
return None
evt.sender.command_status = _get_portal_murder_function("Portal deletion", portal.mxid,
portal.cleanup_and_delete, "delete",
"Portal successfully deleted.")
return await evt.reply("Please confirm deletion of portal "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
f"to Telegram chat \"{portal.title}\" "
"by typing `$cmdprefix+sp confirm-delete`"
"\n\n"
"**WARNING:** If the bridge bot has the power level to do so, **this "
"will kick ALL users** in the room. If you just want to remove the "
"bridge, use `$cmdprefix+sp unbridge` instead.")
@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[EventID]:
portal = await _get_portal_and_check_permission(evt, "unbridge")
if not portal:
return None
evt.sender.command_status = _get_portal_murder_function("Room unbridging", portal.mxid,
portal.unbridge, "unbridge",
"Room successfully unbridged.")
return await evt.reply(f"Please confirm unbridging chat \"{portal.title}\" from room "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
"by typing `$cmdprefix+sp confirm-unbridge`")
+60
View File
@@ -0,0 +1,60 @@
# 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 Tuple, Optional
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: 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 == 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_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_id)
except MatrixRequestError:
return False
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)
-160
View File
@@ -1,160 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 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
import re
from telethon.errors import (
InviteHashInvalidError, InviteHashExpiredError, UserAlreadyParticipantError)
from telethon.tl.types import User as TLUser
from telethon.tl.types import TypeUpdates
from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest
from telethon.tl.functions.channels import JoinChannelRequest
from .. import puppet as pu, portal as po
from . import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS
@command_handler(help_section=SECTION_MISC,
help_args="[_-r|--remote_] <_query_>",
help_text="Search your contacts or the Telegram servers for users.")
async def search(evt: CommandEvent) -> Optional[Dict]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
force_remote = False
if evt.args[0] in {"-r", "--remote"}:
force_remote = True
evt.args.pop(0)
query = " ".join(evt.args)
if force_remote and len(query) < 5:
return await evt.reply("Minimum length of query for remote search is 5 characters.")
results, remote = await evt.sender.search(query, force_remote)
if not results:
if len(query) < 5 and remote:
return await evt.reply("No local results. "
"Minimum length of remote query is 5 characters.")
return await evt.reply("No results 3:")
reply = [] # type: List[str]
if remote:
reply += ["**Results from Telegram server:**", ""]
else:
reply += ["**Results in contacts:**", ""]
reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
f"{puppet.id} ({similarity}% match)")
for puppet, similarity in results]
# TODO somehow show remote channel results when joining by alias is possible?
return await evt.reply("\n".join(reply))
@command_handler(name="pm",
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 private_message(evt: CommandEvent) -> Optional[Dict]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
try:
user = await evt.sender.client.get_entity(evt.args[0])
except ValueError:
return await evt.reply("Invalid user identifier or user not found.")
if not user:
return await evt.reply("User not found.")
elif not isinstance(user, TLUser):
return await evt.reply("That doesn't seem to be a user.")
portal = po.Portal.get_by_entity(user, evt.sender.tgid)
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
return await evt.reply("Created private chat room with "
f"{pu.Puppet.get_displayname(user, False)}")
async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[Dict]]:
if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):]
try:
await evt.sender.client(CheckChatInviteRequest(invite_hash))
except InviteHashInvalidError:
return None, await evt.reply("Invalid invite link.")
except InviteHashExpiredError:
return None, await evt.reply("Invite link expired.")
try:
return (await evt.sender.client(ImportChatInviteRequest(invite_hash))), None
except UserAlreadyParticipantError:
return None, await evt.reply("You are already in that chat.")
else:
channel = await evt.sender.client.get_entity(arg)
if not channel:
return None, await evt.reply("Channel/supergroup not found.")
return await evt.sender.client(JoinChannelRequest(channel)), None
@command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="<_link_>",
help_text="Join a chat with an invite link.")
async def join(evt: CommandEvent) -> Optional[Dict]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
arg = regex.match(evt.args[0])
if not arg:
return await evt.reply("That doesn't look like a Telegram invite link.")
updates, _ = await _join(evt, arg.group(1))
if not updates:
return None
for chat in updates.chats:
portal = po.Portal.get_by_entity(chat)
if portal.mxid:
await portal.invite_to_matrix([evt.sender.mxid])
return await evt.reply(f"Invited you to portal of {portal.title}")
else:
await evt.reply(f"Creating room for {chat.title}... This might take a while.")
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
return await evt.reply(f"Created room for {portal.title}")
return None
@command_handler(help_section=SECTION_MISC,
help_args="[`chats`|`contacts`|`me`]",
help_text="Synchronize your chat portals, contacts and/or own info.")
async def sync(evt: CommandEvent) -> Optional[Dict]:
if len(evt.args) > 0:
sync_only = evt.args[0]
if sync_only not in ("chats", "contacts", "me"):
return await evt.reply("**Usage:** `$cmdprefix+sp sync [chats|contacts|me]`")
else:
sync_only = None
if not sync_only or sync_only == "chats":
await evt.sender.sync_dialogs(synchronous_create=True)
if not sync_only or sync_only == "contacts":
await evt.sender.sync_contacts()
if not sync_only or sync_only == "me":
await evt.sender.update_info()
return await evt.reply("Synchronization complete.")
@@ -0,0 +1 @@
from . import account, auth, misc
@@ -0,0 +1,125 @@
# 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
from telethon.errors import (UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError,
HashInvalidError, AuthKeyError, FirstNameInvalidError)
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
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
help_args="<_new username_>",
help_text="Change your Telegram username.")
async def username(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp username <new username>`")
if evt.sender.is_bot:
return await evt.reply("Bots can't set their own username.")
new_name = evt.args[0]
if new_name == "-":
new_name = ""
try:
await evt.sender.client(UpdateUsernameRequest(username=new_name))
except UsernameInvalidError:
return await evt.reply("Invalid username. Usernames must be between 5 and 30 alphanumeric "
"characters.")
except UsernameNotModifiedError:
return await evt.reply("That is your current username.")
except UsernameOccupiedError:
return await evt.reply("That username is already in use.")
await evt.sender.update_info()
if not evt.sender.username:
await evt.reply("Username removed")
else:
await evt.reply(f"Username changed to {evt.sender.username}")
@command_handler(needs_auth=True, help_section=SECTION_AUTH, help_args="<_new displayname_>",
help_text="Change your Telegram displayname.")
async def displayname(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp displayname <new displayname>`")
if evt.sender.is_bot:
return await evt.reply("Bots can't set their own displayname.")
first_name, last_name = ((evt.args[0], "")
if len(evt.args) == 1
else (" ".join(evt.args[:-1]), evt.args[-1]))
try:
await evt.sender.client(UpdateProfileRequest(first_name=first_name, last_name=last_name))
except FirstNameInvalidError:
return await evt.reply("Invalid first name")
await evt.sender.update_info()
return await evt.reply("Displayname updated")
def _format_session(sess: Authorization) -> str:
return (f"**{sess.app_name} {sess.app_version}** \n"
f" **Platform:** {sess.device_model} {sess.platform} {sess.system_version} \n"
f" **Active:** {sess.date_active} (created {sess.date_created}) \n"
f" **From:** {sess.ip} - {sess.region}, {sess.country}")
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
help_args="<`list`|`terminate`> [_hash_]",
help_text="View or delete other Telegram sessions.")
async def session(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
elif evt.sender.is_bot:
return await evt.reply("Bots can't manage their sessions")
cmd = evt.args[0].lower()
if cmd == "list":
res = await evt.sender.client(GetAuthorizationsRequest())
session_list = res.authorizations
current = [s for s in session_list if s.current][0]
current_text = _format_session(current)
other_text = "\n".join(f"* {_format_session(sess)} \n"
f" **Hash:** {sess.hash}"
for sess in session_list if not sess.current)
return await evt.reply(f"### Current session\n"
f"{current_text}\n"
f"\n"
f"### Other active sessions\n"
f"{other_text}")
elif cmd == "terminate" and len(evt.args) > 1:
try:
session_hash = int(evt.args[1])
except ValueError:
return await evt.reply("Hash must be an integer")
try:
ok = await evt.sender.client(ResetAuthorizationRequest(hash=session_hash))
except HashInvalidError:
return await evt.reply("Invalid session hash.")
except AuthKeyError as e:
if e.message == "FRESH_RESET_AUTHORISATION_FORBIDDEN":
return await evt.reply("New sessions can't terminate other sessions. "
"Please wait a while.")
raise
if ok:
return await evt.reply("Session terminated successfully.")
else:
return await evt.reply("Session not found.")
else:
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -17,24 +16,27 @@
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)
from . import command_handler, CommandEvent, SECTION_AUTH
from .. import puppet as pu, user as u
from ..util import format_duration
from mautrix.types import EventID
from ... import user as u
from ...commands import command_handler, CommandEvent, SECTION_AUTH
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:
return await evt.reply(f"You're logged in as @{me.username}")
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
return await evt.reply(f"You're logged in as {human_tg_id}")
else:
return await evt.reply("You're not logged in.")
@@ -42,87 +44,20 @@ 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.")
bot_info = await evt.tgbot.client.get_me()
mxid = pu.Puppet.get_mxid_from_id(bot_info.id)
displayname = bot_info.first_name
info, mxid = await evt.tgbot.get_me(use_cache=False)
return await evt.reply("Telegram message relay bot is active: "
f"[{displayname}](https://matrix.to/#/{mxid}) (ID {bot_info.id})\n\n"
f"[{info.first_name}](https://matrix.to/#/{mxid}) (ID {info.id})\n\n"
"To use the bot, simply invite it to a portal room.")
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Revert your Telegram account's Matrix puppet to use the default Matrix "
"account.")
async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid)
if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.")
await puppet.switch_mxid(None, None)
return await evt.reply("Reverted your Telegram account's Matrix puppet back to the default.")
@command_handler(needs_auth=True, management_only=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Replace your Telegram account's Matrix puppet with your own Matrix "
"account")
async def login_matrix(evt: CommandEvent) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid)
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.")
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
if allow_matrix_login:
evt.sender.command_status = {
"next": enter_matrix_token,
"action": "Matrix login",
}
if evt.config["appservice.public.enabled"]:
prefix = evt.config["appservice.public.external"]
url = f"{prefix}/matrix-login?token={evt.public_website.make_token(evt.sender.mxid, '/matrix-login')}"
if allow_matrix_login:
return await evt.reply(
"This bridge instance allows you to log in inside or outside Matrix.\n\n"
"If you would like to log in within Matrix, please send your Matrix access token "
"here.\n"
f"If you would like to log in outside of Matrix, [click here]({url}).\n\n"
"Logging in outside of Matrix is recommended, because in-Matrix login would save "
"your access token in the message history.")
return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n"
f"Please visit [the login page]({url}) to log in.")
elif allow_matrix_login:
return await evt.reply(
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
"Please send your Matrix access token here to log in.")
return await evt.reply("This bridge instance has been configured to not allow logging in.")
async def enter_matrix_token(evt: CommandEvent) -> Dict:
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:
return await evt.reply("You can only log in as your own Matrix user.")
elif resp == pu.PuppetError.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}.")
@command_handler(needs_auth=False, management_only=True,
help_section=SECTION_AUTH,
help_args="<_phone_> <_full name_>",
help_text="Register to Telegram")
async def register(evt: CommandEvent) -> Optional[Dict]:
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:
@@ -134,7 +69,7 @@ async def register(evt: CommandEvent) -> Optional[Dict]:
else:
full_name = " ".join(evt.args[1:-1]), evt.args[-1]
await request_code(evt, phone_number, {
await _request_code(evt, phone_number, {
"next": enter_code_register,
"action": "Register",
"full_name": full_name,
@@ -142,7 +77,7 @@ 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:
@@ -171,39 +106,44 @@ 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()
override_sender = True
if await evt.sender.is_logged_in():
return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.")
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
if allow_matrix_login:
if allow_matrix_login and not override_sender:
evt.sender.command_status = {
"next": enter_phone_or_token,
"action": "Login",
}
nb = "**N.B. Logging in grants the bridge full access to your Telegram account.**"
if evt.config["appservice.public.enabled"]:
prefix = evt.config["appservice.public.external"]
url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}"
if allow_matrix_login:
return await evt.reply(
"This bridge instance allows you to log in inside or outside Matrix.\n\n"
"If you would like to log in within Matrix, please send your phone number or bot "
"auth token here.\n"
f"If you would like to log in outside of Matrix, [click here]({url}).\n\n"
"Logging in outside of Matrix is recommended if you have two-factor authentication "
"enabled, because in-Matrix login would save your password in the message history.")
return await evt.reply("This bridge instance does not allow logging in inside Matrix.\n\n"
f"Please visit [the login page]({url}) to log in.")
if override_sender:
return await evt.reply(f"[Click here to log in]({url}) as "
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid}).")
elif allow_matrix_login:
return await evt.reply(f"[Click here to log in]({url}). Alternatively, send your phone"
f" number (or bot auth token) here to log in.\n\n{nb}")
return await evt.reply(f"[Click here to log in]({url}).\n\n{nb}")
elif allow_matrix_login:
return await evt.reply(
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
"Please send your phone number or bot auth token here to start the login process.")
if override_sender:
return await evt.reply(
"This bridge instance does not allow you to log in outside of Matrix. "
"Logging in as another user inside Matrix is not currently possible.")
return await evt.reply("Please send your phone number (or bot auth token) here to start "
f"the login process.\n\n{nb}")
return await evt.reply("This bridge instance has been configured to not allow logging in.")
async def request_code(evt: CommandEvent, phone_number: str, next_status: Dict[str, Any]
) -> Dict:
async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[str, Any]
) -> EventID:
ok = False
try:
await evt.sender.ensure_started(even_if_no_session=True)
@@ -235,7 +175,7 @@ async def request_code(evt: CommandEvent, phone_number: str, next_status: Dict[s
@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):
@@ -245,13 +185,13 @@ async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
# phone numbers don't contain colons but telegram bot auth tokens do
if evt.args[0].find(":") > 0:
try:
await sign_in(evt, bot_token=evt.args[0])
await _sign_in(evt, bot_token=evt.args[0])
except Exception:
evt.log.exception("Error sending auth token")
return await evt.reply("Unhandled exception while sending auth token. "
"Check console for more details.")
else:
await request_code(evt, evt.args[0], {
await _request_code(evt, evt.args[0], {
"next": enter_code,
"action": "Login",
})
@@ -259,14 +199,14 @@ 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):
return await evt.reply("This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions")
try:
await sign_in(evt, code=evt.args[0])
await _sign_in(evt, code=evt.args[0])
except Exception:
evt.log.exception("Error sending phone code")
return await evt.reply("Unhandled exception while sending code. "
@@ -275,14 +215,14 @@ 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):
return await evt.reply("This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions")
try:
await sign_in(evt, password=" ".join(evt.args))
await _sign_in(evt, password=" ".join(evt.args))
except AccessTokenInvalidError:
return await evt.reply("That bot token is not valid.")
except AccessTokenExpiredError:
@@ -294,7 +234,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)
@@ -326,7 +266,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.")
+305
View File
@@ -0,0 +1,305 @@
# 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, cast
import logging
import codecs
import base64
import re
from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError,
UserAlreadyParticipantError, ChatIdInvalidError)
from telethon.tl.patched import Message
from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll,
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
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) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
force_remote = False
if evt.args[0] in {"-r", "--remote"}:
force_remote = True
evt.args.pop(0)
query = " ".join(evt.args)
if force_remote and len(query) < 5:
return await evt.reply("Minimum length of query for remote search is 5 characters.")
results, remote = await evt.sender.search(query, force_remote)
if not results:
if len(query) < 5 and remote:
return await evt.reply("No local results. "
"Minimum length of remote query is 5 characters.")
return await evt.reply("No results 3:")
reply: List[str] = []
if remote:
reply += ["**Results from Telegram server:**", ""]
else:
reply += ["**Results in contacts:**", ""]
reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
f"{puppet.id} ({similarity}% match)")
for puppet, similarity in results]
# TODO somehow show remote channel results when joining by alias is possible?
return await evt.reply("\n".join(reply))
@command_handler(help_section=SECTION_CREATING_PORTALS, help_args="<_identifier_>",
help_text="Open a private chat with the given Telegram user. The identifier is "
"either the internal user ID, the username or the phone number. "
"**N.B.** The phone numbers you start chats with must already be in "
"your contacts.")
async def pm(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
try:
user = await evt.sender.client.get_entity(evt.args[0])
except ValueError:
return await evt.reply("Invalid user identifier or user not found.")
if not user:
return await evt.reply("User not found.")
elif not isinstance(user, TLUser):
return await evt.reply("That doesn't seem to be a user.")
portal = po.Portal.get_by_entity(user, evt.sender.tgid)
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
return await evt.reply("Created private chat room with "
f"{pu.Puppet.get_displayname(user, False)}")
async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[EventID]]:
if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):]
try:
await evt.sender.client(CheckChatInviteRequest(invite_hash))
except InviteHashInvalidError:
return None, await evt.reply("Invalid invite link.")
except InviteHashExpiredError:
return None, await evt.reply("Invite link expired.")
try:
return (await evt.sender.client(ImportChatInviteRequest(invite_hash))), None
except UserAlreadyParticipantError:
return None, await evt.reply("You are already in that chat.")
else:
channel = await evt.sender.client.get_entity(arg)
if not channel:
return None, await evt.reply("Channel/supergroup not found.")
return await evt.sender.client(JoinChannelRequest(channel)), None
@command_handler(help_section=SECTION_CREATING_PORTALS,
help_args="<_link_>",
help_text="Join a chat with an invite link.")
async def join(evt: CommandEvent) -> Optional[EventID]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
arg = regex.match(evt.args[0])
if not arg:
return await evt.reply("That doesn't look like a Telegram invite link.")
updates, _ = await _join(evt, arg.group(1))
if not updates:
return None
for chat in updates.chats:
portal = po.Portal.get_by_entity(chat)
if portal.mxid:
await portal.invite_to_matrix([evt.sender.mxid])
return await evt.reply(f"Invited you to portal of {portal.title}")
else:
await evt.reply(f"Creating room for {chat.title}... This might take a while.")
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
@command_handler(help_section=SECTION_MISC,
help_args="[`chats`|`contacts`|`me`]",
help_text="Synchronize your chat portals, contacts and/or own info.")
async def sync(evt: CommandEvent) -> EventID:
if len(evt.args) > 0:
sync_only = evt.args[0]
if sync_only not in ("chats", "contacts", "me"):
return await evt.reply("**Usage:** `$cmdprefix+sp sync [chats|contacts|me]`")
else:
sync_only = None
if not sync_only or sync_only == "chats":
await evt.sender.sync_dialogs(synchronous_create=True)
if not sync_only or sync_only == "contacts":
await evt.sender.sync_contacts()
if not sync_only or sync_only == "me":
await evt.sender.update_info()
return await evt.reply("Synchronization complete.")
PEER_TYPE_CHAT = b"g"
class MessageIDError(ValueError):
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
) -> Tuple[TypeInputPeer, Message]:
try:
enc_id += (4 - len(enc_id) % 4) * "="
enc_id = base64.b64decode(enc_id)
peer_type, enc_id = bytes([enc_id[0]]), enc_id[1:]
tgid = TelegramID(int(codecs.encode(enc_id[0:5], "hex_codec"), 16))
msg_id = TelegramID(int(codecs.encode(enc_id[5:10], "hex_codec"), 16))
space = None
if peer_type == PEER_TYPE_CHAT:
space = TelegramID(int(codecs.encode(enc_id[10:15], "hex_codec"), 16))
except ValueError as e:
raise MessageIDError(f"Invalid {type_name} ID (format)") from e
if peer_type == PEER_TYPE_CHAT:
orig_msg = DBMessage.get_one_by_tgid(msg_id, space)
if not orig_msg:
raise MessageIDError(f"Invalid {type_name} ID (original message not found in db)")
new_msg = DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
if not new_msg:
raise MessageIDError(f"Invalid {type_name} ID (your copy of message not found in db)")
msg_id = new_msg.tgid
try:
peer = await user.client.get_input_entity(tgid)
except ValueError as e:
raise MessageIDError(f"Invalid {type_name} ID (chat not found)") from e
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, cast(Message, msg)
@command_handler(help_section=SECTION_MISC,
help_args="<_play ID_>",
help_text="Play a Telegram game.")
async def play(evt: CommandEvent) -> EventID:
if len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp play <play ID>`")
elif not await evt.sender.is_logged_in():
return await evt.reply("You must be logged in with a real account to play games.")
elif evt.sender.is_bot:
return await evt.reply("Bots can't play games :(")
try:
peer, msg = await _parse_encoded_msgid(evt.sender, evt.args[0], type_name="play")
except MessageIDError as e:
return await evt.reply(e.message)
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))
if not isinstance(game, BotCallbackAnswer):
return await evt.reply("Game request response invalid")
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) -> 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():
return await evt.reply("You must be logged in with a real account to vote in polls.")
elif evt.sender.is_bot:
return await evt.reply("Bots can't vote in polls :(")
try:
peer, msg = await _parse_encoded_msgid(evt.sender, evt.args[0], type_name="poll")
except MessageIDError as e:
return await evt.reply(e.message)
if not isinstance(msg.media, MessageMediaPoll):
return await evt.reply("Invalid poll ID (message doesn't look like a poll)")
options = []
for option in evt.args[1:]:
try:
if len(option) > 10:
raise ValueError("option index too long")
option_index = int(option) - 1
except ValueError:
option_index = None
if option_index is None:
return await evt.reply(f"Invalid option number \"{option}\"",
render_markdown=False, allow_html=False)
elif option_index < 0:
return await evt.reply(f"Invalid option number {option}. "
f"Option numbers must be positive.")
elif option_index >= len(msg.media.poll.answers):
return await evt.reply(f"Invalid option number {option}. "
f"The poll only has {len(msg.media.poll.answers)} options.")
options.append(msg.media.poll.answers[option_index].option)
options = [msg.media.poll.answers[int(option) - 1].option
for option in evt.args[1:]]
try:
resp = await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
except OptionsTooMuchError:
return await evt.reply("You passed too many options.")
# TODO use response
return await evt.mark_read()
+96 -177
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -14,145 +13,31 @@
#
# 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
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)
class Config(BaseBridgeConfig):
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]
return os.environ[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
except KeyError:
pass
return super().__getitem__(key)
def delete(self, key: str, allow_recursion: bool = True) -> None:
if allow_recursion and '.' in key:
self._recursive_del(self._data, key)
return
try:
del self._data[key]
del self._data.ca.items[key]
except KeyError:
pass
def __delitem__(self, key: str) -> None:
self.delete(key)
class Config(DictWithRecursion):
def __init__(self, path: str, registration_path: str, base_path: str) -> None:
super().__init__()
self.path = path # type: str
self.registration_path = registration_path # type: str
self.base_path = base_path # type: str
self._registration = None # type: Optional[Dict]
def load(self) -> None:
with open(self.path, 'r') as stream:
self._data = yaml.load(stream)
def load_base(self) -> Optional[DictWithRecursion]:
try:
with open(self.base_path, 'r') as stream:
return DictWithRecursion(yaml.load(stream))
except OSError:
pass
return None
def save(self) -> None:
with open(self.path, 'w') as stream:
yaml.dump(self._data, stream)
if self._registration and self.registration_path:
with open(self.registration_path, 'w') as stream:
yaml.dump(self._registration, stream)
@staticmethod
def _new_token() -> str:
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
def update(self) -> None:
base = self.load_base()
if not base:
return
def copy(from_path, to_path=None) -> None:
if from_path in self:
base[to_path or from_path] = self[from_path]
def copy_dict(from_path, to_path=None, override_existing_map=True) -> None:
if from_path in self:
to_path = to_path or from_path
if override_existing_map or to_path not in base:
base[to_path] = CommentedMap()
for key, value in self[from_path].items():
base[to_path][key] = value
def do_update(self, helper: ConfigUpdateHelper) -> None:
copy, copy_dict, base = helper
copy("homeserver.address")
copy("homeserver.verify_ssl")
copy("homeserver.domain")
copy("homeserver.verify_ssl")
if "appservice.protocol" in self and "appservice.address" not in self:
protocol, hostname, port = (self["appservice.protocol"], self["appservice.hostname"],
@@ -165,7 +50,6 @@ class Config(DictWithRecursion):
copy("appservice.max_body_size")
copy("appservice.database")
copy("appservice.sqlalchemy_core_mode")
copy("appservice.public.enabled")
copy("appservice.public.prefix")
@@ -182,17 +66,50 @@ class Config(DictWithRecursion):
copy("appservice.bot_displayname")
copy("appservice.bot_avatar")
copy("appservice.community_id")
copy("appservice.as_token")
copy("appservice.hs_token")
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.edits_as_replies")
copy("bridge.highlight_edits")
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.sync_with_custom_puppets")
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):
base["bridge.bridge_notices"] = {
"default": self["bridge.bridge_notices"],
@@ -200,17 +117,6 @@ class Config(DictWithRecursion):
}
else:
copy("bridge.bridge_notices")
copy("bridge.bot_messages_as_notices")
copy("bridge.max_initial_member_sync")
copy("bridge.sync_channel_members")
copy("bridge.max_telegram_delete")
copy("bridge.allow_matrix_login")
copy("bridge.inline_images")
copy("bridge.plaintext_highlights")
copy("bridge.public_portals")
copy("bridge.native_stickers")
copy("bridge.catch_up")
copy("bridge.sync_with_custom_puppets")
copy("bridge.deduplication.pre_db_check")
copy("bridge.deduplication.cache_queue_length")
@@ -218,6 +124,8 @@ 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")
copy("bridge.state_event_formats.name_change")
@@ -243,6 +151,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")
@@ -251,6 +164,24 @@ class Config(DictWithRecursion):
copy("telegram.api_id")
copy("telegram.api_hash")
copy("telegram.bot_token")
copy("telegram.connection.timeout")
copy("telegram.connection.retries")
copy("telegram.connection.retry_delay")
copy("telegram.connection.flood_sleep_threshold")
copy("telegram.connection.request_retries")
copy("telegram.device_info.device_model")
copy("telegram.device_info.system_version")
copy("telegram.device_info.app_version")
copy("telegram.device_info.lang_code")
copy("telegram.device_info.system_lang_code")
copy("telegram.server.enabled")
copy("telegram.server.dc")
copy("telegram.server.ip")
copy("telegram.server.port")
copy("telegram.proxy.type")
copy("telegram.proxy.address")
copy("telegram.proxy.port")
@@ -266,55 +197,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}",
}]
}
+31 -24
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -15,36 +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 sqlalchemy.orm import scoped_session
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", db: "scoped_session", config: "Config",
loop: "asyncio.AbstractEventLoop", session_container: "AlchemySessionContainer"
) -> None:
self.az = az # type: AppService
self.db = db # type: scoped_session
self.config = config # type: Config
self.loop = loop # type: asyncio.AbstractEventLoop
self.bot = None # type: Optional[Bot]
self.mx = None # type: MatrixHandler
self.session_container = session_container # type: AlchemySessionContainer
self.public_website = None # type: PublicBridgeWebsite
self.provisioning_api = None # type: 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', 'scoped_session', 'Config',
'asyncio.AbstractEventLoop', Optional['Bot']]:
return (self.az, self.db, self.config, self.loop, self.bot)
def core(self) -> Tuple[AppService, 'Config', asyncio.AbstractEventLoop, Optional['Bot']]:
return self.az, self.config, self.loop, self.bot
-258
View File
@@ -1,258 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 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, UniqueConstraint, ForeignKey, ForeignKeyConstraint, Integer,
BigInteger, String, Boolean, Text, Table,
and_, func, select)
from sqlalchemy.engine import Engine, RowProxy
from sqlalchemy.sql import expression
from sqlalchemy.orm import relationship, Query
from sqlalchemy.sql.base import ImmutableColumnCollection
from typing import Dict, Optional, List
import json
from mautrix_telegram.types import MatrixUserID, MatrixRoomID, MatrixEventID
from .types import TelegramID
from .base import Base
class Portal(Base):
query = None # type: Query
__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)
# Matrix portal information
mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID]
config = 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)
class Message(Base):
db = None # type: Engine
t = None # type: Table
c = None # type: ImmutableColumnCollection
__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
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
@staticmethod
def _one_or_none(rows: RowProxy) -> Optional['Message']:
try:
mxid, mx_room, tgid, tg_space = next(rows)
return Message(mxid=mxid, mx_room=mx_room, tgid=tgid, tg_space=tg_space)
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])
for row in rows]
@classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> Optional['Message']:
rows = cls.db.execute(cls.t.select()
.where(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space)))
return cls._one_or_none(rows)
@classmethod
def count_spaces_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID) -> int:
rows = cls.db.execute(select([func.count(cls.c.tg_space)])
.where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room)))
try:
count, = next(rows)
return count
except StopIteration:
return 0
@classmethod
def get_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID, tg_space: TelegramID
) -> Optional['Message']:
rows = cls.db.execute(cls.t.select().where(
and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room, cls.c.tg_space == tg_space)))
return cls._one_or_none(rows)
@classmethod
def update_by_tgid(cls, s_tgid: TelegramID, s_tg_space: TelegramID, **values) -> None:
cls.db.execute(cls.t.update()
.where(and_(cls.c.tgid == s_tgid, cls.c.tg_space == s_tg_space))
.values(**values))
@classmethod
def update_by_mxid(cls, s_mxid: MatrixEventID, s_mx_room: MatrixRoomID, **values) -> None:
cls.db.execute(cls.t.update()
.where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room))
.values(**values))
def update(self, **values) -> None:
for key, value in values.items():
setattr(self, key, value)
self.update_by_tgid(self.tgid, self.tg_space, **values)
def delete(self) -> None:
self.db.execute(self.t.delete().where(
and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space)))
def insert(self) -> None:
self.db.execute(self.t.insert().values(mxid=self.mxid, mx_room=self.mx_room, tgid=self.tgid,
tg_space=self.tg_space))
class UserPortal(Base):
query = None # type: Query
__tablename__ = "user_portal"
user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"),
primary_key=True) # type: TelegramID
portal = Column(Integer, primary_key=True) # type: TelegramID
portal_receiver = Column(Integer, primary_key=True) # type: TelegramID
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"),
onupdate="CASCADE", ondelete="CASCADE"),)
class User(Base):
query = None # type: Query
__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)
contacts = relationship("Contact", uselist=True,
cascade="save-update, merge, delete, delete-orphan"
) # type: List[Contact]
portals = relationship("Portal", secondary="user_portal")
class RoomState(Base):
query = None # type: Query
__tablename__ = "mx_room_state"
room_id = Column(String, primary_key=True) # type: MatrixRoomID
_power_levels_text = Column("power_levels", Text, nullable=True)
_power_levels_json = {} # type: Dict
@property
def has_power_levels(self) -> bool:
return bool(self._power_levels_text)
@property
def power_levels(self) -> Dict:
if not self._power_levels_json and self._power_levels_text:
self._power_levels_json = json.loads(self._power_levels_text)
return self._power_levels_json
@power_levels.setter
def power_levels(self, val: Dict) -> None:
self._power_levels_json = val
self._power_levels_text = json.dumps(val)
class UserProfile(Base):
query = None # type: Query
__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,
}
class Contact(Base):
query = None # type: Query
__tablename__ = "contact"
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) # type: TelegramID
contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True) # type: TelegramID
class Puppet(Base):
query = None # type: Query
__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())
# Fucking Telegram not telling bots what chats they are in 3:<
class BotChat(Base):
query = None # type: Query
__tablename__ = "bot_chat"
id = Column(Integer, primary_key=True) # type: TelegramID
type = Column(String, nullable=False)
class TelegramFile(Base):
query = None # type: Query
__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 = relationship("TelegramFile", uselist=False)
def init(db_session, db_engine) -> None:
Portal.query = db_session.query_property()
Message.db = db_engine
Message.t = Message.__table__
Message.c = Message.t.c
UserPortal.query = db_session.query_property()
User.query = db_session.query_property()
Puppet.query = db_session.query_property()
BotChat.query = db_session.query_property()
TelegramFile.query = db_session.query_property()
UserProfile.query = db_session.query_property()
RoomState.query = db_session.query_property()
+34
View File
@@ -0,0 +1,34 @@
# 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.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 .telegram_file import TelegramFile
from .user import User, UserPortal, Contact
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()
+38
View File
@@ -0,0 +1,38 @@
# 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 Iterable
from sqlalchemy import Column, Integer, String
from mautrix.util.db import Base
from ..types import TelegramID
# Fucking Telegram not telling bots what chats they are in 3:<
class BotChat(Base):
__tablename__ = "bot_chat"
id: TelegramID = Column(Integer, primary_key=True)
type: str = Column(String, nullable=False)
@classmethod
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']:
return cls._select_all()
+84
View File
@@ -0,0 +1,84 @@
# 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, Iterator
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: 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_2"),)
@classmethod
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']:
if edit_index < 0:
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:
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: 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:
count, = next(rows)
return count
except StopIteration:
return 0
@classmethod
def get_by_mxid(cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
) -> Optional['Message']:
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,
**values) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.update()
.where(and_(cls.c.tgid == s_tgid, cls.c.tg_space == s_tg_space,
cls.c.edit_index == s_edit_index))
.values(**values))
@classmethod
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))
+56
View File
@@ -0,0 +1,56 @@
# 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
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: 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: RoomID = Column(String, unique=True, nullable=True)
config: str = Column(Text, nullable=True)
# Telegram chat metadata
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(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver)
@classmethod
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(func.lower(cls.c.username) == username)
+60
View File
@@ -0,0 +1,60 @@
# 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, Iterable
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: 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']:
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: 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(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)
+56
View File
@@ -0,0 +1,56 @@
# 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
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: 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']:
return cls._select_one_or_none(cls.c.id == loc_id)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
id=self.id, mxc=self.mxc, mime_type=self.mime_type,
was_converted=self.was_converted, timestamp=self.timestamp, size=self.size,
width=self.width, height=self.height,
thumbnail=self.thumbnail.id if self.thumbnail else self.thumbnail_id))
+108
View File
@@ -0,0 +1,108 @@
# 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, Iterable, Tuple
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: 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 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: 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(func.lower(cls.c.tg_username) == username)
@property
def contacts(self) -> Iterable[TelegramID]:
rows = self.db.execute(Contact.t.select().where(Contact.c.user == self.tgid))
for row in rows:
user, contact = row
yield contact
@contacts.setter
def contacts(self, puppets: Iterable[TelegramID]) -> None:
with self.db.begin() as conn:
conn.execute(Contact.t.delete().where(Contact.c.user == self.tgid))
insert_puppets = [{"user": self.tgid, "contact": tgid} for tgid in puppets]
if insert_puppets:
conn.execute(Contact.t.insert(), insert_puppets)
@property
def portals(self) -> Iterable[Tuple[TelegramID, TelegramID]]:
rows = self.db.execute(UserPortal.t.select().where(UserPortal.c.user == self.tgid))
for row in rows:
user, portal, portal_receiver = row
yield (portal, portal_receiver)
@portals.setter
def portals(self, portals: Iterable[Tuple[TelegramID, TelegramID]]) -> None:
with self.db.begin() as conn:
conn.execute(UserPortal.t.delete().where(UserPortal.c.user == self.tgid))
insert_portals = [{
"user": self.tgid,
"portal": tgid,
"portal_receiver": tg_receiver
} for tgid, tg_receiver in portals]
if insert_portals:
conn.execute(UserPortal.t.insert(), insert_portals)
def delete(self) -> None:
super().delete()
self.portals = []
self.contacts = []
class UserPortal(Base):
__tablename__ = "user_portal"
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"),
onupdate="CASCADE", ondelete="CASCADE"),)
class Contact(Base):
__tablename__ = "contact"
user: TelegramID = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
contact: TelegramID = Column(Integer, ForeignKey("puppet.id"), primary_key=True)
+1 -2
View File
@@ -1,9 +1,8 @@
from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram,
init_mx)
from .from_telegram import (telegram_reply_to_matrix, telegram_to_matrix, init_tg)
from .from_telegram import telegram_reply_to_matrix, telegram_to_matrix
from .. import context as c
def init(context: c.Context) -> None:
init_mx(context)
init_tg(context)
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -14,34 +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_common import ParsedMessage
try:
from mautrix_telegram.formatter.from_matrix.parser_lxml import parse_html
except ImportError:
from mautrix_telegram.formatter.from_matrix.parser_htmlparser import parse_html
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: 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:
@@ -54,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
@@ -81,9 +81,8 @@ def matrix_to_telegram(html: str) -> ParsedMessage:
if should_bridge_plaintext_highlights:
html = plain_mention_regex.sub(plain_mention_to_html, html)
html = add_surrogates(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
@@ -91,25 +90,16 @@ 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]:
try:
reply = content["m.relates_to"]["m.in_reply_to"]
room_id = room_id or reply["room_id"]
event_id = reply["event_id"]
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
content.trim_reply_fallback()
try:
if content["format"] == "org.matrix.custom.html":
content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"])
except KeyError:
pass
content["body"] = trim_reply_fallback_text(content["body"])
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
return message.tgid
except KeyError:
pass
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
return message.tgid
return None
@@ -125,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()
@@ -149,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"(\s|^)({dn_template})")
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] or False
plain_mention_regex = re.compile(f"^({dn_template})")
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"]
@@ -0,0 +1,89 @@
# 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, Tuple, Optional
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 .telegram_message import TelegramMessage, TelegramEntityType
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
def parse_html(input_html: str) -> ParsedMessage:
msg = MatrixParser.parse(input_html)
return msg.text, msg.telegram_entities
class MatrixParser(BaseMatrixParser[TelegramMessage]):
e = TelegramEntityType
fs = TelegramMessage
read_html = read_html
@classmethod
def custom_node_to_fstring(cls, node: HTMLNode, ctx: RecursionContext
) -> Optional[TelegramMessage]:
msg = cls.tag_aware_parse_node(node, ctx)
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 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 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")
@@ -1,36 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 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/>.
import re
from typing import List, Tuple, Pattern
from telethon.tl.types import TypeMessageEntity
class MatrixParserCommon:
mention_regex = re.compile("https://matrix.to/#/(@.+:.+)") # type: Pattern
room_regex = re.compile("https://matrix.to/#/(#.+:.+)") # type: Pattern
block_tags = ("p", "pre", "blockquote",
"ol", "ul", "li",
"h1", "h2", "h3", "h4", "h5", "h6",
"div", "hr", "table") # type: Tuple[str, ...]
list_bullets = ("", "", "", "") # type: Tuple[str, ...]
@classmethod
def list_bullet(cls, depth: int) -> str:
return cls.list_bullets[(depth - 1) % len(cls.list_bullets)] + " "
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
@@ -1,241 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 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, Tuple, Type, Dict, Any, TYPE_CHECKING, Match)
from html import unescape
from html.parser import HTMLParser
from collections import deque
import math
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityEmail,
MessageEntityUrl, MessageEntityTextUrl, MessageEntityBold,
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
MessageEntityBotCommand, TypeMessageEntity)
from ... import user as u, puppet as pu, portal as po
from ...types import MatrixUserID
from ..util import html_to_unicode
from .parser_common import MatrixParserCommon, ParsedMessage
if TYPE_CHECKING:
from typing import Deque
def parse_html(html: str) -> ParsedMessage:
parser = MatrixParser()
parser.feed(html)
return parser.text, parser.entities
class MatrixParser(HTMLParser, MatrixParserCommon):
def __init__(self):
super(MatrixParser, self).__init__()
self.text = "" # type: str
self.entities = [] # type: List[TypeMessageEntity]
self._building_entities = {} # type: Dict[str, TypeMessageEntity]
self._list_counter = 0 # type: int
self._open_tags = deque() # type: Deque[str]
self._open_tags_meta = deque() # type: Deque[Any]
self._line_is_new = True # type: bool
self._list_entry_is_new = False # type: bool
def _parse_url(self, url: str, args: Dict[str, Any]
) -> Tuple[Optional[Type[TypeMessageEntity]], Optional[str]]:
mention = self.mention_regex.match(url) # type: Match
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 None, None
if user.username:
return MessageEntityMention, f"@{user.username}"
elif user.tgid:
args["user_id"] = user.tgid
return MessageEntityMentionName, user.displayname or None
else:
return None, None
room = self.room_regex.match(url) # type: Match
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 MessageEntityMention, f"@{portal.username}"
if url.startswith("mailto:"):
return MessageEntityEmail, url[len("mailto:"):]
elif self.get_starttag_text() == url:
return MessageEntityUrl, url
else:
args["url"] = url
return MessageEntityTextUrl, None
def handle_starttag(self, tag: str, attrs_list: List[Tuple[str, str]]):
self._open_tags.appendleft(tag)
self._open_tags_meta.appendleft(0)
attrs = dict(attrs_list)
entity_type = None # type: Optional[Type[TypeMessageEntity]]
args = {} # type: Dict[str, Any]
if tag in ("strong", "b"):
entity_type = MessageEntityBold
elif tag in ("em", "i"):
entity_type = MessageEntityItalic
elif tag == "code":
try:
pre = self._building_entities["pre"]
try:
# Pre tag and language found, add language to MessageEntityPre
pre.language = attrs["class"][len("language-"):]
except KeyError:
# Pre tag found, but language not found, keep pre as-is
pass
except KeyError:
# No pre tag found, this is inline code
entity_type = MessageEntityCode
elif tag == "pre":
entity_type = MessageEntityPre
args["language"] = ""
elif tag == "command":
entity_type = MessageEntityBotCommand
elif tag == "li":
self._list_entry_is_new = True
elif tag == "a":
try:
url = attrs["href"]
except KeyError:
return
entity_type, url = self._parse_url(url, args)
self._open_tags_meta.popleft()
self._open_tags_meta.appendleft(url)
if (tag in self.block_tags and ("blockquote" not in self._open_tags)) or tag == "br":
self._newline()
if entity_type and tag not in self._building_entities:
offset = len(self.text)
self._building_entities[tag] = entity_type(offset=offset, length=0, **args)
@property
def _list_indent(self) -> int:
indent = 0
first_skipped = False
for index, tag in enumerate(self._open_tags):
if not first_skipped and tag in ("ol", "ul"):
# The first list level isn't indented, so skip it.
first_skipped = True
continue
if tag == "ol":
n = self._open_tags_meta[index]
extra_length_for_long_index = (int(math.log(n, 10)) - 1) * 3
indent += 4 + extra_length_for_long_index
elif tag == "ul":
indent += 3
return indent
def _newline(self, allow_multi: bool = False):
if self._line_is_new and not allow_multi:
return
self.text += "\n"
self._line_is_new = True
for entity in self._building_entities.values():
entity.length += 1
def _handle_special_previous_tags(self, text: str) -> str:
if "pre" not in self._open_tags and "code" not in self._open_tags:
text = text.replace("\n", "")
else:
text = text.strip()
previous_tag = self._open_tags[0] if len(self._open_tags) > 0 else ""
if previous_tag == "a":
url = self._open_tags_meta[0]
if url:
text = url
elif previous_tag == "command":
text = f"/{text}"
return text
def _html_to_unicode(self, text: str) -> str:
strikethrough, underline = "del" in self._open_tags, "u" in self._open_tags
if strikethrough and underline:
text = html_to_unicode(text, "\u0336\u0332")
elif strikethrough:
text = html_to_unicode(text, "\u0336")
elif underline:
text = html_to_unicode(text, "\u0332")
return text
def _handle_tags_for_data(self, text: str) -> Tuple[str, int]:
extra_offset = 0
list_entry_handled_once = False
# In order to maintain order of things like blockquotes in lists or lists in blockquotes,
# we can't just have ifs/elses and we need to actually loop through the open tags in order.
for index, tag in enumerate(self._open_tags):
if tag == "blockquote" and self._line_is_new:
text = f"> {text}"
extra_offset += 2
elif tag == "li" and not list_entry_handled_once:
list_type_index = index + 1
list_type = self._open_tags[list_type_index]
indent = self._list_indent * " " if self._line_is_new else ""
if list_type == "ol":
n = self._open_tags_meta[list_type_index]
if self._list_entry_is_new:
n += 1
self._open_tags_meta[list_type_index] = n
prefix = f"{n}. "
else:
prefix = int(math.log(n, 10)) * 3 * " " + 4 * " "
else:
prefix = (self.list_bullet(self._open_tags.count('ul'))
if self._list_entry_is_new else 3 * " ")
if not self._list_entry_is_new and not self._line_is_new:
prefix = ""
extra_offset += len(indent) + len(prefix)
text = indent + prefix + text
self._list_entry_is_new = False
list_entry_handled_once = True
return text, extra_offset
def _extend_entities_in_construction(self, text: str, extra_offset: int):
for tag, entity in self._building_entities.items():
entity.length += len(text) - extra_offset
entity.offset += extra_offset
def handle_data(self, text: str):
text = unescape(text)
text = self._handle_special_previous_tags(text)
text = self._html_to_unicode(text)
text, extra_offset = self._handle_tags_for_data(text)
self._extend_entities_in_construction(text, extra_offset)
self._line_is_new = False
self.text += text
def handle_endtag(self, tag: str):
try:
self._open_tags.popleft()
self._open_tags_meta.popleft()
except IndexError:
pass
entity = self._building_entities.pop(tag, None)
if entity:
self.entities.append(entity)
if tag in self.block_tags and tag != "br" and "blockquote" not in self._open_tags:
self._newline(allow_multi=tag == "br")
@@ -1,236 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 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, Tuple
from lxml import html
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
MessageEntityMentionName as MentionName, MessageEntityEmail as Email,
MessageEntityUrl as URL, MessageEntityTextUrl as TextURL,
MessageEntityBold as Bold, MessageEntityItalic as Italic,
MessageEntityCode as Code, MessageEntityPre as Pre)
from ... import user as u, puppet as pu, portal as po
from ...types import MatrixUserID
from ..util import html_to_unicode
from .parser_common import MatrixParserCommon, ParsedMessage
from .telegram_message import TelegramMessage, Entity, offset_length_multiply
def parse_html(input_html: str) -> ParsedMessage:
return MatrixParser.parse(input_html)
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(MatrixParserCommon):
@classmethod
def list_to_tmessage(cls, node: html.HtmlElement, 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 blockquote_to_tmessage(cls, node: html.HtmlElement, ctx: RecursionContext
) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
children = msg.trim().split("\n")
children = [child.prepend("> ") for child in children]
return TelegramMessage.join(children, "\n")
@classmethod
def header_to_tmessage(cls, node: html.HtmlElement, 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: html.HtmlElement, ctx: RecursionContext
) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
if node.tag in ("b", "strong"):
msg.format(Bold)
elif node.tag in ("i", "em"):
msg.format(Italic)
elif node.tag == "command":
msg.format(Command)
elif node.tag in ("s", "strike", "del"):
msg.text = html_to_unicode(msg.text, "\u0336")
elif node.tag in ("u", "ins"):
msg.text = html_to_unicode(msg.text, "\u0332")
if node.tag in ("s", "strike", "del", "u", "ins"):
msg.entities = Entity.adjust(msg.entities, offset_length_multiply(2))
return msg
@classmethod
def link_to_tstring(cls, node: html.HtmlElement, 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:
return TelegramMessage(user.displayname or msg.text).format(MentionName,
user_id=user.tgid)
return msg
room = cls.room_regex.match(href)
if room:
username = po.Portal.get_username_from_mx_alias(room.group(1))
portal = po.Portal.find_by_username(username)
if portal and portal.username:
return TelegramMessage(f"@{portal.username}").format(Mention)
return (msg.format(URL)
if msg.text == href
else msg.format(TextURL, url=href))
@classmethod
def node_to_tmessage(cls, node: html.HtmlElement, ctx: RecursionContext) -> TelegramMessage:
if node.tag == "blockquote":
return cls.blockquote_to_tmessage(node, ctx)
elif node.tag == "ol":
return cls.list_to_tmessage(node, ctx)
elif node.tag == "ul":
return cls.list_to_tmessage(node, ctx.enter_list())
elif node.tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
return cls.header_to_tmessage(node, ctx)
elif node.tag == "br":
return TelegramMessage("\n")
elif node.tag in ("b", "strong", "i", "em", "s", "del", "u", "ins", "command"):
return cls.basic_format_to_tmessage(node, ctx)
elif node.tag == "a":
return cls.link_to_tstring(node, ctx)
elif node.tag == "p":
return cls.tag_aware_parse_node(node, ctx).append("\n")
elif node.tag == "pre":
lang = ""
try:
if node[0].tag == "code":
node = node[0]
lang = node.attrib["class"][len("language-"):]
except (IndexError, KeyError):
pass
return cls.parse_node(node, ctx.enter_code_block()).format(Pre, language=lang)
elif node.tag == "code":
return cls.parse_node(node, ctx.enter_code_block()).format(Code)
return cls.tag_aware_parse_node(node, ctx)
@staticmethod
def text_to_tmessage(text: str, ctx: RecursionContext) -> TelegramMessage:
if ctx.strip_linebreaks:
text = text.replace("\n", "")
return TelegramMessage(text)
@classmethod
def node_to_tagged_tmessages(cls, node: html.HtmlElement, 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: html.HtmlElement, 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: html.HtmlElement, 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: html.HtmlElement, ctx: RecursionContext) -> TelegramMessage:
return TelegramMessage.join(cls.node_to_tmessages(node, ctx))
@classmethod
def parse(cls, data: str) -> ParsedMessage:
document = html.fromstring(f"<html>{data}</html>")
msg = cls.parse_node(document, RecursionContext())
return msg.text, msg.entities
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -14,144 +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
main.text = main.text[:-len(separator)]
return main
@property
def telegram_entities(self) -> List[TypeMessageEntity]:
return [entity.internal for entity in self.entities]
+127 -150
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -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,192 +22,161 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold,
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag,
MessageEntityPhone, TypeMessageEntity, Message, PeerChannel,
MessageFwdHeader, PeerUser)
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, unicode_to_html)
if TYPE_CHECKING:
from ..abstract_user import AbstractUser
from ..context import Context
try:
from lxml.html.diff import htmldiff
except ImportError:
htmldiff = None # type: ignore
log: logging.Logger = logging.getLogger("mau.fmt.tg")
log = logging.getLogger("mau.fmt.tg") # type: logging.Logger
should_highlight_edits = False # type: bool
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_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,
}
}
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(fwd_from.from_id)
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id))
if user:
fwd_from_text = user.displayname or user.mxid
fwd_from_html = f"<a href='https://matrix.to/#/{user.mxid}'>{fwd_from_text}</a>"
fwd_from_html = (f"<a href='https://matrix.to/#/{user.mxid}'>"
f"{escape(fwd_from_text)}</a>")
if not fwd_from_text:
puppet = pu.Puppet.get(TelegramID(fwd_from.from_id), create=False)
if puppet and puppet.displayname:
fwd_from_text = puppet.displayname or puppet.mxid
fwd_from_html = f"<a href='https://matrix.to/#/{puppet.mxid}'>{fwd_from_text}</a>"
fwd_from_html = (f"<a href='https://matrix.to/#/{puppet.mxid}'>"
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>{fwd_from_text}</b>"
if not fwd_from_text:
if fwd_from.from_id:
fwd_from_text = "Unknown user"
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
if portal.alias:
fwd_from_html = (f"<a href='https://matrix.to/#/{portal.alias}'>"
f"{escape(fwd_from_text)}</a>")
else:
fwd_from_html = f"channel <b>{escape(fwd_from_text)}</b>"
else:
fwd_from_text = "Unknown source"
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"
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>")
def highlight_edits(new_html: str, old_html: str) -> str:
# Don't include `Edit:` text in diff.
if old_html.startswith("<u>Edit:</u> "):
old_html = old_html[len("<u>Edit:</u> "):]
# Generate diff with lxml
new_html = htmldiff(old_html, new_html)
# Replace <ins> with <u> since Riot doesn't allow <ins>
new_html = new_html.replace("<ins>", "<u>").replace("</ins>", "</u>")
# Remove <del>s since we just want to hide deletions.
new_html = re.sub("<del>.+?</del>", "", new_html)
return new_html
async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message,
relates_to: Dict, main_intent: IntentAPI, is_edit: bool
) -> Tuple[str, str]:
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_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["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}'>{r_displayname}</a>"
if is_edit and should_highlight_edits:
html = highlight_edits(html or escape(text), r_html_body)
except (ValueError, KeyError, MatrixRequestError):
r_sender_link = "unknown user"
r_displayname = "unknown user"
r_text_body = "Failed to fetch message"
r_html_body = "<em>Failed to fetch message</em>"
if is_edit:
html = f"<u>Edit:</u> {html or escape(text)}"
text = f"Edit: {text}"
r_keyword = "In reply to" if not is_edit else "Edit to"
r_msg_link = f"<a href='https://matrix.to/#/{msg.mx_room}/{msg.mxid}'>{r_keyword}</a>"
html = (
f"<mx-reply><blockquote>{r_msg_link} {r_sender_link}\n{r_html_body}</blockquote></mx-reply>"
+ (html or escape(text)))
lines = r_text_body.strip().split("\n")
text_with_quote = f"> <{r_displayname}> {lines.pop(0)}"
for line in lines:
if line:
text_with_quote += f"\n> {line}"
text_with_quote += "\n\n"
text_with_quote += text
return text_with_quote, html
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",
main_intent: Optional[IntentAPI] = None,
is_edit: bool = False, prefix_text: Optional[str] = None,
prefix_html: Optional[str] = None) -> Tuple[str, str, Dict]:
text = add_surrogates(evt.message)
html = _telegram_entities_to_matrix_catch(text, evt.entities) if evt.entities else None
relates_to = {} # type: Dict
prefix_text: Optional[str] = None, prefix_html: Optional[str] = None,
override_text: str = None,
override_entities: List[TypeMessageEntity] = None,
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
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:
text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent,
is_edit)
if evt.reply_to_msg_id and not no_reply_fallback:
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>"
html = unicode_to_html(text, html, "\u0336", "del")
html = unicode_to_html(text, html, "\u0332", "u")
content.body = del_surrogate(content.body)
if html:
html = html.replace("\n", "<br/>")
if content.formatted_body:
content.formatted_body = del_surrogate(content.formatted_body.replace("\n", "<br/>"))
return remove_surrogates(text), remove_surrogates(html), relates_to
return content
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
@@ -222,25 +190,39 @@ def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEnti
return "[failed conversion in _telegram_entities_to_matrix]"
def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity]) -> str:
def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity],
offset: int = 0, length: int = None) -> str:
if not entities:
return text
return escape(text)
if length is None:
length = len(text)
html = []
last_offset = 0
for entity in entities:
if entity.offset > last_offset:
html.append(escape(text[last_offset:entity.offset]))
elif entity.offset < last_offset:
for i, entity in enumerate(entities):
if entity.offset > offset + length:
break
relative_offset = entity.offset - offset
if relative_offset > last_offset:
html.append(escape(text[last_offset:relative_offset]))
elif relative_offset < last_offset:
continue
skip_entity = False
entity_text = escape(text[entity.offset:entity.offset + entity.length])
entity_text = _telegram_entities_to_matrix(
text=text[relative_offset:relative_offset + entity.length],
entities=entities[i + 1:], offset=entity.offset, length=entity.length)
entity_type = type(entity)
if entity_type == MessageEntityBold:
html.append(f"<strong>{entity_text}</strong>")
elif entity_type == MessageEntityItalic:
html.append(f"<em>{entity_text}</em>")
elif entity_type == MessageEntityUnderline:
html.append(f"<u>{entity_text}</u>")
elif entity_type == MessageEntityStrike:
html.append(f"<del>{entity_text}</del>")
elif entity_type == MessageEntityBlockquote:
html.append(f"<blockquote>{entity_text}</blockquote>")
elif entity_type == MessageEntityCode:
html.append(f"<pre><code>{entity_text}</code></pre>"
if "\n" in entity_text
@@ -262,8 +244,8 @@ def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity]) -
html.append(f"<font color='blue'>{entity_text}</font>")
else:
skip_entity = True
last_offset = entity.offset + (0 if skip_entity else entity.length)
html.append(text[last_offset:])
last_offset = relative_offset + (0 if skip_entity else entity.length)
html.append(escape(text[last_offset:]))
return "".join(html)
@@ -309,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:
@@ -325,14 +307,9 @@ def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
portal = po.Portal.find_by_username(group)
if portal:
message = DBMessage.get_by_tgid(TelegramID(msgid), portal.tgid)
message = DBMessage.get_one_by_tgid(TelegramID(msgid), portal.tgid)
if message:
url = f"https://matrix.to/#/{portal.mxid}/{message.mxid}"
html.append(f"<a href='{url}'>{entity_text}</a>")
return False
def init_tg(context: "Context") -> None:
global should_highlight_edits
should_highlight_edits = htmldiff and context.config["bridge.highlight_edits"]
-89
View File
@@ -1,89 +0,0 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Pattern
from html import escape
import struct
import re
def unicode_to_html(text: str, html: str, ctrl: str, tag: str) -> str:
if ctrl not in text:
return html
if not html:
html = escape(text)
tag_start = f"<{tag}>"
tag_end = f"</{tag}>"
characters = html.split(ctrl)
html = ""
in_tag = False
for char in characters:
if not in_tag:
if len(char) > 1:
html += char[0:-1]
char = char[-1]
html += tag_start
in_tag = True
html += char
else:
if len(char) > 1:
html += tag_end
in_tag = False
html += char
if in_tag:
html += tag_end
return html
def html_to_unicode(text: str, ctrl: str) -> str:
return ctrl.join(text) + ctrl
# add_surrogates and remove_surrogates are unicode surrogate utility functions from Telethon.
# Licensed under the MIT license.
# https://github.com/LonamiWebs/Telethon/blob/7cce7aa3e4c6c7019a55530391b1761d33e5a04e/telethon/helpers.py
def add_surrogates(text: Optional[str]) -> Optional[str]:
if text is None:
return None
return "".join("".join(chr(y) for y in struct.unpack("<HH", x.encode("utf-16-le")))
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text)
def remove_surrogates(text: Optional[str]) -> Optional[str]:
if text is None:
return None
return text.encode("utf-16", "surrogatepass").decode("utf-16")
# trim_reply_fallback_text, html_reply_fallback_regex and trim_reply_fallback_html are Matrix
# reply fallback utility functions.
# You may copy and use them under any OSI-approved license.
def trim_reply_fallback_text(text: str) -> str:
if not text.startswith("> ") or "\n" not in text:
return text
lines = text.split("\n")
while len(lines) > 0 and lines[0].startswith("> "):
lines.pop(0)
return "\n".join(lines)
html_reply_fallback_regex = re.compile("^<mx-reply>"
r"[\s\S]+?"
"</mx-reply>") # type: Pattern
def trim_reply_fallback_html(html: str) -> str:
return html_reply_fallback_regex.sub("", html)
+203 -239
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -14,48 +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 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"])
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.db, 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():
@@ -73,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:
@@ -85,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()
@@ -107,48 +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="",
html="You are not whitelisted to use this bridge.<br/><br/>"
"If you are the owner of this bridge, see the "
"<code>bridge.permissions</code> section in your config file.")
await self.az.intent.leave_room(room_id)
async def handle_invite(self, room_id: MatrixRoomID, user_id: MatrixUserID,
inviter_mxid: MatrixUserID) -> None:
self.log.debug(f"{inviter_mxid} invited {user_id} to {room_id}")
inviter = u.User.get_by_mxid(inviter_mxid)
if inviter is None:
self.log.exception("Failed to find user with Matrix ID {inviter_mxid}")
await inviter.ensure_started()
if user_id == self.az.bot_mxid:
return await self.accept_bot_invite(room_id, inviter)
elif not inviter.whitelisted:
return
puppet = pu.Puppet.get_by_mxid(user_id)
if puppet:
await self.handle_puppet_invite(room_id, puppet, inviter)
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 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.")
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: 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
@@ -156,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)
@@ -169,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
@@ -205,90 +206,68 @@ 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:]
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}:"
" u.User is not whitelisted.")
return
self.log.debug(f"Received Matrix event \"{message}\" from {sender} in {room}")
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, 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()
@@ -296,56 +275,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, 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_name_change(room_id: MatrixRoomID, user_id: MatrixUserID, displayname: str,
prev_displayname: str, event_id: MatrixEventID) -> 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(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
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
@@ -355,70 +347,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_event(self, evt: MatrixEvent) -> None:
try:
await self.handle_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_event(self, evt: MatrixEvent) -> None:
if self.filter_matrix_event(evt):
return
self.log.debug("Received event: %s", evt)
evt_type = evt.get("type", "m.unknown") # type: str
room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID]
event_id = evt.get("event_id", None) # type: Optional[MatrixEventID]
sender = evt.get("sender", None) # type: Optional[MatrixUserID]
content = evt.get("content", {}) # type: Dict
if evt_type == "m.room.member":
state_key = evt["state_key"] # type: MatrixUserID
prev_content = evt.get("unsigned", {}).get("prev_content", {}) # type: Dict
membership = content.get("membership", "") # type: str
prev_membership = prev_content.get("membership", "leave") # type: str
if membership == prev_membership:
match = re.compile("@(.+):(.+)").match(state_key) # type: Match
mxid = match.group(0) # type: str
displayname = content.get("displayname", None) or mxid # type: str
prev_displayname = prev_content.get("displayname", None) or mxid # type: str
if displayname != prev_displayname:
await self.handle_name_change(room_id, state_key, displayname,
prev_displayname, event_id)
elif membership == "invite":
await self.handle_invite(room_id, state_key, sender)
elif prev_membership == "join" and membership == "leave":
await self.handle_part(room_id, state_key, sender, event_id)
elif membership == "join":
await self.handle_join(room_id, state_key, event_id)
elif evt_type in ("m.room.message", "m.sticker"):
if evt_type != "m.room.message":
content["msgtype"] = evt_type
await self.handle_message(room_id, sender, content, event_id)
elif evt_type == "m.room.redaction":
await self.handle_redaction(room_id, sender, evt["redacts"])
elif evt_type == "m.room.power_levels":
prev_content = evt.get("unsigned", {}).get("prev_content", {})
await self.handle_power_levels(room_id, sender, evt["content"], prev_content)
elif evt_type in ("m.room.name", "m.room.avatar", "m.room.topic"):
await self.handle_room_meta(evt_type, room_id, sender, evt["content"])
elif evt_type == "m.room.pinned_events":
new_events = set(evt["content"]["pinned"])
async def handle_event(self, evt: Event) -> None:
if evt.type == EventType.ROOM_REDACTION:
await self.handle_redaction(evt)
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:
old_events = set(evt.unsigned.prev_content.pinned)
except (KeyError, ValueError, TypeError, AttributeError):
old_events = set()
await self.handle_room_pin(room_id, sender, new_events, old_events)
elif 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", []))
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=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"]
+502
View File
@@ -0,0 +1,502 @@
# 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
async def cleanup_room(self, intent: IntentAPI, room_id: RoomID,
message: str = "Portal deleted", puppets_only: bool = False) -> None:
try:
members = await intent.get_room_members(room_id)
except MatrixRequestError:
members = []
if self.username:
try:
await intent.remove_room_alias(self.alias_localpart)
except (MatrixRequestError, IntentError):
self.log.warning("Failed to remove alias when cleaning up room", exc_info=True)
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 unbridge(self) -> None:
await self.cleanup_room(self.main_intent, self.mxid, "Room unbridged", puppets_only=True)
self.delete()
async def cleanup_and_delete(self) -> None:
await self.cleanup_room(self.main_intent, self.mxid)
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})")
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.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
+210 -265
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -14,252 +13,140 @@
#
# 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, Coroutine, Dict, List, Optional, Pattern, 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 sqlalchemy import orm
from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer,
InputPeerPhotoFileLocation, UserProfilePhotoEmpty, TypeInputUser)
from telethon.tl.types import UserProfilePhoto, User, FileLocation
from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError
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 MatrixUserID, TelegramID
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
from .config import Config
from .context import Context
from . import user as u
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
db = None # type: orm.Session
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,
photo_id: Optional[str] = None,
is_bot: bool = False,
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._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.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:
""" Is True if the puppet is logged in. """
return True
# 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)
@property
def plain_displayname(self) -> str:
return self.displayname_template.parse(self.displayname) or self.displayname
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()
def get_input_entity(self, user: 'AbstractUser'
) -> Awaitable[Union[TypeInputPeer, TypeInputUser]]:
return user.client.get_input_entity(self.peer)
err = await self.init_custom_mxid()
if err != PuppetError.Success:
return err
def intent_for(self, portal: 'p.Portal') -> IntentAPI:
if portal.tgid == self.tgid:
return self.default_mxid_intent
return self.intent
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"]:
asyncio.ensure_future(self.sync(), loop=self.loop)
return PuppetError.Success
async def leave_rooms_with_default_user(self) -> None:
for room_id in await self.default_mxid_intent.get_joined_rooms():
try:
await self.default_mxid_intent.leave_room(room_id)
await self.intent.ensure_joined(room_id)
except (IntentError, MatrixRequestError):
pass
def create_sync_filter(self) -> Awaitable[str]:
return self.intent.client.create_filter(self.custom_mxid, {
"room": {
"include_leave": False,
"state": {
"types": []
},
"timeline": {
"types": [],
},
"ephemeral": {
"types": ["m.typing", "m.receipt"],
},
"account_data": {
"types": []
}
},
"account_data": {
"types": [],
},
"presence": {
"types": ["m.presence"],
"senders": [self.custom_mxid],
},
})
def filter_events(self, events: List[Dict]) -> List:
new_events = []
for event in events:
evt_type = event.get("type", None)
event.setdefault("content", {})
if evt_type == "m.typing":
is_typing = self.custom_mxid in event["content"].get("user_ids", [])
event["content"]["user_ids"] = [self.custom_mxid] if is_typing else []
elif evt_type == "m.receipt":
val = None
evt = None
for event_id in event["content"]:
try:
val = event["content"][event_id]["m.read"][self.custom_mxid]
evt = event_id
break
except KeyError:
pass
if val and evt:
event["content"] = {evt: {"m.read": {
self.custom_mxid: val
}}}
else:
continue
new_events.append(event)
return new_events
def handle_sync(self, presence: List, ephemeral: Dict) -> None:
presence_events = [self.mx.try_handle_event(event) for event in presence]
for room_id, events in ephemeral.items():
for event in events:
event["room_id"] = room_id
ephemeral_events = [self.mx.try_handle_event(event)
for events in ephemeral.values()
for event in self.filter_events(events)]
events = ephemeral_events + presence_events # List[Callable[[int], Awaitable[None]]]
coro = asyncio.gather(*events, loop=self.loop)
asyncio.ensure_future(coro, loop=self.loop)
async def sync(self) -> None:
try:
await self._sync()
except 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
@@ -268,32 +155,31 @@ 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)
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.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)
def save(self) -> None:
self.db_instance.access_token = self.access_token
self.db_instance.custom_mxid = self.custom_mxid
self.db_instance.username = self.username
self.db_instance.displayname = self.displayname
self.db_instance.displayname_source = self.displayname_source
self.db_instance.photo_id = self.photo_id
self.db_instance.is_bot = self.is_bot
self.db_instance.matrix_registered = self.is_registered
self.db.commit()
# endregion
# region Info updating
def similarity(self, query: str) -> int:
username_similarity = (SequenceMatcher(None, self.username, query).ratio()
if self.username else 0)
@@ -303,74 +189,132 @@ 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.get("bridge.displayname_preference",
["full name", "username", "phone"])
preferences = config["bridge.displayname_preference"]
name = None
for preference in preferences:
name = data[preference]
if name:
break
if info.deleted:
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.get("bridge.displayname_template", "{displayname} (Telegram)").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:
return
changed = False
if self.username != info.username:
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.photo_big) 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
if changed:
self.save()
async def update_displayname(self, source: 'AbstractUser', info: User) -> bool:
ignore_source = (not source.is_relaybot
and self.displayname_source is not None
and self.displayname_source != source.tgid)
if ignore_source:
async def update_displayname(self, source: 'AbstractUser', info: Union[User, UpdateUserName]
) -> bool:
if self.disable_updates:
return False
allow_source = (source.is_relaybot
or self.displayname_source == source.tgid
# No displayname source, so just trust anything
or self.displayname_source is None)
if not allow_source:
return False
elif isinstance(info, UpdateUserName):
info = await source.client.get_entity(PeerUser(self.tgid))
displayname = self.get_displayname(info)
if displayname != self.displayname:
await self.default_mxid_intent.set_display_name(displayname)
self.displayname = displayname
self.displayname_source = source.tgid
try:
await self.default_mxid_intent.set_displayname(
displayname[:config["bridge.displayname_max_length"]])
except MatrixRequestError:
self.log.exception("Failed to set displayname")
self.displayname = ""
self.displayname_source = None
return True
elif source.is_relaybot or self.displayname_source is None:
self.displayname_source = source.tgid
return True
return False
async def update_avatar(self, source: 'AbstractUser', photo: FileLocation) -> bool:
photo_id = f"{photo.volume_id}-{photo.local_id}"
async def update_avatar(self, source: 'AbstractUser',
photo: Union[UserProfilePhoto, UserProfilePhotoEmpty]) -> bool:
if self.disable_updates:
return False
if isinstance(photo, UserProfilePhotoEmpty):
photo_id = ""
else:
photo_id = str(photo.photo_id)
if self.photo_id != photo_id:
file = await util.transfer_file_to_matrix(self.db, source.client,
self.default_mxid_intent, photo)
if not photo_id:
self.photo_id = ""
try:
await self.default_mxid_intent.set_avatar_url("")
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
return True
loc = InputPeerPhotoFileLocation(
peer=await self.get_input_entity(source),
local_id=photo.photo_big.local_id,
volume_id=photo.photo_big.volume_id,
big=True
)
file = await util.transfer_file_to_matrix(source.client, self.default_mxid_intent, loc)
if file:
await self.default_mxid_intent.set_avatar(file.mxc)
self.photo_id = photo_id
try:
await self.default_mxid_intent.set_avatar_url(file.mxc)
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
return True
return False
@@ -384,20 +328,19 @@ class Puppet:
except KeyError:
pass
puppet = DBPuppet.query.get(tgid)
puppet = DBPuppet.get_by_tgid(tgid)
if puppet:
return cls.from_db(puppet)
if create:
puppet = cls(tgid)
cls.db.add(puppet.db_instance)
cls.db.commit()
puppet.db_instance.insert()
return 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)
@@ -405,7 +348,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")
@@ -414,7 +357,7 @@ class Puppet:
except KeyError:
pass
puppet = DBPuppet.query.filter(DBPuppet.custom_mxid == mxid).one_or_none()
puppet = DBPuppet.get_by_custom_mxid(mxid)
if puppet:
puppet = cls.from_db(puppet)
return puppet
@@ -422,33 +365,32 @@ class Puppet:
return None
@classmethod
def get_all_with_custom_mxid(cls) -> List['Puppet']:
return [cls.by_custom_mxid[puppet.mxid]
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
return (cls.by_custom_mxid[puppet.mxid]
if puppet.custom_mxid in cls.by_custom_mxid
else cls.from_db(puppet)
for puppet in DBPuppet.query.filter(DBPuppet.custom_mxid is not None).all()]
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.query.filter(DBPuppet.username == username).one_or_none()
dbpuppet = DBPuppet.get_by_username(username)
if dbpuppet:
return cls.from_db(dbpuppet)
@@ -463,7 +405,7 @@ class Puppet:
if puppet.displayname and puppet.displayname == displayname:
return puppet
dbpuppet = DBPuppet.query.filter(DBPuppet.displayname == displayname).one_or_none()
dbpuppet = DBPuppet.get_by_displayname(displayname)
if dbpuppet:
return cls.from_db(dbpuppet)
@@ -471,12 +413,15 @@ class Puppet:
# endregion
def init(context: 'Context') -> List[Coroutine]: # [None, None, PuppetError]
def init(context: 'Context') -> Iterable[Awaitable[Any]]:
global config
Puppet.az, Puppet.db, config, Puppet.loop, _ = context.core
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.get_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")
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
@@ -11,20 +13,30 @@ parser.add_argument("-f", "--from-url", type=str, required=True, metavar="<url>"
help="the old database path")
parser.add_argument("-t", "--to-url", type=str, required=True, metavar="<url>",
help="the new database path")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logs while migrating")
args = parser.parse_args()
verbose = args.verbose or False
def log(message, end="\n"):
if verbose:
print(message, end=end, flush=True)
def connect(to):
import mautrix_telegram.base as base
base.Base = declarative_base()
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, {
@@ -46,14 +58,30 @@ def connect(to):
}
log("Connecting to old database")
session, tables = connect(args.from_url)
data = {}
for name, table in tables.items():
log("Reading table {name}...".format(name=name), end=" ")
data[name] = session.query(table).all()
log("Done!")
log("Connecting to new database")
session, tables = connect(args.to_url)
for name, table in tables.items():
log("Writing table {name}".format(name=name), end="")
length = len(data[name])
n = 0
for row in data[name]:
session.merge(row)
n += 5
if n >= length:
log(".", end="")
n = 0
log(" Done!")
log("Committing changes to database...", end=" ")
session.commit()
log("Done!")
@@ -1,10 +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 typing import Dict
import argparse
import sqlalchemy as sql
from sqlalchemy import orm
from mautrix_telegram.base import Base
from mautrix_telegram.config import Config
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
parser = argparse.ArgumentParser(
@@ -21,7 +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
@@ -37,17 +56,18 @@ tm_messages = telematrix.query(TMMessage).all()
telematrix.close()
telematrix_db_engine.dispose()
portals = {} # Dict[int, Portal]
chats = {} # Dict[int, BotChat]
messages = {} # Dict[str, Message]
puppets = {} # 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"):
@@ -61,16 +81,28 @@ for chat_link in chat_links:
portal = Portal(tgid=tgid, tg_receiver=tgid, peer_type=peer_type, megagroup=megagroup,
mxid=chat_link.matrix_room)
bot_chat = BotChat(id=tgid, type=peer_type)
portals[chat_link.tg_room] = portal
chats[tgid] = bot_chat
chats[tgid] = BotChat(id=tgid, type=peer_type)
if chat_link.tg_room in portals_by_tgid:
print(f"Warning: Ignoring bridge from {portal.tgid} to {portal.mxid} "
f"in favor of {portals_by_tgid[portal.tgid].mxid}")
continue
elif chat_link.matrix_room in portals_by_mxid:
print(f"Warning: Ignoring bridge from {portal.mxid} to {portal.tgid} "
f"in favor of {portals_by_mxid[portal.mxid].tgid}")
continue
portals_by_tgid[portal.tgid] = portal
portals_by_mxid[portal.mxid] = portal
for tm_msg in tm_messages:
try:
portal = portals[tm_msg.tg_group_id]
portal = portals_by_tgid[tm_msg.tg_group_id]
except KeyError:
print("Found message entry %d in unlinked chat %d, ignoring..." % (tm_msg.tg_message_id,
tm_msg.tg_group_id))
print(f"Found message entry {tm_msg.tg_message_id} in unlinked chat {tm_msg.tg_group_id},"
" ignoring...")
continue
if tm_msg.matrix_room_id != portal.mxid:
print(f"Found message entry {tm_msg.tg_message_id} with "
f"mismatching matrix room ID {tm_msg.matrix_room_id} (expected {portal.mxid})")
continue
tg_space = portal.tgid if portal.peer_type == "channel" else args.bot_id
message = Message(mxid=tm_msg.matrix_event_id, mx_room=tm_msg.matrix_room_id,
@@ -81,7 +113,7 @@ for user in tg_users:
puppets[user.tg_id] = Puppet(id=user.tg_id, displayname=user.name,
displayname_source=args.bot_id)
for k, v in portals.items():
for k, v in portals_by_tgid.items():
mxtg.add(v)
for k, v in chats.items():
mxtg.add(v)
+16 -100
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -14,109 +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 sqlalchemy import orm
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, db: orm.Session) -> None:
super().__init__()
self.db = db # type: orm.Session
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.query.get(key)
if profile:
self.profile_cache[key] = profile
elif create:
profile = UserProfile(room_id=room_id, user_id=user_id)
self.db.add(profile)
self.db.commit()
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)
self.db.commit()
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.query.get(room_id)
if room:
self.room_state_cache[room_id] = room
elif create:
room = RoomState(room_id=room_id)
self.room_state_cache[room_id] = room
return room
def has_power_levels(self, room: MatrixRoomID) -> bool:
return self._get_room_state(room).has_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
self.db.commit()
def set_power_levels(self, room: MatrixRoomID, content: Dict) -> None:
state = self._get_room_state(room)
state.power_levels = content
self.db.commit()
else:
super().registered(user_id)
+12 -10
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -18,20 +17,23 @@ 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 import custom
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
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":
if (mime_type == "image/png" or mime_type == "image/jpeg") and len(file) < max_image_size:
return InputMediaUploadedPhoto(file_handle)
else:
attributes = attributes or []
@@ -45,7 +47,7 @@ class MautrixTelegramClient(TelegramClient):
async def send_media(self, entity: Union[TypeInputPeer, TypePeer],
media: Union[TypeInputMedia, TypeMessageMedia],
caption: str = None, entities: List[TypeMessageEntity] = None,
reply_to: int = None) -> Optional[custom.Message]:
reply_to: int = None) -> Optional[Message]:
entity = await self.get_input_entity(entity)
reply_to = utils.get_message_id(reply_to)
request = SendMediaRequest(entity, media, message=caption or "", entities=entities or [],
+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)
+132 -93
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -14,22 +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 Coroutine, Dict, List, Match, NewType, Optional, Tuple, cast, 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)
from telethon.tl.types import User as TLUser
from telethon.tl.types import (TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser,
UpdateShortChatMessage, UpdateShortMessage, User as TLUser, Chat)
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 .db import User as DBUser, Contact as DBContact, Portal as DBPortal
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
@@ -37,36 +38,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[List[DBContact]] = None,
db_contacts: Optional[Iterable[TelegramID]] = None,
saved_contacts: int = 0, is_bot: bool = False,
db_portals: Optional[List[DBPortal]] = None,
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.db_contacts = db_contacts # type: List[DBContact]
self.portals = {} # type: Dict[Tuple[int, int], po.Portal]
self.db_portals = db_portals or [] # type: List[DBPortal]
self._db_instance = db_instance # type: Optional[DBUser]
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 = {}
self.db_portals = db_portals or []
self._db_instance = db_instance
self._ensure_started_lock = asyncio.Lock()
self.command_status = None # type: Dict
self.command_status = None
(self.relaybot_whitelisted,
self.whitelisted,
@@ -79,14 +90,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:
@@ -98,24 +111,30 @@ class User(AbstractUser):
return self.mxid_localpart
@property
def db_contacts(self) -> List[DBContact]:
return [self.db.merge(DBContact(user=self.tgid, contact=puppet.id))
for puppet in self.contacts]
@db_contacts.setter
def db_contacts(self, contacts: List[DBContact]) -> None:
self.contacts = [pu.Puppet.get(entry.contact) for entry in contacts] if contacts else []
def plain_displayname(self) -> str:
return self.displayname
@property
def db_portals(self) -> List[DBPortal]:
return [portal.db_instance for portal in self.portals.values() if not portal.deleted]
def db_contacts(self) -> Iterable[TelegramID]:
return (puppet.id
for puppet in self.contacts
if puppet)
@db_contacts.setter
def db_contacts(self, contacts: Iterable[TelegramID]) -> None:
self.contacts = [pu.Puppet.get(entry) for entry in contacts] if contacts else []
@property
def db_portals(self) -> Iterable[Tuple[TelegramID, TelegramID]]:
return (portal.tgid_full
for portal in self.portals.values()
if portal and not portal.deleted)
@db_portals.setter
def db_portals(self, portals: List[DBPortal]) -> None:
def db_portals(self, portals: Iterable[Tuple[TelegramID, TelegramID]]) -> None:
self.portals = {
(portal.tgid, portal.tg_receiver): po.Portal.get_by_tgid(portal.tgid,
portal.tg_receiver)
for portal in portals
tgid_full: po.Portal.get_by_tgid(*tgid_full)
for tgid_full in portals
} if portals else {}
# region Database conversion
@@ -128,27 +147,24 @@ class User(AbstractUser):
def new_db_instance(self) -> DBUser:
return DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username,
contacts=self.db_contacts, saved_contacts=self.saved_contacts,
portals=self.db_portals)
saved_contacts=self.saved_contacts, portals=self.db_portals)
def save(self) -> None:
self.db_instance.tgid = self.tgid
self.db_instance.tg_username = self.username
self.db_instance.tg_phone = self.phone
self.db_instance.contacts = self.db_contacts
self.db_instance.saved_contacts = self.saved_contacts
self.db_instance.portals = self.db_portals
self.db.commit()
def save(self, contacts: bool = False, portals: bool = False) -> None:
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:
self.db_instance.portals = self.db_portals
def delete(self) -> None:
def delete(self, delete_db: bool = True) -> None:
try:
del self.by_mxid[self.mxid]
del self.by_tgid[self.tgid]
except KeyError:
pass
if self._db_instance:
self.db.delete(self._db_instance)
self.db.commit()
if delete_db and self._db_instance:
self._db_instance.delete()
@classmethod
def from_db(cls, db_user: DBUser) -> 'User':
@@ -159,6 +175,18 @@ class User(AbstractUser):
# endregion
# region Telegram connection management
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()
if await self.is_logged_in():
@@ -173,11 +201,9 @@ class User(AbstractUser):
async def post_login(self, info: TLUser = None) -> None:
try:
await self.update_info(info)
if not self.is_bot:
if not self.is_bot and config["bridge.startup_sync"]:
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)
@@ -193,7 +219,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:
@@ -201,15 +227,14 @@ class User(AbstractUser):
if portal:
self.register_portal(portal)
return False
# Don't bother handling the update
return True
# endregion
# region Telegram actions that need custom methods
def ensure_started(self, even_if_no_session: bool = False) -> Coroutine[None, None, 'User']:
return cast(Coroutine[None, None, 'User'], super().ensure_started(even_if_no_session))
async def set_presence(self, online: bool = True) -> None:
if not self.is_bot:
await self.client(UpdateStatusRequest(offline=not online))
@@ -227,7 +252,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()
@@ -237,15 +262,16 @@ class User(AbstractUser):
if puppet.is_real_user:
await puppet.switch_mxid(None, None)
for _, portal in self.portals.items():
if not portal.mxid or portal.has_bot:
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 = {}
self.contacts = []
self.save()
self.save(portals=True, contacts=True)
if self.tgid:
try:
del self.by_tgid[self.tgid]
@@ -261,7 +287,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:
@@ -273,7 +299,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)
@@ -293,15 +319,27 @@ 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=30):
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, 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(
portal.create_matrix_room(self, entity, invites=[self.mxid],
synchronous=synchronous_create))
self.save()
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:
@@ -310,43 +348,43 @@ class User(AbstractUser):
except KeyError:
pass
self.portals[portal.tgid_full] = portal
self.save()
self.save(portals=True)
def unregister_portal(self, portal: po.Portal) -> None:
try:
del self.portals[portal.tgid_full]
self.save()
self.save(portals=True)
except KeyError:
pass
async def needs_relaybot(self, portal: po.Portal) -> bool:
return not await self.is_logged_in() or (
self.is_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
for id in sorted([self.saved_contacts] + [contact.id for contact in self.contacts]):
acc = (acc * 20261 + id) & 0xffffffff
for contact in sorted([self.saved_contacts] + [contact.id for contact in self.contacts]):
acc = (acc * 20261 + contact) & 0xffffffff
return acc & 0x7fffffff
async def sync_contacts(self) -> None:
response = await self.client(GetContactsRequest(hash=self._hash_contacts()))
if isinstance(response, ContactsNotModified):
return
self.log.debug("Updating contacts...")
self.log.debug(f"Updating contacts of {self.name}...")
self.contacts = []
self.saved_contacts = response.saved_count
for user in response.users:
puppet = pu.Puppet.get(user.id)
await puppet.update_info(self, user)
self.contacts.append(puppet)
self.save()
self.save(contacts=True)
# endregion
# 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")
@@ -355,27 +393,26 @@ class User(AbstractUser):
except KeyError:
pass
user = DBUser.query.get(mxid)
user = DBUser.get_by_mxid(mxid)
if user:
user = cls.from_db(user)
return user
if create:
user = cls(mxid)
cls.db.add(user.db_instance)
cls.db.commit()
user.db_instance.insert()
return user
return None
@classmethod
def get_by_tgid(cls, tgid: int) -> Optional['User']:
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
try:
return cls.by_tgid[tgid]
except KeyError:
pass
user = DBUser.query.filter(DBUser.tgid == tgid).one_or_none()
user = DBUser.get_by_tgid(tgid)
if user:
user = cls.from_db(user)
return user
@@ -387,11 +424,13 @@ 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.query.filter(DBUser.tg_username == username).one_or_none()
puppet = DBUser.get_by_username(username)
if puppet:
return cls.from_db(puppet)
@@ -399,9 +438,9 @@ class User(AbstractUser):
# endregion
def init(context: 'Context') -> List[Coroutine]: # [None, None, AbstractUser]
def init(context: 'Context') -> Iterable[Awaitable['User']]:
global config
config = context.config
users = [User.from_db(user) for user in DBUser.query.all()]
return [user.ensure_started() for user in users]
return (User.from_db(db_user).try_ensure_started()
for db_user in DBUser.all_with_tgid())
+2 -1
View File
@@ -1,4 +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
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)
+96 -62
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -21,23 +20,27 @@ import logging
import asyncio
import magic
from sqlalchemy import orm
from sqlalchemy.exc import IntegrityError, InvalidRequestError
from sqlalchemy.orm.exc import FlushError
from telethon.tl.types import (Document, FileLocation, InputFileLocation,
InputDocumentFileLocation, PhotoSize, PhotoCachedSize)
from telethon.tl.types import (Document, InputFileLocation, InputDocumentFileLocation,
TypePhotoSize, PhotoSize, PhotoCachedSize, InputPhotoFileLocation,
InputPeerPhotoFileLocation)
from telethon.errors import (AuthBytesInvalidError, AuthKeyInvalidError, LocationInvalidError,
SecurityError)
from mautrix_appservice import IntentAPI
SecurityError, FileIdInvalidError)
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,9 +50,12 @@ try:
except ImportError:
VideoFileClip = random = string = os = mimetypes = None
log = logging.getLogger("mau.util") # type: logging.Logger
from .tgs_converter import convert_tgs_to
TypeLocation = Union[Document, InputDocumentFileLocation, FileLocation, InputFileLocation]
log: logging.Logger = logging.getLogger("mau.util")
TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation,
InputFileLocation, InputPhotoFileLocation]
def convert_image(file: bytes, source_mime: str = "image/webp", target_type: str = "png",
@@ -58,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()
@@ -101,9 +107,11 @@ 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)):
return f"{location.id}-{location.version}"
elif isinstance(location, (FileLocation, InputFileLocation)):
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}"
@@ -117,8 +125,12 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
if not loc_id:
return None
video_ext = mimetypes.guess_extension(mime)
if VideoFileClip and video_ext:
db_file = DBTelegramFile.get(loc_id)
if db_file:
return db_file
video_ext = sane_mimetypes.guess_extension(mime)
if VideoFileClip and video_ext and video:
try:
file, width, height = _read_video_thumbnail(video, video_ext, frame_ext="png")
except OSError:
@@ -129,24 +141,35 @@ 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)
return DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
was_converted=False, timestamp=int(time.time()), size=len(file),
width=width, height=height)
db_file = DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
was_converted=False, timestamp=int(time.time()), size=len(file),
width=width, height=height)
try:
db_file.insert()
except (IntegrityError, InvalidRequestError) as e:
log.exception(f"{e.__class__.__name__} while saving transferred file thumbnail data. "
"This was probably caused by two simultaneous transfers of the same file, "
"and might (but probably won't) cause problems with thumbnails or something.")
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(db: orm.Session, client: MautrixTelegramClient, intent: IntentAPI,
location: TypeLocation, thumbnail: Optional[TypeLocation] = None,
is_sticker: bool = False) -> Optional[DBTelegramFile]:
async def transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
location: TypeLocation, thumbnail: TypeThumbnail = None,
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
db_file = DBTelegramFile.query.get(location_id)
db_file = DBTelegramFile.get(location_id)
if db_file:
return db_file
@@ -156,44 +179,62 @@ async def transfer_file_to_matrix(db: orm.Session, client: MautrixTelegramClient
lock = asyncio.Lock()
transfer_locks[location_id] = lock
async with lock:
return await _unlocked_transfer_file_to_matrix(db, client, intent, location_id, location,
thumbnail, is_sticker)
return await _unlocked_transfer_file_to_matrix(client, intent, location_id, location,
thumbnail, is_sticker, tgs_convert,
filename, parallel_id)
async def _unlocked_transfer_file_to_matrix(db: orm.Session, client: MautrixTelegramClient,
intent: IntentAPI, loc_id: str, location: TypeLocation,
thumbnail: Optional[TypeLocation],
is_sticker: bool) -> Optional[DBTelegramFile]:
db_file = DBTelegramFile.query.get(loc_id)
async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
loc_id: str, location: TypeLocation,
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:
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
@@ -201,16 +242,9 @@ async def _unlocked_transfer_file_to_matrix(db: orm.Session, client: MautrixTele
mime_type)
try:
db.add(db_file)
db.commit()
except FlushError as e:
log.exception(f"{e.__class__.__name__} while saving transferred file data. "
"This was probably caused by two simultaneous transfers of the same file, "
"and should not cause any problems.")
db_file.insert()
except (IntegrityError, InvalidRequestError) as e:
db.rollback()
log.exception(f"{e.__class__.__name__} while saving transferred file data. "
"This was probably caused by two simultaneous transfers of the same file, "
"and should not cause any problems.")
return db_file
+1 -2
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -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)
+6 -6
View File
@@ -1,6 +1,5 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
# 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
@@ -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

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