Compare commits
3 Commits
v0.9.0-rc1
..
v0.8.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b03cddde8 | |||
| 6eec096501 | |||
| 78cdb43c65 |
@@ -14,5 +14,4 @@ __pycache__
|
||||
/registration.yaml
|
||||
*.log*
|
||||
*.db
|
||||
*.pickle
|
||||
*.bak
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ build amd64:
|
||||
- amd64
|
||||
script:
|
||||
- docker pull $CI_REGISTRY_IMAGE:latest || true
|
||||
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --build-arg TARGETARCH=amd64 --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 .
|
||||
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 .
|
||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
||||
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
|
||||
|
||||
@@ -24,7 +24,7 @@ build arm64:
|
||||
- arm64
|
||||
script:
|
||||
- docker pull $CI_REGISTRY_IMAGE:latest || true
|
||||
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --build-arg TARGETARCH=arm64 --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 .
|
||||
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 .
|
||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
||||
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
|
||||
|
||||
|
||||
+13
-12
@@ -1,7 +1,5 @@
|
||||
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.12
|
||||
|
||||
ARG TARGETARCH=amd64
|
||||
|
||||
RUN echo $'\
|
||||
@edge http://dl-cdn.alpinelinux.org/alpine/edge/main\n\
|
||||
@edge http://dl-cdn.alpinelinux.org/alpine/edge/testing\n\
|
||||
@@ -27,28 +25,31 @@ RUN apk add --no-cache \
|
||||
py3-requests \
|
||||
#imageio
|
||||
py3-numpy \
|
||||
#py3-telethon@edge \ (outdated)
|
||||
py3-telethon@edge \
|
||||
# Optional for socks proxies
|
||||
py3-pysocks \
|
||||
# cryptg
|
||||
py3-cffi \
|
||||
py3-qrcode@edge \
|
||||
py3-brotli \
|
||||
# Other dependencies
|
||||
ffmpeg \
|
||||
ca-certificates \
|
||||
su-exec \
|
||||
netcat-openbsd \
|
||||
# encryption
|
||||
# olm
|
||||
olm-dev \
|
||||
py3-pycryptodome \
|
||||
py3-unpaddedbase64 \
|
||||
# matrix-nio?
|
||||
py3-future \
|
||||
bash \
|
||||
curl \
|
||||
jq && \
|
||||
curl -sLo yq https://github.com/mikefarah/yq/releases/download/3.3.2/yq_linux_${TARGETARCH} && \
|
||||
chmod +x yq && mv yq /usr/bin/yq
|
||||
py3-atomicwrites \
|
||||
py3-pycryptodome \
|
||||
py3-peewee \
|
||||
py3-pyrsistent \
|
||||
py3-jsonschema \
|
||||
#py3-aiofiles \ # (too new)
|
||||
py3-cachetools \
|
||||
py3-unpaddedbase64 \
|
||||
py3-h2@edge \
|
||||
py3-logbook@edge
|
||||
|
||||
COPY requirements.txt /opt/mautrix-telegram/requirements.txt
|
||||
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
|
||||
|
||||
@@ -10,19 +10,9 @@ A Matrix-Telegram hybrid puppeting/relaybot bridge.
|
||||
## Sponsors
|
||||
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
|
||||
|
||||
### Wiki
|
||||
All setup and usage instructions are located in the GitHub
|
||||
[wiki](https://github.com/tulir/mautrix-telegram/wiki). Some quick links:
|
||||
### [Wiki](https://github.com/tulir/mautrix-telegram/wiki)
|
||||
|
||||
* [Bridge setup](https://github.com/tulir/mautrix-telegram/wiki/Bridge-setup)
|
||||
(or [with Docker](https://github.com/tulir/mautrix-telegram/wiki/Bridge-setup-with-Docker))
|
||||
* Basic usage: [Authentication](https://github.com/tulir/mautrix-telegram/wiki/Authentication),
|
||||
[Creating chats](https://github.com/tulir/mautrix-telegram/wiki/Creating-and-managing-chats),
|
||||
[Relaybot setup](https://github.com/tulir/mautrix-telegram/wiki/Relay-bot)
|
||||
|
||||
### Features & Roadmap
|
||||
[ROADMAP.md](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md)
|
||||
contains a general overview of what is supported by the bridge.
|
||||
### [Features & Roadmap](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md)
|
||||
|
||||
## Discussion
|
||||
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
|
||||
@@ -30,4 +20,4 @@ Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net
|
||||
Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room)
|
||||
|
||||
## Preview
|
||||

|
||||

|
||||
|
||||
+12
-11
@@ -6,9 +6,9 @@
|
||||
* [x] Message edits
|
||||
* [ ] ‡ Message history
|
||||
* [x] Presence
|
||||
* [x] Typing notifications
|
||||
* [x] Read receipts
|
||||
* [x] Pinning messages
|
||||
* [x] Typing notifications*
|
||||
* [x] Read receipts*
|
||||
* [x] Pinning messages*
|
||||
* [x] Power level
|
||||
* [x] Normal chats
|
||||
* [ ] Non-hardcoded PL requirements
|
||||
@@ -28,10 +28,10 @@
|
||||
* [ ] Buttons
|
||||
* [x] Message deletions
|
||||
* [x] Message edits
|
||||
* [x] Message history
|
||||
* [ ] Message history
|
||||
* [x] Manually (`!tg backfill`)
|
||||
* [x] Automatically when creating portal
|
||||
* [x] Automatically for missed messages
|
||||
* [ ] Automatically when creating portal
|
||||
* [ ] Automatically for missed messages
|
||||
* [x] Avatars
|
||||
* [x] Presence
|
||||
* [x] Typing notifications
|
||||
@@ -53,11 +53,12 @@
|
||||
* [x] At startup
|
||||
* [x] When receiving invite or message
|
||||
* [x] Private chat creation by inviting Matrix puppet of Telegram user to new room
|
||||
* [x] Option to use bot to relay messages for unauthenticated Matrix users (relaybot)
|
||||
* [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting)
|
||||
* [x] Option to use bot to relay messages for unauthenticated Matrix users
|
||||
* [x] Option to use own Matrix account for messages sent from other Telegram clients
|
||||
* [ ] ‡ Calls (hard, not yet supported by Telethon)
|
||||
* [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram)
|
||||
* [x] End-to-bridge encryption in Matrix rooms (see [wiki](https://github.com/tulir/mautrix-telegram/wiki/End%E2%80%90to%E2%80%90bridge-encryption))
|
||||
* [ ] ‡ Secret chats (not yet supported by Telethon)
|
||||
* [ ] ‡ E2EE in Matrix rooms (not yet supported
|
||||
|
||||
† Information not automatically sent from source, i.e. implementation may not be possible
|
||||
\* Requires [double puppeting](https://github.com/tulir/mautrix-telegram/wiki/Authentication#replacing-telegram-accounts-matrix-puppet-with-matrix-account) to be enabled
|
||||
† 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
|
||||
|
||||
+3
-4
@@ -21,6 +21,7 @@ mxtg_config = Config(mxtg_config_path, None, None)
|
||||
mxtg_config.load()
|
||||
config.set_main_option("sqlalchemy.url", mxtg_config["appservice.database"].replace("%", "%%"))
|
||||
|
||||
|
||||
AlchemySessionContainer.create_table_classes(None, "telethon_", Base)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
@@ -54,8 +55,7 @@ def run_migrations_offline():
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=target_metadata, literal_binds=True,
|
||||
render_as_batch=True)
|
||||
url=url, target_metadata=target_metadata, literal_binds=True)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
@@ -76,8 +76,7 @@ def run_migrations_online():
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
render_as_batch=True
|
||||
target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Store Matrix avatar URL in database
|
||||
|
||||
Revision ID: 3e3745baa458
|
||||
Revises: dff56c93da8d
|
||||
Create Date: 2020-06-15 14:32:10.454033
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3e3745baa458'
|
||||
down_revision = 'dff56c93da8d'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('portal', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('avatar_url', sa.String(), nullable=True))
|
||||
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('portal', schema=None) as batch_op:
|
||||
batch_op.drop_column('avatar_url')
|
||||
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Add double puppet base URL to puppet table
|
||||
|
||||
Revision ID: 888275d58e57
|
||||
Revises: a328bf4f0932
|
||||
Create Date: 2020-10-14 18:52:00.730666
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '888275d58e57'
|
||||
down_revision = 'a328bf4f0932'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('puppet', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('base_url', sa.Text(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('puppet', schema=None) as batch_op:
|
||||
batch_op.drop_column('base_url')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Store encryption state event in db
|
||||
|
||||
Revision ID: a328bf4f0932
|
||||
Revises: ccbaff858240
|
||||
Create Date: 2020-07-11 21:31:27.059813
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from mautrix.client.state_store.sqlalchemy import SerializableType
|
||||
from mautrix.types import RoomEncryptionStateEventContent
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a328bf4f0932'
|
||||
down_revision = 'ccbaff858240'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('mx_room_state', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('encryption',
|
||||
SerializableType(RoomEncryptionStateEventContent),
|
||||
nullable=True))
|
||||
batch_op.add_column(sa.Column('has_full_member_list', sa.Boolean(), nullable=True))
|
||||
batch_op.add_column(sa.Column('is_encrypted', sa.Boolean(), nullable=True))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('mx_room_state', schema=None) as batch_op:
|
||||
batch_op.drop_column('is_encrypted')
|
||||
batch_op.drop_column('has_full_member_list')
|
||||
batch_op.drop_column('encryption')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,71 +0,0 @@
|
||||
"""Switch to mautrix-python crypto
|
||||
|
||||
Revision ID: ccbaff858240
|
||||
Revises: 3e3745baa458
|
||||
Create Date: 2020-07-08 19:06:12.588047
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ccbaff858240'
|
||||
down_revision = '3e3745baa458'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('nio_account')
|
||||
op.drop_table('nio_device_key')
|
||||
op.drop_table('nio_outgoing_key_request')
|
||||
op.drop_table('nio_olm_session')
|
||||
op.drop_table('nio_megolm_inbound_session')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('nio_megolm_inbound_session',
|
||||
sa.Column('session_id', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('sender_key', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('fp_key', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('room_id', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('session', postgresql.BYTEA(), autoincrement=False, nullable=False),
|
||||
sa.Column('forwarded_chains', postgresql.BYTEA(), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('session_id', name='nio_megolm_inbound_session_pkey')
|
||||
)
|
||||
op.create_table('nio_olm_session',
|
||||
sa.Column('session_id', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('sender_key', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('session', postgresql.BYTEA(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
||||
sa.Column('last_used', postgresql.TIMESTAMP(), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('session_id', name='nio_olm_session_pkey')
|
||||
)
|
||||
op.create_table('nio_outgoing_key_request',
|
||||
sa.Column('request_id', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('session_id', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('room_id', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('algorithm', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('request_id', name='nio_outgoing_key_request_pkey')
|
||||
)
|
||||
op.create_table('nio_device_key',
|
||||
sa.Column('user_id', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('device_id', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('display_name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('deleted', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('keys', postgresql.BYTEA(), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('user_id', 'device_id', name='nio_device_key_pkey')
|
||||
)
|
||||
op.create_table('nio_account',
|
||||
sa.Column('user_id', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('device_id', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('shared', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('sync_token', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('account', postgresql.BYTEA(), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('user_id', 'device_id', name='nio_account_pkey')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "0.9.0rc1"
|
||||
__version__ = "0.8.2"
|
||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Optional
|
||||
from itertools import chain
|
||||
|
||||
from alchemysession import AlchemySessionContainer
|
||||
|
||||
from mautrix.types import UserID, RoomID
|
||||
from mautrix.bridge import Bridge
|
||||
from mautrix.util.db import Base
|
||||
|
||||
@@ -31,8 +31,9 @@ from .context import Context
|
||||
from .db import init as init_db
|
||||
from .formatter import init as init_formatter
|
||||
from .matrix import MatrixHandler
|
||||
from .portal import Portal, init as init_portal
|
||||
from .portal import init as init_portal
|
||||
from .puppet import Puppet, init as init_puppet
|
||||
from .sqlstatestore import SQLStateStore
|
||||
from .user import User, init as init_user
|
||||
from .version import version, linkified_version
|
||||
|
||||
@@ -53,6 +54,7 @@ class TelegramBridge(Bridge):
|
||||
markdown_version = linkified_version
|
||||
config_class = Config
|
||||
matrix_class = MatrixHandler
|
||||
state_store_class = SQLStateStore
|
||||
|
||||
config: Config
|
||||
session_container: AlchemySessionContainer
|
||||
@@ -78,6 +80,13 @@ class TelegramBridge(Bridge):
|
||||
provisioning_api.app)
|
||||
context.provisioning_api = provisioning_api
|
||||
|
||||
if self.config["metrics.enabled"]:
|
||||
if prometheus:
|
||||
prometheus.start_http_server(self.config["metrics.listen_port"])
|
||||
else:
|
||||
self.log.warning("Metrics are enabled in the config, "
|
||||
"but prometheus_client is not installed.")
|
||||
|
||||
def prepare_bridge(self) -> None:
|
||||
self.bot = init_bot(self.config)
|
||||
context = Context(self.az, self.config, self.loop, self.session_container, self, self.bot)
|
||||
@@ -88,20 +97,10 @@ class TelegramBridge(Bridge):
|
||||
init_abstract_user(context)
|
||||
init_formatter(context)
|
||||
init_portal(context)
|
||||
self.add_startup_actions(init_puppet(context))
|
||||
self.add_startup_actions(init_user(context))
|
||||
if self.bot:
|
||||
self.add_startup_actions(self.bot.start())
|
||||
if self.config["bridge.resend_bridge_info"]:
|
||||
self.add_startup_actions(self.resend_bridge_info())
|
||||
|
||||
async def resend_bridge_info(self) -> None:
|
||||
self.config["bridge.resend_bridge_info"] = False
|
||||
self.config.save()
|
||||
self.log.info("Re-sending bridge info state event to all portals")
|
||||
for portal in Portal.all():
|
||||
await portal.update_bridge_info()
|
||||
self.log.info("Finished re-sending bridge info state events")
|
||||
puppet_startup = init_puppet(context)
|
||||
user_startup = init_user(context)
|
||||
bot_startup = [self.bot.start()] if self.bot else []
|
||||
self.startup_actions = chain(puppet_startup, user_startup, bot_startup)
|
||||
|
||||
def prepare_stop(self) -> None:
|
||||
for puppet in Puppet.by_custom_mxid.values():
|
||||
@@ -111,23 +110,5 @@ class TelegramBridge(Bridge):
|
||||
self.manhole.close()
|
||||
self.manhole = None
|
||||
|
||||
async def get_user(self, user_id: UserID, create: bool = True) -> User:
|
||||
user = User.get_by_mxid(user_id, create=create)
|
||||
if user:
|
||||
await user.ensure_started()
|
||||
return user
|
||||
|
||||
async def get_portal(self, room_id: RoomID) -> Portal:
|
||||
return Portal.get_by_mxid(room_id)
|
||||
|
||||
async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet:
|
||||
return await Puppet.get_by_mxid(user_id, create=create)
|
||||
|
||||
async def get_double_puppet(self, user_id: UserID) -> Puppet:
|
||||
return await Puppet.get_by_custom_mxid(user_id)
|
||||
|
||||
def is_bridge_ghost(self, user_id: UserID) -> bool:
|
||||
return bool(Puppet.get_id_from_mxid(user_id))
|
||||
|
||||
|
||||
TelegramBridge().run()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2020 Tulir Asokan
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
@@ -26,18 +26,16 @@ from telethon.network import (ConnectionTcpMTProxyRandomizedIntermediate, Connec
|
||||
from telethon.tl.patched import MessageService, Message
|
||||
from telethon.tl.types import (
|
||||
Channel, Chat, MessageActionChannelMigrateFrom, PeerUser, TypeUpdate, UpdateChatPinnedMessage,
|
||||
UpdateChannelPinnedMessage, UpdateChatParticipantAdmin, UpdateChatParticipants, PeerChat,
|
||||
UpdateChannelPinnedMessage, UpdateChatParticipantAdmin, UpdateChatParticipants,
|
||||
UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateNewMessage, UpdateDeleteMessages,
|
||||
UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateReadHistoryOutbox,
|
||||
UpdateShortChatMessage, UpdateShortMessage, UpdateUserName, UpdateUserPhoto, UpdateUserStatus,
|
||||
UpdateUserTyping, User, UserStatusOffline, UserStatusOnline, UpdateReadHistoryInbox,
|
||||
UpdateReadChannelInbox, MessageEmpty)
|
||||
UpdateUserTyping, User, UserStatusOffline, UserStatusOnline)
|
||||
|
||||
from mautrix.types import UserID, PresenceState
|
||||
from mautrix.errors import MatrixError
|
||||
from mautrix.appservice import AppService
|
||||
from mautrix.util.logging import TraceLogger
|
||||
from mautrix.util.opt_prometheus import Histogram, Counter
|
||||
from alchemysession import AlchemySessionContainer
|
||||
|
||||
from . import portal as po, puppet as pu, __version__
|
||||
@@ -58,10 +56,14 @@ UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChann
|
||||
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage]
|
||||
UpdateMessageContent = Union[UpdateShortMessage, UpdateShortChatMessage, Message, MessageService]
|
||||
|
||||
UPDATE_TIME = Histogram("bridge_telegram_update", "Time spent processing Telegram updates",
|
||||
("update_type",))
|
||||
UPDATE_ERRORS = Counter("bridge_telegram_update_error",
|
||||
"Number of fatal errors while handling Telegram updates", ("update_type",))
|
||||
try:
|
||||
from prometheus_client import Histogram
|
||||
|
||||
UPDATE_TIME = Histogram("telegram_update", "Time spent processing Telegram updates",
|
||||
["update_type"])
|
||||
except ImportError:
|
||||
Histogram = None
|
||||
UPDATE_TIME = None
|
||||
|
||||
|
||||
class AbstractUser(ABC):
|
||||
@@ -164,7 +166,6 @@ class AbstractUser(ABC):
|
||||
request_retries=config["telegram.connection.request_retries"],
|
||||
connection=connection,
|
||||
proxy=proxy,
|
||||
raise_last_call_error=True,
|
||||
|
||||
loop=self.loop,
|
||||
base_logger=base_logger
|
||||
@@ -180,23 +181,22 @@ class AbstractUser(ABC):
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def register_portal(self, portal: po.Portal) -> None:
|
||||
def register_portal(self, portal: po.Portal) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
@abstractmethod
|
||||
async def unregister_portal(self, tgid: int, tg_receiver: int) -> None:
|
||||
def unregister_portal(self, portal: po.Portal) -> None:
|
||||
raise NotImplementedError()
|
||||
|
||||
async def _update_catch(self, update: TypeUpdate) -> None:
|
||||
start_time = time.time()
|
||||
update_type = type(update).__name__
|
||||
try:
|
||||
if not await self.update(update):
|
||||
await self._update(update)
|
||||
except Exception:
|
||||
self.log.exception(f"Failed to handle Telegram update {update}")
|
||||
UPDATE_ERRORS.labels(update_type=update_type).inc()
|
||||
UPDATE_TIME.labels(update_type=update_type).observe(time.time() - start_time)
|
||||
if UPDATE_TIME:
|
||||
UPDATE_TIME.labels(update_type=type(update).__name__).observe(time.time() - start_time)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
@@ -258,8 +258,6 @@ class AbstractUser(ABC):
|
||||
await self.update_others_info(update)
|
||||
elif isinstance(update, UpdateReadHistoryOutbox):
|
||||
await self.update_read_receipt(update)
|
||||
elif isinstance(update, (UpdateReadHistoryInbox, UpdateReadChannelInbox)):
|
||||
await self.update_own_read_receipt(update)
|
||||
else:
|
||||
self.log.trace("Unhandled update: %s", update)
|
||||
|
||||
@@ -276,7 +274,7 @@ class AbstractUser(ABC):
|
||||
async def update_participants(update: UpdateChatParticipants) -> None:
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
|
||||
if portal and portal.mxid:
|
||||
await portal.update_power_levels(update.participants.participants)
|
||||
await portal.update_telegram_participants(update.participants.participants)
|
||||
|
||||
async def update_read_receipt(self, update: UpdateReadHistoryOutbox) -> None:
|
||||
if not isinstance(update.peer, PeerUser):
|
||||
@@ -295,32 +293,6 @@ class AbstractUser(ABC):
|
||||
puppet = pu.Puppet.get(TelegramID(update.peer.user_id))
|
||||
await puppet.intent.mark_read(portal.mxid, message.mxid)
|
||||
|
||||
async def update_own_read_receipt(self, update: Union[UpdateReadHistoryInbox,
|
||||
UpdateReadChannelInbox]) -> None:
|
||||
puppet = pu.Puppet.get(self.tgid)
|
||||
if not puppet.is_real_user:
|
||||
return
|
||||
|
||||
if isinstance(update, UpdateReadChannelInbox):
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
||||
elif isinstance(update.peer, PeerChat):
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.peer.chat_id))
|
||||
elif isinstance(update.peer, PeerUser):
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.peer.user_id), self.tgid)
|
||||
else:
|
||||
self.log.debug("Unexpected own read receipt peer: %s", update.peer)
|
||||
return
|
||||
|
||||
if not portal or not portal.mxid:
|
||||
return
|
||||
|
||||
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
|
||||
message = DBMessage.get_one_by_tgid(TelegramID(update.max_id), tg_space, edit_index=-1)
|
||||
if not message:
|
||||
return
|
||||
|
||||
await puppet.intent.mark_read(portal.mxid, message.mxid)
|
||||
|
||||
async def update_admin(self, update: UpdateChatParticipantAdmin) -> None:
|
||||
# TODO duplication not checked
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
||||
@@ -357,10 +329,10 @@ class AbstractUser(ABC):
|
||||
if isinstance(update, UpdateUserName):
|
||||
puppet.username = update.username
|
||||
if await puppet.update_displayname(self, update):
|
||||
await puppet.save()
|
||||
puppet.save()
|
||||
elif isinstance(update, UpdateUserPhoto):
|
||||
if await puppet.update_avatar(self, update.photo):
|
||||
await puppet.save()
|
||||
puppet.save()
|
||||
else:
|
||||
self.log.warning(f"Unexpected other user info update: {type(update)}")
|
||||
|
||||
@@ -388,18 +360,14 @@ class AbstractUser(ABC):
|
||||
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
|
||||
UpdateEditMessage, UpdateEditChannelMessage)):
|
||||
update = update.message
|
||||
if isinstance(update, MessageEmpty):
|
||||
return update, None, None
|
||||
portal = po.Portal.get_by_entity(update.peer_id, receiver_id=self.tgid)
|
||||
if update.out:
|
||||
sender = pu.Puppet.get(self.tgid)
|
||||
elif isinstance(update.from_id, PeerUser):
|
||||
sender = pu.Puppet.get(TelegramID(update.from_id.user_id))
|
||||
if isinstance(update.to_id, PeerUser) and not update.out:
|
||||
portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
|
||||
tg_receiver=self.tgid)
|
||||
else:
|
||||
sender = None
|
||||
portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
|
||||
sender = pu.Puppet.get(update.from_id) if update.from_id else None
|
||||
else:
|
||||
self.log.warning("Unexpected message type in User#get_message_details: "
|
||||
f"{type(update)}")
|
||||
self.log.warning(f"Unexpected message type in User#get_message_details: {type(update)}")
|
||||
return update, None, None
|
||||
return update, sender, portal
|
||||
|
||||
@@ -458,14 +426,10 @@ class AbstractUser(ABC):
|
||||
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
|
||||
return
|
||||
|
||||
await portal.backfill_lock.wait(update.id)
|
||||
|
||||
if isinstance(update, MessageService):
|
||||
if isinstance(update.action, MessageActionChannelMigrateFrom):
|
||||
self.log.trace(f"Received %s in %s by %d, unregistering portal...",
|
||||
update.action, portal.tgid_log, sender.id)
|
||||
await self.unregister_portal(update.action.chat_id, update.action.chat_id)
|
||||
await self.register_portal(portal)
|
||||
self.log.trace(f"Ignoring action %s to %s by %d", update.action, portal.tgid_log,
|
||||
sender.id)
|
||||
return
|
||||
self.log.trace("Handling action %s to %s by %d", update.action, portal.tgid_log,
|
||||
sender.id)
|
||||
|
||||
+19
-20
@@ -117,11 +117,11 @@ class Bot(AbstractUser):
|
||||
except (ChannelPrivateError, ChannelInvalidError):
|
||||
self.remove_chat(TelegramID(channel_id.channel_id))
|
||||
|
||||
async def register_portal(self, portal: po.Portal) -> None:
|
||||
def register_portal(self, portal: po.Portal) -> None:
|
||||
self.add_chat(portal.tgid, portal.peer_type)
|
||||
|
||||
async def unregister_portal(self, tgid: int, tg_receiver: int) -> None:
|
||||
self.remove_chat(tgid)
|
||||
def unregister_portal(self, portal: po.Portal) -> None:
|
||||
self.remove_chat(portal.tgid)
|
||||
|
||||
def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
|
||||
if chat_id not in self.chats:
|
||||
@@ -226,7 +226,7 @@ class Bot(AbstractUser):
|
||||
|
||||
return False
|
||||
|
||||
async def handle_command(self, message: Message) -> None:
|
||||
async def handle_command(self, message: Message) -> Optional[bool]:
|
||||
def reply(reply_text: str) -> Awaitable[Message]:
|
||||
return self.client.send_message(message.chat_id, reply_text, reply_to=message.id)
|
||||
|
||||
@@ -234,8 +234,9 @@ class Bot(AbstractUser):
|
||||
|
||||
if self.match_command(text, "start"):
|
||||
pcm = config["bridge.relaybot.private_chat.message"]
|
||||
if pcm:
|
||||
await reply(pcm)
|
||||
if not pcm:
|
||||
return True
|
||||
await reply(pcm)
|
||||
return
|
||||
elif self.match_command(text, "id"):
|
||||
await self.handle_command_id(message, reply)
|
||||
@@ -245,19 +246,18 @@ class Bot(AbstractUser):
|
||||
|
||||
portal = po.Portal.get_by_entity(message.to_id)
|
||||
|
||||
is_portal_cmd = self.match_command(text, "portal")
|
||||
is_invite_cmd = self.match_command(text, "invite")
|
||||
if is_portal_cmd or is_invite_cmd:
|
||||
if self.match_command(text, "portal"):
|
||||
if not await self.check_can_use_commands(message, reply):
|
||||
return
|
||||
if is_portal_cmd:
|
||||
await self.handle_command_portal(portal, reply)
|
||||
elif is_invite_cmd:
|
||||
try:
|
||||
mxid = text[text.index(" ") + 1:]
|
||||
except ValueError:
|
||||
mxid = ""
|
||||
await self.handle_command_invite(portal, reply, mxid_input=UserID(mxid))
|
||||
await self.handle_command_portal(portal, reply)
|
||||
elif self.match_command(text, "invite"):
|
||||
if not await self.check_can_use_commands(message, reply):
|
||||
return
|
||||
try:
|
||||
mxid = text[text.index(" ") + 1:]
|
||||
except ValueError:
|
||||
mxid = ""
|
||||
await self.handle_command_invite(portal, reply, mxid_input=UserID(mxid))
|
||||
|
||||
def handle_service_message(self, message: MessageService) -> None:
|
||||
to_peer = message.to_id
|
||||
@@ -288,10 +288,9 @@ class Bot(AbstractUser):
|
||||
|
||||
is_command = (isinstance(update.message, Message)
|
||||
and update.message.entities and len(update.message.entities) > 0
|
||||
and isinstance(update.message.entities[0], MessageEntityBotCommand)
|
||||
and update.message.entities[0].offset == 0)
|
||||
and isinstance(update.message.entities[0], MessageEntityBotCommand))
|
||||
if is_command:
|
||||
await self.handle_command(update.message)
|
||||
return not await self.handle_command(update.message)
|
||||
return False
|
||||
|
||||
def is_in_chat(self, peer_id) -> bool:
|
||||
|
||||
@@ -17,7 +17,7 @@ from typing import List, NamedTuple, Tuple, Union
|
||||
|
||||
from mautrix.appservice import IntentAPI
|
||||
from mautrix.errors import MatrixRequestError
|
||||
from mautrix.types import RoomID, UserID, EventID, EventType
|
||||
from mautrix.types import RoomID, UserID, EventID
|
||||
|
||||
from . import command_handler, CommandEvent, SECTION_ADMIN
|
||||
from .. import puppet as pu, portal as po
|
||||
@@ -25,11 +25,10 @@ from .. import puppet as pu, portal as po
|
||||
ManagementRoom = NamedTuple('ManagementRoom', room_id=RoomID, user_id=UserID)
|
||||
|
||||
|
||||
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[RoomID], List[RoomID],
|
||||
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[RoomID],
|
||||
List['po.Portal'], List['po.Portal']]:
|
||||
management_rooms: List[ManagementRoom] = []
|
||||
unidentified_rooms: List[RoomID] = []
|
||||
tombstoned_rooms: List[RoomID] = []
|
||||
portals: List[po.Portal] = []
|
||||
empty_portals: List[po.Portal] = []
|
||||
|
||||
@@ -37,13 +36,6 @@ async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[Roo
|
||||
for room_id in rooms:
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
if not portal:
|
||||
try:
|
||||
tombstone = await intent.get_state_event(room_id, EventType.ROOM_TOMBSTONE)
|
||||
if tombstone and tombstone.replacement_room:
|
||||
tombstoned_rooms.append(room_id)
|
||||
continue
|
||||
except MatrixRequestError:
|
||||
pass
|
||||
try:
|
||||
members = await intent.get_room_members(room_id)
|
||||
except MatrixRequestError:
|
||||
@@ -63,15 +55,14 @@ async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[Roo
|
||||
else:
|
||||
portals.append(portal)
|
||||
|
||||
return management_rooms, unidentified_rooms, tombstoned_rooms, portals, empty_portals
|
||||
return management_rooms, unidentified_rooms, portals, empty_portals
|
||||
|
||||
|
||||
@command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms",
|
||||
help_section=SECTION_ADMIN,
|
||||
help_text="Clean up unused portal/management rooms.")
|
||||
async def clean_rooms(evt: CommandEvent) -> EventID:
|
||||
(management_rooms, unidentified_rooms, tombstoned_rooms,
|
||||
portals, empty_portals) = await _find_rooms(evt.az.intent)
|
||||
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}"
|
||||
@@ -86,10 +77,6 @@ async def clean_rooms(evt: CommandEvent) -> EventID:
|
||||
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("#### Tombstoned rooms (T)")
|
||||
reply += ([f"{n+1}. [T{n+1}](https://matrix.to/#/{room})"
|
||||
for n, room in enumerate(tombstoned_rooms)]
|
||||
or ["No tombstoned rooms found."])
|
||||
reply.append("#### Inactive portal rooms (I)")
|
||||
reply += ([f"{n}. [I{n}](https://matrix.to/#/{portal.mxid}) "
|
||||
f"(to Telegram chat \"{portal.title}\")"
|
||||
@@ -101,7 +88,7 @@ async def clean_rooms(evt: CommandEvent) -> EventID:
|
||||
"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, T)"),
|
||||
"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"
|
||||
@@ -112,8 +99,7 @@ async def clean_rooms(evt: CommandEvent) -> EventID:
|
||||
|
||||
evt.sender.command_status = {
|
||||
"next": lambda clean_evt: set_rooms_to_clean(clean_evt, management_rooms,
|
||||
unidentified_rooms, tombstoned_rooms, portals,
|
||||
empty_portals),
|
||||
unidentified_rooms, portals, empty_portals),
|
||||
"action": "Room cleaning",
|
||||
}
|
||||
|
||||
@@ -121,8 +107,8 @@ async def clean_rooms(evt: CommandEvent) -> EventID:
|
||||
|
||||
|
||||
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
|
||||
unidentified_rooms: List[RoomID], tombstoned_rooms: List[RoomID],
|
||||
portals: List["po.Portal"], empty_portals: List["po.Portal"]) -> None:
|
||||
unidentified_rooms: List[RoomID], portals: List["po.Portal"],
|
||||
empty_portals: List["po.Portal"]) -> None:
|
||||
command = evt.args[0]
|
||||
rooms_to_clean: List[Union[po.Portal, RoomID]] = []
|
||||
if command == "clean-recommended":
|
||||
@@ -140,8 +126,6 @@ async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
|
||||
rooms_to_clean += unidentified_rooms
|
||||
if "I" in groups_to_clean:
|
||||
rooms_to_clean += empty_portals
|
||||
if "T" in groups_to_clean:
|
||||
rooms_to_clean += tombstoned_rooms
|
||||
elif command == "clean-range":
|
||||
try:
|
||||
clean_range = evt.args[1]
|
||||
@@ -156,8 +140,6 @@ async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
|
||||
group = unidentified_rooms
|
||||
elif group == "I":
|
||||
group = empty_portals
|
||||
elif group == "T":
|
||||
group = tombstoned_rooms
|
||||
else:
|
||||
raise ValueError("Unknown group")
|
||||
rooms_to_clean = group[start - 1:end]
|
||||
|
||||
@@ -25,17 +25,11 @@ from mautrix.bridge.commands import (HelpSection, CommandEvent as BaseCommandEve
|
||||
CommandHandlerFunc, command_handler as base_command_handler)
|
||||
|
||||
from ..util import format_duration
|
||||
from .. import user as u, context as c, portal as po
|
||||
|
||||
|
||||
class HelpCacheKey(NamedTuple):
|
||||
is_management: bool
|
||||
is_portal: bool
|
||||
puppet_whitelisted: bool
|
||||
matrix_puppet_whitelisted: bool
|
||||
is_admin: bool
|
||||
is_logged_in: bool
|
||||
from .. import user as u, context as c
|
||||
|
||||
HelpCacheKey = NamedTuple('HelpCacheKey',
|
||||
is_management=bool, is_portal=bool, puppet_whitelisted=bool,
|
||||
matrix_puppet_whitelisted=bool, is_admin=bool, is_logged_in=bool)
|
||||
|
||||
SECTION_AUTH = HelpSection("Authentication", 10, "")
|
||||
SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "")
|
||||
@@ -46,13 +40,12 @@ SECTION_ADMIN = HelpSection("Administration", 50, "")
|
||||
|
||||
class CommandEvent(BaseCommandEvent):
|
||||
sender: u.User
|
||||
portal: po.Portal
|
||||
|
||||
def __init__(self, processor: 'CommandProcessor', room_id: RoomID, event_id: EventID,
|
||||
sender: u.User, command: str, args: List[str], content: MessageEventContent,
|
||||
portal: Optional['po.Portal'], is_management: bool, has_bridge_bot: bool) -> None:
|
||||
is_management: bool, is_portal: bool) -> None:
|
||||
super().__init__(processor, room_id, event_id, sender, command, args, content,
|
||||
portal, is_management, has_bridge_bot)
|
||||
is_management, is_portal)
|
||||
self.bridge = processor.bridge
|
||||
self.tgbot = processor.tgbot
|
||||
self.config = processor.config
|
||||
@@ -63,16 +56,19 @@ class CommandEvent(BaseCommandEvent):
|
||||
return self.sender.is_admin
|
||||
|
||||
async def get_help_key(self) -> HelpCacheKey:
|
||||
return HelpCacheKey(self.is_management, self.portal is not None,
|
||||
self.sender.puppet_whitelisted, self.sender.matrix_puppet_whitelisted,
|
||||
self.sender.is_admin, await self.sender.is_logged_in())
|
||||
return HelpCacheKey(self.is_management, self.is_portal, self.sender.puppet_whitelisted,
|
||||
self.sender.matrix_puppet_whitelisted, self.sender.is_admin,
|
||||
await self.sender.is_logged_in())
|
||||
|
||||
|
||||
class CommandHandler(BaseCommandHandler):
|
||||
name: str
|
||||
|
||||
management_only: bool
|
||||
needs_auth: bool
|
||||
needs_puppeting: bool
|
||||
needs_matrix_puppeting: bool
|
||||
needs_admin: bool
|
||||
|
||||
def __init__(self, handler: Callable[[CommandEvent], Awaitable[EventID]],
|
||||
management_only: bool, name: str, help_text: str, help_args: str,
|
||||
@@ -83,16 +79,25 @@ class CommandHandler(BaseCommandHandler):
|
||||
needs_matrix_puppeting=needs_matrix_puppeting, needs_admin=needs_admin)
|
||||
|
||||
async def get_permission_error(self, evt: CommandEvent) -> Optional[str]:
|
||||
if self.needs_puppeting and not evt.sender.puppet_whitelisted:
|
||||
if self.management_only and not evt.is_management:
|
||||
return (f"`{evt.command}` is a restricted command: "
|
||||
"you may only run it in management rooms.")
|
||||
elif self.needs_puppeting and not evt.sender.puppet_whitelisted:
|
||||
return "This command requires puppeting privileges."
|
||||
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
|
||||
return "This command requires Matrix puppeting privileges."
|
||||
return await super().get_permission_error(evt)
|
||||
elif self.needs_admin and not evt.sender.is_admin:
|
||||
return "This command requires administrator privileges."
|
||||
elif self.needs_auth and not await evt.sender.is_logged_in():
|
||||
return "This command requires you to be logged in."
|
||||
return None
|
||||
|
||||
def has_permission(self, key: HelpCacheKey) -> bool:
|
||||
return (super().has_permission(key) and
|
||||
return ((not self.management_only or key.is_management) and
|
||||
(not self.needs_puppeting or key.puppet_whitelisted) and
|
||||
(not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted))
|
||||
(not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted) and
|
||||
(not self.needs_admin or key.is_admin) and
|
||||
(not self.needs_auth or key.is_logged_in))
|
||||
|
||||
|
||||
def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: bool = True,
|
||||
@@ -110,9 +115,13 @@ def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: b
|
||||
|
||||
class CommandProcessor(BaseCommandProcessor):
|
||||
def __init__(self, context: c.Context) -> None:
|
||||
super().__init__(event_class=CommandEvent, bridge=context.bridge)
|
||||
super().__init__(az=context.az, config=context.config, event_class=CommandEvent,
|
||||
loop=context.loop, bridge=context.bridge)
|
||||
self.tgbot = context.bot
|
||||
self.bridge = context.bridge
|
||||
self.az, self.config, self.loop, self.tgbot = context.core
|
||||
self.public_website = context.public_website
|
||||
self.command_prefix = self.config["bridge.command_prefix"]
|
||||
|
||||
@staticmethod
|
||||
async def _run_handler(handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent
|
||||
|
||||
@@ -15,12 +15,34 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import asyncio
|
||||
|
||||
from mautrix.errors import MatrixRequestError
|
||||
from mautrix.types import EventID
|
||||
|
||||
from ... import portal as po, puppet as pu, user as u
|
||||
from .. import command_handler, CommandEvent, SECTION_ADMIN
|
||||
|
||||
|
||||
@command_handler(needs_admin=True, needs_auth=False, name="set-pl",
|
||||
help_section=SECTION_ADMIN,
|
||||
help_args="<_level_> [_mxid_]",
|
||||
help_text="Set a temporary power level without affecting Telegram.")
|
||||
async def set_power_level(evt: CommandEvent) -> EventID:
|
||||
try:
|
||||
level = int(evt.args[0])
|
||||
except (KeyError, IndexError):
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp set-pl <level> [mxid]`")
|
||||
except ValueError:
|
||||
return await evt.reply("The level must be an integer.")
|
||||
levels = await evt.az.intent.get_power_levels(evt.room_id)
|
||||
mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid
|
||||
levels.users[mxid] = level
|
||||
try:
|
||||
return await evt.az.intent.set_power_levels(evt.room_id, levels)
|
||||
except MatrixRequestError:
|
||||
evt.log.exception("Failed to set power level.")
|
||||
return await evt.reply("Failed to set power level.")
|
||||
|
||||
|
||||
@command_handler(needs_admin=True, needs_auth=False,
|
||||
help_section=SECTION_ADMIN,
|
||||
help_args="<`portal`|`puppet`|`user`>",
|
||||
@@ -64,7 +86,7 @@ async def reload_user(evt: CommandEvent) -> EventID:
|
||||
user = u.User.get_by_mxid(mxid, create=False)
|
||||
if not user:
|
||||
return await evt.reply("User not found")
|
||||
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
|
||||
puppet = pu.Puppet.get_by_custom_mxid(mxid)
|
||||
if puppet:
|
||||
puppet.sync_task.cancel()
|
||||
await user.stop()
|
||||
|
||||
@@ -177,7 +177,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]:
|
||||
portal.mxid = bridge_to_mxid
|
||||
portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id)
|
||||
portal.photo_id = ""
|
||||
await portal.save()
|
||||
portal.save()
|
||||
|
||||
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
|
||||
loop=evt.loop)
|
||||
|
||||
@@ -13,11 +13,9 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Awaitable, Any
|
||||
from typing import Awaitable
|
||||
from io import StringIO
|
||||
|
||||
from ruamel.yaml import YAMLError
|
||||
|
||||
from mautrix.util.config import yaml
|
||||
from mautrix.types import EventID
|
||||
|
||||
@@ -50,11 +48,7 @@ async def config(evt: CommandEvent) -> None:
|
||||
return
|
||||
|
||||
key = evt.args[1] if len(evt.args) > 1 else None
|
||||
try:
|
||||
value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None
|
||||
except YAMLError as e:
|
||||
await evt.reply(f"Invalid value provided. Values must be valid YAML.\n{e}")
|
||||
return
|
||||
value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None
|
||||
if cmd == "set":
|
||||
await config_set(evt, portal, key, value)
|
||||
elif cmd == "unset":
|
||||
@@ -63,7 +57,7 @@ async def config(evt: CommandEvent) -> None:
|
||||
await config_add_del(evt, portal, key, value, cmd)
|
||||
else:
|
||||
return
|
||||
await portal.save()
|
||||
portal.save()
|
||||
|
||||
|
||||
def config_help(evt: CommandEvent) -> Awaitable[EventID]:
|
||||
@@ -80,11 +74,14 @@ def config_help(evt: CommandEvent) -> Awaitable[EventID]:
|
||||
|
||||
|
||||
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[EventID]:
|
||||
return evt.reply(f"Room-specific config:\n{_str_value(portal.local_config).rstrip()}")
|
||||
stream = StringIO()
|
||||
yaml.dump(portal.local_config, stream)
|
||||
return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```")
|
||||
|
||||
|
||||
def config_defaults(evt: CommandEvent) -> Awaitable[EventID]:
|
||||
value = _str_value({
|
||||
stream = StringIO()
|
||||
yaml.dump({
|
||||
"bridge_notices": {
|
||||
"default": evt.config["bridge.bridge_notices.default"],
|
||||
"exceptions": evt.config["bridge.bridge_notices.exceptions"],
|
||||
@@ -95,25 +92,15 @@ def config_defaults(evt: CommandEvent) -> Awaitable[EventID]:
|
||||
"emote_format": evt.config["bridge.emote_format"],
|
||||
"state_event_formats": evt.config["bridge.state_event_formats"],
|
||||
"telegram_link_preview": evt.config["bridge.telegram_link_preview"],
|
||||
})
|
||||
return evt.reply(f"Bridge instance wide config:\n{value.rstrip()}")
|
||||
}, stream)
|
||||
return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```")
|
||||
|
||||
|
||||
def _str_value(value: Any) -> str:
|
||||
stream = StringIO()
|
||||
yaml.dump(value, stream)
|
||||
value_str = stream.getvalue()
|
||||
if "\n" in value_str:
|
||||
return f"\n```yaml\n{value_str}\n```\n"
|
||||
else:
|
||||
return f"`{value_str}`"
|
||||
|
||||
|
||||
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: Any) -> Awaitable[EventID]:
|
||||
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[EventID]:
|
||||
if not key or value is None:
|
||||
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
|
||||
elif util.recursive_set(portal.local_config, key, value):
|
||||
return evt.reply(f"Successfully set the value of `{key}` to {_str_value(value)}".rstrip())
|
||||
return evt.reply(f"Successfully set the value of `{key}` to `{value}`.")
|
||||
else:
|
||||
return evt.reply(f"Failed to set value of `{key}`. "
|
||||
"Does the path contain non-map types?")
|
||||
@@ -141,11 +128,11 @@ def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, c
|
||||
return evt.reply("`{key}` does not seem to be an array.")
|
||||
elif cmd == "add":
|
||||
if value in arr:
|
||||
return evt.reply(f"The array at `{key}` already contains {_str_value(value)}".rstrip())
|
||||
return evt.reply(f"The array at `{key}` already contains `{value}`.")
|
||||
arr.append(value)
|
||||
return evt.reply(f"Successfully added {_str_value(value)} to the array at `{key}`")
|
||||
return evt.reply(f"Successfully added `{value}` to the array at `{key}`")
|
||||
else:
|
||||
if value not in arr:
|
||||
return evt.reply(f"The array at `{key}` does not contain {_str_value(value)}")
|
||||
return evt.reply(f"The array at `{key}` does not contain `{value}`.")
|
||||
arr.remove(value)
|
||||
return evt.reply(f"Successfully removed {_str_value(value)} from the array at `{key}`")
|
||||
return evt.reply(f"Successfully removed `{value}` from the array at `{key}`")
|
||||
|
||||
@@ -35,7 +35,7 @@ async def sync_state(evt: CommandEvent) -> EventID:
|
||||
elif not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
|
||||
return await evt.reply(f"You do not have the permissions to synchronize this room.")
|
||||
|
||||
await portal.main_intent.get_joined_members(portal.mxid)
|
||||
await portal.sync_matrix_members()
|
||||
await evt.reply("Synchronization complete")
|
||||
|
||||
|
||||
|
||||
@@ -55,5 +55,6 @@ async def user_has_power_level(room_id: RoomID, intent: IntentAPI, sender: u.Use
|
||||
await intent.get_power_levels(room_id)
|
||||
except MatrixRequestError:
|
||||
return False
|
||||
event_type = EventType.find(f"net.maunium.telegram.{event}", t_class=EventType.Class.STATE)
|
||||
return await intent.state_store.has_power_level(room_id, sender.mxid, event_type)
|
||||
event_type = EventType.find(f"net.maunium.telegram.{event}")
|
||||
event_type.t_class = EventType.Class.STATE
|
||||
return intent.state_store.has_power_level(room_id, sender.mxid, event_type)
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Any, Dict, Optional
|
||||
import asyncio
|
||||
import io
|
||||
|
||||
from telethon.errors import ( # isort: skip
|
||||
AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError,
|
||||
@@ -23,24 +22,13 @@ from telethon.errors import ( # isort: skip
|
||||
PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError,
|
||||
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError,
|
||||
PhoneNumberInvalidError)
|
||||
from telethon.tl.types import User
|
||||
|
||||
from mautrix.types import (EventID, UserID, MediaMessageEventContent, ImageInfo, MessageType,
|
||||
TextMessageEventContent)
|
||||
from mautrix.types import EventID
|
||||
|
||||
from ... import user as u
|
||||
from ...types import TelegramID
|
||||
from ...commands import command_handler, CommandEvent, SECTION_AUTH
|
||||
from ...util import format_duration
|
||||
|
||||
try:
|
||||
import qrcode
|
||||
import PIL as _
|
||||
from telethon.tl.custom import QRLogin
|
||||
except ImportError:
|
||||
qrcode = None
|
||||
QRLogin = None
|
||||
|
||||
|
||||
@command_handler(needs_auth=False,
|
||||
help_section=SECTION_AUTH,
|
||||
@@ -116,76 +104,18 @@ async def enter_code_register(evt: CommandEvent) -> EventID:
|
||||
"Check console for more details.")
|
||||
|
||||
|
||||
@command_handler(needs_auth=False, management_only=True, help_section=SECTION_AUTH,
|
||||
help_text="Log in by scanning a QR code.")
|
||||
async def login_qr(evt: CommandEvent) -> EventID:
|
||||
login_as = evt.sender
|
||||
if len(evt.args) > 0 and evt.sender.is_admin:
|
||||
login_as = u.User.get_by_mxid(UserID(evt.args[0]))
|
||||
if not qrcode or not QRLogin:
|
||||
return await evt.reply("This bridge instance does not support logging in with a QR code.")
|
||||
if await login_as.is_logged_in():
|
||||
return await evt.reply(f"You are already logged in as {login_as.human_tg_id}.")
|
||||
|
||||
await login_as.ensure_started(even_if_no_session=True)
|
||||
qr_login = QRLogin(login_as.client, ignored_ids=[])
|
||||
qr_event_id: Optional[EventID] = None
|
||||
|
||||
async def upload_qr() -> None:
|
||||
nonlocal qr_event_id
|
||||
buffer = io.BytesIO()
|
||||
image = qrcode.make(qr_login.url)
|
||||
size = image.pixel_size
|
||||
image.save(buffer, "PNG")
|
||||
qr = buffer.getvalue()
|
||||
mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr))
|
||||
content = MediaMessageEventContent(body=qr_login.url, url=mxc, msgtype=MessageType.IMAGE,
|
||||
info=ImageInfo(mimetype="image/png", size=len(qr),
|
||||
width=size, height=size))
|
||||
if qr_event_id:
|
||||
content.set_edit(qr_event_id)
|
||||
await evt.az.intent.send_message(evt.room_id, content)
|
||||
else:
|
||||
content.set_reply(evt.event_id)
|
||||
qr_event_id = await evt.az.intent.send_message(evt.room_id, content)
|
||||
|
||||
retries = 4
|
||||
while retries > 0:
|
||||
await qr_login.recreate()
|
||||
await upload_qr()
|
||||
try:
|
||||
user = await qr_login.wait()
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
retries -= 1
|
||||
except SessionPasswordNeededError:
|
||||
evt.sender.command_status = {
|
||||
"next": enter_password,
|
||||
"login_as": login_as if login_as != evt.sender else None,
|
||||
"action": "Login (password entry)",
|
||||
}
|
||||
return await evt.reply("Your account has two-factor authentication. "
|
||||
"Please send your password here.")
|
||||
else:
|
||||
timeout = TextMessageEventContent(body="Login timed out", msgtype=MessageType.TEXT)
|
||||
timeout.set_edit(qr_event_id)
|
||||
return await evt.az.intent.send_message(evt.room_id, timeout)
|
||||
|
||||
return await _finish_sign_in(evt, user, login_as=login_as)
|
||||
|
||||
|
||||
@command_handler(needs_auth=False, management_only=True,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Get instructions on how to log in.")
|
||||
async def login(evt: CommandEvent) -> EventID:
|
||||
override_sender = False
|
||||
if len(evt.args) > 0 and evt.sender.is_admin:
|
||||
evt.sender = await u.User.get_by_mxid(UserID(evt.args[0])).ensure_started()
|
||||
evt.sender = await u.User.get_by_mxid(evt.args[0]).ensure_started()
|
||||
override_sender = True
|
||||
if await evt.sender.is_logged_in():
|
||||
return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.")
|
||||
|
||||
allow_matrix_login = evt.config["bridge.allow_matrix_login"]
|
||||
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
|
||||
if allow_matrix_login and not override_sender:
|
||||
evt.sender.command_status = {
|
||||
"next": enter_phone_or_token,
|
||||
@@ -295,8 +225,7 @@ async def enter_password(evt: CommandEvent) -> Optional[EventID]:
|
||||
return await evt.reply("This bridge instance does not allow in-Matrix login. "
|
||||
"Please use `$cmdprefix+sp login` to get login instructions")
|
||||
try:
|
||||
await _sign_in(evt, login_as=evt.sender.command_status.get("login_as", None),
|
||||
password=" ".join(evt.args))
|
||||
await _sign_in(evt, password=" ".join(evt.args))
|
||||
except AccessTokenInvalidError:
|
||||
return await evt.reply("That bot token is not valid.")
|
||||
except AccessTokenExpiredError:
|
||||
@@ -308,12 +237,20 @@ async def enter_password(evt: CommandEvent) -> Optional[EventID]:
|
||||
return None
|
||||
|
||||
|
||||
async def _sign_in(evt: CommandEvent, login_as: 'u.User' = None, **sign_in_info) -> EventID:
|
||||
login_as = login_as or evt.sender
|
||||
async def _sign_in(evt: CommandEvent, **sign_in_info) -> EventID:
|
||||
try:
|
||||
await login_as.ensure_started(even_if_no_session=True)
|
||||
user = await login_as.client.sign_in(**sign_in_info)
|
||||
await _finish_sign_in(evt, user)
|
||||
await evt.sender.ensure_started(even_if_no_session=True)
|
||||
user = await evt.sender.client.sign_in(**sign_in_info)
|
||||
existing_user = u.User.get_by_tgid(user.id)
|
||||
if existing_user and existing_user != evt.sender:
|
||||
await existing_user.log_out()
|
||||
await evt.reply(f"[{existing_user.displayname}]"
|
||||
f"(https://matrix.to/#/{existing_user.mxid})"
|
||||
" was logged out from the account.")
|
||||
asyncio.ensure_future(evt.sender.post_login(user, first_login=True), loop=evt.loop)
|
||||
evt.sender.command_status = None
|
||||
name = f"@{user.username}" if user.username else f"+{user.phone}"
|
||||
return await evt.reply(f"Successfully logged in as {name}")
|
||||
except PhoneCodeExpiredError:
|
||||
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
|
||||
except PhoneCodeInvalidError:
|
||||
@@ -329,25 +266,6 @@ async def _sign_in(evt: CommandEvent, login_as: 'u.User' = None, **sign_in_info)
|
||||
"Please send your password here.")
|
||||
|
||||
|
||||
async def _finish_sign_in(evt: CommandEvent, user: User, login_as: 'u.User' = None) -> EventID:
|
||||
login_as = login_as or evt.sender
|
||||
existing_user = u.User.get_by_tgid(TelegramID(user.id))
|
||||
if existing_user and existing_user != login_as:
|
||||
await existing_user.log_out()
|
||||
await evt.reply(f"[{existing_user.displayname}]"
|
||||
f"(https://matrix.to/#/{existing_user.mxid})"
|
||||
" was logged out from the account.")
|
||||
asyncio.ensure_future(login_as.post_login(user, first_login=True), loop=evt.loop)
|
||||
evt.sender.command_status = None
|
||||
name = f"@{user.username}" if user.username else f"+{user.phone}"
|
||||
if login_as != evt.sender:
|
||||
msg = (f"Successfully logged in [{login_as.mxid}](https://matrix.to/#/{login_as.mxid})"
|
||||
f" as {name}")
|
||||
else:
|
||||
msg = f"Successfully logged in as {name}"
|
||||
return await evt.reply(msg)
|
||||
|
||||
|
||||
@command_handler(needs_auth=True,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Log out from Telegram.")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2020 Tulir Asokan
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
@@ -185,10 +185,8 @@ async def sync(evt: CommandEvent) -> EventID:
|
||||
sync_only = None
|
||||
|
||||
if not sync_only or sync_only == "chats":
|
||||
await evt.reply("Synchronizing chats...")
|
||||
await evt.sender.sync_dialogs()
|
||||
await evt.sender.sync_dialogs(synchronous_create=True)
|
||||
if not sync_only or sync_only == "contacts":
|
||||
await evt.reply("Synchronizing contacts...")
|
||||
await evt.sender.sync_contacts()
|
||||
if not sync_only or sync_only == "me":
|
||||
await evt.sender.update_info()
|
||||
@@ -313,20 +311,16 @@ async def vote(evt: CommandEvent) -> EventID:
|
||||
|
||||
|
||||
@command_handler(help_section=SECTION_MISC, help_args="<_emoji_>",
|
||||
help_text="Roll a dice (\U0001F3B2), kick a football (\u26BD\uFE0F) or throw a "
|
||||
"dart (\U0001F3AF) or basketball (\U0001F3C0) on the Telegram servers.")
|
||||
help_text="Roll a dice (\U0001F3B2) or throw a dart (\U0001F3AF) "
|
||||
"on the Telegram servers.")
|
||||
async def random(evt: CommandEvent) -> EventID:
|
||||
if not evt.is_portal:
|
||||
return await evt.reply("You can only randomize values in portal rooms")
|
||||
return await evt.reply("You can only roll dice in portal rooms")
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
arg = evt.args[0] if len(evt.args) > 0 else "dice"
|
||||
emoticon = {
|
||||
"dart": "\U0001F3AF",
|
||||
"dice": "\U0001F3B2",
|
||||
"ball": "\U0001F3C0",
|
||||
"basketball": "\U0001F3C0",
|
||||
"football": "\u26BD",
|
||||
"soccer": "\u26BD",
|
||||
}.get(arg, arg)
|
||||
try:
|
||||
await evt.sender.client.send_media(await portal.get_input_entity(evt.sender),
|
||||
@@ -335,22 +329,15 @@ async def random(evt: CommandEvent) -> EventID:
|
||||
return await evt.reply("Invalid emoji for randomization")
|
||||
|
||||
|
||||
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, help_args="[_limit_]",
|
||||
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
||||
help_text="Backfill messages from Telegram history.")
|
||||
async def backfill(evt: CommandEvent) -> None:
|
||||
if not evt.is_portal:
|
||||
await evt.reply("You can only use backfill in portal rooms")
|
||||
return
|
||||
try:
|
||||
limit = int(evt.args[0])
|
||||
except (ValueError, IndexError):
|
||||
limit = -1
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
if not evt.config["bridge.backfill.normal_groups"] and portal.peer_type == "chat":
|
||||
await evt.reply("Backfilling normal groups is disabled in the bridge config")
|
||||
return
|
||||
try:
|
||||
await portal.backfill(evt.sender, limit=limit)
|
||||
await portal.backfill(evt.sender)
|
||||
except TakeoutInitDelayError:
|
||||
msg = ("Please accept the data export request from a mobile device, "
|
||||
"then re-run the backfill command.")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2020 Tulir Asokan
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
@@ -48,8 +48,6 @@ class Config(BaseBridgeConfig):
|
||||
super().do_update(helper)
|
||||
copy, copy_dict, base = helper
|
||||
|
||||
copy("homeserver.asmux")
|
||||
|
||||
if "appservice.protocol" in self and "appservice.address" not in self:
|
||||
protocol, hostname, port = (self["appservice.protocol"], self["appservice.hostname"],
|
||||
self["appservice.port"])
|
||||
@@ -91,12 +89,7 @@ class Config(BaseBridgeConfig):
|
||||
copy("bridge.sync_channel_members")
|
||||
copy("bridge.skip_deleted_members")
|
||||
copy("bridge.startup_sync")
|
||||
if "bridge.sync_dialog_limit" in self:
|
||||
base["bridge.sync_create_limit"] = self["bridge.sync_dialog_limit"]
|
||||
base["bridge.sync_update_limit"] = self["bridge.sync_dialog_limit"]
|
||||
else:
|
||||
copy("bridge.sync_update_limit")
|
||||
copy("bridge.sync_create_limit")
|
||||
copy("bridge.sync_dialog_limit")
|
||||
copy("bridge.sync_direct_chats")
|
||||
copy("bridge.max_telegram_delete")
|
||||
copy("bridge.sync_matrix_state")
|
||||
@@ -104,15 +97,7 @@ class Config(BaseBridgeConfig):
|
||||
copy("bridge.plaintext_highlights")
|
||||
copy("bridge.public_portals")
|
||||
copy("bridge.sync_with_custom_puppets")
|
||||
copy("bridge.sync_direct_chat_list")
|
||||
copy("bridge.double_puppet_server_map")
|
||||
copy("bridge.double_puppet_allow_discovery")
|
||||
if "bridge.login_shared_secret" in self:
|
||||
base["bridge.login_shared_secret_map"] = {
|
||||
base["homeserver.domain"]: self["bridge.login_shared_secret"]
|
||||
}
|
||||
else:
|
||||
copy("bridge.login_shared_secret_map")
|
||||
copy("bridge.login_shared_secret")
|
||||
copy("bridge.telegram_link_preview")
|
||||
copy("bridge.inline_images")
|
||||
copy("bridge.image_as_file_size")
|
||||
@@ -123,20 +108,9 @@ class Config(BaseBridgeConfig):
|
||||
copy("bridge.animated_sticker.args")
|
||||
copy("bridge.encryption.allow")
|
||||
copy("bridge.encryption.default")
|
||||
copy("bridge.encryption.database")
|
||||
copy("bridge.encryption.key_sharing.allow")
|
||||
copy("bridge.encryption.key_sharing.require_cross_signing")
|
||||
copy("bridge.encryption.key_sharing.require_verification")
|
||||
copy("bridge.private_chat_portal_meta")
|
||||
copy("bridge.delivery_receipts")
|
||||
copy("bridge.delivery_error_reports")
|
||||
copy("bridge.resend_bridge_info")
|
||||
copy("bridge.backfill.invite_own_puppet")
|
||||
copy("bridge.backfill.takeout_limit")
|
||||
copy("bridge.backfill.initial_limit")
|
||||
copy("bridge.backfill.missed_limit")
|
||||
copy("bridge.backfill.disable_notifications")
|
||||
copy("bridge.backfill.normal_groups")
|
||||
|
||||
copy("bridge.initial_power_level_overrides.group")
|
||||
copy("bridge.initial_power_level_overrides.user")
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from sqlalchemy.engine.base import Engine
|
||||
|
||||
from mautrix.client.state_store.sqlalchemy import UserProfile, RoomState
|
||||
from mautrix.bridge.db import UserProfile, RoomState
|
||||
|
||||
from .bot_chat import BotChat
|
||||
from .message import Message
|
||||
@@ -24,8 +24,18 @@ from .puppet import Puppet
|
||||
from .telegram_file import TelegramFile
|
||||
from .user import User, UserPortal, Contact
|
||||
|
||||
try:
|
||||
from mautrix.bridge.db.nio_state_store import init as init_nio_db
|
||||
except ImportError:
|
||||
init_nio_db = None
|
||||
|
||||
|
||||
def init(db_engine: Engine) -> None:
|
||||
for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile,
|
||||
RoomState, BotChat):
|
||||
table.bind(db_engine)
|
||||
table.db = db_engine
|
||||
table.t = table.__table__
|
||||
table.c = table.t.c
|
||||
table.column_names = table.c.keys()
|
||||
if init_nio_db:
|
||||
init_nio_db(db_engine)
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Optional, Iterable
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Text, func, sql
|
||||
|
||||
from mautrix.types import RoomID, ContentURI
|
||||
from mautrix.types import RoomID
|
||||
from mautrix.util.db import Base
|
||||
|
||||
from ..types import TelegramID
|
||||
@@ -33,8 +33,7 @@ class Portal(Base):
|
||||
megagroup: bool = Column(Boolean)
|
||||
|
||||
# Matrix portal information
|
||||
mxid: Optional[RoomID] = Column(String, unique=True, nullable=True)
|
||||
avatar_url: Optional[ContentURI] = Column(String, nullable=True)
|
||||
mxid: RoomID = Column(String, unique=True, nullable=True)
|
||||
encrypted: bool = Column(Boolean, nullable=False, server_default=sql.expression.false())
|
||||
|
||||
config: str = Column(Text, nullable=True)
|
||||
@@ -49,10 +48,6 @@ class Portal(Base):
|
||||
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']:
|
||||
return cls._select_one_or_none(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver)
|
||||
|
||||
@classmethod
|
||||
def find_private_chats(cls, tg_receiver: TelegramID) -> Iterable['Portal']:
|
||||
yield from cls._select_all(cls.c.tg_receiver == tg_receiver, cls.c.peer_type == "user")
|
||||
|
||||
@classmethod
|
||||
def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
|
||||
return cls._select_one_or_none(cls.c.mxid == mxid)
|
||||
@@ -60,7 +55,3 @@ class Portal(Base):
|
||||
@classmethod
|
||||
def get_by_username(cls, username: str) -> Optional['Portal']:
|
||||
return cls._select_one_or_none(func.lower(cls.c.username) == username)
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> Iterable['Portal']:
|
||||
yield from cls._select_all()
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Optional, Iterable
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from sqlalchemy.sql import expression, func
|
||||
|
||||
from mautrix.types import UserID, SyncToken
|
||||
@@ -31,7 +31,6 @@ class Puppet(Base):
|
||||
custom_mxid: UserID = Column(String, nullable=True)
|
||||
access_token: str = Column(String, nullable=True)
|
||||
next_batch: SyncToken = Column(String, nullable=True)
|
||||
base_url: str = Column(Text, nullable=True)
|
||||
displayname: str = Column(String, nullable=True)
|
||||
displayname_source: TelegramID = Column(Integer, nullable=True)
|
||||
username: str = Column(String, nullable=True)
|
||||
|
||||
@@ -7,7 +7,6 @@ homeserver:
|
||||
# Whether or not to verify the SSL certificate of the homeserver.
|
||||
# Only applies if address starts with https://
|
||||
verify_ssl: true
|
||||
asmux: false
|
||||
|
||||
# Application service host/registration related details
|
||||
# Changing these values requires regeneration of the registration.
|
||||
@@ -31,8 +30,6 @@ appservice:
|
||||
# SQLite: sqlite:///filename.db
|
||||
# Postgres: postgres://username:password@hostname/dbname
|
||||
database: sqlite:///mautrix-telegram.db
|
||||
# Optional extra arguments for SQLAlchemy's create_engine
|
||||
database_opts: {}
|
||||
|
||||
# Public part of web server for out-of-Matrix interaction with the bridge.
|
||||
# Used for things like login if the user wants to make sure the 2FA password isn't stored in
|
||||
@@ -47,7 +44,7 @@ appservice:
|
||||
external: https://example.com/public
|
||||
|
||||
# Provisioning API part of the web server for automated portal creation and fetching information.
|
||||
# Used by things like mautrix-manager (https://github.com/tulir/mautrix-manager).
|
||||
# Used by things like Dimension (https://dimension.t2bot.io/).
|
||||
provisioning:
|
||||
# Whether or not the provisioning API should be enabled.
|
||||
enabled: true
|
||||
@@ -72,11 +69,6 @@ appservice:
|
||||
# Example: "+telegram:example.com". Set to false to disable.
|
||||
community_id: false
|
||||
|
||||
# Whether or not to receive ephemeral events via appservice transactions.
|
||||
# Requires MSC2409 support (i.e. Synapse 1.22+).
|
||||
# You should disable bridge -> sync_with_custom_puppets when this is enabled.
|
||||
ephemeral_events: false
|
||||
|
||||
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
|
||||
as_token: "This value is generated when generating the registration"
|
||||
hs_token: "This value is generated when generating the registration"
|
||||
@@ -137,8 +129,8 @@ bridge:
|
||||
# Maximum number of members to sync per portal when starting up. Other members will be
|
||||
# synced when they send messages. The maximum is 10000, after which the Telegram server
|
||||
# will not send any more members.
|
||||
# -1 means no limit (which means it's limited to 10000 by the server)
|
||||
max_initial_member_sync: 100
|
||||
# Defaults to no local limit (-> limited to 10000 by server)
|
||||
max_initial_member_sync: -1
|
||||
# Whether or not to sync the member list in channels.
|
||||
# If no channel admins have logged into the bridge, the bridge won't be able to sync the member
|
||||
# list regardless of this setting.
|
||||
@@ -150,10 +142,7 @@ bridge:
|
||||
startup_sync: true
|
||||
# Number of most recently active dialogs to check when syncing chats.
|
||||
# Set to 0 to remove limit.
|
||||
sync_update_limit: 0
|
||||
# Number of most recently active dialogs to create portals for when syncing chats.
|
||||
# Set to 0 to remove limit.
|
||||
sync_create_limit: 30
|
||||
sync_dialog_limit: 30
|
||||
# Whether or not to sync and create portals for direct chats at startup.
|
||||
sync_direct_chats: false
|
||||
# The maximum number of simultaneous Telegram deletions to handle.
|
||||
@@ -162,8 +151,8 @@ bridge:
|
||||
# Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames)
|
||||
# at startup and when creating a bridge.
|
||||
sync_matrix_state: true
|
||||
# Allow logging in within Matrix. If false, users can only log in using login-qr or the
|
||||
# out-of-Matrix login website (see appservice.public config section)
|
||||
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix
|
||||
# login website (see appservice.public config section)
|
||||
allow_matrix_login: true
|
||||
# Whether or not to bridge plaintext highlights.
|
||||
# Only enable this if your displayname_template has some static part that the bridge can use to
|
||||
@@ -171,27 +160,15 @@ bridge:
|
||||
plaintext_highlights: false
|
||||
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
|
||||
public_portals: true
|
||||
# Whether or not to use /sync to get presence, read receipts and typing notifications
|
||||
# when double puppeting is enabled
|
||||
# Whether or not to use /sync to get presence, read receipts and typing notifications when using
|
||||
# your own Matrix account as the Matrix puppet for your Telegram account.
|
||||
sync_with_custom_puppets: true
|
||||
# Whether or not to update the m.direct account data event when double puppeting is enabled.
|
||||
# Note that updating the m.direct event is not atomic (except with mautrix-asmux)
|
||||
# and is therefore prone to race conditions.
|
||||
sync_direct_chat_list: false
|
||||
# Servers to always allow double puppeting from
|
||||
double_puppet_server_map:
|
||||
example.com: https://example.com
|
||||
# Allow using double puppeting from any server with a valid client .well-known file.
|
||||
double_puppet_allow_discovery: false
|
||||
# Shared secrets for https://github.com/devture/matrix-synapse-shared-secret-auth
|
||||
# Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth
|
||||
#
|
||||
# If set, custom puppets will be enabled automatically for local users
|
||||
# instead of users having to find an access token and run `login-matrix`
|
||||
# manually.
|
||||
# If using this for other servers than the bridge's server,
|
||||
# you must also set the URL in the double_puppet_server_map.
|
||||
login_shared_secret_map:
|
||||
example.com: foobar
|
||||
login_shared_secret: null
|
||||
# Set to false to disable link previews in messages sent to Telegram.
|
||||
telegram_link_preview: true
|
||||
# Use inline images instead of a separate message for the caption.
|
||||
@@ -234,27 +211,6 @@ bridge:
|
||||
# Default to encryption, force-enable encryption in all portals the bridge creates
|
||||
# This will cause the bridge bot to be in private chats for the encryption to work properly.
|
||||
default: false
|
||||
# Database for the encryption data. Currently only supports Postgres and an in-memory
|
||||
# store that's persisted as a pickle.
|
||||
# If set to `default`, will use the appservice postgres database
|
||||
# or a pickle file if the appservice database is sqlite.
|
||||
#
|
||||
# Format examples:
|
||||
# Pickle: pickle:///filename.pickle
|
||||
# Postgres: postgres://username:password@hostname/dbname
|
||||
database: default
|
||||
# Options for automatic key sharing.
|
||||
key_sharing:
|
||||
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
|
||||
# You must use a client that supports requesting keys from other users to use this feature.
|
||||
allow: false
|
||||
# Require the requesting device to have a valid cross-signing signature?
|
||||
# This doesn't require that the bridge has verified the device, only that the user has verified it.
|
||||
# Not yet implemented.
|
||||
require_cross_signing: false
|
||||
# Require devices to be verified by the bridge?
|
||||
# Verification by the bridge is not yet implemented.
|
||||
require_verification: true
|
||||
# Whether or not to explicitly set the avatar and room name for private
|
||||
# chat portal rooms. This will be implicitly enabled if encryption.default is true.
|
||||
private_chat_portal_meta: false
|
||||
@@ -263,39 +219,6 @@ bridge:
|
||||
delivery_receipts: false
|
||||
# Whether or not delivery errors should be reported as messages in the Matrix room.
|
||||
delivery_error_reports: false
|
||||
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
|
||||
# This field will automatically be changed back to false after it,
|
||||
# except if the config file is not writable.
|
||||
resend_bridge_info: false
|
||||
# Settings for backfilling messages from Telegram.
|
||||
backfill:
|
||||
# Whether or not the Telegram ghosts of logged in Matrix users should be
|
||||
# invited to private chats when backfilling history from Telegram. This is
|
||||
# usually needed to prevent rate limits and to allow timestamp massaging.
|
||||
invite_own_puppet: true
|
||||
# Maximum number of messages to backfill without using a takeout.
|
||||
# The first time a takeout is used, the user has to manually approve it from a different
|
||||
# device. If initial_limit or missed_limit are higher than this value, the bridge will ask
|
||||
# the user to accept the takeout after logging in before syncing any chats.
|
||||
takeout_limit: 100
|
||||
# Maximum number of messages to backfill initially.
|
||||
# Set to 0 to disable backfilling when creating portal, or -1 to disable the limit.
|
||||
#
|
||||
# N.B. Initial backfill will only start after member sync. Make sure your
|
||||
# max_initial_member_sync is set to a low enough value so it doesn't take forever.
|
||||
initial_limit: 0
|
||||
# Maximum number of messages to backfill if messages were missed while the bridge was
|
||||
# disconnected. Note that this only works for logged in users and only if the chat isn't
|
||||
# older than sync_update_limit
|
||||
# Set to 0 to disable backfilling missed messages.
|
||||
missed_limit: 50
|
||||
# If using double puppeting, should notifications be disabled
|
||||
# while the initial backfill is in progress?
|
||||
disable_notifications: false
|
||||
# Whether or not to enable backfilling in normal groups.
|
||||
# Normal groups have numerous technical problems in Telegram, and backfilling normal groups
|
||||
# will likely cause problems if there are multiple Matrix users in the group.
|
||||
normal_groups: false
|
||||
|
||||
# Overrides for base power levels.
|
||||
initial_power_level_overrides:
|
||||
|
||||
@@ -48,7 +48,7 @@ class MatrixParser(BaseMatrixParser[TelegramMessage]):
|
||||
|
||||
@classmethod
|
||||
def user_pill_to_fstring(cls, msg: TelegramMessage, user_id: UserID) -> TelegramMessage:
|
||||
user = (pu.Puppet.deprecated_sync_get_by_mxid(user_id)
|
||||
user = (pu.Puppet.get_by_mxid(user_id)
|
||||
or u.User.get_by_mxid(user_id, create=False))
|
||||
if not user:
|
||||
return msg
|
||||
|
||||
@@ -22,7 +22,7 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
|
||||
MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold,
|
||||
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
|
||||
MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag,
|
||||
MessageEntityPhone, TypeMessageEntity, PeerChannel, PeerChat,
|
||||
MessageEntityPhone, TypeMessageEntity, PeerChannel,
|
||||
MessageEntityBlockquote, MessageEntityStrike, MessageFwdHeader,
|
||||
MessageEntityUnderline, PeerUser)
|
||||
from telethon.tl.custom import Message
|
||||
@@ -45,11 +45,11 @@ log: logging.Logger = logging.getLogger("mau.fmt.tg")
|
||||
|
||||
|
||||
def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Optional[RelatesTo]:
|
||||
if evt.reply_to:
|
||||
space = (evt.peer_id.channel_id
|
||||
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
|
||||
if evt.reply_to_msg_id:
|
||||
space = (evt.to_id.channel_id
|
||||
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
|
||||
else source.tgid)
|
||||
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
|
||||
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
|
||||
if msg:
|
||||
return RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
|
||||
return None
|
||||
@@ -61,15 +61,15 @@ async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventC
|
||||
content.format = Format.HTML
|
||||
content.formatted_body = escape(content.body)
|
||||
fwd_from_html, fwd_from_text = None, None
|
||||
if isinstance(fwd_from.from_id, PeerUser):
|
||||
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_id))
|
||||
if fwd_from.from_id:
|
||||
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id))
|
||||
if user:
|
||||
fwd_from_text = user.displayname or user.mxid
|
||||
fwd_from_html = (f"<a href='https://matrix.to/#/{user.mxid}'>"
|
||||
f"{escape(fwd_from_text)}</a>")
|
||||
|
||||
if not fwd_from_text:
|
||||
puppet = pu.Puppet.get(TelegramID(fwd_from.from_id.user_id), create=False)
|
||||
puppet = pu.Puppet.get(TelegramID(fwd_from.from_id), create=False)
|
||||
if puppet and puppet.displayname:
|
||||
fwd_from_text = puppet.displayname or puppet.mxid
|
||||
fwd_from_html = (f"<a href='https://matrix.to/#/{puppet.mxid}'>"
|
||||
@@ -77,16 +77,14 @@ async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventC
|
||||
|
||||
if not fwd_from_text:
|
||||
try:
|
||||
user = await source.client.get_entity(fwd_from.from_id)
|
||||
user = await source.client.get_entity(PeerUser(fwd_from.from_id))
|
||||
if user:
|
||||
fwd_from_text = pu.Puppet.get_displayname(user, False)
|
||||
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
|
||||
except (ValueError, RPCError):
|
||||
fwd_from_text = fwd_from_html = "unknown user"
|
||||
elif isinstance(fwd_from.from_id, (PeerChannel, PeerChat)):
|
||||
from_id = (fwd_from.from_id.chat_id if isinstance(fwd_from.from_id, PeerChat)
|
||||
else fwd_from.from_id.channel_id)
|
||||
portal = po.Portal.get_by_tgid(TelegramID(from_id))
|
||||
elif fwd_from.channel_id:
|
||||
portal = po.Portal.get_by_tgid(TelegramID(fwd_from.channel_id))
|
||||
if portal:
|
||||
fwd_from_text = portal.title
|
||||
if portal.alias:
|
||||
@@ -96,7 +94,7 @@ async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventC
|
||||
fwd_from_html = f"channel <b>{escape(fwd_from_text)}</b>"
|
||||
else:
|
||||
try:
|
||||
channel = await source.client.get_entity(fwd_from.from_id)
|
||||
channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
|
||||
if channel:
|
||||
fwd_from_text = f"channel {channel.title}"
|
||||
fwd_from_html = f"channel <b>{escape(channel.title)}</b>"
|
||||
@@ -118,11 +116,11 @@ async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventC
|
||||
|
||||
async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventContent, evt: Message,
|
||||
main_intent: IntentAPI):
|
||||
space = (evt.peer_id.channel_id
|
||||
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
|
||||
space = (evt.to_id.channel_id
|
||||
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
|
||||
else source.tgid)
|
||||
|
||||
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
|
||||
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
|
||||
if not msg:
|
||||
return
|
||||
|
||||
@@ -132,7 +130,7 @@ async def _add_reply_header(source: 'AbstractUser', content: TextMessageEventCon
|
||||
event: MessageEvent = await main_intent.get_event(msg.mx_room, msg.mxid)
|
||||
if isinstance(event.content, TextMessageEventContent):
|
||||
event.content.trim_reply_fallback()
|
||||
puppet = await pu.Puppet.get_by_mxid(event.sender, create=False)
|
||||
puppet = pu.Puppet.get_by_mxid(event.sender, create=False)
|
||||
content.set_reply(event, displayname=puppet.displayname if puppet else event.sender)
|
||||
except MatrixRequestError:
|
||||
log.exception("Failed to get event to add reply fallback")
|
||||
@@ -164,7 +162,7 @@ async def telegram_to_matrix(evt: Message, source: "AbstractUser",
|
||||
if evt.fwd_from:
|
||||
await _add_forward_header(source, content, evt.fwd_from)
|
||||
|
||||
if evt.reply_to and not no_reply_fallback:
|
||||
if evt.reply_to_msg_id and not no_reply_fallback:
|
||||
await _add_reply_header(source, content, evt, main_intent)
|
||||
|
||||
if isinstance(evt, Message) and evt.post and evt.post_author:
|
||||
|
||||
+51
-16
@@ -13,7 +13,7 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Dict, Set, Tuple, Union, Iterable, TYPE_CHECKING
|
||||
from typing import Dict, Set, Tuple, Union, Iterable, List, TYPE_CHECKING
|
||||
|
||||
from mautrix.bridge import BaseMatrixHandler
|
||||
from mautrix.types import (Event, EventType, RoomID, UserID, EventID, ReceiptEvent, ReceiptType,
|
||||
@@ -30,6 +30,14 @@ if TYPE_CHECKING:
|
||||
from .context import Context
|
||||
from .bot import Bot
|
||||
|
||||
try:
|
||||
from prometheus_client import Histogram
|
||||
|
||||
EVENT_TIME = Histogram("matrix_event", "Time spent processing Matrix events", ["event_type"])
|
||||
except ImportError:
|
||||
Histogram = None
|
||||
EVENT_TIME = None
|
||||
|
||||
RoomMetaStateEventContent = Union[RoomNameStateEventContent, RoomAvatarStateEventContent,
|
||||
RoomTopicStateEventContent]
|
||||
|
||||
@@ -45,15 +53,26 @@ class MatrixHandler(BaseMatrixHandler):
|
||||
self.user_id_prefix = f"@{prefix}"
|
||||
self.user_id_suffix = f"{suffix}:{homeserver}"
|
||||
|
||||
super().__init__(command_processor=com.CommandProcessor(context), bridge=context.bridge)
|
||||
super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop,
|
||||
command_processor=com.CommandProcessor(context),
|
||||
bridge=context.bridge)
|
||||
|
||||
self.bot = context.bot
|
||||
self.previously_typing = {}
|
||||
|
||||
async def get_user(self, user_id: UserID) -> 'u.User':
|
||||
return await u.User.get_by_mxid(user_id).ensure_started()
|
||||
|
||||
async def get_portal(self, room_id: RoomID) -> 'po.Portal':
|
||||
return po.Portal.get_by_mxid(room_id)
|
||||
|
||||
async def get_puppet(self, user_id: UserID) -> 'pu.Puppet':
|
||||
return pu.Puppet.get_by_mxid(user_id)
|
||||
|
||||
async def handle_puppet_invite(self, room_id: RoomID, puppet: pu.Puppet, inviter: u.User,
|
||||
event_id: EventID) -> None:
|
||||
intent = puppet.default_mxid_intent
|
||||
self.log.debug(f"{inviter.mxid} invited puppet for {puppet.tgid} to {room_id}")
|
||||
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room_id}")
|
||||
if not await inviter.is_logged_in():
|
||||
await intent.error_and_leave(
|
||||
room_id, text="Please log in before inviting Telegram puppets.")
|
||||
@@ -68,12 +87,11 @@ class MatrixHandler(BaseMatrixHandler):
|
||||
await intent.join_room(room_id)
|
||||
return
|
||||
try:
|
||||
members = await intent.get_room_members(room_id)
|
||||
members = await self.az.intent.get_room_members(room_id)
|
||||
except MatrixError:
|
||||
self.log.exception(f"Failed to get members after joining {room_id} as {intent.mxid}")
|
||||
return
|
||||
members = []
|
||||
if self.az.bot_mxid not in members:
|
||||
if len(members) > 2:
|
||||
if len(members) > 1:
|
||||
await intent.error_and_leave(room_id, text=None, html=(
|
||||
f"Please invite "
|
||||
f"<a href='https://matrix.to/#/{self.az.bot_mxid}'>the bridge bot</a> "
|
||||
@@ -96,9 +114,9 @@ class MatrixHandler(BaseMatrixHandler):
|
||||
portal.mxid = room_id
|
||||
e2be_ok = None
|
||||
if self.config["bridge.encryption.default"] and self.e2ee:
|
||||
e2be_ok = await portal.enable_dm_encryption()
|
||||
await portal.save()
|
||||
await inviter.register_portal(portal)
|
||||
e2be_ok = await self.enable_dm_encryption(portal, members=members)
|
||||
portal.save()
|
||||
inviter.register_portal(portal)
|
||||
if e2be_ok is True:
|
||||
evt_type, content = await self.e2ee.encrypt(
|
||||
room_id, EventType.ROOM_MESSAGE,
|
||||
@@ -116,6 +134,16 @@ class MatrixHandler(BaseMatrixHandler):
|
||||
await intent.send_notice(room_id, "This puppet will remain inactive until a "
|
||||
"Telegram chat is created for this room.")
|
||||
|
||||
async def enable_dm_encryption(self, portal: po.Portal, members: List[UserID]) -> bool:
|
||||
ok = await super().enable_dm_encryption(portal, members)
|
||||
if ok:
|
||||
try:
|
||||
puppet = pu.Puppet.get(portal.tgid)
|
||||
await portal.main_intent.set_room_name(portal.mxid, puppet.displayname)
|
||||
except Exception:
|
||||
self.log.warning(f"Failed to set room name for {portal.mxid}", exc_info=True)
|
||||
return ok
|
||||
|
||||
async def send_welcome_message(self, room_id: RoomID, inviter: 'u.User') -> None:
|
||||
try:
|
||||
is_management = len(await self.az.intent.get_room_members(room_id)) == 2
|
||||
@@ -198,7 +226,7 @@ class MatrixHandler(BaseMatrixHandler):
|
||||
return
|
||||
await sender.ensure_started()
|
||||
|
||||
puppet = await pu.Puppet.get_by_mxid(user_id)
|
||||
puppet = pu.Puppet.get_by_mxid(user_id)
|
||||
if puppet:
|
||||
if ban:
|
||||
await portal.ban_matrix(puppet, sender)
|
||||
@@ -362,12 +390,10 @@ class MatrixHandler(BaseMatrixHandler):
|
||||
self.previously_typing[room_id] = now_typing
|
||||
|
||||
def filter_matrix_event(self, evt: Event) -> bool:
|
||||
if isinstance(evt, (TypingEvent, ReceiptEvent, PresenceEvent)):
|
||||
return False
|
||||
elif not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent, EncryptedEvent)):
|
||||
if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent, EncryptedEvent)):
|
||||
return True
|
||||
if evt.content.get(self.az.real_user_content_key, False):
|
||||
puppet = pu.Puppet.deprecated_sync_get_by_custom_mxid(evt.sender)
|
||||
if evt.content.get("net.maunium.telegram.puppet", False):
|
||||
puppet = pu.Puppet.get_by_custom_mxid(evt.sender)
|
||||
if puppet:
|
||||
self.log.debug("Ignoring puppet-sent event %s", evt.event_id)
|
||||
return True
|
||||
@@ -404,3 +430,12 @@ class MatrixHandler(BaseMatrixHandler):
|
||||
elif evt.type == EventType.ROOM_TOMBSTONE:
|
||||
await self.handle_room_upgrade(evt.room_id, evt.sender, evt.content.replacement_room,
|
||||
evt.event_id)
|
||||
elif evt.type == EventType.ROOM_ENCRYPTION:
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
if portal:
|
||||
portal.encrypted = True
|
||||
portal.save()
|
||||
|
||||
async def log_event_handle_duration(self, evt: Event, duration: float) -> None:
|
||||
if EVENT_TIME:
|
||||
EVENT_TIME.labels(event_type=str(evt.type)).observe(duration)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Awaitable, Dict, List, Optional, Tuple, Union, Any, Set, Iterable, TYPE_CHECKING
|
||||
from typing import Awaitable, Dict, List, Optional, Tuple, Union, Any, Set, TYPE_CHECKING
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import logging
|
||||
@@ -31,11 +31,9 @@ from telethon.tl.types import (Channel, ChannelFull, Chat, ChatFull, ChatInviteE
|
||||
from mautrix.errors import MatrixRequestError, IntentError
|
||||
from mautrix.appservice import AppService, IntentAPI
|
||||
from mautrix.types import (RoomID, RoomAlias, UserID, EventID, EventType, MessageEventContent,
|
||||
PowerLevelStateEventContent, ContentURI)
|
||||
PowerLevelStateEventContent)
|
||||
from mautrix.util.simple_template import SimpleTemplate
|
||||
from mautrix.util.simple_lock import SimpleLock
|
||||
from mautrix.util.logging import TraceLogger
|
||||
from mautrix.bridge import BasePortal as MautrixBasePortal
|
||||
|
||||
from ..types import TelegramID
|
||||
from ..context import Context
|
||||
@@ -58,7 +56,7 @@ InviteList = Union[UserID, List[UserID]]
|
||||
config: Optional['Config'] = None
|
||||
|
||||
|
||||
class BasePortal(MautrixBasePortal, ABC):
|
||||
class BasePortal(ABC):
|
||||
base_log: TraceLogger = logging.getLogger("mau.portal")
|
||||
az: AppService = None
|
||||
bot: 'Bot' = None
|
||||
@@ -92,11 +90,9 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
about: Optional[str]
|
||||
photo_id: Optional[str]
|
||||
local_config: Dict[str, Any]
|
||||
avatar_url: Optional[ContentURI]
|
||||
encrypted: bool
|
||||
deleted: bool
|
||||
backfill_lock: SimpleLock
|
||||
backfill_method_lock: asyncio.Lock
|
||||
backfilling: bool
|
||||
backfill_leave: Optional[Set[IntentAPI]]
|
||||
log: TraceLogger
|
||||
|
||||
@@ -112,8 +108,8 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
mxid: Optional[RoomID] = None, username: Optional[str] = None,
|
||||
megagroup: Optional[bool] = False, title: Optional[str] = None,
|
||||
about: Optional[str] = None, photo_id: Optional[str] = None,
|
||||
local_config: Optional[str] = None, avatar_url: Optional[ContentURI] = None,
|
||||
encrypted: Optional[bool] = False, db_instance: DBPortal = None) -> None:
|
||||
local_config: Optional[str] = None, encrypted: Optional[bool] = False,
|
||||
db_instance: DBPortal = None) -> None:
|
||||
self.mxid = mxid
|
||||
self.tgid = tgid
|
||||
self.tg_receiver = tg_receiver or tgid
|
||||
@@ -124,15 +120,12 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
self.about = about
|
||||
self.photo_id = photo_id
|
||||
self.local_config = json.loads(local_config or "{}")
|
||||
self.avatar_url = avatar_url
|
||||
self.encrypted = encrypted
|
||||
self._db_instance = db_instance
|
||||
self._main_intent = None
|
||||
self.deleted = False
|
||||
self.log = self.base_log.getChild(self.tgid_log if self.tgid else self.mxid)
|
||||
self.backfill_lock = SimpleLock("Waiting for backfilling to finish before handling %s",
|
||||
log=self.log)
|
||||
self.backfill_method_lock = asyncio.Lock()
|
||||
self.backfilling = False
|
||||
self.backfill_leave = None
|
||||
|
||||
self.dedup = PortalDedup(self)
|
||||
@@ -213,8 +206,9 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
def _get_largest_photo_size(photo: Union[Photo, Document]
|
||||
) -> Tuple[Optional[InputPhotoFileLocation],
|
||||
Optional[TypePhotoSize]]:
|
||||
if not photo or isinstance(photo, PhotoEmpty) or (isinstance(photo, Document)
|
||||
and not photo.thumbs):
|
||||
if not photo:
|
||||
return None, None
|
||||
if isinstance(photo, Document) and not photo.thumbs:
|
||||
return None, None
|
||||
|
||||
largest = max(photo.thumbs if isinstance(photo, Document) else photo.sizes,
|
||||
@@ -238,8 +232,9 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
await self.main_intent.get_power_levels(self.mxid)
|
||||
except MatrixRequestError:
|
||||
return False
|
||||
evt_type = EventType.find(f"net.maunium.telegram.{event}", t_class=EventType.Class.STATE)
|
||||
return await self.main_intent.state_store.has_power_level(self.mxid, user.mxid, evt_type)
|
||||
evt_type = EventType.find(f"net.maunium.telegram.{event}")
|
||||
evt_type.t_class = EventType.Class.STATE
|
||||
return self.main_intent.state_store.has_power_level(self.mxid, user.mxid, evt_type)
|
||||
|
||||
def get_input_entity(self, user: 'AbstractUser'
|
||||
) -> Awaitable[Union[TypeInputPeer, TypeInputChannel]]:
|
||||
@@ -292,13 +287,12 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
@classmethod
|
||||
async def cleanup_room(cls, intent: IntentAPI, room_id: RoomID, message: str,
|
||||
puppets_only: bool = False) -> None:
|
||||
# TODO use the cleanup_room from BasePortal instead of this
|
||||
try:
|
||||
members = await intent.get_room_members(room_id)
|
||||
except MatrixRequestError:
|
||||
members = []
|
||||
for user in members:
|
||||
puppet = await p.Puppet.get_by_mxid(UserID(user), create=False)
|
||||
puppet = p.Puppet.get_by_mxid(UserID(user), create=False)
|
||||
if user != intent.mxid and (not puppets_only or puppet):
|
||||
try:
|
||||
if puppet:
|
||||
@@ -341,14 +335,12 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
|
||||
mxid=self.mxid, username=self.username, megagroup=self.megagroup,
|
||||
title=self.title, about=self.about, photo_id=self.photo_id,
|
||||
config=json.dumps(self.local_config), avatar_url=self.avatar_url,
|
||||
encrypted=self.encrypted)
|
||||
config=json.dumps(self.local_config), encrypted=self.encrypted)
|
||||
|
||||
async def save(self) -> None:
|
||||
def save(self) -> None:
|
||||
self.db_instance.edit(mxid=self.mxid, username=self.username, title=self.title,
|
||||
about=self.about, photo_id=self.photo_id, megagroup=self.megagroup,
|
||||
config=json.dumps(self.local_config), avatar_url=self.avatar_url,
|
||||
encrypted=self.encrypted)
|
||||
config=json.dumps(self.local_config), encrypted=self.encrypted)
|
||||
|
||||
def delete(self) -> None:
|
||||
try:
|
||||
@@ -370,20 +362,11 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
peer_type=db_portal.peer_type, mxid=db_portal.mxid, username=db_portal.username,
|
||||
megagroup=db_portal.megagroup, title=db_portal.title, about=db_portal.about,
|
||||
photo_id=db_portal.photo_id, local_config=db_portal.config,
|
||||
avatar_url=db_portal.avatar_url, encrypted=db_portal.encrypted,
|
||||
db_instance=db_portal)
|
||||
encrypted=db_portal.encrypted, db_instance=db_portal)
|
||||
|
||||
# endregion
|
||||
# region Class instance lookup
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> Iterable['Portal']:
|
||||
for db_portal in DBPortal.all():
|
||||
try:
|
||||
yield cls.by_tgid[(db_portal.tgid, db_portal.tg_receiver)]
|
||||
except KeyError:
|
||||
yield cls.from_db(db_portal)
|
||||
|
||||
@classmethod
|
||||
def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
|
||||
try:
|
||||
@@ -478,6 +461,15 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
type_name if create else None)
|
||||
|
||||
# endregion
|
||||
|
||||
async def _send_message(self, intent: IntentAPI, content: MessageEventContent,
|
||||
event_type: EventType = EventType.ROOM_MESSAGE, **kwargs) -> EventID:
|
||||
if self.encrypted and self.matrix.e2ee:
|
||||
if intent.api.is_real_user:
|
||||
content[intent.api.real_user_content_key] = True
|
||||
event_type, content = await self.matrix.e2ee.encrypt(self.mxid, event_type, content)
|
||||
return await intent.send_message_event(self.mxid, event_type, content, **kwargs)
|
||||
|
||||
# region Abstract methods (cross-called in matrix/metadata/telegram classes)
|
||||
|
||||
@abstractmethod
|
||||
@@ -517,10 +509,6 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
def _migrate_and_save_telegram(self, new_id: TelegramID) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def update_bridge_info(self) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def handle_matrix_power_levels(self, sender: 'u.User', new_levels: Dict[UserID, int],
|
||||
old_levels: Dict[UserID, int], event_id: Optional[EventID]
|
||||
@@ -528,13 +516,7 @@ class BasePortal(MautrixBasePortal, ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def backfill(self, source: 'AbstractUser', is_initial: bool = False,
|
||||
limit: Optional[int] = None, last_id: Optional[int] = None) -> Awaitable[None]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _send_delivery_receipt(self, event_id: EventID, room_id: Optional[RoomID] = None
|
||||
) -> None:
|
||||
def backfill(self, source: 'AbstractUser') -> Awaitable[None]:
|
||||
pass
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -50,7 +50,7 @@ class PortalDedup:
|
||||
|
||||
@property
|
||||
def _always_force_hash(self) -> bool:
|
||||
return self._portal.peer_type == 'chat'
|
||||
return self._portal.peer_type != 'channel'
|
||||
|
||||
@staticmethod
|
||||
def _hash_event(event: TypeMessage) -> str:
|
||||
@@ -69,7 +69,7 @@ class PortalDedup:
|
||||
hash_content += {
|
||||
MessageMediaContact: lambda media: [media.user_id],
|
||||
MessageMediaDocument: lambda media: [media.document.id],
|
||||
MessageMediaPhoto: lambda media: [media.photo.id if media.photo else 0],
|
||||
MessageMediaPhoto: lambda media: [media.photo.id],
|
||||
MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
|
||||
}[type(event.media)](event.media)
|
||||
except KeyError:
|
||||
|
||||
@@ -36,7 +36,8 @@ from telethon.tl.types import (
|
||||
|
||||
from mautrix.types import (EventID, RoomID, UserID, ContentURI, MessageType, MessageEventContent,
|
||||
TextMessageEventContent, MediaMessageEventContent, Format,
|
||||
LocationMessageEventContent, ImageInfo, VideoInfo)
|
||||
LocationMessageEventContent)
|
||||
from mautrix.bridge import BasePortal as MautrixBasePortal
|
||||
|
||||
from ..types import TelegramID
|
||||
from ..db import Message as DBMessage
|
||||
@@ -51,7 +52,7 @@ if TYPE_CHECKING:
|
||||
from ..config import Config
|
||||
|
||||
try:
|
||||
from mautrix.crypto.attachments import decrypt_attachment
|
||||
from nio.crypto import decrypt_attachment
|
||||
except ImportError:
|
||||
decrypt_attachment = None
|
||||
|
||||
@@ -60,7 +61,7 @@ TypeMessage = Union[Message, MessageService]
|
||||
config: Optional['Config'] = None
|
||||
|
||||
|
||||
class PortalMatrix(BasePortal, ABC):
|
||||
class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
|
||||
async def _get_state_change_message(self, event: str, user: 'u.User', **kwargs: Any
|
||||
) -> Optional[str]:
|
||||
tpl = self.get_config(f"state_event_formats.{event}")
|
||||
@@ -87,9 +88,9 @@ class PortalMatrix(BasePortal, ABC):
|
||||
message = await self._get_state_change_message(event, user, **kwargs)
|
||||
if not message:
|
||||
return
|
||||
message, entities = formatter.matrix_to_telegram(message)
|
||||
response = await self.bot.client.send_message(self.peer, message,
|
||||
formatting_entities=entities)
|
||||
response = await self.bot.client.send_message(
|
||||
self.peer, message,
|
||||
parse_mode=self._matrix_event_to_entities)
|
||||
space = self.tgid if self.peer_type == "channel" else self.bot.tgid
|
||||
self.dedup.check(response, (event_id, space))
|
||||
|
||||
@@ -228,25 +229,28 @@ class PortalMatrix(BasePortal, ABC):
|
||||
message, entities = None, None
|
||||
return message, entities
|
||||
|
||||
async def _send_delivery_receipt(self, event_id: EventID) -> None:
|
||||
if event_id and config["bridge.delivery_receipts"]:
|
||||
try:
|
||||
await self.az.intent.mark_read(self.mxid, event_id)
|
||||
except Exception:
|
||||
self.log.exception("Failed to send delivery receipt for %s", event_id)
|
||||
|
||||
async def _handle_matrix_text(self, sender_id: TelegramID, event_id: EventID,
|
||||
space: TelegramID, client: 'MautrixTelegramClient',
|
||||
content: TextMessageEventContent, reply_to: TelegramID) -> None:
|
||||
if content.formatted_body and content.format == Format.HTML:
|
||||
message, entities = formatter.matrix_to_telegram(content.formatted_body)
|
||||
else:
|
||||
message, entities = formatter.matrix_text_to_telegram(content.body)
|
||||
async with self.send_lock(sender_id):
|
||||
lp = self.get_config("telegram_link_preview")
|
||||
if content.get_edit():
|
||||
orig_msg = DBMessage.get_by_mxid(content.get_edit(), self.mxid, space)
|
||||
if orig_msg:
|
||||
response = await client.edit_message(self.peer, orig_msg.tgid, message,
|
||||
formatting_entities=entities,
|
||||
response = await client.edit_message(self.peer, orig_msg.tgid, content,
|
||||
parse_mode=self._matrix_event_to_entities,
|
||||
link_preview=lp)
|
||||
self._add_telegram_message_to_db(event_id, space, -1, response)
|
||||
return
|
||||
response = await client.send_message(self.peer, message, reply_to=reply_to,
|
||||
formatting_entities=entities,
|
||||
response = await client.send_message(self.peer, content, reply_to=reply_to,
|
||||
parse_mode=self._matrix_event_to_entities,
|
||||
link_preview=lp)
|
||||
self._add_telegram_message_to_db(event_id, space, 0, response)
|
||||
await self._send_delivery_receipt(event_id)
|
||||
@@ -256,10 +260,7 @@ class PortalMatrix(BasePortal, ABC):
|
||||
content: MediaMessageEventContent, reply_to: TelegramID,
|
||||
caption: TextMessageEventContent = None) -> None:
|
||||
mime = content.info.mimetype
|
||||
if isinstance(content.info, (ImageInfo, VideoInfo)):
|
||||
w, h = content.info.width, content.info.height
|
||||
else:
|
||||
w = h = None
|
||||
w, h = content.info.width, content.info.height
|
||||
file_name = content["net.maunium.telegram.internal.filename"]
|
||||
max_image_size = config["bridge.image_as_file_size"] * 1000 ** 2
|
||||
|
||||
@@ -301,13 +302,7 @@ class PortalMatrix(BasePortal, ABC):
|
||||
media = InputMediaUploadedDocument(file=file_handle, attributes=attributes,
|
||||
mime_type=mime or "application/octet-stream")
|
||||
|
||||
if caption:
|
||||
if caption.formatted_body and caption.format == Format.HTML:
|
||||
caption, entities = formatter.matrix_to_telegram(caption.formatted_body)
|
||||
else:
|
||||
caption, entities = formatter.matrix_text_to_telegram(content.body)
|
||||
else:
|
||||
caption, entities = None, None
|
||||
caption, entities = self._matrix_event_to_entities(caption) if caption else (None, None)
|
||||
|
||||
async with self.send_lock(sender_id):
|
||||
if await self._matrix_document_edit(client, content, space, caption, media, event_id):
|
||||
@@ -346,7 +341,7 @@ class PortalMatrix(BasePortal, ABC):
|
||||
except (KeyError, ValueError):
|
||||
self.log.exception("Failed to parse location")
|
||||
return None
|
||||
caption, entities = formatter.matrix_text_to_telegram(content.body)
|
||||
caption, entities = self._matrix_event_to_entities(content)
|
||||
media = MessageMediaGeo(geo=GeoPoint(lat, long, access_hash=0))
|
||||
|
||||
async with self.send_lock(sender_id):
|
||||
@@ -382,8 +377,7 @@ class PortalMatrix(BasePortal, ABC):
|
||||
await self._handle_matrix_message(sender, content, event_id)
|
||||
except RPCError as e:
|
||||
if config["bridge.delivery_error_reports"]:
|
||||
await self._send_bridge_error(
|
||||
f"\u26a0 Your message may not have been bridged: {e}")
|
||||
await self._send_bridge_error(f"\u26a0 Your message may not have been bridged: {e}")
|
||||
raise
|
||||
|
||||
async def _handle_matrix_message(self, sender: 'u.User', content: MessageEventContent,
|
||||
@@ -497,7 +491,7 @@ class PortalMatrix(BasePortal, ABC):
|
||||
peer = await self.get_input_entity(sender)
|
||||
await sender.client(EditChatAboutRequest(peer=peer, about=about))
|
||||
self.about = about
|
||||
await self.save()
|
||||
self.save()
|
||||
await self._send_delivery_receipt(event_id)
|
||||
|
||||
async def handle_matrix_title(self, sender: 'u.User', title: str, event_id: EventID) -> None:
|
||||
@@ -511,19 +505,15 @@ class PortalMatrix(BasePortal, ABC):
|
||||
response = await sender.client(EditTitleRequest(channel=channel, title=title))
|
||||
self.dedup.register_outgoing_actions(response)
|
||||
self.title = title
|
||||
await self.save()
|
||||
self.save()
|
||||
await self._send_delivery_receipt(event_id)
|
||||
await self.update_bridge_info()
|
||||
|
||||
async def handle_matrix_avatar(self, sender: 'u.User', url: ContentURI, event_id: EventID
|
||||
) -> None:
|
||||
if self.peer_type not in ("chat", "channel"):
|
||||
# Invalid peer type
|
||||
return
|
||||
elif self.avatar_url == url:
|
||||
return
|
||||
|
||||
self.avatar_url = url
|
||||
file = await self.main_intent.download_media(url)
|
||||
mime = magic.from_buffer(file, mime=True)
|
||||
ext = sane_mimetypes.guess_extension(mime)
|
||||
@@ -543,10 +533,9 @@ class PortalMatrix(BasePortal, ABC):
|
||||
if is_photo_update:
|
||||
loc, size = self._get_largest_photo_size(update.message.action.photo)
|
||||
self.photo_id = f"{size.location.volume_id}-{size.location.local_id}"
|
||||
await self.save()
|
||||
self.save()
|
||||
break
|
||||
await self._send_delivery_receipt(event_id)
|
||||
await self.update_bridge_info()
|
||||
|
||||
async def handle_matrix_upgrade(self, sender: UserID, new_room: RoomID, event_id: EventID
|
||||
) -> None:
|
||||
@@ -576,7 +565,7 @@ class PortalMatrix(BasePortal, ABC):
|
||||
return
|
||||
await self.update_matrix_room(user, entity, direct=self.peer_type == "user")
|
||||
self.log.info(f"{sender} upgraded room from {old_room} to {self.mxid}")
|
||||
await self._send_delivery_receipt(event_id, room_id=old_room)
|
||||
await self._send_delivery_receipt(event_id)
|
||||
|
||||
def migrate_and_save_matrix(self, new_id: RoomID) -> None:
|
||||
try:
|
||||
@@ -587,16 +576,6 @@ class PortalMatrix(BasePortal, ABC):
|
||||
self.db_instance.edit(mxid=self.mxid)
|
||||
self.by_mxid[self.mxid] = self
|
||||
|
||||
async def enable_dm_encryption(self) -> bool:
|
||||
ok = await super().enable_dm_encryption()
|
||||
if ok:
|
||||
try:
|
||||
puppet = p.Puppet.get(self.tgid)
|
||||
await self.main_intent.set_room_name(self.mxid, puppet.displayname)
|
||||
except Exception:
|
||||
self.log.warning(f"Failed to set room name", exc_info=True)
|
||||
return ok
|
||||
|
||||
|
||||
def init(context: Context) -> None:
|
||||
global config
|
||||
|
||||
+130
-209
@@ -13,7 +13,7 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import List, Optional, Iterable, Union, Dict, Any, TYPE_CHECKING
|
||||
from typing import List, Optional, Tuple, Union, Callable, Awaitable, TYPE_CHECKING
|
||||
from abc import ABC
|
||||
import asyncio
|
||||
|
||||
@@ -29,10 +29,10 @@ from telethon.tl.types import (
|
||||
ChatParticipantCreator, ChannelParticipantCreator, UserProfilePhoto, UserProfilePhotoEmpty)
|
||||
|
||||
from mautrix.errors import MForbidden
|
||||
from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership,
|
||||
from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership, Member,
|
||||
PowerLevelStateEventContent, RoomTopicStateEventContent,
|
||||
RoomNameStateEventContent, RoomAvatarStateEventContent,
|
||||
StateEventContent, EventID)
|
||||
StateEventContent)
|
||||
|
||||
from ..types import TelegramID
|
||||
from ..context import Context
|
||||
@@ -45,9 +45,6 @@ if TYPE_CHECKING:
|
||||
|
||||
config: Optional['Config'] = None
|
||||
|
||||
StateBridge = EventType.find("m.bridge", EventType.Class.STATE)
|
||||
StateHalfShotBridge = EventType.find("uk.half-shot.bridge", EventType.Class.STATE)
|
||||
|
||||
|
||||
class PortalMetadata(BasePortal, ABC):
|
||||
_room_create_lock: asyncio.Lock
|
||||
@@ -114,7 +111,7 @@ class PortalMetadata(BasePortal, ABC):
|
||||
await source.client(
|
||||
UpdateUsernameRequest(await self.get_input_entity(source), username))
|
||||
if await self._update_username(username):
|
||||
await self.save()
|
||||
self.save()
|
||||
|
||||
async def create_telegram_chat(self, source: 'u.User', supergroup: bool = False) -> None:
|
||||
if not self.mxid:
|
||||
@@ -172,6 +169,17 @@ class PortalMetadata(BasePortal, ABC):
|
||||
elif not self.bot or self.tg_receiver != self.bot.tgid:
|
||||
raise ValueError("Invalid peer type for Telegram user invite")
|
||||
|
||||
async def sync_matrix_members(self) -> None:
|
||||
resp = await self.main_intent.get_room_joined_memberships(self.mxid)
|
||||
members = resp["joined"]
|
||||
for mxid, info in members.items():
|
||||
member = Member(membership=Membership.JOIN)
|
||||
if "display_name" in info:
|
||||
member.displayname = info["display_name"]
|
||||
if "avatar_url" in info:
|
||||
member.avatar_url = info["avatar_url"]
|
||||
self.az.state_store.set_member(self.mxid, mxid, member)
|
||||
|
||||
# endregion
|
||||
# region Telegram -> Matrix
|
||||
|
||||
@@ -185,24 +193,27 @@ class PortalMetadata(BasePortal, ABC):
|
||||
async def update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
|
||||
direct: bool = None, puppet: p.Puppet = None,
|
||||
levels: PowerLevelStateEventContent = None,
|
||||
users: List[User] = None) -> None:
|
||||
users: List[User] = None,
|
||||
participants: List[TypeParticipant] = None) -> None:
|
||||
if direct is None:
|
||||
direct = self.peer_type == "user"
|
||||
try:
|
||||
await self._update_matrix_room(user, entity, direct, puppet, levels, users)
|
||||
await self._update_matrix_room(user, entity, direct, puppet, levels, users,
|
||||
participants)
|
||||
except Exception:
|
||||
self.log.exception("Fatal error updating Matrix room")
|
||||
|
||||
async def _update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
|
||||
direct: bool, puppet: p.Puppet = None,
|
||||
levels: PowerLevelStateEventContent = None,
|
||||
users: List[User] = None) -> None:
|
||||
users: List[User] = None,
|
||||
participants: List[TypeParticipant] = None) -> None:
|
||||
if not direct:
|
||||
await self.update_info(user, entity)
|
||||
if not users:
|
||||
users = await self._get_users(user, entity)
|
||||
if not users or not participants:
|
||||
users, participants = await self._get_users(user, entity)
|
||||
await self._sync_telegram_users(user, users)
|
||||
await self.update_power_levels(users, levels)
|
||||
await self.update_telegram_participants(participants, levels)
|
||||
else:
|
||||
if not puppet:
|
||||
puppet = p.Puppet.get(self.tgid)
|
||||
@@ -214,24 +225,13 @@ class PortalMetadata(BasePortal, ABC):
|
||||
changed = await self._update_title(puppet.displayname)
|
||||
changed = await self._update_avatar(user, entity.photo) or changed
|
||||
if changed:
|
||||
await self.save()
|
||||
await self.update_bridge_info()
|
||||
|
||||
puppet = await p.Puppet.get_by_custom_mxid(user.mxid)
|
||||
if puppet:
|
||||
try:
|
||||
did_join = await puppet.intent.ensure_joined(self.mxid)
|
||||
if isinstance(user, u.User) and did_join and self.peer_type == "user":
|
||||
await user.update_direct_chats({self.main_intent.mxid: [self.mxid]})
|
||||
except Exception:
|
||||
self.log.exception("Failed to ensure %s is joined to portal", user.mxid)
|
||||
|
||||
self.save()
|
||||
if self.sync_matrix_state:
|
||||
await self.main_intent.get_joined_members(self.mxid)
|
||||
await self.sync_matrix_members()
|
||||
|
||||
async def create_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User] = None,
|
||||
invites: InviteList = None, update_if_exists: bool = True
|
||||
) -> Optional[RoomID]:
|
||||
invites: InviteList = None, update_if_exists: bool = True,
|
||||
synchronous: bool = False) -> Optional[str]:
|
||||
if self.mxid:
|
||||
if update_if_exists:
|
||||
if not entity:
|
||||
@@ -241,7 +241,10 @@ class PortalMetadata(BasePortal, ABC):
|
||||
self.log.exception(f"Failed to get entity through {user.tgid} for update")
|
||||
return self.mxid
|
||||
update = self.update_matrix_room(user, entity, self.peer_type == "user")
|
||||
self.loop.create_task(update)
|
||||
if synchronous:
|
||||
await update
|
||||
else:
|
||||
asyncio.ensure_future(update, loop=self.loop)
|
||||
await self.invite_to_matrix(invites or [])
|
||||
return self.mxid
|
||||
async with self._room_create_lock:
|
||||
@@ -250,49 +253,6 @@ class PortalMetadata(BasePortal, ABC):
|
||||
except Exception:
|
||||
self.log.exception("Fatal error creating Matrix room")
|
||||
|
||||
@property
|
||||
def bridge_info_state_key(self) -> str:
|
||||
return f"net.maunium.telegram://telegram/{self.tgid}"
|
||||
|
||||
@property
|
||||
def bridge_info(self) -> Dict[str, Any]:
|
||||
info = {
|
||||
"bridgebot": self.az.bot_mxid,
|
||||
"creator": self.main_intent.mxid,
|
||||
"protocol": {
|
||||
"id": "telegram",
|
||||
"displayname": "Telegram",
|
||||
"avatar_url": config["appservice.bot_avatar"],
|
||||
"external_url": "https://telegram.org",
|
||||
},
|
||||
"channel": {
|
||||
"id": str(self.tgid),
|
||||
"displayname": self.title,
|
||||
"avatar_url": self.avatar_url,
|
||||
}
|
||||
}
|
||||
if self.username:
|
||||
info["channel"]["external_url"] = f"https://t.me/{self.username}"
|
||||
elif self.peer_type == "user":
|
||||
puppet = p.Puppet.get(self.tgid)
|
||||
if puppet and puppet.username:
|
||||
info["channel"]["external_url"] = f"https://t.me/{puppet.username}"
|
||||
return info
|
||||
|
||||
async def update_bridge_info(self) -> None:
|
||||
if not self.mxid:
|
||||
self.log.debug("Not updating bridge info: no Matrix room created")
|
||||
return
|
||||
try:
|
||||
self.log.debug("Updating bridge info...")
|
||||
await self.main_intent.send_state_event(self.mxid, StateBridge,
|
||||
self.bridge_info, self.bridge_info_state_key)
|
||||
# TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
|
||||
await self.main_intent.send_state_event(self.mxid, StateHalfShotBridge,
|
||||
self.bridge_info, self.bridge_info_state_key)
|
||||
except Exception:
|
||||
self.log.warning("Failed to update bridge info", exc_info=True)
|
||||
|
||||
async def _create_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
|
||||
invites: InviteList) -> Optional[RoomID]:
|
||||
direct = self.peer_type == "user"
|
||||
@@ -343,33 +303,44 @@ class PortalMetadata(BasePortal, ABC):
|
||||
await self.main_intent.remove_room_alias(alias)
|
||||
|
||||
power_levels = self._get_base_power_levels(entity=entity)
|
||||
users = None
|
||||
users = participants = None
|
||||
if not direct:
|
||||
users = await self._get_users(user, entity)
|
||||
users, participants = await self._get_users(user, entity)
|
||||
if self.has_bot:
|
||||
extra_invites = config["bridge.relaybot.group_chat_invite"]
|
||||
invites += extra_invites
|
||||
for invite in extra_invites:
|
||||
power_levels.users.setdefault(invite, 100)
|
||||
await self._participants_to_power_levels(users, power_levels)
|
||||
self._participants_to_power_levels(participants, power_levels)
|
||||
elif self.bot and self.tg_receiver == self.bot.tgid:
|
||||
invites = config["bridge.relaybot.private_chat.invite"]
|
||||
for invite in invites:
|
||||
power_levels.users.setdefault(invite, 100)
|
||||
self.title = puppet.displayname
|
||||
|
||||
bridge_info = {
|
||||
"bridgebot": self.az.bot_mxid,
|
||||
"creator": self.main_intent.mxid,
|
||||
"protocol": {
|
||||
"id": "telegram",
|
||||
"displayname": "Telegram",
|
||||
"avatar_url": config["appservice.bot_avatar"],
|
||||
},
|
||||
"channel": {
|
||||
"id": self.tgid
|
||||
}
|
||||
}
|
||||
initial_state = [{
|
||||
"type": EventType.ROOM_POWER_LEVELS.serialize(),
|
||||
"content": power_levels.serialize(),
|
||||
}, {
|
||||
"type": str(StateBridge),
|
||||
"state_key": self.bridge_info_state_key,
|
||||
"content": self.bridge_info,
|
||||
"type": "m.bridge",
|
||||
"state_key": f"net.maunium.telegram://telegram/{self.tgid}",
|
||||
"content": bridge_info
|
||||
}, {
|
||||
# TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
|
||||
"type": str(StateHalfShotBridge),
|
||||
"state_key": self.bridge_info_state_key,
|
||||
"content": self.bridge_info,
|
||||
"type": "uk.half-shot.bridge",
|
||||
"state_key": f"net.maunium.telegram://telegram/{self.tgid}",
|
||||
"content": bridge_info
|
||||
}]
|
||||
if config["bridge.encryption.default"] and self.matrix.e2ee:
|
||||
self.encrypted = True
|
||||
@@ -390,40 +361,32 @@ class PortalMetadata(BasePortal, ABC):
|
||||
if not config["bridge.federate_rooms"]:
|
||||
creation_content["m.federate"] = False
|
||||
|
||||
with self.backfill_lock:
|
||||
room_id = await self.main_intent.create_room(alias_localpart=alias, preset=preset,
|
||||
is_direct=direct, invitees=invites or [],
|
||||
name=self.title, topic=self.about,
|
||||
initial_state=initial_state,
|
||||
creation_content=creation_content)
|
||||
if not room_id:
|
||||
raise Exception(f"Failed to create room")
|
||||
room_id = await self.main_intent.create_room(alias_localpart=alias, preset=preset,
|
||||
is_direct=direct, invitees=invites or [],
|
||||
name=self.title, topic=self.about,
|
||||
initial_state=initial_state,
|
||||
creation_content=creation_content)
|
||||
if not room_id:
|
||||
raise Exception(f"Failed to create room")
|
||||
|
||||
if self.encrypted and self.matrix.e2ee and direct:
|
||||
if self.encrypted and self.matrix.e2ee:
|
||||
members = [self.main_intent.mxid]
|
||||
if direct:
|
||||
try:
|
||||
await self.az.intent.ensure_joined(room_id)
|
||||
await self.az.intent.join_room_by_id(room_id)
|
||||
members += [self.az.intent.mxid]
|
||||
except Exception:
|
||||
self.log.warning(f"Failed to add bridge bot to new private chat {room_id}")
|
||||
await self.matrix.e2ee.add_room(room_id, members=members, encrypted=True)
|
||||
|
||||
self.mxid = room_id
|
||||
self.by_mxid[self.mxid] = self
|
||||
await self.save()
|
||||
await self.az.state_store.set_power_levels(self.mxid, power_levels)
|
||||
await user.register_portal(self)
|
||||
|
||||
update_room = self.loop.create_task(self.update_matrix_room(
|
||||
user, entity, direct, puppet,
|
||||
levels=power_levels, users=users))
|
||||
|
||||
if config["bridge.backfill.initial_limit"] > 0:
|
||||
self.log.debug("Initial backfill is enabled. Waiting for room members to sync "
|
||||
"and then starting backfill")
|
||||
await update_room
|
||||
|
||||
try:
|
||||
await self.backfill(user, is_initial=True)
|
||||
except Exception:
|
||||
self.log.exception("Failed to backfill new portal")
|
||||
self.mxid = RoomID(room_id)
|
||||
self.by_mxid[self.mxid] = self
|
||||
self.save()
|
||||
self.az.state_store.set_power_levels(self.mxid, power_levels)
|
||||
user.register_portal(self)
|
||||
asyncio.ensure_future(self.update_matrix_room(user, entity, direct, puppet,
|
||||
levels=power_levels, users=users,
|
||||
participants=participants), loop=self.loop)
|
||||
|
||||
return self.mxid
|
||||
|
||||
@@ -496,8 +459,8 @@ class PortalMetadata(BasePortal, ABC):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _participants_to_power_levels(self, users: List[Union[TypeUser, TypeParticipant]],
|
||||
levels: PowerLevelStateEventContent) -> bool:
|
||||
def _participants_to_power_levels(self, participants: List[TypeParticipant],
|
||||
levels: PowerLevelStateEventContent) -> bool:
|
||||
bot_level = levels.get_user_level(self.main_intent.mxid)
|
||||
if bot_level < levels.get_event_level(EventType.ROOM_POWER_LEVELS):
|
||||
return False
|
||||
@@ -507,17 +470,13 @@ class PortalMetadata(BasePortal, ABC):
|
||||
changed = True
|
||||
levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
|
||||
|
||||
for user in users:
|
||||
# The User objects we get from TelegramClient.get_participants have a custom
|
||||
# participant property
|
||||
participant = getattr(user, "participant", user)
|
||||
|
||||
for participant in participants:
|
||||
puppet = p.Puppet.get(TelegramID(participant.user_id))
|
||||
user = u.User.get_by_tgid(TelegramID(participant.user_id))
|
||||
new_level = self._get_level_from_participant(participant)
|
||||
|
||||
if user:
|
||||
await user.register_portal(self)
|
||||
user.register_portal(self)
|
||||
changed = self._participant_to_power_levels(levels, user, new_level,
|
||||
bot_level) or changed
|
||||
|
||||
@@ -526,55 +485,45 @@ class PortalMetadata(BasePortal, ABC):
|
||||
bot_level) or changed
|
||||
return changed
|
||||
|
||||
async def update_power_levels(self, users: List[Union[TypeUser, TypeParticipant]],
|
||||
levels: PowerLevelStateEventContent = None) -> None:
|
||||
async def update_telegram_participants(self, participants: List[TypeParticipant],
|
||||
levels: PowerLevelStateEventContent = None) -> None:
|
||||
if not levels:
|
||||
levels = await self.main_intent.get_power_levels(self.mxid)
|
||||
if await self._participants_to_power_levels(users, levels):
|
||||
if self._participants_to_power_levels(participants, levels):
|
||||
await self.main_intent.set_power_levels(self.mxid, levels)
|
||||
|
||||
async def _add_bot_chat(self, bot: User) -> None:
|
||||
def _add_bot_chat(self, bot: User) -> None:
|
||||
if self.bot and bot.id == self.bot.tgid:
|
||||
self.bot.add_chat(self.tgid, self.peer_type)
|
||||
return
|
||||
|
||||
user = u.User.get_by_tgid(TelegramID(bot.id))
|
||||
if user and user.is_bot:
|
||||
await user.register_portal(self)
|
||||
user.register_portal(self)
|
||||
|
||||
async def _sync_telegram_users(self, source: 'AbstractUser', users: List[User]) -> None:
|
||||
allowed_tgids = set()
|
||||
skip_deleted = config["bridge.skip_deleted_members"]
|
||||
for entity in users:
|
||||
puppet = p.Puppet.get(TelegramID(entity.id))
|
||||
if entity.bot:
|
||||
await self._add_bot_chat(entity)
|
||||
allowed_tgids.add(entity.id)
|
||||
|
||||
await puppet.update_info(source, entity)
|
||||
if skip_deleted and entity.deleted:
|
||||
continue
|
||||
|
||||
puppet = p.Puppet.get(TelegramID(entity.id))
|
||||
if entity.bot:
|
||||
self._add_bot_chat(entity)
|
||||
allowed_tgids.add(entity.id)
|
||||
await puppet.intent_for(self).ensure_joined(self.mxid)
|
||||
await puppet.update_info(source, entity)
|
||||
|
||||
user = u.User.get_by_tgid(TelegramID(entity.id))
|
||||
if user:
|
||||
await self.invite_to_matrix(user.mxid)
|
||||
|
||||
puppet = await p.Puppet.get_by_custom_mxid(user.mxid)
|
||||
if puppet:
|
||||
try:
|
||||
await puppet.intent.ensure_joined(self.mxid)
|
||||
except Exception:
|
||||
self.log.exception("Failed to ensure %s is joined to portal", user.mxid)
|
||||
|
||||
# We can't trust the member list if any of the following cases is true:
|
||||
# * There are close to 10 000 users, because Telegram might not be sending all members.
|
||||
# * The member sync count is limited, because then we might ignore some members.
|
||||
# * It's a channel, because non-admins don't have access to the member list.
|
||||
trust_member_list = ((len(allowed_tgids) < 9900
|
||||
if self.max_initial_member_sync < 0
|
||||
else len(allowed_tgids) < self.max_initial_member_sync - 10)
|
||||
trust_member_list = (len(allowed_tgids) < 9900
|
||||
and self.max_initial_member_sync == -1
|
||||
and (self.megagroup or self.peer_type != "channel"))
|
||||
if trust_member_list:
|
||||
joined_mxids = await self.main_intent.get_room_members(self.mxid)
|
||||
@@ -593,7 +542,7 @@ class PortalMetadata(BasePortal, ABC):
|
||||
continue
|
||||
mx_user = u.User.get_by_mxid(user_mxid, create=False)
|
||||
if mx_user and mx_user.is_bot and mx_user.tgid not in allowed_tgids:
|
||||
await mx_user.unregister_portal(*self.tgid_full)
|
||||
mx_user.unregister_portal(self)
|
||||
|
||||
if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids:
|
||||
try:
|
||||
@@ -613,7 +562,7 @@ class PortalMetadata(BasePortal, ABC):
|
||||
|
||||
user = u.User.get_by_tgid(user_id)
|
||||
if user:
|
||||
await user.register_portal(self)
|
||||
user.register_portal(self)
|
||||
await self.invite_to_matrix(user.mxid)
|
||||
|
||||
async def _delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None:
|
||||
@@ -630,7 +579,7 @@ class PortalMetadata(BasePortal, ABC):
|
||||
else:
|
||||
await puppet.intent_for(self).leave_room(self.mxid)
|
||||
if user:
|
||||
await user.unregister_portal(*self.tgid_full)
|
||||
user.unregister_portal(self)
|
||||
if sender.tgid != puppet.tgid:
|
||||
try:
|
||||
await sender.intent_for(self).kick_user(self.mxid, puppet.mxid)
|
||||
@@ -670,8 +619,7 @@ class PortalMetadata(BasePortal, ABC):
|
||||
self.log.exception(f"Failed to update info from source {user.tgid}")
|
||||
|
||||
if changed:
|
||||
await self.save()
|
||||
await self.update_bridge_info()
|
||||
self.save()
|
||||
|
||||
async def _update_username(self, username: str, save: bool = False) -> bool:
|
||||
if self.username == username:
|
||||
@@ -688,7 +636,7 @@ class PortalMetadata(BasePortal, ABC):
|
||||
await self.main_intent.set_join_rule(self.mxid, "invite")
|
||||
|
||||
if save:
|
||||
await self.save()
|
||||
self.save()
|
||||
return True
|
||||
|
||||
async def _try_set_state(self, sender: Optional['p.Puppet'], evt_type: EventType,
|
||||
@@ -713,7 +661,7 @@ class PortalMetadata(BasePortal, ABC):
|
||||
await self._try_set_state(sender, EventType.ROOM_TOPIC,
|
||||
RoomTopicStateEventContent(topic=self.about))
|
||||
if save:
|
||||
await self.save()
|
||||
self.save()
|
||||
return True
|
||||
|
||||
async def _update_title(self, title: str, sender: Optional['p.Puppet'] = None,
|
||||
@@ -725,7 +673,7 @@ class PortalMetadata(BasePortal, ABC):
|
||||
await self._try_set_state(sender, EventType.ROOM_NAME,
|
||||
RoomNameStateEventContent(name=self.title))
|
||||
if save:
|
||||
await self.save()
|
||||
self.save()
|
||||
return True
|
||||
|
||||
async def _update_avatar(self, user: 'AbstractUser', photo: TypeChatPhoto,
|
||||
@@ -754,93 +702,66 @@ class PortalMetadata(BasePortal, ABC):
|
||||
await self._try_set_state(sender, EventType.ROOM_AVATAR,
|
||||
RoomAvatarStateEventContent(url=None))
|
||||
self.photo_id = ""
|
||||
self.avatar_url = None
|
||||
if save:
|
||||
await self.save()
|
||||
self.save()
|
||||
return True
|
||||
file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc)
|
||||
if file:
|
||||
await self._try_set_state(sender, EventType.ROOM_AVATAR,
|
||||
RoomAvatarStateEventContent(url=file.mxc))
|
||||
self.photo_id = photo_id
|
||||
self.avatar_url = file.mxc
|
||||
if save:
|
||||
await self.save()
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _filter_participants(users: List[TypeUser], participants: List[TypeParticipant]
|
||||
) -> Iterable[TypeUser]:
|
||||
participant_map = {part.user_id: part for part in participants}
|
||||
for user in users:
|
||||
try:
|
||||
user.participant = participant_map[user.id]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
yield user
|
||||
|
||||
async def _get_channel_users(self, user: 'AbstractUser', entity: InputChannel, limit: int
|
||||
) -> List[TypeUser]:
|
||||
if 0 < limit <= 200:
|
||||
response = await user.client(GetParticipantsRequest(
|
||||
entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0))
|
||||
return list(self._filter_participants(response.users, response.participants))
|
||||
elif limit > 200 or limit == -1:
|
||||
users: List[TypeUser] = []
|
||||
offset = 0
|
||||
remaining_quota = limit if limit > 0 else 1000000
|
||||
query = (ChannelParticipantsSearch("") if limit == -1
|
||||
else ChannelParticipantsRecent())
|
||||
while True:
|
||||
if remaining_quota <= 0:
|
||||
break
|
||||
response = await user.client(GetParticipantsRequest(
|
||||
entity, query, offset=offset, limit=min(remaining_quota, 200), hash=0))
|
||||
if not response.users:
|
||||
break
|
||||
users += self._filter_participants(response.users, response.participants)
|
||||
offset += len(response.participants)
|
||||
remaining_quota -= len(response.participants)
|
||||
return users
|
||||
|
||||
async def _get_users(self, user: 'AbstractUser',
|
||||
entity: Union[TypeInputPeer, InputUser, TypeChat, TypeUser, InputChannel]
|
||||
) -> List[TypeUser]:
|
||||
) -> Tuple[List[TypeUser], List[TypeParticipant]]:
|
||||
# TODO replace with client.get_participants
|
||||
if self.peer_type == "chat":
|
||||
chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
|
||||
return list(self._filter_participants(chat.users,
|
||||
chat.full_chat.participants.participants))
|
||||
return chat.users, chat.full_chat.participants.participants
|
||||
elif self.peer_type == "channel":
|
||||
if not self.megagroup and not self.sync_channel_members:
|
||||
return []
|
||||
return [], []
|
||||
|
||||
limit = self.max_initial_member_sync
|
||||
if limit == 0:
|
||||
return []
|
||||
return [], []
|
||||
|
||||
try:
|
||||
return await self._get_channel_users(user, entity, limit)
|
||||
if 0 < limit <= 200:
|
||||
response = await user.client(GetParticipantsRequest(
|
||||
entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0))
|
||||
return response.users, response.participants
|
||||
elif limit > 200 or limit == -1:
|
||||
users: List[TypeUser] = []
|
||||
participants: List[TypeParticipant] = []
|
||||
offset = 0
|
||||
remaining_quota = limit if limit > 0 else 1000000
|
||||
query = (ChannelParticipantsSearch("") if limit == -1
|
||||
else ChannelParticipantsRecent())
|
||||
while True:
|
||||
if remaining_quota <= 0:
|
||||
break
|
||||
response = await user.client(GetParticipantsRequest(
|
||||
entity, query, offset=offset, limit=min(remaining_quota, 100), hash=0))
|
||||
if not response.users:
|
||||
break
|
||||
participants += response.participants
|
||||
users += response.users
|
||||
offset += len(response.participants)
|
||||
remaining_quota -= len(response.participants)
|
||||
return users, participants
|
||||
except ChatAdminRequiredError:
|
||||
return []
|
||||
return [], []
|
||||
elif self.peer_type == "user":
|
||||
return [entity]
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected peer type {self.peer_type}")
|
||||
return [entity], []
|
||||
return [], []
|
||||
|
||||
# endregion
|
||||
|
||||
async def _send_delivery_receipt(self, event_id: EventID, room_id: Optional[RoomID] = None
|
||||
) -> None:
|
||||
# TODO maybe check if the bot is in the room rather than assuming based on self.encrypted
|
||||
if event_id and config["bridge.delivery_receipts"] and (self.encrypted
|
||||
or self.peer_type != "user"):
|
||||
try:
|
||||
await self.az.intent.mark_read(room_id or self.mxid, event_id)
|
||||
except Exception:
|
||||
self.log.exception("Failed to send delivery receipt for %s", event_id)
|
||||
|
||||
|
||||
def init(context: Context) -> None:
|
||||
global config
|
||||
|
||||
@@ -20,7 +20,6 @@ import mimetypes
|
||||
import codecs
|
||||
import unicodedata
|
||||
import base64
|
||||
import asyncio
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
@@ -39,14 +38,12 @@ from telethon.tl.types import (
|
||||
from mautrix.appservice import IntentAPI
|
||||
from mautrix.types import (EventID, UserID, ImageInfo, ThumbnailInfo, RelatesTo, MessageType,
|
||||
EventType, MediaMessageEventContent, TextMessageEventContent,
|
||||
LocationMessageEventContent, Format)
|
||||
from mautrix.bridge import NotificationDisabler
|
||||
LocationMessageEventContent, Format, MessageEventContent)
|
||||
|
||||
from ..types import TelegramID
|
||||
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
|
||||
from ..util import sane_mimetypes
|
||||
from ..context import Context
|
||||
from ..tgclient import TelegramClient
|
||||
from .. import puppet as p, user as u, formatter, util
|
||||
from .base import BasePortal
|
||||
|
||||
@@ -74,32 +71,15 @@ class PortalTelegram(BasePortal, ABC):
|
||||
return f"https://t.me/c/{self.tgid}/{evt.id}"
|
||||
return None
|
||||
|
||||
async def _expire_telegram_photo(self, intent: IntentAPI, event_id: EventID, ttl: int) -> None:
|
||||
try:
|
||||
content = TextMessageEventContent(msgtype=MessageType.NOTICE, body="Photo has expired")
|
||||
content.set_edit(event_id)
|
||||
await asyncio.sleep(ttl)
|
||||
await self._send_message(intent, content)
|
||||
except Exception:
|
||||
self.log.warning("Failed to expire Telegram photo %s", event_id, exc_info=True)
|
||||
|
||||
async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
|
||||
relates_to: RelatesTo = None) -> Optional[EventID]:
|
||||
media: MessageMediaPhoto = evt.media
|
||||
if media.photo is None and media.ttl_seconds:
|
||||
return await self._send_message(intent, TextMessageEventContent(
|
||||
msgtype=MessageType.NOTICE, body="Photo has expired"))
|
||||
loc, largest_size = self._get_largest_photo_size(media.photo)
|
||||
if loc is None:
|
||||
content = TextMessageEventContent(msgtype=MessageType.TEXT,
|
||||
body="Failed to bridge image",
|
||||
external_url=self._get_external_url(evt))
|
||||
return await self._send_message(intent, content, timestamp=evt.date)
|
||||
loc, largest_size = self._get_largest_photo_size(evt.media.photo)
|
||||
file = await util.transfer_file_to_matrix(source.client, intent, loc,
|
||||
encrypt=self.encrypted)
|
||||
if not file:
|
||||
return None
|
||||
if self.get_config("inline_images") and (evt.message or evt.fwd_from or evt.reply_to):
|
||||
if self.get_config("inline_images") and (evt.message
|
||||
or evt.fwd_from or evt.reply_to_msg_id):
|
||||
content = await formatter.telegram_to_matrix(
|
||||
evt, source, self.main_intent,
|
||||
prefix_html=f"<img src='{file.mxc}' alt='Inline Telegram photo'/><br/>",
|
||||
@@ -111,8 +91,7 @@ class PortalTelegram(BasePortal, ABC):
|
||||
height=largest_size.h, width=largest_size.w, orientation=0, mimetype=file.mime_type,
|
||||
size=(len(largest_size.bytes) if (isinstance(largest_size, PhotoCachedSize))
|
||||
else largest_size.size))
|
||||
ext = sane_mimetypes.guess_extension(file.mime_type)
|
||||
name = f"disappearing_image{ext}" if media.ttl_seconds else f"image{ext}"
|
||||
name = f"image{sane_mimetypes.guess_extension(file.mime_type)}"
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
content = MediaMessageEventContent(msgtype=MessageType.IMAGE, info=info,
|
||||
body=name, relates_to=relates_to,
|
||||
@@ -122,9 +101,6 @@ class PortalTelegram(BasePortal, ABC):
|
||||
else:
|
||||
content.url = file.mxc
|
||||
result = await self._send_message(intent, content, timestamp=evt.date)
|
||||
if media.ttl_seconds:
|
||||
self.loop.create_task(self._expire_telegram_photo(intent, result,
|
||||
media.ttl_seconds))
|
||||
if evt.message:
|
||||
caption_content = await formatter.telegram_to_matrix(evt, source, self.main_intent,
|
||||
no_reply_fallback=True)
|
||||
@@ -150,7 +126,7 @@ class PortalTelegram(BasePortal, ABC):
|
||||
def _parse_telegram_document_meta(evt: Message, file: DBTelegramFile, attrs: DocAttrs,
|
||||
thumb_size: TypePhotoSize) -> Tuple[ImageInfo, str]:
|
||||
document = evt.media.document
|
||||
name = attrs.name
|
||||
name = evt.message or attrs.name
|
||||
if attrs.is_sticker:
|
||||
alt = attrs.sticker_alt
|
||||
if len(alt) > 0:
|
||||
@@ -241,13 +217,7 @@ class PortalTelegram(BasePortal, ABC):
|
||||
content.file = file.decryption_info
|
||||
else:
|
||||
content.url = file.mxc
|
||||
res = await self._send_message(intent, content, event_type=event_type, timestamp=evt.date)
|
||||
if evt.message:
|
||||
caption_content = await formatter.telegram_to_matrix(evt, source, self.main_intent,
|
||||
no_reply_fallback=True)
|
||||
caption_content.external_url = content.external_url
|
||||
res = await self._send_message(intent, caption_content, timestamp=evt.date)
|
||||
return res
|
||||
return await self._send_message(intent, content, event_type=event_type, timestamp=evt.date)
|
||||
|
||||
def handle_telegram_location(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
|
||||
relates_to: RelatesTo = None) -> Awaitable[EventID]:
|
||||
@@ -255,13 +225,12 @@ class PortalTelegram(BasePortal, ABC):
|
||||
lat = evt.media.geo.lat
|
||||
long_char = "E" if long > 0 else "W"
|
||||
lat_char = "N" if lat > 0 else "S"
|
||||
geo = f"{round(lat, 6)},{round(long, 6)}"
|
||||
|
||||
body = f"{round(abs(lat), 4)}° {lat_char}, {round(abs(long), 4)}° {long_char}"
|
||||
url = f"https://maps.google.com/?q={geo}"
|
||||
body = f"{round(lat, 5)}° {lat_char}, {round(long, 5)}° {long_char}"
|
||||
url = f"https://maps.google.com/?q={lat},{long}"
|
||||
|
||||
content = LocationMessageEventContent(
|
||||
msgtype=MessageType.LOCATION, geo_uri=f"geo:{geo}",
|
||||
msgtype=MessageType.LOCATION, geo_uri=f"geo:{lat},{long}",
|
||||
body=f"Location: {body}\n{url}",
|
||||
relates_to=relates_to, external_url=self._get_external_url(evt))
|
||||
content["format"] = str(Format.HTML)
|
||||
@@ -271,7 +240,7 @@ class PortalTelegram(BasePortal, ABC):
|
||||
|
||||
async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool,
|
||||
evt: Message) -> EventID:
|
||||
self.log.trace(f"Sending {evt.message} to {self.mxid} by {intent.mxid}")
|
||||
self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}")
|
||||
content = await formatter.telegram_to_matrix(evt, source, self.main_intent)
|
||||
content.external_url = self._get_external_url(evt)
|
||||
if is_bot and self.get_config("bot_messages_as_notices"):
|
||||
@@ -323,8 +292,6 @@ class PortalTelegram(BasePortal, ABC):
|
||||
emoji_text = {
|
||||
"\U0001F3AF": " Dart throw",
|
||||
"\U0001F3B2": " Dice roll",
|
||||
"\U0001F3C0": " Basketball throw",
|
||||
"\u26BD": " Football kick"
|
||||
}
|
||||
roll: MessageMediaDice = evt.media
|
||||
text = f"{roll.emoticon}{emoji_text.get(roll.emoticon, '')} result: {roll.value}"
|
||||
@@ -427,119 +394,52 @@ class PortalTelegram(BasePortal, ABC):
|
||||
edit_index=prev_edit_msg.edit_index + 1).insert()
|
||||
DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id)
|
||||
|
||||
@property
|
||||
def _takeout_options(self) -> Dict[str, Union[bool, int]]:
|
||||
return {
|
||||
"files": True,
|
||||
"megagroups": self.megagroup,
|
||||
"chats": self.peer_type == "chat",
|
||||
"users": self.peer_type == "user",
|
||||
"channels": (self.peer_type == "channel" and not self.megagroup),
|
||||
"max_file_size": min(config["bridge.max_document_size"], 2000) * 1024 * 1024
|
||||
}
|
||||
|
||||
async def backfill(self, source: 'u.User', is_initial: bool = False,
|
||||
limit: Optional[int] = None, last_id: Optional[int] = None) -> None:
|
||||
async with self.backfill_method_lock:
|
||||
await self._locked_backfill(source, is_initial, limit, last_id)
|
||||
|
||||
async def _locked_backfill(self, source: 'u.User', is_initial: bool = False,
|
||||
limit: Optional[int] = None, last_id: Optional[int] = None) -> None:
|
||||
limit = limit or (config["bridge.backfill.initial_limit"] if is_initial
|
||||
else config["bridge.backfill.missed_limit"])
|
||||
if limit == 0:
|
||||
return
|
||||
if not config["bridge.backfill.normal_groups"] and self.peer_type == "chat":
|
||||
return
|
||||
async def backfill(self, source: 'AbstractUser') -> None:
|
||||
self.log.debug("Backfilling history through %s", source.mxid)
|
||||
last = DBMessage.find_last(self.mxid, (source.tgid if self.peer_type != "channel"
|
||||
else self.tgid))
|
||||
min_id = last.tgid if last else 0
|
||||
if last_id is None:
|
||||
messages = await source.client.get_messages(self.peer, limit=1)
|
||||
if not messages:
|
||||
# The chat seems empty
|
||||
return
|
||||
last_id = messages[0].id
|
||||
if last_id <= min_id:
|
||||
# Nothing to backfill
|
||||
return
|
||||
if limit < 0:
|
||||
limit = last_id - min_id
|
||||
self.log.debug(f"Backfilling approximately {last_id - min_id} messages "
|
||||
f"through {source.mxid}")
|
||||
elif self.peer_type == "channel":
|
||||
# This is a channel or supergroup, so we'll backfill messages based on the ID.
|
||||
# There are some cases, such as deleted messages, where this may backfill less
|
||||
# messages than the limit.
|
||||
min_id = max(last_id - limit, min_id)
|
||||
self.log.debug(f"Backfilling messages after ID {min_id} (last message: {last_id}) "
|
||||
f"through {source.mxid}")
|
||||
else:
|
||||
# Private chats and normal groups don't have their own message ID namespace,
|
||||
# which means we'll have to fetch messages a different way.
|
||||
# The _backfill_messages method will detect min_id=None and not use reverse=True
|
||||
min_id = None
|
||||
self.log.debug(f"Backfilling up to {limit} messages through {source.mxid}")
|
||||
with self.backfill_lock:
|
||||
await self._backfill(source, min_id, limit)
|
||||
|
||||
async def _backfill(self, source: 'u.User', min_id: Optional[int], limit: int) -> None:
|
||||
self.backfilling = True
|
||||
self.backfill_leave = set()
|
||||
if ((self.peer_type == "user" and self.tgid != source.tgid
|
||||
and config["bridge.backfill.invite_own_puppet"])):
|
||||
if self.peer_type == "user":
|
||||
self.log.debug("Adding %s's default puppet to room for backfilling", source.mxid)
|
||||
sender = p.Puppet.get(source.tgid)
|
||||
await self.main_intent.invite_user(self.mxid, sender.default_mxid)
|
||||
await sender.default_mxid_intent.join_room_by_id(self.mxid)
|
||||
self.backfill_leave.add(sender.default_mxid_intent)
|
||||
|
||||
client = source.client
|
||||
async with NotificationDisabler(self.mxid, source):
|
||||
if limit > config["bridge.backfill.takeout_limit"]:
|
||||
self.log.debug(f"Opening takeout client for {source.tgid}")
|
||||
async with client.takeout(**self._takeout_options) as takeout:
|
||||
count = await self._backfill_messages(source, min_id, limit, takeout)
|
||||
else:
|
||||
count = await self._backfill_messages(source, min_id, limit, client)
|
||||
|
||||
max_file_size = min(config["bridge.max_document_size"], 1500) * 1024 * 1024
|
||||
self.log.trace("Opening takeout client for %d, message ID %d->", source.tgid, min_id)
|
||||
count = 0
|
||||
async with source.client.takeout(files=True, megagroups=self.megagroup,
|
||||
chats=self.peer_type == "chat",
|
||||
users=self.peer_type == "user",
|
||||
channels=(self.peer_type == "channel"
|
||||
and not self.megagroup),
|
||||
max_file_size=max_file_size
|
||||
) as takeout_client:
|
||||
async for message in takeout_client.iter_messages(await self.get_input_entity(source),
|
||||
reverse=True, min_id=min_id):
|
||||
sender = p.Puppet.get(message.sender_id)
|
||||
# if isinstance(message, MessageService):
|
||||
# await self.handle_telegram_action(source, sender, message)
|
||||
await self.handle_telegram_message(source, sender, message)
|
||||
count += 1
|
||||
for intent in self.backfill_leave:
|
||||
self.log.trace("Leaving room with %s post-backfill", intent.mxid)
|
||||
await intent.leave_room(self.mxid)
|
||||
self.backfilling = False
|
||||
self.backfill_leave = None
|
||||
self.log.info("Backfilled %d messages through %s", count, source.mxid)
|
||||
|
||||
async def _backfill_messages(self, source: 'AbstractUser', min_id: Optional[int], limit: int,
|
||||
client: TelegramClient) -> int:
|
||||
count = 0
|
||||
entity = await self.get_input_entity(source)
|
||||
if min_id is not None:
|
||||
self.log.debug(f"Iterating all messages starting with {min_id} (approx: {limit})")
|
||||
messages = client.iter_messages(entity, reverse=True, min_id=min_id)
|
||||
async for message in messages:
|
||||
sender = (p.Puppet.get(message.from_id.user_id)
|
||||
if isinstance(message.from_id, PeerUser) else None)
|
||||
# TODO handle service messages?
|
||||
await self.handle_telegram_message(source, sender, message)
|
||||
count += 1
|
||||
else:
|
||||
self.log.debug(f"Fetching up to {limit} most recent messages")
|
||||
messages = await client.get_messages(entity, limit=limit)
|
||||
for message in reversed(messages):
|
||||
sender = (p.Puppet.get(TelegramID(message.from_id.user_id))
|
||||
if isinstance(message.from_id, PeerUser) else None)
|
||||
await self.handle_telegram_message(source, sender, message)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def handle_telegram_message(self, source: 'AbstractUser', sender: p.Puppet,
|
||||
evt: Message) -> None:
|
||||
if not self.mxid:
|
||||
self.log.trace("Got telegram message %d, but no room exists, creating...", evt.id)
|
||||
await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
|
||||
|
||||
if (self.peer_type == "user" and sender and sender.tgid == self.tg_receiver
|
||||
and not sender.is_real_user and not await self.az.state_store.is_joined(self.mxid,
|
||||
sender.mxid)):
|
||||
if (self.peer_type == "user" and sender.tgid == self.tg_receiver
|
||||
and not sender.is_real_user and not self.az.state_store.is_joined(self.mxid,
|
||||
sender.mxid)):
|
||||
self.log.debug(f"Ignoring private chat message {evt.id}@{source.tgid} as receiver does"
|
||||
" not have matrix puppeting and their default puppet isn't in the room")
|
||||
return
|
||||
@@ -559,12 +459,12 @@ class PortalTelegram(BasePortal, ABC):
|
||||
tg_space=tg_space, edit_index=0).insert()
|
||||
return
|
||||
|
||||
if self.backfill_lock.locked or (self.dedup.pre_db_check and self.peer_type == "channel"):
|
||||
if self.backfilling or (self.dedup.pre_db_check and self.peer_type == "channel"):
|
||||
msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
|
||||
if msg:
|
||||
self.log.debug(f"Ignoring message {evt.id} (src {source.tgid}) as it was already "
|
||||
self.log.debug(f"Ignoring message {evt.id} (src {source.tgid}) as it was already"
|
||||
f"handled into {msg.mxid}. This duplicate was catched in the db "
|
||||
"check. If you get this message often, consider increasing "
|
||||
"check. If you get this message often, consider increasing"
|
||||
"bridge.deduplication.cache_queue_length in the config.")
|
||||
return
|
||||
|
||||
@@ -583,8 +483,7 @@ class PortalTelegram(BasePortal, ABC):
|
||||
allowed_media) else None
|
||||
if sender:
|
||||
intent = sender.intent_for(self)
|
||||
if ((self.backfill_lock.locked and intent != sender.default_mxid_intent
|
||||
and config["bridge.backfill.invite_own_puppet"])):
|
||||
if self.backfilling and intent != sender.default_mxid_intent:
|
||||
intent = sender.default_mxid_intent
|
||||
self.backfill_leave.add(intent)
|
||||
else:
|
||||
@@ -632,7 +531,6 @@ class PortalTelegram(BasePortal, ABC):
|
||||
"dedup cache queue. You can try enabling bridge.deduplication."
|
||||
"pre_db_check in the config.")
|
||||
await intent.redact(self.mxid, event_id)
|
||||
await self._send_delivery_receipt(event_id)
|
||||
|
||||
async def _create_room_on_action(self, source: 'AbstractUser',
|
||||
action: TypeMessageAction) -> bool:
|
||||
@@ -656,13 +554,10 @@ class PortalTelegram(BasePortal, ABC):
|
||||
return
|
||||
if isinstance(action, MessageActionChatEditTitle):
|
||||
await self._update_title(action.title, sender=sender, save=True)
|
||||
await self.update_bridge_info()
|
||||
elif isinstance(action, MessageActionChatEditPhoto):
|
||||
await self._update_avatar(source, action.photo, sender=sender, save=True)
|
||||
await self.update_bridge_info()
|
||||
elif isinstance(action, MessageActionChatDeletePhoto):
|
||||
await self._update_avatar(source, ChatPhotoEmpty(), sender=sender, save=True)
|
||||
await self.update_bridge_info()
|
||||
elif isinstance(action, MessageActionChatAddUser):
|
||||
for user_id in action.users:
|
||||
await self._add_telegram_user(TelegramID(user_id), source)
|
||||
@@ -676,7 +571,6 @@ class PortalTelegram(BasePortal, ABC):
|
||||
# TODO encrypt
|
||||
await sender.intent_for(self).send_emote(self.mxid,
|
||||
"upgraded this group to a supergroup.")
|
||||
await self.update_bridge_info()
|
||||
elif isinstance(action, MessageActionGameScore):
|
||||
# TODO handle game score
|
||||
pass
|
||||
@@ -714,5 +608,3 @@ class PortalTelegram(BasePortal, ABC):
|
||||
def init(context: Context) -> None:
|
||||
global config
|
||||
config = context.config
|
||||
NotificationDisabler.puppet_cls = p.Puppet
|
||||
NotificationDisabler.config_enabled = config["bridge.backfill.disable_notifications"]
|
||||
|
||||
+21
-36
@@ -21,11 +21,10 @@ import logging
|
||||
|
||||
from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer,
|
||||
InputPeerPhotoFileLocation, UserProfilePhotoEmpty, TypeInputUser)
|
||||
from yarl import URL
|
||||
|
||||
from mautrix.appservice import AppService, IntentAPI
|
||||
from mautrix.errors import MatrixRequestError
|
||||
from mautrix.bridge import BasePuppet
|
||||
from mautrix.bridge import CustomPuppetMixin
|
||||
from mautrix.types import UserID, SyncToken, RoomID
|
||||
from mautrix.util.simple_template import SimpleTemplate
|
||||
|
||||
@@ -42,7 +41,7 @@ if TYPE_CHECKING:
|
||||
config: Optional['Config'] = None
|
||||
|
||||
|
||||
class Puppet(BasePuppet):
|
||||
class Puppet(CustomPuppetMixin):
|
||||
log: logging.Logger = logging.getLogger("mau.puppet")
|
||||
az: AppService
|
||||
mx: 'MatrixHandler'
|
||||
@@ -58,7 +57,6 @@ class Puppet(BasePuppet):
|
||||
access_token: Optional[str]
|
||||
custom_mxid: Optional[UserID]
|
||||
_next_batch: Optional[SyncToken]
|
||||
base_url: Optional[URL]
|
||||
default_mxid: UserID
|
||||
|
||||
username: Optional[str]
|
||||
@@ -81,7 +79,6 @@ class Puppet(BasePuppet):
|
||||
access_token: Optional[str] = None,
|
||||
custom_mxid: Optional[UserID] = None,
|
||||
next_batch: Optional[SyncToken] = None,
|
||||
base_url: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
displayname: Optional[str] = None,
|
||||
displayname_source: Optional[TelegramID] = None,
|
||||
@@ -94,7 +91,6 @@ class Puppet(BasePuppet):
|
||||
self.access_token = access_token
|
||||
self.custom_mxid = custom_mxid
|
||||
self._next_batch = next_batch
|
||||
self.base_url = URL(base_url) if base_url else None
|
||||
self.default_mxid = self.get_mxid_from_id(self.id)
|
||||
|
||||
self.username = username
|
||||
@@ -165,20 +161,20 @@ class Puppet(BasePuppet):
|
||||
custom_mxid=self.custom_mxid, username=self.username, is_bot=self.is_bot,
|
||||
displayname=self.displayname, displayname_source=self.displayname_source,
|
||||
photo_id=self.photo_id, matrix_registered=self.is_registered,
|
||||
disable_updates=self.disable_updates, base_url=self.base_url)
|
||||
disable_updates=self.disable_updates)
|
||||
|
||||
def new_db_instance(self) -> DBPuppet:
|
||||
return DBPuppet(id=self.id, **self._fields)
|
||||
|
||||
async def save(self) -> None:
|
||||
def save(self) -> None:
|
||||
self.db_instance.edit(**self._fields)
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, db_puppet: DBPuppet) -> 'Puppet':
|
||||
return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid,
|
||||
db_puppet.next_batch, db_puppet.base_url, db_puppet.username,
|
||||
db_puppet.displayname, db_puppet.displayname_source, db_puppet.photo_id,
|
||||
db_puppet.is_bot, db_puppet.matrix_registered, db_puppet.disable_updates,
|
||||
db_puppet.next_batch, db_puppet.username, db_puppet.displayname,
|
||||
db_puppet.displayname_source, db_puppet.photo_id, db_puppet.is_bot,
|
||||
db_puppet.matrix_registered, db_puppet.disable_updates,
|
||||
db_instance=db_puppet)
|
||||
|
||||
# endregion
|
||||
@@ -237,22 +233,23 @@ class Puppet(BasePuppet):
|
||||
source.log.exception(f"Failed to update info of {self.tgid}")
|
||||
|
||||
async def update_info(self, source: 'AbstractUser', info: User) -> None:
|
||||
if self.disable_updates:
|
||||
return
|
||||
changed = False
|
||||
if self.username != info.username:
|
||||
self.username = info.username
|
||||
changed = True
|
||||
|
||||
if not self.disable_updates:
|
||||
try:
|
||||
changed = await self.update_displayname(source, info) or changed
|
||||
changed = await self.update_avatar(source, info.photo) or changed
|
||||
except Exception:
|
||||
self.log.exception(f"Failed to update info from source {source.tgid}")
|
||||
try:
|
||||
changed = await self.update_displayname(source, info) or changed
|
||||
changed = await self.update_avatar(source, info.photo) or changed
|
||||
except Exception:
|
||||
self.log.exception(f"Failed to update info from source {source.tgid}")
|
||||
|
||||
self.is_bot = info.bot
|
||||
|
||||
if changed:
|
||||
await self.save()
|
||||
self.save()
|
||||
|
||||
async def update_displayname(self, source: 'AbstractUser', info: Union[User, UpdateUserName]
|
||||
) -> bool:
|
||||
@@ -262,7 +259,7 @@ class Puppet(BasePuppet):
|
||||
allow_because = "user is bot"
|
||||
elif self.displayname_source == source.tgid:
|
||||
allow_because = "user is the primary source"
|
||||
elif not isinstance(info, UpdateUserName) and not info.contact:
|
||||
elif not info.contact:
|
||||
allow_because = "user is not a contact"
|
||||
elif self.displayname_source is None:
|
||||
allow_because = "no primary source set"
|
||||
@@ -334,7 +331,7 @@ class Puppet(BasePuppet):
|
||||
|
||||
def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
|
||||
portal: p.Portal = p.Portal.get_by_mxid(room_id)
|
||||
return portal and not portal.backfill_lock.locked and portal.peer_type != "user"
|
||||
return portal and not portal.backfilling and portal.peer_type != "user"
|
||||
|
||||
# endregion
|
||||
# region Getters
|
||||
@@ -358,7 +355,7 @@ class Puppet(BasePuppet):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def deprecated_sync_get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
|
||||
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
|
||||
tgid = cls.get_id_from_mxid(mxid)
|
||||
if tgid:
|
||||
return cls.get(tgid, create)
|
||||
@@ -366,11 +363,7 @@ class Puppet(BasePuppet):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
|
||||
return cls.deprecated_sync_get_by_mxid(mxid, create)
|
||||
|
||||
@classmethod
|
||||
def deprecated_sync_get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
|
||||
def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
|
||||
if not mxid:
|
||||
raise ValueError("Matrix ID can't be empty")
|
||||
|
||||
@@ -386,10 +379,6 @@ class Puppet(BasePuppet):
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
async def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
|
||||
return cls.deprecated_sync_get_by_custom_mxid(mxid)
|
||||
|
||||
@classmethod
|
||||
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
|
||||
return (cls.by_custom_mxid[puppet.custom_mxid]
|
||||
@@ -450,12 +439,8 @@ def init(context: 'Context') -> Iterable[Awaitable[Any]]:
|
||||
Puppet.displayname_template = SimpleTemplate(config["bridge.displayname_template"],
|
||||
"displayname")
|
||||
|
||||
Puppet.sync_with_custom_puppets = config["bridge.sync_with_custom_puppets"]
|
||||
Puppet.homeserver_url_map = {server: URL(url) for server, url
|
||||
in config["bridge.double_puppet_server_map"].items()}
|
||||
Puppet.allow_discover_url = config["bridge.double_puppet_allow_discovery"]
|
||||
Puppet.login_shared_secret_map = {server: secret.encode("utf-8") for server, secret
|
||||
in config["bridge.login_shared_secret_map"].items()}
|
||||
secret = config["bridge.login_shared_secret"]
|
||||
Puppet.login_shared_secret = secret.encode("utf-8") if secret else None
|
||||
Puppet.login_device_name = "Telegram Bridge"
|
||||
|
||||
return (puppet.try_start() for puppet in Puppet.all_with_custom_mxid())
|
||||
|
||||
@@ -25,7 +25,7 @@ def log(message, end="\n"):
|
||||
|
||||
def connect(to):
|
||||
from mautrix.util.db import Base
|
||||
from mautrix.client.state_store.sqlalchemy import RoomState, UserProfile
|
||||
from mautrix.bridge.db import RoomState, UserProfile
|
||||
from mautrix_telegram.db import (Portal, Message, UserPortal, User, Contact, Puppet, BotChat,
|
||||
TelegramFile)
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU Affero General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from mautrix.types import UserID
|
||||
from mautrix.bridge.db import SQLStateStore as BaseSQLStateStore
|
||||
|
||||
from . import puppet as pu
|
||||
|
||||
|
||||
class SQLStateStore(BaseSQLStateStore):
|
||||
def is_registered(self, user_id: UserID) -> bool:
|
||||
puppet = pu.Puppet.get_by_mxid(user_id, create=False)
|
||||
if puppet:
|
||||
return puppet.is_registered
|
||||
custom_puppet = pu.Puppet.get_by_custom_mxid(user_id)
|
||||
if custom_puppet:
|
||||
return True
|
||||
return super().is_registered(user_id)
|
||||
|
||||
def registered(self, user_id: UserID) -> None:
|
||||
puppet = pu.Puppet.get_by_mxid(user_id, create=True)
|
||||
if puppet:
|
||||
puppet.is_registered = True
|
||||
puppet.save()
|
||||
else:
|
||||
super().registered(user_id)
|
||||
+34
-91
@@ -1,5 +1,5 @@
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2020 Tulir Asokan
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Affero General Public License as published by
|
||||
@@ -13,29 +13,26 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import (Awaitable, Dict, List, Iterable, NamedTuple, Optional, Tuple, Any, cast,
|
||||
from typing import (Awaitable, Dict, List, Iterable, NewType, Optional, Tuple, Any, cast,
|
||||
TYPE_CHECKING)
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from telethon.tl.types import (TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser,
|
||||
UpdateShortChatMessage, UpdateShortMessage, User as TLUser, Chat,
|
||||
ChatForbidden)
|
||||
from telethon.tl.custom import Dialog
|
||||
from telethon.tl.types.contacts import ContactsNotModified
|
||||
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
|
||||
from telethon.tl.functions.account import UpdateStatusRequest
|
||||
|
||||
from mautrix.client import Client
|
||||
from mautrix.errors import MatrixRequestError
|
||||
from mautrix.types import UserID, RoomID
|
||||
from mautrix.types import UserID
|
||||
from mautrix.bridge import BaseUser
|
||||
from mautrix.util.logging import TraceLogger
|
||||
from mautrix.util.opt_prometheus import Gauge
|
||||
|
||||
from .types import TelegramID
|
||||
from .db import User as DBUser, Portal as DBPortal
|
||||
from .db import User as DBUser
|
||||
from .abstract_user import AbstractUser
|
||||
from . import portal as po, puppet as pu
|
||||
|
||||
@@ -45,10 +42,7 @@ if TYPE_CHECKING:
|
||||
|
||||
config: Optional['Config'] = None
|
||||
|
||||
SearchResult = NamedTuple('SearchResult', puppet='pu.Puppet', similarity=int)
|
||||
|
||||
METRIC_LOGGED_IN = Gauge('bridge_logged_in', 'Users logged into bridge')
|
||||
METRIC_CONNECTED = Gauge('bridge_connected', 'Users connected')
|
||||
SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int])
|
||||
|
||||
|
||||
class User(AbstractUser, BaseUser):
|
||||
@@ -64,7 +58,6 @@ class User(AbstractUser, BaseUser):
|
||||
|
||||
_db_instance: Optional[DBUser]
|
||||
_ensure_started_lock: asyncio.Lock
|
||||
_track_connection_task: Optional[asyncio.Task]
|
||||
|
||||
def __init__(self, mxid: UserID, tgid: Optional[TelegramID] = None,
|
||||
username: Optional[str] = None, phone: Optional[str] = None,
|
||||
@@ -85,9 +78,6 @@ class User(AbstractUser, BaseUser):
|
||||
self.db_portals = db_portals or []
|
||||
self._db_instance = db_instance
|
||||
self._ensure_started_lock = asyncio.Lock()
|
||||
self.dm_update_lock = asyncio.Lock()
|
||||
self._metric_value = defaultdict(lambda: False)
|
||||
self._track_connection_task = None
|
||||
|
||||
self.command_status = None
|
||||
|
||||
@@ -161,7 +151,7 @@ class User(AbstractUser, BaseUser):
|
||||
return DBUser(mxid=self.mxid, tgid=self.tgid, tg_username=self.username,
|
||||
saved_contacts=self.saved_contacts, portals=self.db_portals)
|
||||
|
||||
async def save(self, contacts: bool = False, portals: bool = False) -> None:
|
||||
def save(self, contacts: bool = False, portals: bool = False) -> None:
|
||||
self.db_instance.edit(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone,
|
||||
saved_contacts=self.saved_contacts)
|
||||
if contacts:
|
||||
@@ -201,34 +191,15 @@ class User(AbstractUser, BaseUser):
|
||||
|
||||
async def start(self, delete_unless_authenticated: bool = False) -> 'User':
|
||||
await super().start()
|
||||
self._track_metric(METRIC_CONNECTED, True)
|
||||
if await self.is_logged_in():
|
||||
self.log.debug(f"Ensuring post_login() for {self.name}")
|
||||
self.loop.create_task(self.post_login())
|
||||
if config["metrics.enabled"]:
|
||||
self._track_connection_task = self.loop.create_task(self._track_connection())
|
||||
asyncio.ensure_future(self.post_login(), loop=self.loop)
|
||||
elif delete_unless_authenticated:
|
||||
self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting session...")
|
||||
await self.client.disconnect()
|
||||
self._track_metric(METRIC_CONNECTED, False)
|
||||
self.client.session.delete()
|
||||
return self
|
||||
|
||||
async def _track_connection(self) -> None:
|
||||
self.log.debug("Starting loop to track connection state")
|
||||
while True:
|
||||
await asyncio.sleep(3)
|
||||
connected = bool(self.client._sender._transport_connected
|
||||
if self.client and self.client._sender else False)
|
||||
self._track_metric(METRIC_CONNECTED, connected)
|
||||
|
||||
async def stop(self) -> None:
|
||||
await super().stop()
|
||||
if self._track_connection_task:
|
||||
self._track_connection_task.cancel()
|
||||
self._track_connection_task = None
|
||||
self._track_metric(METRIC_CONNECTED, False)
|
||||
|
||||
async def post_login(self, info: TLUser = None, first_login: bool = False) -> None:
|
||||
try:
|
||||
await self.update_info(info)
|
||||
@@ -236,8 +207,6 @@ class User(AbstractUser, BaseUser):
|
||||
self.log.exception("Failed to update telegram account info")
|
||||
return
|
||||
|
||||
self._track_metric(METRIC_LOGGED_IN, True)
|
||||
|
||||
try:
|
||||
puppet = pu.Puppet.get(self.tgid)
|
||||
if puppet.custom_mxid != self.mxid and puppet.can_auto_login(self.mxid):
|
||||
@@ -258,7 +227,12 @@ class User(AbstractUser, BaseUser):
|
||||
return False
|
||||
|
||||
if isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
|
||||
portal = po.Portal.get_by_entity(update.message.peer_id, receiver_id=self.tgid)
|
||||
message = update.message
|
||||
if isinstance(message.to_id, PeerUser) and not message.out:
|
||||
portal = po.Portal.get_by_tgid(message.from_id, peer_type="user",
|
||||
tg_receiver=self.tgid)
|
||||
else:
|
||||
portal = po.Portal.get_by_entity(message.to_id, receiver_id=self.tgid)
|
||||
elif isinstance(update, UpdateShortChatMessage):
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
||||
elif isinstance(update, UpdateShortMessage):
|
||||
@@ -267,7 +241,7 @@ class User(AbstractUser, BaseUser):
|
||||
return False
|
||||
|
||||
if portal:
|
||||
await self.register_portal(portal)
|
||||
self.register_portal(portal)
|
||||
return False
|
||||
|
||||
# Don't bother handling the update
|
||||
@@ -296,7 +270,7 @@ class User(AbstractUser, BaseUser):
|
||||
self.tgid = TelegramID(info.id)
|
||||
self.by_tgid[self.tgid] = self
|
||||
if changed:
|
||||
await self.save()
|
||||
self.save()
|
||||
|
||||
async def log_out(self) -> bool:
|
||||
puppet = pu.Puppet.get(self.tgid)
|
||||
@@ -312,19 +286,18 @@ class User(AbstractUser, BaseUser):
|
||||
pass
|
||||
self.portals = {}
|
||||
self.contacts = []
|
||||
await self.save(portals=True, contacts=True)
|
||||
self.save(portals=True, contacts=True)
|
||||
if self.tgid:
|
||||
try:
|
||||
del self.by_tgid[self.tgid]
|
||||
except KeyError:
|
||||
pass
|
||||
self.tgid = None
|
||||
await self.save()
|
||||
self.save()
|
||||
ok = await self.client.log_out()
|
||||
if not ok:
|
||||
return False
|
||||
self.delete()
|
||||
self._track_metric(METRIC_LOGGED_IN, False)
|
||||
return True
|
||||
|
||||
def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45
|
||||
@@ -333,7 +306,7 @@ class User(AbstractUser, BaseUser):
|
||||
for contact in self.contacts:
|
||||
similarity = contact.similarity(query)
|
||||
if similarity >= min_similarity:
|
||||
results.append(SearchResult(contact, similarity))
|
||||
results.append(SearchResult((contact, similarity)))
|
||||
results.sort(key=lambda tup: tup[1], reverse=True)
|
||||
return results[0:max_results]
|
||||
|
||||
@@ -345,7 +318,7 @@ class User(AbstractUser, BaseUser):
|
||||
for user in server_results.users:
|
||||
puppet = pu.Puppet.get(user.id)
|
||||
await puppet.update_info(self, user)
|
||||
results.append(SearchResult(puppet, puppet.similarity(query)))
|
||||
results.append(SearchResult((puppet, puppet.similarity(query))))
|
||||
results.sort(key=lambda tup: tup[1], reverse=True)
|
||||
return results[0:max_results]
|
||||
|
||||
@@ -360,30 +333,13 @@ class User(AbstractUser, BaseUser):
|
||||
|
||||
return await self._search_remote(query), True
|
||||
|
||||
async def _catch(self, action: str, task: asyncio.Task) -> None:
|
||||
try:
|
||||
await task
|
||||
except Exception:
|
||||
self.log.exception(f"Error while {action}")
|
||||
|
||||
async def get_direct_chats(self) -> Dict[UserID, List[RoomID]]:
|
||||
return {
|
||||
pu.Puppet.get_mxid_from_id(portal.tgid): [portal.mxid]
|
||||
for portal in DBPortal.find_private_chats(self.tgid)
|
||||
if portal.mxid
|
||||
}
|
||||
|
||||
async def sync_dialogs(self) -> None:
|
||||
async def sync_dialogs(self, synchronous_create: bool = False) -> None:
|
||||
if self.is_bot:
|
||||
return
|
||||
creators = []
|
||||
update_limit = config["bridge.sync_update_limit"] or None
|
||||
create_limit = config["bridge.sync_create_limit"]
|
||||
index = 0
|
||||
self.log.debug(f"Syncing dialogs (update_limit={update_limit}, "
|
||||
f"create_limit={create_limit})")
|
||||
dialog: Dialog
|
||||
async for dialog in self.client.iter_dialogs(limit=update_limit, ignore_migrated=True,
|
||||
limit = config["bridge.sync_dialog_limit"] or None
|
||||
self.log.debug(f"Syncing dialogs (limit={limit}, synchronous_create={synchronous_create})")
|
||||
async for dialog in self.client.iter_dialogs(limit=limit, ignore_migrated=True,
|
||||
archived=False):
|
||||
entity = dialog.entity
|
||||
if isinstance(entity, ChatForbidden):
|
||||
@@ -397,38 +353,26 @@ class User(AbstractUser, BaseUser):
|
||||
continue
|
||||
portal = po.Portal.get_by_entity(entity, receiver_id=self.tgid)
|
||||
self.portals[portal.tgid_full] = portal
|
||||
if portal.mxid:
|
||||
update_task = portal.update_matrix_room(self, entity)
|
||||
backfill_task = portal.backfill(self, last_id=dialog.message.id)
|
||||
creators.append(self._catch(f"updating {portal.tgid_log}",
|
||||
self.loop.create_task(update_task)))
|
||||
creators.append(self._catch(f"backfilling {portal.tgid_log}",
|
||||
self.loop.create_task(backfill_task)))
|
||||
elif not create_limit or index < create_limit:
|
||||
create_task = portal.create_matrix_room(self, entity, invites=[self.mxid])
|
||||
creators.append(self._catch(f"creating {portal.tgid_log}",
|
||||
self.loop.create_task(create_task)))
|
||||
index += 1
|
||||
await self.save(portals=True)
|
||||
await asyncio.gather(*creators)
|
||||
await self.update_direct_chats()
|
||||
creators.append(
|
||||
portal.create_matrix_room(self, entity, invites=[self.mxid],
|
||||
synchronous=synchronous_create))
|
||||
self.save(portals=True)
|
||||
await asyncio.gather(*creators, loop=self.loop)
|
||||
self.log.debug("Dialog syncing complete")
|
||||
|
||||
async def register_portal(self, portal: po.Portal) -> None:
|
||||
self.log.trace(f"Registering portal {portal.tgid_full}")
|
||||
def register_portal(self, portal: po.Portal) -> None:
|
||||
try:
|
||||
if self.portals[portal.tgid_full] == portal:
|
||||
return
|
||||
except KeyError:
|
||||
pass
|
||||
self.portals[portal.tgid_full] = portal
|
||||
await self.save(portals=True)
|
||||
self.save(portals=True)
|
||||
|
||||
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
|
||||
self.log.trace(f"Unregistering portal {(tgid, tg_receiver)}")
|
||||
def unregister_portal(self, portal: po.Portal) -> None:
|
||||
try:
|
||||
del self.portals[(tgid, tg_receiver)]
|
||||
await self.save(portals=True)
|
||||
del self.portals[portal.tgid_full]
|
||||
self.save(portals=True)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
@@ -453,7 +397,7 @@ class User(AbstractUser, BaseUser):
|
||||
puppet = pu.Puppet.get(user.id)
|
||||
await puppet.update_info(self, user)
|
||||
self.contacts.append(puppet)
|
||||
await self.save(contacts=True)
|
||||
self.save(contacts=True)
|
||||
|
||||
# endregion
|
||||
# region Class instance lookup
|
||||
@@ -516,7 +460,6 @@ class User(AbstractUser, BaseUser):
|
||||
def init(context: 'Context') -> Iterable[Awaitable['User']]:
|
||||
global config
|
||||
config = context.config
|
||||
User.bridge = context.bridge
|
||||
|
||||
return (User.from_db(db_user).try_ensure_started()
|
||||
for db_user in DBUser.all_with_tgid())
|
||||
|
||||
@@ -49,7 +49,7 @@ except ImportError:
|
||||
VideoFileClip = None
|
||||
|
||||
try:
|
||||
from mautrix.crypto.attachments import encrypt_attachment
|
||||
from nio.crypto import encrypt_attachment
|
||||
except ImportError:
|
||||
encrypt_attachment = None
|
||||
|
||||
@@ -108,10 +108,8 @@ def _location_to_id(location: TypeLocation) -> str:
|
||||
|
||||
|
||||
async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
|
||||
thumbnail_loc: TypeLocation, mime_type: str, encrypt: bool,
|
||||
video: Optional[bytes], custom_data: Optional[bytes] = None,
|
||||
width: Optional[int] = None, height: [int] = None
|
||||
) -> Optional[DBTelegramFile]:
|
||||
thumbnail_loc: TypeLocation, video: bytes, mime: str,
|
||||
encrypt: bool) -> Optional[DBTelegramFile]:
|
||||
if not Image or not VideoFileClip:
|
||||
return None
|
||||
|
||||
@@ -119,17 +117,12 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
|
||||
if not loc_id:
|
||||
return None
|
||||
|
||||
if custom_data:
|
||||
loc_id += "-mau_custom_thumbnail"
|
||||
|
||||
db_file = DBTelegramFile.get(loc_id)
|
||||
if db_file:
|
||||
return db_file
|
||||
|
||||
video_ext = sane_mimetypes.guess_extension(mime_type)
|
||||
if custom_data:
|
||||
file = custom_data
|
||||
elif VideoFileClip and video_ext and video:
|
||||
video_ext = sane_mimetypes.guess_extension(mime)
|
||||
if VideoFileClip and video_ext and video:
|
||||
try:
|
||||
file, width, height = _read_video_thumbnail(video, video_ext, frame_ext="png")
|
||||
except OSError:
|
||||
@@ -143,7 +136,8 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
|
||||
decryption_info = None
|
||||
upload_mime_type = mime_type
|
||||
if encrypt:
|
||||
file, decryption_info = encrypt_attachment(file)
|
||||
file, decryption_info_dict = encrypt_attachment(file)
|
||||
decryption_info = EncryptedFile.deserialize(decryption_info_dict)
|
||||
upload_mime_type = "application/octet-stream"
|
||||
content_uri = await intent.upload_media(file, upload_mime_type)
|
||||
if decryption_info:
|
||||
@@ -200,8 +194,6 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
|
||||
if db_file:
|
||||
return db_file
|
||||
|
||||
converted_anim = None
|
||||
|
||||
if parallel_id and isinstance(location, Document) and (not is_sticker or not tgs_convert):
|
||||
db_file = await parallel_transfer_to_matrix(client, intent, loc_id, location, filename,
|
||||
encrypt, parallel_id)
|
||||
@@ -221,17 +213,13 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
|
||||
|
||||
image_converted = False
|
||||
# A weird bug in alpine/magic makes it return application/octet-stream for gzips...
|
||||
is_tgs = (mime_type == "application/gzip" or (mime_type == "application/octet-stream"
|
||||
and magic.from_buffer(file).startswith(
|
||||
"gzip")))
|
||||
if is_sticker and tgs_convert and is_tgs:
|
||||
converted_anim = await convert_tgs_to(file, tgs_convert["target"],
|
||||
**tgs_convert["args"])
|
||||
mime_type = converted_anim.mime
|
||||
file = converted_anim.data
|
||||
width, height = converted_anim.width, converted_anim.height
|
||||
image_converted = mime_type != "application/gzip"
|
||||
if is_sticker and tgs_convert and (mime_type == "application/gzip" or (
|
||||
mime_type == "application/octet-stream"
|
||||
and magic.from_buffer(file).startswith("gzip"))):
|
||||
mime_type, file, width, height = await convert_tgs_to(
|
||||
file, tgs_convert["target"], **tgs_convert["args"])
|
||||
thumbnail = None
|
||||
image_converted = mime_type != "application/gzip"
|
||||
|
||||
if mime_type == "image/webp":
|
||||
new_mime_type, file, width, height = convert_image(
|
||||
@@ -244,7 +232,8 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
|
||||
decryption_info = None
|
||||
upload_mime_type = mime_type
|
||||
if encrypt and encrypt_attachment:
|
||||
file, decryption_info = encrypt_attachment(file)
|
||||
file, decryption_info_dict = encrypt_attachment(file)
|
||||
decryption_info = EncryptedFile.deserialize(decryption_info_dict)
|
||||
upload_mime_type = "application/octet-stream"
|
||||
content_uri = await intent.upload_media(file, upload_mime_type)
|
||||
if decryption_info:
|
||||
@@ -258,16 +247,10 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
|
||||
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
|
||||
thumbnail = thumbnail.location
|
||||
try:
|
||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(client, intent, thumbnail,
|
||||
video=file, mime_type=mime_type,
|
||||
encrypt=encrypt)
|
||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(client, intent, thumbnail, file,
|
||||
mime_type, encrypt)
|
||||
except FileIdInvalidError:
|
||||
log.warning(f"Failed to transfer thumbnail for {thumbnail!s}", exc_info=True)
|
||||
elif converted_anim and converted_anim.thumbnail_data:
|
||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(
|
||||
client, intent, location, video=None, encrypt=encrypt,
|
||||
custom_data=converted_anim.thumbnail_data, mime_type=converted_anim.thumbnail_mime,
|
||||
width=converted_anim.width, height=converted_anim.height)
|
||||
|
||||
try:
|
||||
db_file.insert()
|
||||
|
||||
@@ -41,7 +41,7 @@ from ..tgclient import MautrixTelegramClient
|
||||
from ..db import TelegramFile as DBTelegramFile
|
||||
|
||||
try:
|
||||
from mautrix.crypto.attachments import async_encrypt_attachment
|
||||
from nio.crypto import async_encrypt_attachment
|
||||
except ImportError:
|
||||
async_encrypt_attachment = None
|
||||
|
||||
@@ -186,9 +186,9 @@ class ParallelTransferrer:
|
||||
|
||||
async def _create_sender(self) -> MTProtoSender:
|
||||
dc = await self.client._get_dc(self.dc_id)
|
||||
sender = MTProtoSender(self.auth_key, loggers=self.client._log)
|
||||
sender = MTProtoSender(self.auth_key, self.loop, loggers=self.client._log)
|
||||
await sender.connect(self.client._connection(dc.ip_address, dc.port, dc.id,
|
||||
loggers=self.client._log,
|
||||
loop=self.loop, loggers=self.client._log,
|
||||
proxy=self.client._proxy))
|
||||
if not self.auth_key:
|
||||
log.debug(f"Exporting auth to DC {self.dc_id}")
|
||||
@@ -262,8 +262,8 @@ async def parallel_transfer_to_matrix(client: MautrixTelegramClient, intent: Int
|
||||
async def encrypted(stream):
|
||||
nonlocal decryption_info
|
||||
async for chunk in async_encrypt_attachment(stream):
|
||||
if isinstance(chunk, EncryptedFile):
|
||||
decryption_info = chunk
|
||||
if isinstance(chunk, dict):
|
||||
decryption_info = EncryptedFile.deserialize(chunk)
|
||||
else:
|
||||
yield chunk
|
||||
|
||||
|
||||
@@ -21,23 +21,8 @@ import shutil
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
from attr import dataclass
|
||||
|
||||
log: logging.Logger = logging.getLogger("mau.util.tgs")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConvertedSticker:
|
||||
mime: str
|
||||
data: bytes
|
||||
thumbnail_mime: Optional[str] = None
|
||||
thumbnail_data: Optional[bytes] = None
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
|
||||
|
||||
Converter = Callable[[bytes, int, int, Any], Awaitable[ConvertedSticker]]
|
||||
converters: Dict[str, Converter] = {}
|
||||
converters: Dict[str, Callable[[bytes, int, int, Any], Awaitable[Tuple[str, bytes]]]] = {}
|
||||
|
||||
|
||||
def abswhich(program: Optional[str]) -> Optional[str]:
|
||||
@@ -49,7 +34,7 @@ lottieconverter = abswhich("lottieconverter")
|
||||
ffmpeg = abswhich("ffmpeg")
|
||||
|
||||
if lottieconverter:
|
||||
async def tgs_to_png(file: bytes, width: int, height: int, **_: Any) -> ConvertedSticker:
|
||||
async def tgs_to_png(file: bytes, width: int, height: int, **_: Any) -> Tuple[str, bytes]:
|
||||
frame = 1
|
||||
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", "-", "png",
|
||||
f"{width}x{height}", str(frame),
|
||||
@@ -57,26 +42,26 @@ if lottieconverter:
|
||||
stdin=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate(file)
|
||||
if proc.returncode == 0:
|
||||
return ConvertedSticker("image/png", stdout)
|
||||
return "image/png", stdout
|
||||
else:
|
||||
log.error("lottieconverter error: " + (stderr.decode("utf-8") if stderr is not None
|
||||
else f"unknown ({proc.returncode})"))
|
||||
return ConvertedSticker("application/gzip", file)
|
||||
else f"unknown ({proc.returncode})"))
|
||||
return "application/gzip", file
|
||||
|
||||
|
||||
async def tgs_to_gif(file: bytes, width: int, height: int, background: str = "202020",
|
||||
**_: Any) -> ConvertedSticker:
|
||||
**_: Any) -> Tuple[str, bytes]:
|
||||
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", "-", "gif",
|
||||
f"{width}x{height}", f"0x{background}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate(file)
|
||||
if proc.returncode == 0:
|
||||
return ConvertedSticker("image/gif", stdout)
|
||||
return "image/gif", stdout
|
||||
else:
|
||||
log.error("lottieconverter error: " + (stderr.decode("utf-8") if stderr is not None
|
||||
else f"unknown ({proc.returncode})"))
|
||||
return ConvertedSticker("application/gzip", file)
|
||||
else f"unknown ({proc.returncode})"))
|
||||
return "application/gzip", file
|
||||
|
||||
|
||||
converters["png"] = tgs_to_png
|
||||
@@ -84,7 +69,7 @@ if lottieconverter:
|
||||
|
||||
if lottieconverter and ffmpeg:
|
||||
async def tgs_to_webm(file: bytes, width: int, height: int, fps: int = 30,
|
||||
**_: Any) -> ConvertedSticker:
|
||||
**_: Any) -> Tuple[str, bytes]:
|
||||
with tempfile.TemporaryDirectory(prefix="tgs_") as tmpdir:
|
||||
file_template = tmpdir + "/out_"
|
||||
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", file_template,
|
||||
@@ -93,8 +78,6 @@ if lottieconverter and ffmpeg:
|
||||
stdin=asyncio.subprocess.PIPE)
|
||||
_, stderr = await proc.communicate(file)
|
||||
if proc.returncode == 0:
|
||||
with open(f"{file_template}00.png", "rb") as first_frame_file:
|
||||
first_frame_data = first_frame_file.read()
|
||||
proc = await asyncio.create_subprocess_exec(ffmpeg, "-hide_banner", "-loglevel",
|
||||
"error", "-framerate", str(fps),
|
||||
"-pattern_type", "glob", "-i",
|
||||
@@ -105,27 +88,25 @@ if lottieconverter and ffmpeg:
|
||||
stdin=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
return ConvertedSticker("video/webm", stdout, "image/png", first_frame_data)
|
||||
return "video/webm", stdout
|
||||
else:
|
||||
log.error("ffmpeg error: " + (stderr.decode("utf-8") if stderr is not None
|
||||
else f"unknown ({proc.returncode})"))
|
||||
else f"unknown ({proc.returncode})"))
|
||||
else:
|
||||
log.error("lottieconverter error: " + (stderr.decode("utf-8") if stderr is not None
|
||||
else f"unknown ({proc.returncode})"))
|
||||
return ConvertedSticker("application/gzip", file)
|
||||
else f"unknown ({proc.returncode})"))
|
||||
return "application/gzip", file
|
||||
|
||||
|
||||
converters["webm"] = tgs_to_webm
|
||||
|
||||
|
||||
async def convert_tgs_to(file: bytes, convert_to: str, width: int, height: int, **kwargs: Any
|
||||
) -> ConvertedSticker:
|
||||
) -> Tuple[str, bytes, Optional[int], Optional[int]]:
|
||||
if convert_to in converters:
|
||||
converter = converters[convert_to]
|
||||
converted = await converter(file, width, height, **kwargs)
|
||||
converted.width = width
|
||||
converted.height = height
|
||||
return converted
|
||||
mime, out = await converter(file, width, height, **kwargs)
|
||||
return mime, out, width, height
|
||||
elif convert_to != "disable":
|
||||
log.warning(f"Unable to convert animated sticker, type {convert_to} not supported")
|
||||
return ConvertedSticker("application/gzip", file)
|
||||
return "application/gzip", file, None, None
|
||||
|
||||
@@ -183,7 +183,7 @@ class ProvisioningAPI(AuthAPI):
|
||||
portal.mxid = room_id
|
||||
portal.title, portal.about, levels = await get_initial_state(self.az.intent, room_id)
|
||||
portal.photo_id = ""
|
||||
await portal.save()
|
||||
portal.save()
|
||||
|
||||
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
|
||||
loop=self.loop)
|
||||
|
||||
@@ -8,11 +8,7 @@ aiodns
|
||||
brotli
|
||||
|
||||
#/webp_convert
|
||||
pillow>=4,<8
|
||||
|
||||
#/qr_login
|
||||
pillow>=4,<8
|
||||
qrcode>=6,<7
|
||||
pillow>=4.3,<8
|
||||
|
||||
#/hq_thumbnails
|
||||
moviepy>=1,<2
|
||||
@@ -24,7 +20,4 @@ prometheus_client>=0.6,<0.9
|
||||
psycopg2-binary>=2,<3
|
||||
|
||||
#/e2be
|
||||
asyncpg>=0.20,<0.22
|
||||
python-olm>=3,<4
|
||||
pycryptodome>=3,<4
|
||||
unpaddedbase64>=1,<2
|
||||
matrix-nio[e2e]>=0.9,<0.13
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 260 KiB |
+3
-4
@@ -3,8 +3,7 @@ alembic>=1,<2
|
||||
ruamel.yaml>=0.15.35,<0.17
|
||||
python-magic>=0.4,<0.5
|
||||
commonmark>=0.8,<0.10
|
||||
aiohttp>=3,<3.7
|
||||
yarl<1.6
|
||||
mautrix==0.8.0rc1
|
||||
telethon>=1.17,<1.18
|
||||
aiohttp>=3,<4
|
||||
mautrix>=0.5.8,<0.6
|
||||
telethon>=1.13,<1.15
|
||||
telethon-session-sqlalchemy>=0.2.14,<0.3
|
||||
|
||||
@@ -61,16 +61,20 @@ setuptools.setup(
|
||||
"Framework :: AsyncIO",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.6",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
],
|
||||
entry_points="""
|
||||
[console_scripts]
|
||||
mautrix-telegram=mautrix_telegram.__main__:main
|
||||
""",
|
||||
package_data={"mautrix_telegram": [
|
||||
"web/public/*.mako", "web/public/*.png", "web/public/*.css",
|
||||
"example-config.yaml",
|
||||
]},
|
||||
data_files=[
|
||||
(".", ["alembic.ini", "mautrix_telegram/example-config.yaml"]),
|
||||
(".", ["alembic.ini"]),
|
||||
("alembic", ["alembic/env.py"]),
|
||||
("alembic/versions", glob.glob("alembic/versions/*.py"))
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user