Merge branch 'master' into next-native-replies

This commit is contained in:
Tulir Asokan
2018-02-13 13:40:00 +02:00
29 changed files with 1199 additions and 564 deletions
+5 -3
View File
@@ -77,17 +77,19 @@
* [ ] Option to use bot to relay messages for unauthenticated Matrix users * [ ] Option to use bot to relay messages for unauthenticated Matrix users
* [ ] Option to use own Matrix account for messages sent from other Telegram clients * [ ] Option to use own Matrix account for messages sent from other Telegram clients
* [Commands](https://github.com/tulir/mautrix-telegram/wiki/Management-commands) * [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`) * [ ] Registering (`register`)
* [x] Searching for users (`search`) * [x] Searching for users (`search`)
* [ ] Searching contacts locally
* [x] Starting private chats (`pm`) * [x] Starting private chats (`pm`)
* [x] Joining chats with invite links (`join`) * [x] Joining chats with invite links (`join`)
* [x] Creating a Telegram chat for an existing Matrix room (`create`) * [x] Creating a Telegram chat for an existing Matrix room (`create`)
* [x] Upgrading the chat of a portal room into a supergroup (`upgrade`) * [x] Upgrading the chat of a portal room into a supergroup (`upgrade`)
* [x] Change username of supergroup/channel (`groupname`) * [x] Change username of supergroup/channel (`groupname`)
* [x] Getting the Telegram invite link to a Matrix room (`invitelink`) * [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 † 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 ‡ Maybe, i.e. this feature may or may not be implemented at some point
+74
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+77
View File
@@ -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()
+24
View File
@@ -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"}
@@ -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 ###
+5 -2
View File
@@ -64,6 +64,9 @@ bridge:
# If native replies are disabled, should the custom replies contain a link to the message being # If native replies are disabled, should the custom replies contain a link to the message being
# replied to? # replied to?
link_in_reply: False 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. # The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg" command_prefix: "!tg"
@@ -71,13 +74,13 @@ bridge:
# Whitelist of user IDs that are allowed to use this bridge. Leave empty to disable. # 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. # You can enter a domain without the localpart to allow all users from that homeserver to use the bridge.
whitelist: whitelist:
- "internal-hs.example.com" - "internal.example.com"
- "@user:public.example.com" - "@user:public.example.com"
# Admins can do things like delete portal rooms. Here you must specify the exact MXID, domains # Admins can do things like delete portal rooms. Here you must specify the exact MXID, domains
# are not accepted. # are not accepted.
admins: admins:
- "@admin:internal-hs.example.com" - "@admin:internal.example.com"
# Telegram config # Telegram config
telegram: telegram:
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# matrix-appservice-python - A Matrix Application Service framework written in Python. # matrix-appservice-python - A Matrix Application Service framework written in Python.
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# matrix-appservice-python - A Matrix Application Service framework written in Python. # matrix-appservice-python - A Matrix Application Service framework written in Python.
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
-515
View File
@@ -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 <http://www.gnu.org/licenses/>.
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>`")
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 <phone>`")
elif len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter_code <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 <phone>`.")
except PhoneCodeExpiredError:
return await evt.reply(
"Phone code expired. Try again with `$cmdprefix+sp login <phone>`.")
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 <phone>`")
elif len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp enter_password <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] <query>`")
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 <user identifier>`")
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 <invite link>`")
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 <name/->`")
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
+2
View File
@@ -0,0 +1,2 @@
from .handler import command_handler, CommandHandler
from . import clean_rooms, auth, meta, telegram
+118
View File
@@ -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 <http://www.gnu.org/licenses/>.
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>`")
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 <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 <phone>`.")
except PhoneCodeExpiredError:
return await evt.reply(
"Phone code expired. Try again with `$cmdprefix+sp login <phone>`.")
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 <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.")
+169
View File
@@ -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 <http://www.gnu.org/licenses/>.
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 <letters>` "
"where `letters` are the first letters of the group names (M, A, U, I)"),
"",
("To clean specific rooms, type `$cmdprefix+sp clean-range <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_><range>")
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.")
+114
View File
@@ -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 <http://www.gnu.org/licenses/>.
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.")
+75
View File
@@ -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 <http://www.gnu.org/licenses/>.
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)
+260
View File
@@ -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 <http://www.gnu.org/licenses/>.
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] <query>`")
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 identifier>`")
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 <invite link>`")
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 <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")
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
+28 -2
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
@@ -14,7 +13,8 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
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 from .base import Base
@@ -50,6 +50,18 @@ class Message(Base):
__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):
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): class User(Base):
query = None query = None
__tablename__ = "user" __tablename__ = "user"
@@ -57,6 +69,19 @@ class User(Base):
mxid = Column(String, primary_key=True) mxid = Column(String, primary_key=True)
tgid = Column(Integer, nullable=True) tgid = Column(Integer, nullable=True)
tg_username = Column(String, 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): class Puppet(Base):
@@ -72,5 +97,6 @@ class Puppet(Base):
def init(db_session): def init(db_session):
Portal.query = db_session.query_property() Portal.query = db_session.query_property()
Message.query = db_session.query_property() Message.query = db_session.query_property()
UserPortal.query = db_session.query_property()
User.query = db_session.query_property() User.query = db_session.query_property()
Puppet.query = db_session.query_property() Puppet.query = db_session.query_property()
+3 -5
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # 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, 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 text = evt.message
html = telegram_to_matrix(evt.message, evt.entities) if evt.entities else None html = telegram_to_matrix(evt.message, evt.entities) if evt.entities else None
relates_to = {} 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 displayname = puppet.displayname if puppet else sender
reply_to_user = (f"<a href='https://matrix.to/#/{sender}'>{displayname}</a>") reply_to_user = (f"<a href='https://matrix.to/#/{sender}'>{displayname}</a>")
reply_to_msg = (("<a href='https://matrix.to/#/" reply_to_msg = (("<a href='https://matrix.to/#/"
+ f"{msg.mx_room}/{msg.mxid}'>Reply</a>") + f"{msg.mx_room}/{msg.mxid}'>{reply_text}</a>")
if message_link_in_reply else "Reply") if message_link_in_reply else "Reply")
quote = f"{reply_to_msg} to {reply_to_user}<blockquote>{body}</blockquote>" quote = f"{reply_to_msg} to {reply_to_user}<blockquote>{body}</blockquote>"
except (ValueError, KeyError, MatrixRequestError): except (ValueError, KeyError, MatrixRequestError):
quote = "Reply to unknown user <em>(Failed to fetch message)</em>:<br/>" quote = "{reply_text} to unknown user <em>(Failed to fetch message)</em>:<br/>"
if html: if html:
html = quote + html html = quote + html
else: else:
+1 -1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
@@ -80,6 +79,7 @@ class MatrixHandler:
pass pass
portal.mxid = room portal.mxid = room
portal.save() portal.save()
inviter.register_portal(portal)
await puppet.intent.send_notice(room, "Portal to private chat created.") await puppet.intent.send_notice(room, "Portal to private chat created.")
else: else:
await puppet.intent.join_room(room) await puppet.intent.join_room(room)
+53 -2
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
@@ -30,6 +29,7 @@ from telethon.tl.functions.messages import *
from telethon.tl.functions.channels import * from telethon.tl.functions.channels import *
from telethon.errors.rpc_error_list import * from telethon.errors.rpc_error_list import *
from telethon.tl.types import * from telethon.tl.types import *
from mautrix_appservice import MatrixRequestError, IntentError
from .db import Portal as DBPortal, Message as DBMessage from .db import Portal as DBPortal, Message as DBMessage
from . import puppet as p, user as u, formatter from . import puppet as p, user as u, formatter
@@ -204,7 +204,7 @@ class Portal:
if alias: if alias:
# TODO properly handle existing room aliases # 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 [], room = await intent.create_room(alias=alias, is_public=public, invitees=invites or [],
name=self.title, is_direct=direct) name=self.title, is_direct=direct)
if not room: if not room:
@@ -213,6 +213,7 @@ class Portal:
self.mxid = room["room_id"] self.mxid = room["room_id"]
self.by_mxid[self.mxid] = self self.by_mxid[self.mxid] = self
self.save() self.save()
user.register_portal(self)
power_level_requirement = 0 if self.peer_type == "chat" and entity.admins_enabled else 50 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) levels = await self.main_intent.get_power_levels(self.mxid)
@@ -245,6 +246,7 @@ class Portal:
user = u.User.get_by_tgid(user_id) user = u.User.get_by_tgid(user_id)
if user: if user:
user.register_portal(self)
await self.main_intent.invite(self.mxid, user.mxid) await self.main_intent.invite(self.mxid, user.mxid)
async def delete_telegram_user(self, user_id, kick_message=None): async def delete_telegram_user(self, user_id, kick_message=None):
@@ -255,6 +257,7 @@ class Portal:
else: else:
await puppet.intent.leave_room(self.mxid) await puppet.intent.leave_room(self.mxid)
if user: if user:
user.unregister_portal(self)
await self.main_intent.kick(self.mxid, user.mxid, kick_message or "Left Telegram chat") await self.main_intent.kick(self.mxid, user.mxid, kick_message or "Left Telegram chat")
async def update_info(self, user, entity=None): async def update_info(self, user, entity=None):
@@ -356,6 +359,38 @@ class Portal:
return link.link 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 # endregion
# region Matrix event handling # region Matrix event handling
@@ -704,6 +739,21 @@ class Portal:
await sender.intent.set_typing(self.mxid, is_typing=False) 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) 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): async def handle_telegram_message(self, source, sender, evt):
if not self.mxid: if not self.mxid:
await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False) await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
@@ -818,6 +868,7 @@ class Portal:
user_levels = levels["users"] user_levels = levels["users"]
if user: if user:
user.register_portal(self)
user_level_defined = user.mxid in user_levels user_level_defined = user.mxid in user_levels
user_has_right_level = (user_levels[user.mxid] == new_level user_has_right_level = (user_levels[user.mxid] == new_level
if user_level_defined else new_level == 0) if user_level_defined else new_level == 0)
+12 -2
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
@@ -14,6 +13,7 @@
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
from difflib import SequenceMatcher
import re import re
import logging import logging
@@ -66,6 +66,16 @@ class Puppet:
self.to_db() self.to_db()
self.db.commit() 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 @staticmethod
def get_displayname(info, format=True): def get_displayname(info, format=True):
data = { data = {
@@ -99,7 +109,7 @@ class Puppet:
changed = await self.update_displayname(source, info) or changed changed = await self.update_displayname(source, info) or changed
if isinstance(info.photo, UserProfilePhoto): 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: if changed:
self.save() self.save()
-1
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
+147 -22
View File
@@ -1,4 +1,3 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2018 Tulir Asokan # Copyright (C) 2018 Tulir Asokan
# #
@@ -19,9 +18,12 @@ import asyncio
import platform import platform
from telethon.tl.types import * from telethon.tl.types import *
from telethon.tl.types.contacts import ContactsNotModified
from telethon.tl.types import User as TLUser 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 .tgclient import MautrixTelegramClient
from . import portal as po, puppet as pu, __version__ from . import portal as po, puppet as pu, __version__
@@ -36,14 +38,20 @@ class User:
by_mxid = {} by_mxid = {}
by_tgid = {} 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.mxid = mxid
self.tgid = tgid self.tgid = tgid
self.username = username 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.command_status = None
self.connected = False self.connected = False
self.client = None self._init_client()
self.is_admin = self.mxid in config.get("bridge.admins", []) self.is_admin = self.mxid in config.get("bridge.admins", [])
@@ -65,13 +73,41 @@ class User:
def has_full_access(self): def has_full_access(self):
return self.logged_in and self.whitelisted 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): def get_input_entity(self, user):
return user.client.get_input_entity(InputUser(user_id=self.tgid, access_hash=0)) return user.client.get_input_entity(InputUser(user_id=self.tgid, access_hash=0))
# region Database conversion # region Database conversion
def to_db(self): 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): def save(self):
self.to_db() self.to_db()
@@ -79,12 +115,13 @@ class User:
@classmethod @classmethod
def from_db(cls, db_user): 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 # endregion
# region Telegram connection management # region Telegram connection management
async def start(self): def _init_client(self):
device = f"{platform.system()} {platform.release()}" device = f"{platform.system()} {platform.release()}"
sysversion = MautrixTelegramClient.__version__ sysversion = MautrixTelegramClient.__version__
self.client = MautrixTelegramClient(self.mxid, self.client = MautrixTelegramClient(self.mxid,
@@ -95,6 +132,8 @@ class User:
system_version=sysversion, system_version=sysversion,
device_model=device) device_model=device)
self.client.add_update_handler(self.update_catch) self.client.add_update_handler(self.update_catch)
async def start(self):
self.connected = await self.client.connect() self.connected = await self.client.connect()
if self.logged_in: if self.logged_in:
asyncio.ensure_future(self.post_login(), loop=self.loop) asyncio.ensure_future(self.post_login(), loop=self.loop)
@@ -102,8 +141,9 @@ class User:
async def post_login(self, info=None): async def post_login(self, info=None):
try: try:
await self.sync_dialogs()
await self.update_info(info) await self.update_info(info)
await self.sync_dialogs()
await self.sync_contacts()
except Exception: except Exception:
self.log.exception("Failed to run post-login functions") self.log.exception("Failed to run post-login functions")
@@ -128,7 +168,14 @@ class User:
self.save() self.save()
async def log_out(self): 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: if self.tgid:
try: try:
del self.by_tgid[self.tgid] del self.by_tgid[self.tgid]
@@ -136,21 +183,94 @@ class User:
pass pass
self.tgid = None self.tgid = None
self.save() self.save()
await self.client.log_out() ok = await self.client.log_out()
# TODO kick user from portals 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): async def sync_dialogs(self):
dialogs = await self.client.get_dialogs(limit=30) dialogs = await self.client.get_dialogs(limit=30)
creators = [] creators = []
for dialog in dialogs: for dialog in dialogs:
entity = dialog.entity entity = dialog.entity
if (isinstance(entity, (TLUser, ChatForbidden, ChannelForbidden)) or ( invalid = (isinstance(entity, (TLUser, ChatForbidden, ChannelForbidden))
isinstance(entity, Chat) and (entity.deactivated or entity.left))): or (isinstance(entity, Chat) and (entity.deactivated or entity.left)))
if invalid:
continue continue
portal = po.Portal.get_by_entity(entity) portal = po.Portal.get_by_entity(entity)
self.portals[portal.tgid_full] = portal
creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid])) creators.append(portal.create_matrix_room(self, entity, invites=[self.mxid]))
self.save()
await asyncio.gather(*creators, loop=self.loop) 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 # endregion
# region Telegram update handling # region Telegram update handling
@@ -161,8 +281,8 @@ class User:
self.log.exception("Failed to handle Telegram update") self.log.exception("Failed to handle Telegram update")
async def update(self, update): async def update(self, update):
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewMessage, if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewChannelMessage)): UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
await self.update_message(update) await self.update_message(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)): elif isinstance(update, (UpdateChatUserTyping, UpdateUserTyping)):
await self.update_typing(update) await self.update_typing(update)
@@ -245,7 +365,8 @@ class User:
elif isinstance(update, UpdateShortMessage): elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(update.user_id, self.tgid, "user") 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) 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 update = update.message
if isinstance(update.to_id, PeerUser) and not update.out: if isinstance(update.to_id, PeerUser) and not update.out:
portal = po.Portal.get_by_tgid(update.from_id, peer_type="user", portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
@@ -259,8 +380,8 @@ class User:
return update, None, None return update, None, None
return update, sender, portal return update, sender, portal
async def update_message(self, update): def update_message(self, original_update):
update, sender, portal = self.get_message_details(update) update, sender, portal = self.get_message_details(original_update)
if isinstance(update, MessageService): if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom): if isinstance(update.action, MessageActionChannelMigrateFrom):
@@ -269,10 +390,14 @@ class User:
return return
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log, self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
sender.id) sender.id)
await portal.handle_telegram_action(self, sender, update.action) return 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) if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
await portal.handle_telegram_message(self, sender, update) 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 # endregion
# region Class instance lookup # region Class instance lookup
+2 -2
View File
@@ -2,7 +2,7 @@ aiohttp
ruamel.yaml ruamel.yaml
python-magic python-magic
SQLAlchemy SQLAlchemy
-e git+git://github.com/LonamiWebs/Telethon@asyncio#egg=Telethon alembic
-e git+https://github.com/LonamiWebs/Telethon@asyncio#egg=Telethon
Markdown Markdown
Pillow Pillow
future-fstrings
+1 -1
View File
@@ -17,10 +17,10 @@ setuptools.setup(
install_requires=[ install_requires=[
"aiohttp>=2.3.10,<3", "aiohttp>=2.3.10,<3",
"SQLAlchemy>=1.2.2,<2", "SQLAlchemy>=1.2.2,<2",
"alembic>=0.9.7",
"Markdown>=2.6.11,<3", "Markdown>=2.6.11,<3",
"ruamel.yaml>=0.15.35,<0.16", "ruamel.yaml>=0.15.35,<0.16",
"Pillow>=5.0.0,<6", "Pillow>=5.0.0,<6",
"future-fstrings>=0.4.1",
"python-magic>=0.4.15,<0.5", "python-magic>=0.4.15,<0.5",
], ],
dependency_links=[ dependency_links=[