# 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
")
tpl = (
self.get_config(f"message_formats.[{content.msgtype.value}]")
or "$sender_displayname: $message"
)
displayname = await self.get_displayname(sender)
tpl_args = dict(
sender_mxid=sender.mxid,
sender_username=sender.mxid_localpart,
sender_displayname=escape_html(displayname),
message=content.formatted_body,
body=content.body,
formatted_body=content.formatted_body,
distinguisher=self._get_distinguisher(sender.mxid),
)
content.formatted_body = Template(tpl).safe_substitute(tpl_args)
async def _apply_emote_format(self, sender: u.User, content: TextMessageEventContent) -> None:
if content.format != Format.HTML:
content.format = Format.HTML
content.formatted_body = escape_html(content.body).replace("\n", "
")
tpl = self.get_config("emote_format")
puppet = await p.Puppet.get_by_tgid(sender.tgid)
content.formatted_body = Template(tpl).safe_substitute(
dict(
sender_mxid=sender.mxid,
sender_username=sender.mxid_localpart,
sender_displayname=escape_html(await self.get_displayname(sender)),
mention=f"{puppet.displayname}",
username=sender.tg_username,
displayname=puppet.displayname,
body=content.body,
formatted_body=content.formatted_body,
)
)
content.msgtype = MessageType.TEXT
async def _pre_process_matrix_message(
self, sender: u.User, use_relaybot: bool, content: MessageEventContent
) -> None:
if use_relaybot:
await self._apply_msg_format(sender, content)
elif content.msgtype == MessageType.EMOTE:
await self._apply_emote_format(sender, content)
async def _handle_matrix_text(
self,
sender: u.User,
logged_in: bool,
event_id: EventID,
space: TelegramID,
client: MautrixTelegramClient,
content: TextMessageEventContent,
reply_to: TelegramID | None,
) -> None:
message, entities = await formatter.matrix_to_telegram(
client, text=content.body, html=content.formatted(Format.HTML)
)
sender_id = sender.tgid if logged_in else self.bot.tgid
async with self.send_lock(sender_id):
lp = self.get_config("telegram_link_preview")
if content.get_edit():
orig_msg = await DBMessage.get_by_mxid(content.get_edit(), self.mxid, space)
if orig_msg:
resp = await client.edit_message(
self.peer,
orig_msg.tgid,
message,
formatting_entities=entities,
link_preview=lp,
)
await self._mark_matrix_handled(
sender, EventType.ROOM_MESSAGE, event_id, space, -1, resp, content.msgtype
)
return
response = await client.send_message(
self.peer,
message,
reply_to=reply_to,
formatting_entities=entities,
link_preview=lp,
)
await self._mark_matrix_handled(
sender, EventType.ROOM_MESSAGE, event_id, space, 0, response, content.msgtype
)
async def _handle_matrix_file(
self,
sender: u.User,
logged_in: bool,
event_id: EventID,
space: TelegramID,
client: MautrixTelegramClient,
content: MediaMessageEventContent,
reply_to: TelegramID,
caption: TextMessageEventContent = None,
) -> None:
sender_id = sender.tgid if logged_in else self.bot.tgid
mime = content.info.mimetype
if isinstance(content.info, (ImageInfo, VideoInfo)):
w, h = content.info.width, content.info.height
else:
w = h = None
file_name = content["net.maunium.telegram.internal.filename"]
max_image_size = self.config["bridge.image_as_file_size"] * 1000**2
max_image_pixels = self.config["bridge.image_as_file_pixels"]
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
)
else:
if content.file:
if not decrypt_attachment:
raise BridgingError(
f"Can't bridge encrypted media event {event_id}: "
"encryption dependencies not installed"
)
file = await self.main_intent.download_media(content.file.url)
file = decrypt_attachment(
file, content.file.key.key, content.file.hashes.get("sha256"), content.file.iv
)
else:
file = await self.main_intent.download_media(content.url)
if content.msgtype == MessageType.STICKER:
if mime != "image/gif":
mime, file, w, h = util.convert_image(
file, source_mime=mime, target_type="webp"
)
else:
# Remove sticker description
file_name = "sticker.gif"
file_handle = await client.upload_file(file)
file_size = len(file)
file_handle.name = file_name
force_document = file_size >= max_image_size
attributes = [DocumentAttributeFilename(file_name=file_name)]
if w and h:
attributes.append(DocumentAttributeImageSize(w, h))
force_document = force_document or w * h >= max_image_pixels
if "fi.mau.telegram.force_document" in content:
force_document = bool(content["fi.mau.telegram.force_document"])
if (mime == "image/png" or mime == "image/jpeg") and not force_document:
media = InputMediaUploadedPhoto(file_handle)
else:
media = InputMediaUploadedDocument(
file=file_handle,
attributes=attributes,
mime_type=mime or "application/octet-stream",
)
capt, entities = (
await formatter.matrix_to_telegram(
client, text=caption.body, html=caption.formatted(Format.HTML)
)
if caption
else (None, None)
)
async with self.send_lock(sender_id):
if await self._matrix_document_edit(
sender, client, content, space, capt, media, event_id
):
return
try:
try:
response = await client.send_media(
self.peer, media, reply_to=reply_to, caption=capt, entities=entities
)
except (
PhotoInvalidDimensionsError,
PhotoSaveFileInvalidError,
PhotoExtInvalidError,
):
media = InputMediaUploadedDocument(
file=media.file, mime_type=mime, attributes=attributes
)
response = await client.send_media(
self.peer, media, reply_to=reply_to, caption=capt, entities=entities
)
except Exception:
raise
else:
await self._mark_matrix_handled(
sender, EventType.ROOM_MESSAGE, event_id, space, 0, response, content.msgtype
)
async def _matrix_document_edit(
self,
sender: u.User,
client: MautrixTelegramClient,
content: MessageEventContent,
space: TelegramID,
caption: str,
media: Any,
event_id: EventID,
) -> bool:
if content.get_edit():
orig_msg = await DBMessage.get_by_mxid(content.get_edit(), self.mxid, space)
if orig_msg:
response = await client.edit_message(self.peer, orig_msg.tgid, caption, file=media)
await self._mark_matrix_handled(
sender, EventType.ROOM_MESSAGE, event_id, space, -1, response, content.msgtype
)
return True
return False
async def _handle_matrix_location(
self,
sender: u.User,
logged_in: bool,
event_id: EventID,
space: TelegramID,
client: MautrixTelegramClient,
content: LocationMessageEventContent,
reply_to: TelegramID,
) -> None:
sender_id = sender.tgid if logged_in else self.bot.tgid
try:
lat, long = content.geo_uri[len("geo:") :].split(";")[0].split(",")
lat, long = float(lat), float(long)
except (KeyError, ValueError):
self.log.exception("Failed to parse location")
return None
try:
caption = content["org.matrix.msc3488.location"]["description"]
entities = []
except KeyError:
caption, entities = await formatter.matrix_to_telegram(client, text=content.body)
media = MessageMediaGeo(geo=GeoPoint(lat=lat, long=long, access_hash=0))
async with self.send_lock(sender_id):
if await self._matrix_document_edit(
sender, client, content, space, caption, media, event_id
):
return
try:
response = await client.send_media(
self.peer, media, reply_to=reply_to, caption=caption, entities=entities
)
except Exception:
raise
else:
await self._mark_matrix_handled(
sender, EventType.ROOM_MESSAGE, event_id, space, 0, response, content.msgtype
)
async def _mark_matrix_handled(
self,
sender: u.User,
event_type: EventType,
event_id: EventID,
space: TelegramID,
edit_index: int,
response: TypeMessage,
msgtype: MessageType | None = None,
) -> None:
self.log.trace("Handled Matrix message: %s", 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)
edit_index = prev_edit.edit_index + 1
await DBMessage(
tgid=TelegramID(response.id),
tg_space=space,
mx_room=self.mxid,
mxid=event_id,
edit_index=edit_index,
content_hash=event_hash,
).insert()
sender.send_remote_checkpoint(
MessageSendCheckpointStatus.SUCCESS,
event_id,
self.mxid,
event_type,
message_type=msgtype,
)
await self._send_delivery_receipt(event_id)
async def _send_bridge_error(
self,
sender: u.User,
err: Exception,
event_id: EventID,
event_type: EventType,
message_type: MessageType | None = None,
msg: str | None = None,
) -> None:
sender.send_remote_checkpoint(
MessageSendCheckpointStatus.PERM_FAILURE,
event_id,
self.mxid,
event_type,
message_type=message_type,
error=err,
)
if msg and self.config["bridge.delivery_error_reports"]:
await self._send_message(
self.main_intent, TextMessageEventContent(msgtype=MessageType.NOTICE, body=msg)
)
async def handle_matrix_message(
self, sender: u.User, content: MessageEventContent, event_id: EventID
) -> None:
try:
await self._handle_matrix_message(sender, content, event_id)
except RPCError as e:
self.log.exception(f"RPCError while bridging {event_id}: {e}")
await self._send_bridge_error(
sender,
e,
event_id,
EventType.ROOM_MESSAGE,
message_type=content.msgtype,
msg=f"\u26a0 Your message may not have been bridged: {e}",
)
raise
except Exception as e:
self.log.exception(f"Failed to bridge {event_id}: {e}")
await self._send_bridge_error(
sender,
e,
event_id,
EventType.ROOM_MESSAGE,
message_type=content.msgtype,
)
async def _handle_matrix_message(
self, sender: u.User, content: MessageEventContent, event_id: EventID
) -> None:
if not content.body or not content.msgtype:
self.log.debug(f"Ignoring message {event_id} in {self.mxid} without body or msgtype")
return
logged_in = not await sender.needs_relaybot(self)
client = sender.client if logged_in else self.bot.client
space = (
self.tgid
if self.peer_type == "channel" # Channels have their own ID space
else (sender.tgid if logged_in else self.bot.tgid)
)
reply_to = await formatter.matrix_reply_to_telegram(content, space, room_id=self.mxid)
media = (
MessageType.STICKER,
MessageType.IMAGE,
MessageType.FILE,
MessageType.AUDIO,
MessageType.VIDEO,
)
if content.msgtype == MessageType.NOTICE:
bridge_notices = self.get_config("bridge_notices.default")
excepted = sender.mxid in self.get_config("bridge_notices.exceptions")
if not bridge_notices and not excepted:
raise BridgingError("Notices are not configured to be bridged.")
if content.msgtype in (MessageType.TEXT, MessageType.EMOTE, MessageType.NOTICE):
await self._pre_process_matrix_message(sender, not logged_in, content)
await self._handle_matrix_text(
sender, logged_in, event_id, space, client, content, reply_to
)
elif content.msgtype == MessageType.LOCATION:
await self._pre_process_matrix_message(sender, not logged_in, content)
await self._handle_matrix_location(
sender, logged_in, event_id, space, client, content, reply_to
)
elif content.msgtype in media:
content["net.maunium.telegram.internal.filename"] = content.body
try:
caption_content: MessageEventContent = sender.command_status["caption"]
reply_to = reply_to or await formatter.matrix_reply_to_telegram(
caption_content, space, room_id=self.mxid
)
sender.command_status = None
except (KeyError, TypeError):
caption_content = None if logged_in else TextMessageEventContent(body=content.body)
if caption_content:
caption_content.msgtype = content.msgtype
await self._pre_process_matrix_message(sender, not logged_in, caption_content)
await self._handle_matrix_file(
sender, logged_in, event_id, space, client, content, reply_to, caption_content
)
else:
self.log.debug(
f"Didn't handle Matrix event {event_id} due to unknown msgtype {content.msgtype}"
)
self.log.trace("Unhandled Matrix event content: %s", content)
raise BridgingError(f"Unhandled msgtype {content.msgtype}")
async def handle_matrix_unpin_all(self, sender: u.User, pin_event_id: EventID) -> None:
await sender.client(UnpinAllMessagesRequest(peer=self.peer))
await self._send_delivery_receipt(pin_event_id)
async def handle_matrix_pin(
self, sender: u.User, changes: dict[EventID, bool], pin_event_id: EventID
) -> None:
tg_space = self.tgid if self.peer_type == "channel" else sender.tgid
ids = {
msg.mxid: msg.tgid
for msg in await DBMessage.get_by_mxids(
list(changes.keys()), mx_room=self.mxid, tg_space=tg_space
)
}
for event_id, pinned in changes.items():
try:
await sender.client(
UpdatePinnedMessageRequest(peer=self.peer, id=ids[event_id], unpin=not pinned)
)
except (ChatNotModifiedError, MessageIdInvalidError, KeyError):
pass
await self._send_delivery_receipt(pin_event_id)
async def handle_matrix_deletion(
self, deleter: u.User, event_id: EventID, redaction_event_id: EventID
) -> None:
try:
await self._handle_matrix_deletion(deleter, event_id)
except BridgingError as e:
self.log.debug(str(e))
await self._send_bridge_error(deleter, e, redaction_event_id, EventType.ROOM_REDACTION)
except Exception as e:
self.log.exception(f"Failed to bridge redaction by {deleter.mxid}")
await self._send_bridge_error(deleter, e, redaction_event_id, EventType.ROOM_REDACTION)
else:
deleter.send_remote_checkpoint(
MessageSendCheckpointStatus.SUCCESS,
redaction_event_id,
self.mxid,
EventType.ROOM_REDACTION,
)
await self._send_delivery_receipt(redaction_event_id)
async def _handle_matrix_reaction_deletion(
self, deleter: u.User, event_id: EventID, tg_space: TelegramID
) -> None:
reaction = await DBReaction.get_by_mxid(event_id, self.mxid)
if not reaction:
raise BridgingError(f"Ignoring Matrix redaction of unknown event {event_id}")
elif reaction.tg_sender != deleter.tgid:
raise BridgingError(f"Ignoring Matrix redaction of reaction by another user")
reaction_target = await DBMessage.get_by_mxid(
reaction.msg_mxid, reaction.mx_room, tg_space
)
if not reaction_target or reaction_target.redacted:
raise BridgingError(
f"Ignoring Matrix redaction of reaction to unknown event {reaction.msg_mxid}"
)
async with self.reaction_lock(reaction_target.mxid):
await reaction.delete()
await deleter.client(SendReactionRequest(peer=self.peer, msg_id=reaction_target.tgid))
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
tg_space = self.tgid if self.peer_type == "channel" else real_deleter.tgid
message = await DBMessage.get_by_mxid(event_id, self.mxid, tg_space)
if not message:
await self._handle_matrix_reaction_deletion(real_deleter, event_id, tg_space)
elif message.redacted:
raise BridgingError(
"Ignoring Matrix redaction of already redacted event "
f"{message.mxid} in {message.mx_room}"
)
elif message.edit_index != 0:
await message.mark_redacted()
raise BridgingError(
f"Ignoring Matrix redaction of edit event {message.mxid} in {message.mx_room}"
)
else:
await message.mark_redacted()
await real_deleter.client.delete_messages(self.peer, [message.tgid])
async def handle_matrix_reaction(
self, user: u.User, target_event_id: EventID, reaction: str, reaction_event_id: EventID
) -> None:
try:
async with self.reaction_lock(target_event_id):
await self._handle_matrix_reaction(
user, target_event_id, reaction, reaction_event_id
)
except BridgingError as e:
self.log.debug(str(e))
await self._send_bridge_error(user, e, reaction_event_id, EventType.REACTION)
except ReactionInvalidError as e:
# Don't redact reactions in relaybot chats, there are usually other Matrix users too.
if not self.has_bot:
await self.main_intent.redact(
self.mxid, reaction_event_id, reason="Emoji not allowed"
)
self.log.debug(f"Failed to bridge reaction by {user.mxid}: emoji not allowed")
await self._send_bridge_error(user, e, reaction_event_id, EventType.REACTION)
except Exception as e:
self.log.exception(f"Failed to bridge reaction by {user.mxid}")
await self._send_bridge_error(user, e, reaction_event_id, EventType.REACTION)
else:
user.send_remote_checkpoint(
MessageSendCheckpointStatus.SUCCESS,
reaction_event_id,
self.mxid,
EventType.REACTION,
)
await self._send_delivery_receipt(reaction_event_id)
async def _handle_matrix_reaction(
self, user: u.User, target_event_id: EventID, emoji: str, reaction_event_id: EventID
) -> None:
tg_space = self.tgid if self.peer_type == "channel" else user.tgid
msg = await DBMessage.get_by_mxid(target_event_id, self.mxid, tg_space)
if not msg:
raise BridgingError(f"Ignoring Matrix reaction to unknown event {target_event_id}")
elif msg.redacted:
raise BridgingError(f"Ignoring Matrix reaction to redacted event {target_event_id}")
elif msg.edit_index != 0:
raise BridgingError(f"Ignoring Matrix reaction to edit event {target_event_id}")
emoji = variation_selector.remove(emoji)
existing_react = await DBReaction.get_by_sender(msg.mxid, msg.mx_room, user.tgid)
await user.client(SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=emoji))
if existing_react:
puppet = await user.get_puppet()
await puppet.intent_for(self).redact(existing_react.mx_room, existing_react.mxid)
existing_react.mxid = reaction_event_id
existing_react.reaction = emoji
await existing_react.save()
else:
await DBReaction(
mxid=reaction_event_id,
mx_room=self.mxid,
msg_mxid=msg.mxid,
tg_sender=user.tgid,
reaction=emoji,
).save()
async def _update_telegram_power_level(
self, sender: u.User, user_id: TelegramID, level: int
) -> None:
moderator = level >= 50
admin = level >= 75
await sender.client.edit_admin(
self.peer,
user_id,
change_info=moderator,
post_messages=moderator,
edit_messages=moderator,
delete_messages=moderator,
ban_users=moderator,
invite_users=moderator,
pin_messages=moderator,
add_admins=admin,
)
async def handle_matrix_power_levels(
self,
sender: u.User,
new_users: dict[UserID, int],
old_users: dict[UserID, int],
event_id: EventID | None,
) -> None:
# TODO handle all power level changes and bridge exact admin rights to supergroups/channels
for user, level in new_users.items():
if not user or user == self.main_intent.mxid or user == sender.mxid:
continue
user_id = p.Puppet.get_id_from_mxid(user)
if not user_id:
mx_user = await u.User.get_by_mxid(user, create=False)
if not mx_user or not mx_user.tgid:
continue
user_id = mx_user.tgid
if not user_id or user_id == sender.tgid:
continue
if user not in old_users or level != old_users[user]:
await self._update_telegram_power_level(sender, user_id, level)
async def handle_matrix_about(self, sender: u.User, about: str, event_id: EventID) -> None:
if self.peer_type not in ("chat", "channel"):
return
peer = await self.get_input_entity(sender)
await sender.client(EditChatAboutRequest(peer=peer, about=about))
self.about = about
await self.save()
await self._send_delivery_receipt(event_id)
async def handle_matrix_title(self, sender: u.User, title: str, event_id: EventID) -> None:
if self.peer_type not in ("chat", "channel"):
return
if self.peer_type == "chat":
response = await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title))
else:
channel = await self.get_input_entity(sender)
response = await sender.client(EditTitleRequest(channel=channel, title=title))
self.dedup.register_outgoing_actions(response)
self.title = title
await self.save()
await self._send_delivery_receipt(event_id)
await self.update_bridge_info()
async def handle_matrix_avatar(
self, sender: u.User, url: ContentURI, event_id: EventID
) -> None:
if self.peer_type not in ("chat", "channel"):
# Invalid peer type
return
elif self.avatar_url == url:
return
self.avatar_url = url
file = await self.main_intent.download_media(url)
mime = magic.from_buffer(file, mime=True)
ext = sane_mimetypes.guess_extension(mime)
uploaded = await sender.client.upload_file(file, file_name=f"avatar{ext}")
photo = InputChatUploadedPhoto(file=uploaded)
if self.peer_type == "chat":
response = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo))
else:
channel = await self.get_input_entity(sender)
response = await sender.client(EditPhotoRequest(channel=channel, photo=photo))
self.dedup.register_outgoing_actions(response)
for update in response.updates:
is_photo_update = (
isinstance(update, UpdateNewMessage)
and isinstance(update.message, MessageService)
and isinstance(update.message.action, MessageActionChatEditPhoto)
)
if is_photo_update:
loc, size = self._get_largest_photo_size(update.message.action.photo)
self.photo_id = str(loc.id)
await self.save()
break
await self._send_delivery_receipt(event_id)
await self.update_bridge_info()
async def handle_matrix_upgrade(
self, sender: UserID, new_room: RoomID, event_id: EventID
) -> None:
_, server = self.main_intent.parse_user_id(sender)
old_room = self.mxid
await self.migrate_and_save_matrix(new_room)
await self.main_intent.join_room(new_room, servers=[server])
entity: TypeChat | User | None = None
user: au.AbstractUser | None = None
if self.bot and self.has_bot:
user = self.bot
entity = await self.get_entity(self.bot)
if not entity:
user_mxids = await self.main_intent.get_room_members(self.mxid)
for user_str in user_mxids:
user_id = UserID(user_str)
if user_id == self.az.bot_mxid:
continue
user = await u.User.get_by_mxid(user_id, create=False)
if user and user.tgid:
entity = await self.get_entity(user)
if entity:
break
if not entity:
self.log.error(
"Failed to fully migrate to upgraded Matrix room: no Telegram user found."
)
return
await self.update_matrix_room(user, entity, direct=self.peer_type == "user")
self.log.info(f"{sender} upgraded room from {old_room} to {self.mxid}")
await self._send_delivery_receipt(event_id, room_id=old_room)
async def migrate_and_save_matrix(self, new_id: RoomID) -> None:
try:
del self.by_mxid[self.mxid]
except KeyError:
pass
self.mxid = new_id
self.by_mxid[self.mxid] = self
await self.save()
async def enable_dm_encryption(self) -> bool:
ok = await super().enable_dm_encryption()
if ok:
puppet = await p.Puppet.get_by_tgid(self.tgid)
await self.update_info_from_puppet(puppet)
return ok
# endregion
# region Telegram -> Matrix bridging
async def handle_telegram_typing(self, user: p.Puppet, update: UpdateTyping) -> None:
if user.is_real_user:
# Ignore typing notifications from double puppeted users to avoid echoing
return
is_typing = isinstance(update.action, SendMessageTypingAction)
await user.default_mxid_intent.set_typing(self.mxid, is_typing=is_typing)
def _get_external_url(self, evt: Message) -> str | None:
if self.peer_type == "channel" and self.username is not None:
return f"https://t.me/{self.username}/{evt.id}"
elif self.peer_type != "user":
return f"https://t.me/c/{self.tgid}/{evt.id}"
return None
async def _handle_telegram_photo(
self, source: au.AbstractUser, intent: IntentAPI, evt: Message, relates_to: RelatesTo
) -> EventID | None:
media: MessageMediaPhoto = evt.media
if media.photo is None and media.ttl_seconds:
return await self._send_message(
intent,
TextMessageEventContent(msgtype=MessageType.NOTICE, body="Photo has expired"),
timestamp=evt.date,
)
loc, largest_size = self._get_largest_photo_size(media.photo)
if loc is None:
content = TextMessageEventContent(
msgtype=MessageType.TEXT,
body="Failed to bridge image",
external_url=self._get_external_url(evt),
)
return await self._send_message(intent, content, timestamp=evt.date)
file = await util.transfer_file_to_matrix(
source.client, intent, loc, encrypt=self.encrypted
)
if not file:
return None
if self.get_config("inline_images") and (evt.message or evt.fwd_from or evt.reply_to):
content = await formatter.telegram_to_matrix(
evt,
source,
self.main_intent,
prefix_html=f"
",
prefix_text="Inline image: ",
)
content.external_url = self._get_external_url(evt)
await intent.set_typing(self.mxid, is_typing=False)
return await self._send_message(intent, content, timestamp=evt.date)
info = ImageInfo(
height=largest_size.h,
width=largest_size.w,
orientation=0,
mimetype=file.mime_type,
size=self._photo_size_key(largest_size),
)
ext = sane_mimetypes.guess_extension(file.mime_type)
name = f"disappearing_image{ext}" if media.ttl_seconds else f"image{ext}"
await intent.set_typing(self.mxid, is_typing=False)
content = MediaMessageEventContent(
msgtype=MessageType.IMAGE,
info=info,
body=name,
relates_to=relates_to,
external_url=self._get_external_url(evt),
)
if file.decryption_info:
content.file = file.decryption_info
else:
content.url = file.mxc
result = await self._send_message(intent, content, timestamp=evt.date)
if media.ttl_seconds:
await DisappearingMessage(self.mxid, result, media.ttl_seconds).insert()
if evt.message:
caption_content = await formatter.telegram_to_matrix(
evt, source, self.main_intent, no_reply_fallback=True
)
caption_content.external_url = content.external_url
result = await self._send_message(intent, caption_content, timestamp=evt.date)
if media.ttl_seconds:
await DisappearingMessage(self.mxid, result, media.ttl_seconds).insert()
return result
@staticmethod
def _parse_telegram_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAttrs:
name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0
is_gif, is_audio, is_voice, duration, waveform = False, False, False, 0, bytes()
for attr in attributes:
if isinstance(attr, DocumentAttributeFilename):
name = name or attr.file_name
mime_type, _ = mimetypes.guess_type(attr.file_name)
elif isinstance(attr, DocumentAttributeSticker):
is_sticker = True
sticker_alt = attr.alt
elif isinstance(attr, DocumentAttributeAnimated):
is_gif = True
elif isinstance(attr, DocumentAttributeVideo):
width, height = attr.w, attr.h
elif isinstance(attr, DocumentAttributeImageSize):
width, height = attr.w, attr.h
elif isinstance(attr, DocumentAttributeAudio):
is_audio = True
is_voice = attr.voice or False
duration = attr.duration
waveform = decode_waveform(attr.waveform) if attr.waveform else b""
return DocAttrs(
name,
mime_type,
is_sticker,
sticker_alt,
width,
height,
is_gif,
is_audio,
is_voice,
duration,
waveform,
)
@staticmethod
def _parse_telegram_document_meta(
evt: Message, file: DBTelegramFile, attrs: DocAttrs, thumb_size: TypePhotoSize
) -> tuple[ImageInfo, str]:
document = evt.media.document
name = attrs.name
if attrs.is_sticker:
alt = attrs.sticker_alt
if len(alt) > 0:
try:
name = f"{alt} ({unicodedata.name(alt[0]).lower()})"
except ValueError:
name = alt
generic_types = ("text/plain", "application/octet-stream")
if file.mime_type in generic_types and document.mime_type not in generic_types:
mime_type = document.mime_type or file.mime_type
elif file.mime_type == "application/ogg":
mime_type = "audio/ogg"
else:
mime_type = file.mime_type or document.mime_type
info = ImageInfo(size=file.size, mimetype=mime_type)
if attrs.mime_type and not file.was_converted:
file.mime_type = attrs.mime_type or file.mime_type
if file.width and file.height:
info.width, info.height = file.width, file.height
elif attrs.width and attrs.height:
info.width, info.height = attrs.width, attrs.height
if file.thumbnail:
if file.thumbnail.decryption_info:
info.thumbnail_file = file.thumbnail.decryption_info
else:
info.thumbnail_url = file.thumbnail.mxc
info.thumbnail_info = ThumbnailInfo(
mimetype=file.thumbnail.mime_type,
height=file.thumbnail.height or thumb_size.h,
width=file.thumbnail.width or thumb_size.w,
size=file.thumbnail.size,
)
elif attrs.is_sticker:
# This is a hack for bad clients like Element iOS that require a thumbnail
info.thumbnail_info = ImageInfo.deserialize(info.serialize())
if file.decryption_info:
info.thumbnail_file = file.decryption_info
else:
info.thumbnail_url = file.mxc
return info, name
async def _handle_telegram_document(
self, source: au.AbstractUser, intent: IntentAPI, evt: Message, relates_to: RelatesTo
) -> EventID | None:
document = evt.media.document
attrs = self._parse_telegram_document_attributes(document.attributes)
if document.size > self.matrix.media_config.upload_size:
name = attrs.name or ""
caption = f"\n{evt.message}" if evt.message else ""
# TODO encrypt
return await intent.send_notice(self.mxid, f"Too large file {name}{caption}")
thumb_loc, thumb_size = self._get_largest_photo_size(document)
if thumb_size and not isinstance(thumb_size, (PhotoSize, PhotoCachedSize)):
self.log.debug(f"Unsupported thumbnail type {type(thumb_size)}")
thumb_loc = None
thumb_size = None
parallel_id = source.tgid if self.config["bridge.parallel_file_transfer"] else None
file = await util.transfer_file_to_matrix(
source.client,
intent,
document,
thumb_loc,
is_sticker=attrs.is_sticker,
tgs_convert=self.config["bridge.animated_sticker"],
filename=attrs.name,
parallel_id=parallel_id,
encrypt=self.encrypted,
)
if not file:
return None
info, name = self._parse_telegram_document_meta(evt, file, attrs, thumb_size)
await intent.set_typing(self.mxid, is_typing=False)
event_type = EventType.ROOM_MESSAGE
# Elements only support images as stickers, so send animated webm stickers as m.video
if attrs.is_sticker and file.mime_type.startswith("image/"):
event_type = EventType.STICKER
# Tell clients to render the stickers as 256x256 if they're bigger
if info.width > 256 or info.height > 256:
if info.width > info.height:
info.height = int(info.height / (info.width / 256))
info.width = 256
else:
info.width = int(info.width / (info.height / 256))
info.height = 256
if info.thumbnail_info:
info.thumbnail_info.width = info.width
info.thumbnail_info.height = info.height
if attrs.is_gif or (attrs.is_sticker and info.mimetype == "video/webm"):
if attrs.is_gif:
info["fi.mau.telegram.gif"] = True
else:
info["fi.mau.telegram.animated_sticker"] = True
info["fi.mau.loop"] = True
info["fi.mau.autoplay"] = True
info["fi.mau.hide_controls"] = True
info["fi.mau.no_audio"] = True
if not name:
ext = sane_mimetypes.guess_extension(file.mime_type)
name = "unnamed_file" + ext
content = MediaMessageEventContent(
body=name,
info=info,
relates_to=relates_to,
external_url=self._get_external_url(evt),
msgtype={
"video/": MessageType.VIDEO,
"audio/": MessageType.AUDIO,
"image/": MessageType.IMAGE,
}.get(info.mimetype[:6], MessageType.FILE),
)
if event_type == EventType.STICKER:
content.msgtype = None
if attrs.is_audio:
content["org.matrix.msc1767.audio"] = {"duration": attrs.duration * 1000}
if attrs.waveform:
content["org.matrix.msc1767.audio"]["waveform"] = [x << 5 for x in attrs.waveform]
if attrs.is_voice:
content["org.matrix.msc3245.voice"] = {}
if file.decryption_info:
content.file = file.decryption_info
else:
content.url = file.mxc
res = await self._send_message(intent, content, event_type=event_type, timestamp=evt.date)
if evt.media.ttl_seconds:
await DisappearingMessage(self.mxid, res, evt.media.ttl_seconds).insert()
if evt.message:
caption_content = await formatter.telegram_to_matrix(
evt, source, self.main_intent, no_reply_fallback=True
)
caption_content.external_url = content.external_url
res = await self._send_message(intent, caption_content, timestamp=evt.date)
if evt.media.ttl_seconds:
await DisappearingMessage(self.mxid, res, evt.media.ttl_seconds).insert()
return res
def _location_message_to_content(
self, evt: Message, relates_to: RelatesTo, note: str
) -> LocationMessageEventContent:
long = evt.media.geo.long
lat = evt.media.geo.lat
long_char = "E" if long > 0 else "W"
lat_char = "N" if lat > 0 else "S"
geo = f"{round(lat, 6)},{round(long, 6)}"
body = f"{round(abs(lat), 4)}° {lat_char}, {round(abs(long), 4)}° {long_char}"
url = f"https://maps.google.com/?q={geo}"
content = LocationMessageEventContent(
msgtype=MessageType.LOCATION,
geo_uri=f"geo:{geo}",
body=f"{note}: {body}\n{url}",
relates_to=relates_to,
external_url=self._get_external_url(evt),
)
content["format"] = str(Format.HTML)
content["formatted_body"] = f"{note}: {body}"
content["org.matrix.msc3488.location"] = {
"uri": content.geo_uri,
"description": note,
}
return content
def _handle_telegram_location(
self, source: au.AbstractUser, intent: IntentAPI, evt: Message, relates_to: RelatesTo
) -> Awaitable[EventID]:
content = self._location_message_to_content(evt, relates_to, "Location")
return self._send_message(intent, content, timestamp=evt.date)
def _handle_telegram_live_location(
self, source: au.AbstractUser, intent: IntentAPI, evt: Message, relates_to: RelatesTo
) -> Awaitable[EventID]:
content = self._location_message_to_content(
evt, relates_to, "Live Location (see your Telegram client for live updates)"
)
return self._send_message(intent, content, timestamp=evt.date)
def _handle_telegram_venue(
self, source: au.AbstractUser, intent: IntentAPI, evt: Message, relates_to: RelatesTo
) -> Awaitable[EventID]:
content = self._location_message_to_content(evt, relates_to, evt.media.title)
return self._send_message(intent, content, timestamp=evt.date)
async def _telegram_webpage_to_beeper_link_preview(
self, source: au.AbstractUser, intent: IntentAPI, webpage: WebPage
) -> dict[str, Any]:
beeper_link_preview: dict[str, Any] = {
"matched_url": webpage.url,
"og:title": webpage.title,
"og:url": webpage.url,
"og:description": webpage.description,
}
# Upload an image corresponding to the link preview if it exists.
if webpage.photo:
loc, largest_size = self._get_largest_photo_size(webpage.photo)
if loc is None:
return beeper_link_preview
beeper_link_preview["og:image:height"] = largest_size.h
beeper_link_preview["og:image:width"] = largest_size.w
file = await util.transfer_file_to_matrix(
source.client, intent, loc, encrypt=self.encrypted
)
if file.decryption_info:
beeper_link_preview[BEEPER_IMAGE_ENCRYPTION_KEY] = file.decryption_info.serialize()
else:
beeper_link_preview["og:image"] = file.mxc
return beeper_link_preview
async def _handle_telegram_text(
self, source: au.AbstractUser, intent: IntentAPI, is_bot: bool, evt: Message
) -> EventID:
self.log.trace(f"Sending {evt.message} to {self.mxid} by {intent.mxid}")
content = await formatter.telegram_to_matrix(evt, source, self.main_intent)
content.external_url = self._get_external_url(evt)
if is_bot and self.get_config("bot_messages_as_notices"):
content.msgtype = MessageType.NOTICE
await intent.set_typing(self.mxid, is_typing=False)
if (
hasattr(evt, "media")
and isinstance(evt.media, MessageMediaWebPage)
and isinstance(evt.media.webpage, WebPage)
):
content[BEEPER_LINK_PREVIEWS_KEY] = [
await self._telegram_webpage_to_beeper_link_preview(
source, intent, evt.media.webpage
)
]
return await self._send_message(intent, content, timestamp=evt.date)
async def _handle_telegram_unsupported(
self, source: au.AbstractUser, intent: IntentAPI, evt: Message, relates_to: RelatesTo
) -> EventID:
override_text = (
"This message is not supported on your version of Mautrix-Telegram. "
"Please check https://github.com/mautrix/telegram or ask your "
"bridge administrator about possible updates."
)
content = await formatter.telegram_to_matrix(
evt, source, self.main_intent, override_text=override_text
)
content.msgtype = MessageType.NOTICE
content.external_url = self._get_external_url(evt)
content["net.maunium.telegram.unsupported"] = True
await intent.set_typing(self.mxid, is_typing=False)
return await self._send_message(intent, content, timestamp=evt.date)
async def _handle_telegram_poll(
self, source: au.AbstractUser, intent: IntentAPI, evt: Message, relates_to: RelatesTo
) -> EventID:
poll: Poll = evt.media.poll
poll_id = self._encode_msgid(source, evt)
_n = 0
def n() -> int:
nonlocal _n
_n += 1
return _n
text_answers = "\n".join(f"{n()}. {answer.text}" for answer in poll.answers)
html_answers = "\n".join(f"
!tg vote {poll_id} <choice number>"
),
relates_to=relates_to,
external_url=self._get_external_url(evt),
)
await intent.set_typing(self.mxid, is_typing=False)
return await self._send_message(intent, content, timestamp=evt.date)
async def _handle_telegram_dice(
self, _: au.AbstractUser, intent: IntentAPI, evt: Message, relates_to: RelatesTo
) -> EventID:
content = putil.make_dice_event_content(evt.media)
content.relates_to = relates_to
content.external_url = self._get_external_url(evt)
await intent.set_typing(self.mxid, is_typing=False)
return await self._send_message(intent, content, timestamp=evt.date)
@staticmethod
def _int_to_bytes(i: int) -> bytes:
hex_value = f"{i:010x}".encode("utf-8")
return codecs.decode(hex_value, "hex_codec")
def _encode_msgid(self, source: au.AbstractUser, evt: Message) -> str:
if self.peer_type == "channel":
play_id = b"c" + self._int_to_bytes(self.tgid) + self._int_to_bytes(evt.id)
elif self.peer_type == "chat":
play_id = (
b"g"
+ self._int_to_bytes(self.tgid)
+ self._int_to_bytes(evt.id)
+ self._int_to_bytes(source.tgid)
)
elif self.peer_type == "user":
play_id = b"u" + self._int_to_bytes(self.tgid) + self._int_to_bytes(evt.id)
else:
raise ValueError("Portal has invalid peer type")
return base64.b64encode(play_id).decode("utf-8").rstrip("=")
async def _handle_telegram_game(
self, source: au.AbstractUser, intent: IntentAPI, evt: Message, relates_to: RelatesTo
) -> EventID:
game = evt.media.game
play_id = self._encode_msgid(source, evt)
command = f"!tg play {play_id}"
override_text = f"Run {command} in your bridge management room to play {game.title}"
override_entities = [
MessageEntityPre(offset=len("Run "), length=len(command), language="")
]
content = await formatter.telegram_to_matrix(
evt,
source,
self.main_intent,
override_text=override_text,
override_entities=override_entities,
)
content.msgtype = MessageType.NOTICE
content.external_url = self._get_external_url(evt)
content.relates_to = relates_to
content["net.maunium.telegram.game"] = play_id
await intent.set_typing(self.mxid, is_typing=False)
return await self._send_message(intent, content, timestamp=evt.date)
async def _handle_telegram_contact(
self, source: au.AbstractUser, intent: IntentAPI, evt: Message, relates_to: RelatesTo
) -> EventID:
content = await putil.make_contact_event_content(source, evt.media)
content.relates_to = relates_to
content.external_url = self._get_external_url(evt)
await intent.set_typing(self.mxid, is_typing=False)
return await self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_edit(
self, source: au.AbstractUser, sender: p.Puppet, evt: Message
) -> None:
if not self.mxid:
self.log.trace("Ignoring edit to %d as chat has no Matrix room", evt.id)
return
elif hasattr(evt, "media") and isinstance(evt.media, MessageMediaGame):
self.log.debug("Ignoring game message edit event")
return
if self.peer_type != "channel" and isinstance(evt, Message) and evt.reactions is not None:
asyncio.create_task(
self.try_handle_telegram_reactions(source, TelegramID(evt.id), evt.reactions)
)
async with self.send_lock(sender.tgid if sender else None, required=False):
tg_space = self.tgid if self.peer_type == "channel" else source.tgid
temporary_identifier = EventID(
f"${random.randint(1000000000000, 9999999999999)}TGBRIDGEDITEMP"
)
event_hash, duplicate_found = self.dedup.check(
evt, (temporary_identifier, tg_space), force_hash=True
)
if duplicate_found:
mxid, other_tg_space = duplicate_found
if tg_space != other_tg_space:
prev_edit_msg = await DBMessage.get_one_by_tgid(
TelegramID(evt.id), tg_space, edit_index=-1
)
if (
not prev_edit_msg
or prev_edit_msg.mxid == mxid
or prev_edit_msg.content_hash == event_hash
):
return
await DBMessage(
mxid=mxid,
mx_room=self.mxid,
tg_space=tg_space,
tgid=TelegramID(evt.id),
edit_index=prev_edit_msg.edit_index + 1,
content_hash=event_hash,
).insert()
return
content = await formatter.telegram_to_matrix(
evt, source, self.main_intent, no_reply_fallback=True
)
editing_msg = await DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
if not editing_msg:
self.log.info(
f"Didn't find edited message {evt.id}@{tg_space} (src {source.tgid}) "
"in database."
)
return
prev_edit_msg = (
await DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) or editing_msg
)
if prev_edit_msg.content_hash == event_hash:
self.log.debug(
f"Ignoring edit of message {evt.id}@{tg_space} (src {source.tgid}):"
" content hash didn't change"
)
await DBMessage.delete_temp_mxid(temporary_identifier, self.mxid)
return
content.msgtype = (
MessageType.NOTICE
if (sender and sender.is_bot and self.get_config("bot_messages_as_notices"))
else MessageType.TEXT
)
content.external_url = self._get_external_url(evt)
content.set_edit(editing_msg.mxid)
intent = sender.intent_for(self) if sender else self.main_intent
await intent.set_typing(self.mxid, is_typing=False)
event_id = await self._send_message(intent, content)
await DBMessage(
mxid=event_id,
mx_room=self.mxid,
tg_space=tg_space,
tgid=TelegramID(evt.id),
edit_index=prev_edit_msg.edit_index + 1,
content_hash=event_hash,
).insert()
await DBMessage.replace_temp_mxid(temporary_identifier, self.mxid, event_id)
@property
def _takeout_options(self) -> dict[str, bool | int]:
return {
"files": True,
"megagroups": self.megagroup,
"chats": self.peer_type == "chat",
"users": self.peer_type == "user",
"channels": (self.peer_type == "channel" and not self.megagroup),
"max_file_size": min(self.matrix.media_config.upload_size, 2000 * 1024 * 1024),
}
async def backfill(
self,
source: u.User,
is_initial: bool = False,
limit: int | None = None,
last_id: int | None = None,
) -> None:
async with self.backfill_method_lock:
await self._locked_backfill(source, is_initial, limit, last_id)
async def _locked_backfill(
self,
source: u.User,
is_initial: bool = False,
limit: int | None = None,
last_tgid: int | None = None,
) -> None:
limit = limit or (
self.config["bridge.backfill.initial_limit"]
if is_initial
else self.config["bridge.backfill.missed_limit"]
)
if limit == 0:
return
if not self.config["bridge.backfill.normal_groups"] and self.peer_type == "chat":
return
last_in_room = await DBMessage.find_last(
self.mxid, (source.tgid if self.peer_type != "channel" else self.tgid)
)
min_id = last_in_room.tgid if last_in_room else 0
if last_tgid is None:
messages = await source.client.get_messages(self.peer, limit=1)
if not messages:
# The chat seems empty
return
last_tgid = messages[0].id
if last_tgid <= min_id or (last_tgid == 1 and self.peer_type == "channel"):
# Nothing to backfill
return
if limit < 0:
limit = last_tgid - min_id
self.log.debug(
f"Backfilling approximately {last_tgid - min_id} messages through {source.mxid}"
)
elif self.peer_type == "channel":
min_id = max(last_tgid - limit, min_id)
self.log.debug(
f"Backfilling messages after ID {min_id} (last message: {last_tgid}) "
f"through {source.mxid}"
)
else:
# This limit will be higher than the actual message count if there are any messages
# in other DMs or normal groups, but that's not too bad.
limit = min(last_tgid - min_id, limit)
self.log.debug(f"Backfilling up to {limit} messages through {source.mxid}")
with self.backfill_lock:
await self._backfill(source, min_id, limit)
async def _backfill(self, source: u.User, min_id: int, limit: int) -> None:
self.backfill_leave = set()
if (
self.peer_type == "user"
and self.tgid != source.tgid
and self.config["bridge.backfill.invite_own_puppet"]
):
self.log.debug("Adding %s's default puppet to room for backfilling", source.mxid)
sender = await p.Puppet.get_by_tgid(source.tgid)
await self.main_intent.invite_user(self.mxid, sender.default_mxid)
await sender.default_mxid_intent.join_room_by_id(self.mxid)
self.backfill_leave.add(sender.default_mxid_intent)
client = source.client
async with NotificationDisabler(self.mxid, source):
if limit > self.config["bridge.backfill.takeout_limit"]:
self.log.debug(f"Opening takeout client for {source.tgid}")
async with client.takeout(**self._takeout_options) as takeout:
count, handled = await self._backfill_messages(source, min_id, limit, takeout)
else:
count, handled = await self._backfill_messages(source, min_id, limit, client)
for intent in self.backfill_leave:
self.log.trace("Leaving room with %s post-backfill", intent.mxid)
await intent.leave_room(self.mxid)
self.backfill_leave = None
self.log.info(
"Backfilled %d (of %d fetched) messages through %s", handled, count, source.mxid
)
async def _backfill_messages(
self, source: u.User, min_id: int, limit: int, client: MautrixTelegramClient
) -> tuple[int, int]:
count = handled_count = 0
entity = await self.get_input_entity(source)
if self.peer_type == "channel":
# This is a channel or supergroup, so we'll backfill messages based on the ID.
# There are some cases, such as deleted messages, where this may backfill less
# messages than the limit.
self.log.debug(f"Iterating all messages starting with {min_id} (approx: {limit})")
messages = client.iter_messages(entity, reverse=True, min_id=min_id)
async for message in messages:
count += 1
was_handled = await self._handle_telegram_backfill_message(source, message)
handled_count += 1 if was_handled else 0
else:
# Private chats and normal groups don't have their own message ID namespace,
# which means we'll have to fetch messages a different way.
self.log.debug(
f"Fetching up to {limit} most recent messages, ignoring anything before {min_id}"
)
messages = await client.get_messages(entity, min_id=min_id, limit=limit)
for message in reversed(messages):
count += 1
if message.id <= min_id:
self.log.trace(
f"Skipping {message.id} in backfill response as it's lower than "
f"the last bridged message ({min_id})"
)
continue
was_handled = await self._handle_telegram_backfill_message(source, message)
handled_count += 1 if was_handled else 0
return count, handled_count
async def _handle_telegram_backfill_message(
self, source: au.AbstractUser, msg: Message | MessageService
) -> bool:
if msg.from_id and isinstance(msg.from_id, (PeerUser, PeerChannel)):
sender = await p.Puppet.get_by_peer(msg.from_id)
elif isinstance(msg.peer_id, PeerUser):
if msg.out:
sender = await p.Puppet.get_by_tgid(source.tgid)
else:
sender = await p.Puppet.get_by_peer(msg.peer_id)
else:
sender = None
if isinstance(msg, MessageService):
if isinstance(msg.action, MessageActionContactSignUp):
await self.handle_telegram_joined(source, sender, msg, backfill=True)
return True
else:
self.log.debug(
f"Unhandled service message {type(msg.action).__name__} in backfill"
)
elif isinstance(msg, Message):
await self.handle_telegram_message(source, sender, msg)
return True
else:
self.log.debug(f"Unhandled message type {type(msg).__name__} in backfill")
return False
def _split_dm_reaction_counts(self, counts: list[ReactionCount]) -> list[MessagePeerReaction]:
if len(counts) == 1:
item = counts[0]
if item.count == 2:
return [
MessagePeerReaction(reaction=item.reaction, peer_id=PeerUser(self.tgid)),
MessagePeerReaction(
reaction=item.reaction, peer_id=PeerUser(self.tg_receiver)
),
]
elif item.count == 1:
return [
MessagePeerReaction(
reaction=item.reaction,
peer_id=PeerUser(self.tg_receiver if item.chosen else self.tgid),
),
]
elif len(counts) == 2:
item1, item2 = counts
return [
MessagePeerReaction(
reaction=item1.reaction,
peer_id=PeerUser(self.tg_receiver if item1.chosen else self.tgid),
),
MessagePeerReaction(
reaction=item2.reaction,
peer_id=PeerUser(self.tg_receiver if item2.chosen else self.tgid),
),
]
return []
async def try_handle_telegram_reactions(
self,
source: au.AbstractUser,
msg_id: TelegramID,
data: MessageReactions,
dbm: DBMessage | None = None,
) -> None:
try:
await self.handle_telegram_reactions(source, msg_id, data, dbm)
except Exception:
self.log.exception(f"Error handling reactions in message {msg_id}")
async def handle_telegram_reactions(
self,
source: au.AbstractUser,
msg_id: TelegramID,
data: MessageReactions,
dbm: DBMessage | None = None,
) -> None:
if self.peer_type == "channel" and not self.megagroup:
# We don't know who reacted in a channel, so we can't bridge it properly either
return
tg_space = self.tgid if self.peer_type == "channel" else source.tgid
if dbm is None:
dbm = await DBMessage.get_one_by_tgid(msg_id, tg_space)
if dbm is None:
return
total_count = sum(item.count for item in data.results)
recent_reactions = data.recent_reactions or []
if not recent_reactions and total_count > 0:
if self.peer_type == "user":
recent_reactions = self._split_dm_reaction_counts(data.results)
elif source.is_bot:
# Can't fetch exact reaction senders as a bot
return
else:
# TODO this doesn't work for some reason
return
# resp = await source.client(
# GetMessageReactionsListRequest(peer=self.peer, id=dbm.tgid, limit=20)
# )
# recent_reactions = resp.reactions
async with self.reaction_lock(dbm.mxid):
await self._handle_telegram_reactions_locked(dbm, recent_reactions, total_count)
async def _handle_telegram_reactions_locked(
self, msg: DBMessage, reaction_list: list[MessagePeerReaction], total_count: int
) -> None:
reactions = {
p.Puppet.get_id_from_peer(reaction.peer_id): reaction.reaction
for reaction in reaction_list
if isinstance(reaction.peer_id, (PeerUser, PeerChannel))
}
is_full = len(reactions) == total_count
existing_reactions = await DBReaction.get_all_by_message(msg.mxid, msg.mx_room)
removed: list[DBReaction] = []
changed: list[tuple[DBReaction, str]] = []
for existing_reaction in existing_reactions:
new_reaction = reactions.get(existing_reaction.tg_sender)
if new_reaction is None:
if is_full:
removed.append(existing_reaction)
# else: assume the reaction is still there, too much effort to fetch it
elif new_reaction == existing_reaction.reaction:
reactions.pop(existing_reaction.tg_sender)
else:
changed.append((existing_reaction, new_reaction))
for sender, new_emoji in reactions.items():
self.log.debug(f"Bridging reaction {new_emoji} 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, variation_selector.add(new_emoji)
)
await DBReaction(
mxid=mxid,
mx_room=msg.mx_room,
msg_mxid=msg.mxid,
tg_sender=sender,
reaction=new_emoji,
).save()
for removed_reaction in removed:
self.log.debug(
f"Removing reaction {removed_reaction.reaction} by {removed_reaction.tg_sender} "
f"to {msg.tgid}"
)
puppet = await p.Puppet.get_by_tgid(removed_reaction.tg_sender)
await puppet.intent_for(self).redact(removed_reaction.mx_room, removed_reaction.mxid)
await removed_reaction.delete()
for changed_reaction, new_emoji in changed:
self.log.debug(
f"Updating reaction {changed_reaction.reaction} -> {new_emoji} "
f"by {changed_reaction.tg_sender} to {msg.tgid}"
)
puppet = await p.Puppet.get_by_tgid(changed_reaction.tg_sender)
intent = puppet.intent_for(self)
await intent.redact(changed_reaction.mx_room, changed_reaction.mxid)
changed_reaction.mxid = await intent.react(
msg.mx_room, msg.mxid, variation_selector.add(new_emoji)
)
changed_reaction.reaction = new_emoji
await changed_reaction.save()
async def handle_telegram_message(
self, source: au.AbstractUser, sender: p.Puppet, evt: Message
) -> None:
if not self.mxid:
self.log.trace("Got telegram message %d, but no room exists, creating...", evt.id)
await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
if (
self.peer_type == "user"
and sender
and sender.tgid == self.tg_receiver
and not sender.is_real_user
and not await self.az.state_store.is_joined(self.mxid, sender.mxid)
):
self.log.debug(
f"Ignoring private chat message {evt.id}@{source.tgid} as receiver does"
" not have matrix puppeting and their default puppet isn't in the room"
)
return
async with self.send_lock(sender.tgid if sender else None, required=False):
tg_space = self.tgid if self.peer_type == "channel" else source.tgid
temporary_identifier = EventID(
f"${random.randint(1000000000000, 9999999999999)}TGBRIDGETEMP"
)
event_hash, duplicate_found = self.dedup.check(evt, (temporary_identifier, tg_space))
if duplicate_found:
mxid, other_tg_space = duplicate_found
self.log.debug(
f"Ignoring message {evt.id}@{tg_space} (src {source.tgid}) "
f"as it was already handled (in space {other_tg_space})"
)
if tg_space != other_tg_space:
await DBMessage(
tgid=TelegramID(evt.id),
mx_room=self.mxid,
mxid=mxid,
tg_space=tg_space,
edit_index=0,
content_hash=event_hash,
).insert()
return
if self.backfill_lock.locked or self.peer_type == "channel":
msg = await DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
if msg:
self.log.debug(
f"Ignoring message {evt.id} (src {source.tgid}) as it was already "
f"handled into {msg.mxid}."
)
return
self.log.trace("Handling Telegram message %s", evt)
if sender and not sender.displayname:
self.log.debug(
f"Telegram user {sender.tgid} sent a message, but doesn't have a displayname,"
" updating info..."
)
entity = await source.client.get_entity(sender.peer)
await sender.update_info(source, entity)
if not sender.displayname:
self.log.debug(
f"Telegram user {sender.tgid} doesn't have a displayname even after"
f" updating with data {entity!s}"
)
allowed_media = (
MessageMediaPhoto,
MessageMediaDocument,
MessageMediaGeo,
MessageMediaGeoLive,
MessageMediaVenue,
MessageMediaGame,
MessageMediaDice,
MessageMediaPoll,
MessageMediaContact,
MessageMediaUnsupported,
)
if sender:
intent = sender.intent_for(self)
if (
self.backfill_lock.locked
and intent != sender.default_mxid_intent
and self.config["bridge.backfill.invite_own_puppet"]
):
intent = sender.default_mxid_intent
self.backfill_leave.add(intent)
else:
intent = self.main_intent
if hasattr(evt, "media") and isinstance(evt.media, allowed_media):
handler: MediaHandler = {
MessageMediaPhoto: self._handle_telegram_photo,
MessageMediaDocument: self._handle_telegram_document,
MessageMediaGeo: self._handle_telegram_location,
MessageMediaGeoLive: self._handle_telegram_live_location,
MessageMediaVenue: self._handle_telegram_venue,
MessageMediaPoll: self._handle_telegram_poll,
MessageMediaDice: self._handle_telegram_dice,
MessageMediaUnsupported: self._handle_telegram_unsupported,
MessageMediaGame: self._handle_telegram_game,
MessageMediaContact: self._handle_telegram_contact,
}[type(evt.media)]
relates_to = await formatter.telegram_reply_to_matrix(evt, source)
event_id = await handler(source, intent, evt, relates_to)
elif evt.message:
is_bot = sender.is_bot if sender else False
event_id = await self._handle_telegram_text(source, intent, is_bot, evt)
else:
self.log.debug("Unhandled Telegram message %d", evt.id)
return
if not event_id:
return
self._new_messages_after_sponsored = True
another_event_hash, prev_id = self.dedup.update(
evt, (event_id, tg_space), (temporary_identifier, tg_space)
)
assert another_event_hash == event_hash
if prev_id:
self.log.debug(
f"Sent message {evt.id}@{tg_space} to Matrix as {event_id}. "
f"Temporary dedup identifier was {temporary_identifier}, "
f"but dedup map contained {prev_id[1]} instead! -- "
"This was probably a race condition caused by Telegram sending updates"
"to other clients before responding to the sender. I'll just redact "
"the likely duplicate message now."
)
await intent.redact(self.mxid, event_id)
return
self.log.debug("Handled telegram message %d -> %s", evt.id, event_id)
try:
dbm = DBMessage(
tgid=TelegramID(evt.id),
mx_room=self.mxid,
mxid=event_id,
tg_space=tg_space,
edit_index=0,
content_hash=event_hash,
)
await dbm.insert()
await DBMessage.replace_temp_mxid(temporary_identifier, self.mxid, event_id)
except (IntegrityError, UniqueViolationError) as e:
self.log.exception(
f"{e.__class__.__name__} while saving message mapping. "
"This might mean that an update was handled after it left the "
"dedup cache queue. You can try enabling bridge.deduplication."
"pre_db_check in the config."
)
await intent.redact(self.mxid, event_id)
return
if isinstance(evt, Message) and evt.reactions:
asyncio.create_task(
self.try_handle_telegram_reactions(source, dbm.tgid, evt.reactions, dbm=dbm)
)
await self._send_delivery_receipt(event_id)
async def _create_room_on_action(
self, source: au.AbstractUser, action: TypeMessageAction
) -> bool:
if source.is_relaybot and self.config["bridge.ignore_unbridged_group_chat"]:
return False
create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate)
create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink)
if isinstance(action, create_and_exit) or isinstance(action, create_and_continue):
await self.create_matrix_room(
source, invites=[source.mxid], update_if_exists=isinstance(action, create_and_exit)
)
if not isinstance(action, create_and_continue):
return False
return True
async def handle_telegram_action(
self, source: au.AbstractUser, sender: p.Puppet, update: MessageService
) -> None:
action = update.action
should_ignore = (
not self.mxid and not await self._create_room_on_action(source, action)
) or self.dedup.check_action(update)
if should_ignore or not self.mxid:
return
if isinstance(action, MessageActionChatEditTitle):
await self._update_title(action.title, sender=sender, save=True)
await self.update_bridge_info()
elif isinstance(action, MessageActionChatEditPhoto):
await self._update_avatar(source, action.photo, sender=sender, save=True)
await self.update_bridge_info()
elif isinstance(action, MessageActionChatDeletePhoto):
await self._update_avatar(source, ChatPhotoEmpty(), sender=sender, save=True)
await self.update_bridge_info()
elif isinstance(action, MessageActionChatAddUser):
for user_id in action.users:
await self._add_telegram_user(TelegramID(user_id), source)
elif isinstance(action, MessageActionChatJoinedByLink):
await self._add_telegram_user(sender.id, source)
elif isinstance(action, MessageActionChatDeleteUser):
await self._delete_telegram_user(TelegramID(action.user_id), sender)
elif isinstance(action, MessageActionChatMigrateTo):
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.update_bridge_info()
elif isinstance(action, MessageActionGameScore):
# TODO handle game score
pass
elif isinstance(action, MessageActionContactSignUp):
await self.handle_telegram_joined(source, sender, update)
else:
self.log.trace("Unhandled Telegram action in %s: %s", self.title, action)
async def handle_telegram_joined(
self,
source: au.AbstractUser,
sender: p.Puppet,
update: MessageService,
backfill: bool = False,
) -> None:
assert isinstance(update.action, MessageActionContactSignUp)
msg = await DBMessage.get_one_by_tgid(TelegramID(update.id), source.tgid)
if msg:
self.log.debug(
f"Ignoring new user message {update.id} (src {source.tgid}) as it was already "
f"handled into {msg.mxid}."
)
return
content = TextMessageEventContent(msgtype=MessageType.EMOTE, body="joined Telegram")
event_id = await self._send_message(
sender.intent_for(self), content, timestamp=update.date
)
await DBMessage(
tgid=TelegramID(update.id),
mx_room=self.mxid,
mxid=event_id,
tg_space=source.tgid,
edit_index=0,
).insert()
# Automatically mark the notice as read if we're backfilling messages, mostly so that
# empty rooms created before the notice was added wouldn't become unread when the notice
# is backfilled in.
if backfill:
double_puppet = await p.Puppet.get_by_tgid(source.tgid)
if double_puppet and double_puppet.is_real_user:
await double_puppet.intent.mark_read(self.mxid, event_id)
async def set_telegram_admin(self, user_id: TelegramID) -> None:
puppet = await p.Puppet.get_by_tgid(user_id)
user = await u.User.get_by_tgid(user_id)
levels = await self.main_intent.get_power_levels(self.mxid)
if user:
levels.users[user.mxid] = 50
if puppet:
levels.users[puppet.mxid] = 50
await self.main_intent.set_power_levels(self.mxid, levels)
async def receive_telegram_pin_ids(
self, msg_ids: list[TelegramID], receiver: TelegramID, remove: bool
) -> None:
async with self._pin_lock:
tg_space = receiver if self.peer_type != "channel" else self.tgid
previously_pinned = await self.main_intent.get_pinned_messages(self.mxid)
currently_pinned_dict = {event_id: True for event_id in previously_pinned}
for message in await DBMessage.get_first_by_tgids(msg_ids, tg_space):
if remove:
currently_pinned_dict.pop(message.mxid, None)
else:
currently_pinned_dict[message.mxid] = True
currently_pinned = list(currently_pinned_dict.keys())
if currently_pinned != previously_pinned:
await self.main_intent.set_pinned_messages(self.mxid, currently_pinned)
async def set_telegram_admins_enabled(self, enabled: bool) -> None:
level = 50 if enabled else 10
levels = await self.main_intent.get_power_levels(self.mxid)
levels.invite = level
levels.events[EventType.ROOM_NAME] = level
levels.events[EventType.ROOM_AVATAR] = level
await self.main_intent.set_power_levels(self.mxid, levels)
# endregion
# region Miscellaneous getters
def get_config(self, key: str) -> Any:
local = util.recursive_get(self.local_config, key)
if local is not None:
return local
return self.config[f"bridge.{key}"]
@staticmethod
def _photo_size_key(photo: TypePhotoSize) -> int:
if isinstance(photo, PhotoSize):
return photo.size
elif isinstance(photo, PhotoSizeProgressive):
return max(photo.sizes)
elif isinstance(photo, PhotoSizeEmpty):
return 0
else:
return len(photo.bytes)
@classmethod
def _get_largest_photo_size(
cls, photo: Photo | Document
) -> tuple[InputPhotoFileLocation | None, TypePhotoSize | None]:
if (
not photo
or isinstance(photo, PhotoEmpty)
or (isinstance(photo, Document) and not photo.thumbs)
):
return None, None
largest = max(
photo.thumbs if isinstance(photo, Document) else photo.sizes, key=cls._photo_size_key
)
return (
InputPhotoFileLocation(
id=photo.id,
access_hash=photo.access_hash,
file_reference=photo.file_reference,
thumb_size=largest.type,
),
largest,
)
async def can_user_perform(self, user: u.User, event: str) -> bool:
if user.is_admin:
return True
if not self.mxid:
# No room for anybody to perform actions in
return False
try:
await self.main_intent.get_power_levels(self.mxid)
except MatrixRequestError:
return False
evt_type = EventType.find(f"net.maunium.telegram.{event}", t_class=EventType.Class.STATE)
return await self.main_intent.state_store.has_power_level(self.mxid, user.mxid, evt_type)
def get_input_entity(
self, user: au.AbstractUser
) -> Awaitable[TypeInputPeer | TypeInputChannel]:
return user.client.get_input_entity(self.peer)
async def get_entity(self, user: au.AbstractUser) -> TypeChat:
try:
return await user.client.get_entity(self.peer)
except ValueError:
if user.is_bot:
self.log.warning(f"Could not find entity with bot {user.tgid}. Failing...")
raise
self.log.warning(
f"Could not find entity with user {user.tgid}. falling back to get_dialogs."
)
async for dialog in user.client.iter_dialogs():
if dialog.entity.id == self.tgid:
return dialog.entity
raise
async def get_invite_link(
self, user: u.User, uses: int | None = None, expire: datetime | None = None
) -> str:
if self.peer_type == "user":
raise ValueError("You can't invite users to private chats.")
if self.username:
return f"https://t.me/{self.username}"
link = await user.client(
ExportChatInviteRequest(
peer=await self.get_input_entity(user), expire_date=expire, usage_limit=uses
)
)
return link.link
# endregion
# region Matrix room cleanup
async def get_authenticated_matrix_users(self) -> list[UserID]:
try:
members = await self.main_intent.get_room_members(self.mxid)
except MatrixRequestError:
return []
authenticated: list[UserID] = []
has_bot = self.has_bot
for member in members:
if p.Puppet.get_id_from_mxid(member) or member == self.az.bot_mxid:
continue
user = await u.User.get_and_start_by_mxid(member)
authenticated_through_bot = has_bot and user.relaybot_whitelisted
if authenticated_through_bot or await user.has_full_access(allow_bot=True):
authenticated.append(user.mxid)
return authenticated
async def cleanup_portal(
self, message: str, puppets_only: bool = False, delete: bool = True
) -> None:
if self.username:
try:
await self.main_intent.remove_room_alias(self.alias_localpart)
except (MatrixRequestError, IntentError):
self.log.warning("Failed to remove alias when cleaning up room", exc_info=True)
await self.cleanup_room(self.main_intent, self.mxid, message, puppets_only)
if delete:
await self.delete()
async def delete(self) -> None:
try:
del self.by_tgid[self.tgid_full]
except KeyError:
pass
try:
del self.by_mxid[self.mxid]
except KeyError:
pass
await super().delete()
await DBMessage.delete_all(self.mxid)
await DBReaction.delete_all(self.mxid)
self.deleted = True
# endregion
# region Class instance lookup
async def postinit(self) -> None:
puppet = await p.Puppet.get_by_tgid(self.tgid) if self.is_direct else None
self._main_intent = puppet.intent_for(self) if self.is_direct else self.az.intent
if self.tgid:
self.by_tgid[self.tgid_full] = self
if self.mxid:
self.by_mxid[self.mxid] = self
@classmethod
async def _yield_portals(
cls, query: Awaitable[list[DBPortal]]
) -> AsyncGenerator[Portal, None]:
portals = await query
portal: cls
for portal in portals:
try:
yield cls.by_tgid[portal.tgid_full]
except KeyError:
await portal.postinit()
yield portal
@classmethod
def all(cls) -> AsyncGenerator[Portal, None]:
return cls._yield_portals(super().all())
@classmethod
def find_private_chats_of(cls, tg_receiver: TelegramID) -> AsyncGenerator[Portal, None]:
return cls._yield_portals(super().find_private_chats_of(tg_receiver))
@classmethod
def find_private_chats_with(cls, tgid: TelegramID) -> AsyncGenerator[Portal, None]:
return cls._yield_portals(super().find_private_chats_with(tgid))
@classmethod
@async_getter_lock
async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
try:
return cls.by_mxid[mxid]
except KeyError:
pass
portal = cast(cls, await super().get_by_mxid(mxid))
if portal:
await portal.postinit()
return portal
return None
@classmethod
def get_username_from_mx_alias(cls, alias: str) -> str | None:
return cls.alias_template.parse(alias)
@classmethod
async def find_by_username(cls, username: str) -> Portal | None:
if not username:
return None
username = username.lower()
for _, portal in cls.by_tgid.items():
if portal.username and portal.username.lower() == username:
return portal
portal = cast(cls, await super().find_by_username(username))
if portal:
try:
return cls.by_tgid[portal.tgid_full]
except KeyError:
await portal.postinit()
return portal
return None
@classmethod
@async_getter_lock
async def get_by_tgid(
cls, tgid: TelegramID, *, tg_receiver: TelegramID | None = None, peer_type: str = None
) -> Portal | None:
if peer_type == "user" and tg_receiver is None:
raise ValueError('tg_receiver is required when peer_type is "user"')
tg_receiver = tg_receiver or tgid
tgid_full = (tgid, tg_receiver)
try:
return cls.by_tgid[tgid_full]
except KeyError:
pass
portal = cast(cls, await super().get_by_tgid(tgid, tg_receiver))
if portal:
await portal.postinit()
return portal
if peer_type:
cls.log.info(f"Creating portal 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":
# import traceback
# cls.log.info("Chat portal stack trace:\n" + "".join(traceback.format_stack()))
portal = cls(tgid, peer_type=peer_type, tg_receiver=tg_receiver)
await portal.postinit()
await portal.insert()
return portal
return None
@classmethod
async def get_by_entity(
cls,
entity: TypeChat | TypePeer | TypeUser | TypeUserFull | TypeInputPeer,
tg_receiver: TelegramID | None = None,
create: bool = True,
) -> Portal | None:
entity_type = type(entity)
if entity_type in (Chat, ChatFull):
type_name = "chat"
entity_id = entity.id
elif entity_type in (PeerChat, InputPeerChat):
type_name = "chat"
entity_id = entity.chat_id
elif entity_type in (Channel, ChannelFull):
type_name = "channel"
entity_id = entity.id
elif entity_type in (PeerChannel, InputPeerChannel, InputChannel):
type_name = "channel"
entity_id = entity.channel_id
elif entity_type in (User, UserFull):
type_name = "user"
entity_id = entity.id
elif entity_type in (PeerUser, InputPeerUser, InputUser):
type_name = "user"
entity_id = entity.user_id
else:
raise ValueError(f"Unknown entity type {entity_type.__name__}")
return await cls.get_by_tgid(
TelegramID(entity_id),
tg_receiver=tg_receiver if type_name == "user" else entity_id,
peer_type=type_name if create else None,
)
# endregion