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
*.log*
*.db
*.db-*
/*.pickle
*.bak
/*.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)
### 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 \
python3 py3-pip py3-setuptools py3-wheel \
py3-pillow \
#py3-pillow \
py3-aiohttp \
py3-magic \
py3-ruamel.yaml \
@@ -14,8 +14,6 @@ RUN apk add --no-cache \
py3-idna \
py3-rsa \
#py3-telethon \ (outdated)
# Optional for socks proxies
py3-pysocks \
py3-pyaes \
# cryptg
py3-cffi \
@@ -34,7 +32,9 @@ RUN apk add --no-cache \
bash \
curl \
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 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>"
+3 -2
View File
@@ -39,6 +39,8 @@ from .abstract_user import AbstractUser # isort: skip
class TelegramBridge(Bridge):
module = "mautrix_telegram"
name = "mautrix-telegram"
beeper_service_name = "telegram"
beeper_network_name = "telegram"
command = "python -m mautrix-telegram"
description = "A Matrix-Telegram puppeting bridge."
repo_url = "https://github.com/mautrix/telegram"
@@ -50,6 +52,7 @@ class TelegramBridge(Bridge):
config: Config
bot: Bot | None
matrix: MatrixHandler
public_website: PublicBridgeWebsite | None
provisioning_api: ProvisioningAPI | None
@@ -101,8 +104,6 @@ class TelegramBridge(Bridge):
self.log.info("Finished re-sending bridge info state events")
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())
if self.bot:
self.add_shutdown_actions(self.bot.stop())
+45 -1
View File
@@ -38,6 +38,7 @@ from telethon.tl.types import (
PeerChannel,
PeerChat,
PeerUser,
PhoneCallRequested,
TypeUpdate,
UpdateChannel,
UpdateChannelUserTyping,
@@ -54,6 +55,7 @@ from telethon.tl.types import (
UpdateNewChannelMessage,
UpdateNewMessage,
UpdateNotifySettings,
UpdatePhoneCall,
UpdatePinnedChannelMessages,
UpdatePinnedDialogs,
UpdatePinnedMessages,
@@ -206,6 +208,8 @@ class AbstractUser(ABC):
sysversion = self.config["telegram.device_info.system_version"]
appversion = self.config["telegram.device_info.app_version"]
connection, proxy = self._proxy_settings
if proxy:
self.log.debug(f"Using proxy setting: {proxy}")
assert isinstance(session, Session)
@@ -233,6 +237,7 @@ class AbstractUser(ABC):
loop=self.loop,
base_logger=base_logger,
update_error_callback=self._telethon_update_error_callback,
use_ipv6=self.config["telegram.connection.use_ipv6"],
)
self.client.add_event_handler(self._update_catch)
@@ -343,6 +348,8 @@ class AbstractUser(ABC):
await self.delete_message(update)
elif isinstance(update, UpdateDeleteChannelMessages):
await self.delete_channel_message(update)
elif isinstance(update, UpdatePhoneCall):
await self.update_phone_call(update)
elif isinstance(update, UpdateMessageReactions):
await self.update_reactions(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
@@ -617,6 +624,19 @@ class AbstractUser(ABC):
return
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions)
async def update_phone_call(self, update: UpdatePhoneCall) -> None:
self.log.debug("Phone call update %s", update)
if not isinstance(update.phone_call, PhoneCallRequested):
return
tgid = TelegramID(update.phone_call.participant_id)
if tgid == self.tgid:
tgid = update.phone_call.admin_id
portal = await po.Portal.get_by_tgid(tgid, tg_receiver=self.tgid, peer_type="user")
if not portal or not portal.mxid or not portal.allow_bridging:
return
sender = await pu.Puppet.get_by_tgid(TelegramID(update.phone_call.admin_id))
await portal.handle_telegram_direct_call(self, sender, update)
async def update_channel(self, update: UpdateChannel) -> None:
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
if not portal:
@@ -659,7 +679,15 @@ class AbstractUser(ABC):
if not portal:
return
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
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)
return
task = self._call_portal_message_handler(update, original_update, portal, sender)
if portal.backfill_lock.locked:
self.log.debug(
f"{portal.tgid_log} is backfill locked, moving incoming message to async task"
)
background_task.create(task)
else:
await task
async def _call_portal_message_handler(
self,
update: UpdateMessageContent,
original_update: UpdateMessage,
portal: po.Portal,
sender: pu.Puppet,
) -> None:
await portal.backfill_lock.wait(f"update {update.id}")
if isinstance(update, MessageService):
+1 -1
View File
@@ -395,7 +395,7 @@ class Bot(AbstractUser):
def reply(reply_text: str) -> Awaitable[Message]:
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"]
if pcm:
await reply(pcm)
@@ -39,8 +39,6 @@ async def clear_db_cache(evt: CommandEvent) -> EventID:
await evt.reply("Cleared portal cache")
elif section == "puppet":
pu.Puppet.by_tgid = {}
for puppet in pu.Puppet.by_custom_mxid.values():
puppet.stop()
pu.Puppet.by_custom_mxid = {}
await asyncio.gather(
*[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:
return await evt.reply("User not found")
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
if puppet:
puppet.stop()
await user.stop()
del u.User.by_tgid[user.tgid]
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.")
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
tgid_str = evt.args[0]
@@ -65,19 +65,11 @@ async def create(evt: CommandEvent) -> EventID:
about=about,
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)
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:
await portal.delete()
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.height")
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_error_reports")
copy("bridge.incoming_bridge_error_reports")
@@ -166,8 +174,27 @@ class Config(BaseBridgeConfig):
copy("bridge.backfill.double_puppet_backfill")
copy("bridge.backfill.normal_groups")
copy("bridge.backfill.unread_hours_threshold")
copy("bridge.backfill.forward.initial_limit")
copy("bridge.backfill.forward.sync_limit")
if "bridge.backfill.forward" in self:
initial_limit = self.get("bridge.backfill.forward.initial_limit", 10)
sync_limit = self.get("bridge.backfill.forward.sync_limit", 100)
base["bridge.backfill.forward_limits.initial.user"] = initial_limit
base["bridge.backfill.forward_limits.initial.normal_group"] = initial_limit
base["bridge.backfill.forward_limits.initial.supergroup"] = initial_limit
base["bridge.backfill.forward_limits.initial.channel"] = initial_limit
base["bridge.backfill.forward_limits.sync.user"] = sync_limit
base["bridge.backfill.forward_limits.sync.normal_group"] = sync_limit
base["bridge.backfill.forward_limits.sync.supergroup"] = sync_limit
base["bridge.backfill.forward_limits.sync.channel"] = sync_limit
else:
copy("bridge.backfill.forward_limits.initial.user")
copy("bridge.backfill.forward_limits.initial.normal_group")
copy("bridge.backfill.forward_limits.initial.supergroup")
copy("bridge.backfill.forward_limits.initial.channel")
copy("bridge.backfill.forward_limits.sync.user")
copy("bridge.backfill.forward_limits.sync.normal_group")
copy("bridge.backfill.forward_limits.sync.supergroup")
copy("bridge.backfill.forward_limits.sync.channel")
copy("bridge.backfill.forward_timeout")
copy("bridge.backfill.incremental.messages_per_batch")
copy("bridge.backfill.incremental.post_batch_delay")
copy("bridge.backfill.incremental.max_batches.user")
@@ -197,6 +224,7 @@ class Config(BaseBridgeConfig):
copy("bridge.filter.mode")
copy("bridge.filter.list")
copy("bridge.filter.users")
copy("bridge.command_prefix")
@@ -241,6 +269,7 @@ class Config(BaseBridgeConfig):
copy("telegram.connection.retry_delay")
copy("telegram.connection.flood_sleep_threshold")
copy("telegram.connection.request_retries")
copy("telegram.connection.use_ipv6")
copy("telegram.device_info.device_model")
copy("telegram.device_info.system_version")
+9 -6
View File
@@ -48,6 +48,7 @@ class Puppet:
avatar_url: ContentURI | None
name_set: bool
avatar_set: bool
contact_info_set: bool
is_bot: bool | None
is_channel: bool
is_premium: bool
@@ -68,7 +69,7 @@ class Puppet:
columns: ClassVar[str] = (
"id, is_registered, displayname, displayname_source, displayname_contact, "
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
"name_set, avatar_set, 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"
)
@@ -108,6 +109,7 @@ class Puppet:
self.avatar_url,
self.name_set,
self.avatar_set,
self.contact_info_set,
self.is_bot,
self.is_channel,
self.is_premium,
@@ -122,8 +124,9 @@ class Puppet:
UPDATE puppet
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10,
avatar_url=$11, name_set=$12, avatar_set=$13, is_bot=$14, is_channel=$15,
is_premium=$16, custom_mxid=$17, access_token=$18, next_batch=$19, base_url=$20
avatar_url=$11, name_set=$12, avatar_set=$13, contact_info_set=$14, is_bot=$15,
is_channel=$16, is_premium=$17, custom_mxid=$18, access_token=$19, next_batch=$20,
base_url=$21
WHERE id=$1
"""
await self.db.execute(q, *self._values)
@@ -133,9 +136,9 @@ class Puppet:
INSERT INTO puppet (
id, is_registered, displayname, displayname_source, displayname_contact,
displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
avatar_set, is_bot, is_channel, is_premium, custom_mxid, access_token, next_batch,
base_url
avatar_set, contact_info_set, is_bot, is_channel, is_premium, custom_mxid,
access_token, next_batch, base_url
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
$19, $20)
$19, $20, $21)
"""
await self.db.execute(q, *self._values)
+1
View File
@@ -20,4 +20,5 @@ from . import (
v15_backfill_anchor_id,
v16_backfill_type,
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/>.
from mautrix.util.async_db import Connection, Scheme
latest_version = 17
latest_version = 18
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,
name_set BOOLEAN NOT NULL DEFAULT false,
avatar_set BOOLEAN NOT NULL DEFAULT false,
contact_info_set BOOLEAN NOT NULL DEFAULT false,
is_bot BOOLEAN,
is_channel BOOLEAN NOT NULL DEFAULT false,
is_premium BOOLEAN NOT NULL DEFAULT false,
@@ -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.
# Format examples:
# SQLite: sqlite:///filename.db
# SQLite: sqlite:filename.db
# Postgres: postgres://username:password@hostname/dbname
database: postgres://username:password@hostname/dbname
# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
@@ -274,6 +274,27 @@ bridge:
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
# You must use a client that supports requesting keys from other users to use this feature.
allow_key_sharing: false
# Options for deleting megolm sessions from the bridge.
delete_keys:
# Beeper-specific: delete outbound sessions when hungryserv confirms
# that the user has uploaded the key to key backup.
delete_outbound_on_ack: false
# Don't store outbound sessions in the inbound table.
dont_store_outbound: false
# Ratchet megolm sessions forward after decrypting messages.
ratchet_on_decrypt: false
# Delete fully used keys (index >= max_messages) after decrypting messages.
delete_fully_used_on_decrypt: false
# Delete previous megolm sessions from same device when receiving a new one.
delete_prev_on_new_session: false
# Delete megolm sessions received from a device when the device is deleted.
delete_on_device_delete: false
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
periodically_delete_expired: false
# Delete inbound megolm sessions that don't have the received_at field used for
# automatic ratcheting and expired session deletion. This is meant as a migration
# to delete old keys prior to the bridge update.
delete_outdated_inbound: false
# What level of device verification should be required from users?
#
# Valid levels:
@@ -309,9 +330,18 @@ bridge:
# default.
messages: 100
# Whether or not to explicitly set the avatar and room name for private
# chat portal rooms. This will be implicitly enabled if encryption.default is true.
private_chat_portal_meta: false
# Disable rotating keys when a user's devices change?
# You should not enable this option unless you understand all the implications.
disable_device_change_key_rotation: false
# Whether to explicitly set the avatar and room name for private chat portal rooms.
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
# If set to `always`, all DM rooms will have explicit names and avatars set.
# If set to `never`, DM rooms will never have names and avatars set.
private_chat_portal_meta: default
# Disable generating reply fallbacks? Some extremely bad clients still rely on them,
# but they're being phased out and will be completely removed in the future.
disable_reply_fallbacks: false
# Whether or not the bridge should send a read receipt from the bridge bot when a message has
# been sent to Telegram.
delivery_receipts: false
@@ -360,6 +390,9 @@ bridge:
# Even without MSC2716, bridging old messages with correct timestamps requires the double
# puppets to be in an appservice namespace, or the server to be modified to allow
# overriding timestamps anyway.
#
# Also note that adding users to the appservice namespace may have unexpected side effects,
# as described in https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method
double_puppet_backfill: false
# Whether or not to enable backfilling in normal groups.
# Normal groups have numerous technical problems in Telegram, and backfilling normal groups
@@ -374,11 +407,21 @@ bridge:
#
# Using a negative initial limit is not recommended, as it would try to backfill everything in a single batch.
# MSC2716 and the incremental settings are meant for backfilling everything incrementally rather than at once.
forward:
forward_limits:
# 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.
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.
incremental:
@@ -458,7 +501,6 @@ bridge:
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
# `filter-mode` management commands.
#
# Filters do not affect direct chats.
# An empty blacklist will essentially disable the filter.
filter:
# Filter mode to use. Either "blacklist" or "whitelist".
@@ -467,6 +509,11 @@ bridge:
mode: blacklist
# The list of group/channel IDs to filter.
list: []
# How to handle direct chats:
# If users is "null", direct chats will follow the previous settings.
# If users is "true", direct chats will always be bridged.
# If users is "false", direct chats will never be bridged.
users: true
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg"
@@ -567,6 +614,8 @@ telegram:
# is not recommended, since some requests can always trigger a call fail (such as searching
# for messages).
request_retries: 5
# Use IPv6 for Telethon connection
use_ipv6: false
# Device info sent to Telegram.
device_info:
+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
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:
await portal.create_telegram_chat(invited_by, invites=invites, supergroup=True)
await portal.create_telegram_chat(invited_by, supergroup=True)
except ValueError as e:
await portal.delete()
await portal.az.intent.send_notice(room_id, e.args[0])
+296 -64
View File
@@ -1,5 +1,5 @@
# 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
# 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/>.
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 datetime import datetime
from html import escape as escape_html
@@ -23,19 +33,34 @@ from sqlite3 import IntegrityError
from string import Template
import asyncio
import base64
import itertools
import random
import time
from asyncpg import UniqueViolationError
from telethon.errors import (
ChatAdminRequiredError,
ChatNotModifiedError,
ChatRestrictedError,
ChatWriteForbiddenError,
EntitiesTooLongError,
EntityBoundsInvalidError,
EntityMentionUserInvalidError,
InputUserDeactivatedError,
MessageEmptyError,
MessageIdInvalidError,
MessageTooLongError,
PhotoExtInvalidError,
PhotoInvalidDimensionsError,
PhotoSaveFileInvalidError,
ReactionInvalidError,
RPCError,
SlowModeWaitError,
UserBannedInChannelError,
UserIsBlockedError,
YouBlockedUserError,
)
from telethon.tl.custom import Dialog
from telethon.tl.functions.channels import (
CreateChannelRequest,
EditPhotoRequest,
@@ -54,6 +79,7 @@ from telethon.tl.functions.messages import (
ExportChatInviteRequest,
GetMessageReactionsListRequest,
GetMessagesReactionsRequest,
GetPeerDialogsRequest,
MigrateChatRequest,
SendReactionRequest,
SetTypingRequest,
@@ -66,22 +92,26 @@ from telethon.tl.types import (
ChannelFull,
Chat,
ChatBannedRights,
ChatEmpty,
ChatFull,
ChatPhoto,
ChatPhotoEmpty,
DocumentAttributeAudio,
DocumentAttributeFilename,
DocumentAttributeImageSize,
DocumentAttributeSticker,
DocumentAttributeVideo,
GeoPoint,
InputChannel,
InputChatUploadedPhoto,
InputDialogPeer,
InputMediaUploadedDocument,
InputMediaUploadedPhoto,
InputPeerChannel,
InputPeerChat,
InputPeerPhotoFileLocation,
InputPeerUser,
InputStickerSetEmpty,
InputUser,
MessageActionChannelCreate,
MessageActionChatAddUser,
@@ -95,6 +125,9 @@ from telethon.tl.types import (
MessageActionChatMigrateTo,
MessageActionContactSignUp,
MessageActionGameScore,
MessageActionGiftPremium,
MessageActionGroupCall,
MessageActionPhoneCall,
MessageMediaGame,
MessageMediaGeo,
MessagePeerReaction,
@@ -102,6 +135,10 @@ from telethon.tl.types import (
PeerChannel,
PeerChat,
PeerUser,
PhoneCallDiscardReasonBusy,
PhoneCallDiscardReasonDisconnect,
PhoneCallDiscardReasonMissed,
PhoneCallRequested,
Photo,
PhotoEmpty,
ReactionCount,
@@ -126,13 +163,16 @@ from telethon.tl.types import (
UpdateChatUserTyping,
UpdateMessageReactions,
UpdateNewMessage,
UpdatePhoneCall,
UpdateUserTyping,
User,
UserEmpty,
UserFull,
UserProfilePhoto,
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
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY, IntentAPI
@@ -171,7 +211,8 @@ from mautrix.types import (
UserID,
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.simple_lock import SimpleLock
from mautrix.util.simple_template import SimpleTemplate
@@ -242,12 +283,13 @@ class Portal(DBPortal, BasePortal):
# Config cache
filter_mode: str
filter_list: list[int]
filter_users: bool | None
max_initial_member_sync: int
sync_channel_members: bool
sync_matrix_state: bool
public_portals: bool
private_chat_portal_meta: bool
private_chat_portal_meta: Literal["default", "always", "never"]
alias_template: SimpleTemplate[str]
hs_domain: str
@@ -416,14 +458,22 @@ class Portal(DBPortal, BasePortal):
def allow_bridging(self) -> bool:
if self._bridging_blocked_at_runtime:
return False
elif self.peer_type == "user":
return True
elif self.peer_type == "user" and self.filter_users is not None:
return self.filter_users
elif self.filter_mode == "whitelist":
return self.tgid in self.filter_list
elif self.filter_mode == "blacklist":
return self.tgid not in self.filter_list
return True
@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
def init_cls(cls, bridge: "TelegramBridge") -> None:
BasePortal.bridge = bridge
@@ -440,6 +490,7 @@ class Portal(DBPortal, BasePortal):
cls.private_chat_portal_meta = cls.config["bridge.private_chat_portal_meta"]
cls.filter_mode = cls.config["bridge.filter.mode"]
cls.filter_list = cls.config["bridge.filter.list"]
cls.filter_users = cls.config["bridge.filter.users"]
cls.hs_domain = cls.config["homeserver.domain"]
cls.backfill_msc2716 = cls.config["bridge.backfill.msc2716"]
cls.backfill_enable = cls.config["bridge.backfill.enable"]
@@ -465,8 +516,9 @@ class Portal(DBPortal, BasePortal):
async def get_telegram_users_in_matrix_room(
self, source: u.User, pre_create: bool = False
) -> tuple[list[InputUser], list[UserID]]:
) -> tuple[list[InputUser], list[UserID], list[u.User]]:
user_tgids = {}
users = []
intent = self.az.intent if pre_create else self.main_intent
user_mxids = await intent.get_room_members(self.mxid, (Membership.JOIN, Membership.INVITE))
for mxid in user_mxids:
@@ -474,6 +526,7 @@ class Portal(DBPortal, BasePortal):
continue
mx_user = await u.User.get_by_mxid(mxid, create=False)
if mx_user and mx_user.tgid:
users.append(mx_user)
user_tgids[mx_user.tgid] = mxid
puppet_id = p.Puppet.get_id_from_mxid(mxid)
if puppet_id:
@@ -489,7 +542,7 @@ class Portal(DBPortal, BasePortal):
f"creating a group: {e}"
)
errors.append(mxid)
return input_users, errors
return input_users, errors, users
async def upgrade_telegram_chat(self, source: u.User) -> None:
if self.peer_type != "chat":
@@ -540,11 +593,23 @@ class Portal(DBPortal, BasePortal):
if await self._update_username(username):
await self.save()
async def create_telegram_chat(
self, source: u.User, invites: list[InputUser], supergroup: bool = False
) -> None:
async def create_telegram_chat(self, source: u.User, supergroup: bool = False) -> None:
if not self.mxid:
raise ValueError("Can't create Telegram chat for portal without Matrix room.")
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:
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.handle_matrix_power_levels(source, levels.users, {}, None)
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}")
async def handle_matrix_invite(
@@ -698,12 +765,8 @@ class Portal(DBPortal, BasePortal):
source: au.AbstractUser | None = None,
photo: UserProfilePhoto | None = None,
) -> None:
if not self.encrypted and not self.private_chat_portal_meta:
return
if puppet is None:
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_title(puppet.displayname) or changed
if changed:
@@ -716,6 +779,7 @@ class Portal(DBPortal, BasePortal):
entity: TypeChat | User = None,
invites: InviteList = None,
update_if_exists: bool = True,
from_dialog_sync: bool = False,
client: MautrixTelegramClient | None = None,
) -> RoomID | None:
if self.mxid:
@@ -732,7 +796,9 @@ class Portal(DBPortal, BasePortal):
return self.mxid
async with self._room_create_lock:
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:
self.log.exception("Fatal error creating Matrix room")
@@ -787,6 +853,7 @@ class Portal(DBPortal, BasePortal):
user: au.AbstractUser,
entity: TypeChat | User,
invites: InviteList,
from_dialog_sync: bool,
client: MautrixTelegramClient | None = None,
) -> RoomID | None:
if self.mxid:
@@ -798,6 +865,37 @@ class Portal(DBPortal, BasePortal):
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:
entity = await self.get_entity(user, client)
self.log.trace("Fetched data: %s", entity)
@@ -900,7 +998,7 @@ class Portal(DBPortal, BasePortal):
)
if self.is_direct:
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
self.title = puppet.displayname
self.avatar_url = puppet.avatar_url
@@ -908,7 +1006,7 @@ class Portal(DBPortal, BasePortal):
creation_content = {}
if not self.config["bridge.federate_rooms"]:
creation_content["m.federate"] = False
if self.avatar_url:
if self.avatar_url and self.set_dm_room_metadata:
initial_state.append(
{
"type": str(EventType.ROOM_AVATAR),
@@ -920,14 +1018,14 @@ class Portal(DBPortal, BasePortal):
self.log.debug(
f"Creating room with parameters invite={create_invites}, {autojoin_invites=}, "
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(
alias_localpart=alias,
preset=preset,
is_direct=self.is_direct,
invitees=list(create_invites),
name=self.title,
name=self.title if self.set_dm_room_metadata else None,
topic=self.about,
initial_state=initial_state,
creation_content=creation_content,
@@ -935,8 +1033,8 @@ class Portal(DBPortal, BasePortal):
)
if not room_id:
raise Exception(f"Failed to create room")
self.name_set = bool(self.title)
self.avatar_set = bool(self.avatar_url)
self.name_set = bool(self.title) and self.set_dm_room_metadata
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:
try:
@@ -950,6 +1048,10 @@ class Portal(DBPortal, BasePortal):
self.log.debug(f"Matrix room created: {self.mxid}")
await self.az.state_store.set_power_levels(self.mxid, power_levels)
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:
await self.invite_to_matrix(invites)
@@ -1246,11 +1348,12 @@ class Portal(DBPortal, BasePortal):
async def _update_title(
self, title: str, sender: p.Puppet | None = None, save: bool = False
) -> 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
self.title = title
if self.mxid:
self.name_set = False
if self.mxid and self.set_dm_room_metadata:
try:
await self._try_set_state(
sender, EventType.ROOM_NAME, RoomNameStateEventContent(name=self.title)
@@ -1258,7 +1361,6 @@ class Portal(DBPortal, BasePortal):
self.name_set = True
except Exception as e:
self.log.warning(f"Failed to set room name: {e}")
self.name_set = False
if save:
await self.save()
return True
@@ -1266,12 +1368,13 @@ class Portal(DBPortal, BasePortal):
async def _update_avatar_from_puppet(
self, puppet: p.Puppet, user: au.AbstractUser | None, photo: UserProfilePhoto | None
) -> 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
if puppet.avatar_url:
self.photo_id = puppet.photo_id
self.avatar_url = puppet.avatar_url
if self.mxid:
self.avatar_set = False
if self.mxid and self.set_dm_room_metadata:
try:
await self._try_set_state(
None,
@@ -1281,9 +1384,8 @@ class Portal(DBPortal, BasePortal):
self.avatar_set = True
except Exception as e:
self.log.warning(f"Failed to set room avatar: {e}")
self.avatar_set = False
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)
else:
return False
@@ -1741,6 +1843,7 @@ class Portal(DBPortal, BasePortal):
max_image_size = self.config["bridge.image_as_file_size"] * 1000**2
max_image_pixels = self.config["bridge.image_as_file_pixels"]
attributes = []
if self.config["bridge.parallel_file_transfer"] and content.url:
file_handle, file_size = await util.parallel_transfer_to_telegram(
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)
if content.msgtype == MessageType.STICKER:
if mime != "image/gif":
mime, file, w, h = util.convert_image(
file, source_mime=mime, target_type="webp"
)
else:
if mime == "image/gif":
# Remove sticker description
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_size = len(file)
file_handle.name = file_name
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:
attributes.append(
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,
h=h or 0,
)
@@ -1790,7 +1899,7 @@ class Portal(DBPortal, BasePortal):
waveform = [round(part / max(waveform_max / 32, 1)) for part in waveform]
attributes.append(
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,
waveform=encode_waveform(waveform) if waveform else None,
)
@@ -1947,7 +2056,7 @@ class Portal(DBPortal, BasePortal):
response: TypeMessage,
msgtype: MessageType | 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)
if edit_index < 0:
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,
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:
if not self.config["bridge.message_status_events"]:
@@ -1995,8 +2135,9 @@ class Portal(DBPortal, BasePortal):
status.status = MessageStatus.FAIL
elif err:
status.reason = MessageStatusReason.GENERIC_ERROR
status.error = str(err)
status.error = f"{type(err)}: {err}"
status.status = MessageStatus.RETRIABLE
status.message = self._error_to_human_message(err)
else:
status.status = MessageStatus.SUCCESS
@@ -2291,6 +2432,10 @@ class Portal(DBPortal, BasePortal):
await deleter.client(
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:
real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot
@@ -2311,6 +2456,7 @@ class Portal(DBPortal, BasePortal):
else:
await message.mark_redacted()
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(
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)
)
puppet = await user.get_puppet()
removed = 0
for db_reaction in reactions_to_remove:
removed += 1
await db_reaction.delete()
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(
mxid=reaction_event_id,
mx_room=self.mxid,
@@ -2671,16 +2823,19 @@ class Portal(DBPortal, BasePortal):
await DBMessage.replace_temp_mxid(temporary_identifier, self.mxid, event_id)
@property
def _default_max_batches(self) -> int:
def _backfill_config_type(self) -> str:
if self.peer_type == "user":
own_type = "user"
return "user"
elif self.peer_type == "chat":
own_type = "normal_group"
return "normal_group"
elif self.megagroup:
own_type = "supergroup"
return "supergroup"
else:
own_type = "channel"
return self.config[f"bridge.backfill.incremental.max_batches.{own_type}"]
return "channel"
@property
def _default_max_batches(self) -> int:
return self.config[f"bridge.backfill.incremental.max_batches.{self._backfill_config_type}"]
async def enqueue_backfill(
self,
@@ -2724,13 +2879,21 @@ class Portal(DBPortal, BasePortal):
if not client:
client = source.client
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:
return "Limit is zero, not backfilling"
timeout = self.config["bridge.backfill.forward_timeout"]
with self.backfill_lock:
output = await self.backfill(
task = self.backfill(
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}")
return output
@@ -2994,9 +3157,16 @@ class Portal(DBPortal, BasePortal):
anchor_id = 2**31 - 1
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
async for msg in client.iter_messages(entity, limit=limit, **minmax):
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):
continue
elif isinstance(msg, MessageService):
@@ -3021,6 +3191,7 @@ class Portal(DBPortal, BasePortal):
events.append(await self._wrap_batch_msg(intent, msg, converted, caption=True))
intents.append(intent)
metas.append(None)
delay_warn_handle.cancel()
if len(events) == 0:
self.log.debug(
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:
if item.count == 2:
reactions += [
MessagePeerReaction(reaction=item.reaction, peer_id=PeerUser(self.tgid)),
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:
@@ -3089,6 +3264,7 @@ class Portal(DBPortal, BasePortal):
MessagePeerReaction(
reaction=item.reaction,
peer_id=PeerUser(self.tg_receiver if item.chosen_order else self.tgid),
date=None,
)
)
return reactions
@@ -3172,17 +3348,18 @@ class Portal(DBPortal, BasePortal):
)
@staticmethod
def _reactions_filter(lst: list[TypeReaction], existing: DBReaction) -> bool:
def _reactions_filter(lst: list[MessagePeerReaction], existing: DBReaction) -> bool:
if not lst:
return False
for reaction in lst:
for wrapped_reaction in lst:
reaction = wrapped_reaction.reaction
if isinstance(reaction, ReactionCustomEmoji) and existing.reaction == str(
reaction.document_id
):
lst.remove(reaction)
lst.remove(wrapped_reaction)
return True
elif isinstance(reaction, ReactionEmoji) and existing.reaction == reaction.emoticon:
lst.remove(reaction)
lst.remove(wrapped_reaction)
return True
return False
@@ -3202,15 +3379,14 @@ class Portal(DBPortal, BasePortal):
total_count: int,
timestamp: datetime | None = None,
) -> None:
reactions: dict[TelegramID, list[TypeReaction]] = {}
reactions: dict[TelegramID, list[MessagePeerReaction]] = {}
custom_emoji_ids: list[int] = []
for reaction in reaction_list:
if isinstance(reaction.peer_id, (PeerUser, PeerChannel)) and isinstance(
reaction.reaction, (ReactionEmoji, ReactionCustomEmoji)
):
reactions.setdefault(p.Puppet.get_id_from_peer(reaction.peer_id), []).append(
reaction.reaction
)
sender_user_id = p.Puppet.get_id_from_peer(reaction.peer_id)
reactions.setdefault(sender_user_id, []).append(reaction)
if isinstance(reaction.reaction, ReactionCustomEmoji):
custom_emoji_ids.append(reaction.reaction.document_id)
is_full = len(reaction_list) == total_count
@@ -3235,7 +3411,8 @@ class Portal(DBPortal, BasePortal):
new_reaction: TypeReaction
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):
emoji_id = 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}")
puppet: p.Puppet = await p.Puppet.get_by_tgid(sender)
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(
mxid=mxid,
@@ -3476,6 +3656,16 @@ class Portal(DBPortal, BasePortal):
return False
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(
self, source: au.AbstractUser, sender: p.Puppet | None, update: MessageService
) -> None:
@@ -3503,11 +3693,53 @@ class Portal(DBPortal, BasePortal):
await self.delete_telegram_user(TelegramID(action.user_id), sender)
elif isinstance(action, MessageActionChatMigrateTo):
await self._migrate_and_save_telegram(TelegramID(action.channel_id))
# TODO encrypt
await sender.intent_for(self).send_emote(
self.mxid, "upgraded this group to a supergroup."
await self._send_message(
sender.intent_for(self),
TextMessageEventContent(
msgtype=MessageType.EMOTE,
body="upgraded this group to a supergroup",
),
)
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):
# TODO handle game score
pass
@@ -3809,7 +4041,7 @@ class Portal(DBPortal, BasePortal):
return portal
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
# (or add better wrong peer type error handling)
# if peer_type == "chat":
@@ -34,6 +34,8 @@ from telethon.tl.types import (
DocumentAttributeVideo,
Game,
InputPhotoFileLocation,
InputStickerSetID,
InputStickerSetShortName,
Message,
MessageEntityPre,
MessageMediaContact,
@@ -42,11 +44,14 @@ from telethon.tl.types import (
MessageMediaGame,
MessageMediaGeo,
MessageMediaGeoLive,
MessageMediaInvoice,
MessageMediaPhoto,
MessageMediaPoll,
MessageMediaStory,
MessageMediaUnsupported,
MessageMediaVenue,
MessageMediaWebPage,
MessageReplyStoryHeader,
PeerChannel,
PeerUser,
Photo,
@@ -104,6 +109,7 @@ class DocAttrs(NamedTuple):
mime_type: str | None
is_sticker: bool
sticker_alt: str | None
sticker_pack_ref: dict | None
width: int
height: int
is_gif: bool
@@ -142,6 +148,8 @@ class TelegramMessageConverter:
MessageMediaUnsupported: self._convert_unsupported,
MessageMediaGame: self._convert_game,
MessageMediaContact: self._convert_contact,
MessageMediaStory: self._convert_story,
MessageMediaInvoice: self._convert_invoice,
}
self._allowed_media = tuple(self._media_converters.keys())
@@ -252,6 +260,8 @@ class TelegramMessageConverter:
) -> None:
if not evt.reply_to:
return
elif isinstance(evt.reply_to, MessageReplyStoryHeader):
return
space = (
evt.peer_id.channel_id
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
@@ -259,6 +269,7 @@ class TelegramMessageConverter:
)
reply_to_id = TelegramID(evt.reply_to.reply_to_msg_id)
msg = await DBMessage.get_one_by_tgid(reply_to_id, space)
no_fallback = no_fallback or self.config["bridge.disable_reply_fallbacks"]
if not msg or msg.mx_room != self.portal.mxid:
if deterministic_id:
content.set_reply(self.deterministic_event_id(space, reply_to_id))
@@ -496,6 +507,7 @@ class TelegramMessageConverter:
info["fi.mau.telegram.gif"] = True
else:
info["fi.mau.telegram.animated_sticker"] = True
info["fi.mau.gif"] = True
info["fi.mau.loop"] = True
info["fi.mau.autoplay"] = True
info["fi.mau.hide_controls"] = True
@@ -698,10 +710,33 @@ class TelegramMessageConverter:
)
return ConvertedMessage(content=content)
@staticmethod
async def _convert_story(
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
) -> ConvertedMessage:
content = await formatter.telegram_to_matrix(
evt, source, client, override_text="Stories are not yet supported"
)
content.msgtype = MessageType.NOTICE
content["fi.mau.telegram.unsupported"] = True
return ConvertedMessage(content=content)
@staticmethod
async def _convert_invoice(
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
) -> ConvertedMessage:
content = await formatter.telegram_to_matrix(
evt, source, client, override_text="Invoices are not yet supported"
)
content.msgtype = MessageType.NOTICE
content["fi.mau.telegram.unsupported"] = True
return ConvertedMessage(content=content)
def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAttrs:
name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0
is_gif, is_audio, is_voice, duration, waveform = False, False, False, 0, bytes()
sticker_pack_ref = None
for attr in attributes:
if isinstance(attr, DocumentAttributeFilename):
name = name or attr.file_name
@@ -709,6 +744,13 @@ def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAt
elif isinstance(attr, DocumentAttributeSticker):
is_sticker = True
sticker_alt = attr.alt
if isinstance(attr.stickerset, InputStickerSetID):
sticker_pack_ref = {
"id": str(attr.stickerset.id),
"access_hash": str(attr.stickerset.access_hash),
}
elif isinstance(attr.stickerset, InputStickerSetShortName):
sticker_pack_ref = {"short_name": attr.stickerset.short_name}
elif isinstance(attr, DocumentAttributeAnimated):
is_gif = True
elif isinstance(attr, DocumentAttributeVideo):
@@ -726,6 +768,7 @@ def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAt
mime_type,
is_sticker,
sticker_alt,
sticker_pack_ref,
width,
height,
is_gif,
@@ -758,6 +801,13 @@ def _parse_document_meta(
mime_type = file.mime_type or document.mime_type
info = ImageInfo(size=file.size, mimetype=mime_type)
if attrs.is_sticker:
info["fi.mau.telegram.sticker"] = {
"alt": attrs.sticker_alt,
"id": str(document.id),
"pack": attrs.sticker_pack_ref,
}
if attrs.mime_type and not file.was_converted:
file.mime_type = attrs.mime_type or file.mime_type
if file.width and file.height:
+42 -4
View File
@@ -55,6 +55,7 @@ if TYPE_CHECKING:
class Puppet(DBPuppet, BasePuppet):
bridge: TelegramBridge
config: Config
hs_domain: str
mxid_template: SimpleTemplate[TelegramID]
@@ -78,6 +79,7 @@ class Puppet(DBPuppet, BasePuppet):
avatar_url: ContentURI | None = None,
name_set: bool = False,
avatar_set: bool = False,
contact_info_set: bool = False,
is_bot: bool = False,
is_channel: bool = False,
is_premium: bool = False,
@@ -100,6 +102,7 @@ class Puppet(DBPuppet, BasePuppet):
avatar_url=avatar_url,
name_set=name_set,
avatar_set=avatar_set,
contact_info_set=contact_info_set,
is_bot=is_bot,
is_channel=is_channel,
is_premium=is_premium,
@@ -154,6 +157,7 @@ class Puppet(DBPuppet, BasePuppet):
@classmethod
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
cls.bridge = bridge
cls.config = bridge.config
cls.loop = bridge.loop
cls.mx = bridge.matrix
@@ -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
)
self.is_bot = is_bot
if is_bot is not None:
self.is_bot = is_bot
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
changed = True
@@ -279,6 +286,8 @@ class Puppet(DBPuppet, BasePuppet):
if not self.disable_updates:
try:
changed = await self._update_contact_info(force=changed) or changed
changed = (
await self.update_displayname(source, info, client_override=client_override)
or changed
@@ -296,8 +305,37 @@ class Puppet(DBPuppet, BasePuppet):
await self.update_portals_meta()
await self.save()
async def _update_contact_info(self, force: bool = False) -> bool:
if not self.bridge.homeserver_software.is_hungry:
return False
if self.contact_info_set and not force:
return False
try:
identifiers = []
if self.username:
identifiers.append(f"telegram:{self.username}")
if self.phone:
phone = "+" + self.phone.lstrip("+")
identifiers.append(f"tel:{phone}")
await self.default_mxid_intent.beeper_update_profile(
{
"com.beeper.bridge.identifiers": identifiers,
"com.beeper.bridge.remote_id": str(self.tgid),
"com.beeper.bridge.service": "telegram",
"com.beeper.bridge.network": "telegram",
"com.beeper.bridge.is_network_bot": self.is_bot,
}
)
self.contact_info_set = True
except Exception:
self.log.exception("Error updating contact info")
self.contact_info_set = False
return True
async def update_portals_meta(self) -> None:
if 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
async for portal in p.Portal.find_private_chats_with(self.tgid):
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 (
InputMediaUploadedDocument,
InputMediaUploadedPhoto,
InputReplyToMessage,
TypeDocumentAttribute,
TypeInputMedia,
TypeInputPeer,
@@ -67,6 +68,10 @@ class MautrixTelegramClient(TelegramClient):
entity = await self.get_input_entity(entity)
reply_to = utils.get_message_id(reply_to)
request = SendMediaRequest(
entity, media, message=caption or "", entities=entities or [], reply_to_msg_id=reply_to
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)
+69 -20
View File
@@ -39,6 +39,9 @@ from telethon.tl.types import (
ChatForbidden,
InputUserSelf,
Message,
MessageActionContactSignUp,
MessageActionHistoryClear,
MessageService,
NotifyPeer,
PeerUser,
TypeUpdate,
@@ -52,6 +55,7 @@ from telethon.tl.types import (
User as TLUser,
)
from telethon.tl.types.contacts import ContactsNotModified
from telethon.tl.types.help import AppConfig
from telethon.tl.types.messages import AvailableReactions
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_lock: asyncio.Lock
_app_config: dict[str, Any] | None
_app_config_hash: int
def __init__(
self,
@@ -143,6 +148,7 @@ class User(DBUser, AbstractUser, BaseUser):
self._available_emoji_reactions_fetched = 0
self._available_emoji_reactions_lock = asyncio.Lock()
self._app_config = None
self._app_config_hash = 0
(
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")
try:
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:
self.log.exception(f"Error while creating {portal.tgid_log}")
else:
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
await self._post_sync_dialog(portal, puppet, was_created=True, **post_sync_args)
await self.post_sync_dialog(portal, puppet=None, was_created=True, **post_sync_args)
async def update(self, update: TypeUpdate) -> bool:
if not self.is_bot:
@@ -535,7 +544,7 @@ class User(DBUser, AbstractUser, BaseUser):
await self.stop()
return None
async def update_info(self, info: TLUser = None) -> None:
async def update_info(self, info: TLUser | None = None) -> None:
if not info:
info = await self.get_me()
if not info:
@@ -608,8 +617,11 @@ class User(DBUser, AbstractUser, BaseUser):
await self.stop()
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?
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:
await self.delete()
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())
async def _sync_dialog(
self, portal: po.Portal, dialog: Dialog, should_create: bool, puppet: pu.Puppet | None
) -> None:
was_created = False
post_sync_args = {
"last_message_ts": cast(datetime, dialog.date).timestamp(),
@staticmethod
def dialog_to_sync_args(dialog: Dialog) -> dict:
return {
"last_message_ts": (
cast(datetime, dialog.date).timestamp() if dialog.date else time.time()
),
"unread_count": dialog.unread_count,
"max_read_id": dialog.dialog.read_inbox_max_id,
"mute_until": (
@@ -759,6 +771,24 @@ class User(DBUser, AbstractUser, BaseUser):
"pinned": dialog.pinned,
"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:
self.log.debug(f"Backfilling and updating {portal.tgid_log} (dialog sync)")
try:
@@ -772,7 +802,9 @@ class User(DBUser, AbstractUser, BaseUser):
elif should_create:
self.log.debug(f"Creating portal for {portal.tgid_log} immediately (dialog sync)")
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
except Exception:
self.log.exception(f"Error while creating {portal.tgid_log}")
@@ -785,7 +817,7 @@ class User(DBUser, AbstractUser, BaseUser):
extra_data=post_sync_args,
)
if portal.mxid and puppet and puppet.is_real_user:
await self._post_sync_dialog(
await self.post_sync_dialog(
portal=portal,
puppet=puppet,
was_created=was_created,
@@ -793,10 +825,10 @@ class User(DBUser, AbstractUser, BaseUser):
)
self.log.debug(f"_sync_dialog finished for {portal.tgid_log}")
async def _post_sync_dialog(
async def post_sync_dialog(
self,
portal: po.Portal,
puppet: pu.Puppet,
puppet: pu.Puppet | None,
was_created: bool,
max_read_id: int,
last_message_ts: float,
@@ -805,6 +837,10 @@ class User(DBUser, AbstractUser, BaseUser):
pinned: bool,
archived: bool,
) -> 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(
f"Running dialog post-sync for {portal.tgid_log} with args "
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")
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]:
if self._available_emoji_reactions_fetched + 12 * 60 * 60 > time.monotonic():
if self._available_reactions_up_to_date:
return self._available_emoji_reactions
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
self.log.debug("Fetching available emoji reactions")
available_reactions = await self.client(
@@ -955,13 +998,18 @@ class User(DBUser, AbstractUser, BaseUser):
self._available_emoji_reactions = {
react.reaction
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_fetched = time.monotonic()
self.log.debug(
"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
def tl_to_json(self) -> Any:
@@ -969,8 +1017,9 @@ class User(DBUser, AbstractUser, BaseUser):
async def get_app_config(self) -> dict[str, Any]:
if not self._app_config:
cfg = await self.client(GetAppConfigRequest())
self._app_config = util.parse_tl_json(cfg)
cfg: AppConfig = await self.client(GetAppConfigRequest(hash=self._app_config_hash))
self._app_config = util.parse_tl_json(cfg.config)
self._app_config_hash = cfg.hash
return self._app_config
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,
PhoneNumberUnoccupiedError,
SessionPasswordNeededError,
SessionRevokedError,
)
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
@@ -288,6 +289,17 @@ class AuthAPI(abc.ABC):
errcode="phone_number_unoccupied",
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:
if not password_in_data:
if user.command_status and user.command_status["action"] == "Login":
@@ -342,6 +354,28 @@ class AuthAPI(abc.ABC):
errcode="password_invalid",
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:
self.log.exception("Error sending password")
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",
status=500,
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
#/qr_login
pillow>=4,<10
pillow>=10.0.1,<11
qrcode>=6,<8
#/formattednumbers
phonenumbers>=8,<9
#/metrics
prometheus_client>=0.6,<0.17
prometheus_client>=0.6,<0.18
#/e2be
python-olm>=3,<4
@@ -23,4 +22,7 @@ pycryptodome>=3,<4
unpaddedbase64>=1,<3
#/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
aiohttp>=3,<4
yarl>=1,<2
mautrix>=0.19.4,<0.20
#telethon>=1.25.4,<1.27
tulir-telethon==1.28.0a3
asyncpg>=0.20,<0.28
mautrix>=0.20.2,<0.21
tulir-telethon==1.30.0a2
asyncpg>=0.20,<0.29
mako>=1,<2
setuptools
+2 -2
View File
@@ -51,7 +51,7 @@ setuptools.setup(
install_requires=install_requires,
extras_require=extras_require,
python_requires="~=3.8",
python_requires="~=3.9",
classifiers=[
"Development Status :: 4 - Beta",
@@ -60,9 +60,9 @@ setuptools.setup(
"Framework :: AsyncIO",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
],
package_data={"mautrix_telegram": [
"web/public/*.mako", "web/public/*.png", "web/public/*.css",