Compare commits

...

768 Commits

Author SHA1 Message Date
Tulir Asokan e3bb26aee1 handlematrix: allow bridging cached custom emoji reactions with any scheme 2026-04-30 16:55:03 +03:00
Tulir Asokan 7c2c72bbde imagepack: implement listing interface 2026-04-30 15:49:10 +03:00
Tulir Asokan 2ffbde7448 .github: add another item to bug report template 2026-04-30 13:23:26 +03:00
Tulir Asokan 2a0da7801a imagepack: move emoji shortcodes to go-util 2026-04-30 12:24:08 +03:00
Tulir Asokan eaf387abfe imagepack: switch to bridgev2 API for importing 2026-04-29 18:01:36 +03:00
Tulir Asokan 64d80c3d1d imagepack: populate cache when importing pack 2026-04-29 16:18:52 +03:00
Tulir Asokan c78b1abd2d imagepack: use emoji shortcode as fallback when importing packs 2026-04-29 14:51:38 +03:00
Tulir Asokan 12f900a7bd dependencies: update mautrix-go 2026-04-29 09:10:02 +03:00
Tulir Asokan cdb77f938a tomatrix: include external_url field in messages 2026-04-28 22:01:54 +03:00
Tulir Asokan 5a1a478992 matrixfmt: convert matrix.to links in other direction too 2026-04-28 21:46:07 +03:00
Tulir Asokan d2a06ebbbe capabilities: mark lottie and webm as allowed sticker formats 2026-04-28 16:09:13 +03:00
Tulir Asokan e6243d8935 imagepack: switch to new shared metadata field 2026-04-27 20:24:10 +03:00
Tulir Asokan 9e1c42a992 matrixfmt: fix trimming all-space entity string 2026-04-27 20:24:10 +03:00
Tulir Asokan 6eacf38d74 tomatrix: use extra field in info for custom fields 2026-04-27 20:24:10 +03:00
Gerardo Rodriguez 65fcf712d3 client: treat pool.ErrConnDead as transient in onPing (#1066) 2026-04-24 13:58:43 +03:00
Tulir Asokan 8512cfe6a6 commands/imagepack: include pack metadata in sticker info 2026-04-23 14:26:52 +03:00
Tulir Asokan 7a6d1bf17a dependencies: update mautrix-go 2026-04-20 23:22:06 +03:00
Tulir Asokan 18f831553d changelog: update 2026-04-20 16:51:51 +03:00
Tulir Asokan dce0c4dbe1 handletelegram: add support for updateBotMessageReaction
Fixes #1064
2026-04-19 17:30:20 +03:00
Tulir Asokan ac2a2c2980 legacymigrate: fix mx_room_state migration on sqlite 2026-04-16 23:11:15 +03:00
Tulir Asokan b9f0962881 Bump version to v26.04 2026-04-16 16:45:33 +03:00
lavacat d7864fcd3a client: add initial proxy support (#1062) 2026-04-16 13:14:29 +03:00
Tulir Asokan 0f0b21b22c dependencies: update mautrix-go 2026-04-15 15:32:24 +03:00
Tulir Asokan 1dd11f1086 dependencies: update mautrix-go 2026-04-14 20:09:17 +03:00
Tulir Asokan 3f155672a7 login: always set update handler 2026-04-14 17:26:33 +03:00
Tulir Asokan b761f04621 dependencies: update mautrix-go 2026-04-14 14:39:13 +03:00
Tulir Asokan 95db7a6d0d tomatrix: fix default file names 2026-04-13 16:48:19 +03:00
Tulir Asokan 8b3707b0ee handlematrix: redact previous sponsored message when sending new one 2026-04-13 12:24:02 +03:00
Tulir Asokan 4d46c5ee7c tomatrix: use m.image for bridging document images 2026-04-12 01:03:06 +03:00
Tulir Asokan 009ce8c0d3 handlematrix: remove unnecessary nil checks 2026-04-11 19:58:39 +03:00
Tulir Asokan a06b7d607d handlematrix: add video document attribute 2026-04-11 19:56:45 +03:00
Tulir Asokan 659b0f82ae dependencies: update mautrix-go 2026-04-10 23:14:46 +03:00
Tulir Asokan 53dec19878 login: increase buffer for QR renewal 2026-04-10 22:58:16 +03:00
Tulir Asokan a5b1927acb handletelegram: don't sync empty reactions on new messages 2026-04-10 20:33:31 +03:00
Tulir Asokan 0988de1267 tomatrix: consistently add extensions for all files 2026-04-10 20:19:16 +03:00
Tulir Asokan 5c1975808a tomatrix: add extension to unnamed documents from telegram 2026-04-10 20:01:30 +03:00
Tulir Asokan 2b79d842a4 dependencies: update mautrix-go 2026-04-10 20:01:17 +03:00
Tulir Asokan 6e08539d57 dependencies: update mautrix-go 2026-04-10 17:15:49 +03:00
Tulir Asokan 634cec5ba9 tomatrix: avoid multipart messages 2026-04-10 14:43:37 +03:00
Tulir Asokan 506e13f6b8 commands: remove extra dots 2026-04-10 13:21:29 +03:00
Tulir Asokan e55e596d68 commands/join: allow different invite hash lengths 2026-04-10 13:20:48 +03:00
Tulir Asokan 11495e6e7e client: fix handling mentions of non-logged-in users 2026-04-09 23:32:34 +03:00
Tulir Asokan 53574754be dependencies: update mautrix-go again 2026-04-09 22:21:06 +03:00
Tulir Asokan 3f725b37b9 dependencies: update mautrix-go 2026-04-09 21:55:08 +03:00
Tulir Asokan 2ad36b6366 legacymigrate: recreate mx_room_state on sqlite only 2026-04-09 13:15:47 +03:00
Tulir Asokan fe119542f8 Revert "legacymigrate: recreate mx_user_profile table to work around broken schemas on sqlite"
This reverts commit 98f24f9b5e.
2026-04-09 13:12:37 +03:00
Tulir Asokan 98f24f9b5e legacymigrate: recreate mx_user_profile table to work around broken schemas on sqlite 2026-04-09 13:00:25 +03:00
Tulir Asokan 60e3cf9c01 gotd/tgerr: reduce default flood wait max duration 2026-04-08 00:41:22 +03:00
Tulir Asokan cc32d48fea backfill: add support for forward backfilling more than 100 messages 2026-04-08 00:41:22 +03:00
Tulir Asokan 117c5cd0ce tomatrix: always add extension for photos 2026-04-06 18:00:08 +03:00
Tulir Asokan 41f2166feb tomatrix: fix adding per-message profile for channel messages 2026-04-06 00:38:31 +03:00
Tulir Asokan abbcacec4b readme: remove broken link 2026-04-05 21:25:00 +03:00
Tulir Asokan 92fdf7b8e9 all: fix inconsistent method receiver names 2026-04-05 21:22:22 +03:00
Tulir Asokan f13af2ef54 userinfo: add missing changed condition 2026-04-05 21:20:51 +03:00
Tulir Asokan 0172a5733b tomatrix: add support for partial quotes 2026-04-04 22:37:22 +03:00
Tulir Asokan 8350230693 legacymigrate: add create_event column to mx_room_state if necessary 2026-04-04 18:30:17 +03:00
Tulir Asokan c3216f1e4d commands/join: include chat name in response 2026-04-03 23:41:34 +03:00
Tulir Asokan 0bee8da0f8 commands: add join group command 2026-04-03 21:59:32 +03:00
Tulir Asokan 795b27275f startchat: allow username links when starting DMs 2026-04-03 21:51:56 +03:00
Tulir Asokan ba7d51e785 .github: add checklist to bug report template
[skip ci]
2026-04-03 18:16:29 +03:00
Tulir Asokan cbff082e4d config: re-add displayname template
Fixes #1057
2026-04-03 15:09:36 +03:00
Tulir Asokan 9b92aa3d50 dependencies: update mautrix-go 2026-04-03 12:13:07 +03:00
Tulir Asokan e96f12cfea changelog: mention how to fix management rooms
Fixes #1058
2026-04-03 12:12:23 +03:00
Tulir Asokan e7099d26f3 handletelegram: set dont_render_edited flag 2026-04-03 01:42:20 +03:00
Tulir Asokan 8b68fdce79 handlematrix: fix delete chat error messages 2026-04-03 00:46:09 +03:00
Tulir Asokan 1dc01bcffd handlematrix: ignore more updates in send response 2026-04-03 00:46:09 +03:00
Tulir Asokan dbab7f0ee4 commands: add upgrade command 2026-04-03 00:46:09 +03:00
Tulir Asokan 835afb0100 matrixfmt,telegramfmt: correctly bridge mentions of other logged-in users 2026-04-02 23:57:27 +03:00
Tulir Asokan 693ced7dea handletelegram: add workaround for instantly deleted messages 2026-04-02 23:53:06 +03:00
Tulir Asokan b6c7b0e78b chatinfo: always bridge own power level in channels 2026-04-02 00:51:47 +03:00
Tulir Asokan 3b939f423a handletelegram: don't refetch channel info if the event already contains it 2026-04-02 00:51:47 +03:00
Tulir Asokan f4555782cf handlematrix: fetch sponsored messages in channels after read receipt 2026-04-02 00:51:47 +03:00
Tulir Asokan 1ff046db0b changelog,roadmap: update 2026-04-01 21:36:14 +03:00
Tulir Asokan a44cc41933 tomatrix: bridge live photos as videos 2026-04-01 21:22:58 +03:00
Tulir Asokan 770b3b8d8c gotd: update to layer 224 2026-04-01 21:08:49 +03:00
Tulir Asokan 7630340ffc dependencies: update mautrix-go 2026-03-31 19:35:58 +03:00
Tulir Asokan dc2b16e1a6 changelog: add more words
Fixes #1050
Fixes #1051

[skip ci]
2026-03-31 14:21:58 +03:00
Tulir Asokan f7b220f711 reactions: handle reactions sent by channels 2026-03-30 23:15:47 +03:00
Tulir Asokan e37619e826 reactions: deduplicate reaction sync code 2026-03-30 23:06:01 +03:00
Tulir Asokan 0dc5045b00 handletelegram: don't sync reactions on channel message edit 2026-03-30 22:57:43 +03:00
Tulir Asokan c8c5c7d272 matrixfmt: fix bridging code blocks 2026-03-30 21:28:55 +03:00
Tulir Asokan c358222ab4 store: fix telegram_file migration delete query on sqlite 2026-03-30 20:57:48 +03:00
Tulir Asokan fdebeb9ca8 handletelegram: avoid redundant getCustomEmojiDocuments calls 2026-03-30 18:06:28 +03:00
Tulir Asokan bd6d40cad0 handlematrix: also read edit responses properly 2026-03-30 17:58:35 +03:00
Tulir Asokan 358318c734 commands/imagepack: use double puppet for fetching matrix packs 2026-03-30 17:41:22 +03:00
Tulir Asokan a0323a5233 handletelegram: handle UpdateMessageReactions 2026-03-30 17:20:19 +03:00
Tulir Asokan 3500590f11 handlematrix: fix reading send response 2026-03-30 17:05:42 +03:00
Tulir Asokan 011894f7b4 store: add support for slightly older postgres versions 2026-03-30 16:44:08 +03:00
Tulir Asokan af9ce963a9 userinfo: sync ghost info for non-broadcast channels too 2026-03-30 14:42:58 +03:00
Tulir Asokan 50a1c21fd1 dependencies: update mautrix-go 2026-03-30 12:58:28 +03:00
Tulir Asokan 606bf92ab1 commands/imagepack: catch both errors
(why are there two with the same description?)
2026-03-30 12:58:06 +03:00
Tulir Asokan 9b7ee5e2c3 commands/imagepack: fail when reaching pack size limit 2026-03-29 22:33:02 +03:00
Tulir Asokan e4195fadb4 commands/imagepack: add missing image/gif import 2026-03-29 22:20:21 +03:00
Tulir Asokan 7cf65b6f6a commands/imagepack: always use decoded dimensions 2026-03-29 22:15:28 +03:00
Tulir Asokan a5a3b9f380 commands/imagepack: deduplicate mxcs in same pack 2026-03-29 22:12:33 +03:00
Tulir Asokan 58d99a806a commands/imagepack: also ignore re-encoding errors 2026-03-29 22:06:56 +03:00
Tulir Asokan acf716c031 commands/imagepack: add duration length for animated emojis 2026-03-29 22:03:06 +03:00
Tulir Asokan 8de8170619 commands/imagepack: ignore images that fail to be added to pack 2026-03-29 22:03:03 +03:00
Tulir Asokan b2f99ec5c0 commands: fix image pack upload room id check 2026-03-29 21:57:02 +03:00
Tulir Asokan ec960a7372 commands: allow spaces in image pack state keys 2026-03-29 21:50:34 +03:00
Tulir Asokan 6d085f477e store: delete conflicting telegram_file rows 2026-03-29 21:38:18 +03:00
Tulir Asokan e68ef24657 commands: add support for bridging image packs 2026-03-29 21:32:58 +03:00
Tulir Asokan f7cbf751a0 store: fix GetByMXC parameter type 2026-03-29 17:44:59 +03:00
Tulir Asokan d124008443 store: fix latest version number 2026-03-29 17:13:02 +03:00
Tulir Asokan 190e65edfb media: read dimensions from file if needed 2026-03-29 17:02:13 +03:00
Tulir Asokan 4f4680b19a store: add more info to telegram_file table 2026-03-29 16:37:06 +03:00
Tulir Asokan c46a3189e0 legacymigrate: delete conflicting index 2026-03-29 16:29:25 +03:00
Tulir Asokan b084627248 commands: remove unnecessary sync types 2026-03-29 14:36:47 +03:00
Tulir Asokan ce70aacdb8 handletelegram: don't log presence updates 2026-03-29 14:25:33 +03:00
Tulir Asokan b6aff6784f matrixfmt: add support for sending pre-bridged custom emojis in text 2026-03-29 14:25:23 +03:00
Tulir Asokan 5d05d7ab05 store: normalize ids in telegram_file and add index 2026-03-29 14:05:17 +03:00
Tulir Asokan 5c37b186d8 config: clarify contact_names option 2026-03-29 14:04:32 +03:00
Tulir Asokan 0881e76205 client: fix link parser log levels 2026-03-29 13:43:30 +03:00
Tulir Asokan c5cdde83e4 tomatrix: include image mime type in url previews 2026-03-29 13:36:56 +03:00
Tulir Asokan 8bd4ff8f82 tomatrix: use channel ghost if portal not found 2026-03-29 13:36:25 +03:00
Tulir Asokan 4a538f77ef gotd: log download response length 2026-03-29 13:11:33 +03:00
Tulir Asokan f64b605443 media: assume thumbnails are jpeg 2026-03-29 13:09:56 +03:00
Tulir Asokan dbe9be2102 userinfo: ignore non-applyMinPhoto avatars 2026-03-29 02:01:26 +02:00
Tulir Asokan 0727857ed0 readme,changelog: update 2026-03-29 01:30:58 +02:00
Tulir Asokan c97c5f6bec misc: remove unused files 2026-03-29 00:27:43 +02:00
Tulir Asokan 01357fe5df ci: switch to v2-as-default script 2026-03-29 00:26:40 +02:00
Tulir Asokan d8188743ba Merge branch 'master' 2026-03-29 00:22:08 +02:00
Tulir Asokan 6d373885d2 dependencies: update mautrix-go 2026-03-28 23:58:52 +02:00
Tulir Asokan 1cd589dbd1 legacymigrate: update ghost metadata fields 2026-03-28 22:44:40 +02:00
Tulir Asokan a96bf7ed95 userinfo: remove redundant custom is bot field 2026-03-28 22:44:24 +02:00
Tulir Asokan bb405b4773 dependencies: update 2026-03-28 22:43:32 +02:00
Tulir Asokan b43adb6bab gotd: update readme
[skip ci]
2026-03-28 16:58:28 +02:00
Tulir Asokan abae7b2854 gotd: assume any response is an ack 2026-03-28 16:50:51 +02:00
Tulir Asokan 472b9df44c gotd: fix infinite loop if server keeps replying with timeout to download request 2026-03-28 16:50:51 +02:00
Nick Mills-Barrett bec7ee8f5e push: only send direct notifications if we have data (#133) 2026-03-24 10:12:04 +00:00
Tulir Asokan ae5f2f3093 dependencies: update mautrix-go again 2026-03-22 12:25:23 +02:00
Tulir Asokan 3910e44639 dependencies: update mautrix-go 2026-03-22 12:18:01 +02:00
Tulir Asokan 50aefd6897 dependencies: update mautrix-go 2026-03-22 00:06:34 +02:00
Tulir Asokan b17bb0d5c7 client: resume chat list sync after restart 2026-03-19 16:48:06 +02:00
Tulir Asokan 64724aa654 commands: restart dialog sync on command 2026-03-19 16:15:44 +02:00
Tulir Asokan 800c15f7b7 backfill: retry takeout if it gets invalidated 2026-03-19 16:14:04 +02:00
Tulir Asokan 7f71e5f09c chatinfo: look inside channelParticipantBanned 2026-03-19 14:16:53 +02:00
Tulir Asokan bfe5999951 chatsync: merge post-login and takeout syncs and refactor everything 2026-03-19 13:13:01 +02:00
Tulir Asokan b1b5745033 gotd: add max duration and log for flood wait 2026-03-19 01:36:24 +02:00
Tulir Asokan 98936fdf7a media: fix sticker dimensions 2026-03-18 21:31:53 +02:00
Tulir Asokan 7baed1c77b handletelegram: don't update remote profile with min info 2026-03-18 21:25:59 +02:00
Tulir Asokan b695e0b4ea tomatrix: add forward headers 2026-03-17 12:01:20 +02:00
Tulir Asokan 326906644e telegramfmt: ignore unrecognized entities 2026-03-16 21:54:21 +02:00
Tulir Asokan 0122ab91d6 dependencies: update mautrix-go 2026-03-16 21:54:14 +02:00
Tulir Asokan 0d818303f4 userinfo: don't apply min names 2026-03-16 17:20:17 +02:00
Tulir Asokan 7fa51da335 dependencies: update mautrix-go 2026-03-16 16:49:23 +02:00
Tulir Asokan 49d99aff82 userinfo: save source_is_contact flag properly 2026-03-15 21:27:13 +02:00
Tulir Asokan cfd9b74d34 userinfo: prefer non-contacts as info source 2026-03-15 20:39:15 +02:00
Tulir Asokan d067348ac5 commands: fix sync command section 2026-03-15 20:38:57 +02:00
Tulir Asokan 84392278c2 legacymigrate: update mx_room_state version 2026-03-15 18:53:10 +02:00
Tulir Asokan 0aed201869 userinfo: add support for avoiding contact names/avatars 2026-03-15 16:14:55 +02:00
Tulir Asokan 62efa2e7b9 userinfo: add support for getting user info via InputUserFromMessage 2026-03-15 12:49:21 +02:00
Tulir Asokan 58f40aeba5 userinfo: use min access hashes for avatars 2026-03-15 12:28:14 +02:00
Tulir Asokan 29000146ba directmedia: fix panic if url preview has no photo 2026-03-11 01:21:59 +02:00
Tulir Asokan a9cb55d109 dependencies: update mautrix-go 2026-03-06 21:10:23 +02:00
Tulir Asokan f7ae7ba804 dependencies: update mautrix-go 2026-03-04 13:58:51 +02:00
Tulir Asokan 0e45edd1f4 gotd: always set field in logger 2026-03-04 01:59:56 +02:00
Tulir Asokan 7fb4539885 gotd: don't log uploaded bytes 2026-03-03 18:13:32 +02:00
Tulir Asokan 42465f1aca handlematrix: add todo for avatar handling 2026-03-03 18:13:32 +02:00
Tulir Asokan 5bf7461566 handlematrix: convert webp images to jpeg 2026-03-03 18:13:32 +02:00
Tulir Asokan a84dd2f30c handletelegram: add log for stuck update handlers 2026-03-03 16:23:50 +02:00
Tulir Asokan 67adededff gotd/message: fix generators and update entity utilities 2026-03-03 15:16:44 +02:00
Tulir Asokan e5914196c5 gotd: update to layer 223 2026-03-03 15:13:10 +02:00
Tulir Asokan 189dbdfc52 gotd: move update dispatcher out of generator 2026-03-03 15:09:05 +02:00
Tulir Asokan 7738fc21f5 handletelegram,gotd: add missing log context 2026-03-03 14:34:02 +02:00
Tulir Asokan 4511c82cb0 gotd: only update server time offset once 2026-03-03 13:16:25 +02:00
Tulir Asokan 6af986ded5 gotd: add time synchronization 2026-02-26 18:24:51 +02:00
Tulir Asokan 93fe3cb0ea media: adjust log level of transfer log 2026-02-26 17:48:15 +02:00
Tulir Asokan 52b2373528 dependencies: update 2026-02-16 15:41:08 +02:00
Tulir Asokan a59c755dd8 login: fix context used for starting takeout 2026-02-11 13:25:32 +02:00
Tulir Asokan 4793b01a29 docker: update to Alpine 3.23 2026-02-11 00:10:56 +02:00
Tulir Asokan e8114ff5ad Pin setuptools version 2026-02-11 00:08:59 +02:00
Tulir Asokan e597eace68 login: allow retrying phone codes and 2fa passwords (#131) 2026-02-10 16:49:49 +02:00
SpiritCroc 4071502854 handletelegram: assign beeper action message content for incoming calls (#132) 2026-01-23 16:45:27 +01:00
Tulir Asokan c7a7f6ec20 dependencies: update mautrix-go 2026-01-20 12:29:37 +02:00
Tulir Asokan 8f3cc2e28e media: fix lottie stickers not being decompressed 2026-01-19 19:59:10 +02:00
Tulir Asokan 6700403118 dependencies: update 2026-01-17 01:23:17 +02:00
Tulir Asokan f4830c71d8 reactions: ignore channel reactions when polling 2026-01-12 15:02:25 +02:00
Tulir Asokan 78ba8e4d45 gotd: add missing getDifference retries 2026-01-07 16:11:41 +02:00
Tulir Asokan 2b1cfae52f tomatrix: fix sticker metadata 2025-12-29 23:25:08 +02:00
Tulir Asokan 274141a1b0 client: add lock for onTransfer
There should never be multiple clients to actually need this, but just in case
2025-12-29 23:21:08 +02:00
Tulir Asokan cac1f5acde gotd: retry auth transfers on AUTH_BYTES_INVALID error 2025-12-29 23:15:40 +02:00
Tulir Asokan 56fe704934 gotd: don't log file download responses 2025-12-29 23:15:40 +02:00
Tulir Asokan 3696c9cff4 handletelegram: don't fail migrateChat if getting chat info fails 2025-12-24 00:42:57 +02:00
Tulir Asokan d83c0ede15 dependencies: update mautrix-go 2025-12-19 13:33:17 +02:00
Tulir Asokan b5d6e2ac6b Revert "handletelegram: do portal re-id in background"
This reverts commit 37d34a4ab6.
2025-12-19 13:33:01 +02:00
Tulir Asokan 37d34a4ab6 handletelegram: do portal re-id in background 2025-12-19 13:25:20 +02:00
Tulir Asokan baba8bd712 handletelegram: improve chat migrate logs 2025-12-19 13:08:54 +02:00
Tulir Asokan 7573e3d5a7 gotd: return fatal errors from all getDifference calls 2025-12-18 17:32:51 +02:00
Tulir Asokan a887f26023 gotd: retry non-fatal errors in getDifference calls 2025-12-18 16:48:03 +02:00
Tulir Asokan ced0a2d067 gotd: don't emit duplicate updates for channels 2025-12-18 16:36:23 +02:00
Tulir Asokan 09227510bc startchat: clean up resolving identifiers 2025-12-17 12:48:11 +02:00
Tulir Asokan dc2a422bbe handletelegram: move raw update logging 2025-12-17 12:47:25 +02:00
Tulir Asokan 49bb93bdc2 dependencies: update mautrix-go again 2025-12-16 19:30:18 +02:00
Tulir Asokan a3ec7f7c33 dependencies: update mautrix-go 2025-12-16 18:58:10 +02:00
Tulir Asokan cd660472d8 reactions,telegramfmt: remove unnecessary warning logs 2025-12-16 18:52:43 +02:00
Tulir Asokan 20446d0d7d gotd: fix logging response payload 2025-12-16 17:33:43 +02:00
Tulir Asokan 769a397a03 tomatrix: don't use portal disappearing timer for incoming messages 2025-12-16 17:32:59 +02:00
Tulir Asokan 1dde2a4a77 changelog: update 2025-12-16 17:32:59 +02:00
Tulir Asokan 85e1a6dc05 telegramfmt: merge duplicate cases 2025-12-16 17:11:59 +02:00
Tulir Asokan e9d95a6e9a reactions: remove incorrect error 2025-12-16 17:11:47 +02:00
Tulir Asokan e1497999d6 dependencies: update mautrix-go for portal deletion fixes 2025-12-16 17:11:34 +02:00
Tulir Asokan dd63436149 changelog: fix merge with legacy bridge 2025-12-16 14:45:24 +02:00
Tulir Asokan dd34256f3d telegramfmt: fix message link urls losing text 2025-12-15 21:23:01 +02:00
Tulir Asokan 7f17c2728e client: use context rather than separate flag for expected disconnects 2025-12-15 16:23:44 +02:00
Tulir Asokan 4342635b8a ci: update actions and pre-commit hooks 2025-12-13 11:11:05 +02:00
Tulir Asokan 80ccbeb449 dependencies: update mautrix-go 2025-12-13 11:10:37 +02:00
Tulir Asokan 283dfc5c77 Merge branch 'master'
[skip cd]
2025-12-12 19:08:41 +02:00
Tulir Asokan d00de62ee7 dependencies: update mautrix-go 2025-12-12 17:32:21 +02:00
Tulir Asokan 1a2fd67ee9 gotd: remove extra wrapping in check participant error 2025-12-12 17:26:41 +02:00
Tulir Asokan 4873ed77ff client: disconnect on auth error 2025-12-12 17:26:16 +02:00
Tulir Asokan d6a8e6a648 gotd: don't return run context error from channel state 2025-12-12 16:16:06 +02:00
Tulir Asokan 095bd65d51 gotd: add extra cache for left channels 2025-12-12 16:15:49 +02:00
Tulir Asokan cd9970055f gotd: fix channel membership check not doing anything 2025-12-12 16:15:26 +02:00
Tulir Asokan 3663f91c8a gotd: add more error wrapping around update loop 2025-12-12 16:04:40 +02:00
Tulir Asokan 0c3749a2ca gotd: don't start getDifference for left channels 2025-12-12 15:57:14 +02:00
Tulir Asokan ba4dd48d5a gotd: ensure user is member of channels before starting getDifference loop 2025-12-12 15:45:39 +02:00
Tulir Asokan c1d92ce051 startchat: fix getting cached contact list 2025-12-12 14:39:13 +02:00
Tulir Asokan ae05331420 startchat: don't allow spamming get contact list request 2025-12-12 14:30:06 +02:00
Tulir Asokan ef65f9f1ea client: log main context status on disconnect 2025-12-12 13:19:44 +02:00
Tulir Asokan 29d8c1b7dd client: send error state if client.Run returns unexpectedly 2025-12-12 13:12:33 +02:00
Tulir Asokan 4775e67476 client: adjust start/stop logs 2025-12-11 15:29:42 +02:00
Tulir Asokan 43b230148b client: don't drop errors from client.Run() 2025-12-11 15:01:12 +02:00
Tulir Asokan d03260c4a7 gotd/updates: initialize channel state runctx immediately 2025-12-11 14:55:43 +02:00
Tulir Asokan 042304f147 dependencies: update 2025-12-11 14:20:54 +02:00
Tulir Asokan de2e87ed52 client,gotd: remove unnecessary dispatcher wrapper 2025-12-11 14:07:48 +02:00
Tulir Asokan 581ba79c84 handletelegram,gotd: stop get difference polling after leaving channel 2025-12-11 13:56:48 +02:00
Tulir Asokan 390f9f422e backfill: clear saved takeout ID on takeout invalid error 2025-12-10 19:47:51 +02:00
Tulir Asokan f80d6de818 gotd: use constants for error strings 2025-12-10 19:39:56 +02:00
Tulir Asokan 69fcbd30ce gotd: don't stop connection on channel error 2025-12-10 19:17:50 +02:00
Tulir Asokan 0e3b1b63a9 gotd/updates: stop listening to channel on ChannelForbidden/Invalid 2025-12-10 19:14:08 +02:00
Tulir Asokan 7f13284b59 gotd: remove redundant closures and improve logs on disconnect 2025-12-10 18:34:07 +02:00
Tulir Asokan 4268ee9909 gotd/transport: add default read/write deadlines 2025-12-10 18:33:37 +02:00
Tulir Asokan 35bf11c158 loginqr: remove unused field 2025-12-08 14:22:34 +02:00
Tulir Asokan 08703e9efb loginqr: fix context used for background request 2025-12-08 14:20:19 +02:00
Tulir Asokan e13502750e handletelegram: log edit content 2025-12-08 00:22:02 +02:00
Tulir Asokan 0da121aebb ids: fix reaction ids 2025-12-08 00:20:06 +02:00
Tulir Asokan d07d2af048 handletelegram: don't try to get app config on bot accounts 2025-12-07 21:24:51 +02:00
Tulir Asokan 76e06d4a33 emojis: initialize maps lazily
Closes #99
2025-12-07 21:24:26 +02:00
Tulir Asokan 10f1583da9 login: add support for bot tokens 2025-12-07 20:06:30 +02:00
Tulir Asokan 48fed1c026 login: refactor to share more code 2025-12-07 20:02:19 +02:00
Tulir Asokan abb4671a16 client: add shortcut field for user login metadata 2025-12-07 20:02:19 +02:00
Tulir Asokan 6729a9ad09 main: switch versioning scheme 2025-12-06 15:30:52 +02:00
Tulir Asokan 96b2afeed1 dbmeta: remove disallowed fields in ghosts 2025-12-06 15:27:51 +02:00
Tulir Asokan d5f87d2ec1 all: add support for topics and refactor other things 2025-12-06 15:27:51 +02:00
Tulir Asokan 14b3b1fed7 handletelegram: adjust some message handling code 2025-12-06 01:02:55 +02:00
Tulir Asokan 16a57d78ac handletelegram: flatten service message handling 2025-12-05 23:51:17 +02:00
Tulir Asokan 548672d243 client: simplify IsLoggedIn check 2025-12-05 23:50:44 +02:00
Tulir Asokan ef23946cbc store: remove redundant index 2025-12-05 23:45:41 +02:00
Tulir Asokan 9b2b691afd handletelegram: resync channel on update event 2025-12-05 23:29:12 +02:00
Tulir Asokan c04866c854 reactions: flatten poll code 2025-12-05 15:39:00 +02:00
Tulir Asokan fa28593635 handlematrix: don't block read receipt handler on reaction poll 2025-12-05 15:39:00 +02:00
Tulir Asokan 9dd8f30480 handlematrix: fix reaction polling logic
Only supergroups need it. The map also needed a lock
2025-12-05 14:55:52 +02:00
Tulir Asokan 19c3121e77 login*: apply zap log level shifting 2025-12-04 16:32:26 +02:00
Tulir Asokan 6232a27881 startchat: don't allow starting chat without access hash 2025-12-04 16:13:04 +02:00
Tulir Asokan 526903cb7c userinfo: refactor GetUserInfo and remote profile handling 2025-12-04 15:39:46 +02:00
Tulir Asokan 2cac8f8b4a client,gotd: refactor connection event handling
This might cause regressions if the onSession handler was load bearing
2025-12-04 14:53:35 +02:00
Tulir Asokan c83a361c0b gotd: reduce unnecessary debug logs 2025-12-04 14:44:13 +02:00
Tulir Asokan c6dd85040c client: fix parsing username message links 2025-12-03 23:23:07 +02:00
Tulir Asokan 08a2fe9753 chatinfo: refactor processing group chat info 2025-12-03 22:34:13 +02:00
Tulir Asokan 2580e28bee media: refactor sticker conversion 2025-12-03 22:15:59 +02:00
Tulir Asokan b7e5078053 chatinfo: ensure own member is always added 2025-12-03 17:44:18 +02:00
Tulir Asokan 8bef95e237 chatinfo,backfill,tomatrix: downgrade unnecessary warnings 2025-12-03 17:11:20 +02:00
Tulir Asokan 04a10f361a gotd: skip broken manager test 2025-12-03 17:11:20 +02:00
Tulir Asokan fed5752f38 handletelegram: don't return errors from message converter 2025-12-03 17:11:20 +02:00
Tulir Asokan 35c161185c directdownload,tomatrix: add missing nil checks 2025-12-03 17:11:20 +02:00
Tulir Asokan 1ecb9e8b64 login*: set device config on login clients 2025-12-03 17:11:20 +02:00
Tulir Asokan 2004085312 connector: fix import ordering 2025-12-03 17:11:20 +02:00
ip75 abd5d058ff gotd: add filename to AudioDocumentBuilder
Cherry-picked from https://github.com/gotd/td/commit/52e0fcb1f655e7c1c09ae45e41fa333422cc4cab
2025-12-03 17:11:20 +02:00
Oleksii Kyslytsia 09185e8e53 gotd: handle all login token response types in QR login
Cherry-picked from https://github.com/gotd/td/commit/4c22747e9a0299b457f45084c3dbcbcbb5a7a5e7
2025-12-03 17:11:20 +02:00
Vadim Tertilov 097211cba1 gotd: add fallback handlers
Cherry-picked from https://github.com/gotd/td/commit/3238f7e7d3623ecd3648e2124f962c4ea0d03134
2025-12-03 17:11:20 +02:00
Tulir Asokan 8e7a7db85f humanise: update error list and move generator 2025-12-03 17:11:20 +02:00
Tulir Asokan 55f8d1423b gotd: update hash computing sort 2025-12-03 17:11:20 +02:00
Tulir Asokan 66b84a7b44 gotd: update to layer 218 2025-12-03 17:11:20 +02:00
Tulir Asokan b38c3cc935 gotd: invoke with layer every time
Cherry-picked from upstream PR 1640. The reason is that tracking whether
the Telegram servers received our layer number is hard, so it's more
reliable to just always send it.
2025-12-03 17:11:19 +02:00
Tulir Asokan b7459ec9eb .gitattributes: exclude gotd generated files 2025-12-03 17:11:19 +02:00
Tulir Asokan 5eb883e934 dependencies: update 2025-12-03 17:11:19 +02:00
1Conan b9d19a3aad handlematrix: implement RoomAvatarHandlingNetworkAPI 2025-11-26 01:27:41 +08:00
1Conan 74a5dfccd5 handlematrix: implement RoomNameHandlingNetworkAPI 2025-11-26 01:25:50 +08:00
Conan b3f9bfb5b3 handlematrix: implement group chat deletes (#126) 2025-11-20 01:22:02 +08:00
Nick Mills-Barrett ca46d36998 media: default unknown media mime to application/octet-stream
Keeps the photo jpeg default but only for photos so we don't incorrectly
flag unknown extensions as jpegs.
2025-11-18 11:22:39 +00:00
Tulir Asokan 795a732720 dependencies: update mautrix-go 2025-10-27 20:54:08 +02:00
Conan 3aa7bdfa91 handlematrix: fix delete chat (#125) 2025-10-28 02:34:24 +08:00
Tulir Asokan 14f40abeca config: update default saved message avatar 2025-10-19 23:37:16 +03:00
Conan b1f3c4c1db handlematrix: Implement DeleteChatHandlingNetworkAPI (#122) 2025-10-07 21:26:10 +08:00
Tulir Asokan 4410415776 chatinfo: flag group resyncs as excluded from timeline 2025-10-01 16:17:06 +03:00
Adam Van Ymeren a38c3e5d00 resync: resync portals upon viewing if they haven't been synced in the last 24h (#124) 2025-09-25 12:34:41 -07:00
Tulir Asokan a280c3a4b9 dependencies: update mautrix-go 2025-09-25 00:15:43 +03:00
Tulir Asokan 280c74e9cd Add config option to self-sign bot device 2025-09-24 00:08:57 +03:00
Tulir Asokan eb5bfb4666 chatinfo: set power level for disappearing timer event 2025-09-17 14:43:53 +03:00
Tulir Asokan fcace69cbd capabilities: fix definition of a month 2025-09-17 00:17:14 +03:00
Tulir Asokan f48737c894 capabilities: fix disappearing timer capability 2025-09-16 23:04:31 +03:00
Tulir Asokan d359bafb53 connector: rename files to be more consistent with other bridges 2025-09-16 22:47:48 +03:00
Tulir Asokan 44be515705 matrix: use Normalize instead of manually changing disappearing type 2025-09-16 22:43:22 +03:00
Tulir Asokan 34683e6d1b handletelegram: fix disabling disappearing timer 2025-09-13 01:39:45 +03:00
Tulir Asokan 8f998cd9cb dependencies: update mautrix-go 2025-09-12 21:09:41 +03:00
Adam Van Ymeren 9706deb27d media: always give the unsupported notice if we don't yet handle the type 2025-09-12 09:22:43 -07:00
Adam Van Ymeren 233516ca4d media: Don't error on MessageMediaPaidMedia, just bridge unsupported notice 2025-09-12 09:17:17 -07:00
Tulir Asokan 0051042555 database: migrate disappearing timer to standard location (#121) 2025-09-12 18:35:09 +03:00
Tulir Asokan 93f55497f4 capabilities: change group creation type name 2025-09-08 21:08:59 +03:00
Tulir Asokan 63e44fb5ad loginphone: clarify where code is sent 2025-09-08 17:16:23 +03:00
Conan 17b69a6eac login: initiate data export immediately after login (#119) 2025-09-08 17:25:06 +08:00
Conan 170b263a6c sync: use GetChatInfo for handleDialogs (#120) 2025-09-06 00:24:02 +08:00
Tulir Asokan 7a726e36a0 startchat: update group creation interface 2025-09-02 20:14:52 +03:00
Tulir Asokan 4f12f5103a matrix: add support for changing disappearing message timer 2025-08-26 18:17:50 +03:00
Tulir Asokan 8341492c9f dependencies: update mautrix-go 2025-08-26 18:07:45 +03:00
Tulir Asokan 9e4b6c3c46 ci: update Go version and pre-commit hooks 2025-08-26 18:05:20 +03:00
Tulir Asokan 597d0e996b docker: install lottieconverter 2025-08-26 18:04:21 +03:00
Tulir Asokan 4f1482e7b0 Update mautrix-python 2025-08-17 14:11:11 +03:00
Tulir Asokan 4641215e97 Update issue templates 2025-08-12 16:21:05 +03:00
Tulir Asokan 2f34ebfed9 Disable kicking unauthenticated joiners too 2025-08-12 16:20:45 +03:00
Conan 3ae88caa80 connector: handle supergroup upgrades (#118)
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2025-08-07 03:09:24 +08:00
Tulir Asokan 3462b75c76 dependencies: update mautrix-go 2025-08-01 11:51:10 +03:00
Conan 6f949b5a1c build: support darwin hosts (#117) 2025-07-31 20:41:27 +08:00
Conan bdf7194691 sync: generate read receipt on SyncChat (#116) 2025-07-31 20:35:12 +08:00
Tulir Asokan f5bfe421d1 handletelegram: stop returning unnecessary errors 2025-07-31 14:12:14 +03:00
Tulir Asokan 0bc1bd04c5 dependencies: update mautrix-go 2025-07-31 14:02:31 +03:00
Tulir Asokan a31787f894 client: include event handling error in returns 2025-07-31 14:02:15 +03:00
Tulir Asokan e1c0e6dd9a gotd: replace old nhooyr websocket 2025-07-31 14:02:14 +03:00
Conan ecb3921260 push: filter muted rooms (#115) 2025-07-30 04:22:23 +08:00
Brad Murray e7fe66a23e login: wrap some common errors to return 400 instead of 500 (#114) 2025-07-25 16:48:09 -04:00
Tulir Asokan 03fe8bf782 from-matrix: don't expect mute timestamp to be max int32 2025-07-23 13:43:06 +03:00
Adam Van Ymeren 840788c1e5 auth: fix cloud auth (#113) 2025-07-19 08:39:48 -07:00
Tulir Asokan d4f6be8155 sync: fix handling chats with no messages 2025-07-17 18:26:16 +03:00
Tulir Asokan d10c528895 dependencies: update mautrix-go again 2025-07-17 17:41:40 +03:00
Tulir Asokan 635345f61d dependencies: update mautrix-go 2025-07-17 16:58:39 +03:00
Adam Van Ymeren e9abeda916 Rework telegram client lifecycle to hopefully fix not stopping issues (#112) 2025-07-17 09:56:11 -04:00
Tulir Asokan b65a1cc60a Update Docker image to Alpine 3.22 2025-07-16 23:50:21 +03:00
Tulir Asokan 53bf278f1e Bump version to 0.15.3 2025-07-16 11:50:47 +03:00
Tulir Asokan b16061db34 docker: update to Alpine 3.22 2025-07-16 11:47:39 +03:00
Tulir Asokan 4b56b6d016 dependencies: update mautrix-go 2025-07-16 11:47:17 +03:00
Tulir Asokan 3fb554b934 client: add todo for usernames in links
[skip ci]
2025-07-15 16:39:50 +03:00
Tulir Asokan 890851e85e dependencies: update mautrix-go again 2025-07-15 14:58:17 +03:00
Tulir Asokan a7aa96ef2b tomatrix: add more nil safety to media id hashing 2025-07-15 14:53:12 +03:00
Tulir Asokan aa02639759 dependencies: update mautrix-go 2025-07-15 14:52:07 +03:00
Tulir Asokan 35f137ccc1 Hardcode v11 for new rooms
Upcoming breaking changes in room v12 prevent safely using the default
room version and security embargoes prevent fixing them ahead of time.
2025-07-15 14:20:19 +03:00
Adam Van Ymeren a501042f1d updates: don't error on edit if not tg.Message, just warn 2025-07-14 11:34:49 -07:00
Adam Van Ymeren 7970a678fc updates: elevate start/stop logs to debug bridge not stopping 2025-07-14 11:33:23 -07:00
Toni Spets cb98833590 directmedia: handle custom emojis 2025-07-10 09:15:58 +03:00
Adam Van Ymeren e6c3454e9f updates: don't try to fetch one more difference when context has been cancelled 2025-07-08 11:43:32 -07:00
Adam Van Ymeren 4c9555eded HandleMute: property set silent and MuteUntil 2025-07-07 14:44:59 -07:00
Tulir Asokan 38f87becb6 ci: disable go 1.23 linting 2025-07-07 18:53:45 +03:00
Tulir Asokan 9e2e2421d2 gotd: remove _tools package that was breaking pre-commit 2025-07-07 17:19:33 +03:00
Adam Van Ymeren 10bc44d17d scoped_store: Fix user access hash fetching 2025-07-04 15:03:59 -07:00
Adam Van Ymeren 125be97201 conn/rpc: Don't bother sending RPCDrop requests to server 2025-07-04 14:16:20 -07:00
Adam Van Ymeren d4239d520a backfill: fix forward backfill
only skip too new messages if we're actually doing backwards backfill
2025-07-03 21:42:33 -07:00
Adam Van Ymeren 399cd5585a ScopedStore: fix GetAccessHash always returning found: false 2025-07-02 14:10:50 -07:00
Adam Van Ymeren ac3ce3c097 don't error on unsupported peer type in settings handler 2025-06-27 21:53:42 -07:00
Adam Van Ymeren 6280a7bae7 updates: don't error just warn on unknown messages/actions 2025-06-27 20:34:30 -07:00
Adam Van Ymeren af630ecbd1 updates: don't die when deleting a message we don't have in db, just log and ignore 2025-06-27 20:20:53 -07:00
Adam Van Ymeren 7a04f298d2 move gotd fork into repo. (#111)
- update to latest telegram layer
- remove some references to fields in tg.Entities that don't exist in
the schema
- originally added here:
https://github.com/beeper/td/commit/820929062a2ba0104397bc01235ab58a9cff780e
  - referenced here
-
https://github.com/mautrix/telegramgo/commit/124f0967ed195b5a380c9bd02e170ada9710dde3
-
https://github.com/mautrix/telegramgo/commit/4205047aab2e0639217148b5d125bfaab668bd8e
2025-06-27 20:03:37 -07:00
Adam Van Ymeren 0952df0244 all: respect/propagate errors from QueueRemoteEvent (#110) 2025-06-26 13:43:35 -07:00
Toni Spets 8ac519d1e5 connector: Disconnect in a goroutine on auth error
If we get bad creds on initial connect the lock is being held when the
callback is called.
2025-06-25 08:55:36 +03:00
Tulir Asokan a49818b863 dependencies: update mautrix-go 2025-06-17 20:35:16 +03:00
Tulir Asokan 31846e7a98 Update changelog 2025-06-16 13:33:52 +03:00
Tulir Asokan 9ab2ee2970 Disable reply fallbacks by default 2025-06-16 13:24:17 +03:00
Tulir Asokan c7dd08ecd1 Update dependencies 2025-06-16 13:20:07 +03:00
Toni Spets fa237a20f7 logging: Move gotd debug logs to trace
Zap doesn't have trace level logging so to make it less noisy we need to
shift them to trace.
2025-06-09 15:01:25 +03:00
Toni Spets 41279ae996 Bump gotd with streaming download fix 2025-06-06 13:46:24 +03:00
Toni Spets e7b87835b6 Fix a race between connect and disconnect
If we get an auth error during connect we did deadlock.
2025-06-06 10:57:07 +03:00
Tulir Asokan c96a241794 legacymigrate: drop invalid disappearing message rows 2025-06-03 16:54:13 +03:00
Toni Spets 3c3c3f1dec client: Prefer contact name if exists
Don't allow TG user to override your own contact name for them after
they have been made a contact.
2025-05-30 13:22:57 +03:00
Toni Spets a9a267bc0d directmedia: handle webpage previews as well 2025-05-27 14:39:47 +03:00
Tulir Asokan 05b1eb1214 handletelegram: provide stream order in read receipts (#102) 2025-05-27 11:45:39 +03:00
Toni Spets 0f36833e89 Revert "Revert "client: unblock connect without network""
This reverts commit ea4626107c.

Adds waiting support for initial connection established to avoid locking
up gotd. This isn't extremely pretty but should do the job for now.
2025-05-27 07:40:38 +03:00
Toni Spets ea4626107c Revert "client: unblock connect without network"
This reverts commit 14c784f2a2.
2025-05-23 10:32:51 +03:00
Toni Spets 39c1b685d6 client: logout with timeout to API call to unblock it 2025-05-23 09:28:32 +03:00
Toni Spets 14c784f2a2 client: unblock connect without network
It'll still probably not race too much if disconnect gets called while
connecting is still stuck doing something.
2025-05-23 09:20:38 +03:00
Toni Spets 11f105c0e7 media: convert png and jpeg stickers to webp without ffmpeg 2025-05-22 13:52:51 +03:00
Toni Spets 9e719429e7 Fix handle channel photo edits as well as group chats 2025-05-21 08:15:23 +03:00
Tulir Asokan d2bb02b259 handlematrix: use client-generated transaction IDs 2025-05-09 16:42:44 +03:00
Tulir Asokan 8fbd723bfa Enable captions by default 2025-05-07 13:40:39 +03:00
Toni Spets 7e75c8ef83 media: make all media direct downloadable
The only exception is emojis.

Also changed direct download encoding field names to be more generic
when used in mixed manner depending on peer type.

Direct downloads are still somewhat inefficient as they require an API
round trip to succeed but we can cache things in the database if needed.
2025-05-07 06:43:51 +03:00
Brad Murray 9d3e9df57e Merge pull request #98 from mautrix/dont-500-on-resolve-identifier-not-resolving
Use 404 status when not matching a valid identifier
2025-05-06 13:16:13 -04:00
Toni Spets 849f3c6f1e Bump gotd with sticky DC hack 2025-04-29 06:38:42 +03:00
Toni Spets 483816cc2b media: Request 1MB chunks for direct media streaming
The default is 512kB and the RPC request overhead has more impact
on download speed than having half smaller chunks.

Next level speed improvement would be to use parallel downloads and
have an on-disk buffer to stream out the rebuilt file on-the-fly when
we consistent stream of data available.
2025-04-28 12:38:02 +03:00
Toni Spets 7c13481ede client: Handle connect/disconnect/auth races properly
When logging out, we should first handle network level logout and after
that ensure the client is disconnected before removing state to avoid
having event handling during disconnect from touching anything anymore.

I don't know why we nilled the client but since so many places use it
we'd rather get errors rather than panics if it's being used after
logging out but previous lifecycle fixes should avoid that.
2025-04-26 08:19:33 +03:00
Adam Van Ymeren eb5ae65402 reactions: fix db race when handling reactions on newly received old messages (#100) 2025-04-25 13:19:50 -07:00
Toni Spets 53e89441b7 media: fix lottie mime type for direct download 2025-04-25 07:38:06 +03:00
Tulir Asokan 48d91fdf76 media/sticker: fix lottie mime type
It's always gunzipped, so should never send the application/x-tgsticker mime.
Also, video/lottie+json was recently registered with IANA, so use that instead
of the old image/lottie+json: <https://www.iana.org/assignments/media-types/video/lottie+json>
2025-04-24 15:56:44 +03:00
Tulir Asokan 530bd9e52e Update telethon 2025-04-19 15:32:39 +03:00
Brad Murray bdae6dd620 Use 404 status when not matching a valid identifier 2025-04-17 13:10:34 -04:00
Toni Spets 75964a00ed dependencies: update mautrix-go 2025-04-15 12:19:28 +03:00
Toni Spets 224b01e7a4 client: Wait for updates manager to finish on disconnect 2025-04-15 12:19:28 +03:00
Tulir Asokan 5421de8e76 handlematrix: include stream order in response 2025-04-11 23:01:06 +03:00
Toni Spets a64a178dc3 client: Don't try to reconnect with canceled context 2025-04-10 10:13:02 +03:00
Toni Spets 538f2a2ec0 client: Wait before returning from disconnect 2025-04-09 10:49:45 +03:00
Nick Mills-Barrett 10b8c4b635 chatinfo: log when chats have no or unknown photo types 2025-04-07 13:58:53 +01:00
Sumner Evans b955252a6a backfill: add stream order
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-03-28 08:33:33 -06:00
Tulir Asokan 6347383788 tomatrix: fix refetching channel media when bridging messages 2025-03-27 18:31:48 +02:00
Tulir Asokan 28d8276554 dependencies: update mautrix-go 2025-03-25 17:00:55 +02:00
Tulir Asokan 6480e7925e Fix login QR filename 2025-03-19 20:47:08 +02:00
Tulir Asokan c70ab2a12b Update Telethon 2025-03-19 20:47:08 +02:00
Sumner Evans 09b1e69c0f backfill: fix NPE if no messages found
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-03-13 11:58:18 -06:00
Tulir Asokan 070bfd4f55 Update dependencies 2025-03-09 13:10:39 +02:00
Tulir Asokan 88c3a93526 Fix text in poll bridging 2025-03-09 13:07:29 +02:00
Sumner Evans 854f66cb04 store: make finding by username case-insensitive
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-03-05 18:17:30 -07:00
Sumner Evans 1bc3a2538e treewide: add copyright/license notices
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-03-05 10:16:50 -07:00
Sumner Evans dcc8689835 emojis: properly handle inline emojis on local
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-03-04 11:27:09 -07:00
Sumner Evans ebc1aa05b1 connector/login: fix a context issue on phone number login
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-02-26 11:22:48 -07:00
Sumner Evans a56f2977b4 login: fix contexts
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-02-25 08:35:31 -07:00
Sumner Evans 36bb741c68 client: refetch message during conversion if file reference expired
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-02-24 11:45:21 -07:00
Sumner Evans f0f92c9dd9 gitattributes: mark humanise/errors.go as generated
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-02-21 09:56:27 -07:00
Sumner Evans e8ee5f174e connector/tomatrix: include previewed URL in hash
This will hopefully make it so that if the preview gets edited in by
Telegram at a later time, it will be bridged.

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-02-17 14:14:15 -07:00
Sumner Evans f86ebad162 pre-commit: update all
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-02-17 14:14:15 -07:00
Sumner Evans 0712ca5d0c dependencies: update go
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-02-17 14:13:12 -07:00
Sumner Evans dad34f9a3c connector: fix getting media filename fallback
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-02-17 13:53:16 -07:00
Tulir Asokan c2e07d5e3f push: add APNs push parsing 2025-02-06 15:13:31 +02:00
Tulir Asokan 255c1e2e57 push: fix types 2025-02-04 19:14:57 +02:00
Tulir Asokan a680036177 push: fix parsing decrypted json 2025-02-04 18:08:23 +02:00
Tulir Asokan 94789daed3 capabilities: fix image mime types 2025-02-04 18:06:25 +02:00
Tulir Asokan e0841e252d dependencies: update 2025-02-04 15:57:33 +02:00
Tulir Asokan ab9ff87815 push: log data if it's not json 2025-02-04 15:46:58 +02:00
Tulir Asokan 4b08ab6ac0 push: implement ConnectBackground (#88)
* push: implement ConnectBackground

* push: disable background resync by default
2025-01-29 15:35:54 +02:00
Tulir Asokan 823eda7589 push: implement parsing native notifications (#87) 2025-01-24 15:34:34 +02:00
Tulir Asokan caefda582b Disable kicking unauthenticated users 2025-01-19 20:38:39 +02:00
Tulir Asokan e1b181ed55 Update mautrix-python to support MSC4190 2025-01-15 18:54:54 +02:00
Tulir Asokan cc6a915ef4 Update dependencies 2025-01-15 17:54:33 +02:00
Tulir Asokan de4df57278 Ignore partial quotes on sticker messages 2025-01-15 17:49:18 +02:00
Tulir Asokan b158ba6b8b capabilities: add default emoji list hash to ID
The list can change, so it should change the ID too
2025-01-14 14:57:58 +02:00
Tulir Asokan 571152cb41 capabilities: update reaction settings 2025-01-14 14:56:22 +02:00
Sumner Evans c82b273155 connector/reactions: return error if not logged in
Previously, the getAvailableReactions function was only called with a
logged in client. However, now that it is called in the GetCapabilities
call, the client is no longer guaranteed to be logged in.

This was causing an NPE due to the (*TelegramClient).client being nil.

This commit makes the getAvailableReactions function not panic when the
client is not logged in.

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-01-13 21:28:26 -07:00
Sumner Evans 4bef6ea09e connector/tomatrix: add timeout for getting webpage preview
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-01-13 11:00:15 -07:00
Tulir Asokan 386cfa4cfb capabilities: update to new format 2025-01-10 21:17:10 +02:00
Sumner Evans f4052dcfd3 connector: set IsSuperGroup on dialog sync
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-01-09 18:59:13 -07:00
Sumner Evans 664d6050df backfill: manually skip too-new messages in backwards backfill
For some reason, even though we provide an offset, Telegram sometimes
sends us more events than we request, including newer events than the
offset ID. Messages beyond the offset are then chopped off by the
bridgev2 code, but we continue trying to backfill the portal thinking
that there is more to backfill. This causes infinite backfill loops.

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-01-09 17:46:36 -07:00
Sumner Evans 9e868e4614 connector: fix linking to premium messages
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-01-09 17:46:36 -07:00
Sumner Evans 2743d5375a connector/tomatrix: fix broadcast messages with no From user
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-01-09 17:46:18 -07:00
Sumner Evans c3fc77c2a8 connector: always use channel sender in broadcast rooms and add per-message profile
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-01-09 12:52:43 -07:00
Sumner Evans 6c7727d6b5 connector/media: fix comment
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-01-07 22:56:32 -07:00
Tulir Asokan 3ef2cbe102 push: extract app sandbox flag to global variable 2025-01-07 18:32:33 +02:00
Sumner Evans 487f11ffd7 connector/tomatrix: strip filename unconditionally on stickers
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-01-06 10:44:17 -07:00
Sumner Evans 655cd98f27 connector/tomatrix: fix video stickers
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2025-01-06 10:26:02 -07:00
Sumner Evans f14c90dc87 deps/td: upgrade
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-19 08:36:01 -07:00
Sumner Evans ee0c2e4f68 connector/client: don't call disconnect on pipe error
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-09 16:17:06 -07:00
Sumner Evans c8590ca402 connector/client: add more logging on Connect
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-09 15:12:15 -07:00
Sumner Evans 964ea69de7 connector/client: check for client context nil on logged in check
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-09 15:11:49 -07:00
Sumner Evans 1de97c9ae0 deps/td: upgrade so secondary connections don't have OnDead handler
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-09 11:42:23 -07:00
Sumner Evans 987395914e connector: add stream order to new messages
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-09 09:43:30 -07:00
Sumner Evans 2a7146d987 client: improve disconnection detection
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-06 14:20:45 -07:00
Sumner Evans 71ebb72ede deps/td: update to remove extraneous logs
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-06 14:14:05 -07:00
Sumner Evans dc2216e60b client: let connect send the bridge state on updates manager fail
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-05 13:09:48 -07:00
Sumner Evans 73934a0594 client: try reconnecting on update manager run error
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-05 13:07:01 -07:00
Sumner Evans 4d33af7f81 client: fix detection of bad credentials on connect
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-05 08:58:09 -07:00
Sumner Evans 80f17d5fbd connector: send BAD_CREDENTIALS if error is an auth error
Previously, we were going into UNKNOWN_ERROR too aggressively

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-04 23:49:49 -07:00
Sumner Evans 6c68351e1f connector/tomatrix: error early if client is nil
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-04 15:21:55 -07:00
Sumner Evans 83acac5175 connector/client: handle updates manager errors
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-04 15:21:55 -07:00
Sumner Evans 46a4b68073 connector/tomatrix: fix nil handling again
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-04 15:21:55 -07:00
Sumner Evans 68f4b0e21f direct media: don't panic if userLogin or userLogin.Client is nil
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-04 15:21:55 -07:00
Sumner Evans 32282a242f login: timeout client after an hour
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-04 15:21:55 -07:00
Sumner Evans 2129dd803d connector/edits: handle edge cases where there are multiple parts to existing messages
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-03 11:44:41 -07:00
Sumner Evans 74d9edf42e connector/edits: add better logging when parts change
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-03 11:14:12 -07:00
Sumner Evans a1f58cad11 connector/client: ignore messages in more situations
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-03 09:01:41 -07:00
Sumner Evans bf3e0ec8ab connector: simplify some of the dispatcher handlers
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-02 15:53:43 -07:00
Sumner Evans 124f0967ed connector: leave chats more aggressively on entity updates
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-02 15:39:14 -07:00
Nick Mills-Barrett 16040adc53 dependencies: update mautrix-go 2024-12-02 13:56:25 -07:00
Sumner Evans 8e994edbde connector: only send UNKNOWN_ERROR if not pipe error
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-02 13:12:38 -07:00
Sumner Evans 54157de58f connector: reconnect on broken pipe error
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-02 12:57:53 -07:00
Sumner Evans 7ce3dacf00 metadata: clear more things from user login metadata on auth error
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-02 12:24:49 -07:00
Sumner Evans 6d82ac18b4 deps/td: upgrade to handle AUTH_KEY_DUPLICATED better
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-02 12:00:33 -07:00
Sumner Evans d6765157ab connector: don't use Part IDs
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-12-02 09:54:12 -07:00
Sumner Evans 7bda4f7855 connector: humanise connection errors
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-25 17:22:11 -07:00
Sumner Evans e603aa6058 connector/mss: humanise send errors
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-25 17:15:28 -07:00
Sumner Evans 4b5ae24a67 humanise: add package to print human-friendly errors
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-25 17:15:28 -07:00
Sumner Evans 6b6a6ba275 connector/ids: fix MakeMessageID
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-21 16:32:40 -07:00
Sumner Evans 22f44734cf connector/edits: prettify error messages
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-21 12:22:11 -07:00
Sumner Evans 844f31827c connector/client: don't explode if client not available on connection state change
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-20 15:33:55 -07:00
Sumner Evans 21ef73d69c connector/client: add more logging to IsLoggedIn
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-20 14:39:12 -07:00
Sumner Evans 9d80c9e396 connector/matrix: more logging for matrix message handling
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-20 14:39:12 -07:00
Tulir Asokan b316cb131a push: enable push encryption key
[skip cd]
2024-11-19 16:06:53 +02:00
Sumner Evans dd64d2c559 connector/matrix: force .jpg suffix on image filenames without extensions
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-18 08:54:14 -07:00
Sumner Evans 1f22aa2072 connector/client: make NormalizeURL not panic if message not found
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-18 08:39:08 -07:00
Sumner Evans d887887d8b connector/matrix: make error messages on message sends more human-readable
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-17 22:36:06 -07:00
Tulir Asokan 5b7a170ad9 dependencies: update 2024-11-14 16:21:32 +02:00
Sumner Evans 463277def0 connector/tomatrix: fix video captions
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-12 09:58:06 -07:00
Sumner Evans 40f259da5e directdownload: don't panic if user not logged in
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-08 02:34:52 -07:00
Sumner Evans d1d3c18670 connector/client: update IsLoggedIn check
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-08 02:13:48 -07:00
Scott Weber 6100335809 deps/mautrix: upgrade 2024-11-06 17:36:10 +01:00
Sumner Evans 869fef0828 connector/matrix: fix uploading non-images
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-06 08:51:38 -07:00
Sumner Evans ada41742a1 connector/matrix: check the telegram image size limits
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-06 08:47:02 -07:00
Sumner Evans 1b4416f291 connector/media: fix transferring non-lottie stickers
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-06 07:02:08 -07:00
Sumner Evans 11a832c575 connector/matrix: fix sending media
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-06 04:20:07 -07:00
Sumner Evans 303274acb6 connector/matrix: send UNSUPPORTED MSS for invalid reactions
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-06 03:07:36 -07:00
Sumner Evans b6d3131caf deps/td: upgrade
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-05 02:00:12 -07:00
Sumner Evans 22c3938b52 connector/client: fix IsLoggedIn check
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-04 10:05:48 -07:00
Sumner Evans 827116658b connector/matrix: implement image size/dimension limits
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-04 09:53:04 -07:00
Sumner Evans ca8aff0534 connector/login: fix crash on login
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-04 08:47:31 -07:00
Sumner Evans 5adb2a6572 connector/client: early return on logout remote
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-04 07:40:24 -07:00
Sumner Evans 69e3a183c7 connector/client: init scoped store earlier
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-04 07:35:15 -07:00
Sumner Evans 52c39eefe0 legacyprovisioning: add check for auth key on logout
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-04 07:28:59 -07:00
Sumner Evans 6fa19bbbda legacyprovisioning: fix reconnect
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-04 03:18:51 -07:00
Sumner Evans 8025404958 connector/client: don't unset auth key on unknown error
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-11-04 03:00:29 -07:00
Sumner Evans 07a8553b22 connector: fix chat info for Saved Messages chat
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-31 14:23:18 -06:00
Sumner Evans 15fdd89e3d connector/client: convert some bad credentials to unknown errors
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-30 14:27:28 -06:00
Sumner Evans bda33687af connector/client: send bad credentials in the correct places
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-30 14:08:10 -06:00
Sumner Evans ea9bd01d06 connector/chatinfo: allow bridging non-supergroup channels with lots of subscribers
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-30 10:45:23 -06:00
Sumner Evans e846fb168c pre-commit: github.com/beeper/pre-commit-go v0.4.0 -> v0.4.1
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-30 09:32:37 -06:00
Sumner Evans 0046975aa5 treewide: ban global zerolog
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-30 09:27:58 -06:00
Sumner Evans aa7a2d186b connector/client: check for auth key on login check
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-29 14:28:03 -06:00
Sumner Evans f195e2cac0 sync: fix setting membership of channel user
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-29 09:53:39 -06:00
Sumner Evans b33209fafa connector: remove debug line
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-29 09:47:54 -06:00
Tulir Asokan 265c2835e8 legacymigrate: ignore sessions without auth key 2024-10-29 17:04:04 +02:00
Tulir Asokan 86a77996d4 legacymigrate: remove unknown message sender values 2024-10-29 16:17:08 +02:00
Sumner Evans da894bec25 connector/tomatrix: fix nil handling
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-29 08:12:11 -06:00
Sumner Evans cc8dce3959 deps/mautrix: upgrade for more ergonomic event meta handling
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-28 14:01:22 -06:00
Sumner Evans 22488fbc5f connector: add notice on chat creation
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-28 13:16:55 -06:00
Sumner Evans 3498ed8dc1 calls: fix notifications
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-28 13:06:37 -06:00
Sumner Evans bcea875e66 connector/tomatrix: handle nil better in mediaHashID
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-28 08:04:18 -06:00
Sumner Evans 7cb70d9753 connector: only save access hash if not a min entity
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-26 22:48:54 -06:00
Sumner Evans e266d1ac80 reactions: poll for reactions on read receipt
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-24 12:57:43 -06:00
Sumner Evans 0f933f691b typing: support typing as a channel user
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-24 11:15:48 -06:00
Sumner Evans c6afaf5504 sync: always needs backfill if no latest message present
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-24 11:01:31 -06:00
Sumner Evans 9f6a54be81 connector/tomatrix: log when hashing unsupported media type
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-24 10:53:11 -06:00
Sumner Evans 229efdd487 chatinfo: handle forbidden channels/chats without panicking
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-24 10:45:32 -06:00
Sumner Evans 4bdd415dbe connector: send notice about TTL changes
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-23 10:12:23 -06:00
Sumner Evans 31dc0259f3 connector/matrixfmt: use different bullet types for each nesting of lists
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-22 13:39:32 -06:00
Sumner Evans 13f21a7c70 media: implement streaming for direct downloads
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-22 11:37:59 -06:00
Sumner Evans a573740b9a media/transfer: add function to directly download bytes
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-22 09:21:56 -06:00
Sumner Evans 5448648c32 deps/mautrix: upgrade
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-22 08:10:34 -06:00
Tulir Asokan 706d4a5e5c .github: update issue template 2024-10-22 12:51:58 +03:00
Tulir Asokan df3cd765fe legacymigrate: set members fetched to not null on sqlite too 2024-10-22 12:51:23 +03:00
Sumner Evans bd7c724341 stickers: support sending
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-21 16:40:27 -06:00
Sumner Evans f076376caa connector/tomatrix: handle circular videos
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-21 15:55:45 -06:00
Sumner Evans 19a3c8a4d9 github/dependabot: enable for GitHub Actions
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-21 15:35:37 -06:00
Sumner Evans a9f8a3aa0f deps/td: update to fix read receipts
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-16 09:35:03 -06:00
Sumner Evans f91b429c47 connector: notify when call starts/ends
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-15 08:26:05 -06:00
Sumner Evans b0e6dcb1d6 client: support TG -> Matrix disappearing messages
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-14 14:33:17 -06:00
Sumner Evans 132585de34 user info: handle deleted users
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-14 14:09:08 -06:00
Sumner Evans 4d1cec979b backfill: use offset ID instead of max ID
According to Telethon, max_id doesn't work:
https://github.com/tulir/telethon/blob/c1e961ce2506d92f962a7d4ca5897d57cdaeb6d3/telethon/client/messages.py#L33-L34

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-14 13:02:02 -06:00
Sumner Evans 679b4bd157 connector/reactions: fallback to sensible defaults if config doesn't have the correct values
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-11 10:22:21 -06:00
Sumner Evans 73d0b189bb scoped store: implement new AccessHasher interface
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-11 08:52:26 -06:00
Sumner Evans 48059a3a51 logout: delete user-specific state
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-10 10:07:26 -06:00
Sumner Evans 4205047aab chat delete: bridge properly
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-09 11:24:55 -06:00
Sumner Evans 03c7028460 power levels: prevent sending to blocked users
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-09 09:57:02 -06:00
Sumner Evans c75ac58763 client: add option to disable bridging view-once and disappearing media
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-08 13:41:00 -06:00
Sumner Evans a85659df9d backfill: fix request on forward backfill
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-08 12:59:22 -06:00
Sumner Evans 9a8f356348 backfill: fix dialog fetch, HasMore, and skip forbidden channels
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-08 12:22:04 -06:00
Sumner Evans 9576f48c5b stickers: strip filename
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-08 08:21:23 -06:00
Sumner Evans 96331761b8 snc: fix resolving identifier
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-07 16:32:48 -06:00
Sumner Evans 4821865cad deps: un-upgrade gorilla packages
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-07 09:45:25 -06:00
Sumner Evans 7efad4a990 deps: upgrade all
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-07 09:26:13 -06:00
Sumner Evans 6c44ba487a backfill: set CanBackfill in the correct places
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-07 09:16:24 -06:00
Sumner Evans 57b32f6ac6 backfill: implement marking read
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-04 08:17:33 -06:00
Sumner Evans 17e4e20a93 sync: fix setting memberships
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-04 08:12:46 -06:00
Sumner Evans 8480c8aa68 client: make GetUserInfo work for channels
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-04 07:59:57 -06:00
Sumner Evans d14f365fe1 sync: fix room name bridging on backfill
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-04 07:59:36 -06:00
Sumner Evans 7d9836c86b power levels: bridge rights for group chats
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-03 11:16:18 -06:00
Tulir Asokan 1c7e626c97 sticker: fix lottie conversion 2024-10-03 14:07:11 +03:00
Tulir Asokan 4bd57f7cab ci: add old issue locking 2024-10-03 14:04:14 +03:00
Tulir Asokan 9da87fc789 dependencies: update 2024-10-03 14:03:57 +03:00
Tulir Asokan 2139bf25eb push: implement PushableNetworkAPI 2024-10-03 14:02:32 +03:00
Sumner Evans 083837aa9e pins: bridge from Telegram -> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-02 12:28:05 -06:00
Sumner Evans abba9bcf81 pins: handle (un)favourite tags from the network connector
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-02 12:17:04 -06:00
Sumner Evans 171b621999 client: implement MuteHandlingNetworkAPI
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-02 10:12:41 -06:00
Sumner Evans 52fab81e55 mute: sync from Telegram -> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-02 10:12:33 -06:00
Sumner Evans 6f4e32fad0 client: handle group chat and channel creation events
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-01 11:10:12 -06:00
Sumner Evans 9609f437d5 deps/mautrix: upgrade
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-10-01 10:50:25 -06:00
Sumner Evans e1a56778f5 media: default to JPEG MIME-type for direct-downloaded images
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-27 16:14:28 -06:00
Sumner Evans 23bb0febe9 client: ignore messages in left channels
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-27 13:12:32 -06:00
Sumner Evans 31397681f5 client: save channel usernames in database
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-26 14:01:09 -06:00
Sumner Evans 332bbb8de1 client: handle channel updates
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-26 11:50:11 -06:00
Sumner Evans 7ccd8ab4ab portal: handle self-leaves of groups
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-26 10:02:55 -06:00
Sumner Evans 7af4ecc719 backfill: fix stopTakeoutTimer
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-26 10:01:05 -06:00
Sumner Evans ce1c28832e reactions: use allowed reactions when possible
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-26 07:48:51 -06:00
Sumner Evans 81c913bdd3 client: better logging on connection state changes
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-25 13:58:03 -06:00
Sumner Evans 5c23e9695f deps/td: upgrade
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-25 13:52:05 -06:00
Sumner Evans c6e96682b6 treewide: separate user and channel namespaces
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-25 07:16:05 -06:00
Sumner Evans 65da56b2a6 lottie: include in Docker image
Closes PLAT-27635

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-17 10:41:08 -06:00
Sumner Evans a73f9d1ec2 connector/tomatrix: fix replies
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-16 15:50:29 -06:00
Sumner Evans 7a02d6a35b client: use ping callback to determine if connection is still alive
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-16 14:20:36 -06:00
Tulir Asokan ff48398430 ids: add support for split portals 2024-09-14 12:50:31 +03:00
Tulir Asokan 7ed3c46f23 dependencies: update 2024-09-13 23:54:47 +03:00
Sumner Evans 3acd95741f connector: check for nil on cancel
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-10 13:02:49 -06:00
Tulir Asokan 3f69f29d49 config: remove pointer 2024-09-10 21:24:28 +03:00
Sumner Evans fab98cfdea takeout: use takeout to list dialogs once permission granted
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-10 11:27:00 -06:00
Sumner Evans 4692d46305 client: use both reconnection detection methods
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-10 08:30:33 -06:00
Sumner Evans 87f9f008e6 formatting: fix username parsing and insertion
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-10 00:08:00 -06:00
Sumner Evans 50ab23423f client: update for better connection detection
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-09 21:50:41 -06:00
Sumner Evans cd0d940889 connector/login: normalize phone number on finalize
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-09 21:47:19 -06:00
Sumner Evans 777225c252 roadmap: update
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-05 10:47:45 -06:00
Sumner Evans 89b1caadbf takeout: use takeout for backwards backfill
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-05 08:42:00 -06:00
Tulir Asokan 4d4060f37b legacymigrate: fix handling empty content hashes 2024-09-05 02:29:39 +03:00
Sumner Evans 75eea8e2cb reactions: fix double-puppeting
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-03 15:10:56 -06:00
Sumner Evans 3b6af95976 connector: support messages sent by a channel
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2024-09-03 13:34:13 -06:00
Sumner Evans 8925318ec4 legacyprovisioning: implement SNC endpoints
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-03 09:51:58 -06:00
Sumner Evans ec330c72be legacyprovisioning: fix getting user ID from request
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-02 16:58:47 -06:00
Sumner Evans 088900aee1 connector: save channel access hashes in more places
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-02 11:52:31 -06:00
Sumner Evans 86a2b3fa15 provisioning: send code faster and fix password after QR support
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-09-02 11:52:31 -06:00
Tulir Asokan afd9850c4b legacymigrate: drop telegram_file table separately on sqlite (#28)
* legacymigrate: drop telegram_file table separately on sqlite

* legacymigrate: check foreign keys after dropping table just to be safe
2024-09-02 11:21:59 -06:00
Sumner Evans 60fe2e07c2 bridge state: set remote name and profile
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-28 12:22:58 -06:00
Sumner Evans c2d94947ee provisioning: implement legacy QR endpoint
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-28 09:26:30 -06:00
Sumner Evans 4d9ad4f0af login: implement QR login
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-28 09:26:30 -06:00
Sumner Evans bbf53fb28b provisioning: implement legacy endpoints
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-28 09:26:30 -06:00
Tulir Asokan ced27a9974 legacymigrate: fix ghost metadata booleans 2024-08-28 12:18:19 +03:00
Tulir Asokan 3378467378 config: preserve spacing when rewriting config 2024-08-27 17:16:54 +03:00
Tulir Asokan 1c2b902de4 legacymigrate: handle portal and message metadata 2024-08-27 17:01:29 +03:00
Tulir Asokan f7be907633 matrix: fix making message ID 2024-08-27 16:51:57 +03:00
Tulir Asokan 1e39877af3 ids: remove emoji ID prefix 2024-08-27 16:45:55 +03:00
Tulir Asokan 6b092026c3 legacymigrate: add peer type to portal IDs and fix other things 2024-08-27 16:36:09 +03:00
Tulir Asokan 68e835c658 legacymigrate: add support for migrating legacy database and config (#23) 2024-08-27 15:13:11 +03:00
Tulir Asokan e3e709eec6 ids: add channel ID to message ID to ensure uniqueness (#25) 2024-08-26 20:42:06 +03:00
Sumner Evans d7508579e5 deps/mautrix: upgrade with fix to NPE on search
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-26 08:57:44 -06:00
Sumner Evans d8d4a60855 snc: implement creating normal groups
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 14:22:56 -06:00
Sumner Evans 196eaac917 snc: use local caches for resolving identifiers
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 13:47:55 -06:00
Sumner Evans 91ce540a66 contact list: implement fetching
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 13:47:55 -06:00
Sumner Evans dcf43ca9d9 search: implement searching by username
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 13:47:55 -06:00
Sumner Evans 15b0dc51b3 snc: implement resolving Telegram IDs, usernames, and phone numbers
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 12:18:21 -06:00
Tulir Asokan 9d8f162f41 chatinfo: override name for saved messages 2024-08-22 18:26:08 +03:00
Tulir Asokan eec5cbe447 login: fix bugs in refactor 2024-08-22 18:02:57 +03:00
Tulir Asokan b25c09fc53 store: refactor access hash and session tables
* Move sessions to user_login metadata, as that data rarely changes after login.
* Merge user and channel access hashes. Those IDs don't conflict.
* Split usernames into a new table to allow better `ON CONFLICT` updates
  (when a username moves to another entity, we want the old row to be replaced).
  Usernames also don't need to be scoped to a login.
2024-08-22 17:54:10 +03:00
Tulir Asokan e611c87342 all: add some todos and fix small issues 2024-08-22 17:53:50 +03:00
Sumner Evans a6946f8119 sync: skip deleted users and use messages from GetDialogs call
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 08:51:35 -06:00
Sumner Evans 5960a2307e sync: fix check for needing backfill
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 08:34:16 -06:00
Sumner Evans 6aaf786ea9 backfill: run on login
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 08:32:43 -06:00
Sumner Evans 8b8b689187 sync: add on-command sync
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 07:57:28 -06:00
Sumner Evans 24d0d4687a connector/tomatrix: fix NPE with unsupported media
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 07:57:28 -06:00
Sumner Evans 0670c2b2bc updates: add wrapper for API calls to update users
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 07:57:28 -06:00
Sumner Evans 284178df65 client: enqueue backfill if channel too long
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 07:57:28 -06:00
Sumner Evans 56f83315ed backfill: implement
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-22 07:57:28 -06:00
Sumner Evans 7e2d9bbc4e avatar: fix downloading avatars
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-20 14:13:50 -06:00
Sumner Evans d11af1a463 db: fix latest revision
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-20 14:12:42 -06:00
Sumner Evans dc4c3ee382 connector: fix NPE with read receipts
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-20 08:55:32 -06:00
Sumner Evans 0ef8581764 connector/client: cleanup
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-20 08:55:11 -06:00
Sumner Evans 6c4c0f4821 connector/chatinfo: use access hash for user
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-20 08:54:34 -06:00
Sumner Evans b11479e4e2 client: clean up connection code and add bad credentials handling
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-20 07:42:30 -06:00
Sumner Evans 3a11ac217e client: make ping interval and timeout configurable
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-15 17:36:47 -06:00
Sumner Evans d94dbe81dc bridge states: send CONNECTED/TRANSIENT_DISCONNECT
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-15 17:35:41 -06:00
Sumner Evans 6462b709f5 deps/td: use Beeper fork that doesn't eat receipts
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-14 13:11:18 -06:00
Sumner Evans a86c2c2544 read receipts: bridges TG <-> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-08 15:51:27 -06:00
Sumner Evans 838f291220 store: move the access_hash and username to separate per-user table
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-08 12:12:17 -06:00
Sumner Evans aeb8fba288 msgconv: annotate GIFs bridged as videos with correct flags
Closes PLAT-25993

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-08 11:36:37 -06:00
Sumner Evans 497bfb152e media: bridge GIFs as documents rather than images
This allows them to be animated.

Closes PLAT-25990

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-07 16:25:22 -06:00
Sumner Evans 83695b4336 directdownload: include receiver in media ID
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-07 11:18:59 -06:00
Sumner Evans e0194f7621 typing: support TG <-> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-07 11:01:24 -06:00
Sumner Evans 7fd280ea10 chat metadata: bridge join/leave events TG -> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-06 14:39:20 -06:00
Sumner Evans ca4d566490 chat metadata: bridge title/avatar edits TG -> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-06 12:57:32 -06:00
Sumner Evans 7e53698696 roadmap: update
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-06 12:34:14 -06:00
Sumner Evans 54f971f578 connector: convert to simplevent
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-06 10:24:23 -06:00
Sumner Evans 18337c6941 reactions: use ReactionSync event
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-06 10:24:23 -06:00
Sumner Evans f56f520308 (telegram|matrix)fmt: mention formatting
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-05 15:19:53 -06:00
Sumner Evans b539e5d63d ghost: improve metadata handling
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-05 15:19:53 -06:00
Sumner Evans e8b5d286dc matrixfmt: text formatting Matrix -> TG
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-05 15:19:53 -06:00
Sumner Evans 882582456e telegramfmt: text formatting TG -> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-08-05 15:19:39 -06:00
Sumner Evans e7522be252 reactions: handle async
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-19 14:49:49 -06:00
Sumner Evans 5ea342e788 edits: bridge Matrix -> TG
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-19 14:33:52 -06:00
Sumner Evans 314b2da99f edits: bridge TG -> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-19 14:33:52 -06:00
Sumner Evans 5a3b52dff2 reactions: remove as the correct user
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-19 12:34:28 -06:00
Sumner Evans 98a0ed0a5b deps: td v0.102.0 -> v0.105.0, mautrix@cc5f225
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-19 09:42:10 -06:00
Sumner Evans 29c3c4009a client: improve logging on getEventSender
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-18 17:40:21 -06:00
Sumner Evans fe550da243 metadata: allow disabling channel memebr sync
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-18 08:50:35 -06:00
Sumner Evans a0d88da480 metadata: add pagination config for members initial sync
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-17 16:44:53 -06:00
Sumner Evans ec56fb6b28 metadata: refactor getting chat info
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-17 15:26:34 -06:00
Sumner Evans 9d77bebe3e config: remove set_private_chat_portal_meta option
It's handled by bridgev2

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-17 12:34:28 -06:00
Sumner Evans 48858ac28f config: add member list and max member count options
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-17 12:03:58 -06:00
Sumner Evans 0e6ea310d1 metadata: gate setting DM portal metadata behind config option
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-16 18:50:15 -06:00
Sumner Evans 69c9e3c38c client: fix GetChatInfo for channels
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-16 12:30:02 -06:00
Tulir Asokan 0068341185 Bump version to 0.15.2 2024-07-16 11:53:19 +03:00
Sumner Evans a8142cd8a0 remove all printf's and update logging
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-15 23:53:15 -06:00
Sumner Evans 6e0f604209 updates: don't panic on channel too long
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-15 22:14:09 -06:00
Sumner Evans 34832c7ff7 channels: handle messages Matrix <-> TG
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-15 15:23:50 -06:00
Sumner Evans 62f77686c4 ci: remove unnecessary files and align with what mautrix-slack has
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-15 11:02:40 -06:00
Scott Weber 5eaec4d0e0 Quick hack to get the bridge to send CONNECTED 2024-07-15 11:01:18 -06:00
Sumner Evans 35c5518d1d Revert "metadata: prefix fields"
This reverts commit 548356189b.
2024-07-15 09:06:23 -06:00
Sumner Evans 548356189b metadata: prefix fields
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-15 08:30:13 -06:00
Scott Weber aa45619244 Update mautrix-go (and update to new metadata system) 2024-07-15 08:30:13 -06:00
Tulir Asokan efcf1535ff Update mautrix-python 2024-07-12 20:25:57 +03:00
Sumner Evans 92b8541654 pre-commit: enforce go mod tidy, no literal HTTP methods
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-10 12:00:29 -06:00
Sumner Evans 62d6145c14 stickers: support receiving and converting
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-10 10:45:56 -06:00
Sumner Evans 58cc638058 media: major refactor of downloading/direct URL
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-09 23:03:15 -06:00
Sumner Evans 7e680f1fee reactions: support deletions
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-09 14:04:49 -06:00
Sumner Evans a63f264804 reactions: support custom emojis
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-09 14:04:25 -06:00
Tulir Asokan 99f633e98d Update telethon and changelog 2024-07-09 12:15:41 +03:00
Tulir Asokan 0137bfcbf6 Update mautrix-python 2024-07-09 12:15:41 +03:00
Sumner Evans 33dc5bad03 reactions: support Matrix -> TG
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-08 08:47:30 -06:00
Sumner Evans 5d39fc8c5f pkg/download -> pkg/media
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-08 08:47:30 -06:00
Sumner Evans 0921168b91 pkg/store -> pkg/connector/store
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-08 08:47:30 -06:00
Sumner Evans cbba340da6 db: add telegram_file table
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-08 08:47:30 -06:00
Sumner Evans a2b810e34e reactions: support unicode custom emojis
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-08 08:47:30 -06:00
Sumner Evans feab4607b5 reactions: support TG -> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-08 08:47:29 -06:00
Sumner Evans 15cb6ef44f deps/mautrix: upgrade to latest
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-08 08:21:09 -06:00
Sumner Evans f524f365f1 deps/go-util: upgrade
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-07-02 14:21:24 -06:00
Sumner Evans 3d8b9d6291 client: handle message deletions TG <-> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-25 15:33:00 -06:00
Sumner Evans 55a9375938 media: support thumbnails
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-25 13:52:55 -06:00
Sumner Evans 6a6e129c0a client: remove unnecessary log
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-25 12:20:24 -06:00
Sumner Evans 6bd2ef5b34 media: decode waveform TG -> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-25 11:02:55 -06:00
Sumner Evans 7437240f2f user metadata: bridge profile pictures
This commit includes bridging of both the initial profile pictures and
real-time updates to the profile pictures.

Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-25 10:47:03 -06:00
Sumner Evans 8ad516c5a4 cmd/directdl: delete experiment
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-24 14:20:50 -06:00
Sumner Evans eef68706d9 dockerfile: add v2 CI
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-24 14:14:05 -06:00
Sumner Evans 752107ffb0 initial metadata: set room avatar
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-24 14:05:22 -06:00
Sumner Evans 5193cd899f initial metadata: set room name
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-24 14:05:16 -06:00
Sumner Evans 1563ee014d deps/mautrix: upgrade
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-24 11:52:21 -06:00
Sumner Evans a24079494d directdownload: fix logging and remove outdated comment
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-24 10:57:50 -06:00
Sumner Evans 44cb928707 msgconv: fix location messages
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-24 09:33:07 -06:00
Sumner Evans 4d82cb7883 media: add fallbacks for a couple more types
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans 867cbd582e media: fallback for games
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans 17badab358 media: handle dice rolls
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans ee583af4f9 media: handle polls
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans 499678d092 media: handle location shares
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans 9d9c82c9e9 media: handle unsupported types
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans c0c7ad7d0f media: handle contact shares
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans 63645e50b2 handle matrix message: suppress previews if event's link previews is as empty array
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans 891750592d converter: handle link previews TG -> Matrix
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans b568ef8d8c media: support voice messages
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans 16706d8338 media: support documents
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-20 18:42:09 -06:00
Sumner Evans 2df6f73098 disappearing images: implement
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-19 13:11:35 -06:00
Sumner Evans 7963e52405 direct media: implement direct download for photos
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-19 12:47:41 -06:00
Sumner Evans d0626e670c deps/mautrix: upgrade to latest bridgev2
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-19 12:36:10 -06:00
Sumner Evans f3f6ea8b2f connector: ensure it adheres to the network connector interface
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-18 11:21:53 -06:00
Sumner Evans 871a9705e3 images: implement sending from Matrix -> Telegram
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-17 17:34:49 -06:00
Sumner Evans 5de193d087 ci: use gov2 builder
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-17 17:34:49 -06:00
Sumner Evans 60f668deb4 msgconv: clean up TG->Matrix photo logic
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-17 16:11:59 -06:00
Sumner Evans 61c06396fc msgconv: basic photo support
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-17 14:46:45 -06:00
Sumner Evans 323fe1603e store: save updates state in database
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-17 14:46:45 -06:00
Sumner Evans a4aedec044 dms: implement basic text message handling
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-17 14:46:45 -06:00
Sumner Evans 6c88b21b75 example config: update for bridgev2
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-17 14:46:45 -06:00
Sumner Evans 6511adc480 login: reimplement login in connector interface
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-06-17 14:46:44 -06:00
Javier Cuevas f6cb26f7f5 Merge pull request #964 from mautrix/feature/periodic-refresh
Add periodic connection refresh
2024-05-24 10:19:43 +02:00
Javier Cuevas 6418202118 Update mautrix_telegram/abstract_user.py
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2024-05-24 09:39:20 +02:00
Javier Cuevas 4b25e855e0 Add force_refresh_interval_seconds to config.py 2024-05-24 09:36:09 +02:00
Javier Cuevas a35f6abfd1 Change default for force_refresh_interval_seconds (disabled by default) 2024-05-24 09:36:03 +02:00
Javier Cuevas 716222a671 Format to pass linting 2024-05-23 17:18:06 +02:00
Javier Cuevas 31801a436c Add periodic connection refresh 2024-05-23 17:06:04 +02:00
Sumner Evans f2219a1e06 cmd/directdl: add experimental direct download handler
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-05-15 16:54:34 -06:00
Sumner Evans 72fc81b239 msgconv: start experimenting with direct download URL
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-05-15 16:53:57 -06:00
Sumner Evans 43212ad8db ci: install staticcheck
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-05-14 13:49:29 -06:00
Sumner Evans 0d502a8c55 Basic message converter and login
Signed-off-by: Sumner Evans <sumner.evans@automattic.com>
2024-05-14 10:17:19 -06:00
Tulir Asokan 043cb7f854 Remove everything and add stub Go module 2024-05-14 19:04:23 +03:00
Tulir Asokan 8bd5a4e367 Update changelog 2024-05-03 11:47:48 +02:00
Tulir Asokan 43d17a335b Fix call end message 2024-04-08 17:47:44 +03:00
Nick Mills-Barrett 84a3fde1ca Implement bot/channel file size limit 2024-03-25 14:36:29 +00:00
Nick Mills-Barrett 05d05e671b Add config to limit size of documents from bots/channels copied to Matrix 2024-03-25 14:36:29 +00:00
Nick Mills-Barrett ab6a6654f7 Pass through is channel to msg conversion 2024-03-25 14:36:29 +00:00
Tulir Asokan dbfbf12862 Fix error handling replies in some cases 2024-03-19 12:02:58 +02:00
Tulir Asokan 6166173376 Fix message in MSS events 2024-03-14 13:08:53 +02:00
Tulir Asokan 2232d9898e Avoid logging RPCErrors twice 2024-03-14 13:07:22 +02:00
Tulir Asokan 3cf279718f Don't send notices for some errors 2024-03-14 13:05:55 +02:00
Tulir Asokan 65ec4491e2 Merge branch 'tulir/bot-reactions' 2024-03-13 15:21:33 +02:00
Tulir Asokan ce43607c56 Update dependencies 2024-03-13 15:20:41 +02:00
Nick Mills-Barrett 150bf5e338 Return if no document contained in media document event 2024-02-14 09:58:24 +00:00
Tulir Asokan 77cbbebfb2 Update Black to 2024 style and Python 3.10 target 2024-01-29 18:52:10 +02:00
Tulir Asokan 511043a720 Add support for bot-specific reaction update 2024-01-13 22:42:42 +02:00
Tulir Asokan 19a4b4374d Update dependencies and drop Python 3.9 support 2024-01-08 17:35:37 +02:00
Tulir Asokan 731d5e028a Bump version to 0.15.1 2023-12-26 17:07:43 +01:00
Tulir Asokan 5ea9e48954 Don't trust member list if source user isn't there 2023-12-26 16:57:43 +01:00
Tulir Asokan 73b26e3fbd Update Telethon 2023-12-26 16:54:18 +01:00
Tulir Asokan 48be895938 Update dependencies 2023-12-15 22:36:46 +02:00
Tulir Asokan 87909d07ec Fix potential issues with ignore_unbridged_group_chat option 2023-12-15 22:28:10 +02:00
Tulir Asokan 3609eb2b70 Update Docker image to Alpine 3.19 2023-12-08 15:39:02 +02:00
Tulir Asokan 562f646fea Bump version to 0.15.0 2023-11-26 20:15:48 +02:00
Nick Mills-Barrett ab3cf5bc5f Add missing break in connect loop 2023-11-13 18:28:23 +00:00
Nick Mills-Barrett 1b2f07dfa2 Add quick retries when connecting to Telegram (#941)
* Add quick 5 retries when connecting to Telegram

* Fix attempt initialisation

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

* Log only when retrying

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

---------

Co-authored-by: Tulir Asokan <tulir@maunium.net>
2023-11-13 18:21:39 +00:00
Tulir Asokan 2a67c96db3 Update mautrix-python 2023-11-10 22:07:56 +02:00
Tulir Asokan 3fdb789745 Update dependencies 2023-11-10 14:51:02 +02:00
Tulir Asokan e4c239e6bc Update changelog 2023-11-10 14:46:41 +02:00
Tulir Asokan 897a35be5d Add commands to add and delete contacts. Fixes #885 2023-11-10 14:42:06 +02:00
Tulir Asokan d72897dfe8 Remove support for MSC2716 2023-11-01 01:03:45 +02:00
Tulir Asokan 27723f5055 Update Telethon again 2023-10-30 12:14:54 +02:00
Tulir Asokan a84e5ebc6a Remove redundant <br>'s after block tags when converting from Telegram 2023-10-29 12:06:39 +02:00
Tulir Asokan 90a8583ad0 Include partial quote target text in Matrix event 2023-10-29 12:04:46 +02:00
Tulir Asokan bf2cef424b Add support for cross-room replies from Telegram 2023-10-29 02:12:17 +03:00
Tulir Asokan 6809ebcde9 Update Telethon 2023-10-29 02:00:10 +03:00
Tulir Asokan 6fafc533ab Catch AuthKeyNotFound in start 2023-10-22 11:43:56 +03:00
Tulir Asokan 060dd647c3 Add comment 2023-10-22 11:43:56 +03:00
Tulir Asokan 812b4ec8db Adjust kick message when user joins portal with no relaybot
Closes #875
2023-10-16 19:36:16 +03:00
Tulir Asokan 8c1ddec136 Update Telethon again 2023-10-16 18:07:47 +03:00
Tulir Asokan 08db5a687c Improve .gitignore 2023-10-16 12:58:17 +03:00
Tulir Asokan ec298b2b90 Update Telethon and fix handling disappearing media 2023-10-16 12:58:16 +03:00
Tulir Asokan 22f91d51a3 Handle weird missing sizes in stickers 2023-09-19 15:55:43 -04:00
18145 changed files with 954468 additions and 20856 deletions
-2
View File
@@ -1,9 +1,7 @@
.editorconfig .editorconfig
.codeclimate.yml
*.png *.png
*.md *.md
logs logs
.venv
start start
config.yaml config.yaml
registration.yaml registration.yaml
+5 -6
View File
@@ -10,12 +10,11 @@ insert_final_newline = true
[*.md] [*.md]
trim_trailing_whitespace = false trim_trailing_whitespace = false
indent_size = 2
[*.py]
max_line_length = 99
[*.{yaml,yml,py}]
indent_style = space indent_style = space
[{.gitlab-ci.yml,.pre-commit-config.yaml,mautrix_telegram/web/provisioning/spec.yaml}] [*.{yaml,yml,sql}]
indent_style = space
[{.gitlab-ci.yml,.pre-commit-config.yaml,provisioning-spec.yaml,.github/workflows/*.yml}]
indent_size = 2 indent_size = 2
+4
View File
@@ -0,0 +1,4 @@
pkg/connector/humanise/errors.go linguist-generated=true
pkg/gotd/tg/* linguist-generated=true
tl_*_gen.go linguist-generated=true
*.gen.go linguist-generated=true
+13 -2
View File
@@ -1,7 +1,18 @@
--- ---
name: Bug report name: Bug report
about: If something is definitely wrong in the bridge (rather than just a setup issue), about: If something is definitely wrong in the bridge (rather than just a setup issue),
file a bug report. Remember to include relevant logs. file a bug report. Remember to include relevant logs. Asking in the Matrix room first
labels: bug is strongly recommended.
type: Bug
--- ---
<!-- Include relevant logs, the bridge version and other important details here -->
### Checklist
<!-- All items below are mandatory. Issues not following the rules may be closed without comment. -->
* [ ] This is an actual bug, not just a setup issue (see the [troubleshooting docs](https://docs.mau.fi/bridges/general/troubleshooting.html) or ask in the Matrix room for setup help).
* [ ] I am certain that sufficient information is included. Ask in the Matrix room first if not.
* [ ] The bug is still present on the main branch.
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: Enhancement request name: Enhancement request
about: Submit a feature request or other suggestion about: Submit a feature request or other suggestion
labels: enhancement type: Feature
--- ---
+39
View File
@@ -0,0 +1,39 @@
name: Go
on: [push, pull_request]
env:
GOTOOLCHAIN: local
jobs:
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.25", "1.26"]
name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }}
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Install libolm
run: sudo apt-get install libolm-dev libolm3
- name: Install dependencies
run: |
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
export PATH="$HOME/go/bin:$PATH"
- name: Install pre-commit
run: pip install pre-commit
- name: Lint
run: pre-commit run -a
-26
View File
@@ -1,26 +0,0 @@
name: Python lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: "3.11"
- uses: isort/isort-action@master
with:
sortPaths: "./mautrix_telegram"
- uses: psf/black@stable
with:
src: "./mautrix_telegram"
version: "23.1.0"
- name: pre-commit
run: |
pip install pre-commit
pre-commit run -av trailing-whitespace
pre-commit run -av end-of-file-fixer
pre-commit run -av check-yaml
pre-commit run -av check-added-large-files
+29
View File
@@ -0,0 +1,29 @@
name: 'Lock old issues'
on:
schedule:
- cron: '0 20 * * *'
workflow_dispatch:
permissions:
issues: write
# pull-requests: write
# discussions: write
concurrency:
group: lock-threads
jobs:
lock-stale:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6
id: lock
with:
issue-inactive-days: 90
process-only: issues
- name: Log processed threads
run: |
if [ '${{ steps.lock.outputs.issues }}' ]; then
echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"'
fi
+13 -19
View File
@@ -1,22 +1,16 @@
/.idea/ .idea
/.venv *.yaml
/env/ !.pre-commit-config.yaml
pip-selfcheck.json !example-config.yaml
*.pyc !provisioning-spec.yaml
__pycache__
/build
/dist
/*.egg-info
/.eggs
/config.yaml *.json
/registration.yaml !pkg/connector/emojis/unicodemojipack.json
*.log* *.db*
*.db *.log
*.db-*
/*.pickle
*.bak *.bak
/*.session
/*.session-journal /mautrix-telegram
/*.json /mautrix-telegramgo
/start
+1 -1
View File
@@ -1,3 +1,3 @@
include: include:
- project: 'mautrix/ci' - project: 'mautrix/ci'
file: '/python.yml' file: '/gov2-as-default.yml'
+1
View File
@@ -0,0 +1 @@
<svg id="Livello_1" data-name="Livello 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 240 240"><defs><linearGradient id="linear-gradient" x1="120" y1="240" x2="120" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d93d2"/><stop offset="1" stop-color="#38b0e3"/></linearGradient></defs><title>Telegram_logo</title><circle cx="120" cy="120" r="120" fill="url(#linear-gradient)"/><path d="M81.229,128.772l14.237,39.406s1.78,3.687,3.686,3.687,30.255-29.492,30.255-29.492l31.525-60.89L81.737,118.6Z" fill="#c8daea"/><path d="M100.106,138.878l-2.733,29.046s-1.144,8.9,7.754,0,17.415-15.763,17.415-15.763" fill="#a9c6d8"/><path d="M81.486,130.178,52.2,120.636s-3.5-1.42-2.373-4.64c.232-.664.7-1.229,2.1-2.2,6.489-4.523,120.106-45.36,120.106-45.36s3.208-1.081,5.1-.362a2.766,2.766,0,0,1,1.885,2.055,9.357,9.357,0,0,1,.254,2.585c-.009.752-.1,1.449-.169,2.542-.692,11.165-21.4,94.493-21.4,94.493s-1.239,4.876-5.678,5.043A8.13,8.13,0,0,1,146.1,172.5c-8.711-7.493-38.819-27.727-45.472-32.177a1.27,1.27,0,0,1-.546-.9c-.093-.469.417-1.05.417-1.05s52.426-46.6,53.821-51.492c.108-.379-.3-.566-.848-.4-3.482,1.281-63.844,39.4-70.506,43.607A3.21,3.21,0,0,1,81.486,130.178Z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+21 -10
View File
@@ -1,20 +1,31 @@
exclude: pkg/gotd/_fuzz/.*|pkg/gotd/_schema/.*|pkg/gotd/.*\.tmpl
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0 rev: v6.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
exclude_types: [markdown] exclude_types: [markdown]
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: check-added-large-files - id: check-added-large-files
- repo: https://github.com/psf/black
rev: 23.1.0 - repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.4
hooks: hooks:
- id: black - id: go-imports
language_version: python3 args:
files: ^mautrix_telegram/.*\.pyi?$ - "-local"
- repo: https://github.com/PyCQA/isort - "go.mau.fi/mautrix-telegram"
rev: 5.12.0 - "-w"
- id: go-vet-mod
# Disabled for now until we can find a way to filter out the gotd package
# - id: go-staticcheck-repo-mod
- id: go-mod-tidy
- repo: https://github.com/beeper/pre-commit-go
rev: v0.4.2
hooks: hooks:
- id: isort - id: prevent-literal-http-methods
files: ^mautrix_telegram/.*\.pyi?$ - id: zerolog-ban-global-log
- id: zerolog-ban-msgf
- id: zerolog-use-stringer
+83
View File
@@ -1,3 +1,86 @@
# unreleased
* Added support for bridging message reactions from Telegram when logged in as
a bot.
* Fixed `mx_room_state` table not being migrated correctly from the Python
bridge in SQLite databases.
# v26.04
* Rewrote bridge in Go using bridgev2 architecture.
* To migrate the bridge, simply upgrade in-place from v0.15.3 or later. The
database and config will be migrated automatically, although some parts of
the config aren't migrated (e.g. log config, permission types specific to
the legacy bridge). Taking backups beforehand is always recommended.
* It is recommended to check the config file after upgrading. If you have
prevented the bridge from writing to the config, you should update it
manually.
* The old-style relaybot is not yet supported and will not be migrated.
Setups using the relaybot will have to manually log in as a bot and use
`set-relay` to enable the generic [relay mode](https://docs.mau.fi/bridges/general/relay-mode.html).
The `default_relays` config option can be used to allow users to bridge
chats through the relay user to emulate the old-style relaybot.
* For multi-user bridges, normal (mini/non-super) group portal rooms are no
longer shared, which means every Matrix user will have their own room. The
old room will be assigned to one Matrix user randomly and others will get
a new room created automatically when receiving a message in the chat or
when using the `!tg sync-chats` command.
* If you want shared portals, upgrade the affected groups to supergroups.
You can upgrade groups using the `!tg upgrade` command, or in the
official apps by enabling any setting that requires a supergroup
(e.g. add a member tag for any admin).
* Management room status will not be migrated. Use `!tg set-management-room`
after the upgrade to re-allow commands without the `!tg` prefix in your
private chat with the bridge bot.
* Any migration issues should be reported in the Matrix room linked in the
readme.
* Notable new features include:
* Topic groups are now bridged as spaces
* Entire sticker and emoji packs can be synced in both directions
Note: the last (unreleased) version of the legacy Python bridge is available in
the [`python-final` tag](https://github.com/mautrix/telegram/tree/python-final).
# v0.15.3 (2025-07-16)
* Updated Telegram API to layer 204.
* Added support for MSC4190.
* Enabled captions by default, as they are now supported by most clients.
* Existing configs will still need to enable `caption_in_message` manually.
* Changed new room creation to hardcode room v11 to avoid v12 rooms being
created before proper support for them can be added.
* Fixed bridging sticker messages with partial quote replies from Telegram.
* Fixed text in poll bridging.
* Disabled kicking unauthenticated users from portals.
# v0.15.2 (2024-07-16)
* Dropped support for Python 3.9.
* Updated Telegram API to layer 183.
* Added support for authenticated media downloads.
* Added support for receiving reactions when using a bot account.
* Added option to limit file size by chat type.
* Fixed reply bridging breaking in some cases.
# v0.15.1 (2023-12-26)
* Updated Telegram API to layer 169.
* Updated Docker image to Alpine 3.19.
* Fixed some potential cases where a portal room would be created for the
relaybot even if `ignore_unbridged_group_chat` was enabled.
* Fixed member sync in groups with hidden members causing puppeted Matrix users
to be kicked even if they're still in the group.
# v0.15.0 (2023-11-26)
* Removed support for MSC2716 backfilling.
* Added `add-contact` and `delete-contact` commands.
* Updated Telegram API layer to 166.
* Includes receiving view-once media, blockquotes, quote replies and other
such things
* Fixed AuthKeyNotFound errors not being handled and causing users to get stuck
in a non-logged-in state.
# v0.14.2 (2023-09-19) # v0.14.2 (2023-09-19)
* **Security:** Updated Pillow to 10.0.1. * **Security:** Updated Pillow to 10.0.1.
+14 -51
View File
@@ -1,57 +1,20 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18 FROM golang:1-alpine3.23 AS builder
RUN apk add --no-cache \ RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
python3 py3-pip py3-setuptools py3-wheel \
#py3-pillow \
py3-aiohttp \
py3-magic \
py3-ruamel.yaml \
py3-commonmark \
py3-phonenumbers \
py3-mako \
#py3-prometheus-client \ (pulls in twisted unnecessarily)
# Indirect dependencies
py3-idna \
py3-rsa \
#py3-telethon \ (outdated)
py3-pyaes \
# cryptg
py3-cffi \
py3-qrcode \
py3-brotli \
# Other dependencies
ffmpeg \
ca-certificates \
su-exec \
netcat-openbsd \
# encryption
py3-olm \
py3-pycryptodome \
py3-unpaddedbase64 \
py3-future \
bash \
curl \
jq \
yq \
# Temporarily install pillow from edge repo to get up-to-date version
&& apk add --no-cache py3-pillow --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community
COPY requirements.txt /opt/mautrix-telegram/requirements.txt COPY . /build
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt WORKDIR /build
WORKDIR /opt/mautrix-telegram RUN ./build.sh
RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \
&& pip3 install /cryptg-*.whl \
&& pip3 install --no-cache-dir -r requirements.txt -r optional-requirements.txt \
&& apk del .build-deps \
&& rm -f /cryptg-*.whl
COPY . /opt/mautrix-telegram FROM alpine:3.23
RUN apk add git && pip3 install --no-cache-dir .[all] && apk del git \
# This doesn't make the image smaller, but it's needed so that the `version` command works properly
&& cp mautrix_telegram/example-config.yaml . && rm -rf mautrix_telegram .git build
ENV UID=1337 \
GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
COPY --from=builder /build/mautrix-telegram /usr/bin/mautrix-telegram
COPY --from=builder /build/docker-run.sh /docker-run.sh
VOLUME /data VOLUME /data
ENV UID=1337 GID=1337 \
FFMPEG_BINARY=/usr/bin/ffmpeg
CMD ["/opt/mautrix-telegram/docker-run.sh"] CMD ["/docker-run.sh"]
+17
View File
@@ -0,0 +1,17 @@
ARG DOCKER_HUB="docker.io"
FROM ${DOCKER_HUB}/alpine:3.23
ENV UID=1337 \
GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottieconverter
ARG EXECUTABLE=./mautrix-telegram
COPY $EXECUTABLE /usr/bin/mautrix-telegram
COPY ./docker-run.sh /docker-run.sh
ENV BRIDGEV2=1
VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"]
+12
View File
@@ -0,0 +1,12 @@
The mautrix-telegram developers grant the following special exceptions:
* to Beeper the right to embed the program in the Beeper clients and servers,
and use and distribute the collective work without applying the license to
the whole.
* to Element the right to distribute compiled binaries of the program as a part
of the Element Server Suite and other server bundles without applying the
license.
All exceptions are only valid under the condition that any modifications to
the source code of mautrix-telegram remain publicly available under the terms
of the GNU AGPL version 3 or later.
-5
View File
@@ -1,5 +0,0 @@
include README.md
include CHANGELOG.md
include LICENSE
include requirements.txt
include optional-requirements.txt
+6 -14
View File
@@ -2,34 +2,26 @@
![Languages](https://img.shields.io/github/languages/top/mautrix/telegram.svg) ![Languages](https://img.shields.io/github/languages/top/mautrix/telegram.svg)
[![License](https://img.shields.io/github/license/mautrix/telegram.svg)](LICENSE) [![License](https://img.shields.io/github/license/mautrix/telegram.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/mautrix/telegram/all.svg)](https://github.com/mautrix/telegram/releases) [![Release](https://img.shields.io/github/release/mautrix/telegram/all.svg)](https://github.com/mautrix/telegram/releases)
[![GitLab CI](https://mau.dev/mautrix/telegram/badges/master/pipeline.svg)](https://mau.dev/mautrix/telegram/container_registry) [![GitLab CI](https://mau.dev/mautrix/telegram/badges/main/pipeline.svg)](https://mau.dev/mautrix/telegram/container_registry)
[![Code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Imports](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
A Matrix-Telegram hybrid puppeting/relaybot bridge. A Matrix-Telegram puppeting/relaybot bridge.
## Sponsors ## Sponsors
* [Joel Lehtonen / Zouppen](https://github.com/zouppen) * [Joel Lehtonen / Zouppen](https://github.com/zouppen)
## Documentation ## Documentation
All setup and usage instructions are located on All setup and usage instructions are located on
[docs.mau.fi](https://docs.mau.fi/bridges/python/telegram/index.html). [docs.mau.fi](https://docs.mau.fi/bridges/go/telegram/index.html).
Some quick links: Some quick links:
* [Bridge setup](https://docs.mau.fi/bridges/python/setup.html?bridge=telegram) * [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=telegram)
(or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=telegram)) (or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=telegram))
* Basic usage: [Authentication](https://docs.mau.fi/bridges/python/telegram/authentication.html), * Basic usage: [Authentication](https://docs.mau.fi/bridges/go/telegram/authentication.html)
[Creating chats](https://docs.mau.fi/bridges/python/telegram/creating-and-managing-chats.html),
[Relaybot setup](https://docs.mau.fi/bridges/python/telegram/relay-bot.html)
### Features & Roadmap ### Features & Roadmap
[ROADMAP.md](https://github.com/mautrix/telegram/blob/master/ROADMAP.md) [ROADMAP.md](ROADMAP.md) contains a general overview of what is supported by the bridge.
contains a general overview of what is supported by the bridge.
## Discussion ## Discussion
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net) Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room) Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room)
## Preview
![Preview](preview.png)
+17 -25
View File
@@ -6,42 +6,34 @@
* [x] Message reactions * [x] Message reactions
* [x] Message edits * [x] Message edits
* [ ] ‡ Message history * [ ] ‡ Message history
* [x] Presence * [ ] Presence
* [x] Typing notifications * [x] Typing notifications
* [x] Read receipts * [x] Read receipts
* [x] Pinning messages * [ ] Pinning messages
* [x] Power level * [ ] Power level
* [x] Normal chats * [ ] Membership actions (invite/kick/join/leave)
* [ ] Non-hardcoded PL requirements * [ ] Room metadata changes (name, topic, avatar)
* [x] Supergroups/channels * [ ] Initial room metadata
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
* [x] Membership actions (invite/kick/join/leave)
* [x] Room metadata changes (name, topic, avatar)
* [x] Initial room metadata
* [ ] User metadata
* [ ] Initial displayname/username/avatar at register
* [ ] ‡ Changes to displayname/avatar
* Telegram → Matrix * Telegram → Matrix
* [x] Message content (text, formatting, files, etc..) * [x] Message content (text, formatting, files, etc..)
* [ ] Advanced message content/media * [ ] Advanced message content/media
* [x] Custom emojis * [x] Custom emojis
* [x] Polls * [ ] Polls
* [x] Games * [ ] Games
* [ ] Buttons * [ ] Buttons
* [x] Message deletions * [x] Message deletions
* [x] Message reactions * [x] Message reactions
* [x] Message edits * [x] Message edits
* [x] Message history * [x] Message history
* [x] Manually (`!tg backfill`)
* [x] Automatically when creating portal * [x] Automatically when creating portal
* [x] Automatically for missed messages * [x] Automatically for missed messages
* [x] Avatars * [x] Avatars
* [x] Presence * [ ] Presence
* [x] Typing notifications * [x] Typing notifications
* [x] Read receipts (private chat only) * [x] Read receipts (DMs only)
* [x] Pinning messages * [ ] Pinning messages
* [x] Admin/chat creator status * [x] Admin/chat creator status
* [ ] Supergroup/channel permissions (precise per-user permissions not supported in Matrix) * [x] Supergroup/channel permissions (precise per-user permissions not supported in Matrix)
* [x] Membership actions (invite/kick/join/leave) * [x] Membership actions (invite/kick/join/leave)
* [ ] Chat metadata changes * [ ] Chat metadata changes
* [x] Title * [x] Title
@@ -51,16 +43,16 @@
* [x] Initial chat metadata (about text missing) * [x] Initial chat metadata (about text missing)
* [x] User metadata (displayname/avatar) * [x] User metadata (displayname/avatar)
* [x] Supergroup upgrade * [x] Supergroup upgrade
* [x] Topics (spaces)
* Misc * Misc
* [x] Automatic portal creation * [x] Automatic portal creation
* [x] At startup * [x] At startup
* [x] When receiving invite or message * [x] When receiving invite or message
* [x] Portal creation by inviting Matrix puppet of Telegram user to new room * [x] Private chat creation by inviting Matrix ghost of Telegram user to new room
* [x] Option to use bot to relay messages for unauthenticated Matrix users (relaybot) * [x] Option to use bot to relay messages for unauthenticated Matrix users (relaybot)
* [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting) * [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting)
* [ ] ‡ Calls (hard, not yet supported by Telethon) * [ ] ‡ Calls
* [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram) * [ ] ‡ Secret chats (i.e. end-to-bridge encryption on Telegram)
* [x] End-to-bridge encryption in Matrix rooms (see [docs](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html))
† Information not automatically sent from source, i.e. implementation may not be possible † 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 ‡ Maybe, i.e. this feature may or may not be implemented at some point
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
BINARY_NAME=mautrix-telegram go tool maubuild "$@"
+102
View File
@@ -0,0 +1,102 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2025 Sumner Evans
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
_ "embed"
"fmt"
"github.com/rs/zerolog"
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
)
const legacyMigrateRenameTablesQuery = `
ALTER TABLE backfill_queue RENAME TO backfill_queue_old;
ALTER TABLE bot_chat RENAME TO bot_chat_old;
ALTER TABLE contact RENAME TO contact_old;
ALTER TABLE disappearing_message RENAME TO disappearing_message_old;
ALTER TABLE message RENAME TO message_old;
ALTER TABLE portal RENAME TO portal_old;
ALTER TABLE puppet RENAME TO puppet_old;
ALTER TABLE reaction RENAME TO reaction_old;
ALTER TABLE telegram_file RENAME TO telegram_file_old;
ALTER TABLE telethon_entities RENAME TO telethon_entities_old;
ALTER TABLE telethon_sent_files RENAME TO telethon_sent_files_old;
ALTER TABLE telethon_sessions RENAME TO telethon_sessions_old;
ALTER TABLE telethon_update_state RENAME TO telethon_update_state_old;
ALTER TABLE "user" RENAME TO user_old;
ALTER TABLE user_portal RENAME TO user_portal_old;
DROP INDEX IF EXISTS telegram_file_mxc_idx;
`
func legacyMigrateRenameTables(ctx context.Context, db *dbutil.Database) error {
_, err := db.Exec(ctx, legacyMigrateRenameTablesQuery)
if err != nil {
return err
}
var mxVersion int
err = db.QueryRow(ctx, "SELECT version FROM mx_version").Scan(&mxVersion)
if err != nil {
return fmt.Errorf("failed to get mx_version: %w", err)
} else if mxVersion == 3 {
zerolog.Ctx(ctx).Debug().Msg("mx_version is 3, adding create_event column before running actual migration")
_, err = db.Exec(ctx, `
ALTER TABLE mx_room_state ADD COLUMN create_event TEXT;
UPDATE mx_version SET version=4;
`)
if err != nil {
return fmt.Errorf("failed to add create_event column to mx_room_state: %w", err)
}
}
return nil
}
//go:embed legacymigrate.sql
var legacyMigrateCopyData string
func migrateLegacyConfig(helper up.Helper) {
helper.Set(up.Str, "mautrix.bridge.e2ee", "encryption", "pickle_key")
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"telegram", "api_id"}, []string{"network", "api_id"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "api_hash"}, []string{"network", "api_hash"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "device_model"}, []string{"network", "device_info", "device_model"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "system_version"}, []string{"network", "device_info", "system_version"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "app_version"}, []string{"network", "device_info", "app_version"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "lang_code"}, []string{"network", "device_info", "lang_code"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "system_lang_code"}, []string{"network", "device_info", "system_lang_code"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "animated_sticker", "target"}, []string{"network", "animated_sticker", "target"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "animated_sticker", "convert_from_webm"}, []string{"network", "animated_sticker", "convert_from_webm"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "animated_sticker", "width"}, []string{"network", "animated_sticker", "width"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "animated_sticker", "height"}, []string{"network", "animated_sticker", "height"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "animated_sticker", "fps"}, []string{"network", "animated_sticker", "fps"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "max_initial_member_sync"}, []string{"network", "member_list", "max_initial_sync"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "sync_channel_members"}, []string{"network", "member_list", "sync_broadcast_channels"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "skip_deleted_members"}, []string{"network", "member_list", "skip_deleted"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "proxy", "type"}, []string{"network", "proxy", "type"})
proxyAddress, _ := helper.Get(up.Str, "telegram", "proxy", "address")
proxyPort, _ := helper.Get(up.Int, "telegram", "proxy", "port")
helper.Set(up.Str, fmt.Sprintf("%s:%s", proxyAddress, proxyPort), "network", "proxy", "address")
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "proxy", "username"}, []string{"network", "proxy", "username"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "proxy", "password"}, []string{"network", "proxy", "password"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "max_member_count"}, []string{"network", "max_member_count"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "sync_update_limit"}, []string{"network", "sync", "update_limit"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "sync_create_limit"}, []string{"network", "sync", "create_limit"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "sync_direct_chats"}, []string{"network", "sync", "direct_chats"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "always_custom_emoji_reaction"}, []string{"network", "always_custom_emoji_reaction"})
}
+300
View File
@@ -0,0 +1,300 @@
INSERT INTO "user" (bridge_id, mxid)
SELECT '', mxid FROM user_old;
DELETE FROM telethon_sessions_old WHERE auth_key IS NULL;
ALTER TABLE telethon_sessions_old ADD COLUMN json_data jsonb;
UPDATE telethon_sessions_old SET json_data=
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'auth_key', encode(auth_key, 'base64'),
'dc_id', dc_id,
'server_address', server_address,
'port', port
);
INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, remote_profile, space_room, metadata)
SELECT
'', -- bridge_id
mxid, -- user_mxid
CAST(tgid AS TEXT), -- id
COALESCE(tg_username, tg_phone, ''), -- remote_name
'{}', -- remote_profile
'', -- space_room
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'phone', COALESCE('+' || tg_phone, ''),
'session', json((SELECT json_data FROM telethon_sessions_old WHERE session_id=mxid))
) -- metadata
FROM user_old
WHERE tgid IS NOT NULL;
INSERT INTO ghost (
bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc,
name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata
)
SELECT
'', -- bridge_id
CAST(id AS TEXT), -- id
COALESCE(displayname, ''), -- name
COALESCE(photo_id, ''), -- avatar_id
'', -- avatar_hash
COALESCE(avatar_url, ''), -- avatar_mxc
name_set,
avatar_set,
contact_info_set,
COALESCE(is_bot, false),
'[]', -- identifiers
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'is_premium', CASE WHEN is_premium THEN json('true') ELSE json('false') END,
'is_channel', CASE WHEN is_channel THEN json('true') ELSE json('false') END,
'contact_source', displayname_source,
'source_is_contact', CASE WHEN displayname_contact THEN json('true') ELSE json('false') END
) -- metadata
FROM puppet_old;
DELETE FROM user_portal_old WHERE portal IN (SELECT tgid FROM portal_old WHERE peer_type<>'channel');
DELETE FROM backfill_queue_old WHERE portal_tgid IN (SELECT tgid FROM portal_old WHERE peer_type<>'channel');
UPDATE portal_old
SET tg_receiver=COALESCE((SELECT "user" FROM user_portal_old WHERE portal=portal_old.tgid LIMIT 1), tg_receiver)
WHERE peer_type='chat' AND tgid=tg_receiver;
UPDATE portal_old
SET tg_receiver=COALESCE((SELECT tgid FROM user_old WHERE tgid IS NOT NULL LIMIT 1), tg_receiver)
WHERE peer_type='chat' AND tgid=tg_receiver;
DELETE FROM portal_old WHERE peer_type='chat' AND tgid=tg_receiver;
INSERT INTO portal (
bridge_id, id, receiver, mxid, other_user_id, name, topic, avatar_id, avatar_hash, avatar_mxc,
name_set, avatar_set, topic_set, name_is_custom, in_space, room_type, metadata
)
SELECT
'', -- bridge_id
peer_type || ':' || CAST(tgid AS TEXT), -- id
CASE WHEN peer_type='channel' THEN '' ELSE CAST(tg_receiver AS TEXT) END, -- receiver
mxid, -- mxid
CASE WHEN peer_type='user' THEN CAST(tgid AS TEXT) END, -- other_user_id
COALESCE(title, ''), -- name
COALESCE(about, ''), -- topic
COALESCE(photo_id, ''), -- avatar_id
'', -- avatar_hash
COALESCE(avatar_url, ''), -- avatar_mxc
name_set, -- name_set
avatar_set, -- avatar_set
false, -- topic_set
peer_type<>'user', -- name_is_custom
false, -- in_space
CASE WHEN peer_type='user' THEN 'dm' ELSE '' END, -- room_type
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'is_supergroup', CASE WHEN megagroup THEN json('true') ELSE json('false') END
) -- metadata
FROM portal_old;
INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred)
SELECT
'', -- bridge_id
user_old.mxid, -- user_mxid
CAST(user_portal_old.user AS TEXT), -- login_id
portal_old.peer_type || ':' || CAST(user_portal_old.portal AS TEXT), -- portal_id
CASE WHEN peer_type='channel' THEN '' ELSE CAST(user_portal_old.portal_receiver AS TEXT) END, -- portal_receiver
false, -- in_space
false -- preferred
FROM user_portal_old
INNER JOIN user_old ON user_portal_old."user" = user_old.tgid
INNER JOIN portal_old ON user_portal_old.portal = portal_old.tgid and user_portal_old.portal_receiver = portal_old.tg_receiver;
INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred)
SELECT
'', -- bridge_id
user_old.mxid, -- user_mxid
CAST(portal_old.tg_receiver AS TEXT), -- login_id
portal_old.peer_type || ':' || CAST(portal_old.tgid AS TEXT), -- portal_id
CAST(portal_old.tg_receiver AS TEXT), -- portal_receiver
false, -- in_space
false -- preferred
FROM portal_old
INNER JOIN user_old ON portal_old.tg_receiver = user_old.tgid
WHERE portal_old.tg_receiver<>portal_old.tgid
ON CONFLICT (bridge_id, user_mxid, login_id, portal_id, portal_receiver) DO NOTHING;
INSERT INTO ghost (bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata)
VALUES ('', '', '', '', '', '', false, false, false, false, '[]', '{}');
UPDATE message_old SET sender=NULL WHERE sender IS NOT NULL AND NOT EXISTS(SELECT 1 FROM puppet_old WHERE id=sender);
INSERT INTO message (
bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, timestamp, edit_count, metadata
)
SELECT
'', -- bridge_id
CASE WHEN tg_space=portal_old.tgid THEN (CAST(tg_space AS TEXT) || '.') ELSE '' END || CAST(message_old.tgid AS TEXT), -- id
'', -- part_id
message_old.mxid, -- mxid
portal_old.peer_type || ':' || CAST(portal_old.tgid AS TEXT), -- room_id
CASE WHEN portal_old.peer_type='channel' THEN '' ELSE CAST(portal_old.tg_receiver AS TEXT) END, -- room_receiver
COALESCE(CAST(sender AS TEXT), ''), -- sender_id
COALESCE(sender_mxid, ''),
0, -- timestamp
0, -- edit_count
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'content_hash', CASE WHEN content_hash IS NULL THEN '' ELSE encode(content_hash, 'base64') END
) -- metadata
FROM message_old
INNER JOIN portal_old ON mx_room=portal_old.mxid
WHERE (tg_space=portal_old.tgid OR tg_space=portal_old.tg_receiver) AND edit_index=0;
-- TODO migrate edit_index?
INSERT INTO reaction (
bridge_id, message_id, message_part_id, sender_id, emoji_id, room_id, room_receiver, mxid, timestamp, emoji, metadata
)
SELECT
'', -- bridge_id
message.id, -- message_id
message.part_id, -- message_part_id
CAST(tg_sender AS TEXT), -- sender_id
reaction, -- emoji_id
message.room_id, -- room_id
message.room_receiver, -- room_receiver
reaction_old.mxid, -- mxid
0, -- timestamp
reaction, -- emoji
'{}' -- metadata
FROM reaction_old
INNER JOIN message ON reaction_old.msg_mxid=message.mxid;
INSERT INTO telegram_access_hash (user_id, entity_type, entity_id, access_hash)
SELECT
user_old.tgid,
CASE WHEN id < 0 THEN 'channel' ELSE 'user' END,
CASE WHEN id < 0 THEN -id - 1000000000000 ELSE id END,
hash
FROM telethon_entities_old
LEFT JOIN user_old ON user_old.mxid=session_id
WHERE user_old.tgid IS NOT NULL AND hash<>0;
INSERT INTO telegram_user_state (user_id, pts, qts, date, seq)
SELECT user_old.tgid, pts, qts, date, seq
FROM telethon_update_state_old
LEFT JOIN user_old ON user_old.mxid=session_id
WHERE entity_id=0 AND user_old.tgid IS NOT NULL;
INSERT INTO telegram_channel_state (user_id, channel_id, pts)
SELECT user_old.tgid, entity_id, pts
FROM telethon_update_state_old
LEFT JOIN user_old ON user_old.mxid=session_id
WHERE entity_id<>0 AND user_old.tgid IS NOT NULL;
INSERT INTO telegram_username (username, entity_type, entity_id)
SELECT
username,
CASE WHEN id < 0 THEN 'channel' ELSE 'user' END,
CASE WHEN id < 0 THEN -id - 1000000000000 ELSE id END
FROM telethon_entities_old
WHERE username<>''
ON CONFLICT DO NOTHING;
INSERT INTO telegram_phone_number (phone_number, entity_id)
SELECT phone, id
FROM telethon_entities_old
WHERE phone<>''
ON CONFLICT DO NOTHING;
INSERT INTO telegram_file (id, mxc, mime_type, size, width, height, timestamp)
SELECT id, mxc, mime_type, size, width, height, timestamp
FROM telegram_file_old;
INSERT INTO disappearing_message (bridge_id, mx_room, mxid, type, timer, disappear_at)
SELECT
'', -- bridge_id
room_id,
event_id,
'after_send',
expiration_seconds * 1000000000,
expiration_ts * 1000000
FROM disappearing_message_old
WHERE expiration_ts<9999999999999 AND expiration_seconds<999999
AND room_id IN (SELECT mxid FROM portal WHERE mxid IS NOT NULL);
-- TODO do something with the bot_chat table?
-- Python -> Go mx_ table migration
-- only: postgres until "end only"
ALTER TABLE mx_room_state DROP COLUMN is_encrypted;
ALTER TABLE mx_room_state RENAME COLUMN has_full_member_list TO members_fetched;
UPDATE mx_room_state SET members_fetched=false WHERE members_fetched IS NULL;
ALTER TABLE mx_room_state ADD COLUMN join_rules jsonb;
ALTER TABLE mx_room_state ALTER COLUMN power_levels TYPE jsonb USING power_levels::jsonb;
ALTER TABLE mx_room_state ALTER COLUMN encryption TYPE jsonb USING encryption::jsonb;
ALTER TABLE mx_room_state ALTER COLUMN create_event TYPE jsonb USING create_event::jsonb;
ALTER TABLE mx_room_state ALTER COLUMN members_fetched SET DEFAULT false;
ALTER TABLE mx_room_state ALTER COLUMN members_fetched SET NOT NULL;
-- end only postgres
-- only: sqlite until "end only"
CREATE TABLE new_mx_room_state (
room_id TEXT PRIMARY KEY,
power_levels jsonb,
encryption jsonb,
create_event jsonb,
join_rules jsonb,
members_fetched BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO new_mx_room_state (room_id, encryption, power_levels, create_event, members_fetched)
SELECT room_id, encryption, power_levels, create_event, COALESCE(has_full_member_list, false)
FROM mx_room_state;
DROP TABLE mx_room_state;
ALTER TABLE new_mx_room_state RENAME TO mx_room_state;
-- end only sqlite
ALTER TABLE mx_user_profile ADD COLUMN name_skeleton bytea;
CREATE INDEX mx_user_profile_membership_idx ON mx_user_profile (room_id, membership);
CREATE INDEX mx_user_profile_name_skeleton_idx ON mx_user_profile (room_id, name_skeleton);
UPDATE mx_user_profile SET displayname='' WHERE displayname IS NULL;
UPDATE mx_user_profile SET avatar_url='' WHERE avatar_url IS NULL;
CREATE TABLE mx_registrations (
user_id TEXT PRIMARY KEY
);
UPDATE mx_version SET version=10;
DELETE FROM mx_user_profile WHERE room_id='' OR user_id='';
DELETE FROM mx_room_state WHERE room_id='';
DROP TABLE user_portal_old;
DROP TABLE backfill_queue_old;
DROP TABLE bot_chat_old;
DROP TABLE contact_old;
DROP TABLE disappearing_message_old;
DROP TABLE message_old;
DROP TABLE reaction_old;
DROP TABLE portal_old;
DROP TABLE puppet_old;
DROP TABLE user_old;
-- only: postgres (this is deleted separately for sqlite)
DROP TABLE telegram_file_old;
DROP TABLE telethon_entities_old;
DROP TABLE telethon_sent_files_old;
DROP TABLE telethon_sessions_old;
DROP TABLE telethon_update_state_old;
+427
View File
@@ -0,0 +1,427 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2024 Sumner Evans
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"go.mau.fi/util/exhttp"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-telegram/pkg/connector"
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
)
type response struct {
Username id.UserID `json:"username,omitempty"`
State string `json:"state,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
ErrCode string `json:"errcode,omitempty"`
}
func (r response) WithState(state string) response {
r.State = state
return r
}
func (r response) WithMessage(message string) response {
r.Message = message
return r
}
func (r response) WithError(errCode, error string) response {
r.ErrCode = errCode
r.Error = error
return r
}
type legacyLogin struct {
Process bridgev2.LoginProcess
NextStep *bridgev2.LoginStep
}
var inflightLegacyLoginsLock sync.RWMutex
var inflightLegacyLogins = map[id.UserID]*legacyLogin{}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
Subprotocols: []string{"net.maunium.telegram.login"},
}
func legacyProvLoginQR(w http.ResponseWriter, r *http.Request) {
log := zerolog.Ctx(r.Context()).With().Str("prov_method", "qr_login").Logger()
ctx := log.WithContext(r.Context())
user := m.Matrix.Provisioning.GetUser(r)
resp := response{Username: user.MXID}
var err error
var loginProcess bridgev2.LoginProcess
var nextStep *bridgev2.LoginStep
if loginProcess, err = c.CreateLogin(ctx, user, connector.LoginFlowIDQR); err != nil {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("create_login_failed", fmt.Sprintf("Failed to create a QR login process: %s", err.Error())))
} else if nextStep, err = loginProcess.Start(ctx); err != nil {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("start_login_failed", fmt.Sprintf("Failed to start login process: %s", err.Error())))
} else if nextStep.StepID != connector.LoginStepIDShowQR {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected first step %s", nextStep.StepID)))
}
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Err(err).Msg("Failed to upgrade connection to websocket")
return
}
defer func() {
err := ws.Close()
if err != nil {
log.Debug().Err(err).Msg("Error closing websocket")
}
}()
go func() {
// Read everything so SetCloseHandler() works
for {
_, _, err = ws.ReadMessage()
if err != nil {
break
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
ws.SetCloseHandler(func(code int, text string) error {
log.Debug().Int("close_code", code).Msg("Login websocket closed, cancelling login")
cancel()
return nil
})
for {
switch nextStep.StepID {
case connector.LoginStepIDShowQR:
ws.WriteJSON(map[string]any{"code": nextStep.DisplayAndWaitParams.Data})
nextStep, err = loginProcess.(bridgev2.LoginProcessDisplayAndWait).Wait(ctx)
if err != nil {
ws.WriteJSON(map[string]any{
"success": false,
"error": "qr_login_failed",
"message": fmt.Sprintf("Failed to login using QR code: %s", err),
})
return
}
case connector.LoginStepIDComplete:
ws.WriteJSON(map[string]any{"success": true})
go handleLoginComplete(ctx, user, nextStep.CompleteParams.UserLogin)
return
case connector.LoginStepIDPassword:
inflightLegacyLoginsLock.Lock()
inflightLegacyLogins[user.MXID] = &legacyLogin{Process: loginProcess, NextStep: nextStep}
inflightLegacyLoginsLock.Unlock()
ws.WriteJSON(map[string]any{"success": false, "error": "password-needed"})
return
default:
ws.WriteJSON(map[string]any{
"success": false,
"error": "unexpected_step",
"message": fmt.Sprintf("Unexpected step in QR code login process %s", nextStep.StepID),
})
return
}
}
}
func legacyProvLoginRequestCode(w http.ResponseWriter, r *http.Request) {
log := zerolog.Ctx(r.Context()).With().Str("prov_step", "request_code").Logger()
ctx := log.WithContext(r.Context())
user := m.Matrix.Provisioning.GetUser(r)
resp := response{Username: user.MXID, State: "request"}
legacyProvRequestCodeReq := map[string]string{}
if err := json.NewDecoder(r.Body).Decode(&legacyProvRequestCodeReq); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_body_invalid", "Request body is invalid"))
} else if phone, ok := legacyProvRequestCodeReq["phone"]; !ok || phone == "" {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("phone_missing", "Phone number missing"))
} else if loginProcess, err := c.CreateLogin(ctx, user, connector.LoginFlowIDPhone); err != nil {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("create_login_failed", fmt.Sprintf("Failed to create a phone number login process: %s", err.Error())))
} else if firstStep, err := loginProcess.Start(ctx); err != nil {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("start_login_failed", fmt.Sprintf("Failed to start login process: %s", err.Error())))
} else if firstStep.StepID != connector.LoginStepIDPhoneNumber {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected first step %s", firstStep.StepID)))
} else if nextStep, err := loginProcess.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{connector.LoginStepIDPhoneNumber: phone}); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_code_failed", fmt.Sprintf("Failed to request code: %s", err.Error())))
} else if nextStep.StepID != connector.LoginStepIDCode {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", nextStep.StepID)))
} else {
inflightLegacyLoginsLock.Lock()
inflightLegacyLogins[user.MXID] = &legacyLogin{Process: loginProcess, NextStep: nextStep}
inflightLegacyLoginsLock.Unlock()
exhttp.WriteJSONResponse(w, http.StatusOK, resp.
WithState("code").
WithMessage("Code requested successfully. Check your SMS or Telegram app and enter the code below."),
)
}
}
func legacyProvLoginSendCode(w http.ResponseWriter, r *http.Request) {
log := zerolog.Ctx(r.Context()).With().Str("prov_step", "send_code").Logger()
ctx := log.WithContext(r.Context())
user := m.Matrix.Provisioning.GetUser(r)
resp := response{Username: user.MXID, State: "code"}
legacyProvSendCodeReq := map[string]string{}
if inflightLogin, ok := inflightLegacyLogins[user.MXID]; !ok {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("no_login", "No login process in progress"))
} else if inflightLogin.NextStep.StepID != connector.LoginStepIDCode {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", inflightLogin.NextStep.StepID)))
} else if err := json.NewDecoder(r.Body).Decode(&legacyProvSendCodeReq); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_body_invalid", "Request body is invalid"))
} else if code, ok := legacyProvSendCodeReq["code"]; !ok || code == "" {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("phone_code_missing", "You must provide the code from your phone."))
} else if nextStep, err := inflightLogin.Process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{connector.LoginStepIDCode: code}); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("send_code_failed", fmt.Sprintf("Failed to send code: %s", err.Error())))
} else if nextStep.StepID == connector.LoginStepIDPassword {
inflightLegacyLoginsLock.Lock()
defer inflightLegacyLoginsLock.Unlock()
inflightLegacyLogins[user.MXID].NextStep = nextStep
exhttp.WriteJSONResponse(w, http.StatusAccepted, resp.
WithState("password").
WithMessage("Code accepted, but you have 2-factor authentication enabled. Please enter your password."),
)
return // Don't delete the inflight login yet, we need to submit the password.
} else if nextStep.StepID == connector.LoginStepIDComplete {
exhttp.WriteJSONResponse(w, http.StatusOK, resp.WithState("logged-in"))
go handleLoginComplete(ctx, user, nextStep.CompleteParams.UserLogin)
} else {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", nextStep.StepID)))
}
// If we got here, then there was an error, or the login is complete.
// Delete the in-flight login.
inflightLegacyLoginsLock.Lock()
delete(inflightLegacyLogins, user.MXID)
inflightLegacyLoginsLock.Unlock()
}
func legacyProvLoginSendPassword(w http.ResponseWriter, r *http.Request) {
log := zerolog.Ctx(r.Context()).With().Str("prov_step", "send_password").Logger()
ctx := log.WithContext(r.Context())
user := m.Matrix.Provisioning.GetUser(r)
resp := response{Username: user.MXID, State: "password"}
legacyProvSendPasswordReq := map[string]string{}
if inflightLogin, ok := inflightLegacyLogins[user.MXID]; !ok {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("no_login", "No login process in progress"))
} else if inflightLogin.NextStep.StepID != connector.LoginStepIDPassword {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", inflightLogin.NextStep.StepID)))
} else if err := json.NewDecoder(r.Body).Decode(&legacyProvSendPasswordReq); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_body_invalid", "Request body is invalid"))
} else if password, ok := legacyProvSendPasswordReq["password"]; !ok || password == "" {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("password_missing", "You must provide your password."))
} else if nextStep, err := inflightLogin.Process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{connector.LoginStepIDPassword: password}); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("send_password_failed", fmt.Sprintf("Failed to send password: %s", err.Error())))
} else if nextStep.StepID == connector.LoginStepIDComplete {
exhttp.WriteJSONResponse(w, http.StatusOK, resp.WithState("logged-in").WithMessage(nextStep.Instructions))
go handleLoginComplete(ctx, user, nextStep.CompleteParams.UserLogin)
} else {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", nextStep.StepID)))
}
// If we got here, then there was an error, or the login is complete.
// Delete the in-flight login.
inflightLegacyLoginsLock.Lock()
delete(inflightLegacyLogins, user.MXID)
inflightLegacyLoginsLock.Unlock()
}
func legacyProvLogout(w http.ResponseWriter, r *http.Request) {
user := m.Matrix.Provisioning.GetUser(r)
resp := response{Username: user.MXID}
logins := user.GetUserLogins()
if len(logins) == 0 {
exhttp.WriteJSONResponse(w, http.StatusOK, resp.WithError("not logged in", "You're not logged in"))
return
}
for _, login := range logins {
login.Client.(*connector.TelegramClient).LogoutRemote(r.Context())
}
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
}
func handleLoginComplete(ctx context.Context, user *bridgev2.User, newLogin *bridgev2.UserLogin) {
allLogins := user.GetUserLogins()
for _, login := range allLogins {
if login.ID != newLogin.ID {
login.Delete(ctx, status.BridgeState{StateEvent: status.StateLoggedOut, Reason: "LOGIN_OVERRIDDEN"}, bridgev2.DeleteOpts{})
}
}
}
func legacyProvContacts(w http.ResponseWriter, r *http.Request) {
log := zerolog.Ctx(r.Context()).With().
Str("prov_method", "contacts").
Logger()
ctx := log.WithContext(r.Context())
var resp response
login := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if login == nil {
exhttp.WriteJSONResponse(w, http.StatusNotFound, resp.WithError(mautrix.MNotFound.ErrCode, "No login found"))
return
}
api := login.Client.(bridgev2.ContactListingNetworkAPI)
contacts, err := api.GetContactList(ctx)
if err != nil {
log.Err(err).Msg("Failed to get contacts")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("M_UNKNOWN", fmt.Sprintf("Failed to get contacts: %v", err)))
return
}
contactsMap := map[int64]*legacyContactInfo{}
for _, contact := range contacts {
peerType, id, err := ids.ParseUserID(contact.UserID)
if err != nil {
log.Err(err).Msg("Failed to parse user id")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("M_UNKNOWN", fmt.Sprintf("Failed to parse user id: %v", err)))
return
} else if peerType != ids.PeerTypeUser {
log.Err(err).Msg("Unexpected peer type")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("M_UNKNOWN", fmt.Sprintf("Unexpected peer type: %s", peerType)))
return
}
if contact.UserInfo != nil {
contact.Ghost.UpdateInfo(ctx, contact.UserInfo)
}
contactsMap[id] = legacyContactInfoFromGhost(contact.Ghost)
}
exhttp.WriteJSONResponse(w, http.StatusOK, contactsMap)
}
func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
legacyResolveIdentifierOrStartChat(w, r, false)
}
func legacyProvPM(w http.ResponseWriter, r *http.Request) {
legacyResolveIdentifierOrStartChat(w, r, true)
}
type legacyResolveIdentifierResponse struct {
RoomID id.RoomID `json:"room_id,omitempty"`
JustCreated bool `json:"just_created,omitempty"`
ID int `json:"id,omitempty"`
ContactInfo *legacyContactInfo `json:"contact_info,omitempty"`
}
type legacyContactInfo struct {
Name string `json:"name,omitempty"`
Username string `json:"username,omitempty"`
Phone string `json:"phone,omitempty"`
IsBot bool `json:"is_bot,omitempty"`
AvatarURL id.ContentURIString `json:"avatar_url,omitempty"`
}
func legacyContactInfoFromGhost(ghost *bridgev2.Ghost) *legacyContactInfo {
var username, phone string
for _, id := range ghost.Identifiers {
if strings.HasPrefix(id, "telegram:") {
username = strings.TrimPrefix(id, "telegram:")
} else if strings.HasPrefix(id, "tel:") {
phone = strings.TrimPrefix(id, "tel:")
}
}
return &legacyContactInfo{
Name: ghost.Name,
Username: username,
Phone: phone,
IsBot: ghost.IsBot,
AvatarURL: ghost.AvatarMXC,
}
}
func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, create bool) {
log := zerolog.Ctx(r.Context()).With().
Str("prov_method", "resolve_identifier").
Bool("create", create).
Logger()
ctx := log.WithContext(r.Context())
var resp response
login := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if login == nil {
exhttp.WriteJSONResponse(w, http.StatusNotFound, resp.WithError(mautrix.MNotFound.ErrCode, "No login found"))
return
}
api := login.Client.(bridgev2.IdentifierResolvingNetworkAPI)
identResp, err := api.ResolveIdentifier(ctx, r.PathValue("identifier"), create)
if err != nil {
log.Err(err).Msg("Failed to resolve identifier")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError,
resp.WithError("M_UNKNOWN", fmt.Sprintf("Failed to resolve identifier: %v", err)))
return
} else if identResp == nil {
exhttp.WriteJSONResponse(w, http.StatusNotFound,
resp.WithError(mautrix.MNotFound.ErrCode, "User not found on Telegram"))
return
}
status := http.StatusOK
var apiResp legacyResolveIdentifierResponse
if identResp.Ghost != nil {
if identResp.UserInfo != nil {
identResp.Ghost.UpdateInfo(ctx, identResp.UserInfo)
}
apiResp.ContactInfo = legacyContactInfoFromGhost(identResp.Ghost)
}
if identResp.Chat != nil {
if identResp.Chat.Portal == nil {
identResp.Chat.Portal, err = m.Bridge.GetPortalByKey(ctx, identResp.Chat.PortalKey)
if err != nil {
log.Err(err).Msg("Failed to get portal")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError,
resp.WithError("M_UNKNOWN", "Failed to get portal"))
return
}
}
if create && identResp.Chat.Portal.MXID == "" {
apiResp.JustCreated = true
status = http.StatusCreated
err = identResp.Chat.Portal.CreateMatrixRoom(ctx, login, identResp.Chat.PortalInfo)
if err != nil {
log.Err(err).Msg("Failed to create portal room")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError,
resp.WithError("M_UNKNOWN", "Failed to create portal room"))
return
}
}
apiResp.RoomID = identResp.Chat.Portal.MXID
}
exhttp.WriteJSONResponse(w, status, &apiResp)
}
+127
View File
@@ -0,0 +1,127 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2024 Sumner Evans
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package main
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"go.mau.fi/util/dbutil/litestream"
"go.mau.fi/util/exerrors"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/matrix/mxmain"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-telegram/pkg/connector"
"go.mau.fi/mautrix-telegram/pkg/connector/store/upgrades"
)
// Information to find out exactly which commit the bridge was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
var c = &connector.TelegramConnector{}
var m = mxmain.BridgeMain{
Name: "mautrix-telegram",
URL: "https://github.com/mautrix/telegram",
Description: "A Matrix-Telegram puppeting bridge.",
Version: "26.04",
SemCalVer: true,
Connector: c,
}
func init() {
litestream.Functions["encode"] = func(data []byte, encoding string) string {
if encoding == "base64" {
return base64.StdEncoding.EncodeToString(data)
}
panic(fmt.Errorf("unknown encoding %q", encoding))
}
}
func main() {
bridgeconfig.HackyMigrateLegacyNetworkConfig = migrateLegacyConfig
versionWithoutCommit := m.Version
m.PostStart = func() {
if m.Matrix.Provisioning != nil {
m.Matrix.Provisioning.GetAuthFromRequest = func(r *http.Request) string {
if !strings.HasSuffix(r.URL.Path, "/login/qr") {
return ""
}
authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",")
for _, part := range authParts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "net.maunium.telegram.auth-") {
return strings.TrimPrefix(part, "net.maunium.telegram.auth-")
}
}
return ""
}
m.Matrix.Provisioning.GetUserIDFromRequest = func(r *http.Request) id.UserID {
return id.UserID(r.PathValue("userID"))
}
m.Matrix.Provisioning.Router.HandleFunc("/v1/user/{userID}/login/qr", legacyProvLoginQR)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/login/request_code", legacyProvLoginRequestCode)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/login/send_code", legacyProvLoginSendCode)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/login/send_password", legacyProvLoginSendPassword)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/logout", legacyProvLogout)
m.Matrix.Provisioning.Router.HandleFunc("GET /v1/user/{userID}/contacts", legacyProvContacts)
m.Matrix.Provisioning.Router.HandleFunc("/v1/user/{userID}/resolve_identifier/{identifier}", legacyProvResolveIdentifier)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/pm/{identifier}", legacyProvPM)
}
}
m.PostInit = func() {
if c.Config.DeviceInfo.AppVersion == "auto" {
c.Config.DeviceInfo.AppVersion = versionWithoutCommit
}
if c.Config.DeviceInfo.SystemVersion == "auto" {
c.Config.DeviceInfo.SystemVersion = ""
}
if c.Config.DeviceInfo.DeviceModel == "auto" || c.Config.DeviceInfo.DeviceModel == "" {
c.Config.DeviceInfo.DeviceModel = "mautrix-telegram"
}
m.CheckLegacyDB(
18,
"v0.14.0",
"v26.04",
m.LegacyMigrateWithAnotherUpgrader(
legacyMigrateRenameTables, legacyMigrateCopyData, 27,
upgrades.Table, "telegram_version", 6,
),
true,
)
ctx := context.TODO()
if exists, _ := m.DB.TableExists(ctx, "telegram_file_old"); exists {
exerrors.Must(m.DB.Exec(ctx, `
PRAGMA foreign_keys = 'OFF';
DROP TABLE telegram_file_old;
PRAGMA foreign_key_check;
PRAGMA foreign_keys = 'ON';
`))
}
}
m.InitVersion(Tag, Commit, BuildTime)
m.Run()
}
-3
View File
@@ -1,3 +0,0 @@
pre-commit>=2.10.1,<3
isort>=5.10.1,<6
black>=23,<24
+13 -26
View File
@@ -1,50 +1,37 @@
#!/bin/sh #!/bin/sh
if [ ! -z "$MAUTRIX_DIRECT_STARTUP" ]; then
if [ $(id -u) == 0 ]; then if [[ -z "$GID" ]]; then
echo "|------------------------------------------|" GID="$UID"
echo "| Warning: running bridge unsafely as root |"
echo "|------------------------------------------|"
fi
exec python3 -m mautrix_telegram -c /data/config.yaml
elif [ $(id -u) != 0 ]; then
echo "The startup script must run as root. It will use su-exec to drop permissions before running the bridge."
echo "To bypass the startup script, either set the `MAUTRIX_DIRECT_STARTUP` environment variable,"
echo "or just use `python3 -m mautrix_telegram -c /data/config.yaml` as the run command."
echo "Note that the config and registration will not be auto-generated when bypassing the startup script."
exit 1
fi fi
# Define functions. BINARY_NAME=/usr/bin/mautrix-telegram
function fixperms { function fixperms {
chown -R $UID:$GID /data chown -R $UID:$GID /data
# /opt/mautrix-telegram is read-only, so disable file logging if it's pointing there. # /opt/mautrix-telegram is read-only, so disable file logging if it's pointing there.
if [[ "$(yq e '.logging.handlers.file.filename' /data/config.yaml)" == "./mautrix-telegram.log" ]]; then if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/mautrix-telegram.log" ]]; then
yq -I4 e -i 'del(.logging.root.handlers[] | select(. == "file"))' /data/config.yaml yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml
yq -I4 e -i 'del(.logging.handlers.file)' /data/config.yaml
fi fi
} }
cd /opt/mautrix-telegram if [[ ! -f /data/config.yaml ]]; then
$BINARY_NAME -c /data/config.yaml -e
if [ ! -f /data/config.yaml ]; then
cp example-config.yaml /data/config.yaml
echo "Didn't find a config file." echo "Didn't find a config file."
echo "Copied default config file to /data/config.yaml" echo "Copied default config file to /data/config.yaml"
echo "Modify that config file to your liking." echo "Modify that config file to your liking."
echo "Start the container again after that to generate the registration file." echo "Start the container again after that to generate the registration file."
fixperms
exit exit
fi fi
if [ ! -f /data/registration.yaml ]; then if [[ ! -f /data/registration.yaml ]]; then
python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml || exit $? $BINARY_NAME -g -c /data/config.yaml -r /data/registration.yaml
echo "Didn't find a registration file." echo "Didn't find a registration file."
echo "Generated one for you." echo "Generated one for you."
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it." echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
fixperms
exit exit
fi fi
cd /data
fixperms fixperms
exec su-exec $UID:$GID python3 -m mautrix_telegram -c /data/config.yaml exec su-exec $UID:$GID $BINARY_NAME
+81
View File
@@ -0,0 +1,81 @@
module go.mau.fi/mautrix-telegram
go 1.25.0
toolchain go1.26.2
tool (
go.mau.fi/util/cmd/maubuild
golang.org/x/tools/cmd/stringer
)
require (
github.com/cenkalti/backoff/v4 v4.3.0
github.com/coder/websocket v1.8.14
github.com/go-faster/errors v0.7.1
github.com/go-faster/jx v1.2.0
github.com/go-faster/xor v1.0.0
github.com/go-openapi/inflect v0.21.5
github.com/gorilla/websocket v1.5.0
github.com/gotd/getdoc v0.51.0
github.com/gotd/ige v0.2.2
github.com/gotd/neo v0.1.5
github.com/gotd/tl v0.4.0
github.com/k0kubun/pp/v3 v3.5.1
github.com/klauspost/compress v1.18.5
github.com/rogpeppe/go-internal v1.14.1
github.com/rs/zerolog v1.35.0
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
go.mau.fi/util v0.9.9-0.20260430092340-8772e7714ea5
go.mau.fi/webp v0.2.0
go.mau.fi/zerozap v0.1.2
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
go.uber.org/atomic v1.11.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.50.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
golang.org/x/net v0.53.0
golang.org/x/sync v0.20.0
golang.org/x/tools v0.44.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.27.1-0.20260430124810-125ac2c48014
rsc.io/qr v0.2.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/benbjohnson/clock v1.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-faster/sdk v0.28.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)
+242
View File
@@ -0,0 +1,242 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=
github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=
github.com/go-faster/sdk v0.28.0 h1:zIu1bt0aeujpUJ/3GxaKy/Yn8Y5K9em4yYNsMHqOl+4=
github.com/go-faster/sdk v0.28.0/go.mod h1:Ts+Rd1B0ltePMxuuCwphkfPVtTIbJhV6jzsV46MVM5w=
github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/inflect v0.21.5 h1:M2RCq6PPS3YbIaL7CXosGL3BbzAcmfBAT0nC3YfesZA=
github.com/go-openapi/inflect v0.21.5/go.mod h1:GypUyi6bU880NYurWaEH2CmH84zFDNd+EhhmzroHmB4=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotd/getdoc v0.51.0 h1:69sHC34TnRCizsL/2fGWkbs88GYmEHbRHmHQxF8+umM=
github.com/gotd/getdoc v0.51.0/go.mod h1:yGFagVr+5jxhUkVTQGOqiBG4Pty0slsLGBqEYhNrGIU=
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
github.com/gotd/tl v0.4.0 h1:8k2z0drujiPyhpLDa9PRm/yU1Gwlfn3iUzeInPiXwMA=
github.com/gotd/tl v0.4.0/go.mod h1:CMIcjPWFS4qxxJ+1Ce7U/ilbtPrkoVo/t8uhN5Y/D7c=
github.com/k0kubun/pp/v3 v3.5.1 h1:fS8Xt0MWVVSiKwfXeIdE0WJlktdA87/gt0Hs0+j2R2s=
github.com/k0kubun/pp/v3 v3.5.1/go.mod h1:s7qPOSp65uuilpprLJs2yDi9DNd7JGyWJPtPvDFpG9w=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mau.fi/util v0.9.9-0.20260430092340-8772e7714ea5 h1:cNm4gkt7j907g1Q4XvyNKW8tTM8BaU91Kbfa5GGyiCs=
go.mau.fi/util v0.9.9-0.20260430092340-8772e7714ea5/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
go.mau.fi/zerozap v0.1.2 h1:ffH+8kPveX1qE0IbzeBu4pJ15vwp7Sz3H13qlZ1myGs=
go.mau.fi/zerozap v0.1.2/go.mod h1:I+w0ErpJijmc7q/63ns98W1jkBqF8iXwAe1krrd1IHU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.27.1-0.20260430124810-125ac2c48014 h1:KwXGBWwUHYJKVTYWgbZEFcaM6uYLMvfjzHJg/TLwvKc=
maunium.net/go/mautrix v0.27.1-0.20260430124810-125ac2c48014/go.mod h1:4fZ0M0xB5ZtueQI65RilX28J/3794BeK+LaCg4U61Jk=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
-2
View File
@@ -1,2 +0,0 @@
__version__ = "0.14.2"
__author__ = "Tulir Asokan <tulir@maunium.net>"
-145
View File
@@ -1,145 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any
from telethon import __version__ as __telethon_version__
from mautrix.bridge import Bridge
from mautrix.types import RoomID, UserID
from .bot import Bot
from .config import Config
from .db import init as init_db, upgrade_table
from .matrix import MatrixHandler
from .portal import Portal
from .puppet import Puppet
from .user import User
from .version import linkified_version, version
from .web.provisioning import ProvisioningAPI
from .web.public import PublicBridgeWebsite
from .abstract_user import AbstractUser # isort: skip
class TelegramBridge(Bridge):
module = "mautrix_telegram"
name = "mautrix-telegram"
beeper_service_name = "telegram"
beeper_network_name = "telegram"
command = "python -m mautrix-telegram"
description = "A Matrix-Telegram puppeting bridge."
repo_url = "https://github.com/mautrix/telegram"
version = version
markdown_version = linkified_version
config_class = Config
matrix_class = MatrixHandler
upgrade_table = upgrade_table
config: Config
bot: Bot | None
matrix: MatrixHandler
public_website: PublicBridgeWebsite | None
provisioning_api: ProvisioningAPI | None
def prepare_db(self) -> None:
super().prepare_db()
init_db(self.db)
def _prepare_website(self) -> None:
if self.config["appservice.provisioning.enabled"]:
self.provisioning_api = ProvisioningAPI(self)
self.az.app.add_subapp(
self.config["appservice.provisioning.prefix"], self.provisioning_api.app
)
else:
self.provisioning_api = None
if self.config["appservice.public.enabled"]:
self.public_website = PublicBridgeWebsite(self.loop)
self.az.app.add_subapp(
self.config["appservice.public.prefix"], self.public_website.app
)
else:
self.public_website = None
def prepare_bridge(self) -> None:
self._prepare_website()
AbstractUser.init_cls(self)
bot_token: str = self.config["telegram.bot_token"]
if bot_token and not bot_token.lower().startswith("disable"):
self.bot = AbstractUser.relaybot = Bot(bot_token)
else:
self.bot = AbstractUser.relaybot = None
self.matrix = MatrixHandler(self)
Portal.init_cls(self)
self.add_startup_actions(Puppet.init_cls(self))
self.add_startup_actions(User.init_cls(self))
self.add_startup_actions(Portal.restart_scheduled_disappearing())
if self.bot:
self.add_startup_actions(self.bot.start())
if self.config["bridge.resend_bridge_info"]:
self.add_startup_actions(self.resend_bridge_info())
async def resend_bridge_info(self) -> None:
self.config["bridge.resend_bridge_info"] = False
self.config.save()
self.log.info("Re-sending bridge info state event to all portals")
async for portal in Portal.all():
await portal.update_bridge_info()
self.log.info("Finished re-sending bridge info state events")
def prepare_stop(self) -> None:
self.add_shutdown_actions(user.stop() for user in User.by_tgid.values())
if self.bot:
self.add_shutdown_actions(self.bot.stop())
async def get_user(self, user_id: UserID, create: bool = True) -> User | None:
user = await User.get_by_mxid(user_id, create=create)
if user:
await user.ensure_started()
return user
async def get_portal(self, room_id: RoomID) -> Portal | None:
return await Portal.get_by_mxid(room_id)
async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet | None:
return await Puppet.get_by_mxid(user_id, create=create)
async def get_double_puppet(self, user_id: UserID) -> Puppet | None:
return await Puppet.get_by_custom_mxid(user_id)
def is_bridge_ghost(self, user_id: UserID) -> bool:
return bool(Puppet.get_id_from_mxid(user_id))
async def count_logged_in_users(self) -> int:
return len([user for user in User.by_tgid.values() if user.tgid])
async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]:
return {
**await super().manhole_global_namespace(user_id),
"User": User,
"Portal": Portal,
"Puppet": Puppet,
}
@property
def manhole_banner_program_version(self) -> str:
return f"{super().manhole_banner_program_version} and Telethon {__telethon_version__}"
TelegramBridge().run()
-757
View File
@@ -1,757 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Union
from abc import ABC, abstractmethod
import asyncio
import logging
import platform
import time
from telethon.errors import AuthKeyError, UnauthorizedError
from telethon.network import (
Connection,
ConnectionTcpFull,
ConnectionTcpMTProxyRandomizedIntermediate,
)
from telethon.sessions import Session
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (
Channel,
Chat,
MessageActionChannelMigrateFrom,
MessageEmpty,
PeerChannel,
PeerChat,
PeerUser,
PhoneCallRequested,
TypeUpdate,
UpdateChannel,
UpdateChannelUserTyping,
UpdateChatDefaultBannedRights,
UpdateChatParticipantAdmin,
UpdateChatParticipants,
UpdateChatUserTyping,
UpdateDeleteChannelMessages,
UpdateDeleteMessages,
UpdateEditChannelMessage,
UpdateEditMessage,
UpdateFolderPeers,
UpdateMessageReactions,
UpdateNewChannelMessage,
UpdateNewMessage,
UpdateNotifySettings,
UpdatePhoneCall,
UpdatePinnedChannelMessages,
UpdatePinnedDialogs,
UpdatePinnedMessages,
UpdateReadChannelInbox,
UpdateReadHistoryInbox,
UpdateReadHistoryOutbox,
UpdateShort,
UpdateShortChatMessage,
UpdateShortMessage,
UpdateUser,
UpdateUserName,
UpdateUserStatus,
UpdateUserTyping,
User,
UserStatusOffline,
UserStatusOnline,
)
from mautrix.appservice import AppService
from mautrix.errors import MatrixError
from mautrix.types import PresenceState, UserID
from mautrix.util import background_task
from mautrix.util.logging import TraceLogger
from mautrix.util.opt_prometheus import Counter, Histogram
from . import __version__, portal as po, puppet as pu
from .config import Config
from .db import Message as DBMessage, PgSession
from .tgclient import MautrixTelegramClient
from .types import TelegramID
if TYPE_CHECKING:
from .__main__ import TelegramBridge
from .bot import Bot
UpdateMessage = Union[
UpdateShortChatMessage,
UpdateShortMessage,
UpdateNewChannelMessage,
UpdateNewMessage,
UpdateEditMessage,
UpdateEditChannelMessage,
]
UpdateMessageContent = Union[
UpdateShortMessage, UpdateShortChatMessage, Message, MessageService, MessageEmpty
]
UPDATE_TIME = Histogram(
name="bridge_telegram_update",
documentation="Time spent processing Telegram updates",
labelnames=("update_type",),
)
UPDATE_ERRORS = Counter(
name="bridge_telegram_update_error",
documentation="Number of fatal errors while handling Telegram updates",
labelnames=("update_type",),
)
class AbstractUser(ABC):
loop: asyncio.AbstractEventLoop = None
log: TraceLogger
az: AppService
bridge: "TelegramBridge"
config: Config
relaybot: "Bot"
ignore_incoming_bot_events: bool = True
max_deletions: int = 10
client: MautrixTelegramClient | None
mxid: UserID | None
tgid: TelegramID | None
username: str | None
is_bot: bool
is_relaybot: bool
puppet_whitelisted: bool
whitelisted: bool
relaybot_whitelisted: bool
matrix_puppet_whitelisted: bool
is_admin: bool
def __init__(self) -> None:
self.is_admin = False
self.matrix_puppet_whitelisted = False
self.puppet_whitelisted = False
self.whitelisted = False
self.relaybot_whitelisted = False
self.client = None
self.is_relaybot = False
self.is_bot = False
@property
def connected(self) -> bool:
return self.client and self.client.is_connected()
@property
def _proxy_settings(self) -> tuple[type[Connection], tuple[Any, ...] | None]:
proxy_type = self.config["telegram.proxy.type"].lower()
connection = ConnectionTcpFull
connection_data = (
self.config["telegram.proxy.address"],
self.config["telegram.proxy.port"],
self.config["telegram.proxy.rdns"],
self.config["telegram.proxy.username"],
self.config["telegram.proxy.password"],
)
if proxy_type == "disabled":
connection_data = None
elif proxy_type == "socks4":
connection_data = (1,) + connection_data
elif proxy_type == "socks5":
connection_data = (2,) + connection_data
elif proxy_type == "http":
connection_data = (3,) + connection_data
elif proxy_type == "mtproxy":
connection = ConnectionTcpMTProxyRandomizedIntermediate
connection_data = (connection_data[0], connection_data[1], connection_data[4])
return connection, connection_data
@classmethod
def init_cls(cls, bridge: "TelegramBridge") -> None:
cls.bridge = bridge
cls.config = bridge.config
cls.loop = bridge.loop
cls.az = bridge.az
cls.ignore_incoming_bot_events = cls.config["bridge.relaybot.ignore_own_incoming_events"]
cls.max_deletions = cls.config["bridge.max_telegram_delete"]
async def _init_client(self) -> None:
self.log.debug(f"Initializing client for {self.name}")
session = await PgSession.get(self.name)
if self.config["telegram.server.enabled"]:
session.set_dc(
self.config["telegram.server.dc"],
self.config["telegram.server.ip"],
self.config["telegram.server.port"],
)
if self.is_relaybot:
base_logger = logging.getLogger("telethon.relaybot")
else:
base_logger = logging.getLogger(f"telethon.{self.tgid or -hash(self.mxid)}")
device = self.config["telegram.device_info.device_model"]
sysversion = self.config["telegram.device_info.system_version"]
appversion = self.config["telegram.device_info.app_version"]
connection, proxy = self._proxy_settings
if proxy:
self.log.debug(f"Using proxy setting: {proxy}")
assert isinstance(session, Session)
self.client = MautrixTelegramClient(
session=session,
api_id=self.config["telegram.api_id"],
api_hash=self.config["telegram.api_hash"],
app_version=__version__ if appversion == "auto" else appversion,
system_version=(
MautrixTelegramClient.__version__ if sysversion == "auto" else sysversion
),
device_model=(
f"{platform.system()} {platform.release()}" if device == "auto" else device
),
timeout=self.config["telegram.connection.timeout"],
connection_retries=self.config["telegram.connection.retries"],
retry_delay=self.config["telegram.connection.retry_delay"],
flood_sleep_threshold=self.config["telegram.connection.flood_sleep_threshold"],
request_retries=self.config["telegram.connection.request_retries"],
connection=connection,
proxy=proxy,
raise_last_call_error=True,
catch_up=self.config["telegram.catch_up"],
sequential_updates=self.config["telegram.sequential_updates"],
loop=self.loop,
base_logger=base_logger,
update_error_callback=self._telethon_update_error_callback,
use_ipv6=self.config["telegram.connection.use_ipv6"],
)
self.client.add_event_handler(self._update_catch)
@abstractmethod
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
pass
async def _telethon_update_error_callback(self, err: Exception) -> None:
if isinstance(err, (UnauthorizedError, AuthKeyError)):
background_task.create(self.on_signed_out(err))
return
if self.config["telegram.exit_on_update_error"]:
self.log.critical(f"Stopping due to update handling error {type(err).__name__}")
self.bridge.manual_stop(50)
else:
self.log.info("Recreating Telethon connection in 60 seconds")
await asyncio.sleep(60)
self.log.debug("Now recreating Telethon connection")
await self.stop()
await self.start()
@abstractmethod
async def update(self, update: TypeUpdate) -> bool:
return False
@abstractmethod
async def post_login(self) -> None:
raise NotImplementedError()
@abstractmethod
async def register_portal(self, portal: po.Portal) -> None:
raise NotImplementedError()
@abstractmethod
async def unregister_portal(self, tgid: int, tg_receiver: int) -> None:
raise NotImplementedError()
async def _update_catch(self, update: TypeUpdate) -> None:
start_time = time.time()
update_type = type(update).__name__
try:
if not await self.update(update):
await self._update(update)
except Exception:
self.log.exception("Failed to handle Telegram update")
UPDATE_ERRORS.labels(update_type=update_type).inc()
UPDATE_TIME.labels(update_type=update_type).observe(time.time() - start_time)
@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:
await self._init_client()
await self.client.connect()
self.log.debug(f"{'Bot' if self.is_relaybot else self.mxid} connected: {self.connected}")
return self
async def ensure_started(self, even_if_no_session=False) -> AbstractUser:
if self.connected:
return self
session_exists = await PgSession.has(self.mxid)
if even_if_no_session or session_exists:
self.log.debug(
f"Starting client due to ensure_started({even_if_no_session=}, {session_exists=})"
)
await self.start(delete_unless_authenticated=not even_if_no_session)
return self
async def stop(self) -> None:
if self.client:
await self.client.disconnect()
self.client = None
# region Telegram update handling
async def _update(self, update: TypeUpdate) -> None:
if isinstance(update, UpdateShort):
update = update.update
background_task.create(self._handle_entity_updates(getattr(update, "_entities", {})))
if isinstance(
update,
(
UpdateShortChatMessage,
UpdateShortMessage,
UpdateNewChannelMessage,
UpdateNewMessage,
UpdateEditMessage,
UpdateEditChannelMessage,
),
):
await self.update_message(update)
elif isinstance(update, UpdateDeleteMessages):
await self.delete_message(update)
elif isinstance(update, UpdateDeleteChannelMessages):
await self.delete_channel_message(update)
elif isinstance(update, UpdatePhoneCall):
await self.update_phone_call(update)
elif isinstance(update, UpdateMessageReactions):
await self.update_reactions(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
await self.update_typing(update)
elif isinstance(update, UpdateUserStatus):
await self.update_status(update)
elif isinstance(update, UpdateChatParticipantAdmin):
await self.update_admin(update)
elif isinstance(update, UpdateChatParticipants):
await self.update_participants(update)
elif isinstance(update, UpdateChatDefaultBannedRights):
await self.update_default_banned_rights(update)
elif isinstance(update, (UpdatePinnedMessages, UpdatePinnedChannelMessages)):
await self.update_pinned_messages(update)
elif isinstance(update, (UpdateUserName, UpdateUser)):
await self.update_others_info(update)
elif isinstance(update, UpdateReadHistoryOutbox):
await self.update_read_receipt(update)
elif isinstance(update, (UpdateReadHistoryInbox, UpdateReadChannelInbox)):
await self.update_own_read_receipt(update)
elif isinstance(update, UpdateFolderPeers):
await self.update_folder_peers(update)
elif isinstance(update, UpdatePinnedDialogs):
await self.update_pinned_dialogs(update)
elif isinstance(update, UpdateNotifySettings):
await self.update_notify_settings(update)
elif isinstance(update, UpdateChannel):
await self.update_channel(update)
else:
self.log.trace("Unhandled update: %s", update)
async def update_folder_peers(self, update: UpdateFolderPeers) -> None:
pass
async def update_pinned_dialogs(self, update: UpdatePinnedDialogs) -> None:
pass
async def update_notify_settings(self, update: UpdateNotifySettings) -> None:
pass
async def update_pinned_messages(
self, update: UpdatePinnedMessages | UpdatePinnedChannelMessages
) -> None:
if isinstance(update, UpdatePinnedMessages):
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
else:
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
if portal and portal.mxid:
await portal.receive_telegram_pin_ids(
update.messages, self.tgid, remove=not update.pinned
)
@staticmethod
async def update_participants(update: UpdateChatParticipants) -> None:
portal = await po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
if portal and portal.mxid:
await portal.update_power_levels(update.participants.participants)
@staticmethod
async def update_default_banned_rights(update: UpdateChatDefaultBannedRights) -> None:
portal = await po.Portal.get_by_entity(update.peer)
if portal and portal.mxid:
await portal.update_default_banned_rights(update.default_banned_rights)
async def update_read_receipt(self, update: UpdateReadHistoryOutbox) -> None:
if not isinstance(update.peer, PeerUser):
self.log.debug("Unexpected read receipt peer: %s", update.peer)
return
portal = await po.Portal.get_by_tgid(
TelegramID(update.peer.user_id), tg_receiver=self.tgid
)
if not portal or not portal.mxid:
return
# We check that these are user read receipts, so tg_space is always the user ID.
message = await DBMessage.get_one_by_tgid(
TelegramID(update.max_id), self.tgid, edit_index=-1
)
if not message:
return
puppet = await pu.Puppet.get_by_peer(update.peer)
await puppet.intent.mark_read(portal.mxid, message.mxid)
async def update_own_read_receipt(
self, update: UpdateReadHistoryInbox | UpdateReadChannelInbox
) -> None:
puppet = await pu.Puppet.get_by_tgid(self.tgid)
if not puppet.is_real_user:
return
self.log.debug("Handling own read receipt: %s", update)
if isinstance(update, UpdateReadChannelInbox):
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
elif isinstance(update.peer, PeerChat):
portal = await po.Portal.get_by_tgid(TelegramID(update.peer.chat_id))
elif isinstance(update.peer, PeerUser):
portal = await po.Portal.get_by_tgid(
TelegramID(update.peer.user_id), tg_receiver=self.tgid
)
else:
self.log.debug("Unexpected own read receipt peer: %s", update.peer)
return
if not portal or not portal.mxid:
self.log.debug(f"Dropping own read receipt in unknown chat ({update.peer})")
return
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
message = await DBMessage.get_one_by_tgid(
TelegramID(update.max_id), tg_space, edit_index=-1
)
if not message:
self.log.debug(
f"Dropping own read receipt: unknown message {update.max_id}@{tg_space}"
)
return
await puppet.intent.mark_read(portal.mxid, message.mxid)
async def update_admin(self, update: UpdateChatParticipantAdmin) -> None:
# TODO duplication not checked
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
if not portal or not portal.mxid:
return
await portal.set_telegram_admin(TelegramID(update.user_id))
async def update_typing(
self, update: UpdateUserTyping | UpdateChatUserTyping | UpdateChannelUserTyping
) -> None:
sender = None
if isinstance(update, UpdateUserTyping):
portal = await po.Portal.get_by_tgid(
TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user"
)
sender = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
elif isinstance(update, UpdateChannelUserTyping):
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
elif isinstance(update, UpdateChatUserTyping):
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
else:
return
if isinstance(update, (UpdateChannelUserTyping, UpdateChatUserTyping)):
sender = await pu.Puppet.get_by_peer(update.from_id)
if not sender or not portal or not portal.mxid:
return
await portal.handle_telegram_typing(sender, update)
async def _handle_entity_updates(self, entities: dict[int, User | Chat | Channel]) -> None:
try:
users = (entity for entity in entities.values() if isinstance(entity, (User, Channel)))
puppets = ((await pu.Puppet.get_by_peer(user), user) for user in users)
await asyncio.gather(
*[puppet.try_update_info(self, info) async for puppet, info in puppets if puppet]
)
except Exception:
self.log.exception("Failed to handle entity updates")
async def update_others_info(self, update: UpdateUserName | UpdateUser) -> None:
# TODO duplication not checked
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
if isinstance(update, UpdateUserName):
if len(update.usernames) > 1:
self.log.warning(
"Got update with multiple usernames (%s) for %s, only saving first one",
update.usernames,
update.user_id,
)
puppet.username = update.usernames[0].username if update.usernames else None
if await puppet.update_displayname(self, update):
await puppet.save()
await puppet.update_portals_meta()
elif isinstance(update, UpdateUser):
info = await self.client.get_entity(puppet.peer)
await puppet.update_info(self, info)
else:
self.log.warning(f"Unexpected other user info update: {type(update)}")
async def update_status(self, update: UpdateUserStatus) -> None:
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
if isinstance(update.status, UserStatusOnline):
await puppet.default_mxid_intent.set_presence(PresenceState.ONLINE)
elif isinstance(update.status, UserStatusOffline):
await puppet.default_mxid_intent.set_presence(PresenceState.OFFLINE)
else:
self.log.warning(f"Unexpected user status update: type({update})")
return
async def get_message_details(
self, update: UpdateMessage
) -> tuple[UpdateMessageContent, pu.Puppet | None, po.Portal | None]:
if isinstance(update, UpdateShortChatMessage):
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id))
elif isinstance(update, UpdateShortMessage):
portal = await po.Portal.get_by_tgid(
TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user"
)
sender = await pu.Puppet.get_by_tgid(self.tgid if update.out else update.user_id)
elif isinstance(
update,
(
UpdateNewMessage,
UpdateNewChannelMessage,
UpdateEditMessage,
UpdateEditChannelMessage,
),
):
update = update.message
if isinstance(update, MessageEmpty):
return update, None, None
portal = await po.Portal.get_by_entity(update.peer_id, tg_receiver=self.tgid)
if update.out:
sender = await pu.Puppet.get_by_tgid(self.tgid)
elif isinstance(update.from_id, (PeerUser, PeerChannel)):
sender = await pu.Puppet.get_by_peer(update.from_id)
elif isinstance(update.peer_id, PeerUser):
sender = await pu.Puppet.get_by_peer(update.peer_id)
else:
sender = 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 = await po.Portal.get_by_mxid(message.mx_room)
if not portal:
return
try:
await portal.main_intent.redact(message.mx_room, message.mxid)
except MatrixError:
pass
async def delete_message(self, update: UpdateDeleteMessages) -> None:
if len(update.messages) > self.max_deletions:
return
for message_id in update.messages:
for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid):
if message.redacted:
continue
await message.delete()
number_left = await DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
if number_left == 0:
await self._try_redact(message)
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
if len(update.messages) > self.max_deletions:
return
channel_id = TelegramID(update.channel_id)
for message_id in update.messages:
for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id):
if message.redacted:
continue
await message.delete()
await self._try_redact(message)
async def update_reactions(self, update: UpdateMessageReactions) -> None:
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
if not portal or not portal.mxid or not portal.allow_bridging:
return
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions)
async def update_phone_call(self, update: UpdatePhoneCall) -> None:
self.log.debug("Phone call update %s", update)
if not isinstance(update.phone_call, PhoneCallRequested):
return
tgid = TelegramID(update.phone_call.participant_id)
if tgid == self.tgid:
tgid = update.phone_call.admin_id
portal = await po.Portal.get_by_tgid(tgid, tg_receiver=self.tgid, peer_type="user")
if not portal or not portal.mxid or not portal.allow_bridging:
return
sender = await pu.Puppet.get_by_tgid(TelegramID(update.phone_call.admin_id))
await portal.handle_telegram_direct_call(self, sender, update)
async def update_channel(self, update: UpdateChannel) -> None:
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
if not portal:
return
if getattr(update, "mau_telethon_is_leave", False):
self.log.debug("UpdateChannel has mau_telethon_is_leave, leaving portal")
await portal.delete_telegram_user(self.tgid, sender=None)
elif chan := getattr(update, "mau_channel", None):
if not portal.mxid:
background_task.create(self._delayed_create_channel(chan))
else:
self.log.debug("Updating channel info with data fetched by Telethon")
await portal.update_info(self, chan)
await portal.invite_to_matrix(self.mxid)
async def _delayed_create_channel(self, chan: Channel) -> None:
self.log.debug(
f"Waiting 5 seconds before handling UpdateChannel for non-existent portal {chan.id}"
)
await asyncio.sleep(5)
portal = await po.Portal.get_by_tgid(TelegramID(chan.id))
if portal.mxid:
self.log.debug(
"Portal started existing after waiting 5 seconds, "
f"dropping UpdateChannel for {portal.tgid}"
)
return
else:
self.log.info(
f"Creating Matrix room for {portal.tgid}"
" with data fetched by Telethon due to UpdateChannel"
)
await portal.create_matrix_room(self, chan, invites=[self.mxid])
async def _check_server_notice_edit(self, message: Message) -> None:
pass
async def update_message(self, original_update: UpdateMessage) -> None:
update, sender, portal = await self.get_message_details(original_update)
if not portal:
return
elif portal and not portal.allow_bridging:
self.log.debug(
f"Ignoring message {update.id} in portal {portal.tgid_log} (bridging disallowed)"
)
return
if not portal.mxid and getattr(original_update, "mau_left_channel", False):
self.log.debug(
f"Ignoring message {update.id} in portal {portal.tgid_log} because user isn't in the chat"
)
return
if self.is_relaybot:
if update.is_private:
if not self.config["bridge.relaybot.private_chat.invite"]:
if sender:
self.log.debug(f"Ignoring private message to bot from {sender.id}")
return
elif not portal.mxid and self.config["bridge.relaybot.ignore_unbridged_group_chat"]:
self.log.debug(
f"Ignoring message received by bot in unbridged chat {portal.tgid_log}"
)
return
if (
self.ignore_incoming_bot_events
and self.relaybot
and sender
and sender.id == self.relaybot.tgid
):
self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
return
task = self._call_portal_message_handler(update, original_update, portal, sender)
if portal.backfill_lock.locked:
self.log.debug(
f"{portal.tgid_log} is backfill locked, moving incoming message to async task"
)
background_task.create(task)
else:
await task
async def _call_portal_message_handler(
self,
update: UpdateMessageContent,
original_update: UpdateMessage,
portal: po.Portal,
sender: pu.Puppet,
) -> None:
await portal.backfill_lock.wait(f"update {update.id}")
if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom):
self.log.debug(
"Received %s in %s by %d, unregistering portal...",
update.action,
portal.tgid_log,
sender.id,
)
await self.unregister_portal(update.action.chat_id, update.action.chat_id)
await self.register_portal(portal)
return
self.log.debug(
"Handling action %s to %s by %d",
update.action,
portal.tgid_log,
(sender.id if sender else 0),
)
return await portal.handle_telegram_action(self, sender, update)
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
if sender and sender.tgid == 777000:
await self._check_server_notice_edit(update)
return await portal.handle_telegram_edit(self, sender, update)
return await portal.handle_telegram_message(self, sender, update)
# endregion
-457
View File
@@ -1,457 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, Awaitable, Callable, Literal
import logging
import time
from telethon.errors import (
AuthKeyError,
ChannelInvalidError,
ChannelPrivateError,
UnauthorizedError,
)
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (
ChannelParticipantAdmin,
ChannelParticipantCreator,
ChatForbidden,
ChatParticipantAdmin,
ChatParticipantCreator,
ChatParticipantsForbidden,
InputChannel,
InputUser,
MessageActionChatAddUser,
MessageActionChatDeleteUser,
MessageActionChatMigrateTo,
MessageEntityBotCommand,
PeerChannel,
PeerChat,
PeerUser,
TypeChannelParticipant,
TypeChatParticipant,
TypeInputPeer,
TypePeer,
UpdateNewChannelMessage,
UpdateNewMessage,
User,
)
from telethon.utils import add_surrogate, del_surrogate
from mautrix.errors import MBadState, MForbidden
from mautrix.types import RoomID, UserID
from . import portal as po, puppet as pu, user as u
from .abstract_user import AbstractUser
from .db import BotChat, Message as DBMessage
from .types import TelegramID
if TYPE_CHECKING:
from asyncio import Future
ReplyFunc = Callable[[str], Awaitable[Message]]
BanFunc = Callable[[RoomID, UserID, str], Awaitable[None]]
TelegramAdminPermission = Literal[
"change_info",
"post_messages",
"edit_messages",
"delete_messages",
"ban_users",
"invite_users",
"pin_messages",
"add_admins",
"anonymous",
"manage_call",
"other",
]
class Bot(AbstractUser):
log: logging.Logger = logging.getLogger("mau.user.bot")
token: str
chats: dict[int, str]
tg_whitelist: list[int]
whitelist_group_admins: bool
_me_info: User | None
_me_mxid: UserID | None
_admin_cache: dict[
tuple[int, int],
tuple[ChatParticipantAdmin | ChatParticipantCreator | None, float],
]
_login_wait_fut: Future | None
required_permissions: dict[str, TelegramAdminPermission] = {
"portal": None,
"invite": "invite_users",
"mxban": "ban_users",
"mxkick": "ban_users",
}
def __init__(self, token: str) -> None:
super().__init__()
self.token = token
self.tgid = None
self.mxid = None
self.puppet_whitelisted = True
self.whitelisted = True
self.relaybot_whitelisted = True
self.tg_username = None
self.is_relaybot = True
self.is_bot = True
self.chats = {}
self._admin_cache = {}
self.tg_whitelist = []
self.whitelist_group_admins = (
self.config["bridge.relaybot.whitelist_group_admins"] or False
)
self._me_info = None
self._me_mxid = None
self._login_wait_fut = self.loop.create_future()
async def get_me(self, use_cache: bool = True) -> tuple[User, UserID]:
if not use_cache or not self._me_mxid:
self._me_info = await self.client.get_me()
self._me_mxid = pu.Puppet.get_mxid_from_id(TelegramID(self._me_info.id))
return self._me_info, self._me_mxid
async def init_permissions(self) -> None:
whitelist = self.config["bridge.relaybot.whitelist"] or []
for user_id in whitelist:
if isinstance(user_id, str):
entity = await self.client.get_input_entity(user_id)
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 await BotChat.all()}
await super().start(delete_unless_authenticated)
if not await self.is_logged_in():
await self.client.sign_in(bot_token=self.token)
await self.post_login()
return self
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
self.log.fatal("Relay bot got signed out, crashing bridge", exc_info=err)
self.bridge.manual_stop(51)
async def post_login(self) -> None:
await self.init_permissions()
info = await self.client.get_me()
self.tgid = TelegramID(info.id)
self.tg_username = info.username
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
if self._login_wait_fut:
self._login_wait_fut.set_result(None)
self._login_wait_fut = None
chat_ids = [chat_id for chat_id, chat_type in self.chats.items() if chat_type == "chat"]
response = await self.client(GetChatsRequest(chat_ids))
for chat in response.chats:
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
await self.remove_chat(TelegramID(chat.id))
channel_ids = [
InputChannel(chat_id, 0)
for chat_id, chat_type in self.chats.items()
if chat_type == "channel"
]
for channel_id in channel_ids:
try:
await self.client(GetChannelsRequest([channel_id]))
except (ChannelPrivateError, ChannelInvalidError):
await self.remove_chat(TelegramID(channel_id.channel_id))
async def register_portal(self, portal: po.Portal) -> None:
await self.add_chat(portal.tgid, portal.peer_type)
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
await self.remove_chat(tgid)
async def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
if chat_id not in self.chats:
self.chats[chat_id] = chat_type
await BotChat(id=chat_id, type=chat_type).insert()
async def remove_chat(self, chat_id: TelegramID) -> None:
try:
del self.chats[chat_id]
except KeyError:
pass
await BotChat.delete_by_id(chat_id)
async def _get_admin_participant(
self, chat: TypePeer | TypeInputPeer, tgid: TelegramID
) -> TypeChatParticipant | TypeChannelParticipant | None:
chan_id = chat.channel_id if isinstance(chat, PeerChannel) else chat.chat_id
try:
cached, created = self._admin_cache[chan_id, tgid]
if created + 60 < time.time():
return cached
except KeyError:
pass
if isinstance(chat, PeerChannel):
p = await self.client(GetParticipantRequest(chat, tgid))
pcp = p.participant
self._admin_cache[chat.channel_id, tgid] = (pcp, time.time())
return pcp
elif isinstance(chat, PeerChat):
chat = await self.client(GetFullChatRequest(chat.chat_id))
if isinstance(chat.full_chat.participants, ChatParticipantsForbidden):
return None
participants = chat.full_chat.participants.participants
for p in participants:
self._admin_cache[chat.channel_id, tgid] = (p, time.time())
if p.user_id == tgid:
return p
return None
@staticmethod
def _has_participant_permission(
pcp: TypeChatParticipant | TypeChannelParticipant | None,
permission: TelegramAdminPermission | None,
) -> bool:
if isinstance(pcp, (ChannelParticipantCreator, ChannelParticipantAdmin)):
return permission is None or getattr(pcp.admin_rights, permission, False)
elif isinstance(pcp, (ChatParticipantCreator, ChatParticipantAdmin)):
return True
return False
async def _can_use_commands(
self, chat: TypePeer, tgid: TelegramID, permission: TelegramAdminPermission | None = None
) -> bool:
if tgid in self.tg_whitelist:
return True
user = await u.User.get_by_tgid(tgid)
if user and user.is_admin:
self.tg_whitelist.append(user.tgid)
return True
if self.whitelist_group_admins:
pcp = await self._get_admin_participant(chat, tgid)
return self._has_participant_permission(pcp, permission)
return False
async def check_can_use_command(self, event: Message, reply: ReplyFunc, command: str) -> bool:
if command not in self.required_permissions:
# Unknown command
return False
elif not isinstance(event.from_id, PeerUser):
await reply("Channels can't use commands")
return False
elif not await self._can_use_commands(
event.to_id, TelegramID(event.from_id.user_id), self.required_permissions[command]
):
await reply("You do not have the permission to use that command.")
return False
return True
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc) -> Message:
if not self.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.")
else:
return await reply("Couldn't create portal room")
async def handle_command_invite(
self, portal: po.Portal, reply: ReplyFunc, mxid_input: UserID
) -> Message:
if len(mxid_input) == 0:
return await reply("Usage: `/invite <mxid>`")
elif not portal.mxid:
return await reply("Portal does not have Matrix room. Create one with /portal first.")
if mxid_input[0] != "@" or mxid_input.find(":") < 2:
return await reply("That doesn't look like a Matrix ID.")
user = await u.User.get_and_start_by_mxid(mxid_input)
if not user.relaybot_whitelisted:
return await reply("That user is not whitelisted to use the bridge.")
elif await user.is_logged_in():
displayname = f"@{user.tg_username}" if user.tg_username else user.displayname
return await reply(
"That user seems to be logged in. "
f"Just invite [{displayname}](tg://user?id={user.tgid})"
)
else:
try:
await portal.invite_to_matrix(user.mxid)
except MBadState:
try:
await portal.main_intent.unban_user(
portal.mxid, user.mxid, reason="Invited from Telegram"
)
except Exception:
return await reply(f"Failed to unban `{user.mxid}` from the portal.")
await portal.invite_to_matrix(user.mxid)
return await reply(f"Unbanned and invited `{user.mxid}` to the portal.")
return await reply(f"Invited `{user.mxid}` to the portal.")
async def handle_command_ban(
self,
message: Message,
portal: po.Portal,
reply: ReplyFunc,
reason: str,
action: Literal["kick", "ban"] = "ban",
) -> Message:
if not message.reply_to:
return await reply("You must reply to a relaybot message when using that command")
reply_to_id = TelegramID(message.reply_to.reply_to_msg_id)
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
msg = await DBMessage.get_one_by_tgid(reply_to_id, tg_space)
if not msg or msg.sender != self.tgid or not msg.sender_mxid:
return await reply("Target message is not a relayed message")
puppet = await pu.Puppet.get_by_peer(message.from_id)
actioned = "Banned" if action == "ban" else "Kicked"
try:
intent = puppet.intent_for(portal)
func: BanFunc = intent.ban_user if action == "ban" else intent.kick_user
await func(portal.mxid, msg.sender_mxid, reason)
except MForbidden as e:
self.log.warning(
f"Failed to {action} {msg.sender_mxid} from {portal.mxid} as {puppet.mxid}: {e}, "
f"falling back to bridge bot"
)
reason_prefix = f"{actioned} by {puppet.displayname or puppet.tgid}"
reason = f"{reason_prefix}: {reason}" if reason else reason_prefix
try:
func: BanFunc = (
self.az.intent.ban_user if action == "ban" else self.az.intent.kick_user
)
await func(portal.mxid, msg.sender_mxid, reason)
except MForbidden as e:
self.log.warning(
f"Failed to {action} {msg.sender_mxid} from {portal.mxid} as bridge bot: {e}"
)
return await reply(f"Failed to {action} `{msg.sender_mxid}`")
return await reply(f"Successfully {actioned.lower()} `{msg.sender_mxid}`")
@staticmethod
def handle_command_id(message: Message, reply: ReplyFunc) -> Awaitable[Message]:
# Provide the prefixed ID to the user so that the user wouldn't need to specify whether the
# chat is a normal group or a supergroup/channel when using the ID.
if isinstance(message.to_id, PeerChannel):
return reply(f"-100{message.to_id.channel_id}")
elif isinstance(message.to_id, PeerChat):
return reply(str(-message.to_id.chat_id))
elif isinstance(message.to_id, PeerUser):
return reply(
f"Your user ID is {message.to_id.user_id}.\n\n"
f"If you're trying to bridge a group chat to Matrix, you must run the command in "
f"the group, not here. **The ID above will not work** with `!tg bridge`."
)
else:
return reply("Failed to find chat ID.")
def parse_command(self, message: Message) -> tuple[str | None, str | None]:
if not message.entities or len(message.entities) < 1 or not message.message:
return None, None
cmd_entity = message.entities[0]
if not isinstance(cmd_entity, MessageEntityBotCommand) or cmd_entity.offset != 0:
return None, None
surrogated_text = add_surrogate(message.message)
command: str = del_surrogate(surrogated_text[: cmd_entity.length]).lower()
rest_of_message: str = ""
if len(surrogated_text) > cmd_entity.length + 1:
rest_of_message: str = del_surrogate(surrogated_text[cmd_entity.length + 1 :])
command, *target = command.split("@", 1)
if not command.startswith("/"):
return None, None
elif target and target[0] != self.tg_username.lower():
return None, None
return command[1:], rest_of_message
async def handle_command(self, message: Message, command: str, args: str) -> None:
def reply(reply_text: str) -> Awaitable[Message]:
return self.client.send_message(message.chat_id, reply_text, reply_to=message.id)
if command == "start" and message.is_private:
pcm = self.config["bridge.relaybot.private_chat.message"]
if pcm:
await reply(pcm)
elif command == "id":
await self.handle_command_id(message, reply)
elif not message.is_private:
if not await self.check_can_use_command(message, reply, command):
return
portal = await po.Portal.get_by_entity(message.to_id)
if command == "portal":
await self.handle_command_portal(portal, reply)
elif command == "invite":
await self.handle_command_invite(portal, reply, mxid_input=UserID(args))
elif command == "mxban":
await self.handle_command_ban(message, portal, reply, reason=args)
elif command == "mxkick":
await self.handle_command_ban(message, portal, reply, reason=args, action="kick")
async def handle_service_message(self, message: MessageService) -> None:
to_peer = message.to_id
if isinstance(to_peer, PeerChannel):
to_id = TelegramID(to_peer.channel_id)
chat_type = "channel"
elif isinstance(to_peer, PeerChat):
to_id = TelegramID(to_peer.chat_id)
chat_type = "chat"
else:
return
action = message.action
if isinstance(action, MessageActionChatAddUser) and self.tgid in action.users:
await self.add_chat(to_id, chat_type)
elif isinstance(action, MessageActionChatDeleteUser) and action.user_id == self.tgid:
await self.remove_chat(to_id)
elif isinstance(action, MessageActionChatMigrateTo):
await self.remove_chat(to_id)
await self.add_chat(TelegramID(action.channel_id), "channel")
async def update(self, update) -> bool:
if self._login_wait_fut:
await self._login_wait_fut
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
return False
if isinstance(update.message, MessageService):
await self.handle_service_message(update.message)
return False
if isinstance(update.message, Message):
command, args = self.parse_command(update.message)
if command:
await self.handle_command(update.message, command, args)
return False
def is_in_chat(self, peer_id) -> bool:
return peer_id in self.chats
@property
def name(self) -> str:
return "bot"
-26
View File
@@ -1,26 +0,0 @@
from .handler import (
SECTION_ADMIN,
SECTION_AUTH,
SECTION_CREATING_PORTALS,
SECTION_MISC,
SECTION_PORTAL_MANAGEMENT,
CommandEvent,
CommandHandler,
CommandProcessor,
command_handler,
)
# This has to happen after the handler imports
from . import matrix_auth, portal, telegram # isort: skip
__all__ = [
"command_handler",
"CommandHandler",
"CommandProcessor",
"CommandEvent",
"SECTION_AUTH",
"SECTION_MISC",
"SECTION_ADMIN",
"SECTION_CREATING_PORTALS",
"SECTION_PORTAL_MANAGEMENT",
]
-194
View File
@@ -1,194 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Awaitable, Callable, NamedTuple
from telethon.errors import FloodWaitError
from mautrix.bridge.commands import (
CommandEvent as BaseCommandEvent,
CommandHandler as BaseCommandHandler,
CommandHandlerFunc,
CommandProcessor as BaseCommandProcessor,
HelpSection,
command_handler as base_command_handler,
)
from mautrix.types import EventID, MessageEventContent, RoomID
from mautrix.util.format_duration import format_duration
from .. import portal as po, user as u
if TYPE_CHECKING:
from ..__main__ import TelegramBridge
class HelpCacheKey(NamedTuple):
is_management: bool
is_portal: bool
puppet_whitelisted: bool
matrix_puppet_whitelisted: bool
is_admin: bool
is_logged_in: bool
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, "")
class CommandEvent(BaseCommandEvent):
sender: u.User
portal: po.Portal
def __init__(
self,
processor: CommandProcessor,
room_id: RoomID,
event_id: EventID,
sender: u.User,
command: str,
args: list[str],
content: MessageEventContent,
portal: po.Portal | None,
is_management: bool,
has_bridge_bot: bool,
) -> None:
super().__init__(
processor,
room_id,
event_id,
sender,
command,
args,
content,
portal,
is_management,
has_bridge_bot,
)
self.bridge = processor.bridge
self.tgbot = processor.tgbot
self.config = processor.config
self.public_website = processor.public_website
@property
def print_error_traceback(self) -> bool:
return self.sender.is_admin
async def get_help_key(self) -> HelpCacheKey:
return HelpCacheKey(
self.is_management,
self.portal is not None,
self.sender.puppet_whitelisted,
self.sender.matrix_puppet_whitelisted,
self.sender.is_admin,
await self.sender.is_logged_in(),
)
class CommandHandler(BaseCommandHandler):
name: str
needs_puppeting: bool
needs_matrix_puppeting: bool
def __init__(
self,
handler: Callable[[CommandEvent], Awaitable[EventID]],
management_only: bool,
name: str,
help_text: str,
help_args: str,
help_section: HelpSection,
needs_auth: bool,
needs_puppeting: bool,
needs_matrix_puppeting: bool,
needs_admin: bool,
**kwargs,
) -> None:
super().__init__(
handler,
management_only,
name,
help_text,
help_args,
help_section,
needs_auth=needs_auth,
needs_puppeting=needs_puppeting,
needs_matrix_puppeting=needs_matrix_puppeting,
needs_admin=needs_admin,
**kwargs,
)
async def get_permission_error(self, evt: CommandEvent) -> str | None:
if self.needs_puppeting and not evt.sender.puppet_whitelisted:
return "That command is limited to users with puppeting privileges."
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
return "That command is limited to users with full puppeting privileges."
return await super().get_permission_error(evt)
def has_permission(self, key: HelpCacheKey) -> bool:
return (
super().has_permission(key)
and (not self.needs_puppeting or key.puppet_whitelisted)
and (not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted)
)
def command_handler(
_func: CommandHandlerFunc | None = None,
*,
needs_auth: bool = True,
needs_puppeting: bool = True,
needs_matrix_puppeting: bool = False,
needs_admin: bool = False,
management_only: bool = False,
name: str | None = None,
help_text: str = "",
help_args: str = "",
help_section: HelpSection = None,
) -> Callable[[CommandHandlerFunc], CommandHandler]:
return base_command_handler(
_func,
_handler_class=CommandHandler,
name=name,
help_text=help_text,
help_args=help_args,
help_section=help_section,
management_only=management_only,
needs_auth=needs_auth,
needs_admin=needs_admin,
needs_puppeting=needs_puppeting,
needs_matrix_puppeting=needs_matrix_puppeting,
)
class CommandProcessor(BaseCommandProcessor):
def __init__(self, bridge: "TelegramBridge") -> None:
super().__init__(event_class=CommandEvent, bridge=bridge)
self.tgbot = bridge.bot
self.public_website = bridge.public_website
@staticmethod
async def _run_handler(
handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent
) -> Any:
try:
return await handler(evt)
except FloodWaitError as e:
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
-85
View File
@@ -1,85 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
from mautrix.types import EventID
from .. import puppet as pu
from . import SECTION_AUTH, CommandEvent, command_handler
@command_handler(
needs_auth=True,
management_only=True,
needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Replace your Telegram account's Matrix puppet with your own Matrix account.",
)
async def login_matrix(evt: CommandEvent) -> EventID:
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
if puppet.is_real_user:
return await evt.reply(
"You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first."
)
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.")
async def enter_matrix_token(evt: CommandEvent) -> EventID:
evt.sender.command_status = None
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
if puppet.is_real_user:
return await evt.reply(
"You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first."
)
try:
await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
except OnlyLoginSelf:
return await evt.reply("You can only log in as your own Matrix user.")
except InvalidAccessToken:
return await evt.reply("Failed to verify access token.")
return await evt.reply(
f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}."
)
@@ -1 +0,0 @@
from . import admin, bridge, config, create_chat, filter, misc, unbridge
-77
View File
@@ -1,77 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import asyncio
from mautrix.types import EventID
from ... import portal as po, puppet as pu, user as u
from .. import SECTION_ADMIN, CommandEvent, command_handler
@command_handler(
needs_admin=True,
needs_auth=False,
help_section=SECTION_ADMIN,
help_args="<`portal`|`puppet`|`user`>",
help_text="Clear internal bridge caches",
)
async def clear_db_cache(evt: CommandEvent) -> EventID:
try:
section = evt.args[0].lower()
except IndexError:
return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache <section>`")
if section == "portal":
po.Portal.by_tgid = {}
po.Portal.by_mxid = {}
await evt.reply("Cleared portal cache")
elif section == "puppet":
pu.Puppet.by_tgid = {}
pu.Puppet.by_custom_mxid = {}
await asyncio.gather(
*[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()]
)
await evt.reply("Cleared puppet cache and restarted custom puppet syncers")
elif section == "user":
u.User.by_mxid = {user.mxid: user for user in u.User.by_tgid.values()}
await evt.reply("Cleared non-logged-in user cache")
else:
return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache <section>`")
@command_handler(
needs_admin=True,
needs_auth=False,
help_section=SECTION_ADMIN,
help_args="[_mxid_]",
help_text="Reload and reconnect a user",
)
async def reload_user(evt: CommandEvent) -> EventID:
if len(evt.args) > 0:
mxid = evt.args[0]
else:
mxid = evt.sender.mxid
user = await u.User.get_by_mxid(mxid, create=False)
if not user:
return await evt.reply("User not found")
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
await user.stop()
del u.User.by_tgid[user.tgid]
del u.User.by_mxid[user.mxid]
user = await u.User.get_by_mxid(mxid)
await user.ensure_started()
if puppet:
await puppet.start()
return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
-261
View File
@@ -1,261 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Awaitable
import asyncio
from telethon.tl.types import ChannelForbidden, ChatForbidden
from mautrix.types import EventID, RoomID
from mautrix.util import background_task
from ... import portal as po
from ...types import TelegramID
from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler
from .util import get_initial_state, user_has_power_level, warn_missing_power
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_CREATING_PORTALS,
help_args="[_id_]",
help_text=(
"Bridge the current Matrix room to the Telegram chat with the given ID. The ID must be "
"the prefixed version that you get with the `/id` command of the Telegram-side bot."
),
)
async def bridge(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply(
"**Usage:** `$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`"
)
force_use_bot = False
if evt.args[0] == "--usebot" and evt.sender.is_admin:
force_use_bot = True
evt.args = evt.args[1:]
room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
that_this = "This" if room_id == evt.room_id else "That"
portal = await po.Portal.get_by_mxid(room_id)
if portal:
return await evt.reply(f"{that_this} room is already a portal room.")
if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply(
f"You do not have the permissions to bridge {that_this.lower()} room."
)
# The /id bot command provides the prefixed ID, so we assume
tgid_str = evt.args[0]
tgid = None
try:
if tgid_str.startswith("-100"):
tgid = TelegramID(int(tgid_str[4:]))
peer_type = "channel"
elif tgid_str.startswith("-"):
tgid = TelegramID(-int(tgid_str))
peer_type = "chat"
except ValueError:
# Invalid integer
pass
if not tgid:
return await evt.reply(
"That doesn't seem like a prefixed Telegram chat ID.\n\n"
"If you did not get the ID using the `/id` bot command, please prefix"
"channel/supergroup IDs with `-100` and non-super group IDs with `-`.\n\n"
"Bridging private chats to existing rooms is not allowed."
)
portal = await po.Portal.get_by_tgid(tgid, peer_type=peer_type)
if not portal.allow_bridging:
return await evt.reply(
"This bridge doesn't allow bridging that Telegram chat.\n"
"If you're the bridge admin, try "
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first."
)
elif portal.mxid:
has_portal_message = (
"That Telegram chat already has a portal at "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). "
)
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": 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": 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, Awaitable[None] | None]:
if not portal.mxid:
await evt.reply(
"The portal seems to have lost its Matrix room between you"
"calling `$cmdprefix+sp bridge` and this command.\n\n"
"Continuing without touching previous Matrix room..."
)
return True, None
elif evt.args[0] == "delete-and-continue":
return True, portal.cleanup_portal("Portal deleted (moving to another room)", delete=False)
elif evt.args[0] == "unbridge-and-continue":
return True, portal.cleanup_portal(
"Room unbridged (portal moving to another room)", puppets_only=True, delete=False
)
else:
await evt.reply(
"The chat you were trying to bridge already has a Matrix portal room.\n\n"
"Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-"
"continue` to either delete or unbridge the existing room (respectively) and "
"continue with the bridging.\n\n"
"If you changed your mind, use `$cmdprefix+sp cancel` to cancel."
)
return False, None
async def confirm_bridge(evt: CommandEvent) -> EventID | None:
status = evt.sender.command_status
try:
portal = await po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
bridge_to_mxid = status["bridge_to_mxid"]
except KeyError:
evt.sender.command_status = None
return await evt.reply(
"Fatal error: tgid or peer_type missing from command_status. "
"This shouldn't happen unless you're messing with the command handler code."
)
is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"]
if "mxid" in status:
if portal.peer_type != status["peer_type"]:
evt.log.warning(
"Portal %d in database has mismatching peer type %s (expected %s),"
" trusting database as a room already existed",
portal.tgid,
portal.peer_type,
status["peer_type"],
)
await evt.reply(
"Mismatching peer type in command and portal table, "
"trusting portal as room already existed"
)
ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
if not ok:
return None
elif coro:
background_task.create(coro)
await evt.reply("Cleaning up previous portal room...")
elif portal.mxid:
evt.sender.command_status = None
return await evt.reply(
"The portal seems to have created a Matrix room between you "
"calling `$cmdprefix+sp bridge` and this command.\n\n"
"Please start over by calling the bridge command again."
)
elif evt.args[0] != "continue":
return await evt.reply(
"Please use `$cmdprefix+sp continue` to confirm the bridging or "
"`$cmdprefix+sp cancel` to cancel."
)
elif portal.peer_type != status["peer_type"]:
evt.log.warning(
"Portal %d in database has mismatching peer type %s (expected %s),"
" trusting new peer type as there's no existing room",
portal.tgid,
portal.peer_type,
status["peer_type"],
)
await evt.reply(
"Mismatching peer type in command and portal table, "
"trusting you as portal room doesn't exist"
)
portal.peer_type = status["peer_type"]
evt.sender.command_status = None
async with portal._room_create_lock:
await _locked_confirm_bridge(
evt, portal=portal, room_id=bridge_to_mxid, is_logged_in=is_logged_in
)
async def _locked_confirm_bridge(
evt: CommandEvent, portal: po.Portal, room_id: RoomID, is_logged_in: bool
) -> EventID | None:
user = evt.sender if is_logged_in else evt.tgbot
try:
entity = await user.client.get_entity(portal.peer)
except Exception:
evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer)
if is_logged_in:
return await evt.reply(
"Failed to get info of telegram chat. You are logged in, are you in that chat?"
)
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.")
portal.mxid = room_id
portal.by_mxid[portal.mxid] = portal
(portal.title, portal.about, levels, portal.encrypted) = await get_initial_state(
evt.az.intent, evt.room_id
)
portal.photo_id = ""
await portal.save()
await portal.update_bridge_info()
background_task.create(portal.update_matrix_room(user, entity, levels=levels))
await warn_missing_power(levels, evt)
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
-162
View File
@@ -1,162 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any, Awaitable
from io import StringIO
from ruamel.yaml import YAMLError
from mautrix.types import EventID
from mautrix.util.config import yaml
from ... import portal as po, util
from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="View or change per-portal settings.",
help_args="<`help`|_subcommand_> [...]",
)
async def config(evt: CommandEvent) -> None:
cmd = evt.args[0].lower() if len(evt.args) > 0 else "help"
if cmd not in ("view", "defaults", "set", "unset", "add", "del"):
await config_help(evt)
return
elif cmd == "defaults":
await config_defaults(evt)
return
portal = await 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
if not await portal.can_user_perform(evt.sender, "config"):
await evt.reply("You do not have the permissions to configure this room.")
return
key = evt.args[1] if len(evt.args) > 1 else None
try:
value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None
except YAMLError as e:
await evt.reply(f"Invalid value provided. Values must be valid YAML.\n{e}")
return
if cmd == "set":
await config_set(evt, portal, key, value)
elif cmd == "unset":
await config_unset(evt, portal, key)
elif cmd == "add" or cmd == "del":
await config_add_del(evt, portal, key, value, cmd)
else:
return
await portal.save()
def config_help(evt: CommandEvent) -> Awaitable[EventID]:
return evt.reply(
"""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
* **help** - View this help text.
* **view** - View the current config data.
* **defaults** - View the default config values.
* **set** <_key_> <_value_> - Set a config value.
* **unset** <_key_> - Remove a config value.
* **add** <_key_> <_value_> - Add a value to an array.
* **del** <_key_> <_value_> - Remove a value from an array.
"""
)
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[EventID]:
return evt.reply(f"Room-specific config:\n{_str_value(portal.local_config).rstrip()}")
def config_defaults(evt: CommandEvent) -> Awaitable[EventID]:
value = _str_value(
{
"bridge_notices": {
"default": evt.config["bridge.bridge_notices.default"],
"exceptions": evt.config["bridge.bridge_notices.exceptions"],
},
"bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"],
"caption_in_message": evt.config["bridge.caption_in_message"],
"message_formats": evt.config["bridge.message_formats"],
"emote_format": evt.config["bridge.emote_format"],
"state_event_formats": evt.config["bridge.state_event_formats"],
"telegram_link_preview": evt.config["bridge.telegram_link_preview"],
}
)
return evt.reply(f"Bridge instance wide config:\n{value.rstrip()}")
def _str_value(value: Any) -> str:
stream = StringIO()
yaml.dump(value, stream)
value_str = stream.getvalue()
if "\n" in value_str:
return f"\n```yaml\n{value_str}\n```\n"
else:
return f"`{value_str}`"
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: Any) -> Awaitable[EventID]:
if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
elif util.recursive_set(portal.local_config, key, value):
return evt.reply(f"Successfully set the value of `{key}` to {_str_value(value)}".rstrip())
else:
return evt.reply(f"Failed to set value of `{key}`. Does the path contain non-map types?")
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[EventID]:
if not key:
return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`")
elif util.recursive_del(portal.local_config, key):
return evt.reply(f"Successfully deleted `{key}` from config.")
else:
return evt.reply(f"`{key}` not found in config.")
def config_add_del(
evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
) -> Awaitable[EventID]:
if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
arr = util.recursive_get(portal.local_config, key)
if not arr:
return evt.reply(
f"`{key}` not found in config. 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 {_str_value(value)}".rstrip())
arr.append(value)
return evt.reply(f"Successfully added {_str_value(value)} to the array at `{key}`")
else:
if value not in arr:
return evt.reply(f"The array at `{key}` does not contain {_str_value(value)}")
arr.remove(value)
return evt.reply(f"Successfully removed {_str_value(value)} from the array at `{key}`")
@@ -1,75 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from mautrix.types import EventID
from ... import portal as po
from ...types import TelegramID
from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler
from .util import get_initial_state, user_has_power_level, warn_missing_power
@command_handler(
help_section=SECTION_CREATING_PORTALS,
help_args="[_type_]",
help_text=(
"Create a Telegram chat of the given type for the current Matrix room. "
"The type is either `group`, `supergroup` or `channel` (defaults to `supergroup`)."
),
)
async def create(evt: CommandEvent) -> EventID:
type = evt.args[0] if len(evt.args) > 0 else "supergroup"
if type not in ("chat", "group", "supergroup", "channel"):
return await evt.reply(
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`"
)
if await po.Portal.get_by_mxid(evt.room_id):
return await evt.reply("This is already a portal room.")
if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply("You do not have the permissions to bridge this room.")
title, about, levels, encrypted = await get_initial_state(evt.az.intent, evt.room_id)
if not title:
return await evt.reply("Please set a title before creating a Telegram chat.")
supergroup = type == "supergroup"
type = {
"supergroup": "channel",
"channel": "channel",
"chat": "chat",
"group": "chat",
}[type]
portal = po.Portal(
tgid=TelegramID(0),
tg_receiver=TelegramID(0),
peer_type=type,
mxid=evt.room_id,
title=title,
about=about,
encrypted=encrypted,
)
await warn_missing_power(levels, evt)
try:
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
except ValueError as e:
await portal.delete()
return await evt.reply(e.args[0])
-105
View File
@@ -1,105 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from mautrix.types import EventID
from ... import portal as po
from .. import SECTION_ADMIN, CommandEvent, command_handler
@command_handler(
needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`>",
help_text="Change whether the bridge will allow or disallow bridging rooms by default.",
)
async def filter_mode(evt: CommandEvent) -> EventID:
try:
mode = evt.args[0]
if mode not in ("whitelist", "blacklist"):
raise ValueError()
except (IndexError, ValueError):
return await evt.reply("**Usage:** `$cmdprefix+sp filter-mode <whitelist/blacklist>`")
evt.config["bridge.filter.mode"] = mode
evt.config.save()
po.Portal.filter_mode = mode
if mode == "whitelist":
return await evt.reply(
"The bridge will now disallow bridging chats by default.\n"
"To allow bridging a specific chat, use"
"`!filter whitelist <chat ID>`."
)
else:
return await evt.reply(
"The bridge will now allow bridging chats by default.\n"
"To disallow bridging a specific chat, use"
"`!filter blacklist <chat ID>`."
)
@command_handler(
name="filter",
needs_admin=True,
help_section=SECTION_ADMIN,
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
help_text="Allow or disallow bridging a specific chat.",
)
async def edit_filter(evt: CommandEvent) -> EventID:
try:
action = evt.args[0]
if action not in ("whitelist", "blacklist", "add", "remove"):
raise ValueError()
id_str = evt.args[1]
if id_str.startswith("-100"):
filter_id = int(id_str[4:])
elif id_str.startswith("-"):
filter_id = int(id_str[1:])
else:
filter_id = int(id_str)
except (IndexError, ValueError):
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
mode = evt.config["bridge.filter.mode"]
if mode not in ("blacklist", "whitelist"):
return await evt.reply(f'Unknown filter mode "{mode}". Please fix the bridge config.')
filter_id_list = evt.config["bridge.filter.list"]
if action in ("blacklist", "whitelist"):
action = "add" if mode == action else "remove"
def save() -> None:
evt.config["bridge.filter.list"] = filter_id_list
evt.config.save()
po.Portal.filter_list = filter_id_list
if action == "add":
if filter_id in filter_id_list:
return await evt.reply(f"That chat is already {mode}ed.")
filter_id_list.append(filter_id)
save()
return await evt.reply(f"Chat ID added to {mode}.")
elif action == "remove":
if filter_id not in filter_id_list:
return await evt.reply(f"That chat is not {mode}ed.")
filter_id_list.remove(filter_id)
save()
return await evt.reply(f"Chat ID removed from {mode}.")
else:
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
-347
View File
@@ -1,347 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from datetime import datetime, timedelta
import re
from telethon.errors import (
ChatAdminRequiredError,
RPCError,
UsernameInvalidError,
UsernameNotModifiedError,
UsernameOccupiedError,
)
from telethon.helpers import add_surrogate
from telethon.tl.functions.channels import GetFullChannelRequest
from telethon.tl.functions.messages import GetExportedChatInvitesRequest, GetFullChatRequest
from telethon.tl.types import (
ChatInviteExported,
InputMessageEntityMentionName,
InputUserSelf,
MessageEntityMention,
TypeInputPeer,
TypeInputUser,
)
from telethon.tl.types.messages import ExportedChatInvites
from mautrix.types import EventID
from ... import formatter as fmt, portal as po, puppet as pu
from .. import SECTION_MISC, SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
from .util import user_has_power_level
@command_handler(
needs_admin=False,
needs_puppeting=False,
needs_auth=False,
help_section=SECTION_MISC,
help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.",
)
async def sync_state(evt: CommandEvent) -> EventID:
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply(f"You do not have the permissions to synchronize this room.")
await portal.main_intent.get_joined_members(portal.mxid)
await evt.reply("Synchronization complete")
@command_handler(
needs_admin=False, needs_puppeting=False, needs_auth=False, help_section=SECTION_MISC
)
async def sync_full(evt: CommandEvent) -> EventID:
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
if len(evt.args) > 0 and evt.args[0] == "--usebot" and evt.sender.is_admin:
src = evt.tgbot
else:
src = evt.tgbot if await evt.sender.needs_relaybot(portal) else evt.sender
try:
if portal.peer_type == "channel":
res = await src.client(GetFullChannelRequest(portal.peer))
elif portal.peer_type == "chat":
res = await src.client(GetFullChatRequest(portal.tgid))
else:
return await evt.reply("This is not a channel or chat portal.")
except (ValueError, RPCError):
return await evt.reply("Failed to get portal info from Telegram.")
await portal.update_matrix_room(src, res.full_chat)
return await evt.reply("Portal synced successfully.")
@command_handler(
name="id",
needs_admin=False,
needs_puppeting=False,
needs_auth=False,
help_section=SECTION_MISC,
help_text="Get the ID of the Telegram chat where this room is bridged.",
)
async def get_id(evt: CommandEvent) -> EventID:
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
tgid = portal.tgid
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}`.")
invite_link_usage = (
"**Usage:** `$cmdprefix+sp invite-link "
"[--uses=<amount>] [--expire=<delta>] [--request-needed] -- [title]`"
"\n\n"
"* `--uses`: the number of times the invite link can be used."
" Defaults to unlimited.\n"
"* `--expire`: the duration after which the link will expire."
" A number suffixed with d(ay), h(our), m(inute) or s(econd)\n"
"* `--request-needed`: should the link require admins to approve joins?\n"
"* `title`: a description of the link (only shown to admins)."
)
def _parse_flag(args: list[str]) -> tuple[str, str]:
arg = args.pop(0).lower()
if arg == "--":
return "", ""
value = ""
if arg.startswith("--"):
value_start = arg.find("=")
if value_start > 0:
flag = arg[2:value_start]
value = arg[value_start + 1 :]
else:
flag = arg[2:]
if arg not in ("request", "request-needed"):
value = args.pop(0).lower()
elif arg.startswith("-"):
flag = arg[1]
if len(arg) > 3 and arg[2] == "=":
value = arg[3:]
elif arg != "r":
value = args.pop(0).lower()
else:
raise ValueError("invalid flag")
return flag, value
delta_regex = re.compile(
"([0-9]+)(w(?:eek)?|d(?:ay)?|h(?:our)?|m(?:in(?:ute)?)?|s(?:ec(?:ond)?)?)"
)
def _parse_delta(value: str) -> timedelta | None:
match = delta_regex.fullmatch(value)
if not match:
return None
number = int(match.group(1))
unit = match.group(2)[0]
if unit == "w":
return timedelta(weeks=number)
elif unit == "d":
return timedelta(days=number)
elif unit == "h":
return timedelta(hours=number)
elif unit == "m":
return timedelta(minutes=number)
elif unit == "s":
return timedelta(seconds=number)
else:
return None
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Get a Telegram invite link to the current chat.",
help_args="[--uses=<amount>] [--expire=<time delta, e.g. 1d>] [--request-needed] -- [title]",
)
async def invite_link(evt: CommandEvent) -> EventID:
if not evt.is_portal:
return await evt.reply("This is not a portal room.")
# TODO once we switch to Python 3.9 minimum, use argparse with exit_on_error=False
uses = None
expire = None
request_needed = False
while evt.args:
try:
flag, value = _parse_flag(evt.args)
except (ValueError, IndexError):
return await evt.reply(invite_link_usage)
if not flag:
break
elif flag in ("uses", "u"):
try:
uses = int(value)
except ValueError:
await evt.reply("The number of uses must be an integer")
elif flag in ("expire", "e"):
expire_delta = _parse_delta(value)
if not expire_delta:
await evt.reply("Invalid format for expiry time delta")
expire = datetime.now() + expire_delta
elif flag in ("request", "request-needed", "r"):
request_needed = True
title = " ".join(evt.args)
if evt.portal.peer_type == "user":
return await evt.reply("You can't invite users to private chats.")
try:
link = await evt.portal.get_invite_link(
evt.sender, uses=uses, expire=expire, request_needed=request_needed, title=title
)
return await evt.reply(f"Invite link to {evt.portal.title}: {link}")
except ValueError as e:
return await evt.reply(e.args[0])
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to create an invite link.")
async def _format_invite_link(link: ChatInviteExported) -> str:
desc = f"* {link.link}"
if link.title:
desc += f" - {link.title}"
if link.expire_date:
desc += f" \n Expires at {link.expire_date.isoformat()}"
if link.usage_limit:
desc += f" \n Used {link.usage or 0} out of {link.usage_limit} times"
elif link.usage:
desc += f" \n Used {link.usage} times"
else:
desc += " \n Never used"
if link.request_needed:
desc += " \n Join requests enabled - using link requires admin approval"
return desc
async def _hacky_find_mention(evt: CommandEvent) -> TypeInputUser | TypeInputPeer | None:
if len(evt.args) == 0:
return None
text, entities = await fmt.matrix_to_telegram(
evt.sender.client, text=evt.content.body, html=evt.content.formatted_body
)
for entity in entities:
if isinstance(entity, MessageEntityMention):
admin_username = add_surrogate(text)[entity.offset + 1 : entity.offset + entity.length]
return await evt.sender.client.get_input_entity(admin_username)
elif isinstance(entity, InputMessageEntityMentionName):
return entity.user_id
return None
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="List existing Telegram invite links to the current chat.",
help_args="[creator]",
)
async def list_invite_links(evt: CommandEvent) -> EventID:
admin_id = InputUserSelf()
try:
admin_id = await _hacky_find_mention(evt) or InputUserSelf()
except Exception:
pass
resp: ExportedChatInvites = await evt.sender.client(
GetExportedChatInvitesRequest(
peer=await evt.portal.get_input_entity(evt.sender),
admin_id=admin_id,
limit=100,
)
)
if resp.count == 0:
if isinstance(admin_id, InputUserSelf):
return await evt.reply("You haven't created any invite links to the current chat")
else:
return await evt.reply("That user hasn't created any invite links to the current chat")
formatted_links = "\n".join([await _format_invite_link(link) for link in resp.invites])
if isinstance(admin_id, InputUserSelf):
await evt.reply(f"Your links to this chat:\n\n{formatted_links}")
else:
puppet = await pu.Puppet.get_by_peer(admin_id)
await evt.reply(
f"[{puppet.displayname}](https://matrix.to/#/{puppet.mxid})'s links to this chat:\n\n"
f"{formatted_links}"
)
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.",
)
async def upgrade(evt: CommandEvent) -> EventID:
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type == "channel":
return await evt.reply("This is already a supergroup or a channel.")
elif portal.peer_type == "user":
return await evt.reply("You can't upgrade private chats.")
try:
await portal.upgrade_telegram_chat(evt.sender)
return await evt.reply(f"Group upgraded to supergroup. New ID: -100{portal.tgid}")
except ChatAdminRequiredError:
return await evt.reply("You don't have the permission to upgrade this group.")
except ValueError as e:
return await evt.reply(e.args[0])
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_args="<_name_|`-`>",
help_text=(
"Change the username of a supergroup/channel. To disable, use a dash (`-`) as the name."
),
)
async def group_name(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
elif portal.peer_type != "channel":
return await evt.reply("Only channels and supergroups have usernames.")
try:
await portal.set_telegram_username(evt.sender, evt.args[0] if evt.args[0] != "-" else "")
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")
@@ -1,118 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Callable
from mautrix.types import EventID, RoomID
from ... import portal as po
from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
from .util import user_has_power_level
async def _get_portal_and_check_permission(evt: CommandEvent) -> po.Portal | None:
room_id = RoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
portal = await po.Portal.get_by_mxid(room_id)
if not portal:
that_this = "This" if room_id == evt.room_id else "That"
await evt.reply(f"{that_this} is not a portal room.")
return None
if portal.peer_type == "user":
if portal.tg_receiver != evt.sender.tgid:
await evt.reply("You do not have the permissions to unbridge that portal.")
return None
return portal
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
await evt.reply("You do not have the permissions to unbridge that portal.")
return None
return portal
def _get_portal_murder_function(
action: str, room_id: str, function: Callable, command: str, completed_message: str
) -> dict:
async def post_confirm(confirm) -> EventID | None:
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) -> EventID | None:
portal = await _get_portal_and_check_permission(evt)
if not portal:
return None
evt.sender.command_status = _get_portal_murder_function(
"Portal deletion",
portal.mxid,
portal.cleanup_and_delete,
"delete",
"Portal successfully deleted.",
)
return await evt.reply(
"Please confirm deletion of portal "
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
f'to Telegram chat "{portal.title}" '
"by typing `$cmdprefix+sp confirm-delete`"
"\n\n"
"**WARNING:** If the bridge bot has the power level to do so, **this "
"will kick ALL users** in the room. If you just want to remove the "
"bridge, use `$cmdprefix+sp unbridge` instead."
)
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove puppets from the current portal room and forget the portal.",
)
async def unbridge(evt: CommandEvent) -> EventID | None:
portal = await _get_portal_and_check_permission(evt)
if not portal:
return None
evt.sender.command_status = _get_portal_murder_function(
"Room unbridging", portal.mxid, portal.unbridge, "unbridge", "Room successfully unbridged."
)
return await evt.reply(
f'Please confirm unbridging chat "{portal.title}" from room '
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
"by typing `$cmdprefix+sp confirm-unbridge`"
)
-72
View File
@@ -1,72 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from mautrix.appservice import IntentAPI
from mautrix.errors import MatrixRequestError
from mautrix.types import EventType, PowerLevelStateEventContent, RoomID
from ... import user as u
from .. import CommandEvent
async def get_initial_state(
intent: IntentAPI, room_id: RoomID
) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool]:
state = await intent.get_state(room_id)
title: str | None = None
about: str | None = None
levels: PowerLevelStateEventContent | None = None
encrypted: bool = False
for event in state:
try:
if event.type == EventType.ROOM_NAME:
title = event.content.name
elif event.type == EventType.ROOM_TOPIC:
about = event.content.topic
elif event.type == EventType.ROOM_POWER_LEVELS:
levels = event.content
elif event.type == EventType.ROOM_CANONICAL_ALIAS:
title = title or event.content.canonical_alias
elif event.type == EventType.ROOM_ENCRYPTION:
encrypted = True
except KeyError:
# Some state event probably has empty content
pass
return title, about, levels, encrypted
async def warn_missing_power(levels: PowerLevelStateEventContent, evt: CommandEvent) -> None:
if levels.get_user_level(evt.az.bot_mxid) < levels.redact:
await evt.reply(
"Warning: The bot does not have privileges to redact messages on Matrix. "
"Message deletions from Telegram will not be bridged unless you give "
f"redaction permissions to [{evt.az.bot_mxid}](https://matrix.to/#/{evt.az.bot_mxid})"
)
async def user_has_power_level(
room_id: RoomID, intent: IntentAPI, sender: u.User, event: str
) -> bool:
if sender.is_admin:
return True
# Make sure the state store contains the power levels.
try:
await intent.get_power_levels(room_id)
except MatrixRequestError:
return False
event_type = EventType.find(f"fi.mau.telegram.{event}", t_class=EventType.Class.STATE)
return await intent.state_store.has_power_level(room_id, sender.mxid, event_type)
@@ -1 +0,0 @@
from . import account, auth, misc
@@ -1,173 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from telethon.errors import (
AboutTooLongError,
AuthKeyError,
FirstNameInvalidError,
HashInvalidError,
UsernameInvalidError,
UsernameNotModifiedError,
UsernameOccupiedError,
)
from telethon.tl.functions.account import (
GetAuthorizationsRequest,
ResetAuthorizationRequest,
UpdateProfileRequest,
UpdateUsernameRequest,
)
from telethon.tl.types import Authorization
from mautrix.types import EventID
from .. import SECTION_AUTH, CommandEvent, command_handler
@command_handler(
needs_auth=True,
help_section=SECTION_AUTH,
help_args="<_new username_>",
help_text="Change your Telegram username.",
)
async def username(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp username <new username>`")
if evt.sender.is_bot:
return await evt.reply("Bots can't set their own username.")
new_name = evt.args[0]
if new_name == "-":
new_name = ""
try:
await evt.sender.client(UpdateUsernameRequest(username=new_name))
except UsernameInvalidError:
return await evt.reply(
"Invalid username. Usernames must be between 5 and 30 alphanumeric characters."
)
except UsernameNotModifiedError:
return await evt.reply("That is your current username.")
except UsernameOccupiedError:
return await evt.reply("That username is already in use.")
await evt.sender.update_info()
if not evt.sender.tg_username:
await evt.reply("Username removed")
else:
await evt.reply(f"Username changed to {evt.sender.tg_username}")
@command_handler(
needs_auth=True,
help_section=SECTION_AUTH,
help_args="<_new about_>",
help_text="Change your Telegram about section.",
)
async def about(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp about <new about>`")
if evt.sender.is_bot:
return await evt.reply("Bots can't set their own about section.")
new_about = " ".join(evt.args)
if new_about == "-":
new_about = ""
try:
await evt.sender.client(UpdateProfileRequest(about=new_about))
except AboutTooLongError:
return await evt.reply("The provided about section is too long")
return await evt.reply("About section updated")
@command_handler(
needs_auth=True,
help_section=SECTION_AUTH,
help_args="<_new displayname_>",
help_text="Change your Telegram displayname.",
)
async def displayname(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp displayname <new displayname>`")
if evt.sender.is_bot:
return await evt.reply("Bots can't set their own displayname.")
first_name, last_name = (
(evt.args[0], "") if len(evt.args) == 1 else (" ".join(evt.args[:-1]), evt.args[-1])
)
try:
await evt.sender.client(UpdateProfileRequest(first_name=first_name, last_name=last_name))
except FirstNameInvalidError:
return await evt.reply("Invalid first name")
await evt.sender.update_info()
return await evt.reply("Displayname updated")
def _format_session(sess: Authorization) -> str:
return (
f"**{sess.app_name} {sess.app_version}** \n"
f" **Platform:** {sess.device_model} {sess.platform} {sess.system_version} \n"
f" **Active:** {sess.date_active} (created {sess.date_created}) \n"
f" **From:** {sess.ip} - {sess.region}, {sess.country}"
)
@command_handler(
needs_auth=True,
help_section=SECTION_AUTH,
help_args="<`list`|`terminate`> [_hash_]",
help_text="View or delete other Telegram sessions.",
)
async def session(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
elif evt.sender.is_bot:
return await evt.reply("Bots can't manage their sessions")
cmd = evt.args[0].lower()
if cmd == "list":
res = await evt.sender.client(GetAuthorizationsRequest())
session_list = res.authorizations
current = [s for s in session_list if s.current][0]
current_text = _format_session(current)
other_text = "\n".join(
f"* {_format_session(sess)} \n **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]`")
-388
View File
@@ -1,388 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any
import asyncio
import io
from telethon.errors import (
AccessTokenExpiredError,
AccessTokenInvalidError,
FloodWaitError,
PasswordHashInvalidError,
PhoneCodeExpiredError,
PhoneCodeInvalidError,
PhoneNumberAppSignupForbiddenError,
PhoneNumberBannedError,
PhoneNumberFloodError,
PhoneNumberInvalidError,
PhoneNumberUnoccupiedError,
SessionPasswordNeededError,
)
from telethon.tl.types import User
from mautrix.client import Client
from mautrix.types import (
EventID,
ImageInfo,
MediaMessageEventContent,
MessageType,
TextMessageEventContent,
UserID,
)
from mautrix.util import background_task
from mautrix.util.format_duration import format_duration as fmt_duration
from ... import user as u
from ...commands import SECTION_AUTH, CommandEvent, command_handler
from ...types import TelegramID
try:
from telethon.tl.custom import QRLogin
import PIL as _
import qrcode
except ImportError:
qrcode = None
QRLogin = None
@command_handler(
needs_auth=False, help_section=SECTION_AUTH, help_text="Check if you're logged into Telegram."
)
async def ping(evt: CommandEvent) -> EventID:
if await evt.sender.is_logged_in():
me = await evt.sender.get_me()
if me:
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
return await evt.reply(f"You're logged in as {human_tg_id}")
else:
return await evt.reply("You were logged in, but there appears to have been an error.")
else:
return await evt.reply("You're not logged in.")
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_AUTH,
help_text="Get the info of the message relay Telegram bot.",
)
async def ping_bot(evt: CommandEvent) -> EventID:
if not evt.tgbot:
return await evt.reply("Telegram message relay bot not configured.")
info, mxid = await evt.tgbot.get_me(use_cache=False)
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_text="Log in by scanning a QR code.",
)
async def login_qr(evt: CommandEvent) -> EventID:
login_as = evt.sender
if len(evt.args) > 0 and evt.sender.is_admin:
login_as = await u.User.get_by_mxid(UserID(evt.args[0]))
if not qrcode or not QRLogin:
return await evt.reply("This bridge instance does not support logging in with a QR code.")
if await login_as.is_logged_in():
return await evt.reply(f"You are already logged in as {login_as.human_tg_id}.")
await login_as.ensure_started(even_if_no_session=True)
qr_login = QRLogin(login_as.client, ignored_ids=[])
qr_event_id: EventID | None = None
async def upload_qr() -> None:
nonlocal qr_event_id
buffer = io.BytesIO()
image = qrcode.make(qr_login.url)
size = image.pixel_size
image.save(buffer, "PNG")
qr = buffer.getvalue()
mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr))
content = MediaMessageEventContent(
body=qr_login.url,
url=mxc,
msgtype=MessageType.IMAGE,
info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size),
)
if qr_event_id:
content.set_edit(qr_event_id)
await evt.az.intent.send_message(evt.room_id, content)
else:
content.set_reply(evt.event_id)
qr_event_id = await evt.az.intent.send_message(evt.room_id, content)
retries = 4
while retries > 0:
await qr_login.recreate()
await upload_qr()
try:
user = await qr_login.wait()
break
except asyncio.TimeoutError:
retries -= 1
except SessionPasswordNeededError:
evt.sender.command_status = {
"next": enter_password,
"login_as": login_as if login_as != evt.sender else None,
"action": "Login (password entry)",
}
return await evt.reply(
"Your account has two-factor authentication. Please send your password here."
)
try:
await evt.main_intent.redact(evt.room_id, qr_event_id, reason="QR code scanned")
except Exception:
pass
else:
timeout = TextMessageEventContent(body="Login timed out", msgtype=MessageType.TEXT)
timeout.set_edit(qr_event_id)
return await evt.az.intent.send_message(evt.room_id, timeout)
return await _finish_sign_in(evt, user, login_as=login_as)
@command_handler(
needs_auth=False,
management_only=True,
help_section=SECTION_AUTH,
help_text="Get instructions on how to log in.",
)
async def login(evt: CommandEvent) -> EventID:
override_sender = False
if len(evt.args) > 0 and evt.sender.is_admin and evt.args[0]:
override_user_id = UserID(evt.args[0])
try:
Client.parse_user_id(override_user_id)
except ValueError:
return await evt.reply(
f"**Usage:** `$cmdprefix+sp login [override user ID]`\n\n"
f"{override_user_id!r} is not a valid Matrix user ID"
)
orig_user_id = evt.sender.mxid
evt.sender = await u.User.get_and_start_by_mxid(override_user_id)
override_sender = True
if orig_user_id != evt.sender:
await evt.reply(
f"Admin override: logging in as {evt.sender.mxid} instead of {orig_user_id}"
)
if await evt.sender.is_logged_in():
return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.")
allow_matrix_login = evt.config["bridge.allow_matrix_login"]
if allow_matrix_login and not override_sender:
evt.sender.command_status = {
"next": enter_phone_or_token,
"action": "Login",
}
nb = "**N.B. Logging in grants the bridge full access to your Telegram account.**"
if evt.config["appservice.public.enabled"]:
prefix = evt.config["appservice.public.external"]
url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}"
if 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]
) -> EventID:
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 {fmt_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 sign up to Telegram using an official mobile client first."
)
except PhoneNumberInvalidError:
return await evt.reply("That phone number is not valid.")
except Exception:
evt.log.exception("Error requesting phone code")
return await evt.reply(
"Unhandled exception while requesting code. Check console for more details."
)
finally:
evt.sender.command_status = next_status if ok else None
@command_handler(needs_auth=False)
async def enter_phone_or_token(evt: CommandEvent) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`")
elif not evt.config.get("bridge.allow_matrix_login", True):
return await evt.reply(
"This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions"
)
# 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) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
elif not evt.config.get("bridge.allow_matrix_login", True):
return await evt.reply(
"This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions"
)
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) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
elif not evt.config.get("bridge.allow_matrix_login", True):
return await evt.reply(
"This bridge instance does not allow in-Matrix login. "
"Please use `$cmdprefix+sp login` to get login instructions"
)
await evt.redact()
try:
await _sign_in(
evt,
login_as=evt.sender.command_status.get("login_as", None),
password=" ".join(evt.args),
)
except AccessTokenInvalidError:
return await evt.reply("That bot token is not valid.")
except AccessTokenExpiredError:
return await evt.reply("That bot token has expired.")
except Exception:
evt.log.exception("Error sending password")
return await evt.reply(
"Unhandled exception while sending password. Check console for more details."
)
return None
async def _sign_in(evt: CommandEvent, login_as: u.User = None, **sign_in_info) -> EventID:
login_as = login_as or evt.sender
try:
await login_as.ensure_started(even_if_no_session=True)
user = await login_as.client.sign_in(**sign_in_info)
await _finish_sign_in(evt, user)
except PhoneCodeExpiredError:
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
except PhoneCodeInvalidError:
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."
)
async def _finish_sign_in(evt: CommandEvent, user: User, login_as: u.User = None) -> EventID:
login_as = login_as or evt.sender
existing_user = await u.User.get_by_tgid(TelegramID(user.id))
if existing_user and existing_user != login_as:
await existing_user.log_out()
await evt.reply(
f"[{existing_user.displayname}] (https://matrix.to/#/{existing_user.mxid})"
" was logged out from the account."
)
background_task.create(login_as.post_login(user, first_login=True))
evt.sender.command_status = None
name = f"@{user.username}" if user.username else f"+{user.phone}"
if login_as != evt.sender:
msg = (
f"Successfully logged in [{login_as.mxid}](https://matrix.to/#/{login_as.mxid})"
f" as {name}"
)
else:
msg = f"Successfully logged in as {name}"
return await evt.reply(msg)
@command_handler(needs_auth=False, help_section=SECTION_AUTH, help_text="Log out from Telegram.")
async def logout(evt: CommandEvent) -> EventID:
if not evt.sender.tgid:
return await evt.reply("You're not logged in")
if await evt.sender.log_out():
return await evt.reply("Logged out successfully.")
return await evt.reply("Failed to log out.")
-453
View File
@@ -1,453 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import cast
import base64
import codecs
import math
import re
from aiohttp import ClientSession, InvalidURL
from telethon.errors import (
ChatIdInvalidError,
EmoticonInvalidError,
InviteHashExpiredError,
InviteHashInvalidError,
InviteRequestSentError,
OptionsTooMuchError,
TakeoutInitDelayError,
UserAlreadyParticipantError,
)
from telethon.tl.functions.channels import JoinChannelRequest
from telethon.tl.functions.messages import (
CheckChatInviteRequest,
GetBotCallbackAnswerRequest,
ImportChatInviteRequest,
SendVoteRequest,
)
from telethon.tl.patched import Message
from telethon.tl.types import (
InputMediaDice,
MessageMediaGame,
MessageMediaPoll,
TypeInputPeer,
TypeUpdates,
User as TLUser,
)
from telethon.tl.types.messages import BotCallbackAnswer
from mautrix.types import EventID, Format
from ... import portal as po, puppet as pu
from ...abstract_user import AbstractUser
from ...commands import (
SECTION_CREATING_PORTALS,
SECTION_MISC,
SECTION_PORTAL_MANAGEMENT,
CommandEvent,
command_handler,
)
from ...db import Message as DBMessage
from ...types import TelegramID
@command_handler(
needs_auth=False,
needs_puppeting=False,
help_section=SECTION_MISC,
help_args="<_caption_>",
help_text="Set a caption for the next image you send",
)
async def caption(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp caption <caption>`")
prefix = f"{evt.command_prefix} caption "
if evt.content.format == Format.HTML:
evt.content.formatted_body = evt.content.formatted_body.replace(prefix, "", 1)
evt.content.body = evt.content.body.replace(prefix, "", 1)
evt.sender.command_status = {"caption": evt.content, "action": "Caption"}
return await evt.reply(
"Your next image or file will be sent with that caption. "
"Use `$cmdprefix+sp cancel` to cancel the caption."
)
@command_handler(
help_section=SECTION_MISC,
help_args="[_-r|--remote_] <_query_>",
help_text="Search your contacts or the Telegram servers for users.",
)
async def search(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
force_remote = False
if evt.args[0] in {"-r", "--remote"}:
force_remote = True
evt.args.pop(0)
query = " ".join(evt.args)
if force_remote and len(query) < 5:
return await evt.reply("Minimum length of query for remote search is 5 characters.")
results, remote = await evt.sender.search(query, force_remote)
if not results:
if len(query) < 5 and remote:
return await evt.reply(
"No local results. Minimum length of remote query is 5 characters."
)
return await evt.reply("No results 3:")
reply: list[str] = []
if remote:
reply += ["**Results from Telegram server:**", ""]
else:
reply += ["**Results in contacts:**", ""]
reply += [
(
f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
f"{puppet.id} ({similarity}% match)"
)
for puppet, similarity in results
]
# TODO somehow show remote channel results when joining by alias is possible?
return await evt.reply("\n".join(reply))
@command_handler(
help_section=SECTION_CREATING_PORTALS,
help_args="<_username_>",
help_text=(
"Open a private chat with the given Telegram user. You can also use a "
"phone number instead of username, but you must have the number in "
"your Telegram contacts for that to work."
),
)
async def pm(evt: CommandEvent) -> EventID:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp pm <username>`")
try:
id = "".join(evt.args).translate({ord(c): None for c in "+()- "})
user = await evt.sender.client.get_entity(id)
except ValueError:
return await evt.reply("Invalid user identifier or user not found.")
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 = await po.Portal.get_by_entity(user, tg_receiver=evt.sender.tgid)
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
displayname, _ = pu.Puppet.get_displayname(user, False)
return await evt.reply(f"Created private chat room with {displayname}")
async def _join(
evt: CommandEvent, identifier: str, link_type: str
) -> tuple[TypeUpdates | None, EventID | None]:
if link_type == "joinchat":
try:
await evt.sender.client(CheckChatInviteRequest(identifier))
except InviteHashInvalidError:
return None, await evt.reply("Invalid invite link.")
except InviteHashExpiredError:
return None, await evt.reply("Invite link expired.")
try:
return (await evt.sender.client(ImportChatInviteRequest(identifier))), None
except UserAlreadyParticipantError:
return None, await evt.reply("You are already in that chat.")
except InviteRequestSentError:
return None, await evt.reply("Invite request sent successfully.")
else:
channel = await evt.sender.client.get_entity(identifier)
if not channel:
return None, await evt.reply("Channel/supergroup not found.")
return await evt.sender.client(JoinChannelRequest(channel)), None
@command_handler(
help_section=SECTION_CREATING_PORTALS,
help_args="<_link_>",
help_text="Join a chat with an invite link.",
)
async def join(evt: CommandEvent) -> EventID | None:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
url = evt.args[0]
if evt.config["bridge.invite_link_resolve"]:
try:
async with ClientSession() as sess, sess.get(url) as resp:
url = str(resp.url)
except InvalidURL:
return await evt.reply("That doesn't look like a Telegram invite link.")
regex = re.compile(
r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:/(?P<type>joinchat|s))?/(?P<id>[^/]+)/?",
flags=re.IGNORECASE,
)
arg = regex.match(url)
if not arg:
return await evt.reply("That doesn't look like a Telegram invite link.")
data = arg.groupdict()
identifier = data["id"]
link_type = data["type"]
if link_type:
link_type = link_type.lower()
elif identifier.startswith("+"):
link_type = "joinchat"
identifier = identifier[1:]
updates, _ = await _join(evt, identifier, link_type)
if not updates:
return None
for chat in updates.chats:
portal = await po.Portal.get_by_entity(chat)
if portal.mxid:
await portal.invite_to_matrix([evt.sender.mxid])
return await evt.reply(f"Invited you to portal of {portal.title}")
else:
await evt.reply(f"Creating room for {chat.title}... This might take a while.")
try:
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
except ChatIdInvalidError as e:
evt.log.trace(
"ChatIdInvalidError while creating portal from !tg join command: %s",
updates.stringify(),
)
raise e
if portal.mxid:
return await evt.reply(f"Created room for {portal.title}")
else:
return await evt.reply(f"Couldn't create room for {portal.title}")
return None
@command_handler(
help_section=SECTION_MISC,
help_args="[`chats`|`contacts`|`me`]",
help_text="Synchronize your chat portals, contacts and/or own info.",
)
async def sync(evt: CommandEvent) -> EventID:
if len(evt.args) > 0:
sync_only = evt.args[0]
if sync_only not in ("chats", "contacts", "me"):
return await evt.reply("**Usage:** `$cmdprefix+sp sync [chats|contacts|me]`")
else:
sync_only = None
if not sync_only or sync_only == "chats":
await evt.reply("Synchronizing chats...")
await evt.sender.sync_dialogs()
if not sync_only or sync_only == "contacts":
await evt.reply("Synchronizing contacts...")
await evt.sender.sync_contacts()
if not sync_only or sync_only == "me":
await evt.sender.update_info()
return await evt.reply("Synchronization complete.")
PEER_TYPE_CHAT = b"g"
class MessageIDError(ValueError):
def __init__(self, message: str) -> None:
super().__init__(message)
self.message = message
async def _parse_encoded_msgid(
user: AbstractUser, enc_id: str, type_name: str
) -> tuple[TypeInputPeer, Message]:
try:
enc_id += (4 - len(enc_id) % 4) * "="
enc_id = base64.b64decode(enc_id)
peer_type, enc_id = bytes([enc_id[0]]), enc_id[1:]
tgid = TelegramID(int(codecs.encode(enc_id[0:5], "hex_codec"), 16))
msg_id = TelegramID(int(codecs.encode(enc_id[5:10], "hex_codec"), 16))
space = None
if peer_type == PEER_TYPE_CHAT:
space = TelegramID(int(codecs.encode(enc_id[10:15], "hex_codec"), 16))
except ValueError as e:
raise MessageIDError(f"Invalid {type_name} ID (format)") from e
if peer_type == PEER_TYPE_CHAT:
orig_msg = await DBMessage.get_one_by_tgid(msg_id, space)
if not orig_msg:
raise MessageIDError(f"Invalid {type_name} ID (original message not found in db)")
new_msg = await DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
if not new_msg:
raise MessageIDError(f"Invalid {type_name} ID (your copy of message not found in db)")
msg_id = new_msg.tgid
try:
peer = await user.client.get_input_entity(tgid)
except ValueError as e:
raise MessageIDError(f"Invalid {type_name} ID (chat not found)") from e
msg = await user.client.get_messages(entity=peer, ids=msg_id)
if not msg:
raise MessageIDError(f"Invalid {type_name} ID (message not found)")
return peer, cast(Message, msg)
@command_handler(
help_section=SECTION_MISC, help_args="<_play ID_>", help_text="Play a Telegram game."
)
async def play(evt: CommandEvent) -> EventID:
if len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp play <play ID>`")
elif not await evt.sender.is_logged_in():
return await evt.reply("You must be logged in with a real account to play games.")
elif evt.sender.is_bot:
return await evt.reply("Bots can't play games :(")
try:
peer, msg = await _parse_encoded_msgid(evt.sender, evt.args[0], type_name="play")
except MessageIDError as e:
return await evt.reply(e.message)
if not isinstance(msg.media, MessageMediaGame):
return await evt.reply("Invalid play ID (message doesn't look like a game)")
game = await evt.sender.client(
GetBotCallbackAnswerRequest(peer=peer, msg_id=msg.id, game=True)
)
if not isinstance(game, BotCallbackAnswer):
return await evt.reply("Game request response invalid")
return await evt.reply(
f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
f"{msg.media.game.description}"
)
@command_handler(
help_section=SECTION_MISC,
help_args="<_poll ID_> <_choice number_>",
help_text="Vote in a Telegram poll.",
)
async def vote(evt: CommandEvent) -> EventID | None:
if len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice number>`")
elif not await evt.sender.is_logged_in():
return await evt.reply("You must be logged in with a real account to vote in polls.")
elif evt.sender.is_bot:
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}. 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:
await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
except OptionsTooMuchError:
return await evt.reply("You passed too many options.")
# TODO use response
return await evt.mark_read()
@command_handler(
help_section=SECTION_MISC,
help_args="<_emoji_>",
help_text="Roll a dice (\U0001F3B2), kick a football (\u26BD\uFE0F) or throw a "
"dart (\U0001F3AF) or basketball (\U0001F3C0) on the Telegram servers.",
)
async def random(evt: CommandEvent) -> EventID:
if not evt.is_portal:
return await evt.reply("You can only randomize values in portal rooms")
portal = await po.Portal.get_by_mxid(evt.room_id)
arg = evt.args[0] if len(evt.args) > 0 else "dice"
emoticon = {
"dart": "\U0001F3AF",
"dice": "\U0001F3B2",
"ball": "\U0001F3C0",
"basketball": "\U0001F3C0",
"football": "\u26BD",
"soccer": "\u26BD",
}.get(arg, arg)
try:
await evt.sender.client.send_media(
await portal.get_input_entity(evt.sender), InputMediaDice(emoticon)
)
except EmoticonInvalidError:
return await evt.reply("Invalid emoji for randomization")
@command_handler(
help_section=SECTION_PORTAL_MANAGEMENT,
help_args="[_limit_]",
help_text="Backfill messages from Telegram history.",
)
async def backfill(evt: CommandEvent) -> None:
if not evt.is_portal:
await evt.reply("You can only use backfill in portal rooms")
return
elif not evt.config["bridge.backfill.enable"]:
await evt.reply("Backfilling is disabled in the bridge config")
return
try:
limit = int(evt.args[0])
except (ValueError, IndexError):
limit = -1
portal = await po.Portal.get_by_mxid(evt.room_id)
if not evt.config["bridge.backfill.normal_groups"] and portal.peer_type == "chat":
await evt.reply("Backfilling normal groups is disabled in the bridge config")
return
if portal.backfill_msc2716:
messages_per_batch = evt.config["bridge.backfill.incremental.messages_per_batch"]
batches = math.ceil(limit / messages_per_batch)
rounded = ""
if batches * messages_per_batch != limit:
rounded = f" (rounded message limit to {batches}*{messages_per_batch})"
await portal.enqueue_backfill(evt.sender, priority=0, max_batches=batches)
await evt.reply(f"Backfill queued{rounded}")
else:
output = await portal.forward_backfill(evt.sender, initial=False, override_limit=limit)
await evt.reply(output)
-310
View File
@@ -1,310 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Any, List, NamedTuple
import os
from ruamel.yaml.comments import CommentedMap
from mautrix.bridge.config import BaseBridgeConfig
from mautrix.client import Client
from mautrix.types import UserID
from mautrix.util.config import ConfigUpdateHelper, ForbiddenDefault, ForbiddenKey
Permissions = NamedTuple(
"Permissions",
relaybot=bool,
user=bool,
puppeting=bool,
matrix_puppeting=bool,
admin=bool,
level=str,
)
class Config(BaseBridgeConfig):
@property
def forbidden_defaults(self) -> List[ForbiddenDefault]:
return [
*super().forbidden_defaults,
ForbiddenDefault(
"appservice.database",
"postgres://username:password@hostname/dbname",
),
ForbiddenDefault(
"appservice.public.external",
"https://example.com/public",
condition="appservice.public.enabled",
),
ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
ForbiddenDefault("telegram.api_id", 12345),
ForbiddenDefault("telegram.api_hash", "tjyd5yge35lbodk1xwzw2jstp90k55qz"),
]
def do_update(self, helper: ConfigUpdateHelper) -> None:
super().do_update(helper)
copy, copy_dict, base = helper
if "appservice.protocol" in self and "appservice.address" not in self:
protocol, hostname, port = (
self["appservice.protocol"],
self["appservice.hostname"],
self["appservice.port"],
)
base["appservice.address"] = f"{protocol}://{hostname}:{port}"
if "appservice.debug" in self and "logging" not in self:
level = "DEBUG" if self["appservice.debug"] else "INFO"
base["logging.root.level"] = level
base["logging.loggers.mau.level"] = level
base["logging.loggers.telethon.level"] = level
copy("appservice.public.enabled")
copy("appservice.public.prefix")
copy("appservice.public.external")
copy("appservice.provisioning.enabled")
copy("appservice.provisioning.prefix")
if base["appservice.provisioning.prefix"].endswith("/v1"):
base["appservice.provisioning.prefix"] = base["appservice.provisioning.prefix"][
: -len("/v1")
]
copy("appservice.provisioning.shared_secret")
if base["appservice.provisioning.shared_secret"] == "generate":
base["appservice.provisioning.shared_secret"] = self._new_token()
if "pool_size" in base["appservice.database_opts"]:
pool_size = base["appservice.database_opts"].pop("pool_size")
base["appservice.database_opts.min_size"] = pool_size
base["appservice.database_opts.max_size"] = pool_size
if "pool_pre_ping" in base["appservice.database_opts"]:
del base["appservice.database_opts.pool_pre_ping"]
copy("metrics.enabled")
copy("metrics.listen_port")
copy("bridge.username_template")
copy("bridge.alias_template")
copy("bridge.displayname_template")
copy("bridge.displayname_preference")
copy("bridge.displayname_max_length")
copy("bridge.allow_avatar_remove")
copy("bridge.allow_contact_info")
copy("bridge.max_initial_member_sync")
copy("bridge.max_member_count")
copy("bridge.sync_channel_members")
copy("bridge.skip_deleted_members")
copy("bridge.startup_sync")
if "bridge.sync_dialog_limit" in self:
base["bridge.sync_create_limit"] = self["bridge.sync_dialog_limit"]
base["bridge.sync_update_limit"] = self["bridge.sync_dialog_limit"]
else:
copy("bridge.sync_update_limit")
copy("bridge.sync_create_limit")
copy("bridge.sync_deferred_create_all")
copy("bridge.sync_direct_chats")
copy("bridge.max_telegram_delete")
copy("bridge.sync_matrix_state")
copy("bridge.allow_matrix_login")
copy("bridge.public_portals")
copy("bridge.sync_with_custom_puppets")
copy("bridge.sync_direct_chat_list")
copy("bridge.double_puppet_server_map")
copy("bridge.double_puppet_allow_discovery")
copy("bridge.create_group_on_invite")
if "bridge.login_shared_secret" in self:
base["bridge.login_shared_secret_map"] = {
base["homeserver.domain"]: self["bridge.login_shared_secret"]
}
else:
copy("bridge.login_shared_secret_map")
copy("bridge.telegram_link_preview")
copy("bridge.invite_link_resolve")
copy("bridge.caption_in_message")
copy("bridge.image_as_file_size")
copy("bridge.image_as_file_pixels")
copy("bridge.parallel_file_transfer")
copy("bridge.federate_rooms")
copy("bridge.always_custom_emoji_reaction")
copy("bridge.animated_sticker.target")
copy("bridge.animated_sticker.convert_from_webm")
copy("bridge.animated_sticker.args.width")
copy("bridge.animated_sticker.args.height")
copy("bridge.animated_sticker.args.fps")
copy("bridge.animated_emoji.target")
copy("bridge.animated_emoji.args.width")
copy("bridge.animated_emoji.args.height")
copy("bridge.animated_emoji.args.fps")
if isinstance(self.get("bridge.private_chat_portal_meta", "default"), bool):
base["bridge.private_chat_portal_meta"] = (
"always" if self["bridge.private_chat_portal_meta"] else "default"
)
else:
copy("bridge.private_chat_portal_meta")
if base["bridge.private_chat_portal_meta"] not in ("default", "always", "never"):
base["bridge.private_chat_portal_meta"] = "default"
copy("bridge.disable_reply_fallbacks")
copy("bridge.delivery_receipts")
copy("bridge.delivery_error_reports")
copy("bridge.incoming_bridge_error_reports")
copy("bridge.message_status_events")
copy("bridge.resend_bridge_info")
copy("bridge.mute_bridging")
copy("bridge.pinned_tag")
copy("bridge.archive_tag")
copy("bridge.tag_only_on_create")
copy("bridge.bridge_matrix_leave")
copy("bridge.kick_on_logout")
copy("bridge.always_read_joined_telegram_notice")
copy("bridge.backfill.enable")
copy("bridge.backfill.msc2716")
copy("bridge.backfill.double_puppet_backfill")
copy("bridge.backfill.normal_groups")
copy("bridge.backfill.unread_hours_threshold")
if "bridge.backfill.forward" in self:
initial_limit = self.get("bridge.backfill.forward.initial_limit", 10)
sync_limit = self.get("bridge.backfill.forward.sync_limit", 100)
base["bridge.backfill.forward_limits.initial.user"] = initial_limit
base["bridge.backfill.forward_limits.initial.normal_group"] = initial_limit
base["bridge.backfill.forward_limits.initial.supergroup"] = initial_limit
base["bridge.backfill.forward_limits.initial.channel"] = initial_limit
base["bridge.backfill.forward_limits.sync.user"] = sync_limit
base["bridge.backfill.forward_limits.sync.normal_group"] = sync_limit
base["bridge.backfill.forward_limits.sync.supergroup"] = sync_limit
base["bridge.backfill.forward_limits.sync.channel"] = sync_limit
else:
copy("bridge.backfill.forward_limits.initial.user")
copy("bridge.backfill.forward_limits.initial.normal_group")
copy("bridge.backfill.forward_limits.initial.supergroup")
copy("bridge.backfill.forward_limits.initial.channel")
copy("bridge.backfill.forward_limits.sync.user")
copy("bridge.backfill.forward_limits.sync.normal_group")
copy("bridge.backfill.forward_limits.sync.supergroup")
copy("bridge.backfill.forward_limits.sync.channel")
copy("bridge.backfill.forward_timeout")
copy("bridge.backfill.incremental.messages_per_batch")
copy("bridge.backfill.incremental.post_batch_delay")
copy("bridge.backfill.incremental.max_batches.user")
copy("bridge.backfill.incremental.max_batches.normal_group")
copy("bridge.backfill.incremental.max_batches.supergroup")
copy("bridge.backfill.incremental.max_batches.channel")
copy("bridge.initial_power_level_overrides.group")
copy("bridge.initial_power_level_overrides.user")
copy("bridge.bot_messages_as_notices")
if isinstance(self["bridge.bridge_notices"], bool):
base["bridge.bridge_notices"]["default"] = self["bridge.bridge_notices"]
else:
copy("bridge.bridge_notices.default")
copy("bridge.bridge_notices.exceptions")
if "bridge.message_formats.m_text" in self:
del self["bridge.message_formats"]
copy_dict("bridge.message_formats", override_existing_map=False)
copy("bridge.emote_format")
copy("bridge.relay_user_distinguishers")
copy("bridge.state_event_formats.join")
copy("bridge.state_event_formats.leave")
copy("bridge.state_event_formats.name_change")
copy("bridge.filter.mode")
copy("bridge.filter.list")
copy("bridge.filter.users")
copy("bridge.command_prefix")
migrate_permissions = (
"bridge.permissions" not in self
or "bridge.whitelist" in self
or "bridge.admins" in self
)
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", override_existing_map=True)
if "bridge.relaybot" not in self:
copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals")
else:
copy("bridge.relaybot.private_chat.invite")
copy("bridge.relaybot.private_chat.state_changes")
copy("bridge.relaybot.private_chat.message")
copy("bridge.relaybot.group_chat_invite")
copy("bridge.relaybot.ignore_unbridged_group_chat")
copy("bridge.relaybot.authless_portals")
copy("bridge.relaybot.whitelist_group_admins")
copy("bridge.relaybot.whitelist")
copy("bridge.relaybot.ignore_own_incoming_events")
copy("telegram.api_id")
copy("telegram.api_hash")
copy("telegram.bot_token")
copy("telegram.catch_up")
copy("telegram.sequential_updates")
copy("telegram.exit_on_update_error")
copy("telegram.connection.timeout")
copy("telegram.connection.retries")
copy("telegram.connection.retry_delay")
copy("telegram.connection.flood_sleep_threshold")
copy("telegram.connection.request_retries")
copy("telegram.connection.use_ipv6")
copy("telegram.device_info.device_model")
copy("telegram.device_info.system_version")
copy("telegram.device_info.app_version")
copy("telegram.device_info.lang_code")
copy("telegram.device_info.system_lang_code")
copy("telegram.server.enabled")
copy("telegram.server.dc")
copy("telegram.server.ip")
copy("telegram.server.port")
copy("telegram.proxy.type")
copy("telegram.proxy.address")
copy("telegram.proxy.port")
copy("telegram.proxy.rdns")
copy("telegram.proxy.username")
copy("telegram.proxy.password")
def _get_permissions(self, key: str) -> Permissions:
level = self["bridge.permissions"].get(key, "")
admin = level == "admin"
matrix_puppeting = level == "full" or admin
puppeting = level == "puppeting" or matrix_puppeting
user = level == "user" or puppeting
relaybot = level == "relaybot" or user
return Permissions(relaybot, user, puppeting, matrix_puppeting, admin, level)
def get_permissions(self, mxid: UserID) -> Permissions:
permissions = self["bridge.permissions"]
if mxid in permissions:
return self._get_permissions(mxid)
_, homeserver = Client.parse_user_id(mxid)
if homeserver in permissions:
return self._get_permissions(homeserver)
return self._get_permissions("*")
-60
View File
@@ -1,60 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Database
from .backfill_queue import Backfill, BackfillType
from .bot_chat import BotChat
from .disappearing_message import DisappearingMessage
from .message import Message
from .portal import Portal
from .puppet import Puppet
from .reaction import Reaction
from .telegram_file import TelegramFile
from .telethon_session import PgSession
from .upgrade import upgrade_table
from .user import User
def init(db: Database) -> None:
for table in (
Portal,
Message,
Reaction,
User,
Puppet,
TelegramFile,
BotChat,
PgSession,
DisappearingMessage,
Backfill,
):
table.db = db
__all__ = [
"upgrade_table",
"init",
"Portal",
"Message",
"Reaction",
"User",
"Puppet",
"TelegramFile",
"BotChat",
"PgSession",
"DisappearingMessage",
"Backfill",
]
-235
View File
@@ -1,235 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan, Sumner Evans
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
from datetime import datetime, timedelta
from enum import Enum
import json
from asyncpg import Record
from attr import dataclass
from mautrix.types import UserID
from mautrix.util.async_db import Connection, Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
class BackfillType(Enum):
HISTORICAL = "historical"
SYNC_DIALOG = "sync_dialog"
@dataclass
class Backfill:
db: ClassVar[Database] = fake_db
queue_id: int | None
user_mxid: UserID
priority: int
type: BackfillType
portal_tgid: TelegramID
portal_tg_receiver: TelegramID
anchor_msg_id: TelegramID | None
extra_data: dict[str, Any]
messages_per_batch: int
post_batch_delay: int
max_batches: int
dispatch_time: datetime | None
completed_at: datetime | None
cooldown_timeout: datetime | None
@staticmethod
def new(
user_mxid: UserID,
priority: int,
type: BackfillType,
portal_tgid: TelegramID,
portal_tg_receiver: TelegramID,
messages_per_batch: int,
anchor_msg_id: TelegramID | None = None,
extra_data: dict[str, Any] | None = None,
post_batch_delay: int = 0,
max_batches: int = -1,
) -> "Backfill":
return Backfill(
queue_id=None,
user_mxid=user_mxid,
priority=priority,
type=type,
portal_tgid=portal_tgid,
portal_tg_receiver=portal_tg_receiver,
anchor_msg_id=anchor_msg_id,
extra_data=extra_data or {},
messages_per_batch=messages_per_batch,
post_batch_delay=post_batch_delay,
max_batches=max_batches,
dispatch_time=None,
completed_at=None,
cooldown_timeout=None,
)
@classmethod
def _from_row(cls, row: Record | None) -> Backfill | None:
if row is None:
return None
data = {**row}
type = BackfillType(data.pop("type"))
extra_data = json.loads(data.pop("extra_data", None) or "{}")
return cls(**data, type=type, extra_data=extra_data)
columns = [
"user_mxid",
"priority",
"type",
"portal_tgid",
"portal_tg_receiver",
"anchor_msg_id",
"extra_data",
"messages_per_batch",
"post_batch_delay",
"max_batches",
"dispatch_time",
"completed_at",
"cooldown_timeout",
]
columns_str = ",".join(columns)
@classmethod
async def get_next(cls, user_mxid: UserID) -> Backfill | None:
q = f"""
SELECT queue_id, {cls.columns_str}
FROM backfill_queue
WHERE user_mxid=$1
AND (
dispatch_time IS NULL
OR (
dispatch_time < $2
AND completed_at IS NULL
)
)
AND (
cooldown_timeout IS NULL
OR cooldown_timeout < current_timestamp
)
ORDER BY priority, queue_id
LIMIT 1
"""
return cls._from_row(
await cls.db.fetchrow(q, user_mxid, datetime.now() - timedelta(minutes=15))
)
@classmethod
async def delete_existing(
cls,
user_mxid: UserID,
portal_tgid: int,
portal_tg_receiver: int,
type: BackfillType,
) -> Backfill | None:
q = f"""
WITH deleted_entries AS (
DELETE FROM backfill_queue
WHERE user_mxid=$1
AND portal_tgid=$2
AND portal_tg_receiver=$3
AND type=$4
AND dispatch_time IS NULL
AND completed_at IS NULL
RETURNING 1
)
WITH dispatched_entries AS (
SELECT 1 FROM backfill_queue
WHERE user_mxid=$1
AND portal_tgid=$2
AND portal_tg_receiver=$3
AND type=$4
AND dispatch_time IS NOT NULL
AND completed_at IS NULL
)
"""
return cls._from_row(
await cls.db.fetchrow(q, user_mxid, portal_tgid, portal_tg_receiver, type.value)
)
@classmethod
async def delete_all(cls, user_mxid: UserID, conn: Connection | None = None) -> None:
await (conn or cls.db).execute("DELETE FROM backfill_queue WHERE user_mxid=$1", user_mxid)
@classmethod
async def delete_for_portal(cls, tgid: int, tg_receiver: int) -> None:
q = "DELETE FROM backfill_queue WHERE portal_tgid=$1 AND portal_tg_receiver=$2"
await cls.db.execute(q, tgid, tg_receiver)
async def insert(self) -> list[Backfill]:
delete_q = f"""
DELETE FROM backfill_queue
WHERE user_mxid=$1
AND portal_tgid=$2
AND portal_tg_receiver=$3
AND type=$4
AND dispatch_time IS NULL
AND completed_at IS NULL
RETURNING queue_id, {self.columns_str}
"""
q = f"""
INSERT INTO backfill_queue ({self.columns_str})
VALUES ({','.join(f'${i+1}' for i in range(len(self.columns)))})
RETURNING queue_id
"""
async with self.db.acquire() as conn, conn.transaction():
deleted_rows = await conn.fetch(
delete_q,
self.user_mxid,
self.portal_tgid,
self.portal_tg_receiver,
self.type.value,
)
self.queue_id = await conn.fetchval(
q,
self.user_mxid,
self.priority,
self.type.value,
self.portal_tgid,
self.portal_tg_receiver,
self.anchor_msg_id,
json.dumps(self.extra_data) if self.extra_data else None,
self.messages_per_batch,
self.post_batch_delay,
self.max_batches,
self.dispatch_time,
self.completed_at,
self.cooldown_timeout,
)
return [self._from_row(row) for row in deleted_rows]
async def mark_dispatched(self) -> None:
q = "UPDATE backfill_queue SET dispatch_time=$1 WHERE queue_id=$2"
await self.db.execute(q, datetime.now(), self.queue_id)
async def mark_done(self) -> None:
q = "UPDATE backfill_queue SET completed_at=$1 WHERE queue_id=$2"
await self.db.execute(q, datetime.now(), self.queue_id)
async def set_cooldown_timeout(self, timeout: int) -> None:
"""
Set the backfill request to cooldown for ``timeout`` seconds.
"""
q = "UPDATE backfill_queue SET cooldown_timeout=$1 WHERE queue_id=$2"
await self.db.execute(q, datetime.now() + timedelta(seconds=timeout), self.queue_id)
-55
View File
@@ -1,55 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
# Fucking Telegram not telling bots what chats they are in 3:<
@dataclass
class BotChat:
db: ClassVar[Database] = fake_db
id: TelegramID
type: str
@classmethod
def _from_row(cls, row: Record | None) -> BotChat | None:
if row is None:
return None
return cls(**row)
@classmethod
async def delete_by_id(cls, chat_id: TelegramID) -> None:
await cls.db.execute("DELETE FROM bot_chat WHERE id=$1", chat_id)
@classmethod
async def all(cls) -> list[BotChat]:
rows = await cls.db.fetch("SELECT id, type FROM bot_chat")
return [cls._from_row(row) for row in rows]
async def insert(self) -> None:
q = "INSERT INTO bot_chat (id, type) VALUES ($1, $2)"
await self.db.execute(q, self.id, self.type)
@@ -1,78 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Sumner Evans
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import asyncpg
from mautrix.bridge import AbstractDisappearingMessage
from mautrix.types import EventID, RoomID
from mautrix.util.async_db import Database
fake_db = Database.create("") if TYPE_CHECKING else None
class DisappearingMessage(AbstractDisappearingMessage):
db: ClassVar[Database] = fake_db
async def insert(self) -> None:
q = """
INSERT INTO disappearing_message (room_id, event_id, expiration_seconds, expiration_ts)
VALUES ($1, $2, $3, $4)
"""
await self.db.execute(
q, self.room_id, self.event_id, self.expiration_seconds, self.expiration_ts
)
async def update(self) -> None:
q = "UPDATE disappearing_message SET expiration_ts=$3 WHERE room_id=$1 AND event_id=$2"
await self.db.execute(q, self.room_id, self.event_id, self.expiration_ts)
async def delete(self) -> None:
q = "DELETE from disappearing_message WHERE room_id=$1 AND event_id=$2"
await self.db.execute(q, self.room_id, self.event_id)
@classmethod
def _from_row(cls, row: asyncpg.Record) -> DisappearingMessage:
return cls(**row)
@classmethod
async def get(cls, room_id: RoomID, event_id: EventID) -> DisappearingMessage | None:
q = """
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
WHERE room_id=$1 AND mxid=$2
"""
try:
return cls._from_row(await cls.db.fetchrow(q, room_id, event_id))
except Exception:
return None
@classmethod
async def get_all_scheduled(cls) -> list[DisappearingMessage]:
q = """
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
WHERE expiration_ts IS NOT NULL
"""
return [cls._from_row(r) for r in await cls.db.fetch(q)]
@classmethod
async def get_unscheduled_for_room(cls, room_id: RoomID) -> list[DisappearingMessage]:
q = """
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
WHERE room_id = $1 AND expiration_ts IS NULL
"""
return [cls._from_row(r) for r in await cls.db.fetch(q, room_id)]
-226
View File
@@ -1,226 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
import attr
from mautrix.types import EventID, RoomID, UserID
from mautrix.util.async_db import Database, Scheme
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
@dataclass
class Message:
db: ClassVar[Database] = fake_db
mxid: EventID
mx_room: RoomID
tgid: TelegramID
tg_space: TelegramID
edit_index: int
redacted: bool = False
content_hash: bytes | None = None
sender_mxid: UserID | None = None
sender: TelegramID | None = None
@classmethod
def _from_row(cls, row: Record | None) -> Message | None:
if row is None:
return None
return cls(**row)
columns: ClassVar[str] = ", ".join(
(
"mxid",
"mx_room",
"tgid",
"tg_space",
"edit_index",
"redacted",
"content_hash",
"sender_mxid",
"sender",
)
)
@classmethod
async def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> list[Message]:
q = f"SELECT {cls.columns} FROM message WHERE tgid=$1 AND tg_space=$2"
rows = await cls.db.fetch(q, tgid, tg_space)
return [cls._from_row(row) for row in rows]
@classmethod
async def get_one_by_tgid(
cls, tgid: TelegramID, tg_space: TelegramID, edit_index: int = 0
) -> Message | None:
if edit_index < 0:
q = (
f"SELECT {cls.columns} FROM message WHERE tgid=$1 AND tg_space=$2 "
f"ORDER BY edit_index DESC LIMIT 1 OFFSET {-edit_index - 1}"
)
row = await cls.db.fetchrow(q, tgid, tg_space)
else:
q = (
f"SELECT {cls.columns} FROM message"
" WHERE tgid=$1 AND tg_space=$2 AND edit_index=$3"
)
row = await cls.db.fetchrow(q, tgid, tg_space, edit_index)
return cls._from_row(row)
@classmethod
async def get_first_by_tgids(
cls, tgids: list[TelegramID], tg_space: TelegramID
) -> list[Message]:
if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
q = (
f"SELECT {cls.columns} FROM message"
" WHERE tgid=ANY($1) AND tg_space=$2 AND edit_index=0"
)
rows = await cls.db.fetch(q, tgids, tg_space)
else:
tgid_placeholders = ("?," * len(tgids)).rstrip(",")
q = (
f"SELECT {cls.columns} FROM message "
f"WHERE tg_space=? AND edit_index=0 AND tgid IN ({tgid_placeholders})"
)
rows = await cls.db.fetch(q, tg_space, *tgids)
return [cls._from_row(row) for row in rows]
@classmethod
async def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> int:
return (
await cls.db.fetchval(
"SELECT COUNT(tg_space) FROM message WHERE mxid=$1 AND mx_room=$2", mxid, mx_room
)
or 0
)
@classmethod
async def find_last(cls, mx_room: RoomID, tg_space: TelegramID) -> Message | None:
q = (
f"SELECT {cls.columns} FROM message WHERE mx_room=$1 AND tg_space=$2 "
f"ORDER BY tgid DESC LIMIT 1"
)
return cls._from_row(await cls.db.fetchrow(q, mx_room, tg_space))
@classmethod
async def find_first(cls, mx_room: RoomID, tg_space: TelegramID) -> Message | None:
q = (
f"SELECT {cls.columns} FROM message WHERE mx_room=$1 AND tg_space=$2 "
f"ORDER BY tgid ASC LIMIT 1"
)
return cls._from_row(await cls.db.fetchrow(q, mx_room, tg_space))
@classmethod
async def delete_all(cls, mx_room: RoomID) -> None:
await cls.db.execute("DELETE FROM message WHERE mx_room=$1", mx_room)
@classmethod
async def get_by_mxid(
cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
) -> Message | None:
q = f"SELECT {cls.columns} FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room, tg_space))
@classmethod
async def get_by_mxids(
cls, mxids: list[EventID], mx_room: RoomID, tg_space: TelegramID
) -> list[Message]:
if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
q = (
f"SELECT {cls.columns} FROM message"
" WHERE mxid=ANY($1) AND mx_room=$2 AND tg_space=$3"
)
rows = await cls.db.fetch(q, mxids, mx_room, tg_space)
else:
mxid_placeholders = ("?," * len(mxids)).rstrip(",")
q = (
f"SELECT {cls.columns} FROM message "
f"WHERE mx_room=? AND tg_space=? AND mxid IN ({mxid_placeholders})"
)
rows = await cls.db.fetch(q, mx_room, tg_space, *mxids)
return [cls._from_row(row) for row in rows]
@classmethod
async def find_recent(
cls, mx_room: RoomID, not_sender: TelegramID, limit: int = 20
) -> list[Message]:
q = f"""
SELECT {cls.columns} FROM message
WHERE mx_room=$1 AND sender<>$2
ORDER BY tgid DESC LIMIT $3
"""
return [cls._from_row(row) for row in await cls.db.fetch(q, mx_room, not_sender, limit)]
@classmethod
async def replace_temp_mxid(cls, temp_mxid: str, mx_room: RoomID, real_mxid: EventID) -> None:
q = "UPDATE message SET mxid=$1 WHERE mxid=$2 AND mx_room=$3"
await cls.db.execute(q, real_mxid, temp_mxid, mx_room)
@classmethod
async def delete_temp_mxid(cls, temp_mxid: str, mx_room: RoomID) -> None:
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2"
await cls.db.execute(q, temp_mxid, mx_room)
@classmethod
async def bulk_insert(cls, messages: list[Message]) -> None:
columns = cls.columns.split(", ")
records = [attr.astuple(message) for message in messages]
async with cls.db.acquire() as conn, conn.transaction():
if cls.db.scheme == Scheme.POSTGRES:
await conn.copy_records_to_table("message", records=records, columns=columns)
else:
await conn.executemany(cls._insert_query, records)
_insert_query: ClassVar[
str
] = """
INSERT INTO message (mxid, mx_room, tgid, tg_space, edit_index, redacted, content_hash, sender_mxid, sender)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"""
@property
def _values(self):
return (
self.mxid,
self.mx_room,
self.tgid,
self.tg_space,
self.edit_index,
self.redacted,
self.content_hash,
self.sender_mxid,
self.sender,
)
async def insert(self) -> None:
await self.db.execute(self._insert_query, *self._values)
async def delete(self) -> None:
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
await self.db.execute(q, self.mxid, self.mx_room, self.tg_space)
async def mark_redacted(self) -> None:
self.redacted = True
q = "UPDATE message SET redacted=true WHERE mxid=$1 AND mx_room=$2"
await self.db.execute(q, self.mxid, self.mx_room)
-192
View File
@@ -1,192 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, Any, ClassVar
import json
from asyncpg import Record
from attr import dataclass
import attr
from mautrix.types import BatchID, ContentURI, EventID, RoomID
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
@dataclass
class Portal:
db: ClassVar[Database] = fake_db
# Telegram chat information
tgid: TelegramID
tg_receiver: TelegramID
peer_type: str
megagroup: bool
# Matrix portal information
mxid: RoomID | None
avatar_url: ContentURI | None
encrypted: bool
first_event_id: EventID | None
next_batch_id: BatchID | None
base_insertion_id: EventID | None
sponsored_event_id: EventID | None
sponsored_event_ts: int | None
sponsored_msg_random_id: bytes | None
# Telegram chat metadata
username: str | None
title: str | None
about: str | None
photo_id: str | None
name_set: bool
avatar_set: bool
local_config: dict[str, Any] = attr.ib(factory=lambda: {})
@classmethod
def _from_row(cls, row: Record | None) -> Portal | None:
if row is None:
return None
data = {**row}
data["local_config"] = json.loads(data.pop("config", None) or "{}")
return cls(**data)
columns: ClassVar[str] = ", ".join(
(
"tgid",
"tg_receiver",
"peer_type",
"megagroup",
"mxid",
"avatar_url",
"encrypted",
"first_event_id",
"next_batch_id",
"base_insertion_id",
"sponsored_event_id",
"sponsored_event_ts",
"sponsored_msg_random_id",
"username",
"title",
"about",
"photo_id",
"name_set",
"avatar_set",
"config",
)
)
@classmethod
async def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Portal | None:
q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND tg_receiver=$2"
return cls._from_row(await cls.db.fetchrow(q, tgid, tg_receiver))
@classmethod
async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
q = f"SELECT {cls.columns} FROM portal WHERE mxid=$1"
return cls._from_row(await cls.db.fetchrow(q, mxid))
@classmethod
async def find_by_username(cls, username: str) -> Portal | None:
q = f"SELECT {cls.columns} FROM portal WHERE lower(username)=$1"
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
@classmethod
async def find_private_chats_of(cls, tg_receiver: TelegramID) -> list[Portal]:
q = f"SELECT {cls.columns} FROM portal WHERE tg_receiver=$1 AND peer_type='user'"
return [cls._from_row(row) for row in await cls.db.fetch(q, tg_receiver)]
@classmethod
async def find_private_chats_with(cls, tgid: TelegramID) -> list[Portal]:
q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND peer_type='user'"
return [cls._from_row(row) for row in await cls.db.fetch(q, tgid)]
@classmethod
async def all(cls) -> list[Portal]:
rows = await cls.db.fetch(f"SELECT {cls.columns} FROM portal")
return [cls._from_row(row) for row in rows]
@property
def _values(self):
return (
self.tgid,
self.tg_receiver,
self.peer_type,
self.mxid,
self.avatar_url,
self.encrypted,
self.first_event_id,
self.next_batch_id,
self.base_insertion_id,
self.sponsored_event_id,
self.sponsored_event_ts,
self.sponsored_msg_random_id,
self.username,
self.title,
self.about,
self.photo_id,
self.name_set,
self.avatar_set,
self.megagroup,
json.dumps(self.local_config) if self.local_config else None,
)
async def save(self) -> None:
q = """
UPDATE portal
SET mxid=$4, avatar_url=$5, encrypted=$6,
first_event_id=$7, next_batch_id=$8, base_insertion_id=$9,
sponsored_event_id=$10, sponsored_event_ts=$11, sponsored_msg_random_id=$12,
username=$13, title=$14, about=$15, photo_id=$16, name_set=$17, avatar_set=$18,
megagroup=$19, config=$20
WHERE tgid=$1 AND tg_receiver=$2 AND (peer_type=$3 OR true)
"""
await self.db.execute(q, *self._values)
async def update_id(self, id: TelegramID, peer_type: str) -> None:
q = (
"UPDATE portal SET tgid=$1, tg_receiver=$1, peer_type=$2 "
"WHERE tgid=$3 AND tg_receiver=$3"
)
clear_queue = "DELETE FROM backfill_queue WHERE portal_tgid=$1 AND portal_tg_receiver=$2"
async with self.db.acquire() as conn, conn.transaction():
await conn.execute(clear_queue, self.tgid, self.tg_receiver)
await conn.execute(q, id, peer_type, self.tgid)
self.tgid = id
self.tg_receiver = id
self.peer_type = peer_type
async def insert(self) -> None:
q = """
INSERT INTO portal (
tgid, tg_receiver, peer_type, mxid, avatar_url, encrypted,
first_event_id, base_insertion_id, next_batch_id,
sponsored_event_id, sponsored_event_ts, sponsored_msg_random_id,
username, title, about, photo_id, name_set, avatar_set, megagroup, config
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19, $20)
"""
await self.db.execute(q, *self._values)
async def delete(self) -> None:
q = "DELETE FROM portal WHERE tgid=$1 AND tg_receiver=$2"
await self.db.execute(q, self.tgid, self.tg_receiver)
-144
View File
@@ -1,144 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
from yarl import URL
from mautrix.types import ContentURI, SyncToken, UserID
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
@dataclass
class Puppet:
db: ClassVar[Database] = fake_db
id: TelegramID
is_registered: bool
displayname: str | None
displayname_source: TelegramID | None
displayname_contact: bool
displayname_quality: int
disable_updates: bool
username: str | None
phone: str | None
photo_id: str | None
avatar_url: ContentURI | None
name_set: bool
avatar_set: bool
contact_info_set: bool
is_bot: bool | None
is_channel: bool
is_premium: bool
custom_mxid: UserID | None
access_token: str | None
next_batch: SyncToken | None
base_url: URL | None
@classmethod
def _from_row(cls, row: Record | None) -> Puppet | None:
if row is None:
return None
data = {**row}
base_url = data.pop("base_url", None)
return cls(**data, base_url=URL(base_url) if base_url else None)
columns: ClassVar[str] = (
"id, is_registered, displayname, displayname_source, displayname_contact, "
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
"name_set, avatar_set, contact_info_set, is_bot, is_channel, is_premium, "
"custom_mxid, access_token, next_batch, base_url"
)
@classmethod
async def all_with_custom_mxid(cls) -> list[Puppet]:
q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid<>''"
return [cls._from_row(row) for row in await cls.db.fetch(q)]
@classmethod
async def get_by_tgid(cls, tgid: TelegramID) -> Puppet | None:
q = f"SELECT {cls.columns} FROM puppet WHERE id=$1"
return cls._from_row(await cls.db.fetchrow(q, tgid))
@classmethod
async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid=$1"
return cls._from_row(await cls.db.fetchrow(q, mxid))
@classmethod
async def find_by_username(cls, username: str) -> Puppet | None:
q = f"SELECT {cls.columns} FROM puppet WHERE lower(username)=$1"
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
@property
def _values(self):
return (
self.id,
self.is_registered,
self.displayname,
self.displayname_source,
self.displayname_contact,
self.displayname_quality,
self.disable_updates,
self.username,
self.phone,
self.photo_id,
self.avatar_url,
self.name_set,
self.avatar_set,
self.contact_info_set,
self.is_bot,
self.is_channel,
self.is_premium,
self.custom_mxid,
self.access_token,
self.next_batch,
str(self.base_url) if self.base_url else None,
)
async def save(self) -> None:
q = """
UPDATE puppet
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10,
avatar_url=$11, name_set=$12, avatar_set=$13, contact_info_set=$14, is_bot=$15,
is_channel=$16, is_premium=$17, custom_mxid=$18, access_token=$19, next_batch=$20,
base_url=$21
WHERE id=$1
"""
await self.db.execute(q, *self._values)
async def insert(self) -> None:
q = """
INSERT INTO puppet (
id, is_registered, displayname, displayname_source, displayname_contact,
displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
avatar_set, contact_info_set, is_bot, is_channel, is_premium, custom_mxid,
access_token, next_batch, base_url
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19, $20, $21)
"""
await self.db.execute(q, *self._values)
-100
View File
@@ -1,100 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
from telethon.tl.types import ReactionCustomEmoji, ReactionEmoji, TypeReaction
from mautrix.types import EventID, RoomID
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
@dataclass
class Reaction:
db: ClassVar[Database] = fake_db
mxid: EventID
mx_room: RoomID
msg_mxid: EventID
tg_sender: TelegramID
reaction: str
@classmethod
def _from_row(cls, row: Record | None) -> Reaction | None:
if row is None:
return None
return cls(**row)
columns: ClassVar[str] = "mxid, mx_room, msg_mxid, tg_sender, reaction"
@classmethod
async def delete_all(cls, mx_room: RoomID) -> None:
await cls.db.execute("DELETE FROM reaction WHERE mx_room=$1", mx_room)
@classmethod
async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None:
q = f"SELECT {cls.columns} FROM reaction WHERE mxid=$1 AND mx_room=$2"
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room))
@classmethod
async def get_by_sender(
cls, mxid: EventID, mx_room: RoomID, tg_sender: TelegramID
) -> list[Reaction]:
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3"
rows = await cls.db.fetch(q, mxid, mx_room, tg_sender)
return [cls._from_row(row) for row in rows]
@classmethod
async def get_all_by_message(cls, mxid: EventID, mx_room: RoomID) -> list[Reaction]:
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2"
rows = await cls.db.fetch(q, mxid, mx_room)
return [cls._from_row(row) for row in rows]
@property
def telegram(self) -> TypeReaction:
if self.reaction.isdecimal():
return ReactionCustomEmoji(document_id=int(self.reaction))
else:
return ReactionEmoji(emoticon=self.reaction)
@property
def _values(self):
return (
self.mxid,
self.mx_room,
self.msg_mxid,
self.tg_sender,
self.reaction,
)
async def save(self) -> None:
q = """
INSERT INTO reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (msg_mxid, mx_room, tg_sender, reaction)
DO UPDATE SET mxid=excluded.mxid
"""
await self.db.execute(q, *self._values)
async def delete(self) -> None:
q = "DELETE FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3 AND reaction=$4"
await self.db.execute(q, self.msg_mxid, self.mx_room, self.tg_sender, self.reaction)
-111
View File
@@ -1,111 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
from mautrix.types import ContentURI, EncryptedFile
from mautrix.util.async_db import Database, Scheme
fake_db = Database.create("") if TYPE_CHECKING else None
@dataclass
class TelegramFile:
db: ClassVar[Database] = fake_db
id: str
mxc: ContentURI
mime_type: str
was_converted: bool
timestamp: int
size: int | None
width: int | None
height: int | None
decryption_info: EncryptedFile | None
thumbnail: TelegramFile | None = None
columns: ClassVar[str] = (
"id, mxc, mime_type, was_converted, timestamp, size, width, height, thumbnail, "
"decryption_info"
)
@classmethod
def _from_row(cls, row: Record | None) -> TelegramFile | None:
if row is None:
return None
data = {**row}
data.pop("thumbnail", None)
decryption_info = data.pop("decryption_info", None)
return cls(
**data,
thumbnail=None,
decryption_info=EncryptedFile.parse_json(decryption_info) if decryption_info else None,
)
@classmethod
async def get_many(cls, loc_ids: list[str]) -> list[TelegramFile]:
if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
q = f"SELECT {cls.columns} FROM telegram_file WHERE id=ANY($1)"
rows = await cls.db.fetch(q, loc_ids)
else:
tgid_placeholders = ("?," * len(loc_ids)).rstrip(",")
q = f"SELECT {cls.columns} FROM telegram_file WHERE id IN ({tgid_placeholders})"
rows = await cls.db.fetch(q, *loc_ids)
return [cls._from_row(row) for row in rows]
@classmethod
async def get(cls, loc_id: str, *, _thumbnail: bool = False) -> TelegramFile | None:
q = f"SELECT {cls.columns} FROM telegram_file WHERE id=$1"
row = await cls.db.fetchrow(q, loc_id)
file = cls._from_row(row)
if file is None:
return None
try:
thumbnail_id = row["thumbnail"]
except KeyError:
thumbnail_id = None
if thumbnail_id and not _thumbnail:
file.thumbnail = await cls.get(thumbnail_id, _thumbnail=True)
return file
@classmethod
async def find_by_mxc(cls, mxc: ContentURI) -> TelegramFile | None:
q = f"SELECT {cls.columns} FROM telegram_file WHERE mxc=$1"
return cls._from_row(await cls.db.fetchrow(q, mxc))
async def insert(self) -> None:
q = (
"INSERT INTO telegram_file (id, mxc, mime_type, was_converted, timestamp,"
" size, width, height, thumbnail, decryption_info) "
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"
)
await self.db.execute(
q,
self.id,
self.mxc,
self.mime_type,
self.was_converted,
self.timestamp,
self.size,
self.width,
self.height,
self.thumbnail.id if self.thumbnail else None,
self.decryption_info.json() if self.decryption_info else None,
)
-266
View File
@@ -1,266 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, Iterable
import asyncio
import datetime
from telethon import utils
from telethon.crypto import AuthKey
from telethon.sessions import MemorySession
from telethon.tl.types import PeerChannel, PeerChat, PeerUser, updates
from mautrix.util.async_db import Database, Scheme
fake_db = Database.create("") if TYPE_CHECKING else None
class PgSession(MemorySession):
db: ClassVar[Database] = fake_db
session_id: str
_dc_id: int
_server_address: str | None
_port: int | None
_auth_key: AuthKey | None
_takeout_id: int | None
_process_entities_lock: asyncio.Lock
def __init__(
self,
session_id: str,
dc_id: int = 0,
server_address: str | None = None,
port: int | None = None,
auth_key: AuthKey | None = None,
takeout_id: int | None = None,
) -> None:
super().__init__()
self.session_id = session_id
self._dc_id = dc_id
self._server_address = server_address
self._port = port
self._auth_key = auth_key
self._takeout_id = takeout_id
self._process_entities_lock = asyncio.Lock()
def clone(self, to_instance=None) -> MemorySession:
# We don't want to store data of clones
# (which are used for temporarily connecting to different DCs)
return super().clone(MemorySession())
@property
def auth_key_bytes(self) -> bytes | None:
return self._auth_key.key if self._auth_key else None
@classmethod
async def get(cls, session_id: str) -> PgSession:
q = (
"SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions "
"WHERE session_id=$1"
)
row = await cls.db.fetchrow(q, session_id)
if row is None:
return cls(session_id)
data = {**row}
auth_key = AuthKey(data.pop("auth_key", None))
return cls(**data, auth_key=auth_key)
@classmethod
async def has(cls, session_id: str) -> bool:
q = "SELECT COUNT(*) FROM telethon_sessions WHERE session_id=$1"
count = await cls.db.fetchval(q, session_id)
return count > 0
async def save(self) -> None:
q = (
"INSERT INTO telethon_sessions (session_id, dc_id, server_address, port, auth_key) "
"VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_id) "
"DO UPDATE SET dc_id=$2, server_address=$3, port=$4, auth_key=$5"
)
await self.db.execute(
q, self.session_id, self.dc_id, self.server_address, self.port, self.auth_key_bytes
)
_tables: ClassVar[tuple[str, ...]] = (
"telethon_sessions",
"telethon_entities",
"telethon_sent_files",
"telethon_update_state",
)
async def delete(self) -> None:
async with self.db.acquire() as conn, conn.transaction():
for table in self._tables:
await conn.execute(f"DELETE FROM {table} WHERE session_id=$1", self.session_id)
async def close(self) -> None:
# Nothing to do here, DB connection is global
pass
async def get_update_state(self, entity_id: int) -> updates.State | None:
q = (
"SELECT pts, qts, date, seq, unread_count FROM telethon_update_state "
"WHERE session_id=$1 AND entity_id=$2"
)
row = await self.db.fetchrow(q, self.session_id, entity_id)
if row is None:
return None
date = datetime.datetime.utcfromtimestamp(row["date"])
return updates.State(row["pts"], row["qts"], date, row["seq"], row["unread_count"])
_set_update_state_q = """
INSERT INTO telethon_update_state (session_id, entity_id, pts, qts, date, seq, unread_count)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (session_id, entity_id) DO UPDATE SET
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
unread_count=excluded.unread_count
"""
async def set_update_state(self, entity_id: int, row: updates.State) -> None:
q = self._set_update_state_q
ts = row.date.timestamp()
await self.db.execute(
q, self.session_id, entity_id, row.pts, row.qts, ts, row.seq, row.unread_count
)
async def set_update_states(self, rows: list[tuple[int, updates.State]]) -> None:
rows = [
(
self.session_id,
entity_id,
row.pts,
row.qts,
row.date.timestamp(),
row.seq,
row.unread_count,
)
for entity_id, row in rows
]
if self.db.scheme == Scheme.POSTGRES:
q = """
INSERT INTO telethon_update_state (
session_id, entity_id, pts, qts, date, seq, unread_count
)
VALUES (
$1,
unnest($2::bigint[]), unnest($3::bigint[]), unnest($4::bigint[]),
unnest($5::bigint[]), unnest($6::bigint[]), unnest($7::integer[])
)
ON CONFLICT (session_id, entity_id) DO UPDATE SET
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
unread_count=excluded.unread_count
"""
_, entity_ids, ptses, qtses, timestamps, seqs, unread_counts = zip(*rows)
await self.db.execute(
q, self.session_id, entity_ids, ptses, qtses, timestamps, seqs, unread_counts
)
else:
await self.db.executemany(self._set_update_state_q, rows)
async def delete_update_state(self, entity_id: int) -> None:
q = "DELETE FROM telethon_update_state WHERE session_id=$1 AND entity_id=$2"
await self.db.execute(q, self.session_id, entity_id)
async def get_update_states(self) -> Iterable[tuple[int, updates.State], ...]:
q = (
"SELECT entity_id, pts, qts, date, seq, unread_count FROM telethon_update_state "
"WHERE session_id=$1"
)
rows = await self.db.fetch(q, self.session_id)
return (
(
row["entity_id"],
updates.State(
row["pts"],
row["qts"],
datetime.datetime.utcfromtimestamp(row["date"]),
row["seq"],
row["unread_count"],
),
)
for row in rows
)
def _entity_values_to_row(
self, id: int, hash: int, username: str | None, phone: str | int | None, name: str | None
) -> tuple[str, int, int, str | None, str | None, str | None]:
return self.session_id, id, hash, username, str(phone) if phone else None, name
async def process_entities(self, tlo) -> None:
# Postgres likes to deadlock on simultaneous upserts, so just lock the whole thing here
# TODO: make sure postgres doesn't deadlock on upserts when session_id is different
async with self._process_entities_lock:
await self._locked_process_entities(tlo)
async def _locked_process_entities(self, tlo) -> None:
rows: list[
tuple[str, int, int, str | None, str | None, str | None]
] = self._entities_to_rows(tlo)
if not rows:
return
if self.db.scheme == Scheme.POSTGRES:
q = (
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
"VALUES ($1, unnest($2::bigint[]), unnest($3::bigint[]), "
" unnest($4::text[]), unnest($5::text[]), unnest($6::text[])) "
"ON CONFLICT (session_id, id) DO UPDATE"
" SET hash=excluded.hash, username=excluded.username,"
" phone=excluded.phone, name=excluded.name"
)
_, ids, hashes, usernames, phones, names = zip(*rows)
await self.db.execute(q, self.session_id, ids, hashes, usernames, phones, names)
else:
q = (
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
"VALUES ($1, $2, $3, $4, $5, $6) "
"ON CONFLICT (session_id, id) DO UPDATE "
" SET hash=$3, username=$4, phone=$5, name=$6"
)
await self.db.executemany(q, rows)
async def _select_entity(
self, constraint: str, *args: str | int | tuple[int, ...]
) -> tuple[int, int] | None:
q = f"SELECT id, hash FROM telethon_entities WHERE session_id=$1 AND {constraint}"
row = await self.db.fetchrow(q, self.session_id, *args)
if row is None:
return None
return row["id"], row["hash"]
async def get_entity_rows_by_phone(self, key: str | int) -> tuple[int, int] | None:
return await self._select_entity("phone=$2", str(key))
async def get_entity_rows_by_username(self, key: str) -> tuple[int, int] | None:
return await self._select_entity("username=$2", key)
async def get_entity_rows_by_name(self, key: str) -> tuple[int, int] | None:
return await self._select_entity("name=$2", key)
async def get_entity_rows_by_id(self, key: int, exact: bool = True) -> tuple[int, int] | None:
if exact:
return await self._select_entity("id=$2", key)
ids = (
utils.get_peer_id(PeerUser(key)),
utils.get_peer_id(PeerChat(key)),
utils.get_peer_id(PeerChannel(key)),
)
if self.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
return await self._select_entity("id=ANY($2)", ids)
else:
return await self._select_entity(f"id IN ($2, $3, $4)", *ids)
-24
View File
@@ -1,24 +0,0 @@
from mautrix.util.async_db import UpgradeTable
upgrade_table = UpgradeTable()
from . import (
v01_initial_revision,
v02_sponsored_events,
v03_reactions,
v04_disappearing_messages,
v05_channel_ghosts,
v06_puppet_avatar_url,
v07_puppet_phone_number,
v08_portal_first_event,
v09_puppet_username_index,
v10_more_backfill_fields,
v11_backfill_queue,
v12_message_sender,
v13_multiple_reactions,
v14_puppet_custom_mxid_index,
v15_backfill_anchor_id,
v16_backfill_type,
v17_message_find_recent,
v18_puppet_contact_info_set,
)
@@ -1,242 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection, Scheme
latest_version = 18
async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
await conn.execute(
"""CREATE TABLE "user" (
mxid TEXT PRIMARY KEY,
tgid BIGINT UNIQUE,
tg_username TEXT,
tg_phone TEXT,
is_bot BOOLEAN NOT NULL DEFAULT false,
is_premium BOOLEAN NOT NULL DEFAULT false,
saved_contacts INTEGER NOT NULL DEFAULT 0
)"""
)
await conn.execute(
"""CREATE TABLE portal (
tgid BIGINT,
tg_receiver BIGINT,
peer_type TEXT NOT NULL,
mxid TEXT UNIQUE,
avatar_url TEXT,
encrypted BOOLEAN NOT NULL DEFAULT false,
username TEXT,
title TEXT,
about TEXT,
photo_id TEXT,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
megagroup BOOLEAN,
config jsonb,
first_event_id TEXT,
next_batch_id TEXT,
base_insertion_id TEXT,
sponsored_event_id TEXT,
sponsored_event_ts BIGINT,
sponsored_msg_random_id bytea,
PRIMARY KEY (tgid, tg_receiver)
)"""
)
await conn.execute(
"""CREATE TABLE message (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
tgid BIGINT,
tg_space BIGINT,
edit_index INTEGER,
redacted BOOLEAN NOT NULL DEFAULT false,
content_hash bytea,
sender_mxid TEXT,
sender BIGINT,
PRIMARY KEY (tgid, tg_space, edit_index),
UNIQUE (mxid, mx_room, tg_space)
)"""
)
await conn.execute("CREATE INDEX message_mx_room_and_tgid_idx ON message(mx_room, tgid DESC)")
await conn.execute(
"""CREATE TABLE reaction (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
msg_mxid TEXT NOT NULL,
tg_sender BIGINT,
reaction TEXT NOT NULL,
PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction),
UNIQUE (mxid, mx_room)
)"""
)
await conn.execute(
"""CREATE TABLE disappearing_message (
room_id TEXT,
event_id TEXT,
expiration_seconds BIGINT,
expiration_ts BIGINT,
PRIMARY KEY (room_id, event_id)
)"""
)
await conn.execute(
"""CREATE TABLE puppet (
id BIGINT PRIMARY KEY,
is_registered BOOLEAN NOT NULL DEFAULT false,
displayname TEXT,
displayname_source BIGINT,
displayname_contact BOOLEAN NOT NULL DEFAULT true,
displayname_quality INTEGER NOT NULL DEFAULT 0,
disable_updates BOOLEAN NOT NULL DEFAULT false,
username TEXT,
phone TEXT,
photo_id TEXT,
avatar_url TEXT,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
contact_info_set BOOLEAN NOT NULL DEFAULT false,
is_bot BOOLEAN,
is_channel BOOLEAN NOT NULL DEFAULT false,
is_premium BOOLEAN NOT NULL DEFAULT false,
access_token TEXT,
custom_mxid TEXT,
next_batch TEXT,
base_url TEXT
)"""
)
await conn.execute("CREATE INDEX puppet_username_idx ON puppet(LOWER(username))")
await conn.execute("CREATE INDEX puppet_custom_mxid_idx ON puppet(custom_mxid)")
await conn.execute(
"""CREATE TABLE telegram_file (
id TEXT PRIMARY KEY,
mxc TEXT NOT NULL,
mime_type TEXT,
was_converted BOOLEAN NOT NULL DEFAULT false,
timestamp BIGINT NOT NULL DEFAULT 0,
size BIGINT,
width INTEGER,
height INTEGER,
thumbnail TEXT,
decryption_info jsonb,
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
ON UPDATE CASCADE ON DELETE SET NULL
)"""
)
await conn.execute("CREATE INDEX telegram_file_mxc_idx ON telegram_file(mxc)")
await conn.execute(
"""CREATE TABLE bot_chat (
id BIGINT PRIMARY KEY,
type TEXT NOT NULL
)"""
)
await conn.execute(
"""CREATE TABLE user_portal (
"user" BIGINT,
portal BIGINT,
portal_receiver BIGINT,
PRIMARY KEY ("user", portal, portal_receiver),
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal, portal_receiver) REFERENCES portal(tgid, tg_receiver)
ON DELETE CASCADE ON UPDATE CASCADE
)"""
)
await conn.execute(
"""CREATE TABLE contact (
"user" BIGINT,
contact BIGINT,
PRIMARY KEY ("user", contact),
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (contact) REFERENCES puppet(id) ON DELETE CASCADE ON UPDATE CASCADE
)"""
)
await conn.execute(
"""CREATE TABLE telethon_sessions (
session_id TEXT PRIMARY KEY,
dc_id INTEGER,
server_address TEXT,
port INTEGER,
auth_key bytea
)"""
)
await conn.execute(
"""CREATE TABLE telethon_entities (
session_id TEXT,
id BIGINT,
hash BIGINT NOT NULL,
username TEXT,
phone TEXT,
name TEXT,
PRIMARY KEY (session_id, id)
)"""
)
await conn.execute(
"""CREATE TABLE telethon_sent_files (
session_id TEXT,
md5_digest bytea,
file_size INTEGER,
type INTEGER,
id BIGINT,
hash BIGINT,
PRIMARY KEY (session_id, md5_digest, file_size, type)
)"""
)
await conn.execute(
"""CREATE TABLE telethon_update_state (
session_id TEXT,
entity_id BIGINT,
pts BIGINT,
qts BIGINT,
date BIGINT,
seq BIGINT,
unread_count INTEGER,
PRIMARY KEY (session_id, entity_id)
)"""
)
gen = ""
if scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
gen = "GENERATED ALWAYS AS IDENTITY"
await conn.execute(
f"""
CREATE TABLE backfill_queue (
queue_id INTEGER PRIMARY KEY {gen},
user_mxid TEXT,
priority INTEGER NOT NULL,
type TEXT NOT NULL,
portal_tgid BIGINT,
portal_tg_receiver BIGINT,
anchor_msg_id BIGINT,
extra_data jsonb,
messages_per_batch INTEGER NOT NULL,
post_batch_delay INTEGER NOT NULL,
max_batches INTEGER NOT NULL,
dispatch_time TIMESTAMP,
completed_at TIMESTAMP,
cooldown_timeout TIMESTAMP,
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal_tgid, portal_tg_receiver)
REFERENCES portal(tgid, tg_receiver) ON DELETE CASCADE
)
"""
)
return latest_version
@@ -1,181 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from mautrix.util.async_db import Connection, Scheme
from . import upgrade_table
from .v00_latest_revision import create_latest_tables, latest_version
legacy_version_query = "SELECT version_num FROM alembic_version"
last_legacy_version = "bfc0a39bfe02"
async def first_upgrade_target(conn: Connection, scheme: Scheme) -> int:
is_legacy = await conn.table_exists("alembic_version")
# If it's a legacy db, the upgrade process will go to v1 and run each migration up to latest.
# If it's a new db, we'll create the latest tables directly (see create_latest_tables call).
return 1 if is_legacy else latest_version
@upgrade_table.register(description="Initial asyncpg revision", upgrades_to=first_upgrade_target)
async def upgrade_v1(conn: Connection, scheme: Scheme) -> int:
is_legacy = await conn.table_exists("alembic_version")
if is_legacy:
await migrate_legacy_to_v1(conn, scheme)
return 1
else:
return await create_latest_tables(conn, scheme)
async def drop_constraints(conn: Connection, table: str, contype: str) -> None:
q = (
"SELECT conname FROM pg_constraint con INNER JOIN pg_class rel ON rel.oid=con.conrelid "
f"WHERE rel.relname='{table}' AND contype='{contype}'"
)
names = [row["conname"] for row in await conn.fetch(q)]
drops = ", ".join(f"DROP CONSTRAINT {name}" for name in names)
await conn.execute(f"ALTER TABLE {table} {drops}")
async def migrate_legacy_to_v1(conn: Connection, scheme: Scheme) -> None:
legacy_version = await conn.fetchval(legacy_version_query)
if legacy_version != last_legacy_version:
raise RuntimeError(
"Legacy database is not on last version. "
"Please upgrade the old database with alembic or drop it completely first."
)
if scheme != Scheme.SQLITE:
await drop_constraints(conn, "contact", contype="f")
await conn.execute(
"""
ALTER TABLE contact
ADD CONSTRAINT contact_user_fkey FOREIGN KEY (contact) REFERENCES puppet(id)
ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT contact_contact_fkey FOREIGN KEY ("user") REFERENCES "user"(tgid)
ON DELETE CASCADE ON UPDATE CASCADE
"""
)
await drop_constraints(conn, "telethon_sessions", contype="p")
await conn.execute(
"""
ALTER TABLE telethon_sessions
ADD CONSTRAINT telethon_sessions_pkey PRIMARY KEY (session_id)
"""
)
await drop_constraints(conn, "telegram_file", contype="f")
await conn.execute(
"""
ALTER TABLE telegram_file
ADD CONSTRAINT fk_file_thumbnail
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
ON UPDATE CASCADE ON DELETE SET NULL
"""
)
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP IDENTITY IF EXISTS")
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP DEFAULT")
await conn.execute("DROP SEQUENCE IF EXISTS puppet_id_seq")
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP IDENTITY IF EXISTS")
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP DEFAULT")
await conn.execute("DROP SEQUENCE IF EXISTS bot_chat_id_seq")
await conn.execute("ALTER TABLE portal ALTER COLUMN config TYPE jsonb USING config::jsonb")
await conn.execute(
"ALTER TABLE telegram_file ALTER COLUMN decryption_info TYPE jsonb "
"USING decryption_info::jsonb"
)
await varchar_to_text(conn)
else:
await conn.execute(
"""CREATE TABLE telethon_sessions_new (
session_id TEXT PRIMARY KEY,
dc_id INTEGER,
server_address TEXT,
port INTEGER,
auth_key bytea
)"""
)
await conn.execute(
"""
INSERT INTO telethon_sessions_new (session_id, dc_id, server_address, port, auth_key)
SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions
"""
)
await conn.execute("DROP TABLE telethon_sessions")
await conn.execute("ALTER TABLE telethon_sessions_new RENAME TO telethon_sessions")
await update_state_store(conn, scheme)
await conn.execute('ALTER TABLE "user" ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false')
await conn.execute("ALTER TABLE puppet RENAME COLUMN matrix_registered TO is_registered")
await conn.execute("DROP TABLE telethon_version")
await conn.execute("DROP TABLE alembic_version")
async def update_state_store(conn: Connection, scheme: Scheme) -> None:
# The Matrix state store already has more or less the correct schema, so set the version
await conn.execute("CREATE TABLE mx_version (version INTEGER PRIMARY KEY)")
await conn.execute("INSERT INTO mx_version (version) VALUES (2)")
await conn.execute("UPDATE mx_user_profile SET membership='LEAVE' WHERE membership='LEFT'")
if scheme != Scheme.SQLITE:
# Also add the membership type on postgres
await conn.execute(
"CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock')"
)
await conn.execute(
"ALTER TABLE mx_user_profile ALTER COLUMN membership TYPE membership "
"USING LOWER(membership)::membership"
)
else:
# On SQLite there's no custom type, but we still want to lowercase everything
await conn.execute("UPDATE mx_user_profile SET membership=LOWER(membership)")
async def varchar_to_text(conn: Connection) -> None:
columns_to_adjust = {
"user": ("mxid", "tg_username", "tg_phone"),
"portal": (
"peer_type",
"mxid",
"username",
"title",
"about",
"photo_id",
"avatar_url",
"config",
),
"message": ("mxid", "mx_room"),
"puppet": (
"displayname",
"username",
"photo_id",
"access_token",
"custom_mxid",
"next_batch",
"base_url",
),
"bot_chat": ("type",),
"telegram_file": ("id", "mxc", "mime_type", "thumbnail"),
# Phone is a bigint in the old schema, which is safe, but we don't do math on it,
# so let's change it to a string
"telethon_entities": ("session_id", "username", "name", "phone"),
"telethon_sent_files": ("session_id",),
"telethon_sessions": ("session_id", "server_address"),
"telethon_update_state": ("session_id",),
"mx_room_state": ("room_id",),
"mx_user_profile": ("room_id", "user_id", "displayname", "avatar_url"),
}
for table, columns in columns_to_adjust.items():
for column in columns:
await conn.execute(f'ALTER TABLE "{table}" ALTER COLUMN {column} TYPE TEXT')
@@ -1,25 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add column to store sponsored message event ID in channels")
async def upgrade_v2(conn: Connection) -> None:
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_event_id TEXT")
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_event_ts BIGINT")
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_msg_random_id bytea")
@@ -1,39 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add support for reactions")
async def upgrade_v3(conn: Connection, scheme: str) -> None:
await conn.execute(
"""CREATE TABLE reaction (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
msg_mxid TEXT NOT NULL,
tg_sender BIGINT,
reaction TEXT NOT NULL,
PRIMARY KEY (msg_mxid, mx_room, tg_sender),
UNIQUE (mxid, mx_room)
)"""
)
if scheme != "sqlite":
await conn.execute("DELETE FROM message WHERE mxid IS NULL OR mx_room IS NULL")
await conn.execute("ALTER TABLE message ALTER COLUMN mxid SET NOT NULL")
await conn.execute("ALTER TABLE message ALTER COLUMN mx_room SET NOT NULL")
await conn.execute("ALTER TABLE message ADD COLUMN content_hash bytea")
@@ -1,32 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add support for disappearing messages")
async def upgrade_v4(conn: Connection) -> None:
await conn.execute(
"""CREATE TABLE disappearing_message (
room_id TEXT,
event_id TEXT,
expiration_seconds BIGINT,
expiration_ts BIGINT,
PRIMARY KEY (room_id, event_id)
)"""
)
@@ -1,25 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection, Scheme
from . import upgrade_table
@upgrade_table.register(description="Add separate ghost users for channel senders")
async def upgrade_v5(conn: Connection, scheme: str) -> None:
await conn.execute("ALTER TABLE puppet ADD COLUMN is_channel BOOLEAN NOT NULL DEFAULT false")
if scheme == Scheme.POSTGRES:
await conn.execute("ALTER TABLE puppet ALTER COLUMN is_channel DROP DEFAULT")
@@ -1,31 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Store avatar mxc URI in puppet table")
async def upgrade_v6(conn: Connection) -> None:
await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_url TEXT")
await conn.execute("ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("UPDATE puppet SET name_set=true WHERE displayname<>''")
await conn.execute("UPDATE puppet SET avatar_set=true WHERE photo_id<>''")
await conn.execute("ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("UPDATE portal SET name_set=true WHERE title<>'' AND mxid<>''")
await conn.execute("UPDATE portal SET avatar_set=true WHERE photo_id<>'' AND mxid<>''")
@@ -1,23 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Store phone number in puppet table")
async def upgrade_v7(conn: Connection) -> None:
await conn.execute("ALTER TABLE puppet ADD COLUMN phone TEXT")
@@ -1,24 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Track first event ID in portals for infinite backfilling")
async def upgrade_v8(conn: Connection) -> None:
await conn.execute("ALTER TABLE portal ADD COLUMN first_event_id TEXT")
await conn.execute("ALTER TABLE portal ADD COLUMN next_batch_id TEXT")
@@ -1,23 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add index to puppet username column")
async def upgrade_v9(conn: Connection) -> None:
await conn.execute("CREATE INDEX puppet_username_idx ON puppet(LOWER(username))")
@@ -1,23 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add more portal columns related to infinite backfill")
async def upgrade_v10(conn: Connection) -> None:
await conn.execute("ALTER TABLE portal ADD COLUMN base_insertion_id TEXT")
@@ -1,45 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan, Sumner Evans
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection, Scheme
from . import upgrade_table
@upgrade_table.register(description="Add the backfill queue table")
async def upgrade_v11(conn: Connection, scheme: Scheme) -> None:
gen = ""
if scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
gen = "GENERATED ALWAYS AS IDENTITY"
await conn.execute(
f"""
CREATE TABLE backfill_queue (
queue_id INTEGER PRIMARY KEY {gen},
user_mxid TEXT,
priority INTEGER NOT NULL,
portal_tgid BIGINT,
portal_tg_receiver BIGINT,
messages_per_batch INTEGER NOT NULL,
post_batch_delay INTEGER NOT NULL,
max_batches INTEGER NOT NULL,
dispatch_time TIMESTAMP,
completed_at TIMESTAMP,
cooldown_timeout TIMESTAMP,
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (portal_tgid, portal_tg_receiver)
REFERENCES portal(tgid, tg_receiver) ON DELETE CASCADE
)
"""
)
@@ -1,24 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Store sender in message table")
async def upgrade_v12(conn: Connection) -> None:
await conn.execute("ALTER TABLE message ADD COLUMN sender_mxid TEXT")
await conn.execute("ALTER TABLE message ADD COLUMN sender BIGINT")
@@ -1,54 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection, Scheme
from . import upgrade_table
@upgrade_table.register(description="Allow multiple reactions from the same user")
async def upgrade_v13(conn: Connection, scheme: Scheme) -> None:
await conn.execute("CREATE INDEX telegram_file_mxc_idx ON telegram_file(mxc)")
await conn.execute('ALTER TABLE "user" ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false')
await conn.execute("ALTER TABLE puppet ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false")
if scheme == Scheme.POSTGRES:
await conn.execute(
"""
ALTER TABLE reaction
DROP CONSTRAINT reaction_pkey,
ADD CONSTRAINT reaction_pkey PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction)
"""
)
else:
await conn.execute(
"""CREATE TABLE new_reaction (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
msg_mxid TEXT NOT NULL,
tg_sender BIGINT,
reaction TEXT NOT NULL,
PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction),
UNIQUE (mxid, mx_room)
)"""
)
await conn.execute(
"""
INSERT INTO new_reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
SELECT mxid, mx_room, msg_mxid, tg_sender, reaction FROM reaction
"""
)
await conn.execute("DROP TABLE reaction")
await conn.execute("ALTER TABLE new_reaction RENAME TO reaction")
@@ -1,23 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add index to puppet custom_mxid column")
async def upgrade_v14(conn: Connection) -> None:
await conn.execute("CREATE INDEX IF NOT EXISTS puppet_custom_mxid_idx ON puppet(custom_mxid)")
@@ -1,23 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Store lowest message ID in backfill queue")
async def upgrade_v15(conn: Connection) -> None:
await conn.execute("ALTER TABLE backfill_queue ADD COLUMN anchor_msg_id BIGINT")
@@ -1,28 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection, Scheme
from . import upgrade_table
@upgrade_table.register(description="Add type for backfill queue items")
async def upgrade_v16(conn: Connection, scheme: Scheme) -> None:
await conn.execute(
"ALTER TABLE backfill_queue ADD COLUMN type TEXT NOT NULL DEFAULT 'historical'"
)
await conn.execute("ALTER TABLE backfill_queue ADD COLUMN extra_data jsonb")
if scheme != Scheme.SQLITE:
await conn.execute("ALTER TABLE backfill_queue ALTER COLUMN type DROP DEFAULT")
@@ -1,25 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add index for Message.find_recent")
async def upgrade_v17(conn: Connection) -> None:
await conn.execute(
"CREATE INDEX IF NOT EXISTS message_mx_room_and_tgid_idx ON message(mx_room, tgid DESC)"
)
@@ -1,25 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add contact_info_set column to puppet table")
async def upgrade_v18(conn: Connection) -> None:
await conn.execute(
"ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false"
)
-158
View File
@@ -1,158 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar, Iterable
from asyncpg import Record
from attr import dataclass
from mautrix.types import UserID
from mautrix.util.async_db import Connection, Database, Scheme
from ..types import TelegramID
from .backfill_queue import Backfill
fake_db = Database.create("") if TYPE_CHECKING else None
@dataclass
class User:
db: ClassVar[Database] = fake_db
mxid: UserID
tgid: TelegramID | None
tg_username: str | None
tg_phone: str | None
is_bot: bool
is_premium: bool
saved_contacts: int
@classmethod
def _from_row(cls, row: Record | None) -> User | None:
if row is None:
return None
return cls(**row)
columns: ClassVar[str] = ", ".join(
("mxid", "tgid", "tg_username", "tg_phone", "is_bot", "is_premium", "saved_contacts")
)
@classmethod
async def get_by_tgid(cls, tgid: TelegramID) -> User | None:
q = f'SELECT {cls.columns} FROM "user" WHERE tgid=$1'
return cls._from_row(await cls.db.fetchrow(q, tgid))
@classmethod
async def get_by_mxid(cls, mxid: UserID) -> User | None:
q = f'SELECT {cls.columns} FROM "user" WHERE mxid=$1'
return cls._from_row(await cls.db.fetchrow(q, mxid))
@classmethod
async def find_by_username(cls, username: str) -> User | None:
q = f'SELECT {cls.columns} FROM "user" WHERE lower(tg_username)=$1'
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
@classmethod
async def all_with_tgid(cls) -> list[User]:
q = f'SELECT {cls.columns} FROM "user" WHERE tgid IS NOT NULL'
return [cls._from_row(row) for row in await cls.db.fetch(q)]
async def delete(self) -> None:
await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid)
async def remove_tgid(self) -> None:
async with self.db.acquire() as conn, conn.transaction():
if self.tgid:
await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid)
await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid)
await Backfill.delete_all(self.mxid, conn=conn)
self.tgid = None
self.tg_username = None
self.tg_phone = None
self.is_bot = False
self.is_premium = False
self.saved_contacts = 0
await self.save(conn=conn)
@property
def _values(self):
return (
self.mxid,
self.tgid,
self.tg_username,
self.tg_phone,
self.is_bot,
self.is_premium,
self.saved_contacts,
)
async def save(self, conn: Connection | None = None) -> None:
q = """
UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, is_premium=$6,
saved_contacts=$7
WHERE mxid=$1
"""
await (conn or self.db).execute(q, *self._values)
async def insert(self) -> None:
q = """
INSERT INTO "user" (mxid, tgid, tg_username, tg_phone, is_bot, is_premium, saved_contacts)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"""
await self.db.execute(q, *self._values)
async def get_contacts(self) -> list[TelegramID]:
rows = await self.db.fetch('SELECT contact FROM contact WHERE "user"=$1', self.tgid)
return [TelegramID(row["contact"]) for row in rows]
async def set_contacts(self, puppets: Iterable[TelegramID]) -> None:
columns = ["user", "contact"]
records = [(self.tgid, puppet_id) for puppet_id in puppets]
async with self.db.acquire() as conn, conn.transaction():
await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid)
if self.db.scheme == Scheme.POSTGRES:
await conn.copy_records_to_table("contact", records=records, columns=columns)
else:
q = 'INSERT INTO contact ("user", contact) VALUES ($1, $2)'
await conn.executemany(q, records)
async def get_portals(self) -> list[tuple[TelegramID, TelegramID]]:
q = 'SELECT portal, portal_receiver FROM user_portal WHERE "user"=$1'
rows = await self.db.fetch(q, self.tgid)
return [(TelegramID(row["portal"]), TelegramID(row["portal_receiver"])) for row in rows]
async def set_portals(self, portals: Iterable[tuple[TelegramID, TelegramID]]) -> None:
columns = ["user", "portal", "portal_receiver"]
records = [(self.tgid, tgid, tg_receiver) for tgid, tg_receiver in portals]
async with self.db.acquire() as conn, conn.transaction():
await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid)
if self.db.scheme == Scheme.POSTGRES:
await conn.copy_records_to_table("user_portal", records=records, columns=columns)
else:
q = 'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3)'
await conn.executemany(q, records)
async def register_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
q = (
'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3) '
'ON CONFLICT ("user", portal, portal_receiver) DO NOTHING'
)
await self.db.execute(q, self.tgid, tgid, tg_receiver)
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
q = 'DELETE FROM user_portal WHERE "user"=$1 AND portal=$2 AND portal_receiver=$3'
await self.db.execute(q, self.tgid, tgid, tg_receiver)
-688
View File
@@ -1,688 +0,0 @@
# Homeserver details
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://example.com
# The domain of the homeserver (for MXIDs, etc).
domain: example.com
# Whether or not to verify the SSL certificate of the homeserver.
# Only applies if address starts with https://
verify_ssl: true
# What software is the homeserver running?
# Standard Matrix homeservers like Synapse, Dendrite and Conduit should just use "standard" here.
software: standard
# Number of retries for all HTTP requests if the homeserver isn't reachable.
http_retry_count: 4
# The URL to push real-time bridge status to.
# If set, the bridge will make POST requests to this URL whenever a user's Telegram connection state changes.
# The bridge will use the appservice as_token to authorize requests.
status_endpoint: null
# Endpoint for reporting per-message status.
message_send_checkpoint_endpoint: null
# Whether asynchronous uploads via MSC2246 should be enabled for media.
# Requires a media repo that supports MSC2246.
async_media: false
# Application service host/registration related details
# Changing these values requires regeneration of the registration.
appservice:
# The address that the homeserver can use to connect to this appservice.
address: http://localhost:29317
# When using https:// the TLS certificate and key files for the address.
tls_cert: false
tls_key: false
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 29317
# The maximum body size of appservice API requests (from the homeserver) in mebibytes
# Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
max_body_size: 1
# The full URI to the database. SQLite and Postgres are supported.
# Format examples:
# SQLite: sqlite:filename.db
# Postgres: postgres://username:password@hostname/dbname
database: postgres://username:password@hostname/dbname
# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
# https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.pool.create_pool
# https://docs.python.org/3/library/sqlite3.html#sqlite3.connect
# For sqlite, min_size is used as the connection thread pool size and max_size is ignored.
# Additionally, SQLite supports init_commands as an array of SQL queries to run on connect (e.g. to set PRAGMAs).
database_opts:
min_size: 1
max_size: 10
# Public part of web server for out-of-Matrix interaction with the bridge.
# Used for things like login if the user wants to make sure the 2FA password isn't stored in
# the HS database.
public:
# Whether or not the public-facing endpoints should be enabled.
enabled: false
# The prefix to use in the public-facing endpoints.
prefix: /public
# The base URL where the public-facing endpoints are available. The prefix is not added
# implicitly.
external: https://example.com/public
# Provisioning API part of the web server for automated portal creation and fetching information.
# Used by things like mautrix-manager (https://github.com/tulir/mautrix-manager).
provisioning:
# Whether or not the provisioning API should be enabled.
enabled: true
# The prefix to use in the provisioning API endpoints.
prefix: /_matrix/provision
# The shared secret to authorize users of the API.
# Set to "generate" to generate and save a new token.
shared_secret: generate
# The unique ID of this appservice.
id: telegram
# Username of the appservice bot.
bot_username: telegrambot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
bot_displayname: Telegram bridge bot
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
# Whether or not to receive ephemeral events via appservice transactions.
# Requires MSC2409 support (i.e. Synapse 1.22+).
# You should disable bridge -> sync_with_custom_puppets when this is enabled.
ephemeral_events: true
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration"
hs_token: "This value is generated when generating the registration"
# Prometheus telemetry config. Requires prometheus-client to be installed.
metrics:
enabled: false
listen_port: 8000
# Manhole config.
manhole:
# Whether or not opening the manhole is allowed.
enabled: false
# The path for the unix socket.
path: /var/tmp/mautrix-telegram.manhole
# The list of UIDs who can be added to the whitelist.
# If empty, any UIDs can be specified in the open-manhole command.
whitelist:
- 0
# Bridge config
bridge:
# Localpart template of MXIDs for Telegram users.
# {userid} is replaced with the user ID of the Telegram user.
username_template: "telegram_{userid}"
# Localpart template of room aliases for Telegram portal rooms.
# {groupname} is replaced with the name part of the public channel/group invite link ( https://t.me/{} )
alias_template: "telegram_{groupname}"
# Displayname template for Telegram users.
# {displayname} is replaced with the display name of the Telegram user.
displayname_template: "{displayname} (Telegram)"
# Set the preferred order of user identifiers which to use in the Matrix puppet display name.
# In the (hopefully unlikely) scenario that none of the given keys are found, the numeric user
# ID is used.
#
# If the bridge is working properly, a phone number or an username should always be known, but
# the other one can very well be empty.
#
# Valid keys:
# "full name" (First and/or last name)
# "full name reversed" (Last and/or first name)
# "first name"
# "last name"
# "username"
# "phone number"
displayname_preference:
- full name
- username
- phone number
# Maximum length of displayname
displayname_max_length: 100
# Remove avatars from Telegram ghost users when removed on Telegram. This is disabled by default
# as there's no way to determine whether an avatar is removed or just hidden from some users. If
# you're on a single-user instance, this should be safe to enable.
allow_avatar_remove: false
# Should contact names and profile pictures be allowed?
# This is only safe to enable on single-user instances.
allow_contact_info: false
# Maximum number of members to sync per portal when starting up. Other members will be
# synced when they send messages. The maximum is 10000, after which the Telegram server
# will not send any more members.
# -1 means no limit (which means it's limited to 10000 by the server)
max_initial_member_sync: 100
# Maximum number of participants in chats to bridge. Only applies when the portal is being created.
# If there are more members when trying to create a room, the room creation will be cancelled.
# -1 means no limit (which means all chats can be bridged)
max_member_count: -1
# Whether or not to sync the member list in channels.
# If no channel admins have logged into the bridge, the bridge won't be able to sync the member
# list regardless of this setting.
sync_channel_members: false
# Whether or not to skip deleted members when syncing members.
skip_deleted_members: true
# Whether or not to automatically synchronize contacts and chats of Matrix users logged into
# their Telegram account at startup.
startup_sync: false
# Number of most recently active dialogs to check when syncing chats.
# Set to 0 to remove limit.
sync_update_limit: 0
# Number of most recently active dialogs to create portals for when syncing chats.
# Set to 0 to remove limit.
sync_create_limit: 15
# Should all chats be scheduled to be created later?
# This is best used in combination with MSC2716 infinite backfill.
sync_deferred_create_all: false
# Whether or not to sync and create portals for direct chats at startup.
sync_direct_chats: false
# The maximum number of simultaneous Telegram deletions to handle.
# A large number of simultaneous redactions could put strain on your homeserver.
max_telegram_delete: 10
# Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames)
# at startup and when creating a bridge.
sync_matrix_state: true
# Allow logging in within Matrix. If false, users can only log in using login-qr or the
# out-of-Matrix login website (see appservice.public config section)
allow_matrix_login: true
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
public_portals: false
# Whether or not to use /sync to get presence, read receipts and typing notifications
# when double puppeting is enabled
sync_with_custom_puppets: false
# Whether or not to update the m.direct account data event when double puppeting is enabled.
# Note that updating the m.direct event is not atomic (except with mautrix-asmux)
# and is therefore prone to race conditions.
sync_direct_chat_list: false
# Servers to always allow double puppeting from
double_puppet_server_map:
example.com: https://example.com
# Allow using double puppeting from any server with a valid client .well-known file.
double_puppet_allow_discovery: false
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
#
# If set, custom puppets will be enabled automatically for local users
# instead of users having to find an access token and run `login-matrix`
# manually.
# If using this for other servers than the bridge's server,
# you must also set the URL in the double_puppet_server_map.
login_shared_secret_map:
example.com: foobar
# Set to false to disable link previews in messages sent to Telegram.
telegram_link_preview: true
# Whether or not the !tg join command should do a HTTP request
# to resolve redirects in invite links.
invite_link_resolve: false
# Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552.
# This is currently not supported in most clients.
caption_in_message: false
# Maximum size of image in megabytes before sending to Telegram as a document.
image_as_file_size: 10
# Maximum number of pixels in an image before sending to Telegram as a document. Defaults to 4096x4096 = 16777216.
image_as_file_pixels: 16777216
# Enable experimental parallel file transfer, which makes uploads/downloads much faster by
# streaming from/to Matrix and using many connections for Telegram.
# Note that generating HQ thumbnails for videos is not possible with streamed transfers.
# This option uses internal Telethon implementation details and may break with minor updates.
parallel_file_transfer: false
# Whether or not created rooms should have federation enabled.
# If false, created portal rooms will never be federated.
federate_rooms: true
# Should the bridge send all unicode reactions as custom emoji reactions to Telegram?
# By default, the bridge only uses custom emojis for unicode emojis that aren't allowed in reactions.
always_custom_emoji_reaction: false
# Settings for converting animated stickers.
animated_sticker:
# Format to which animated stickers should be converted.
# disable - No conversion, send as-is (gzipped lottie)
# png - converts to non-animated png (fastest),
# gif - converts to animated gif
# webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support
# webp - converts to animated webp, requires ffmpeg executable with webp codec/container support
target: gif
# Should video stickers be converted to the specified format as well?
convert_from_webm: false
# Arguments for converter. All converters take width and height.
args:
width: 256
height: 256
fps: 25 # only for webm, webp and gif (2, 5, 10, 20 or 25 recommended)
# Settings for converting animated emoji.
# Same as animated_sticker, but webm is not supported as the target
# (because inline images can only contain images, not videos).
animated_emoji:
target: webp
args:
width: 64
height: 64
fps: 25
# End-to-bridge encryption support options.
#
# See https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html for more info.
encryption:
# Allow encryption, work in group chat rooms with e2ee enabled
allow: false
# Default to encryption, force-enable encryption in all portals the bridge creates
# This will cause the bridge bot to be in private chats for the encryption to work properly.
default: false
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data.
appservice: false
# Require encryption, drop any unencrypted messages.
require: false
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
# You must use a client that supports requesting keys from other users to use this feature.
allow_key_sharing: false
# Options for deleting megolm sessions from the bridge.
delete_keys:
# Beeper-specific: delete outbound sessions when hungryserv confirms
# that the user has uploaded the key to key backup.
delete_outbound_on_ack: false
# Don't store outbound sessions in the inbound table.
dont_store_outbound: false
# Ratchet megolm sessions forward after decrypting messages.
ratchet_on_decrypt: false
# Delete fully used keys (index >= max_messages) after decrypting messages.
delete_fully_used_on_decrypt: false
# Delete previous megolm sessions from same device when receiving a new one.
delete_prev_on_new_session: false
# Delete megolm sessions received from a device when the device is deleted.
delete_on_device_delete: false
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
periodically_delete_expired: false
# Delete inbound megolm sessions that don't have the received_at field used for
# automatic ratcheting and expired session deletion. This is meant as a migration
# to delete old keys prior to the bridge update.
delete_outdated_inbound: false
# What level of device verification should be required from users?
#
# Valid levels:
# unverified - Send keys to all device in the room.
# cross-signed-untrusted - Require valid cross-signing, but trust all cross-signing keys.
# cross-signed-tofu - Require valid cross-signing, trust cross-signing keys on first use (and reject changes).
# cross-signed-verified - Require valid cross-signing, plus a valid user signature from the bridge bot.
# Note that creating user signatures from the bridge bot is not currently possible.
# verified - Require manual per-device verification
# (currently only possible by modifying the `trust` column in the `crypto_device` database table).
verification_levels:
# Minimum level for which the bridge should send keys to when bridging messages from Telegram to Matrix.
receive: unverified
# Minimum level that the bridge should accept for incoming Matrix messages.
send: unverified
# Minimum level that the bridge should require for accepting key requests.
share: cross-signed-tofu
# Options for Megolm room key rotation. These options allow you to
# configure the m.room.encryption event content. See:
# https://spec.matrix.org/v1.3/client-server-api/#mroomencryption for
# more information about that event.
rotation:
# Enable custom Megolm room key rotation settings. Note that these
# settings will only apply to rooms created after this option is
# set.
enable_custom: false
# The maximum number of milliseconds a session should be used
# before changing it. The Matrix spec recommends 604800000 (a week)
# as the default.
milliseconds: 604800000
# The maximum number of messages that should be sent with a given a
# session before changing it. The Matrix spec recommends 100 as the
# default.
messages: 100
# Disable rotating keys when a user's devices change?
# You should not enable this option unless you understand all the implications.
disable_device_change_key_rotation: false
# Whether to explicitly set the avatar and room name for private chat portal rooms.
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
# If set to `always`, all DM rooms will have explicit names and avatars set.
# If set to `never`, DM rooms will never have names and avatars set.
private_chat_portal_meta: default
# Disable generating reply fallbacks? Some extremely bad clients still rely on them,
# but they're being phased out and will be completely removed in the future.
disable_reply_fallbacks: false
# Whether or not the bridge should send a read receipt from the bridge bot when a message has
# been sent to Telegram.
delivery_receipts: false
# Whether or not delivery errors should be reported as messages in the Matrix room.
delivery_error_reports: false
# Should errors in incoming message handling send a message to the Matrix room?
incoming_bridge_error_reports: false
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
message_status_events: false
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
# This field will automatically be changed back to false after it,
# except if the config file is not writable.
resend_bridge_info: false
# When using double puppeting, should muted chats be muted in Matrix?
mute_bridging: false
# When using double puppeting, should pinned chats be moved to a specific tag in Matrix?
# The favorites tag is `m.favourite`.
pinned_tag: null
# Same as above for archived chats, the low priority tag is `m.lowpriority`.
archive_tag: null
# Whether or not mute status and tags should only be bridged when the portal room is created.
tag_only_on_create: true
# Should leaving the room on Matrix make the user leave on Telegram?
bridge_matrix_leave: true
# Should the user be kicked out of all portals when logging out of the bridge?
kick_on_logout: true
# Should the "* user joined Telegram" notice always be marked as read automatically?
always_read_joined_telegram_notice: true
# Should the bridge auto-create a group chat on Telegram when a ghost is invited to a room?
# Requires the user to have sufficient power level and double puppeting enabled.
create_group_on_invite: true
# Settings for backfilling messages from Telegram.
backfill:
# Allow backfilling at all?
enable: true
# Use MSC2716 for backfilling?
#
# This requires a server with MSC2716 support, which is currently an experimental feature in Synapse.
# It can be enabled by setting experimental_features -> msc2716_enabled to true in homeserver.yaml.
msc2716: false
# Use double puppets for backfilling?
#
# If using MSC2716, the double puppets must be in the appservice's user ID namespace
# (because the bridge can't use the double puppet access token with batch sending).
#
# Even without MSC2716, bridging old messages with correct timestamps requires the double
# puppets to be in an appservice namespace, or the server to be modified to allow
# overriding timestamps anyway.
#
# Also note that adding users to the appservice namespace may have unexpected side effects,
# as described in https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method
double_puppet_backfill: false
# Whether or not to enable backfilling in normal groups.
# Normal groups have numerous technical problems in Telegram, and backfilling normal groups
# will likely cause problems if there are multiple Matrix users in the group.
normal_groups: false
# If a backfilled chat is older than this number of hours, mark it as read even if it's unread on Telegram.
# Set to -1 to let any chat be unread.
unread_hours_threshold: 720
# Forward backfilling limits. These apply to both MSC2716 and legacy backfill.
#
# Using a negative initial limit is not recommended, as it would try to backfill everything in a single batch.
# MSC2716 and the incremental settings are meant for backfilling everything incrementally rather than at once.
forward_limits:
# Number of messages to backfill immediately after creating a portal.
initial:
user: 50
normal_group: 100
supergroup: 10
channel: 10
# Number of messages to backfill when syncing chats.
sync:
user: 100
normal_group: 100
supergroup: 100
channel: 100
# Timeout for forward backfills in seconds. If you have a high limit, you'll have to increase this too.
forward_timeout: 900
# Settings for incremental backfill of history. These only apply when using MSC2716.
incremental:
# Maximum number of messages to backfill per batch.
messages_per_batch: 100
# The number of seconds to wait after backfilling the batch of messages.
post_batch_delay: 20
# The maximum number of batches to backfill per portal, split by the chat type.
# If set to -1, all messages in the chat will eventually be backfilled.
max_batches:
# Direct chats
user: -1
# Normal groups. Note that the normal_groups option above must be enabled
# for these to be backfilled.
normal_group: -1
# Supergroups
supergroup: 10
# Broadcast channels
channel: -1
# Overrides for base power levels.
initial_power_level_overrides:
user: {}
group: {}
# Whether to bridge Telegram bot messages as m.notices or m.texts.
bot_messages_as_notices: true
bridge_notices:
# Whether or not Matrix bot messages (type m.notice) should be bridged.
default: false
# List of user IDs for whom the previous flag is flipped.
# e.g. if bridge_notices.default is false, notices from other users will not be bridged, but
# notices from users listed here will be bridged.
exceptions: []
# An array of possible values for the $distinguisher variable in message formats.
# Each user gets one of the values here, based on a hash of their user ID.
# If the array is empty, the $distinguisher variable will also be empty.
relay_user_distinguishers: ["🟦", "🟣", "🟩", "⭕️", "🔶", "⬛️", "🔵", "🟢"]
# The formats to use when sending messages to Telegram via the relay bot.
# Text msgtypes (m.text, m.notice and m.emote) support HTML, media msgtypes don't.
#
# Available variables:
# $sender_displayname - The display name of the sender (e.g. Example User)
# $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
# $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
# $distinguisher - A random string from the options in the relay_user_distinguishers array.
# $message - The message content
message_formats:
m.text: "$distinguisher <b>$sender_displayname</b>: $message"
m.notice: "$distinguisher <b>$sender_displayname</b>: $message"
m.emote: "* $distinguisher <b>$sender_displayname</b> $message"
m.file: "$distinguisher <b>$sender_displayname</b> sent a file: $message"
m.image: "$distinguisher <b>$sender_displayname</b> sent an image: $message"
m.audio: "$distinguisher <b>$sender_displayname</b> sent an audio file: $message"
m.video: "$distinguisher <b>$sender_displayname</b> sent a video: $message"
m.location: "$distinguisher <b>$sender_displayname</b> sent a location: $message"
# Telegram doesn't have built-in emotes, this field specifies how m.emote's from authenticated
# users are sent to telegram. All fields in message_formats are supported. Additionally, the
# Telegram user info is available in the following variables:
# $displayname - Telegram displayname
# $username - Telegram username (may not exist)
# $mention - Telegram @username or displayname mention (depending on which exists)
emote_format: "* $mention $formatted_body"
# The formats to use when sending state events to Telegram via the relay bot.
#
# Variables from `message_formats` that have the `sender_` prefix are available without the prefix.
# In name_change events, `$prev_displayname` is the previous displayname.
#
# Set format to an empty string to disable the messages for that event.
state_event_formats:
join: "$distinguisher <b>$displayname</b> joined the room."
leave: "$distinguisher <b>$displayname</b> left the room."
name_change: "$distinguisher <b>$prev_displayname</b> changed their name to $distinguisher <b>$displayname</b>"
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
# `filter-mode` management commands.
#
# An empty blacklist will essentially disable the filter.
filter:
# Filter mode to use. Either "blacklist" or "whitelist".
# If the mode is "blacklist", the listed chats will never be bridged.
# If the mode is "whitelist", only the listed chats can be bridged.
mode: blacklist
# The list of group/channel IDs to filter.
list: []
# How to handle direct chats:
# If users is "null", direct chats will follow the previous settings.
# If users is "true", direct chats will always be bridged.
# If users is "false", direct chats will never be bridged.
users: true
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg"
# Messages sent upon joining a management room.
# Markdown is supported. The defaults are listed below.
management_room_text:
# Sent when joining a room.
welcome: "Hello, I'm a Telegram bridge bot."
# Sent when joining a management room and the user is already logged in.
welcome_connected: "Use `help` for help."
# Sent when joining a management room and the user is not logged in.
welcome_unconnected: "Use `help` for help or `login` to log in."
# Optional extra text sent when joining a management room.
additional_help: ""
# Send each message separately (for readability in some clients)
management_room_multiple_messages: false
# Permissions for using the bridge.
# Permitted values:
# relaybot - Only use the bridge via the relaybot, no access to commands.
# user - Relaybot level + access to commands to create bridges.
# puppeting - User level + logging in with a Telegram account.
# full - Full access to use the bridge, i.e. previous levels + Matrix login.
# admin - Full access to use the bridge and some extra administration commands.
# Permitted keys:
# * - All Matrix users
# domain - All users on that homeserver
# mxid - Specific user
permissions:
"*": "relaybot"
"public.example.com": "user"
"example.com": "full"
"@admin:example.com": "admin"
# Options related to the message relay Telegram bot.
relaybot:
private_chat:
# List of users to invite to the portal when someone starts a private chat with the bot.
# If empty, private chats with the bot won't create a portal.
invite: []
# Whether or not to bridge state change messages in relaybot private chats.
state_changes: true
# When private_chat_invite is empty, this message is sent to users /starting the
# relaybot. Telegram's "markdown" is supported.
message: This is a Matrix bridge relaybot and does not support direct chats
# List of users to invite to all group chat portals created by the bridge.
group_chat_invite: []
# Whether or not the relaybot should not bridge events in unbridged group chats.
# If false, portals will be created when the relaybot receives messages, just like normal
# users. This behavior is usually not desirable, as it interferes with manually bridging
# the chat to another room.
ignore_unbridged_group_chat: true
# Whether or not to allow creating portals from Telegram.
authless_portals: true
# Whether or not to allow Telegram group admins to use the bot commands.
whitelist_group_admins: true
# Whether or not to ignore incoming events sent by the relay bot.
ignore_own_incoming_events: true
# List of usernames/user IDs who are also allowed to use the bot commands.
whitelist:
- myusername
- 12345678
# Telegram config
telegram:
# Get your own API keys at https://my.telegram.org/apps
api_id: 12345
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
# (Optional) Create your own bot at https://t.me/BotFather
bot_token: disabled
# Should the bridge request missed updates from Telegram when restarting?
catch_up: true
# Should incoming updates be handled sequentially to make sure order is preserved on Matrix?
sequential_updates: true
exit_on_update_error: false
# Telethon connection options.
connection:
# The timeout in seconds to be used when connecting.
timeout: 120
# How many times the reconnection should retry, either on the initial connection or when
# Telegram disconnects us. May be set to a negative or null value for infinite retries, but
# this is not recommended, since the program can get stuck in an infinite loop.
retries: 5
# The delay in seconds to sleep between automatic reconnections.
retry_delay: 1
# The threshold below which the library should automatically sleep on flood wait errors
# (inclusive). For instance, if a FloodWaitError for 17s occurs and flood_sleep_threshold
# is 20s, the library will sleep automatically. If the error was for 21s, it would raise
# the error instead. Values larger than a day (86400) will be changed to a day.
flood_sleep_threshold: 60
# How many times a request should be retried. Request are retried when Telegram is having
# internal issues, when there is a FloodWaitError less than flood_sleep_threshold, or when
# there's a migrate error. May take a negative or null value for infinite retries, but this
# is not recommended, since some requests can always trigger a call fail (such as searching
# for messages).
request_retries: 5
# Use IPv6 for Telethon connection
use_ipv6: false
# Device info sent to Telegram.
device_info:
# "auto" = OS name+version.
device_model: mautrix-telegram
# "auto" = Telethon version.
system_version: auto
# "auto" = mautrix-telegram version.
app_version: auto
lang_code: en
system_lang_code: en
# Custom server to connect to.
server:
# Set to true to use these server settings. If false, will automatically
# use production server assigned by Telegram. Set to false in production.
enabled: false
# The DC ID to connect to.
dc: 2
# The IP to connect to.
ip: 149.154.167.40
# The port to connect to. 443 may not work, 80 is better and both are equally secure.
port: 80
# Telethon proxy configuration.
# You must install PySocks from pip for proxies to work.
proxy:
# Allowed types: disabled, socks4, socks5, http, mtproxy
type: disabled
# Proxy IP address and port.
address: 127.0.0.1
port: 1080
# Whether or not to perform DNS resolving remotely. Only for socks/http proxies.
rdns: true
# Proxy authentication (optional). Put MTProxy secret in password field.
username: ""
password: ""
# Python logging configuration.
#
# See section 16.7.2 of the Python documentation for more info:
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
logging:
version: 1
formatters:
colored:
(): mautrix_telegram.util.ColorFormatter
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
normal:
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
handlers:
file:
class: logging.handlers.RotatingFileHandler
formatter: normal
filename: ./mautrix-telegram.log
maxBytes: 10485760
backupCount: 10
console:
class: logging.StreamHandler
formatter: colored
loggers:
mau:
level: DEBUG
telethon:
level: INFO
aiohttp:
level: INFO
root:
level: DEBUG
handlers: [file, console]
-2
View File
@@ -1,2 +0,0 @@
from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram
from .from_telegram import telegram_to_matrix
@@ -1,110 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import re
from telethon import TelegramClient
from telethon.helpers import add_surrogate, del_surrogate, strip_text
from telethon.tl.types import MessageEntityItalic, TypeMessageEntity
from mautrix.types import MessageEventContent, RoomID
from ...db import Message as DBMessage
from ...types import TelegramID
from .parser import MatrixParser
command_regex = re.compile(r"^!([A-Za-z0-9@]+)")
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)")
MAX_LENGTH = 4096
CUTOFF_TEXT = " [message cut]"
CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT)
class FormatError(Exception):
pass
async def matrix_reply_to_telegram(
content: MessageEventContent, tg_space: TelegramID, room_id: RoomID | None = None
) -> TelegramID | None:
event_id = content.get_reply_to()
if not event_id:
return
content.trim_reply_fallback()
message = await DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
return message.tgid
return None
async def matrix_to_telegram(
client: TelegramClient, *, text: str | None = None, html: str | None = None
) -> tuple[str, list[TypeMessageEntity]]:
if html is not None:
return await _matrix_html_to_telegram(client, html)
elif text is not None:
return _matrix_text_to_telegram(text)
else:
raise ValueError("text or html must be provided to convert formatting")
async def _matrix_html_to_telegram(
client: TelegramClient, html: str
) -> tuple[str, list[TypeMessageEntity]]:
try:
html = command_regex.sub(r"<command>\1</command>", html)
html = html.replace("\t", " " * 4)
html = not_command_regex.sub(r"\1", html)
parsed = await MatrixParser(client).parse(add_surrogate(html))
text, entities = _cut_long_message(parsed.text, parsed.telegram_entities)
text = del_surrogate(strip_text(text, entities))
return text, entities
except Exception as e:
raise FormatError(f"Failed to convert Matrix format: {html}") from e
def _cut_long_message(
message: str, entities: list[TypeMessageEntity]
) -> tuple[str, list[TypeMessageEntity]]:
if len(message) > MAX_LENGTH:
message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT
new_entities = []
for entity in entities:
if entity.offset > CUT_MAX_LENGTH:
continue
if entity.offset + entity.length > CUT_MAX_LENGTH:
entity.length = CUT_MAX_LENGTH - entity.offset
new_entities.append(entity)
new_entities.append(MessageEntityItalic(CUT_MAX_LENGTH, len(CUTOFF_TEXT)))
entities = new_entities
return message, entities
def _matrix_text_to_telegram(text: str) -> tuple[str, list[TypeMessageEntity]]:
text = command_regex.sub(r"/\1", text)
text = text.replace("\t", " " * 4)
text = not_command_regex.sub(r"\1", text)
entities = []
surrogated_text = add_surrogate(text)
if len(surrogated_text) > MAX_LENGTH:
surrogated_text, entities = _cut_long_message(surrogated_text, entities)
text = del_surrogate(surrogated_text)
return text, entities
@@ -1,100 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import logging
from telethon import TelegramClient
from mautrix.types import RoomID, UserID
from mautrix.util.formatter import HTMLNode, MatrixParser as BaseMatrixParser, RecursionContext
from mautrix.util.logging import TraceLogger
from ... import portal as po, puppet as pu, user as u
from .telegram_message import TelegramEntityType, TelegramMessage
log: TraceLogger = logging.getLogger("mau.fmt.mx")
class MatrixParser(BaseMatrixParser[TelegramMessage]):
e = TelegramEntityType
fs = TelegramMessage
client: TelegramClient
def __init__(self, client: TelegramClient) -> None:
self.client = client
async def custom_node_to_fstring(
self, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage | None:
if node.tag == "command":
msg = await self.tag_aware_parse_node(node, ctx)
return msg.prepend("/").format(TelegramEntityType.COMMAND)
return None
async def user_pill_to_fstring(self, msg: TelegramMessage, user_id: UserID) -> TelegramMessage:
user = await pu.Puppet.get_by_mxid(user_id) or await u.User.get_by_mxid(
user_id, create=False
)
if not user:
return msg
if user.tg_username:
return TelegramMessage(f"@{user.tg_username}").format(TelegramEntityType.MENTION)
elif user.tgid:
displayname = user.plain_displayname or msg.text
msg = TelegramMessage(displayname)
try:
input_entity = await self.client.get_input_entity(user.tgid)
except (ValueError, TypeError) as e:
log.trace(f"Dropping mention of {user.tgid}: {e}")
else:
msg = msg.format(TelegramEntityType.MENTION_NAME, user_id=input_entity)
return msg
async def url_to_fstring(self, msg: TelegramMessage, url: str) -> TelegramMessage:
if url == msg.text:
return msg.format(self.e.URL)
else:
return msg.format(self.e.INLINE_URL, url=url)
async def room_pill_to_fstring(self, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage:
username = po.Portal.get_username_from_mx_alias(room_id)
portal = await po.Portal.find_by_username(username)
if portal and portal.username:
return TelegramMessage(f"@{portal.username}").format(TelegramEntityType.MENTION)
async def header_to_fstring(self, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
children = await self.node_to_fstrings(node, ctx)
length = int(node.tag[1])
prefix = "#" * length + " "
return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD)
async def blockquote_to_fstring(
self, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage:
msg = await self.tag_aware_parse_node(node, ctx)
children = msg.trim().split("\n")
children = [child.prepend("> ") for child in children]
return TelegramMessage.join(children, "\n")
async def color_to_fstring(self, msg: TelegramMessage, color: str) -> TelegramMessage:
return msg
async def spoiler_to_fstring(self, msg: TelegramMessage, reason: str) -> TelegramMessage:
msg = msg.format(self.e.SPOILER)
if reason:
msg = msg.prepend(f"{reason}: ")
return msg
@@ -1,122 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any, Type
from enum import Enum
from telethon.tl.types import (
InputMessageEntityMentionName as InputMentionName,
MessageEntityBlockquote as Blockquote,
MessageEntityBold as Bold,
MessageEntityBotCommand as Command,
MessageEntityCode as Code,
MessageEntityEmail as Email,
MessageEntityItalic as Italic,
MessageEntityMention as Mention,
MessageEntityMentionName as MentionName,
MessageEntityPre as Pre,
MessageEntitySpoiler as Spoiler,
MessageEntityStrike as Strike,
MessageEntityTextUrl as TextURL,
MessageEntityUnderline as Underline,
MessageEntityUrl as URL,
TypeMessageEntity,
)
from mautrix.util.formatter import EntityString, SemiAbstractEntity
class TelegramEntityType(Enum):
"""EntityType is a Matrix formatting entity type."""
BOLD = Bold
ITALIC = Italic
STRIKETHROUGH = Strike
UNDERLINE = Underline
URL = URL
INLINE_URL = TextURL
EMAIL = Email
PREFORMATTED = Pre
INLINE_CODE = Code
BLOCKQUOTE = Blockquote
MENTION = Mention
MENTION_NAME = InputMentionName
COMMAND = Command
SPOILER = Spoiler
USER_MENTION = 1
ROOM_MENTION = 2
HEADER = 3
class TelegramEntity(SemiAbstractEntity):
internal: TypeMessageEntity
def __init__(
self,
type: TelegramEntityType | Type[TypeMessageEntity],
offset: int,
length: int,
extra_info: dict[str, Any],
) -> None:
if isinstance(type, TelegramEntityType):
if isinstance(type.value, int):
raise ValueError(f"Can't create Entity with non-Telegram EntityType {type}")
type = type.value
self.internal = type(offset=offset, length=length, **extra_info)
def copy(self) -> TelegramEntity:
extra_info = {}
if isinstance(self.internal, Pre):
extra_info["language"] = self.internal.language
elif isinstance(self.internal, TextURL):
extra_info["url"] = self.internal.url
elif isinstance(self.internal, (MentionName, InputMentionName)):
extra_info["user_id"] = self.internal.user_id
return TelegramEntity(
type(self.internal),
offset=self.internal.offset,
length=self.internal.length,
extra_info=extra_info,
)
def __repr__(self) -> str:
return str(self.internal)
@property
def offset(self) -> int:
return self.internal.offset
@offset.setter
def offset(self, value: int) -> None:
self.internal.offset = value
@property
def length(self) -> int:
return self.internal.length
@length.setter
def length(self, value: int) -> None:
self.internal.length = value
class TelegramMessage(EntityString[TelegramEntity, TelegramEntityType]):
entity_class = TelegramEntity
@property
def telegram_entities(self) -> list[TypeMessageEntity]:
return [entity.internal for entity in self.entities]
-418
View File
@@ -1,418 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from html import escape
import logging
import re
from telethon.errors import RPCError
from telethon.helpers import add_surrogate, del_surrogate
from telethon.tl.custom import Message
from telethon.tl.types import (
Channel,
InputPeerChannelFromMessage,
InputPeerUserFromMessage,
MessageEntityBlockquote,
MessageEntityBold,
MessageEntityBotCommand,
MessageEntityCashtag,
MessageEntityCode,
MessageEntityCustomEmoji,
MessageEntityEmail,
MessageEntityHashtag,
MessageEntityItalic,
MessageEntityMention,
MessageEntityMentionName,
MessageEntityPhone,
MessageEntityPre,
MessageEntitySpoiler,
MessageEntityStrike,
MessageEntityTextUrl,
MessageEntityUnderline,
MessageEntityUrl,
MessageFwdHeader,
PeerChannel,
PeerChat,
PeerUser,
SponsoredMessage,
TypeMessageEntity,
User,
)
from mautrix.types import Format, MessageType, TextMessageEventContent
from .. import abstract_user as au, portal as po, puppet as pu, user as u
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
from ..tgclient import MautrixTelegramClient
from ..types import TelegramID
from ..util.file_transfer import UnicodeCustomEmoji, transfer_custom_emojis_to_matrix
log: logging.Logger = logging.getLogger("mau.fmt.tg")
async def _get_fwd_entity(client: MautrixTelegramClient, evt: Message) -> Channel | User | None:
try:
return await client.get_entity(evt.fwd_from.from_id)
except (ValueError, RPCError) as e:
try:
input_peer = await client.get_input_entity(evt.peer_id)
if isinstance(evt.fwd_from.from_id, PeerUser):
return await client.get_entity(
InputPeerUserFromMessage(
peer=input_peer, msg_id=evt.id, user_id=evt.fwd_from.from_id.user_id
)
)
elif isinstance(evt.fwd_from.from_id, PeerChannel):
return await client.get_entity(
InputPeerChannelFromMessage(
peer=input_peer, msg_id=evt.id, channel_id=evt.fwd_from.from_id.channel_id
)
)
except (ValueError, RPCError) as e:
pass
return None
async def _add_forward_header(
client: MautrixTelegramClient, content: TextMessageEventContent, evt: Message
) -> None:
fwd_from = evt.fwd_from
fwd_from_html, fwd_from_text = None, None
if isinstance(fwd_from.from_id, PeerUser):
user = await u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_id))
if user:
fwd_from_text = user.displayname or user.mxid
fwd_from_html = (
f"<a href='https://matrix.to/#/{user.mxid}'>{escape(fwd_from_text)}</a>"
)
if not fwd_from_text:
puppet = await pu.Puppet.get_by_peer(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}'>{escape(fwd_from_text)}</a>"
)
if not fwd_from_text:
user = await _get_fwd_entity(client, evt)
if user:
fwd_from_text, _ = pu.Puppet.get_displayname(user, False)
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
else:
fwd_from_text = fwd_from_html = "unknown user"
elif isinstance(fwd_from.from_id, (PeerChannel, PeerChat)):
from_id = (
fwd_from.from_id.chat_id
if isinstance(fwd_from.from_id, PeerChat)
else fwd_from.from_id.channel_id
)
portal = await po.Portal.get_by_tgid(TelegramID(from_id))
if portal and portal.title:
fwd_from_text = portal.title
if portal.alias:
fwd_from_html = (
f"<a href='https://matrix.to/#/{portal.alias}'>{escape(fwd_from_text)}</a>"
)
else:
fwd_from_html = f"channel <b>{escape(fwd_from_text)}</b>"
else:
channel = await _get_fwd_entity(client, evt)
if channel:
fwd_from_text = f"channel {channel.title}"
fwd_from_html = f"channel <b>{escape(channel.title)}</b>"
else:
fwd_from_text = fwd_from_html = "unknown channel"
elif fwd_from.from_name:
fwd_from_text = fwd_from.from_name
fwd_from_html = f"<b>{escape(fwd_from.from_name)}</b>"
else:
fwd_from_text = "unknown source"
fwd_from_html = f"unknown source"
content.ensure_has_html()
content.body = "\n".join([f"> {line}" for line in content.body.split("\n")])
content.body = f"Forwarded from {fwd_from_text}:\n{content.body}"
content.formatted_body = (
f"Forwarded message from {fwd_from_html}<br/>"
f"<tg-forward><blockquote>{content.formatted_body}</blockquote></tg-forward>"
)
class ReuploadedCustomEmoji(MessageEntityCustomEmoji):
file: DBTelegramFile
def __init__(self, parent: MessageEntityCustomEmoji, file: DBTelegramFile) -> None:
super().__init__(parent.offset, parent.length, parent.document_id)
self.file = file
async def _convert_custom_emoji(
source: au.AbstractUser,
entities: list[TypeMessageEntity],
client: MautrixTelegramClient | None = None,
) -> None:
emoji_ids = [
entity.document_id for entity in entities if isinstance(entity, MessageEntityCustomEmoji)
]
custom_emojis = await transfer_custom_emojis_to_matrix(source, emoji_ids, client=client)
if len(custom_emojis) > 0:
for i, entity in enumerate(entities):
if isinstance(entity, MessageEntityCustomEmoji):
entities[i] = ReuploadedCustomEmoji(entity, custom_emojis[entity.document_id])
async def telegram_to_matrix(
evt: Message | SponsoredMessage,
source: au.AbstractUser,
client: MautrixTelegramClient | None = None,
override_text: str = None,
override_entities: list[TypeMessageEntity] = None,
require_html: bool = False,
) -> TextMessageEventContent:
if not client:
client = source.client
content = TextMessageEventContent(
msgtype=MessageType.TEXT,
body=override_text or evt.message,
)
entities = override_entities or evt.entities
if entities:
await _convert_custom_emoji(source, entities, client=client)
content.format = Format.HTML
html = await _telegram_entities_to_matrix_catch(add_surrogate(content.body), entities)
content.formatted_body = del_surrogate(html)
if require_html:
content.ensure_has_html()
if getattr(evt, "fwd_from", None):
await _add_forward_header(client, content, evt)
if isinstance(evt, Message) and evt.post and evt.post_author:
content.ensure_has_html()
content.body += f"\n- {evt.post_author}"
content.formatted_body += f"<br/><i>- <u>{evt.post_author}</u></i>"
return content
async def _telegram_entities_to_matrix_catch(text: str, entities: list[TypeMessageEntity]) -> str:
try:
return await _telegram_entities_to_matrix(text, entities)
except Exception:
log.exception(
"Failed to convert Telegram format:\nmessage=%s\nentities=%s", text, entities
)
return "[failed conversion in _telegram_entities_to_matrix]"
def within_surrogate(text, index):
"""
`True` if ``index`` is within a surrogate (before and after it, not at!).
"""
return (
1 < index < len(text) # in bounds
and "\ud800" <= text[index - 1] <= "\udbff" # current is low surrogate
and "\udc00" <= text[index] <= "\udfff" # previous is high surrogate
)
async def _telegram_entities_to_matrix(
text: str,
entities: list[TypeMessageEntity | ReuploadedCustomEmoji],
offset: int = 0,
length: int = None,
in_codeblock: bool = False,
) -> str:
def text_to_html(
val: str, _in_codeblock: bool = in_codeblock, escape_html: bool = True
) -> str:
if escape_html:
val = escape(val)
if not _in_codeblock:
val = val.replace("\n", "<br/>")
return val
if not entities:
return text_to_html(text)
if length is None:
length = len(text)
html = []
last_offset = 0
for i, entity in enumerate(entities):
if entity.offset >= offset + length:
break
relative_offset = entity.offset - offset
if relative_offset > last_offset:
html.append(text_to_html(text[last_offset:relative_offset]))
elif relative_offset < last_offset:
continue
while within_surrogate(text, relative_offset):
relative_offset += 1
while within_surrogate(text, relative_offset + entity.length):
entity.length += 1
skip_entity = False
is_code_entity = isinstance(entity, (MessageEntityCode, MessageEntityPre))
entity_text = await _telegram_entities_to_matrix(
text=text[relative_offset : relative_offset + entity.length],
entities=entities[i + 1 :],
offset=entity.offset,
length=entity.length,
in_codeblock=is_code_entity,
)
entity_text = text_to_html(entity_text, is_code_entity, escape_html=False)
entity_type = type(entity)
if entity_type == MessageEntityBold:
html.append(f"<strong>{entity_text}</strong>")
elif entity_type == MessageEntityItalic:
html.append(f"<em>{entity_text}</em>")
elif entity_type == MessageEntityUnderline:
html.append(f"<u>{entity_text}</u>")
elif entity_type == MessageEntityStrike:
html.append(f"<del>{entity_text}</del>")
elif entity_type == MessageEntityBlockquote:
html.append(f"<blockquote>{entity_text}</blockquote>")
elif entity_type == MessageEntityCode:
html.append(
f"<pre><code>{entity_text}</code></pre>"
if "\n" in entity_text
else f"<code>{entity_text}</code>"
)
elif entity_type == MessageEntityPre:
skip_entity = _parse_pre(html, entity_text, entity.language)
elif entity_type == MessageEntityMention:
skip_entity = await _parse_mention(html, entity_text)
elif entity_type == MessageEntityMentionName:
skip_entity = await _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):
await _parse_url(
html, entity_text, entity.url if entity_type == MessageEntityTextUrl else None
)
elif entity_type == MessageEntityCustomEmoji:
html.append(entity_text)
elif entity_type == ReuploadedCustomEmoji:
if isinstance(entity.file, UnicodeCustomEmoji):
html.append(entity.file.emoji)
else:
html.append(
f"<img data-mx-emoticon data-mau-animated-emoji"
f' src="{escape(entity.file.mxc)}" height="32" width="32"'
f' alt="{entity_text}" title="{entity_text}"/>'
)
elif entity_type in (
MessageEntityBotCommand,
MessageEntityHashtag,
MessageEntityCashtag,
MessageEntityPhone,
):
html.append(f"<font color='#3771bb'>{entity_text}</font>")
elif entity_type == MessageEntitySpoiler:
html.append(f"<span data-mx-spoiler>{entity_text}</span>")
else:
skip_entity = True
last_offset = relative_offset + (0 if skip_entity else entity.length)
html.append(text_to_html(text[last_offset:]))
return "".join(html)
def _parse_pre(html: list[str], entity_text: str, language: str) -> bool:
if language:
html.append(f"<pre><code class='language-{language}'>{entity_text}</code></pre>")
else:
html.append(f"<pre><code>{entity_text}</code></pre>")
return False
async def _parse_mention(html: list[str], entity_text: str) -> bool:
username = entity_text[1:]
mxid = None
portal = None
# This is a bit complicated because public channels have both Puppet and Portal instances.
# Basically the currently intended output is:
# User/bot mention (bridge user) -> real user mention
# User/bot mention (normal Telegram user) -> ghost user mention
# Public channel with existing portal -> room mention
# Public channel without portal -> ghost user mention
# Other chat -> room mention
user = await u.User.find_by_username(username) or await pu.Puppet.find_by_username(username)
if user:
if isinstance(user, pu.Puppet) and user.is_channel:
portal = await po.Portal.get_by_tgid(user.tgid)
mxid = user.mxid
else:
portal = await po.Portal.find_by_username(username)
if portal and (portal.mxid or not user):
mxid = portal.alias or portal.mxid
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
else:
return True
return False
async def _parse_name_mention(html: list[str], entity_text: str, user_id: TelegramID) -> bool:
user = await u.User.get_by_tgid(user_id)
if user:
mxid = user.mxid
else:
puppet = await pu.Puppet.get_by_tgid(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)"
# /username or /c/id
r"/([A-Za-z][A-Za-z0-9_]{3,31}[A-Za-z0-9]|[Cc]/[0-9]{1,20})"
# /messageid
r"/([0-9]{1,20})"
)
async def _parse_url(html: list[str], entity_text: str, url: str) -> None:
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)
if group.lower().startswith("c/"):
portal = await po.Portal.get_by_tgid(TelegramID(int(group[2:])))
else:
portal = await po.Portal.find_by_username(group)
if portal:
message = await 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>")
-49
View File
@@ -1,49 +0,0 @@
import os
import shutil
import subprocess
from . import __version__
cmd_env = {
"PATH": os.environ["PATH"],
"HOME": os.environ["HOME"],
"LANG": "C",
"LC_ALL": "C",
}
def run(cmd):
return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env)
if os.path.exists(".git") and shutil.which("git"):
try:
git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii")
git_revision_url = f"https://github.com/mautrix/telegram/commit/{git_revision}"
git_revision = git_revision[:8]
except (subprocess.SubprocessError, OSError):
git_revision = "unknown"
git_revision_url = None
try:
git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii")
except (subprocess.SubprocessError, OSError):
git_tag = None
else:
git_revision = "unknown"
git_revision_url = None
git_tag = None
git_tag_url = f"https://github.com/mautrix/telegram/releases/tag/{git_tag}" if git_tag else None
if git_tag and __version__ == git_tag[1:].replace("-", ""):
version = __version__
linkified_version = f"[{version}]({git_tag_url})"
else:
if not __version__.endswith("+dev"):
__version__ += "+dev"
version = f"{__version__}.{git_revision}"
if git_revision_url:
linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})"
else:
linkified_version = version
-436
View File
@@ -1,436 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING
import sys
from mautrix.bridge import BaseMatrixHandler
from mautrix.types import (
Event,
EventID,
EventType,
MemberStateEventContent,
PresenceEvent,
PresenceState,
ReactionEvent,
ReceiptEvent,
RedactionEvent,
RoomAvatarStateEventContent as AvatarContent,
RoomID,
RoomNameStateEventContent as NameContent,
RoomTopicStateEventContent as TopicContent,
SingleReceiptEventContent,
StateEvent,
TypingEvent,
UserID,
)
from . import commands as com, portal as po, puppet as pu, user as u
from .commands.portal.util import get_initial_state, user_has_power_level, warn_missing_power
from .types import TelegramID
if TYPE_CHECKING:
from .__main__ import TelegramBridge
class MatrixHandler(BaseMatrixHandler):
commands: com.CommandProcessor
_previously_typing: dict[RoomID, set[UserID]]
def __init__(self, bridge: "TelegramBridge") -> None:
prefix, suffix = bridge.config["bridge.username_template"].format(userid=":").split(":")
homeserver = bridge.config["homeserver.domain"]
self.user_id_prefix = f"@{prefix}"
self.user_id_suffix = f"{suffix}:{homeserver}"
super().__init__(command_processor=com.CommandProcessor(bridge), bridge=bridge)
self._previously_typing = {}
async def check_versions(self) -> None:
await super().check_versions()
if self.config["bridge.backfill.msc2716"] and not (
support := self.versions.supports("org.matrix.msc2716")
):
self.log.fatal(
"Backfilling with MSC2716 is enabled in bridge config, but "
+ (
"batch sending is not enabled on homeserver"
if support is False
else "homeserver does not support batch sending"
)
)
sys.exit(18)
async def handle_puppet_group_invite(
self,
room_id: RoomID,
puppet: pu.Puppet,
invited_by: u.User,
evt: StateEvent,
members: list[UserID],
) -> None:
double_puppet = await pu.Puppet.get_by_custom_mxid(invited_by.mxid)
if (
not double_puppet
or self.az.bot_mxid in members
or not self.config["bridge.create_group_on_invite"]
):
if self.az.bot_mxid not in members:
await puppet.default_mxid_intent.leave_room(
room_id,
reason="This ghost does not join multi-user rooms without the bridge bot.",
)
else:
await puppet.default_mxid_intent.send_notice(
room_id,
"This ghost will remain inactive "
"until a Telegram chat is created for this room.",
)
return
elif not await user_has_power_level(
evt.room_id, double_puppet.intent, invited_by, "bridge"
):
await puppet.default_mxid_intent.leave_room(
room_id, reason="You do not have the permissions to bridge this room."
)
return
await double_puppet.intent.invite_user(room_id, self.az.bot_mxid)
title, about, levels, encrypted = await get_initial_state(double_puppet.intent, room_id)
if not title:
await puppet.default_mxid_intent.leave_room(
room_id, reason="Please set a title before inviting Telegram ghosts."
)
return
portal = po.Portal(
tgid=TelegramID(0),
tg_receiver=TelegramID(0),
peer_type="channel",
mxid=evt.room_id,
title=title,
about=about,
encrypted=encrypted,
)
await portal.az.intent.ensure_joined(room_id)
levels = await portal.az.intent.get_power_levels(room_id)
invited_by_level = levels.get_user_level(invited_by.mxid)
if invited_by_level > levels.get_user_level(self.az.bot_mxid):
levels.users[self.az.bot_mxid] = 100 if invited_by_level >= 100 else invited_by_level
await double_puppet.intent.set_power_levels(room_id, levels)
try:
await portal.create_telegram_chat(invited_by, supergroup=True)
except ValueError as e:
await portal.delete()
await portal.az.intent.send_notice(room_id, e.args[0])
return
async def handle_invite(
self, room_id: RoomID, user_id: UserID, inviter: u.User, event_id: EventID
) -> None:
user = await u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
portal = await po.Portal.get_by_mxid(room_id)
if (
user
and portal
and await user.has_full_access(allow_bot=True)
and portal.allow_bridging
):
await portal.handle_matrix_invite(inviter, user)
async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
user = await u.User.get_and_start_by_mxid(user_id)
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.allow_bridging:
return
if not user.relaybot_whitelisted:
await portal.main_intent.kick_user(
room_id, user.mxid, "You are not whitelisted on this Telegram bridge."
)
return
elif not await user.is_logged_in() and not portal.has_bot:
await portal.main_intent.kick_user(
room_id,
user.mxid,
"This chat does not have a bot relaying messages for unauthenticated users.",
)
return
self.log.debug(f"{user.mxid} joined {room_id}")
if await user.is_logged_in() or portal.has_bot:
await portal.join_matrix(user, event_id)
async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
self.log.debug(f"{user_id} left {room_id}")
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.allow_bridging:
return
user = await u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
await portal.leave_matrix(user, event_id)
async def handle_kick_ban(
self,
ban: bool,
room_id: RoomID,
user_id: UserID,
sender: UserID,
reason: str,
event_id: EventID,
) -> None:
action = "banned" if ban else "kicked"
self.log.debug(f"{user_id} was {action} from {room_id} by {sender} for {reason}")
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.allow_bridging:
return
if user_id == self.az.bot_mxid:
# Direct chat portal unbridging is handled in portal.kick_matrix
if portal.peer_type != "user":
await portal.unbridge()
return
sender = await u.User.get_by_mxid(sender, create=False)
if not sender:
return
await sender.ensure_started()
puppet = await pu.Puppet.get_by_mxid(user_id)
if puppet:
if ban:
await portal.ban_matrix(puppet, sender)
else:
await portal.kick_matrix(puppet, sender)
return
user = await u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
if ban:
await portal.ban_matrix(user, sender)
else:
await portal.kick_matrix(user, sender)
async def handle_kick(
self, room_id: RoomID, user_id: UserID, kicked_by: UserID, reason: str, event_id: EventID
) -> None:
await self.handle_kick_ban(False, room_id, user_id, kicked_by, reason, event_id)
async def handle_unban(
self, room_id: RoomID, user_id: UserID, unbanned_by: UserID, reason: str, event_id: EventID
) -> None:
# TODO handle unbans properly instead of handling it as a kick
await self.handle_kick_ban(False, room_id, user_id, unbanned_by, reason, event_id)
async def handle_ban(
self, room_id: RoomID, user_id: UserID, banned_by: UserID, reason: str, event_id: EventID
) -> None:
await self.handle_kick_ban(True, room_id, user_id, banned_by, reason, event_id)
async def allow_message(self, user: u.User) -> bool:
return user.relaybot_whitelisted
async def allow_command(self, user: u.User) -> bool:
return user.whitelisted
@staticmethod
async def allow_bridging_message(user: u.User, portal: po.Portal) -> bool:
return await user.is_logged_in() or portal.has_bot
@staticmethod
async def handle_redaction(evt: RedactionEvent) -> None:
sender = await u.User.get_and_start_by_mxid(evt.sender)
if not sender.relaybot_whitelisted:
return
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal or not portal.allow_bridging:
return
await portal.handle_matrix_deletion(sender, evt.redacts, evt.event_id)
@staticmethod
async def handle_reaction(evt: ReactionEvent) -> None:
sender = await u.User.get_and_start_by_mxid(evt.sender)
if not await sender.has_full_access():
return
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal or not portal.allow_bridging:
return
await portal.handle_matrix_reaction(
sender, evt.content.relates_to.event_id, evt.content.relates_to.key, evt.event_id
)
@staticmethod
async def handle_power_levels(evt: StateEvent) -> None:
portal = await po.Portal.get_by_mxid(evt.room_id)
sender = await u.User.get_and_start_by_mxid(evt.sender)
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
await portal.handle_matrix_power_levels(
sender, evt.content.users, evt.unsigned.prev_content.users, evt.event_id
)
@staticmethod
async def handle_room_meta(
evt_type: EventType,
room_id: RoomID,
sender_mxid: UserID,
content: NameContent | AvatarContent | TopicContent,
event_id: EventID,
) -> None:
portal = await po.Portal.get_by_mxid(room_id)
sender = await u.User.get_and_start_by_mxid(sender_mxid)
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
handler, content_type, content_key = {
EventType.ROOM_NAME: (portal.handle_matrix_title, NameContent, "name"),
EventType.ROOM_TOPIC: (portal.handle_matrix_about, TopicContent, "topic"),
EventType.ROOM_AVATAR: (portal.handle_matrix_avatar, AvatarContent, "url"),
}[evt_type]
if not isinstance(content, content_type):
return
await handler(sender, content[content_key], event_id)
@staticmethod
async def handle_room_pin(
room_id: RoomID,
sender_mxid: UserID,
new_events: set[str],
old_events: set[str],
event_id: EventID,
) -> None:
portal = await po.Portal.get_by_mxid(room_id)
sender = await u.User.get_and_start_by_mxid(sender_mxid)
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
if not new_events:
await portal.handle_matrix_unpin_all(sender, event_id)
else:
changes = {
event_id: event_id in new_events for event_id in new_events ^ old_events
}
await portal.handle_matrix_pin(sender, changes, event_id)
@staticmethod
async def handle_room_upgrade(
room_id: RoomID, sender: UserID, new_room_id: RoomID, event_id: EventID
) -> None:
portal = await po.Portal.get_by_mxid(room_id)
if portal and portal.allow_bridging:
await portal.handle_matrix_upgrade(sender, new_room_id, event_id)
async def handle_member_info_change(
self,
room_id: RoomID,
user_id: UserID,
profile: MemberStateEventContent,
prev_profile: MemberStateEventContent,
event_id: EventID,
) -> None:
if profile.displayname == prev_profile.displayname:
return
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.has_bot or not portal.allow_bridging:
return
user = await u.User.get_and_start_by_mxid(user_id)
if await user.needs_relaybot(portal):
await portal.name_change_matrix(
user, profile.displayname, prev_profile.displayname, event_id
)
async def handle_read_receipt(
self, user: u.User, portal: po.Portal, event_id: EventID, data: SingleReceiptEventContent
) -> None:
if not portal.allow_bridging:
return
await portal.mark_read(user, event_id, data.get("ts", 0))
@staticmethod
async def handle_presence(user_id: UserID, presence: PresenceState) -> None:
user = await u.User.get_by_mxid(user_id, check_db=False, create=False)
if user and await user.is_logged_in():
await user.set_presence(presence == PresenceState.ONLINE)
async def handle_typing(self, room_id: RoomID, now_typing: set[UserID]) -> None:
portal = await po.Portal.get_by_mxid(room_id)
if not portal or not portal.allow_bridging:
return
previously_typing = self._previously_typing.get(room_id, set())
for user_id in set(previously_typing | now_typing):
is_typing = user_id in now_typing
was_typing = user_id in previously_typing
if is_typing and was_typing:
continue
user = await u.User.get_by_mxid(user_id, check_db=False, create=False)
if user and await user.is_logged_in():
await portal.set_typing(user, is_typing)
self._previously_typing[room_id] = now_typing
async def handle_ephemeral_event(
self, evt: ReceiptEvent | PresenceEvent | TypingEvent
) -> None:
if evt.type == EventType.RECEIPT:
await self.handle_receipt(evt)
elif evt.type == EventType.PRESENCE:
await self.handle_presence(evt.sender, evt.content.presence)
elif evt.type == EventType.TYPING:
await self.handle_typing(evt.room_id, set(evt.content.user_ids))
async def handle_event(self, evt: Event) -> None:
if evt.type == EventType.ROOM_REDACTION:
await self.handle_redaction(evt)
elif evt.type == EventType.REACTION:
await self.handle_reaction(evt)
async def handle_state_event(self, evt: StateEvent) -> None:
if evt.type == EventType.ROOM_POWER_LEVELS:
await self.handle_power_levels(evt)
elif evt.type in (EventType.ROOM_NAME, EventType.ROOM_AVATAR, EventType.ROOM_TOPIC):
await self.handle_room_meta(
evt.type, evt.room_id, evt.sender, evt.content, evt.event_id
)
elif evt.type == EventType.ROOM_PINNED_EVENTS:
new_events = set(evt.content.pinned)
try:
old_events = set(evt.unsigned.prev_content.pinned)
except (KeyError, ValueError, TypeError, AttributeError):
old_events = set()
await self.handle_room_pin(
evt.room_id, evt.sender, new_events, old_events, evt.event_id
)
elif evt.type == EventType.ROOM_TOMBSTONE:
await self.handle_room_upgrade(
evt.room_id, evt.sender, evt.content.replacement_room, evt.event_id
)
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
from .deduplication import PortalDedup
from .message_convert import ConvertedMessage, TelegramMessageConverter
from .participants import get_users
from .power_levels import get_base_power_levels, participants_to_power_levels
from .send_lock import PortalReactionLock, PortalSendLock
from .sponsored_message import get_sponsored_message, make_sponsored_message_content
@@ -1,157 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any, Generator, Tuple, Union
from collections import deque
import hashlib
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (
Message,
MessageMediaContact,
MessageMediaDice,
MessageMediaDocument,
MessageMediaGame,
MessageMediaGeo,
MessageMediaPhoto,
MessageMediaPoll,
MessageMediaUnsupported,
MessageService,
PeerChannel,
PeerChat,
PeerUser,
TypeUpdates,
UpdateNewChannelMessage,
UpdateNewMessage,
UpdateShortChatMessage,
UpdateShortMessage,
)
from mautrix.types import EventID
from .. import portal as po
from ..types import TelegramID
DedupMXID = Tuple[EventID, TelegramID]
TypeMessage = Union[Message, MessageService, UpdateShortMessage, UpdateShortChatMessage]
media_content_table = {
MessageMediaContact: lambda media: [media.user_id],
MessageMediaDocument: lambda media: [media.document.id],
MessageMediaPhoto: lambda media: [media.photo.id if media.photo else 0],
MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
MessageMediaGame: lambda media: [media.game.id],
MessageMediaPoll: lambda media: [media.poll.id],
MessageMediaDice: lambda media: [media.value, media.emoticon],
MessageMediaUnsupported: lambda media: ["unsupported media"],
}
class PortalDedup:
cache_queue_length: int = 256
_dedup: deque[bytes | int]
_dedup_mxid: dict[bytes | int, DedupMXID]
_dedup_action: deque[bytes | int]
_portal: po.Portal
def __init__(self, portal: po.Portal) -> None:
self._dedup = deque()
self._dedup_mxid = {}
self._dedup_action = deque(maxlen=self.cache_queue_length)
self._portal = portal
@property
def _always_force_hash(self) -> bool:
return self._portal.peer_type == "chat"
def _hash_content(self, event: TypeMessage) -> Generator[Any, None, None]:
if not self._always_force_hash:
yield event.id
yield int(event.date.timestamp())
if isinstance(event, MessageService):
yield event.from_id
yield event.action
else:
yield event.message.strip()
if event.fwd_from:
yield event.fwd_from.from_id
if isinstance(event, Message) and event.media:
media_hash_func = media_content_table.get(type(event.media)) or (
lambda media: ["unknown media"]
)
yield media_hash_func(event.media)
def hash_event(self, event: TypeMessage) -> bytes:
return hashlib.sha256(
"-".join(str(a) for a in self._hash_content(event)).encode("utf-8")
).digest()
def check_action(self, event: TypeMessage) -> bool:
dedup_id = self.hash_event(event) if self._always_force_hash else event.id
if dedup_id in self._dedup_action:
return True
self._dedup_action.appendleft(dedup_id)
return False
def update(
self,
event: TypeMessage,
mxid: DedupMXID = None,
expected_mxid: DedupMXID | None = None,
force_hash: bool = False,
) -> tuple[bytes, DedupMXID | None]:
evt_hash = self.hash_event(event)
dedup_id = evt_hash if self._always_force_hash or force_hash else event.id
try:
found_mxid = self._dedup_mxid[dedup_id]
except KeyError:
return evt_hash, None
if found_mxid != expected_mxid:
return evt_hash, found_mxid
self._dedup_mxid[dedup_id] = mxid
if evt_hash != dedup_id:
self._dedup_mxid[evt_hash] = mxid
return evt_hash, None
def check(
self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False
) -> tuple[bytes, DedupMXID | None]:
evt_hash = self.hash_event(event)
dedup_id = evt_hash if self._always_force_hash or force_hash else event.id
if dedup_id in self._dedup:
return evt_hash, self._dedup_mxid[dedup_id]
self._dedup_mxid[dedup_id] = mxid
self._dedup.appendleft(dedup_id)
if evt_hash != dedup_id:
self._dedup_mxid[evt_hash] = mxid
self._dedup.appendleft(evt_hash)
while len(self._dedup) > self.cache_queue_length:
del self._dedup_mxid[self._dedup.pop()]
return evt_hash, None
def register_outgoing_actions(self, response: TypeUpdates) -> None:
for update in response.updates:
check_dedup = isinstance(
update, (UpdateNewMessage, UpdateNewChannelMessage)
) and isinstance(update.message, MessageService)
if check_dedup:
self.check(update.message)
@@ -1,882 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Any, NamedTuple
import base64
import codecs
import hashlib
import html
import mimetypes
import unicodedata
from attr import dataclass
from telethon.tl.types import (
Document,
DocumentAttributeAnimated,
DocumentAttributeAudio,
DocumentAttributeFilename,
DocumentAttributeImageSize,
DocumentAttributeSticker,
DocumentAttributeVideo,
Game,
InputPhotoFileLocation,
InputStickerSetID,
InputStickerSetShortName,
Message,
MessageEntityPre,
MessageMediaContact,
MessageMediaDice,
MessageMediaDocument,
MessageMediaGame,
MessageMediaGeo,
MessageMediaGeoLive,
MessageMediaInvoice,
MessageMediaPhoto,
MessageMediaPoll,
MessageMediaStory,
MessageMediaUnsupported,
MessageMediaVenue,
MessageMediaWebPage,
MessageReplyStoryHeader,
PeerChannel,
PeerUser,
Photo,
PhotoCachedSize,
PhotoEmpty,
PhotoSize,
PhotoSizeEmpty,
PhotoSizeProgressive,
Poll,
TypeDocumentAttribute,
TypePhotoSize,
WebPage,
)
from telethon.utils import decode_waveform
from mautrix.appservice import IntentAPI
from mautrix.types import (
EventID,
EventType,
Format,
ImageInfo,
LocationMessageEventContent,
MediaMessageEventContent,
MessageEventContent,
MessageType,
TextMessageEventContent,
ThumbnailInfo,
)
from mautrix.util.logging import TraceLogger
from .. import abstract_user as au, formatter, matrix as m, portal as po, puppet as pu, util
from ..config import Config
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
from ..tgclient import MautrixTelegramClient
from ..types import TelegramID
from ..util import sane_mimetypes
try:
import phonenumbers
except ImportError:
phonenumbers = None
@dataclass
class ConvertedMessage:
content: MessageEventContent
caption: MessageEventContent | None = None
type: EventType = EventType.ROOM_MESSAGE
disappear_seconds: int | None = None
disappear_start_immediately: bool = False
class DocAttrs(NamedTuple):
name: str | None
mime_type: str | None
is_sticker: bool
sticker_alt: str | None
sticker_pack_ref: dict | None
width: int
height: int
is_gif: bool
is_audio: bool
is_voice: bool
duration: int
waveform: bytes
BEEPER_LINK_PREVIEWS_KEY = "com.beeper.linkpreviews"
BEEPER_IMAGE_ENCRYPTION_KEY = "beeper:image:encryption"
class TelegramMessageConverter:
portal: po.Portal
matrix: m.MatrixHandler
config: Config
command_prefix: str
log: TraceLogger
def __init__(self, portal: po.Portal) -> None:
self.portal = portal
self.matrix = portal.matrix
self.config = portal.config
self.command_prefix = self.config["bridge.command_prefix"]
self.log = portal.log.getChild("msg_conv")
self._media_converters = {
MessageMediaPhoto: self._convert_photo,
MessageMediaDocument: self._convert_document,
MessageMediaGeo: self._convert_location,
MessageMediaGeoLive: self._convert_location,
MessageMediaVenue: self._convert_location,
MessageMediaPoll: self._convert_poll,
MessageMediaDice: self._convert_dice,
MessageMediaUnsupported: self._convert_unsupported,
MessageMediaGame: self._convert_game,
MessageMediaContact: self._convert_contact,
MessageMediaStory: self._convert_story,
MessageMediaInvoice: self._convert_invoice,
}
self._allowed_media = tuple(self._media_converters.keys())
async def convert(
self,
source: au.AbstractUser,
intent: IntentAPI,
is_bot: bool,
evt: Message,
no_reply_fallback: bool = False,
deterministic_reply_id: bool = False,
client: MautrixTelegramClient | None = None,
) -> ConvertedMessage | None:
if not client:
client = source.client
if hasattr(evt, "media") and isinstance(evt.media, self._allowed_media):
convert_media = self._media_converters[type(evt.media)]
converted = await convert_media(source=source, intent=intent, evt=evt, client=client)
elif evt.message:
converted = await self._convert_text(source, intent, is_bot, evt, client)
else:
self.log.debug("Unhandled Telegram message %d", evt.id)
return
if converted:
if evt.ttl_period and not converted.disappear_seconds:
converted.disappear_seconds = evt.ttl_period
converted.disappear_start_immediately = True
converted.content.external_url = self._get_external_url(evt)
converted.content["fi.mau.telegram.source"] = {
"space": self.portal.tgid if self.portal.peer_type == "channel" else source.tgid,
"chat_id": self.portal.tgid,
"peer_type": self.portal.peer_type,
"id": evt.id,
}
if converted.caption:
converted.caption["fi.mau.telegram.source"] = converted.content[
"fi.mau.telegram.source"
]
converted.caption.external_url = converted.content.external_url
if self.portal.get_config("caption_in_message"):
self._caption_to_message(converted)
await self._set_reply(
source,
evt,
converted.content,
no_fallback=no_reply_fallback,
deterministic_id=deterministic_reply_id,
)
return converted
@staticmethod
def _caption_to_message(converted: ConvertedMessage) -> None:
content, caption = converted.content, converted.caption
converted.caption = None
content["filename"] = content.body
content["org.matrix.msc1767.caption"] = {
"org.matrix.msc1767.text": caption.body,
}
content.body = caption.body
if caption.format == Format.HTML:
content["org.matrix.msc1767.caption"][
"org.matrix.msc1767.html"
] = caption.formatted_body
content["formatted_body"] = caption.formatted_body
content["format"] = Format.HTML.value
def _get_external_url(self, evt: Message) -> str | None:
if self.portal.peer_type == "channel" and self.portal.username is not None:
return f"https://t.me/{self.portal.username}/{evt.id}"
elif self.portal.peer_type != "user":
return f"https://t.me/c/{self.portal.tgid}/{evt.id}"
return None
@staticmethod
def _int_to_bytes(i: int) -> bytes:
return codecs.decode(f"{i:010x}", "hex")
def _encode_msgid(self, source: au.AbstractUser, evt: Message) -> str:
if self.portal.peer_type == "channel":
play_id = b"c" + self._int_to_bytes(self.portal.tgid) + self._int_to_bytes(evt.id)
elif self.portal.peer_type == "chat":
play_id = (
b"g"
+ self._int_to_bytes(self.portal.tgid)
+ self._int_to_bytes(evt.id)
+ self._int_to_bytes(source.tgid)
)
elif self.portal.peer_type == "user":
play_id = b"u" + self._int_to_bytes(self.portal.tgid) + self._int_to_bytes(evt.id)
else:
raise ValueError("Portal has invalid peer type")
return base64.b64encode(play_id).decode("utf-8").rstrip("=")
def deterministic_event_id(self, space: TelegramID, msg_id: TelegramID) -> EventID:
hash_content = f"{self.portal.mxid}/telegram/{space}/{msg_id}"
hashed = hashlib.sha256(hash_content.encode("utf-8")).digest()
b64hash = base64.urlsafe_b64encode(hashed).decode("utf-8").rstrip("=")
return EventID(f"${b64hash}:telegram.org")
async def _set_reply(
self,
source: au.AbstractUser,
evt: Message,
content: MessageEventContent,
no_fallback: bool = False,
deterministic_id: bool = False,
) -> None:
if not evt.reply_to:
return
elif isinstance(evt.reply_to, MessageReplyStoryHeader):
return
space = (
evt.peer_id.channel_id
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
else source.tgid
)
reply_to_id = TelegramID(evt.reply_to.reply_to_msg_id)
msg = await DBMessage.get_one_by_tgid(reply_to_id, space)
no_fallback = no_fallback or self.config["bridge.disable_reply_fallbacks"]
if not msg or msg.mx_room != self.portal.mxid:
if deterministic_id:
content.set_reply(self.deterministic_event_id(space, reply_to_id))
return
elif not isinstance(content, TextMessageEventContent) or no_fallback:
# Not a text message, just set the reply metadata and return
content.set_reply(msg.mxid)
return
# Text message, try to fetch original message to generate reply fallback.
try:
event = await self.portal.main_intent.get_event(msg.mx_room, msg.mxid)
if event.type == EventType.ROOM_ENCRYPTED and source.bridge.matrix.e2ee:
event = await source.bridge.matrix.e2ee.decrypt(event)
if isinstance(event.content, TextMessageEventContent):
event.content.trim_reply_fallback()
puppet = await pu.Puppet.get_by_mxid(event.sender, create=False)
content.set_reply(event, displayname=puppet.displayname if puppet else event.sender)
except Exception:
self.log.exception("Failed to get event to add reply fallback")
content.set_reply(msg.mxid)
@staticmethod
def _photo_size_key(photo: TypePhotoSize) -> int:
if isinstance(photo, PhotoSize):
return photo.size
elif isinstance(photo, PhotoSizeProgressive):
return max(photo.sizes)
elif isinstance(photo, PhotoSizeEmpty):
return 0
else:
return len(photo.bytes)
@classmethod
def get_largest_photo_size(
cls, photo: Photo | Document
) -> tuple[InputPhotoFileLocation | None, TypePhotoSize | None]:
if (
not photo
or isinstance(photo, PhotoEmpty)
or (isinstance(photo, Document) and not photo.thumbs)
):
return None, None
largest = max(
photo.thumbs if isinstance(photo, Document) else photo.sizes, key=cls._photo_size_key
)
return (
InputPhotoFileLocation(
id=photo.id,
access_hash=photo.access_hash,
file_reference=photo.file_reference,
thumb_size=largest.type,
),
largest,
)
async def _webpage_to_beeper_link_preview(
self, source: au.AbstractUser, intent: IntentAPI, webpage: WebPage
) -> dict[str, Any]:
beeper_link_preview: dict[str, Any] = {
"matched_url": webpage.url,
"og:title": webpage.title,
"og:url": webpage.url,
"og:description": webpage.description,
}
# Upload an image corresponding to the link preview if it exists.
if webpage.photo:
loc, largest_size = self.get_largest_photo_size(webpage.photo)
if loc is None:
return beeper_link_preview
beeper_link_preview["og:image:height"] = largest_size.h
beeper_link_preview["og:image:width"] = largest_size.w
file = await util.transfer_file_to_matrix(
source.client,
intent,
loc,
encrypt=self.portal.encrypted,
async_upload=self.config["homeserver.async_media"],
)
if file.decryption_info:
beeper_link_preview[BEEPER_IMAGE_ENCRYPTION_KEY] = file.decryption_info.serialize()
else:
beeper_link_preview["og:image"] = file.mxc
return beeper_link_preview
async def _convert_text(
self,
source: au.AbstractUser,
intent: IntentAPI,
is_bot: bool,
evt: Message,
client: MautrixTelegramClient,
) -> ConvertedMessage:
content = await formatter.telegram_to_matrix(evt, source, client)
if is_bot and self.portal.get_config("bot_messages_as_notices"):
content.msgtype = MessageType.NOTICE
if (
hasattr(evt, "media")
and isinstance(evt.media, MessageMediaWebPage)
and isinstance(evt.media.webpage, WebPage)
):
content[BEEPER_LINK_PREVIEWS_KEY] = [
await self._webpage_to_beeper_link_preview(source, intent, evt.media.webpage)
]
return ConvertedMessage(content=content)
async def _convert_photo(
self,
source: au.AbstractUser,
intent: IntentAPI,
evt: Message,
client: MautrixTelegramClient,
) -> ConvertedMessage | None:
media: MessageMediaPhoto = evt.media
if media.photo is None and media.ttl_seconds:
return ConvertedMessage(
content=TextMessageEventContent(
msgtype=MessageType.NOTICE, body="Photo has expired"
)
)
loc, largest_size = self.get_largest_photo_size(media.photo)
if loc is None:
return ConvertedMessage(
content=TextMessageEventContent(
msgtype=MessageType.TEXT,
body="Failed to bridge image",
)
)
file = await util.transfer_file_to_matrix(
client,
intent,
loc,
encrypt=self.portal.encrypted,
async_upload=self.config["homeserver.async_media"],
)
if not file:
return None
info = ImageInfo(
height=largest_size.h,
width=largest_size.w,
orientation=0,
mimetype=file.mime_type,
size=self._photo_size_key(largest_size),
)
if media.spoiler:
info["fi.mau.telegram.spoiler"] = True
ext = sane_mimetypes.guess_extension(file.mime_type)
name = f"disappearing_image{ext}" if media.ttl_seconds else f"image{ext}"
content = MediaMessageEventContent(
msgtype=MessageType.IMAGE,
info=info,
body=name,
)
if file.decryption_info:
content.file = file.decryption_info
else:
content.url = file.mxc
caption_content = (
await formatter.telegram_to_matrix(evt, source, client) if evt.message else None
)
return ConvertedMessage(
content=content,
caption=caption_content,
disappear_seconds=media.ttl_seconds,
)
async def _convert_document(
self,
source: au.AbstractUser,
intent: IntentAPI,
evt: Message,
client: MautrixTelegramClient,
) -> ConvertedMessage | None:
document = evt.media.document
attrs = _parse_document_attributes(document.attributes)
if document.size > self.matrix.media_config.upload_size:
name = attrs.name or ""
caption = f"\n{evt.message}" if evt.message else ""
return ConvertedMessage(
content=TextMessageEventContent(
msgtype=MessageType.NOTICE, body=f"Too large file {name}{caption}"
)
)
thumb_loc, thumb_size = self.get_largest_photo_size(document)
if thumb_size and not isinstance(thumb_size, (PhotoSize, PhotoCachedSize)):
self.log.debug(f"Unsupported thumbnail type {type(thumb_size)}")
thumb_loc = None
thumb_size = None
parallel_id = source.tgid if self.config["bridge.parallel_file_transfer"] else None
tgs_convert = self.config["bridge.animated_sticker"]
file = await util.transfer_file_to_matrix(
client,
intent,
document,
thumb_loc,
is_sticker=attrs.is_sticker,
tgs_convert=tgs_convert,
webm_convert=tgs_convert["target"] if tgs_convert["convert_from_webm"] else None,
filename=attrs.name,
parallel_id=parallel_id,
encrypt=self.portal.encrypted,
async_upload=self.config["homeserver.async_media"],
)
if not file:
return None
info, name = _parse_document_meta(evt, file, attrs, thumb_size)
event_type = EventType.ROOM_MESSAGE
# Elements only support images as stickers, so send animated webm stickers as m.video
if attrs.is_sticker and file.mime_type.startswith("image/"):
event_type = EventType.STICKER
# Tell clients to render the stickers as 256x256 if they're bigger
if info.width > 256 or info.height > 256:
if info.width > info.height:
info.height = int(info.height / (info.width / 256))
info.width = 256
else:
info.width = int(info.width / (info.height / 256))
info.height = 256
if info.thumbnail_info:
info.thumbnail_info.width = info.width
info.thumbnail_info.height = info.height
if attrs.is_gif or (attrs.is_sticker and info.mimetype == "video/webm"):
if attrs.is_gif:
info["fi.mau.telegram.gif"] = True
else:
info["fi.mau.telegram.animated_sticker"] = True
info["fi.mau.gif"] = True
info["fi.mau.loop"] = True
info["fi.mau.autoplay"] = True
info["fi.mau.hide_controls"] = True
info["fi.mau.no_audio"] = True
if evt.media.spoiler:
info["fi.mau.telegram.spoiler"] = True
if not name:
ext = sane_mimetypes.guess_extension(file.mime_type) or ""
name = "unnamed_file" + ext
content = MediaMessageEventContent(
body=name,
info=info,
msgtype={
"video/": MessageType.VIDEO,
"audio/": MessageType.AUDIO,
"image/": MessageType.IMAGE,
}.get(info.mimetype[:6], MessageType.FILE),
)
if event_type == EventType.STICKER:
content.msgtype = None
if attrs.is_audio:
content["org.matrix.msc1767.audio"] = {"duration": attrs.duration * 1000}
if attrs.waveform:
content["org.matrix.msc1767.audio"]["waveform"] = [x << 5 for x in attrs.waveform]
if attrs.is_voice:
content["org.matrix.msc3245.voice"] = {}
if file.decryption_info:
content.file = file.decryption_info
else:
content.url = file.mxc
caption_content = (
await formatter.telegram_to_matrix(evt, source, client) if evt.message else None
)
return ConvertedMessage(
type=event_type,
content=content,
caption=caption_content,
disappear_seconds=evt.media.ttl_seconds,
)
@staticmethod
async def _convert_location(evt: Message, **_) -> ConvertedMessage:
long = evt.media.geo.long
lat = evt.media.geo.lat
long_char = "E" if long > 0 else "W"
lat_char = "N" if lat > 0 else "S"
geo = f"{round(lat, 6)},{round(long, 6)}"
body = f"{round(abs(lat), 4)}° {lat_char}, {round(abs(long), 4)}° {long_char}"
url = f"https://maps.google.com/?q={geo}"
if isinstance(evt.media, MessageMediaGeoLive):
note = "Live Location (see your Telegram client for live updates)"
elif isinstance(evt.media, MessageMediaVenue):
note = evt.media.title
else:
note = "Location"
content = LocationMessageEventContent(
msgtype=MessageType.LOCATION,
geo_uri=f"geo:{geo}",
body=f"{note}: {body}\n{url}",
)
content["format"] = str(Format.HTML)
content["formatted_body"] = f"{note}: <a href='{url}'>{body}</a>"
content["org.matrix.msc3488.location"] = {
"uri": content.geo_uri,
"description": note,
}
return ConvertedMessage(content=content)
@staticmethod
async def _convert_unsupported(
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
) -> ConvertedMessage:
override_text = (
"This message is not supported on your version of Mautrix-Telegram. "
"Please check https://github.com/mautrix/telegram or ask your "
"bridge administrator about possible updates."
)
content = await formatter.telegram_to_matrix(
evt, source, client, override_text=override_text
)
content.msgtype = MessageType.NOTICE
content["fi.mau.telegram.unsupported"] = True
return ConvertedMessage(content=content)
async def _convert_poll(self, source: au.AbstractUser, evt: Message, **_) -> ConvertedMessage:
poll: Poll = evt.media.poll
poll_id = self._encode_msgid(source, evt)
_n = 0
def n() -> int:
nonlocal _n
_n += 1
return _n
text_answers = "\n".join(f"{n()}. {answer.text}" for answer in poll.answers)
html_answers = "\n".join(f"<li>{answer.text}</li>" for answer in poll.answers)
vote_command = f"{self.command_prefix} vote {poll_id}"
content = TextMessageEventContent(
msgtype=MessageType.TEXT,
format=Format.HTML,
body=(
f"Poll: {poll.question}\n{text_answers}\n"
f"Vote with {vote_command} <choice number>"
),
formatted_body=(
f"<strong>Poll</strong>: {poll.question}<br/>\n"
f"<ol>{html_answers}</ol>\n"
f"Vote with <code>{vote_command} &lt;choice number&gt;</code>"
),
)
return ConvertedMessage(content=content)
@staticmethod
async def _convert_dice(evt: Message, **_) -> ConvertedMessage:
roll: MessageMediaDice = evt.media
emoji_text = {
"\U0001F3AF": " Dart throw",
"\U0001F3B2": " Dice roll",
"\U0001F3C0": " Basketball throw",
"\U0001F3B0": " Slot machine",
"\U0001F3B3": " Bowling",
"\u26BD": " Football kick",
}
text = f"{roll.emoticon}{emoji_text.get(roll.emoticon, '')} result: {_format_dice(roll)}"
content = TextMessageEventContent(
msgtype=MessageType.TEXT,
format=Format.HTML,
body=text,
formatted_body=f"<h4>{text}</h4>",
)
content["fi.mau.telegram.dice"] = {"emoticon": roll.emoticon, "value": roll.value}
return ConvertedMessage(content=content)
async def _convert_game(
self, source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
) -> ConvertedMessage:
game: Game = evt.media.game
play_id = self._encode_msgid(source, evt)
command = f"{self.command_prefix} play {play_id}"
override_text = f"Run {command} in your bridge management room to play {game.title}"
override_entities = [
MessageEntityPre(offset=len("Run "), length=len(command), language="")
]
content = await formatter.telegram_to_matrix(
evt, source, client, override_text=override_text, override_entities=override_entities
)
content.msgtype = MessageType.NOTICE
content["fi.mau.telegram.game"] = play_id
return ConvertedMessage(content=content)
@staticmethod
async def _convert_contact(
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
) -> ConvertedMessage:
contact: MessageMediaContact = evt.media
name = " ".join(x for x in [contact.first_name, contact.last_name] if x)
formatted_phone = f"+{contact.phone_number}"
if phonenumbers is not None:
try:
parsed = phonenumbers.parse(formatted_phone)
fmt = phonenumbers.PhoneNumberFormat.INTERNATIONAL
formatted_phone = phonenumbers.format_number(parsed, fmt)
except phonenumbers.NumberParseException:
pass
content = TextMessageEventContent(
msgtype=MessageType.TEXT,
body=f"Shared contact info for {name}: {formatted_phone}",
)
content["fi.mau.telegram.contact"] = {
"user_id": contact.user_id,
"first_name": contact.first_name,
"last_name": contact.last_name,
"phone_number": contact.phone_number,
"vcard": contact.vcard,
}
puppet = await pu.Puppet.get_by_tgid(TelegramID(contact.user_id))
if not puppet.displayname:
try:
entity = await client.get_entity(PeerUser(contact.user_id))
await puppet.update_info(source, entity, client_override=client)
except Exception as e:
source.log.warning(f"Failed to sync puppet info of received contact: {e}")
else:
content.format = Format.HTML
content.formatted_body = (
f"Shared contact info for "
f"<a href='https://matrix.to/#/{puppet.mxid}'>{html.escape(name)}</a>: "
f"{html.escape(formatted_phone)}"
)
return ConvertedMessage(content=content)
@staticmethod
async def _convert_story(
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
) -> ConvertedMessage:
content = await formatter.telegram_to_matrix(
evt, source, client, override_text="Stories are not yet supported"
)
content.msgtype = MessageType.NOTICE
content["fi.mau.telegram.unsupported"] = True
return ConvertedMessage(content=content)
@staticmethod
async def _convert_invoice(
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
) -> ConvertedMessage:
content = await formatter.telegram_to_matrix(
evt, source, client, override_text="Invoices are not yet supported"
)
content.msgtype = MessageType.NOTICE
content["fi.mau.telegram.unsupported"] = True
return ConvertedMessage(content=content)
def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAttrs:
name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0
is_gif, is_audio, is_voice, duration, waveform = False, False, False, 0, bytes()
sticker_pack_ref = None
for attr in attributes:
if isinstance(attr, DocumentAttributeFilename):
name = name or attr.file_name
mime_type, _ = mimetypes.guess_type(attr.file_name)
elif isinstance(attr, DocumentAttributeSticker):
is_sticker = True
sticker_alt = attr.alt
if isinstance(attr.stickerset, InputStickerSetID):
sticker_pack_ref = {
"id": str(attr.stickerset.id),
"access_hash": str(attr.stickerset.access_hash),
}
elif isinstance(attr.stickerset, InputStickerSetShortName):
sticker_pack_ref = {"short_name": attr.stickerset.short_name}
elif isinstance(attr, DocumentAttributeAnimated):
is_gif = True
elif isinstance(attr, DocumentAttributeVideo):
width, height = attr.w, attr.h
elif isinstance(attr, DocumentAttributeImageSize):
width, height = attr.w, attr.h
elif isinstance(attr, DocumentAttributeAudio):
is_audio = True
is_voice = attr.voice or False
duration = attr.duration
waveform = decode_waveform(attr.waveform) if attr.waveform else b""
return DocAttrs(
name,
mime_type,
is_sticker,
sticker_alt,
sticker_pack_ref,
width,
height,
is_gif,
is_audio,
is_voice,
duration,
waveform,
)
def _parse_document_meta(
evt: Message, file: DBTelegramFile, attrs: DocAttrs, thumb_size: TypePhotoSize
) -> tuple[ImageInfo, str]:
document = evt.media.document
name = attrs.name
if attrs.is_sticker:
alt = attrs.sticker_alt
if len(alt) > 0:
try:
name = f"{alt} ({unicodedata.name(alt[0]).lower()})"
except ValueError:
name = alt
generic_types = ("text/plain", "application/octet-stream")
if file.mime_type in generic_types and document.mime_type not in generic_types:
mime_type = document.mime_type or file.mime_type
elif file.mime_type == "application/ogg":
mime_type = "audio/ogg"
else:
mime_type = file.mime_type or document.mime_type
info = ImageInfo(size=file.size, mimetype=mime_type)
if attrs.is_sticker:
info["fi.mau.telegram.sticker"] = {
"alt": attrs.sticker_alt,
"id": str(document.id),
"pack": attrs.sticker_pack_ref,
}
if attrs.mime_type and not file.was_converted:
file.mime_type = attrs.mime_type or file.mime_type
if file.width and file.height:
info.width, info.height = file.width, file.height
elif attrs.width and attrs.height:
info.width, info.height = attrs.width, attrs.height
if file.thumbnail:
if file.thumbnail.decryption_info:
info.thumbnail_file = file.thumbnail.decryption_info
else:
info.thumbnail_url = file.thumbnail.mxc
info.thumbnail_info = ThumbnailInfo(
mimetype=file.thumbnail.mime_type,
height=file.thumbnail.height or thumb_size.h,
width=file.thumbnail.width or thumb_size.w,
size=file.thumbnail.size,
)
elif attrs.is_sticker:
# This is a hack for bad clients like Element iOS that require a thumbnail
info.thumbnail_info = ImageInfo.deserialize(info.serialize())
if file.decryption_info:
info.thumbnail_file = file.decryption_info
else:
info.thumbnail_url = file.mxc
return info, name
def _format_dice(roll: MessageMediaDice) -> str:
if roll.emoticon == "\U0001F3B0":
emojis = {
0: "\U0001F36B", # "🍫",
1: "\U0001F352", # "🍒",
2: "\U0001F34B", # "🍋",
3: "7\ufe0f\u20e3", # "7️⃣",
}
res = roll.value - 1
slot1, slot2, slot3 = emojis[res % 4], emojis[res // 4 % 4], emojis[res // 16]
return f"{slot1} {slot2} {slot3} ({roll.value})"
elif roll.emoticon == "\u26BD":
results = {
1: "miss",
2: "hit the woodwork",
3: "goal", # seems to go in through the center
4: "goal",
5: "goal 🎉", # seems to go in through the top right corner, includes confetti
}
elif roll.emoticon == "\U0001F3B3":
results = {
1: "miss",
2: "1 pin down",
3: "3 pins down, split",
4: "4 pins down, split",
5: "5 pins down",
6: "strike 🎉",
}
# elif roll.emoticon == "\U0001F3C0":
# results = {
# 2: "rolled off",
# 3: "stuck",
# }
# elif roll.emoticon == "\U0001F3AF":
# results = {
# 1: "bounced off",
# 2: "outer rim",
#
# 6: "bullseye",
# }
else:
return str(roll.value)
return f"{results[roll.value]} ({roll.value})"
@@ -1,109 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import Iterable
from telethon.errors import ChatAdminRequiredError
from telethon.tl.functions.channels import GetParticipantsRequest
from telethon.tl.functions.messages import GetFullChatRequest
from telethon.tl.types import (
ChannelParticipantBanned,
ChannelParticipantsRecent,
ChannelParticipantsSearch,
ChatParticipantsForbidden,
InputChannel,
InputUser,
TypeChannelParticipant,
TypeChat,
TypeChatParticipant,
TypeInputPeer,
TypeUser,
)
from ..tgclient import MautrixTelegramClient
def _filter_participants(
users: list[TypeUser], participants: list[TypeChatParticipant | TypeChannelParticipant]
) -> Iterable[TypeUser]:
participant_map = {
part.user_id: part
for part in participants
if not isinstance(part, ChannelParticipantBanned)
}
for user in users:
try:
user.participant = participant_map[user.id]
except KeyError:
pass
else:
yield user
async def _get_channel_users(
client: MautrixTelegramClient, entity: InputChannel, limit: int
) -> list[TypeUser]:
if 0 < limit <= 200:
response = await client(
GetParticipantsRequest(
entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0
)
)
return list(_filter_participants(response.users, response.participants))
elif limit > 200 or limit == -1:
users: list[TypeUser] = []
offset = 0
remaining_quota = limit if limit > 0 else 1000000
query = ChannelParticipantsSearch("") if limit == -1 else ChannelParticipantsRecent()
while True:
if remaining_quota <= 0:
break
response = await client(
GetParticipantsRequest(
entity, query, offset=offset, limit=min(remaining_quota, 200), hash=0
)
)
if not response.users:
break
users += _filter_participants(response.users, response.participants)
offset += len(response.participants)
remaining_quota -= len(response.participants)
return users
async def get_users(
client: MautrixTelegramClient,
tgid: int,
entity: TypeInputPeer | InputUser | TypeChat | TypeUser | InputChannel,
limit: int,
peer_type: str,
) -> list[TypeUser]:
if peer_type == "chat":
chat = await client(GetFullChatRequest(chat_id=tgid))
if isinstance(chat.full_chat.participants, ChatParticipantsForbidden):
return []
users = list(_filter_participants(chat.users, chat.full_chat.participants.participants))
return users[:limit] if limit > 0 else users
elif peer_type == "channel":
try:
return await _get_channel_users(client, entity, limit)
except ChatAdminRequiredError:
return []
elif peer_type == "user":
return [entity]
else:
raise RuntimeError(f"Unexpected peer type {peer_type}")
@@ -1,156 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from telethon.tl.types import (
ChannelParticipantAdmin,
ChannelParticipantCreator,
ChatBannedRights,
ChatParticipantAdmin,
ChatParticipantCreator,
TypeChannelParticipant,
TypeChat,
TypeChatParticipant,
TypeUser,
)
from mautrix.types import EventType, PowerLevelStateEventContent as PowerLevelContent, UserID
from .. import portal as po, puppet as pu, user as u
from ..types import TelegramID
def get_base_power_levels(
portal: po.Portal,
levels: PowerLevelContent = None,
entity: TypeChat | None = None,
dbr: ChatBannedRights | None = None,
) -> PowerLevelContent:
is_initial = not levels
levels = levels or PowerLevelContent()
if portal.peer_type == "user":
overrides = portal.config["bridge.initial_power_level_overrides.user"]
levels.ban = overrides.get("ban", 100)
levels.kick = overrides.get("kick", 100)
levels.invite = overrides.get("invite", 100)
levels.redact = overrides.get("redact", 0)
levels.events[EventType.ROOM_NAME] = 0
levels.events[EventType.ROOM_AVATAR] = 0
levels.events[EventType.ROOM_TOPIC] = 0
levels.state_default = overrides.get("state_default", 0)
levels.users_default = overrides.get("users_default", 0)
levels.events_default = overrides.get("events_default", 0)
else:
overrides = portal.config["bridge.initial_power_level_overrides.group"]
dbr = dbr or entity.default_banned_rights
if not dbr:
portal.log.debug(f"default_banned_rights is None in {entity}")
dbr = ChatBannedRights(
invite_users=True,
change_info=True,
pin_messages=True,
send_stickers=False,
send_messages=False,
until_date=None,
)
levels.ban = overrides.get("ban", 50)
levels.kick = overrides.get("kick", 50)
levels.redact = overrides.get("redact", 50)
levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0)
levels.events[EventType.ROOM_ENCRYPTION] = 50 if portal.matrix.e2ee else 99
levels.events[EventType.ROOM_TOMBSTONE] = 99
levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0
levels.events[EventType.ROOM_AVATAR] = 50 if dbr.change_info else 0
levels.events[EventType.ROOM_TOPIC] = 50 if dbr.change_info else 0
levels.events[EventType.ROOM_PINNED_EVENTS] = 50 if dbr.pin_messages else 0
levels.events[EventType.ROOM_POWER_LEVELS] = 75
levels.events[EventType.ROOM_HISTORY_VISIBILITY] = 75
levels.events[EventType.STICKER] = 50 if dbr.send_stickers else levels.events_default
levels.state_default = overrides.get("state_default", 50)
levels.users_default = overrides.get("users_default", 0)
levels.events_default = overrides.get(
"events_default",
50
if portal.peer_type == "channel" and not portal.megagroup or dbr.send_messages
else 0,
)
for evt_type, value in overrides.get("events", {}).items():
levels.events[EventType.find(evt_type)] = value
userlevel_overrides = overrides.get("users", {})
if is_initial:
levels.users.update(userlevel_overrides)
if portal.main_intent.mxid not in levels.users:
levels.users[portal.main_intent.mxid] = 100
return levels
async def participants_to_power_levels(
portal: po.Portal,
users: list[TypeUser | TypeChatParticipant | TypeChannelParticipant],
levels: PowerLevelContent,
) -> bool:
bot_level = levels.get_user_level(portal.main_intent.mxid)
if bot_level < levels.get_event_level(EventType.ROOM_POWER_LEVELS):
return False
changed = False
admin_power_level = min(75 if portal.peer_type == "channel" else 50, bot_level)
if levels.get_event_level(EventType.ROOM_POWER_LEVELS) != admin_power_level:
changed = True
levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
for user in users:
# The User objects we get from TelegramClient.get_participants have a custom
# participant property
participant = getattr(user, "participant", user)
puppet = await pu.Puppet.get_by_tgid(TelegramID(participant.user_id))
user = await u.User.get_by_tgid(TelegramID(participant.user_id))
new_level = _get_level_from_participant(portal.az.bot_mxid, participant, levels)
if user:
await user.register_portal(portal)
changed = _participant_to_power_levels(levels, user, new_level, bot_level) or changed
if puppet:
changed = _participant_to_power_levels(levels, puppet, new_level, bot_level) or changed
return changed
def _get_level_from_participant(
bot_mxid: UserID,
participant: TypeUser | TypeChatParticipant | TypeChannelParticipant,
levels: PowerLevelContent,
) -> int:
# TODO use the power level requirements to get better precision in channels
if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
return levels.state_default or 50
elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)):
return levels.get_user_level(bot_mxid) - 5
return levels.users_default or 0
def _participant_to_power_levels(
levels: PowerLevelContent,
user: u.User | pu.Puppet,
new_level: int,
bot_level: int,
) -> bool:
new_level = min(new_level, bot_level)
user_level = levels.get_user_level(user.mxid)
if user_level != new_level and user_level < bot_level:
levels.users[user.mxid] = new_level
return True
return False
-57
View File
@@ -1,57 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from asyncio import Lock
from collections import defaultdict
from mautrix.types import EventID
from ..types import TelegramID
class FakeLock:
async def __aenter__(self) -> None:
pass
async def __aexit__(self, exc_type, exc, tb) -> None:
pass
class PortalSendLock:
_send_locks: dict[int, Lock]
_noop_lock: Lock = FakeLock()
def __init__(self) -> None:
self._send_locks = {}
def __call__(self, user_id: TelegramID, required: bool = True) -> Lock:
if user_id is None and required:
raise ValueError("Required send lock for none id")
try:
return self._send_locks[user_id]
except KeyError:
return self._send_locks.setdefault(user_id, Lock()) if required else self._noop_lock
class PortalReactionLock:
_reaction_locks: dict[EventID, Lock]
def __init__(self) -> None:
self._reaction_locks = defaultdict(lambda: Lock())
def __call__(self, mxid: EventID) -> Lock:
return self._reaction_locks[mxid]
@@ -1,97 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
import base64
import html
from telethon.tl.functions.channels import GetSponsoredMessagesRequest
from telethon.tl.types import Channel, InputChannel, PeerChannel, PeerUser, SponsoredMessage, User
from telethon.tl.types.messages import SponsoredMessages, SponsoredMessagesEmpty
from mautrix.types import MessageType, TextMessageEventContent
from .. import user as u
from ..formatter import telegram_to_matrix
async def get_sponsored_message(
user: u.User,
entity: InputChannel,
) -> tuple[SponsoredMessage | None, int | None, Channel | User | None]:
resp = await user.client(GetSponsoredMessagesRequest(entity))
if isinstance(resp, SponsoredMessagesEmpty):
return None, None, None
assert isinstance(resp, SponsoredMessages)
msg = resp.messages[0]
if isinstance(msg.from_id, PeerUser):
entities = resp.users
target_id = msg.from_id.user_id
else:
entities = resp.chats
target_id = msg.from_id.channel_id
try:
entity = next(ent for ent in entities if ent.id == target_id)
except StopIteration:
entity = None
return msg, target_id, entity
async def make_sponsored_message_content(
source: u.User, msg: SponsoredMessage, entity: Channel | User
) -> TextMessageEventContent | None:
content = await telegram_to_matrix(msg, source, require_html=True)
content.external_url = f"https://t.me/{entity.username}"
content.msgtype = MessageType.NOTICE
sponsored_meta = {
"random_id": base64.b64encode(msg.random_id).decode("utf-8"),
}
if isinstance(msg.from_id, PeerChannel):
sponsored_meta["channel_id"] = msg.from_id.channel_id
if getattr(msg, "channel_post", None) is not None:
sponsored_meta["channel_post"] = msg.channel_post
content.external_url += f"/{msg.channel_post}"
action = "View Post"
else:
action = "View Channel"
elif isinstance(msg.from_id, PeerUser):
sponsored_meta["bot_id"] = msg.from_id.user_id
if msg.start_param:
content.external_url += f"?start={msg.start_param}"
action = "View Bot"
else:
return None
if isinstance(entity, User):
name_parts = [entity.first_name, entity.last_name]
sponsor_name = " ".join(x for x in name_parts if x)
sponsor_name_html = f"<strong>{html.escape(sponsor_name)}</strong>"
elif isinstance(entity, Channel):
sponsor_name = entity.title
sponsor_name_html = f"<strong>{html.escape(sponsor_name)}</strong>"
else:
sponsor_name = sponsor_name_html = "unknown entity"
content["fi.mau.telegram.sponsored"] = sponsored_meta
content.formatted_body += (
f"<br/><br/>Sponsored message from {sponsor_name_html} "
f"- <a href='{content.external_url}'>{action}</a>"
)
content.body += (
f"\n\nSponsored message from {sponsor_name} - {action} at {content.external_url}"
)
return content
-601
View File
@@ -1,601 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, cast
from difflib import SequenceMatcher
import unicodedata
from telethon import utils
from telethon.tl.types import (
Channel,
ChatPhoto,
ChatPhotoEmpty,
InputPeerPhotoFileLocation,
InputPeerUser,
PeerChannel,
PeerChat,
PeerUser,
TypeChatPhoto,
TypePeer,
TypeUserProfilePhoto,
UpdateUserName,
User,
UserProfilePhoto,
UserProfilePhotoEmpty,
)
from yarl import URL
from mautrix.appservice import IntentAPI
from mautrix.bridge import BasePuppet, async_getter_lock
from mautrix.types import ContentURI, RoomID, SyncToken, UserID
from mautrix.util.simple_template import SimpleTemplate
from . import abstract_user as au, portal as p, util
from .config import Config
from .db import Puppet as DBPuppet
from .tgclient import MautrixTelegramClient
from .types import TelegramID
if TYPE_CHECKING:
from .__main__ import TelegramBridge
class Puppet(DBPuppet, BasePuppet):
bridge: TelegramBridge
config: Config
hs_domain: str
mxid_template: SimpleTemplate[TelegramID]
displayname_template: SimpleTemplate[str]
by_tgid: dict[TelegramID, Puppet] = {}
by_custom_mxid: dict[UserID, Puppet] = {}
def __init__(
self,
id: TelegramID,
is_registered: bool = False,
displayname: str | None = None,
displayname_source: TelegramID | None = None,
displayname_contact: bool = True,
displayname_quality: int = 0,
disable_updates: bool = False,
username: str | None = None,
phone: str | None = None,
photo_id: str | None = None,
avatar_url: ContentURI | None = None,
name_set: bool = False,
avatar_set: bool = False,
contact_info_set: bool = False,
is_bot: bool = False,
is_channel: bool = False,
is_premium: bool = False,
custom_mxid: UserID | None = None,
access_token: str | None = None,
next_batch: SyncToken | None = None,
base_url: str | None = None,
) -> None:
super().__init__(
id=id,
is_registered=is_registered,
displayname=displayname,
displayname_source=displayname_source,
displayname_contact=displayname_contact,
displayname_quality=displayname_quality,
disable_updates=disable_updates,
username=username,
phone=phone,
photo_id=photo_id,
avatar_url=avatar_url,
name_set=name_set,
avatar_set=avatar_set,
contact_info_set=contact_info_set,
is_bot=is_bot,
is_channel=is_channel,
is_premium=is_premium,
custom_mxid=custom_mxid,
access_token=access_token,
next_batch=next_batch,
base_url=base_url,
)
self.default_mxid = self.get_mxid_from_id(self.id)
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
self.intent = self._fresh_intent()
self.by_tgid[id] = self
if self.custom_mxid:
self.by_custom_mxid[self.custom_mxid] = self
self.log = self.log.getChild(str(self.id))
@property
def tgid(self) -> TelegramID:
return self.id
@property
def tg_username(self) -> str | None:
return self.username
@property
def peer(self) -> PeerUser:
return (
PeerChannel(channel_id=self.tgid) if self.is_channel else PeerUser(user_id=self.tgid)
)
@property
def contact_info(self) -> dict:
return {
"name": self.displayname,
"username": self.username,
"phone": f"+{self.phone.lstrip('+')}" if self.phone else None,
"is_bot": self.is_bot,
"avatar_url": self.avatar_url,
}
@property
def plain_displayname(self) -> str:
return self.displayname_template.parse(self.displayname) or self.displayname
def intent_for(self, portal: p.Portal) -> IntentAPI:
if portal.tgid == self.tgid:
return self.default_mxid_intent
return self.intent
@classmethod
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
cls.bridge = bridge
cls.config = bridge.config
cls.loop = bridge.loop
cls.mx = bridge.matrix
cls.az = bridge.az
cls.hs_domain = cls.config["homeserver.domain"]
mxid_tpl = SimpleTemplate(
cls.config["bridge.username_template"],
"userid",
prefix="@",
suffix=f":{Puppet.hs_domain}",
type=int,
)
cls.mxid_template = cast(SimpleTemplate[TelegramID], mxid_tpl)
cls.displayname_template = SimpleTemplate(
cls.config["bridge.displayname_template"], "displayname"
)
cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"]
cls.homeserver_url_map = {
server: URL(url)
for server, url in cls.config["bridge.double_puppet_server_map"].items()
}
cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"]
cls.login_shared_secret_map = {
server: secret.encode("utf-8")
for server, secret in cls.config["bridge.login_shared_secret_map"].items()
}
cls.login_device_name = "Telegram Bridge"
return (puppet.try_start() async for puppet in cls.all_with_custom_mxid())
# 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.plain_displayname, query).ratio() if self.displayname else 0
)
similarity = max(username_similarity, displayname_similarity)
return int(round(similarity * 100))
@staticmethod
def _filter_name(name: str) -> str:
if not name:
return ""
whitespace = (
"\t\n\r\v\f \u00a0\u034f\u180e\u2063\u202f\u205f\u2800\u3000\u3164\ufeff\u2000\u2001"
"\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b\u200c\u200d\u200e\u200f"
"\ufe0f"
)
allowed_other_format = ("\u200d", "\u200c")
name = "".join(
c
for c in name.strip(whitespace)
if unicodedata.category(c) != "Cf" or c in allowed_other_format
)
return name
@classmethod
def get_displayname(cls, info: User | Channel, enable_format: bool = True) -> tuple[str, int]:
if isinstance(info, Channel):
fn, ln = cls._filter_name(info.title), ""
else:
fn = cls._filter_name(info.first_name)
ln = cls._filter_name(info.last_name)
data = {
"phone number": info.phone if hasattr(info, "phone") else None,
"username": info.username,
"full name": " ".join([fn, ln]).strip(),
"full name reversed": " ".join([ln, fn]).strip(),
"first name": fn,
"last name": ln,
}
preferences = cls.config["bridge.displayname_preference"]
name = None
quality = 99
for preference in preferences:
name = data[preference]
if name:
break
quality -= 1
if isinstance(info, User) and info.deleted:
name = f"Deleted account {info.id}"
quality = 99
elif not name:
name = str(info.id)
quality = 0
return (cls.displayname_template.format_full(name) if enable_format else name), quality
async def try_update_info(self, source: au.AbstractUser, info: User | Channel) -> None:
try:
await self.update_info(source, info)
except Exception:
source.log.exception(f"Failed to update info of {self.tgid}")
async def update_info(
self,
source: au.AbstractUser,
info: User | Channel,
client_override: MautrixTelegramClient | None = None,
) -> None:
is_bot = False if isinstance(info, Channel) else info.bot
is_premium = False if isinstance(info, Channel) else info.premium
is_channel = isinstance(info, Channel)
changed = (
is_bot != self.is_bot or is_channel != self.is_channel or is_premium != self.is_premium
)
if is_bot is not None:
self.is_bot = is_bot
self.is_channel = is_channel
if is_premium is not None:
self.is_premium = is_premium
if self.username != info.username and (info.username or not info.min):
self.log.debug(f"Updating username {self.username} -> {info.username}")
self.username = info.username
changed = True
if getattr(info, "phone", None) and self.phone != info.phone:
self.phone = info.phone
changed = True
if not self.disable_updates:
try:
changed = await self._update_contact_info(force=changed) or changed
changed = (
await self.update_displayname(source, info, client_override=client_override)
or changed
)
changed = (
await self.update_avatar(
source, info.photo, entity=info, client_override=client_override
)
or changed
)
except Exception:
self.log.exception(f"Failed to update info from source {source.tgid}")
if changed:
await self.update_portals_meta()
await self.save()
async def _update_contact_info(self, force: bool = False) -> bool:
if not self.bridge.homeserver_software.is_hungry:
return False
if self.contact_info_set and not force:
return False
try:
identifiers = []
if self.username:
identifiers.append(f"telegram:{self.username}")
if self.phone:
phone = "+" + self.phone.lstrip("+")
identifiers.append(f"tel:{phone}")
await self.default_mxid_intent.beeper_update_profile(
{
"com.beeper.bridge.identifiers": identifiers,
"com.beeper.bridge.remote_id": str(self.tgid),
"com.beeper.bridge.service": "telegram",
"com.beeper.bridge.network": "telegram",
"com.beeper.bridge.is_network_bot": self.is_bot,
}
)
self.contact_info_set = True
except Exception:
self.log.exception("Error updating contact info")
self.contact_info_set = False
return True
async def update_portals_meta(self) -> None:
if p.Portal.private_chat_portal_meta != "always" and not self.mx.e2ee:
return
async for portal in p.Portal.find_private_chats_with(self.tgid):
await portal.update_info_from_puppet(self)
async def update_displayname(
self,
source: au.AbstractUser,
info: User | Channel | UpdateUserName,
client_override: MautrixTelegramClient | None = None,
) -> bool:
if self.disable_updates:
return False
if (
self.displayname
and self.displayname.startswith("Deleted user ")
and not getattr(info, "deleted", False)
):
allow_because = "target user was previously deleted"
self.displayname_quality = 0
elif source.is_relaybot or source.is_bot:
allow_because = "source user is a bot"
elif self.displayname_source == source.tgid:
allow_because = "source user is the primary source"
elif isinstance(info, Channel):
allow_because = "target user is a channel"
elif not isinstance(info, UpdateUserName) and not info.contact:
allow_because = "target user is not a contact"
elif not self.displayname_source:
allow_because = "no primary source set"
elif not self.displayname:
allow_because = "target user has no name"
else:
return False
if isinstance(info, UpdateUserName):
info = await (client_override or source.client).get_entity(self.peer)
is_contact_name = not isinstance(info, Channel) and info.contact
# Reject name change if the contact status is moving in an unwanted direction,
# and we already have a name for the ghost.
if (
is_contact_name != self.displayname_contact
and is_contact_name != self.config["bridge.allow_contact_info"]
and self.displayname
):
return False
displayname, quality = self.get_displayname(info)
needs_reset = displayname != self.displayname or not self.name_set
is_high_quality = quality >= self.displayname_quality
if needs_reset and is_high_quality:
allow_because = f"{allow_because} and quality {quality} >= {self.displayname_quality}"
self.log.debug(
f"Updating displayname of {self.id} (src: {source.tgid}, "
f"contact: {is_contact_name}, allowed because {allow_because}) "
f"from {self.displayname} to {displayname}"
)
self.log.trace("Displayname source data: %s", info)
self.displayname = displayname
self.displayname_source = source.tgid
self.displayname_contact = is_contact_name
self.displayname_quality = quality
try:
await self.default_mxid_intent.set_displayname(
displayname[: self.config["bridge.displayname_max_length"]]
)
self.name_set = True
except Exception as e:
self.log.warning(f"Failed to set displayname: {e}")
self.name_set = False
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: au.AbstractUser,
photo: TypeUserProfilePhoto | TypeChatPhoto,
entity: User | None = None,
client_override: MautrixTelegramClient | None = None,
) -> bool:
if self.disable_updates:
return False
if (
isinstance(photo, UserProfilePhoto)
and photo.personal
and not self.config["bridge.allow_contact_info"]
):
self.log.trace(
"Dropping user avatar as it's personal "
"and contact info is disabled in bridge config"
)
return False
if photo is None or isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty)):
photo_id = ""
elif isinstance(photo, (UserProfilePhoto, ChatPhoto)):
photo_id = str(photo.photo_id)
else:
self.log.warning(f"Unknown user profile photo type: {type(photo)}")
return False
if not photo_id and not self.config["bridge.allow_avatar_remove"]:
return False
if self.photo_id != photo_id or not self.avatar_set:
if not photo_id:
self.photo_id = ""
self.avatar_url = None
elif self.photo_id != photo_id or not self.avatar_url:
client = client_override or source.client
try:
peer = await client.get_input_entity(entity or self.peer)
except ValueError:
if entity:
peer = utils.get_input_peer(entity, check_hash=False)
else:
self.log.warning(f"Couldn't get input entity to update avatar")
return False
file = await util.transfer_file_to_matrix(
client=client,
intent=self.default_mxid_intent,
location=InputPeerPhotoFileLocation(
peer=peer,
photo_id=photo.photo_id,
big=True,
),
async_upload=self.config["homeserver.async_media"],
)
if not file:
return False
self.photo_id = photo_id
self.avatar_url = file.mxc
try:
await self.default_mxid_intent.set_avatar_url(self.avatar_url or "")
self.avatar_set = True
except Exception as e:
self.log.warning(f"Failed to set avatar: {e}")
self.avatar_set = False
return True
return False
async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
portal: p.Portal = await p.Portal.get_by_mxid(room_id)
return portal and portal.peer_type != "user"
# endregion
# region Getters
def _add_to_cache(self) -> None:
self.by_tgid[self.id] = self
if self.custom_mxid:
self.by_custom_mxid[self.custom_mxid] = self
@classmethod
@async_getter_lock
async def get_by_tgid(
cls, tgid: TelegramID, /, *, create: bool = True, is_channel: bool = False
) -> Puppet | None:
if tgid is None:
return None
try:
return cls.by_tgid[tgid]
except KeyError:
pass
puppet = cast(cls, await super().get_by_tgid(tgid))
if puppet:
puppet._add_to_cache()
return puppet
if create:
puppet = cls(tgid, is_channel=is_channel)
await puppet.insert()
puppet._add_to_cache()
return puppet
return None
@staticmethod
def get_id_from_peer(peer: TypePeer | User | Channel) -> TelegramID:
if isinstance(peer, (PeerUser, InputPeerUser)):
return TelegramID(peer.user_id)
elif isinstance(peer, PeerChannel):
return TelegramID(peer.channel_id)
elif isinstance(peer, PeerChat):
return TelegramID(peer.chat_id)
elif isinstance(peer, (User, Channel)):
return TelegramID(peer.id)
raise TypeError(f"invalid type {type(peer).__name__!r} in _id_from_peer()")
@classmethod
async def get_by_peer(
cls, peer: TypePeer | User | Channel, *, create: bool = True
) -> Puppet | None:
if isinstance(peer, PeerChat):
return None
return await cls.get_by_tgid(
cls.get_id_from_peer(peer),
create=create,
is_channel=isinstance(peer, (PeerChannel, Channel)),
)
@classmethod
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Awaitable[Puppet | None]:
return cls.get_by_tgid(cls.get_id_from_mxid(mxid), create=create)
@classmethod
@async_getter_lock
async def get_by_custom_mxid(cls, mxid: UserID, /) -> Puppet | None:
try:
return cls.by_custom_mxid[mxid]
except KeyError:
pass
puppet = cast(cls, await super().get_by_custom_mxid(mxid))
if puppet:
puppet._add_to_cache()
return puppet
return None
@classmethod
async def all_with_custom_mxid(cls) -> AsyncGenerator[Puppet, None]:
puppets = await super().all_with_custom_mxid()
puppet: cls
for puppet in puppets:
try:
yield cls.by_tgid[puppet.tgid]
except KeyError:
puppet._add_to_cache()
yield puppet
@classmethod
def get_id_from_mxid(cls, mxid: UserID) -> TelegramID | None:
return cls.mxid_template.parse(mxid)
@classmethod
def get_mxid_from_id(cls, tgid: TelegramID) -> UserID:
return UserID(cls.mxid_template.format_full(tgid))
@classmethod
async def find_by_username(cls, username: str) -> Puppet | None:
if not username:
return None
username = username.lower()
for _, puppet in cls.by_tgid.items():
if puppet.username and puppet.username.lower() == username:
return puppet
puppet = cast(cls, await super().find_by_username(username))
if puppet:
try:
return cls.by_tgid[puppet.tgid]
except KeyError:
puppet._add_to_cache()
return puppet
return None
# endregion
@@ -1,397 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Any, Literal, TypedDict
from pathlib import Path
import argparse
import asyncio
import io
import json
import logging
import math
import mimetypes
import pickle
import random
import string
from lottie.exporters import export_tgs
from lottie.exporters.cairo import export_png
from lottie.exporters.tgs_validator import Severity, TgsValidator
from lottie.importers.svg import import_svg
from lottie.objects import Animation
from lottie.utils.stripper import float_strip
from PIL import Image
from telethon import TelegramClient
from telethon.custom import Conversation, Message
from telethon.tl.functions.messages import GetStickerSetRequest
from telethon.tl.types import (
Document,
DocumentAttributeCustomEmoji,
DocumentAttributeFilename,
DocumentAttributeImageSize,
InputMediaUploadedDocument,
InputStickerSetShortName,
)
import aiohttp
mimetypes.add_type("image/webp", ".webp")
parser = argparse.ArgumentParser(description="mautrix-telegram unicode emoji packer")
parser.add_argument(
"-i", "--api-id", type=int, required=True, metavar="<api id>", help="Telegram API ID"
)
parser.add_argument(
"-a", "--api-hash", type=str, required=True, metavar="<api hash>", help="Telegram API hash"
)
parser.add_argument(
"-s",
"--session",
type=str,
default="unicodemojipacker.session",
metavar="<file name>",
help="Telethon session name",
)
parser.add_argument(
"-o",
"--output",
type=str,
default="mautrix_telegram/unicodemojipack.json",
metavar="<file name>",
help="Path to save created emoji pack document IDs",
)
parser.add_argument(
"-f",
"--font-directory",
type=Path,
required=True,
metavar="<directory path>",
help="Path to the Noto color emoji files",
)
parser.add_argument(
"-m",
"--media-directory",
type=Path,
required=True,
metavar="<directory path>",
help="Path to save converted tgs and webp emoji files",
)
args = parser.parse_args()
font_dir: Path = args.font_directory
media_dir: Path = args.media_directory
EMOJI_DATA_URL = "https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json"
def unified_to_unicode(unified: str) -> str:
return (
"".join(rf"\U{chunk:0>8}" for chunk in unified.split("-"))
.encode("ascii")
.decode("unicode_escape")
)
def tag_to_str(unified: str) -> str:
return "".join(chr(int(x.removeprefix("E00"), 16)) for x in unified.split("-"))
EmojiType = Literal["webp", "tgs"]
PackType = Literal["Animated emoji", "Static emoji"]
class Emoji(TypedDict):
hex: str
emoji: str
type: EmojiType
filename: str
class EmojiData(TypedDict):
tgs: list[Emoji]
webp: list[Emoji]
def parse_emoji_data(tone: dict[str, Any], emoji: dict[str, Any]) -> Emoji:
hex = (tone["non_qualified"] or tone["unified"]).replace("-FE0F", "")
filename_hex = hex.replace("-", "_").lower()
filename = f"svg/emoji_u{filename_hex}.svg"
if emoji["category"] == "Flags" and emoji["subcategory"] in (
"country-flag",
"subdivision-flag",
):
filename = f"third_party/region-flags/waved-svg/emoji_u{filename_hex}.svg"
with (font_dir / filename).open() as f:
lot: Animation = import_svg(f)
float_strip(lot)
lot.tgs_sanitize()
output = io.BytesIO()
export_tgs(lot, output)
validator = TgsValidator()
validator(lot)
validator.check_size(len(output.getvalue()))
errors = [err for err in validator.errors if err.severity != Severity.Note]
if errors or ("region-flags" in filename and len(output.getvalue()) > 32768):
lot.scale(100, 100)
png_out = io.BytesIO()
export_png(lot, png_out)
img = Image.open(png_out)
output = io.BytesIO()
output.name = "image.webp"
img.save(output, "webp")
media_type: EmojiType = "webp"
else:
media_type: EmojiType = "tgs"
path = media_dir / f"{filename_hex}.{media_type}"
with path.open("wb") as f:
f.write(output.getvalue())
print(
"Converted", filename, "->", path.name, "//" if errors else "", "\n".join(map(str, errors))
)
return {
"hex": hex,
"emoji": unified_to_unicode(tone["unified"]),
"type": media_type,
"filename": path.name,
}
async def load_emoji_data() -> EmojiData:
cache_path = media_dir / "conversion-cache.json"
try:
with cache_path.open() as f:
return json.load(f)
except FileNotFoundError:
pass
async with aiohttp.ClientSession() as sess, sess.get(EMOJI_DATA_URL) as resp:
raw_emoji_data = sorted(
await resp.json(content_type=None),
key=lambda dat: dat["sort_order"],
)
tgs_emoji = []
webp_emoji = []
for emoji in raw_emoji_data:
for tone in (emoji, *emoji.get("skin_variations", {}).values()):
parsed_emoji = parse_emoji_data(tone, emoji)
if parsed_emoji["type"] == "tgs":
tgs_emoji.append(parsed_emoji)
else:
webp_emoji.append(parsed_emoji)
full_data = {"tgs": tgs_emoji, "webp": webp_emoji}
with cache_path.open("w") as f:
json.dump(full_data, f, ensure_ascii=False)
return full_data
async def create_pack(conv: Conversation, name: str, pack_type: str) -> None:
await conv.send_message("/newemojipack")
resp: Message = await conv.get_response()
assert "A new set of custom emoji" in resp.raw_text
assert "Please choose the type" in resp.raw_text
await conv.send_message(pack_type)
resp = await conv.get_response()
if pack_type == "Animated emoji":
assert "When ready to upload, tell me the name of your set." in resp.raw_text
else:
assert "Now choose a name for your set." in resp.raw_text
await conv.send_message(name)
resp = await conv.get_response()
if pack_type == "Animated emoji":
assert "Now send me the first animated emoji" in resp.raw_text
else:
assert "Now send me the custom emoji" in resp.raw_text
async def publish_pack(conv: Conversation, shortname: str) -> None:
await conv.send_message("/publish")
resp: Message = await conv.get_response()
assert "You can send me a custom emoji from your emoji set" in resp.raw_text
await conv.send_message("/skip")
resp = await conv.get_response()
assert "Please provide a short name for your emoji set" in resp.raw_text
await conv.send_message(shortname)
resp = await conv.get_response()
assert "I've just published your emoji set" in resp.raw_text
async def send_emoji(
conv: Conversation, file: bytes | Path | InputMediaUploadedDocument, emoji: str
) -> None:
await conv.send_file(file)
resp: Message = await conv.get_response()
assert "Send me a replacement emoji that corresponds to your custom emoji" in resp.raw_text
await conv.send_message(emoji)
resp = await conv.get_response()
if "Sorry, too many attempts" in resp.raw_text:
print(resp.raw_text)
input("Press enter to continue")
await conv.send_message(emoji)
resp = await conv.get_response()
while "Please send an emoji that best describes your custom emoji." in resp.raw_text:
emoji = input(f"{emoji} was rejected, provide replacement: ")
await conv.send_message(emoji)
resp = await conv.get_response()
assert "Congratulations" in resp.raw_text
class CachedPack(TypedDict):
name: str
short_name: str
part: int
type: PackType
published: bool
collected: bool
emojis: list[Emoji]
class CachedData(TypedDict):
packs: list[CachedPack]
def _split_packs_int(
emoji_list: list[Emoji], pack_type: PackType, current_part: int, total_parts: int
) -> tuple[list[CachedPack], int]:
packs = []
current_pack: CachedPack | None = None
for i, emoji in enumerate(emoji_list):
if i % 200 == 0:
current_part += 1
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
short_name = f"mxtg_unicodemoji_{random_id}"
name = f"mautrix-telegram unicodemoji ({current_part}/{total_parts})"
current_pack = {
"type": pack_type,
"short_name": short_name,
"part": current_part,
"name": name,
"published": False,
"collected": False,
"emojis": [],
}
packs.append(current_pack)
current_pack["emojis"].append(emoji)
return packs, current_part
def split_packs(emoji_data: EmojiData) -> list[CachedPack]:
total_parts = math.ceil(len(emoji_data["tgs"]) / 200) + math.ceil(
len(emoji_data["webp"]) / 200
)
current_part = 0
animated_packs, current_part = _split_packs_int(
emoji_data["tgs"], "Animated emoji", current_part, total_parts
)
static_packs, current_part = _split_packs_int(
emoji_data["webp"], "Static emoji", current_part, total_parts
)
return animated_packs + static_packs
async def create_and_fill_pack(
client: TelegramClient, conv: Conversation, pack: CachedPack
) -> None:
if pack["short_name"] == "mxtg_unicodemoji_xvzs6743":
print("Continuing pack", pack["name"])
else:
print("Creating pack", pack["name"])
await create_pack(conv, pack["name"], pack["type"])
total = len(pack["emojis"])
for i, emoji in enumerate(pack["emojis"]):
if pack["short_name"] == "mxtg_unicodemoji_xvzs6743" and i < 87:
continue
print(f"Adding emoji {i+1}/{total}", emoji["hex"], emoji["emoji"])
emoji_file = media_dir / emoji["filename"]
if emoji["type"] == "webp":
attrs = [
DocumentAttributeImageSize(w=100, h=100),
DocumentAttributeFilename(file_name="image.webp"),
]
with emoji_file.open("rb") as f:
file_handle = await client.upload_file(f, file_name="emoji.webp")
emoji_file = InputMediaUploadedDocument(
file_handle, mime_type="image/webp", attributes=attrs
)
await send_emoji(conv, emoji_file, emoji["emoji"])
await asyncio.sleep(2)
print("Publishing pack", pack["short_name"])
await publish_pack(conv, pack["short_name"])
async def main():
logging.basicConfig(level=logging.INFO)
emoji_data = await load_emoji_data()
split_cache = media_dir / "split-cache.json"
try:
with split_cache.open() as f:
packs: list[CachedPack] = json.load(f)
except FileNotFoundError:
packs = split_packs(emoji_data)
with split_cache.open("w") as f:
json.dump(packs, f)
doc_id_file = Path(args.output)
try:
with doc_id_file.open() as f:
doc_ids = json.load(f)
except FileNotFoundError:
doc_ids = {}
client = TelegramClient(args.session, args.api_id, args.api_hash, flood_sleep_threshold=3600)
await client.start()
async with client.conversation("Stickers", max_messages=20000) as conv:
for pack in packs:
if not pack["published"]:
await create_and_fill_pack(client, conv, pack)
pack["published"] = True
with split_cache.open("w") as f:
json.dump(packs, f, ensure_ascii=False)
if not pack["collected"] or True:
print("Collecting document IDs from pack", pack["short_name"])
stickers = await client(
GetStickerSetRequest(InputStickerSetShortName(pack["short_name"]), 0)
)
doc: Document
for i, doc in enumerate(stickers.documents):
attr = next(
attr
for attr in doc.attributes
if isinstance(attr, DocumentAttributeCustomEmoji)
)
base_emoji = attr.alt.replace("\ufe0f", "")
emoji = pack["emojis"][i]["emoji"].replace("\ufe0f", "")
doc_ids[emoji] = doc.id
print(f"Mapped {emoji} (fallback: {base_emoji}) -> {doc_ids[emoji]}")
pack["collected"] = True
with split_cache.open("w") as f:
json.dump(packs, f, ensure_ascii=False)
with doc_id_file.open("w") as f:
json.dump(doc_ids, f, ensure_ascii=False)
print("Pack completed")
await asyncio.sleep(5)
with open(args.output.replace(".json", ".pickle"), "wb") as f:
pickle.dump(doc_ids, f)
print("Wrote pickle")
asyncio.run(main())
-77
View File
@@ -1,77 +0,0 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import List, Optional, Union
from telethon import TelegramClient, utils
from telethon.sessions.abstract import Session
from telethon.tl.functions.messages import SendMediaRequest
from telethon.tl.patched import Message
from telethon.tl.types import (
InputMediaUploadedDocument,
InputMediaUploadedPhoto,
InputReplyToMessage,
TypeDocumentAttribute,
TypeInputMedia,
TypeInputPeer,
TypeMessageEntity,
TypeMessageMedia,
TypePeer,
)
class MautrixTelegramClient(TelegramClient):
session: Session
async def upload_file_direct(
self,
file: bytes,
mime_type: str = None,
attributes: List[TypeDocumentAttribute] = None,
file_name: str = None,
max_image_size: float = 10 * 1000**2,
) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]:
file_handle = await super().upload_file(file, file_name=file_name)
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}
return InputMediaUploadedDocument(
file=file_handle,
mime_type=mime_type or "application/octet-stream",
attributes=list(attr_dict.values()),
)
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=InputReplyToMessage(reply_to_msg_id=reply_to) if reply_to else None,
)
return self._get_response_message(request, await self(request), entity)
-3
View File
@@ -1,3 +0,0 @@
from typing import NewType
TelegramID = NewType("TelegramID", int)

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