Compare commits

...

615 Commits

Author SHA1 Message Date
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
Tulir Asokan 9aad6de823 Bump version to 0.4.0rc2 2018-11-15 22:46:36 +02:00
Tulir Asokan 3d3afdb645 Fix bug in 82d7e78455 2018-11-15 22:45:48 +02:00
Tulir Asokan 983f5001ab Bump version to 0.4.0rc1 2018-11-15 22:27:25 +02:00
Tulir Asokan a80fdf0990 Fix bug in 720210ac08 2018-11-15 22:25:49 +02:00
Tulir Asokan 82d7e78455 Handle kicking puppets separately. Fixes #191 2018-11-15 11:57:02 +02:00
Tulir Asokan d514b929b3 Automatically log out when logging in with a user someone logged in with previously. Fixes #198 2018-11-15 11:45:46 +02:00
Tulir Asokan 720210ac08 Check if client is connected before checking if authorized. Fixes #215 2018-11-15 11:45:36 +02:00
Tulir Asokan 2dfc05db5f Fall back to get_dialogs if get_entity fails. Fixes #229 2018-11-15 11:20:43 +02:00
Tulir Asokan d551934ec1 Fix command suggestion when trying to bridge non-whitelisted chat 2018-11-01 01:55:54 +02:00
Tulir Asokan bac1e30cf0 Fix Matrix->Telegram code blocks without language. Fixes #240 2018-10-27 19:22:04 +03:00
Tulir Asokan 8fdb2c4e57 Merge pull request #239 from tulir/sqlalchemy-core
Port Message table to SQLAlchemy Core
2018-10-21 00:32:14 +03:00
Tulir Asokan 8da1fb78b8 Handle aiohttp errors in syncer. Fixes #210 2018-10-21 00:09:37 +03:00
Tulir Asokan cea8163366 Only match integers in puppet mxid regex. Fixes #234 2018-10-21 00:08:02 +03:00
Tulir Asokan 388e4f8601 Port Message table to SQLAlchemy Core 2018-10-20 23:11:10 +03:00
Tulir Asokan 2756873c53 Add SIGINT/SIGTERM handler 2018-10-20 21:21:26 +03:00
Tulir Asokan a770e1f67e Merge pull request #237 from turt2live/travis/fix-chat-id-request
Don't try permission checks on rooms that aren't bridged
2018-10-20 14:56:27 +03:00
Tulir Asokan f8c844c4c0 Add flag to enable alchemysession core mode 2018-10-20 14:46:26 +03:00
Travis Ralston 7f23d4cf68 Don't try permission checks on rooms that aren't bridged
This is the proper way to fix https://github.com/tulir/mautrix-telegram/pull/235
2018-10-19 19:31:58 -06:00
Tulir Asokan 247c75191b Merge pull request #226 from turt2live/travis/bridge-info
Add provisioning route for getting misc bridge info
2018-10-08 14:02:19 +03:00
Travis Ralston 4f3e1b4fe6 Fix errors in spec.yaml 2018-10-08 01:16:29 -06:00
Travis Ralston 6291e92ed7 Remove extraneous fstring 2018-10-08 01:15:49 -06:00
Tulir Asokan 5054afcbb5 Fix Python 3.5 compatibility 2018-10-02 14:51:54 +03:00
Tulir Asokan 980e0d6ef7 Send captions as second message by default. Fixes #233 2018-09-29 10:56:04 +03:00
Tulir Asokan 2f6147f325 Fix notice bridging exceptions 2018-09-29 01:35:30 +03:00
Tulir Asokan 56fb88b75e Use mxids instead of localparts as default displaynames and fix name add/remove message. Fixes #228 2018-09-29 00:59:02 +03:00
Tulir Asokan 24bdda8ca1 Reorganize formatter utils and add more blue text 2018-09-28 18:39:57 +03:00
Tulir Asokan c38e46fc2a Fix linebreaks in pre blocks 2018-09-28 17:15:57 +03:00
Tulir Asokan 916cc3746d Fix block tag newlines and allow <strike>. Fixes #232 2018-09-28 17:06:42 +03:00
Tulir Asokan a32bc2985a Show phone number when username doesn't exist. Fixes #213 2018-09-28 02:46:02 +03:00
Tulir Asokan 8d982b4615 Bump minimum mautrix-appservice version. Fixes #217 2018-09-28 02:22:54 +03:00
Tulir Asokan 10e77707d0 Fix HTML escaping in command reply markdown parser 2018-09-28 02:18:41 +03:00
Tulir Asokan b0fe208768 Add missing await to portal.set_typing 2018-09-28 01:18:39 +03:00
Tulir Asokan b44d6d2d90 Fix minor things and type hints 2018-09-28 01:02:09 +03:00
Tulir Asokan 828047e272 Split TelegramMessage helper to separate file 2018-09-28 00:49:37 +03:00
Tulir Asokan a9cb1bf518 Fix linebreak handling in lxml parser and add better bullets
Fixes #218
2018-09-28 00:45:37 +03:00
Tulir Asokan d71f421981 Use <pre> for multiline MessageEntityCode entities 2018-09-26 00:24:04 +03:00
Tulir Asokan 26e947992e Merge pull request #231 from tulir/room-specific-settings
Add room specific config
2018-09-25 00:47:44 +03:00
Tulir Asokan 78e4804774 Fix minor things and improve code style 2018-09-25 00:47:16 +03:00
Tulir Asokan 5ccd1bc2fe Fix bugs and switch to commonmark for command replies 2018-09-25 00:26:02 +03:00
Tulir Asokan f758884c75 Fix example config and add alembic migration 2018-09-24 23:41:18 +03:00
Tulir Asokan 9d2d34a25c Add command to update room-specific config 2018-09-24 17:44:00 +03:00
Tulir Asokan fc23461445 Add room specific settings. Probably broken 2018-09-24 16:01:16 +03:00
Tulir Asokan 5253504df9 Update setup.py classifiers 2018-09-24 01:26:02 +03:00
Tulir Asokan dd270b862e Fix handling capitalized file extensions. Fixes #156 2018-09-24 01:25:51 +03:00
Travis Ralston 5bc1362493 Add provisioning route for getting misc bridge info
Currently only the relay bot's username is exposed here.
2018-09-19 22:44:27 -06:00
Tulir Asokan 96a0c923c2 Merge pull request #225 from turt2live/travis/unbridge-info
Add a flag to indicate if the requesting user can unbridge the portal
2018-09-17 01:24:17 +03:00
Travis Ralston 23bb2871fd Add a flag to indicate if the requesting user can unbridge the portal 2018-09-16 16:16:33 -06:00
Tulir Asokan d4ea5f8b38 Improve type hints and set version to 0.4.0+dev 2018-09-10 01:14:12 +03:00
Tulir Asokan 4b2cdc3d39 Add missing command status clear 2018-09-10 00:14:35 +03:00
Tulir Asokan 4c54d9c9ea Fix previous commit (ref #219) and update catch_up config comment 2018-09-10 00:11:13 +03:00
Tulir Asokan 9541d5eceb Don't bridge messages from unbridged chats received by bot (ref #219) 2018-09-09 01:26:22 +03:00
Tulir Asokan c9c1023ece Merge pull request #223 from turt2live/patch-1
Allow negative numbers in /connect
2018-09-09 01:14:38 +03:00
Travis Ralston cb2073eb8b Allow negative numbers in /connect 2018-09-08 16:14:00 -06:00
Tulir Asokan d35104aea6 Fix incorrect type hint 2018-09-05 10:55:12 +03:00
Tulir Asokan ad342f2ca4 Ignore old log files too 2018-09-01 19:08:29 +03:00
Tulir Asokan 29541ff520 Pass logging a copy of the config to stop editing. Fixes #216 2018-09-01 14:07:44 +03:00
Tulir Asokan 6a1c160608 Await set_presence. Fixes #209 2018-09-01 14:03:13 +03:00
Tulir Asokan 731c802fcd Only import deque in type checking mode to fix 3.5 runtime support 2018-08-30 19:03:22 +03:00
Tulir Asokan b6f15934f2 Fix conversational command handling 2018-08-30 13:32:04 +03:00
Tulir Asokan 068449c59c Update ROADMAP.md 2018-08-24 09:47:54 +03:00
Tulir Asokan 4f36a2c7c1 Simplify displayname similarity calculation 2018-08-17 00:06:37 +03:00
Tulir Asokan bb04231880 Fix bugs in migrations 2018-08-17 00:06:02 +03:00
Tulir Asokan 1ef790ce31 Merge pull request #206 from V02460/master
Add type annotations
2018-08-15 10:18:39 +03:00
Tulir Asokan 65490f3cf4 Bump version to 0.3.0 and bump max Telethon version to 1.2 2018-08-15 10:11:58 +03:00
Tulir Asokan ec43b5c822 Add DB URI format examples (ref #208) 2018-08-11 21:48:27 +03:00
Kai A. Hiller 81531235bc Replace double quote type annotations with single quotes 2018-08-09 14:36:14 +02:00
Kai A. Hiller 66683151ec Make SearchResult a NewType and make its List explicit 2018-08-09 14:23:18 +02:00
Kai A. Hiller e751d140f2 Change case of new types 2018-08-09 14:11:41 +02:00
Kai A. Hiller 0f8009b1e9 Add missing type hints and fix most type errors except for Optionals. 2018-08-09 03:31:04 +02:00
Kai A. Hiller 01e153662e Replace star imports with literal values 2018-08-09 02:42:48 +02:00
Kai A. Hiller 08dd5b5b15 Add None return type to functions 2018-08-09 02:42:47 +02:00
Tulir Asokan c9ffd23729 Bump version to 0.3.0rc3 2018-08-08 10:19:04 +03:00
Tulir Asokan ccd2eaec70 Improve Telegram message deduplication
* Add pre-send message database check for deduplication
* Make dedup cache queue length configurable
2018-08-07 23:29:12 +03:00
Tulir Asokan 79cdc2e952 Set PyPI long description content type to markdown 2018-08-07 16:14:28 +03:00
Tulir Asokan d5193438de Update dependencies and bump version to 0.3.0rc2 2018-08-06 20:38:28 +03:00
Tulir Asokan 0d22f7a6e3 Merge pull request #203 from turt2live/travis/patch-power-level-1
Fix a minor error regarding power level changes
2018-08-06 20:31:43 +03:00
Travis Ralston b36f962761 Fix a minor error regarding power level changes
The first power level event won't have previous power levels. This can cause problems sometimes, although usually minor.
2018-08-06 10:50:24 -06:00
Tulir Asokan ff3da70494 Fix max_body_size config option 2018-08-06 00:14:18 +03:00
Tulir Asokan 0848938174 Add option to change max body size for AS API
ref tulir/mautrix-appservice-python#3
2018-08-06 00:06:13 +03:00
Tulir Asokan a82a124b11 Bump version to 0.3.0rc1 2018-08-05 22:58:48 +03:00
Tulir Asokan 1b7a10218a Fix logging out if portal was deleted/unbridged. Fixes #173 2018-08-05 22:53:15 +03:00
Tulir Asokan 6c8cfc1b26 Implement /connect endpoint in provisioning API. Fixes #180 2018-08-05 22:39:58 +03:00
Tulir Asokan 9b0be2dd55 Add option to disable channel member list syncing 2018-08-05 22:07:12 +03:00
Tulir Asokan 704e00540e Add new permission level before "full" that can't use Matrix login. Fixes #199 2018-08-05 20:39:45 +03:00
Tulir Asokan 14b105e74f Never bridge messages from the relay bot. Fixes #202 2018-08-05 20:11:13 +03:00
Tulir Asokan f2390c4937 Fix some Nones and fix TelegramMessage.prepend() 2018-07-26 10:16:21 -04:00
Tulir Asokan 83a9de164e Revert "Add psycopg2 to optional dependencies (ref #195)"
This reverts commit a27af08410.
2018-07-25 22:44:33 -04:00
Tulir Asokan a27af08410 Add psycopg2 to optional dependencies (ref #195) 2018-07-25 22:31:39 -04:00
Tulir Asokan fd6e22fa5c Merge pull request #196 from tulir/lxml-formatter
Add tree-based HTML parser for Matrix->Telegram formatting
2018-07-25 22:25:07 -04:00
Tulir Asokan 9d6c3a2ed3 Add psycopg2 apk package to Dockerfile. Fixes #195 2018-07-25 22:13:49 -04:00
Tulir Asokan 629a406051 Fix small formatting things 2018-07-25 22:10:45 -04:00
Tulir Asokan 1421ae0cce Implement strikethrough and underline in new HTML parser 2018-07-25 22:05:42 -04:00
Tulir Asokan 3cca11a997 Implement lxml parser 2018-07-25 21:45:25 -04:00
Tulir Asokan c08659c75a Fix bugs 2018-07-25 11:53:31 -04:00
Tulir Asokan d5f6e45363 Merge branch 'master' into lxml-formatter 2018-07-25 11:39:48 -04:00
Tulir Asokan dbfb980bde Add more type hints 2018-07-25 11:02:38 -04:00
Tulir Asokan ae334b9a04 Add hacky local filtering for ephemeral events 2018-07-24 14:42:28 -04:00
Tulir Asokan 55b6773b5e Limit custom puppet syncing to own EDUs to prevent echoing/duplicates 2018-07-24 12:47:27 -04:00
Tulir Asokan a22b83de44 Disable presence and read receipt bridging for bots. Fixes #194 2018-07-24 12:46:54 -04:00
Tulir Asokan c5bec37401 Disable unimplemented password login checkbox in Matrix web login 2018-07-23 13:50:49 -04:00
Tulir Asokan aaa4f96805 Merge pull request #190 from tulir/replace_matrix_puppet
Add option to replace the Matrix puppet of own Telegram account with real Matrix account
2018-07-23 13:49:11 -04:00
Tulir Asokan 4736686454 Implement Matrix login with web interface 2018-07-23 11:49:42 -04:00
Tulir Asokan f3e1c755eb Bump mautrix-appservice version requirement 2018-07-22 18:22:13 -04:00
Tulir Asokan ab098879fd Don't set presence when /syncing custom puppets 2018-07-22 18:08:18 -04:00
Tulir Asokan 76410ee7cb Implement Matrix->Telegram presence 2018-07-22 17:42:29 -04:00
Tulir Asokan af46aee191 Implement Matrix->Telegram read receipts 2018-07-22 17:42:14 -04:00
Tulir Asokan e4e100a184 Add option to disable /syncing with custom puppets 2018-07-22 17:28:27 -04:00
Tulir Asokan 54d7ac5542 Implement Matrix->Telegram typing notifications 2018-07-22 17:28:27 -04:00
Tulir Asokan 54287c344f Implement syncing with custom puppets 2018-07-21 10:45:29 -04:00
Tulir Asokan ecdca21e32 Stop handling events from custom puppets 2018-07-20 14:13:13 -04:00
Tulir Asokan 2b92483c50 Initial option to replace Matrix puppet of own Telegram account 2018-07-20 12:35:22 -04:00
Tulir Asokan ad7b7f5c06 Stop using f-strings in Alembic migrations. Fixes #189 2018-07-20 10:05:23 -04:00
Tulir Asokan 340360e6a0 Merge pull request #188 from V02460/master
Fix install of web resources
2018-07-20 03:44:27 +03:00
Kai A. Hiller 64d726ec2b Fix install of web resources 2018-07-20 02:02:09 +02:00
Tulir Asokan e4ce73cbba Revert Context iter changes in 87dc1a44b2 and fix a f-string
Closes #185
2018-07-17 09:49:01 +03:00
Tulir Asokan 88d50879d5 Merge pull request #186 from turt2live/travis/telematrix-safety
De-duplicate objects in the Telematrix import
2018-07-17 09:45:31 +03:00
Travis Ralston c8e44d4ab4 De-duplicate objects in the Telematrix import 2018-07-16 18:05:06 -06:00
Tulir Asokan e9348c9550 Rename db_migrate script to dbms_migrate 2018-07-16 23:31:36 +03:00
Tulir Asokan d4b725a508 Add comment about supported DBMSes 2018-07-16 23:27:06 +03:00
Tulir Asokan 9830842707 Add db_migrate script. Fixes #178 2018-07-16 23:21:40 +03:00
Tulir Asokan 6926bce139 Remove unnecessary __init__s and fix telematrix import script program name 2018-07-16 23:21:14 +03:00
Tulir Asokan 0625b2d661 Handle FileNotFoundError when migrating state store 2018-07-16 20:09:42 +03:00
Tulir Asokan 8aae5beb27 Merge pull request #183 from turt2live/travis/fix-user-level
Enable user-level access to bridge and unbridge commands
2018-07-16 09:25:52 +03:00
Travis Ralston 122699593d Enable user-level access to bridge and unbridge commands 2018-07-15 22:39:52 -06:00
Tulir Asokan 996e8ab445 Update alembic version 2018-07-15 16:21:11 +03:00
Tulir Asokan 23232cf88c Don't crash on TimeoutError when initializing AS bot. Fixes #179 2018-07-15 16:13:02 +03:00
Tulir Asokan 87dc1a44b2 Add bot_avatar config field 2018-07-15 16:08:49 +03:00
Tulir Asokan dfca56b292 Fix cleaning up management rooms. Fixes #172 2018-07-15 15:46:28 +03:00
Tulir Asokan c4b41f0a5c Merge pull request #177 from tulir/provisioning-api
Add provisioning API
2018-07-15 15:38:07 +03:00
Tulir Asokan 4d63cd75d4 Update spec metadata 2018-07-15 15:32:37 +03:00
Tulir Asokan 64391ae20d Ignore .log files instead of logs/ 2018-07-15 15:19:59 +03:00
Tulir Asokan c55967c9f0 Implement disconnecting portals via provisioning API 2018-07-15 15:19:37 +03:00
Tulir Asokan c2879408cc Make bridging permission checks consistent 2018-07-15 15:02:15 +03:00
Tulir Asokan a46cc7a788 Add logout endpoint 2018-07-15 12:38:24 +03:00
Tulir Asokan 9f4f63f084 Merge branch 'master' into provisioning-api 2018-07-15 11:50:29 +03:00
Tulir Asokan e71f7280b8 Fix command in dockerfile 2018-07-15 01:22:14 +03:00
Tulir Asokan b4dd05ab04 Simplify docker setup 2018-07-15 01:16:34 +03:00
Tulir Asokan 2aa0ed3825 Merge pull request #158 from tulir/mautrix-appservice-0.3.0
Move Matrix state cache to main database
2018-07-15 00:16:26 +03:00
Tulir Asokan bfaec2eb81 Merge branch 'master' into mautrix-appservice-0.3.0 2018-07-15 00:15:30 +03:00
Tulir Asokan 0f1ac98b9f Remove old things from gitignore 2018-07-15 00:14:43 +03:00
Tulir Asokan 2a65ccc674 Cache RoomStates and UserProfiles 2018-07-15 00:07:45 +03:00
Tulir Asokan e16e53c261 Ignore alembic in code climate 2018-07-14 23:31:11 +03:00
Tulir Asokan 96ac0a0b17 Merge branch 'master' into provisioning-api 2018-07-14 23:28:10 +03:00
Tulir Asokan 6cef4d81c6 Add .codeclimate.yml 2018-07-14 23:27:55 +03:00
Tulir Asokan cea5210290 Add /v1 prefix to provisioning API by default 2018-07-14 23:15:28 +03:00
Tulir Asokan 4cef2be0db Implement /portal/{mxid}/create 2018-07-14 23:14:04 +03:00
Tulir Asokan 34cc810d62 Fix /portal/{chat_id} 2018-07-14 19:33:55 +03:00
Tulir Asokan bbc7912a49 Allow getting user info of unauthenticated users and add /portal/{chat_id} 2018-07-14 19:27:16 +03:00
Tulir Asokan 2b5426fda3 Add portal info and user chat list endpoints 2018-07-14 18:57:46 +03:00
Tulir Asokan d97281bcdc Require authentication for web login. Fixes #163 2018-07-14 16:00:20 +03:00
Tulir Asokan 298e326de7 Fix login command and add token login error handlers 2018-07-14 14:39:49 +03:00
Tulir Asokan 90e7a09b7e Automatically generate provisioning shared secret if it has the default value 2018-07-13 23:03:34 +03:00
Tulir Asokan f6fb37f5da Update endpoint paths 2018-07-13 22:59:26 +03:00
Tulir Asokan ac4d7cc412 Add /get_me endpoint 2018-07-13 22:58:07 +03:00
Tulir Asokan 94a2344f3b Enable and spec authorization and json validation 2018-07-13 22:47:09 +03:00
Tulir Asokan 998e2fa19c Enable aiohttp logging by default 2018-07-13 22:46:38 +03:00
Tulir Asokan 5082cd1c94 Fix bad JSON handling and include state in all responses 2018-07-13 22:28:43 +03:00
Tulir Asokan 48665acf1d Fix imports and other mistakes 2018-07-13 22:15:40 +03:00
Tulir Asokan bc160e0593 Update logger names 2018-07-13 22:11:05 +03:00
Tulir Asokan 1fd920255f Finish initial provisioning API spec and impl 2018-07-13 21:25:51 +03:00
Tulir Asokan c0ceb1b2b0 Move post_login_token to common/auth_api 2018-07-12 23:45:15 +03:00
Tulir Asokan f07009d0d2 Add initial parts of provisioning API spec 2018-07-12 23:39:23 +03:00
Tulir Asokan fa30cb5c1f Move web stuff to web package 2018-07-12 23:39:23 +03:00
Tulir Asokan 5d48040eb8 Separate auth methods from public API 2018-07-12 23:39:23 +03:00
Tulir Asokan f6923a5e1b Add provisioning API config (ref #154) 2018-07-12 23:39:23 +03:00
Tulir Asokan 15fd394d54 Add proxy config. Fixes #153 2018-07-12 23:08:08 +03:00
Tulir Asokan 1d9455f639 Allow specifying address and listen host/port separately. Fixes #160 2018-07-12 22:59:17 +03:00
Tulir Asokan 042d89cf65 Add full log config. Fixes #166 2018-07-12 22:49:53 +03:00
Tulir Asokan 7515b31164 Move Matrix state cache to main database. Fixes #159 2018-07-12 16:05:54 +03:00
Tulir Asokan 99f84b5dfe Initial split to htmlparser/lxml matrix->telegram formatters 2018-07-12 15:58:07 +03:00
Tulir Asokan 2172587286 Merge pull request #175 from digitalatigid/digital-bot-login
Add command to log in as bot
2018-07-11 23:34:32 +03:00
digital 193c4409ee Improve command based login as bot 2018-07-11 01:03:19 +02:00
digital 74bc89475e Add command to log in as bot 2018-07-10 18:25:29 +02:00
Tulir Asokan 7c2e689813 Update mautrix-appservice dependency 2018-07-10 14:45:50 +03:00
Tulir Asokan 0a171d242f Handle empty/invalid state event content in _get_initial_state()
Fixes #171
2018-07-10 14:24:10 +03:00
Tulir Asokan 7a4d29e1e4 Make help message dynamic based on permissions 2018-07-10 14:21:21 +03:00
Tulir Asokan ecf0e262df Switch to telethon package on pypi 2018-07-10 14:21:21 +03:00
Tulir Asokan d035e9da73 Add user auth level
Fixes #162
Closes #168
Closes #170
2018-07-10 14:21:21 +03:00
Tulir Asokan 74f3956608 Unrestrict telethon version 2018-07-09 20:36:24 +03:00
Tulir Asokan 62b66040e7 Add some more debug messages to message receiving/handling 2018-07-01 18:41:05 +03:00
Tulir Asokan 8a198e67a8 Register bot chat membership when receiving messages 2018-06-28 00:21:10 +03:00
Tulir Asokan d9e4cc9d4e Require telethon 1.0rc1 or higher 2018-06-25 23:23:09 +03:00
Tulir Asokan 371c6813de Stop creating connections for unauthenticated users at startup 2018-06-25 21:30:54 +03:00
Tulir Asokan 0f8a2e7c51 Fix Matrix->Telegram redactions 2018-06-24 02:10:41 +03:00
Tulir Asokan 895f9ac98a Fix bridge.message_formats config updating 2018-06-24 01:50:22 +03:00
Tulir Asokan 86bda1bb45 Allow disabling state event relaying by setting format to empty string. Fixes #130 2018-06-24 01:46:06 +03:00
Tulir Asokan 99f0c02766 Bump minimum mautrix-appservice version 2018-06-24 01:31:57 +03:00
Tulir Asokan 4a0d00e74c Add support for Matrix displaynames in relaybot messages 2018-06-24 01:24:24 +03:00
Tulir Asokan f5c4b477e5 Remove custom download_file_bytes() function 2018-06-24 00:20:05 +03:00
Tulir Asokan b50558a37d Remove custom send_message() function 2018-06-24 00:03:20 +03:00
Tulir Asokan ad23445b69 Simplify and improve message format config 2018-06-23 23:46:41 +03:00
Tulir Asokan f473c02bc3 Retry joins in bridge bot invite accepting. Fixes #150 2018-06-23 22:19:53 +03:00
Tulir Asokan f1b52e7465 Merge pull request #157 from tulir/telematrix-import
Telematrix import script
2018-06-23 22:05:19 +03:00
Tulir Asokan e6e6af0689 Make potential datacenter switch related file transfer auth errors non-fatal 2018-06-23 21:51:22 +03:00
Tulir Asokan 7a7c0b780f Convert user_level to int in _participant_to_power_levels 2018-06-23 21:43:06 +03:00
Tulir Asokan 3775206ab3 Move scripts under mautrix_telegram to allow calling them when installing with pip 2018-06-23 21:18:45 +03:00
Tulir Asokan 1d54d6755c Add initial telematrix import script (ref #112) 2018-06-23 21:17:25 +03:00
Tulir Asokan 42fc48adfe Replace tabs with 4 spaces
Telegram doesn't allow tabs and was converting them to a space.
The local formatter needs to account for all of telegram's formatting
rules as otherwise the content-based duplicate checker will fail.
2018-06-23 19:57:11 +03:00
Tulir Asokan 3068d41570 Remove unused import 2018-06-23 14:53:28 +03:00
Tulir Asokan f51d43b999 Increase connection timeout 2018-06-23 11:26:21 +03:00
Tulir Asokan fb43f13ed5 Remove unused alembic upgrade 2018-06-23 00:45:44 +03:00
Tulir Asokan 25b1adf626 Add support for logging in with a bot. Fixes #155 2018-06-23 00:44:41 +03:00
Tulir Asokan 17aefd02da Make alembic result consistent with definitions in db.py and add bot_id to bot_chat table 2018-06-22 21:20:00 +03:00
Tulir Asokan b127afbf9b Delete unauthenticated sessions 2018-06-22 15:13:22 +03:00
Tulir Asokan b8f2c9a8f7 Add recommendation to use out-of-Matrix login for telegram 2FA 2018-06-22 12:48:05 +03:00
Tulir Asokan d466060c44 Make logged_in and has_full_access async functions instead of properties 2018-06-22 12:45:19 +03:00
Tulir Asokan 42056b91c5 Fix critical Telethon core rewrite compatibility bugs 2018-06-21 16:16:16 +03:00
Tulir Asokan 68e6a70234 Merge pull request #152 from turt2live/travis/display_name
Add configuration for basic message formats
2018-06-08 12:01:14 +03:00
Tulir Asokan 642ea2baae Bump version to 0.3.0+dev 2018-06-08 12:00:33 +03:00
Tulir Asokan 005daa9ee2 Bump version to 0.2.0 2018-06-08 11:55:59 +03:00
Travis Ralston dad99823fc Add the m.emote message formats to the config 2018-06-07 14:58:46 -06:00
Travis Ralston 0d264e09a8 Add configuration for basic message formats
Fixes https://github.com/tulir/mautrix-telegram/issues/92
2018-06-07 13:49:03 -06:00
Tulir Asokan 7029102c0f Bump version to 0.2.0rc6 2018-06-06 13:39:09 +03:00
Tulir Asokan 708110eb08 Make cascade rules alembic upgrade optional to un-break sqlite 2018-06-03 14:30:19 +03:00
Tulir Asokan c0da861562 Add warning about delete-portal kicking all room members 2018-06-01 18:05:35 +03:00
Tulir Asokan 844cf14bcd Bump version to 0.2.0rc5 2018-06-01 13:27:20 +03:00
Tulir Asokan fe32475e10 Fix kicking Telegram puppets and fix error message when bridging chats you've left 2018-05-31 11:19:24 +03:00
Tulir Asokan f28f5915a4 Don't create portal in response to relaybot events. Fixes #145 2018-05-31 11:18:48 +03:00
Tulir Asokan 1aa80c1a8f Fix user_portal delete cascade when deleting portals 2018-05-31 11:18:20 +03:00
Tulir Asokan 5d9b94fa5f Bump version to 0.2.0rc4 2018-05-29 22:26:26 +03:00
Tulir Asokan 6ef31599e9 Read database path from config in alembic env.py
Slightly related to #135
2018-05-29 18:37:08 +03:00
Tulir Asokan e961e0bcc6 Fix manual bridging using the relay bot 2018-05-29 15:26:40 +03:00
Tulir Asokan dc85754b1e Fix postgres compatibility 2018-05-29 15:17:08 +03:00
Tulir Asokan 04e2c03dae Allow inviting relaybot-whitelisted Matrix users to portal from telegram 2018-05-29 15:15:42 +03:00
Tulir Asokan 42d54dac5b Bump version to 0.2.0rc3 2018-05-25 00:08:46 +03:00
Tulir Asokan 767a51f994 Merge pull request #142 from jcgruenhage/master
Rework Dockerfile to remove virtualenv
2018-05-25 00:07:57 +03:00
Jan Christian Grünhage 313b5e5d07 rework Dockerfile to remove virtualenv 2018-05-24 00:59:26 +02:00
Tulir Asokan 961707dd30 Bump version to 0.2.0rc2 2018-05-21 00:39:27 +03:00
Tulir Asokan 90197f1a40 Update links in README so they work on docker hub 2018-05-21 00:39:13 +03:00
Tulir Asokan 53a7111550 Merge pull request #137 from jcgruenhage/master
fix ffmpeg in docker
2018-05-21 00:25:02 +03:00
Jan Christian Grünhage 78d1f92c13 fix ffmpeg in docker 2018-05-20 23:22:07 +02:00
Tulir Asokan 37b13fe31b Merge pull request #136 from jcgruenhage/docker
Add Dockerfile
2018-05-20 16:10:06 +03:00
Jan Christian Grünhage 39c9548983 add Dockerfile 2018-05-20 14:39:28 +02:00
Tulir Asokan 606686ce84 Bump version to 0.2.0rc1 2018-05-19 23:13:19 +03:00
Tulir Asokan 649f8aa9a4 Allow escaping ! -> / conversion. Fixes #134 2018-05-19 21:51:52 +03:00
Tulir Asokan 13db0eea93 Sync telegram user's puppet at message send time if no display name is set. Fixes #131 2018-05-19 21:45:47 +03:00
Tulir Asokan adbd048108 Remove temporary debug messages 2018-05-19 21:42:48 +03:00
Tulir Asokan 1639099401 Update command help 2018-05-19 20:57:46 +03:00
Tulir Asokan 7a373fa556 Add option to filter telegram chats from being bridged. Fixes #41 2018-05-19 19:35:01 +03:00
Tulir Asokan 1f5261ff8f Initial solution and database update for #11
The database now contains a displayname_source field which is the
telegram user ID of the user whose point of view the displayname
is from.

Updates from the relaybot user always take precendence, but currently
the relaybot will never automatically fetch displaynames.
2018-05-19 17:22:16 +03:00
Tulir Asokan 0833850f4f Fix potential duplicate unauthenticated user join/leave message in Matrix 2018-05-19 16:18:04 +03:00
Tulir Asokan 87a715aa10 Add missing await when joining chat with invite link 2018-05-17 17:20:56 +03:00
Tulir Asokan ea209498ba Fix dependency version requirements 2018-05-14 21:37:29 +03:00
Tulir Asokan 79341b8d28 Add support for Telethon's catch_up() (ref #124) 2018-05-13 11:22:20 +03:00
Tulir Asokan fd763b953d Update dependencies and remove python 3.5 special casing 2018-05-13 10:52:44 +03:00
Tulir Asokan 949c380235 Update reply format again 2018-05-13 10:28:56 +03:00
Tulir Asokan 81d982d254 Add/handle <!--end-mx-reply--> at end of native reply fallbacks. Fixes #133 2018-05-04 15:40:26 +03:00
Tulir Asokan f7dfbbf3f4 Bump telethon-session-sqlalchemy version 2018-04-30 17:40:07 +03:00
Tulir Asokan 1e0f2c72b5 Fix line lengths and add limit to .editorconfig 2018-04-29 23:51:28 +03:00
Tulir Asokan 73e7b8f635 Add option to send bot messages as m.notice. Fixes #121 2018-04-29 23:51:27 +03:00
Tulir Asokan 8354bf6bb5 Send gif stickers as-is rather than converting to webp. Fixes #132 2018-04-29 23:15:05 +03:00
Tulir Asokan db5441c3eb Fix some potential errors in matrix file handling 2018-04-29 23:15:04 +03:00
Tulir Asokan bb13813952 Check if portal is channel before trusting member list 2018-04-29 15:54:16 +03:00
Tulir Asokan 2c47cdfac6 Add option to limit number of members in startup sync. Fixes #115 2018-04-29 15:37:53 +03:00
Tulir Asokan d9dd304b26 Send notification and leave when non-whitelisted user invites bridge bot. Fixes #122 2018-04-29 12:54:20 +03:00
Tulir Asokan 45981b9c77 Add Matrix->Telegram sticker bridging. Fixes #105 2018-04-29 01:49:19 +03:00
Tulir Asokan c040c0d59c Cut messages over 4096 characters long. Fixes #117 2018-04-29 01:19:12 +03:00
Tulir Asokan 4c26d7e59a Add some locks to fix #109 2018-04-29 00:50:50 +03:00
Tulir Asokan ae792a7b33 Bridge chat photo removing from Telegram to Matrix. Fixes #123 2018-04-29 00:31:25 +03:00
Tulir Asokan a3ed8dbce3 Add missing await (ref #123) 2018-04-29 00:25:28 +03:00
Tulir Asokan d332a429d6 Add option to disable native stickers. Fixes #116 2018-04-28 22:09:40 +03:00
Tulir Asokan 797ff06d10 Catch ValueError in pm command. Fixes #126 2018-04-28 22:05:27 +03:00
Tulir Asokan 193dcc714b Wait for sync to complete when running sync explicitly 2018-04-28 22:01:29 +03:00
Tulir Asokan 445d997be8 Allow deleting messages via relay bot. Fixes #114 2018-04-28 21:52:24 +03:00
Tulir Asokan 8da06c969c Add option to not make publicly joinable channels public on Matrix. Fixes #128 2018-04-28 21:39:43 +03:00
Tulir Asokan c87f410d3e Disable Telethon error reporter 2018-04-28 21:30:47 +03:00
Tulir Asokan 824725a698 Remove unnecessary newlines from some places. Fixes #113 2018-04-28 21:29:11 +03:00
Tulir Asokan 780edd7e57 Add user+portal-specific lock for sending/receiving messages of authenticated users. Fixes #108 2018-04-28 21:21:51 +03:00
Tulir Asokan e231c3ec9a Check prev_content before handling membership event
Fixes #111, #102 not fully fixed, prev_content doesn't
seem to exist every time even if it should
2018-04-28 20:17:55 +03:00
Tulir Asokan f5e3b39105 Merge pull request #127 from V02460/master
Fix Telegram registration
2018-04-23 11:42:42 +03:00
Kai A. Hiller fbb9075bbe Fix Telegram registration 2018-04-23 06:04:54 +02:00
Tulir Asokan 07f5348ff0 Enable debug mode by default in example config 2018-04-19 11:16:32 +03:00
Tulir Asokan 1ce8f08ff2 Remove debug prints and don't set TelegramFile.was_converted if webp image wasn't converted 2018-04-19 11:16:32 +03:00
Jan Christian Grünhage a652fb1d8c fix: copy database path to updated config (#119) 2018-04-17 18:38:39 +03:00
Tulir Asokan 41f2f64322 Update Telethon 2018-04-15 19:01:05 +03:00
Tulir Asokan 2eba5f687a Improve hacky post-error handling for #108 2018-04-15 17:45:28 +03:00
Tulir Asokan 423731751d Make sure BotChat row exists before trying to delete it 2018-04-13 19:45:54 +03:00
Tulir Asokan 92b86deeba Move invite debug log to start of handler 2018-04-13 19:44:06 +03:00
Tulir Asokan b4b1951509 Add hacky post-error handling for #108 2018-04-13 19:23:54 +03:00
Tulir Asokan cc29aec3f6 Remove timestamp massaging from edits. Fixes #106 2018-04-13 18:09:56 +03:00
Tulir Asokan 65174d9998 Allow signing in with passwords containing spaces 2018-04-13 14:51:08 +03:00
Tulir Asokan 4804023acf Fix bridging Telegram documents (video/audio/file) 2018-04-09 13:35:54 +03:00
Tulir Asokan 459128a417 Fix error when handling DocumentAttributeSticker without alt 2018-04-08 17:57:30 +03:00
Tulir Asokan d40b0b896b Bump mautrix-appservice dependency version and fix mime type document attribute handling 2018-04-07 00:48:55 +03:00
Tulir Asokan 006a5971ea Split up telegram document handling and send stickers as m.sticker
Also add sticker resizing (max 256x256). Cached stickers won't be resized,
delete the `telegram_file` database table if you want all stickers to be
resized.

Fixes #104
2018-04-07 00:34:06 +03:00
Tulir Asokan 4498ab4721 Compress completed roadmap things and add new points 2018-04-02 11:46:42 +03:00
Tulir Asokan 133e4af712 Fix replying to replies of forwarded messages
Fixes #93
2018-03-31 19:48:31 +03:00
Tulir Asokan 66d68f6b63 Fix error when trying to mention unauthenticated users 2018-03-31 11:18:39 +03:00
Tulir Asokan a1297e90ce Update alchemysession to fix get_entity 2018-03-30 12:50:48 +03:00
Tulir Asokan c24cd8fbb1 Update mautrix-appservice to fix timestamp massaging timezone problems 2018-03-29 23:32:09 +03:00
Tulir Asokan 59a0ca33ee Update mautrix-appservice and python 3.5 version of telethon 2018-03-29 22:23:17 +03:00
Tulir Asokan 502a3599fc Add preview 2018-03-29 22:06:45 +03:00
Tulir Asokan 6c0399ac7b Convert t.me message URLs to matrix.to message URLs. Fixes #98 2018-03-29 21:23:47 +03:00
Tulir Asokan 68a743a563 Send Telegram timestamps and source URLs to Matrix
Fixes #97
Fixes #100
2018-03-29 20:57:17 +03:00
Tulir Asokan 22f430c340 Fix forwarded messages from channels not appearing 2018-03-24 17:01:09 +02:00
Tulir Asokan 91ae50911e Fix Telethon 0.18.1 compatibility. Fixes #96 2018-03-24 16:39:28 +02:00
Tulir Asokan 2bf327dbc5 Accept jpegs as images 2018-03-15 20:54:06 +02:00
Tulir Asokan 0e23aafa3d Fix duplicate participants causing some users to be left out 2018-03-12 11:02:43 +02:00
Tulir Asokan 87c87f93ef Fix license in setup.py 2018-03-11 21:07:08 +02:00
Tulir Asokan 578b025f17 Merge pull request #91 from tulir/allow-portals-without-power
Allow portals without power level for AS bot
2018-03-11 21:06:35 +02:00
Tulir Asokan 73de61dabf Fix mautrix-appservice dependency name and bump version 2018-03-11 21:05:18 +02:00
Tulir Asokan c4b2cf3553 Add link to Telegram chat 2018-03-11 14:25:35 +02:00
Tulir Asokan 733bbb30c3 Use canonical alias instead of MXID as default title 2018-03-11 13:46:02 +02:00
Tulir Asokan 88a8404898 Fix saving created portals and use mxid as title by default 2018-03-11 13:41:05 +02:00
Tulir Asokan 54d2b4bba8 Make puppets leave room instead of kicking by AS bot 2018-03-11 13:12:40 +02:00
Tulir Asokan 4448077d43 Fix bot.add_chat() when creating Telegram chat 2018-03-11 13:06:26 +02:00
Tulir Asokan 209d7cbdcc Merge branch 'master' into allow-portals-without-power 2018-03-11 13:01:24 +02:00
Tulir Asokan 715b658a3d Switch to a simpler non-versioned automatic config update 2018-03-11 11:25:45 +02:00
Tulir Asokan 68648d7b5c Improve support for portals without power levels 2018-03-11 10:39:24 +02:00
Tulir Asokan ad9cd27185 Merge branch 'master' into allow-portals-without-power 2018-03-11 10:24:15 +02:00
Tulir Asokan ad67996d91 Update ROADMAP.md 2018-03-11 00:35:37 +02:00
Tulir Asokan b06e7932f0 Add Matrix->Telegram location bridging and add user to relaybot files. Fixes #89 2018-03-10 19:53:08 +02:00
Tulir Asokan 7837f03532 Add Matrix->Telegram message pinning and show user in Telegram->Matrix pinning. Fixes #90 2018-03-10 16:03:37 +02:00
Tulir Asokan 42e33ab54d Add temporary patch for TypeMessageEntity 2018-03-10 14:56:25 +02:00
Tulir Asokan 7f52238fbb Add Telegram bot command access whitelist. Fixes #80 2018-03-10 14:36:44 +02:00
Tulir Asokan ae88aa0553 Add type hints to formatter 2018-03-10 12:36:11 +02:00
Tulir Asokan 2d63c5b3ce Fix and refactor Matrix->Telegram formatter 2018-03-10 09:39:53 +02:00
Tulir Asokan 77c57eb64b Handle FlushError in transfer_file_to_matrix 2018-03-09 23:44:29 +02:00
Tulir Asokan c98e822e6d Add some extra checks before generating thumbnail 2018-03-09 23:25:56 +02:00
Tulir Asokan 85a4982ad9 Update roadmap and remove unnecessary newline 2018-03-09 17:49:26 +02:00
Tulir Asokan b1c85d5cda Add moviepy as optional dep for HQ thumbnails, make Pillow optional
[db updated]
2018-03-09 16:54:35 +02:00
Tulir Asokan a469e6ed10 Switch to AGPLv3 2018-03-08 23:49:56 +02:00
Tulir Asokan 517c7d8b70 Move mautrix-appservice to separate repo. Fixes #37 2018-03-08 23:18:35 +02:00
Tulir Asokan 8bfb416735 Add config option for plaintext highlight bridging 2018-03-08 20:33:42 +02:00
Tulir Asokan 9709768b17 Add mxid parameter to set-pl 2018-03-08 20:01:48 +02:00
Tulir Asokan f6e3903b45 Add command to force set a Matrix power level without affecting Telegram. Fixes #60 2018-03-08 19:58:49 +02:00
Tulir Asokan b3082da999 Add option to underline edited part of message in edits. Fixes #61 2018-03-08 19:44:53 +02:00
Tulir Asokan 61d9d6890a Bridge plaintext mentions of Telegram puppets into Telegram mentions 2018-03-08 18:39:27 +02:00
Tulir Asokan 150321a4d7 Fix replies/forwards to/of images 2018-03-08 18:01:58 +02:00
Tulir Asokan 3eefbc4e34 Update README 2018-03-08 16:53:20 +02:00
Tulir Asokan ee8531143f Fix small typo 2018-03-08 11:42:53 +02:00
Tulir Asokan 96d3ca106a Fix Matrix -> Telegram code block bridging 2018-03-07 23:28:36 +02:00
Tulir Asokan 8d1de218a1 Implement registering (untested), fix auth stuff and possibly break stuff. Fixes #44 2018-03-07 22:09:56 +02:00
Tulir Asokan cf9a1f3afb Add appservice.public to config in v2 update 2018-03-07 21:31:27 +02:00
Tulir Asokan 2c68bd7378 Update config updater 2018-03-07 21:25:49 +02:00
Tulir Asokan 6ff89d1fe4 Add option to disable homeserver SSL verification 2018-03-07 21:20:59 +02:00
Tulir Asokan 30768d0a06 Add option to use inline images for better captions. Fixes #83 2018-03-07 21:16:09 +02:00
Tulir Asokan 7004da9268 Handle SQL InvalidRequestErrors when transferring files 2018-03-07 19:11:29 +02:00
Tulir Asokan 0e6940eea5 Add bridge command to !help (ref #87) 2018-03-07 15:37:19 +02:00
Tulir Asokan 7b4b7509f3 Minor improvements to unicode->html formatter 2018-03-07 14:50:41 +02:00
Tulir Asokan 8bbd1f7db1 Fix duplicate unicode formatting when mixing strikethrough and underline 2018-03-07 14:12:37 +02:00
Tulir Asokan a6f26c16fc Add strikethrough/underline <-> unicode converter to formatter 2018-03-07 14:03:38 +02:00
Tulir Asokan 13dddb4c10 Override alias if it already exists 2018-03-07 13:00:13 +02:00
Tulir Asokan 3aff450bae Fix error with large thumbnails 2018-03-06 22:43:31 +02:00
Tulir Asokan 97957a5731 Use native reply fallback format. Fixes #86 2018-03-06 21:24:45 +02:00
Tulir Asokan fe00145d1c Fix bridging documents without thumbnails 2018-03-06 15:13:13 +02:00
Tulir Asokan e2ba478095 Fix highlighting Telegram users without usernames 2018-03-06 00:27:35 +02:00
Tulir Asokan ed8c933772 Fix possible web login bug 2018-03-05 15:04:18 +02:00
Tulir Asokan a8322992cc Escape HTML tags in quoted text of non-native replies 2018-03-04 23:20:21 +02:00
Tulir Asokan e8c0312839 Fix messages again 2018-03-04 21:22:19 +02:00
Tulir Asokan e98acf39ae Fix messages with URL previews not being bridged 2018-03-04 21:12:24 +02:00
Tulir Asokan 26b8efb1e6 Send thumbnail and size info with Telegram -> Matrix videos 2018-03-04 20:59:45 +02:00
Tulir Asokan 8cce7a7c3a Initial attempt at removing appservice bot power level requirements 2018-03-04 17:39:56 +02:00
Tulir Asokan 6d648d51da Add basic support for bridging existing rooms to existing chats. Fixes #52 2018-03-04 17:10:03 +02:00
Tulir Asokan 57a00468ba Improve bot command handling and add /id command (ref #52) 2018-03-04 15:42:38 +02:00
Tulir Asokan cd055e1ba7 Handle UpdateShort*Messages properly 2018-03-04 15:41:42 +02:00
Tulir Asokan 021b60a45e Update Telethon and use PyPI telethon-aio package 2018-03-04 13:35:24 +02:00
Tulir Asokan 172e472221 Replace send_message_super with markdown flag in send_message 2018-03-03 21:15:44 +02:00
Tulir Asokan 0f706d511a Fix bot commands and simplify bot-specific update handler 2018-03-03 20:55:13 +02:00
Tulir Asokan f57d1e7311 Handle deleted accounts in get_displayname properly 2018-03-03 20:21:19 +02:00
Tulir Asokan fd4eb7aa49 Update Telethon 2018-03-03 19:29:05 +02:00
Tulir Asokan 4237c36dae Fix sending files to Telegram 2018-03-03 15:03:46 +02:00
Tulir Asokan 633aea45d9 Fix and/or break user_portal management 2018-03-03 14:45:52 +02:00
Tulir Asokan 08b6f9dbbf Fix Telegram media handling 2018-03-03 14:20:37 +02:00
Tulir Asokan a9b362943f Update Telethon, fix leave messages and stop deleting sessions 2018-03-03 13:35:50 +02:00
Tulir Asokan ead445b81f Merge pull request #84 from tulir/sqlalchemy-session
Move session storage to the main database
2018-03-03 13:34:05 +02:00
Tulir Asokan 1bea158191 Remove automatic table creation to force users to run Alembic 2018-03-03 11:59:44 +02:00
Tulir Asokan 3a22c1463a Move versions back to TelegramClient init 2018-03-03 11:39:32 +02:00
Tulir Asokan 3a4628cb6e Use Telethon's new AlchemySession for session storage 2018-03-02 20:22:03 +02:00
Tulir Asokan 46cac040c7 Add room unbridge command 2018-03-01 21:10:21 +02:00
Tulir Asokan 64b60559ee Update Telethon 2018-03-01 19:35:59 +02:00
Tulir Asokan 56e4f00705 Add sync command and move commands around 2018-02-25 22:22:11 +02:00
Tulir Asokan da3e37ccc0 Don't await Matrix room updating. Fixes #82 2018-02-25 22:07:59 +02:00
Tulir Asokan f37ea89e98 Remove need to install Telethon manually in production setup 2018-02-25 21:48:31 +02:00
Tulir Asokan a41bf286f2 Fix Telethon when pip --upgrading 2018-02-25 12:44:21 +02:00
Tulir Asokan 1f6b9bd04a Fix error when ignoring update in update_message 2018-02-25 12:16:28 +02:00
Tulir Asokan 836232db00 Update Telethon version 2018-02-25 12:07:48 +02:00
Tulir Asokan 14c2312f9a Add *.mako to mautrix_telegram package_data 2018-02-24 19:12:50 +02:00
Tulir Asokan 5fa8dea06f Update roadmap 2018-02-24 17:14:22 +02:00
Tulir Asokan d5038e6b98 Fix non-inline URL parsing 2018-02-24 14:04:23 +02:00
Tulir Asokan 55046e15b2 Add support for /command@bot bot command syntax 2018-02-24 12:57:18 +02:00
Tulir Asokan 8a7ccc0007 Refactor code 2018-02-24 12:37:12 +02:00
Tulir Asokan 1372a16459 Bridge !commands to Telegram /commands 2018-02-24 12:25:13 +02:00
Tulir Asokan 6c7f687539 Fix MessageEntityBotCommand handling 2018-02-24 12:06:30 +02:00
Tulir Asokan fed8adae97 Actually use bridge.allow_matrix_login 2018-02-24 12:06:19 +02:00
Tulir Asokan 566a2b3892 Add join/leave notifications for unauthenticated users. Fixes #81 2018-02-24 11:44:49 +02:00
Tulir Asokan 9e5cb84140 Refactor more code 2018-02-23 21:24:18 +02:00
Tulir Asokan 9e5843a0dc Refactor and clean up code 2018-02-23 21:06:28 +02:00
Tulir Asokan 2aa48f37a9 Merge pull request #79 from tulir/authless-relaybot-portals
Allow creating relaybot portals without any authenticated users
2018-02-23 18:21:35 +02:00
Tulir Asokan a1ba82c3b7 Actually use bridge.authless_relaybot_portals somewhere 2018-02-23 18:03:08 +02:00
Tulir Asokan 6fced123b1 Add comments in config updates 2018-02-23 17:56:50 +02:00
Tulir Asokan 22e4a189eb Convert Telegram room mentions into pills (ref #62) 2018-02-23 16:45:48 +02:00
Tulir Asokan c2e4f5596c Merge branch 'master' into authless-relaybot-portals 2018-02-23 16:02:18 +02:00
Tulir Asokan a26f2c2c36 Improve Matrix -> Telegram formatter. Fixes #34 2018-02-23 15:53:55 +02:00
Tulir Asokan 74a0a3b621 Fix egg name in dependency_links 2018-02-23 12:22:02 +02:00
Tulir Asokan 5c46aad0d1 Maybe fix production setup 2018-02-23 12:17:17 +02:00
Tulir Asokan 2d2fe86757 Move all permissions to single object in config 2018-02-23 12:07:42 +02:00
Tulir Asokan fb37af12b4 Fix bugs in command handlers and split them to separate methods 2018-02-22 22:09:35 +02:00
Tulir Asokan f635d87ea3 Update Telethon to fix mention generation in markdown parser 2018-02-22 21:59:36 +02:00
Tulir Asokan 7c54436dff Initial support for creating portals without any authenticated users 2018-02-22 21:12:35 +02:00
Tulir Asokan 232ec6ee42 Add room pill bridging. Fixes #62 2018-02-22 19:21:29 +02:00
Tulir Asokan 3e62a89b30 Update dependencies 2018-02-22 17:49:08 +02:00
Tulir Asokan 2f9cd15013 Fix registration generation on Python 3.5 2018-02-22 17:28:18 +02:00
Tulir Asokan aded9d9210 Small bugfixes 2018-02-22 17:20:37 +02:00
Tulir Asokan 25252c7b79 Remove delete-orphan cascade rule from User.portals. Hopefully fixes #76 2018-02-22 00:50:13 +02:00
Tulir Asokan 8e98ca1ce8 Don't kick user from portal on logout if chat has relay bot. Fixes #75 2018-02-22 00:37:03 +02:00
Tulir Asokan bbab5a1376 Fix phone flood error display 2018-02-22 00:35:26 +02:00
Tulir Asokan caab071a55 Don't require auth for meta commands 2018-02-22 00:34:57 +02:00
Tulir Asokan cf162e76ec Update setup.py 2018-02-22 00:14:45 +02:00
Tulir Asokan c1eb907e8a Merge pull request #73 from tulir/independent-login
Add support for out-of-Matrix login
2018-02-22 00:04:49 +02:00
Tulir Asokan 725b3a1182 Fix command names in roadmap and add clean-rooms 2018-02-21 23:53:57 +02:00
Tulir Asokan bc1d0c1d2a Fix portal avatar updating 2018-02-21 23:47:41 +02:00
Tulir Asokan 74935de459 Fix minor things 2018-02-21 23:47:23 +02:00
Tulir Asokan b4d23af05d Merge branch 'master' into independent-login 2018-02-21 23:36:09 +02:00
Tulir Asokan 2d13c30a26 Fix possible errors 2018-02-21 23:35:59 +02:00
Tulir Asokan 29c71b48de Improve login page style and fix bugs 2018-02-21 23:35:44 +02:00
Tulir Asokan 03734a6745 Fix Telegram -> Matrix image bridging 2018-02-21 17:11:47 +02:00
Tulir Asokan e96e1459eb Move commands/util.py to util/ 2018-02-20 21:49:52 +02:00
Tulir Asokan 1cf0a6b150 Merge branch 'master' into independent-login 2018-02-20 21:48:05 +02:00
Tulir Asokan 6e1d497e66 Fix edit handling/deduplication in channels. Fixes #74 2018-02-20 21:43:08 +02:00
Tulir Asokan 12d4025752 Implement Telegram->Matrix deletion bridging. Fixes #63 2018-02-20 20:43:05 +02:00
Tulir Asokan 05853115c6 Use file deduplication for avatars 2018-02-20 13:34:40 +02:00
Tulir Asokan bbc5f99ae9 Fix Alembic setup and add timestamp to TelegramFile 2018-02-20 00:14:47 +02:00
Tulir Asokan f9d2d32ef0 Save and reuse MXC URIs of bridged files. Fixes #40 2018-02-19 23:27:46 +02:00
Tulir Asokan c21a55ebc7 portal.py refactoring 2018-02-19 22:30:34 +02:00
Tulir Asokan 799dfdb2ac Use correct defaults in has_power_level() 2018-02-19 22:17:54 +02:00
Tulir Asokan 092b80ad02 Handle surrogates in a non-hacky way 2018-02-19 20:53:37 +02:00
Tulir Asokan 51b868d9ce Split formatter to two files 2018-02-19 20:45:40 +02:00
Tulir Asokan 5930b2e3bb Stop using db.merge() in most places 2018-02-19 20:35:34 +02:00
Tulir Asokan 710976c27e Merge pull request #71 from tulir/bots
Add option for message relay bot
2018-02-19 20:09:52 +02:00
Tulir Asokan 0a6130607d Fix avatar changes and outgoing meta change deduplication
Also move the telegram ID -> MXID generation to Puppet.get_mxid_from_id()
2018-02-19 19:52:45 +02:00
Tulir Asokan f926727a8d Update README 2018-02-19 19:39:26 +02:00
Tulir Asokan 5c5915ae66 Make default query handler async 2018-02-19 19:33:37 +02:00
Tulir Asokan f6b18497b4 Update bot chats when updating portal participants 2018-02-19 19:32:40 +02:00
Tulir Asokan d8dc7c59f4 Fix chat join rule preset 2018-02-19 19:31:38 +02:00
Tulir Asokan d21ac58929 Merge branch 'master' into bots 2018-02-19 18:19:53 +02:00
Tulir Asokan 7f86ec6c5d Remove debug prints 2018-02-19 18:13:44 +02:00
Tulir Asokan 1a1d7e6d90 Synchronize all users and fix joining chats via invite links, deleting portals 2018-02-19 18:10:14 +02:00
Tulir Asokan 4af4f90a3d Add some empty parameter checks 2018-02-19 18:09:12 +02:00
Tulir Asokan e003151c7b Merge branch 'master' into bots 2018-02-18 21:19:17 +02:00
Tulir Asokan ad11abb56e Add initial out-of-Matrix login system 2018-02-18 19:44:32 +02:00
Tulir Asokan 7d2af0ce75 Merge branch 'hotfix/matrix-formatting' 2018-02-18 16:32:26 +02:00
Tulir Asokan f66c182e82 Fix Matrix->Telegram formatted messages throwing an error 2018-02-18 16:29:19 +02:00
Tulir Asokan 91f34543dc Bump version to 0.2.0-dev 2018-02-18 15:50:21 +02:00
Tulir Asokan 95fad313c5 Deduplicate outgoing avatar/title changes 2018-02-18 12:37:47 +02:00
Tulir Asokan 1560647a5d Fix Matrix->Telegram room meta changes 2018-02-18 12:32:56 +02:00
Tulir Asokan 457df435ac Deduplicate service messages, typing notifications and presence 2018-02-18 12:31:52 +02:00
Tulir Asokan 7b0c58aa27 Handle incoming messages from bot 2018-02-18 12:03:35 +02:00
Tulir Asokan 7dc5384d52 Update future-fstrings and stop concatenating multiline strings 2018-02-18 11:24:51 +02:00
Tulir Asokan c1f582f17a Remove debug print 2018-02-17 21:26:22 +02:00
Tulir Asokan eef48a9a56 Synchronize deleted users in sync_telegram_users() 2018-02-17 20:35:23 +02:00
Tulir Asokan d7e40a86c6 Check if bot is still in chat at startup 2018-02-17 20:35:07 +02:00
Tulir Asokan 4673546b42 Add option to bridge notices and command to get relaybot info 2018-02-17 19:17:17 +02:00
Tulir Asokan 2f75fa1cfe Add support for bot message relaying 2018-02-17 17:48:48 +02:00
125 changed files with 12231 additions and 3225 deletions
+8
View File
@@ -0,0 +1,8 @@
engines:
sonar-python:
enabled: true
checks:
python:S107:
enabled: false
exclude_patterns:
- "alembic/"
+4
View File
@@ -0,0 +1,4 @@
.editorconfig
.codeclimate.yml
*.png
*.md
+3
View File
@@ -8,5 +8,8 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.py]
max_line_length = 99
[*.{yaml,yml,py}]
indent_style = space
+6 -2
View File
@@ -1,12 +1,16 @@
.idea/
.venv
env/
pip-selfcheck.json
*.pyc
__pycache__
build
dist
*.egg-info
.eggs
config.yaml
registration.yaml
*.log*
*.db
*.session
*.json
+44
View File
@@ -0,0 +1,44 @@
FROM docker.io/alpine:3.9
ENV UID=1337 \
GID=1337 \
FFMPEG_BINARY=/usr/bin/ffmpeg
COPY . /opt/mautrix-telegram
WORKDIR /opt/mautrix-telegram
RUN apk add --no-cache \
py3-virtualenv \
py3-pillow \
py3-aiohttp \
py3-magic \
py3-sqlalchemy \
py3-markdown \
py3-psycopg2 \
# Not yet in stable repos:
#py3-ruamel \
# Indirect dependencies
#commonmark
py3-future \
#alembic
py3-mako \
py3-dateutil \
py3-markupsafe \
#moviepy
py3-decorator \
#py3-tqdm \
py3-requests \
#imageio
py3-numpy \
#telethon
py3-rsa \
# Other dependencies
python3-dev \
build-base \
ffmpeg \
ca-certificates \
su-exec \
&& pip3 install .[all]
VOLUME /data
CMD ["/opt/mautrix-telegram/docker-run.sh"]
+67 -80
View File
@@ -1,23 +1,21 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
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 General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
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/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+6 -4
View File
@@ -1,12 +1,14 @@
# mautrix-telegram
A Matrix-Telegram puppeting bridge.
A Matrix-Telegram hybrid puppeting/relaybot bridge.
### [Wiki](https://github.com/tulir/mautrix-telegram/wiki)
### [Features & Roadmap](ROADMAP.md)
### [Features & Roadmap](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md)
## Discussion
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
A Telegram chat bridged to the Matrix room will be created once the bridge supports using a bot
for unauthenticated users.
Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room)
## Preview
![Preview](https://raw.githubusercontent.com/tulir/mautrix-telegram/master/preview.png)
+24 -59
View File
@@ -1,95 +1,60 @@
# Features & roadmap
* Matrix → Telegram
* [x] Plaintext messages
* [x] Formatted messages
* [ ] Bot commands (!command -> /command)
* [x] Mentions
* [x] Rich quotes
* [ ] Locations (not implemented in Riot)
* [x] Images
* [x] Files
* [x] Message content (text, formatting, files, etc..)
* [x] Message redactions
* [ ] † Presence
* [ ] † Typing notifications
* [ ] † Read receipts
* [ ] Pinning messages
* [x] Message edits
* [ ] ‡ Message history
* [x] Presence
* [x] Typing notifications
* [x] Read receipts
* [x] Pinning messages
* [x] Power level
* [x] Normal chats
* [ ] Non-hardcoded PL requirements
* [x] Supergroups/channels
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
* [ ] Membership actions
* [x] Inviting
* [x] Puppets
* [x] Matrix users who have logged into Telegram
* [x] Kicking
* [ ] Joining
* [ ] Chat name as alias
* [ ] ‡ Chat invite link as alias
* [x] Leaving
* [x] Membership actions (invite/kick/join/leave)
* [x] Room metadata changes (name, topic, avatar)
* [x] Initial room metadata
* [ ] User metadata
* [ ] Initial displayname/username/avatar at register
* [ ] ‡ Changes to displayname/avatar
* Telegram → Matrix
* [x] Plaintext messages
* [x] Formatted messages
* [x] Bot commands (/command -> !command)
* [x] Mentions
* [x] Replies
* [x] Forwards
* [x] Images
* [x] Locations
* [x] Stickers
* [x] Audio messages
* [x] Video messages
* [x] Documents
* [ ] Message deletions (no way to tell difference between user-specific deletion and global deletion)
* [ ] Message edits (not supported in 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
* [x] Avatars
* [x] Presence
* [x] Typing notifications
* [x] Read receipts (private chat only)
* [x] Pinning messages
* [x] Admin/chat creator status
* [ ] Supergroup/channel permissions (precise per-user not supported in Matrix)
* [x] Membership actions
* [x] Inviting
* [x] Kicking
* [x] Joining/leaving
* [ ] Supergroup/channel permissions (precise per-user permissions not supported in Matrix)
* [x] Membership actions (invite/kick/join/leave)
* [ ] Chat metadata changes
* [x] Title
* [x] Avatar
* [ ] † About text
* [ ] † Public channel username
* [x] Initial chat metadata (about text missing)
* [x] User metadata
* [x] Initial displayname/avatar
* [x] Changes to displayname/avatar
* [x] User metadata (displayname/avatar)
* [x] Supergroup upgrade
* Misc
* [x] Automatic portal creation
* [x] At startup
* [x] When receiving invite or message
* [x] Private chat creation by inviting Matrix puppet of Telegram user to new room
* [ ] Option to use bot to relay messages for unauthenticated Matrix users
* [ ] Option to use own Matrix account for messages sent from other Telegram clients
* [Commands](https://github.com/tulir/mautrix-telegram/wiki/Management-commands)
* [x] Logging in and out (`login` + code entering)
* [x] Logging out
* [ ] Registering (`register`)
* [x] Searching for users (`search`)
* [x] Starting private chats (`pm`)
* [x] Joining chats with invite links (`join`)
* [x] Creating a Telegram chat for an existing Matrix room (`create`)
* [x] Upgrading the chat of a portal room into a supergroup (`upgrade`)
* [x] Change username of supergroup/channel (`groupname`)
* [x] Getting the Telegram invite link to a Matrix room (`invitelink`)
* Bridge administration
* [x] Clean up and forget a portal room (`deleteportal`)
* [ ] Setting Matrix-only power levels (`powerlevel`)
* [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
-3
View File
@@ -35,9 +35,6 @@ script_location = alembic
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = sqlite:///mautrix-telegram.db
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
+21 -3
View File
@@ -1,19 +1,35 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import sys
from os.path import abspath, dirname
sys.path.insert(0, dirname(dirname(abspath(__file__))))
from mautrix_telegram.base import Base
import mautrix_telegram.db
from mautrix_telegram.db import Base
from mautrix_telegram.config import Config
from alchemysession import AlchemySessionContainer
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
mxtg_config = Config(mxtg_config_path, None, None)
mxtg_config.load()
config.set_main_option("sqlalchemy.url",
mxtg_config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
class FakeDB:
@staticmethod
def query_property():
return None
AlchemySessionContainer.create_table_classes(FakeDB(), "telethon_", Base)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
@@ -24,6 +40,7 @@ fileConfig(config.config_file_name)
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
@@ -71,6 +88,7 @@ def run_migrations_online():
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
@@ -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")
@@ -0,0 +1,28 @@
"""Add TelegramFile table
Revision ID: 1b241f7e8530
Revises: 97d2a942bcf8
Create Date: 2018-02-19 23:52:06.605741
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1b241f7e8530'
down_revision = '97d2a942bcf8'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('telegram_file',
sa.Column('id', sa.String(), nullable=False),
sa.Column('mxc', sa.String(), nullable=True),
sa.Column('mime_type', sa.String(), nullable=True),
sa.Column('was_converted', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'))
def downgrade():
op.drop_table('telegram_file')
@@ -0,0 +1,26 @@
"""Add is_bot field to puppets
Revision ID: 1fa46383a9d3
Revises: 30eca60587f1
Create Date: 2018-04-29 23:44:40.102333
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1fa46383a9d3'
down_revision = '30eca60587f1'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.add_column(sa.Column('is_bot', sa.Boolean(), nullable=True))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column('is_bot')
@@ -0,0 +1,41 @@
"""Add cascade rules to UserPortal
Revision ID: 2228d49c383f
Revises: bcfefa1f1299
Create Date: 2018-05-31 11:11:59.482112
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2228d49c383f'
down_revision = 'bcfefa1f1299'
branch_labels = None
depends_on = None
def upgrade():
try:
with op.batch_alter_table("user_portal") as batch_op:
batch_op.drop_constraint("user_portal_user_fkey", type_="foreignkey")
batch_op.drop_constraint("user_portal_portal_fkey", type_="foreignkey")
batch_op.create_foreign_key("user_portal_user_fkey", "user", ["user"], ["tgid"],
onupdate="CASCADE", ondelete="CASCADE")
batch_op.create_foreign_key("user_portal_portal_fkey", "portal",
["portal", "portal_receiver"], ["tgid", "tg_receiver"],
onupdate="CASCADE", ondelete="CASCADE")
except ValueError:
return
def downgrade():
try:
with op.batch_alter_table("user_portal") as batch_op:
batch_op.drop_constraint("user_portal_user_fkey", type_="foreignkey")
batch_op.drop_constraint("user_portal_portal_fkey", type_="foreignkey")
batch_op.create_foreign_key("user_portal_user_fkey", "portal",
["portal", "portal_receiver"], ["tgid", "tg_receiver"])
batch_op.create_foreign_key("user_portal_portal_fkey", "user", ["user"], ["tgid"])
except ValueError:
return
@@ -0,0 +1,25 @@
"""Add megagroup field to portals
Revision ID: 30eca60587f1
Revises: cfc972368e50
Create Date: 2018-04-29 15:51:04.656605
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '30eca60587f1'
down_revision = 'cfc972368e50'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("portal") as batch_op:
batch_op.add_column(sa.Column('megagroup', sa.Boolean()))
def downgrade():
with op.batch_alter_table("portal") as batch_op:
batch_op.drop_column('megagroup')
@@ -0,0 +1,111 @@
"""Move sessions to main database
Revision ID: 501dad2868bc
Revises: 7d47d84380b6
Create Date: 2018-03-02 19:15:53.826985
"""
from alembic import op
import sqlalchemy as sa
import sqlite3
import os
# revision identifiers, used by Alembic.
revision = '501dad2868bc'
down_revision = '7d47d84380b6'
branch_labels = None
depends_on = None
def upgrade():
Session = op.create_table('telethon_sessions',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('dc_id', sa.Integer, nullable=False),
sa.Column('server_address', sa.String, nullable=True),
sa.Column('port', sa.Integer, nullable=True),
sa.Column('auth_key', sa.LargeBinary, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'dc_id'))
SentFile = op.create_table('telethon_sent_files',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('md5_digest', sa.LargeBinary, nullable=False),
sa.Column('file_size', sa.Integer, nullable=False),
sa.Column('type', sa.Integer, nullable=False),
sa.Column('id', sa.BigInteger, nullable=True),
sa.Column('hash', sa.BigInteger, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'md5_digest', 'file_size',
'type'))
Entity = op.create_table('telethon_entities',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('id', sa.Integer, nullable=False),
sa.Column('hash', sa.Integer, nullable=False),
sa.Column('username', sa.String, nullable=True),
sa.Column('phone', sa.Integer, nullable=True),
sa.Column('name', sa.String, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'id'))
Version = op.create_table('telethon_version',
sa.Column('version', sa.Integer, nullable=False),
sa.PrimaryKeyConstraint('version'))
conn = op.get_bind()
sessions = [os.path.basename(f) for f in os.listdir(".") if f.endswith(".session")]
for session in sessions:
session_to_sqlalchemy(conn, session, Session, SentFile, Entity)
def session_to_sqlalchemy(conn, path, Session, SentFile, Entity):
session_conn = sqlite3.connect(path)
session_id = os.path.splitext(path)[0]
c = session_conn.cursor()
auth_data_tuples = c.execute("SELECT * FROM sessions").fetchall()
auth_data_dicts = []
for row in auth_data_tuples:
dc_id, server_address, port, auth_key = row
auth_data_dicts.append({
"session_id": session_id,
"dc_id": dc_id,
"server_address": server_address,
"port": port,
"auth_key": auth_key,
})
if auth_data_dicts:
conn.execute(Session.insert().values(auth_data_dicts))
sent_file_tuples = c.execute("SELECT * FROM sent_files").fetchall()
sent_file_dicts = []
for row in sent_file_tuples:
md5_digest, file_size, type, id, hash = row
sent_file_dicts.append({
"session_id": session_id,
"md5_digest": md5_digest,
"file_size": file_size,
"type": type,
"id": id,
"hash": hash,
})
if sent_file_dicts:
conn.execute(SentFile.insert().values(sent_file_dicts))
entity_tuples = c.execute("SELECT * FROM entities").fetchall()
entity_dicts = []
for row in entity_tuples:
id, hash, username, phone, name = row
entity_dicts.append({
"session_id": session_id,
"id": id,
"hash": hash,
"username": username,
"phone": phone,
"name": name,
})
if entity_dicts:
conn.execute(Entity.insert().values(entity_dicts))
c.close()
session_conn.close()
def downgrade():
op.drop_table('telethon_version')
op.drop_table('telethon_entities')
op.drop_table('telethon_sent_files')
op.drop_table('telethon_sessions')
@@ -0,0 +1,137 @@
"""Move state store to main database
Revision ID: 6ca3d74d51e4
Revises: 2228d49c383f
Create Date: 2018-06-26 21:31:26.911307
"""
from alembic import context, op
import sqlalchemy.orm as orm
import sqlalchemy as sa
import json
import re
from mautrix_telegram.config import Config
from mautrix_telegram.db import Base
# revision identifiers, used by Alembic.
revision = "6ca3d74d51e4"
down_revision = "2228d49c383f"
branch_labels = None
depends_on = None
class RoomState(Base):
query = None
__tablename__ = "mx_room_state"
__table_args__ = {"extend_existing": True}
room_id = sa.Column(sa.String, primary_key=True)
power_levels = sa.Column("power_levels", sa.Text, nullable=True)
class UserProfile(Base):
query = None
__tablename__ = "mx_user_profile"
__table_args__ = {"extend_existing": True}
room_id = sa.Column(sa.String, primary_key=True)
user_id = sa.Column(sa.String, primary_key=True)
membership = sa.Column(sa.String, nullable=False, default="leave")
displayname = sa.Column(sa.String, nullable=True)
avatar_url = sa.Column(sa.String, nullable=True)
class Puppet(Base):
query = None
__tablename__ = "puppet"
__table_args__ = {"extend_existing": True}
id = sa.Column(sa.Integer, primary_key=True)
displayname = sa.Column(sa.String, nullable=True)
displayname_source = sa.Column(sa.Integer, nullable=True)
username = sa.Column(sa.String, nullable=True)
photo_id = sa.Column(sa.String, nullable=True)
is_bot = sa.Column(sa.Boolean, nullable=True)
matrix_registered = sa.Column(sa.Boolean, nullable=False, default=False)
def upgrade():
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),
sa.Column("power_levels", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("room_id"))
op.create_table("mx_user_profile",
sa.Column("room_id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("membership", sa.String(), nullable=False,
default="leave"),
sa.Column("displayname", sa.String(), nullable=True),
sa.Column("avatar_url", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("room_id", "user_id"))
try:
migrate_state_store()
except Exception as e:
print("Failed to migrate state store:", e)
print("Migrating the state store isn't required, but you can retry by alembic downgrading "
"to revision 2228d49c383f and upgrading again.")
def migrate_state_store():
conn = op.get_bind()
session = orm.sessionmaker(bind=conn)() # type: orm.Session
try:
with open("mx-state.json") as file:
data = json.load(file)
except FileNotFoundError:
return
if not data:
return
registrations = data.get("registrations", [])
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
mxtg_config = Config(mxtg_config_path, None, None)
mxtg_config.load()
username_template = mxtg_config.get("bridge.username_template", "telegram_{userid}")
hs_domain = mxtg_config["homeserver.domain"]
localpart = username_template.format(userid="(.+)")
mxid_regex = re.compile("@{}:{}".format(localpart, hs_domain))
for user in registrations:
match = mxid_regex.match(user)
if not match:
continue
puppet = session.query(Puppet).get(match.group(1))
if not puppet:
continue
puppet.matrix_registered = True
session.merge(puppet)
session.commit()
user_profiles = [UserProfile(room_id=room, user_id=user,
membership=member.get("membership", "leave"),
displayname=member.get("displayname", None),
avatar_url=member.get("avatar_url", None))
for room, members in data.get("members", {}).items()
for user, member in members.items()]
session.add_all(user_profiles)
session.commit()
room_state = [RoomState(room_id=room, power_levels=json.dumps(levels))
for room, levels in data.get("power_levels", {}).items()]
session.add_all(room_state)
session.commit()
def downgrade():
op.drop_table("mx_user_profile")
op.drop_table("mx_room_state")
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column("matrix_registered")
@@ -0,0 +1,26 @@
"""Add timestamp to TelegramFile
Revision ID: 7d47d84380b6
Revises: 1b241f7e8530
Create Date: 2018-02-19 23:53:18.050871
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7d47d84380b6'
down_revision = '1b241f7e8530'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('telegram_file',
sa.Column('timestamp', sa.BigInteger(), nullable=True, default=0,
server_default="0"))
def downgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.drop_column('timestamp')
@@ -1,14 +1,13 @@
"""initial revision
Revision ID: 97d2a942bcf8
Revises:
Revises:
Create Date: 2018-02-11 18:40:55.483842
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '97d2a942bcf8'
down_revision = None
@@ -17,12 +16,65 @@ depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.create_table('portal',
sa.Column('tgid', sa.Integer),
sa.Column('tg_receiver', sa.Integer),
sa.Column('peer_type', sa.String, nullable=False, default=""),
sa.Column('mxid', sa.String, nullable=True),
sa.Column('username', sa.String, nullable=True),
sa.Column('title', sa.String, nullable=True),
sa.Column('about', sa.String, nullable=True),
sa.Column('photo_id', sa.String, nullable=True),
sa.PrimaryKeyConstraint('tgid', 'tg_receiver'),
sa.UniqueConstraint('mxid'))
op.create_table('user',
sa.Column('mxid', sa.String),
sa.Column('tgid', sa.Integer, nullable=True, unique=True),
sa.Column('tg_username', sa.String, nullable=True),
sa.Column('saved_contacts', sa.Integer, nullable=False, default=0),
sa.PrimaryKeyConstraint('mxid'))
op.create_table('puppet',
sa.Column('id', sa.Integer),
sa.Column('displayname', sa.String, nullable=True),
sa.Column('username', sa.String, nullable=True),
sa.Column('photo_id', sa.String, nullable=True),
sa.PrimaryKeyConstraint('id'))
op.create_table('contact',
sa.Column('user', sa.Integer),
sa.Column('contact', sa.Integer),
sa.ForeignKeyConstraint(("user",), ("user.tgid",)),
sa.ForeignKeyConstraint(("contact",), ("puppet.id",)),
sa.PrimaryKeyConstraint('user', 'contact'))
op.create_table('user_portal',
sa.Column('user', sa.Integer),
sa.Column('portal', sa.Integer),
sa.Column('portal_receiver', sa.Integer),
sa.PrimaryKeyConstraint('user', 'portal', 'portal_receiver'),
sa.ForeignKeyConstraint(("user",), ("user.tgid",),
name="user_portal_user_fkey",
onupdate="CASCADE", ondelete="CASCADE"),
sa.ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"),
name="user_portal_portal_fkey",
onupdate="CASCADE", ondelete="CASCADE"))
op.create_table('message',
sa.Column('mxid', sa.String),
sa.Column('mx_room', sa.String),
sa.Column('tgid', sa.Integer),
sa.Column('tg_space', sa.Integer),
sa.PrimaryKeyConstraint('tgid', 'tg_space'),
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"))
op.create_table('bot_chat',
sa.Column('id', sa.Integer),
sa.Column('type', sa.String, nullable=False, default=""),
sa.PrimaryKeyConstraint('id'))
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
op.drop_table('bot_chat')
op.drop_table('message')
op.drop_table('user_portal')
op.drop_table('contact')
op.drop_table('puppet')
op.drop_table('user')
op.drop_table('portal')
@@ -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,25 @@
"""Add phone number field to users
Revision ID: a9119be92164
Revises: b54929c22c86
Create Date: 2018-09-28 02:38:40.626282
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a9119be92164"
down_revision = "b54929c22c86"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("user", sa.Column("tg_phone", sa.String(), nullable=True))
def downgrade():
with op.batch_alter_table("user") as batch_op:
batch_op.drop_column("tg_phone")
@@ -0,0 +1,25 @@
"""Add portal-specific config
Revision ID: b54929c22c86
Revises: d5f7b8b4b456
Create Date: 2018-09-24 23:40:33.528710
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b54929c22c86"
down_revision = "d5f7b8b4b456"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("portal", sa.Column("config", sa.Text(), nullable=True))
def downgrade():
with op.batch_alter_table("portal") as batch_op:
batch_op.drop_column("config")
@@ -0,0 +1,24 @@
"""Add displayname source fields for puppets
Revision ID: bcfefa1f1299
Revises: bdadd173ee02
Create Date: 2018-05-19 17:00:21.078098
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bcfefa1f1299'
down_revision = 'bdadd173ee02'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('puppet', sa.Column('displayname_source', sa.Integer(), nullable=True))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column('displayname_source')
@@ -0,0 +1,43 @@
"""Update telethon update state table
Revision ID: bdadd173ee02
Revises: eeaf0dae87ce
Create Date: 2018-05-13 10:42:59.395597
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bdadd173ee02'
down_revision = 'eeaf0dae87ce'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column("id", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("hash", existing_type=sa.Integer, type_=sa.BigInteger)
with op.batch_alter_table("telethon_update_state") as batch_op:
batch_op.alter_column("entity_id", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("pts", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("qts", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("date", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("seq", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.add_column(sa.Column("unread_count", sa.Integer))
def downgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column("id", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("hash", existing_type=sa.BigInteger, type_=sa.Integer)
with op.batch_alter_table("telethon_update_state") as batch_op:
batch_op.alter_column("entity_id", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("pts", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("qts", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("date", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("seq", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.drop_column("unread_count")
@@ -0,0 +1,35 @@
"""Add metadata to TelegramFile
Revision ID: cfc972368e50
Revises: 501dad2868bc
Create Date: 2018-03-09 16:07:01.236712
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cfc972368e50'
down_revision = '501dad2868bc'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.add_column(sa.Column('size', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('width', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('height', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('thumbnail', sa.String(), nullable=True))
batch_op.create_foreign_key(constraint_name="fk_file_thumbnail",
referent_table="telegram_file",
local_cols=['thumbnail'],
remote_cols=['id'])
def downgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.drop_column('size')
batch_op.drop_column('width')
batch_op.drop_column('height')
batch_op.drop_column('thumbnail')
@@ -0,0 +1,26 @@
"""Add access_token and custom_mxid fields for puppets
Revision ID: d5f7b8b4b456
Revises: 6ca3d74d51e4
Create Date: 2018-07-20 12:09:30.277960
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "d5f7b8b4b456"
down_revision = "6ca3d74d51e4"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("puppet", sa.Column("access_token", sa.String(), nullable=True))
op.add_column("puppet", sa.Column("custom_mxid", sa.String(), nullable=True))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column("custom_mxid")
batch_op.drop_column("access_token")
@@ -0,0 +1,34 @@
"""Add telethon update state table
Revision ID: eeaf0dae87ce
Revises: 1fa46383a9d3
Create Date: 2018-04-30 17:30:59.610885
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'eeaf0dae87ce'
down_revision = '1fa46383a9d3'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column('phone', existing_type=sa.Integer, type_=sa.BigInteger)
op.create_table('telethon_update_state',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('entity_id', sa.Integer, nullable=False),
sa.Column('pts', sa.Integer, nullable=True),
sa.Column('qts', sa.Integer, nullable=True),
sa.Column('date', sa.Integer, nullable=True),
sa.Column('seq', sa.Integer, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'entity_id'))
def downgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column('phone', existing_type=sa.BigInteger, type_=sa.Integer)
op.drop_table('telethon_update_state')
Executable
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Define functions.
function fixperms {
chown -R $UID:$GID /data /opt/mautrix-telegram
}
cd /opt/mautrix-telegram
# Replace database path in config.
sed -i "s#sqlite:///mautrix-telegram.db#sqlite:////data/mautrix-telegram.db#" /data/config.yaml
if [ -f /data/mx-state.json ]; then
ln -s /data/mx-state.json
fi
# Check that database is in the right state
alembic -x config=/data/config.yaml upgrade head
if [ ! -f /data/config.yaml ]; then
cp example-config.yaml /data/config.yaml
echo "Didn't find a config file."
echo "Copied default config file to /data/config.yaml"
echo "Modify that config file to your liking."
echo "Start the container again after that to generate the registration file."
fixperms
exit
fi
if [ ! -f /data/registration.yaml ]; then
python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml
echo "Didn't find a registration file."
echo "Generated one for you."
echo "Copy that over to synapses app service directory."
fixperms
exit
fi
fixperms
exec su-exec $UID:$GID python3 -m mautrix_telegram -c /data/config.yaml
+281 -25
View File
@@ -1,32 +1,74 @@
# Homeserver details
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://matrix.org
# The domain of the homeserver (for MXIDs, etc).
domain: matrix.org
# Whether or not to verify the SSL certificate of the homeserver.
# Only applies if address starts with https://
verify_ssl: true
# Application service host/registration related details
# Changing these values requires regeneration of the registration.
appservice:
# The protocol the homeserver should use when connecting to this appservice.
# Usually "http" or "https".
protocol: http
# The address that the homeserver can use to connect to this appservice.
address: http://localhost:8080
# The hostname and port where the homeserver can find this appservice.
hostname: localhost
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 8080
# The maximum body size of appservice API requests (from the homeserver) in mebibytes
# Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
max_body_size: 1
# Whether or not to enable debug messages in the console.
debug: false
# The full URI to the database. SQLite and Postgres are fully supported.
# Other DBMSes supported by SQLAlchemy may or may not work.
# Format examples:
# SQLite: sqlite:///filename.db
# Postgres: postgres://username:password@hostname/dbname
database: sqlite:///mautrix-telegram.db
# Public part of web server for out-of-Matrix interaction with the bridge.
# Used for things like login if the user wants to make sure the 2FA password isn't stored in
# the HS database.
public:
# Whether or not the public-facing endpoints should be enabled.
enabled: true
# The prefix to use in the public-facing endpoints.
prefix: /public
# The base URL where the public-facing endpoints are available. The prefix is not added
# implicitly.
external: https://example.com/public
# Provisioning API part of the web server for automated portal creation and fetching information.
# Used by things like Dimension (https://dimension.t2bot.io/).
provisioning:
# Whether or not the provisioning API should be enabled.
enabled: true
# The prefix to use in the provisioning API endpoints.
prefix: /_matrix/provision/v1
# The shared secret to authorize users of the API.
# Set to "generate" to generate and save a new token.
shared_secret: generate
# The unique ID of this appservice.
id: telegram
# Username of the appservice bot.
bot_username: telegrambot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
bot_displayname: Telegram bridge bot
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration"
hs_token: "This value is generated when generating the registration"
# Prometheus telemetry config. Requires prometheus-aio to be installed.
metrics:
enabled: false
listen_port: 8000
# Bridge config
bridge:
# Localpart template of MXIDs for Telegram users.
@@ -58,32 +100,246 @@ bridge:
- username
- phone number
# Whether or not to use native Matrix replies. At the time of writing, only riot-web supports
# replies and the format of them is subject to change.
native_replies: True
# If native replies are disabled, should the custom replies contain a link to the message being
# replied to?
link_in_reply: False
# Show message editing as a reply to the original message.
# If this is false, message edits are not shown at all, as Matrix does not support editing yet.
edits_as_replies: False
# Maximum number of members to sync per portal when starting up. Other members will be
# synced when they send messages. The maximum is 10000, after which the Telegram server
# will not send any more members.
# Defaults to no local limit (-> limited to 10000 by server)
max_initial_member_sync: -1
# Whether or not to sync the member list in channels.
# If no channel admins have logged into the bridge, the bridge won't be able to sync the member
# list regardless of this setting.
sync_channel_members: true
# Whether or not to skip deleted members when syncing members.
skip_deleted_members: true
# Whether or not to automatically synchronize contacts and chats of Matrix users logged into
# their Telegram account at startup.
startup_sync: true
# Number of most recently active dialogs to check when syncing chats.
# Dialogs include groups and private chats, but only groups are synced.
# Set to 0 to remove limit.
sync_dialog_limit: 30
# The maximum number of simultaneous Telegram deletions to handle.
# A large number of simultaneous redactions could put strain on your homeserver.
max_telegram_delete: 10
# Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames)
# at startup and when creating a bridge.
sync_matrix_state: true
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix
# login website (see appservice.public config section)
allow_matrix_login: true
# Whether or not to bridge plaintext highlights.
# Only enable this if your displayname_template has some static part that the bridge can use to
# reliably identify what is a plaintext highlight.
plaintext_highlights: false
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
public_portals: true
# Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down.
# Currently only works for private chats and normal groups.
# WARNING: This feature seems to be broken in the telegram library.
catch_up: false
# Whether or not to use /sync to get presence, read receipts and typing notifications when using
# your own Matrix account as the Matrix puppet for your Telegram account.
sync_with_custom_puppets: true
# 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
# Whether to bridge Telegram bot messages as m.notices or m.texts.
bot_messages_as_notices: true
bridge_notices:
# Whether or not Matrix bot messages (type m.notice) should be bridged.
default: false
# List of user IDs for whom the previous flag is flipped.
# e.g. if bridge_notices.default is false, notices from other users will not be bridged, but
# notices from users listed here will be bridged.
exceptions:
- "@importantbot:example.com"
# Some config options related to Telegram message deduplication.
# The default values are usually fine, but some debug messages/warnings might recommend you
# change these.
deduplication:
# Whether or not to check the database if the message about to be sent is a duplicate.
pre_db_check: false
# The number of latest events to keep when checking for duplicates.
# You might need to increase this on high-traffic bridge instances.
cache_queue_length: 20
# The formats to use when sending messages to Telegram via the relay bot.
#
# Telegram doesn't have built-in emotes, so the m.emote format is also used for non-relaybot users.
#
# Available variables:
# $sender_displayname - The display name of the sender (e.g. Example User)
# $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
# $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
# $message - The message content as HTML
message_formats:
m.text: "<b>$sender_displayname</b>: $message"
m.emote: "* <b>$sender_displayname</b> $message"
m.file: "<b>$sender_displayname</b> sent a file: $message"
m.image: "<b>$sender_displayname</b> sent an image: $message"
m.audio: "<b>$sender_displayname</b> sent an audio file: $message"
m.video: "<b>$sender_displayname</b> sent a video: $message"
m.location: "<b>$sender_displayname</b> sent a location: $message"
# The formats to use when sending state events to Telegram via the relay bot.
#
# Variables from `message_formats` that have the `sender_` prefix are available without the prefix.
# In name_change events, `$prev_displayname` is the previous displayname.
#
# Set format to an empty string to disable the messages for that event.
state_event_formats:
join: "<b>$displayname</b> joined the room."
leave: "<b>$displayname</b> left the room."
name_change: "<b>$prev_displayname</b> changed their name to <b>$displayname</b>"
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
# `filter-mode` management commands.
#
# Filters do not affect direct chats.
# An empty blacklist will essentially disable the filter.
filter:
# Filter mode to use. Either "blacklist" or "whitelist".
# If the mode is "blacklist", the listed chats will never be bridged.
# If the mode is "whitelist", only the listed chats can be bridged.
mode: blacklist
# The list of group/channel IDs to filter.
list: []
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg"
# Whitelist of user IDs that are allowed to use this bridge. Leave empty to disable.
# You can enter a domain without the localpart to allow all users from that homeserver to use the bridge.
whitelist:
- "internal.example.com"
- "@user:public.example.com"
# 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"
# Admins can do things like delete portal rooms. Here you must specify the exact MXID, domains
# are not accepted.
admins:
- "@admin:internal.example.com"
# Options related to the message relay Telegram bot.
relaybot:
# Whether or not to allow creating portals from Telegram.
authless_portals: true
# Whether or not to allow Telegram group admins to use the bot commands.
whitelist_group_admins: true
# Whether or not to ignore incoming events sent by the relay bot.
ignore_own_incoming_events: true
# List of usernames/user IDs who are also allowed to use the bot commands.
whitelist:
- myusername
- 12345678
# Telegram config
telegram:
# Get your own API keys at https://my.telegram.org/apps
api_id: 12345
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
# (Optional) Create your own bot at https://t.me/BotFather
bot_token: disabled
# 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
type: disabled
# Proxy IP address and port.
address: 127.0.0.1
port: 1080
# Whether or not to perform DNS resolving remotely.
rdns: true
# Proxy authentication (optional).
username: ""
password: ""
# Python logging configuration.
#
# See section 16.7.2 of the Python documentation for more info:
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
logging:
version: 1
formatters:
precise:
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
handlers:
file:
class: logging.handlers.RotatingFileHandler
formatter: precise
filename: ./mautrix-telegram.log
maxBytes: 10485760
backupCount: 10
console:
class: logging.StreamHandler
formatter: precise
loggers:
mau:
level: DEBUG
telethon:
level: DEBUG
aiohttp:
level: INFO
root:
level: DEBUG
handlers: [file, console]
-5
View File
@@ -1,5 +0,0 @@
from .appservice import AppService
from .errors import MatrixError, MatrixRequestError, IntentError
__version__ = "0.1.0"
__author__ = "Tulir Asokan <tulir@maunium.net>"
-180
View File
@@ -1,180 +0,0 @@
# -*- coding: future_fstrings -*-
# matrix-appservice-python - A Matrix Application Service framework written in Python.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# Partly based on github.com/Cadair/python-appservice-framework (MIT license)
from functools import partial
from contextlib import contextmanager
from aiohttp import web
import aiohttp
import asyncio
import logging
from .intent_api import HTTPAPI
from .state_store import StateStore
class AppService:
def __init__(self, server, domain, as_token, hs_token, bot_localpart, loop=None, log=None,
query_user=None, query_alias=None):
self.server = server
self.domain = domain
self.as_token = as_token
self.hs_token = hs_token
self.bot_mxid = f"@{bot_localpart}:{domain}"
self.state_store = StateStore(autosave_file="mx-state.json")
self.state_store.load("mx-state.json")
self.transactions = []
self._http_session = None
self._intent = None
self.loop = loop or asyncio.get_event_loop()
self.log = (logging.getLogger(log) if isinstance(log, str)
else log or logging.getLogger("mautrix_appservice"))
def default_query_handler(_):
return None
self.query_user = query_user or default_query_handler
self.query_alias = query_alias or default_query_handler
self.event_handlers = []
self.app = web.Application(loop=self.loop)
self.app.router.add_route("PUT", "/transactions/{transaction_id}",
self._http_handle_transaction)
self.app.router.add_route("GET", "/rooms/{alias}", self._http_query_alias)
self.app.router.add_route("GET", "/users/{user_id}", self._http_query_user)
self.matrix_event_handler(self.update_state_store)
@property
def http_session(self):
if self._http_session is None:
raise AttributeError("the http_session attribute can only be used "
"from within the `AppService.run` context manager")
else:
return self._http_session
@property
def intent(self):
if self._intent is None:
raise AttributeError("the intent attribute can only be used from "
"within the `AppService.run` context manager")
else:
return self._intent
@contextmanager
def run(self, host="127.0.0.1", port=8080):
self._http_session = aiohttp.ClientSession(loop=self.loop)
self._intent = HTTPAPI(base_url=self.server, domain=self.domain, bot_mxid=self.bot_mxid,
token=self.as_token, log=self.log, state_store=self.state_store,
client_session=self._http_session).bot_intent()
yield self.loop.create_server(self.app.make_handler(), host, port)
self._intent = None
self._http_session.close()
self._http_session = None
def _check_token(self, request):
try:
token = request.rel_url.query["access_token"]
except KeyError:
return False
if token != self.hs_token:
return False
return True
async def _http_query_user(self, request):
if not self._check_token(request):
return web.Response(status=401)
user_id = request.match_info["userId"]
try:
response = await self.query_user(user_id)
except Exception:
self.log.exception("Exception in user query handler")
return web.Response(status=500)
if not response:
return web.Response(status=404)
return web.json_response(response)
async def _http_query_alias(self, request):
if not self._check_token(request):
return web.Response(status=401)
alias = request.match_info["alias"]
try:
response = await self.query_alias(alias)
except Exception:
self.log.exception("Exception in alias query handler")
return web.Response(status=500)
if not response:
return web.Response(status=404)
return web.json_response(response)
async def _http_handle_transaction(self, request):
if not self._check_token(request):
return web.Response(status=401)
transaction_id = request.match_info["transaction_id"]
if transaction_id in self.transactions:
return web.Response(status=200)
json = await request.json()
try:
events = json["events"]
except KeyError:
return web.Response(status=400)
for event in events:
self.handle_matrix_event(event)
self.transactions.append(transaction_id)
return web.json_response({})
async def update_state_store(self, event):
event_type = event["type"]
if event_type == "m.room.power_levels":
self.state_store.set_power_levels(event["room_id"], event["content"])
elif event_type == "m.room.member":
self.state_store.set_membership(event["room_id"], event["state_key"],
event["content"]["membership"])
def handle_matrix_event(self, event):
async def try_handle(handler):
try:
await handler(event)
except Exception:
self.log.exception("Exception in Matrix event handler")
for handler in self.event_handlers:
asyncio.ensure_future(try_handle(handler), loop=self.loop)
def matrix_event_handler(self, func):
self.event_handlers.append(func)
return func
-38
View File
@@ -1,38 +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 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
class MatrixError(Exception):
"""A generic Matrix error. Specific errors will subclass this."""
pass
class IntentError(MatrixError):
def __init__(self, message, source):
super().__init__(message)
self.source = source
class MatrixRequestError(MatrixError):
""" The home server returned an error response. """
def __init__(self, code=0, text="", errcode=None, message=None):
super().__init__("%d: %s" % (code, text))
self.code = code
self.text = text
self.errcode = errcode
self.message = message
-521
View File
@@ -1,521 +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 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from urllib.parse import quote
from time import time
from json.decoder import JSONDecodeError
from aiohttp.client_exceptions import ContentTypeError
import re
import json
import magic
import asyncio
from .errors import MatrixError, MatrixRequestError, IntentError
class HTTPAPI:
def __init__(self, base_url, domain=None, bot_mxid=None, token=None, identity=None, log=None,
state_store=None, client_session=None, child=False):
self.base_url = base_url
self.token = token
self.identity = identity
self.validate_cert = True
self.session = client_session
self.domain = domain
self.bot_mxid = bot_mxid
self._bot_intent = None
self.state_store = state_store
if child:
self.log = log
else:
self.intent_log = log.getChild("intent")
self.log = log.getChild("api")
self.txn_id = 0
self.children = {}
def user(self, user):
try:
return self.children[user]
except KeyError:
child = ChildHTTPAPI(user, self)
self.children[user] = child
return child
def bot_intent(self):
if self._bot_intent:
return self._bot_intent
return IntentAPI(self.bot_mxid, self, state_store=self.state_store, log=self.intent_log)
def intent(self, user):
return IntentAPI(user, self.user(user), self.bot_intent(), self.state_store,
self.intent_log)
async def _send(self, method, endpoint, content, query_params, headers):
while True:
query_params["access_token"] = self.token
request = self.session.request(method, endpoint, params=query_params,
data=content, headers=headers)
async with request as response:
if response.status < 200 or response.status >= 300:
errcode = message = None
try:
response_data = await response.json()
errcode = response_data["errcode"]
message = response_data["error"]
except (JSONDecodeError, ContentTypeError, KeyError):
pass
raise MatrixRequestError(code=response.status, text=await response.text(),
errcode=errcode, message=message)
if response.status == 429:
await asyncio.sleep(response.json()["retry_after_ms"] / 1000)
else:
return await response.json()
def _log_request(self, method, path, content, query_params):
log_content = content if not isinstance(content, bytes) else f"<{len(content)} bytes>"
log_content = log_content or "(No content)"
query_identity = query_params["user_id"] if "user_id" in query_params else "No identity"
self.log.debug("%s %s %s as user %s", method, path, log_content, query_identity)
def request(self, method, path, content=None, query_params=None, headers=None,
api_path="/_matrix/client/r0"):
content = content or {}
query_params = query_params or {}
headers = headers or {}
method = method.upper()
if method not in ["GET", "PUT", "DELETE", "POST"]:
raise MatrixError("Unsupported HTTP method: %s" % method)
if "Content-Type" not in headers:
headers["Content-Type"] = "application/json"
if headers["Content-Type"] == "application/json":
content = json.dumps(content)
if self.identity:
query_params["user_id"] = self.identity
self._log_request(method, path, content, query_params)
endpoint = self.base_url + api_path + path
return self._send(method, endpoint, content, query_params, headers or {})
def get_download_url(self, mxcurl):
if mxcurl.startswith('mxc://'):
return f"{self.base_url}/_matrix/media/r0/download/{mxcurl[6:]}"
else:
raise ValueError("MXC URL did not begin with 'mxc://'")
async def get_display_name(self, user_id):
content = await self.request("GET", f"/profile/{user_id}/displayname")
return content.get('displayname', None)
async def get_avatar_url(self, user_id):
content = await self.request("GET", f"/profile/{user_id}/avatar_url")
return content.get('avatar_url', None)
async def get_room_id(self, room_alias):
content = await self.request("GET", f"/directory/room/{quote(room_alias)}")
return content.get("room_id", None)
def set_typing(self, room_id, is_typing=True, timeout=5000, user=None):
content = {
"typing": is_typing
}
if is_typing:
content["timeout"] = timeout
user = user or self.identity
return self.request("PUT", f"/rooms/{room_id}/typing/{user}", content)
class ChildHTTPAPI(HTTPAPI):
def __init__(self, user, parent):
super().__init__(parent.base_url, parent.domain, parent.bot_mxid, parent.token, user,
parent.log, parent.state_store, parent.session, child=True)
self.parent = parent
@property
def txn_id(self):
return self.parent.txn_id
@txn_id.setter
def txn_id(self, value):
self.parent.txn_id = value
class IntentAPI:
mxid_regex = re.compile("@(.+):(.+)")
def __init__(self, mxid, client, bot=None, state_store=None, log=None):
self.client = client
self.bot = bot
self.mxid = mxid
self.log = log
results = self.mxid_regex.search(mxid)
if not results:
raise ValueError("invalid MXID")
self.localpart = results.group(1)
self.state_store = state_store
def user(self, user):
if not self.bot:
return self.client.intent(user)
else:
self.log.warning("Called IntentAPI#user() of child intent object.")
return self.bot.client.intent(user)
# region User actions
async def get_joined_rooms(self):
await self.ensure_registered()
response = await self.client.request("GET", "/joined_rooms")
return response["joined_rooms"]
async def set_display_name(self, name):
await self.ensure_registered()
content = {"displayname": name}
return await self.client.request("PUT", f"/profile/{self.mxid}/displayname", content)
async def set_presence(self, status="online"):
await self.ensure_registered()
content = {
"presence": status
}
return await self.client.request("PUT", f"/presence/{self.mxid}/status", content)
async def set_avatar(self, url):
await self.ensure_registered()
content = {"avatar_url": url}
return await self.client.request("PUT", f"/profile/{self.mxid}/avatar_url", content)
async def upload_file(self, data, mime_type=None):
await self.ensure_registered()
mime_type = mime_type or magic.from_buffer(data, mime=True)
return await self.client.request("POST", "", content=data,
headers={"Content-Type": mime_type},
api_path="/_matrix/media/r0/upload")
async def download_file(self, url):
await self.ensure_registered()
url = self.client.get_download_url(url)
async with self.client.session.get(url) as response:
return await response.read()
# endregion
# region Room actions
async def create_room(self, alias=None, is_public=False, name=None, topic=None,
is_direct=False, invitees=None, initial_state=None):
await self.ensure_registered()
content = {
"visibility": "public" if is_public else "private",
"is_direct": is_direct,
}
if alias:
content["room_alias_name"] = alias
if invitees:
content["invite"] = invitees
if name:
content["name"] = name
if topic:
content["topic"] = topic
if initial_state:
content["initial_state"] = initial_state
return await self.client.request("POST", "/createRoom", content)
def _invite_direct(self, room_id, user_id):
content = {"user_id": user_id}
return self.client.request("POST", "/rooms/" + room_id + "/invite", content)
async def invite(self, room_id, user_id, check_cache=False):
await self.ensure_joined(room_id)
try:
ok_states = {"invite", "join"}
do_invite = (not check_cache
or self.state_store.get_membership(room_id, user_id) not in ok_states)
if do_invite:
response = await self._invite_direct(room_id, user_id)
self.state_store.invited(room_id, user_id)
return response
except MatrixRequestError as e:
if e.errcode != "M_FORBIDDEN":
raise IntentError(f"Failed to invite {user_id} to {room_id}", e)
if "is already in the room" in e.message:
self.state_store.joined(room_id, user_id)
def set_room_avatar(self, room_id, avatar_url, info=None):
content = {
"url": avatar_url,
}
if info:
content["info"] = info
return self.send_state_event(room_id, "m.room.avatar", content)
async def add_room_alias(self, room_id, localpart):
await self.ensure_registered()
content = {"room_id": room_id}
alias = f"#{localpart}:{self.client.domain}"
return await self.client.request("PUT", f"/directory/room/{quote(alias)}", content)
async def remove_room_alias(self, localpart):
await self.ensure_registered()
alias = f"#{localpart}:{self.client.domain}"
return await self.client.request("DELETE", f"/directory/room/{quote(alias)}")
def set_room_name(self, room_id, name):
body = {"name": name}
return self.send_state_event(room_id, "m.room.name", body)
async def get_power_levels(self, room_id, ignore_cache=False):
await self.ensure_joined(room_id)
if not ignore_cache:
try:
return self.state_store.get_power_levels(room_id)
except KeyError:
pass
levels = await self.client.request("GET",
f"/rooms/{quote(room_id)}/state/m.room.power_levels")
self.state_store.set_power_levels(room_id, levels)
return levels
async def set_power_levels(self, room_id, content):
if "events" not in content:
content["events"] = {}
response = await self.send_state_event(room_id, "m.room.power_levels", content)
self.state_store.set_power_levels(room_id, content)
return response
async def get_pinned_messages(self, room_id):
await self.ensure_joined(room_id)
response = await self.client.request("GET", f"/rooms/{room_id}/state/m.room.pinned_events")
return response["content"]["pinned"]
def set_pinned_messages(self, room_id, events):
return self.send_state_event(room_id, "m.room.pinned_events", {
"pinned": events
})
async def pin_message(self, room_id, event_id):
events = await self.get_pinned_messages(room_id)
if event_id not in events:
events.append(event_id)
await self.set_pinned_messages(room_id, events)
async def unpin_message(self, room_id, event_id):
events = await self.get_pinned_messages(room_id)
if event_id in events:
events.remove(event_id)
await self.set_pinned_messages(room_id, events)
async def get_event(self, room_id, event_id):
await self.ensure_joined(room_id)
return await self.client.request("GET", f"/rooms/{room_id}/event/{event_id}")
async def set_typing(self, room_id, is_typing=True, timeout=5000):
await self.ensure_joined(room_id)
content = {
"typing": is_typing
}
if is_typing:
content["timeout"] = timeout
return await self.client.request("PUT", f"/rooms/{room_id}/typing/{self.mxid}", content)
async def mark_read(self, room_id, event_id):
await self.ensure_joined(room_id)
return await self.client.request("POST", f"/rooms/{room_id}/receipt/m.read/{event_id}",
content={})
def send_notice(self, room_id, text, html=None, relates_to=None):
return self.send_text(room_id, text, html, "m.notice", relates_to)
def send_emote(self, room_id, text, html=None, relates_to=None):
return self.send_text(room_id, text, html, "m.emote", relates_to)
def send_image(self, room_id, url, info=None, text=None, relates_to=None):
return self.send_file(room_id, url, info or {}, text, "m.image", relates_to)
def send_file(self, room_id, url, info=None, text=None, file_type="m.file", relates_to=None):
return self.send_message(room_id, {
"msgtype": file_type,
"url": url,
"body": text or "Uploaded file",
"info": info or {},
"m.relates_to": relates_to or None,
})
def send_text(self, room_id, text, html=None, msgtype="m.text", relates_to=None):
if html:
if not text:
text = html
return self.send_message(room_id, {
"body": text,
"msgtype": msgtype,
"format": "org.matrix.custom.html",
"formatted_body": html or text,
"m.relates_to": relates_to or None,
})
else:
return self.send_message(room_id, {
"body": text,
"msgtype": msgtype,
"m.relates_to": relates_to or None,
})
def send_message(self, room_id, body):
return self.send_event(room_id, "m.room.message", body)
async def error_and_leave(self, room_id, text, html=None):
await self.ensure_joined(room_id)
await self.send_notice(room_id, text, html=html)
await self.leave_room(room_id)
def kick(self, room_id, user_id, message):
return self.set_membership(room_id, user_id, "leave", message)
def get_membership(self, room_id, user_id):
return self.get_state_event(room_id, "m.room.member", state_key=user_id)
def set_membership(self, room_id, user_id, membership, reason="", profile=None):
body = {
"membership": membership,
"reason": reason
}
profile = profile or {}
if "displayname" in profile:
body["displayname"] = profile["displayname"]
if "avatar_url" in profile:
body["avatar_url"] = profile["avatar_url"]
return self.send_state_event(room_id, "m.room.member", body, state_key=user_id)
@staticmethod
def _get_event_url(room_id, event_type, txn_id):
return f"/rooms/{quote(room_id)}/send/{quote(event_type)}/{quote(txn_id)}"
async def send_event(self, room_id, event_type, content, txn_id=None):
await self.ensure_joined(room_id)
await self._ensure_has_power_level_for(room_id, event_type)
txn_id = txn_id or str(self.client.txn_id) + str(int(time() * 1000))
self.client.txn_id += 1
url = self._get_event_url(room_id, event_type, txn_id)
return await self.client.request("PUT", url, content)
@staticmethod
def _get_state_url(room_id, event_type, state_key=""):
url = f"/rooms/{quote(room_id)}/state/{quote(event_type)}"
if state_key:
url += f"/{quote(state_key)}"
return url
async def send_state_event(self, room_id, event_type, content, state_key=""):
await self.ensure_joined(room_id)
await self._ensure_has_power_level_for(room_id, event_type)
url = self._get_state_url(room_id, event_type, state_key)
return await self.client.request("PUT", url, content)
async def get_state_event(self, room_id, event_type, state_key=""):
await self.ensure_joined(room_id)
url = self._get_state_url(room_id, event_type, state_key)
return await self.client.request("GET", url)
def join_room(self, room_id):
return self.ensure_joined(room_id, ignore_cache=True)
def _join_room_direct(self, room):
return self.client.request("POST", f"/join/{quote(room)}")
def leave_room(self, room_id):
try:
self.state_store.left(room_id, self.mxid)
return self.client.request("POST", f"/rooms/{quote(room_id)}/leave")
except MatrixRequestError as e:
if "not in room" not in e.message:
raise
def get_room_memberships(self, room_id):
return self.client.request("GET", f"/rooms/{quote(room_id)}/members")
async def get_room_members(self, room_id, allowed_memberships=("join",)):
memberships = await self.get_room_memberships(room_id)
return [membership["state_key"] for membership in memberships["chunk"] if
membership["content"]["membership"] in allowed_memberships]
async def get_room_state(self, room_id):
await self.ensure_joined(room_id)
state = await self.client.request("GET", f"/rooms/{quote(room_id)}/state")
# TODO update values based on state?
return state
# endregion
# region Ensure functions
async def ensure_joined(self, room_id, ignore_cache=False):
if not ignore_cache and self.state_store.is_joined(room_id, self.mxid):
return
await self.ensure_registered()
try:
await self._join_room_direct(room_id)
self.state_store.joined(room_id, self.mxid)
except MatrixRequestError as e:
if e.errcode != "M_FORBIDDEN" or not self.bot:
raise IntentError(f"Failed to join room {room_id} as {self.mxid}", e)
try:
await self.bot.invite(room_id, self.mxid)
await self._join_room_direct(room_id)
self.state_store.joined(room_id, self.mxid)
except MatrixRequestError as e2:
raise IntentError(f"Failed to join room {room_id} as {self.mxid}", e2)
def _register(self):
content = {"username": self.localpart}
query_params = {"kind": "user"}
return self.client.request("POST", "/register", content, query_params)
async def ensure_registered(self):
if self.state_store.is_registered(self.mxid):
return
try:
await self._register()
except MatrixRequestError as e:
if e.errcode != "M_USER_IN_USE":
self.log.exception(f"Failed to register {self.mxid}!")
# raise IntentError(f"Failed to register {self.mxid}", e)
return
self.state_store.registered(self.mxid)
async def _ensure_has_power_level_for(self, room_id, event_type):
if not self.state_store.has_power_levels(room_id):
await self.get_power_levels(room_id)
if self.state_store.has_power_level(room_id, self.mxid, event_type):
return
elif not self.bot:
self.log.warning(
f"Power level of {self.mxid} is not enough for {event_type} in {room_id}")
# raise IntentError(f"Power level of {self.mxid} is not enough"
# + f"for {event_type} in {room_id}")
return
# TODO implement
# endregion
-123
View File
@@ -1,123 +0,0 @@
# -*- coding: future_fstrings -*-
# matrix-appservice-python - A Matrix Application Service framework written in Python.
# Copyright (C) 2018 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import json
class StateStore:
def __init__(self, autosave_file=None):
self.registrations = set()
self.memberships = {}
self.power_levels = {}
self.autosave_file = autosave_file
def save(self, file):
if isinstance(file, str):
output = open(file, "w")
else:
output = file
json.dump({
"registrations": list(self.registrations),
"memberships": self.memberships,
"power_levels": self.power_levels,
}, output)
if isinstance(file, str):
output.close()
def load(self, file):
if isinstance(file, str):
try:
input_source = open(file, "r")
except FileNotFoundError:
return
else:
input_source = file
data = json.load(input_source)
if "registrations" in data:
self.registrations = set(data["registrations"])
if "memberships" in data:
self.memberships = data["memberships"]
if "power_levels" in data:
self.power_levels = data["power_levels"]
if isinstance(file, str):
input_source.close()
def _autosave(self):
if self.autosave_file:
self.save(self.autosave_file)
def is_registered(self, user):
return user in self.registrations
def registered(self, user):
self.registrations.add(user)
self._autosave()
def get_membership(self, room, user):
return self.memberships.get(room, {}).get(user, "left")
def is_joined(self, room, user):
return self.get_membership(room, user) == "join"
def set_membership(self, room, user, membership):
if room not in self.memberships:
self.memberships[room] = {}
self.memberships[room][user] = membership
self._autosave()
def joined(self, room, user):
return self.set_membership(room, user, "join")
def invited(self, room, user):
return self.set_membership(room, user, "invite")
def left(self, room, user):
return self.set_membership(room, user, "left")
def has_power_levels(self, room):
return room in self.power_levels
def get_power_levels(self, room):
return self.power_levels[room]
def has_power_level(self, room, user, event):
room_levels = self.power_levels.get(room, {})
required = room_levels.get("events", {}).get(event, 95)
has = room_levels.get("users", {}).get(user, 0)
return has >= required
def set_power_level(self, room, user, level):
if room not in self.power_levels:
self.power_levels[room] = {
"users": {},
"events": {},
}
elif "users" not in self.power_levels[room]:
self.power_levels[room]["users"] = {}
self.power_levels[room]["users"][user] = level
self._autosave()
def set_power_levels(self, room, content):
if "events" not in content:
content["events"] = {}
if "users" not in content:
content["users"] = {}
self.power_levels[room] = content
self._autosave()
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.1.0"
__version__ = "0.6.0rc1"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+107 -37
View File
@@ -1,57 +1,71 @@
# -*- 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, List, Any
from time import time
import argparse
import sys
import logging
import asyncio
import logging.config
import sys
import copy
import signal
import os
import sqlalchemy as sql
from sqlalchemy import orm
from mautrix_appservice import AppService
from alchemysession import AlchemySessionContainer
from .base import Base
from .web.provisioning import ProvisioningAPI
from .web.public import PublicBridgeWebsite
from .abstract_user import init as init_abstract_user
from .bot import init as init_bot
from .config import Config
from .context import Context
from .db import Base, init as init_db
from .formatter import init as init_formatter
from .matrix import MatrixHandler
from .db import init as init_db
from .user import init as init_user, User
from .portal import init as init_portal
from .puppet import init as init_puppet
from .sqlstatestore import SQLStateStore
from .user import User, init as init_user
from . import __version__
log = logging.getLogger("mau")
time_formatter = logging.Formatter("[%(asctime)s] [%(levelname)s@%(name)s] %(message)s")
handler = logging.StreamHandler()
handler.setFormatter(time_formatter)
log.addHandler(handler)
try:
import prometheus_client as prometheus
except ImportError:
prometheus = None
parser = argparse.ArgumentParser(
description="A Matrix-Telegram puppeting bridge.",
prog="python -m mautrix-telegram")
parser.add_argument("-c", "--config", type=str, default="config.yaml",
metavar="<path>", help="the path to your config file")
parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml",
metavar="<path>", help="the path to the example config "
"(for automatic config updates)")
parser.add_argument("-g", "--generate-registration", action="store_true",
help="generate registration and quit")
parser.add_argument("-r", "--registration", type=str, default="registration.yaml",
metavar="<path>", help="the path to save the generated registration to")
args = parser.parse_args()
config = Config(args.config, args.registration)
config = Config(args.config, args.registration, args.base_config, os.environ)
config.load()
config.update()
if args.generate_registration:
config.generate_registration()
@@ -59,35 +73,91 @@ if args.generate_registration:
print(f"Registration generated and saved to {config.registration_path}")
sys.exit(0)
if config["appservice.debug"]:
telethon_log = logging.getLogger("telethon")
telethon_log.addHandler(handler)
telethon_log.setLevel(logging.DEBUG)
log.setLevel(logging.DEBUG)
log.debug("Debug messages enabled.")
logging.config.dictConfig(copy.deepcopy(config["logging"]))
log = logging.getLogger("mau.init") # type: logging.Logger
log.debug(f"Initializing mautrix-telegram {__version__}")
db_engine = sql.create_engine(config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
db_factory = orm.sessionmaker(bind=db_engine)
db_session = orm.scoping.scoped_session(db_factory)
db_engine = sql.create_engine(config["appservice.database"] or "sqlite:///mautrix-telegram.db")
Base.metadata.bind = db_engine
Base.metadata.create_all()
loop = asyncio.get_event_loop()
session_container = AlchemySessionContainer(engine=db_engine, table_base=Base, session=False,
table_prefix="telethon_", manage_tables=False)
session_container.core_mode = True
try:
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
log.debug("Using uvloop for asyncio")
except ImportError:
pass
loop = asyncio.get_event_loop() # type: asyncio.AbstractEventLoop
state_store = SQLStateStore()
mebibyte = 1024 ** 2
appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
config["appservice.as_token"], config["appservice.hs_token"],
config["appservice.bot_username"], log="mau.as", loop=loop)
context = (appserv, db_session, config, loop)
config["appservice.bot_username"], log="mau.as", loop=loop,
verify_ssl=config["homeserver.verify_ssl"], state_store=state_store,
real_user_content_key="net.maunium.telegram.puppet",
aiohttp_params={
"client_max_size": config["appservice.max_body_size"] * mebibyte
})
bot = init_bot(config)
context = Context(appserv, config, loop, session_container, bot)
if config["appservice.public.enabled"]:
public_website = PublicBridgeWebsite(loop)
appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app)
context.public_website = public_website
if config["appservice.provisioning.enabled"]:
provisioning_api = ProvisioningAPI(context)
appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning",
provisioning_api.app)
context.provisioning_api = provisioning_api
context.mx = MatrixHandler(context)
if config["metrics.enabled"]:
if prometheus:
prometheus.start_http_server(config["metrics.listen_port"])
else:
log.warn("Metrics are enabled in the config, but prometheus-async is not installed.")
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
MatrixHandler(context)
init_db(db_session)
start_ts = time()
init_db(db_engine)
init_abstract_user(context)
init_formatter(context)
init_portal(context)
init_puppet(context)
startup_actions = init_user(context) + [start]
startup_actions = (init_puppet(context) +
init_user(context) +
[start, context.mx.init_as_bot()]) # type: List[Awaitable[Any]]
if context.bot:
startup_actions.append(context.bot.start())
signal.signal(signal.SIGINT, signal.default_int_handler)
signal.signal(signal.SIGTERM, signal.default_int_handler)
end_ts = time()
try:
log.debug(f"Initialization complete in {round(end_ts - start_ts, 2)} seconds,"
" running startup actions")
start_ts = time()
loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop))
end_ts = time()
log.debug(f"Startup actions complete in {round(end_ts - start_ts, 2)} seconds,"
" now running forever")
loop.run_forever()
except KeyboardInterrupt:
for user in User.by_tgid.values():
user.stop()
log.debug("Interrupt received, stopping clients")
loop.run_until_complete(
asyncio.gather(*[user.stop() for user in User.by_tgid.values()], loop=loop))
log.debug("Clients stopped, shutting down")
sys.exit(0)
except Exception as e:
log.exception("Unexpected error")
sys.exit(1)
+423
View File
@@ -0,0 +1,423 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Tuple, Optional, List, Union, Dict, TYPE_CHECKING
from abc import ABC, abstractmethod
import asyncio
import logging
import platform
import time
from telethon.tl.patched import MessageService, Message
from telethon.tl.types import (
Channel, ChannelForbidden, Chat, ChatForbidden, MessageActionChannelMigrateFrom, PeerUser,
TypeUpdate, UpdateChannelPinnedMessage, UpdateChatPinnedMessage, UpdateChatParticipantAdmin,
UpdateChatParticipants, UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateDeleteMessages,
UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateNewMessage,
UpdateReadHistoryOutbox, UpdateShortChatMessage, UpdateShortMessage, UpdateUserName,
UpdateUserPhoto, UpdateUserStatus, UpdateUserTyping, User, UserStatusOffline, UserStatusOnline)
from mautrix_appservice import MatrixRequestError, 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 .tgclient import MautrixTelegramClient
if TYPE_CHECKING:
from .context import Context
from .config import Config
from .bot import Bot
config = None # type: Config
# Value updated from config in init()
MAX_DELETIONS = 10 # type: int
UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage]
UpdateMessageContent = Union[UpdateShortMessage, UpdateShortChatMessage, Message, MessageService]
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
az = None # type: AppService
bot = None # type: Bot
ignore_incoming_bot_events = True # type: 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]
@property
def connected(self) -> bool:
return self.client and self.client.is_connected()
@property
def _proxy_settings(self) -> Optional[Tuple[int, str, str, str, str, str]]:
proxy_type = config["telegram.proxy.type"].lower()
if proxy_type == "disabled":
return None
elif proxy_type == "socks4":
proxy_type = 1
elif proxy_type == "socks5":
proxy_type = 2
elif proxy_type == "http":
proxy_type = 3
return (proxy_type,
config["telegram.proxy.address"], config["telegram.proxy.port"],
config["telegram.proxy.rdns"],
config["telegram.proxy.username"], config["telegram.proxy.password"])
def _init_client(self) -> None:
self.log.debug(f"Initializing client for {self.name}")
self.session = self.session_container.new_session(self.name)
if config["telegram.server.enabled"]:
self.session.set_dc(config["telegram.server.dc"],
config["telegram.server.ip"],
config["telegram.server.port"])
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"]
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"],
proxy=self._proxy_settings,
loop=self.loop,
base_logger=base_logger
)
self.client.add_event_handler(self._update_catch)
@abstractmethod
async def update(self, update: TypeUpdate) -> bool:
return False
@abstractmethod
async def post_login(self) -> None:
raise NotImplementedError()
@abstractmethod
def register_portal(self, portal: po.Portal) -> None:
raise NotImplementedError()
@abstractmethod
def unregister_portal(self, portal: po.Portal) -> None:
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")
if UPDATE_TIME:
UPDATE_TIME.labels(update_type=type(update).__name__).observe(time.time() - start_time)
async def get_dialogs(self, limit: int = None) -> List[Union[Chat, Channel]]:
if self.is_bot:
return []
dialogs = await self.client.get_dialogs(limit=limit)
return [dialog.entity for dialog in dialogs if (
not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden))
and not (isinstance(dialog.entity, Chat)
and (dialog.entity.deactivated or dialog.entity.left)))]
@property
@abstractmethod
def name(self) -> str:
raise NotImplementedError()
async def is_logged_in(self) -> bool:
return self.client and self.client.is_connected() and await self.client.is_user_authorized()
async def has_full_access(self, allow_bot: bool = False) -> bool:
return (self.puppet_whitelisted
and (not self.is_bot or allow_bot)
and await self.is_logged_in())
async def start(self, delete_unless_authenticated: bool = False) -> 'AbstractUser':
if not self.client:
self._init_client()
await self.client.connect()
self.log.debug("%s connected: %s", self.mxid, self.connected)
return self
async def ensure_started(self, even_if_no_session=False) -> 'AbstractUser':
if not self.puppet_whitelisted or self.connected:
return self
self.log.debug("ensure_started(%s, even_if_no_session=%s)", self.mxid, even_if_no_session)
if even_if_no_session or self.session_container.has_session(self.mxid):
await self.start(delete_unless_authenticated=not even_if_no_session)
return self
async def stop(self) -> None:
await self.client.disconnect()
self.client = None
# 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)
elif isinstance(update, UpdateDeleteMessages):
await self.delete_message(update)
elif isinstance(update, UpdateDeleteChannelMessages):
await self.delete_channel_message(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)):
await self.update_typing(update)
elif isinstance(update, UpdateUserStatus):
await self.update_status(update)
elif isinstance(update, UpdateChatParticipantAdmin):
await self.update_admin(update)
elif isinstance(update, UpdateChatParticipants):
await self.update_participants(update)
elif isinstance(update, (UpdateChannelPinnedMessage, UpdateChatPinnedMessage)):
await self.update_pinned_messages(update)
elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
await self.update_others_info(update)
elif isinstance(update, UpdateReadHistoryOutbox):
await self.update_read_receipt(update)
else:
self.log.debug("Unhandled update: %s", update)
async def update_pinned_messages(self, update: Union[UpdateChannelPinnedMessage,
UpdateChatPinnedMessage]) -> None:
if isinstance(update, UpdateChatPinnedMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
else:
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
if portal and portal.mxid:
await portal.receive_telegram_pin_id(update.id, self.tgid)
@staticmethod
async def update_participants(update: UpdateChatParticipants) -> None:
portal = po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
if portal and portal.mxid:
await portal.update_telegram_participants(update.participants.participants)
async def update_read_receipt(self, update: UpdateReadHistoryOutbox) -> None:
if not isinstance(update.peer, PeerUser):
self.log.debug("Unexpected read receipt peer: %s", update.peer)
return
portal = po.Portal.get_by_tgid(TelegramID(update.peer.user_id), self.tgid)
if not portal or not portal.mxid:
return
# We check that these are user read receipts, so tg_space is always the user ID.
message = DBMessage.get_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: UpdateChatParticipantAdmin) -> None:
# TODO duplication not checked
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
if not portal or not portal.mxid:
return
await portal.set_telegram_admin(TelegramID(update.user_id))
async def update_typing(self, update: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None:
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")
if not portal or not portal.mxid:
return
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.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):
puppet.save()
else:
self.log.warning("Unexpected other user info update: %s", update)
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")
elif isinstance(update.status, UserStatusOffline):
await puppet.default_mxid_intent.set_presence("offline")
else:
self.log.warning("Unexpected user status update: %s", update)
return
def get_message_details(self, update: UpdateMessage) -> Tuple[UpdateMessageContent,
Optional[pu.Puppet],
Optional[po.Portal]]:
if isinstance(update, UpdateShortChatMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
sender = pu.Puppet.get(TelegramID(update.from_id))
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
sender = pu.Puppet.get(self.tgid if update.out else update.user_id)
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
UpdateEditMessage, UpdateEditChannelMessage)):
update = update.message
if isinstance(update.to_id, PeerUser) and not update.out:
portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
tg_receiver=self.tgid)
else:
portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
sender = pu.Puppet.get(update.from_id) if update.from_id else None
else:
self.log.warning(
f"Unexpected message type in User#get_message_details: {type(update)}")
return update, None, None
return update, sender, portal
@staticmethod
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:
pass
async def delete_message(self, update: UpdateDeleteMessages) -> None:
if len(update.messages) > MAX_DELETIONS:
return
for message_id in update.messages:
messages = DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid)
for message in messages:
message.delete()
number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
if number_left == 0:
portal = po.Portal.get_by_mxid(message.mx_room)
await self._try_redact(message)
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
if len(update.messages) > MAX_DELETIONS:
return
channel_id = TelegramID(update.channel_id)
for message_id in update.messages:
messages = DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id)
for message in messages:
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)
return
if self.ignore_incoming_bot_events and self.bot and sender.id == self.bot.tgid:
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update, portal.tgid_log)
return
if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom):
self.log.debug(f"Ignoring action %s to %s by %d", update.action,
portal.tgid_log,
sender.id)
return
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
return await portal.handle_telegram_action(self, sender, update)
user = sender.tgid if sender else "admin"
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
return await portal.handle_telegram_edit(self, sender, update)
self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
return await portal.handle_telegram_message(self, sender, update)
# endregion
def init(context: "Context") -> None:
global config, MAX_DELETIONS
AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
AbstractUser.session_container = context.session_container
MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10)
-2
View File
@@ -1,2 +0,0 @@
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
+298
View File
@@ -0,0 +1,298 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, Callable, Dict, List, Optional, Pattern, Tuple, TYPE_CHECKING
import logging
import re
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (
ChannelParticipantAdmin, ChannelParticipantCreator, ChatForbidden, ChatParticipantAdmin,
ChatParticipantCreator, InputChannel, InputUser, MessageActionChatAddUser,
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 .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
ReplyFunc = Callable[[str], Awaitable[Message]]
class Bot(AbstractUser):
log = logging.getLogger("mau.bot") # type: logging.Logger
mxid_regex = re.compile("@.+:.+") # type: Pattern
def __init__(self, token: str) -> None:
super().__init__()
self.token = token # type: str
self.puppet_whitelisted = True # type: bool
self.whitelisted = True # type: bool
self.relaybot_whitelisted = True # type: bool
self.username = None # type: str
self.is_relaybot = True # type: bool
self.is_bot = True # type: bool
self.chats = {} # type: Dict[int, str]
self.tg_whitelist = [] # type: List[int]
self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"]
or False) # type: bool
self._me_info = None # type: Optional[User]
self._me_mxid = None # type: Optional[MatrixUserID]
async def get_me(self, use_cache: bool = True) -> Tuple[User, MatrixUserID]:
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 []
for user_id in whitelist:
if isinstance(user_id, str):
entity = await self.client.get_input_entity(user_id)
if isinstance(entity, InputUser):
user_id = entity.user_id
else:
user_id = None
if isinstance(user_id, int):
self.tg_whitelist.append(user_id)
async def start(self, delete_unless_authenticated: bool = False) -> 'Bot':
self.chats = {chat.id: chat.type for chat in BotChat.all()}
await super().start(delete_unless_authenticated)
if not await self.is_logged_in():
await self.client.sign_in(bot_token=self.token)
await self.post_login()
return self
async def post_login(self) -> None:
await self.init_permissions()
info = await self.client.get_me()
self.tgid = info.id
self.username = info.username
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
chat_ids = [chat_id for chat_id, chat_type in self.chats.items() if chat_type == "chat"]
response = await self.client(GetChatsRequest(chat_ids))
for chat in response.chats:
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
self.remove_chat(TelegramID(chat.id))
channel_ids = [InputChannel(chat_id, 0)
for chat_id, chat_type in self.chats.items()
if chat_type == "channel"]
for channel_id in channel_ids:
try:
await self.client(GetChannelsRequest([channel_id]))
except (ChannelPrivateError, ChannelInvalidError):
self.remove_chat(TelegramID(channel_id.channel_id))
if config["bridge.catch_up"]:
try:
await self.client.catch_up()
except Exception:
self.log.exception("Failed to run catch_up() for bot")
def register_portal(self, portal: po.Portal) -> None:
self.add_chat(portal.tgid, portal.peer_type)
def unregister_portal(self, portal: po.Portal) -> None:
self.remove_chat(portal.tgid)
def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
if chat_id not in self.chats:
self.chats[chat_id] = chat_type
BotChat(id=TelegramID(chat_id), type=chat_type).insert()
def remove_chat(self, chat_id: TelegramID) -> None:
try:
del self.chats[chat_id]
except KeyError:
pass
BotChat.delete(chat_id)
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
if tgid in self.tg_whitelist:
return True
user = u.User.get_by_tgid(tgid)
if user and user.is_admin:
self.tg_whitelist.append(user.tgid)
return True
if self.whitelist_group_admins:
if isinstance(chat, PeerChannel):
p = await self.client(GetParticipantRequest(chat, tgid))
return isinstance(p, (ChannelParticipantCreator, ChannelParticipantAdmin))
elif isinstance(chat, PeerChat):
chat = await self.client(GetFullChatRequest(chat.chat_id))
participants = chat.full_chat.participants.participants
for p in participants:
if p.user_id == tgid:
return isinstance(p, (ChatParticipantCreator, ChatParticipantAdmin))
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):
await reply("You do not have the permission to use that command.")
return False
return True
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc) -> Message:
if not config["bridge.relaybot.authless_portals"]:
return await reply("This bridge doesn't allow portal creation from Telegram.")
if not portal.allow_bridging():
return await reply("This bridge doesn't allow bridging this chat.")
await portal.create_matrix_room(self)
if portal.mxid:
if portal.username:
return await reply(
f"Portal is public: [{portal.alias}](https://matrix.to/#/{portal.alias})")
else:
return await reply(
"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:
if len(mxid_input) == 0:
return await reply("Usage: `/invite <mxid>`")
elif not portal.mxid:
return await reply("Portal does not have Matrix room. "
"Create one with /portal first.")
if not self.mxid_regex.match(mxid_input):
return await reply("That doesn't look like a Matrix ID.")
user = await u.User.get_by_mxid(MatrixUserID(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():
displayname = f"@{user.username}" if user.username else user.displayname
return await reply("That user seems to be logged in. "
f"Just invite [{displayname}](tg://user?id={user.tgid})")
else:
await portal.main_intent.invite(portal.mxid, user.mxid)
return await reply(f"Invited `{user.mxid}` to the portal.")
@staticmethod
def handle_command_id(message: Message, reply: ReplyFunc) -> Awaitable[Message]:
# Provide the prefixed ID to the user so that the user wouldn't need to specify whether the
# chat is a normal group or a supergroup/channel when using the ID.
if isinstance(message.to_id, PeerChannel):
return reply(f"-100{message.to_id.channel_id}")
return reply(str(-message.to_id.chat_id))
def match_command(self, text: str, command: str) -> bool:
text = text.lower()
command = f"/{command.lower()}"
command_targeted = f"{command}@{self.username.lower()}"
is_plain_command = text == command or text == command_targeted
if is_plain_command:
return True
is_arg_command = text.startswith(command + " ") or text.startswith(command_targeted + " ")
if is_arg_command:
return True
return False
async def handle_command(self, message: Message) -> None:
def reply(reply_text: str) -> Awaitable[Message]:
return self.client.send_message(message.to_id, reply_text, reply_to=message.id)
text = message.message
if self.match_command(text, "id"):
await self.handle_command_id(message, reply)
return
portal = po.Portal.get_by_entity(message.to_id)
if self.match_command(text, "portal"):
if not await self.check_can_use_commands(message, reply):
return
await self.handle_command_portal(portal, reply)
elif self.match_command(text, "invite"):
if not await self.check_can_use_commands(message, reply):
return
try:
mxid = text[text.index(" ") + 1:]
except ValueError:
mxid = ""
await self.handle_command_invite(portal, reply, mxid_input=mxid)
def handle_service_message(self, message: MessageService) -> None:
to_id = message.to_id # type: TelegramID
if isinstance(to_id, PeerChannel):
to_id = to_id.channel_id
chat_type = "channel"
elif isinstance(to_id, PeerChat):
to_id = to_id.chat_id
chat_type = "chat"
else:
return
action = message.action
if isinstance(action, MessageActionChatAddUser) and self.tgid in action.users:
self.add_chat(to_id, chat_type)
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)):
return False
if isinstance(update.message, MessageService):
self.handle_service_message(update.message)
return False
is_command = (isinstance(update.message, Message)
and update.message.entities and len(update.message.entities) > 0
and isinstance(update.message.entities[0], MessageEntityBotCommand))
if is_command:
await self.handle_command(update.message)
return True
return False
def is_in_chat(self, peer_id) -> bool:
return peer_id in self.chats
@property
def name(self) -> str:
return "bot"
def init(cfg: 'Config') -> Optional[Bot]:
global config
config = cfg
token = config["telegram.bot_token"]
if token and not token.lower().startswith("disable"):
return Bot(token)
return None
+5 -2
View File
@@ -1,2 +1,5 @@
from .handler import command_handler, CommandHandler
from . import clean_rooms, auth, meta, telegram
from .handler import (command_handler, command_handlers as _command_handlers,
CommandHandler, CommandProcessor, CommandEvent,
SECTION_GENERAL, SECTION_AUTH, SECTION_CREATING_PORTALS,
SECTION_PORTAL_MANAGEMENT, SECTION_MISC, SECTION_ADMIN)
from . import portal, telegram, clean_rooms, matrix_auth, meta
-119
View File
@@ -1,119 +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 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
from telethon.errors import *
from . import command_handler
@command_handler(needs_auth=False)
async def ping(evt):
if not evt.sender.logged_in:
return await evt.reply("You're not logged in.")
me = await evt.sender.client.get_me()
if me:
return await evt.reply(f"You're logged in as @{me.username}")
else:
return await evt.reply("You're not logged in.")
@command_handler(needs_auth=False, management_only=True)
def register(evt):
return evt.reply("Not yet implemented.")
@command_handler(needs_auth=False, management_only=True)
async def login(evt):
if evt.sender.logged_in:
return await evt.reply("You are already logged in.")
elif len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp login <phone number>`")
phone_number = evt.args[0]
await evt.sender.client.sign_in(phone_number)
evt.sender.command_status = {
"next": enter_code,
"action": "Login",
}
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
@command_handler(needs_auth=False)
async def enter_code(evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
try:
user = await evt.sender.client.sign_in(code=evt.args[0])
asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
evt.sender.command_status = None
return await evt.reply(f"Successfully logged in as @{user.username}")
except PhoneNumberUnoccupiedError:
return await evt.reply("That phone number has not been registered."
"Please register with `$cmdprefix+sp register <phone>`.")
except PhoneCodeExpiredError:
return await evt.reply(
"Phone code expired. Try again with `$cmdprefix+sp login <phone>`.")
except PhoneCodeInvalidError:
return await evt.reply("Invalid phone code.")
except PhoneNumberAppSignupForbiddenError:
return await evt.reply(
"Your phone number does not allow 3rd party apps to sign in.")
except PhoneNumberFloodError:
return await evt.reply(
"Your phone number has been temporarily blocked for flooding. "
"The block is usually applied for around a day.")
except PhoneNumberBannedError:
return await evt.reply("Your phone number has been banned from Telegram.")
except SessionPasswordNeededError:
evt.sender.command_status = {
"next": enter_password,
"action": "Login (password entry)",
}
return await evt.reply("Your account has two-factor authentication."
"Please send your password here.")
except Exception:
evt.log.exception("Error sending phone code")
return await evt.reply("Unhandled exception while sending code."
"Check console for more details.")
@command_handler(needs_auth=False)
async def enter_password(evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
try:
user = await evt.sender.client.sign_in(password=evt.args[0])
asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
evt.sender.command_status = None
return await evt.reply(f"Successfully logged in as @{user.username}")
except PasswordHashInvalidError:
return await evt.reply("Incorrect password.")
except Exception:
evt.log.exception("Error sending password")
return await evt.reply("Unhandled exception while sending password. "
"Check console for more details.")
@command_handler(needs_auth=False)
async def logout(evt):
if not evt.sender.logged_in:
return await evt.reply("You're not logged in.")
if await evt.sender.log_out():
return await evt.reply("Logged out successfully.")
return await evt.reply("Failed to log out.")
+51 -43
View File
@@ -1,33 +1,40 @@
# -*- 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from mautrix_appservice import MatrixRequestError
# 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 . import command_handler
from mautrix_appservice import MatrixRequestError, IntentAPI
from ..types import MatrixRoomID, MatrixUserID
from . import command_handler, CommandEvent, SECTION_ADMIN
from .. import puppet as pu, portal as po
ManagementRoom = NewType('ManagementRoom', Tuple[MatrixRoomID, MatrixUserID])
async def _find_rooms(intent):
management_rooms = []
unidentified_rooms = []
portals = []
empty_portals = []
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[MatrixRoomID],
List['po.Portal'], List['po.Portal']]:
management_rooms = [] # type: List[ManagementRoom]
unidentified_rooms = [] # type: List[MatrixRoomID]
portals = [] # type: List[po.Portal]
empty_portals = [] # type: List[po.Portal]
rooms = await intent.get_joined_rooms()
for room in rooms:
for room_str in rooms:
room = MatrixRoomID(room_str)
portal = po.Portal.get_by_mxid(room)
if not portal:
try:
@@ -35,11 +42,11 @@ async def _find_rooms(intent):
except MatrixRequestError:
members = []
if len(members) == 2:
other_member = members[0] if members[0] != intent.mxid else members[1]
other_member = MatrixUserID(members[0] if members[0] != intent.mxid else members[1])
if pu.Puppet.get_id_from_mxid(other_member):
unidentified_rooms.append(room)
else:
management_rooms.append((room, other_member))
management_rooms.append(ManagementRoom((room, other_member)))
else:
unidentified_rooms.append(room)
else:
@@ -52,12 +59,10 @@ async def _find_rooms(intent):
return management_rooms, unidentified_rooms, portals, empty_portals
@command_handler(needs_admin=True, name="clean-rooms")
async def clean_rooms(evt):
if not evt.is_management:
return await evt.reply("`clean-rooms` is a particularly spammy command. Please don't "
"run it in non-management rooms.")
@command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms",
help_section=SECTION_ADMIN,
help_text="Clean up unused portal/management rooms.")
async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent)
reply = ["#### Management rooms (M)"]
@@ -65,8 +70,8 @@ async def clean_rooms(evt):
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}) "
+ f"(to Telegram chat \"{portal.title}\")"
reply += ([f"{n+1}. [A{n+1}](https://matrix.to/#/{portal.mxid}) "
f"(to Telegram chat \"{portal.title}\")"
for n, portal in enumerate(portals)]
or ["No active portal rooms found."])
reply.append("#### Unidentified rooms (U)")
@@ -74,8 +79,8 @@ async def clean_rooms(evt):
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}) "
+ f"(to Telegram chat \"{portal.title}\")"
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."])
@@ -88,9 +93,9 @@ async def clean_rooms(evt):
"",
("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 cleanrooms` "
("Please note that you will have to re-run `$cmdprefix+sp clean-rooms` "
"between each use of the commands above.")]
evt.sender.command_status = {
@@ -102,17 +107,20 @@ async def clean_rooms(evt):
return await evt.reply("\n".join(reply))
async def set_rooms_to_clean(evt, management_rooms, unidentified_rooms, portals, empty_portals):
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
unidentified_rooms: List[MatrixRoomID], portals: List["po.Portal"],
empty_portals: List["po.Portal"]) -> None:
command = evt.args[0]
rooms_to_clean = []
rooms_to_clean = [] # type: List[Union[po.Portal, MatrixRoomID]]
if command == "clean-recommended":
rooms_to_clean = empty_portals + unidentified_rooms
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 += management_rooms
rooms_to_clean += [room_id for (room_id, user_id) in management_rooms]
if "A" in groups_to_clean:
rooms_to_clean += portals
if "U" in groups_to_clean:
@@ -121,12 +129,12 @@ async def set_rooms_to_clean(evt, management_rooms, unidentified_rooms, portals,
rooms_to_clean += empty_portals
elif command == "clean-range":
try:
range = evt.args[1]
group, range = range[0], range[1:]
start, end = range.split("-")
clean_range = evt.args[1]
group, clean_range = clean_range[0], clean_range[1:]
start, end = clean_range.split("-")
start, end = int(start), int(end)
if group == "M":
group = management_rooms
group = [room_id for (room_id, user_id) in management_rooms]
elif group == "A":
group = portals
elif group == "U":
@@ -141,28 +149,28 @@ async def set_rooms_to_clean(evt, management_rooms, unidentified_rooms, portals,
"**Usage:** `$cmdprefix+sp clean-groups <_M|A|U|I_><range>")
else:
return await evt.reply(f"Unknown room cleaning action `{command}`. "
+ "Use `$cmdprefix+sp cancel` to cancel room "
+ "cleaning.")
"Use `$cmdprefix+sp cancel` to cancel room "
"cleaning.")
evt.sender.command_status = {
"next": lambda confirm: execute_room_cleanup(confirm, rooms_to_clean),
"action": "Room cleaning",
}
await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type"
+ "`$cmdprefix+sp confirm-clean`.")
"`$cmdprefix+sp confirm-clean`.")
async def execute_room_cleanup(evt, rooms_to_clean):
async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, MatrixRoomID]]) -> None:
if len(evt.args) > 0 and evt.args[0] == "confirm-clean":
await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. "
+ "This might take a while.")
"This might take a while.")
cleaned = 0
for room in rooms_to_clean:
if isinstance(room, po.Portal):
await room.cleanup_and_delete()
cleaned += 1
elif isinstance(room, str):
await po.Portal.cleanup_room(evt.az.intent, room, type="Room")
elif isinstance(room, str): # str is aliased by MatrixRoomID
await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted")
cleaned += 1
evt.sender.command_status = None
await evt.reply(f"{cleaned} rooms cleaned up successfully.")
+316 -66
View File
@@ -1,115 +1,365 @@
# -*- 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import markdown
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""This module contains classes handling commands issued by Matrix users."""
from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional
import logging
import traceback
import commonmark
from telethon.errors import FloodWaitError
command_handlers = {}
from ..types import MatrixRoomID, MatrixEventID
from ..util import format_duration
from .. import user as u, context as c
command_handlers = {} # type: Dict[str, CommandHandler]
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, "")
SECTION_MISC = HelpSection("Miscellaneous", 40, "")
SECTION_ADMIN = HelpSection("Administration", 50, "")
def command_handler(needs_auth=True, management_only=False, needs_admin=False, name=None):
def decorator(func):
def wrapper(evt):
if management_only and not evt.is_management:
return evt.reply(f"`{evt.command}` is a restricted command:"
+ "you may only run it in management rooms.")
elif needs_auth and not evt.sender.logged_in:
return evt.reply("This command requires you to be logged in.")
elif needs_admin and not evt.sender.is_admin:
return evt.reply("This is command requires administrator privileges.")
return func(evt)
class HtmlEscapingRenderer(commonmark.HtmlRenderer):
def __init__(self, allow_html: bool = False):
super().__init__()
self.allow_html = allow_html
command_handlers[name or func.__name__.replace("_", "-")] = wrapper
return wrapper
def lit(self, s):
if self.allow_html:
return super().lit(s)
return super().lit(s.replace("<", "&lt;").replace(">", "&gt;"))
return decorator
def image(self, node, entering):
prev = self.allow_html
self.allow_html = True
super().image(node, entering)
self.allow_html = prev
md_parser = commonmark.Parser()
md_renderer = HtmlEscapingRenderer()
def ensure_trailing_newline(s: str) -> str:
"""Returns the passed string, but with a guaranteed trailing newline."""
return s + ("" if s[-1] == "\n" else "\n")
class CommandEvent:
def __init__(self, handler, room, sender, command, args, is_management, is_portal):
self.az = handler.az
self.log = handler.log
self.loop = handler.loop
self.command_prefix = handler.command_prefix
"""Holds information about a command issued in a Matrix room.
When a Matrix command was issued to the bot, CommandEvent will hold
information regarding the event.
Attributes:
room_id: The id of the Matrix room in which the command was issued.
event_id: The id of the matrix event which contained the command.
sender: The user who issued the command.
command: The issued command.
args: Arguments given with the issued command.
is_management: Determines whether the room in which the command wa
issued is a management room.
is_portal: Determines whether the room in which the command was issued
is a portal.
"""
def __init__(self, processor: 'CommandProcessor', room: MatrixRoomID, event: MatrixEventID,
sender: u.User, command: str, args: List[str], is_management: bool,
is_portal: bool) -> None:
self.az = processor.az
self.log = processor.log
self.loop = processor.loop
self.tgbot = processor.tgbot
self.config = processor.config
self.public_website = processor.public_website
self.command_prefix = processor.command_prefix
self.room_id = room
self.event_id = event
self.sender = sender
self.command = command
self.args = args
self.is_management = is_management
self.is_portal = is_portal
def reply(self, message, allow_html=False, render_markdown=True):
message = message.replace("$cmdprefix+sp ",
"" if self.is_management else f"{self.command_prefix} ")
message = message.replace("$cmdprefix", self.command_prefix)
html = None
def reply(self, message: str, allow_html: bool = False, render_markdown: bool = True
) -> Awaitable[Dict]:
"""Write a reply to the room in which the command was issued.
Replaces occurences of "$cmdprefix" in the message with the command
prefix and replaces occurences of "$cmdprefix+sp " with the command
prefix if the command was not issued in a management room.
If allow_html and render_markdown are both False, the message will not
be rendered to html and sending of html is disabled.
Args:
message: The message to post in the room.
allow_html: Escape html in the message or don't render html at all
if markdown is disabled.
render_markdown: Use markdown formatting to render the passed
message to html.
Returns:
Handler for the message sending function.
"""
message_cmd = self._replace_command_prefix(message)
html = self._render_message(message_cmd, allow_html=allow_html,
render_markdown=render_markdown)
return self.az.intent.send_notice(self.room_id, message_cmd, html=html)
def mark_read(self) -> Awaitable[Dict]:
"""Marks the command as read by the bot."""
return self.az.intent.mark_read(self.room_id, self.event_id)
def _replace_command_prefix(self, message: str) -> str:
"""Returns the string with the proper command prefix entered."""
message = message.replace(
"$cmdprefix+sp ", "" if self.is_management else f"{self.command_prefix} "
)
return message.replace("$cmdprefix", self.command_prefix)
@staticmethod
def _render_message(message: str, allow_html: bool, render_markdown: bool) -> Optional[str]:
"""Renders the message as HTML.
Args:
allow_html: Flag to allow custom HTML in the message.
render_markdown: If true, markdown styling is applied to the message.
Returns:
The message rendered as HTML.
None is returned if no styled output is required.
"""
html = ""
if render_markdown:
html = markdown.markdown(message, safe_mode="escape" if allow_html else False)
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)
def format_duration(seconds):
def pluralize(count, singular): return singular if count == 1 else singular + "s"
def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else ""
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
parts = [a for a in [
include(days, "day"),
include(hours, "hour"),
include(minutes, "minute"),
include(seconds, "second")] if a]
if len(parts) > 2:
return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
return " and ".join(parts)
return ensure_trailing_newline(html) if html else None
class CommandHandler:
"""A command which can be executed from a Matrix room.
The command manages its permission and help texts.
When called, it will check the permission of the command event and execute
the command or, in case of error, report back to the user.
Attributes:
needs_auth: Flag indicating if the sender is required to be logged in.
needs_puppeting: Flag indicating if the sender is required to use
Telegram puppeteering for this command.
needs_matrix_puppeting: Flag indicating if the sender is required to use
Matrix pupeteering.
needs_admin: Flag for whether only admin users can issue this command.
management_only: Whether the command can exclusively be issued in a
management room.
name: The name of this command.
help_section: Section of the help in which this command will appear.
"""
def __init__(self, handler: Callable[[CommandEvent], Awaitable[Dict]], needs_auth: bool,
needs_puppeting: bool, needs_matrix_puppeting: bool, needs_admin: bool,
management_only: bool, name: str, help_text: str, help_args: str,
help_section: HelpSection) -> None:
"""
Args:
handler: The function handling the execution of this command.
needs_auth: Flag indicating if the sender is required to be logged in.
needs_puppeting: Flag indicating if the sender is required to use
Telegram puppeteering for this command.
needs_matrix_puppeting: Flag indicating if the sender is required to
use Matrix pupeteering.
needs_admin: Flag for whether only admin users can issue this command.
management_only: Whether the command can exclusively be issued
in a management room.
name: The name of this command.
help_text: The text displayed in the help for this command.
help_args: Help text for the arguments of this command.
help_section: Section of the help in which this command will appear.
"""
self._handler = handler
self.needs_auth = needs_auth
self.needs_puppeting = needs_puppeting
self.needs_matrix_puppeting = needs_matrix_puppeting
self.needs_admin = needs_admin
self.management_only = management_only
self.name = name
self._help_text = help_text
self._help_args = help_args
self.help_section = help_section
async def get_permission_error(self, evt: CommandEvent) -> Optional[str]:
"""Returns the reason why the command could not be issued.
Args:
evt: The event for which to get the error information.
Returns:
A string describing the error or None if there was no error.
"""
if self.management_only and not evt.is_management:
return (f"`{evt.command}` is a restricted command: "
"you may only run it in management rooms.")
elif self.needs_puppeting and not evt.sender.puppet_whitelisted:
return "This command requires puppeting privileges."
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
return "This command requires Matrix puppeting privileges."
elif self.needs_admin and not evt.sender.is_admin:
return "This command requires administrator privileges."
elif self.needs_auth and not await evt.sender.is_logged_in():
return "This command requires you to be logged in."
return None
def has_permission(self, is_management: bool, puppet_whitelisted: bool,
matrix_puppet_whitelisted: bool, is_admin: bool, is_logged_in: bool) -> bool:
"""Checks the permission for this command with the given status.
Args:
is_management: If the room in which the command will be issued is a
management room.
puppet_whitelisted: If the connected Telegram account puppet is
allowed to issue the command.
matrix_puppet_whitelisted: If the connected Matrix account puppet is
allowed to issue the command.
is_admin: If the issuing user is an admin.
is_logged_in: If the issuing user is logged in.
Returns:
True if a user with the given state is allowed to issue the
command.
"""
return ((not self.management_only or is_management) and
(not self.needs_puppeting or puppet_whitelisted) and
(not self.needs_matrix_puppeting or matrix_puppet_whitelisted) and
(not self.needs_admin or is_admin) and
(not self.needs_auth or is_logged_in))
async def __call__(self, evt: CommandEvent) -> Dict:
"""Executes the command if evt was issued with proper rights.
Args:
evt: The CommandEvent for which to check permissions.
Returns:
The result of the command or the error message function.
Raises:
FloodWaitError
"""
error = await self.get_permission_error(evt)
if error is not None:
return await evt.reply(error)
return await self._handler(evt)
@property
def has_help(self) -> bool:
"""Returns true if this command has a help text."""
return bool(self.help_section) and bool(self._help_text)
@property
def help(self) -> str:
"""Returns the help text to this command."""
return f"**{self.name}** {self._help_args} - {self._help_text}"
def command_handler(_func: Optional[Callable[[CommandEvent], Awaitable[Dict]]] = None, *,
needs_auth: bool = True, needs_puppeting: bool = True,
needs_matrix_puppeting: bool = False, needs_admin: bool = False,
management_only: bool = False, name: Optional[str] = None,
help_text: str = "", help_args: str = "", help_section: HelpSection = None
) -> Callable[[Callable[[CommandEvent], Awaitable[Optional[Dict]]]],
CommandHandler]:
def decorator(func: Callable[[CommandEvent], Awaitable[Optional[Dict]]]) -> CommandHandler:
actual_name = name or func.__name__.replace("_", "-")
handler = CommandHandler(func, needs_auth, needs_puppeting, needs_matrix_puppeting,
needs_admin, management_only, actual_name, help_text, help_args,
help_section)
command_handlers[handler.name] = handler
return handler
return decorator if _func is None else decorator(_func)
class CommandProcessor:
"""Handles the raw commands issued by a user to the Matrix bot."""
log = logging.getLogger("mau.commands")
def __init__(self, context):
self.az, self.db, self.config, self.loop = context
def __init__(self, context: c.Context) -> None:
self.az, self.config, self.loop, self.tgbot = context.core
self.public_website = context.public_website
self.command_prefix = self.config["bridge.command_prefix"]
# region Utility functions for handling commands
async def handle(self, room: MatrixRoomID, event_id: MatrixEventID, sender: u.User,
command: str, args: List[str], is_management: bool, is_portal: bool
) -> Optional[Dict]:
"""Handles the raw commands issued by a user to the Matrix bot.
async def handle(self, room, sender, command, args, is_management, is_portal):
evt = CommandEvent(self, room, sender, command, args,
is_management, is_portal)
If the command is not known, it might be a followup command and is
delegated to a command handler registered for that purpose in the
senders command_status as "next".
Args:
room: ID of the Matrix room in which the command was issued.
event_id: ID of the event by which the command was issued.
sender: The sender who issued the command.
command: The issued command, case insensitive.
args: Arguments given with the command.
is_management: Whether the room is a management room.
is_portal: Whether the room is a portal.
Returns:
The result of the error message function or None if no error
occured. Unknown and delegated commands do not count as errors.
"""
if not command_handlers or "unknown-command" not in command_handlers:
raise ValueError("command_handlers are not properly initialized.")
evt = CommandEvent(self, room, event_id, sender, command, args, is_management, is_portal)
orig_command = command
command = command.lower()
try:
command = command_handlers[command]
handler = command_handlers[command]
except KeyError:
if sender.command_status and "next" in sender.command_status:
args.insert(0, command)
args.insert(0, orig_command)
evt.command = ""
command = sender.command_status["next"]
handler = sender.command_status["next"]
else:
command = command_handlers["unknown-command"]
handler = command_handlers["unknown-command"]
try:
await command(evt)
await handler(evt)
except FloodWaitError as e:
return evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
except Exception:
self.log.exception(f"Fatal error handling command "
+ f"{evt.command} {' '.join(args)} from {sender.mxid}")
return evt.reply("Fatal error while handling command. Check logs for more details.")
self.log.exception("Unhandled error while handling command "
f"{evt.command} {' '.join(args)} from {sender.mxid}")
if evt.sender.is_admin and evt.is_management:
return await evt.reply("Unhandled error while handling command:\n\n"
"```traceback\n"
f"{traceback.format_exc()}"
"```")
return await evt.reply("Unhandled error while handling command. "
"Check logs for more details.")
return None
+101
View File
@@ -0,0 +1,101 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional
from . import command_handler, CommandEvent, SECTION_AUTH
from .. import puppet as pu
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Revert your Telegram account's Matrix puppet to use the default Matrix "
"account.")
async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
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"]
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) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid)
if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.")
resp = await puppet.init_custom_mxid()
if resp == pu.PuppetError.InvalidAccessToken:
return await evt.reply("Your access token is invalid.")
elif resp == pu.PuppetError.Success:
return await evt.reply("Your Matrix login is working.")
return await evt.reply(f"Unknown response while checking your Matrix login: {resp}.")
async def enter_matrix_token(evt: CommandEvent) -> Dict:
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}.")
+47 -51
View File
@@ -1,76 +1,72 @@
# -*- 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from . import command_handler
# 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()
def cancel(evt):
@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 evt.reply(f"{action} cancelled.")
return await evt.reply(f"{action} cancelled.")
else:
return evt.reply("No ongoing command.")
return await evt.reply("No ongoing command.")
@command_handler()
def unknown_command(evt):
return evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
@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.")
@command_handler()
def help(evt):
help_cache = {} # type: Dict[Tuple[bool, bool, bool, bool, bool], str]
async def _get_help_text(evt: CommandEvent) -> str:
cache_key = (evt.is_management, evt.sender.puppet_whitelisted,
evt.sender.matrix_puppet_whitelisted, evt.sender.is_admin,
await evt.sender.is_logged_in())
if cache_key not in help_cache:
help_sections = {} # type: Dict[HelpSection, List[str]]
for handler in _command_handlers.values():
if handler.has_help and handler.has_permission(*cache_key):
help_sections.setdefault(handler.help_section, [])
help_sections[handler.help_section].append(handler.help + " ")
help_sorted = sorted(help_sections.items(), key=lambda item: item[0].order)
helps = ["#### {}\n{}\n".format(key.name, "\n".join(value)) for key, value in help_sorted]
help_cache[cache_key] = "\n".join(helps)
return help_cache[cache_key]
def _get_management_status(evt: CommandEvent) -> str:
if evt.is_management:
management_status = ("This is a management room: prefixing commands "
"with `$cmdprefix` is not required.\n")
return "This is a management room: prefixing commands with `$cmdprefix` is not required."
elif evt.is_portal:
management_status = ("**This is a portal room**: you must always "
"prefix commands with `$cmdprefix`.\n"
"Management commands will not be sent to Telegram.")
else:
management_status = ("**This is not a management room**: you must "
"prefix commands with `$cmdprefix`.\n")
help = """\n
#### Generic bridge commands
**help** - Show this help message.
**cancel** - Cancel an ongoing action (such as login).
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`."
#### Authentication
**login** <_phone_> - Request an authentication code.
**logout** - Log out from Telegram.
**ping** - Check if you're logged into Telegram.
#### Initiating chats
**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users.
**pm** <_identifier_> - Open a private chat with the given Telegram user. The
identifier is either the internal user ID, the username or
the phone number.
**join** <_link_> - Join a chat with an invite link.
**create** [_type_] - Create a Telegram chat of the given type for the current
Matrix room. The type is either `group`, `supergroup` or
`channel` (defaults to `group`).
#### Portal management
**upgrade** - Upgrade a normal Telegram group to a supergroup.
**invite-link** - Get a Telegram invite link to the current chat.
**delete-portal** - Forget the current portal room. Only works for group chats; to delete
a private chat portal, simply leave the room.
**group-name** <_name_|`-`> - Change the username of a supergroup/channel. To disable, use a dash
(`-`) as the name.
**clean-rooms** - Clean up unused portal/management rooms.
"""
return evt.reply(management_status + help)
@command_handler(name="help", needs_auth=False, needs_puppeting=False,
help_section=SECTION_GENERAL,
help_text="Show this help message.")
async def help_cmd(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply(_get_management_status(evt) + "\n" + await _get_help_text(evt))
@@ -0,0 +1 @@
from . import admin, bridge, config, create_chat, filter, misc, unbridge
+101
View File
@@ -0,0 +1,101 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
import asyncio
from mautrix_appservice import MatrixRequestError
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) -> Dict:
try:
level = int(evt.args[0])
except KeyError:
return await evt.reply("**Usage:** `$cmdprefix+sp set-pl <level> [mxid]`")
except ValueError:
return await evt.reply("The level must be an integer.")
levels = await evt.az.intent.get_power_levels(evt.room_id)
mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid
levels["users"][mxid] = level
try:
await evt.az.intent.set_power_levels(evt.room_id, levels)
except MatrixRequestError:
evt.log.exception("Failed to set power level.")
return await evt.reply("Failed to set power level.")
return {}
@command_handler(needs_admin=True, needs_auth=False,
help_section=SECTION_ADMIN,
help_args="<`portal`|`puppet`|`user`>",
help_text="Clear internal bridge caches")
async def clear_db_cache(evt: CommandEvent) -> Dict:
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.init_custom_mxid() 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) -> Dict:
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.init_custom_mxid()
await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
+187
View File
@@ -0,0 +1,187 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional, Tuple, Coroutine
import asyncio
from telethon.tl.types import ChatForbidden, ChannelForbidden
from ...types import MatrixRoomID, TelegramID
from ...util import ignore_coro
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_CREATING_PORTALS,
help_args="[_id_]",
help_text="Bridge the current Matrix room to the Telegram chat with the given "
"ID. The ID must be the prefixed version that you get with the `/id` "
"command of the Telegram-side bot.")
async def bridge(evt: CommandEvent) -> Dict:
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 = 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,
"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[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:
ignore_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()
ignore_coro(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.")
+132
View File
@@ -0,0 +1,132 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Awaitable
from io import StringIO
from ...config import yaml
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[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({
"bridge_notices": {
"default": evt.config["bridge.bridge_notices.default"],
"exceptions": evt.config["bridge.bridge_notices.exceptions"],
},
"bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"],
"inline_images": evt.config["bridge.inline_images"],
"message_formats": evt.config["bridge.message_formats"],
"state_event_formats": evt.config["bridge.state_event_formats"],
"telegram_link_preview": evt.config["bridge.telegram_link_preview"],
}, stream)
return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```")
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[Dict]:
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}`")
@@ -0,0 +1,61 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
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) -> 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=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 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional
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) -> 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(name="filter", needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
help_text="Allow or disallow bridging a specific chat.")
async def edit_filter(evt: CommandEvent) -> Optional[Dict]:
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}.")
return None
+127
View File
@@ -0,0 +1,127 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
UsernameNotModifiedError, UsernameOccupiedError)
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) -> Dict:
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(name="id", needs_admin=False, needs_puppeting=False, needs_auth=False,
help_section=SECTION_MISC,
help_text="Get the ID of the Telegram chat where this room is bridged.")
async def get_id(evt: CommandEvent) -> Dict:
portal = po.Portal.get_by_mxid(evt.room_id)
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) -> 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.")
@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: -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) -> 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")
@@ -0,0 +1,97 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Callable, Optional
from ...types import MatrixRoomID
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 = 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`")
+56
View File
@@ -0,0 +1,56 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Tuple
from mautrix_appservice import MatrixRequestError, IntentAPI
from ... import user as u
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
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)
-261
View File
@@ -1,261 +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 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from telethon.errors import *
from telethon.tl.types import User as TLUser
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
@command_handler()
async def search(evt):
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 = []
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()
async def pm(evt):
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
user = await evt.sender.client.get_entity(evt.args[0])
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(
f"Created private chat room with {pu.Puppet.get_displayname(user, False)}")
@command_handler()
async def invite_link(evt):
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(needs_admin=True)
async def delete_portal(evt):
room_id = 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"
return await evt.reply(f"{that_this} is not a portal room.")
async def post_confirm(confirm):
evt.sender.command_status = None
if len(confirm.args) > 0 and confirm.args[0] == "confirm-delete":
await portal.cleanup_and_delete()
if confirm.room_id != room_id:
return await confirm.reply("Portal successfully deleted.")
else:
return await confirm.reply("Portal deletion cancelled.")
evt.sender.command_status = {
"next": post_confirm,
"action": "Portal deletion",
}
return await evt.reply("Please confirm deletion of portal "
+ f"[{room_id}](https://matrix.to/#/{room_id}) "
+ f"to Telegram chat \"{portal.title}\" "
+ "by typing `$cmdprefix+sp confirm-delete`")
@command_handler()
async def join(evt):
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.")
arg = arg.group(1)
if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):]
try:
await evt.sender.client(CheckChatInviteRequest(invite_hash))
except InviteHashInvalidError:
return await evt.reply("Invalid invite link.")
except InviteHashExpiredError:
return await evt.reply("Invite link expired.")
try:
updates = evt.sender.client(ImportChatInviteRequest(invite_hash))
except UserAlreadyParticipantError:
return await evt.reply("You are already in that chat.")
else:
channel = await evt.sender.client.get_entity(arg)
if not channel:
return await evt.reply("Channel/supergroup not found.")
updates = await evt.sender.client(JoinChannelRequest(channel))
for chat in updates.chats:
portal = po.Portal.get_by_entity(chat)
if portal.mxid:
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
return await evt.reply(f"Created room for {portal.title}")
else:
await portal.invite_matrix([evt.sender.mxid])
return await evt.reply(f"Invited you to portal of {portal.title}")
@command_handler()
async def create(evt):
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.")
state = await evt.az.intent.get_room_state(evt.room_id)
title = None
about = None
levels = None
for event in state:
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"]
if not title:
return await evt.reply("Please set a title before creating a Telegram chat.")
elif (not levels or not levels["users"] or evt.az.intent.mxid not in levels["users"] or
levels["users"][evt.az.intent.mxid] < 100):
return await evt.reply(f"Please give "
+ f"[the bridge bot](https://matrix.to/#/{evt.az.intent.mxid})"
+ f" a power level of 100 before creating a Telegram chat.")
else:
for user, level in levels["users"].items():
if level >= 100 and user != evt.az.intent.mxid:
return await evt.reply(
f"Please make sure only the bridge bot has power level above"
+ f"99 before creating a Telegram chat.\n\n"
+ f"Use power level 95 instead of 100 for admins.")
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:
return await evt.reply(e.args[0])
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
@command_handler()
async def upgrade(evt):
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()
async def group_name(evt):
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 @@
from . import account, auth, misc
@@ -0,0 +1,124 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional
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 .. 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) -> Optional[Dict]:
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) -> Optional[Dict]:
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()
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) -> Optional[Dict]:
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]`")
+271
View File
@@ -0,0 +1,271 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Any, Dict, Optional
import asyncio
from telethon.errors import (
AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError,
PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError,
PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError,
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError)
from ... import puppet as pu, user as u
from ...commands import command_handler, CommandEvent, SECTION_AUTH
from ...util import format_duration, ignore_coro
@command_handler(needs_auth=False,
help_section=SECTION_AUTH,
help_text="Check if you're logged into Telegram.")
async def ping(evt: CommandEvent) -> Optional[Dict]:
me = await evt.sender.client.get_me() if await evt.sender.is_logged_in() else None
if me:
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
return await evt.reply(f"You're logged in as {human_tg_id}")
else:
return await evt.reply("You're not logged in.")
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_AUTH,
help_text="Get the info of the message relay Telegram bot.")
async def ping_bot(evt: CommandEvent) -> Optional[Dict]:
if not evt.tgbot:
return await evt.reply("Telegram message relay bot not configured.")
info, mxid = await evt.tgbot.get_me(use_cache=False)
return await evt.reply("Telegram message relay bot is active: "
f"[{info.first_name}](https://matrix.to/#/{mxid}) (ID {info.id})\n\n"
"To use the bot, simply invite it to a portal room.")
@command_handler(needs_auth=False, management_only=True,
help_section=SECTION_AUTH,
help_args="<_phone_> <_full name_>",
help_text="Register to Telegram")
async def register(evt: CommandEvent) -> Optional[Dict]:
if await evt.sender.is_logged_in():
return await evt.reply("You are already logged in.")
elif len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp register <phone> <full name>`")
phone_number = evt.args[0]
if len(evt.args) == 2:
full_name = evt.args[1], ""
else:
full_name = " ".join(evt.args[1:-1]), evt.args[-1]
await _request_code(evt, phone_number, {
"next": enter_code_register,
"action": "Register",
"full_name": full_name,
})
return None
async def enter_code_register(evt: CommandEvent) -> Dict:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
try:
await evt.sender.ensure_started(even_if_no_session=True)
first_name, last_name = evt.sender.command_status["full_name"]
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name)
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
evt.sender.command_status = None
return await evt.reply(f"Successfully registered to Telegram.")
except PhoneNumberOccupiedError:
return await evt.reply("That phone number has already been registered. "
"You can log in with `$cmdprefix+sp login`.")
except FirstNameInvalidError:
return await evt.reply("Invalid name. Please set a Matrix displayname before registering.")
except PhoneCodeExpiredError:
return await evt.reply(
"Phone code expired. Try again with `$cmdprefix+sp register <phone>`.")
except PhoneCodeInvalidError:
return await evt.reply("Invalid phone code.")
except Exception:
evt.log.exception("Error sending phone code")
return await evt.reply("Unhandled exception while sending code. "
"Check console for more details.")
@command_handler(needs_auth=False, management_only=True,
help_section=SECTION_AUTH,
help_text="Get instructions on how to log in.")
async def login(evt: CommandEvent) -> Optional[Dict]:
override_sender = False
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 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 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:
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:
ok = False
try:
await evt.sender.ensure_started(even_if_no_session=True)
await evt.sender.client.sign_in(phone_number)
ok = True
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
except PhoneNumberAppSignupForbiddenError:
return await evt.reply(
"Your phone number does not allow 3rd party apps to sign in.")
except PhoneNumberFloodError:
return await evt.reply(
"Your phone number has been temporarily blocked for flooding. "
"The ban is usually applied for around a day.")
except FloodWaitError as e:
return await evt.reply(
"Your phone number has been temporarily blocked for flooding. "
f"Please wait for {format_duration(e.seconds)} before trying again.")
except PhoneNumberBannedError:
return await evt.reply("Your phone number has been banned from Telegram.")
except PhoneNumberUnoccupiedError:
return await evt.reply("That phone number has not been registered. "
"Please register with `$cmdprefix+sp register <phone>`.")
except Exception:
evt.log.exception("Error requesting phone code")
return await evt.reply("Unhandled exception while requesting code. "
"Check console for more details.")
finally:
evt.sender.command_status = next_status if ok else None
@command_handler(needs_auth=False)
async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`")
elif not evt.config.get("bridge.allow_matrix_login", True):
return await evt.reply("This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions")
# 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])
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], {
"next": enter_code,
"action": "Login",
})
return None
@command_handler(needs_auth=False)
async def enter_code(evt: CommandEvent) -> Optional[Dict]:
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])
except Exception:
evt.log.exception("Error sending phone code")
return await evt.reply("Unhandled exception while sending code. "
"Check console for more details.")
return None
@command_handler(needs_auth=False)
async def enter_password(evt: CommandEvent) -> Optional[Dict]:
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))
except AccessTokenInvalidError:
return await evt.reply("That bot token is not valid.")
except AccessTokenExpiredError:
return await evt.reply("That bot token has expired.")
except Exception:
evt.log.exception("Error sending password")
return await evt.reply("Unhandled exception while sending password. "
"Check console for more details.")
return None
async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
try:
await evt.sender.ensure_started(even_if_no_session=True)
user = await evt.sender.client.sign_in(**sign_in_info)
existing_user = u.User.get_by_tgid(user.id)
if existing_user and existing_user != evt.sender:
await existing_user.log_out()
await evt.reply(f"[{existing_user.displayname}]"
f"(https://matrix.to/#/{existing_user.mxid})"
" was logged out from the account.")
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
evt.sender.command_status = None
name = f"@{user.username}" if user.username else f"+{user.phone}"
return await evt.reply(f"Successfully logged in as {name}")
except PhoneCodeExpiredError:
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
except PhoneCodeInvalidError:
return await evt.reply("Invalid phone code.")
except PasswordHashInvalidError:
return await evt.reply("Incorrect password.")
except SessionPasswordNeededError:
evt.sender.command_status = {
"next": enter_password,
"action": "Login (password entry)",
}
return await evt.reply("Your account has two-factor authentication. "
"Please send your password here.")
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
help_text="Log out from Telegram.")
async def logout(evt: CommandEvent) -> Optional[Dict]:
if await evt.sender.log_out():
return await evt.reply("Logged out successfully.")
return await evt.reply("Failed to log out.")
+283
View File
@@ -0,0 +1,283 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple
import codecs
import base64
import re
from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError,
UserAlreadyParticipantError)
from telethon.tl.patched import Message
from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll,
TypePeer)
from telethon.tl.types.messages import BotCallbackAnswer
from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatInviteRequest,
GetBotCallbackAnswerRequest, SendVoteRequest)
from telethon.tl.functions.channels import JoinChannelRequest
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(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(help_section=SECTION_CREATING_PORTALS,
help_args="<_identifier_>",
help_text="Open a private chat with the given Telegram user. The identifier is "
"either the internal user ID, the username or the phone number. "
"**N.B.** The phone numbers you start chats with must already be in "
"your contacts.")
async def pm(evt: CommandEvent) -> Optional[Dict]:
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.")
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[TypePeer, 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, msg
@command_handler(help_section=SECTION_MISC,
help_args="<_play ID_>",
help_text="Play a Telegram game.")
async def play(evt: CommandEvent) -> Optional[Dict]:
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")
await evt.reply(f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
f"{msg.media.game.description}")
@command_handler(help_section=SECTION_MISC,
help_args="<_poll ID_> <_choice number_>",
help_text="Vote in a Telegram poll.")
async def vote(evt: CommandEvent) -> Optional[Dict]:
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()
+275 -36
View File
@@ -1,77 +1,136 @@
# -*- 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# 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 ruamel.yaml.comments import CommentedMap
import random
import string
yaml = YAML()
yaml = YAML() # type: YAML
yaml.indent(4)
class DictWithRecursion:
def __init__(self, data=None):
self._data = data or {}
def __init__(self, data: Optional[CommentedMap] = None) -> None:
self._data = data or CommentedMap() # type: CommentedMap
def _recursive_get(self, data, key, default_value):
if '.' in key:
key, next_key = key.split('.', 1)
next_data = data.get(key, {})
@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, default_value, allow_recursion=True):
def get(self, key: str, default_value: Any, allow_recursion: bool = True) -> Any:
if allow_recursion and '.' in key:
return self._recursive_get(self._data, key, default_value)
return self._data.get(key, default_value)
def __getitem__(self, key):
def __getitem__(self, key: str) -> Any:
return self.get(key, None)
def _recursive_set(self, data, key, value):
if '.' in key:
key, next_key = key.split('.', 1)
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] = {}
next_data = data.get(key, {})
self._recursive_set(next_data, next_key, value)
return
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, value, allow_recursion=True):
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, value):
def __setitem__(self, key: str, value: Any) -> None:
self.set(key, value)
def _recursive_del(self, data: CommentedMap, key: str) -> None:
key, next_key = self._parse_key(key)
if next_key is not None:
if key not in data:
return
next_data = data[key]
return self._recursive_del(next_data, next_key)
try:
del data[key]
del data.ca.items[key]
except KeyError:
pass
def delete(self, key: str, allow_recursion: bool = True) -> None:
if allow_recursion and '.' in key:
self._recursive_del(self._data, key)
return
try:
del self._data[key]
del self._data.ca.items[key]
except KeyError:
pass
def __delitem__(self, key: str) -> None:
self.delete(key)
class Config(DictWithRecursion):
def __init__(self, path, registration_path):
def __init__(self, path: str, registration_path: str, base_path: str,
overrides: Dict[str, Any] = None) -> None:
super().__init__()
self.path = path
self.registration_path = registration_path
self._registration = None
self.path = path # type: str
self.registration_path = registration_path # type: str
self.base_path = base_path # type: str
self._registration = None # type: Optional[Dict]
self._overrides = overrides or {} # type: Dict[str, Any]
def load(self):
def __getitem__(self, key: str) -> Any:
try:
return self._overrides[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
except KeyError:
return super().__getitem__(key)
def load(self) -> None:
with open(self.path, 'r') as stream:
self._data = yaml.load(stream)
def save(self):
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:
@@ -79,10 +138,192 @@ class Config(DictWithRecursion):
yaml.dump(self._registration, stream)
@staticmethod
def _new_token():
return "".join(random.choices(string.ascii_lowercase + string.digits, k=64))
def _new_token() -> str:
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
def generate_registration(self):
def update(self) -> None:
base = self.load_base()
if not base:
return
def copy(from_path, to_path=None) -> None:
if from_path in self:
base[to_path or from_path] = self[from_path]
def copy_dict(from_path, to_path=None, override_existing_map=True) -> None:
if from_path in self:
to_path = to_path or from_path
if override_existing_map or to_path not in base:
base[to_path] = CommentedMap()
for key, value in self[from_path].items():
base[to_path][key] = value
copy("homeserver.address")
copy("homeserver.domain")
copy("homeserver.verify_ssl")
if "appservice.protocol" in self and "appservice.address" not in self:
protocol, hostname, port = (self["appservice.protocol"], self["appservice.hostname"],
self["appservice.port"])
base["appservice.address"] = f"{protocol}://{hostname}:{port}"
else:
copy("appservice.address")
copy("appservice.hostname")
copy("appservice.port")
copy("appservice.max_body_size")
copy("appservice.database")
copy("appservice.public.enabled")
copy("appservice.public.prefix")
copy("appservice.public.external")
copy("appservice.provisioning.enabled")
copy("appservice.provisioning.prefix")
copy("appservice.provisioning.shared_secret")
if base["appservice.provisioning.shared_secret"] == "generate":
base["appservice.provisioning.shared_secret"] = self._new_token()
copy("appservice.id")
copy("appservice.bot_username")
copy("appservice.bot_displayname")
copy("appservice.bot_avatar")
copy("appservice.as_token")
copy("appservice.hs_token")
copy("metrics.enabled")
copy("metrics.listen_port")
copy("bridge.username_template")
copy("bridge.alias_template")
copy("bridge.displayname_template")
copy("bridge.displayname_preference")
copy("bridge.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.max_telegram_delete")
copy("bridge.sync_matrix_state")
copy("bridge.allow_matrix_login")
copy("bridge.plaintext_highlights")
copy("bridge.public_portals")
copy("bridge.catch_up")
copy("bridge.sync_with_custom_puppets")
copy("bridge.telegram_link_preview")
copy("bridge.inline_images")
copy("bridge.image_as_file_size")
copy("bridge.max_document_size")
copy("bridge.bot_messages_as_notices")
if isinstance(self["bridge.bridge_notices"], bool):
base["bridge.bridge_notices"] = {
"default": self["bridge.bridge_notices"],
"exceptions": ["@importantbot:example.com"],
}
else:
copy("bridge.bridge_notices")
copy("bridge.deduplication.pre_db_check")
copy("bridge.deduplication.cache_queue_length")
if "bridge.message_formats.m_text" in self:
del self["bridge.message_formats"]
copy_dict("bridge.message_formats", override_existing_map=False)
copy("bridge.state_event_formats.join")
copy("bridge.state_event_formats.leave")
copy("bridge.state_event_formats.name_change")
copy("bridge.filter.mode")
copy("bridge.filter.list")
copy("bridge.command_prefix")
migrate_permissions = ("bridge.permissions" not in self
or "bridge.whitelist" in self
or "bridge.admins" in self)
if migrate_permissions:
permissions = self["bridge.permissions"] or CommentedMap()
for entry in self["bridge.whitelist"] or []:
permissions[entry] = "full"
for entry in self["bridge.admins"] or []:
permissions[entry] = "admin"
base["bridge.permissions"] = permissions
else:
copy_dict("bridge.permissions")
if "bridge.relaybot" not in self:
copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals")
else:
copy("bridge.relaybot.authless_portals")
copy("bridge.relaybot.whitelist_group_admins")
copy("bridge.relaybot.whitelist")
copy("bridge.relaybot.ignore_own_incoming_events")
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")
copy("telegram.proxy.rdns")
copy("telegram.proxy.username")
copy("telegram.proxy.password")
if "appservice.debug" in self and "logging" not in self:
level = "DEBUG" if self["appservice.debug"] else "INFO"
base["logging.root.level"] = level
base["logging.loggers.mau.level"] = level
base["logging.loggers.telethon.level"] = level
else:
copy("logging")
self._data = base._data
self.save()
def _get_permissions(self, key: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
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
def get_permissions(self, mxid: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
permissions = self["bridge.permissions"] or {}
if mxid in permissions:
return self._get_permissions(mxid)
homeserver = mxid[mxid.index(":") + 1:]
if homeserver in permissions:
return self._get_permissions(homeserver)
return self._get_permissions("*")
def generate_registration(self) -> None:
homeserver = self["homeserver.domain"]
username_format = self.get("bridge.username_template", "telegram_{userid}") \
@@ -93,10 +334,8 @@ class Config(DictWithRecursion):
self.set("appservice.as_token", self._new_token())
self.set("appservice.hs_token", self._new_token())
url = (f"{self['appservice.protocol']}://"
+ f"{self['appservice.hostname']}:{self['appservice.port']}")
self._registration = {
"id": self.get("appservice.id", "telegram"),
"id": self["appservice.id"] or "telegram",
"as_token": self["appservice.as_token"],
"hs_token": self["appservice.hs_token"],
"namespaces": {
@@ -109,7 +348,7 @@ class Config(DictWithRecursion):
"regex": f"#{alias_format}:{homeserver}"
}]
},
"url": url,
"url": self["appservice.address"],
"sender_localpart": self["appservice.bot_username"],
"rate_limited": False
}
+45
View File
@@ -0,0 +1,45 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
import asyncio
from alchemysession import AlchemySessionContainer
from mautrix_appservice import AppService
from .web import PublicBridgeWebsite, ProvisioningAPI
from .config import Config
from .bot import Bot
from .matrix import MatrixHandler
class Context:
def __init__(self, az: 'AppService', config: 'Config', loop: 'asyncio.AbstractEventLoop',
session_container: 'AlchemySessionContainer', bot: Optional['Bot']) -> None:
self.az = az # type: AppService
self.config = config # type: Config
self.loop = loop # type: asyncio.AbstractEventLoop
self.bot = bot # type: Optional[Bot]
self.mx = None # type: Optional[MatrixHandler]
self.session_container = session_container # type: AlchemySessionContainer
self.public_website = None # type: Optional[PublicBridgeWebsite]
self.provisioning_api = None # type: Optional[ProvisioningAPI]
@property
def core(self) -> Tuple['AppService', 'Config', 'asyncio.AbstractEventLoop', Optional['Bot']]:
return self.az, self.config, self.loop, self.bot
-103
View File
@@ -1,103 +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 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sqlalchemy import Column, UniqueConstraint, ForeignKey, ForeignKeyConstraint, Integer, String
from sqlalchemy.orm import relationship
from .base import Base
class Portal(Base):
query = None
__tablename__ = "portal"
# Telegram chat information
tgid = Column(Integer, primary_key=True)
tg_receiver = Column(Integer, primary_key=True)
peer_type = Column(String)
# Matrix portal information
mxid = Column(String, unique=True, 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):
query = None
__tablename__ = "message"
mxid = Column(String)
mx_room = Column(String)
tgid = Column(Integer, primary_key=True)
tg_space = Column(Integer, primary_key=True)
__table_args__ = (UniqueConstraint('mxid', 'mx_room', 'tg_space', name='_mx_id_room'),)
class UserPortal(Base):
query = None
__tablename__ = "user_portal"
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
portal = Column(Integer, primary_key=True)
portal_receiver = Column(Integer, primary_key=True)
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver")),)
class User(Base):
query = None
__tablename__ = "user"
mxid = Column(String, primary_key=True)
tgid = Column(Integer, nullable=True)
tg_username = Column(String, nullable=True)
saved_contacts = Column(Integer, default=0)
contacts = relationship("Contact", uselist=True,
cascade="save-update, merge, delete, delete-orphan")
portals = relationship("Portal", secondary="user_portal", single_parent=True,
cascade="save-update, merge, delete, delete-orphan")
class Contact(Base):
query = None
__tablename__ = "contact"
user = Column("user", Integer, ForeignKey("user.tgid"), primary_key=True)
contact = Column("contact", Integer, ForeignKey("puppet.id"), primary_key=True)
class Puppet(Base):
query = None
__tablename__ = "puppet"
id = Column(Integer, primary_key=True)
displayname = Column(String, nullable=True)
username = Column(String, nullable=True)
photo_id = Column(String, nullable=True)
def init(db_session):
Portal.query = db_session.query_property()
Message.query = db_session.query_property()
UserPortal.query = db_session.query_property()
User.query = db_session.query_property()
Puppet.query = db_session.query_property()
+33
View File
@@ -0,0 +1,33 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from .base import Base
from .bot_chat import BotChat
from .message import Message
from .portal import Portal
from .puppet import Puppet
from .room_state import RoomState
from .telegram_file import TelegramFile
from .user import User, UserPortal, Contact
from .user_profile import UserProfile
def init(db_engine) -> None:
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
+58
View File
@@ -0,0 +1,58 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from abc import abstractmethod
from sqlalchemy import Table
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.ext.declarative import declarative_base
class BaseBase:
db = None # type: Engine
t = None # type: Table
__table__ = None # type: Table
c = None # type: ImmutableColumnCollection
@classmethod
@abstractmethod
def _one_or_none(cls, rows: RowProxy):
pass
@classmethod
def _select_one_or_none(cls, *args):
return cls._one_or_none(cls.db.execute(cls.t.select().where(*args)))
@property
@abstractmethod
def _edit_identity(self):
pass
def update(self, **values) -> None:
with self.db.begin() as conn:
conn.execute(self.t.update()
.where(self._edit_identity)
.values(**values))
for key, value in values.items():
setattr(self, key, value)
def delete(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.delete().where(self._edit_identity))
Base = declarative_base(cls=BaseBase)
+26
View File
@@ -0,0 +1,26 @@
from abc import abstractmethod
from sqlalchemy import Table
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.ext.declarative import declarative_base
class Base(declarative_base):
db: Engine
t: Table
__table__: Table
c: ImmutableColumnCollection
@classmethod
@abstractmethod
def _one_or_none(cls, rows: RowProxy): ...
@classmethod
def _select_one_or_none(cls, *args): ...
def _edit_identity(self): ...
def update(self, **values) -> None: ...
def delete(self) -> None: ...
+45
View File
@@ -0,0 +1,45 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Iterable
from sqlalchemy import Column, Integer, String
from ..types import TelegramID
from .base import Base
# Fucking Telegram not telling bots what chats they are in 3:<
class BotChat(Base):
__tablename__ = "bot_chat"
id = Column(Integer, primary_key=True) # type: TelegramID
type = Column(String, nullable=False)
@classmethod
def delete(cls, chat_id: TelegramID) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.id == chat_id))
@classmethod
def all(cls) -> Iterable['BotChat']:
rows = cls.db.execute(cls.t.select())
for row in rows:
chat_id, chat_type = row
yield cls(id=chat_id, type=chat_type)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(id=self.id, type=self.type))
+113
View File
@@ -0,0 +1,113 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, desc, select
from sqlalchemy.engine.result import RowProxy
from typing import Optional, List
from ..types import MatrixRoomID, MatrixEventID, TelegramID
from .base import Base
class Message(Base):
__tablename__ = "message"
mxid = Column(String) # type: MatrixEventID
mx_room = Column(String) # type: MatrixRoomID
tgid = Column(Integer, primary_key=True) # type: TelegramID
tg_space = Column(Integer, primary_key=True) # type: TelegramID
edit_index = Column(Integer, primary_key=True) # type: int
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Message']:
try:
mxid, mx_room, tgid, tg_space, edit_index = next(rows)
return cls(mxid=mxid, mx_room=mx_room, tgid=tgid, tg_space=tg_space,
edit_index=edit_index)
except StopIteration:
return None
@staticmethod
def _all(rows: RowProxy) -> List['Message']:
return [Message(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3],
edit_index=row[4])
for row in rows]
@classmethod
def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> List['Message']:
return cls._all(cls.db.execute(cls.t.select().where(and_(cls.c.tgid == tgid,
cls.c.tg_space == tg_space))))
@classmethod
def get_one_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID, edit_index: int = 0
) -> Optional['Message']:
query = cls.t.select()
if edit_index < 0:
query = (query
.where(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space))
.order_by(desc(cls.c.edit_index))
.limit(1)
.offset(-edit_index - 1))
else:
query = query.where(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space,
cls.c.edit_index == edit_index))
return cls._one_or_none(cls.db.execute(query))
@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']:
return cls._select_one_or_none(and_(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: MatrixEventID, s_mx_room: MatrixRoomID, **values) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.update()
.where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room))
.values(**values))
@property
def _edit_identity(self):
return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space,
self.c.edit_index == self.edit_index)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(mxid=self.mxid, mx_room=self.mx_room,
tgid=self.tgid, tg_space=self.tg_space,
edit_index=self.edit_index))
+81
View File
@@ -0,0 +1,81 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String, Boolean, Text, and_
from sqlalchemy.engine.result import RowProxy
from typing import Optional
from ..types import MatrixRoomID, TelegramID
from .base import Base
class Portal(Base):
__tablename__ = "portal"
# Telegram chat information
tgid = Column(Integer, primary_key=True) # type: TelegramID
tg_receiver = Column(Integer, primary_key=True) # type: TelegramID
peer_type = Column(String, nullable=False)
megagroup = Column(Boolean)
# 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)
@classmethod
def scan(cls, row) -> Optional['Portal']:
(tgid, tg_receiver, peer_type, megagroup, mxid, config, username, title, about,
photo_id) = row
return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup,
mxid=mxid, config=config, username=username, title=title, about=about,
photo_id=photo_id)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Portal']:
try:
return cls.scan(next(rows))
except StopIteration:
return None
@classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']:
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver))
@classmethod
def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod
def get_by_username(cls, username: str) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.username == username)
@property
def _edit_identity(self):
return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
megagroup=self.megagroup, mxid=self.mxid, config=self.config,
username=self.username, title=self.title, about=self.about, photo_id=self.photo_id))
+88
View File
@@ -0,0 +1,88 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql import expression
from typing import Optional, Iterable
from ..types import MatrixUserID, MatrixRoomID, TelegramID
from .base import Base
class Puppet(Base):
__tablename__ = "puppet"
id = Column(Integer, primary_key=True) # type: TelegramID
custom_mxid = Column(String, nullable=True) # type: Optional[MatrixUserID]
access_token = Column(String, nullable=True)
displayname = Column(String, nullable=True)
displayname_source = Column(Integer, nullable=True) # type: Optional[TelegramID]
username = Column(String, nullable=True)
photo_id = Column(String, nullable=True)
is_bot = Column(Boolean, nullable=True)
matrix_registered = Column(Boolean, nullable=False, server_default=expression.false())
disable_updates = Column(Boolean, nullable=False, server_default=expression.false())
@classmethod
def scan(cls, row) -> Optional['Puppet']:
(id, custom_mxid, access_token, displayname, displayname_source, username, photo_id,
is_bot, matrix_registered, disable_updates) = row
return cls(id=id, custom_mxid=custom_mxid, access_token=access_token,
displayname=displayname, displayname_source=displayname_source,
username=username, photo_id=photo_id, is_bot=is_bot,
matrix_registered=matrix_registered, disable_updates=disable_updates)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Puppet']:
try:
return cls.scan(next(rows))
except StopIteration:
return None
@classmethod
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
rows = cls.db.execute(cls.t.select().where(cls.c.custom_mxid != None))
for row in rows:
yield cls.scan(row)
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.id == tgid)
@classmethod
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.custom_mxid == mxid)
@classmethod
def get_by_username(cls, username: str) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.username == username)
@classmethod
def get_by_displayname(cls, displayname: str) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.displayname == displayname)
@property
def _edit_identity(self):
return self.c.id == self.id
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
id=self.id, custom_mxid=self.custom_mxid, access_token=self.access_token,
displayname=self.displayname, displayname_source=self.displayname_source,
username=self.username, photo_id=self.photo_id, is_bot=self.is_bot,
matrix_registered=self.matrix_registered, disable_updates=self.disable_updates))
+62
View File
@@ -0,0 +1,62 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, String, Text
from typing import Dict, Optional
import json
from ..types import MatrixRoomID
from .base import Base
class RoomState(Base):
__tablename__ = "mx_room_state"
room_id = Column(String, primary_key=True) # type: MatrixRoomID
power_levels = Column("power_levels", Text, nullable=True) # type: Optional[Dict]
@property
def _power_levels_text(self) -> Optional[str]:
return json.dumps(self.power_levels) if self.power_levels else None
@property
def has_power_levels(self) -> bool:
return bool(self.power_levels)
@classmethod
def get(cls, room_id: MatrixRoomID) -> Optional['RoomState']:
rows = cls.db.execute(cls.t.select().where(cls.c.room_id == room_id))
try:
room_id, power_levels_text = next(rows)
return cls(room_id=room_id, power_levels=(json.loads(power_levels_text)
if power_levels_text else None))
except StopIteration:
return None
def update(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.update()
.where(self.c.room_id == self.room_id)
.values(power_levels=self._power_levels_text))
@property
def _edit_identity(self):
return self.c.room_id == self.room_id
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(room_id=self.room_id,
power_levels=self._power_levels_text))
+56
View File
@@ -0,0 +1,56 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
from typing import Optional
from .base import Base
class TelegramFile(Base):
__tablename__ = "telegram_file"
id = Column(String, primary_key=True)
mxc = Column(String)
mime_type = Column(String)
was_converted = Column(Boolean)
timestamp = Column(BigInteger)
size = Column(Integer, nullable=True)
width = Column(Integer, nullable=True)
height = Column(Integer, nullable=True)
thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
thumbnail = None # type: Optional[TelegramFile]
@classmethod
def get(cls, loc_id: str) -> Optional['TelegramFile']:
rows = cls.db.execute(cls.t.select().where(cls.c.id == loc_id))
try:
loc_id, mxc, mime, conv, ts, s, w, h, thumb_id = next(rows)
thumb = None
if thumb_id:
thumb = cls.get(thumb_id)
return cls(id=loc_id, mxc=mxc, mime_type=mime, was_converted=conv, timestamp=ts,
size=s, width=w, height=h, thumbnail_id=thumb_id, thumbnail=thumb)
except StopIteration:
return None
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))
+130
View File
@@ -0,0 +1,130 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
from sqlalchemy.engine.result import RowProxy
from typing import Optional, Iterable, Tuple
from ..types import MatrixUserID, TelegramID
from .base import Base
class User(Base):
__tablename__ = "user"
mxid = Column(String, primary_key=True) # type: MatrixUserID
tgid = Column(Integer, nullable=True, unique=True) # type: Optional[TelegramID]
tg_username = Column(String, nullable=True)
tg_phone = Column(String, nullable=True)
saved_contacts = Column(Integer, default=0, nullable=False)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['User']:
try:
mxid, tgid, tg_username, tg_phone, saved_contacts = next(rows)
return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
saved_contacts=saved_contacts)
except StopIteration:
return None
@classmethod
def all(cls) -> Iterable['User']:
rows = cls.db.execute(cls.t.select())
for row in rows:
mxid, tgid, tg_username, tg_phone, saved_contacts = row
yield cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
saved_contacts=saved_contacts)
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
return cls._select_one_or_none(cls.c.tgid == tgid)
@classmethod
def get_by_mxid(cls, mxid: MatrixUserID) -> Optional['User']:
return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod
def get_by_username(cls, username: str) -> Optional['User']:
return cls._select_one_or_none(cls.c.tg_username == username)
@property
def _edit_identity(self):
return self.c.mxid == self.mxid
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
mxid=self.mxid, tgid=self.tgid, tg_username=self.tg_username,
tg_phone=self.tg_phone, saved_contacts=self.saved_contacts))
@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 = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"),
primary_key=True) # type: TelegramID
portal = Column(Integer, primary_key=True) # type: TelegramID
portal_receiver = Column(Integer, primary_key=True) # type: TelegramID
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"),
onupdate="CASCADE", ondelete="CASCADE"),)
class Contact(Base):
__tablename__ = "contact"
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) # type: TelegramID
contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True) # type: TelegramID
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, String, and_
from typing import Dict, Optional
from ..types import MatrixUserID, MatrixRoomID
from .base import Base
class UserProfile(Base):
__tablename__ = "mx_user_profile"
room_id = Column(String, primary_key=True) # type: MatrixRoomID
user_id = Column(String, primary_key=True) # type: MatrixUserID
membership = Column(String, nullable=False, default="leave")
displayname = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
def dict(self) -> Dict[str, str]:
return {
"membership": self.membership,
"displayname": self.displayname,
"avatar_url": self.avatar_url,
}
@classmethod
def get(cls, room_id: MatrixRoomID, user_id: MatrixUserID) -> Optional['UserProfile']:
rows = cls.db.execute(
cls.t.select().where(and_(cls.c.room_id == room_id, cls.c.user_id == user_id)))
try:
room_id, user_id, membership, displayname, avatar_url = next(rows)
return cls(room_id=room_id, user_id=user_id, membership=membership,
displayname=displayname, avatar_url=avatar_url)
except StopIteration:
return None
@classmethod
def delete_all(cls, room_id: MatrixRoomID) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.room_id == room_id))
def update(self) -> None:
super().update(membership=self.membership, displayname=self.displayname,
avatar_url=self.avatar_url)
@property
def _edit_identity(self):
return and_(self.c.room_id == self.room_id, self.c.user_id == self.user_id)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(room_id=self.room_id, user_id=self.user_id,
membership=self.membership,
displayname=self.displayname,
avatar_url=self.avatar_url))
-380
View File
@@ -1,380 +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 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from html import escape, unescape
from html.parser import HTMLParser
from collections import deque
import re
import logging
from mautrix_appservice import MatrixRequestError
from telethon.tl.types import *
from . import user as u, puppet as p
from .db import Message as DBMessage
log = logging.getLogger("mau.formatter")
# TEXT LEN EXPLANATION:
# Telegram formatting counts two bytes in an UTF-16 string as one character.
#
# For Telegram -> Matrix formatting, we get the same counting mechanism by encoding the input
# text as UTF-16 Little Endian and doubling all the offsets and lengths given by Telegram. With
# those doubled values, we process the input entities and text. The text is converted back to
# native str format before it's inserted into the output HTML.
#
# For Matrix -> Telegram formatting, do the same input encoding, but divide the length by two
# instead of multiplying when generating the lengths and offsets of Telegram entities.
#
# The endianness doesn't matter, but it has to be specified to avoid the two BOM bits messing
# everything up.
TEMP_ENC = "utf-16-le"
# region Matrix to Telegram
class MatrixParser(HTMLParser):
mention_regex = re.compile("https://matrix.to/#/(@.+)")
def __init__(self):
super().__init__()
self.text = ""
self.entities = []
self._building_entities = {}
self._list_counter = 0
self._open_tags = deque()
self._open_tags_meta = deque()
self._previous_ended_line = True
def handle_starttag(self, tag, attrs):
self._open_tags.appendleft(tag)
self._open_tags_meta.appendleft(0)
attrs = dict(attrs)
entity_type = None
args = {}
if tag == "strong" or tag == "b":
entity_type = MessageEntityBold
elif tag == "em" or tag == "i":
entity_type = MessageEntityItalic
elif tag == "code":
try:
pre = self._building_entities["pre"]
try:
pre.language = attrs["class"][len("language-"):]
except KeyError:
pass
except KeyError:
entity_type = MessageEntityCode
elif tag == "pre":
entity_type = MessageEntityPre
args["language"] = ""
elif tag == "a":
try:
url = attrs["href"]
except KeyError:
return
mention = self.mention_regex.search(url)
if mention:
mxid = mention.group(1)
user = p.Puppet.get_by_mxid(mxid, create=False)
if not user:
user = u.User.get_by_mxid(mxid, create=False)
if not user:
return
if user.username:
entity_type = MessageEntityMention
url = f"@{user.username}"
else:
entity_type = MessageEntityMentionName
args["user_id"] = user.tgid
elif url.startswith("mailto:"):
url = url[len("mailto:"):]
entity_type = MessageEntityEmail
else:
if self.get_starttag_text() == url:
entity_type = MessageEntityUrl
else:
entity_type = MessageEntityTextUrl
args["url"] = url
url = None
self._open_tags_meta.popleft()
self._open_tags_meta.appendleft(url)
if entity_type and tag not in self._building_entities:
# See "TEXT LEN EXPLANATION" near start of file
offset = int(len(self.text.encode(TEMP_ENC)) / 2)
self._building_entities[tag] = entity_type(offset=offset, length=0, **args)
def _list_depth(self):
depth = 0
for tag in self._open_tags:
if tag == "ol" or tag == "ul":
depth += 1
return depth
def handle_data(self, text):
text = unescape(text)
previous_tag = self._open_tags[0] if len(self._open_tags) > 0 else ""
list_format_offset = 0
if previous_tag == "a":
url = self._open_tags_meta[0]
if url:
text = url
elif len(self._open_tags) > 1 and self._previous_ended_line and previous_tag == "li":
list_type = self._open_tags[1]
indent = (self._list_depth() - 1) * 4 * " "
text = text.strip("\n")
if len(text) == 0:
return
elif list_type == "ul":
text = f"{indent}* {text}"
list_format_offset = len(indent) + 2
elif list_type == "ol":
n = self._open_tags_meta[1]
n += 1
self._open_tags_meta[1] = n
text = f"{indent}{n}. {text}"
list_format_offset = len(indent) + 3
for tag, entity in self._building_entities.items():
# See "TEXT LEN EXPLANATION" near start of file
entity.length += int(len(text.strip("\n").encode(TEMP_ENC)) / 2)
entity.offset += list_format_offset
if text.endswith("\n"):
self._previous_ended_line = True
else:
self._previous_ended_line = False
self.text += text
def handle_endtag(self, tag):
try:
self._open_tags.popleft()
self._open_tags_meta.popleft()
except IndexError:
pass
if (tag == "ul" or tag == "ol") and self.text.endswith("\n"):
self.text = self.text[:-1]
entity = self._building_entities.pop(tag, None)
if entity:
self.entities.append(entity)
def matrix_to_telegram(html):
try:
parser = MatrixParser()
parser.feed(html)
return parser.text, parser.entities
except Exception:
log.exception("Failed to convert Matrix format:\nhtml=%s", html)
def matrix_reply_to_telegram(content, tg_space, room_id=None):
try:
reply = content["m.relates_to"]["m.in_reply_to"]
room_id = room_id or reply["room_id"]
event_id = reply["event_id"]
message = DBMessage.query.filter(DBMessage.mxid == event_id and
DBMessage.tg_space == tg_space and
DBMessage.mx_room == room_id).one_or_none()
if message:
return message.tgid
except KeyError:
pass
return None
# endregion
# region Telegram to Matrix
def telegram_reply_to_matrix(evt, source):
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.query.get((evt.reply_to_msg_id, space))
if msg:
return {
"m.in_reply_to": {
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
}
return {}
async def telegram_event_to_matrix(evt, source, native_replies=False, message_link_in_reply=False,
main_intent=None, reply_text="Reply"):
text = evt.message
html = telegram_to_matrix(evt.message, evt.entities) if evt.entities else None
relates_to = {}
if evt.fwd_from:
if not html:
html = escape(text)
from_id = evt.fwd_from.from_id
user = u.User.get_by_tgid(from_id)
if user:
fwd_from = f"<a href='https://matrix.to/#/{user.mxid}'>{user.mxid}</a>"
else:
puppet = p.Puppet.get(from_id, create=False)
if puppet and puppet.displayname:
fwd_from = f"<a href='https://matrix.to/#/{puppet.mxid}'>{puppet.displayname}</a>"
else:
user = await source.client.get_entity(from_id)
if user:
fwd_from = p.Puppet.get_displayname(user, format=False)
else:
fwd_from = None
if not fwd_from:
fwd_from = "Unknown user"
html = (f"Forwarded message from <b>{fwd_from}</b><br/>"
+ f"<blockquote>{html}</blockquote>")
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.query.get((evt.reply_to_msg_id, space))
if msg:
if native_replies:
relates_to["m.in_reply_to"] = {
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
if reply_text == "Edit":
html = "<u>Edit:</u> " + (html or escape(text))
else:
try:
event = await main_intent.get_event(msg.mx_room, msg.mxid)
content = event["content"]
body = (content["formatted_body"]
if "formatted_body" in content
else content["body"])
sender = event['sender']
puppet = p.Puppet.get_by_mxid(sender, create=False)
displayname = puppet.displayname if puppet else sender
reply_to_user = f"<a href='https://matrix.to/#/{sender}'>{displayname}</a>"
reply_to_msg = (("<a href='https://matrix.to/#/"
+ f"{msg.mx_room}/{msg.mxid}'>{reply_text}</a>")
if message_link_in_reply else "Reply")
quote = f"{reply_to_msg} to {reply_to_user}<blockquote>{body}</blockquote>"
except (ValueError, KeyError, MatrixRequestError):
quote = "{reply_text} to unknown user <em>(Failed to fetch message)</em>:<br/>"
if html:
html = quote + html
else:
html = quote + escape(text)
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 html:
html = html.replace("\n", "<br/>")
return text, html, relates_to
def telegram_to_matrix(text, entities):
try:
return _telegram_to_matrix(text, entities)
except Exception:
log.exception("Failed to convert Telegram format:\n"
"message=%s\n"
"entities=%s",
text, entities)
def _telegram_to_matrix(text, entities):
if not entities:
return text
# See "TEXT LEN EXPLANATION" near start of file
text = text.encode(TEMP_ENC)
html = []
last_offset = 0
for entity in entities:
entity.offset *= 2
entity.length *= 2
if entity.offset > last_offset:
html.append(escape(text[last_offset:entity.offset].decode(TEMP_ENC)))
elif entity.offset < last_offset:
continue
skip_entity = False
entity_text = escape(text[entity.offset:entity.offset + entity.length].decode(TEMP_ENC))
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 == MessageEntityCode:
html.append(f"<code>{entity_text}</code>")
elif entity_type == MessageEntityPre:
if entity.language:
html.append("<pre>"
+ f"<code class='language-{entity.language}'>{entity_text}</code>"
+ "</pre>")
else:
html.append(f"<pre><code>{entity_text}</code></pre>")
elif entity_type == MessageEntityMention:
username = entity_text[1:]
user = u.User.find_by_username(username)
if user:
mxid = user.mxid
else:
puppet = p.Puppet.find_by_username(username)
mxid = puppet.mxid if puppet else None
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
else:
skip_entity = True
elif entity_type == MessageEntityMentionName:
user = u.User.get_by_tgid(entity.user_id)
if user:
mxid = user.mxid
else:
puppet = p.Puppet.get(entity.user_id, create=False)
mxid = puppet.mxid if puppet else None
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
else:
skip_entity = True
elif entity_type == MessageEntityEmail:
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
elif entity_type in {MessageEntityTextUrl, MessageEntityUrl}:
url = escape(entity.url) if entity_type == MessageEntityTextUrl else entity_text
if not url.startswith(("https://", "http://", "ftp://", "magnet://")):
url = "http://" + url
html.append(f"<a href='{url}'>{entity_text}</a>")
elif entity_type == MessageEntityBotCommand:
html.append(f"<font color='blue'>!{entity_text[1:]}")
elif entity_type == MessageEntityHashtag:
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:].decode(TEMP_ENC))
return "".join(html)
# endregion
+8
View File
@@ -0,0 +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
from .. import context as c
def init(context: c.Context) -> None:
init_mx(context)
@@ -0,0 +1,154 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING, Dict, Any
import re
import logging
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
TypeMessageEntity)
from ... import puppet as pu
from ...types import TelegramID, MatrixRoomID
from ...db import Message as DBMessage
from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text)
from .parser import ParsedMessage, parse_html
if TYPE_CHECKING:
from ...context import Context
log = logging.getLogger("mau.fmt.mx") # type: logging.Logger
should_bridge_plaintext_highlights = False # type: bool
command_regex = re.compile(r"^!([A-Za-z0-9@]+)") # type: Pattern
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)") # type: Pattern
plain_mention_regex = None # type: Optional[Pattern]
def plain_mention_to_html(match: Match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
return (f"{match.group(1)}"
f"<a href='https://matrix.to/#/{puppet.mxid}'>"
f"{puppet.displayname}"
"</a>")
return "".join(match.groups())
def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage:
if len(message) > 4096:
message = message[0:4082] + " [message cut]"
new_entities = []
for entity in entities:
if entity.offset > 4082:
continue
if entity.offset + entity.length > 4082:
entity.length = 4082 - entity.offset
new_entities.append(entity)
new_entities.append(MessageEntityItalic(4082, len(" [message cut]")))
entities = new_entities
return message, entities
class FormatError(Exception):
pass
def matrix_to_telegram(html: str) -> ParsedMessage:
try:
html = command_regex.sub(r"<command>\1</command>", html)
html = html.replace("\t", " " * 4)
html = not_command_regex.sub(r"\1", html)
if should_bridge_plaintext_highlights:
html = plain_mention_regex.sub(plain_mention_to_html, html)
text, entities = parse_html(add_surrogates(html))
text = remove_surrogates(text.strip())
text, entities = cut_long_message(text, entities)
return text, entities
except Exception as e:
raise FormatError(f"Failed to convert Matrix format: {html}") from e
def matrix_reply_to_telegram(content: Dict[str, Any], tg_space: TelegramID,
room_id: Optional[MatrixRoomID] = None) -> Optional[TelegramID]:
relates_to = content.get("m.relates_to", None) or {}
if not relates_to:
return None
reply = (relates_to if relates_to.get("rel_type", None) == "m.reference"
else relates_to.get("m.in_reply_to", None) or {})
if not reply:
return None
room_id = room_id or reply.get("room_id", None)
event_id = reply.get("event_id", None)
if not event_id:
return
try:
if content["format"] == "org.matrix.custom.html":
content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"])
except KeyError:
pass
content["body"] = trim_reply_fallback_text(content["body"])
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
return message.tgid
return None
def matrix_text_to_telegram(text: str) -> ParsedMessage:
text = command_regex.sub(r"/\1", text)
text = text.replace("\t", " " * 4)
text = not_command_regex.sub(r"\1", text)
if should_bridge_plaintext_highlights:
entities, pmr_replacer = plain_mention_to_text()
text = plain_mention_regex.sub(pmr_replacer, text)
else:
entities = []
return text, entities
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], str]]:
entities = []
def replacer(match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
offset = match.start()
length = match.end() - offset
if puppet.username:
entity = MessageEntityMention(offset, length)
text = f"@{puppet.username}"
else:
entity = MessageEntityMentionName(offset, length, user_id=puppet.tgid)
text = puppet.displayname
entities.append(entity)
return text
return "".join(match.groups())
return entities, replacer
def init_mx(context: "Context") -> None:
global plain_mention_regex, should_bridge_plaintext_highlights
config = context.config
dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)")
dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+")
plain_mention_regex = re.compile(f"^({dn_template})")
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] or False
@@ -0,0 +1,66 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Tuple
from html.parser import HTMLParser
class HTMLNode(list):
def __init__(self, tag: str, attrs: List[Tuple[str, str]]):
super().__init__()
self.tag = tag # type: str
self.text = "" # type: str
self.tail = "" # type: str
self.attrib = dict(attrs) # type: Dict[str, str]
class NodeifyingParser(HTMLParser):
# From https://www.w3.org/TR/html5/syntax.html#writing-html-documents-elements
void_tags = ("area", "base", "br", "col", "command", "embed", "hr", "img", "input", "link",
"meta", "param", "source", "track", "wbr")
def __init__(self):
super().__init__()
self.stack = [HTMLNode("html", [])] # type: List[HTMLNode]
def handle_starttag(self, tag, attrs):
node = HTMLNode(tag, attrs)
self.stack[-1].append(node)
if tag not in self.void_tags:
self.stack.append(node)
def handle_startendtag(self, tag, attrs):
self.stack[-1].append(HTMLNode(tag, attrs))
def handle_endtag(self, tag):
if tag == self.stack[-1].tag:
self.stack.pop()
def handle_data(self, data):
if len(self.stack[-1]) > 0:
self.stack[-1][-1].tail += data
else:
self.stack[-1].text += data
def error(self, message):
pass
def read_html(data: str) -> HTMLNode:
parser = NodeifyingParser()
parser.feed(data)
return parser.stack[0]
@@ -0,0 +1,11 @@
from typing import Dict, List
class HTMLNode(List['HTMLNode']):
tag: str
text: str
tail: str
attrib: Dict[str, str]
def read_html(data: str) -> HTMLNode: ...
@@ -0,0 +1,249 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import List, Tuple, Pattern
import re
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
MessageEntityMentionName as MentionName, MessageEntityEmail as Email,
MessageEntityUrl as URL, MessageEntityTextUrl as TextURL,
MessageEntityBold as Bold, MessageEntityItalic as Italic,
MessageEntityCode as Code, MessageEntityPre as Pre,
TypeMessageEntity)
from ... import user as u, puppet as pu, portal as po
from ...types import MatrixUserID
from ..util import html_to_unicode
from .telegram_message import TelegramMessage, Entity, offset_length_multiply
from .html_reader import HTMLNode, read_html
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
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:
mention_regex = re.compile("https://matrix.to/#/(@.+:.+)") # type: Pattern
room_regex = re.compile("https://matrix.to/#/(#.+:.+)") # type: Pattern
block_tags = ("p", "pre", "blockquote",
"ol", "ul", "li",
"h1", "h2", "h3", "h4", "h5", "h6",
"div", "hr", "table") # type: Tuple[str, ...]
list_bullets = ("", "", "", "") # type: Tuple[str, ...]
@classmethod
def list_bullet(cls, depth: int) -> str:
return cls.list_bullets[(depth - 1) % len(cls.list_bullets)] + " "
@classmethod
def list_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
ordered = node.tag == "ol"
tagged_children = cls.node_to_tagged_tmessages(node, ctx)
counter = 1
indent_length = 0
if ordered:
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: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
children = msg.trim().split("\n")
children = [child.prepend("> ") for child in children]
return TelegramMessage.join(children, "\n")
@classmethod
def header_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
children = cls.node_to_tmessages(node, ctx)
length = int(node.tag[1])
prefix = "#" * length + " "
return TelegramMessage.join(children, "").prepend(prefix).format(Bold)
@classmethod
def basic_format_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
if node.tag in ("b", "strong"):
msg.format(Bold)
elif node.tag in ("i", "em"):
msg.format(Italic)
elif node.tag == "command":
msg.format(Command)
elif node.tag in ("s", "strike", "del"):
msg.text = html_to_unicode(msg.text, "\u0336")
elif node.tag in ("u", "ins"):
msg.text = html_to_unicode(msg.text, "\u0332")
if node.tag in ("s", "strike", "del", "u", "ins"):
msg.entities = Entity.adjust(msg.entities, offset_length_multiply(2))
return msg
@classmethod
def link_to_tstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
href = node.attrib.get("href", "")
if not href:
return msg
if href.startswith("mailto:"):
return TelegramMessage(href[len("mailto:"):]).format(Email)
mention = cls.mention_regex.match(href)
if mention:
mxid = MatrixUserID(mention.group(1))
user = (pu.Puppet.get_by_mxid(mxid)
or u.User.get_by_mxid(mxid, create=False))
if not user:
return msg
if user.username:
return TelegramMessage(f"@{user.username}").format(Mention)
elif user.tgid:
displayname = user.plain_displayname or msg.text
return TelegramMessage(displayname).format(MentionName, user_id=user.tgid)
return msg
room = cls.room_regex.match(href)
if room:
username = po.Portal.get_username_from_mx_alias(room.group(1))
portal = po.Portal.find_by_username(username)
if portal and portal.username:
return TelegramMessage(f"@{portal.username}").format(Mention)
return (msg.format(URL)
if msg.text == href
else msg.format(TextURL, url=href))
@classmethod
def node_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
if node.tag == "blockquote":
return cls.blockquote_to_tmessage(node, ctx)
elif node.tag == "ol":
return cls.list_to_tmessage(node, ctx)
elif node.tag == "ul":
return cls.list_to_tmessage(node, ctx.enter_list())
elif node.tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
return cls.header_to_tmessage(node, ctx)
elif node.tag == "br":
return TelegramMessage("\n")
elif node.tag in ("b", "strong", "i", "em", "s", "del", "u", "ins", "command"):
return cls.basic_format_to_tmessage(node, ctx)
elif node.tag == "a":
return cls.link_to_tstring(node, ctx)
elif node.tag == "p":
return cls.tag_aware_parse_node(node, ctx).append("\n")
elif node.tag == "pre":
lang = ""
try:
if node[0].tag == "code":
node = node[0]
lang = node.attrib["class"][len("language-"):]
except (IndexError, KeyError):
pass
return cls.parse_node(node, ctx.enter_code_block()).format(Pre, language=lang)
elif node.tag == "code":
return cls.parse_node(node, ctx.enter_code_block()).format(Code)
return cls.tag_aware_parse_node(node, ctx)
@staticmethod
def text_to_tmessage(text: str, ctx: RecursionContext) -> TelegramMessage:
if ctx.strip_linebreaks:
text = text.replace("\n", "")
return TelegramMessage(text)
@classmethod
def node_to_tagged_tmessages(cls, node: HTMLNode, ctx: RecursionContext
) -> List[Tuple[TelegramMessage, str]]:
output = []
if node.text:
output.append((cls.text_to_tmessage(node.text, ctx), "text"))
for child in node:
output.append((cls.node_to_tmessage(child, ctx), child.tag))
if child.tail:
output.append((cls.text_to_tmessage(child.tail, ctx), "text"))
return output
@classmethod
def node_to_tmessages(cls, node: HTMLNode, ctx: RecursionContext
) -> List[TelegramMessage]:
return [msg for (msg, tag) in cls.node_to_tagged_tmessages(node, ctx)]
@classmethod
def tag_aware_parse_node(cls, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage:
msgs = cls.node_to_tagged_tmessages(node, ctx)
output = TelegramMessage()
prev_was_block = False
for msg, tag in msgs:
if tag in cls.block_tags:
msg = msg.append("\n")
if not prev_was_block:
msg = msg.prepend("\n")
prev_was_block = True
output = output.append(msg)
return output.trim()
@classmethod
def parse_node(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
return TelegramMessage.join(cls.node_to_tmessages(node, ctx))
@classmethod
def parse(cls, data: str) -> ParsedMessage:
msg = cls.node_to_tmessage(read_html(f"<body>{data}</body>"), RecursionContext())
return msg.text, msg.entities
@@ -0,0 +1,158 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Callable, List, Optional, Sequence, Type, Union
from telethon.tl.types import (MessageEntityMentionName as MentionName,
MessageEntityTextUrl as TextURL, MessageEntityPre as Pre,
TypeMessageEntity, InputMessageEntityMentionName as InputMentionName)
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)
@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
def offset_diff(amount: int) -> Callable[[TypeMessageEntity], None]:
def func(entity: TypeMessageEntity) -> None:
entity.offset += amount
return func
def offset_length_multiply(amount: int) -> Callable[[TypeMessageEntity], None]:
def func(entity: TypeMessageEntity) -> None:
entity.offset *= amount
entity.length *= amount
return func
class TelegramMessage:
def __init__(self, text: str = "", entities: Optional[List[TypeMessageEntity]] = None) -> None:
self.text = text # type: str
self.entities = entities or [] # type: List[TypeMessageEntity]
def offset_entities(self, offset: int) -> 'TelegramMessage':
def apply_offset(entity: TypeMessageEntity, inner_offset: int
) -> Optional[TypeMessageEntity]:
entity = Entity.copy(entity)
entity.offset += inner_offset
if entity.offset < 0:
entity.offset = 0
elif entity.offset > len(self.text):
return None
elif entity.offset + entity.length > len(self.text):
entity.length = len(self.text) - entity.offset
return entity
self.entities = [apply_offset(entity, offset) for entity in self.entities if entity]
self.entities = [x for x in self.entities if x is not None]
return self
def append(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
for msg in args:
if isinstance(msg, str):
msg = TelegramMessage(text=msg)
self.entities += Entity.adjust(msg.entities, offset_diff(len(self.text)))
self.text += msg.text
return self
def prepend(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
for msg in args:
if isinstance(msg, str):
msg = TelegramMessage(text=msg)
self.entities = msg.entities + Entity.adjust(self.entities, offset_diff(len(msg.text)))
self.text = msg.text + self.text
return self
def format(self, entity_type: Type[TypeMessageEntity], offset: int = None, length: int = None,
**kwargs) -> 'TelegramMessage':
self.entities.append(entity_type(offset=offset or 0,
length=length if length is not None else len(self.text),
**kwargs))
return self
def concat(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
return TelegramMessage().append(self, *args)
def trim(self) -> 'TelegramMessage':
orig_len = len(self.text)
self.text = self.text.lstrip()
diff = orig_len - len(self.text)
self.text = self.text.rstrip()
self.offset_entities(-diff)
return self
def split(self, separator, max_items: int = 0) -> List['TelegramMessage']:
text_parts = self.text.split(separator, max_items - 1)
output = [] # type: List[TelegramMessage]
offset = 0
for part in text_parts:
msg = TelegramMessage(part)
for entity in self.entities:
start_in_range = len(part) > entity.offset - offset >= 0
end_in_range = len(part) >= entity.offset - offset + entity.length > 0
if start_in_range and end_in_range:
msg.entities.append(Entity.adjust(entity, offset_diff(-offset)))
output.append(msg)
offset += len(part)
offset += len(separator)
return output
@staticmethod
def join(items: Sequence[Union[str, 'TelegramMessage']],
separator: str = " ") -> 'TelegramMessage':
main = TelegramMessage()
for msg in items:
if isinstance(msg, str):
msg = TelegramMessage(text=msg)
main.entities += Entity.adjust(msg.entities, offset_diff(len(main.text)))
main.text += msg.text + separator
if len(separator) > 0:
main.text = main.text[:-len(separator)]
return main
+325
View File
@@ -0,0 +1,325 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
from html import escape
import logging
import re
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityUrl,
MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold,
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag,
MessageEntityPhone, TypeMessageEntity, Message, PeerChannel,
MessageFwdHeader, PeerUser)
from mautrix_appservice import MatrixRequestError
from mautrix_appservice.intent_api import IntentAPI
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
log = logging.getLogger("mau.fmt.tg") # type: logging.Logger
def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Dict:
if evt.reply_to_msg_id:
space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid)
msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space)
if msg:
return {
"m.in_reply_to": {
"event_id": msg.mxid,
"room_id": msg.mx_room,
},
"rel_type": "m.reference",
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
return {}
async def _add_forward_header(source, text: str, html: Optional[str],
fwd_from: MessageFwdHeader) -> Tuple[str, str]:
if not html:
html = escape(text)
fwd_from_html, fwd_from_text = None, None
if 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}'>"
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}'>"
f"{escape(fwd_from_text)}</a>")
if not fwd_from_text:
user = await source.client.get_entity(PeerUser(fwd_from.from_id))
if user:
fwd_from_text = pu.Puppet.get_displayname(user, False)
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
else:
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"<b>{escape(fwd_from_text)}</b>"
else:
channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
if channel:
fwd_from_text = channel.title
fwd_from_html = f"<b>{fwd_from_text}</b>"
if not fwd_from_text:
if fwd_from.from_id:
fwd_from_text = "Unknown user"
else:
fwd_from_text = "Unknown source"
fwd_from_html = f"<b>{fwd_from_text}</b>"
text = "\n".join([f"> {line}" for line in text.split("\n")])
text = f"Forwarded from {fwd_from_text}:\n{text}"
html = (f"Forwarded message from {fwd_from_html}<br/>"
f"<tg-forward><blockquote>{html}</blockquote></tg-forward>")
return text, html
async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message,
relates_to: Dict, main_intent: IntentAPI) -> Tuple[str, str]:
space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid)
msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space)
if not msg:
return text, html
relates_to["rel_type"] = "m.reference"
relates_to["event_id"] = msg.mxid
relates_to["room_id"] = msg.mx_room
relates_to["m.in_reply_to"] = {
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
try:
event = await main_intent.get_event(msg.mx_room, msg.mxid)
content = event["content"]
r_sender = event["sender"]
r_text_body = trim_reply_fallback_text(content["body"])
r_html_body = trim_reply_fallback_html(content["formatted_body"]
if "formatted_body" in content
else escape(content["body"]))
puppet = pu.Puppet.get_by_mxid(r_sender, create=False)
r_displayname = puppet.displayname if puppet else r_sender
r_sender_link = f"<a href='https://matrix.to/#/{r_sender}'>{escape(r_displayname)}</a>"
except (ValueError, KeyError, MatrixRequestError):
r_sender_link = "unknown user"
r_displayname = "unknown user"
r_text_body = "Failed to fetch message"
r_html_body = "<em>Failed to fetch message</em>"
r_msg_link = f"<a href='https://matrix.to/#/{msg.mx_room}/{msg.mxid}'>In reply to</a>"
html = (
f"<mx-reply><blockquote>{r_msg_link} {r_sender_link}\n{r_html_body}</blockquote></mx-reply>"
+ (html or escape(text)))
lines = r_text_body.strip().split("\n")
text_with_quote = f"> <{r_displayname}> {lines.pop(0)}"
for line in lines:
if line:
text_with_quote += f"\n> {line}"
text_with_quote += "\n\n"
text_with_quote += text
return text_with_quote, html
async def telegram_to_matrix(evt: Message, source: "AbstractUser",
main_intent: Optional[IntentAPI] = None,
prefix_text: Optional[str] = None, prefix_html: Optional[str] = None,
override_text: str = None,
override_entities: List[TypeMessageEntity] = None,
no_reply_fallback: bool = False) -> Tuple[str, str, Dict]:
text = add_surrogates(override_text or evt.message)
entities = override_entities or evt.entities
html = _telegram_entities_to_matrix_catch(text, entities) if entities else None
relates_to = {} # type: Dict
if prefix_html:
html = prefix_html + (html or escape(text))
if prefix_text:
text = prefix_text + text
if evt.fwd_from:
text, html = await _add_forward_header(source, text, html, evt.fwd_from)
if evt.reply_to_msg_id and not no_reply_fallback:
text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent)
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>"
html = unicode_to_html(text, html, "\u0336", "del")
html = unicode_to_html(text, html, "\u0332", "u")
if html:
html = html.replace("\n", "<br/>")
return remove_surrogates(text), remove_surrogates(html), relates_to
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
try:
return _telegram_entities_to_matrix(text, entities)
except Exception:
log.exception("Failed to convert Telegram format:\n"
"message=%s\n"
"entities=%s",
text, entities)
return "[failed conversion in _telegram_entities_to_matrix]"
def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity]) -> str:
if not entities:
return 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:
continue
skip_entity = False
entity_text = escape(text[entity.offset:entity.offset + 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 == MessageEntityCode:
html.append(("<pre><code>{entity_text}</code></pre>"
if "\n" in entity_text
else "<code>{entity_text}</code>").format(entity_text=entity_text))
elif entity_type == MessageEntityPre:
skip_entity = _parse_pre(html, entity_text, entity.language)
elif entity_type == MessageEntityMention:
skip_entity = _parse_mention(html, entity_text)
elif entity_type == MessageEntityMentionName:
skip_entity = _parse_name_mention(html, entity_text, TelegramID(entity.user_id))
elif entity_type == MessageEntityEmail:
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
elif entity_type in (MessageEntityTextUrl, MessageEntityUrl):
skip_entity = _parse_url(html, entity_text,
entity.url if entity_type == MessageEntityTextUrl else None)
elif entity_type == MessageEntityBotCommand:
html.append(f"<font color='blue'>!{entity_text[1:]}</font>")
elif entity_type in (MessageEntityHashtag, MessageEntityCashtag, MessageEntityPhone):
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:])
return "".join(html)
def _parse_pre(html: List[str], entity_text: str, language: str) -> bool:
if language:
html.append("<pre>"
f"<code class='language-{language}'>{entity_text}</code>"
"</pre>")
else:
html.append(f"<pre><code>{entity_text}</code></pre>")
return False
def _parse_mention(html: List[str], entity_text: str) -> bool:
username = entity_text[1:]
user = u.User.find_by_username(username) or pu.Puppet.find_by_username(username)
if user:
mxid = user.mxid
else:
portal = po.Portal.find_by_username(username)
mxid = portal.alias or portal.mxid if portal else None
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
else:
return True
return False
def _parse_name_mention(html: List[str], entity_text: str, user_id: TelegramID) -> bool:
user = u.User.get_by_tgid(user_id)
if user:
mxid = user.mxid
else:
puppet = pu.Puppet.get(user_id, create=False)
mxid = puppet.mxid if puppet else None
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
else:
return True
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})")
def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
url = escape(url) if url else entity_text
if not url.startswith(("https://", "http://", "ftp://", "magnet://")):
url = "http://" + url
message_link_match = message_link_regex.match(url)
if message_link_match:
group, msgid_str = message_link_match.groups()
msgid = int(msgid_str)
portal = po.Portal.find_by_username(group)
if portal:
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
+89
View File
@@ -0,0 +1,89 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Pattern
from html import escape
import struct
import re
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)
+351 -125
View File
@@ -1,171 +1,266 @@
# -*- 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Match, Optional, Set, Tuple, TYPE_CHECKING
import logging
import asyncio
import time
import re
from mautrix_appservice import MatrixRequestError
from mautrix_appservice import MatrixRequestError, IntentError
from .user import User
from .portal import Portal
from .puppet import Puppet
from .commands import CommandHandler
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
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
class MatrixHandler:
log = logging.getLogger("mau.mx")
log = logging.getLogger("mau.mx") # type: logging.Logger
def __init__(self, context):
self.az, self.db, self.config, _ = context
self.commands = CommandHandler(context)
def __init__(self, context: 'Context') -> None:
self.az, self.config, _, self.tgbot = context.core
self.commands = com.CommandProcessor(context) # type: com.CommandProcessor
self.previously_typing = [] # type: List[MatrixUserID]
self.az.matrix_event_handler(self.handle_event)
async def init_as_bot(self):
self.az.intent.set_display_name(
self.config.get("appservice.bot_displayname", "Telegram bridge bot"))
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 handle_puppet_invite(self, room, puppet, inviter):
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room}")
if not inviter.logged_in:
await puppet.intent.error_and_leave(
room, text="Please log in before inviting Telegram puppets.")
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 handle_puppet_invite(self, room_id: MatrixRoomID, puppet: pu.Puppet, inviter: u.User
) -> 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():
await intent.error_and_leave(
room_id, text="Please log in before inviting Telegram puppets.")
return
portal = Portal.get_by_mxid(room)
portal = po.Portal.get_by_mxid(room_id)
if portal:
if portal.peer_type == "user":
await puppet.intent.error_and_leave(
room, text="You can not invite additional users to private chats.")
await intent.error_and_leave(
room_id, text="You can not invite additional users to private chats.")
return
await portal.invite_telegram(inviter, puppet)
await puppet.intent.join_room(room)
await intent.join_room(room_id)
return
try:
members = await self.az.intent.get_room_members(room)
members = await self.az.intent.get_room_members(room_id)
except MatrixRequestError:
members = []
if self.az.intent.mxid not in members:
if self.az.bot_mxid not in members:
if len(members) > 1:
await puppet.intent.error_and_leave(room, text=None, html=(
await intent.error_and_leave(room_id, text=None, html=(
f"Please invite "
+ f"<a href='https://matrix.to/#/{self.az.intent.mxid}'>the bridge bot</a> "
+ f"first if you want to create a Telegram chat."))
f"<a href='https://matrix.to/#/{self.az.bot_mxid}'>the bridge bot</a> "
f"first if you want to create a Telegram chat."))
return
await puppet.intent.join_room(room)
portal = Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
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 puppet.intent.invite(portal.mxid, inviter.mxid)
await puppet.intent.send_notice(room, text=None, html=(
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 puppet.intent.leave_room(room)
f"<a href='https://matrix.to/#/{portal.mxid}'>"
"Link to room"
"</a>"))
await intent.leave_room(room_id)
return
except MatrixRequestError:
pass
portal.mxid = room
portal.mxid = room_id
portal.save()
inviter.register_portal(portal)
await puppet.intent.send_notice(room, "Portal to private chat created.")
await intent.send_notice(room_id, "Portal to private chat created.")
else:
await puppet.intent.join_room(room)
await puppet.intent.send_notice(room, "This puppet will remain inactive until a "
"Telegram chat is created for this room.")
await intent.join_room(room_id)
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
async def handle_invite(self, room, user, inviter):
inviter = User.get_by_mxid(inviter)
if not inviter.whitelisted:
return
elif user == self.az.bot_mxid:
await self.az.intent.join_room(room)
await self.az.intent.send_notice(
room_id,
text="You are not whitelisted to use this bridge.\n\n"
"If you are the owner of this bridge, see the "
"`bridge.permissions` section in your config file.",
html="<p>You are not whitelisted to use this bridge.</p>"
"<p>If you are the owner of this bridge, see the "
"<code>bridge.permissions</code> section in your config file.</p>")
await self.az.intent.leave_room(room_id)
try:
is_management = len(await self.az.intent.get_room_members(room_id)) == 2
except MatrixRequestError:
is_management = False
cmd_prefix = self.commands.command_prefix
text = html = "Hello, I'm a Telegram bridge bot. "
if is_management and inviter.puppet_whitelisted and not await inviter.is_logged_in():
text += f"Use `{cmd_prefix} help` for help or `{cmd_prefix} login` to log in."
html += (f"Use <code>{cmd_prefix} help</code> for help"
f" or <code>{cmd_prefix} login</code> to log in.")
pass
else:
text += f"Use `{cmd_prefix} help` for help."
html += f"Use <code>{cmd_prefix} help</code> for help."
await self.az.intent.send_notice(room_id, text=text, html=html)
async def handle_invite(self, room_id: MatrixRoomID, user_id: MatrixUserID,
inviter_mxid: MatrixUserID) -> None:
self.log.debug(f"{inviter_mxid} invited {user_id} to {room_id}")
inviter = u.User.get_by_mxid(inviter_mxid)
if inviter is None:
self.log.exception("Failed to find user with Matrix ID {inviter_mxid}")
await inviter.ensure_started()
if user_id == self.az.bot_mxid:
return await self.accept_bot_invite(room_id, inviter)
elif not inviter.whitelisted:
return
puppet = Puppet.get_by_mxid(user)
puppet = pu.Puppet.get_by_mxid(user_id)
if puppet:
await self.handle_puppet_invite(room, puppet, inviter)
await self.handle_puppet_invite(room_id, puppet, inviter)
return
user = User.get_by_mxid(user, create=False)
portal = Portal.get_by_mxid(room)
if user and user.has_full_access and portal:
user = u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
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
self.log.debug(f"{inviter} invited {user} to {room}")
async def handle_join(self, room, user):
user = User.get_by_mxid(user)
async def handle_join(self, room_id: MatrixRoomID, user_id: MatrixUserID,
event_id: MatrixEventID) -> None:
user = await u.User.get_by_mxid(user_id).ensure_started()
portal = Portal.get_by_mxid(room)
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
if not user.whitelisted:
await portal.main_intent.kick(room, user.mxid,
if not user.relaybot_whitelisted:
await portal.main_intent.kick(room_id, user.mxid,
"You are not whitelisted on this Telegram bridge.")
return
elif not user.logged_in:
# TODO[waiting-for-bots] once we have bot support, this won't be needed.
await portal.main_intent.kick(room, user.mxid,
"You are not logged into this Telegram bridge.")
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.")
return
self.log.debug(f"{user} joined {room}")
# TODO join Telegram chat if applicable
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, user, sender):
self.log.debug(f"{user} left {room}")
async def handle_part(self, room_id: MatrixRoomID, user_id: MatrixUserID,
sender_mxid: MatrixUserID, event_id: MatrixEventID) -> None:
self.log.debug(f"{user_id} left {room_id}")
sender = User.get_by_mxid(sender, create=False)
sender = u.User.get_by_mxid(sender_mxid, create=False)
if not sender:
return
await sender.ensure_started()
portal = Portal.get_by_mxid(room)
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
puppet = Puppet.get_by_mxid(user)
if sender and puppet:
await portal.leave_matrix(puppet, sender)
puppet = pu.Puppet.get_by_mxid(user_id)
if puppet:
if sender:
await portal.kick_matrix(puppet, sender)
return
user = User.get_by_mxid(user, create=False)
if user and user.logged_in:
await portal.leave_matrix(user, sender)
user = u.User.get_by_mxid(user_id, create=False)
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)
def is_command(self, message):
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:]
text = text[len(prefix) + 1:].lstrip()
return is_command, text
async def handle_message(self, room, sender, message, event_id):
self.log.debug(f"{sender} sent {message} to ${room}")
async def handle_message(self, room: MatrixRoomID, sender_id: MatrixUserID, message: Dict,
event_id: MatrixEventID) -> None:
is_command, text = self.is_command(message)
sender = User.get_by_mxid(sender)
sender = await u.User.get_by_mxid(sender_id).ensure_started()
if not sender.relaybot_whitelisted:
self.log.debug(f"Ignoring message \"{message}\" from {sender} to {room}:"
" User is not whitelisted.")
return
self.log.debug(f"Received Matrix event \"{message}\" from {sender} in {room}")
portal = Portal.get_by_mxid(room)
if sender.has_full_access and portal and not is_command:
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 message["msgtype"] != "m.text":
if not sender.whitelisted or message.get("msgtype", "m.unknown") != "m.text":
return
try:
@@ -182,59 +277,190 @@ class MatrixHandler:
# Not enough values to unpack, i.e. no arguments
command = text
args = []
await self.commands.handle(room, sender, command, args, is_management,
await self.commands.handle(room, event_id, sender, command, args, is_management,
is_portal=portal is not None)
async def handle_redaction(self, room, sender, event_id):
portal = Portal.get_by_mxid(room)
sender = User.get_by_mxid(sender)
if sender.has_full_access and portal:
await portal.handle_matrix_deletion(sender, 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()
if not sender.relaybot_whitelisted:
return
async def handle_power_levels(self, room, sender, new, old):
portal = Portal.get_by_mxid(room)
sender = User.get_by_mxid(sender)
if sender.has_full_access and portal:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
await portal.handle_matrix_deletion(sender, event_id)
@staticmethod
async def handle_power_levels(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
new: Dict, old: 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:
await portal.handle_matrix_power_levels(sender, new["users"], old["users"])
async def handle_room_meta(self, type, room, sender, content):
portal = Portal.get_by_mxid(room)
sender = User.get_by_mxid(sender)
if sender.has_full_access and portal:
@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"),
}[type]
}[evt_type]
if content_key not in content:
# FIXME handle
pass
await handler(sender, content[content_key])
return
await handler(sender, content[content_key])
def filter_matrix_event(self, event):
return (event["sender"] == self.az.bot_mxid
or Puppet.get_id_from_mxid(event["sender"]) is not None)
@staticmethod
async def handle_room_pin(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
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()
if await sender.has_full_access(allow_bot=True) and portal:
events = new_events - old_events
if len(events) > 0:
# New event pinned, set that as pinned in Telegram.
await portal.handle_matrix_pin(sender, MatrixEventID(events.pop()))
elif len(new_events) == 0:
# All pinned events removed, remove pinned event in Telegram.
await portal.handle_matrix_pin(sender, None)
async def handle_event(self, evt):
@staticmethod
async def handle_room_upgrade(room_id: MatrixRoomID, new_room_id: MatrixRoomID) -> None:
portal = po.Portal.get_by_mxid(room_id)
if portal:
await portal.handle_matrix_upgrade(new_room_id)
@staticmethod
async def handle_name_change(room_id: MatrixRoomID, user_id: MatrixUserID, displayname: str,
prev_displayname: str, event_id: MatrixEventID) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal or not portal.has_bot:
return
user = await u.User.get_by_mxid(user_id).ensure_started()
if await user.needs_relaybot(portal):
await portal.name_change_matrix(user, displayname, prev_displayname, event_id)
@staticmethod
def parse_read_receipts(content: Dict) -> Dict[MatrixUserID, MatrixEventID]:
return {user_id: event_id
for event_id, receipts in content.items()
for user_id in receipts.get("m.read", {})}
@staticmethod
async def handle_read_receipts(room_id: MatrixRoomID,
receipts: Dict[MatrixUserID, MatrixEventID]) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
for user_id, event_id in receipts.items():
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:
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")
async def handle_typing(self, room_id: MatrixRoomID, now_typing: List[MatrixUserID]) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
for user_id in set(self.previously_typing + now_typing):
is_typing = user_id in now_typing
was_typing = user_id in self.previously_typing
if is_typing and was_typing:
continue
user = await u.User.get_by_mxid(user_id).ensure_started()
if not await user.is_logged_in():
continue
await portal.set_typing(user, is_typing)
self.previously_typing = 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)
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_event(self, evt: MatrixEvent) -> None:
if self.filter_matrix_event(evt):
return
start_time = time.time()
self.log.debug("Received event: %s", evt)
type = evt["type"]
content = evt.get("content", {})
if type == "m.room.member":
membership = content.get("membership", "")
if membership == "invite":
await self.handle_invite(evt["room_id"], evt["state_key"], evt["sender"])
elif membership == "leave":
await self.handle_part(evt["room_id"], evt["state_key"], evt["sender"])
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(evt["room_id"], evt["state_key"])
elif type == "m.room.message":
await self.handle_message(evt["room_id"], evt["sender"], content, evt["event_id"])
elif type == "m.room.redaction":
await self.handle_redaction(evt["room_id"], evt["sender"], evt["redacts"])
elif type == "m.room.power_levels":
await self.handle_power_levels(evt["room_id"], evt["sender"], evt["content"],
evt["prev_content"])
elif type == "m.room.name" or type == "m.room.avatar" or type == "m.room.topic":
await self.handle_room_meta(type, evt["room_id"], evt["sender"], evt["content"])
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"])
try:
old_events = set(evt["unsigned"]["prev_content"]["pinned"])
except KeyError:
old_events = set()
await self.handle_room_pin(room_id, sender, new_events, old_events)
elif evt_type == "m.room.tombstone":
await self.handle_room_upgrade(room_id, evt["content"]["replacement_room"])
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", []))
else:
return
if EVENT_TIME:
EVENT_TIME.labels(event_type=evt_type).observe(time.time() - start_time)
+1613 -514
View File
File diff suppressed because it is too large Load Diff
+426 -77
View File
@@ -1,79 +1,322 @@
# -*- 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, Any, Dict, List, Iterable, Optional, Pattern, Union, TYPE_CHECKING
from difflib import SequenceMatcher
import re
from enum import Enum
from aiohttp import ServerDisconnectedError
import asyncio
import logging
import re
from telethon.tl.types import UserProfilePhoto, PeerUser
from telethon.errors.rpc_error_list import LocationInvalidError
from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer,
InputPeerPhotoFileLocation, UserProfilePhotoEmpty)
from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError
from .types import MatrixUserID, TelegramID
from .db import Puppet as DBPuppet
from . import util
config = None
if TYPE_CHECKING:
from .matrix import MatrixHandler
from .config import Config
from .context import Context
from .abstract_user import AbstractUser
PuppetError = Enum('PuppetError', 'Success OnlyLoginSelf InvalidAccessToken')
config = None # type: Config
class Puppet:
log = logging.getLogger("mau.puppet")
db = None
az = None
mxid_regex = None
cache = {}
log = logging.getLogger("mau.puppet") # type: logging.Logger
az = None # type: AppService
mx = None # type: MatrixHandler
loop = None # type: asyncio.AbstractEventLoop
mxid_regex = None # type: Pattern
username_template = None # type: str
hs_domain = None # type: str
cache = {} # type: Dict[TelegramID, Puppet]
by_custom_mxid = {} # type: Dict[str, Puppet]
def __init__(self, id=None, username=None, displayname=None, photo_id=None):
self.id = id
def __init__(self,
id: TelegramID,
access_token: Optional[str] = None,
custom_mxid: Optional[MatrixUserID] = 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.localpart = config.get("bridge.username_template", "telegram_{userid}").format(
userid=self.id)
hs = config["homeserver"]["domain"]
self.mxid = f"@{self.localpart}:{hs}"
self.username = username
self.displayname = displayname
self.photo_id = photo_id
self.intent = self.az.intent.user(self.mxid)
self.username = username # type: Optional[str]
self.displayname = displayname # type: Optional[str]
self.displayname_source = displayname_source # type: Optional[TelegramID]
self.photo_id = photo_id # type: Optional[str]
self.is_bot = is_bot # type: bool
self.is_registered = is_registered # type: bool
self.disable_updates = disable_updates # type: bool
self._db_instance = db_instance # type: Optional[DBPuppet]
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
self.intent = self._fresh_intent() # type: IntentAPI
self.sync_task = None # type: Optional[asyncio.Future]
self.cache[id] = self
if self.custom_mxid:
self.by_custom_mxid[self.custom_mxid] = self
@property
def tgid(self):
def mxid(self) -> MatrixUserID:
return self.custom_mxid or self.default_mxid
@property
def tgid(self) -> TelegramID:
return self.id
def to_db(self):
return self.db.merge(
DBPuppet(id=self.id, username=self.username, displayname=self.displayname,
photo_id=self.photo_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)
@staticmethod
async def is_logged_in() -> bool:
""" Is True if the puppet is logged in. """
return True
@property
def plain_displayname(self) -> str:
tpl = config["bridge.displayname_template"]
if tpl == "{displayname}":
# Template has no extra stuff, no need to parse.
return self.displayname
regex = re.compile("^" + re.escape(tpl).replace(re.escape("{displayname}"), "(.+?)") + "$")
match = regex.match(self.displayname)
return match.group(1) or self.displayname
def get_input_entity(self, user: 'AbstractUser') -> Awaitable[TypeInputPeer]:
return user.client.get_input_entity(PeerUser(user_id=self.tgid))
# 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)
async def switch_mxid(self, access_token: Optional[str],
mxid: Optional[MatrixUserID]) -> PuppetError:
prev_mxid = self.custom_mxid
self.custom_mxid = mxid
self.access_token = access_token
self.intent = self._fresh_intent()
err = await self.init_custom_mxid()
if err != PuppetError.Success:
return err
try:
del self.by_custom_mxid[prev_mxid] # type: ignore
except KeyError:
pass
if self.mxid != self.default_mxid:
self.by_custom_mxid[self.mxid] = self
await self.leave_rooms_with_default_user()
self.save()
return PuppetError.Success
async def init_custom_mxid(self) -> PuppetError:
if not self.is_real_user:
return PuppetError.Success
mxid = await self.intent.whoami()
if not mxid or mxid != self.custom_mxid:
self.custom_mxid = None
self.access_token = None
self.intent = self._fresh_intent()
if mxid != self.custom_mxid:
return PuppetError.OnlyLoginSelf
return PuppetError.InvalidAccessToken
if config["bridge.sync_with_custom_puppets"]:
self.sync_task = asyncio.ensure_future(self.sync(), loop=self.loop)
return PuppetError.Success
async def leave_rooms_with_default_user(self) -> None:
for room_id in await self.default_mxid_intent.get_joined_rooms():
try:
await self.default_mxid_intent.leave_room(room_id)
await self.intent.ensure_joined(room_id)
except (IntentError, MatrixRequestError):
pass
def create_sync_filter(self) -> Awaitable[str]:
return self.intent.client.create_filter(self.custom_mxid, {
"room": {
"include_leave": False,
"state": {
"types": []
},
"timeline": {
"types": [],
},
"ephemeral": {
"types": ["m.typing", "m.receipt"],
},
"account_data": {
"types": []
}
},
"account_data": {
"types": [],
},
"presence": {
"types": ["m.presence"],
"senders": [self.custom_mxid],
},
})
def filter_events(self, events: List[Dict]) -> List:
new_events = []
for event in events:
evt_type = event.get("type", None)
event.setdefault("content", {})
if evt_type == "m.typing":
is_typing = self.custom_mxid in event["content"].get("user_ids", [])
event["content"]["user_ids"] = [self.custom_mxid] if is_typing else []
elif evt_type == "m.receipt":
val = None
evt = None
for event_id in event["content"]:
try:
val = event["content"][event_id]["m.read"][self.custom_mxid]
evt = event_id
break
except KeyError:
pass
if val and evt:
event["content"] = {evt: {"m.read": {
self.custom_mxid: val
}}}
else:
continue
new_events.append(event)
return new_events
def handle_sync(self, presence: List, ephemeral: Dict) -> None:
presence_events = [self.mx.try_handle_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 asyncio.CancelledError:
self.log.info("Syncing cancelled")
except Exception:
self.log.exception("Fatal error syncing")
async def _sync(self) -> None:
if not self.is_real_user:
self.log.warning("Called sync() for non-custom puppet.")
return
custom_mxid = self.custom_mxid
access_token_at_start = self.access_token
errors = 0
next_batch = None
filter_id = await self.create_sync_filter()
self.log.debug(f"Starting syncer for {custom_mxid} with sync filter {filter_id}.")
while access_token_at_start == self.access_token:
try:
sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch,
set_presence="offline") # type: Dict
errors = 0
if next_batch is not None:
presence = sync_resp.get("presence", {}).get("events", []) # type: List
ephemeral = {room: data.get("ephemeral", {}).get("events", [])
for room, data
in sync_resp.get("rooms", {}).get("join", {}).items()
} # type: Dict
self.handle_sync(presence, ephemeral)
next_batch = sync_resp.get("next_batch", None)
except (MatrixRequestError, ServerDisconnectedError) as e:
wait = min(errors, 11) ** 2
self.log.warning(f"Syncer for {custom_mxid} errored: {e}. "
f"Waiting for {wait} seconds...")
errors += 1
await asyncio.sleep(wait)
self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.")
# endregion
# region DB conversion
@property
def db_instance(self) -> DBPuppet:
if not self._db_instance:
self._db_instance = self.new_db_instance()
return self._db_instance
def new_db_instance(self) -> DBPuppet:
return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid,
username=self.username, displayname=self.displayname,
displayname_source=self.displayname_source, photo_id=self.photo_id,
is_bot=self.is_bot, matrix_registered=self.is_registered,
disable_updates=self.disable_updates)
@classmethod
def from_db(cls, db_puppet):
return Puppet(db_puppet.id, db_puppet.username, db_puppet.displayname, db_puppet.photo_id)
def from_db(cls, db_puppet: DBPuppet) -> 'Puppet':
return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid,
db_puppet.username, db_puppet.displayname, db_puppet.displayname_source,
db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered,
db_puppet.disable_updates, db_instance=db_puppet)
def save(self):
self.to_db()
self.db.commit()
def save(self) -> None:
self.db_instance.update(access_token=self.access_token, custom_mxid=self.custom_mxid,
username=self.username, displayname=self.displayname,
displayname_source=self.displayname_source, photo_id=self.photo_id,
is_bot=self.is_bot, matrix_registered=self.is_registered,
disable_updates=self.disable_updates)
def similarity(self, query):
# endregion
# region Info updating
def similarity(self, query: str) -> int:
username_similarity = (SequenceMatcher(None, self.username, query).ratio()
if self.username else 0)
displayname_similarity = (SequenceMatcher(None, self.displayname, query).ratio()
if self.displayname else 0)
similarity = max(username_similarity, displayname_similarity)
return round(similarity * 1000) / 10
return int(round(similarity * 100))
@staticmethod
def get_displayname(info, format=True):
def get_displayname(info: User, enable_format: bool = True) -> str:
data = {
"phone number": info.phone if hasattr(info, "phone") else None,
"username": info.username,
@@ -82,22 +325,26 @@ class Puppet:
"first name": info.first_name,
"last name": info.last_name,
}
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 not name:
if isinstance(info, User) and info.deleted:
name = f"Deleted account {info.id}"
elif not name:
name = info.id
if not format:
if not enable_format:
return name
return config.get("bridge.displayname_template", "{displayname} (Telegram)").format(
return config["bridge.displayname_template"].format(
displayname=name)
async def update_info(self, source, info):
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
@@ -105,78 +352,180 @@ class Puppet:
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
changed = await self.update_avatar(source, info.photo) or changed
self.is_bot = info.bot
if changed:
self.save()
async def update_displayname(self, source, info):
async def update_displayname(self, source: 'AbstractUser', info: Union[User, UpdateUserName]
) -> bool:
if self.disable_updates:
return False
is_main_source = (source.is_relaybot or (self.displayname_source is not None
and self.displayname_source == source.tgid))
# No phone -> not in contact list -> can't set custom name -> name is trustworthy
is_trustworthy_source = isinstance(info, User) and info.phone is None
if not is_main_source and not is_trustworthy_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.intent.set_display_name(displayname)
self.displayname = displayname
return True
async def update_avatar(self, source, photo):
photo_id = f"{photo.volume_id}-{photo.local_id}"
if self.photo_id != photo_id:
self.displayname_source = source.tgid
try:
file = await source.client.download_file_bytes(photo)
except LocationInvalidError:
return False
uploaded = await self.intent.upload_file(file)
await self.intent.set_avatar(uploaded["content_uri"])
self.photo_id = photo_id
await self.default_mxid_intent.set_display_name(displayname)
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: 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:
if not photo_id:
self.photo_id = ""
try:
await self.default_mxid_intent.set_avatar("")
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:
self.photo_id = photo_id
try:
await self.default_mxid_intent.set_avatar(file.mxc)
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
return True
return False
# endregion
# region Getters
@classmethod
def get(cls, id, create=True):
def get(cls, tgid: TelegramID, create: bool = True) -> Optional['Puppet']:
try:
return cls.cache[id]
return cls.cache[tgid]
except KeyError:
pass
puppet = DBPuppet.query.get(id)
puppet = DBPuppet.get_by_tgid(tgid)
if puppet:
return cls.from_db(puppet)
if create:
puppet = cls(id)
cls.db.add(puppet.to_db())
cls.db.commit()
puppet = cls(tgid)
puppet.db_instance.insert()
return puppet
return None
@classmethod
def get_by_mxid(cls, mxid, create=True):
def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['Puppet']:
tgid = cls.get_id_from_mxid(mxid)
return cls.get(tgid, create) if tgid else None
if tgid:
return cls.get(tgid, create)
return None
@classmethod
def get_id_from_mxid(cls, mxid):
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
if not mxid:
raise ValueError("Matrix ID can't be empty")
try:
return cls.by_custom_mxid[mxid]
except KeyError:
pass
puppet = DBPuppet.get_by_custom_mxid(mxid)
if puppet:
puppet = cls.from_db(puppet)
return puppet
return None
@classmethod
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.all_with_custom_mxid())
@classmethod
def get_id_from_mxid(cls, mxid: MatrixUserID) -> Optional[TelegramID]:
match = cls.mxid_regex.match(mxid)
if match:
return int(match.group(1))
return TelegramID(int(match.group(1)))
return None
@classmethod
def find_by_username(cls, username):
def get_mxid_from_id(cls, tgid: TelegramID) -> MatrixUserID:
return MatrixUserID(f"@{cls.username_template.format(userid=tgid)}:{cls.hs_domain}")
@classmethod
def find_by_username(cls, username: str) -> Optional['Puppet']:
if not username:
return None
for _, puppet in cls.cache.items():
if puppet.username == username:
if puppet.username and puppet.username.lower() == username.lower():
return puppet
puppet = DBPuppet.query.filter(DBPuppet.username == username).one_or_none()
if puppet:
return cls.from_db(puppet)
dbpuppet = DBPuppet.get_by_username(username)
if dbpuppet:
return cls.from_db(dbpuppet)
return None
@classmethod
def find_by_displayname(cls, displayname: str) -> Optional['Puppet']:
if not displayname:
return None
def init(context):
for _, puppet in cls.cache.items():
if puppet.displayname and puppet.displayname == displayname:
return puppet
dbpuppet = DBPuppet.get_by_displayname(displayname)
if dbpuppet:
return cls.from_db(dbpuppet)
return None
# endregion
def init(context: 'Context') -> List[Awaitable[Any]]: # [None, None, PuppetError]
global config
Puppet.az, Puppet.db, config, _ = context
localpart = config.get("bridge.username_template", "telegram_{userid}").format(userid="(.+)")
hs = config["homeserver"]["domain"]
Puppet.mxid_regex = re.compile(f"@{localpart}:{hs}")
Puppet.az, config, Puppet.loop, _ = context.core
Puppet.mx = context.mx
Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}")
Puppet.hs_domain = config["homeserver"]["domain"]
Puppet.mxid_regex = re.compile(
f"@{Puppet.username_template.format(userid='([0-9]+)')}:{Puppet.hs_domain}")
return [puppet.init_custom_mxid() for puppet in Puppet.all_with_custom_mxid()]
@@ -0,0 +1,81 @@
import argparse
import sqlalchemy as sql
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declarative_base
from alchemysession import AlchemySessionContainer
parser = argparse.ArgumentParser(description="mautrix-telegram dbms migration script",
prog="python -m mautrix_telegram.scripts.dbms_migrate")
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.db.base as base
base.Base = declarative_base(cls=base.BaseBase)
from mautrix_telegram.db import (Portal, Message, UserPortal, User, RoomState, UserProfile,
Contact, Puppet, BotChat, TelegramFile)
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
session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
table_base=base.Base, table_prefix="telethon_",
manage_tables=False)
return db_session, {
"Version": session_container.Version,
"Session": session_container.Session,
"Entity": session_container.Entity,
"SentFile": session_container.SentFile,
"UpdateState": session_container.UpdateState,
"Portal": Portal,
"Message": Message,
"Puppet": Puppet,
"User": User,
"UserPortal": UserPortal,
"RoomState": RoomState,
"UserProfile": UserProfile,
"Contact": Contact,
"BotChat": BotChat,
"TelegramFile": TelegramFile,
}
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!")
@@ -0,0 +1,124 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
from sqlalchemy import orm
import sqlalchemy as sql
import argparse
from mautrix_telegram.db import Base, 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(
description="mautrix-telegram telematrix import script",
prog="python -m mautrix_telegram.scripts.telematrix_import")
parser.add_argument("-c", "--config", type=str, default="config.yaml",
metavar="<path>", help="the path to your mautrix-telegram config file")
parser.add_argument("-b", "--bot-id", type=int, required=True,
metavar="<id>", help="the telegram user ID of your relay bot")
parser.add_argument("-t", "--telematrix-database", type=str, default="sqlite:///database.db",
metavar="<url>", help="your telematrix database URL")
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 = orm.sessionmaker(bind=mxtg_db_engine)()
Base.metadata.bind = mxtg_db_engine
telematrix_db_engine = sql.create_engine(args.telematrix_database)
telematrix = orm.sessionmaker(bind=telematrix_db_engine)()
TelematrixBase.metadata.bind = telematrix_db_engine
chat_links = telematrix.query(ChatLink).all()
tg_users = telematrix.query(TgUser).all()
mx_users = telematrix.query(MatrixUser).all()
tm_messages = telematrix.query(TMMessage).all()
telematrix.close()
telematrix_db_engine.dispose()
portals_by_tgid = {} # type: Dict[int, Portal]
portals_by_mxid = {} # type: Dict[str, Portal]
chats = {} # type: Dict[int, BotChat]
messages = {} # type: Dict[str, Message]
puppets = {} # type: Dict[int, Puppet]
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)
continue
if chat_link.tg_room >= 0:
print("Unexpected unprefixed telegram chat ID: %s, ignoring..." % chat_link.tg_room)
continue
tgid = str(chat_link.tg_room)
if tgid.startswith("-100"):
tgid = int(tgid[4:])
peer_type = "channel"
megagroup = True
else:
tgid = -chat_link.tg_room
peer_type = "chat"
megagroup = False
portal = Portal(tgid=tgid, tg_receiver=tgid, peer_type=peer_type, megagroup=megagroup,
mxid=chat_link.matrix_room)
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_by_tgid[tm_msg.tg_group_id]
except KeyError:
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,
tgid=tm_msg.tg_message_id, tg_space=tg_space)
messages[tm_msg.matrix_event_id] = message
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_by_tgid.items():
mxtg.add(v)
for k, v in chats.items():
mxtg.add(v)
for k, v in messages.items():
mxtg.add(v)
for k, v in puppets.items():
mxtg.add(v)
mxtg.commit()
@@ -0,0 +1,44 @@
import sqlalchemy as sa
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class ChatLink(Base):
__tablename__ = "chat_link"
id = sa.Column(sa.Integer, primary_key=True)
matrix_room = sa.Column(sa.String)
tg_room = sa.Column(sa.BigInteger)
active = sa.Column(sa.Boolean)
class TgUser(Base):
__tablename__ = "tg_user"
id = sa.Column(sa.Integer, primary_key=True)
tg_id = sa.Column(sa.BigInteger)
name = sa.Column(sa.String)
profile_pic_id = sa.Column(sa.String, nullable=True)
class MatrixUser(Base):
__tablename__ = "matrix_user"
id = sa.Column(sa.Integer, primary_key=True)
matrix_id = sa.Column(sa.String)
name = sa.Column(sa.String)
class Message(Base):
"""Describes a message in a room bridged between Telegram and Matrix"""
__tablename__ = "message"
id = sa.Column(sa.Integer, primary_key=True)
tg_group_id = sa.Column(sa.BigInteger)
tg_message_id = sa.Column(sa.BigInteger)
matrix_room_id = sa.Column(sa.String)
matrix_event_id = sa.Column(sa.String)
displayname = sa.Column(sa.String)
+119
View File
@@ -0,0 +1,119 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Tuple
from mautrix_appservice import StateStore
from .types import MatrixUserID, MatrixRoomID
from . import puppet as pu
from .db import RoomState, UserProfile
class SQLStateStore(StateStore):
def __init__(self) -> None:
super().__init__()
self.profile_cache = {} # type: Dict[Tuple[str, str], UserProfile]
self.room_state_cache = {} # type: Dict[str, RoomState]
@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)
if puppet:
puppet.is_registered = True
puppet.save()
def update_state(self, event: Dict) -> None:
event_type = event["type"]
if event_type == "m.room.power_levels":
self.set_power_levels(event["room_id"], event["content"])
elif event_type == "m.room.member":
self.set_member(event["room_id"], event["state_key"], event["content"])
def _get_user_profile(self, room_id: MatrixRoomID, user_id: MatrixUserID, create: bool = True
) -> UserProfile:
key = (room_id, user_id)
try:
return self.profile_cache[key]
except KeyError:
pass
profile = UserProfile.get(*key)
if profile:
self.profile_cache[key] = profile
elif create:
profile = UserProfile(room_id=room_id, user_id=user_id, membership="leave")
profile.insert()
self.profile_cache[key] = profile
return profile
def get_member(self, room: MatrixRoomID, user: MatrixUserID) -> Dict:
return self._get_user_profile(room, user).dict()
def set_member(self, room: MatrixRoomID, user: MatrixUserID, member: Dict) -> None:
profile = self._get_user_profile(room, user)
profile.membership = member.get("membership", profile.membership or "leave")
profile.displayname = member.get("displayname", profile.displayname)
profile.avatar_url = member.get("avatar_url", profile.avatar_url)
profile.update()
def set_membership(self, room: MatrixRoomID, user: MatrixUserID, membership: str) -> None:
self.set_member(room, user, {
"membership": membership,
})
def _get_room_state(self, room_id: MatrixRoomID, create: bool = True) -> RoomState:
try:
return self.room_state_cache[room_id]
except KeyError:
pass
room = RoomState.get(room_id)
if room:
self.room_state_cache[room_id] = room
elif create:
room = RoomState(room_id=room_id)
room.insert()
self.room_state_cache[room_id] = room
return room
def has_power_levels(self, room: MatrixRoomID) -> bool:
return bool(self._get_room_state(room).power_levels)
def get_power_levels(self, room: MatrixRoomID) -> Dict:
return self._get_room_state(room).power_levels
def set_power_level(self, room: MatrixRoomID, user: MatrixUserID, level: int) -> None:
room_state = self._get_room_state(room)
power_levels = room_state.power_levels
if not power_levels:
power_levels = {
"users": {},
"events": {},
}
power_levels[room]["users"][user] = level
room_state.power_levels = power_levels
room_state.update()
def set_power_levels(self, room: MatrixRoomID, content: Dict) -> None:
state = self._get_room_state(room)
state.power_levels = content
state.update()
+30 -61
View File
@@ -1,84 +1,53 @@
# -*- 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from io import BytesIO
# 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, Union, Optional
from telethon import TelegramClient
from telethon.tl.functions.messages import SendMessageRequest, SendMediaRequest
from telethon.tl.types import *
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.patched import Message
class MautrixTelegramClient(TelegramClient):
async def send_message(self, entity, message, reply_to=None, entities=None, link_preview=True):
entity = await self.get_input_entity(entity)
async def upload_file_direct(self, file: bytes, mime_type: str = None,
attributes: List[TypeDocumentAttribute] = None,
file_name: str = None, max_image_size: float = 10 * 1000 ** 2,
) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]:
file_handle = await super().upload_file(file, file_name=file_name, use_cache=False)
request = SendMessageRequest(
peer=entity,
message=message,
entities=entities,
no_webpage=not link_preview,
reply_to_msg_id=self._get_message_id(reply_to)
)
result = await self(request)
if isinstance(result, UpdateShortSentMessage):
return Message(
id=result.id,
to_id=entity,
message=message,
date=result.date,
out=result.out,
media=result.media,
entities=result.entities
)
return self._get_response_message(request, result)
async def send_file(self, entity, file, mime_type=None, caption=None, attributes=None,
file_name=None, reply_to=None, **kwargs):
entity = await self.get_input_entity(entity)
reply_to = self._get_message_id(reply_to)
file_handle = await self.upload_file(file, file_name=file_name, use_cache=False)
if mime_type == "image/png":
media = InputMediaUploadedPhoto(file_handle, caption or "")
if (mime_type == "image/png" or mime_type == "image/jpeg") and len(file) < max_image_size:
return InputMediaUploadedPhoto(file_handle)
else:
attributes = attributes or []
attr_dict = {type(attr): attr for attr in attributes}
media = InputMediaUploadedDocument(
return InputMediaUploadedDocument(
file=file_handle,
mime_type=mime_type or "application/octet-stream",
attributes=list(attr_dict.values()),
caption=caption or "")
attributes=list(attr_dict.values()))
request = SendMediaRequest(entity, media, reply_to_msg_id=reply_to)
return self._get_response_message(request, await self(request))
async def download_file_bytes(self, location):
if isinstance(location, Document):
location = InputDocumentFileLocation(location.id, location.access_hash,
location.version)
elif not isinstance(location, (InputFileLocation, InputDocumentFileLocation)):
location = InputFileLocation(location.volume_id, location.local_id, location.secret)
file = BytesIO()
await self.download_file(location, file)
data = file.getvalue()
file.close()
return data
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[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 [],
reply_to_msg_id=reply_to)
return self._get_response_message(request, await self(request), entity)
+9
View File
@@ -0,0 +1,9 @@
from typing import Dict, NewType
MatrixUserID = NewType('MatrixUserID', str)
MatrixRoomID = NewType('MatrixRoomID', str)
MatrixEventID = NewType('MatrixEventID', str)
MatrixEvent = NewType('MatrixEvent', Dict)
TelegramID = NewType('TelegramID', int)
+208 -274
View File
@@ -1,194 +1,253 @@
# -*- 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 General Public License as published by
# 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 General Public License for more details.
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, Dict, List, Iterable, Match, NewType, Optional, Tuple, TYPE_CHECKING
import logging
import asyncio
import platform
import re
from telethon.tl.types import *
from telethon.tl.types import (
TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser,
UpdateShortChatMessage, UpdateShortMessage, User as TLUser)
from telethon.tl.types.contacts import ContactsNotModified
from telethon.tl.types import User as TLUser
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
from telethon.tl.functions.account import UpdateStatusRequest
from mautrix_appservice import MatrixRequestError
from .db import User as DBUser, Message as DBMessage, Contact as DBContact
from .tgclient import MautrixTelegramClient
from . import portal as po, puppet as pu, __version__
from .types import MatrixUserID, TelegramID
from .db import User as DBUser
from .abstract_user import AbstractUser
from . import portal as po, puppet as pu
config = None
if TYPE_CHECKING:
from .config import Config
from .context import Context
config = None # type: Config
SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int])
class User:
loop = None
log = logging.getLogger("mau.user")
db = None
az = None
by_mxid = {}
by_tgid = {}
class User(AbstractUser):
log = logging.getLogger("mau.user") # type: logging.Logger
by_mxid = {} # type: Dict[str, User]
by_tgid = {} # type: Dict[int, User]
def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0,
db_portals=None):
self.mxid = mxid
self.tgid = tgid
self.username = username
self.contacts = []
self.saved_contacts = saved_contacts
def __init__(self, mxid: MatrixUserID, tgid: Optional[TelegramID] = None,
username: Optional[str] = None, phone: Optional[str] = None,
db_contacts: Optional[Iterable[TelegramID]] = None,
saved_contacts: int = 0, is_bot: bool = False,
db_portals: Optional[Iterable[Tuple[TelegramID, TelegramID]]] = None,
db_instance: Optional[DBUser] = None) -> None:
super().__init__()
self.mxid = mxid # type: MatrixUserID
self.tgid = tgid # type: TelegramID
self.is_bot = is_bot # type: bool
self.username = username # type: str
self.phone = phone # type: str
self.contacts = [] # type: List[pu.Puppet]
self.saved_contacts = saved_contacts # type: int
self.db_contacts = db_contacts
self.portals = {}
self.db_portals = db_portals
self.portals = {} # type: Dict[Tuple[TelegramID, TelegramID], po.Portal]
self.db_portals = db_portals or []
self._db_instance = db_instance # type: Optional[DBUser]
self.command_status = None
self.connected = False
self.client = None
self._init_client()
self.command_status = None # type: Optional[Dict]
self.is_admin = self.mxid in config.get("bridge.admins", [])
whitelist = config.get("bridge.whitelist", None) or [self.mxid]
self.whitelisted = not whitelist or self.mxid in whitelist
if not self.whitelisted:
homeserver = self.mxid[self.mxid.index(":") + 1:]
self.whitelisted = homeserver in whitelist
(self.relaybot_whitelisted,
self.whitelisted,
self.puppet_whitelisted,
self.matrix_puppet_whitelisted,
self.is_admin,
self.permissions) = config.get_permissions(self.mxid)
self.by_mxid[mxid] = self
if tgid:
self.by_tgid[tgid] = self
@property
def logged_in(self):
return self.client.is_user_authorized()
def name(self) -> str:
return self.mxid
@property
def has_full_access(self):
return self.logged_in and self.whitelisted
def mxid_localpart(self) -> str:
match = re.compile("@(.+):(.+)").match(self.mxid) # type: Match
return match.group(1)
@property
def db_contacts(self):
return [self.db.merge(DBContact(user=self.tgid, contact=puppet.id))
for puppet in self.contacts]
def human_tg_id(self) -> str:
return f"@{self.username}" if self.username else f"+{self.phone}" or None
# TODO replace with proper displayname getting everywhere
@property
def displayname(self) -> str:
return self.mxid_localpart
@property
def plain_displayname(self) -> str:
return self.displayname
@property
def db_contacts(self) -> Iterable[TelegramID]:
return (puppet.id
for puppet in self.contacts
if puppet)
@db_contacts.setter
def db_contacts(self, contacts):
if contacts:
self.contacts = [pu.Puppet.get(entry.contact) for entry in contacts]
else:
self.contacts = []
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):
return [portal.to_db(merge=False) for _, portal in self.portals.items()]
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):
if portals:
self.portals = {(portal.tgid, portal.tg_receiver):
po.Portal.get_by_tgid(portal.tgid, portal.tg_receiver)
for portal in portals}
else:
self.portals = {}
def db_portals(self, portals: Iterable[Tuple[TelegramID, TelegramID]]) -> None:
self.portals = {
tgid_full: po.Portal.get_by_tgid(*tgid_full)
for tgid_full in portals
} if portals else {}
# region Database conversion
def to_db(self):
return self.db.merge(
DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username,
contacts=self.db_contacts, saved_contacts=self.saved_contacts,
portals=self.db_portals))
@property
def db_instance(self) -> DBUser:
if not self._db_instance:
self._db_instance = self.new_db_instance()
return self._db_instance
def save(self):
self.to_db()
self.db.commit()
def new_db_instance(self) -> DBUser:
return DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username,
saved_contacts=self.saved_contacts, portals=self.db_portals)
def delete(self):
def save(self, contacts: bool = False, portals: bool = False) -> None:
self.db_instance.update(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone,
saved_contacts=self.saved_contacts)
if contacts:
self.db_instance.contacts = self.db_contacts
if portals:
self.db_instance.portals = self.db_portals
def delete(self, delete_db: bool = True) -> None:
try:
del self.by_mxid[self.mxid]
del self.by_tgid[self.tgid]
except KeyError:
pass
self.db.delete(self.to_db())
self.db.commit()
if delete_db and self._db_instance:
self._db_instance.delete()
@classmethod
def from_db(cls, db_user):
return User(db_user.mxid, db_user.tgid, db_user.tg_username, db_user.contacts,
db_user.saved_contacts, db_user.portals)
def from_db(cls, db_user: DBUser) -> 'User':
return User(db_user.mxid, db_user.tgid, db_user.tg_username, db_user.tg_phone,
db_user.contacts, db_user.saved_contacts, False, db_user.portals,
db_instance=db_user)
# endregion
# region Telegram connection management
def _init_client(self):
device = f"{platform.system()} {platform.release()}"
sysversion = MautrixTelegramClient.__version__
self.client = MautrixTelegramClient(self.mxid,
config["telegram.api_id"],
config["telegram.api_hash"],
loop=self.loop,
app_version=__version__,
system_version=sysversion,
device_model=device)
self.client.add_update_handler(self.update_catch)
def ensure_started(self, even_if_no_session=False) -> Awaitable['User']:
return super().ensure_started(even_if_no_session)
async def start(self, delete_unless_authenticated=False):
self.connected = await self.client.connect()
if self.logged_in:
async def start(self, delete_unless_authenticated: bool = False) -> 'User':
await super().start()
if await self.is_logged_in():
self.log.debug(f"Ensuring post_login() for {self.name}")
asyncio.ensure_future(self.post_login(), loop=self.loop)
elif delete_unless_authenticated:
# User not logged in -> forget user
self.client.disconnect()
self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting session...")
await self.client.disconnect()
self.client.session.delete()
self.delete()
return self
async def post_login(self, info=None):
async def post_login(self, info: TLUser = None) -> None:
try:
await self.update_info(info)
await self.sync_dialogs()
await self.sync_contacts()
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")
self.log.exception("Failed to run post-login functions for %s", self.mxid)
def stop(self):
self.client.disconnect()
self.client = None
self.connected = False
async def update(self, update: TypeUpdate) -> bool:
if not self.is_bot:
return False
if isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
message = update.message
if isinstance(message.to_id, PeerUser) and not message.out:
portal = po.Portal.get_by_tgid(message.from_id, peer_type="user",
tg_receiver=self.tgid)
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")
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
else:
return False
if portal:
self.register_portal(portal)
return True
# endregion
# region Telegram actions that need custom methods
async def update_info(self, info=None):
async def set_presence(self, online: bool = True) -> None:
if not self.is_bot:
await self.client(UpdateStatusRequest(offline=not online))
async def update_info(self, info: TLUser = None) -> None:
info = info or await self.client.get_me()
changed = False
if self.is_bot != info.bot:
self.is_bot = info.bot
changed = True
if self.username != info.username:
self.username = info.username
changed = True
if self.phone != info.phone:
self.phone = info.phone
changed = True
if self.tgid != info.id:
self.tgid = info.id
self.by_tgid[self.tgid] = self
if changed:
self.save()
async def log_out(self):
async def log_out(self) -> bool:
puppet = pu.Puppet.get(self.tgid)
if puppet.is_real_user:
await puppet.switch_mxid(None, None)
for _, portal in self.portals.items():
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.")
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]
@@ -202,28 +261,30 @@ class User:
self.delete()
return True
def _search_local(self, query, max_results=5, min_similarity=45):
results = []
def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45
) -> List[SearchResult]:
results = [] # type: List[SearchResult]
for contact in self.contacts:
similarity = contact.similarity(query)
if similarity >= min_similarity:
results.append((contact, similarity))
results.append(SearchResult((contact, similarity)))
results.sort(key=lambda tup: tup[1], reverse=True)
return results[0:max_results]
async def _search_remote(self, query, max_results=5):
async def _search_remote(self, query: str, max_results: int = 5) -> List[SearchResult]:
if len(query) < 5:
return []
server_results = await self.client(SearchRequest(q=query, limit=max_results))
results = []
results = [] # type: List[SearchResult]
for user in server_results.users:
puppet = pu.Puppet.get(user.id)
await puppet.update_info(self, user)
results.append((puppet, puppet.similarity(query)))
results.append(SearchResult((puppet, puppet.similarity(query))))
results.sort(key=lambda tup: tup[1], reverse=True)
return results[0:max_results]
async def search(self, query, force_remote=False):
async def search(self, query: str, force_remote: bool = False
) -> Tuple[List[SearchResult], bool]:
if force_remote:
return await self._search_remote(query), True
@@ -233,232 +294,105 @@ class User:
return await self._search_remote(query), True
async def sync_dialogs(self):
dialogs = await self.client.get_dialogs(limit=30)
async def sync_dialogs(self, synchronous_create: bool = False) -> None:
creators = []
for dialog in dialogs:
entity = dialog.entity
invalid = (isinstance(entity, (TLUser, ChatForbidden, ChannelForbidden))
or (isinstance(entity, Chat) and (entity.deactivated or entity.left)))
if invalid:
continue
for entity in await self.get_dialogs(limit=config["bridge.sync_dialog_limit"] or None):
portal = po.Portal.get_by_entity(entity)
self.portals[portal.tgid_full] = portal
creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid]))
self.save()
creators.append(
portal.create_matrix_room(self, entity, invites=[self.mxid],
synchronous=synchronous_create))
self.save(portals=True)
await asyncio.gather(*creators, loop=self.loop)
def register_portal(self, portal):
def register_portal(self, portal: po.Portal) -> None:
try:
if self.portals[portal.tgid_full] == portal:
return
except KeyError:
pass
self.portals[portal.tgid_full] = portal
self.save()
self.save(portals=True)
def unregister_portal(self, portal):
def unregister_portal(self, portal: po.Portal) -> None:
try:
del self.portals[portal.tgid_full]
self.save()
self.save(portals=True)
except KeyError:
pass
def _hash_contacts(self):
async def needs_relaybot(self, portal: po.Portal) -> bool:
return not await self.is_logged_in() or (
(portal.has_bot or self.bot) and portal.tgid_full not in self.portals)
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):
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()
# endregion
# region Telegram update handling
async def update_catch(self, update):
try:
await self.update(update)
except Exception:
self.log.exception("Failed to handle Telegram update")
async def update(self, update):
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
await self.update_message(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)):
await self.update_typing(update)
elif isinstance(update, UpdateUserStatus):
await self.update_status(update)
elif isinstance(update, (UpdateChatAdmins, UpdateChatParticipantAdmin)):
await self.update_admin(update)
elif isinstance(update, UpdateChatParticipants):
portal = po.Portal.get_by_tgid(update.participants.chat_id)
if portal and portal.mxid:
await portal.update_telegram_participants(update.participants.participants)
elif isinstance(update, UpdateChannelPinnedMessage):
portal = po.Portal.get_by_tgid(update.channel_id)
if portal and portal.mxid:
await portal.update_telegram_pin(self, update.id)
elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
await self.update_others_info(update)
elif isinstance(update, UpdateReadHistoryOutbox):
await self.update_read_receipt(update)
else:
self.log.debug("Unhandled update: %s", update)
async def update_read_receipt(self, update):
if not isinstance(update.peer, PeerUser):
self.log.debug("Unexpected read receipt peer: %s", update.peer)
return
portal = po.Portal.get_by_tgid(update.peer.user_id, self.tgid)
if not portal or not portal.mxid:
return
# We check that these are user read receipts, so tg_space is always the user ID.
message = DBMessage.query.get((update.max_id, self.tgid))
if not message:
return
puppet = pu.Puppet.get(update.peer.user_id)
await puppet.intent.mark_read(portal.mxid, message.mxid)
async def update_admin(self, update):
portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
if isinstance(update, UpdateChatAdmins):
await portal.set_telegram_admins_enabled(update.enabled)
elif isinstance(update, UpdateChatParticipantAdmin):
puppet = pu.Puppet.get(update.user_id)
user = User.get_by_tgid(update.user_id)
await portal.set_telegram_admin(puppet, user)
async def update_typing(self, update):
if isinstance(update, UpdateUserTyping):
portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user")
else:
portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
sender = pu.Puppet.get(update.user_id)
await portal.handle_telegram_typing(sender, update)
async def update_others_info(self, update):
puppet = pu.Puppet.get(update.user_id)
if isinstance(update, UpdateUserName):
if await puppet.update_displayname(self, update):
puppet.save()
elif isinstance(update, UpdateUserPhoto):
if await puppet.update_avatar(self, update.photo.photo_big):
puppet.save()
async def update_status(self, update):
puppet = pu.Puppet.get(update.user_id)
if isinstance(update.status, UserStatusOnline):
await puppet.intent.set_presence("online")
elif isinstance(update.status, UserStatusOffline):
await puppet.intent.set_presence("offline")
else:
self.log.warning("Unexpected user status update: %s", update)
return
def get_message_details(self, update):
if isinstance(update, UpdateShortChatMessage):
portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
sender = pu.Puppet.get(update.from_id)
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user")
sender = pu.Puppet.get(self.tgid if update.out else update.user_id)
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
UpdateEditMessage, UpdateEditChannelMessage)):
update = update.message
if isinstance(update.to_id, PeerUser) and not update.out:
portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
tg_receiver=self.tgid)
else:
portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
sender = pu.Puppet.get(update.from_id) if update.from_id else None
else:
self.log.warning(
f"Unexpected message type in User#get_message_details: {type(update)}")
return update, None, None
return update, sender, portal
def update_message(self, original_update):
update, sender, portal = self.get_message_details(original_update)
if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom):
self.log.debug(f"Ignoring action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
return
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
return portal.handle_telegram_action(self, sender, update.action)
user = sender.tgid if sender else "admin"
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
self.log.debug("Handling edit %s to %s by %s", update, portal.tgid_log, user)
return portal.handle_telegram_edit(self, sender, update)
self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
return portal.handle_telegram_message(self, sender, update)
self.save(contacts=True)
# endregion
# region Class instance lookup
@classmethod
def get_by_mxid(cls, mxid, create=True):
def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['User']:
if not mxid:
raise ValueError("Matrix ID can't be empty")
try:
return cls.by_mxid[mxid]
except KeyError:
pass
user = DBUser.query.get(mxid)
user = DBUser.get_by_mxid(mxid)
if user:
user = cls.from_db(user)
asyncio.ensure_future(user.start(), loop=cls.loop)
return user
if create:
user = cls(mxid)
cls.db.add(user.to_db())
cls.db.commit()
asyncio.ensure_future(user.start(), loop=cls.loop)
user.db_instance.insert()
return user
return None
@classmethod
def get_by_tgid(cls, tgid):
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)
asyncio.ensure_future(user.start(), loop=cls.loop)
return user
return None
@classmethod
def find_by_username(cls, username):
def find_by_username(cls, username: str) -> Optional['User']:
if not username:
return None
for _, user in cls.by_tgid.items():
if user.username == username:
if user.username and user.username.lower() == username.lower():
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)
@@ -466,9 +400,9 @@ class User:
# endregion
def init(context):
def init(context: 'Context') -> List[Awaitable['User']]:
global config
User.az, User.db, config, User.loop = context
config = context.config
users = [User.from_db(user) for user in DBUser.query.all()]
return [user.start(delete_unless_authenticated=True) for user in users]
users = [User.from_db(user) for user in DBUser.all()]
return [user.ensure_started() for user in users if user.tgid]
+7
View File
@@ -0,0 +1,7 @@
from .file_transfer import transfer_file_to_matrix, convert_image
from .format_duration import format_duration
from .signed_token import sign_token, verify_token
from .recursive_dict import recursive_del, recursive_set, recursive_get
def ignore_coro(coro):
pass
+223
View File
@@ -0,0 +1,223 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Tuple, Union, Dict
from io import BytesIO
import time
import logging
import asyncio
import magic
from sqlalchemy.exc import IntegrityError, InvalidRequestError
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
from ..tgclient import MautrixTelegramClient
from ..db import TelegramFile as DBTelegramFile
from ..util import sane_mimetypes
try:
from PIL import Image
except ImportError:
Image = None
try:
from moviepy.editor import VideoFileClip
import random
import string
import os
import mimetypes
except ImportError:
VideoFileClip = random = string = os = mimetypes = None
log = logging.getLogger("mau.util") # type: logging.Logger
TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation,
InputFileLocation, InputPhotoFileLocation]
def convert_image(file: bytes, source_mime: str = "image/webp", target_type: str = "png",
thumbnail_to: Optional[Tuple[int, int]] = None
) -> Tuple[str, bytes, Optional[int], Optional[int]]:
if not Image:
return source_mime, file, None, None
try:
image = Image.open(BytesIO(file)).convert("RGBA") # type: Image.Image
if thumbnail_to:
image.thumbnail(thumbnail_to, Image.ANTIALIAS)
new_file = BytesIO()
image.save(new_file, target_type)
w, h = image.size
return f"image/{target_type}", new_file.getvalue(), w, h
except Exception:
log.exception(f"Failed to convert {source_mime} to {target_type}")
return source_mime, file, None, None
def _temp_file_name(ext: str) -> str:
return ("/tmp/mxtg-video-"
+ "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10))
+ ext)
def _read_video_thumbnail(data: bytes, video_ext: str = "mp4", frame_ext: str = "png",
max_size: Tuple[int, int] = (1024, 720)) -> Tuple[bytes, int, int]:
# We don't have any way to read the video from memory, so save it to disk.
temp_file = _temp_file_name(video_ext)
with open(temp_file, "wb") as file:
file.write(data)
# Read temp file and get frame
clip = VideoFileClip(temp_file)
frame = clip.get_frame(0)
# Convert to png and save to BytesIO
image = Image.fromarray(frame).convert("RGBA")
thumbnail_file = BytesIO()
if max_size:
image.thumbnail(max_size, Image.ANTIALIAS)
image.save(thumbnail_file, frame_ext)
os.remove(temp_file)
w, h = image.size
return thumbnail_file.getvalue(), w, h
def _location_to_id(location: TypeLocation) -> str:
if isinstance(location, (Document, InputDocumentFileLocation, InputPhotoFileLocation)):
return f"{location.id}-{location.access_hash}"
elif isinstance(location, (InputFileLocation, InputPeerPhotoFileLocation)):
return f"{location.volume_id}-{location.local_id}"
async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
thumbnail_loc: TypeLocation, video: bytes,
mime: str) -> Optional[DBTelegramFile]:
if not Image or not VideoFileClip:
return None
loc_id = _location_to_id(thumbnail_loc)
if not loc_id:
return None
db_file = DBTelegramFile.get(loc_id)
if db_file:
return db_file
video_ext = sane_mimetypes.guess_extension(mime)
if VideoFileClip and video_ext:
try:
file, width, height = _read_video_thumbnail(video, video_ext, frame_ext="png")
except OSError:
return None
mime_type = "image/png"
else:
file = await client.download_file(thumbnail_loc)
width, height = None, None
mime_type = magic.from_buffer(file, mime=True)
content_uri = await intent.upload_file(file, mime_type)
db_file = DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
was_converted=False, timestamp=int(time.time()), size=len(file),
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]
TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]]
async def transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
location: TypeLocation, thumbnail: TypeThumbnail = None,
is_sticker: bool = False) -> Optional[DBTelegramFile]:
location_id = _location_to_id(location)
if not location_id:
return None
db_file = DBTelegramFile.get(location_id)
if db_file:
return db_file
try:
lock = transfer_locks[location_id]
except KeyError:
lock = asyncio.Lock()
transfer_locks[location_id] = lock
async with lock:
return await _unlocked_transfer_file_to_matrix(client, intent, location_id, location,
thumbnail, is_sticker)
async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
loc_id: str, location: TypeLocation,
thumbnail: TypeThumbnail, is_sticker: bool
) -> 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
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
content_uri = await intent.upload_file(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
db_file.thumbnail = await transfer_thumbnail_to_matrix(client, intent, thumbnail, file,
mime_type)
try:
db_file.insert()
except (IntegrityError, InvalidRequestError) 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.")
return db_file
+36
View File
@@ -0,0 +1,36 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
def format_duration(seconds: int) -> str:
def pluralize(count: int, singular: str) -> str:
return singular if count == 1 else singular + "s"
def include(count: int, word: str) -> str:
return f"{count} {pluralize(count, word)}" if count > 0 else ""
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
parts = [a for a in [
include(days, "day"),
include(hours, "hour"),
include(minutes, "minute"),
include(seconds, "second")] if a]
if len(parts) > 2:
return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
return " and ".join(parts)
+54
View File
@@ -0,0 +1,54 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Any
from ..config import DictWithRecursion
def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool:
key, next_key = DictWithRecursion._parse_key(key)
if next_key is not None:
if key not in data:
data[key] = {}
next_data = data.get(key, {})
if not isinstance(next_data, dict):
return False
return recursive_set(next_data, next_key, value)
data[key] = value
return True
def recursive_get(data: Dict[str, Any], key: str) -> Any:
key, next_key = DictWithRecursion._parse_key(key)
if next_key is not None:
next_data = data.get(key, None)
if not next_data:
return None
return recursive_get(next_data, next_key)
return data.get(key, None)
def recursive_del(data: Dict[str, any], key: str) -> bool:
key, next_key = DictWithRecursion._parse_key(key)
if next_key is not None:
if key not in data:
return False
next_data = data.get(key, {})
return recursive_del(next_data, next_key)
if key in data:
del data[key]
return True
return False
+38
View File
@@ -0,0 +1,38 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import mimetypes
mimetypes.init()
sanity_overrides = {
"image/jpeg": ".jpeg",
"image/tiff": ".tiff",
"text/plain": ".txt",
"text/html": ".html",
"audio/mpeg": ".mp3",
"audio/ogg": ".ogg",
"application/xml": ".xml",
"application/octet-stream": "",
"application/x-msdos-program": ".exe",
}
def guess_extension(mime: str) -> str:
try:
return sanity_overrides[mime]
except KeyError:
return mimetypes.guess_extension(mime)

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