Compare commits

...

96 Commits

Author SHA1 Message Date
Tulir Asokan ab671ac7eb Bump version to 0.9.0rc1 2020-10-24 21:35:01 +03:00
Tulir Asokan 2343e85f4d Update ROADMAP.md 2020-10-24 21:33:59 +03:00
Tulir Asokan 70a6b847e2 Fix random bugs and update mautrix-python 2020-10-24 21:13:57 +03:00
Tulir Asokan a3f6bc2acb Add config option for receiving ephemeral events with MSC2409 2020-10-24 21:01:34 +03:00
Tulir Asokan 1bce95586b Update mautrix-python 2020-10-24 20:24:06 +03:00
Tulir Asokan 80aa557e0c Fix resolving UpdateNewMessage sender in private chats 2020-10-23 23:42:45 +03:00
Tulir Asokan 686e26a503 Merge branch 'telethon-1.17' 2020-10-22 17:42:25 +03:00
Tulir Asokan a33cdae4c3 Add missing parameter to get_user 2020-10-16 17:11:26 +03:00
Tulir Asokan 258f665338 Update mautrix-python 2020-10-16 15:18:34 +03:00
Tulir Asokan 3b70829d72 Use Gauge instead of Enum to count connected users 2020-10-15 18:35:21 +03:00
Tulir Asokan 524f60ab48 Update to mautrix-python 0.8.0.beta3
* Cross-server double puppeting is now possible
* End-to-bridge encryption no longer requires login_shared_secret,
  but the homeserver must support MSC2778 (Synapse 1.21+)
