Compare commits

...

70 Commits

Author SHA1 Message Date
Tulir Asokan d033042ee1 Bump version to 0.14.2 2023-09-19 12:55:37 -04:00
Tulir Asokan 2270f4fe40 Update pillow 2023-09-19 10:59:15 -04:00
Tulir Asokan 6d208b37a5 Stringify sticker pack IDs and include sticker ID 2023-09-14 17:15:17 -04:00
Tulir Asokan 55ebaef6e3 Include sticker pack reference in events 2023-09-14 14:06:19 -04:00
Tulir Asokan 215f077cf0 Make forward backfill timeout configurable 2023-08-29 21:10:37 +03:00
Tulir Asokan 4e4f409f87 Update changelog 2023-08-27 00:52:38 +03:00
Tulir Asokan 4d145f4716 Update mautrix-python 2023-08-27 00:52:21 +03:00
Tulir Asokan b833a41a88 Update Telethon 2023-08-27 00:11:05 +03:00
Tulir Asokan 768d51c4ae Add fallback message for invoices 2023-08-19 12:09:22 +03:00
Tulir Asokan f7db298fda Ignore stories and story replies properly 2023-08-19 12:08:12 +03:00
Tulir Asokan 4f2118c7ee Fix sending media 2023-08-14 20:47:40 +03:00
Tulir Asokan 4f0770b92d Update Telethon 2023-08-13 17:40:39 +03:00
Sumner Evans 1fb8a7a0a5 stickers: passthrough webm and tgs files
I got the mime type of tgs files from here:
https://github.com/tulir/Telethon/blob/main/telethon/utils.py#L54

Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-08-08 13:51:28 -06:00
Tulir Asokan f79ab283f3 Don't clear saved username based on min user object 2023-08-03 20:37:01 +03:00
Tulir Asokan 23ec691128 Handle "inactive" reactions when fetching allowed list 2023-07-21 12:33:28 +03:00
Tulir Asokan 59213ebeae Add warning log if GetAvailableReactions returns unexpected data 2023-07-06 13:26:01 +03:00
Tulir Asokan 36b2f6af2e Fix bridging reactions if server was rebooted less than 12 hours ago
Fixes #915
2023-07-06 13:24:13 +03:00
Tulir Asokan b2249f7756 Add log when message handling finishes 2023-07-06 13:24:10 +03:00
Tulir Asokan 212023d296 Don't send logout bridge state event if the user was already logged out 2023-07-03 19:21:24 +03:00
Tulir Asokan 4b03134620 Log when username changes 2023-07-03 19:20:12 +03:00
Tulir Asokan 806eea53eb Bump version to 0.14.1 2023-06-26 13:11:49 +03:00
Tulir Asokan 4ca3ee58ac Update changelog 2023-06-26 13:08:47 +03:00
Tulir Asokan 8b003f1187 Drop Python 3.8 support 2023-06-26 13:08:38 +03:00
Tulir Asokan c06a2b2473 Update Docker image to Alpine 3.18 2023-06-26 13:08:02 +03:00
Tulir Asokan f2194c6f33 Update mautrix-python 2023-06-25 13:47:01 +03:00
Tulir Asokan b5c294a558 Update dependencies 2023-06-14 16:15:56 +03:00
Tulir Asokan c6b6ec048e Add debug logs and workaround for forward backfill getting stuck 2023-06-14 16:15:56 +03:00
Nick Mills-Barrett fb461109c1 Fix socks proxy (#921)
* Replace pysocks with python-socks

* Log proxy settings on init

* Rename extra requirement group

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

---------

Co-authored-by: Tulir Asokan <tulir@maunium.net>
2023-06-09 15:38:32 +01:00
Tulir Asokan 0411affc88 Merge pull request #920 from exciler/support_ipv6
Add support for IPv6-only hosts
2023-06-06 11:48:13 +03:00
Andreas Palm dfe22800dd Add support for IPv6-only hosts 2023-06-05 22:53:37 +02:00
Tulir Asokan 7868b05ed3 Fix typo when reading config option. Fixes #916 2023-05-31 21:42:53 +03:00
Tulir Asokan 0474f81044 Update Telethon 2023-05-26 13:43:19 +03:00
Tulir Asokan ed471a6623 Add db wal files to gitignore 2023-05-26 13:43:14 +03:00
Tulir Asokan 4504973aff Bump version to 0.14.0 2023-05-26 12:24:43 +03:00
Tulir Asokan a5a71edede Add missing word 2023-05-17 19:04:54 +03:00
Tulir Asokan e1c800f3e6 Update mautrix-python 2023-05-16 19:47:01 +03:00
Tulir Asokan 810f86343a Fix group backfill limit copying 2023-05-08 17:56:27 +03:00
Tulir Asokan 5f7d3ac8c1 Split forward backfill limits by chat type 2023-05-08 17:46:09 +03:00
Malte E cb5c51cd27 Add portal to cache when creating chat from Matrix side (#902) 2023-05-07 18:09:20 +03:00
Stefano Pigozzi 759ccf301c Allow filtering direct chats with filter config (#892) 2023-05-07 18:03:48 +03:00
Tulir Asokan 40e4c7e251 Update changelog 2023-05-07 17:57:21 +03:00
Tulir Asokan e12f1784e2 Only handle /start in private chats 2023-05-07 17:39:25 +03:00
Tulir Asokan 6b8e265f8b Fix case of word in error response 2023-04-30 22:20:55 +03:00
Tulir Asokan de33b553be Add messages to MSS events 2023-04-26 15:46:09 +03:00
Tulir Asokan ed24a0b89f Handle flood waits in provisioning API code and password steps 2023-04-25 19:29:25 +03:00
Tulir Asokan e2697e5a17 Update dependencies 2023-04-24 18:42:19 +03:00
Tulir Asokan c4037ccf11 Add option to disable reply fallbacks 2023-04-23 22:47:28 +03:00
Sumner Evans 6c6fe134ba contact info: omit is_bridge_bot, is_bot -> is_network_bot
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-18 10:20:36 -06:00
Sumner Evans e3c45f6f27 puppet/contact info: set is_bot correctly
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-18 09:46:10 -06:00
Tulir Asokan 732258c093 Don't sync dialogs with no real messages 2023-04-18 17:22:57 +03:00
Sumner Evans 8726fa5d74 puppet: add contact info to all member events
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-14 09:36:09 -06:00
Sumner Evans da61ba96f1 db/puppet: add contact_info_set flag
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-14 09:36:06 -06:00
Tulir Asokan 815ce40989 Add option to not set room meta in encrypted rooms 2023-04-14 14:32:55 +03:00
Tulir Asokan 4ff6a62dab Update mautrix-python 2023-04-14 12:16:59 +03:00
Sumner Evans 918582c967 auth: change wording of error when user terminates all sessions
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-14 00:21:59 -06:00
Tulir Asokan 40c584b121 Add options to automatically delete/ratchet megolm sessions 2023-04-13 21:23:44 +03:00
Tulir Asokan f189dc8c88 Update mautrix-python 2023-04-13 11:25:09 +03:00
Sumner Evans b291c246f4 auth: better error when user terminates session
Signed-off-by: Sumner Evans <sumner@beeper.com>
2023-04-12 22:49:08 -06:00
Tulir Asokan 59ab7be283 Add fi.mau.gif flag to gifs and animated stickers 2023-03-28 12:26:17 +03:00
Tulir Asokan 60981386ec Update mautrix-python 2023-03-23 14:06:23 +02:00
Tulir Asokan 436781215f Don't explode if fetching dialog info fails 2023-03-18 12:05:42 +02:00
Tulir Asokan 9c4b24475c Add missing int casts when sending audio/video 2023-03-14 10:45:00 +02:00
Tulir Asokan ff8d1fc9ec Fix variable name. Fixes #898 2023-03-13 17:17:53 +02:00
Tulir Asokan 5f04729ce8 Preserve reaction timestamps if possible 2023-03-13 13:45:32 +02:00
Tulir Asokan 60526f981a Add another warning to double_puppet_backfill option 2023-03-13 13:39:42 +02:00
Tulir Asokan e39d4972fb Update Telethon 2023-03-13 13:39:25 +02:00
Tulir Asokan 233468b37b Sync mute status even if portal is created outside dialog sync
Closes #897
2023-03-10 13:35:26 +02:00
Tulir Asokan 6eda8bd165 Update Telethon
Fixes #896
2023-03-10 13:23:15 +02:00
Tulir Asokan 7372e7cbea Add fallback messages for calls and premium gifts 2023-03-01 14:02:17 +02:00
Tulir Asokan 1fed2201db Update Telethon to fix handling logouts and other update loop errors 2023-02-28 13:49:41 +02:00
26 changed files with 761 additions and 155 deletions
+1
View File
@@ -14,6 +14,7 @@ __pycache__
/registration.yaml /registration.yaml
*.log* *.log*
*.db *.db
*.db-*
/*.pickle /*.pickle
*.bak *.bak
/*.session /*.session
+65
View File
@@ -1,3 +1,68 @@
# v0.14.2 (2023-09-19)
* **Security:** Updated Pillow to 10.0.1.
* Added support for double puppeting with arbitrary `as_token`s.
See [docs](https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method-new) for more info.
* Added support for sending webm and tgs files as stickers.
* Updated to Telegram API layer 161.
* Fixed cached usernames for Telegram users being cleared incorrectly, leading
to mentions not being bridged as usernames.
* Fixed reaction bridging failing if the server running the bridge was rebooted
less than 12 hours ago.
# v0.14.1 (2023-06-26)
### Added
* Added option to delete megolm sessions that were received before the
automatic ratcheting options were introduced.
* Added config option to use IPv6 for Telegram connection
(thanks to [@exciler] in [#920]).
### Improved
* Dropped support for Python 3.8.
* Updated Docker image to Alpine 3.18.
* Added timeout for forward backfills to prevent it from getting stuck
permanently.
### Fixed
* Fixed `bridge.filter.users` config option not being read correctly.
* Fixed proxy support to use python-socks instead of pysocks.
[@exciler]: https://github.com/exciler
[#920]: https://github.com/mautrix/telegram/pull/920
# v0.14.0 (2023-05-26)
### Added
* Added fallback messages for calls and premium gifts.
* Added options to automatically ratchet/delete megolm sessions to minimize
access to old messages.
* Added option to not set room name/avatar even in encrypted rooms.
* Implemented appservice pinging using MSC2659.
* Added option to disable or filter bridging direct chats
(thanks to [@Steffo99] in [#892]).
* Added options to specify different limits for forward and catchup backfilling
depending on chat type.
### Improved
* Improved handling logouts and certain connection errors.
* Changed reaction bridging to preserve timestamps.
* Disabled creating portals for DMs that don't have any messages when
`sync_direct_chats` is enabled.
### Fixed
* Fixed syncing mute status when portal is created through incoming message
rather than in startup sync.
* Fixed bridge incorrectly trusting member list and kicking users when
supergroup has member list hidden.
* Fixed sending messages after creating groups from Matrix using relaybot
instead of puppet (thanks to [@maltee1] in [#902]).
[@Steffo99]: https://github.com/Steffo99
[@maltee1]: https://github.com/maltee1
[#892]: https://github.com/mautrix/telegram/pull/892
[#902]: https://github.com/mautrix/telegram/pull/902
# v0.13.0 (2023-02-26) # v0.13.0 (2023-02-26)
### Added ### Added
+5 -5
View File
@@ -1,8 +1,8 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17 FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18
RUN apk add --no-cache \ RUN apk add --no-cache \
python3 py3-pip py3-setuptools py3-wheel \ python3 py3-pip py3-setuptools py3-wheel \
py3-pillow \ #py3-pillow \
py3-aiohttp \ py3-aiohttp \
py3-magic \ py3-magic \
py3-ruamel.yaml \ py3-ruamel.yaml \
@@ -14,8 +14,6 @@ RUN apk add --no-cache \
py3-idna \ py3-idna \
py3-rsa \ py3-rsa \
#py3-telethon \ (outdated) #py3-telethon \ (outdated)
# Optional for socks proxies
py3-pysocks \
py3-pyaes \ py3-pyaes \
# cryptg # cryptg
py3-cffi \ py3-cffi \
@@ -34,7 +32,9 @@ RUN apk add --no-cache \
bash \ bash \
curl \ curl \
jq \ jq \
yq 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 requirements.txt /opt/mautrix-telegram/requirements.txt
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.13.0" __version__ = "0.14.2"
__author__ = "Tulir Asokan <tulir@maunium.net>" __author__ = "Tulir Asokan <tulir@maunium.net>"
+3 -2
View File
@@ -39,6 +39,8 @@ from .abstract_user import AbstractUser # isort: skip
class TelegramBridge(Bridge): class TelegramBridge(Bridge):
module = "mautrix_telegram" module = "mautrix_telegram"
name = "mautrix-telegram" name = "mautrix-telegram"
beeper_service_name = "telegram"
beeper_network_name = "telegram"
command = "python -m mautrix-telegram" command = "python -m mautrix-telegram"
description = "A Matrix-Telegram puppeting bridge." description = "A Matrix-Telegram puppeting bridge."
repo_url = "https://github.com/mautrix/telegram" repo_url = "https://github.com/mautrix/telegram"
@@ -50,6 +52,7 @@ class TelegramBridge(Bridge):
config: Config config: Config
bot: Bot | None bot: Bot | None
matrix: MatrixHandler
public_website: PublicBridgeWebsite | None public_website: PublicBridgeWebsite | None
provisioning_api: ProvisioningAPI | None provisioning_api: ProvisioningAPI | None
@@ -101,8 +104,6 @@ class TelegramBridge(Bridge):
self.log.info("Finished re-sending bridge info state events") self.log.info("Finished re-sending bridge info state events")
def prepare_stop(self) -> None: def prepare_stop(self) -> None:
for puppet in Puppet.by_custom_mxid.values():
puppet.stop()
self.add_shutdown_actions(user.stop() for user in User.by_tgid.values()) self.add_shutdown_actions(user.stop() for user in User.by_tgid.values())
if self.bot: if self.bot:
self.add_shutdown_actions(self.bot.stop()) self.add_shutdown_actions(self.bot.stop())
+45 -1
View File
@@ -38,6 +38,7 @@ from telethon.tl.types import (
PeerChannel, PeerChannel,
PeerChat, PeerChat,
PeerUser, PeerUser,
PhoneCallRequested,
TypeUpdate, TypeUpdate,
UpdateChannel, UpdateChannel,
UpdateChannelUserTyping, UpdateChannelUserTyping,
@@ -54,6 +55,7 @@ from telethon.tl.types import (
UpdateNewChannelMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateNewMessage,
UpdateNotifySettings, UpdateNotifySettings,
UpdatePhoneCall,
UpdatePinnedChannelMessages, UpdatePinnedChannelMessages,
UpdatePinnedDialogs, UpdatePinnedDialogs,
UpdatePinnedMessages, UpdatePinnedMessages,
@@ -206,6 +208,8 @@ class AbstractUser(ABC):
sysversion = self.config["telegram.device_info.system_version"] sysversion = self.config["telegram.device_info.system_version"]
appversion = self.config["telegram.device_info.app_version"] appversion = self.config["telegram.device_info.app_version"]
connection, proxy = self._proxy_settings connection, proxy = self._proxy_settings
if proxy:
self.log.debug(f"Using proxy setting: {proxy}")
assert isinstance(session, Session) assert isinstance(session, Session)
@@ -233,6 +237,7 @@ class AbstractUser(ABC):
loop=self.loop, loop=self.loop,
base_logger=base_logger, base_logger=base_logger,
update_error_callback=self._telethon_update_error_callback, update_error_callback=self._telethon_update_error_callback,
use_ipv6=self.config["telegram.connection.use_ipv6"],
) )
self.client.add_event_handler(self._update_catch) self.client.add_event_handler(self._update_catch)
@@ -343,6 +348,8 @@ class AbstractUser(ABC):
await self.delete_message(update) await self.delete_message(update)
elif isinstance(update, UpdateDeleteChannelMessages): elif isinstance(update, UpdateDeleteChannelMessages):
await self.delete_channel_message(update) await self.delete_channel_message(update)
elif isinstance(update, UpdatePhoneCall):
await self.update_phone_call(update)
elif isinstance(update, UpdateMessageReactions): elif isinstance(update, UpdateMessageReactions):
await self.update_reactions(update) await self.update_reactions(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)): elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
@@ -617,6 +624,19 @@ class AbstractUser(ABC):
return return
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions) 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: async def update_channel(self, update: UpdateChannel) -> None:
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id)) portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
if not portal: if not portal:
@@ -659,7 +679,15 @@ class AbstractUser(ABC):
if not portal: if not portal:
return return
elif portal and not portal.allow_bridging: elif portal and not portal.allow_bridging:
self.log.debug(f"Ignoring message in portal {portal.tgid_log} (bridging disallowed)") 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 return
if self.is_relaybot: if self.is_relaybot:
@@ -683,6 +711,22 @@ class AbstractUser(ABC):
self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log) self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
return 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}") await portal.backfill_lock.wait(f"update {update.id}")
if isinstance(update, MessageService): if isinstance(update, MessageService):
+1 -1
View File
@@ -395,7 +395,7 @@ class Bot(AbstractUser):
def reply(reply_text: str) -> Awaitable[Message]: def reply(reply_text: str) -> Awaitable[Message]:
return self.client.send_message(message.chat_id, reply_text, reply_to=message.id) return self.client.send_message(message.chat_id, reply_text, reply_to=message.id)
if command == "start": if command == "start" and message.is_private:
pcm = self.config["bridge.relaybot.private_chat.message"] pcm = self.config["bridge.relaybot.private_chat.message"]
if pcm: if pcm:
await reply(pcm) await reply(pcm)
@@ -39,8 +39,6 @@ async def clear_db_cache(evt: CommandEvent) -> EventID:
await evt.reply("Cleared portal cache") await evt.reply("Cleared portal cache")
elif section == "puppet": elif section == "puppet":
pu.Puppet.by_tgid = {} pu.Puppet.by_tgid = {}
for puppet in pu.Puppet.by_custom_mxid.values():
puppet.stop()
pu.Puppet.by_custom_mxid = {} pu.Puppet.by_custom_mxid = {}
await asyncio.gather( await asyncio.gather(
*[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()] *[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()]
@@ -69,8 +67,6 @@ async def reload_user(evt: CommandEvent) -> EventID:
if not user: if not user:
return await evt.reply("User not found") return await evt.reply("User not found")
puppet = await pu.Puppet.get_by_custom_mxid(mxid) puppet = await pu.Puppet.get_by_custom_mxid(mxid)
if puppet:
puppet.stop()
await user.stop() await user.stop()
del u.User.by_tgid[user.tgid] del u.User.by_tgid[user.tgid]
del u.User.by_mxid[user.mxid] del u.User.by_mxid[user.mxid]
+3 -1
View File
@@ -56,7 +56,9 @@ async def bridge(evt: CommandEvent) -> EventID:
return await evt.reply(f"{that_this} room is already a portal room.") 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"): if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
return await evt.reply(f"You do not have the permissions to bridge {that_this} room.") return await evt.reply(
f"You do not have the permissions to bridge {that_this.lower()} room."
)
# The /id bot command provides the prefixed ID, so we assume # The /id bot command provides the prefixed ID, so we assume
tgid_str = evt.args[0] tgid_str = evt.args[0]
@@ -65,19 +65,11 @@ async def create(evt: CommandEvent) -> EventID:
about=about, about=about,
encrypted=encrypted, encrypted=encrypted,
) )
invites, errors = await portal.get_telegram_users_in_matrix_room(evt.sender, pre_create=True)
if len(errors) > 0:
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
await evt.reply(
f"Failed to add the following users to the chat:\n\n{error_list}\n\n"
"You can try `$cmdprefix+sp search -r <username>` to help the bridge find "
"those users."
)
await warn_missing_power(levels, evt) await warn_missing_power(levels, evt)
try: try:
await portal.create_telegram_chat(evt.sender, invites=invites, supergroup=supergroup) await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
except ValueError as e: except ValueError as e:
await portal.delete() await portal.delete()
return await evt.reply(e.args[0]) return await evt.reply(e.args[0])
+32 -3
View File
@@ -148,7 +148,15 @@ class Config(BaseBridgeConfig):
copy("bridge.animated_emoji.args.width") copy("bridge.animated_emoji.args.width")
copy("bridge.animated_emoji.args.height") copy("bridge.animated_emoji.args.height")
copy("bridge.animated_emoji.args.fps") copy("bridge.animated_emoji.args.fps")
copy("bridge.private_chat_portal_meta") 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_receipts")
copy("bridge.delivery_error_reports") copy("bridge.delivery_error_reports")
copy("bridge.incoming_bridge_error_reports") copy("bridge.incoming_bridge_error_reports")
@@ -166,8 +174,27 @@ class Config(BaseBridgeConfig):
copy("bridge.backfill.double_puppet_backfill") copy("bridge.backfill.double_puppet_backfill")
copy("bridge.backfill.normal_groups") copy("bridge.backfill.normal_groups")
copy("bridge.backfill.unread_hours_threshold") copy("bridge.backfill.unread_hours_threshold")
copy("bridge.backfill.forward.initial_limit") if "bridge.backfill.forward" in self:
copy("bridge.backfill.forward.sync_limit") 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.messages_per_batch")
copy("bridge.backfill.incremental.post_batch_delay") copy("bridge.backfill.incremental.post_batch_delay")
copy("bridge.backfill.incremental.max_batches.user") copy("bridge.backfill.incremental.max_batches.user")
@@ -197,6 +224,7 @@ class Config(BaseBridgeConfig):
copy("bridge.filter.mode") copy("bridge.filter.mode")
copy("bridge.filter.list") copy("bridge.filter.list")
copy("bridge.filter.users")
copy("bridge.command_prefix") copy("bridge.command_prefix")
@@ -241,6 +269,7 @@ class Config(BaseBridgeConfig):
copy("telegram.connection.retry_delay") copy("telegram.connection.retry_delay")
copy("telegram.connection.flood_sleep_threshold") copy("telegram.connection.flood_sleep_threshold")
copy("telegram.connection.request_retries") copy("telegram.connection.request_retries")
copy("telegram.connection.use_ipv6")
copy("telegram.device_info.device_model") copy("telegram.device_info.device_model")
copy("telegram.device_info.system_version") copy("telegram.device_info.system_version")
+9 -6
View File
@@ -48,6 +48,7 @@ class Puppet:
avatar_url: ContentURI | None avatar_url: ContentURI | None
name_set: bool name_set: bool
avatar_set: bool avatar_set: bool
contact_info_set: bool
is_bot: bool | None is_bot: bool | None
is_channel: bool is_channel: bool
is_premium: bool is_premium: bool
@@ -68,7 +69,7 @@ class Puppet:
columns: ClassVar[str] = ( columns: ClassVar[str] = (
"id, is_registered, displayname, displayname_source, displayname_contact, " "id, is_registered, displayname, displayname_source, displayname_contact, "
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, " "displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
"name_set, avatar_set, is_bot, is_channel, is_premium, " "name_set, avatar_set, contact_info_set, is_bot, is_channel, is_premium, "
"custom_mxid, access_token, next_batch, base_url" "custom_mxid, access_token, next_batch, base_url"
) )
@@ -108,6 +109,7 @@ class Puppet:
self.avatar_url, self.avatar_url,
self.name_set, self.name_set,
self.avatar_set, self.avatar_set,
self.contact_info_set,
self.is_bot, self.is_bot,
self.is_channel, self.is_channel,
self.is_premium, self.is_premium,
@@ -122,8 +124,9 @@ class Puppet:
UPDATE puppet UPDATE puppet
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5, 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, displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10,
avatar_url=$11, name_set=$12, avatar_set=$13, is_bot=$14, is_channel=$15, avatar_url=$11, name_set=$12, avatar_set=$13, contact_info_set=$14, is_bot=$15,
is_premium=$16, custom_mxid=$17, access_token=$18, next_batch=$19, base_url=$20 is_channel=$16, is_premium=$17, custom_mxid=$18, access_token=$19, next_batch=$20,
base_url=$21
WHERE id=$1 WHERE id=$1
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
@@ -133,9 +136,9 @@ class Puppet:
INSERT INTO puppet ( INSERT INTO puppet (
id, is_registered, displayname, displayname_source, displayname_contact, id, is_registered, displayname, displayname_source, displayname_contact,
displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set, displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
avatar_set, is_bot, is_channel, is_premium, custom_mxid, access_token, next_batch, avatar_set, contact_info_set, is_bot, is_channel, is_premium, custom_mxid,
base_url 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, ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19, $20) $19, $20, $21)
""" """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
+1
View File
@@ -20,4 +20,5 @@ from . import (
v15_backfill_anchor_id, v15_backfill_anchor_id,
v16_backfill_type, v16_backfill_type,
v17_message_find_recent, v17_message_find_recent,
v18_puppet_contact_info_set,
) )
@@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection, Scheme from mautrix.util.async_db import Connection, Scheme
latest_version = 17 latest_version = 18
async def create_latest_tables(conn: Connection, scheme: Scheme) -> int: async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
@@ -113,6 +113,7 @@ async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
avatar_url TEXT, avatar_url TEXT,
name_set BOOLEAN NOT NULL DEFAULT false, name_set BOOLEAN NOT NULL DEFAULT false,
avatar_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_bot BOOLEAN,
is_channel BOOLEAN NOT NULL DEFAULT false, is_channel BOOLEAN NOT NULL DEFAULT false,
is_premium BOOLEAN NOT NULL DEFAULT false, is_premium BOOLEAN NOT NULL DEFAULT false,
@@ -0,0 +1,25 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from mautrix.util.async_db import Connection
from . import upgrade_table
@upgrade_table.register(description="Add contact_info_set column to puppet table")
async def upgrade_v18(conn: Connection) -> None:
await conn.execute(
"ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false"
)
+57 -8
View File
@@ -40,7 +40,7 @@ appservice:
# The full URI to the database. SQLite and Postgres are supported. # The full URI to the database. SQLite and Postgres are supported.
# Format examples: # Format examples:
# SQLite: sqlite:///filename.db # SQLite: sqlite:filename.db
# Postgres: postgres://username:password@hostname/dbname # Postgres: postgres://username:password@hostname/dbname
database: postgres://username:password@hostname/dbname database: postgres://username:password@hostname/dbname
# Additional arguments for asyncpg.create_pool() or sqlite3.connect() # Additional arguments for asyncpg.create_pool() or sqlite3.connect()
@@ -274,6 +274,27 @@ bridge:
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled. # 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. # You must use a client that supports requesting keys from other users to use this feature.
allow_key_sharing: false 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? # What level of device verification should be required from users?
# #
# Valid levels: # Valid levels:
@@ -309,9 +330,18 @@ bridge:
# default. # default.
messages: 100 messages: 100
# Whether or not to explicitly set the avatar and room name for private # Disable rotating keys when a user's devices change?
# chat portal rooms. This will be implicitly enabled if encryption.default is true. # You should not enable this option unless you understand all the implications.
private_chat_portal_meta: false 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 # Whether or not the bridge should send a read receipt from the bridge bot when a message has
# been sent to Telegram. # been sent to Telegram.
delivery_receipts: false delivery_receipts: false
@@ -360,6 +390,9 @@ bridge:
# Even without MSC2716, bridging old messages with correct timestamps requires the double # 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 # puppets to be in an appservice namespace, or the server to be modified to allow
# overriding timestamps anyway. # 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 double_puppet_backfill: false
# Whether or not to enable backfilling in normal groups. # Whether or not to enable backfilling in normal groups.
# Normal groups have numerous technical problems in Telegram, and backfilling normal groups # Normal groups have numerous technical problems in Telegram, and backfilling normal groups
@@ -374,11 +407,21 @@ bridge:
# #
# Using a negative initial limit is not recommended, as it would try to backfill everything in a single batch. # 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. # MSC2716 and the incremental settings are meant for backfilling everything incrementally rather than at once.
forward: forward_limits:
# Number of messages to backfill immediately after creating a portal. # Number of messages to backfill immediately after creating a portal.
initial_limit: 10 initial:
user: 50
normal_group: 100
supergroup: 10
channel: 10
# Number of messages to backfill when syncing chats. # Number of messages to backfill when syncing chats.
sync_limit: 100 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. # Settings for incremental backfill of history. These only apply when using MSC2716.
incremental: incremental:
@@ -458,7 +501,6 @@ bridge:
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and # Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
# `filter-mode` management commands. # `filter-mode` management commands.
# #
# Filters do not affect direct chats.
# An empty blacklist will essentially disable the filter. # An empty blacklist will essentially disable the filter.
filter: filter:
# Filter mode to use. Either "blacklist" or "whitelist". # Filter mode to use. Either "blacklist" or "whitelist".
@@ -467,6 +509,11 @@ bridge:
mode: blacklist mode: blacklist
# The list of group/channel IDs to filter. # The list of group/channel IDs to filter.
list: [] 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. # The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg" command_prefix: "!tg"
@@ -567,6 +614,8 @@ telegram:
# is not recommended, since some requests can always trigger a call fail (such as searching # is not recommended, since some requests can always trigger a call fail (such as searching
# for messages). # for messages).
request_retries: 5 request_retries: 5
# Use IPv6 for Telethon connection
use_ipv6: false
# Device info sent to Telegram. # Device info sent to Telegram.
device_info: device_info:
+1 -13
View File
@@ -135,20 +135,8 @@ class MatrixHandler(BaseMatrixHandler):
levels.users[self.az.bot_mxid] = 100 if invited_by_level >= 100 else invited_by_level 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) await double_puppet.intent.set_power_levels(room_id, levels)
invites, errors = await portal.get_telegram_users_in_matrix_room(
invited_by, pre_create=True
)
if len(errors) > 0:
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
await portal.az.intent.send_notice(
room_id,
f"Failed to add the following users to the chat:\n\n{error_list}\n\n"
"You can try `$cmdprefix+sp search -r <username>` to help the bridge find "
"those users.",
)
try: try:
await portal.create_telegram_chat(invited_by, invites=invites, supergroup=True) await portal.create_telegram_chat(invited_by, supergroup=True)
except ValueError as e: except ValueError as e:
await portal.delete() await portal.delete()
await portal.az.intent.send_notice(room_id, e.args[0]) await portal.az.intent.send_notice(room_id, e.args[0])
+296 -64
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan # Copyright (C) 2023 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@@ -15,7 +15,17 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, List, Union, cast from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Awaitable,
Callable,
List,
Literal,
Union,
cast,
)
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from html import escape as escape_html from html import escape as escape_html
@@ -23,19 +33,34 @@ from sqlite3 import IntegrityError
from string import Template from string import Template
import asyncio import asyncio
import base64 import base64
import itertools
import random import random
import time import time
from asyncpg import UniqueViolationError from asyncpg import UniqueViolationError
from telethon.errors import ( from telethon.errors import (
ChatAdminRequiredError,
ChatNotModifiedError, ChatNotModifiedError,
ChatRestrictedError,
ChatWriteForbiddenError,
EntitiesTooLongError,
EntityBoundsInvalidError,
EntityMentionUserInvalidError,
InputUserDeactivatedError,
MessageEmptyError,
MessageIdInvalidError, MessageIdInvalidError,
MessageTooLongError,
PhotoExtInvalidError, PhotoExtInvalidError,
PhotoInvalidDimensionsError, PhotoInvalidDimensionsError,
PhotoSaveFileInvalidError, PhotoSaveFileInvalidError,
ReactionInvalidError, ReactionInvalidError,
RPCError, RPCError,
SlowModeWaitError,
UserBannedInChannelError,
UserIsBlockedError,
YouBlockedUserError,
) )
from telethon.tl.custom import Dialog
from telethon.tl.functions.channels import ( from telethon.tl.functions.channels import (
CreateChannelRequest, CreateChannelRequest,
EditPhotoRequest, EditPhotoRequest,
@@ -54,6 +79,7 @@ from telethon.tl.functions.messages import (
ExportChatInviteRequest, ExportChatInviteRequest,
GetMessageReactionsListRequest, GetMessageReactionsListRequest,
GetMessagesReactionsRequest, GetMessagesReactionsRequest,
GetPeerDialogsRequest,
MigrateChatRequest, MigrateChatRequest,
SendReactionRequest, SendReactionRequest,
SetTypingRequest, SetTypingRequest,
@@ -66,22 +92,26 @@ from telethon.tl.types import (
ChannelFull, ChannelFull,
Chat, Chat,
ChatBannedRights, ChatBannedRights,
ChatEmpty,
ChatFull, ChatFull,
ChatPhoto, ChatPhoto,
ChatPhotoEmpty, ChatPhotoEmpty,
DocumentAttributeAudio, DocumentAttributeAudio,
DocumentAttributeFilename, DocumentAttributeFilename,
DocumentAttributeImageSize, DocumentAttributeImageSize,
DocumentAttributeSticker,
DocumentAttributeVideo, DocumentAttributeVideo,
GeoPoint, GeoPoint,
InputChannel, InputChannel,
InputChatUploadedPhoto, InputChatUploadedPhoto,
InputDialogPeer,
InputMediaUploadedDocument, InputMediaUploadedDocument,
InputMediaUploadedPhoto, InputMediaUploadedPhoto,
InputPeerChannel, InputPeerChannel,
InputPeerChat, InputPeerChat,
InputPeerPhotoFileLocation, InputPeerPhotoFileLocation,
InputPeerUser, InputPeerUser,
InputStickerSetEmpty,
InputUser, InputUser,
MessageActionChannelCreate, MessageActionChannelCreate,
MessageActionChatAddUser, MessageActionChatAddUser,
@@ -95,6 +125,9 @@ from telethon.tl.types import (
MessageActionChatMigrateTo, MessageActionChatMigrateTo,
MessageActionContactSignUp, MessageActionContactSignUp,
MessageActionGameScore, MessageActionGameScore,
MessageActionGiftPremium,
MessageActionGroupCall,
MessageActionPhoneCall,
MessageMediaGame, MessageMediaGame,
MessageMediaGeo, MessageMediaGeo,
MessagePeerReaction, MessagePeerReaction,
@@ -102,6 +135,10 @@ from telethon.tl.types import (
PeerChannel, PeerChannel,
PeerChat, PeerChat,
PeerUser, PeerUser,
PhoneCallDiscardReasonBusy,
PhoneCallDiscardReasonDisconnect,
PhoneCallDiscardReasonMissed,
PhoneCallRequested,
Photo, Photo,
PhotoEmpty, PhotoEmpty,
ReactionCount, ReactionCount,
@@ -126,13 +163,16 @@ from telethon.tl.types import (
UpdateChatUserTyping, UpdateChatUserTyping,
UpdateMessageReactions, UpdateMessageReactions,
UpdateNewMessage, UpdateNewMessage,
UpdatePhoneCall,
UpdateUserTyping, UpdateUserTyping,
User, User,
UserEmpty,
UserFull, UserFull,
UserProfilePhoto, UserProfilePhoto,
UserProfilePhotoEmpty, UserProfilePhotoEmpty,
) )
from telethon.utils import encode_waveform from telethon.tl.types.messages import PeerDialogs
from telethon.utils import encode_waveform, get_peer_id
import attr import attr
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY, IntentAPI from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY, IntentAPI
@@ -171,7 +211,8 @@ from mautrix.types import (
UserID, UserID,
VideoInfo, VideoInfo,
) )
from mautrix.util import background_task, magic, variation_selector from mautrix.util import background_task, magic, markdown, variation_selector
from mautrix.util.format_duration import format_duration
from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus
from mautrix.util.simple_lock import SimpleLock from mautrix.util.simple_lock import SimpleLock
from mautrix.util.simple_template import SimpleTemplate from mautrix.util.simple_template import SimpleTemplate
@@ -242,12 +283,13 @@ class Portal(DBPortal, BasePortal):
# Config cache # Config cache
filter_mode: str filter_mode: str
filter_list: list[int] filter_list: list[int]
filter_users: bool | None
max_initial_member_sync: int max_initial_member_sync: int
sync_channel_members: bool sync_channel_members: bool
sync_matrix_state: bool sync_matrix_state: bool
public_portals: bool public_portals: bool
private_chat_portal_meta: bool private_chat_portal_meta: Literal["default", "always", "never"]
alias_template: SimpleTemplate[str] alias_template: SimpleTemplate[str]
hs_domain: str hs_domain: str
@@ -416,14 +458,22 @@ class Portal(DBPortal, BasePortal):
def allow_bridging(self) -> bool: def allow_bridging(self) -> bool:
if self._bridging_blocked_at_runtime: if self._bridging_blocked_at_runtime:
return False return False
elif self.peer_type == "user": elif self.peer_type == "user" and self.filter_users is not None:
return True return self.filter_users
elif self.filter_mode == "whitelist": elif self.filter_mode == "whitelist":
return self.tgid in self.filter_list return self.tgid in self.filter_list
elif self.filter_mode == "blacklist": elif self.filter_mode == "blacklist":
return self.tgid not in self.filter_list return self.tgid not in self.filter_list
return True return True
@property
def set_dm_room_metadata(self) -> bool:
return (
not self.is_direct
or self.private_chat_portal_meta == "always"
or (self.encrypted and self.private_chat_portal_meta != "never")
)
@classmethod @classmethod
def init_cls(cls, bridge: "TelegramBridge") -> None: def init_cls(cls, bridge: "TelegramBridge") -> None:
BasePortal.bridge = bridge BasePortal.bridge = bridge
@@ -440,6 +490,7 @@ class Portal(DBPortal, BasePortal):
cls.private_chat_portal_meta = cls.config["bridge.private_chat_portal_meta"] cls.private_chat_portal_meta = cls.config["bridge.private_chat_portal_meta"]
cls.filter_mode = cls.config["bridge.filter.mode"] cls.filter_mode = cls.config["bridge.filter.mode"]
cls.filter_list = cls.config["bridge.filter.list"] cls.filter_list = cls.config["bridge.filter.list"]
cls.filter_users = cls.config["bridge.filter.users"]
cls.hs_domain = cls.config["homeserver.domain"] cls.hs_domain = cls.config["homeserver.domain"]
cls.backfill_msc2716 = cls.config["bridge.backfill.msc2716"] cls.backfill_msc2716 = cls.config["bridge.backfill.msc2716"]
cls.backfill_enable = cls.config["bridge.backfill.enable"] cls.backfill_enable = cls.config["bridge.backfill.enable"]
@@ -465,8 +516,9 @@ class Portal(DBPortal, BasePortal):
async def get_telegram_users_in_matrix_room( async def get_telegram_users_in_matrix_room(
self, source: u.User, pre_create: bool = False self, source: u.User, pre_create: bool = False
) -> tuple[list[InputUser], list[UserID]]: ) -> tuple[list[InputUser], list[UserID], list[u.User]]:
user_tgids = {} user_tgids = {}
users = []
intent = self.az.intent if pre_create else self.main_intent intent = self.az.intent if pre_create else self.main_intent
user_mxids = await intent.get_room_members(self.mxid, (Membership.JOIN, Membership.INVITE)) user_mxids = await intent.get_room_members(self.mxid, (Membership.JOIN, Membership.INVITE))
for mxid in user_mxids: for mxid in user_mxids:
@@ -474,6 +526,7 @@ class Portal(DBPortal, BasePortal):
continue continue
mx_user = await u.User.get_by_mxid(mxid, create=False) mx_user = await u.User.get_by_mxid(mxid, create=False)
if mx_user and mx_user.tgid: if mx_user and mx_user.tgid:
users.append(mx_user)
user_tgids[mx_user.tgid] = mxid user_tgids[mx_user.tgid] = mxid
puppet_id = p.Puppet.get_id_from_mxid(mxid) puppet_id = p.Puppet.get_id_from_mxid(mxid)
if puppet_id: if puppet_id:
@@ -489,7 +542,7 @@ class Portal(DBPortal, BasePortal):
f"creating a group: {e}" f"creating a group: {e}"
) )
errors.append(mxid) errors.append(mxid)
return input_users, errors return input_users, errors, users
async def upgrade_telegram_chat(self, source: u.User) -> None: async def upgrade_telegram_chat(self, source: u.User) -> None:
if self.peer_type != "chat": if self.peer_type != "chat":
@@ -540,11 +593,23 @@ class Portal(DBPortal, BasePortal):
if await self._update_username(username): if await self._update_username(username):
await self.save() await self.save()
async def create_telegram_chat( async def create_telegram_chat(self, source: u.User, supergroup: bool = False) -> None:
self, source: u.User, invites: list[InputUser], supergroup: bool = False
) -> None:
if not self.mxid: if not self.mxid:
raise ValueError("Can't create Telegram chat for portal without Matrix room.") raise ValueError("Can't create Telegram chat for portal without Matrix room.")
invites, errors, users = await self.get_telegram_users_in_matrix_room(
source, pre_create=True
)
if len(errors) > 0:
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
command_prefix = self.config["bridge.command_prefix"]
message = (
f"Failed to add the following users to the chat:\n\n{error_list}\n\n"
f"You can try `{command_prefix} search -r <username>` to help the bridge find "
"those users."
)
await self.az.intent.send_notice(
self.mxid, text=message, html=markdown.render(message)
)
elif self.tgid: elif self.tgid:
raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.") raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.")
@@ -594,6 +659,8 @@ class Portal(DBPortal, BasePortal):
await self.main_intent.set_power_levels(self.mxid, levels) await self.main_intent.set_power_levels(self.mxid, levels)
await self.handle_matrix_power_levels(source, levels.users, {}, None) await self.handle_matrix_power_levels(source, levels.users, {}, None)
await self.update_bridge_info() await self.update_bridge_info()
for user in users:
await user.register_portal(self)
await self.main_intent.send_notice(self.mxid, f"Telegram chat created. ID: {self.tgid}") await self.main_intent.send_notice(self.mxid, f"Telegram chat created. ID: {self.tgid}")
async def handle_matrix_invite( async def handle_matrix_invite(
@@ -698,12 +765,8 @@ class Portal(DBPortal, BasePortal):
source: au.AbstractUser | None = None, source: au.AbstractUser | None = None,
photo: UserProfilePhoto | None = None, photo: UserProfilePhoto | None = None,
) -> None: ) -> None:
if not self.encrypted and not self.private_chat_portal_meta:
return
if puppet is None: if puppet is None:
puppet = await self.get_dm_puppet() puppet = await self.get_dm_puppet()
# The bridge bot needs to join for e2ee, but that messes up the default name
# generation. If/when canonical DMs happen, this might not be necessary anymore.
changed = await self._update_avatar_from_puppet(puppet, source, photo) changed = await self._update_avatar_from_puppet(puppet, source, photo)
changed = await self._update_title(puppet.displayname) or changed changed = await self._update_title(puppet.displayname) or changed
if changed: if changed:
@@ -716,6 +779,7 @@ class Portal(DBPortal, BasePortal):
entity: TypeChat | User = None, entity: TypeChat | User = None,
invites: InviteList = None, invites: InviteList = None,
update_if_exists: bool = True, update_if_exists: bool = True,
from_dialog_sync: bool = False,
client: MautrixTelegramClient | None = None, client: MautrixTelegramClient | None = None,
) -> RoomID | None: ) -> RoomID | None:
if self.mxid: if self.mxid:
@@ -732,7 +796,9 @@ class Portal(DBPortal, BasePortal):
return self.mxid return self.mxid
async with self._room_create_lock: async with self._room_create_lock:
try: try:
return await self._create_matrix_room(user, entity, invites, client=client) return await self._create_matrix_room(
user, entity, invites, client=client, from_dialog_sync=from_dialog_sync
)
except Exception: except Exception:
self.log.exception("Fatal error creating Matrix room") self.log.exception("Fatal error creating Matrix room")
@@ -787,6 +853,7 @@ class Portal(DBPortal, BasePortal):
user: au.AbstractUser, user: au.AbstractUser,
entity: TypeChat | User, entity: TypeChat | User,
invites: InviteList, invites: InviteList,
from_dialog_sync: bool,
client: MautrixTelegramClient | None = None, client: MautrixTelegramClient | None = None,
) -> RoomID | None: ) -> RoomID | None:
if self.mxid: if self.mxid:
@@ -798,6 +865,37 @@ class Portal(DBPortal, BasePortal):
invites = invites or [] invites = invites or []
dialog = None
if not from_dialog_sync and not user.is_bot:
self.log.debug("Fetching dialog info for new portal")
try:
dialogs: PeerDialogs | None = await user.client(
GetPeerDialogsRequest(
peers=[InputDialogPeer(await self.get_input_entity(user))]
)
)
except Exception:
self.log.warning("Failed to fetch dialog info", exc_info=True)
dialogs = None
if dialogs and dialogs.chats and dialogs.chats[0].id == self.tgid:
entity = dialogs.chats[0]
self.log.debug("Got entity info from get dialogs request")
elif dialogs and self.is_direct and dialogs.users:
for dialog_user in dialogs.users:
if dialog_user.id == self.tgid:
entity = dialog_user
self.log.debug("Got user entity info from get dialogs request")
break
if dialogs and dialogs.dialogs:
entities = {
get_peer_id(x): x
for x in itertools.chain(dialogs.users, dialogs.chats)
if not isinstance(x, (UserEmpty, ChatEmpty))
}
msg = dialogs.messages[0] if len(dialogs.messages) == 1 else None
dialog = Dialog(user.client, dialogs.dialogs[0], entities, msg)
self.log.debug("Got dialog info for new portal: %s", dialog)
if not entity: if not entity:
entity = await self.get_entity(user, client) entity = await self.get_entity(user, client)
self.log.trace("Fetched data: %s", entity) self.log.trace("Fetched data: %s", entity)
@@ -900,7 +998,7 @@ class Portal(DBPortal, BasePortal):
) )
if self.is_direct: if self.is_direct:
create_invites.add(self.az.bot_mxid) create_invites.add(self.az.bot_mxid)
if self.is_direct and (self.encrypted or self.private_chat_portal_meta): if self.is_direct:
assert puppet is not None assert puppet is not None
self.title = puppet.displayname self.title = puppet.displayname
self.avatar_url = puppet.avatar_url self.avatar_url = puppet.avatar_url
@@ -908,7 +1006,7 @@ class Portal(DBPortal, BasePortal):
creation_content = {} creation_content = {}
if not self.config["bridge.federate_rooms"]: if not self.config["bridge.federate_rooms"]:
creation_content["m.federate"] = False creation_content["m.federate"] = False
if self.avatar_url: if self.avatar_url and self.set_dm_room_metadata:
initial_state.append( initial_state.append(
{ {
"type": str(EventType.ROOM_AVATAR), "type": str(EventType.ROOM_AVATAR),
@@ -920,14 +1018,14 @@ class Portal(DBPortal, BasePortal):
self.log.debug( self.log.debug(
f"Creating room with parameters invite={create_invites}, {autojoin_invites=}, " f"Creating room with parameters invite={create_invites}, {autojoin_invites=}, "
f"{preset=}, {alias=!r}, name={self.title!r}, topic={self.about!r}, " f"{preset=}, {alias=!r}, name={self.title!r}, topic={self.about!r}, "
f"{creation_content=}, is_direct={self.is_direct}" f"{creation_content=}, is_direct={self.is_direct}, {self.set_dm_room_metadata=}"
) )
room_id = await self.main_intent.create_room( room_id = await self.main_intent.create_room(
alias_localpart=alias, alias_localpart=alias,
preset=preset, preset=preset,
is_direct=self.is_direct, is_direct=self.is_direct,
invitees=list(create_invites), invitees=list(create_invites),
name=self.title, name=self.title if self.set_dm_room_metadata else None,
topic=self.about, topic=self.about,
initial_state=initial_state, initial_state=initial_state,
creation_content=creation_content, creation_content=creation_content,
@@ -935,8 +1033,8 @@ class Portal(DBPortal, BasePortal):
) )
if not room_id: if not room_id:
raise Exception(f"Failed to create room") raise Exception(f"Failed to create room")
self.name_set = bool(self.title) self.name_set = bool(self.title) and self.set_dm_room_metadata
self.avatar_set = bool(self.avatar_url) self.avatar_set = bool(self.avatar_url) and self.set_dm_room_metadata
if not autojoin_invites and self.encrypted and self.matrix.e2ee and self.is_direct: if not autojoin_invites and self.encrypted and self.matrix.e2ee and self.is_direct:
try: try:
@@ -950,6 +1048,10 @@ class Portal(DBPortal, BasePortal):
self.log.debug(f"Matrix room created: {self.mxid}") self.log.debug(f"Matrix room created: {self.mxid}")
await self.az.state_store.set_power_levels(self.mxid, power_levels) await self.az.state_store.set_power_levels(self.mxid, power_levels)
await user.register_portal(self) await user.register_portal(self)
if dialog and isinstance(user, u.User):
await user.post_sync_dialog(
self, puppet=None, was_created=True, **user.dialog_to_sync_args(dialog)
)
if not autojoin_invites or not self.is_direct: if not autojoin_invites or not self.is_direct:
await self.invite_to_matrix(invites) await self.invite_to_matrix(invites)
@@ -1246,11 +1348,12 @@ class Portal(DBPortal, BasePortal):
async def _update_title( async def _update_title(
self, title: str, sender: p.Puppet | None = None, save: bool = False self, title: str, sender: p.Puppet | None = None, save: bool = False
) -> bool: ) -> bool:
if self.title == title and self.name_set: if self.title == title and (self.name_set or not self.set_dm_room_metadata):
return False return False
self.title = title self.title = title
if self.mxid: self.name_set = False
if self.mxid and self.set_dm_room_metadata:
try: try:
await self._try_set_state( await self._try_set_state(
sender, EventType.ROOM_NAME, RoomNameStateEventContent(name=self.title) sender, EventType.ROOM_NAME, RoomNameStateEventContent(name=self.title)
@@ -1258,7 +1361,6 @@ class Portal(DBPortal, BasePortal):
self.name_set = True self.name_set = True
except Exception as e: except Exception as e:
self.log.warning(f"Failed to set room name: {e}") self.log.warning(f"Failed to set room name: {e}")
self.name_set = False
if save: if save:
await self.save() await self.save()
return True return True
@@ -1266,12 +1368,13 @@ class Portal(DBPortal, BasePortal):
async def _update_avatar_from_puppet( async def _update_avatar_from_puppet(
self, puppet: p.Puppet, user: au.AbstractUser | None, photo: UserProfilePhoto | None self, puppet: p.Puppet, user: au.AbstractUser | None, photo: UserProfilePhoto | None
) -> bool: ) -> bool:
if self.photo_id == puppet.photo_id and self.avatar_set: if self.photo_id == puppet.photo_id and (self.avatar_set or not self.set_dm_room_metadata):
return False return False
if puppet.avatar_url: if puppet.avatar_url:
self.photo_id = puppet.photo_id self.photo_id = puppet.photo_id
self.avatar_url = puppet.avatar_url self.avatar_url = puppet.avatar_url
if self.mxid: self.avatar_set = False
if self.mxid and self.set_dm_room_metadata:
try: try:
await self._try_set_state( await self._try_set_state(
None, None,
@@ -1281,9 +1384,8 @@ class Portal(DBPortal, BasePortal):
self.avatar_set = True self.avatar_set = True
except Exception as e: except Exception as e:
self.log.warning(f"Failed to set room avatar: {e}") self.log.warning(f"Failed to set room avatar: {e}")
self.avatar_set = False
return True return True
elif photo is not None and user is not None: elif photo is not None and user is not None and self.set_dm_room_metadata:
return await self._update_avatar(user, photo=photo) return await self._update_avatar(user, photo=photo)
else: else:
return False return False
@@ -1741,6 +1843,7 @@ class Portal(DBPortal, BasePortal):
max_image_size = self.config["bridge.image_as_file_size"] * 1000**2 max_image_size = self.config["bridge.image_as_file_size"] * 1000**2
max_image_pixels = self.config["bridge.image_as_file_pixels"] max_image_pixels = self.config["bridge.image_as_file_pixels"]
attributes = []
if self.config["bridge.parallel_file_transfer"] and content.url: if self.config["bridge.parallel_file_transfer"] and content.url:
file_handle, file_size = await util.parallel_transfer_to_telegram( file_handle, file_size = await util.parallel_transfer_to_telegram(
client, self.main_intent, content.url, sender_id client, self.main_intent, content.url, sender_id
@@ -1760,25 +1863,31 @@ class Portal(DBPortal, BasePortal):
file = await self.main_intent.download_media(content.url) file = await self.main_intent.download_media(content.url)
if content.msgtype == MessageType.STICKER: if content.msgtype == MessageType.STICKER:
if mime != "image/gif": if mime == "image/gif":
mime, file, w, h = util.convert_image(
file, source_mime=mime, target_type="webp"
)
else:
# Remove sticker description # Remove sticker description
file_name = "sticker.gif" file_name = "sticker.gif"
else:
if mime not in ("video/webm", "application/x-tgsticker"):
mime, file, w, h = util.convert_image(
file, source_mime=mime, target_type="webp"
)
attributes.append(
DocumentAttributeSticker(
alt=content.body, stickerset=InputStickerSetEmpty()
)
)
file_handle = await client.upload_file(file) file_handle = await client.upload_file(file)
file_size = len(file) file_size = len(file)
file_handle.name = file_name file_handle.name = file_name
force_document = file_size >= max_image_size force_document = file_size >= max_image_size
attributes.append(DocumentAttributeFilename(file_name=file_name))
attributes = [DocumentAttributeFilename(file_name=file_name)]
if content.msgtype == MessageType.VIDEO: if content.msgtype == MessageType.VIDEO:
attributes.append( attributes.append(
DocumentAttributeVideo( DocumentAttributeVideo(
duration=content.info.duration // 1000 if content.info.duration else 0, duration=int(content.info.duration // 1000 if content.info.duration else 0),
w=w or 0, w=w or 0,
h=h or 0, h=h or 0,
) )
@@ -1790,7 +1899,7 @@ class Portal(DBPortal, BasePortal):
waveform = [round(part / max(waveform_max / 32, 1)) for part in waveform] waveform = [round(part / max(waveform_max / 32, 1)) for part in waveform]
attributes.append( attributes.append(
DocumentAttributeAudio( DocumentAttributeAudio(
duration=content.info.duration // 1000 if content.info.duration else 0, duration=int(content.info.duration // 1000 if content.info.duration else 0),
voice="org.matrix.msc3245.voice" in content, voice="org.matrix.msc3245.voice" in content,
waveform=encode_waveform(waveform) if waveform else None, waveform=encode_waveform(waveform) if waveform else None,
) )
@@ -1947,7 +2056,7 @@ class Portal(DBPortal, BasePortal):
response: TypeMessage, response: TypeMessage,
msgtype: MessageType | None = None, msgtype: MessageType | None = None,
) -> None: ) -> None:
self.log.trace("Handled Matrix message: %s", response) self.log.trace("Raw event handling response for %s: %s", event_id, response)
event_hash, _ = self.dedup.check(response, (event_id, space), force_hash=edit_index != 0) event_hash, _ = self.dedup.check(response, (event_id, space), force_hash=edit_index != 0)
if edit_index < 0: if edit_index < 0:
prev_edit = await DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1) prev_edit = await DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1)
@@ -1977,6 +2086,37 @@ class Portal(DBPortal, BasePortal):
seconds=response.ttl_period, seconds=response.ttl_period,
expires_at=int(response.date.timestamp()) + response.ttl_period, expires_at=int(response.date.timestamp()) + response.ttl_period,
) )
self.log.debug(
f"Handled Matrix message {event_id} -> {response.id} (edit index {edit_index})"
)
@staticmethod
def _error_to_human_message(err: Exception) -> str | None:
if isinstance(err, YouBlockedUserError):
return "You blocked this user"
elif isinstance(err, UserIsBlockedError):
return "You were blocked by this user"
elif isinstance(err, UserBannedInChannelError):
return "You're banned from sending messages in supergroups/channels"
elif isinstance(err, InputUserDeactivatedError):
return "This user was deleted"
elif isinstance(err, ChatAdminRequiredError):
return "Only admins can do that"
elif isinstance(err, (ChatRestrictedError, ChatWriteForbiddenError)):
return "You can't send messages in this chat"
elif isinstance(err, SlowModeWaitError):
return f"Slow mode enabled, wait {format_duration(err.seconds)} before sending"
elif isinstance(err, MessageEmptyError):
return "Message is empty"
elif isinstance(err, MessageTooLongError):
return "Message is too long"
elif isinstance(err, EntitiesTooLongError):
return "Message has too many formatting entities"
elif isinstance(err, EntityBoundsInvalidError):
return "Message formatting entities are malformed"
elif isinstance(err, EntityMentionUserInvalidError):
return "You mentioned an invalid user"
return None
async def _send_message_status(self, event_id: EventID, err: Exception | None) -> None: async def _send_message_status(self, event_id: EventID, err: Exception | None) -> None:
if not self.config["bridge.message_status_events"]: if not self.config["bridge.message_status_events"]:
@@ -1995,8 +2135,9 @@ class Portal(DBPortal, BasePortal):
status.status = MessageStatus.FAIL status.status = MessageStatus.FAIL
elif err: elif err:
status.reason = MessageStatusReason.GENERIC_ERROR status.reason = MessageStatusReason.GENERIC_ERROR
status.error = str(err) status.error = f"{type(err)}: {err}"
status.status = MessageStatus.RETRIABLE status.status = MessageStatus.RETRIABLE
status.message = self._error_to_human_message(err)
else: else:
status.status = MessageStatus.SUCCESS status.status = MessageStatus.SUCCESS
@@ -2291,6 +2432,10 @@ class Portal(DBPortal, BasePortal):
await deleter.client( await deleter.client(
SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=new_reactions) SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=new_reactions)
) )
self.log.debug(
f"Handled Matrix deletion of reaction {event_id} to {msg.tgid} "
f"(new reaction count: {len(new_reactions) if new_reactions else 0})"
)
async def _handle_matrix_deletion(self, deleter: u.User, event_id: EventID) -> None: async def _handle_matrix_deletion(self, deleter: u.User, event_id: EventID) -> None:
real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot
@@ -2311,6 +2456,7 @@ class Portal(DBPortal, BasePortal):
else: else:
await message.mark_redacted() await message.mark_redacted()
await real_deleter.client.delete_messages(self.peer, [message.tgid]) await real_deleter.client.delete_messages(self.peer, [message.tgid])
self.log.debug(f"Handled Matrix redaction of {event_id} / {message.tgid}")
async def handle_matrix_reaction( async def handle_matrix_reaction(
self, user: u.User, target_event_id: EventID, emoji: str, reaction_event_id: EventID self, user: u.User, target_event_id: EventID, emoji: str, reaction_event_id: EventID
@@ -2417,9 +2563,15 @@ class Portal(DBPortal, BasePortal):
SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=new_tg_reactions) SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=new_tg_reactions)
) )
puppet = await user.get_puppet() puppet = await user.get_puppet()
removed = 0
for db_reaction in reactions_to_remove: for db_reaction in reactions_to_remove:
removed += 1
await db_reaction.delete() await db_reaction.delete()
await puppet.intent_for(self).redact(db_reaction.mx_room, db_reaction.mxid) await puppet.intent_for(self).redact(db_reaction.mx_room, db_reaction.mxid)
self.log.debug(
f"Handled Matrix reaction {reaction_event_id} to {msg.tgid} "
f"(new reaction count: {len(new_tg_reactions)}, removed {removed} old reactions)"
)
await DBReaction( await DBReaction(
mxid=reaction_event_id, mxid=reaction_event_id,
mx_room=self.mxid, mx_room=self.mxid,
@@ -2671,16 +2823,19 @@ class Portal(DBPortal, BasePortal):
await DBMessage.replace_temp_mxid(temporary_identifier, self.mxid, event_id) await DBMessage.replace_temp_mxid(temporary_identifier, self.mxid, event_id)
@property @property
def _default_max_batches(self) -> int: def _backfill_config_type(self) -> str:
if self.peer_type == "user": if self.peer_type == "user":
own_type = "user" return "user"
elif self.peer_type == "chat": elif self.peer_type == "chat":
own_type = "normal_group" return "normal_group"
elif self.megagroup: elif self.megagroup:
own_type = "supergroup" return "supergroup"
else: else:
own_type = "channel" return "channel"
return self.config[f"bridge.backfill.incremental.max_batches.{own_type}"]
@property
def _default_max_batches(self) -> int:
return self.config[f"bridge.backfill.incremental.max_batches.{self._backfill_config_type}"]
async def enqueue_backfill( async def enqueue_backfill(
self, self,
@@ -2724,13 +2879,21 @@ class Portal(DBPortal, BasePortal):
if not client: if not client:
client = source.client client = source.client
type = "initial" if initial else "sync" type = "initial" if initial else "sync"
limit = override_limit or self.config[f"bridge.backfill.forward.{type}_limit"] limit = (
override_limit
or self.config[f"bridge.backfill.forward_limits.{type}.{self._backfill_config_type}"]
)
if limit == 0: if limit == 0:
return "Limit is zero, not backfilling" return "Limit is zero, not backfilling"
timeout = self.config["bridge.backfill.forward_timeout"]
with self.backfill_lock: with self.backfill_lock:
output = await self.backfill( task = self.backfill(
source, client, forward=True, forward_limit=limit, last_tgid=last_tgid source, client, forward=True, forward_limit=limit, last_tgid=last_tgid
) )
if timeout > 0:
output = await asyncio.wait_for(task, timeout=timeout)
else:
output = await task
self.log.debug(f"Forward backfill complete, status: {output}") self.log.debug(f"Forward backfill complete, status: {output}")
return output return output
@@ -2994,9 +3157,16 @@ class Portal(DBPortal, BasePortal):
anchor_id = 2**31 - 1 anchor_id = 2**31 - 1
minmax = {} minmax = {}
self.log.debug(f"Iterating messages through {source.tgid} with {limit=}, {minmax}") self.log.debug(f"Iterating messages through {source.tgid} with {limit=}, {minmax}")
delay_warn_handle = self.loop.call_later(
5 * 60, lambda: self.log.warning("Iterating messages is taking long")
)
# Iterate messages newest to oldest and collect the results # Iterate messages newest to oldest and collect the results
async for msg in client.iter_messages(entity, limit=limit, **minmax): async for msg in client.iter_messages(entity, limit=limit, **minmax):
message_count += 1 message_count += 1
if message_count == 1:
self.log.debug(f"Backfill iter: got first message {msg.id}")
elif message_count % 50 == 0:
self.log.debug(f"Backfill iter: got {message_count} messages so far (at {msg.id})")
if (forward and msg.id <= anchor_id) or (not forward and msg.id >= anchor_id): if (forward and msg.id <= anchor_id) or (not forward and msg.id >= anchor_id):
continue continue
elif isinstance(msg, MessageService): elif isinstance(msg, MessageService):
@@ -3021,6 +3191,7 @@ class Portal(DBPortal, BasePortal):
events.append(await self._wrap_batch_msg(intent, msg, converted, caption=True)) events.append(await self._wrap_batch_msg(intent, msg, converted, caption=True))
intents.append(intent) intents.append(intent)
metas.append(None) metas.append(None)
delay_warn_handle.cancel()
if len(events) == 0: if len(events) == 0:
self.log.debug( self.log.debug(
f"Didn't get any events to send out of {message_count} messages fetched " f"Didn't get any events to send out of {message_count} messages fetched "
@@ -3079,9 +3250,13 @@ class Portal(DBPortal, BasePortal):
for item in counts: for item in counts:
if item.count == 2: if item.count == 2:
reactions += [ reactions += [
MessagePeerReaction(reaction=item.reaction, peer_id=PeerUser(self.tgid)),
MessagePeerReaction( MessagePeerReaction(
reaction=item.reaction, peer_id=PeerUser(self.tg_receiver) reaction=item.reaction, peer_id=PeerUser(self.tgid), date=None
),
MessagePeerReaction(
reaction=item.reaction,
peer_id=PeerUser(self.tg_receiver),
date=None,
), ),
] ]
elif item.count == 1: elif item.count == 1:
@@ -3089,6 +3264,7 @@ class Portal(DBPortal, BasePortal):
MessagePeerReaction( MessagePeerReaction(
reaction=item.reaction, reaction=item.reaction,
peer_id=PeerUser(self.tg_receiver if item.chosen_order else self.tgid), peer_id=PeerUser(self.tg_receiver if item.chosen_order else self.tgid),
date=None,
) )
) )
return reactions return reactions
@@ -3172,17 +3348,18 @@ class Portal(DBPortal, BasePortal):
) )
@staticmethod @staticmethod
def _reactions_filter(lst: list[TypeReaction], existing: DBReaction) -> bool: def _reactions_filter(lst: list[MessagePeerReaction], existing: DBReaction) -> bool:
if not lst: if not lst:
return False return False
for reaction in lst: for wrapped_reaction in lst:
reaction = wrapped_reaction.reaction
if isinstance(reaction, ReactionCustomEmoji) and existing.reaction == str( if isinstance(reaction, ReactionCustomEmoji) and existing.reaction == str(
reaction.document_id reaction.document_id
): ):
lst.remove(reaction) lst.remove(wrapped_reaction)
return True return True
elif isinstance(reaction, ReactionEmoji) and existing.reaction == reaction.emoticon: elif isinstance(reaction, ReactionEmoji) and existing.reaction == reaction.emoticon:
lst.remove(reaction) lst.remove(wrapped_reaction)
return True return True
return False return False
@@ -3202,15 +3379,14 @@ class Portal(DBPortal, BasePortal):
total_count: int, total_count: int,
timestamp: datetime | None = None, timestamp: datetime | None = None,
) -> None: ) -> None:
reactions: dict[TelegramID, list[TypeReaction]] = {} reactions: dict[TelegramID, list[MessagePeerReaction]] = {}
custom_emoji_ids: list[int] = [] custom_emoji_ids: list[int] = []
for reaction in reaction_list: for reaction in reaction_list:
if isinstance(reaction.peer_id, (PeerUser, PeerChannel)) and isinstance( if isinstance(reaction.peer_id, (PeerUser, PeerChannel)) and isinstance(
reaction.reaction, (ReactionEmoji, ReactionCustomEmoji) reaction.reaction, (ReactionEmoji, ReactionCustomEmoji)
): ):
reactions.setdefault(p.Puppet.get_id_from_peer(reaction.peer_id), []).append( sender_user_id = p.Puppet.get_id_from_peer(reaction.peer_id)
reaction.reaction reactions.setdefault(sender_user_id, []).append(reaction)
)
if isinstance(reaction.reaction, ReactionCustomEmoji): if isinstance(reaction.reaction, ReactionCustomEmoji):
custom_emoji_ids.append(reaction.reaction.document_id) custom_emoji_ids.append(reaction.reaction.document_id)
is_full = len(reaction_list) == total_count is_full = len(reaction_list) == total_count
@@ -3235,7 +3411,8 @@ class Portal(DBPortal, BasePortal):
new_reaction: TypeReaction new_reaction: TypeReaction
for sender, new_reactions in reactions.items(): for sender, new_reactions in reactions.items():
for new_reaction in new_reactions: for new_wrapped_reaction in new_reactions:
new_reaction = new_wrapped_reaction.reaction
if isinstance(new_reaction, ReactionEmoji): if isinstance(new_reaction, ReactionEmoji):
emoji_id = new_reaction.emoticon emoji_id = new_reaction.emoticon
matrix_reaction = variation_selector.add(new_reaction.emoticon) matrix_reaction = variation_selector.add(new_reaction.emoticon)
@@ -3252,7 +3429,10 @@ class Portal(DBPortal, BasePortal):
self.log.debug(f"Bridging reaction {emoji_id} by {sender} to {msg.tgid}") self.log.debug(f"Bridging reaction {emoji_id} by {sender} to {msg.tgid}")
puppet: p.Puppet = await p.Puppet.get_by_tgid(sender) puppet: p.Puppet = await p.Puppet.get_by_tgid(sender)
mxid = await puppet.intent_for(self).react( mxid = await puppet.intent_for(self).react(
msg.mx_room, msg.mxid, matrix_reaction, timestamp=timestamp msg.mx_room,
msg.mxid,
matrix_reaction,
timestamp=new_wrapped_reaction.date or timestamp,
) )
await DBReaction( await DBReaction(
mxid=mxid, mxid=mxid,
@@ -3476,6 +3656,16 @@ class Portal(DBPortal, BasePortal):
return False return False
return True return True
async def handle_telegram_direct_call(
self, source: au.AbstractUser, sender: p.Puppet, update: UpdatePhoneCall
) -> None:
if isinstance(update.phone_call, PhoneCallRequested):
call_type = "video call" if update.phone_call.video else "call"
await self._send_message(
sender.intent_for(self),
TextMessageEventContent(msgtype=MessageType.EMOTE, body=f"started a {call_type}"),
)
async def handle_telegram_action( async def handle_telegram_action(
self, source: au.AbstractUser, sender: p.Puppet | None, update: MessageService self, source: au.AbstractUser, sender: p.Puppet | None, update: MessageService
) -> None: ) -> None:
@@ -3503,11 +3693,53 @@ class Portal(DBPortal, BasePortal):
await self.delete_telegram_user(TelegramID(action.user_id), sender) await self.delete_telegram_user(TelegramID(action.user_id), sender)
elif isinstance(action, MessageActionChatMigrateTo): elif isinstance(action, MessageActionChatMigrateTo):
await self._migrate_and_save_telegram(TelegramID(action.channel_id)) await self._migrate_and_save_telegram(TelegramID(action.channel_id))
# TODO encrypt await self._send_message(
await sender.intent_for(self).send_emote( sender.intent_for(self),
self.mxid, "upgraded this group to a supergroup." TextMessageEventContent(
msgtype=MessageType.EMOTE,
body="upgraded this group to a supergroup",
),
) )
await self.update_bridge_info() await self.update_bridge_info()
elif isinstance(action, MessageActionPhoneCall):
call_type = "Video call" if action.video else "Call"
end_reason = "ended"
if isinstance(action.reason, PhoneCallDiscardReasonMissed):
end_reason = "cancelled" if sender.tgid == source.tgid else "missed"
elif isinstance(action.reason, PhoneCallDiscardReasonBusy):
end_reason = "rejected"
elif isinstance(action.reason, PhoneCallDiscardReasonDisconnect):
end_reason = "disconnected"
body = f"{call_type} {end_reason}"
if action.duration:
body += f" ({format_duration(action.duration)}"
await self._send_message(
sender.intent_for(self),
TextMessageEventContent(msgtype=MessageType.NOTICE, body=body),
)
elif isinstance(action, MessageActionGroupCall):
await self._send_message(
sender.intent_for(self),
TextMessageEventContent(
msgtype=MessageType.EMOTE,
body=(
"started a video chat"
if action.duration is None
else f"ended the video chat ({format_duration(action.duration)})"
),
),
)
elif isinstance(action, MessageActionGiftPremium):
await self._send_message(
sender.intent_for(self),
TextMessageEventContent(
msgtype=MessageType.EMOTE,
body=(
f"gifted Telegram Premium for {action.months} months "
f"({action.amount / 100} {action.currency})"
),
),
)
elif isinstance(action, MessageActionGameScore): elif isinstance(action, MessageActionGameScore):
# TODO handle game score # TODO handle game score
pass pass
@@ -3809,7 +4041,7 @@ class Portal(DBPortal, BasePortal):
return portal return portal
if peer_type: if peer_type:
cls.log.info(f"Creating portal for {peer_type} {tgid} (receiver {tg_receiver})") cls.log.info(f"Creating portal object for {peer_type} {tgid} (receiver {tg_receiver})")
# TODO enable this for non-release builds # TODO enable this for non-release builds
# (or add better wrong peer type error handling) # (or add better wrong peer type error handling)
# if peer_type == "chat": # if peer_type == "chat":
@@ -34,6 +34,8 @@ from telethon.tl.types import (
DocumentAttributeVideo, DocumentAttributeVideo,
Game, Game,
InputPhotoFileLocation, InputPhotoFileLocation,
InputStickerSetID,
InputStickerSetShortName,
Message, Message,
MessageEntityPre, MessageEntityPre,
MessageMediaContact, MessageMediaContact,
@@ -42,11 +44,14 @@ from telethon.tl.types import (
MessageMediaGame, MessageMediaGame,
MessageMediaGeo, MessageMediaGeo,
MessageMediaGeoLive, MessageMediaGeoLive,
MessageMediaInvoice,
MessageMediaPhoto, MessageMediaPhoto,
MessageMediaPoll, MessageMediaPoll,
MessageMediaStory,
MessageMediaUnsupported, MessageMediaUnsupported,
MessageMediaVenue, MessageMediaVenue,
MessageMediaWebPage, MessageMediaWebPage,
MessageReplyStoryHeader,
PeerChannel, PeerChannel,
PeerUser, PeerUser,
Photo, Photo,
@@ -104,6 +109,7 @@ class DocAttrs(NamedTuple):
mime_type: str | None mime_type: str | None
is_sticker: bool is_sticker: bool
sticker_alt: str | None sticker_alt: str | None
sticker_pack_ref: dict | None
width: int width: int
height: int height: int
is_gif: bool is_gif: bool
@@ -142,6 +148,8 @@ class TelegramMessageConverter:
MessageMediaUnsupported: self._convert_unsupported, MessageMediaUnsupported: self._convert_unsupported,
MessageMediaGame: self._convert_game, MessageMediaGame: self._convert_game,
MessageMediaContact: self._convert_contact, MessageMediaContact: self._convert_contact,
MessageMediaStory: self._convert_story,
MessageMediaInvoice: self._convert_invoice,
} }
self._allowed_media = tuple(self._media_converters.keys()) self._allowed_media = tuple(self._media_converters.keys())
@@ -252,6 +260,8 @@ class TelegramMessageConverter:
) -> None: ) -> None:
if not evt.reply_to: if not evt.reply_to:
return return
elif isinstance(evt.reply_to, MessageReplyStoryHeader):
return
space = ( space = (
evt.peer_id.channel_id evt.peer_id.channel_id
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel) if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
@@ -259,6 +269,7 @@ class TelegramMessageConverter:
) )
reply_to_id = TelegramID(evt.reply_to.reply_to_msg_id) reply_to_id = TelegramID(evt.reply_to.reply_to_msg_id)
msg = await DBMessage.get_one_by_tgid(reply_to_id, space) 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 not msg or msg.mx_room != self.portal.mxid:
if deterministic_id: if deterministic_id:
content.set_reply(self.deterministic_event_id(space, reply_to_id)) content.set_reply(self.deterministic_event_id(space, reply_to_id))
@@ -496,6 +507,7 @@ class TelegramMessageConverter:
info["fi.mau.telegram.gif"] = True info["fi.mau.telegram.gif"] = True
else: else:
info["fi.mau.telegram.animated_sticker"] = True info["fi.mau.telegram.animated_sticker"] = True
info["fi.mau.gif"] = True
info["fi.mau.loop"] = True info["fi.mau.loop"] = True
info["fi.mau.autoplay"] = True info["fi.mau.autoplay"] = True
info["fi.mau.hide_controls"] = True info["fi.mau.hide_controls"] = True
@@ -698,10 +710,33 @@ class TelegramMessageConverter:
) )
return ConvertedMessage(content=content) 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: def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAttrs:
name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0 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() is_gif, is_audio, is_voice, duration, waveform = False, False, False, 0, bytes()
sticker_pack_ref = None
for attr in attributes: for attr in attributes:
if isinstance(attr, DocumentAttributeFilename): if isinstance(attr, DocumentAttributeFilename):
name = name or attr.file_name name = name or attr.file_name
@@ -709,6 +744,13 @@ def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAt
elif isinstance(attr, DocumentAttributeSticker): elif isinstance(attr, DocumentAttributeSticker):
is_sticker = True is_sticker = True
sticker_alt = attr.alt 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): elif isinstance(attr, DocumentAttributeAnimated):
is_gif = True is_gif = True
elif isinstance(attr, DocumentAttributeVideo): elif isinstance(attr, DocumentAttributeVideo):
@@ -726,6 +768,7 @@ def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAt
mime_type, mime_type,
is_sticker, is_sticker,
sticker_alt, sticker_alt,
sticker_pack_ref,
width, width,
height, height,
is_gif, is_gif,
@@ -758,6 +801,13 @@ def _parse_document_meta(
mime_type = file.mime_type or document.mime_type mime_type = file.mime_type or document.mime_type
info = ImageInfo(size=file.size, mimetype=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: if attrs.mime_type and not file.was_converted:
file.mime_type = attrs.mime_type or file.mime_type file.mime_type = attrs.mime_type or file.mime_type
if file.width and file.height: if file.width and file.height:
+42 -4
View File
@@ -55,6 +55,7 @@ if TYPE_CHECKING:
class Puppet(DBPuppet, BasePuppet): class Puppet(DBPuppet, BasePuppet):
bridge: TelegramBridge
config: Config config: Config
hs_domain: str hs_domain: str
mxid_template: SimpleTemplate[TelegramID] mxid_template: SimpleTemplate[TelegramID]
@@ -78,6 +79,7 @@ class Puppet(DBPuppet, BasePuppet):
avatar_url: ContentURI | None = None, avatar_url: ContentURI | None = None,
name_set: bool = False, name_set: bool = False,
avatar_set: bool = False, avatar_set: bool = False,
contact_info_set: bool = False,
is_bot: bool = False, is_bot: bool = False,
is_channel: bool = False, is_channel: bool = False,
is_premium: bool = False, is_premium: bool = False,
@@ -100,6 +102,7 @@ class Puppet(DBPuppet, BasePuppet):
avatar_url=avatar_url, avatar_url=avatar_url,
name_set=name_set, name_set=name_set,
avatar_set=avatar_set, avatar_set=avatar_set,
contact_info_set=contact_info_set,
is_bot=is_bot, is_bot=is_bot,
is_channel=is_channel, is_channel=is_channel,
is_premium=is_premium, is_premium=is_premium,
@@ -154,6 +157,7 @@ class Puppet(DBPuppet, BasePuppet):
@classmethod @classmethod
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]: def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
cls.bridge = bridge
cls.config = bridge.config cls.config = bridge.config
cls.loop = bridge.loop cls.loop = bridge.loop
cls.mx = bridge.matrix cls.mx = bridge.matrix
@@ -265,11 +269,14 @@ class Puppet(DBPuppet, BasePuppet):
is_bot != self.is_bot or is_channel != self.is_channel or is_premium != self.is_premium is_bot != self.is_bot or is_channel != self.is_channel or is_premium != self.is_premium
) )
self.is_bot = is_bot if is_bot is not None:
self.is_bot = is_bot
self.is_channel = is_channel self.is_channel = is_channel
self.is_premium = is_premium if is_premium is not None:
self.is_premium = is_premium
if self.username != info.username: 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 self.username = info.username
changed = True changed = True
@@ -279,6 +286,8 @@ class Puppet(DBPuppet, BasePuppet):
if not self.disable_updates: if not self.disable_updates:
try: try:
changed = await self._update_contact_info(force=changed) or changed
changed = ( changed = (
await self.update_displayname(source, info, client_override=client_override) await self.update_displayname(source, info, client_override=client_override)
or changed or changed
@@ -296,8 +305,37 @@ class Puppet(DBPuppet, BasePuppet):
await self.update_portals_meta() await self.update_portals_meta()
await self.save() 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: async def update_portals_meta(self) -> None:
if not p.Portal.private_chat_portal_meta and not self.mx.e2ee: if p.Portal.private_chat_portal_meta != "always" and not self.mx.e2ee:
return return
async for portal in p.Portal.find_private_chats_with(self.tgid): async for portal in p.Portal.find_private_chats_with(self.tgid):
await portal.update_info_from_puppet(self) await portal.update_info_from_puppet(self)
+6 -1
View File
@@ -22,6 +22,7 @@ from telethon.tl.patched import Message
from telethon.tl.types import ( from telethon.tl.types import (
InputMediaUploadedDocument, InputMediaUploadedDocument,
InputMediaUploadedPhoto, InputMediaUploadedPhoto,
InputReplyToMessage,
TypeDocumentAttribute, TypeDocumentAttribute,
TypeInputMedia, TypeInputMedia,
TypeInputPeer, TypeInputPeer,
@@ -67,6 +68,10 @@ class MautrixTelegramClient(TelegramClient):
entity = await self.get_input_entity(entity) entity = await self.get_input_entity(entity)
reply_to = utils.get_message_id(reply_to) reply_to = utils.get_message_id(reply_to)
request = SendMediaRequest( request = SendMediaRequest(
entity, media, message=caption or "", entities=entities or [], reply_to_msg_id=reply_to 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) return self._get_response_message(request, await self(request), entity)
+69 -20
View File
@@ -39,6 +39,9 @@ from telethon.tl.types import (
ChatForbidden, ChatForbidden,
InputUserSelf, InputUserSelf,
Message, Message,
MessageActionContactSignUp,
MessageActionHistoryClear,
MessageService,
NotifyPeer, NotifyPeer,
PeerUser, PeerUser,
TypeUpdate, TypeUpdate,
@@ -52,6 +55,7 @@ from telethon.tl.types import (
User as TLUser, User as TLUser,
) )
from telethon.tl.types.contacts import ContactsNotModified from telethon.tl.types.contacts import ContactsNotModified
from telethon.tl.types.help import AppConfig
from telethon.tl.types.messages import AvailableReactions from telethon.tl.types.messages import AvailableReactions
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY
@@ -106,6 +110,7 @@ class User(DBUser, AbstractUser, BaseUser):
_available_emoji_reactions_fetched: float _available_emoji_reactions_fetched: float
_available_emoji_reactions_lock: asyncio.Lock _available_emoji_reactions_lock: asyncio.Lock
_app_config: dict[str, Any] | None _app_config: dict[str, Any] | None
_app_config_hash: int
def __init__( def __init__(
self, self,
@@ -143,6 +148,7 @@ class User(DBUser, AbstractUser, BaseUser):
self._available_emoji_reactions_fetched = 0 self._available_emoji_reactions_fetched = 0
self._available_emoji_reactions_lock = asyncio.Lock() self._available_emoji_reactions_lock = asyncio.Lock()
self._app_config = None self._app_config = None
self._app_config_hash = 0
( (
self.relaybot_whitelisted, self.relaybot_whitelisted,
@@ -487,13 +493,16 @@ class User(DBUser, AbstractUser, BaseUser):
self.log.info(f"Creating portal for {portal.tgid_log} as part of backfill loop") self.log.info(f"Creating portal for {portal.tgid_log} as part of backfill loop")
try: try:
await portal.create_matrix_room( await portal.create_matrix_room(
self, client=client, update_if_exists=False, invites=[self.mxid] self,
client=client,
update_if_exists=False,
invites=[self.mxid],
from_dialog_sync=True,
) )
except Exception: except Exception:
self.log.exception(f"Error while creating {portal.tgid_log}") self.log.exception(f"Error while creating {portal.tgid_log}")
else: else:
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid) await self.post_sync_dialog(portal, puppet=None, was_created=True, **post_sync_args)
await self._post_sync_dialog(portal, puppet, was_created=True, **post_sync_args)
async def update(self, update: TypeUpdate) -> bool: async def update(self, update: TypeUpdate) -> bool:
if not self.is_bot: if not self.is_bot:
@@ -535,7 +544,7 @@ class User(DBUser, AbstractUser, BaseUser):
await self.stop() await self.stop()
return None return None
async def update_info(self, info: TLUser = None) -> None: async def update_info(self, info: TLUser | None = None) -> None:
if not info: if not info:
info = await self.get_me() info = await self.get_me()
if not info: if not info:
@@ -608,8 +617,11 @@ class User(DBUser, AbstractUser, BaseUser):
await self.stop() await self.stop()
await sess.delete() await sess.delete()
# Drop LOGGED_OUT states if the user was already logged out previously
# and doesn't have a remote ID anymore
# TODO send a management room notice for non-manual logouts? # TODO send a management room notice for non-manual logouts?
await self.push_bridge_state(state, error=error, message=message) if self.tgid or state != BridgeStateEvent.LOGGED_OUT:
await self.push_bridge_state(state, error=error, message=message)
if delete: if delete:
await self.delete() await self.delete()
self.by_mxid.pop(self.mxid, None) self.by_mxid.pop(self.mxid, None)
@@ -743,12 +755,12 @@ class User(DBUser, AbstractUser, BaseUser):
) )
await self._mute_room(puppet, portal, update.notify_settings.mute_until.timestamp()) await self._mute_room(puppet, portal, update.notify_settings.mute_until.timestamp())
async def _sync_dialog( @staticmethod
self, portal: po.Portal, dialog: Dialog, should_create: bool, puppet: pu.Puppet | None def dialog_to_sync_args(dialog: Dialog) -> dict:
) -> None: return {
was_created = False "last_message_ts": (
post_sync_args = { cast(datetime, dialog.date).timestamp() if dialog.date else time.time()
"last_message_ts": cast(datetime, dialog.date).timestamp(), ),
"unread_count": dialog.unread_count, "unread_count": dialog.unread_count,
"max_read_id": dialog.dialog.read_inbox_max_id, "max_read_id": dialog.dialog.read_inbox_max_id,
"mute_until": ( "mute_until": (
@@ -759,6 +771,24 @@ class User(DBUser, AbstractUser, BaseUser):
"pinned": dialog.pinned, "pinned": dialog.pinned,
"archived": dialog.archived, "archived": dialog.archived,
} }
async def _sync_dialog(
self, portal: po.Portal, dialog: Dialog, should_create: bool, puppet: pu.Puppet | None
) -> None:
if (
not portal.mxid
and isinstance(dialog.message, MessageService)
and isinstance(
dialog.message.action, (MessageActionContactSignUp, MessageActionHistoryClear)
)
):
self.log.debug(
f"Not syncing {portal.tgid_log} "
f"(last message is a {type(dialog.message.action).__name__})"
)
return
was_created = False
post_sync_args = self.dialog_to_sync_args(dialog)
if portal.mxid: if portal.mxid:
self.log.debug(f"Backfilling and updating {portal.tgid_log} (dialog sync)") self.log.debug(f"Backfilling and updating {portal.tgid_log} (dialog sync)")
try: try:
@@ -772,7 +802,9 @@ class User(DBUser, AbstractUser, BaseUser):
elif should_create: elif should_create:
self.log.debug(f"Creating portal for {portal.tgid_log} immediately (dialog sync)") self.log.debug(f"Creating portal for {portal.tgid_log} immediately (dialog sync)")
try: try:
await portal.create_matrix_room(self, dialog.entity, invites=[self.mxid]) await portal.create_matrix_room(
self, dialog.entity, invites=[self.mxid], from_dialog_sync=True
)
was_created = True was_created = True
except Exception: except Exception:
self.log.exception(f"Error while creating {portal.tgid_log}") self.log.exception(f"Error while creating {portal.tgid_log}")
@@ -785,7 +817,7 @@ class User(DBUser, AbstractUser, BaseUser):
extra_data=post_sync_args, extra_data=post_sync_args,
) )
if portal.mxid and puppet and puppet.is_real_user: if portal.mxid and puppet and puppet.is_real_user:
await self._post_sync_dialog( await self.post_sync_dialog(
portal=portal, portal=portal,
puppet=puppet, puppet=puppet,
was_created=was_created, was_created=was_created,
@@ -793,10 +825,10 @@ class User(DBUser, AbstractUser, BaseUser):
) )
self.log.debug(f"_sync_dialog finished for {portal.tgid_log}") self.log.debug(f"_sync_dialog finished for {portal.tgid_log}")
async def _post_sync_dialog( async def post_sync_dialog(
self, self,
portal: po.Portal, portal: po.Portal,
puppet: pu.Puppet, puppet: pu.Puppet | None,
was_created: bool, was_created: bool,
max_read_id: int, max_read_id: int,
last_message_ts: float, last_message_ts: float,
@@ -805,6 +837,10 @@ class User(DBUser, AbstractUser, BaseUser):
pinned: bool, pinned: bool,
archived: bool, archived: bool,
) -> None: ) -> None:
if puppet is None:
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
if not puppet or not puppet.is_real_user:
return
self.log.debug( self.log.debug(
f"Running dialog post-sync for {portal.tgid_log} with args " f"Running dialog post-sync for {portal.tgid_log} with args "
f"{was_created=}, {max_read_id=}, {last_message_ts=}, {unread_count=}, " f"{was_created=}, {max_read_id=}, {last_message_ts=}, {unread_count=}, "
@@ -941,11 +977,18 @@ class User(DBUser, AbstractUser, BaseUser):
self.log.debug("Contact syncing complete") self.log.debug("Contact syncing complete")
return contacts return contacts
@property
def _available_reactions_up_to_date(self) -> bool:
return (
bool(self._available_emoji_reactions)
and self._available_emoji_reactions_fetched + 12 * 60 * 60 > time.monotonic()
)
async def get_available_reactions(self) -> set[str]: async def get_available_reactions(self) -> set[str]:
if self._available_emoji_reactions_fetched + 12 * 60 * 60 > time.monotonic(): if self._available_reactions_up_to_date:
return self._available_emoji_reactions return self._available_emoji_reactions
async with self._available_emoji_reactions_lock: async with self._available_emoji_reactions_lock:
if self._available_emoji_reactions_fetched + 12 * 60 * 60 > time.monotonic(): if self._available_reactions_up_to_date:
return self._available_emoji_reactions return self._available_emoji_reactions
self.log.debug("Fetching available emoji reactions") self.log.debug("Fetching available emoji reactions")
available_reactions = await self.client( available_reactions = await self.client(
@@ -955,13 +998,18 @@ class User(DBUser, AbstractUser, BaseUser):
self._available_emoji_reactions = { self._available_emoji_reactions = {
react.reaction react.reaction
for react in available_reactions.reactions for react in available_reactions.reactions
if self.is_premium or not react.premium if not react.inactive and (self.is_premium or not react.premium)
} }
self._available_emoji_reactions_hash = available_reactions.hash self._available_emoji_reactions_hash = available_reactions.hash
self._available_emoji_reactions_fetched = time.monotonic() self._available_emoji_reactions_fetched = time.monotonic()
self.log.debug( self.log.debug(
"Got available emoji reactions: %s", self._available_emoji_reactions "Got available emoji reactions: %s", self._available_emoji_reactions
) )
elif self._available_emoji_reactions is None:
self.log.warning(
f"Got {available_reactions} in response to available reactions request"
" even though nothing is cached"
)
return self._available_emoji_reactions return self._available_emoji_reactions
def tl_to_json(self) -> Any: def tl_to_json(self) -> Any:
@@ -969,8 +1017,9 @@ class User(DBUser, AbstractUser, BaseUser):
async def get_app_config(self) -> dict[str, Any]: async def get_app_config(self) -> dict[str, Any]:
if not self._app_config: if not self._app_config:
cfg = await self.client(GetAppConfigRequest()) cfg: AppConfig = await self.client(GetAppConfigRequest(hash=self._app_config_hash))
self._app_config = util.parse_tl_json(cfg) self._app_config = util.parse_tl_json(cfg.config)
self._app_config_hash = cfg.hash
return self._app_config return self._app_config
async def get_max_reactions(self, is_premium: bool | None = None) -> int: async def get_max_reactions(self, is_premium: bool | None = None) -> int:
+35 -1
View File
@@ -35,6 +35,7 @@ from telethon.errors import (
PhoneNumberInvalidError, PhoneNumberInvalidError,
PhoneNumberUnoccupiedError, PhoneNumberUnoccupiedError,
SessionPasswordNeededError, SessionPasswordNeededError,
SessionRevokedError,
) )
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
@@ -288,6 +289,17 @@ class AuthAPI(abc.ABC):
errcode="phone_number_unoccupied", errcode="phone_number_unoccupied",
error="That phone number has not been registered.", error="That phone number has not been registered.",
) )
except FloodWaitError as e:
return self.get_login_response(
mxid=user.mxid,
state="code",
status=429,
errcode="flood_wait",
error=(
"You tried to enter your phone code too many times. "
f"Please wait for {format_duration(e.seconds)} before trying again."
),
)
except SessionPasswordNeededError: except SessionPasswordNeededError:
if not password_in_data: if not password_in_data:
if user.command_status and user.command_status["action"] == "Login": if user.command_status and user.command_status["action"] == "Login":
@@ -342,6 +354,28 @@ class AuthAPI(abc.ABC):
errcode="password_invalid", errcode="password_invalid",
error="Incorrect password.", error="Incorrect password.",
) )
except SessionRevokedError:
return self.get_login_response(
mxid=user.mxid,
state="request",
status=401,
errcode="session_revoked",
error=(
"Please try again. Login cancelled because your other sessions were "
"terminated via the Telegram app."
),
)
except FloodWaitError as e:
return self.get_login_response(
mxid=user.mxid,
state="password",
status=429,
errcode="flood_wait",
error=(
"You tried to enter your password too many times. "
f"Please wait for {format_duration(e.seconds)} before trying again."
),
)
except Exception as e: except Exception as e:
self.log.exception("Error sending password") self.log.exception("Error sending password")
if isinstance(e, ValueError) and "You must provide a phone and a code" in str(e): if isinstance(e, ValueError) and "You must provide a phone and a code" in str(e):
@@ -357,5 +391,5 @@ class AuthAPI(abc.ABC):
state="password", state="password",
status=500, status=500,
errcode="unknown_error", errcode="unknown_error",
error="Internal server error while sending password.", error=f"Internal server error while sending password. {e}",
) )
+6 -4
View File
@@ -7,15 +7,14 @@ aiodns
brotli brotli
#/qr_login #/qr_login
pillow>=4,<10 pillow>=10.0.1,<11
qrcode>=6,<8 qrcode>=6,<8
#/formattednumbers #/formattednumbers
phonenumbers>=8,<9 phonenumbers>=8,<9
#/metrics #/metrics
prometheus_client>=0.6,<0.17 prometheus_client>=0.6,<0.18
#/e2be #/e2be
python-olm>=3,<4 python-olm>=3,<4
@@ -23,4 +22,7 @@ pycryptodome>=3,<4
unpaddedbase64>=1,<3 unpaddedbase64>=1,<3
#/sqlite #/sqlite
aiosqlite>=0.16,<0.19 aiosqlite>=0.16,<0.20
#/proxy
python-socks[asyncio]
+3 -4
View File
@@ -3,9 +3,8 @@ python-magic>=0.4,<0.5
commonmark>=0.8,<0.10 commonmark>=0.8,<0.10
aiohttp>=3,<4 aiohttp>=3,<4
yarl>=1,<2 yarl>=1,<2
mautrix>=0.19.4,<0.20 mautrix>=0.20.2,<0.21
#telethon>=1.25.4,<1.27 tulir-telethon==1.30.0a2
tulir-telethon==1.28.0a3 asyncpg>=0.20,<0.29
asyncpg>=0.20,<0.28
mako>=1,<2 mako>=1,<2
setuptools setuptools
+2 -2
View File
@@ -51,7 +51,7 @@ setuptools.setup(
install_requires=install_requires, install_requires=install_requires,
extras_require=extras_require, extras_require=extras_require,
python_requires="~=3.8", python_requires="~=3.9",
classifiers=[ classifiers=[
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
@@ -60,9 +60,9 @@ setuptools.setup(
"Framework :: AsyncIO", "Framework :: AsyncIO",
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
], ],
package_data={"mautrix_telegram": [ package_data={"mautrix_telegram": [
"web/public/*.mako", "web/public/*.png", "web/public/*.css", "web/public/*.mako", "web/public/*.png", "web/public/*.css",