")
else:
return await evt.reply(f"Unknown room cleaning action `{command}`. "
- + "Use `$cmdprefix+sp cancel` to cancel room "
- + "cleaning.")
+ "Use `$cmdprefix+sp cancel` to cancel room "
+ "cleaning.")
evt.sender.command_status = {
"next": lambda confirm: execute_room_cleanup(confirm, rooms_to_clean),
"action": "Room cleaning",
}
await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type"
- + "`$cmdprefix+sp confirm-clean`.")
+ "`$cmdprefix+sp confirm-clean`.")
async def execute_room_cleanup(evt, rooms_to_clean):
if len(evt.args) > 0 and evt.args[0] == "confirm-clean":
await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. "
- + "This might take a while.")
+ "This might take a while.")
cleaned = 0
for room in rooms_to_clean:
if isinstance(room, po.Portal):
diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py
index 0e06e3a4..94c6a356 100644
--- a/mautrix_telegram/commands/handler.py
+++ b/mautrix_telegram/commands/handler.py
@@ -27,7 +27,7 @@ def command_handler(needs_auth=True, management_only=False, needs_admin=False, n
def wrapper(evt):
if management_only and not evt.is_management:
return evt.reply(f"`{evt.command}` is a restricted command:"
- + "you may only run it in management rooms.")
+ "you may only run it in management rooms.")
elif needs_auth and not evt.sender.logged_in:
return evt.reply("This command requires you to be logged in.")
elif needs_admin and not evt.sender.is_admin:
@@ -45,6 +45,8 @@ class CommandEvent:
self.az = handler.az
self.log = handler.log
self.loop = handler.loop
+ self.tgbot = handler.tgbot
+ self.config = handler.config
self.command_prefix = handler.command_prefix
self.room_id = room
self.sender = sender
@@ -87,7 +89,7 @@ class CommandHandler:
log = logging.getLogger("mau.commands")
def __init__(self, context):
- self.az, self.db, self.config, self.loop = context
+ self.az, self.db, self.config, self.loop, self.tgbot = context
self.command_prefix = self.config["bridge.command_prefix"]
# region Utility functions for handling commands
@@ -110,6 +112,6 @@ class CommandHandler:
except FloodWaitError as e:
return evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
except Exception:
- self.log.exception(f"Fatal error handling command "
- + f"{evt.command} {' '.join(args)} from {sender.mxid}")
+ self.log.exception("Fatal error handling command "
+ f"{evt.command} {' '.join(args)} from {sender.mxid}")
return evt.reply("Fatal error while handling command. Check logs for more details.")
diff --git a/mautrix_telegram/commands/meta.py b/mautrix_telegram/commands/meta.py
index 6ffff94f..0338b402 100644
--- a/mautrix_telegram/commands/meta.py
+++ b/mautrix_telegram/commands/meta.py
@@ -65,6 +65,7 @@ def help(evt):
`channel` (defaults to `group`).
#### Portal management
+**ping-bot** - Get info of the message relay Telegram bot.
**upgrade** - Upgrade a normal Telegram group to a supergroup.
**invite-link** - Get a Telegram invite link to the current chat.
**delete-portal** - Forget the current portal room. Only works for group chats; to delete
diff --git a/mautrix_telegram/commands/telegram.py b/mautrix_telegram/commands/telegram.py
index cb1f0b17..3d94645a 100644
--- a/mautrix_telegram/commands/telegram.py
+++ b/mautrix_telegram/commands/telegram.py
@@ -51,7 +51,7 @@ async def search(evt):
else:
reply += ["**Results in contacts:**", ""]
reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
- + f"{puppet.id} ({similarity}% match)")
+ f"{puppet.id} ({similarity}% match)")
for puppet, similarity in results]
# TODO somehow show remote channel results when joining by alias is possible?
@@ -71,8 +71,8 @@ async def pm(evt):
return await evt.reply("That doesn't seem to be a user.")
portal = po.Portal.get_by_entity(user, evt.sender.tgid)
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
- return await evt.reply(
- f"Created private chat room with {pu.Puppet.get_displayname(user, False)}")
+ return await evt.reply("Created private chat room with "
+ f"{pu.Puppet.get_displayname(user, False)}")
@command_handler()
@@ -116,9 +116,9 @@ async def delete_portal(evt):
"action": "Portal deletion",
}
return await evt.reply("Please confirm deletion of portal "
- + f"[{room_id}](https://matrix.to/#/{room_id}) "
- + f"to Telegram chat \"{portal.title}\" "
- + "by typing `$cmdprefix+sp confirm-delete`")
+ f"[{room_id}](https://matrix.to/#/{room_id}) "
+ f"to Telegram chat \"{portal.title}\" "
+ "by typing `$cmdprefix+sp confirm-delete`")
@command_handler()
@@ -184,16 +184,16 @@ async def create(evt):
return await evt.reply("Please set a title before creating a Telegram chat.")
elif (not levels or not levels["users"] or evt.az.intent.mxid not in levels["users"] or
levels["users"][evt.az.intent.mxid] < 100):
- return await evt.reply(f"Please give "
- + f"[the bridge bot](https://matrix.to/#/{evt.az.intent.mxid})"
- + f" a power level of 100 before creating a Telegram chat.")
+ return await evt.reply("Please give "
+ f"[the bridge bot](https://matrix.to/#/{evt.az.intent.mxid})"
+ " a power level of 100 before creating a Telegram chat.")
else:
for user, level in levels["users"].items():
if level >= 100 and user != evt.az.intent.mxid:
return await evt.reply(
f"Please make sure only the bridge bot has power level above"
- + f"99 before creating a Telegram chat.\n\n"
- + f"Use power level 95 instead of 100 for admins.")
+ f"99 before creating a Telegram chat.\n\n"
+ f"Use power level 95 instead of 100 for admins.")
supergroup = type == "supergroup"
type = {
diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py
index f2f81426..8b4dd5e3 100644
--- a/mautrix_telegram/config.py
+++ b/mautrix_telegram/config.py
@@ -94,7 +94,7 @@ class Config(DictWithRecursion):
self.set("appservice.hs_token", self._new_token())
url = (f"{self['appservice.protocol']}://"
- + f"{self['appservice.hostname']}:{self['appservice.port']}")
+ f"{self['appservice.hostname']}:{self['appservice.port']}")
self._registration = {
"id": self.get("appservice.id", "telegram"),
"as_token": self["appservice.as_token"],
diff --git a/mautrix_telegram/context.py b/mautrix_telegram/context.py
new file mode 100644
index 00000000..d53f7dc9
--- /dev/null
+++ b/mautrix_telegram/context.py
@@ -0,0 +1,34 @@
+# -*- coding: future_fstrings -*-
+# mautrix-telegram - A Matrix-Telegram puppeting bridge
+# Copyright (C) 2018 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+
+class Context:
+ def __init__(self, az, db, config, loop, bot, mx):
+ self.az = az
+ self.db = db
+ self.config = config
+ self.loop = loop
+ self.bot = bot
+ self.mx = mx
+
+ def __iter__(self):
+ yield self.az
+ yield self.db
+ yield self.config
+ yield self.loop
+ yield self.bot
+ # yield self.mx
diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py
index b8a4b372..20dff82b 100644
--- a/mautrix_telegram/db.py
+++ b/mautrix_telegram/db.py
@@ -48,7 +48,7 @@ class Message(Base):
tgid = Column(Integer, primary_key=True)
tg_space = Column(Integer, primary_key=True)
- __table_args__ = (UniqueConstraint('mxid', 'mx_room', 'tg_space', name='_mx_id_room'),)
+ __table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
class UserPortal(Base):
@@ -95,9 +95,18 @@ class Puppet(Base):
photo_id = Column(String, nullable=True)
+# Fucking Telegram not telling bots what chats they are in 3:<
+class BotChat(Base):
+ query = None
+ __tablename__ = "bot_chat"
+ id = Column(Integer, primary_key=True)
+ type = Column(String, nullable=False)
+
+
def init(db_session):
Portal.query = db_session.query_property()
Message.query = db_session.query_property()
UserPortal.query = db_session.query_property()
User.query = db_session.query_property()
Puppet.query = db_session.query_property()
+ BotChat.query = db_session.query_property()
diff --git a/mautrix_telegram/formatter.py b/mautrix_telegram/formatter.py
index 9590b5ec..1a21d68a 100644
--- a/mautrix_telegram/formatter.py
+++ b/mautrix_telegram/formatter.py
@@ -189,8 +189,8 @@ def matrix_reply_to_telegram(content, tg_space, room_id=None):
reply = content["m.relates_to"]["m.in_reply_to"]
room_id = room_id or reply["room_id"]
event_id = reply["event_id"]
- message = DBMessage.query.filter(DBMessage.mxid == event_id and
- DBMessage.tg_space == tg_space and
+ message = DBMessage.query.filter(DBMessage.mxid == event_id,
+ DBMessage.tg_space == tg_space,
DBMessage.mx_room == room_id).one_or_none()
if message:
return message.tgid
@@ -244,7 +244,7 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li
if not fwd_from:
fwd_from = "Unknown user"
html = (f"Forwarded message from {fwd_from}
"
- + f"{html}
")
+ f"{html}
")
if evt.reply_to_msg_id:
space = (evt.to_id.channel_id
@@ -271,7 +271,7 @@ async def telegram_event_to_matrix(evt, source, native_replies=False, message_li
displayname = puppet.displayname if puppet else sender
reply_to_user = f"{displayname}"
reply_to_msg = (("{reply_text}")
+ f"{msg.mx_room}/{msg.mxid}'>{reply_text}")
if message_link_in_reply else "Reply")
quote = f"{reply_to_msg} to {reply_to_user}{body}
"
except (ValueError, KeyError, MatrixRequestError):
@@ -331,8 +331,8 @@ def _telegram_to_matrix(text, entities):
elif entity_type == MessageEntityPre:
if entity.language:
html.append(""
- + f"{entity_text}"
- + "
")
+ f"{entity_text}"
+ "")
else:
html.append(f"{entity_text}
")
elif entity_type == MessageEntityMention:
diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py
index f5f36adf..25b2eb97 100644
--- a/mautrix_telegram/matrix.py
+++ b/mautrix_telegram/matrix.py
@@ -28,13 +28,13 @@ class MatrixHandler:
log = logging.getLogger("mau.mx")
def __init__(self, context):
- self.az, self.db, self.config, _ = context
+ self.az, self.db, self.config, _, self.tgbot = context
self.commands = CommandHandler(context)
self.az.matrix_event_handler(self.handle_event)
async def init_as_bot(self):
- self.az.intent.set_display_name(
+ await self.az.intent.set_display_name(
self.config.get("appservice.bot_displayname", "Telegram bridge bot"))
async def handle_puppet_invite(self, room, puppet, inviter):
@@ -60,8 +60,8 @@ class MatrixHandler:
if len(members) > 1:
await puppet.intent.error_and_leave(room, text=None, html=(
f"Please invite "
- + f"the bridge bot "
- + f"first if you want to create a Telegram chat."))
+ f"the bridge bot "
+ f"first if you want to create a Telegram chat."))
return
await puppet.intent.join_room(room)
@@ -71,9 +71,9 @@ class MatrixHandler:
await puppet.intent.invite(portal.mxid, inviter.mxid)
await puppet.intent.send_notice(room, text=None, html=(
"You already have a private chat with me: "
- + f""
- + "Link to room"
- + ""))
+ f""
+ "Link to room"
+ ""))
await puppet.intent.leave_room(room)
return
except MatrixRequestError:
@@ -88,7 +88,7 @@ class MatrixHandler:
"Telegram chat is created for this room.")
async def handle_invite(self, room, user, inviter):
- inviter = User.get_by_mxid(inviter)
+ inviter = await User.get_by_mxid(inviter).ensure_started()
if not inviter.whitelisted:
return
elif user == self.az.bot_mxid:
@@ -101,6 +101,9 @@ class MatrixHandler:
return
user = User.get_by_mxid(user, create=False)
+ if not user:
+ return
+ await user.ensure_started()
portal = Portal.get_by_mxid(room)
if user and user.has_full_access and portal:
await portal.invite_telegram(inviter, user)
@@ -110,7 +113,7 @@ class MatrixHandler:
self.log.debug(f"{inviter} invited {user} to {room}")
async def handle_join(self, room, user):
- user = User.get_by_mxid(user)
+ user = await User.get_by_mxid(user).ensure_started()
portal = Portal.get_by_mxid(room)
if not portal:
@@ -120,19 +123,23 @@ class MatrixHandler:
await portal.main_intent.kick(room, user.mxid,
"You are not whitelisted on this Telegram bridge.")
return
- elif not user.logged_in:
- # TODO[waiting-for-bots] once we have bot support, this won't be needed.
+ elif not user.logged_in and not portal.has_bot:
await portal.main_intent.kick(room, user.mxid,
- "You are not logged into this Telegram bridge.")
+ "This chat does not have a bot relaying "
+ "messages for unauthenticated users.")
return
self.log.debug(f"{user} joined {room}")
- # TODO join Telegram chat if applicable
+ if user.logged_in:
+ await portal.join_matrix(user)
async def handle_part(self, room, user, sender):
self.log.debug(f"{user} left {room}")
sender = User.get_by_mxid(sender, create=False)
+ if not sender:
+ return
+ await sender.ensure_started()
portal = Portal.get_by_mxid(room)
if not portal:
@@ -143,7 +150,10 @@ class MatrixHandler:
await portal.leave_matrix(puppet, sender)
user = User.get_by_mxid(user, create=False)
- if user and user.logged_in:
+ if not user:
+ return
+ await user.ensure_started()
+ if user.logged_in:
await portal.leave_matrix(user, sender)
def is_command(self, message):
@@ -158,10 +168,12 @@ class MatrixHandler:
self.log.debug(f"{sender} sent {message} to ${room}")
is_command, text = self.is_command(message)
- sender = User.get_by_mxid(sender)
+ sender = await User.get_by_mxid(sender).ensure_started()
+ if not sender.whitelisted:
+ return
portal = Portal.get_by_mxid(room)
- if sender.has_full_access and portal and not is_command:
+ if not is_command and portal and (sender.logged_in or portal.has_bot):
await portal.handle_matrix_message(sender, message, event_id)
return
@@ -187,19 +199,19 @@ class MatrixHandler:
async def handle_redaction(self, room, sender, event_id):
portal = Portal.get_by_mxid(room)
- sender = User.get_by_mxid(sender)
+ sender = await User.get_by_mxid(sender).ensure_started()
if sender.has_full_access and portal:
await portal.handle_matrix_deletion(sender, event_id)
async def handle_power_levels(self, room, sender, new, old):
portal = Portal.get_by_mxid(room)
- sender = User.get_by_mxid(sender)
+ sender = await User.get_by_mxid(sender).ensure_started()
if sender.has_full_access and portal:
await portal.handle_matrix_power_levels(sender, new["users"], old["users"])
async def handle_room_meta(self, type, room, sender, content):
portal = Portal.get_by_mxid(room)
- sender = User.get_by_mxid(sender)
+ sender = await User.get_by_mxid(sender).ensure_started()
if sender.has_full_access and portal:
handler, content_key = {
"m.room.name": (portal.handle_matrix_title, "name"),
@@ -209,7 +221,7 @@ class MatrixHandler:
if content_key not in content:
# FIXME handle
pass
- await handler(sender, content[content_key])
+ await handler(sender, content[content_key])
def filter_matrix_event(self, event):
return (event["sender"] == self.az.bot_mxid
@@ -236,5 +248,5 @@ class MatrixHandler:
elif type == "m.room.power_levels":
await self.handle_power_levels(evt["room_id"], evt["sender"], evt["content"],
evt["prev_content"])
- elif type == "m.room.name" or type == "m.room.avatar" or type == "m.room.topic":
+ elif type in ("m.room.name", "m.room.avatar", "m.room.topic"):
await self.handle_room_meta(type, evt["room_id"], evt["sender"], evt["content"])
diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py
index 6a65aede..7cc88c6d 100644
--- a/mautrix_telegram/portal.py
+++ b/mautrix_telegram/portal.py
@@ -44,6 +44,8 @@ class Portal:
log = logging.getLogger("mau.portal")
db = None
az = None
+ bot = None
+ bridge_notices = False
by_mxid = {}
by_tgid = {}
@@ -62,6 +64,7 @@ class Portal:
self._dedup = deque()
self._dedup_mxid = {}
+ self._dedup_action = deque()
if save_to_cache:
if tgid:
@@ -88,36 +91,51 @@ class Portal:
elif self.peer_type == "channel":
return PeerChannel(channel_id=self.tgid)
- def _hash_event(self, event):
- if self.peer_type == "channel":
- # Message IDs are unique per-channel
- return event.id
+ @property
+ def has_bot(self):
+ return self.bot and self.bot.is_in_chat(self.tgid)
+ @staticmethod
+ def _hash_event(event):
# Non-channel messages are unique per-user (wtf telegram), so we have no other choice than
# to deduplicate based on a hash of the message content.
- # The timestamp is only accurate to the second, so we can't rely on solely that either.
- hash_content = [event.date.timestamp(), event.message]
- if event.fwd_from:
- hash_content += [event.fwd_from.from_id, event.fwd_from.channel_id]
- elif isinstance(event, Message) and event.media:
- try:
- hash_content += {
- MessageMediaContact: lambda media: [media.user_id],
- MessageMediaDocument: lambda media: [media.document.id, media.caption],
- MessageMediaPhoto: lambda media: [media.photo.id, media.caption],
- MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
- }[type(event.media)](event.media)
- except KeyError:
- pass
+ # The timestamp is only accurate to the second, so we can't rely solely on that either.
+ if isinstance(event, MessageService):
+ hash_content = [event.date.timestamp(), event.from_id, event.action]
+ else:
+ hash_content = [event.date.timestamp(), event.message]
+ if event.fwd_from:
+ hash_content += [event.fwd_from.from_id, event.fwd_from.channel_id]
+ elif isinstance(event, Message) and event.media:
+ try:
+ hash_content += {
+ MessageMediaContact: lambda media: [media.user_id],
+ MessageMediaDocument: lambda media: [media.document.id, media.caption],
+ MessageMediaPhoto: lambda media: [media.photo.id, media.caption],
+ MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
+ }[type(event.media)](event.media)
+ except KeyError:
+ pass
return hashlib.md5("-"
.join(str(a) for a in hash_content)
.encode("utf-8")
).hexdigest()
+ def is_duplicate_action(self, event):
+ hash = self._hash_event(event) if self.peer_type != "channel" else event.id
+ if hash in self._dedup_action:
+ return True
+
+ self._dedup_action.append(hash)
+
+ if len(self._dedup_action) > 20:
+ self._dedup_action.popleft()
+ return False
+
def is_duplicate(self, event, mxid=None):
- hash = self._hash_event(event)
+ hash = self._hash_event(event) if self.peer_type != "channel" else event.id
if hash in self._dedup:
return self._dedup_mxid[hash]
@@ -196,8 +214,7 @@ class Portal:
self._main_intent = puppet.intent if direct else self.az.intent
if self.peer_type == "channel" and entity.username:
- # TODO make public once safe
- public = False
+ public = True
alias = self._get_room_alias(entity.username)
self.username = entity.username
else:
@@ -206,7 +223,7 @@ class Portal:
alias = None
if alias:
- # TODO properly handle existing room aliases
+ # TODO? properly handle existing room aliases
await self.main_intent.remove_room_alias(alias)
power_levels = self._get_base_power_levels({}, entity)
@@ -260,11 +277,32 @@ class Portal:
groupname=username)
async def sync_telegram_users(self, source, users):
+ allowed_tgids = set()
for entity in users:
puppet = p.Puppet.get(entity.id)
+ if self.bot and puppet.tgid == self.bot.tgid:
+ self.bot.add_chat(self.tgid, self.peer_type)
+ allowed_tgids.add(entity.id)
await puppet.intent.ensure_joined(self.mxid)
await puppet.update_info(source, entity)
+ joined_mxids = await self.main_intent.get_room_members(self.mxid)
+ for user in joined_mxids:
+ if user == self.az.intent.mxid:
+ continue
+ puppet_id = p.Puppet.get_id_from_mxid(user)
+ if puppet_id and puppet_id not in allowed_tgids:
+ if self.bot and puppet_id == self.bot.tgid:
+ self.bot.remove_chat(self.tgid)
+ await self.main_intent.kick(self.mxid, user,
+ "User had left this Telegram chat.")
+ continue
+ mx_user = u.User.get_by_mxid(user, create=False)
+ if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids:
+ await self.main_intent.kick(self.mxid, mx_user.mxid,
+ "You had left this Telegram chat.")
+ continue
+
async def add_telegram_user(self, user_id, source=None):
puppet = p.Puppet.get(user_id)
if source:
@@ -319,6 +357,9 @@ class Portal:
self.username = username or None
if self.username:
await self.main_intent.add_room_alias(self.mxid, self._get_room_alias())
+ await self.main_intent.set_join_rule(self.mxid, "public")
+ else:
+ await self.main_intent.set_join_rule(self.mxid, "invite")
return True
return False
@@ -404,7 +445,7 @@ class Portal:
for member in members:
if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid:
continue
- user = u.User.get_by_mxid(member)
+ user = await u.User.get_by_mxid(member).ensure_started()
if user.has_full_access:
authenticated.append(user)
return authenticated
@@ -463,22 +504,42 @@ class Portal:
channel = await self.get_input_entity(user)
await user.client(LeaveChannelRequest(channel=channel))
+ async def join_matrix(self, user):
+ if self.peer_type == "channel":
+ await user.client(JoinChannelRequest(channel=await self.get_input_entity(user)))
+ else:
+ # We'll just assume the user is already in the chat.
+ pass
+
async def handle_matrix_message(self, sender, message, event_id):
type = message["msgtype"]
- space = self.tgid if self.peer_type == "channel" else sender.tgid
+ if sender.logged_in:
+ client = sender.client
+ space = self.tgid if self.peer_type == "channel" else sender.tgid
+ else:
+ client = self.bot.client
+ space = self.tgid if self.peer_type == "channel" else self.bot.tgid
reply_to = formatter.matrix_reply_to_telegram(message, space, room_id=self.mxid)
- if type in {"m.text", "m.emote"}:
+
+ if type == "m.emote":
+ if "formatted_body" in message:
+ message["formatted_body"] = f"* {sender.displayname} {message['formatted_body']}"
+ message["body"] = f"* {sender.displayname} {message['body']}"
+ type = "m.text"
+ elif not sender.logged_in:
+ if "formatted_body" in message:
+ message["formatted_body"] = (f"<{sender.displayname}> "
+ f"{message['formatted_body']}")
+ message["body"] = f"<{sender.displayname}> {message['body']}"
+
+ if type == "m.text" or (self.bridge_notices and type == "m.notice"):
if "format" in message and message["format"] == "org.matrix.custom.html":
message, entities = formatter.matrix_to_telegram(message["formatted_body"])
- if type == "m.emote":
- message = "/me " + message
- response = await sender.client.send_message(self.peer, message, entities=entities,
- reply_to=reply_to)
+ response = await client.send_message(self.peer, message, entities=entities,
+ reply_to=reply_to)
else:
- if type == "m.emote":
- message["body"] = "/me " + message["body"]
- response = await sender.client.send_message(self.peer, message["body"],
- reply_to=reply_to)
+ response = await client.send_message(self.peer, message["body"],
+ reply_to=reply_to)
elif type in {"m.image", "m.file", "m.audio", "m.video"}:
file = await self.main_intent.download_file(message["url"])
@@ -491,8 +552,8 @@ class Portal:
if "w" in info and "h" in info:
attributes.append(DocumentAttributeImageSize(w=info["w"], h=info["h"]))
- response = await sender.client.send_file(self.peer, file, mime, caption, attributes,
- file_name, reply_to=reply_to)
+ response = await client.send_file(self.peer, file, mime, caption, attributes,
+ file_name, reply_to=reply_to)
else:
self.log.debug("Unhandled Matrix event: %s", message)
return
@@ -506,8 +567,8 @@ class Portal:
async def handle_matrix_deletion(self, deleter, event_id):
space = self.tgid if self.peer_type == "channel" else deleter.tgid
- message = DBMessage.query.filter(DBMessage.mxid == event_id and
- DBMessage.tg_space == space and
+ message = DBMessage.query.filter(DBMessage.mxid == event_id,
+ DBMessage.tg_space == space,
DBMessage.mx_room == self.mxid).one_or_none()
if not message:
return
@@ -553,10 +614,11 @@ class Portal:
return
if self.peer_type == "chat":
- await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title))
+ response = await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title))
else:
channel = await self.get_input_entity(sender)
- await sender.client(EditTitleRequest(channel=channel, title=title))
+ response = await sender.client(EditTitleRequest(channel=channel, title=title))
+ self._register_outgoing_actions_for_dedup(response)
self.title = title
self.save()
@@ -572,11 +634,12 @@ class Portal:
photo = InputChatUploadedPhoto(file=uploaded)
if self.peer_type == "chat":
- updates = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo))
+ response = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo))
else:
channel = await self.get_input_entity(sender)
- updates = await sender.client(EditPhotoRequest(channel=channel, photo=photo))
- for update in updates.updates:
+ response = await sender.client(EditPhotoRequest(channel=channel, photo=photo))
+ self._register_outgoing_actions_for_dedup(response)
+ for update in response.updates:
is_photo_update = (isinstance(update, UpdateNewMessage)
and isinstance(update.message, MessageService)
and isinstance(update.message.action, MessageActionChatEditPhoto))
@@ -586,6 +649,13 @@ class Portal:
self.save()
break
+ def _register_outgoing_actions_for_dedup(self, response):
+ for update in response.updates:
+ check_dedup = (isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage))
+ and isinstance(update.message, MessageService))
+ if check_dedup:
+ self.is_duplicate_action(update.message)
+
# endregion
# region Telegram chat info updating
@@ -635,7 +705,6 @@ class Portal:
invites = await self._get_telegram_users_in_matrix_room()
if len(invites) < 2:
- # TODO[waiting-for-bots] This won't happen when the bot is enabled
raise ValueError("Not enough Telegram users to create a chat")
if self.peer_type == "chat":
@@ -658,6 +727,9 @@ class Portal:
await self.update_info(source, entity)
self.save()
+ if self.bot and self.bot.mxid in invites:
+ self.bot.add_chat(self.tgid, self.peer_type)
+
levels = await self.main_intent.get_power_levels(self.mxid)
levels = self._get_base_power_levels(levels, entity)
already_saved = await self.handle_matrix_power_levels(source, levels["users"], {})
@@ -864,7 +936,8 @@ class Portal:
self.db.add(DBMessage(tgid=evt.id, mx_room=self.mxid, mxid=mxid, tg_space=tg_space))
self.db.commit()
- async def handle_telegram_action(self, source, sender, action):
+ async def handle_telegram_action(self, source, sender, update):
+ action = update.action
if not self.mxid:
create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate)
create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink)
@@ -874,6 +947,9 @@ class Portal:
if not isinstance(action, create_and_continue):
return
+ if self.is_duplicate_action(update):
+ return
+
# TODO figure out how to see changes to about text / channel username
if isinstance(action, MessageActionChatEditTitle):
if await self.update_title(action.title):
@@ -898,7 +974,10 @@ class Portal:
else:
self.log.debug("Unhandled Telegram action in %s: %s", self.title, action)
- async def set_telegram_admin(self, puppet, user):
+ async def set_telegram_admin(self, user_id):
+ puppet = p.Puppet.get(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
@@ -1078,4 +1157,5 @@ class Portal:
def init(context):
global config
- Portal.az, Portal.db, config, _ = context
+ Portal.az, Portal.db, config, _, Portal.bot = context
+ Portal.bridge_notices = config["bridge.bridge_notices"]
diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py
index 596bcd2e..bfb9c28c 100644
--- a/mautrix_telegram/puppet.py
+++ b/mautrix_telegram/puppet.py
@@ -31,15 +31,14 @@ class Puppet:
db = None
az = None
mxid_regex = None
+ username_template = None
+ hs_domain = None
cache = {}
def __init__(self, id=None, username=None, displayname=None, photo_id=None):
self.id = id
+ self.mxid = self.get_mxid_from_id(self.id)
- self.localpart = config.get("bridge.username_template", "telegram_{userid}").format(
- userid=self.id)
- hs = config["homeserver"]["domain"]
- self.mxid = f"@{self.localpart}:{hs}"
self.username = username
self.displayname = displayname
self.photo_id = photo_id
@@ -161,6 +160,10 @@ class Puppet:
return int(match.group(1))
return None
+ @classmethod
+ def get_mxid_from_id(cls, id):
+ return f"@{cls.username_template.format(userid=id)}:{cls.hs_domain}"
+
@classmethod
def find_by_username(cls, username):
for _, puppet in cls.cache.items():
@@ -176,7 +179,8 @@ class Puppet:
def init(context):
global config
- Puppet.az, Puppet.db, config, _ = context
- localpart = config.get("bridge.username_template", "telegram_{userid}").format(userid="(.+)")
- hs = config["homeserver"]["domain"]
- Puppet.mxid_regex = re.compile(f"@{localpart}:{hs}")
+ Puppet.az, Puppet.db, config, _, _ = context
+ Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}")
+ Puppet.hs_domain = config["homeserver"]["domain"]
+ localpart = Puppet.username_template.format(userid="(.+)")
+ Puppet.mxid_regex = re.compile(f"@{localpart}:{Puppet.hs_domain}")
diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py
index b1cc5aea..4ecd64f6 100644
--- a/mautrix_telegram/user.py
+++ b/mautrix_telegram/user.py
@@ -16,31 +16,28 @@
# along with this program. If not, see .
import logging
import asyncio
-import platform
+import re
from telethon.tl.types import *
from telethon.tl.types.contacts import ContactsNotModified
-from telethon.tl.types import User as TLUser
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
from mautrix_appservice import MatrixRequestError
-from .db import User as DBUser, Message as DBMessage, Contact as DBContact
-from .tgclient import MautrixTelegramClient
-from . import portal as po, puppet as pu, __version__
+from .db import User as DBUser, Contact as DBContact
+from .abstract_user import AbstractUser
+from . import portal as po, puppet as pu
config = None
-class User:
- loop = None
+class User(AbstractUser):
log = logging.getLogger("mau.user")
- db = None
- az = None
by_mxid = {}
by_tgid = {}
def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0,
db_portals=None):
+ super().__init__()
self.mxid = mxid
self.tgid = tgid
self.username = username
@@ -51,9 +48,6 @@ class User:
self.db_portals = db_portals
self.command_status = None
- self.connected = False
- self.client = None
- self._init_client()
self.is_admin = self.mxid in config.get("bridge.admins", [])
@@ -67,13 +61,17 @@ class User:
if tgid:
self.by_tgid[tgid] = self
- @property
- def logged_in(self):
- return self.client.is_user_authorized()
+ self._init_client()
@property
- def has_full_access(self):
- return self.logged_in and self.whitelisted
+ def name(self):
+ return self.mxid
+
+ @property
+ def displayname(self):
+ # TODO show better username
+ match = re.compile("@(.+):(.+)").match(self.mxid)
+ return match.group(1)
@property
def db_contacts(self):
@@ -129,23 +127,13 @@ class User:
# endregion
# region Telegram connection management
- def _init_client(self):
- device = f"{platform.system()} {platform.release()}"
- sysversion = MautrixTelegramClient.__version__
- self.client = MautrixTelegramClient(self.mxid,
- config["telegram.api_id"],
- config["telegram.api_hash"],
- loop=self.loop,
- app_version=__version__,
- system_version=sysversion,
- device_model=device)
- self.client.add_update_handler(self.update_catch)
-
async def start(self, delete_unless_authenticated=False):
- self.connected = await self.client.connect()
+ await super().start()
if self.logged_in:
+ self.log.debug(f"Ensuring post_login() for {self.name}")
asyncio.ensure_future(self.post_login(), loop=self.loop)
elif delete_unless_authenticated:
+ self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting...")
# User not logged in -> forget user
self.client.disconnect()
self.client.session.delete()
@@ -160,11 +148,6 @@ class User:
except Exception:
self.log.exception("Failed to run post-login functions")
- def stop(self):
- self.client.disconnect()
- self.client = None
- self.connected = False
-
# endregion
# region Telegram actions that need custom methods
@@ -234,14 +217,8 @@ class User:
return await self._search_remote(query), True
async def sync_dialogs(self):
- dialogs = await self.client.get_dialogs(limit=30)
creators = []
- for dialog in dialogs:
- entity = dialog.entity
- invalid = (isinstance(entity, (TLUser, ChatForbidden, ChannelForbidden))
- or (isinstance(entity, Chat) and (entity.deactivated or entity.left)))
- if invalid:
- continue
+ for entity in await self._get_dialogs(limit=30):
portal = po.Portal.get_by_entity(entity)
self.portals[portal.tgid_full] = portal
creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid]))
@@ -283,135 +260,6 @@ class User:
self.contacts.append(puppet)
self.save()
- # endregion
- # region Telegram update handling
-
- async def update_catch(self, update):
- try:
- await self.update(update)
- except Exception:
- self.log.exception("Failed to handle Telegram update")
-
- async def update(self, update):
- if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
- UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
- await self.update_message(update)
- elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)):
- await self.update_typing(update)
- elif isinstance(update, UpdateUserStatus):
- await self.update_status(update)
- elif isinstance(update, (UpdateChatAdmins, UpdateChatParticipantAdmin)):
- await self.update_admin(update)
- elif isinstance(update, UpdateChatParticipants):
- portal = po.Portal.get_by_tgid(update.participants.chat_id)
- if portal and portal.mxid:
- await portal.update_telegram_participants(update.participants.participants)
- elif isinstance(update, UpdateChannelPinnedMessage):
- portal = po.Portal.get_by_tgid(update.channel_id)
- if portal and portal.mxid:
- await portal.update_telegram_pin(self, update.id)
- elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
- await self.update_others_info(update)
- elif isinstance(update, UpdateReadHistoryOutbox):
- await self.update_read_receipt(update)
- else:
- self.log.debug("Unhandled update: %s", update)
-
- async def update_read_receipt(self, update):
- if not isinstance(update.peer, PeerUser):
- self.log.debug("Unexpected read receipt peer: %s", update.peer)
- return
-
- portal = po.Portal.get_by_tgid(update.peer.user_id, self.tgid)
- if not portal or not portal.mxid:
- return
-
- # We check that these are user read receipts, so tg_space is always the user ID.
- message = DBMessage.query.get((update.max_id, self.tgid))
- if not message:
- return
-
- puppet = pu.Puppet.get(update.peer.user_id)
- await puppet.intent.mark_read(portal.mxid, message.mxid)
-
- async def update_admin(self, update):
- portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
- if isinstance(update, UpdateChatAdmins):
- await portal.set_telegram_admins_enabled(update.enabled)
- elif isinstance(update, UpdateChatParticipantAdmin):
- puppet = pu.Puppet.get(update.user_id)
- user = User.get_by_tgid(update.user_id)
- await portal.set_telegram_admin(puppet, user)
-
- async def update_typing(self, update):
- if isinstance(update, UpdateUserTyping):
- portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user")
- else:
- portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
- sender = pu.Puppet.get(update.user_id)
- await portal.handle_telegram_typing(sender, update)
-
- async def update_others_info(self, update):
- puppet = pu.Puppet.get(update.user_id)
- if isinstance(update, UpdateUserName):
- if await puppet.update_displayname(self, update):
- puppet.save()
- elif isinstance(update, UpdateUserPhoto):
- if await puppet.update_avatar(self, update.photo.photo_big):
- puppet.save()
-
- async def update_status(self, update):
- puppet = pu.Puppet.get(update.user_id)
- if isinstance(update.status, UserStatusOnline):
- await puppet.intent.set_presence("online")
- elif isinstance(update.status, UserStatusOffline):
- await puppet.intent.set_presence("offline")
- else:
- self.log.warning("Unexpected user status update: %s", update)
- return
-
- def get_message_details(self, update):
- if isinstance(update, UpdateShortChatMessage):
- portal = po.Portal.get_by_tgid(update.chat_id, peer_type="chat")
- sender = pu.Puppet.get(update.from_id)
- elif isinstance(update, UpdateShortMessage):
- portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user")
- sender = pu.Puppet.get(self.tgid if update.out else update.user_id)
- elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
- UpdateEditMessage, UpdateEditChannelMessage)):
- update = update.message
- if isinstance(update.to_id, PeerUser) and not update.out:
- portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
- tg_receiver=self.tgid)
- else:
- portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
- sender = pu.Puppet.get(update.from_id) if update.from_id else None
- else:
- self.log.warning(
- f"Unexpected message type in User#get_message_details: {type(update)}")
- return update, None, None
- return update, sender, portal
-
- def update_message(self, original_update):
- update, sender, portal = self.get_message_details(original_update)
-
- if isinstance(update, MessageService):
- if isinstance(update.action, MessageActionChannelMigrateFrom):
- self.log.debug(f"Ignoring action %s to %s by %d", update.action, portal.tgid_log,
- sender.id)
- return
- self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
- sender.id)
- return portal.handle_telegram_action(self, sender, update.action)
-
- user = sender.tgid if sender else "admin"
- if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
- self.log.debug("Handling edit %s to %s by %s", update, portal.tgid_log, user)
- return portal.handle_telegram_edit(self, sender, update)
-
- self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
- return portal.handle_telegram_message(self, sender, update)
-
# endregion
# region Class instance lookup
@@ -425,14 +273,12 @@ class User:
user = DBUser.query.get(mxid)
if user:
user = cls.from_db(user)
- asyncio.ensure_future(user.start(), loop=cls.loop)
return user
if create:
user = cls(mxid)
cls.db.add(user.to_db())
cls.db.commit()
- asyncio.ensure_future(user.start(), loop=cls.loop)
return user
return None
@@ -447,7 +293,6 @@ class User:
user = DBUser.query.filter(DBUser.tgid == tgid).one_or_none()
if user:
user = cls.from_db(user)
- asyncio.ensure_future(user.start(), loop=cls.loop)
return user
return None
@@ -468,7 +313,7 @@ class User:
def init(context):
global config
- User.az, User.db, config, User.loop = context
+ config = context.config
users = [User.from_db(user) for user in DBUser.query.all()]
return [user.start(delete_unless_authenticated=True) for user in users]
diff --git a/setup.py b/setup.py
index 6f02919e..2b78675c 100644
--- a/setup.py
+++ b/setup.py
@@ -17,12 +17,12 @@ setuptools.setup(
install_requires=[
"aiohttp>=2.3.10,<3",
- "SQLAlchemy>=1.2.2,<2",
+ "SQLAlchemy>=1.2.3,<2",
"alembic>=0.9.7",
"Markdown>=2.6.11,<3",
"ruamel.yaml>=0.15.35,<0.16",
"Pillow>=5.0.0,<6",
- "future-fstrings>=0.4.1",
+ "future-fstrings>=0.4.2",
"python-magic>=0.4.15,<0.5",
],
dependency_links=[