2020-10-14 18:56:26 +03:00
Tulir Asokan fdc58ce450 Fix bridging non-image files 2020-10-13 13:38:44 +03:00
Tulir Asokan a4595b427d Don't send delivery receipts to unencrypted private chat portals. Fixes #483 2020-10-09 16:50:12 +03:00
Tulir Asokan 522e33be12 Add png thumbnails for webm animated stickers. Fixes #467 2020-10-09 16:47:41 +03:00
Tulir Asokan 146a79b516 Update mautrix-python 2020-10-09 14:50:37 +03:00
Tulir Asokan 4f85cf1723 Update preview and readme 2020-10-05 11:42:14 +03:00
Tulir Asokan d35799e2ce Add some checks 2020-10-04 15:09:07 +03:00
Tulir Asokan a003e2e979 Update for Telethon 1.17 and TL layer 119 2020-10-02 22:05:15 +03:00
Tulir Asokan f4b8e85689 Update mautrix-python 2020-10-02 14:58:51 +03:00
Tulir Asokan 6b94097f29 Bump mautrix-python version 2020-09-25 15:40:19 +03:00
Tulir Asokan 6e1dbf3a8e Update mautrix-python 2020-09-22 13:10:12 +03:00
Tulir Asokan 0dc56aad1c Update prometheus stuff 2020-09-19 01:04:34 +03:00
Tulir Asokan a565853c5e Update things in setup.py 2020-09-18 17:41:49 +03:00
Tulir Asokan ac56ee1553 Bump mautrix-python version 2020-09-18 17:35:14 +03:00
Tulir Asokan 349914f447 Update mautrix-python 2020-09-14 00:41:04 +03:00
Tulir Asokan 2a1bddf5e4 Move prometheus setup to mautrix-python 2020-09-09 14:02:37 +03:00
Tulir Asokan 668dad9c6f Move .github metadata to common repo 2020-09-09 01:19:38 +03:00
Tulir Asokan 2b978be79c Update deps 2020-09-04 16:50:06 +03:00
Tulir Asokan 66917b6db0 Add option to update m.direct with double puppeting 2020-08-21 21:20:49 +03:00
Tulir Asokan 292745866d Improve trust member list check 2020-08-19 00:21:01 +03:00
Tulir Asokan f86fabafbe Trust member list if there are less members than the sync limit 2020-08-19 00:18:28 +03:00
Tulir Asokan 48a624bd07 Re-add custom get_users method to avoid expensive API calls 2020-08-19 00:11:52 +03:00
Tulir Asokan 66c2e779ea Add mutex for backfill method 2020-08-18 23:56:24 +03:00
Tulir Asokan f84dcb64d3 Replace custom get_users with client.get_participants 2020-08-18 23:41:38 +03:00
Tulir Asokan 95bb974ca6 Update handling of deleted members 2020-08-18 20:32:41 +03:00
Tulir Asokan 953ef0e5bc Maybe fix encrypted parallel file transfer 2020-08-18 20:27:40 +03:00
Tulir Asokan 1b2024e456 Update username even if disable_updates is true 2020-08-18 20:27:10 +03:00
Tulir Asokan e961c3a9ed Pass through messages even if they're commands 2020-08-16 18:24:48 +03:00
Tulir Asokan 22d50208d8 Fix checking if message is command 2020-08-16 18:24:48 +03:00
Tulir Asokan b43cc72de2 Merge pull request #518 from kubesail/master
add jq / yq
2020-08-16 00:09:47 +03:00
Dan Pastusek a06691b214 add TARGETARCH as build arg in ci pipeline 2020-08-14 15:39:53 -06:00
Dan Pastusek 3461ee6a72 remove empty line 2020-08-14 15:04:12 -06:00
Dan Pastusek 8662db67b8 add jq / yq 2020-08-14 15:03:15 -06:00
Tulir Asokan 321a7810c4 Catch individual errors when syncing dialogs 2020-08-06 20:42:19 +03:00
Tulir Asokan eae7bba649 Update to mautrix-python v0.7 2020-08-06 20:34:09 +03:00
Tulir Asokan 92c572d761 Maybe fix parallel file transfer 2020-08-04 16:56:59 +03:00
Tulir Asokan 868ebf2025 Improve YAML handling in !tg config. Fixes #377 2020-08-02 21:19:20 +03:00
Tulir Asokan 9f9182c564 Show upgraded rooms separately in clean-rooms list. Fixes #369 2020-08-02 01:00:09 +03:00
Tulir Asokan c62774f1a6 Implement disappearing photos. Fixes #481 2020-08-02 00:54:37 +03:00
Tulir Asokan eace9b4ef6 Unregister old chat when a group is upgraded 2020-08-02 00:54:16 +03:00
Tulir Asokan bc4610af04 Add option to disable backfilling normal groups 2020-08-01 14:11:34 +03:00
Tulir Asokan 729fa8eb46 Update Telethon 2020-07-30 21:26:20 +03:00
Tulir Asokan 8ca78e21b6 Remove incorrect check in own read receipt bridging 2020-07-30 19:22:13 +03:00
Tulir Asokan b17454723e Bridge own read receipts from other Telegram clients with double puppeting 2020-07-30 19:20:39 +03:00
Tulir Asokan 5e8aa8818f Implement disabling notifications while backfilling 2020-07-29 22:47:00 +03:00
Tulir Asokan ffcfd019c2 Fix auto-accepting private chat portals with double puppeting 2020-07-29 22:21:26 +03:00
Tulir Asokan 7298d9dfdc Handle channel messages correctly in backfill 2020-07-29 22:19:21 +03:00
Tulir Asokan be3b135cc7 Merge branch 'automatic-backfill'
Fixes #476
Fixes #477
2020-07-29 22:15:48 +03:00
Tulir Asokan 9848f8b92c Separate dialog syncing and creation limits and fix bugs 2020-07-29 21:55:51 +03:00
Tulir Asokan 59eb7376c9 Add missed message backfilling 2020-07-28 18:32:34 +03:00
Tulir Asokan ea017467fd Add support for football 2020-07-28 18:01:44 +03:00
Tulir Asokan 2c0a2e694b Add option for automatic backfilling when creating portal 2020-07-28 17:28:07 +03:00
Tulir Asokan 993354bce5 Update mautrix-python 2020-07-27 13:28:08 +03:00
Tulir Asokan 8299b68b96 Update wording in roadmap 2020-07-27 12:36:49 +03:00
Tulir Asokan bf9f9e1064 Merge pull request #503 from SharkyRawr/dbms-import-fix
Fixup `mautrix_telegram.scripts.dbms_migrate` imports as they changed upstream
2020-07-26 22:25:56 +03:00
Sophie 'Sharky' Schumann 5cf8a7a8a4 Fixup mautrix_telegram.scripts.dbms_migrate import for RoomState and UserProfile as it changed upstream. 2020-07-26 21:20:49 +02:00
Tulir Asokan da91df5754 Make management API comment more accurate 2020-07-23 20:16:27 +03:00
Tulir Asokan 341b69ed75 Update roadmap 2020-07-16 15:18:12 +03:00
Tulir Asokan a7a3ce4ea1 Update mautrix-python to fix duplicate message indexes in e2be 2020-07-12 22:03:59 +03:00
Tulir Asokan f83d03fb16 Update mautrix-python a third time 2020-07-12 17:23:52 +03:00
Tulir Asokan 34e1935a97 Update mautrix-python again 2020-07-12 16:34:25 +03:00
Tulir Asokan 0080b028bf Update mautrix-python 2020-07-12 15:48:35 +03:00
Tulir Asokan 689d84fa78 Move enable_dm_encryption helper to Portal 2020-07-09 19:45:28 +03:00
Tulir Asokan 64c9759de8 Update mautrix-python again and fix bugs in accepting invites as puppets 2020-07-09 19:05:40 +03:00
Tulir Asokan 31cac3eef3 Update mautrix-python 2020-07-09 16:59:01 +03:00
Tulir Asokan 4e670a8cbe Switch to mautrix-python crypto 2020-07-08 23:05:39 +03:00
Tulir Asokan bbfcc9d7d8 Fix handling messages with PhotoEmpty. Fixes #494 2020-07-06 12:41:04 +03:00
Tulir Asokan 29cc98a7f5 Update Telethon and mautrix-python 2020-07-05 13:47:17 +03:00
Tulir Asokan 8e54d2e253 Add basketball to known dice throw emojis 2020-07-05 13:47:08 +03:00
Tulir Asokan dd69204f5a Move handle_telegram_text log to trace level (ref #321) 2020-07-04 22:01:01 +03:00
Tulir Asokan 44a102c3b1 Automatically accept invitations when using double puppeting 2020-06-24 23:33:22 +03:00
Tulir Asokan f487853954 Fix handling file captions. Fixes #475 2020-06-24 22:32:16 +03:00
Tulir Asokan a29d9cf4ff Add QR login command. Fixes #399
Requires LonamiWebs/Telethon#1494 until it's merged, then requires using
the master branch of Telethon until a release is made.
2020-06-24 15:04:51 +03:00
Tulir Asokan 3fa6ed74e5 Fix sign in location messages 2020-06-22 13:53:00 +03:00
Tulir Asokan d3c1c2be6c Update deps 2020-06-18 10:51:56 +03:00
Tulir Asokan f274fe1cf6 Add FUNDING.yml 2020-06-18 10:48:04 +03:00
Tulir Asokan f358eab214 Don't mutate EventType objects 2020-06-17 16:39:56 +03:00
Tulir Asokan 59d76148dc Don't try to send m.bridge events before portal is created 2020-06-15 16:13:49 +03:00
Tulir Asokan 489e520ddd Add option to resend bridge info to all portals 2020-06-15 15:30:57 +03:00
Tulir Asokan 60ecb03f64 Add external url to bridge info 2020-06-15 15:02:08 +03:00
Tulir Asokan 8a99e67c6d Update bridge info when portal metadata changes 2020-06-15 14:43:38 +03:00
Tulir Asokan 482a52cb5e Fix using edge repos in docker image. Fixes #482 2020-06-11 19:46:29 +03:00
Tulir Asokan ba13c5cae1 Send "delivery" receipt for messages bridged from Telegram 2020-06-11 19:09:01 +03:00
Tulir Asokan 4b57be3917 Bump version to 0.8.1 2020-06-08 17:45:19 +03:00
Tulir Asokan 9383e5eed2 Allow any 0.5.x version of mautrix-python
Fixes #479
2020-06-08 12:36:18 +03:00
Tulir Asokan a3b4a5e30e Update Docker image to Alpine 3.12 2020-06-06 20:10:14 +03:00
48 changed files with 1381 additions and 679 deletions
+1
View File
@@ -14,4 +14,5 @@ __pycache__
/registration.yaml
*.log*
*.db
*.pickle
*.bak
+2 -2
View File
@@ -14,7 +14,7 @@ build amd64:
- amd64
script:
- docker pull $CI_REGISTRY_IMAGE:latest || true
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 .
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --build-arg TARGETARCH=amd64 --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 --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 .
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --build-arg TARGETARCH=arm64 --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
+22 -22
View File
@@ -1,19 +1,24 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.11
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.12
RUN echo "@edge_main http://dl-cdn.alpinelinux.org/alpine/edge/main" >> /etc/apk/repositories
RUN echo "@edge_testing http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories
RUN echo "@edge_community http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories
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\
@edge http://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories
RUN apk add --no-cache \
python3 py3-pip py3-setuptools py3-wheel \
py3-virtualenv \
py3-pillow \
py3-aiohttp \
py3-magic \
py3-sqlalchemy \
py3-alembic@edge_testing \
py3-telethon-session-sqlalchemy@edge \
py3-alembic@edge \
py3-psycopg2 \
py3-ruamel.yaml \
py3-commonmark@edge_testing \
py3-commonmark@edge \
# Indirect dependencies
py3-idna \
#moviepy
@@ -22,33 +27,28 @@ RUN apk add --no-cache \
py3-requests \
#imageio
py3-numpy \
#telethon
py3-rsa \
#py3-telethon@edge \ (outdated)
# Optional for socks proxies
py3-pysocks \
# cryptg
py3-cffi \
py3-qrcode@edge \
py3-brotli \
# Other dependencies
ffmpeg \
ca-certificates \
su-exec \
netcat-openbsd \
# olm
olm-dev@edge_community \
# matrix-nio?
py3-future \
py3-atomicwrites \
py3-pycryptodome@edge_main \
py3-peewee@edge_community \
py3-pyrsistent@edge_community \
py3-jsonschema \
py3-aiofiles \
py3-cachetools@edge_community \
py3-prometheus-client@edge_community \
# encryption
olm-dev \
py3-pycryptodome \
py3-unpaddedbase64 \
py3-pyaes@edge_testing \
py3-logbook@edge_testing
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
COPY requirements.txt /opt/mautrix-telegram/requirements.txt
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
+13 -3
View File
@@ -10,9 +10,19 @@ A Matrix-Telegram hybrid puppeting/relaybot bridge.
## Sponsors
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
### [Wiki](https://github.com/tulir/mautrix-telegram/wiki)
### Wiki
All setup and usage instructions are located in the GitHub
[wiki](https://github.com/tulir/mautrix-telegram/wiki). Some quick links:
### [Features & Roadmap](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md)
* [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.
## Discussion
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
@@ -20,4 +30,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
![Preview](https://raw.githubusercontent.com/tulir/mautrix-telegram/master/preview.png)
![Preview](preview.png)
+11 -12
View File
@@ -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
* [ ] Message history
* [x] Message history
* [x] Manually (`!tg backfill`)
* [ ] Automatically when creating portal
* [ ] Automatically for missed messages
* [x] Automatically when creating portal
* [x] Automatically for missed messages
* [x] Avatars
* [x] Presence
* [x] Typing notifications
@@ -53,12 +53,11 @@
* [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
* [x] Option to use own Matrix account for messages sent from other Telegram clients
* [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)
* [ ] ‡ Calls (hard, not yet supported by Telethon)
* [ ] ‡ Secret chats (not yet supported by Telethon)
* [ ] ‡ E2EE in Matrix rooms (not yet supported
* [ ] ‡ 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))
\* 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
† 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
+4 -3
View File
@@ -21,7 +21,6 @@ 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.
@@ -55,7 +54,8 @@ def run_migrations_offline():
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True)
url=url, target_metadata=target_metadata, literal_binds=True,
render_as_batch=True)
with context.begin_transaction():
context.run_migrations()
@@ -76,7 +76,8 @@ def run_migrations_online():
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
target_metadata=target_metadata,
render_as_batch=True
)
with context.begin_transaction():
@@ -0,0 +1,32 @@
"""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 ###
@@ -0,0 +1,30 @@
"""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 ###
@@ -0,0 +1,38 @@
"""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 ###
@@ -0,0 +1,71 @@
"""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 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.8.0"
__version__ = "0.9.0rc1"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+34 -15
View File
@@ -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,9 +31,8 @@ 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 init as init_portal
from .portal import Portal, 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
@@ -54,7 +53,6 @@ class TelegramBridge(Bridge):
markdown_version = linkified_version
config_class = Config
matrix_class = MatrixHandler
state_store_class = SQLStateStore
config: Config
session_container: AlchemySessionContainer
@@ -80,13 +78,6 @@ 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)
@@ -97,10 +88,20 @@ class TelegramBridge(Bridge):
init_abstract_user(context)
init_formatter(context)
init_portal(context)
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)
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")
def prepare_stop(self) -> None:
for puppet in Puppet.by_custom_mxid.values():
@@ -110,5 +111,23 @@ 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()
+62 -26
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2020 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,16 +26,18 @@ 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,
UpdateChannelPinnedMessage, UpdateChatParticipantAdmin, UpdateChatParticipants, PeerChat,
UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateNewMessage, UpdateDeleteMessages,
UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateReadHistoryOutbox,
UpdateShortChatMessage, UpdateShortMessage, UpdateUserName, UpdateUserPhoto, UpdateUserStatus,
UpdateUserTyping, User, UserStatusOffline, UserStatusOnline)
UpdateUserTyping, User, UserStatusOffline, UserStatusOnline, UpdateReadHistoryInbox,
UpdateReadChannelInbox, MessageEmpty)
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__
@@ -56,14 +58,10 @@ UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChann
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage]
UpdateMessageContent = Union[UpdateShortMessage, UpdateShortChatMessage, Message, MessageService]
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
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",))
class AbstractUser(ABC):
@@ -166,6 +164,7 @@ 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
@@ -181,22 +180,23 @@ class AbstractUser(ABC):
raise NotImplementedError()
@abstractmethod
def register_portal(self, portal: po.Portal) -> None:
async def register_portal(self, portal: po.Portal) -> None:
raise NotImplementedError()
@abstractmethod
def unregister_portal(self, portal: po.Portal) -> None:
async def unregister_portal(self, tgid: int, tg_receiver: int) -> 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}")
if UPDATE_TIME:
UPDATE_TIME.labels(update_type=type(update).__name__).observe(time.time() - start_time)
UPDATE_ERRORS.labels(update_type=update_type).inc()
UPDATE_TIME.labels(update_type=update_type).observe(time.time() - start_time)
@property
@abstractmethod
@@ -258,6 +258,8 @@ 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)
@@ -274,7 +276,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_telegram_participants(update.participants.participants)
await portal.update_power_levels(update.participants.participants)
async def update_read_receipt(self, update: UpdateReadHistoryOutbox) -> None:
if not isinstance(update.peer, PeerUser):
@@ -293,6 +295,32 @@ 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))
@@ -329,10 +357,10 @@ class AbstractUser(ABC):
if isinstance(update, UpdateUserName):
puppet.username = update.username
if await puppet.update_displayname(self, update):
puppet.save()
await puppet.save()
elif isinstance(update, UpdateUserPhoto):
if await puppet.update_avatar(self, update.photo):
puppet.save()
await puppet.save()
else:
self.log.warning(f"Unexpected other user info update: {type(update)}")
@@ -360,14 +388,18 @@ class AbstractUser(ABC):
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
UpdateEditMessage, UpdateEditChannelMessage)):
update = update.message
if isinstance(update.to_id, PeerUser) and not update.out:
portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
tg_receiver=self.tgid)
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))
else:
portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
sender = pu.Puppet.get(update.from_id) if update.from_id else None
sender = None
else:
self.log.warning(f"Unexpected message type in User#get_message_details: {type(update)}")
self.log.warning("Unexpected message type in User#get_message_details: "
f"{type(update)}")
return update, None, None
return update, sender, portal
@@ -426,10 +458,14 @@ 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"Ignoring action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
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)
return
self.log.trace("Handling action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
+20 -19
View File
@@ -117,11 +117,11 @@ class Bot(AbstractUser):
except (ChannelPrivateError, ChannelInvalidError):
self.remove_chat(TelegramID(channel_id.channel_id))
def register_portal(self, portal: po.Portal) -> None:
async def register_portal(self, portal: po.Portal) -> None:
self.add_chat(portal.tgid, portal.peer_type)
def unregister_portal(self, portal: po.Portal) -> None:
self.remove_chat(portal.tgid)
async def unregister_portal(self, tgid: int, tg_receiver: int) -> None:
self.remove_chat(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) -> Optional[bool]:
async def handle_command(self, message: Message) -> None:
def reply(reply_text: str) -> Awaitable[Message]:
return self.client.send_message(message.chat_id, reply_text, reply_to=message.id)
@@ -234,9 +234,8 @@ class Bot(AbstractUser):
if self.match_command(text, "start"):
pcm = config["bridge.relaybot.private_chat.message"]
if not pcm:
return True
await reply(pcm)
if pcm:
await reply(pcm)
return
elif self.match_command(text, "id"):
await self.handle_command_id(message, reply)
@@ -246,18 +245,19 @@ class Bot(AbstractUser):
portal = po.Portal.get_by_entity(message.to_id)
if self.match_command(text, "portal"):
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 not await self.check_can_use_commands(message, reply):
return
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))
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))
def handle_service_message(self, message: MessageService) -> None:
to_peer = message.to_id
@@ -288,9 +288,10 @@ 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 isinstance(update.message.entities[0], MessageEntityBotCommand)
and update.message.entities[0].offset == 0)
if is_command:
return not await self.handle_command(update.message)
await self.handle_command(update.message)
return False
def is_in_chat(self, peer_id) -> bool:
+26 -8
View File
@@ -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
from mautrix.types import RoomID, UserID, EventID, EventType
from . import command_handler, CommandEvent, SECTION_ADMIN
from .. import puppet as pu, portal as po
@@ -25,10 +25,11 @@ 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],
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[RoomID], 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] = []
@@ -36,6 +37,13 @@ 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:
@@ -55,14 +63,15 @@ async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[Roo
else:
portals.append(portal)
return management_rooms, unidentified_rooms, portals, empty_portals
return management_rooms, unidentified_rooms, tombstoned_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, portals, empty_portals = await _find_rooms(evt.az.intent)
(management_rooms, unidentified_rooms, tombstoned_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}"
@@ -77,6 +86,10 @@ 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}\")"
@@ -88,7 +101,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)"),
"where `letters` are the first letters of the group names (M, A, U, I, T)"),
"",
("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"
@@ -99,7 +112,8 @@ 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, portals, empty_portals),
unidentified_rooms, tombstoned_rooms, portals,
empty_portals),
"action": "Room cleaning",
}
@@ -107,8 +121,8 @@ async def clean_rooms(evt: CommandEvent) -> EventID:
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
unidentified_rooms: List[RoomID], portals: List["po.Portal"],
empty_portals: List["po.Portal"]) -> None:
unidentified_rooms: List[RoomID], tombstoned_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":
@@ -126,6 +140,8 @@ 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]
@@ -140,6 +156,8 @@ 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]
+21 -30
View File
@@ -25,11 +25,17 @@ 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
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
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, "")
@@ -40,12 +46,13 @@ 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,
is_management: bool, is_portal: bool) -> None:
portal: Optional['po.Portal'], is_management: bool, has_bridge_bot: bool) -> None:
super().__init__(processor, room_id, event_id, sender, command, args, content,
is_management, is_portal)
portal, is_management, has_bridge_bot)
self.bridge = processor.bridge
self.tgbot = processor.tgbot
self.config = processor.config
@@ -56,19 +63,16 @@ class CommandEvent(BaseCommandEvent):
return self.sender.is_admin
async def get_help_key(self) -> HelpCacheKey:
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())
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())
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,
@@ -79,25 +83,16 @@ 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.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:
if 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."
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
return await super().get_permission_error(evt)
def has_permission(self, key: HelpCacheKey) -> bool:
return ((not self.management_only or key.is_management) and
return (super().has_permission(key) and
(not self.needs_puppeting or key.puppet_whitelisted) and
(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))
(not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted))
def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: bool = True,
@@ -115,13 +110,9 @@ def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: b
class CommandProcessor(BaseCommandProcessor):
def __init__(self, context: c.Context) -> None:
super().__init__(az=context.az, config=context.config, event_class=CommandEvent,
loop=context.loop, bridge=context.bridge)
super().__init__(event_class=CommandEvent, 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
+1 -23
View File
@@ -15,34 +15,12 @@
# 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`>",
@@ -86,7 +64,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 = pu.Puppet.get_by_custom_mxid(mxid)
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
if puppet:
puppet.sync_task.cancel()
await user.stop()
+1 -1
View File
@@ -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 = ""
portal.save()
await portal.save()
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
loop=evt.loop)
+29 -16
View File
@@ -13,9 +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 Awaitable
from typing import Awaitable, Any
from io import StringIO
from ruamel.yaml import YAMLError
from mautrix.util.config import yaml
from mautrix.types import EventID
@@ -48,7 +50,11 @@ async def config(evt: CommandEvent) -> None:
return
key = evt.args[1] if len(evt.args) > 1 else None
value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 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
if cmd == "set":
await config_set(evt, portal, key, value)
elif cmd == "unset":
@@ -57,7 +63,7 @@ async def config(evt: CommandEvent) -> None:
await config_add_del(evt, portal, key, value, cmd)
else:
return
portal.save()
await portal.save()
def config_help(evt: CommandEvent) -> Awaitable[EventID]:
@@ -74,14 +80,11 @@ def config_help(evt: CommandEvent) -> Awaitable[EventID]:
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[EventID]:
stream = StringIO()
yaml.dump(portal.local_config, stream)
return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```")
return evt.reply(f"Room-specific config:\n{_str_value(portal.local_config).rstrip()}")
def config_defaults(evt: CommandEvent) -> Awaitable[EventID]:
stream = StringIO()
yaml.dump({
value = _str_value({
"bridge_notices": {
"default": evt.config["bridge.bridge_notices.default"],
"exceptions": evt.config["bridge.bridge_notices.exceptions"],
@@ -92,15 +95,25 @@ 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"],
}, stream)
return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```")
})
return evt.reply(f"Bridge instance wide config:\n{value.rstrip()}")
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[EventID]:
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]:
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 `{value}`.")
return evt.reply(f"Successfully set the value of `{key}` to {_str_value(value)}".rstrip())
else:
return evt.reply(f"Failed to set value of `{key}`. "
"Does the path contain non-map types?")
@@ -128,11 +141,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 `{value}`.")
return evt.reply(f"The array at `{key}` already contains {_str_value(value)}".rstrip())
arr.append(value)
return evt.reply(f"Successfully added `{value}` to the array at `{key}`")
return evt.reply(f"Successfully added {_str_value(value)} to the array at `{key}`")
else:
if value not in arr:
return evt.reply(f"The array at `{key}` does not contain `{value}`.")
return evt.reply(f"The array at `{key}` does not contain {_str_value(value)}")
arr.remove(value)
return evt.reply(f"Successfully removed `{value}` from the array at `{key}`")
return evt.reply(f"Successfully removed {_str_value(value)} from the array at `{key}`")
+1 -1
View File
@@ -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.sync_matrix_members()
await portal.main_intent.get_joined_members(portal.mxid)
await evt.reply("Synchronization complete")
+2 -3
View File
@@ -55,6 +55,5 @@ 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}")
event_type.t_class = EventType.Class.STATE
return intent.state_store.has_power_level(room_id, sender.mxid, event_type)
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)
+99 -17
View File
@@ -15,6 +15,7 @@
# 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,
@@ -22,13 +23,24 @@ from telethon.errors import ( # isort: skip
PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError,
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError,
PhoneNumberInvalidError)
from telethon.tl.types import User
from mautrix.types import EventID
from mautrix.types import (EventID, UserID, MediaMessageEventContent, ImageInfo, MessageType,
TextMessageEventContent)
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,
@@ -104,18 +116,76 @@ 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(evt.args[0]).ensure_started()
evt.sender = await u.User.get_by_mxid(UserID(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.get("bridge.allow_matrix_login", True)
allow_matrix_login = evt.config["bridge.allow_matrix_login"]
if allow_matrix_login and not override_sender:
evt.sender.command_status = {
"next": enter_phone_or_token,
@@ -225,7 +295,8 @@ 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, password=" ".join(evt.args))
await _sign_in(evt, login_as=evt.sender.command_status.get("login_as", None),
password=" ".join(evt.args))
except AccessTokenInvalidError:
return await evt.reply("That bot token is not valid.")
except AccessTokenExpiredError:
@@ -237,20 +308,12 @@ async def enter_password(evt: CommandEvent) -> Optional[EventID]:
return None
async def _sign_in(evt: CommandEvent, **sign_in_info) -> EventID:
async def _sign_in(evt: CommandEvent, login_as: 'u.User' = None, **sign_in_info) -> EventID:
login_as = login_as or evt.sender
try:
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}")
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)
except PhoneCodeExpiredError:
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
except PhoneCodeInvalidError:
@@ -266,6 +329,25 @@ async def _sign_in(evt: CommandEvent, **sign_in_info) -> EventID:
"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.")
+21 -8
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2020 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,8 +185,10 @@ async def sync(evt: CommandEvent) -> EventID:
sync_only = None
if not sync_only or sync_only == "chats":
await evt.sender.sync_dialogs(synchronous_create=True)
await evt.reply("Synchronizing chats...")
await evt.sender.sync_dialogs()
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()
@@ -311,16 +313,20 @@ async def vote(evt: CommandEvent) -> EventID:
@command_handler(help_section=SECTION_MISC, help_args="<_emoji_>",
help_text="Roll a dice (\U0001F3B2) or throw a dart (\U0001F3AF) "
"on the Telegram servers.")
help_text="Roll a dice (\U0001F3B2), kick a football (\u26BD\uFE0F) or throw a "
"dart (\U0001F3AF) or basketball (\U0001F3C0) on the Telegram servers.")
async def random(evt: CommandEvent) -> EventID:
if not evt.is_portal:
return await evt.reply("You can only roll dice in portal rooms")
return await evt.reply("You can only randomize values 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),
@@ -329,15 +335,22 @@ async def random(evt: CommandEvent) -> EventID:
return await evt.reply("Invalid emoji for randomization")
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT, help_args="[_limit_]",
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
portal = po.Portal.get_by_mxid(evt.room_id)
try:
await portal.backfill(evt.sender)
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)
except TakeoutInitDelayError:
msg = ("Please accept the data export request from a mobile device, "
"then re-run the backfill command.")
+29 -3
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2020 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,6 +48,8 @@ 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"])
@@ -89,7 +91,12 @@ class Config(BaseBridgeConfig):
copy("bridge.sync_channel_members")
copy("bridge.skip_deleted_members")
copy("bridge.startup_sync")
copy("bridge.sync_dialog_limit")
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_direct_chats")
copy("bridge.max_telegram_delete")
copy("bridge.sync_matrix_state")
@@ -97,7 +104,15 @@ class Config(BaseBridgeConfig):
copy("bridge.plaintext_highlights")
copy("bridge.public_portals")
copy("bridge.sync_with_custom_puppets")
copy("bridge.login_shared_secret")
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.telegram_link_preview")
copy("bridge.inline_images")
copy("bridge.image_as_file_size")
@@ -108,9 +123,20 @@ 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")
+2 -12
View File
@@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy.engine.base import Engine
from mautrix.bridge.db import UserProfile, RoomState
from mautrix.client.state_store.sqlalchemy import UserProfile, RoomState
from .bot_chat import BotChat
from .message import Message
@@ -24,18 +24,8 @@ 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.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)
table.bind(db_engine)
+12 -3
View File
@@ -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
from typing import Optional, Iterable
from sqlalchemy import Column, Integer, String, Boolean, Text, func, sql
from mautrix.types import RoomID
from mautrix.types import RoomID, ContentURI
from mautrix.util.db import Base
from ..types import TelegramID
@@ -33,7 +33,8 @@ class Portal(Base):
megagroup: bool = Column(Boolean)
# Matrix portal information
mxid: RoomID = Column(String, unique=True, nullable=True)
mxid: Optional[RoomID] = Column(String, unique=True, nullable=True)
avatar_url: Optional[ContentURI] = Column(String, nullable=True)
encrypted: bool = Column(Boolean, nullable=False, server_default=sql.expression.false())
config: str = Column(Text, nullable=True)
@@ -48,6 +49,10 @@ 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)
@@ -55,3 +60,7 @@ 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()
+2 -1
View File
@@ -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, Boolean
from sqlalchemy import Column, Integer, String, Text, Boolean
from sqlalchemy.sql import expression, func
from mautrix.types import UserID, SyncToken
@@ -31,6 +31,7 @@ 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)
+87 -10
View File
@@ -7,6 +7,7 @@ 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.
@@ -30,6 +31,8 @@ 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
@@ -44,7 +47,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 Dimension (https://dimension.t2bot.io/).
# Used by things like mautrix-manager (https://github.com/tulir/mautrix-manager).
provisioning:
# Whether or not the provisioning API should be enabled.
enabled: true
@@ -69,6 +72,11 @@ 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"
@@ -129,8 +137,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.
# Defaults to no local limit (-> limited to 10000 by server)
max_initial_member_sync: -1
# -1 means no limit (which means it's limited to 10000 by the server)
max_initial_member_sync: 100
# 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.
@@ -142,7 +150,10 @@ bridge:
startup_sync: true
# Number of most recently active dialogs to check when syncing chats.
# Set to 0 to remove limit.
sync_dialog_limit: 30
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
# 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.
@@ -151,8 +162,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, the only way to log in is using the out-of-Matrix
# login website (see appservice.public config section)
# 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_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
@@ -160,15 +171,27 @@ 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 using
# your own Matrix account as the Matrix puppet for your Telegram account.
# Whether or not to use /sync to get presence, read receipts and typing notifications
# when double puppeting is enabled
sync_with_custom_puppets: true
# Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth
# 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
#
# 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.
login_shared_secret: null
# 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
# 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.
@@ -211,6 +234,27 @@ 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
@@ -219,6 +263,39 @@ 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.get_by_mxid(user_id)
user = (pu.Puppet.deprecated_sync_get_by_mxid(user_id)
or u.User.get_by_mxid(user_id, create=False))
if not user:
return msg
+19 -17
View File
@@ -22,7 +22,7 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold,
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag,
MessageEntityPhone, TypeMessageEntity, PeerChannel,
MessageEntityPhone, TypeMessageEntity, PeerChannel, PeerChat,
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_msg_id:
space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
if evt.reply_to:
space = (evt.peer_id.channel_id
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
else source.tgid)
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.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 fwd_from.from_id:
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id))
if isinstance(fwd_from.from_id, PeerUser):
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_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), create=False)
puppet = pu.Puppet.get(TelegramID(fwd_from.from_id.user_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,14 +77,16 @@ async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventC
if not fwd_from_text:
try:
user = await source.client.get_entity(PeerUser(fwd_from.from_id))
user = await source.client.get_entity(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 fwd_from.channel_id:
portal = po.Portal.get_by_tgid(TelegramID(fwd_from.channel_id))
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))
if portal:
fwd_from_text = portal.title
if portal.alias:
@@ -94,7 +96,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(PeerChannel(fwd_from.channel_id))
channel = await source.client.get_entity(fwd_from.from_id)
if channel:
fwd_from_text = f"channel {channel.title}"
fwd_from_html = f"channel <b>{escape(channel.title)}</b>"
@@ -116,11 +118,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.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
space = (evt.peer_id.channel_id
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
else source.tgid)
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to.reply_to_msg_id), space)
if not msg:
return
@@ -130,7 +132,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 = pu.Puppet.get_by_mxid(event.sender, create=False)
puppet = await 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")
@@ -162,7 +164,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_msg_id and not no_reply_fallback:
if evt.reply_to 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:
+16 -51
View File
@@ -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, List, TYPE_CHECKING
from typing import Dict, Set, Tuple, Union, Iterable, TYPE_CHECKING
from mautrix.bridge import BaseMatrixHandler
from mautrix.types import (Event, EventType, RoomID, UserID, EventID, ReceiptEvent, ReceiptType,
@@ -30,14 +30,6 @@ 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]
@@ -53,26 +45,15 @@ class MatrixHandler(BaseMatrixHandler):
self.user_id_prefix = f"@{prefix}"
self.user_id_suffix = f"{suffix}:{homeserver}"
super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop,
command_processor=com.CommandProcessor(context),
bridge=context.bridge)
super().__init__(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} invited puppet for {puppet.tgid} to {room_id}")
self.log.debug(f"{inviter.mxid} 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.")
@@ -87,11 +68,12 @@ class MatrixHandler(BaseMatrixHandler):
await intent.join_room(room_id)
return
try:
members = await self.az.intent.get_room_members(room_id)
members = await intent.get_room_members(room_id)
except MatrixError:
members = []
self.log.exception(f"Failed to get members after joining {room_id} as {intent.mxid}")
return
if self.az.bot_mxid not in members:
if len(members) > 1:
if len(members) > 2:
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> "
@@ -114,9 +96,9 @@ class MatrixHandler(BaseMatrixHandler):
portal.mxid = room_id
e2be_ok = None
if self.config["bridge.encryption.default"] and self.e2ee:
e2be_ok = await self.enable_dm_encryption(portal, members=members)
portal.save()
inviter.register_portal(portal)
e2be_ok = await portal.enable_dm_encryption()
await portal.save()
await inviter.register_portal(portal)
if e2be_ok is True:
evt_type, content = await self.e2ee.encrypt(
room_id, EventType.ROOM_MESSAGE,
@@ -134,16 +116,6 @@ 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
@@ -226,7 +198,7 @@ class MatrixHandler(BaseMatrixHandler):
return
await sender.ensure_started()
puppet = pu.Puppet.get_by_mxid(user_id)
puppet = await pu.Puppet.get_by_mxid(user_id)
if puppet:
if ban:
await portal.ban_matrix(puppet, sender)
@@ -390,10 +362,12 @@ class MatrixHandler(BaseMatrixHandler):
self.previously_typing[room_id] = now_typing
def filter_matrix_event(self, evt: Event) -> bool:
if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent, EncryptedEvent)):
if isinstance(evt, (TypingEvent, ReceiptEvent, PresenceEvent)):
return False
elif not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent, EncryptedEvent)):
return True
if evt.content.get("net.maunium.telegram.puppet", False):
puppet = pu.Puppet.get_by_custom_mxid(evt.sender)
if evt.content.get(self.az.real_user_content_key, False):
puppet = pu.Puppet.deprecated_sync_get_by_custom_mxid(evt.sender)
if puppet:
self.log.debug("Ignoring puppet-sent event %s", evt.event_id)
return True
@@ -430,12 +404,3 @@ 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)
+46 -28
View File
@@ -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, TYPE_CHECKING
from typing import Awaitable, Dict, List, Optional, Tuple, Union, Any, Set, Iterable, TYPE_CHECKING
from abc import ABC, abstractmethod
import asyncio
import logging
@@ -31,9 +31,11 @@ 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)
PowerLevelStateEventContent, ContentURI)
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
@@ -56,7 +58,7 @@ InviteList = Union[UserID, List[UserID]]
config: Optional['Config'] = None
class BasePortal(ABC):
class BasePortal(MautrixBasePortal, ABC):
base_log: TraceLogger = logging.getLogger("mau.portal")
az: AppService = None
bot: 'Bot' = None
@@ -90,9 +92,11 @@ class BasePortal(ABC):
about: Optional[str]
photo_id: Optional[str]
local_config: Dict[str, Any]
avatar_url: Optional[ContentURI]
encrypted: bool
deleted: bool
backfilling: bool
backfill_lock: SimpleLock
backfill_method_lock: asyncio.Lock
backfill_leave: Optional[Set[IntentAPI]]
log: TraceLogger
@@ -108,8 +112,8 @@ class BasePortal(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, encrypted: Optional[bool] = False,
db_instance: DBPortal = None) -> None:
local_config: Optional[str] = None, avatar_url: Optional[ContentURI] = None,
encrypted: Optional[bool] = False, db_instance: DBPortal = None) -> None:
self.mxid = mxid
self.tgid = tgid
self.tg_receiver = tg_receiver or tgid
@@ -120,12 +124,15 @@ class BasePortal(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.backfilling = False
self.backfill_lock = SimpleLock("Waiting for backfilling to finish before handling %s",
log=self.log)
self.backfill_method_lock = asyncio.Lock()
self.backfill_leave = None
self.dedup = PortalDedup(self)
@@ -206,9 +213,8 @@ class BasePortal(ABC):
def _get_largest_photo_size(photo: Union[Photo, Document]
) -> Tuple[Optional[InputPhotoFileLocation],
Optional[TypePhotoSize]]:
if not photo:
return None, None
if isinstance(photo, Document) and not photo.thumbs:
if not photo or isinstance(photo, PhotoEmpty) or (isinstance(photo, Document)
and not photo.thumbs):
return None, None
largest = max(photo.thumbs if isinstance(photo, Document) else photo.sizes,
@@ -232,9 +238,8 @@ class BasePortal(ABC):
await self.main_intent.get_power_levels(self.mxid)
except MatrixRequestError:
return False
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)
evt_type = EventType.find(f"net.maunium.telegram.{event}", t_class=EventType.Class.STATE)
return await self.main_intent.state_store.has_power_level(self.mxid, user.mxid, evt_type)
def get_input_entity(self, user: 'AbstractUser'
) -> Awaitable[Union[TypeInputPeer, TypeInputChannel]]:
@@ -287,12 +292,13 @@ class BasePortal(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 = p.Puppet.get_by_mxid(UserID(user), create=False)
puppet = await p.Puppet.get_by_mxid(UserID(user), create=False)
if user != intent.mxid and (not puppets_only or puppet):
try:
if puppet:
@@ -335,12 +341,14 @@ class BasePortal(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), encrypted=self.encrypted)
config=json.dumps(self.local_config), avatar_url=self.avatar_url,
encrypted=self.encrypted)
def save(self) -> None:
async 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), encrypted=self.encrypted)
config=json.dumps(self.local_config), avatar_url=self.avatar_url,
encrypted=self.encrypted)
def delete(self) -> None:
try:
@@ -362,11 +370,20 @@ class BasePortal(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,
encrypted=db_portal.encrypted, db_instance=db_portal)
avatar_url=db_portal.avatar_url, 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:
@@ -461,15 +478,6 @@ class BasePortal(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
@@ -509,6 +517,10 @@ class BasePortal(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]
@@ -516,7 +528,13 @@ class BasePortal(ABC):
pass
@abstractmethod
def backfill(self, source: 'AbstractUser') -> Awaitable[None]:
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:
pass
# endregion
+2 -2
View File
@@ -50,7 +50,7 @@ class PortalDedup:
@property
def _always_force_hash(self) -> bool:
return self._portal.peer_type != 'channel'
return self._portal.peer_type == 'chat'
@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],
MessageMediaPhoto: lambda media: [media.photo.id if media.photo else 0],
MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
}[type(event.media)](event.media)
except KeyError:
+47 -26
View File
@@ -36,8 +36,7 @@ from telethon.tl.types import (
from mautrix.types import (EventID, RoomID, UserID, ContentURI, MessageType, MessageEventContent,
TextMessageEventContent, MediaMessageEventContent, Format,
LocationMessageEventContent)
from mautrix.bridge import BasePortal as MautrixBasePortal
LocationMessageEventContent, ImageInfo, VideoInfo)
from ..types import TelegramID
from ..db import Message as DBMessage
@@ -52,7 +51,7 @@ if TYPE_CHECKING:
from ..config import Config
try:
from nio.crypto import decrypt_attachment
from mautrix.crypto.attachments import decrypt_attachment
except ImportError:
decrypt_attachment = None
@@ -61,7 +60,7 @@ TypeMessage = Union[Message, MessageService]
config: Optional['Config'] = None
class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
class PortalMatrix(BasePortal, 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}")
@@ -88,9 +87,9 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
message = await self._get_state_change_message(event, user, **kwargs)
if not message:
return
response = await self.bot.client.send_message(
self.peer, message,
parse_mode=self._matrix_event_to_entities)
message, entities = formatter.matrix_to_telegram(message)
response = await self.bot.client.send_message(self.peer, message,
formatting_entities=entities)
space = self.tgid if self.peer_type == "channel" else self.bot.tgid
self.dedup.check(response, (event_id, space))
@@ -229,28 +228,25 @@ class PortalMatrix(BasePortal, MautrixBasePortal, 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, content,
parse_mode=self._matrix_event_to_entities,
response = await client.edit_message(self.peer, orig_msg.tgid, message,
formatting_entities=entities,
link_preview=lp)
self._add_telegram_message_to_db(event_id, space, -1, response)
return
response = await client.send_message(self.peer, content, reply_to=reply_to,
parse_mode=self._matrix_event_to_entities,
response = await client.send_message(self.peer, message, reply_to=reply_to,
formatting_entities=entities,
link_preview=lp)
self._add_telegram_message_to_db(event_id, space, 0, response)
await self._send_delivery_receipt(event_id)
@@ -260,7 +256,10 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
content: MediaMessageEventContent, reply_to: TelegramID,
caption: TextMessageEventContent = None) -> None:
mime = content.info.mimetype
w, h = content.info.width, content.info.height
if isinstance(content.info, (ImageInfo, VideoInfo)):
w, h = content.info.width, content.info.height
else:
w = h = None
file_name = content["net.maunium.telegram.internal.filename"]
max_image_size = config["bridge.image_as_file_size"] * 1000 ** 2
@@ -302,7 +301,13 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
media = InputMediaUploadedDocument(file=file_handle, attributes=attributes,
mime_type=mime or "application/octet-stream")
caption, entities = self._matrix_event_to_entities(caption) if caption else (None, None)
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
async with self.send_lock(sender_id):
if await self._matrix_document_edit(client, content, space, caption, media, event_id):
@@ -341,7 +346,7 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
except (KeyError, ValueError):
self.log.exception("Failed to parse location")
return None
caption, entities = self._matrix_event_to_entities(content)
caption, entities = formatter.matrix_text_to_telegram(content.body)
media = MessageMediaGeo(geo=GeoPoint(lat, long, access_hash=0))
async with self.send_lock(sender_id):
@@ -377,7 +382,8 @@ class PortalMatrix(BasePortal, MautrixBasePortal, 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,
@@ -491,7 +497,7 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
peer = await self.get_input_entity(sender)
await sender.client(EditChatAboutRequest(peer=peer, about=about))
self.about = about
self.save()
await self.save()
await self._send_delivery_receipt(event_id)
async def handle_matrix_title(self, sender: 'u.User', title: str, event_id: EventID) -> None:
@@ -505,15 +511,19 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
response = await sender.client(EditTitleRequest(channel=channel, title=title))
self.dedup.register_outgoing_actions(response)
self.title = title
self.save()
await self.save()
await self._send_delivery_receipt(event_id)
await self.update_bridge_info()
async def handle_matrix_avatar(self, sender: 'u.User', url: ContentURI, event_id: EventID
) -> None:
if self.peer_type not in ("chat", "channel"):
# Invalid peer type
return
elif self.avatar_url == url:
return
self.avatar_url = url
file = await self.main_intent.download_media(url)
mime = magic.from_buffer(file, mime=True)
ext = sane_mimetypes.guess_extension(mime)
@@ -533,9 +543,10 @@ class PortalMatrix(BasePortal, MautrixBasePortal, 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}"
self.save()
await self.save()
break
await self._send_delivery_receipt(event_id)
await self.update_bridge_info()
async def handle_matrix_upgrade(self, sender: UserID, new_room: RoomID, event_id: EventID
) -> None:
@@ -565,7 +576,7 @@ class PortalMatrix(BasePortal, MautrixBasePortal, 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)
await self._send_delivery_receipt(event_id, room_id=old_room)
def migrate_and_save_matrix(self, new_id: RoomID) -> None:
try:
@@ -576,6 +587,16 @@ class PortalMatrix(BasePortal, MautrixBasePortal, 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
+208 -129
View File
@@ -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, Tuple, Union, Callable, Awaitable, TYPE_CHECKING
from typing import List, Optional, Iterable, Union, Dict, Any, 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, Member,
from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership,
PowerLevelStateEventContent, RoomTopicStateEventContent,
RoomNameStateEventContent, RoomAvatarStateEventContent,
StateEventContent)
StateEventContent, EventID)
from ..types import TelegramID
from ..context import Context
@@ -45,6 +45,9 @@ 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
@@ -111,7 +114,7 @@ class PortalMetadata(BasePortal, ABC):
await source.client(
UpdateUsernameRequest(await self.get_input_entity(source), username))
if await self._update_username(username):
self.save()
await self.save()
async def create_telegram_chat(self, source: 'u.User', supergroup: bool = False) -> None:
if not self.mxid:
@@ -169,17 +172,6 @@ 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
@@ -193,27 +185,24 @@ 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,
participants: List[TypeParticipant] = None) -> None:
users: List[User] = None) -> None:
if direct is None:
direct = self.peer_type == "user"
try:
await self._update_matrix_room(user, entity, direct, puppet, levels, users,
participants)
await self._update_matrix_room(user, entity, direct, puppet, levels, users)
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,
participants: List[TypeParticipant] = None) -> None:
users: List[User] = None) -> None:
if not direct:
await self.update_info(user, entity)
if not users or not participants:
users, participants = await self._get_users(user, entity)
if not users:
users = await self._get_users(user, entity)
await self._sync_telegram_users(user, users)
await self.update_telegram_participants(participants, levels)
await self.update_power_levels(users, levels)
else:
if not puppet:
puppet = p.Puppet.get(self.tgid)
@@ -225,13 +214,24 @@ class PortalMetadata(BasePortal, ABC):
changed = await self._update_title(puppet.displayname)
changed = await self._update_avatar(user, entity.photo) or changed
if changed:
self.save()
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)
if self.sync_matrix_state:
await self.sync_matrix_members()
await self.main_intent.get_joined_members(self.mxid)
async def create_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User] = None,
invites: InviteList = None, update_if_exists: bool = True,
synchronous: bool = False) -> Optional[str]:
invites: InviteList = None, update_if_exists: bool = True
) -> Optional[RoomID]:
if self.mxid:
if update_if_exists:
if not entity:
@@ -241,10 +241,7 @@ 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")
if synchronous:
await update
else:
asyncio.ensure_future(update, loop=self.loop)
self.loop.create_task(update)
await self.invite_to_matrix(invites or [])
return self.mxid
async with self._room_create_lock:
@@ -253,6 +250,49 @@ 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"
@@ -303,44 +343,33 @@ class PortalMetadata(BasePortal, ABC):
await self.main_intent.remove_room_alias(alias)
power_levels = self._get_base_power_levels(entity=entity)
users = participants = None
users = None
if not direct:
users, participants = await self._get_users(user, entity)
users = 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)
self._participants_to_power_levels(participants, power_levels)
await self._participants_to_power_levels(users, 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": "m.bridge",
"state_key": f"net.maunium.telegram://telegram/{self.tgid}",
"content": bridge_info
"type": str(StateBridge),
"state_key": self.bridge_info_state_key,
"content": self.bridge_info,
}, {
# TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
"type": "uk.half-shot.bridge",
"state_key": f"net.maunium.telegram://telegram/{self.tgid}",
"content": bridge_info
"type": str(StateHalfShotBridge),
"state_key": self.bridge_info_state_key,
"content": self.bridge_info,
}]
if config["bridge.encryption.default"] and self.matrix.e2ee:
self.encrypted = True
@@ -361,32 +390,40 @@ class PortalMetadata(BasePortal, ABC):
if not config["bridge.federate_rooms"]:
creation_content["m.federate"] = False
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")
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")
if self.encrypted and self.matrix.e2ee:
members = [self.main_intent.mxid]
if direct:
if self.encrypted and self.matrix.e2ee and direct:
try:
await self.az.intent.join_room_by_id(room_id)
members += [self.az.intent.mxid]
await self.az.intent.ensure_joined(room_id)
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 = 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)
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")
return self.mxid
@@ -459,8 +496,8 @@ class PortalMetadata(BasePortal, ABC):
return True
return False
def _participants_to_power_levels(self, participants: List[TypeParticipant],
levels: PowerLevelStateEventContent) -> bool:
async def _participants_to_power_levels(self, users: List[Union[TypeUser, 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
@@ -470,13 +507,17 @@ class PortalMetadata(BasePortal, ABC):
changed = True
levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
for participant in participants:
for user in users:
# The User objects we get from TelegramClient.get_participants have a custom
# participant property
participant = getattr(user, "participant", user)
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:
user.register_portal(self)
await user.register_portal(self)
changed = self._participant_to_power_levels(levels, user, new_level,
bot_level) or changed
@@ -485,45 +526,55 @@ class PortalMetadata(BasePortal, ABC):
bot_level) or changed
return changed
async def update_telegram_participants(self, participants: List[TypeParticipant],
levels: PowerLevelStateEventContent = None) -> None:
async def update_power_levels(self, users: List[Union[TypeUser, TypeParticipant]],
levels: PowerLevelStateEventContent = None) -> None:
if not levels:
levels = await self.main_intent.get_power_levels(self.mxid)
if self._participants_to_power_levels(participants, levels):
if await self._participants_to_power_levels(users, levels):
await self.main_intent.set_power_levels(self.mxid, levels)
def _add_bot_chat(self, bot: User) -> None:
async 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:
user.register_portal(self)
await 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:
if skip_deleted and entity.deleted:
continue
puppet = p.Puppet.get(TelegramID(entity.id))
if entity.bot:
self._add_bot_chat(entity)
await 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)
if skip_deleted and entity.deleted:
continue
await puppet.intent_for(self).ensure_joined(self.mxid)
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
and self.max_initial_member_sync == -1
trust_member_list = ((len(allowed_tgids) < 9900
if self.max_initial_member_sync < 0
else len(allowed_tgids) < self.max_initial_member_sync - 10)
and (self.megagroup or self.peer_type != "channel"))
if trust_member_list:
joined_mxids = await self.main_intent.get_room_members(self.mxid)
@@ -542,7 +593,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:
mx_user.unregister_portal(self)
await mx_user.unregister_portal(*self.tgid_full)
if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids:
try:
@@ -562,7 +613,7 @@ class PortalMetadata(BasePortal, ABC):
user = u.User.get_by_tgid(user_id)
if user:
user.register_portal(self)
await user.register_portal(self)
await self.invite_to_matrix(user.mxid)
async def _delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None:
@@ -579,7 +630,7 @@ class PortalMetadata(BasePortal, ABC):
else:
await puppet.intent_for(self).leave_room(self.mxid)
if user:
user.unregister_portal(self)
await user.unregister_portal(*self.tgid_full)
if sender.tgid != puppet.tgid:
try:
await sender.intent_for(self).kick_user(self.mxid, puppet.mxid)
@@ -619,7 +670,8 @@ class PortalMetadata(BasePortal, ABC):
self.log.exception(f"Failed to update info from source {user.tgid}")
if changed:
self.save()
await self.save()
await self.update_bridge_info()
async def _update_username(self, username: str, save: bool = False) -> bool:
if self.username == username:
@@ -636,7 +688,7 @@ class PortalMetadata(BasePortal, ABC):
await self.main_intent.set_join_rule(self.mxid, "invite")
if save:
self.save()
await self.save()
return True
async def _try_set_state(self, sender: Optional['p.Puppet'], evt_type: EventType,
@@ -661,7 +713,7 @@ class PortalMetadata(BasePortal, ABC):
await self._try_set_state(sender, EventType.ROOM_TOPIC,
RoomTopicStateEventContent(topic=self.about))
if save:
self.save()
await self.save()
return True
async def _update_title(self, title: str, sender: Optional['p.Puppet'] = None,
@@ -673,7 +725,7 @@ class PortalMetadata(BasePortal, ABC):
await self._try_set_state(sender, EventType.ROOM_NAME,
RoomNameStateEventContent(name=self.title))
if save:
self.save()
await self.save()
return True
async def _update_avatar(self, user: 'AbstractUser', photo: TypeChatPhoto,
@@ -702,66 +754,93 @@ 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:
self.save()
await 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:
self.save()
await 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]
) -> Tuple[List[TypeUser], List[TypeParticipant]]:
# TODO replace with client.get_participants
) -> List[TypeUser]:
if self.peer_type == "chat":
chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
return chat.users, chat.full_chat.participants.participants
return list(self._filter_participants(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:
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
return await self._get_channel_users(user, entity, limit)
except ChatAdminRequiredError:
return [], []
return []
elif self.peer_type == "user":
return [entity], []
return [], []
return [entity]
else:
raise RuntimeError(f"Unexpected peer type {self.peer_type}")
# 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
+148 -40
View File
@@ -20,6 +20,7 @@ import mimetypes
import codecs
import unicodedata
import base64
import asyncio
from sqlalchemy.exc import IntegrityError
@@ -38,12 +39,14 @@ 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, MessageEventContent)
LocationMessageEventContent, Format)
from mautrix.bridge import NotificationDisabler
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
@@ -71,15 +74,32 @@ 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]:
loc, largest_size = self._get_largest_photo_size(evt.media.photo)
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)
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_msg_id):
if self.get_config("inline_images") and (evt.message or evt.fwd_from or evt.reply_to):
content = await formatter.telegram_to_matrix(
evt, source, self.main_intent,
prefix_html=f"<img src='{file.mxc}' alt='Inline Telegram photo'/><br/>",
@@ -91,7 +111,8 @@ 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))
name = f"image{sane_mimetypes.guess_extension(file.mime_type)}"
ext = sane_mimetypes.guess_extension(file.mime_type)
name = f"disappearing_image{ext}" if media.ttl_seconds else f"image{ext}"
await intent.set_typing(self.mxid, is_typing=False)
content = MediaMessageEventContent(msgtype=MessageType.IMAGE, info=info,
body=name, relates_to=relates_to,
@@ -101,6 +122,9 @@ 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)
@@ -126,7 +150,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 = evt.message or attrs.name
name = attrs.name
if attrs.is_sticker:
alt = attrs.sticker_alt
if len(alt) > 0:
@@ -217,7 +241,13 @@ class PortalTelegram(BasePortal, ABC):
content.file = file.decryption_info
else:
content.url = file.mxc
return await self._send_message(intent, content, event_type=event_type, timestamp=evt.date)
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
def handle_telegram_location(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: RelatesTo = None) -> Awaitable[EventID]:
@@ -225,12 +255,13 @@ 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(lat, 5)}° {lat_char}, {round(long, 5)}° {long_char}"
url = f"https://maps.google.com/?q={lat},{long}"
body = f"{round(abs(lat), 4)}° {lat_char}, {round(abs(long), 4)}° {long_char}"
url = f"https://maps.google.com/?q={geo}"
content = LocationMessageEventContent(
msgtype=MessageType.LOCATION, geo_uri=f"geo:{lat},{long}",
msgtype=MessageType.LOCATION, geo_uri=f"geo:{geo}",
body=f"Location: {body}\n{url}",
relates_to=relates_to, external_url=self._get_external_url(evt))
content["format"] = str(Format.HTML)
@@ -240,7 +271,7 @@ class PortalTelegram(BasePortal, ABC):
async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool,
evt: Message) -> EventID:
self.log.debug(f"Sending {evt.message} to {self.mxid} by {intent.mxid}")
self.log.trace(f"Sending {evt.message} to {self.mxid} by {intent.mxid}")
content = await formatter.telegram_to_matrix(evt, source, self.main_intent)
content.external_url = self._get_external_url(evt)
if is_bot and self.get_config("bot_messages_as_notices"):
@@ -292,6 +323,8 @@ 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}"
@@ -394,52 +427,119 @@ class PortalTelegram(BasePortal, ABC):
edit_index=prev_edit_msg.edit_index + 1).insert()
DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id)
async def backfill(self, source: 'AbstractUser') -> None:
self.log.debug("Backfilling history through %s", source.mxid)
@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
last = DBMessage.find_last(self.mxid, (source.tgid if self.peer_type != "channel"
else self.tgid))
min_id = last.tgid if last else 0
self.backfilling = True
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.backfill_leave = set()
if self.peer_type == "user":
if ((self.peer_type == "user" and self.tgid != source.tgid
and config["bridge.backfill.invite_own_puppet"])):
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)
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
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)
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.tgid == self.tg_receiver
and not sender.is_real_user and not self.az.state_store.is_joined(self.mxid,
sender.mxid)):
if (self.peer_type == "user" and sender and sender.tgid == self.tg_receiver
and not sender.is_real_user and not await self.az.state_store.is_joined(self.mxid,
sender.mxid)):
self.log.debug(f"Ignoring private chat message {evt.id}@{source.tgid} as receiver does"
" not have matrix puppeting and their default puppet isn't in the room")
return
@@ -459,12 +559,12 @@ class PortalTelegram(BasePortal, ABC):
tg_space=tg_space, edit_index=0).insert()
return
if self.backfilling or (self.dedup.pre_db_check and self.peer_type == "channel"):
if self.backfill_lock.locked 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
@@ -483,7 +583,8 @@ class PortalTelegram(BasePortal, ABC):
allowed_media) else None
if sender:
intent = sender.intent_for(self)
if self.backfilling and intent != sender.default_mxid_intent:
if ((self.backfill_lock.locked and intent != sender.default_mxid_intent
and config["bridge.backfill.invite_own_puppet"])):
intent = sender.default_mxid_intent
self.backfill_leave.add(intent)
else:
@@ -531,6 +632,7 @@ 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:
@@ -554,10 +656,13 @@ 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)
@@ -571,6 +676,7 @@ 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
@@ -608,3 +714,5 @@ 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"]
+36 -21
View File
@@ -21,10 +21,11 @@ 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 CustomPuppetMixin
from mautrix.bridge import BasePuppet
from mautrix.types import UserID, SyncToken, RoomID
from mautrix.util.simple_template import SimpleTemplate
@@ -41,7 +42,7 @@ if TYPE_CHECKING:
config: Optional['Config'] = None
class Puppet(CustomPuppetMixin):
class Puppet(BasePuppet):
log: logging.Logger = logging.getLogger("mau.puppet")
az: AppService
mx: 'MatrixHandler'
@@ -57,6 +58,7 @@ class Puppet(CustomPuppetMixin):
access_token: Optional[str]
custom_mxid: Optional[UserID]
_next_batch: Optional[SyncToken]
base_url: Optional[URL]
default_mxid: UserID
username: Optional[str]
@@ -79,6 +81,7 @@ class Puppet(CustomPuppetMixin):
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,
@@ -91,6 +94,7 @@ class Puppet(CustomPuppetMixin):
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
@@ -161,20 +165,20 @@ class Puppet(CustomPuppetMixin):
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)
disable_updates=self.disable_updates, base_url=self.base_url)
def new_db_instance(self) -> DBPuppet:
return DBPuppet(id=self.id, **self._fields)
def save(self) -> None:
async 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.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.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_instance=db_puppet)
# endregion
@@ -233,23 +237,22 @@ class Puppet(CustomPuppetMixin):
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
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}")
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}")
self.is_bot = info.bot
if changed:
self.save()
await self.save()
async def update_displayname(self, source: 'AbstractUser', info: Union[User, UpdateUserName]
) -> bool:
@@ -259,7 +262,7 @@ class Puppet(CustomPuppetMixin):
allow_because = "user is bot"
elif self.displayname_source == source.tgid:
allow_because = "user is the primary source"
elif not info.contact:
elif not isinstance(info, UpdateUserName) and not info.contact:
allow_because = "user is not a contact"
elif self.displayname_source is None:
allow_because = "no primary source set"
@@ -331,7 +334,7 @@ class Puppet(CustomPuppetMixin):
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.backfilling and portal.peer_type != "user"
return portal and not portal.backfill_lock.locked and portal.peer_type != "user"
# endregion
# region Getters
@@ -355,7 +358,7 @@ class Puppet(CustomPuppetMixin):
return None
@classmethod
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
def deprecated_sync_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)
@@ -363,7 +366,11 @@ class Puppet(CustomPuppetMixin):
return None
@classmethod
def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
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']:
if not mxid:
raise ValueError("Matrix ID can't be empty")
@@ -379,6 +386,10 @@ class Puppet(CustomPuppetMixin):
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]
@@ -439,8 +450,12 @@ def init(context: 'Context') -> Iterable[Awaitable[Any]]:
Puppet.displayname_template = SimpleTemplate(config["bridge.displayname_template"],
"displayname")
secret = config["bridge.login_shared_secret"]
Puppet.login_shared_secret = secret.encode("utf-8") if secret else None
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()}
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.bridge.db import RoomState, UserProfile
from mautrix.client.state_store.sqlalchemy import RoomState, UserProfile
from mautrix_telegram.db import (Portal, Message, UserPortal, User, Contact, Puppet, BotChat,
TelegramFile)
-38
View File
@@ -1,38 +0,0 @@
# 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)
+91 -34
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
# Copyright (C) 2020 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,26 +13,29 @@
#
# 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, NewType, Optional, Tuple, Any, cast,
from typing import (Awaitable, Dict, List, Iterable, NamedTuple, 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
from mautrix.types import UserID, RoomID
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
from .db import User as DBUser, Portal as DBPortal
from .abstract_user import AbstractUser
from . import portal as po, puppet as pu
@@ -42,7 +45,10 @@ if TYPE_CHECKING:
config: Optional['Config'] = None
SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int])
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')
class User(AbstractUser, BaseUser):
@@ -58,6 +64,7 @@ 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,
@@ -78,6 +85,9 @@ 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
@@ -151,7 +161,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)
def save(self, contacts: bool = False, portals: bool = False) -> None:
async 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:
@@ -191,15 +201,34 @@ 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}")
asyncio.ensure_future(self.post_login(), loop=self.loop)
self.loop.create_task(self.post_login())
if config["metrics.enabled"]:
self._track_connection_task = self.loop.create_task(self._track_connection())
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)
@@ -207,6 +236,8 @@ 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):
@@ -227,12 +258,7 @@ class User(AbstractUser, BaseUser):
return False
if isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
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)
portal = po.Portal.get_by_entity(update.message.peer_id, receiver_id=self.tgid)
elif isinstance(update, UpdateShortChatMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
elif isinstance(update, UpdateShortMessage):
@@ -241,7 +267,7 @@ class User(AbstractUser, BaseUser):
return False
if portal:
self.register_portal(portal)
await self.register_portal(portal)
return False
# Don't bother handling the update
@@ -270,7 +296,7 @@ class User(AbstractUser, BaseUser):
self.tgid = TelegramID(info.id)
self.by_tgid[self.tgid] = self
if changed:
self.save()
await self.save()
async def log_out(self) -> bool:
puppet = pu.Puppet.get(self.tgid)
@@ -286,18 +312,19 @@ class User(AbstractUser, BaseUser):
pass
self.portals = {}
self.contacts = []
self.save(portals=True, contacts=True)
await self.save(portals=True, contacts=True)
if self.tgid:
try:
del self.by_tgid[self.tgid]
except KeyError:
pass
self.tgid = None
self.save()
await 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
@@ -306,7 +333,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]
@@ -318,7 +345,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]
@@ -333,13 +360,30 @@ class User(AbstractUser, BaseUser):
return await self._search_remote(query), True
async def sync_dialogs(self, synchronous_create: bool = False) -> None:
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:
if self.is_bot:
return
creators = []
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,
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,
archived=False):
entity = dialog.entity
if isinstance(entity, ChatForbidden):
@@ -353,26 +397,38 @@ class User(AbstractUser, BaseUser):
continue
portal = po.Portal.get_by_entity(entity, receiver_id=self.tgid)
self.portals[portal.tgid_full] = portal
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)
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()
self.log.debug("Dialog syncing complete")
def register_portal(self, portal: po.Portal) -> None:
async def register_portal(self, portal: po.Portal) -> None:
self.log.trace(f"Registering portal {portal.tgid_full}")
try:
if self.portals[portal.tgid_full] == portal:
return
except KeyError:
pass
self.portals[portal.tgid_full] = portal
self.save(portals=True)
await self.save(portals=True)
def unregister_portal(self, portal: po.Portal) -> None:
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
self.log.trace(f"Unregistering portal {(tgid, tg_receiver)}")
try:
del self.portals[portal.tgid_full]
self.save(portals=True)
del self.portals[(tgid, tg_receiver)]
await self.save(portals=True)
except KeyError:
pass
@@ -397,7 +453,7 @@ class User(AbstractUser, BaseUser):
puppet = pu.Puppet.get(user.id)
await puppet.update_info(self, user)
self.contacts.append(puppet)
self.save(contacts=True)
await self.save(contacts=True)
# endregion
# region Class instance lookup
@@ -460,6 +516,7 @@ 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())
+34 -17
View File
@@ -49,7 +49,7 @@ except ImportError:
VideoFileClip = None
try:
from nio.crypto import encrypt_attachment
from mautrix.crypto.attachments import encrypt_attachment
except ImportError:
encrypt_attachment = None
@@ -108,8 +108,10 @@ def _location_to_id(location: TypeLocation) -> str:
async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
thumbnail_loc: TypeLocation, video: bytes, mime: str,
encrypt: bool) -> Optional[DBTelegramFile]:
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]:
if not Image or not VideoFileClip:
return None
@@ -117,12 +119,17 @@ 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)
if VideoFileClip and video_ext and video:
video_ext = sane_mimetypes.guess_extension(mime_type)
if custom_data:
file = custom_data
elif VideoFileClip and video_ext and video:
try:
file, width, height = _read_video_thumbnail(video, video_ext, frame_ext="png")
except OSError:
@@ -136,8 +143,7 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
decryption_info = None
upload_mime_type = mime_type
if encrypt:
file, decryption_info_dict = encrypt_attachment(file)
decryption_info = EncryptedFile.deserialize(decryption_info_dict)
file, decryption_info = encrypt_attachment(file)
upload_mime_type = "application/octet-stream"
content_uri = await intent.upload_media(file, upload_mime_type)
if decryption_info:
@@ -194,6 +200,8 @@ 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)
@@ -213,13 +221,17 @@ 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...
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
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"
thumbnail = None
if mime_type == "image/webp":
new_mime_type, file, width, height = convert_image(
@@ -232,8 +244,7 @@ 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_dict = encrypt_attachment(file)
decryption_info = EncryptedFile.deserialize(decryption_info_dict)
file, decryption_info = encrypt_attachment(file)
upload_mime_type = "application/octet-stream"
content_uri = await intent.upload_media(file, upload_mime_type)
if decryption_info:
@@ -247,10 +258,16 @@ 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, file,
mime_type, encrypt)
db_file.thumbnail = await transfer_thumbnail_to_matrix(client, intent, thumbnail,
video=file, mime_type=mime_type,
encrypt=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 nio.crypto import async_encrypt_attachment
from mautrix.crypto.attachments 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, self.loop, loggers=self.client._log)
sender = MTProtoSender(self.auth_key, loggers=self.client._log)
await sender.connect(self.client._connection(dc.ip_address, dc.port, dc.id,
loop=self.loop, loggers=self.client._log,
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, dict):
decryption_info = EncryptedFile.deserialize(chunk)
if isinstance(chunk, EncryptedFile):
decryption_info = chunk
else:
yield chunk
+37 -18
View File
@@ -21,8 +21,23 @@ import shutil
import os.path
import tempfile
from attr import dataclass
log: logging.Logger = logging.getLogger("mau.util.tgs")
converters: Dict[str, Callable[[bytes, int, int, Any], Awaitable[Tuple[str, bytes]]]] = {}
@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] = {}
def abswhich(program: Optional[str]) -> Optional[str]:
@@ -34,7 +49,7 @@ lottieconverter = abswhich("lottieconverter")
ffmpeg = abswhich("ffmpeg")
if lottieconverter:
async def tgs_to_png(file: bytes, width: int, height: int, **_: Any) -> Tuple[str, bytes]:
async def tgs_to_png(file: bytes, width: int, height: int, **_: Any) -> ConvertedSticker:
frame = 1
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", "-", "png",
f"{width}x{height}", str(frame),
@@ -42,26 +57,26 @@ if lottieconverter:
stdin=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate(file)
if proc.returncode == 0:
return "image/png", stdout
return ConvertedSticker("image/png", stdout)
else:
log.error("lottieconverter error: " + (stderr.decode("utf-8") if stderr is not None
else f"unknown ({proc.returncode})"))
return "application/gzip", file
else f"unknown ({proc.returncode})"))
return ConvertedSticker("application/gzip", file)
async def tgs_to_gif(file: bytes, width: int, height: int, background: str = "202020",
**_: Any) -> Tuple[str, bytes]:
**_: Any) -> ConvertedSticker:
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 "image/gif", stdout
return ConvertedSticker("image/gif", stdout)
else:
log.error("lottieconverter error: " + (stderr.decode("utf-8") if stderr is not None
else f"unknown ({proc.returncode})"))
return "application/gzip", file
else f"unknown ({proc.returncode})"))
return ConvertedSticker("application/gzip", file)
converters["png"] = tgs_to_png
@@ -69,7 +84,7 @@ if lottieconverter:
if lottieconverter and ffmpeg:
async def tgs_to_webm(file: bytes, width: int, height: int, fps: int = 30,
**_: Any) -> Tuple[str, bytes]:
**_: Any) -> ConvertedSticker:
with tempfile.TemporaryDirectory(prefix="tgs_") as tmpdir:
file_template = tmpdir + "/out_"
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", file_template,
@@ -78,6 +93,8 @@ 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",
@@ -88,25 +105,27 @@ if lottieconverter and ffmpeg:
stdin=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
return "video/webm", stdout
return ConvertedSticker("video/webm", stdout, "image/png", first_frame_data)
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 "application/gzip", file
else f"unknown ({proc.returncode})"))
return ConvertedSticker("application/gzip", file)
converters["webm"] = tgs_to_webm
async def convert_tgs_to(file: bytes, convert_to: str, width: int, height: int, **kwargs: Any
) -> Tuple[str, bytes, Optional[int], Optional[int]]:
) -> ConvertedSticker:
if convert_to in converters:
converter = converters[convert_to]
mime, out = await converter(file, width, height, **kwargs)
return mime, out, width, height
converted = await converter(file, width, height, **kwargs)
converted.width = width
converted.height = height
return converted
elif convert_to != "disable":
log.warning(f"Unable to convert animated sticker, type {convert_to} not supported")
return "application/gzip", file, None, None
return ConvertedSticker("application/gzip", file)
@@ -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 = ""
portal.save()
await portal.save()
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
loop=self.loop)
+9 -2
View File
@@ -8,7 +8,11 @@ aiodns
brotli
#/webp_convert
pillow>=4.3,<8
pillow>=4,<8
#/qr_login
pillow>=4,<8
qrcode>=6,<7
#/hq_thumbnails
moviepy>=1,<2
@@ -20,4 +24,7 @@ prometheus_client>=0.6,<0.9
psycopg2-binary>=2,<3
#/e2be
matrix-nio[e2e]>=0.9,<0.13
asyncpg>=0.20,<0.22
python-olm>=3,<4
pycryptodome>=3,<4
unpaddedbase64>=1,<2
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 500 KiB

+4 -3
View File
@@ -3,7 +3,8 @@ alembic>=1,<2
ruamel.yaml>=0.15.35,<0.17
python-magic>=0.4,<0.5
commonmark>=0.8,<0.10
aiohttp>=3,<4
mautrix==0.5.0
telethon>=1.13,<1.15
aiohttp>=3,<3.7
yarl<1.6
mautrix==0.8.0rc1
telethon>=1.17,<1.18
telethon-session-sqlalchemy>=0.2.14,<0.3
+2 -6
View File
@@ -61,20 +61,16 @@ 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"]),
(".", ["alembic.ini", "mautrix_telegram/example-config.yaml"]),
("alembic", ["alembic/env.py"]),
("alembic/versions", glob.glob("alembic/versions/*.py"))
],