diff --git a/ROADMAP.md b/ROADMAP.md
index 3bec6ef4..89b1763d 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -77,17 +77,19 @@
* [ ] Option to use bot to relay messages for unauthenticated Matrix users
* [ ] Option to use own Matrix account for messages sent from other Telegram clients
* [Commands](https://github.com/tulir/mautrix-telegram/wiki/Management-commands)
- * [x] Logging in and out (`login` + code entering, `logout`)
+ * [x] Logging in and out (`login` + code entering)
+ * [x] Logging out
* [ ] Registering (`register`)
* [x] Searching for users (`search`)
- * [ ] Searching contacts locally
* [x] Starting private chats (`pm`)
* [x] Joining chats with invite links (`join`)
* [x] Creating a Telegram chat for an existing Matrix room (`create`)
* [x] Upgrading the chat of a portal room into a supergroup (`upgrade`)
* [x] Change username of supergroup/channel (`groupname`)
* [x] Getting the Telegram invite link to a Matrix room (`invitelink`)
- * [x] Clean up and forget a portal room (`deleteportal`)
+ * Bridge administration
+ * [x] Clean up and forget a portal room (`deleteportal`)
+ * [ ] Setting Matrix-only power levels (`powerlevel`)
† Information not automatically sent from source, i.e. implementation may not be possible
‡ Maybe, i.e. this feature may or may not be implemented at some point
diff --git a/alembic.ini b/alembic.ini
new file mode 100644
index 00000000..be00fc0b
--- /dev/null
+++ b/alembic.ini
@@ -0,0 +1,74 @@
+# A generic, single database configuration.
+
+[alembic]
+# path to migration scripts
+script_location = alembic
+
+# template used to generate migration files
+# file_template = %%(rev)s_%%(slug)s
+
+# timezone to use when rendering the date
+# within the migration file as well as the filename.
+# string value is passed to dateutil.tz.gettz()
+# leave blank for localtime
+# timezone =
+
+# max length of characters to apply to the
+# "slug" field
+#truncate_slug_length = 40
+
+# set to 'true' to run the environment during
+# the 'revision' command, regardless of autogenerate
+# revision_environment = false
+
+# set to 'true' to allow .pyc and .pyo files without
+# a source .py file to be detected as revisions in the
+# versions/ directory
+# sourceless = false
+
+# version location specification; this defaults
+# to alembic/versions. When using multiple version
+# directories, initial revisions must be specified with --version-path
+# version_locations = %(here)s/bar %(here)s/bat alembic/versions
+
+# the output encoding used when revision files
+# are written from script.py.mako
+# output_encoding = utf-8
+
+sqlalchemy.url = sqlite:///mautrix-telegram.db
+
+
+# Logging configuration
+[loggers]
+keys = root,sqlalchemy,alembic
+
+[handlers]
+keys = console
+
+[formatters]
+keys = generic
+
+[logger_root]
+level = WARN
+handlers = console
+qualname =
+
+[logger_sqlalchemy]
+level = WARN
+handlers =
+qualname = sqlalchemy.engine
+
+[logger_alembic]
+level = INFO
+handlers =
+qualname = alembic
+
+[handler_console]
+class = StreamHandler
+args = (sys.stderr,)
+level = NOTSET
+formatter = generic
+
+[formatter_generic]
+format = %(levelname)-5.5s [%(name)s] %(message)s
+datefmt = %H:%M:%S
diff --git a/alembic/README b/alembic/README
new file mode 100644
index 00000000..98e4f9c4
--- /dev/null
+++ b/alembic/README
@@ -0,0 +1 @@
+Generic single-database configuration.
\ No newline at end of file
diff --git a/alembic/env.py b/alembic/env.py
new file mode 100644
index 00000000..ea7af9f1
--- /dev/null
+++ b/alembic/env.py
@@ -0,0 +1,77 @@
+from __future__ import with_statement
+from alembic import context
+from sqlalchemy import engine_from_config, pool
+from logging.config import fileConfig
+
+import sys
+from os.path import abspath, dirname
+sys.path.insert(0, dirname(dirname(abspath(__file__))))
+
+from mautrix_telegram.base import Base
+import mautrix_telegram.db
+
+# this is the Alembic Config object, which provides
+# access to the values within the .ini file in use.
+config = context.config
+
+# Interpret the config file for Python logging.
+# This line sets up loggers basically.
+fileConfig(config.config_file_name)
+
+# add your model's MetaData object here
+# for 'autogenerate' support
+# from myapp import mymodel
+# target_metadata = mymodel.Base.metadata
+target_metadata = Base.metadata
+
+# other values from the config, defined by the needs of env.py,
+# can be acquired:
+# my_important_option = config.get_main_option("my_important_option")
+# ... etc.
+
+
+def run_migrations_offline():
+ """Run migrations in 'offline' mode.
+
+ This configures the context with just a URL
+ and not an Engine, though an Engine is acceptable
+ here as well. By skipping the Engine creation
+ we don't even need a DBAPI to be available.
+
+ Calls to context.execute() here emit the given string to the
+ script output.
+
+ """
+ url = config.get_main_option("sqlalchemy.url")
+ context.configure(
+ url=url, target_metadata=target_metadata, literal_binds=True)
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+
+def run_migrations_online():
+ """Run migrations in 'online' mode.
+
+ In this scenario we need to create an Engine
+ and associate a connection with the context.
+
+ """
+ connectable = engine_from_config(
+ config.get_section(config.config_ini_section),
+ prefix='sqlalchemy.',
+ poolclass=pool.NullPool)
+
+ with connectable.connect() as connection:
+ context.configure(
+ connection=connection,
+ target_metadata=target_metadata
+ )
+
+ with context.begin_transaction():
+ context.run_migrations()
+
+if context.is_offline_mode():
+ run_migrations_offline()
+else:
+ run_migrations_online()
diff --git a/alembic/script.py.mako b/alembic/script.py.mako
new file mode 100644
index 00000000..2c015630
--- /dev/null
+++ b/alembic/script.py.mako
@@ -0,0 +1,24 @@
+"""${message}
+
+Revision ID: ${up_revision}
+Revises: ${down_revision | comma,n}
+Create Date: ${create_date}
+
+"""
+from alembic import op
+import sqlalchemy as sa
+${imports if imports else ""}
+
+# revision identifiers, used by Alembic.
+revision = ${repr(up_revision)}
+down_revision = ${repr(down_revision)}
+branch_labels = ${repr(branch_labels)}
+depends_on = ${repr(depends_on)}
+
+
+def upgrade():
+ ${upgrades if upgrades else "pass"}
+
+
+def downgrade():
+ ${downgrades if downgrades else "pass"}
diff --git a/alembic/versions/97d2a942bcf8_initial_revision.py b/alembic/versions/97d2a942bcf8_initial_revision.py
new file mode 100644
index 00000000..449f7f54
--- /dev/null
+++ b/alembic/versions/97d2a942bcf8_initial_revision.py
@@ -0,0 +1,28 @@
+"""initial revision
+
+Revision ID: 97d2a942bcf8
+Revises:
+Create Date: 2018-02-11 18:40:55.483842
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '97d2a942bcf8'
+down_revision = None
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ pass
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ pass
+ # ### end Alembic commands ###
diff --git a/example-config.yaml b/example-config.yaml
index 31b65a59..1aa2a508 100644
--- a/example-config.yaml
+++ b/example-config.yaml
@@ -64,6 +64,9 @@ bridge:
# If native replies are disabled, should the custom replies contain a link to the message being
# replied to?
link_in_reply: False
+ # Show message editing as a reply to the original message.
+ # If this is false, message edits are not shown at all, as Matrix does not support editing yet.
+ edits_as_replies: False
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg"
@@ -71,13 +74,13 @@ bridge:
# Whitelist of user IDs that are allowed to use this bridge. Leave empty to disable.
# You can enter a domain without the localpart to allow all users from that homeserver to use the bridge.
whitelist:
- - "internal-hs.example.com"
+ - "internal.example.com"
- "@user:public.example.com"
# Admins can do things like delete portal rooms. Here you must specify the exact MXID, domains
# are not accepted.
admins:
- - "@admin:internal-hs.example.com"
+ - "@admin:internal.example.com"
# Telegram config
telegram:
diff --git a/mautrix_appservice/appservice.py b/mautrix_appservice/appservice.py
index 1d26fbb2..7d25c4d7 100644
--- a/mautrix_appservice/appservice.py
+++ b/mautrix_appservice/appservice.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# matrix-appservice-python - A Matrix Application Service framework written in Python.
# Copyright (C) 2018 Tulir Asokan
#
diff --git a/mautrix_appservice/errors.py b/mautrix_appservice/errors.py
index 8c09936f..b0834283 100644
--- a/mautrix_appservice/errors.py
+++ b/mautrix_appservice/errors.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
diff --git a/mautrix_appservice/intent_api.py b/mautrix_appservice/intent_api.py
index 480e939e..49bf5e41 100644
--- a/mautrix_appservice/intent_api.py
+++ b/mautrix_appservice/intent_api.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
diff --git a/mautrix_appservice/state_store.py b/mautrix_appservice/state_store.py
index 1174dcde..5ce32aa1 100644
--- a/mautrix_appservice/state_store.py
+++ b/mautrix_appservice/state_store.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# matrix-appservice-python - A Matrix Application Service framework written in Python.
# Copyright (C) 2018 Tulir Asokan
#
diff --git a/mautrix_telegram/__main__.py b/mautrix_telegram/__main__.py
index a5415f5d..8439dfb0 100644
--- a/mautrix_telegram/__main__.py
+++ b/mautrix_telegram/__main__.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
diff --git a/mautrix_telegram/commands.py b/mautrix_telegram/commands.py
deleted file mode 100644
index f3c72f13..00000000
--- a/mautrix_telegram/commands.py
+++ /dev/null
@@ -1,515 +0,0 @@
-# -*- 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 .
-import markdown
-import logging
-import asyncio
-
-from mautrix_appservice import MatrixRequestError
-
-from telethon.errors import *
-from telethon.tl.types import *
-from telethon.tl.functions.contacts import SearchRequest
-from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest
-from telethon.tl.functions.channels import JoinChannelRequest
-
-from . import puppet as pu, portal as po
-
-command_handlers = {}
-
-
-def command_handler(func):
- command_handlers[func.__name__] = func
- return func
-
-
-def format_duration(seconds):
- def pluralize(count, singular): return singular if count == 1 else singular + "s"
-
- def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else ""
-
- minutes, seconds = divmod(seconds, 60)
- hours, minutes = divmod(minutes, 60)
- days, hours = divmod(hours, 24)
- parts = [a for a in [
- include(days, "day"),
- include(hours, "hour"),
- include(minutes, "minute"),
- include(seconds, "second")] if a]
- if len(parts) > 2:
- return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
- return " and ".join(parts)
-
-
-class CommandEvent:
- def __init__(self, az, command_prefix, room, sender, args, is_management, is_portal):
- self.az = az
- self.command_prefix = command_prefix
- self.room_id = room
- self.sender = sender
- self.args = args
- self.is_management = is_management
- self.is_portal = is_portal
-
- def reply(self, message, allow_html=False, render_markdown=True):
- if not self.room_id:
- raise AttributeError("the reply function can only be used from within"
- "the `CommandHandler.run` context manager")
-
- message = message.replace("$cmdprefix+sp ",
- "" if self.is_management else f"{self.command_prefix} ")
- message = message.replace("$cmdprefix", self.command_prefix)
- html = None
- if render_markdown:
- html = markdown.markdown(message, safe_mode="escape" if allow_html else False)
- elif allow_html:
- html = message
- return self.az.intent.send_notice(self.room_id, message, html=html)
-
-
-class CommandHandler:
- log = logging.getLogger("mau.commands")
-
- def __init__(self, context):
- self.az, self.db, self.config, self.loop = context
- self.command_prefix = self.config["bridge.command_prefix"]
-
- # region Utility functions for handling commands
-
- async def handle(self, room, sender, command, args, is_management, is_portal):
- evt = CommandEvent(self.az, self.command_prefix, room, sender, args, is_management,
- is_portal)
- command = command.lower()
- try:
- command = command_handlers[command]
- except KeyError:
- if sender.command_status and "next" in sender.command_status:
- args.insert(0, command)
- command = sender.command_status["next"]
- else:
- command = command_handlers["unknown_command"]
- try:
- await command(self, evt)
- 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"'$cmdprefix {command} {''.join(args)}' from {sender.mxid}")
- return evt.reply("Fatal error while handling command. Check logs for more details.")
-
- # endregion
- # region Command handlers
-
- @command_handler
- async def ping(self, evt):
- if not evt.sender.logged_in:
- return await evt.reply("You're not logged in.")
- me = await evt.sender.client.get_me()
- if me:
- return await evt.reply(f"You're logged in as @{me.username}")
- else:
- return await evt.reply("You're not logged in.")
-
- # region Authentication commands
- @command_handler
- def register(self, evt):
- return evt.reply("Not yet implemented.")
-
- @command_handler
- async def login(self, evt):
- if not evt.is_management:
- return await evt.reply(
- "`login` is a restricted command: you may only run it in management rooms.")
- elif evt.sender.logged_in:
- return await evt.reply("You are already logged in.")
- elif len(evt.args) == 0:
- return await evt.reply("**Usage:** `$cmdprefix+sp login `")
- phone_number = evt.args[0]
- await evt.sender.client.sign_in(phone_number)
- evt.sender.command_status = {
- "next": command_handlers["enter_code"],
- "action": "Login",
- }
- return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
-
- @command_handler
- async def enter_code(self, evt):
- if not evt.sender.command_status:
- return await evt.reply(
- "Request a login code first with `$cmdprefix+sp login `")
- elif len(evt.args) == 0:
- return await evt.reply("**Usage:** `$cmdprefix+sp enter_code `")
-
- try:
- user = await evt.sender.client.sign_in(code=evt.args[0])
- asyncio.ensure_future(evt.sender.post_login(user), loop=self.loop)
- evt.sender.command_status = None
- return await evt.reply(f"Successfully logged in as @{user.username}")
- except PhoneNumberUnoccupiedError:
- return await evt.reply("That phone number has not been registered."
- "Please register with `$cmdprefix+sp register `.")
- except PhoneCodeExpiredError:
- return await evt.reply(
- "Phone code expired. Try again with `$cmdprefix+sp login `.")
- except PhoneCodeInvalidError:
- return await evt.reply("Invalid phone code.")
- except PhoneNumberAppSignupForbiddenError:
- return await evt.reply(
- "Your phone number does not allow 3rd party apps to sign in.")
- except PhoneNumberFloodError:
- return await evt.reply(
- "Your phone number has been temporarily blocked for flooding. "
- "The block is usually applied for around a day.")
- except PhoneNumberBannedError:
- return await evt.reply("Your phone number has been banned from Telegram.")
- except SessionPasswordNeededError:
- evt.sender.command_status = {
- "next": command_handlers["enter_password"],
- "action": "Login (password entry)",
- }
- return await evt.reply("Your account has two-factor authentication."
- "Please send your password here.")
- except Exception:
- self.log.exception("Error sending phone code")
- return await evt.reply("Unhandled exception while sending code."
- "Check console for more details.")
-
- @command_handler
- async def enter_password(self, evt):
- if not evt.sender.command_status:
- return await evt.reply(
- "Request a login code first with `$cmdprefix+sp login `")
- elif len(evt.args) == 0:
- return await evt.reply("**Usage:** `$cmdprefix+sp enter_password `")
-
- try:
- user = await evt.sender.client.sign_in(password=evt.args[0])
- asyncio.ensure_future(evt.sender.post_login(user), loop=self.loop)
- evt.sender.command_status = None
- return await evt.reply(f"Successfully logged in as @{user.username}")
- except PasswordHashInvalidError:
- return await evt.reply("Incorrect password.")
- except Exception:
- self.log.exception("Error sending password")
- return await evt.reply("Unhandled exception while sending password. "
- "Check console for more details.")
-
- @command_handler
- async def logout(self, evt):
- if not evt.sender.logged_in:
- return await evt.reply("You're not logged in.")
- if await evt.sender.log_out():
- return await evt.reply("Logged out successfully.")
- return await evt.reply("Failed to log out.")
-
- # endregion
- # region Telegram interaction commands
-
- @command_handler
- async def search(self, evt):
- if len(evt.args) == 0:
- return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] `")
- elif not evt.sender.logged_in:
- return await evt.reply("This command requires you to be logged in.")
- # force_remote = False
- if evt.args[0] in {"-r", "--remote"}:
- # force_remote = True
- evt.args.pop(0)
- query = " ".join(evt.args)
- if len(query) < 5:
- return await evt.reply("Minimum length of query for remote search is 5 characters.")
- found = await evt.sender.client(SearchRequest(q=query, limit=10))
-
- # reply = ["**People:**", ""]
- reply = ["**Results from Telegram server:**", ""]
- for result in found.users:
- puppet = pu.Puppet.get(result.id)
- await puppet.update_info(evt.sender, result)
- reply.append(
- f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): {puppet.id}")
- # reply.extend(("", "**Chats:**", ""))
- # for result in found.chats:
- # reply.append(f"* {result.title}")
- return await evt.reply("\n".join(reply))
-
- @command_handler
- async def pm(self, evt):
- if len(evt.args) == 0:
- return await evt.reply("**Usage:** `$cmdprefix+sp pm `")
- elif not evt.sender.logged_in:
- return await evt.reply("This command requires you to be logged in.")
-
- user = await evt.sender.client.get_entity(evt.args[0])
- if not user:
- return await evt.reply("User not found.")
- elif not isinstance(user, User):
- 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)}")
-
- @command_handler
- async def invitelink(self, evt):
- if not evt.sender.logged_in:
- return await evt.reply("This command requires you to be logged in.")
-
- portal = po.Portal.get_by_mxid(evt.room_id)
- if not portal:
- return await evt.reply("This is not a portal room.")
-
- if portal.peer_type == "user":
- return await evt.reply("You can't invite users to private chats.")
-
- try:
- link = await portal.get_invite_link(evt.sender)
- return await evt.reply(f"Invite link to {portal.title}: {link}")
- except ValueError as e:
- return await evt.reply(e.args[0])
- except ChatAdminRequiredError:
- return await evt.reply("You don't have the permission to create an invite link.")
-
- @command_handler
- async def deleteportal(self, evt):
- if not evt.sender.logged_in:
- return await evt.reply("This command requires you to be logged in.")
- elif not evt.sender.is_admin:
- return await evt.reply("This is command requires administrator privileges.")
-
- portal = po.Portal.get_by_mxid(evt.room_id)
- if not portal:
- return await evt.reply("This is not a portal room.")
-
- for user in await portal.main_intent.get_room_members(portal.mxid):
- if user != portal.main_intent.mxid:
- try:
- await portal.main_intent.kick(portal.mxid, user, "Portal deleted.")
- except MatrixRequestError:
- pass
- await portal.main_intent.leave_room(portal.mxid)
- portal.delete()
-
- @staticmethod
- def _strip_prefix(value, prefixes):
- for prefix in prefixes:
- if value.startswith(prefix):
- return value[len(prefix):]
- return value
-
- @command_handler
- async def join(self, evt):
- if len(evt.args) == 0:
- return await evt.reply("**Usage:** `$cmdprefix+sp join `")
- elif not evt.sender.logged_in:
- return await evt.reply("This command requires you to be logged in.")
-
- regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
- arg = regex.match(evt.args[0])
- if not arg:
- return await evt.reply("That doesn't look like a Telegram invite link.")
- arg = arg.group(1)
- if arg.startswith("joinchat/"):
- invite_hash = arg[len("joinchat/"):]
- try:
- await evt.sender.client(CheckChatInviteRequest(invite_hash))
- except InviteHashInvalidError:
- return await evt.reply("Invalid invite link.")
- except InviteHashExpiredError:
- return await evt.reply("Invite link expired.")
- try:
- updates = evt.sender.client(ImportChatInviteRequest(invite_hash))
- except UserAlreadyParticipantError:
- return await evt.reply("You are already in that chat.")
- else:
- channel = await evt.sender.client.get_entity(arg)
- if not channel:
- return await evt.reply("Channel/supergroup not found.")
- updates = await evt.sender.client(JoinChannelRequest(channel))
- for chat in updates.chats:
- portal = po.Portal.get_by_entity(chat)
- if portal.mxid:
- await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
- return await evt.reply(f"Created room for {portal.title}")
- else:
- await portal.invite_matrix([evt.sender.mxid])
- return await evt.reply(f"Invited you to portal of {portal.title}")
-
- @command_handler
- async def create(self, evt):
- type = evt.args[0] if len(evt.args) > 0 else "group"
- if type not in {"chat", "group", "supergroup", "channel"}:
- return await evt.reply(
- "**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
- elif not evt.sender.logged_in:
- return await evt.reply("This command requires you to be logged in.")
-
- if po.Portal.get_by_mxid(evt.room_id):
- return await evt.reply("This is already a portal room.")
-
- state = await self.az.intent.get_room_state(evt.room_id)
- title = None
- about = None
- levels = None
- for event in state:
- if event["type"] == "m.room.name":
- title = event["content"]["name"]
- elif event["type"] == "m.room.topic":
- about = event["content"]["topic"]
- elif event["type"] == "m.room.power_levels":
- levels = event["content"]
- if not title:
- return await evt.reply("Please set a title before creating a Telegram chat.")
- elif (not levels or not levels["users"] or self.az.intent.mxid not in levels["users"] or
- levels["users"][self.az.intent.mxid] < 100):
- return await evt.reply(f"Please give "
- + f"[the bridge bot](https://matrix.to/#/{self.az.intent.mxid})"
- + f" a power level of 100 before creating a Telegram chat.")
- else:
- for user, level in levels["users"].items():
- if level >= 100 and user != self.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.")
-
- supergroup = type == "supergroup"
- type = {
- "supergroup": "channel",
- "channel": "channel",
- "chat": "chat",
- "group": "chat",
- }[type]
-
- portal = po.Portal(tgid=None, mxid=evt.room_id, title=title, about=about, peer_type=type)
- try:
- await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
- except ValueError as e:
- return await evt.reply(e.args[0])
- return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
-
- @command_handler
- async def upgrade(self, evt):
- if not evt.sender.logged_in:
- return await evt.reply("This command requires you to be logged in.")
-
- portal = po.Portal.get_by_mxid(evt.room_id)
- if not portal:
- return await evt.reply("This is not a portal room.")
- elif portal.peer_type == "channel":
- return await evt.reply("This is already a supergroup or a channel.")
- elif portal.peer_type == "user":
- return await evt.reply("You can't upgrade private chats.")
-
- try:
- await portal.upgrade_telegram_chat(evt.sender)
- return await evt.reply(f"Group upgraded to supergroup. New ID: {portal.tgid}")
- except ChatAdminRequiredError:
- return await evt.reply("You don't have the permission to upgrade this group.")
- except ValueError as e:
- return await evt.reply(e.args[0])
-
- @command_handler
- async def groupname(self, evt):
- if len(evt.args) == 0:
- return await evt.reply("**Usage:** `$cmdprefix+sp groupname `")
- if not evt.sender.logged_in:
- return await evt.reply("This command requires you to be logged in.")
-
- portal = po.Portal.get_by_mxid(evt.room_id)
- if not portal:
- return await evt.reply("This is not a portal room.")
- elif portal.peer_type != "channel":
- return await evt.reply("Only channels and supergroups have usernames.")
-
- try:
- await portal.set_telegram_username(evt.sender,
- evt.args[0] if evt.args[0] != "-" else "")
- if portal.username:
- return await evt.reply(f"Username of channel changed to {portal.username}.")
- else:
- return await evt.reply(f"Channel is now private.")
- except ChatAdminRequiredError:
- return await evt.reply(
- "You don't have the permission to set the username of this channel.")
- except UsernameNotModifiedError:
- if portal.username:
- return await evt.reply("That is already the username of this channel.")
- else:
- return await evt.reply("This channel is already private")
- except UsernameOccupiedError:
- return await evt.reply("That username is already in use.")
- except UsernameInvalidError:
- return await evt.reply("Invalid username")
-
- # endregion
- # region Command-related commands
- @command_handler
- def cancel(self, evt):
- if evt.sender.command_status:
- action = evt.sender.command_status["action"]
- evt.sender.command_status = None
- return evt.reply(f"{action} cancelled.")
- else:
- return evt.reply("No ongoing command.")
-
- @command_handler
- def unknown_command(self, evt):
- return evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
-
- @command_handler
- def help(self, evt):
- if evt.is_management:
- management_status = ("This is a management room: prefixing commands "
- "with `$cmdprefix` is not required.\n")
- elif evt.is_portal:
- management_status = ("**This is a portal room**: you must always "
- "prefix commands with `$cmdprefix`.\n"
- "Management commands will not be sent to Telegram.")
- else:
- management_status = ("**This is not a management room**: you must "
- "prefix commands with `$cmdprefix`.\n")
- help = """\n
-#### Generic bridge commands
-**help** - Show this help message.
-**cancel** - Cancel an ongoing action (such as login).
-
-#### Authentication
-**login** <_phone_> - Request an authentication code.
-**logout** - Log out from Telegram.
-**ping** - Check if you're logged into Telegram.
-
-#### Initiating chats
-**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users.
-**pm** <_identifier_> - Open a private chat with the given Telegram user. The
- identifier is either the internal user ID, the username or
- the phone number.
-**join** <_link_> - Join a chat with an invite link.
-**create** [_type_] - Create a Telegram chat of the given type for the current
- Matrix room. The type is either `group`, `supergroup` or
- `channel` (defaults to `group`).
-
-#### Portal management
-**upgrade** - Upgrade a normal Telegram group to a supergroup.
-**invitelink** - Get a Telegram invite link to the current chat.
-**deleteportal** - Forget the current portal room. Only works for group chats; to delete
- a private chat portal, simply leave the room.
-**groupname** <_name_|`-`> - Change the username of a supergroup/channel. To disable, use a dash
- (`-`) as the name.
-"""
- return evt.reply(management_status + help)
-
- # endregion
- # endregion
diff --git a/mautrix_telegram/commands/__init__.py b/mautrix_telegram/commands/__init__.py
new file mode 100644
index 00000000..3e456c9b
--- /dev/null
+++ b/mautrix_telegram/commands/__init__.py
@@ -0,0 +1,2 @@
+from .handler import command_handler, CommandHandler
+from . import clean_rooms, auth, meta, telegram
diff --git a/mautrix_telegram/commands/auth.py b/mautrix_telegram/commands/auth.py
new file mode 100644
index 00000000..ceaeeca5
--- /dev/null
+++ b/mautrix_telegram/commands/auth.py
@@ -0,0 +1,118 @@
+# 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 .
+import asyncio
+
+from telethon.errors import *
+
+from . import command_handler
+
+
+@command_handler(needs_auth=False)
+async def ping(evt):
+ if not evt.sender.logged_in:
+ return await evt.reply("You're not logged in.")
+ me = await evt.sender.client.get_me()
+ if me:
+ return await evt.reply(f"You're logged in as @{me.username}")
+ else:
+ return await evt.reply("You're not logged in.")
+
+
+@command_handler(needs_auth=False, management_only=True)
+def register(evt):
+ return evt.reply("Not yet implemented.")
+
+
+@command_handler(needs_auth=False, management_only=True)
+async def login(evt):
+ if evt.sender.logged_in:
+ return await evt.reply("You are already logged in.")
+ elif len(evt.args) == 0:
+ return await evt.reply("**Usage:** `$cmdprefix+sp login `")
+ phone_number = evt.args[0]
+ await evt.sender.client.sign_in(phone_number)
+ evt.sender.command_status = {
+ "next": enter_code,
+ "action": "Login",
+ }
+ return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
+
+
+@command_handler(needs_auth=False)
+async def enter_code(evt):
+ if len(evt.args) == 0:
+ return await evt.reply("**Usage:** `$cmdprefix+sp enter-code `")
+
+ try:
+ user = await evt.sender.client.sign_in(code=evt.args[0])
+ asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
+ evt.sender.command_status = None
+ return await evt.reply(f"Successfully logged in as @{user.username}")
+ except PhoneNumberUnoccupiedError:
+ return await evt.reply("That phone number has not been registered."
+ "Please register with `$cmdprefix+sp register `.")
+ except PhoneCodeExpiredError:
+ return await evt.reply(
+ "Phone code expired. Try again with `$cmdprefix+sp login `.")
+ except PhoneCodeInvalidError:
+ return await evt.reply("Invalid phone code.")
+ except PhoneNumberAppSignupForbiddenError:
+ return await evt.reply(
+ "Your phone number does not allow 3rd party apps to sign in.")
+ except PhoneNumberFloodError:
+ return await evt.reply(
+ "Your phone number has been temporarily blocked for flooding. "
+ "The block is usually applied for around a day.")
+ except PhoneNumberBannedError:
+ return await evt.reply("Your phone number has been banned from Telegram.")
+ except SessionPasswordNeededError:
+ evt.sender.command_status = {
+ "next": enter_password,
+ "action": "Login (password entry)",
+ }
+ return await evt.reply("Your account has two-factor authentication."
+ "Please send your password here.")
+ except Exception:
+ evt.log.exception("Error sending phone code")
+ return await evt.reply("Unhandled exception while sending code."
+ "Check console for more details.")
+
+
+@command_handler(needs_auth=False)
+async def enter_password(evt):
+ if len(evt.args) == 0:
+ return await evt.reply("**Usage:** `$cmdprefix+sp enter-password `")
+
+ try:
+ user = await evt.sender.client.sign_in(password=evt.args[0])
+ asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop)
+ evt.sender.command_status = None
+ return await evt.reply(f"Successfully logged in as @{user.username}")
+ except PasswordHashInvalidError:
+ return await evt.reply("Incorrect password.")
+ except Exception:
+ evt.log.exception("Error sending password")
+ return await evt.reply("Unhandled exception while sending password. "
+ "Check console for more details.")
+
+
+@command_handler(needs_auth=False)
+async def logout(evt):
+ if not evt.sender.logged_in:
+ return await evt.reply("You're not logged in.")
+ if await evt.sender.log_out():
+ return await evt.reply("Logged out successfully.")
+ return await evt.reply("Failed to log out.")
diff --git a/mautrix_telegram/commands/clean_rooms.py b/mautrix_telegram/commands/clean_rooms.py
new file mode 100644
index 00000000..23ee4bf9
--- /dev/null
+++ b/mautrix_telegram/commands/clean_rooms.py
@@ -0,0 +1,169 @@
+# 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 .
+from mautrix_appservice import MatrixRequestError
+
+from . import command_handler
+from .. import puppet as pu, portal as po
+
+
+async def _find_rooms(intent):
+ management_rooms = []
+ unidentified_rooms = []
+ portals = []
+ empty_portals = []
+
+ rooms = await intent.get_joined_rooms()
+ for room in rooms:
+ portal = po.Portal.get_by_mxid(room)
+ if not portal:
+ try:
+ members = await intent.get_room_members(room)
+ except MatrixRequestError:
+ members = []
+ if len(members) == 2:
+ other_member = members[0] if members[0] != intent.mxid else members[1]
+ if pu.Puppet.get_id_from_mxid(other_member):
+ unidentified_rooms.append(room)
+ else:
+ management_rooms.append((room, other_member))
+ else:
+ unidentified_rooms.append(room)
+ else:
+ members = await portal.get_authenticated_matrix_users()
+ if len(members) == 0:
+ empty_portals.append(portal)
+ else:
+ portals.append(portal)
+
+ return management_rooms, unidentified_rooms, portals, empty_portals
+
+
+@command_handler(needs_admin=True, name="clean-rooms")
+async def clean_rooms(evt):
+ if not evt.is_management:
+ return await evt.reply("`clean-rooms` is a particularly spammy command. Please don't "
+ "run it in non-management rooms.")
+
+ management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent)
+
+ reply = ["#### Management rooms (M)"]
+ reply += ([f"{n+1}. [M{n+1}](https://matrix.to/#/{room}) (with {other_member}"
+ for n, (room, other_member) in enumerate(management_rooms)]
+ or ["No management rooms found."])
+ reply.append("#### Active portal rooms (A)")
+ reply += ([f"{n+1}. [P{n+1}](https://matrix.to/#/{portal.mxid}) "
+ + f"(to Telegram chat \"{portal.title}\")"
+ for n, portal in enumerate(portals)]
+ or ["No active portal rooms found."])
+ reply.append("#### Unidentified rooms (U)")
+ reply += ([f"{n+1}. [U{n+1}](https://matrix.to/#/{room})"
+ for n, room in enumerate(unidentified_rooms)]
+ or ["No unidentified rooms found."])
+ reply.append("#### Inactive portal rooms (I)")
+ reply += ([f"{n}. [E{n}](https://matrix.to/#/{portal.mxid}) "
+ + f"(to Telegram chat \"{portal.title}\")"
+ for n, portal in enumerate(empty_portals)]
+ or ["No inactive portal rooms found."])
+
+ reply += ["#### Usage",
+ ("To clean the recommended set of rooms (unidentified & inactive portals), "
+ "type `$cmdprefix+sp clean-recommended`"),
+ "",
+ ("To clean other groups of rooms, type `$cmdprefix+sp clean-groups ` "
+ "where `letters` are the first letters of the group names (M, A, U, I)"),
+ "",
+ ("To clean specific rooms, type `$cmdprefix+sp clean-range ` "
+ "where `range` is the range (e.g. `5-21`) prefixed with the first letter of"
+ "the group name."),
+ "",
+ ("Please note that you will have to re-run `$cmdprefix+sp cleanrooms` "
+ "between each use of the commands above.")]
+
+ evt.sender.command_status = {
+ "next": lambda clean_evt: set_rooms_to_clean(clean_evt, management_rooms,
+ unidentified_rooms, portals, empty_portals),
+ "action": "Room cleaning",
+ }
+
+ return await evt.reply("\n".join(reply))
+
+
+async def set_rooms_to_clean(evt, management_rooms, unidentified_rooms, portals, empty_portals):
+ command = evt.args[0]
+ rooms_to_clean = []
+ if command == "clean-recommended":
+ rooms_to_clean = empty_portals + unidentified_rooms
+ elif command == "clean-groups":
+ if len(evt.args) < 2:
+ return await evt.reply("**Usage:** `$cmdprefix+sp clean-groups [M][A][U][I]")
+ groups_to_clean = evt.args[1]
+ if "M" in groups_to_clean:
+ rooms_to_clean += management_rooms
+ if "A" in groups_to_clean:
+ rooms_to_clean += portals
+ if "U" in groups_to_clean:
+ rooms_to_clean += unidentified_rooms
+ if "I" in groups_to_clean:
+ rooms_to_clean += empty_portals
+ elif command == "clean-range":
+ try:
+ range = evt.args[1]
+ group, range = range[0], range[1:]
+ start, end = range.split("-")
+ start, end = int(start), int(end)
+ if group == "M":
+ group = management_rooms
+ elif group == "A":
+ group = portals
+ elif group == "U":
+ group = unidentified_rooms
+ elif group == "I":
+ group = empty_portals
+ else:
+ raise ValueError("Unknown group")
+ rooms_to_clean = group[start - 1:end]
+ except (KeyError, ValueError):
+ return await evt.reply(
+ "**Usage:** `$cmdprefix+sp clean-groups <_M|A|U|I_>")
+ else:
+ return await evt.reply(f"Unknown room cleaning action `{command}`. "
+ + "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`.")
+
+
+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.")
+ cleaned = 0
+ for room in rooms_to_clean:
+ if isinstance(room, po.Portal):
+ await room.cleanup_and_delete()
+ cleaned += 1
+ elif isinstance(room, str):
+ await po.Portal.cleanup_room(evt.az.intent, room, type="Room")
+ cleaned += 1
+ evt.sender.command_status = None
+ await evt.reply(f"{cleaned} rooms cleaned up successfully.")
+ else:
+ await evt.reply("Room cleaning cancelled.")
diff --git a/mautrix_telegram/commands/handler.py b/mautrix_telegram/commands/handler.py
new file mode 100644
index 00000000..798d9c2b
--- /dev/null
+++ b/mautrix_telegram/commands/handler.py
@@ -0,0 +1,114 @@
+# 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 .
+import markdown
+import logging
+
+from telethon.errors import FloodWaitError
+
+command_handlers = {}
+
+
+def command_handler(needs_auth=True, management_only=False, needs_admin=False, name=None):
+ def decorator(func):
+ 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.")
+ 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:
+ return evt.reply("This is command requires administrator privileges.")
+ return func(evt)
+
+ command_handlers[name or func.__name__.replace("_", "-")] = wrapper
+ return wrapper
+
+ return decorator
+
+
+class CommandEvent:
+ def __init__(self, handler, room, sender, command, args, is_management, is_portal):
+ self.az = handler.az
+ self.log = handler.log
+ self.loop = handler.loop
+ self.command_prefix = handler.command_prefix
+ self.room_id = room
+ self.sender = sender
+ self.command = command
+ self.args = args
+ self.is_management = is_management
+ self.is_portal = is_portal
+
+ def reply(self, message, allow_html=False, render_markdown=True):
+ message = message.replace("$cmdprefix+sp ",
+ "" if self.is_management else f"{self.command_prefix} ")
+ message = message.replace("$cmdprefix", self.command_prefix)
+ html = None
+ if render_markdown:
+ html = markdown.markdown(message, safe_mode="escape" if allow_html else False)
+ elif allow_html:
+ html = message
+ return self.az.intent.send_notice(self.room_id, message, html=html)
+
+
+def format_duration(seconds):
+ def pluralize(count, singular): return singular if count == 1 else singular + "s"
+
+ def include(count, word): return f"{count} {pluralize(count, word)}" if count > 0 else ""
+
+ minutes, seconds = divmod(seconds, 60)
+ hours, minutes = divmod(minutes, 60)
+ days, hours = divmod(hours, 24)
+ parts = [a for a in [
+ include(days, "day"),
+ include(hours, "hour"),
+ include(minutes, "minute"),
+ include(seconds, "second")] if a]
+ if len(parts) > 2:
+ return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
+ return " and ".join(parts)
+
+
+class CommandHandler:
+ log = logging.getLogger("mau.commands")
+
+ def __init__(self, context):
+ self.az, self.db, self.config, self.loop = context
+ self.command_prefix = self.config["bridge.command_prefix"]
+
+ # region Utility functions for handling commands
+
+ async def handle(self, room, sender, command, args, is_management, is_portal):
+ evt = CommandEvent(self, room, sender, command, args,
+ is_management, is_portal)
+ command = command.lower()
+ try:
+ command = command_handlers[command]
+ except KeyError:
+ if sender.command_status and "next" in sender.command_status:
+ args.insert(0, command)
+ evt.command = ""
+ command = sender.command_status["next"]
+ else:
+ command = command_handlers["unknown_command"]
+ try:
+ await command(evt)
+ 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}")
+ 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
new file mode 100644
index 00000000..2fb310f7
--- /dev/null
+++ b/mautrix_telegram/commands/meta.py
@@ -0,0 +1,75 @@
+# 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 .
+from . import command_handler
+
+
+@command_handler()
+def cancel(evt):
+ if evt.sender.command_status:
+ action = evt.sender.command_status["action"]
+ evt.sender.command_status = None
+ return evt.reply(f"{action} cancelled.")
+ else:
+ return evt.reply("No ongoing command.")
+
+
+@command_handler()
+def unknown_command(evt):
+ return evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
+
+
+@command_handler()
+def help(evt):
+ if evt.is_management:
+ management_status = ("This is a management room: prefixing commands "
+ "with `$cmdprefix` is not required.\n")
+ elif evt.is_portal:
+ management_status = ("**This is a portal room**: you must always "
+ "prefix commands with `$cmdprefix`.\n"
+ "Management commands will not be sent to Telegram.")
+ else:
+ management_status = ("**This is not a management room**: you must "
+ "prefix commands with `$cmdprefix`.\n")
+ help = """\n
+#### Generic bridge commands
+**help** - Show this help message.
+**cancel** - Cancel an ongoing action (such as login).
+
+#### Authentication
+**login** <_phone_> - Request an authentication code.
+**logout** - Log out from Telegram.
+**ping** - Check if you're logged into Telegram.
+
+#### Initiating chats
+**search** [_-r|--remote_] <_query_> - Search your contacts or the Telegram servers for users.
+**pm** <_identifier_> - Open a private chat with the given Telegram user. The
+ identifier is either the internal user ID, the username or
+ the phone number.
+**join** <_link_> - Join a chat with an invite link.
+**create** [_type_] - Create a Telegram chat of the given type for the current
+ Matrix room. The type is either `group`, `supergroup` or
+ `channel` (defaults to `group`).
+
+#### Portal management
+**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
+ a private chat portal, simply leave the room.
+**group-name** <_name_|`-`> - Change the username of a supergroup/channel. To disable, use a dash
+ (`-`) as the name.
+**clean-rooms** - Clean up unused portal/management rooms.
+"""
+ return evt.reply(management_status + help)
diff --git a/mautrix_telegram/commands/telegram.py b/mautrix_telegram/commands/telegram.py
new file mode 100644
index 00000000..6b157d63
--- /dev/null
+++ b/mautrix_telegram/commands/telegram.py
@@ -0,0 +1,260 @@
+# 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 .
+from telethon.errors import *
+from telethon.tl.types import User as TLUser
+from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest
+from telethon.tl.functions.channels import JoinChannelRequest
+
+from .. import puppet as pu, portal as po
+from . import command_handler
+
+
+@command_handler()
+async def search(evt):
+ if len(evt.args) == 0:
+ return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] `")
+
+ force_remote = False
+ if evt.args[0] in {"-r", "--remote"}:
+ force_remote = True
+ evt.args.pop(0)
+
+ query = " ".join(evt.args)
+ if force_remote and len(query) < 5:
+ return await evt.reply("Minimum length of query for remote search is 5 characters.")
+
+ results, remote = await evt.sender.search(query, force_remote)
+
+ if not results:
+ if len(query) < 5 and remote:
+ return await evt.reply("No local results. "
+ "Minimum length of remote query is 5 characters.")
+ return await evt.reply("No results 3:")
+
+ reply = []
+ if remote:
+ reply += ["**Results from Telegram server:**", ""]
+ else:
+ reply += ["**Results in contacts:**", ""]
+ reply += [(f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
+ + f"{puppet.id} ({similarity}% match)")
+ for puppet, similarity in results]
+
+ # TODO somehow show remote channel results when joining by alias is possible?
+
+ return await evt.reply("\n".join(reply))
+
+
+@command_handler()
+async def pm(evt):
+ if len(evt.args) == 0:
+ return await evt.reply("**Usage:** `$cmdprefix+sp pm `")
+
+ user = await evt.sender.client.get_entity(evt.args[0])
+ if not user:
+ return await evt.reply("User not found.")
+ elif not isinstance(user, TLUser):
+ return await evt.reply("That doesn't seem to be a user.")
+ portal = 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)}")
+
+
+@command_handler()
+async def invite_link(evt):
+ portal = po.Portal.get_by_mxid(evt.room_id)
+ if not portal:
+ return await evt.reply("This is not a portal room.")
+
+ if portal.peer_type == "user":
+ return await evt.reply("You can't invite users to private chats.")
+
+ try:
+ link = await portal.get_invite_link(evt.sender)
+ return await evt.reply(f"Invite link to {portal.title}: {link}")
+ except ValueError as e:
+ return await evt.reply(e.args[0])
+ except ChatAdminRequiredError:
+ return await evt.reply("You don't have the permission to create an invite link.")
+
+
+@command_handler(needs_admin=True)
+async def delete_portal(evt):
+ room_id = evt.args[0] if len(evt.args) > 0 else evt.room_id
+
+ portal = po.Portal.get_by_mxid(room_id)
+ if not portal:
+ that_this = "This" if room_id == evt.room_id else "That"
+ return await evt.reply(f"{that_this} is not a portal room.")
+
+ async def post_confirm(_, confirm):
+ evt.sender.command_status = None
+ if len(confirm.args) > 0 and confirm.args[0] == "confirm-delete":
+ await portal.cleanup_and_delete()
+ if confirm.room_id != room_id:
+ return await confirm.reply("Portal successfully deleted.")
+ else:
+ return await confirm.reply("Portal deletion cancelled.")
+
+ evt.sender.command_status = {
+ "next": post_confirm,
+ "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`")
+
+
+@command_handler()
+async def join(evt):
+ if len(evt.args) == 0:
+ return await evt.reply("**Usage:** `$cmdprefix+sp join `")
+
+ regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
+ arg = regex.match(evt.args[0])
+ if not arg:
+ return await evt.reply("That doesn't look like a Telegram invite link.")
+ arg = arg.group(1)
+ if arg.startswith("joinchat/"):
+ invite_hash = arg[len("joinchat/"):]
+ try:
+ await evt.sender.client(CheckChatInviteRequest(invite_hash))
+ except InviteHashInvalidError:
+ return await evt.reply("Invalid invite link.")
+ except InviteHashExpiredError:
+ return await evt.reply("Invite link expired.")
+ try:
+ updates = evt.sender.client(ImportChatInviteRequest(invite_hash))
+ except UserAlreadyParticipantError:
+ return await evt.reply("You are already in that chat.")
+ else:
+ channel = await evt.sender.client.get_entity(arg)
+ if not channel:
+ return await evt.reply("Channel/supergroup not found.")
+ updates = await evt.sender.client(JoinChannelRequest(channel))
+ for chat in updates.chats:
+ portal = po.Portal.get_by_entity(chat)
+ if portal.mxid:
+ await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
+ return await evt.reply(f"Created room for {portal.title}")
+ else:
+ await portal.invite_matrix([evt.sender.mxid])
+ return await evt.reply(f"Invited you to portal of {portal.title}")
+
+
+@command_handler()
+async def create(evt):
+ type = evt.args[0] if len(evt.args) > 0 else "group"
+ if type not in {"chat", "group", "supergroup", "channel"}:
+ return await evt.reply(
+ "**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
+
+ if po.Portal.get_by_mxid(evt.room_id):
+ return await evt.reply("This is already a portal room.")
+
+ state = await evt.az.intent.get_room_state(evt.room_id)
+ title = None
+ about = None
+ levels = None
+ for event in state:
+ if event["type"] == "m.room.name":
+ title = event["content"]["name"]
+ elif event["type"] == "m.room.topic":
+ about = event["content"]["topic"]
+ elif event["type"] == "m.room.power_levels":
+ levels = event["content"]
+ if not title:
+ 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.")
+ 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.")
+
+ supergroup = type == "supergroup"
+ type = {
+ "supergroup": "channel",
+ "channel": "channel",
+ "chat": "chat",
+ "group": "chat",
+ }[type]
+
+ portal = po.Portal(tgid=None, mxid=evt.room_id, title=title, about=about, peer_type=type)
+ try:
+ await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
+ except ValueError as e:
+ return await evt.reply(e.args[0])
+ return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
+
+
+@command_handler()
+async def upgrade(evt):
+ portal = po.Portal.get_by_mxid(evt.room_id)
+ if not portal:
+ return await evt.reply("This is not a portal room.")
+ elif portal.peer_type == "channel":
+ return await evt.reply("This is already a supergroup or a channel.")
+ elif portal.peer_type == "user":
+ return await evt.reply("You can't upgrade private chats.")
+
+ try:
+ await portal.upgrade_telegram_chat(evt.sender)
+ return await evt.reply(f"Group upgraded to supergroup. New ID: {portal.tgid}")
+ except ChatAdminRequiredError:
+ return await evt.reply("You don't have the permission to upgrade this group.")
+ except ValueError as e:
+ return await evt.reply(e.args[0])
+
+
+@command_handler()
+async def group_name(evt):
+ if len(evt.args) == 0:
+ return await evt.reply("**Usage:** `$cmdprefix+sp group-name `")
+
+ portal = po.Portal.get_by_mxid(evt.room_id)
+ if not portal:
+ return await evt.reply("This is not a portal room.")
+ elif portal.peer_type != "channel":
+ return await evt.reply("Only channels and supergroups have usernames.")
+
+ try:
+ await portal.set_telegram_username(evt.sender,
+ evt.args[0] if evt.args[0] != "-" else "")
+ if portal.username:
+ return await evt.reply(f"Username of channel changed to {portal.username}.")
+ else:
+ return await evt.reply(f"Channel is now private.")
+ except ChatAdminRequiredError:
+ return await evt.reply(
+ "You don't have the permission to set the username of this channel.")
+ except UsernameNotModifiedError:
+ if portal.username:
+ return await evt.reply("That is already the username of this channel.")
+ else:
+ return await evt.reply("This channel is already private")
+ except UsernameOccupiedError:
+ return await evt.reply("That username is already in use.")
+ except UsernameInvalidError:
+ return await evt.reply("Invalid username")
diff --git a/mautrix_telegram/config.py b/mautrix_telegram/config.py
index f2f81426..82817f73 100644
--- a/mautrix_telegram/config.py
+++ b/mautrix_telegram/config.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
diff --git a/mautrix_telegram/db.py b/mautrix_telegram/db.py
index 7b546e7c..0b6341d8 100644
--- a/mautrix_telegram/db.py
+++ b/mautrix_telegram/db.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
@@ -14,7 +13,8 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
-from sqlalchemy import Column, UniqueConstraint, Integer, String
+from sqlalchemy import Column, UniqueConstraint, ForeignKey, ForeignKeyConstraint, Integer, String
+from sqlalchemy.orm import relationship
from .base import Base
@@ -50,6 +50,18 @@ class Message(Base):
__table_args__ = (UniqueConstraint('mxid', 'mx_room', 'tg_space', name='_mx_id_room'),)
+class UserPortal(Base):
+ query = None
+ __tablename__ = "user_portal"
+
+ user = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
+ portal = Column(Integer, primary_key=True)
+ portal_receiver = Column(Integer, primary_key=True)
+
+ __table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
+ ("portal.tgid", "portal.tg_receiver")),)
+
+
class User(Base):
query = None
__tablename__ = "user"
@@ -57,6 +69,19 @@ class User(Base):
mxid = Column(String, primary_key=True)
tgid = Column(Integer, nullable=True)
tg_username = Column(String, nullable=True)
+ saved_contacts = Column(Integer, default=0)
+ contacts = relationship("Contact", uselist=True,
+ cascade="save-update, merge, delete, delete-orphan")
+ portals = relationship("Portal", secondary="user_portal", single_parent=True,
+ cascade="save-update, merge, delete, delete-orphan")
+
+
+class Contact(Base):
+ query = None
+ __tablename__ = "contact"
+
+ user = Column("user", Integer, ForeignKey("user.tgid"), primary_key=True)
+ contact = Column("contact", Integer, ForeignKey("puppet.id"), primary_key=True)
class Puppet(Base):
@@ -72,5 +97,6 @@ class Puppet(Base):
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()
diff --git a/mautrix_telegram/formatter.py b/mautrix_telegram/formatter.py
index 0a2d7377..c1567a40 100644
--- a/mautrix_telegram/formatter.py
+++ b/mautrix_telegram/formatter.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
@@ -219,7 +218,7 @@ def telegram_reply_to_matrix(evt, source):
async def telegram_event_to_matrix(evt, source, native_replies=False, message_link_in_reply=False,
- main_intent=None):
+ main_intent=None, reply_text="Reply"):
text = evt.message
html = telegram_to_matrix(evt.message, evt.entities) if evt.entities else None
relates_to = {}
@@ -269,12 +268,11 @@ 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")
+ + 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):
- quote = "Reply to unknown user (Failed to fetch message):
"
-
+ quote = "{reply_text} to unknown user (Failed to fetch message):
"
if html:
html = quote + html
else:
diff --git a/mautrix_telegram/matrix.py b/mautrix_telegram/matrix.py
index 6765a8b7..cbca6d09 100644
--- a/mautrix_telegram/matrix.py
+++ b/mautrix_telegram/matrix.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
@@ -80,6 +79,7 @@ class MatrixHandler:
pass
portal.mxid = room
portal.save()
+ inviter.register_portal(portal)
await puppet.intent.send_notice(room, "Portal to private chat created.")
else:
await puppet.intent.join_room(room)
diff --git a/mautrix_telegram/portal.py b/mautrix_telegram/portal.py
index 6e91b44a..ab001782 100644
--- a/mautrix_telegram/portal.py
+++ b/mautrix_telegram/portal.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
@@ -30,6 +29,7 @@ from telethon.tl.functions.messages import *
from telethon.tl.functions.channels import *
from telethon.errors.rpc_error_list import *
from telethon.tl.types import *
+from mautrix_appservice import MatrixRequestError, IntentError
from .db import Portal as DBPortal, Message as DBMessage
from . import puppet as p, user as u, formatter
@@ -204,7 +204,7 @@ class Portal:
if alias:
# TODO properly handle existing room aliases
- intent.remove_room_alias(alias)
+ await intent.remove_room_alias(alias)
room = await intent.create_room(alias=alias, is_public=public, invitees=invites or [],
name=self.title, is_direct=direct)
if not room:
@@ -213,6 +213,7 @@ class Portal:
self.mxid = room["room_id"]
self.by_mxid[self.mxid] = self
self.save()
+ user.register_portal(self)
power_level_requirement = 0 if self.peer_type == "chat" and entity.admins_enabled else 50
levels = await self.main_intent.get_power_levels(self.mxid)
@@ -245,6 +246,7 @@ class Portal:
user = u.User.get_by_tgid(user_id)
if user:
+ user.register_portal(self)
await self.main_intent.invite(self.mxid, user.mxid)
async def delete_telegram_user(self, user_id, kick_message=None):
@@ -255,6 +257,7 @@ class Portal:
else:
await puppet.intent.leave_room(self.mxid)
if user:
+ user.unregister_portal(self)
await self.main_intent.kick(self.mxid, user.mxid, kick_message or "Left Telegram chat")
async def update_info(self, user, entity=None):
@@ -356,6 +359,38 @@ class Portal:
return link.link
+ async def get_authenticated_matrix_users(self):
+ try:
+ members = await self.main_intent.get_room_members(self.mxid)
+ except MatrixRequestError:
+ return []
+ authenticated = []
+ 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)
+ if user.has_full_access:
+ authenticated.append(user)
+ return authenticated
+
+ @staticmethod
+ async def cleanup_room(intent, room_id, type="Portal"):
+ try:
+ members = await intent.get_room_members(room_id)
+ except MatrixRequestError:
+ members = []
+ for user in members:
+ if user != intent.mxid:
+ try:
+ await intent.kick(room_id, user, f"{type} deleted.")
+ except (MatrixRequestError, IntentError):
+ pass
+ await intent.leave_room(room_id)
+
+ async def cleanup_and_delete(self):
+ await self.cleanup_room(self.main_intent, self.mxid)
+ self.delete()
+
# endregion
# region Matrix event handling
@@ -704,6 +739,21 @@ class Portal:
await sender.intent.set_typing(self.mxid, is_typing=False)
return await sender.intent.send_text(self.mxid, text, html=html, relates_to=relates_to)
+ async def handle_telegram_edit(self, source, sender, evt):
+ if not self.mxid:
+ return
+ elif not config["bridge.edits_as_replies"]:
+ self.log.debug("Edits as replies disabled, ignoring edit event...")
+ return
+ evt.reply_to_msg_id = evt.id
+ text, html, relates_to = await formatter.telegram_event_to_matrix(
+ evt, source,
+ config["bridge.native_replies"],
+ config["bridge.link_in_reply"],
+ self.main_intent, reply_text="Edit")
+ await sender.intent.set_typing(self.mxid, is_typing=False)
+ return await sender.intent.send_text(self.mxid, text, html=html, relates_to=relates_to)
+
async def handle_telegram_message(self, source, sender, evt):
if not self.mxid:
await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
@@ -818,6 +868,7 @@ class Portal:
user_levels = levels["users"]
if user:
+ user.register_portal(self)
user_level_defined = user.mxid in user_levels
user_has_right_level = (user_levels[user.mxid] == new_level
if user_level_defined else new_level == 0)
diff --git a/mautrix_telegram/puppet.py b/mautrix_telegram/puppet.py
index 0f75be31..8df387b6 100644
--- a/mautrix_telegram/puppet.py
+++ b/mautrix_telegram/puppet.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
@@ -14,6 +13,7 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+from difflib import SequenceMatcher
import re
import logging
@@ -66,6 +66,16 @@ class Puppet:
self.to_db()
self.db.commit()
+ def similarity(self, query):
+ username_similarity = (SequenceMatcher(None, self.username, query).ratio()
+ if self.username else 0)
+ displayname_similarity = (SequenceMatcher(None, self.displayname, query).ratio()
+ if self.displayname else 0)
+ #phone_number_similarity = (SequenceMatcher(None, self.phone_number, query).ratio()
+ # if self.phone_number else 0)
+ similarity = max(username_similarity, displayname_similarity)
+ return round(similarity * 1000) / 10
+
@staticmethod
def get_displayname(info, format=True):
data = {
@@ -99,7 +109,7 @@ class Puppet:
changed = await self.update_displayname(source, info) or changed
if isinstance(info.photo, UserProfilePhoto):
- changed = await self.update_avatar(source, info.photo.photo_big)
+ changed = await self.update_avatar(source, info.photo.photo_big) or changed
if changed:
self.save()
diff --git a/mautrix_telegram/tgclient.py b/mautrix_telegram/tgclient.py
index 335d8adf..84b6728e 100644
--- a/mautrix_telegram/tgclient.py
+++ b/mautrix_telegram/tgclient.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
diff --git a/mautrix_telegram/user.py b/mautrix_telegram/user.py
index d3a8eb06..1c19dd96 100644
--- a/mautrix_telegram/user.py
+++ b/mautrix_telegram/user.py
@@ -1,4 +1,3 @@
-# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan
#
@@ -19,9 +18,12 @@ import asyncio
import platform
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
+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__
@@ -36,14 +38,20 @@ class User:
by_mxid = {}
by_tgid = {}
- def __init__(self, mxid, tgid=None, username=None):
+ def __init__(self, mxid, tgid=None, username=None, db_contacts=None, saved_contacts=0,
+ db_portals=None):
self.mxid = mxid
self.tgid = tgid
self.username = username
+ self.contacts = []
+ self.saved_contacts = saved_contacts
+ self.db_contacts = db_contacts
+ self.portals = {}
+ 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", [])
@@ -65,13 +73,41 @@ class User:
def has_full_access(self):
return self.logged_in and self.whitelisted
+ @property
+ def db_contacts(self):
+ return [self.db.merge(DBContact(user=self.tgid, contact=puppet.id))
+ for puppet in self.contacts]
+
+ @db_contacts.setter
+ def db_contacts(self, contacts):
+ if contacts:
+ self.contacts = [pu.Puppet.get(entry.contact) for entry in contacts]
+ else:
+ self.contacts = []
+
+ @property
+ def db_portals(self):
+ return [portal.to_db(merge=False) for _, portal in self.portals.items()]
+
+ @db_portals.setter
+ def db_portals(self, portals):
+ if portals:
+ self.portals = {(portal.tgid, portal.tg_receiver):
+ po.Portal.get_by_tgid(portal.tgid, portal.tg_receiver)
+ for portal in portals}
+ else:
+ self.portals = {}
+
def get_input_entity(self, user):
return user.client.get_input_entity(InputUser(user_id=self.tgid, access_hash=0))
# region Database conversion
def to_db(self):
- return self.db.merge(DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username))
+ return self.db.merge(
+ DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username,
+ contacts=self.db_contacts, saved_contacts=self.saved_contacts,
+ portals=self.db_portals))
def save(self):
self.to_db()
@@ -79,12 +115,13 @@ class User:
@classmethod
def from_db(cls, db_user):
- return User(db_user.mxid, db_user.tgid, db_user.tg_username)
+ return User(db_user.mxid, db_user.tgid, db_user.tg_username, db_user.contacts,
+ db_user.saved_contacts, db_user.portals)
# endregion
# region Telegram connection management
- async def start(self):
+ def _init_client(self):
device = f"{platform.system()} {platform.release()}"
sysversion = MautrixTelegramClient.__version__
self.client = MautrixTelegramClient(self.mxid,
@@ -95,6 +132,8 @@ class User:
system_version=sysversion,
device_model=device)
self.client.add_update_handler(self.update_catch)
+
+ async def start(self):
self.connected = await self.client.connect()
if self.logged_in:
asyncio.ensure_future(self.post_login(), loop=self.loop)
@@ -102,8 +141,9 @@ class User:
async def post_login(self, info=None):
try:
- await self.sync_dialogs()
await self.update_info(info)
+ await self.sync_dialogs()
+ await self.sync_contacts()
except Exception:
self.log.exception("Failed to run post-login functions")
@@ -128,7 +168,14 @@ class User:
self.save()
async def log_out(self):
- self.connected = False
+ for _, portal in self.portals.items():
+ try:
+ await portal.main_intent.kick(portal.mxid, self.mxid, "Logged out of Telegram.")
+ except MatrixRequestError:
+ pass
+ self.portals = {}
+ self.contacts = []
+ self.save()
if self.tgid:
try:
del self.by_tgid[self.tgid]
@@ -136,21 +183,94 @@ class User:
pass
self.tgid = None
self.save()
- await self.client.log_out()
- # TODO kick user from portals
+ ok = await self.client.log_out()
+ if not ok:
+ return False
+ self._init_client()
+ await self.start()
+ return True
+
+ def _search_local(self, query, max_results=5, min_similarity=45):
+ results = []
+ for contact in self.contacts:
+ similarity = contact.similarity(query)
+ if similarity >= min_similarity:
+ results.append((contact, similarity))
+ results.sort(key=lambda tup: tup[1], reverse=True)
+ return results[0:max_results]
+
+ async def _search_remote(self, query, max_results=5):
+ if len(query) < 5:
+ return []
+ server_results = await self.client(SearchRequest(q=query, limit=max_results))
+ results = []
+ for user in server_results.users:
+ puppet = pu.Puppet.get(user.id)
+ await puppet.update_info(self, user)
+ results.append((puppet, puppet.similarity(query)))
+ results.sort(key=lambda tup: tup[1], reverse=True)
+ return results[0:max_results]
+
+ async def search(self, query, force_remote=False):
+ if force_remote:
+ return await self._search_remote(query), True
+
+ results = self._search_local(query)
+ if results:
+ return results, False
+
+ 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
- if (isinstance(entity, (TLUser, ChatForbidden, ChannelForbidden)) or (
- isinstance(entity, Chat) and (entity.deactivated or entity.left))):
+ invalid = (isinstance(entity, (TLUser, ChatForbidden, ChannelForbidden))
+ or (isinstance(entity, Chat) and (entity.deactivated or entity.left)))
+ if invalid:
continue
portal = po.Portal.get_by_entity(entity)
+ self.portals[portal.tgid_full] = portal
creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid]))
+ self.save()
await asyncio.gather(*creators, loop=self.loop)
+ def register_portal(self, portal):
+ try:
+ if self.portals[portal.tgid_full] == portal:
+ return
+ except KeyError:
+ pass
+ self.portals[portal.tgid_full] = portal
+ self.save()
+
+ def unregister_portal(self, portal):
+ try:
+ del self.portals[portal.tgid_full]
+ self.save()
+ except KeyError:
+ pass
+
+ def _hash_contacts(self):
+ acc = 0
+ for id in sorted([self.saved_contacts] + [contact.id for contact in self.contacts]):
+ acc = (acc * 20261 + id) & 0xffffffff
+ return acc & 0x7fffffff
+
+ async def sync_contacts(self):
+ response = await self.client(GetContactsRequest(hash=self._hash_contacts()))
+ if isinstance(response, ContactsNotModified):
+ return
+ self.log.debug("Updating contacts...")
+ self.contacts = []
+ self.saved_contacts = response.saved_count
+ for user in response.users:
+ puppet = pu.Puppet.get(user.id)
+ await puppet.update_info(self, user)
+ self.contacts.append(puppet)
+ self.save()
+
# endregion
# region Telegram update handling
@@ -161,8 +281,8 @@ class User:
self.log.exception("Failed to handle Telegram update")
async def update(self, update):
- if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewMessage,
- UpdateNewChannelMessage)):
+ if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
+ UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
await self.update_message(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)):
await self.update_typing(update)
@@ -245,7 +365,8 @@ class User:
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)):
+ 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",
@@ -259,8 +380,8 @@ class User:
return update, None, None
return update, sender, portal
- async def update_message(self, update):
- update, sender, portal = self.get_message_details(update)
+ def update_message(self, original_update):
+ update, sender, portal = self.get_message_details(original_update)
if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom):
@@ -269,10 +390,14 @@ class User:
return
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
- await portal.handle_telegram_action(self, sender, update.action)
- else:
- self.log.debug("Handling message %s to %s by %d", update, portal.tgid_log, sender.tgid)
- await portal.handle_telegram_message(self, sender, update)
+ return portal.handle_telegram_action(self, sender, update.action)
+
+ if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
+ self.log.debug("Handling edit %s to %s by %d", update, portal.tgid_log, sender.tgid)
+ return portal.handle_telegram_edit(self, sender, update)
+
+ self.log.debug("Handling message %s to %s by %d", update, portal.tgid_log, sender.tgid)
+ return portal.handle_telegram_message(self, sender, update)
# endregion
# region Class instance lookup
diff --git a/requirements.txt b/requirements.txt
index 3c33c25b..c7ee0ddf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,7 +2,7 @@ aiohttp
ruamel.yaml
python-magic
SQLAlchemy
--e git+git://github.com/LonamiWebs/Telethon@asyncio#egg=Telethon
+alembic
+-e git+https://github.com/LonamiWebs/Telethon@asyncio#egg=Telethon
Markdown
Pillow
-future-fstrings
diff --git a/setup.py b/setup.py
index 59fbefea..d9df9d6d 100644
--- a/setup.py
+++ b/setup.py
@@ -17,10 +17,10 @@ setuptools.setup(
install_requires=[
"aiohttp>=2.3.10,<3",
"SQLAlchemy>=1.2.2,<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",
"python-magic>=0.4.15,<0.5",
],
dependency_links=[