Compare commits
257 Commits
v0.5.1
..
v0.7.0-rc3
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f691bf1b8 | |||
| 50984dab14 | |||
| 6f6ce4bcc7 | |||
| 119729393c | |||
| 9f3869e878 | |||
| 9fb2a73ec5 | |||
| 64b3699b3c | |||
| 76ad31a3bc | |||
| 71cdee5a4d | |||
| 2ae4b23528 | |||
| 39927ac6c0 | |||
| 3e6e59db29 | |||
| 36e2c6f66f | |||
| 69d56f4632 | |||
| af0f731a8a | |||
| cf8c05e1c5 | |||
| 7d5e307368 | |||
| 701b28c33c | |||
| a239ca439a | |||
| 578af19baa | |||
| 792ed007b5 | |||
| 539c2338fc | |||
| 792694b2d9 | |||
| 8e20d56091 | |||
| 1986142db3 | |||
| c52df5dc36 | |||
| 617d44ed75 | |||
| 91e6a73f33 | |||
| 25d7087d07 | |||
| f72267e81d | |||
| ab3b0f3c3c | |||
| 883c4dcf19 | |||
| a5aa73dea6 | |||
| ed90c2667a | |||
| 87d9477bc7 | |||
| b854119445 | |||
| 0e56ab131e | |||
| e319417fbc | |||
| 9e831689e9 | |||
| 0a5f4e6551 | |||
| aaf158cc29 | |||
| 2c2dd37275 | |||
| 4d4a3b6bf6 | |||
| b6b1d72ecb | |||
| 6fa44ce5e9 | |||
| 90e7a303ab | |||
| 54256be459 | |||
| 1c662c55cc | |||
| abd1adaabf | |||
| 5411de90fc | |||
| f9a692b5ef | |||
| 9205ef8024 | |||
| 4260afaa7e | |||
| ef3a60397f | |||
| 8acc51116d | |||
| cbbc5e8500 | |||
| 0192fb8308 | |||
| 3841528f5a | |||
| 91c3825ae3 | |||
| 8c26dd8382 | |||
| 01b317484f | |||
| 73a6ad2cf2 | |||
| 574312d7c5 | |||
| 6cb8e007aa | |||
| 22f6a12842 | |||
| c15508150a | |||
| a0f12a2c48 | |||
| c919a1762b | |||
| 6dc73bf710 | |||
| 623b802d56 | |||
| 0726289c7a | |||
| d2edf12fdf | |||
| 9694fb901a | |||
| a8982cf8c7 | |||
| f430ed7169 | |||
| 4f5a501be4 | |||
| 6c312efc9a | |||
| 1b987be562 | |||
| c84536fef7 | |||
| 1044298d76 | |||
| 4e971932d1 | |||
| 4834e2297a | |||
| 2a3f70eb4a | |||
| ea633ce3f9 | |||
| f6b64126cf | |||
| 9d3c15f284 | |||
| 7d224ec5ac | |||
| ed4e34b808 | |||
| f5c008c1a7 | |||
| dc71f74c0c | |||
| d5470de8fd | |||
| dff5903c53 | |||
| fc241b1cdc | |||
| 77ba732eec | |||
| 835175aa36 | |||
| 2e2827717d | |||
| 209f85c17e | |||
| 37c373c51f | |||
| 62fe03e8c1 | |||
| 427c28db7a | |||
| 835b363661 | |||
| df67ed57ee | |||
| 43b3cc2ca4 | |||
| 3c2268870b | |||
| fbb1267609 | |||
| 2c443a3b93 | |||
| 13fd8db0b7 | |||
| cdee0df5ab | |||
| 9e418afe64 | |||
| 7d43eb5d2e | |||
| de4c16431d | |||
| d3e6860b1c | |||
| 6bccf5595b | |||
| 35023efbf2 | |||
| d33460e3bd | |||
| eea059c0d3 | |||
| 2a327cc29e | |||
| 1ac1bf5b60 | |||
| ad5cace75b | |||
| bf49843721 | |||
| 25d9e3b1ca | |||
| dc07b2bdf4 | |||
| 0093acb578 | |||
| b89ecf4c03 | |||
| 468412100c | |||
| ea7e4b277f | |||
| 60e35c1bb9 | |||
| 117bb5bd86 | |||
| e8ba274776 | |||
| 76a1e20f13 | |||
| 8cab2fdcb6 | |||
| 354fcdc84b | |||
| 99e26a5805 | |||
| d354d6e788 | |||
| 28bcf479f3 | |||
| e3f8fc0e01 | |||
| e8184f0248 | |||
| 937de0fa00 | |||
| ac24bc86a0 | |||
| 1338a43c03 | |||
| 8889105d5a | |||
| 9cbe6b73fc | |||
| ff98fe38c2 | |||
| 9899c15d36 | |||
| 601b29c28b | |||
| 76e16b365d | |||
| 1021e8bc00 | |||
| 4f740fc9f8 | |||
| 75fc5c6e1e | |||
| 47cf63e0e6 | |||
| b4a1aacd12 | |||
| ad499b977e | |||
| b5c55f4e65 | |||
| 65b69829d7 | |||
| cf6eb604bd | |||
| 8655f5903a | |||
| 45f1dddb81 | |||
| 299d20aac9 | |||
| 43d16474c2 | |||
| ee08458df1 | |||
| c80958a776 | |||
| 13d8a8420a | |||
| 01a58ad2ed | |||
| a4e66e708a | |||
| 66e0698d2f | |||
| 935694cb64 | |||
| e2404f919e | |||
| c9810dd9eb | |||
| 6bfd3eada4 | |||
| 6852bae7f9 | |||
| 8536bdd614 | |||
| bd13c73f2f | |||
| 2a9ab569b4 | |||
| d6ebce0425 | |||
| 3af306abe0 | |||
| 30563f3648 | |||
| d6a2e7a9f7 | |||
| 32d686e908 | |||
| 05f906427e | |||
| d8653961af | |||
| d521bbc0fa | |||
| 281f7203dc | |||
| dd683af5f5 | |||
| 9a5506d901 | |||
| 5fc2907392 | |||
| 1443082991 | |||
| d4e3956941 | |||
| e3a457f84c | |||
| e40cd9f6a2 | |||
| eef498d47a | |||
| 8d4a9dc231 | |||
| e0d3c940f8 | |||
| be6d395ed6 | |||
| 87aa0b6659 | |||
| bb167b14ef | |||
| 351866d9e4 | |||
| 9a8f8433b0 | |||
| 4942789213 | |||
| 0741265837 | |||
| 06d4e1703e | |||
| 41be2a7b78 | |||
| 610d12283d | |||
| fee8da1613 | |||
| 28bed96e40 | |||
| 050800f5f7 | |||
| 21fe94b38c | |||
| ce639c12d8 | |||
| 78dd4e0086 | |||
| 0f7eebd683 | |||
| 860b635188 | |||
| 0710b4e8a1 | |||
| 823abc121e | |||
| 3fa6128561 | |||
| ca00e53a40 | |||
| 0003d2efd3 | |||
| 0efe9f05f2 | |||
| 88d0c5feb3 | |||
| 912aa38063 | |||
| 5fba658c66 | |||
| 070601689a | |||
| bde177fc34 | |||
| a593f71901 | |||
| 107fc501e4 | |||
| cd51fb85cf | |||
| 9591a05361 | |||
| ddfffaf6a2 | |||
| baffe1b79e | |||
| 145eb8f611 | |||
| a279835cf8 | |||
| 2dc04a8517 | |||
| 5c076933e7 | |||
| 417c2e4d1e | |||
| cbfb4d6d32 | |||
| 99ac768778 | |||
| 7177d0c37e | |||
| ff257fcd77 | |||
| 47243334f4 | |||
| 1693b643a7 | |||
| 9790dff27e | |||
| ab1d65e6f0 | |||
| 5bbadbbdc8 | |||
| ce92cd31bf | |||
| 8689d0e8b0 | |||
| f47e548b04 | |||
| 6fef2a9a87 | |||
| bc3ceab039 | |||
| b9a0e6cbb6 | |||
| c50fd4b3ac | |||
| 430f7b7217 | |||
| 72a3cea948 | |||
| fce22b08e9 | |||
| a2e64b4e0b | |||
| 1df87447bd | |||
| 75b2b3b163 | |||
| 80d90f93cd | |||
| e1ac4233c7 | |||
| 46c3bbff3c |
@@ -2,3 +2,9 @@
|
||||
.codeclimate.yml
|
||||
*.png
|
||||
*.md
|
||||
logs
|
||||
.venv
|
||||
start
|
||||
config.yaml
|
||||
registration.yaml
|
||||
*.db
|
||||
|
||||
+10
-5
@@ -1,12 +1,17 @@
|
||||
.idea/
|
||||
/.idea/
|
||||
|
||||
.venv
|
||||
env/
|
||||
/.venv
|
||||
/env/
|
||||
pip-selfcheck.json
|
||||
*.pyc
|
||||
__pycache__
|
||||
/build
|
||||
/dist
|
||||
/*.egg-info
|
||||
/.eggs
|
||||
|
||||
config.yaml
|
||||
registration.yaml
|
||||
/config.yaml
|
||||
/registration.yaml
|
||||
*.log*
|
||||
*.db
|
||||
*.bak
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
image: docker:stable
|
||||
|
||||
stages:
|
||||
- build
|
||||
- push
|
||||
|
||||
default:
|
||||
before_script:
|
||||
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
|
||||
|
||||
build:
|
||||
stage: build
|
||||
script:
|
||||
- docker pull $CI_REGISTRY_IMAGE:latest || true
|
||||
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
|
||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
|
||||
|
||||
push latest:
|
||||
stage: push
|
||||
only:
|
||||
- master
|
||||
variables:
|
||||
GIT_STRATEGY: none
|
||||
script:
|
||||
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
|
||||
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest
|
||||
- docker push $CI_REGISTRY_IMAGE:latest
|
||||
|
||||
push tag:
|
||||
stage: push
|
||||
variables:
|
||||
GIT_STRATEGY: none
|
||||
except:
|
||||
- master
|
||||
script:
|
||||
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
|
||||
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
|
||||
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
[settings]
|
||||
line_length=99
|
||||
indent=4
|
||||
|
||||
multi_line_output=5
|
||||
|
||||
sections=FUTURE,STDLIB,THIRDPARTY,TELETHON,MAUTRIX,FIRSTPARTY,LOCALFOLDER
|
||||
no_lines_before=LOCALFOLDER
|
||||
default_section=FIRSTPARTY
|
||||
|
||||
known_thirdparty=aiohttp,sqlalchemy,alembic,commonmark,ruamel.yaml,PIL,moviepy,prometheus_client,yarl,mako,pkg_resources
|
||||
known_telethon=telethon,alchemysession,cryptg
|
||||
known_mautrix=mautrix
|
||||
|
||||
balanced_wrapping=True
|
||||
length_sort=True
|
||||
+42
-10
@@ -1,23 +1,49 @@
|
||||
FROM docker.io/alpine:3.9
|
||||
FROM docker.io/alpine:3.10 AS lottieconverter
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
RUN apk add --no-cache git build-base cmake \
|
||||
&& git clone https://github.com/Samsung/rlottie.git \
|
||||
&& cd rlottie \
|
||||
&& mkdir build \
|
||||
&& cd build \
|
||||
&& cmake .. \
|
||||
&& make -j2 \
|
||||
&& make install \
|
||||
&& cd ../..
|
||||
|
||||
RUN apk add --no-cache libpng libpng-dev zlib zlib-dev \
|
||||
&& git clone https://github.com/Eramde/LottieConverter.git \
|
||||
&& cd LottieConverter \
|
||||
&& git checkout 543c1d23ac9322f4f03c7fb6612ea7d026d44ac0 \
|
||||
&& make
|
||||
|
||||
FROM docker.io/alpine:3.11
|
||||
|
||||
ENV UID=1337 \
|
||||
GID=1337 \
|
||||
FFMPEG_BINARY=/usr/bin/ffmpeg
|
||||
|
||||
COPY --from=lottieconverter /usr/lib/librlottie* /usr/lib/
|
||||
COPY --from=lottieconverter /build/LottieConverter/dist/Debug/GNU-Linux/lottieconverter /usr/local/bin/lottieconverter
|
||||
|
||||
COPY . /opt/mautrix-telegram
|
||||
WORKDIR /opt/mautrix-telegram
|
||||
RUN apk add --no-cache \
|
||||
RUN apk add --no-cache --virtual .build-deps \
|
||||
python3-dev \
|
||||
libffi-dev \
|
||||
build-base \
|
||||
git \
|
||||
&& apk add --no-cache \
|
||||
py3-virtualenv \
|
||||
py3-pillow \
|
||||
py3-aiohttp \
|
||||
py3-lxml \
|
||||
py3-magic \
|
||||
py3-sqlalchemy \
|
||||
py3-markdown \
|
||||
py3-psycopg2 \
|
||||
# Not yet in stable repos:
|
||||
#py3-ruamel \
|
||||
py3-ruamel.yaml \
|
||||
# Indirect dependencies
|
||||
py3-idna \
|
||||
#commonmark
|
||||
py3-future \
|
||||
#alembic
|
||||
@@ -26,19 +52,25 @@ RUN apk add --no-cache \
|
||||
py3-markupsafe \
|
||||
#moviepy
|
||||
py3-decorator \
|
||||
#py3-tqdm \
|
||||
py3-tqdm \
|
||||
py3-requests \
|
||||
#imageio
|
||||
py3-numpy \
|
||||
#telethon
|
||||
py3-rsa \
|
||||
# cryptg
|
||||
py3-cffi \
|
||||
# Other dependencies
|
||||
python3-dev \
|
||||
build-base \
|
||||
ffmpeg \
|
||||
ca-certificates \
|
||||
su-exec \
|
||||
&& pip3 install .[all]
|
||||
netcat-openbsd \
|
||||
# lottieconverter
|
||||
zlib libpng \
|
||||
&& pip3 install .[speedups,hq_thumbnails,metrics] \
|
||||
# pip installs the sources to /usr/lib/python3.8/site-packages, so we don't need them here
|
||||
&& rm -rf /opt/mautrix-telegram/mautrix_telegram \
|
||||
&& apk del .build-deps
|
||||
|
||||
VOLUME /data
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
# mautrix-telegram
|
||||

|
||||
[](LICENSE)
|
||||
[](https://github.com/tulir/mautrix-telegram/releases)
|
||||
[](https://mau.dev/tulir/mautrix-telegram/container_registry)
|
||||
[](https://codeclimate.com/github/tulir/mautrix-telegram)
|
||||
|
||||
A Matrix-Telegram hybrid puppeting/relaybot bridge.
|
||||
|
||||
### [Wiki](https://github.com/tulir/mautrix-telegram/wiki)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* Matrix → Telegram
|
||||
* [x] Message content (text, formatting, files, etc..)
|
||||
* [x] Message redactions
|
||||
* [x] Message edits
|
||||
* [ ] ‡ Message history
|
||||
* [x] Presence
|
||||
* [x] Typing notifications
|
||||
|
||||
+4
-10
@@ -7,7 +7,8 @@ from os.path import abspath, dirname
|
||||
|
||||
sys.path.insert(0, dirname(dirname(abspath(__file__))))
|
||||
|
||||
from mautrix_telegram.db import Base
|
||||
from mautrix.util.db import Base
|
||||
import mautrix_telegram.db
|
||||
from mautrix_telegram.config import Config
|
||||
from alchemysession import AlchemySessionContainer
|
||||
|
||||
@@ -18,17 +19,10 @@ config = context.config
|
||||
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
|
||||
mxtg_config = Config(mxtg_config_path, None, None)
|
||||
mxtg_config.load()
|
||||
config.set_main_option("sqlalchemy.url",
|
||||
mxtg_config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
|
||||
config.set_main_option("sqlalchemy.url", mxtg_config["appservice.database"].replace("%", "%%"))
|
||||
|
||||
|
||||
class FakeDB:
|
||||
@staticmethod
|
||||
def query_property():
|
||||
return None
|
||||
|
||||
|
||||
AlchemySessionContainer.create_table_classes(FakeDB(), "telethon_", Base)
|
||||
AlchemySessionContainer.create_table_classes(None, "telethon_", Base)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Add disable_updates field for puppets
|
||||
|
||||
Revision ID: 17574c57f3f8
|
||||
Revises: a9119be92164
|
||||
Create Date: 2019-05-15 00:24:46.967529
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '17574c57f3f8'
|
||||
down_revision = 'a9119be92164'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("puppet") as batch_op:
|
||||
batch_op.add_column(sa.Column("disable_updates", sa.Boolean(), nullable=False,
|
||||
server_default=sa.sql.expression.false()))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("puppet") as batch_op:
|
||||
batch_op.drop_column("disable_updates")
|
||||
@@ -17,7 +17,8 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('puppet', sa.Column('is_bot', sa.Boolean(), nullable=True))
|
||||
with op.batch_alter_table("puppet") as batch_op:
|
||||
batch_op.add_column(sa.Column('is_bot', sa.Boolean(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -16,7 +16,8 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('portal', sa.Column('megagroup', sa.Boolean()))
|
||||
with op.batch_alter_table("portal") as batch_op:
|
||||
batch_op.add_column(sa.Column('megagroup', sa.Boolean()))
|
||||
|
||||
|
||||
def downgrade():
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Switch mx_user_profile to native enum
|
||||
|
||||
Revision ID: 4f7d7ed5792a
|
||||
Revises: 9e9c89b0b877
|
||||
Create Date: 2019-08-04 17:47:36.568120
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '4f7d7ed5792a'
|
||||
down_revision = '9e9c89b0b877'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
conn = op.get_bind()
|
||||
conn.execute("UPDATE mx_user_profile SET membership=UPPER(membership)")
|
||||
conn.execute("UPDATE mx_user_profile SET membership='LEAVE' WHERE membership='LEFT'")
|
||||
|
||||
|
||||
def downgrade():
|
||||
conn = op.get_bind()
|
||||
conn.execute("UPDATE mx_user_profile SET membership=LOWER(membership)")
|
||||
@@ -5,14 +5,16 @@ Revises: 2228d49c383f
|
||||
Create Date: 2018-06-26 21:31:26.911307
|
||||
|
||||
"""
|
||||
from alembic import context, op
|
||||
import sqlalchemy.orm as orm
|
||||
import sqlalchemy as sa
|
||||
import json
|
||||
import re
|
||||
|
||||
from alembic import context, op
|
||||
import sqlalchemy.orm as orm
|
||||
import sqlalchemy as sa
|
||||
|
||||
from mautrix.util.db import Base
|
||||
|
||||
from mautrix_telegram.config import Config
|
||||
from mautrix_telegram.db import Base
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "6ca3d74d51e4"
|
||||
@@ -22,7 +24,6 @@ depends_on = None
|
||||
|
||||
|
||||
class RoomState(Base):
|
||||
query = None
|
||||
__tablename__ = "mx_room_state"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
@@ -31,7 +32,6 @@ class RoomState(Base):
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
query = None
|
||||
__tablename__ = "mx_user_profile"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
@@ -43,7 +43,6 @@ class UserProfile(Base):
|
||||
|
||||
|
||||
class Puppet(Base):
|
||||
query = None
|
||||
__tablename__ = "puppet"
|
||||
__table_args__ = {"extend_existing": True}
|
||||
|
||||
@@ -57,7 +56,8 @@ class Puppet(Base):
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("puppet", sa.Column("matrix_registered", sa.Boolean(), nullable=False,
|
||||
with op.batch_alter_table("puppet") as batch_op:
|
||||
batch_op.add_column(sa.Column("matrix_registered", sa.Boolean(), nullable=False,
|
||||
server_default=sa.sql.expression.false()))
|
||||
op.create_table("mx_room_state",
|
||||
sa.Column("room_id", sa.String(), nullable=False),
|
||||
@@ -82,7 +82,7 @@ def upgrade():
|
||||
|
||||
def migrate_state_store():
|
||||
conn = op.get_bind()
|
||||
session = orm.sessionmaker(bind=conn)() # type: orm.Session
|
||||
session: orm.Session = orm.sessionmaker(bind=conn)()
|
||||
|
||||
try:
|
||||
with open("mx-state.json") as file:
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Add edit index to messages
|
||||
|
||||
Revision ID: 9e9c89b0b877
|
||||
Revises: 17574c57f3f8
|
||||
Create Date: 2019-05-29 15:28:23.128377
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '9e9c89b0b877'
|
||||
down_revision = '17574c57f3f8'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table('_message_temp',
|
||||
sa.Column('mxid', sa.String),
|
||||
sa.Column('mx_room', sa.String),
|
||||
sa.Column('tgid', sa.Integer),
|
||||
sa.Column('tg_space', sa.Integer),
|
||||
sa.Column('edit_index', sa.Integer),
|
||||
sa.PrimaryKeyConstraint('tgid', 'tg_space', 'edit_index'),
|
||||
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room_2"))
|
||||
c = op.get_bind()
|
||||
c.execute("INSERT INTO _message_temp (mxid, mx_room, tgid, tg_space, edit_index) "
|
||||
"SELECT message.mxid, message.mx_room, message.tgid, message.tg_space, 0 "
|
||||
"FROM message")
|
||||
c.execute("DROP TABLE message")
|
||||
c.execute("ALTER TABLE _message_temp RENAME TO message")
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.create_table('_message_temp',
|
||||
sa.Column('mxid', sa.String),
|
||||
sa.Column('mx_room', sa.String),
|
||||
sa.Column('tgid', sa.Integer),
|
||||
sa.Column('tg_space', sa.Integer),
|
||||
sa.PrimaryKeyConstraint('tgid', 'tg_space'),
|
||||
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"))
|
||||
c = op.get_bind()
|
||||
c.execute("INSERT INTO _message_temp (mxid, mx_room, tgid, tg_space) "
|
||||
"SELECT message.mxid, message.mx_room, message.tgid, message.tg_space "
|
||||
"FROM message")
|
||||
c.execute("DROP TABLE message")
|
||||
c.execute("ALTER TABLE _message_temp RENAME TO message")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Store custom puppet next_batch in database
|
||||
|
||||
Revision ID: a7c04a56041b
|
||||
Revises: 4f7d7ed5792a
|
||||
Create Date: 2019-08-06 23:08:51.087651
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "a7c04a56041b"
|
||||
down_revision = "4f7d7ed5792a"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("puppet") as batch_op:
|
||||
batch_op.add_column(sa.Column("next_batch", sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("puppet") as batch_op:
|
||||
batch_op.drop_column("next_batch")
|
||||
+137
-27
@@ -1,9 +1,9 @@
|
||||
# Homeserver details
|
||||
homeserver:
|
||||
# The address that this appservice can use to connect to the homeserver.
|
||||
address: https://matrix.org
|
||||
address: https://example.com
|
||||
# The domain of the homeserver (for MXIDs, etc).
|
||||
domain: matrix.org
|
||||
domain: example.com
|
||||
# Whether or not to verify the SSL certificate of the homeserver.
|
||||
# Only applies if address starts with https://
|
||||
verify_ssl: true
|
||||
@@ -12,11 +12,11 @@ homeserver:
|
||||
# Changing these values requires regeneration of the registration.
|
||||
appservice:
|
||||
# The address that the homeserver can use to connect to this appservice.
|
||||
address: http://localhost:8080
|
||||
address: http://localhost:29317
|
||||
|
||||
# The hostname and port where this appservice should listen.
|
||||
hostname: 0.0.0.0
|
||||
port: 8080
|
||||
port: 29317
|
||||
# The maximum body size of appservice API requests (from the homeserver) in mebibytes
|
||||
# Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
|
||||
max_body_size: 1
|
||||
@@ -33,7 +33,7 @@ appservice:
|
||||
# the HS database.
|
||||
public:
|
||||
# Whether or not the public-facing endpoints should be enabled.
|
||||
enabled: true
|
||||
enabled: false
|
||||
# The prefix to use in the public-facing endpoints.
|
||||
prefix: /public
|
||||
# The base URL where the public-facing endpoints are available. The prefix is not added
|
||||
@@ -60,10 +60,30 @@ appservice:
|
||||
bot_displayname: Telegram bridge bot
|
||||
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
|
||||
|
||||
# Community ID for bridged users (changes registration file) and rooms.
|
||||
# Must be created manually.
|
||||
community_id: 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"
|
||||
|
||||
# Prometheus telemetry config. Requires prometheus-client to be installed.
|
||||
metrics:
|
||||
enabled: false
|
||||
listen_port: 8000
|
||||
|
||||
# Manhole config.
|
||||
manhole:
|
||||
# Whether or not opening the manhole is allowed.
|
||||
enabled: false
|
||||
# The path for the unix socket.
|
||||
path: /var/tmp/mautrix-telegram.manhole
|
||||
# The list of UIDs who can be added to the whitelist.
|
||||
# If empty, any UIDs can be specified in the open-manhole command.
|
||||
whitelist:
|
||||
- 0
|
||||
|
||||
# Bridge config
|
||||
bridge:
|
||||
# Localpart template of MXIDs for Telegram users.
|
||||
@@ -94,6 +114,8 @@ bridge:
|
||||
- full name
|
||||
- username
|
||||
- phone number
|
||||
# Maximum length of displayname
|
||||
displayname_max_length: 100
|
||||
|
||||
# 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
|
||||
@@ -110,9 +132,10 @@ bridge:
|
||||
# their Telegram account at startup.
|
||||
startup_sync: true
|
||||
# Number of most recently active dialogs to check when syncing chats.
|
||||
# Dialogs include groups and private chats, but only groups are synced.
|
||||
# Set to 0 to remove limit.
|
||||
sync_dialog_limit: 30
|
||||
# Whether or not to sync and create portals for direct chats at startup.
|
||||
sync_direct_chats: false
|
||||
# The maximum number of simultaneous Telegram deletions to handle.
|
||||
# A large number of simultaneous redactions could put strain on your homeserver.
|
||||
max_telegram_delete: 10
|
||||
@@ -126,19 +149,17 @@ bridge:
|
||||
# Only enable this if your displayname_template has some static part that the bridge can use to
|
||||
# reliably identify what is a plaintext highlight.
|
||||
plaintext_highlights: false
|
||||
# Show message editing as a reply to the original message.
|
||||
# If this is false, message edits are not shown at all, as Matrix does not support editing yet.
|
||||
edits_as_replies: true
|
||||
# Highlight changed/added parts in edits. Requires lxml.
|
||||
highlight_edits: false
|
||||
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
|
||||
public_portals: true
|
||||
# Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down.
|
||||
# Currently only works for private chats and normal groups.
|
||||
catch_up: false
|
||||
# Whether or not to use /sync to get presence, read receipts and typing notifications when using
|
||||
# your own Matrix account as the Matrix puppet for your Telegram account.
|
||||
sync_with_custom_puppets: true
|
||||
# Shared secret for https://github.com/devture/matrix-synapse-shared-secret-auth
|
||||
#
|
||||
# If set, custom puppets will be enabled automatically for local users
|
||||
# instead of users having to find an access token and run `login-matrix`
|
||||
# manually.
|
||||
login_shared_secret: null
|
||||
# Set to false to disable link previews in messages sent to Telegram.
|
||||
telegram_link_preview: true
|
||||
# Use inline images instead of a separate message for the caption.
|
||||
@@ -146,6 +167,35 @@ bridge:
|
||||
inline_images: false
|
||||
# Maximum size of image in megabytes before sending to Telegram as a document.
|
||||
image_as_file_size: 10
|
||||
# Maximum size of Telegram documents in megabytes to bridge.
|
||||
max_document_size: 100
|
||||
# Enable experimental parallel file transfer, which makes uploads/downloads much faster by
|
||||
# streaming from/to Matrix and using many connections for Telegram.
|
||||
# Note that generating HQ thumbnails for videos is not possible with streamed transfers.
|
||||
parallel_file_transfer: false
|
||||
# Whether or not created rooms should have federation enabled.
|
||||
# If false, created portal rooms will never be federated.
|
||||
federate_rooms: true
|
||||
# Settings for converting animated stickers.
|
||||
animated_sticker:
|
||||
# Format to which animated stickers should be converted.
|
||||
# disable - No conversion, send as-is (gzipped lottie)
|
||||
# png - converts to non-animated png (fastest),
|
||||
# gif - converts to animated gif, but loses transparency
|
||||
# webm - converts to webm video, requires ffmpeg executable with vp9 codec and webm container support
|
||||
target: gif
|
||||
# Arguments for converter. All converters take width and height.
|
||||
# GIF converter takes background as a hex color.
|
||||
args:
|
||||
width: 256
|
||||
height: 256
|
||||
background: "020202" # only for gif
|
||||
fps: 30 # only for webm
|
||||
|
||||
# Overrides for base power levels.
|
||||
initial_power_level_overrides:
|
||||
user: {}
|
||||
group: {}
|
||||
|
||||
# Whether to bridge Telegram bot messages as m.notices or m.texts.
|
||||
bot_messages_as_notices: true
|
||||
@@ -168,24 +218,30 @@ bridge:
|
||||
# You might need to increase this on high-traffic bridge instances.
|
||||
cache_queue_length: 20
|
||||
|
||||
|
||||
# The formats to use when sending messages to Telegram via the relay bot.
|
||||
#
|
||||
# Telegram doesn't have built-in emotes, so the m.emote format is also used for non-relaybot users.
|
||||
# Text msgtypes (m.text, m.notice and m.emote) support HTML, media msgtypes don't.
|
||||
#
|
||||
# Available variables:
|
||||
# $sender_displayname - The display name of the sender (e.g. Example User)
|
||||
# $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
|
||||
# $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
|
||||
# $message - The message content as HTML
|
||||
# $sender_displayname - The display name of the sender (e.g. Example User)
|
||||
# $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
|
||||
# $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
|
||||
# $message - The message content
|
||||
message_formats:
|
||||
m.text: "<b>$sender_displayname</b>: $message"
|
||||
m.notice: "<b>$sender_displayname</b>: $message"
|
||||
m.emote: "* <b>$sender_displayname</b> $message"
|
||||
m.file: "<b>$sender_displayname</b> sent a file: $message"
|
||||
m.image: "<b>$sender_displayname</b> sent an image: $message"
|
||||
m.audio: "<b>$sender_displayname</b> sent an audio file: $message"
|
||||
m.video: "<b>$sender_displayname</b> sent a video: $message"
|
||||
m.location: "<b>$sender_displayname</b> sent a location: $message"
|
||||
# Telegram doesn't have built-in emotes, this field specifies how m.emote's from authenticated
|
||||
# users are sent to telegram. All fields in message_formats are supported. Additionally, the
|
||||
# Telegram user info is available in the following variables:
|
||||
# $displayname - Telegram displayname
|
||||
# $username - Telegram username (may not exist)
|
||||
# $mention - Telegram @username or displayname mention (depending on which exists)
|
||||
emote_format: "* $mention $formatted_body"
|
||||
|
||||
# The formats to use when sending state events to Telegram via the relay bot.
|
||||
#
|
||||
@@ -233,6 +289,22 @@ bridge:
|
||||
|
||||
# Options related to the message relay Telegram bot.
|
||||
relaybot:
|
||||
private_chat:
|
||||
# List of users to invite to the portal when someone starts a private chat with the bot.
|
||||
# If empty, private chats with the bot won't create a portal.
|
||||
invite: []
|
||||
# Whether or not to bridge state change messages in relaybot private chats.
|
||||
state_changes: true
|
||||
# When private_chat_invite is empty, this message is sent to users /starting the
|
||||
# relaybot. Telegram's "markdown" is supported.
|
||||
message: This is a Matrix bridge relaybot and does not support direct chats
|
||||
# List of users to invite to all group chat portals created by the bridge.
|
||||
group_chat_invite: []
|
||||
# Whether or not the relaybot should not bridge events in unbridged group chats.
|
||||
# If false, portals will be created when the relaybot receives messages, just like normal
|
||||
# users. This behavior is usually not desirable, as it interferes with manually bridging
|
||||
# the chat to another room.
|
||||
ignore_unbridged_group_chat: true
|
||||
# Whether or not to allow creating portals from Telegram.
|
||||
authless_portals: true
|
||||
# Whether or not to allow Telegram group admins to use the bot commands.
|
||||
@@ -251,6 +323,40 @@ telegram:
|
||||
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
|
||||
# (Optional) Create your own bot at https://t.me/BotFather
|
||||
bot_token: disabled
|
||||
|
||||
# Telethon connection options.
|
||||
connection:
|
||||
# The timeout in seconds to be used when connecting.
|
||||
timeout: 120
|
||||
# How many times the reconnection should retry, either on the initial connection or when
|
||||
# Telegram disconnects us. May be set to a negative or null value for infinite retries, but
|
||||
# this is not recommended, since the program can get stuck in an infinite loop.
|
||||
retries: 5
|
||||
# The delay in seconds to sleep between automatic reconnections.
|
||||
retry_delay: 1
|
||||
# The threshold below which the library should automatically sleep on flood wait errors
|
||||
# (inclusive). For instance, if a FloodWaitError for 17s occurs and flood_sleep_threshold
|
||||
# is 20s, the library will sleep automatically. If the error was for 21s, it would raise
|
||||
# the error instead. Values larger than a day (86400) will be changed to a day.
|
||||
flood_sleep_threshold: 60
|
||||
# How many times a request should be retried. Request are retried when Telegram is having
|
||||
# internal issues, when there is a FloodWaitError less than flood_sleep_threshold, or when
|
||||
# there's a migrate error. May take a negative or null value for infinite retries, but this
|
||||
# is not recommended, since some requests can always trigger a call fail (such as searching
|
||||
# for messages).
|
||||
request_retries: 5
|
||||
|
||||
# Device info sent to Telegram.
|
||||
device_info:
|
||||
# "auto" = OS name+version.
|
||||
device_model: auto
|
||||
# "auto" = Telethon version.
|
||||
system_version: auto
|
||||
# "auto" = mautrix-telegram version.
|
||||
app_version: auto
|
||||
lang_code: en
|
||||
system_lang_code: en
|
||||
|
||||
# Custom server to connect to.
|
||||
server:
|
||||
# Set to true to use these server settings. If false, will automatically
|
||||
@@ -262,17 +368,18 @@ telegram:
|
||||
ip: 149.154.167.40
|
||||
# The port to connect to. 443 may not work, 80 is better and both are equally secure.
|
||||
port: 80
|
||||
|
||||
# Telethon proxy configuration.
|
||||
# You must install PySocks from pip for proxies to work.
|
||||
proxy:
|
||||
# Allowed types: disabled, socks4, socks5, http
|
||||
# Allowed types: disabled, socks4, socks5, http, mtproxy
|
||||
type: disabled
|
||||
# Proxy IP address and port.
|
||||
address: 127.0.0.1
|
||||
port: 1080
|
||||
# Whether or not to perform DNS resolving remotely.
|
||||
# Whether or not to perform DNS resolving remotely. Only for socks/http proxies.
|
||||
rdns: true
|
||||
# Proxy authentication (optional).
|
||||
# Proxy authentication (optional). Put MTProxy secret in password field.
|
||||
username: ""
|
||||
password: ""
|
||||
|
||||
@@ -283,18 +390,21 @@ telegram:
|
||||
logging:
|
||||
version: 1
|
||||
formatters:
|
||||
precise:
|
||||
colored:
|
||||
(): mautrix_telegram.util.ColorFormatter
|
||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
||||
normal:
|
||||
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
|
||||
handlers:
|
||||
file:
|
||||
class: logging.handlers.RotatingFileHandler
|
||||
formatter: precise
|
||||
formatter: normal
|
||||
filename: ./mautrix-telegram.log
|
||||
maxBytes: 10485760
|
||||
backupCount: 10
|
||||
console:
|
||||
class: logging.StreamHandler
|
||||
formatter: precise
|
||||
formatter: colored
|
||||
loggers:
|
||||
mau:
|
||||
level: DEBUG
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[*.{yaml,yml}]
|
||||
indent_size = 2
|
||||
@@ -0,0 +1 @@
|
||||
charts/*
|
||||
@@ -0,0 +1,22 @@
|
||||
# Patterns to ignore when building packages.
|
||||
# This supports shell glob matching, relative path matching, and
|
||||
# negation (prefixed with !). Only one pattern per line.
|
||||
.DS_Store
|
||||
# Common VCS dirs
|
||||
.git/
|
||||
.gitignore
|
||||
.bzr/
|
||||
.bzrignore
|
||||
.hg/
|
||||
.hgignore
|
||||
.svn/
|
||||
# Common backup files
|
||||
*.swp
|
||||
*.bak
|
||||
*.tmp
|
||||
*~
|
||||
# Various IDEs
|
||||
.project
|
||||
.idea/
|
||||
*.tmproj
|
||||
.vscode/
|
||||
@@ -0,0 +1,14 @@
|
||||
apiVersion: v1
|
||||
name: mautrix-telegram
|
||||
version: 0.1.0
|
||||
appVersion: "0.7.0"
|
||||
description: A Matrix-Telegram hybrid puppeting/relaybot bridge.
|
||||
keywords:
|
||||
- matrix
|
||||
- bridge
|
||||
- telegram
|
||||
maintainers:
|
||||
- name: Tulir Asokan
|
||||
email: tulir@maunium.net
|
||||
sources:
|
||||
- https://github.com/tulir/mautrix-telegram
|
||||
@@ -0,0 +1,6 @@
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
repository: https://kubernetes-charts.storage.googleapis.com/
|
||||
version: 6.5.0
|
||||
digest: sha256:85139e9d4207e49c11c5f84d7920d0135cffd3d427f3f3638d4e51258990de2a
|
||||
generated: "2019-10-23T22:11:37.005827507+03:00"
|
||||
@@ -0,0 +1,5 @@
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 6.5.0
|
||||
repository: https://kubernetes-charts.storage.googleapis.com/
|
||||
condition: postgresql.enabled
|
||||
@@ -0,0 +1,21 @@
|
||||
Your registration file is below. Save it into a YAML file and give the path to that file to synapse:
|
||||
|
||||
id: {{ .Values.appservice.id }}
|
||||
as_token: {{ .Values.appservice.asToken }}
|
||||
hs_token: {{ .Values.appservice.hsToken }}
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: true
|
||||
regex: "@{{ .Values.bridge.username_template | replace "{userid}" ".+"}}:{{ .Values.homeserver.domain }}"
|
||||
{{- if .Values.appservice.communityID }}
|
||||
group_id: {{ .Values.appservice.communityID }}
|
||||
{{- end }}
|
||||
aliases:
|
||||
- exclusive: true
|
||||
regex: "@{{ .Values.bridge.alias_template | replace "{groupname}" ".+"}}:{{ .Values.homeserver.domain }}"
|
||||
{{- if .Values.appservice.communityID }}
|
||||
group_id: {{ .Values.appservice.communityID }}
|
||||
{{- end }}
|
||||
url: {{ .Values.appservice.address }}
|
||||
sender_localpart: {{ .Values.appservice.botUsername }}
|
||||
rate_limited: false
|
||||
@@ -0,0 +1,55 @@
|
||||
{{/*
|
||||
Expand the name of the chart.
|
||||
*/}}
|
||||
{{- define "mautrix-telegram.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create a default fully qualified app name.
|
||||
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
|
||||
If release name contains chart name it will be used as a full name.
|
||||
*/}}
|
||||
{{- define "mautrix-telegram.fullname" -}}
|
||||
{{- if .Values.fullnameOverride -}}
|
||||
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||
{{- if contains $name .Release.Name -}}
|
||||
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- else -}}
|
||||
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create chart name and version as used by the chart label.
|
||||
*/}}
|
||||
{{- define "mautrix-telegram.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Common labels
|
||||
*/}}
|
||||
{{- define "mautrix-telegram.labels" -}}
|
||||
app.kubernetes.io/name: {{ include "mautrix-telegram.name" . }}
|
||||
helm.sh/chart: {{ include "mautrix-telegram.chart" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end -}}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
{{- define "mautrix-telegram.serviceAccountName" -}}
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
{{ default (include "mautrix-telegram.fullname" .) .Values.serviceAccount.name }}
|
||||
{{- else -}}
|
||||
{{ default "default" .Values.serviceAccount.name }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,57 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ template "mautrix-telegram.fullname" . }}
|
||||
labels:
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
|
||||
app.kubernetes.io/name: {{ template "mautrix-telegram.name" . }}
|
||||
data:
|
||||
config.yaml: |
|
||||
homeserver:
|
||||
address: {{ .Values.homeserver.address }}
|
||||
domain: {{ .Values.homeserver.domain }}
|
||||
verify_ssl: {{ .Values.homeserver.verifySSL }}
|
||||
|
||||
appservice:
|
||||
address: http://{{ include "mautrix-telegram.fullname" . }}:{{ .Values.service.port }}
|
||||
|
||||
hostname: 0.0.0.0
|
||||
port: {{ .Values.service.port }}
|
||||
max_body_size: {{ .Values.appservice.maxBodySize }}
|
||||
|
||||
{{- if .Values.postgresql.enabled }}
|
||||
database: "postgres://postgres:{{ .Values.postgresql.postgresqlPassword }}@{{ .Release.Name }}-postgresql/{{ .Values.postgresql.postgresqlDatabase }}"
|
||||
{{- else }}
|
||||
database: {{ .Values.appservice.database | quote }}
|
||||
{{- end }}
|
||||
|
||||
public:
|
||||
{{- toYaml .Values.appservice.public | nindent 8 }}
|
||||
|
||||
provisioning:
|
||||
{{- toYaml .Values.appservice.provisioning | nindent 8 }}
|
||||
|
||||
id: {{ .Values.appservice.id }}
|
||||
bot_username: {{ .Values.appservice.botUsername }}
|
||||
bot_displayname: {{ .Values.appservice.botDisplayname }}
|
||||
bot_avatar: {{ .Values.appservice.botAvatar }}
|
||||
|
||||
community_id: {{ .Values.appservice.communityID }}
|
||||
|
||||
as_token: {{ .Values.appservice.asToken }}
|
||||
hs_token: {{ .Values.appservice.hsToken }}
|
||||
|
||||
metrics:
|
||||
{{- toYaml .Values.metrics | nindent 6 }}
|
||||
|
||||
bridge:
|
||||
{{- toYaml .Values.bridge | nindent 6 }}
|
||||
|
||||
telegram:
|
||||
{{- toYaml .Values.telegram | nindent 6 }}
|
||||
|
||||
logging:
|
||||
{{- toYaml .Values.logging | nindent 6 }}
|
||||
registration.yaml: ""
|
||||
@@ -0,0 +1,69 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "mautrix-telegram.fullname" . }}
|
||||
labels:
|
||||
{{- include "mautrix-telegram.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: {{ include "mautrix-telegram.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
template:
|
||||
{{- if .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml .Values.podAnnotations | nindent 6 }}
|
||||
{{- end }}
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: {{ include "mautrix-telegram.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
spec:
|
||||
serviceAccountName: {{ template "mautrix-telegram.serviceAccountName" . }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
volumeMounts:
|
||||
- mountPath: /data
|
||||
name: config-volume
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /_matrix/mau/live
|
||||
port: http
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /_matrix/mau/ready
|
||||
port: http
|
||||
initialDelaySeconds: 60
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
volumes:
|
||||
- name: config-volume
|
||||
configMap:
|
||||
name: {{ template "mautrix-telegram.fullname" . }}
|
||||
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,16 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "mautrix-telegram.fullname" . }}
|
||||
labels:
|
||||
{{ include "mautrix-telegram.labels" . | indent 4 }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- port: {{ .Values.service.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
app.kubernetes.io/name: {{ include "mautrix-telegram.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{- if .Values.serviceAccount.create -}}
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ template "mautrix-telegram.serviceAccountName" . }}
|
||||
labels:
|
||||
{{ include "mautrix-telegram.labels" . | indent 4 }}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,141 @@
|
||||
image:
|
||||
repository: dock.mau.dev/tulir/mautrix-telegram
|
||||
tag: latest
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
nameOverride: ""
|
||||
fullnameOverride: ""
|
||||
|
||||
serviceAccount:
|
||||
# Specifies whether a service account should be created
|
||||
create: true
|
||||
# The name of the service account to use.
|
||||
# If not set and create is true, a name is generated using the fullname template
|
||||
name:
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 29317
|
||||
|
||||
resources: {}
|
||||
# limits:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
# requests:
|
||||
# cpu: 100m
|
||||
# memory: 128Mi
|
||||
|
||||
nodeSelector: {}
|
||||
|
||||
tolerations: []
|
||||
|
||||
affinity: {}
|
||||
|
||||
# Postgres pod configs
|
||||
postgresql:
|
||||
enabled: true
|
||||
postgresqlDatabase: mxtg
|
||||
postgresqlPassword: SET TO RANDOM STRING
|
||||
persistence:
|
||||
size: 2Gi
|
||||
resources:
|
||||
requests:
|
||||
memory: 256Mi
|
||||
cpu: 100m
|
||||
|
||||
# Homeserver details
|
||||
homeserver:
|
||||
# The address that this appservice can use to connect to the homeserver.
|
||||
address: https://example.com
|
||||
# The domain of the homeserver (for MXIDs, etc).
|
||||
domain: example.com
|
||||
# Whether or not to verify the SSL certificate of the homeserver.
|
||||
# Only applies if address starts with https://
|
||||
verifySSL: true
|
||||
|
||||
# Application service host/registration related details
|
||||
# Changing these values requires regeneration of the registration.
|
||||
appservice:
|
||||
# The maximum body size of appservice API requests (from the homeserver) in mebibytes
|
||||
# Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
|
||||
maxBodySize: 1
|
||||
|
||||
# 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
|
||||
# the HS database.
|
||||
public:
|
||||
# Whether or not the public-facing endpoints should be enabled.
|
||||
enabled: true
|
||||
# The prefix to use in the public-facing endpoints.
|
||||
prefix: /public
|
||||
# The base URL where the public-facing endpoints are available. The prefix is not added
|
||||
# implicitly.
|
||||
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/).
|
||||
provisioning:
|
||||
# Whether or not the provisioning API should be enabled.
|
||||
enabled: true
|
||||
# The prefix to use in the provisioning API endpoints.
|
||||
prefix: /_matrix/provision/v1
|
||||
# The shared secret to authorize users of the API.
|
||||
shared_secret: SET TO RANDOM STRING
|
||||
|
||||
id: telegram
|
||||
botUsername: telegrambot
|
||||
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
|
||||
# to leave display name/avatar as-is.
|
||||
botDisplayname: Telegram bridge bot
|
||||
botAvatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
|
||||
|
||||
# Community ID for bridged users (changes registration file) and rooms.
|
||||
# Must be created manually.
|
||||
communityID: false
|
||||
|
||||
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
|
||||
asToken: SET TO RANDOM STRING
|
||||
hsToken: SET TO RANDOM STRING
|
||||
|
||||
# The keys below can be used to override the configs in the base config:
|
||||
# https://github.com/tulir/mautrix-telegram/blob/master/example-config.yaml
|
||||
# Note that the "appservice" and "homeserver" sections are above and slightly different than the base.
|
||||
|
||||
# Bridge config
|
||||
bridge:
|
||||
# Localpart template of MXIDs for Telegram users.
|
||||
# {userid} is replaced with the user ID of the Telegram user.
|
||||
username_template: "telegram_{userid}"
|
||||
# Localpart template of room aliases for Telegram portal rooms.
|
||||
# {groupname} is replaced with the name part of the public channel/group invite link ( https://t.me/{} )
|
||||
alias_template: "telegram_{groupname}"
|
||||
|
||||
# Permissions for using the bridge.
|
||||
# Permitted values:
|
||||
# relaybot - Only use the bridge via the relaybot, no access to commands.
|
||||
# user - Relaybot level + access to commands to create bridges.
|
||||
# puppeting - User level + logging in with a Telegram account.
|
||||
# full - Full access to use the bridge, i.e. previous levels + Matrix login.
|
||||
# admin - Full access to use the bridge and some extra administration commands.
|
||||
# Permitted keys:
|
||||
# * - All Matrix users
|
||||
# domain - All users on that homeserver
|
||||
# mxid - Specific user
|
||||
permissions:
|
||||
"*": "relaybot"
|
||||
"public.example.com": "user"
|
||||
"example.com": "full"
|
||||
"@admin:example.com": "admin"
|
||||
|
||||
# Prometheus telemetry config.
|
||||
metrics:
|
||||
enabled: false
|
||||
listen_port: 8000
|
||||
|
||||
# Telegram config
|
||||
telegram:
|
||||
# Get your own API keys at https://my.telegram.org/apps
|
||||
api_id: 12345
|
||||
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
|
||||
# (Optional) Create your own bot at https://t.me/BotFather
|
||||
# bot_token: 123456789:
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "0.5.1"
|
||||
__version__ = "0.7.0rc3"
|
||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
||||
|
||||
+73
-111
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,138 +13,101 @@
|
||||
#
|
||||
# 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, List, Any
|
||||
from time import time
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging.config
|
||||
import sys
|
||||
import copy
|
||||
import signal
|
||||
from typing import Optional
|
||||
from itertools import chain
|
||||
|
||||
import sqlalchemy as sql
|
||||
|
||||
from mautrix_appservice import AppService
|
||||
from alchemysession import AlchemySessionContainer
|
||||
|
||||
from mautrix.bridge import Bridge
|
||||
from mautrix.util.db import Base
|
||||
|
||||
from .web.provisioning import ProvisioningAPI
|
||||
from .web.public import PublicBridgeWebsite
|
||||
from .commands.manhole import ManholeState
|
||||
from .abstract_user import init as init_abstract_user
|
||||
from .bot import init as init_bot
|
||||
from .bot import Bot, init as init_bot
|
||||
from .config import Config
|
||||
from .context import Context
|
||||
from .db import Base, init as init_db
|
||||
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 .puppet import init as init_puppet
|
||||
from .puppet import Puppet, init as init_puppet
|
||||
from .sqlstatestore import SQLStateStore
|
||||
from .user import User, init as init_user
|
||||
from . import __version__
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="A Matrix-Telegram puppeting bridge.",
|
||||
prog="python -m mautrix-telegram")
|
||||
parser.add_argument("-c", "--config", type=str, default="config.yaml",
|
||||
metavar="<path>", help="the path to your config file")
|
||||
parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml",
|
||||
metavar="<path>", help="the path to the example config "
|
||||
"(for automatic config updates)")
|
||||
parser.add_argument("-g", "--generate-registration", action="store_true",
|
||||
help="generate registration and quit")
|
||||
parser.add_argument("-r", "--registration", type=str, default="registration.yaml",
|
||||
metavar="<path>", help="the path to save the generated registration to")
|
||||
args = parser.parse_args()
|
||||
|
||||
config = Config(args.config, args.registration, args.base_config)
|
||||
config.load()
|
||||
config.update()
|
||||
|
||||
if args.generate_registration:
|
||||
config.generate_registration()
|
||||
config.save()
|
||||
print(f"Registration generated and saved to {config.registration_path}")
|
||||
sys.exit(0)
|
||||
|
||||
logging.config.dictConfig(copy.deepcopy(config["logging"]))
|
||||
log = logging.getLogger("mau.init") # type: logging.Logger
|
||||
log.debug(f"Initializing mautrix-telegram {__version__}")
|
||||
|
||||
db_engine = sql.create_engine(config["appservice.database"] or "sqlite:///mautrix-telegram.db")
|
||||
Base.metadata.bind = db_engine
|
||||
|
||||
session_container = AlchemySessionContainer(engine=db_engine, table_base=Base, session=False,
|
||||
table_prefix="telethon_", manage_tables=False)
|
||||
session_container.core_mode = True
|
||||
from .version import version, linkified_version
|
||||
|
||||
try:
|
||||
import uvloop
|
||||
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
log.debug("Using uvloop for asyncio")
|
||||
import prometheus_client as prometheus
|
||||
except ImportError:
|
||||
pass
|
||||
prometheus = None
|
||||
|
||||
loop = asyncio.get_event_loop() # type: asyncio.AbstractEventLoop
|
||||
|
||||
state_store = SQLStateStore()
|
||||
mebibyte = 1024 ** 2
|
||||
appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
|
||||
config["appservice.as_token"], config["appservice.hs_token"],
|
||||
config["appservice.bot_username"], log="mau.as", loop=loop,
|
||||
verify_ssl=config["homeserver.verify_ssl"], state_store=state_store,
|
||||
real_user_content_key="net.maunium.telegram.puppet",
|
||||
aiohttp_params={
|
||||
"client_max_size": config["appservice.max_body_size"] * mebibyte
|
||||
})
|
||||
bot = init_bot(config)
|
||||
context = Context(appserv, config, loop, session_container, bot)
|
||||
class TelegramBridge(Bridge):
|
||||
name = "mautrix-telegram"
|
||||
command = "python -m mautrix-telegram"
|
||||
description = "A Matrix-Telegram puppeting bridge."
|
||||
repo_url = "https://github.com/tulir/mautrix-telegram"
|
||||
real_user_content_key = "net.maunium.telegram.puppet"
|
||||
version = version
|
||||
markdown_version = linkified_version
|
||||
config_class = Config
|
||||
matrix_class = MatrixHandler
|
||||
state_store_class = SQLStateStore
|
||||
|
||||
if config["appservice.public.enabled"]:
|
||||
public_website = PublicBridgeWebsite(loop)
|
||||
appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app)
|
||||
context.public_website = public_website
|
||||
config: Config
|
||||
session_container: AlchemySessionContainer
|
||||
bot: Bot
|
||||
manhole: Optional[ManholeState]
|
||||
|
||||
if config["appservice.provisioning.enabled"]:
|
||||
provisioning_api = ProvisioningAPI(context)
|
||||
appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning",
|
||||
provisioning_api.app)
|
||||
context.provisioning_api = provisioning_api
|
||||
def prepare_db(self) -> None:
|
||||
super().prepare_db()
|
||||
init_db(self.db)
|
||||
self.session_container = AlchemySessionContainer(
|
||||
engine=self.db, table_base=Base, session=False,
|
||||
table_prefix="telethon_", manage_tables=False)
|
||||
|
||||
context.mx = MatrixHandler(context)
|
||||
def _prepare_website(self, context: Context) -> None:
|
||||
if self.config["appservice.public.enabled"]:
|
||||
public_website = PublicBridgeWebsite(self.loop)
|
||||
self.az.app.add_subapp(self.config["appservice.public.prefix"], public_website.app)
|
||||
context.public_website = public_website
|
||||
|
||||
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
|
||||
start_ts = time()
|
||||
init_db(db_engine)
|
||||
init_abstract_user(context)
|
||||
init_formatter(context)
|
||||
init_portal(context)
|
||||
startup_actions = (init_puppet(context) +
|
||||
init_user(context) +
|
||||
[start, context.mx.init_as_bot()]) # type: List[Awaitable[Any]]
|
||||
if self.config["appservice.provisioning.enabled"]:
|
||||
provisioning_api = ProvisioningAPI(context)
|
||||
self.az.app.add_subapp(self.config["appservice.provisioning.prefix"],
|
||||
provisioning_api.app)
|
||||
context.provisioning_api = provisioning_api
|
||||
|
||||
if context.bot:
|
||||
startup_actions.append(context.bot.start())
|
||||
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.")
|
||||
|
||||
signal.signal(signal.SIGINT, signal.default_int_handler)
|
||||
signal.signal(signal.SIGTERM, signal.default_int_handler)
|
||||
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)
|
||||
self._prepare_website(context)
|
||||
self.matrix = context.mx = MatrixHandler(context)
|
||||
self.manhole = None
|
||||
|
||||
end_ts = time()
|
||||
try:
|
||||
log.debug(f"Initialization complete in {round(end_ts - start_ts, 2)} seconds,"
|
||||
" running startup actions")
|
||||
start_ts = time()
|
||||
loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop))
|
||||
end_ts = time()
|
||||
log.debug(f"Startup actions complete in {round(end_ts - start_ts, 2)} seconds,"
|
||||
" now running forever")
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
log.debug("Interrupt received, stopping clients")
|
||||
loop.run_until_complete(
|
||||
asyncio.gather(*[user.stop() for user in User.by_tgid.values()], loop=loop))
|
||||
log.debug("Clients stopped, shutting down")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
log.exception("Unexpected error")
|
||||
sys.exit(1)
|
||||
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)
|
||||
|
||||
def prepare_stop(self) -> None:
|
||||
for puppet in Puppet.by_custom_mxid.values():
|
||||
puppet.stop()
|
||||
self.shutdown_actions = (user.stop() for user in User.by_tgid.values())
|
||||
if self.manhole:
|
||||
self.manhole.close()
|
||||
self.manhole = None
|
||||
|
||||
|
||||
TelegramBridge().run()
|
||||
|
||||
+166
-104
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,27 +13,33 @@
|
||||
#
|
||||
# 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 Tuple, Optional, List, Union, TYPE_CHECKING
|
||||
from typing import Tuple, Optional, Union, Dict, Type, Any, TYPE_CHECKING
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import logging
|
||||
import platform
|
||||
import time
|
||||
|
||||
from telethon.sessions import Session
|
||||
from telethon.network import (ConnectionTcpMTProxyRandomizedIntermediate, ConnectionTcpFull,
|
||||
Connection)
|
||||
from telethon.tl.patched import MessageService, Message
|
||||
from telethon.tl.types import (
|
||||
Channel, ChannelForbidden, Chat, ChatForbidden, MessageActionChannelMigrateFrom, PeerUser,
|
||||
TypeUpdate, UpdateChannelPinnedMessage, UpdateChatPinnedMessage, UpdateChatParticipantAdmin,
|
||||
UpdateChatParticipants, UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateDeleteMessages,
|
||||
UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateNewMessage,
|
||||
UpdateReadHistoryOutbox, UpdateShortChatMessage, UpdateShortMessage, UpdateUserName,
|
||||
UpdateUserPhoto, UpdateUserStatus, UpdateUserTyping, User, UserStatusOffline, UserStatusOnline)
|
||||
Channel, Chat, MessageActionChannelMigrateFrom, PeerUser, TypeUpdate, UpdateChatPinnedMessage,
|
||||
UpdateChannelPinnedMessage, UpdateChatParticipantAdmin, UpdateChatParticipants,
|
||||
UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateNewMessage, UpdateDeleteMessages,
|
||||
UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateReadHistoryOutbox,
|
||||
UpdateShortChatMessage, UpdateShortMessage, UpdateUserName, UpdateUserPhoto, UpdateUserStatus,
|
||||
UpdateUserTyping, User, UserStatusOffline, UserStatusOnline)
|
||||
|
||||
from mautrix_appservice import MatrixRequestError, AppService
|
||||
from mautrix.types import UserID, PresenceState
|
||||
from mautrix.errors import MatrixError
|
||||
from mautrix.appservice import AppService
|
||||
from alchemysession import AlchemySessionContainer
|
||||
|
||||
from . import portal as po, puppet as pu, __version__
|
||||
from .db import Message as DBMessage
|
||||
from .types import TelegramID, MatrixUserID
|
||||
from .types import TelegramID
|
||||
from .tgclient import MautrixTelegramClient
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -42,80 +47,129 @@ if TYPE_CHECKING:
|
||||
from .config import Config
|
||||
from .bot import Bot
|
||||
|
||||
config = None # type: Config
|
||||
config: Optional['Config'] = None
|
||||
# Value updated from config in init()
|
||||
MAX_DELETIONS = 10 # type: int
|
||||
MAX_DELETIONS: int = 10
|
||||
|
||||
UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
|
||||
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
|
||||
|
||||
|
||||
class AbstractUser(ABC):
|
||||
session_container = None # type: AlchemySessionContainer
|
||||
loop = None # type: asyncio.AbstractEventLoop
|
||||
log = None # type: logging.Logger
|
||||
az = None # type: AppService
|
||||
bot = None # type: Bot
|
||||
ignore_incoming_bot_events = True # type: bool
|
||||
session_container: AlchemySessionContainer = None
|
||||
loop: asyncio.AbstractEventLoop = None
|
||||
log: logging.Logger
|
||||
az: AppService
|
||||
relaybot: Optional['Bot']
|
||||
ignore_incoming_bot_events: bool = True
|
||||
|
||||
client: Optional[MautrixTelegramClient]
|
||||
mxid: Optional[UserID]
|
||||
|
||||
tgid: Optional[TelegramID]
|
||||
username: Optional['str']
|
||||
is_bot: bool
|
||||
|
||||
is_relaybot: bool
|
||||
|
||||
puppet_whitelisted: bool
|
||||
whitelisted: bool
|
||||
relaybot_whitelisted: bool
|
||||
matrix_puppet_whitelisted: bool
|
||||
is_admin: bool
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.is_admin = False # type: bool
|
||||
self.matrix_puppet_whitelisted = False # type: bool
|
||||
self.puppet_whitelisted = False # type: bool
|
||||
self.whitelisted = False # type: bool
|
||||
self.relaybot_whitelisted = False # type: bool
|
||||
self.client = None # type: MautrixTelegramClient
|
||||
self.tgid = None # type: TelegramID
|
||||
self.mxid = None # type: MatrixUserID
|
||||
self.is_relaybot = False # type: bool
|
||||
self.is_bot = False # type: bool
|
||||
self.relaybot = None # type: Optional[Bot]
|
||||
self.is_admin = False
|
||||
self.matrix_puppet_whitelisted = False
|
||||
self.puppet_whitelisted = False
|
||||
self.whitelisted = False
|
||||
self.relaybot_whitelisted = False
|
||||
self.client = None
|
||||
self.is_relaybot = False
|
||||
self.is_bot = False
|
||||
self.relaybot = None
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
return self.client and self.client.is_connected()
|
||||
|
||||
@property
|
||||
def _proxy_settings(self) -> Optional[Tuple[int, str, str, str, str, str]]:
|
||||
def _proxy_settings(self) -> Tuple[Type[Connection], Optional[Tuple[Any, ...]]]:
|
||||
proxy_type = config["telegram.proxy.type"].lower()
|
||||
connection = ConnectionTcpFull
|
||||
connection_data = (config["telegram.proxy.address"],
|
||||
config["telegram.proxy.port"],
|
||||
config["telegram.proxy.rdns"],
|
||||
config["telegram.proxy.username"],
|
||||
config["telegram.proxy.password"])
|
||||
if proxy_type == "disabled":
|
||||
return None
|
||||
connection_data = None
|
||||
elif proxy_type == "socks4":
|
||||
proxy_type = 1
|
||||
connection_data = (1,) + connection_data
|
||||
elif proxy_type == "socks5":
|
||||
proxy_type = 2
|
||||
connection_data = (2,) + connection_data
|
||||
elif proxy_type == "http":
|
||||
proxy_type = 3
|
||||
connection_data = (3,) + connection_data
|
||||
elif proxy_type == "mtproxy":
|
||||
connection = ConnectionTcpMTProxyRandomizedIntermediate
|
||||
connection_data = (connection_data[0], connection_data[1], connection_data[4])
|
||||
|
||||
return (proxy_type,
|
||||
config["telegram.proxy.address"], config["telegram.proxy.port"],
|
||||
config["telegram.proxy.rdns"],
|
||||
config["telegram.proxy.username"], config["telegram.proxy.password"])
|
||||
return connection, connection_data
|
||||
|
||||
def _init_client(self) -> None:
|
||||
self.log.debug(f"Initializing client for {self.name}")
|
||||
device = f"{platform.system()} {platform.release()}"
|
||||
sysversion = MautrixTelegramClient.__version__
|
||||
|
||||
self.session = self.session_container.new_session(self.name)
|
||||
if config["telegram.server.enabled"]:
|
||||
self.session.set_dc(config["telegram.server.dc"],
|
||||
config["telegram.server.ip"],
|
||||
config["telegram.server.port"])
|
||||
|
||||
if self.is_relaybot:
|
||||
base_logger = logging.getLogger("telethon.relaybot")
|
||||
else:
|
||||
base_logger = logging.getLogger(f"telethon.{self.tgid or -hash(self.mxid)}")
|
||||
self.client = MautrixTelegramClient(session=self.session,
|
||||
api_id=config["telegram.api_id"],
|
||||
api_hash=config["telegram.api_hash"],
|
||||
loop=self.loop,
|
||||
app_version=__version__,
|
||||
system_version=sysversion,
|
||||
device_model=device,
|
||||
timeout=120,
|
||||
base_logger=base_logger,
|
||||
proxy=self._proxy_settings)
|
||||
|
||||
device = config["telegram.device_info.device_model"]
|
||||
sysversion = config["telegram.device_info.system_version"]
|
||||
appversion = config["telegram.device_info.app_version"]
|
||||
connection, proxy = self._proxy_settings
|
||||
|
||||
assert isinstance(self.session, Session)
|
||||
|
||||
self.client = MautrixTelegramClient(
|
||||
session=self.session,
|
||||
|
||||
api_id=config["telegram.api_id"],
|
||||
api_hash=config["telegram.api_hash"],
|
||||
|
||||
app_version=__version__ if appversion == "auto" else appversion,
|
||||
system_version=(MautrixTelegramClient.__version__
|
||||
if sysversion == "auto" else sysversion),
|
||||
device_model=(f"{platform.system()} {platform.release()}"
|
||||
if device == "auto" else device),
|
||||
|
||||
timeout=config["telegram.connection.timeout"],
|
||||
connection_retries=config["telegram.connection.retries"],
|
||||
retry_delay=config["telegram.connection.retry_delay"],
|
||||
flood_sleep_threshold=config["telegram.connection.flood_sleep_threshold"],
|
||||
request_retries=config["telegram.connection.request_retries"],
|
||||
connection=connection,
|
||||
proxy=proxy,
|
||||
|
||||
loop=self.loop,
|
||||
base_logger=base_logger
|
||||
)
|
||||
self.client.add_event_handler(self._update_catch)
|
||||
|
||||
@abstractmethod
|
||||
@@ -135,20 +189,14 @@ class AbstractUser(ABC):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def _update_catch(self, update: TypeUpdate) -> None:
|
||||
start_time = time.time()
|
||||
try:
|
||||
if not await self.update(update):
|
||||
await self._update(update)
|
||||
except Exception:
|
||||
self.log.exception("Failed to handle Telegram update")
|
||||
|
||||
async def get_dialogs(self, limit: int = None) -> List[Union[Chat, Channel]]:
|
||||
if self.is_bot:
|
||||
return []
|
||||
dialogs = await self.client.get_dialogs(limit=limit)
|
||||
return [dialog.entity for dialog in dialogs if (
|
||||
not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden))
|
||||
and not (isinstance(dialog.entity, Chat)
|
||||
and (dialog.entity.deactivated or dialog.entity.left)))]
|
||||
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)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
@@ -156,7 +204,8 @@ class AbstractUser(ABC):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def is_logged_in(self) -> bool:
|
||||
return self.client and self.client.is_connected() and await self.client.is_user_authorized()
|
||||
return (self.client and self.client.is_connected()
|
||||
and await self.client.is_user_authorized())
|
||||
|
||||
async def has_full_access(self, allow_bot: bool = False) -> bool:
|
||||
return (self.puppet_whitelisted
|
||||
@@ -167,14 +216,15 @@ class AbstractUser(ABC):
|
||||
if not self.client:
|
||||
self._init_client()
|
||||
await self.client.connect()
|
||||
self.log.debug("%s connected: %s", self.mxid, self.connected)
|
||||
self.log.debug(f"{'Bot' if self.is_relaybot else self.mxid} connected: {self.connected}")
|
||||
return self
|
||||
|
||||
async def ensure_started(self, even_if_no_session=False) -> 'AbstractUser':
|
||||
if not self.puppet_whitelisted or self.connected:
|
||||
if self.connected:
|
||||
return self
|
||||
self.log.debug("ensure_started(%s, even_if_no_session=%s)", self.mxid, even_if_no_session)
|
||||
if even_if_no_session or self.session_container.has_session(self.mxid):
|
||||
self.log.debug("Starting client due to ensure_started"
|
||||
f"(even_if_no_session={even_if_no_session})")
|
||||
await self.start(delete_unless_authenticated=not even_if_no_session)
|
||||
return self
|
||||
|
||||
@@ -185,6 +235,8 @@ class AbstractUser(ABC):
|
||||
# region Telegram update handling
|
||||
|
||||
async def _update(self, update: TypeUpdate) -> None:
|
||||
asyncio.ensure_future(self._handle_entity_updates(getattr(update, "_entities", {})),
|
||||
loop=self.loop)
|
||||
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
|
||||
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
|
||||
await self.update_message(update)
|
||||
@@ -234,7 +286,7 @@ class AbstractUser(ABC):
|
||||
return
|
||||
|
||||
# We check that these are user read receipts, so tg_space is always the user ID.
|
||||
message = DBMessage.get_by_tgid(TelegramID(update.max_id), self.tgid)
|
||||
message = DBMessage.get_one_by_tgid(TelegramID(update.max_id), self.tgid, edit_index=-1)
|
||||
if not message:
|
||||
return
|
||||
|
||||
@@ -243,7 +295,7 @@ class AbstractUser(ABC):
|
||||
|
||||
async def update_admin(self, update: UpdateChatParticipantAdmin) -> None:
|
||||
# TODO duplication not checked
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
||||
if not portal or not portal.mxid:
|
||||
return
|
||||
|
||||
@@ -253,7 +305,7 @@ class AbstractUser(ABC):
|
||||
if isinstance(update, UpdateUserTyping):
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
|
||||
else:
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
||||
|
||||
if not portal or not portal.mxid:
|
||||
return
|
||||
@@ -261,6 +313,16 @@ class AbstractUser(ABC):
|
||||
sender = pu.Puppet.get(TelegramID(update.user_id))
|
||||
await portal.handle_telegram_typing(sender, update)
|
||||
|
||||
async def _handle_entity_updates(self, entities: Dict[int, Union[User, Chat, Channel]]
|
||||
) -> None:
|
||||
try:
|
||||
users = (entity for entity in entities.values() if isinstance(entity, User))
|
||||
puppets = ((pu.Puppet.get(TelegramID(user.id)), user) for user in users)
|
||||
await asyncio.gather(*[puppet.try_update_info(self, info)
|
||||
for puppet, info in puppets if puppet])
|
||||
except Exception:
|
||||
self.log.exception("Failed to handle entity updates")
|
||||
|
||||
async def update_others_info(self, update: Union[UpdateUserName, UpdateUserPhoto]) -> None:
|
||||
# TODO duplication not checked
|
||||
puppet = pu.Puppet.get(TelegramID(update.user_id))
|
||||
@@ -269,7 +331,7 @@ class AbstractUser(ABC):
|
||||
if await puppet.update_displayname(self, update):
|
||||
puppet.save()
|
||||
elif isinstance(update, UpdateUserPhoto):
|
||||
if await puppet.update_avatar(self, update.photo.photo_big):
|
||||
if await puppet.update_avatar(self, update.photo):
|
||||
puppet.save()
|
||||
else:
|
||||
self.log.warning("Unexpected other user info update: %s", update)
|
||||
@@ -277,9 +339,9 @@ class AbstractUser(ABC):
|
||||
async def update_status(self, update: UpdateUserStatus) -> None:
|
||||
puppet = pu.Puppet.get(TelegramID(update.user_id))
|
||||
if isinstance(update.status, UserStatusOnline):
|
||||
await puppet.default_mxid_intent.set_presence("online")
|
||||
await puppet.default_mxid_intent.set_presence(PresenceState.ONLINE)
|
||||
elif isinstance(update.status, UserStatusOffline):
|
||||
await puppet.default_mxid_intent.set_presence("offline")
|
||||
await puppet.default_mxid_intent.set_presence(PresenceState.OFFLINE)
|
||||
else:
|
||||
self.log.warning("Unexpected user status update: %s", update)
|
||||
return
|
||||
@@ -288,7 +350,9 @@ class AbstractUser(ABC):
|
||||
Optional[pu.Puppet],
|
||||
Optional[po.Portal]]:
|
||||
if isinstance(update, UpdateShortChatMessage):
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
||||
if not portal:
|
||||
self.log.warning(f"Received message in chat with unknown type {update.chat_id}")
|
||||
sender = pu.Puppet.get(TelegramID(update.from_id))
|
||||
elif isinstance(update, UpdateShortMessage):
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
|
||||
@@ -309,52 +373,56 @@ class AbstractUser(ABC):
|
||||
return update, sender, portal
|
||||
|
||||
@staticmethod
|
||||
async def _try_redact(portal: po.Portal, message: DBMessage) -> None:
|
||||
async def _try_redact(message: DBMessage) -> None:
|
||||
portal = po.Portal.get_by_mxid(message.mx_room)
|
||||
if not portal:
|
||||
return
|
||||
try:
|
||||
await portal.main_intent.redact(message.mx_room, message.mxid)
|
||||
except MatrixRequestError:
|
||||
except MatrixError:
|
||||
pass
|
||||
|
||||
async def delete_message(self, update: UpdateDeleteMessages) -> None:
|
||||
if len(update.messages) > MAX_DELETIONS:
|
||||
return
|
||||
|
||||
for message in update.messages:
|
||||
message = DBMessage.get_by_tgid(TelegramID(message), self.tgid)
|
||||
if not message:
|
||||
continue
|
||||
message.delete()
|
||||
number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
|
||||
if number_left == 0:
|
||||
portal = po.Portal.get_by_mxid(message.mx_room)
|
||||
await self._try_redact(portal, message)
|
||||
for message_id in update.messages:
|
||||
for message in DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid):
|
||||
message.delete()
|
||||
number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
|
||||
if number_left == 0:
|
||||
await self._try_redact(message)
|
||||
|
||||
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
|
||||
if len(update.messages) > MAX_DELETIONS:
|
||||
return
|
||||
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
||||
if not portal:
|
||||
return
|
||||
channel_id = TelegramID(update.channel_id)
|
||||
|
||||
for message in update.messages:
|
||||
message = DBMessage.get_by_tgid(TelegramID(message), portal.tgid)
|
||||
if not message:
|
||||
continue
|
||||
message.delete()
|
||||
await self._try_redact(portal, message)
|
||||
for message_id in update.messages:
|
||||
for message in DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id):
|
||||
message.delete()
|
||||
await self._try_redact(message)
|
||||
|
||||
async def update_message(self, original_update: UpdateMessage) -> None:
|
||||
update, sender, portal = self.get_message_details(original_update)
|
||||
|
||||
if self.is_bot and not portal.mxid:
|
||||
self.log.debug(f"Ignoring message received by bot in unbridged chat %s",
|
||||
portal.tgid_log)
|
||||
if not portal:
|
||||
return
|
||||
elif portal and not portal.allow_bridging:
|
||||
self.log.debug(f"Ignoring message in portal {portal.tgid_log} (bridging disallowed)")
|
||||
return
|
||||
|
||||
if self.ignore_incoming_bot_events and self.bot and sender.id == self.bot.tgid:
|
||||
if self.is_relaybot:
|
||||
if update.is_private:
|
||||
if not config["bridge.relaybot.private_chat.invite"]:
|
||||
self.log.debug(f"Ignoring private message to bot from {sender.id}")
|
||||
return
|
||||
elif not portal.mxid and config["bridge.relaybot.ignore_unbridged_group_chat"]:
|
||||
self.log.debug("Ignoring message received by bot"
|
||||
f" in unbridged chat {portal.tgid_log}")
|
||||
return
|
||||
|
||||
if self.ignore_incoming_bot_events and self.relaybot and sender.id == self.relaybot.tgid:
|
||||
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update, portal.tgid_log)
|
||||
return
|
||||
|
||||
@@ -368,20 +436,14 @@ class AbstractUser(ABC):
|
||||
sender.id)
|
||||
return await portal.handle_telegram_action(self, sender, update)
|
||||
|
||||
user = sender.tgid if sender else "admin"
|
||||
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
|
||||
if config["bridge.edits_as_replies"]:
|
||||
self.log.debug("Handling edit %s to %s by %s", update, portal.tgid_log, user)
|
||||
return await portal.handle_telegram_edit(self, sender, update)
|
||||
return
|
||||
|
||||
self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
|
||||
return await portal.handle_telegram_edit(self, sender, update)
|
||||
return await portal.handle_telegram_message(self, sender, update)
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
def init(context: "Context") -> None:
|
||||
def init(context: 'Context') -> None:
|
||||
global config, MAX_DELETIONS
|
||||
AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core
|
||||
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
|
||||
|
||||
+68
-48
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,21 +13,21 @@
|
||||
#
|
||||
# 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, Callable, Dict, List, Optional, Pattern, TYPE_CHECKING
|
||||
from typing import Awaitable, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
import logging
|
||||
import re
|
||||
|
||||
from telethon.tl.patched import Message, MessageService
|
||||
from telethon.tl.types import (
|
||||
ChannelParticipantAdmin, ChannelParticipantCreator, ChatForbidden, ChatParticipantAdmin,
|
||||
ChatParticipantCreator, InputChannel, InputUser, MessageActionChatAddUser,
|
||||
ChatParticipantCreator, InputChannel, InputUser, MessageActionChatAddUser, PeerUser,
|
||||
MessageActionChatDeleteUser, MessageEntityBotCommand, PeerChannel, PeerChat, TypePeer,
|
||||
UpdateNewChannelMessage, UpdateNewMessage, MessageActionChatMigrateTo)
|
||||
UpdateNewChannelMessage, UpdateNewMessage, MessageActionChatMigrateTo, User)
|
||||
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
|
||||
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
|
||||
from telethon.errors import ChannelInvalidError, ChannelPrivateError
|
||||
|
||||
from .types import MatrixUserID
|
||||
from mautrix.types import UserID
|
||||
|
||||
from .abstract_user import AbstractUser
|
||||
from .db import BotChat
|
||||
from .types import TelegramID
|
||||
@@ -36,30 +35,45 @@ from . import puppet as pu, portal as po, user as u
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import Config
|
||||
from .context import Context
|
||||
|
||||
config = None # type: Config
|
||||
config: Optional['Config'] = None
|
||||
|
||||
ReplyFunc = Callable[[str], Awaitable[Message]]
|
||||
|
||||
|
||||
class Bot(AbstractUser):
|
||||
log = logging.getLogger("mau.bot") # type: logging.Logger
|
||||
mxid_regex = re.compile("@.+:.+") # type: Pattern
|
||||
log: logging.Logger = logging.getLogger("mau.user.bot")
|
||||
|
||||
token: str
|
||||
chats: Dict[int, str]
|
||||
tg_whitelist: List[int]
|
||||
whitelist_group_admins: bool
|
||||
_me_info: Optional[User]
|
||||
_me_mxid: Optional[UserID]
|
||||
|
||||
def __init__(self, token: str) -> None:
|
||||
super().__init__()
|
||||
self.token = token # type: str
|
||||
self.puppet_whitelisted = True # type: bool
|
||||
self.whitelisted = True # type: bool
|
||||
self.relaybot_whitelisted = True # type: bool
|
||||
self.username = None # type: str
|
||||
self.is_relaybot = True # type: bool
|
||||
self.is_bot = True # type: bool
|
||||
self.chats = {} # type: Dict[int, str]
|
||||
self.tg_whitelist = [] # type: List[int]
|
||||
self.token = token
|
||||
self.tgid = None
|
||||
self.mxid = None
|
||||
self.puppet_whitelisted = True
|
||||
self.whitelisted = True
|
||||
self.relaybot_whitelisted = True
|
||||
self.username = None
|
||||
self.is_relaybot = True
|
||||
self.is_bot = True
|
||||
self.chats = {}
|
||||
self.tg_whitelist = []
|
||||
self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"]
|
||||
or False) # type: bool
|
||||
or False)
|
||||
self._me_info = None
|
||||
self._me_mxid = None
|
||||
|
||||
async def get_me(self, use_cache: bool = True) -> Tuple[User, UserID]:
|
||||
if not use_cache or not self._me_mxid:
|
||||
self._me_info = await self.client.get_me()
|
||||
self._me_mxid = pu.Puppet.get_mxid_from_id(TelegramID(self._me_info.id))
|
||||
return self._me_info, self._me_mxid
|
||||
|
||||
async def init_permissions(self) -> None:
|
||||
whitelist = config["bridge.relaybot.whitelist"] or []
|
||||
@@ -84,7 +98,7 @@ class Bot(AbstractUser):
|
||||
async def post_login(self) -> None:
|
||||
await self.init_permissions()
|
||||
info = await self.client.get_me()
|
||||
self.tgid = info.id
|
||||
self.tgid = TelegramID(info.id)
|
||||
self.username = info.username
|
||||
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
|
||||
|
||||
@@ -94,21 +108,15 @@ class Bot(AbstractUser):
|
||||
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
|
||||
self.remove_chat(TelegramID(chat.id))
|
||||
|
||||
channel_ids = [InputChannel(chat_id, 0)
|
||||
channel_ids = (InputChannel(chat_id, 0)
|
||||
for chat_id, chat_type in self.chats.items()
|
||||
if chat_type == "channel"]
|
||||
if chat_type == "channel")
|
||||
for channel_id in channel_ids:
|
||||
try:
|
||||
await self.client(GetChannelsRequest([channel_id]))
|
||||
except (ChannelPrivateError, ChannelInvalidError):
|
||||
self.remove_chat(TelegramID(channel_id.channel_id))
|
||||
|
||||
if config["bridge.catch_up"]:
|
||||
try:
|
||||
await self.client.catch_up()
|
||||
except Exception:
|
||||
self.log.exception("Failed to run catch_up() for bot")
|
||||
|
||||
def register_portal(self, portal: po.Portal) -> None:
|
||||
self.add_chat(portal.tgid, portal.peer_type)
|
||||
|
||||
@@ -125,7 +133,7 @@ class Bot(AbstractUser):
|
||||
del self.chats[chat_id]
|
||||
except KeyError:
|
||||
pass
|
||||
BotChat.delete(chat_id)
|
||||
BotChat.delete_by_id(chat_id)
|
||||
|
||||
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
|
||||
if tgid in self.tg_whitelist:
|
||||
@@ -149,7 +157,7 @@ class Bot(AbstractUser):
|
||||
return False
|
||||
|
||||
async def check_can_use_commands(self, event: Message, reply: ReplyFunc) -> bool:
|
||||
if not await self._can_use_commands(event.to_id, event.from_id):
|
||||
if not await self._can_use_commands(event.to_id, TelegramID(event.from_id)):
|
||||
await reply("You do not have the permission to use that command.")
|
||||
return False
|
||||
return True
|
||||
@@ -158,7 +166,7 @@ class Bot(AbstractUser):
|
||||
if not config["bridge.relaybot.authless_portals"]:
|
||||
return await reply("This bridge doesn't allow portal creation from Telegram.")
|
||||
|
||||
if not portal.allow_bridging():
|
||||
if not portal.allow_bridging:
|
||||
return await reply("This bridge doesn't allow bridging this chat.")
|
||||
|
||||
await portal.create_matrix_room(self)
|
||||
@@ -171,15 +179,15 @@ class Bot(AbstractUser):
|
||||
"Portal is not public. Use `/invite <mxid>` to get an invite.")
|
||||
|
||||
async def handle_command_invite(self, portal: po.Portal, reply: ReplyFunc,
|
||||
mxid_input: MatrixUserID) -> Message:
|
||||
mxid_input: UserID) -> Message:
|
||||
if len(mxid_input) == 0:
|
||||
return await reply("Usage: `/invite <mxid>`")
|
||||
elif not portal.mxid:
|
||||
return await reply("Portal does not have Matrix room. "
|
||||
"Create one with /portal first.")
|
||||
if not self.mxid_regex.match(mxid_input):
|
||||
if mxid_input[0] != '@' or mxid_input.find(':') < 2:
|
||||
return await reply("That doesn't look like a Matrix ID.")
|
||||
user = await u.User.get_by_mxid(MatrixUserID(mxid_input)).ensure_started()
|
||||
user = await u.User.get_by_mxid(mxid_input).ensure_started()
|
||||
if not user.relaybot_whitelisted:
|
||||
return await reply("That user is not whitelisted to use the bridge.")
|
||||
elif await user.is_logged_in():
|
||||
@@ -187,7 +195,7 @@ class Bot(AbstractUser):
|
||||
return await reply("That user seems to be logged in. "
|
||||
f"Just invite [{displayname}](tg://user?id={user.tgid})")
|
||||
else:
|
||||
await portal.main_intent.invite(portal.mxid, user.mxid)
|
||||
await portal.main_intent.invite_user(portal.mxid, user.mxid)
|
||||
return await reply(f"Invited `{user.mxid}` to the portal.")
|
||||
|
||||
@staticmethod
|
||||
@@ -196,7 +204,12 @@ class Bot(AbstractUser):
|
||||
# chat is a normal group or a supergroup/channel when using the ID.
|
||||
if isinstance(message.to_id, PeerChannel):
|
||||
return reply(f"-100{message.to_id.channel_id}")
|
||||
return reply(str(-message.to_id.chat_id))
|
||||
elif isinstance(message.to_id, PeerChat):
|
||||
return reply(str(-message.to_id.chat_id))
|
||||
elif isinstance(message.to_id, PeerUser):
|
||||
return reply(f"Your user ID is {message.from_id}.")
|
||||
else:
|
||||
return reply("Failed to find chat ID.")
|
||||
|
||||
def match_command(self, text: str, command: str) -> bool:
|
||||
text = text.lower()
|
||||
@@ -213,15 +226,23 @@ class Bot(AbstractUser):
|
||||
|
||||
return False
|
||||
|
||||
async def handle_command(self, message: Message) -> None:
|
||||
async def handle_command(self, message: Message) -> Optional[bool]:
|
||||
def reply(reply_text: str) -> Awaitable[Message]:
|
||||
return self.client.send_message(message.to_id, reply_text, reply_to=message.id)
|
||||
return self.client.send_message(message.chat_id, reply_text, reply_to=message.id)
|
||||
|
||||
text = message.message
|
||||
|
||||
if self.match_command(text, "id"):
|
||||
if self.match_command(text, "start"):
|
||||
pcm = config["bridge.relaybot.private_chat.message"]
|
||||
if not pcm:
|
||||
return True
|
||||
await reply(pcm)
|
||||
return
|
||||
elif self.match_command(text, "id"):
|
||||
await self.handle_command_id(message, reply)
|
||||
return
|
||||
elif message.is_private:
|
||||
return
|
||||
|
||||
portal = po.Portal.get_by_entity(message.to_id)
|
||||
|
||||
@@ -236,15 +257,15 @@ class Bot(AbstractUser):
|
||||
mxid = text[text.index(" ") + 1:]
|
||||
except ValueError:
|
||||
mxid = ""
|
||||
await self.handle_command_invite(portal, reply, mxid_input=mxid)
|
||||
await self.handle_command_invite(portal, reply, mxid_input=UserID(mxid))
|
||||
|
||||
def handle_service_message(self, message: MessageService) -> None:
|
||||
to_id = message.to_id # type: TelegramID
|
||||
if isinstance(to_id, PeerChannel):
|
||||
to_id = to_id.channel_id
|
||||
to_peer = message.to_id
|
||||
if isinstance(to_peer, PeerChannel):
|
||||
to_id = TelegramID(to_peer.channel_id)
|
||||
chat_type = "channel"
|
||||
elif isinstance(to_id, PeerChat):
|
||||
to_id = to_id.chat_id
|
||||
elif isinstance(to_peer, PeerChat):
|
||||
to_id = TelegramID(to_peer.chat_id)
|
||||
chat_type = "chat"
|
||||
else:
|
||||
return
|
||||
@@ -269,8 +290,7 @@ class Bot(AbstractUser):
|
||||
and update.message.entities and len(update.message.entities) > 0
|
||||
and isinstance(update.message.entities[0], MessageEntityBotCommand))
|
||||
if is_command:
|
||||
await self.handle_command(update.message)
|
||||
return True
|
||||
return not await self.handle_command(update.message)
|
||||
return False
|
||||
|
||||
def is_in_chat(self, peer_id) -> bool:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from .handler import (command_handler, command_handlers as _command_handlers,
|
||||
CommandHandler, CommandProcessor, CommandEvent,
|
||||
SECTION_GENERAL, SECTION_AUTH, SECTION_CREATING_PORTALS,
|
||||
SECTION_PORTAL_MANAGEMENT, SECTION_MISC, SECTION_ADMIN)
|
||||
from . import portal, telegram, clean_rooms, matrix_auth, meta
|
||||
from .handler import (command_handler, CommandHandler, CommandProcessor, CommandEvent,
|
||||
SECTION_AUTH, SECTION_CREATING_PORTALS, SECTION_PORTAL_MANAGEMENT,
|
||||
SECTION_MISC, SECTION_ADMIN)
|
||||
from . import portal, telegram, clean_rooms, matrix_auth, manhole
|
||||
|
||||
__all__ = ["command_handler", "CommandHandler", "CommandProcessor", "CommandEvent",
|
||||
"SECTION_AUTH", "SECTION_MISC", "SECTION_ADMIN", "SECTION_CREATING_PORTALS",
|
||||
"SECTION_PORTAL_MANAGEMENT"]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,41 +13,41 @@
|
||||
#
|
||||
# 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, List, NewType, Optional, Tuple, Union
|
||||
from typing import List, NamedTuple, Tuple, Union
|
||||
|
||||
from mautrix_appservice import MatrixRequestError, IntentAPI
|
||||
from mautrix.appservice import IntentAPI
|
||||
from mautrix.errors import MatrixRequestError
|
||||
from mautrix.types import RoomID, UserID, EventID
|
||||
|
||||
from ..types import MatrixRoomID, MatrixUserID
|
||||
from . import command_handler, CommandEvent, SECTION_ADMIN
|
||||
from .. import puppet as pu, portal as po
|
||||
|
||||
ManagementRoom = NewType('ManagementRoom', Tuple[MatrixRoomID, MatrixUserID])
|
||||
ManagementRoom = NamedTuple('ManagementRoom', room_id=RoomID, user_id=UserID)
|
||||
|
||||
|
||||
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[MatrixRoomID],
|
||||
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[RoomID],
|
||||
List['po.Portal'], List['po.Portal']]:
|
||||
management_rooms = [] # type: List[ManagementRoom]
|
||||
unidentified_rooms = [] # type: List[MatrixRoomID]
|
||||
portals = [] # type: List[po.Portal]
|
||||
empty_portals = [] # type: List[po.Portal]
|
||||
management_rooms: List[ManagementRoom] = []
|
||||
unidentified_rooms: List[RoomID] = []
|
||||
portals: List[po.Portal] = []
|
||||
empty_portals: List[po.Portal] = []
|
||||
|
||||
rooms = await intent.get_joined_rooms()
|
||||
for room_str in rooms:
|
||||
room = MatrixRoomID(room_str)
|
||||
portal = po.Portal.get_by_mxid(room)
|
||||
for room_id in rooms:
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
if not portal:
|
||||
try:
|
||||
members = await intent.get_room_members(room)
|
||||
members = await intent.get_room_members(room_id)
|
||||
except MatrixRequestError:
|
||||
members = []
|
||||
if len(members) == 2:
|
||||
other_member = MatrixUserID(members[0] if members[0] != intent.mxid else members[1])
|
||||
other_member = members[0] if members[0] != intent.mxid else members[1]
|
||||
if pu.Puppet.get_id_from_mxid(other_member):
|
||||
unidentified_rooms.append(room)
|
||||
unidentified_rooms.append(room_id)
|
||||
else:
|
||||
management_rooms.append(ManagementRoom((room, other_member)))
|
||||
management_rooms.append(ManagementRoom(room_id, other_member))
|
||||
else:
|
||||
unidentified_rooms.append(room)
|
||||
unidentified_rooms.append(room_id)
|
||||
else:
|
||||
members = await portal.get_authenticated_matrix_users()
|
||||
if len(members) == 0:
|
||||
@@ -62,7 +61,7 @@ async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[Mat
|
||||
@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) -> Optional[Dict]:
|
||||
async def clean_rooms(evt: CommandEvent) -> EventID:
|
||||
management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent)
|
||||
|
||||
reply = ["#### Management rooms (M)"]
|
||||
@@ -108,10 +107,10 @@ async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
|
||||
|
||||
|
||||
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
|
||||
unidentified_rooms: List[MatrixRoomID], portals: List["po.Portal"],
|
||||
unidentified_rooms: List[RoomID], portals: List["po.Portal"],
|
||||
empty_portals: List["po.Portal"]) -> None:
|
||||
command = evt.args[0]
|
||||
rooms_to_clean = [] # type: List[Union[po.Portal, MatrixRoomID]]
|
||||
rooms_to_clean: List[Union[po.Portal, RoomID]] = []
|
||||
if command == "clean-recommended":
|
||||
rooms_to_clean += empty_portals
|
||||
rooms_to_clean += unidentified_rooms
|
||||
@@ -156,11 +155,11 @@ async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
|
||||
"next": lambda confirm: execute_room_cleanup(confirm, rooms_to_clean),
|
||||
"action": "Room cleaning",
|
||||
}
|
||||
await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type"
|
||||
await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type "
|
||||
"`$cmdprefix+sp confirm-clean`.")
|
||||
|
||||
|
||||
async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, MatrixRoomID]]) -> None:
|
||||
async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, RoomID]]) -> None:
|
||||
if len(evt.args) > 0 and evt.args[0] == "confirm-clean":
|
||||
await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. "
|
||||
"This might take a while.")
|
||||
@@ -169,8 +168,8 @@ async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, Matrix
|
||||
if isinstance(room, po.Portal):
|
||||
await room.cleanup_and_delete()
|
||||
cleaned += 1
|
||||
elif isinstance(room, str): # str is aliased by MatrixRoomID
|
||||
await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted")
|
||||
else:
|
||||
await po.Portal.cleanup_room(evt.az.intent, room, "Room deleted")
|
||||
cleaned += 1
|
||||
evt.sender.command_status = None
|
||||
await evt.reply(f"{cleaned} rooms cleaned up successfully.")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -15,23 +14,23 @@
|
||||
# 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/>.
|
||||
"""This module contains classes handling commands issued by Matrix users."""
|
||||
from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
import commonmark
|
||||
from typing import Awaitable, Callable, List, Optional, NamedTuple, Any
|
||||
|
||||
from telethon.errors import FloodWaitError
|
||||
|
||||
from ..types import MatrixRoomID, MatrixEventID
|
||||
from mautrix.types import RoomID, EventID, MessageEventContent
|
||||
from mautrix.bridge.commands import (HelpSection, CommandEvent as BaseCommandEvent,
|
||||
CommandHandler as BaseCommandHandler,
|
||||
CommandProcessor as BaseCommandProcessor,
|
||||
CommandHandlerFunc, command_handler as base_command_handler)
|
||||
|
||||
from ..util import format_duration
|
||||
from .. import user as u, context as c
|
||||
|
||||
command_handlers = {} # type: Dict[str, CommandHandler]
|
||||
HelpCacheKey = NamedTuple('HelpCacheKey',
|
||||
is_management=bool, is_portal=bool, puppet_whitelisted=bool,
|
||||
matrix_puppet_whitelisted=bool, is_admin=bool, is_logged_in=bool)
|
||||
|
||||
HelpSection = NamedTuple('HelpSection', [('name', str), ('order', int), ('description', str)])
|
||||
|
||||
SECTION_GENERAL = HelpSection("General", 0, "")
|
||||
SECTION_AUTH = HelpSection("Authentication", 10, "")
|
||||
SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "")
|
||||
SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "")
|
||||
@@ -39,186 +38,47 @@ SECTION_MISC = HelpSection("Miscellaneous", 40, "")
|
||||
SECTION_ADMIN = HelpSection("Administration", 50, "")
|
||||
|
||||
|
||||
class HtmlEscapingRenderer(commonmark.HtmlRenderer):
|
||||
def __init__(self, allow_html: bool = False):
|
||||
super().__init__()
|
||||
self.allow_html = allow_html
|
||||
class CommandEvent(BaseCommandEvent):
|
||||
sender: u.User
|
||||
|
||||
def lit(self, s):
|
||||
if self.allow_html:
|
||||
return super().lit(s)
|
||||
return super().lit(s.replace("<", "<").replace(">", ">"))
|
||||
|
||||
def image(self, node, entering):
|
||||
prev = self.allow_html
|
||||
self.allow_html = True
|
||||
super().image(node, entering)
|
||||
self.allow_html = prev
|
||||
|
||||
|
||||
md_parser = commonmark.Parser()
|
||||
md_renderer = HtmlEscapingRenderer()
|
||||
|
||||
|
||||
def ensure_trailing_newline(s: str) -> str:
|
||||
"""Returns the passed string, but with a guaranteed trailing newline."""
|
||||
return s + ("" if s[-1] == "\n" else "\n")
|
||||
|
||||
|
||||
class CommandEvent:
|
||||
"""Holds information about a command issued in a Matrix room.
|
||||
|
||||
When a Matrix command was issued to the bot, CommandEvent will hold
|
||||
information regarding the event.
|
||||
|
||||
Attributes:
|
||||
room_id: The id of the Matrix room in which the command was issued.
|
||||
event_id: The id of the matrix event which contained the command.
|
||||
sender: The user who issued the command.
|
||||
command: The issued command.
|
||||
args: Arguments given with the issued command.
|
||||
is_management: Determines whether the room in which the command wa
|
||||
issued is a management room.
|
||||
is_portal: Determines whether the room in which the command was issued
|
||||
is a portal.
|
||||
"""
|
||||
|
||||
def __init__(self, processor: 'CommandProcessor', room: MatrixRoomID, event: MatrixEventID,
|
||||
sender: u.User, command: str, args: List[str], is_management: bool,
|
||||
is_portal: bool) -> None:
|
||||
self.az = processor.az
|
||||
self.log = processor.log
|
||||
self.loop = processor.loop
|
||||
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:
|
||||
super().__init__(processor, room_id, event_id, sender, command, args, content,
|
||||
is_management, is_portal)
|
||||
self.bridge = processor.bridge
|
||||
self.tgbot = processor.tgbot
|
||||
self.config = processor.config
|
||||
self.public_website = processor.public_website
|
||||
self.command_prefix = processor.command_prefix
|
||||
self.room_id = room
|
||||
self.event_id = event
|
||||
self.sender = sender
|
||||
self.command = command
|
||||
self.args = args
|
||||
self.is_management = is_management
|
||||
self.is_portal = is_portal
|
||||
|
||||
def reply(self, message: str, allow_html: bool = False, render_markdown: bool = True
|
||||
) -> Awaitable[Dict]:
|
||||
"""Write a reply to the room in which the command was issued.
|
||||
@property
|
||||
def print_error_traceback(self) -> bool:
|
||||
return self.sender.is_admin
|
||||
|
||||
Replaces occurences of "$cmdprefix" in the message with the command
|
||||
prefix and replaces occurences of "$cmdprefix+sp " with the command
|
||||
prefix if the command was not issued in a management room.
|
||||
If allow_html and render_markdown are both False, the message will not
|
||||
be rendered to html and sending of html is disabled.
|
||||
|
||||
Args:
|
||||
message: The message to post in the room.
|
||||
allow_html: Escape html in the message or don't render html at all
|
||||
if markdown is disabled.
|
||||
render_markdown: Use markdown formatting to render the passed
|
||||
message to html.
|
||||
|
||||
Returns:
|
||||
Handler for the message sending function.
|
||||
"""
|
||||
message_cmd = self._replace_command_prefix(message)
|
||||
html = self._render_message(message_cmd, allow_html=allow_html,
|
||||
render_markdown=render_markdown)
|
||||
|
||||
return self.az.intent.send_notice(self.room_id, message_cmd, html=html)
|
||||
|
||||
def mark_read(self) -> Awaitable[Dict]:
|
||||
"""Marks the command as read by the bot."""
|
||||
return self.az.intent.mark_read(self.room_id, self.event_id)
|
||||
|
||||
def _replace_command_prefix(self, message: str) -> str:
|
||||
"""Returns the string with the proper command prefix entered."""
|
||||
message = message.replace(
|
||||
"$cmdprefix+sp ", "" if self.is_management else f"{self.command_prefix} "
|
||||
)
|
||||
return message.replace("$cmdprefix", self.command_prefix)
|
||||
|
||||
@staticmethod
|
||||
def _render_message(message: str, allow_html: bool, render_markdown: bool) -> Optional[str]:
|
||||
"""Renders the message as HTML.
|
||||
|
||||
Args:
|
||||
allow_html: Flag to allow custom HTML in the message.
|
||||
render_markdown: If true, markdown styling is applied to the message.
|
||||
|
||||
Returns:
|
||||
The message rendered as HTML.
|
||||
None is returned if no styled output is required.
|
||||
"""
|
||||
html = ""
|
||||
if render_markdown:
|
||||
md_renderer.allow_html = allow_html
|
||||
html = md_renderer.render(md_parser.parse(message))
|
||||
elif allow_html:
|
||||
html = message
|
||||
return ensure_trailing_newline(html) if html else None
|
||||
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())
|
||||
|
||||
|
||||
class CommandHandler:
|
||||
"""A command which can be executed from a Matrix room.
|
||||
class CommandHandler(BaseCommandHandler):
|
||||
name: str
|
||||
|
||||
The command manages its permission and help texts.
|
||||
When called, it will check the permission of the command event and execute
|
||||
the command or, in case of error, report back to the user.
|
||||
management_only: bool
|
||||
needs_auth: bool
|
||||
needs_puppeting: bool
|
||||
needs_matrix_puppeting: bool
|
||||
needs_admin: bool
|
||||
|
||||
Attributes:
|
||||
needs_auth: Flag indicating if the sender is required to be logged in.
|
||||
needs_puppeting: Flag indicating if the sender is required to use
|
||||
Telegram puppeteering for this command.
|
||||
needs_matrix_puppeting: Flag indicating if the sender is required to use
|
||||
Matrix pupeteering.
|
||||
needs_admin: Flag for whether only admin users can issue this command.
|
||||
management_only: Whether the command can exclusively be issued in a
|
||||
management room.
|
||||
name: The name of this command.
|
||||
help_section: Section of the help in which this command will appear.
|
||||
"""
|
||||
|
||||
def __init__(self, handler: Callable[[CommandEvent], Awaitable[Dict]], 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,
|
||||
help_section: HelpSection) -> None:
|
||||
"""
|
||||
Args:
|
||||
handler: The function handling the execution of this command.
|
||||
needs_auth: Flag indicating if the sender is required to be logged in.
|
||||
needs_puppeting: Flag indicating if the sender is required to use
|
||||
Telegram puppeteering for this command.
|
||||
needs_matrix_puppeting: Flag indicating if the sender is required to
|
||||
use Matrix pupeteering.
|
||||
needs_admin: Flag for whether only admin users can issue this command.
|
||||
management_only: Whether the command can exclusively be issued
|
||||
in a management room.
|
||||
name: The name of this command.
|
||||
help_text: The text displayed in the help for this command.
|
||||
help_args: Help text for the arguments of this command.
|
||||
help_section: Section of the help in which this command will appear.
|
||||
"""
|
||||
self._handler = handler
|
||||
self.needs_auth = needs_auth
|
||||
self.needs_puppeting = needs_puppeting
|
||||
self.needs_matrix_puppeting = needs_matrix_puppeting
|
||||
self.needs_admin = needs_admin
|
||||
self.management_only = management_only
|
||||
self.name = name
|
||||
self._help_text = help_text
|
||||
self._help_args = help_args
|
||||
self.help_section = help_section
|
||||
help_section: HelpSection, needs_auth: bool, needs_puppeting: bool,
|
||||
needs_matrix_puppeting: bool, needs_admin: bool) -> None:
|
||||
super().__init__(handler, management_only, name, help_text, help_args, help_section,
|
||||
needs_auth=needs_auth, needs_puppeting=needs_puppeting,
|
||||
needs_matrix_puppeting=needs_matrix_puppeting, needs_admin=needs_admin)
|
||||
|
||||
async def get_permission_error(self, evt: CommandEvent) -> Optional[str]:
|
||||
"""Returns the reason why the command could not be issued.
|
||||
|
||||
Args:
|
||||
evt: The event for which to get the error information.
|
||||
|
||||
Returns:
|
||||
A string describing the error or None if there was no error.
|
||||
"""
|
||||
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.")
|
||||
@@ -232,134 +92,41 @@ class CommandHandler:
|
||||
return "This command requires you to be logged in."
|
||||
return None
|
||||
|
||||
def has_permission(self, is_management: bool, puppet_whitelisted: bool,
|
||||
matrix_puppet_whitelisted: bool, is_admin: bool, is_logged_in: bool) -> bool:
|
||||
"""Checks the permission for this command with the given status.
|
||||
|
||||
Args:
|
||||
is_management: If the room in which the command will be issued is a
|
||||
management room.
|
||||
puppet_whitelisted: If the connected Telegram account puppet is
|
||||
allowed to issue the command.
|
||||
matrix_puppet_whitelisted: If the connected Matrix account puppet is
|
||||
allowed to issue the command.
|
||||
is_admin: If the issuing user is an admin.
|
||||
is_logged_in: If the issuing user is logged in.
|
||||
|
||||
Returns:
|
||||
True if a user with the given state is allowed to issue the
|
||||
command.
|
||||
"""
|
||||
return ((not self.management_only or is_management) and
|
||||
(not self.needs_puppeting or puppet_whitelisted) and
|
||||
(not self.needs_matrix_puppeting or matrix_puppet_whitelisted) and
|
||||
(not self.needs_admin or is_admin) and
|
||||
(not self.needs_auth or is_logged_in))
|
||||
|
||||
async def __call__(self, evt: CommandEvent) -> Dict:
|
||||
"""Executes the command if evt was issued with proper rights.
|
||||
|
||||
Args:
|
||||
evt: The CommandEvent for which to check permissions.
|
||||
|
||||
Returns:
|
||||
The result of the command or the error message function.
|
||||
|
||||
Raises:
|
||||
FloodWaitError
|
||||
"""
|
||||
error = await self.get_permission_error(evt)
|
||||
if error is not None:
|
||||
return await evt.reply(error)
|
||||
return await self._handler(evt)
|
||||
|
||||
@property
|
||||
def has_help(self) -> bool:
|
||||
"""Returns true if this command has a help text."""
|
||||
return bool(self.help_section) and bool(self._help_text)
|
||||
|
||||
@property
|
||||
def help(self) -> str:
|
||||
"""Returns the help text to this command."""
|
||||
return f"**{self.name}** {self._help_args} - {self._help_text}"
|
||||
def has_permission(self, key: HelpCacheKey) -> bool:
|
||||
return ((not self.management_only or key.is_management) and
|
||||
(not self.needs_puppeting or key.puppet_whitelisted) and
|
||||
(not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted) and
|
||||
(not self.needs_admin or key.is_admin) and
|
||||
(not self.needs_auth or key.is_logged_in))
|
||||
|
||||
|
||||
def command_handler(_func: Optional[Callable[[CommandEvent], Awaitable[Dict]]] = None, *,
|
||||
needs_auth: bool = True, needs_puppeting: bool = True,
|
||||
needs_matrix_puppeting: bool = False, needs_admin: bool = False,
|
||||
management_only: bool = False, name: Optional[str] = None,
|
||||
help_text: str = "", help_args: str = "", help_section: HelpSection = None
|
||||
) -> Callable[[Callable[[CommandEvent], Awaitable[Optional[Dict]]]],
|
||||
CommandHandler]:
|
||||
def decorator(func: Callable[[CommandEvent], Awaitable[Optional[Dict]]]) -> CommandHandler:
|
||||
actual_name = name or func.__name__.replace("_", "-")
|
||||
handler = CommandHandler(func, needs_auth, needs_puppeting, needs_matrix_puppeting,
|
||||
needs_admin, management_only, actual_name, help_text, help_args,
|
||||
help_section)
|
||||
command_handlers[handler.name] = handler
|
||||
return handler
|
||||
|
||||
return decorator if _func is None else decorator(_func)
|
||||
def command_handler(_func: Optional[CommandHandlerFunc] = None, *, needs_auth: bool = True,
|
||||
needs_puppeting: bool = True, needs_matrix_puppeting: bool = False,
|
||||
needs_admin: bool = False, management_only: bool = False,
|
||||
name: Optional[str] = None, help_text: str = "", help_args: str = "",
|
||||
help_section: HelpSection = None) -> Callable[[CommandHandlerFunc],
|
||||
CommandHandler]:
|
||||
return base_command_handler(
|
||||
_func, _handler_class=CommandHandler, name=name, help_text=help_text, help_args=help_args,
|
||||
help_section=help_section, management_only=management_only, needs_auth=needs_auth,
|
||||
needs_admin=needs_admin, needs_puppeting=needs_puppeting,
|
||||
needs_matrix_puppeting=needs_matrix_puppeting)
|
||||
|
||||
|
||||
class CommandProcessor:
|
||||
"""Handles the raw commands issued by a user to the Matrix bot."""
|
||||
log = logging.getLogger("mau.commands")
|
||||
|
||||
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)
|
||||
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"]
|
||||
|
||||
async def handle(self, room: MatrixRoomID, event_id: MatrixEventID, sender: u.User,
|
||||
command: str, args: List[str], is_management: bool, is_portal: bool
|
||||
) -> Optional[Dict]:
|
||||
"""Handles the raw commands issued by a user to the Matrix bot.
|
||||
|
||||
If the command is not known, it might be a followup command and is
|
||||
delegated to a command handler registered for that purpose in the
|
||||
senders command_status as "next".
|
||||
|
||||
Args:
|
||||
room: ID of the Matrix room in which the command was issued.
|
||||
event_id: ID of the event by which the command was issued.
|
||||
sender: The sender who issued the command.
|
||||
command: The issued command, case insensitive.
|
||||
args: Arguments given with the command.
|
||||
is_management: Whether the room is a management room.
|
||||
is_portal: Whether the room is a portal.
|
||||
|
||||
Returns:
|
||||
The result of the error message function or None if no error
|
||||
occured. Unknown and delegated commands do not count as errors.
|
||||
"""
|
||||
if not command_handlers or "unknown-command" not in command_handlers:
|
||||
raise ValueError("command_handlers are not properly initialized.")
|
||||
|
||||
evt = CommandEvent(self, room, event_id, sender, command, args, is_management, is_portal)
|
||||
orig_command = command
|
||||
command = command.lower()
|
||||
@staticmethod
|
||||
async def _run_handler(handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent
|
||||
) -> Any:
|
||||
try:
|
||||
handler = command_handlers[command]
|
||||
except KeyError:
|
||||
if sender.command_status and "next" in sender.command_status:
|
||||
args.insert(0, orig_command)
|
||||
evt.command = ""
|
||||
handler = sender.command_status["next"]
|
||||
else:
|
||||
handler = command_handlers["unknown-command"]
|
||||
try:
|
||||
await handler(evt)
|
||||
return await handler(evt)
|
||||
except FloodWaitError as e:
|
||||
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
|
||||
except Exception:
|
||||
self.log.exception("Unhandled error while handling command "
|
||||
f"{evt.command} {' '.join(args)} from {sender.mxid}")
|
||||
if evt.sender.is_admin and evt.is_management:
|
||||
return await evt.reply("Unhandled error while handling command:\n\n"
|
||||
"```traceback\n"
|
||||
f"{traceback.format_exc()}"
|
||||
"```")
|
||||
return await evt.reply("Unhandled error while handling command. "
|
||||
"Check logs for more details.")
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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 typing import Set, Callable
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
from attr import dataclass
|
||||
|
||||
from telethon import __version__ as __telethon_version__
|
||||
|
||||
from mautrix import __version__ as __mautrix_version__
|
||||
from mautrix.types import UserID
|
||||
from mautrix.errors import MatrixConnectionError
|
||||
from mautrix.util.manhole import start_manhole
|
||||
|
||||
from .. import __version__
|
||||
from . import command_handler, CommandEvent, SECTION_ADMIN
|
||||
|
||||
|
||||
@dataclass
|
||||
class ManholeState:
|
||||
server: asyncio.AbstractServer
|
||||
opened_by: UserID
|
||||
close: Callable[[], None]
|
||||
whitelist: Set[int]
|
||||
|
||||
|
||||
@command_handler(needs_auth=False, needs_admin=True, help_section=SECTION_ADMIN,
|
||||
help_text="Open a manhole into the bridge.", help_args="<_uid..._>")
|
||||
async def open_manhole(evt: CommandEvent) -> None:
|
||||
if not evt.config["manhole.enabled"]:
|
||||
await evt.reply("The manhole has been disabled in the config.")
|
||||
return
|
||||
elif len(evt.args) == 0:
|
||||
await evt.reply("**Usage:** `$cmdprefix+sp open-manhole <uid...>`")
|
||||
return
|
||||
|
||||
whitelist = set()
|
||||
whitelist_whitelist = evt.config["manhole.whitelist"]
|
||||
for arg in evt.args:
|
||||
try:
|
||||
uid = int(arg)
|
||||
except ValueError:
|
||||
await evt.reply(f"{arg} is not an integer.")
|
||||
return
|
||||
if whitelist_whitelist and uid not in whitelist_whitelist:
|
||||
await evt.reply(f"{uid} is not in the list of allowed UIDs.")
|
||||
return
|
||||
whitelist.add(uid)
|
||||
|
||||
if evt.bridge.manhole:
|
||||
added = [uid for uid in whitelist
|
||||
if uid not in evt.bridge.manhole.whitelist]
|
||||
evt.bridge.manhole.whitelist |= set(added)
|
||||
if len(added) == 0:
|
||||
await evt.reply(f"There's an existing manhole opened by {evt.bridge.manhole.opened_by}"
|
||||
" and all the given UIDs are already whitelisted.")
|
||||
else:
|
||||
added_str = (f"{', '.join(str(uid) for uid in added[:-1])} and {added[-1]}"
|
||||
if len(added) > 1 else added[0])
|
||||
await evt.reply(f"There's an existing manhole opened by {evt.bridge.manhole.opened_by}"
|
||||
f". Added {added_str} to the whitelist.")
|
||||
evt.log.info(f"{evt.sender.mxid} added {added_str} to the manhole whitelist.")
|
||||
return
|
||||
|
||||
from ..portal import Portal
|
||||
from ..puppet import Puppet
|
||||
from ..user import User
|
||||
namespace = {
|
||||
"bridge": evt.bridge,
|
||||
"User": User,
|
||||
"Portal": Portal,
|
||||
"Puppet": Puppet,
|
||||
}
|
||||
banner = (f"Python {sys.version} on {sys.platform}\n"
|
||||
f"mautrix-telegram {__version__} with mautrix-python {__mautrix_version__} "
|
||||
f"and Telethon {__telethon_version__}\n\nManhole opened by {evt.sender.mxid}\n")
|
||||
path = evt.config["manhole.path"]
|
||||
|
||||
wl_list = list(whitelist)
|
||||
whitelist_str = (f"{', '.join(str(uid) for uid in wl_list[:-1])} and {wl_list[-1]}"
|
||||
if len(wl_list) > 1 else wl_list[0])
|
||||
evt.log.info(f"{evt.sender.mxid} opened a manhole with {whitelist_str} whitelisted.")
|
||||
server, close = await start_manhole(path=path, banner=banner, namespace=namespace,
|
||||
loop=evt.loop, whitelist=whitelist)
|
||||
evt.bridge.manhole = ManholeState(server=server, opened_by=evt.sender.mxid, close=close,
|
||||
whitelist=whitelist)
|
||||
plrl = "s" if len(whitelist) != 1 else ""
|
||||
await evt.reply(f"Opened manhole at unix://{path} with UID{plrl} {whitelist_str} whitelisted")
|
||||
await server.wait_closed()
|
||||
evt.bridge.manhole = None
|
||||
try:
|
||||
os.unlink(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
evt.log.info(f"{evt.sender.mxid}'s manhole was closed.")
|
||||
try:
|
||||
await evt.reply("Your manhole was closed.")
|
||||
except (AttributeError, MatrixConnectionError) as e:
|
||||
evt.log.warning(f"Failed to send manhole close notification: {e}")
|
||||
|
||||
|
||||
@command_handler(needs_auth=False, needs_admin=True, help_section=SECTION_ADMIN,
|
||||
help_text="Close an open manhole.")
|
||||
async def close_manhole(evt: CommandEvent) -> None:
|
||||
if not evt.bridge.manhole:
|
||||
await evt.reply("There is no open manhole.")
|
||||
return
|
||||
|
||||
opened_by = evt.bridge.manhole.opened_by
|
||||
evt.bridge.manhole.close()
|
||||
evt.bridge.manhole = None
|
||||
if opened_by != evt.sender.mxid:
|
||||
await evt.reply(f"Closed manhole opened by {opened_by}")
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,17 +13,17 @@
|
||||
#
|
||||
# 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, Optional
|
||||
from mautrix.types import EventID
|
||||
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
|
||||
|
||||
from . import command_handler, CommandEvent, SECTION_AUTH
|
||||
from .. import puppet as pu
|
||||
|
||||
|
||||
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Revert your Telegram account's Matrix puppet to use the default Matrix "
|
||||
"account.")
|
||||
async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
|
||||
help_section=SECTION_AUTH, help_text="Revert your Telegram account's Matrix "
|
||||
"puppet to use the default Matrix account.")
|
||||
async def logout_matrix(evt: CommandEvent) -> EventID:
|
||||
puppet = pu.Puppet.get(evt.sender.tgid)
|
||||
if not puppet.is_real_user:
|
||||
return await evt.reply("You are not logged in with your Matrix account.")
|
||||
@@ -36,7 +35,7 @@ async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Replace your Telegram account's Matrix puppet with your own Matrix "
|
||||
"account.")
|
||||
async def login_matrix(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def login_matrix(evt: CommandEvent) -> EventID:
|
||||
puppet = pu.Puppet.get(evt.sender.tgid)
|
||||
if puppet.is_real_user:
|
||||
return await evt.reply("You have already logged in with your Matrix account. "
|
||||
@@ -71,31 +70,44 @@ async def login_matrix(evt: CommandEvent) -> Optional[Dict]:
|
||||
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Pings the server with the stored matrix authentication.")
|
||||
async def ping_matrix(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def ping_matrix(evt: CommandEvent) -> EventID:
|
||||
puppet = pu.Puppet.get(evt.sender.tgid)
|
||||
if not puppet.is_real_user:
|
||||
return await evt.reply("You are not logged in with your Matrix account.")
|
||||
resp = await puppet.init_custom_mxid()
|
||||
if resp == pu.PuppetError.InvalidAccessToken:
|
||||
try:
|
||||
await puppet.start()
|
||||
except InvalidAccessToken:
|
||||
return await evt.reply("Your access token is invalid.")
|
||||
elif resp == pu.PuppetError.Success:
|
||||
return await evt.reply("Your Matrix login is working.")
|
||||
return await evt.reply(f"Unknown response while checking your Matrix login: {resp}.")
|
||||
return await evt.reply("Your Matrix login is working.")
|
||||
|
||||
|
||||
async def enter_matrix_token(evt: CommandEvent) -> Dict:
|
||||
@command_handler(needs_auth=True, needs_matrix_puppeting=True, help_section=SECTION_AUTH,
|
||||
help_text="Clear the Matrix sync token stored for your custom puppet.")
|
||||
async def clear_cache_matrix(evt: CommandEvent) -> EventID:
|
||||
puppet = pu.Puppet.get(evt.sender.tgid)
|
||||
if not puppet.is_real_user:
|
||||
return await evt.reply("You are not logged in with your Matrix account.")
|
||||
try:
|
||||
puppet.stop()
|
||||
puppet.next_batch = None
|
||||
await puppet.start()
|
||||
except InvalidAccessToken:
|
||||
return await evt.reply("Your access token is invalid.")
|
||||
return await evt.reply("Cleared cache successfully.")
|
||||
|
||||
|
||||
async def enter_matrix_token(evt: CommandEvent) -> EventID:
|
||||
evt.sender.command_status = None
|
||||
|
||||
puppet = pu.Puppet.get(evt.sender.tgid)
|
||||
if puppet.is_real_user:
|
||||
return await evt.reply("You have already logged in with your Matrix account. "
|
||||
"Log out with `$cmdprefix+sp logout-matrix` first.")
|
||||
|
||||
resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
|
||||
if resp == pu.PuppetError.OnlyLoginSelf:
|
||||
try:
|
||||
await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
|
||||
except OnlyLoginSelf:
|
||||
return await evt.reply("You can only log in as your own Matrix user.")
|
||||
elif resp == pu.PuppetError.InvalidAccessToken:
|
||||
except InvalidAccessToken:
|
||||
return await evt.reply("Failed to verify access token.")
|
||||
assert resp == pu.PuppetError.Success, "Encountered an unhandled PuppetError."
|
||||
return await evt.reply(
|
||||
f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.")
|
||||
return await evt.reply("Replaced your Telegram account's Matrix puppet "
|
||||
f"with {puppet.custom_mxid}.")
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# 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 typing import Dict, List, Optional, Tuple
|
||||
|
||||
from . import command_handler, CommandEvent, _command_handlers, SECTION_GENERAL
|
||||
from .handler import HelpSection
|
||||
|
||||
|
||||
@command_handler(needs_auth=False, needs_puppeting=False,
|
||||
help_section=SECTION_GENERAL,
|
||||
help_text="Cancel an ongoing action (such as login)")
|
||||
async def cancel(evt: CommandEvent) -> Optional[Dict]:
|
||||
if evt.sender.command_status:
|
||||
action = evt.sender.command_status["action"]
|
||||
evt.sender.command_status = None
|
||||
return await evt.reply(f"{action} cancelled.")
|
||||
else:
|
||||
return await evt.reply("No ongoing command.")
|
||||
|
||||
|
||||
@command_handler(needs_auth=False, needs_puppeting=False)
|
||||
async def unknown_command(evt: CommandEvent) -> Optional[Dict]:
|
||||
return await evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
|
||||
|
||||
|
||||
help_cache = {} # type: Dict[Tuple[bool, bool, bool, bool, bool], str]
|
||||
|
||||
|
||||
async def _get_help_text(evt: CommandEvent) -> str:
|
||||
cache_key = (evt.is_management, evt.sender.puppet_whitelisted,
|
||||
evt.sender.matrix_puppet_whitelisted, evt.sender.is_admin,
|
||||
await evt.sender.is_logged_in())
|
||||
if cache_key not in help_cache:
|
||||
help_sections = {} # type: Dict[HelpSection, List[str]]
|
||||
for handler in _command_handlers.values():
|
||||
if handler.has_help and handler.has_permission(*cache_key):
|
||||
help_sections.setdefault(handler.help_section, [])
|
||||
help_sections[handler.help_section].append(handler.help + " ")
|
||||
help_sorted = sorted(help_sections.items(), key=lambda item: item[0].order)
|
||||
helps = ["#### {}\n{}\n".format(key.name, "\n".join(value)) for key, value in help_sorted]
|
||||
help_cache[cache_key] = "\n".join(helps)
|
||||
return help_cache[cache_key]
|
||||
|
||||
|
||||
def _get_management_status(evt: CommandEvent) -> str:
|
||||
if evt.is_management:
|
||||
return "This is a management room: prefixing commands with `$cmdprefix` is not required."
|
||||
elif evt.is_portal:
|
||||
return ("**This is a portal room**: you must always prefix commands with `$cmdprefix`.\n"
|
||||
"Management commands will not be sent to Telegram.")
|
||||
return "**This is not a management room**: you must prefix commands with `$cmdprefix`."
|
||||
|
||||
|
||||
@command_handler(name="help", needs_auth=False, needs_puppeting=False,
|
||||
help_section=SECTION_GENERAL,
|
||||
help_text="Show this help message.")
|
||||
async def help_cmd(evt: CommandEvent) -> Optional[Dict]:
|
||||
return await evt.reply(_get_management_status(evt) + "\n" + await _get_help_text(evt))
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,10 +13,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 Dict
|
||||
import asyncio
|
||||
|
||||
from mautrix_appservice import MatrixRequestError
|
||||
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
|
||||
@@ -27,29 +26,28 @@ from .. import command_handler, CommandEvent, SECTION_ADMIN
|
||||
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) -> Dict:
|
||||
async def set_power_level(evt: CommandEvent) -> EventID:
|
||||
try:
|
||||
level = int(evt.args[0])
|
||||
except KeyError:
|
||||
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
|
||||
levels.users[mxid] = level
|
||||
try:
|
||||
await evt.az.intent.set_power_levels(evt.room_id, levels)
|
||||
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.")
|
||||
return {}
|
||||
|
||||
|
||||
@command_handler(needs_admin=True, needs_auth=False,
|
||||
help_section=SECTION_ADMIN,
|
||||
help_args="<`portal`|`puppet`|`user`>",
|
||||
help_text="Clear internal bridge caches")
|
||||
async def clear_db_cache(evt: CommandEvent) -> Dict:
|
||||
async def clear_db_cache(evt: CommandEvent) -> EventID:
|
||||
try:
|
||||
section = evt.args[0].lower()
|
||||
except IndexError:
|
||||
@@ -63,9 +61,8 @@ async def clear_db_cache(evt: CommandEvent) -> Dict:
|
||||
for puppet in pu.Puppet.by_custom_mxid.values():
|
||||
puppet.sync_task.cancel()
|
||||
pu.Puppet.by_custom_mxid = {}
|
||||
await asyncio.gather(
|
||||
*[puppet.init_custom_mxid() for puppet in pu.Puppet.all_with_custom_mxid()],
|
||||
loop=evt.loop)
|
||||
await asyncio.gather(*[puppet.try_start() for puppet in pu.Puppet.all_with_custom_mxid()],
|
||||
loop=evt.loop)
|
||||
await evt.reply("Cleared puppet cache and restarted custom puppet syncers")
|
||||
elif section == "user":
|
||||
u.User.by_mxid = {
|
||||
@@ -81,7 +78,7 @@ async def clear_db_cache(evt: CommandEvent) -> Dict:
|
||||
help_section=SECTION_ADMIN,
|
||||
help_args="[_mxid_]",
|
||||
help_text="Reload and reconnect a user")
|
||||
async def reload_user(evt: CommandEvent) -> Dict:
|
||||
async def reload_user(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) > 0:
|
||||
mxid = evt.args[0]
|
||||
else:
|
||||
@@ -97,5 +94,5 @@ async def reload_user(evt: CommandEvent) -> Dict:
|
||||
user = u.User.get_by_mxid(mxid)
|
||||
await user.ensure_started()
|
||||
if puppet:
|
||||
await puppet.init_custom_mxid()
|
||||
await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
|
||||
await puppet.start()
|
||||
return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,13 +13,14 @@
|
||||
#
|
||||
# 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, Optional, Tuple, Coroutine
|
||||
from typing import Optional, Tuple, Coroutine
|
||||
import asyncio
|
||||
|
||||
from telethon.tl.types import ChatForbidden, ChannelForbidden
|
||||
|
||||
from ...types import MatrixRoomID, TelegramID
|
||||
from ...util import ignore_coro
|
||||
from mautrix.types import EventID, RoomID
|
||||
|
||||
from ...types import TelegramID
|
||||
from ... import portal as po
|
||||
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
|
||||
from .util import user_has_power_level, get_initial_state
|
||||
@@ -32,11 +32,15 @@ from .util import user_has_power_level, get_initial_state
|
||||
help_text="Bridge the current Matrix room to the Telegram chat with the given "
|
||||
"ID. The ID must be the prefixed version that you get with the `/id` "
|
||||
"command of the Telegram-side bot.")
|
||||
async def bridge(evt: CommandEvent) -> Dict:
|
||||
async def bridge(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** "
|
||||
"`$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`")
|
||||
room_id = MatrixRoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
|
||||
force_use_bot = False
|
||||
if evt.args[0] == "--usebot" and evt.sender.is_admin:
|
||||
force_use_bot = True
|
||||
evt.args = evt.args[1:]
|
||||
room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
|
||||
that_this = "This" if room_id == evt.room_id else "That"
|
||||
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
@@ -61,7 +65,7 @@ async def bridge(evt: CommandEvent) -> Dict:
|
||||
"Bridging private chats to existing rooms is not allowed.")
|
||||
|
||||
portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type)
|
||||
if not portal.allow_bridging():
|
||||
if not portal.allow_bridging:
|
||||
return await evt.reply("This bridge doesn't allow bridging that Telegram chat.\n"
|
||||
"If you're the bridge admin, try "
|
||||
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first.")
|
||||
@@ -80,6 +84,7 @@ async def bridge(evt: CommandEvent) -> Dict:
|
||||
"bridge_to_mxid": room_id,
|
||||
"tgid": portal.tgid,
|
||||
"peer_type": portal.peer_type,
|
||||
"force_use_bot": force_use_bot,
|
||||
}
|
||||
return await evt.reply(f"{has_portal_message}"
|
||||
"However, you have the permissions to unbridge that room.\n\n"
|
||||
@@ -93,25 +98,25 @@ async def bridge(evt: CommandEvent) -> Dict:
|
||||
"bridge_to_mxid": room_id,
|
||||
"tgid": portal.tgid,
|
||||
"peer_type": portal.peer_type,
|
||||
"force_use_bot": force_use_bot,
|
||||
}
|
||||
return await evt.reply("That Telegram chat has no existing portal. To confirm bridging the "
|
||||
"chat to this room, use `$cmdprefix+sp continue`")
|
||||
|
||||
|
||||
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
|
||||
) -> Tuple[bool, Optional[Coroutine[None, None, None]]]:
|
||||
) -> Tuple[
|
||||
bool, Optional[Coroutine[None, None, None]]]:
|
||||
if not portal.mxid:
|
||||
await evt.reply("The portal seems to have lost its Matrix room between you"
|
||||
"calling `$cmdprefix+sp bridge` and this command.\n\n"
|
||||
"Continuing without touching previous Matrix room...")
|
||||
return True, None
|
||||
elif evt.args[0] == "delete-and-continue":
|
||||
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
|
||||
message="Portal deleted (moving to another room)")
|
||||
return True, portal.cleanup_portal("Portal deleted (moving to another room)")
|
||||
elif evt.args[0] == "unbridge-and-continue":
|
||||
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
|
||||
message="Room unbridged (portal moving to another room)",
|
||||
puppets_only=True)
|
||||
return True, portal.cleanup_portal("Room unbridged (portal moving to another room)",
|
||||
puppets_only=True)
|
||||
else:
|
||||
await evt.reply(
|
||||
"The chat you were trying to bridge already has a Matrix portal room.\n\n"
|
||||
@@ -122,7 +127,7 @@ async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Porta
|
||||
return False, None
|
||||
|
||||
|
||||
async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def confirm_bridge(evt: CommandEvent) -> Optional[EventID]:
|
||||
status = evt.sender.command_status
|
||||
try:
|
||||
portal = po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
|
||||
@@ -137,7 +142,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
|
||||
if not ok:
|
||||
return None
|
||||
elif coro:
|
||||
ignore_coro(asyncio.ensure_future(coro, loop=evt.loop))
|
||||
asyncio.ensure_future(coro, loop=evt.loop)
|
||||
await evt.reply("Cleaning up previous portal room...")
|
||||
elif portal.mxid:
|
||||
evt.sender.command_status = None
|
||||
@@ -149,7 +154,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
|
||||
"`$cmdprefix+sp cancel` to cancel.")
|
||||
|
||||
evt.sender.command_status = None
|
||||
is_logged_in = await evt.sender.is_logged_in()
|
||||
is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"]
|
||||
user = evt.sender if is_logged_in else evt.tgbot
|
||||
try:
|
||||
entity = await user.client.get_entity(portal.peer)
|
||||
@@ -174,8 +179,7 @@ async def confirm_bridge(evt: CommandEvent) -> Optional[Dict]:
|
||||
portal.photo_id = ""
|
||||
portal.save()
|
||||
|
||||
ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct,
|
||||
levels=levels),
|
||||
loop=evt.loop))
|
||||
asyncio.ensure_future(portal.update_matrix_room(user, entity, direct, levels=levels),
|
||||
loop=evt.loop)
|
||||
|
||||
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,15 +13,17 @@
|
||||
#
|
||||
# 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, Awaitable
|
||||
from typing import Awaitable
|
||||
from io import StringIO
|
||||
|
||||
from ...config import yaml
|
||||
from mautrix.util.config import yaml
|
||||
from mautrix.types import EventID
|
||||
|
||||
from ... import portal as po, util
|
||||
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
|
||||
|
||||
|
||||
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
||||
@command_handler(needs_auth=False, help_section=SECTION_PORTAL_MANAGEMENT,
|
||||
help_text="View or change per-portal settings.",
|
||||
help_args="<`help`|_subcommand_> [...]")
|
||||
async def config(evt: CommandEvent) -> None:
|
||||
@@ -42,6 +43,10 @@ async def config(evt: CommandEvent) -> None:
|
||||
await config_view(evt, portal)
|
||||
return
|
||||
|
||||
if not await portal.can_user_perform(evt.sender, "config"):
|
||||
await evt.reply("You do not have the permissions to configure this room.")
|
||||
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
|
||||
if cmd == "set":
|
||||
@@ -55,7 +60,7 @@ async def config(evt: CommandEvent) -> None:
|
||||
portal.save()
|
||||
|
||||
|
||||
def config_help(evt: CommandEvent) -> Awaitable[Dict]:
|
||||
def config_help(evt: CommandEvent) -> Awaitable[EventID]:
|
||||
return evt.reply("""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
|
||||
|
||||
* **help** - View this help text.
|
||||
@@ -68,16 +73,15 @@ def config_help(evt: CommandEvent) -> Awaitable[Dict]:
|
||||
""")
|
||||
|
||||
|
||||
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[Dict]:
|
||||
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()}```")
|
||||
|
||||
|
||||
def config_defaults(evt: CommandEvent) -> Awaitable[Dict]:
|
||||
def config_defaults(evt: CommandEvent) -> Awaitable[EventID]:
|
||||
stream = StringIO()
|
||||
yaml.dump({
|
||||
"edits_as_replies": evt.config["bridge.edits_as_replies"],
|
||||
"bridge_notices": {
|
||||
"default": evt.config["bridge.bridge_notices.default"],
|
||||
"exceptions": evt.config["bridge.bridge_notices.exceptions"],
|
||||
@@ -85,13 +89,14 @@ def config_defaults(evt: CommandEvent) -> Awaitable[Dict]:
|
||||
"bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"],
|
||||
"inline_images": evt.config["bridge.inline_images"],
|
||||
"message_formats": evt.config["bridge.message_formats"],
|
||||
"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()}```")
|
||||
|
||||
|
||||
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[Dict]:
|
||||
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[EventID]:
|
||||
if not key or value is None:
|
||||
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
|
||||
elif util.recursive_set(portal.local_config, key, value):
|
||||
@@ -101,7 +106,7 @@ def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Aw
|
||||
"Does the path contain non-map types?")
|
||||
|
||||
|
||||
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Dict]:
|
||||
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[EventID]:
|
||||
if not key:
|
||||
return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`")
|
||||
elif util.recursive_del(portal.local_config, key):
|
||||
@@ -111,7 +116,7 @@ def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[Di
|
||||
|
||||
|
||||
def config_add_del(evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
|
||||
) -> Awaitable[Dict]:
|
||||
) -> Awaitable[EventID]:
|
||||
if not key or value is None:
|
||||
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,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
|
||||
from mautrix.types import EventID
|
||||
|
||||
from ... import portal as po
|
||||
from ...types import TelegramID
|
||||
@@ -27,7 +26,7 @@ from .util import user_has_power_level, get_initial_state
|
||||
help_text="Create a Telegram chat of the given type for the current Matrix room. "
|
||||
"The type is either `group`, `supergroup` or `channel` (defaults to "
|
||||
"`group`).")
|
||||
async def create(evt: CommandEvent) -> Dict:
|
||||
async def create(evt: CommandEvent) -> EventID:
|
||||
type = evt.args[0] if len(evt.args) > 0 else "group"
|
||||
if type not in {"chat", "group", "supergroup", "channel"}:
|
||||
return await evt.reply(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,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, Optional
|
||||
from mautrix.types import EventID
|
||||
|
||||
from ... import portal as po
|
||||
from .. import command_handler, CommandEvent, SECTION_ADMIN
|
||||
@@ -25,7 +24,7 @@ from .. import command_handler, CommandEvent, SECTION_ADMIN
|
||||
help_args="<`whitelist`|`blacklist`>",
|
||||
help_text="Change whether the bridge will allow or disallow bridging rooms by "
|
||||
"default.")
|
||||
async def filter_mode(evt: CommandEvent) -> Dict:
|
||||
async def filter_mode(evt: CommandEvent) -> EventID:
|
||||
try:
|
||||
mode = evt.args[0]
|
||||
if mode not in ("whitelist", "blacklist"):
|
||||
@@ -50,7 +49,7 @@ async def filter_mode(evt: CommandEvent) -> Dict:
|
||||
help_section=SECTION_ADMIN,
|
||||
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
|
||||
help_text="Allow or disallow bridging a specific chat.")
|
||||
async def edit_filter(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def edit_filter(evt: CommandEvent) -> EventID:
|
||||
try:
|
||||
action = evt.args[0]
|
||||
if action not in ("whitelist", "blacklist", "add", "remove"):
|
||||
@@ -92,4 +91,5 @@ async def edit_filter(evt: CommandEvent) -> Optional[Dict]:
|
||||
filter_id_list.remove(filter_id)
|
||||
save()
|
||||
return await evt.reply(f"Chat ID removed from {mode}.")
|
||||
return None
|
||||
else:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,10 +13,12 @@
|
||||
#
|
||||
# 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
|
||||
|
||||
from telethon.tl.functions.channels import GetFullChannelRequest
|
||||
from telethon.tl.functions.messages import GetFullChatRequest
|
||||
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
|
||||
UsernameNotModifiedError, UsernameOccupiedError)
|
||||
UsernameNotModifiedError, UsernameOccupiedError, RPCError)
|
||||
|
||||
from mautrix.types import EventID
|
||||
|
||||
from ... import portal as po
|
||||
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC
|
||||
@@ -27,7 +28,7 @@ from .util import user_has_power_level
|
||||
@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False,
|
||||
help_section=SECTION_MISC,
|
||||
help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.")
|
||||
async def sync_state(evt: CommandEvent) -> Dict:
|
||||
async def sync_state(evt: CommandEvent) -> EventID:
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
if not portal:
|
||||
return await evt.reply("This is not a portal room.")
|
||||
@@ -38,10 +39,36 @@ async def sync_state(evt: CommandEvent) -> Dict:
|
||||
await evt.reply("Synchronization complete")
|
||||
|
||||
|
||||
@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False,
|
||||
help_section=SECTION_MISC)
|
||||
async def sync_full(evt: CommandEvent) -> EventID:
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
if not portal:
|
||||
return await evt.reply("This is not a portal room.")
|
||||
|
||||
if len(evt.args) > 0 and evt.args[0] == "--usebot" and evt.sender.is_admin:
|
||||
src = evt.tgbot
|
||||
else:
|
||||
src = evt.tgbot if await evt.sender.needs_relaybot(portal) else evt.sender
|
||||
|
||||
try:
|
||||
if portal.peer_type == "channel":
|
||||
res = await src.client(GetFullChannelRequest(portal.peer))
|
||||
elif portal.peer_type == "chat":
|
||||
res = await src.client(GetFullChatRequest(portal.tgid))
|
||||
else:
|
||||
return await evt.reply("This is not a channel or chat portal.")
|
||||
except (ValueError, RPCError):
|
||||
return await evt.reply("Failed to get portal info from Telegram.")
|
||||
|
||||
await portal.update_matrix_room(src, res.full_chat)
|
||||
return await evt.reply("Portal synced successfully.")
|
||||
|
||||
|
||||
@command_handler(name="id", needs_admin=False, needs_puppeting=False, needs_auth=False,
|
||||
help_section=SECTION_MISC,
|
||||
help_text="Get the ID of the Telegram chat where this room is bridged.")
|
||||
async def get_id(evt: CommandEvent) -> Dict:
|
||||
async def get_id(evt: CommandEvent) -> EventID:
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
if not portal:
|
||||
return await evt.reply("This is not a portal room.")
|
||||
@@ -55,7 +82,7 @@ async def get_id(evt: CommandEvent) -> Dict:
|
||||
|
||||
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
||||
help_text="Get a Telegram invite link to the current chat.")
|
||||
async def invite_link(evt: CommandEvent) -> Dict:
|
||||
async def invite_link(evt: CommandEvent) -> EventID:
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
if not portal:
|
||||
return await evt.reply("This is not a portal room.")
|
||||
@@ -74,7 +101,7 @@ async def invite_link(evt: CommandEvent) -> Dict:
|
||||
|
||||
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
|
||||
help_text="Upgrade a normal Telegram group to a supergroup.")
|
||||
async def upgrade(evt: CommandEvent) -> Dict:
|
||||
async def upgrade(evt: CommandEvent) -> EventID:
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
if not portal:
|
||||
return await evt.reply("This is not a portal room.")
|
||||
@@ -96,7 +123,7 @@ async def upgrade(evt: CommandEvent) -> Dict:
|
||||
help_args="<_name_|`-`>",
|
||||
help_text="Change the username of a supergroup/channel. "
|
||||
"To disable, use a dash (`-`) as the name.")
|
||||
async def group_name(evt: CommandEvent) -> Dict:
|
||||
async def group_name(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -16,7 +15,8 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Dict, Callable, Optional
|
||||
|
||||
from ...types import MatrixRoomID
|
||||
from mautrix.types import RoomID, EventID
|
||||
|
||||
from ... import portal as po
|
||||
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
|
||||
from .util import user_has_power_level
|
||||
@@ -25,7 +25,7 @@ from .util import user_has_power_level
|
||||
async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
|
||||
action: Optional[str] = None
|
||||
) -> Optional[po.Portal]:
|
||||
room_id = MatrixRoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
|
||||
room_id = RoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
|
||||
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
if not portal:
|
||||
@@ -42,7 +42,7 @@ async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
|
||||
|
||||
def _get_portal_murder_function(action: str, room_id: str, function: Callable, command: str,
|
||||
completed_message: str) -> Dict:
|
||||
async def post_confirm(confirm) -> Optional[Dict]:
|
||||
async def post_confirm(confirm) -> Optional[EventID]:
|
||||
confirm.sender.command_status = None
|
||||
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
|
||||
await function()
|
||||
@@ -63,7 +63,7 @@ def _get_portal_murder_function(action: str, room_id: str, function: Callable, c
|
||||
help_text="Remove all users from the current portal room and forget the portal. "
|
||||
"Only works for group chats; to delete a private chat portal, simply "
|
||||
"leave the room.")
|
||||
async def delete_portal(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def delete_portal(evt: CommandEvent) -> Optional[EventID]:
|
||||
portal = await _get_portal_and_check_permission(evt, "unbridge")
|
||||
if not portal:
|
||||
return None
|
||||
@@ -84,7 +84,7 @@ async def delete_portal(evt: CommandEvent) -> Optional[Dict]:
|
||||
@command_handler(needs_auth=False, needs_puppeting=False,
|
||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
||||
help_text="Remove puppets from the current portal room and forget the portal.")
|
||||
async def unbridge(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def unbridge(evt: CommandEvent) -> Optional[EventID]:
|
||||
portal = await _get_portal_and_check_permission(evt, "unbridge")
|
||||
if not portal:
|
||||
return None
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,43 +13,48 @@
|
||||
#
|
||||
# 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, Tuple
|
||||
from typing import Tuple, Optional
|
||||
|
||||
from mautrix_appservice import MatrixRequestError, IntentAPI
|
||||
from mautrix.errors import MatrixRequestError
|
||||
from mautrix.appservice import IntentAPI
|
||||
from mautrix.types import RoomID, EventType, PowerLevelStateEventContent
|
||||
|
||||
from ... import user as u
|
||||
|
||||
OptStr = Optional[str]
|
||||
|
||||
async def get_initial_state(intent: IntentAPI, room_id: str) -> Tuple[str, str, Dict]:
|
||||
state = await intent.get_room_state(room_id)
|
||||
title = None
|
||||
about = None
|
||||
levels = None
|
||||
|
||||
async def get_initial_state(intent: IntentAPI, room_id: RoomID
|
||||
) -> Tuple[OptStr, OptStr, Optional[PowerLevelStateEventContent]]:
|
||||
state = await intent.get_state(room_id)
|
||||
title: OptStr = None
|
||||
about: OptStr = None
|
||||
levels: Optional[PowerLevelStateEventContent] = None
|
||||
for event in state:
|
||||
try:
|
||||
if event["type"] == "m.room.name":
|
||||
title = event["content"]["name"]
|
||||
elif event["type"] == "m.room.topic":
|
||||
about = event["content"]["topic"]
|
||||
elif event["type"] == "m.room.power_levels":
|
||||
levels = event["content"]
|
||||
elif event["type"] == "m.room.canonical_alias":
|
||||
title = title or event["content"]["alias"]
|
||||
if event.type == EventType.ROOM_NAME:
|
||||
title = event.content.name
|
||||
elif event.type == EventType.ROOM_TOPIC:
|
||||
about = event.content.topic
|
||||
elif event.type == EventType.ROOM_POWER_LEVELS:
|
||||
levels = event.content
|
||||
elif event.type == EventType.ROOM_CANONICAL_ALIAS:
|
||||
title = title or event.content.canonical_alias
|
||||
except KeyError:
|
||||
# Some state event probably has empty content
|
||||
pass
|
||||
return title, about, levels
|
||||
|
||||
|
||||
async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50
|
||||
) -> bool:
|
||||
async def user_has_power_level(room_id: RoomID, intent: IntentAPI, sender: u.User,
|
||||
event: str) -> bool:
|
||||
if sender.is_admin:
|
||||
return True
|
||||
# Make sure the state store contains the power levels.
|
||||
try:
|
||||
await intent.get_power_levels(room)
|
||||
await intent.get_power_levels(room_id)
|
||||
except MatrixRequestError:
|
||||
return False
|
||||
return intent.state_store.has_power_level(room, sender.mxid,
|
||||
event=f"net.maunium.telegram.{event}",
|
||||
default=default)
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,13 +13,15 @@
|
||||
#
|
||||
# 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, Optional
|
||||
from typing import Optional
|
||||
|
||||
from telethon.errors import (UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError,
|
||||
HashInvalidError, AuthKeyError)
|
||||
HashInvalidError, AuthKeyError, FirstNameInvalidError)
|
||||
from telethon.tl.types import Authorization
|
||||
from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest,
|
||||
ResetAuthorizationRequest)
|
||||
ResetAuthorizationRequest, UpdateProfileRequest)
|
||||
|
||||
from mautrix.types import EventID
|
||||
|
||||
from .. import command_handler, CommandEvent, SECTION_AUTH
|
||||
|
||||
@@ -29,7 +30,7 @@ from .. import command_handler, CommandEvent, SECTION_AUTH
|
||||
help_section=SECTION_AUTH,
|
||||
help_args="<_new username_>",
|
||||
help_text="Change your Telegram username.")
|
||||
async def username(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def username(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp username <new username>`")
|
||||
if evt.sender.is_bot:
|
||||
@@ -53,6 +54,25 @@ async def username(evt: CommandEvent) -> Optional[Dict]:
|
||||
await evt.reply(f"Username changed to {evt.sender.username}")
|
||||
|
||||
|
||||
@command_handler(needs_auth=True, help_section=SECTION_AUTH, help_args="<_new displayname_>",
|
||||
help_text="Change your Telegram displayname.")
|
||||
async def displayname(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp displayname <new displayname>`")
|
||||
if evt.sender.is_bot:
|
||||
return await evt.reply("Bots can't set their own displayname.")
|
||||
|
||||
first_name, last_name = ((evt.args[0], "")
|
||||
if len(evt.args) == 1
|
||||
else (" ".join(evt.args[:-1]), evt.args[-1]))
|
||||
try:
|
||||
await evt.sender.client(UpdateProfileRequest(first_name=first_name, last_name=last_name))
|
||||
except FirstNameInvalidError:
|
||||
return await evt.reply("Invalid first name")
|
||||
await evt.sender.update_info()
|
||||
return await evt.reply("Displayname updated")
|
||||
|
||||
|
||||
def _format_session(sess: Authorization) -> str:
|
||||
return (f"**{sess.app_name} {sess.app_version}** \n"
|
||||
f" **Platform:** {sess.device_model} {sess.platform} {sess.system_version} \n"
|
||||
@@ -64,7 +84,7 @@ def _format_session(sess: Authorization) -> str:
|
||||
help_section=SECTION_AUTH,
|
||||
help_args="<`list`|`terminate`> [_hash_]",
|
||||
help_text="View or delete other Telegram sessions.")
|
||||
async def session(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def session(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
|
||||
elif evt.sender.is_bot:
|
||||
@@ -87,9 +107,7 @@ async def session(evt: CommandEvent) -> Optional[Dict]:
|
||||
try:
|
||||
session_hash = int(evt.args[1])
|
||||
except ValueError:
|
||||
return await evt.reply("Hash must be a positive integer")
|
||||
if session_hash <= 0:
|
||||
return await evt.reply("Hash must be a positive integer")
|
||||
return await evt.reply("Hash must be an integer")
|
||||
try:
|
||||
ok = await evt.sender.client(ResetAuthorizationRequest(hash=session_hash))
|
||||
except HashInvalidError:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -17,24 +16,27 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import asyncio
|
||||
|
||||
from telethon.errors import (
|
||||
from telethon.errors import ( # isort: skip
|
||||
AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError,
|
||||
PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError,
|
||||
PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError,
|
||||
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError)
|
||||
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError,
|
||||
PhoneNumberInvalidError)
|
||||
|
||||
from ... import puppet as pu, user as u
|
||||
from mautrix.types import EventID
|
||||
|
||||
from ... import user as u
|
||||
from ...commands import command_handler, CommandEvent, SECTION_AUTH
|
||||
from ...util import format_duration, ignore_coro
|
||||
from ...util import format_duration
|
||||
|
||||
|
||||
@command_handler(needs_auth=False,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Check if you're logged into Telegram.")
|
||||
async def ping(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def ping(evt: CommandEvent) -> EventID:
|
||||
me = await evt.sender.client.get_me() if await evt.sender.is_logged_in() else None
|
||||
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
|
||||
if me:
|
||||
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
|
||||
return await evt.reply(f"You're logged in as {human_tg_id}")
|
||||
else:
|
||||
return await evt.reply("You're not logged in.")
|
||||
@@ -43,14 +45,12 @@ async def ping(evt: CommandEvent) -> Optional[Dict]:
|
||||
@command_handler(needs_auth=False, needs_puppeting=False,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Get the info of the message relay Telegram bot.")
|
||||
async def ping_bot(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def ping_bot(evt: CommandEvent) -> EventID:
|
||||
if not evt.tgbot:
|
||||
return await evt.reply("Telegram message relay bot not configured.")
|
||||
bot_info = await evt.tgbot.client.get_me()
|
||||
mxid = pu.Puppet.get_mxid_from_id(bot_info.id)
|
||||
displayname = bot_info.first_name
|
||||
info, mxid = await evt.tgbot.get_me(use_cache=False)
|
||||
return await evt.reply("Telegram message relay bot is active: "
|
||||
f"[{displayname}](https://matrix.to/#/{mxid}) (ID {bot_info.id})\n\n"
|
||||
f"[{info.first_name}](https://matrix.to/#/{mxid}) (ID {info.id})\n\n"
|
||||
"To use the bot, simply invite it to a portal room.")
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ async def ping_bot(evt: CommandEvent) -> Optional[Dict]:
|
||||
help_section=SECTION_AUTH,
|
||||
help_args="<_phone_> <_full name_>",
|
||||
help_text="Register to Telegram")
|
||||
async def register(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def register(evt: CommandEvent) -> Optional[EventID]:
|
||||
if await evt.sender.is_logged_in():
|
||||
return await evt.reply("You are already logged in.")
|
||||
elif len(evt.args) < 1:
|
||||
@@ -78,14 +78,14 @@ async def register(evt: CommandEvent) -> Optional[Dict]:
|
||||
return None
|
||||
|
||||
|
||||
async def enter_code_register(evt: CommandEvent) -> Dict:
|
||||
async def enter_code_register(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
|
||||
try:
|
||||
await evt.sender.ensure_started(even_if_no_session=True)
|
||||
first_name, last_name = evt.sender.command_status["full_name"]
|
||||
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name)
|
||||
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
|
||||
asyncio.ensure_future(evt.sender.post_login(user, first_login=True), loop=evt.loop)
|
||||
evt.sender.command_status = None
|
||||
return await evt.reply(f"Successfully registered to Telegram.")
|
||||
except PhoneNumberOccupiedError:
|
||||
@@ -107,7 +107,7 @@ async def enter_code_register(evt: CommandEvent) -> Dict:
|
||||
@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) -> Optional[Dict]:
|
||||
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()
|
||||
@@ -126,46 +126,25 @@ async def login(evt: CommandEvent) -> Optional[Dict]:
|
||||
if evt.config["appservice.public.enabled"]:
|
||||
prefix = evt.config["appservice.public.external"]
|
||||
url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}"
|
||||
if allow_matrix_login:
|
||||
if override_sender:
|
||||
return await evt.reply(
|
||||
"This bridge instance allows you to log in inside or outside of Matrix, but "
|
||||
"logging in as another user is only possible via the web interface.\n\n"
|
||||
f"Please visit [the login page]({url}) to log in as "
|
||||
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid}).\n\n")
|
||||
return await evt.reply(
|
||||
"This bridge instance allows you to log in inside or outside Matrix.\n\n"
|
||||
"If you would like to log in within Matrix, please send your phone number or bot "
|
||||
"auth token here.\n"
|
||||
"If you would like to log in outside of Matrix, please visit [the login page]"
|
||||
f"({url}).\n\n"
|
||||
"Logging in outside of Matrix is recommended if you have two-factor authentication "
|
||||
"enabled, because in-Matrix login would save your password in the message history."
|
||||
f"\n\n{nb}")
|
||||
if override_sender:
|
||||
return await evt.reply(
|
||||
"This bridge instance does not allow logging in inside Matrix, and logging in as "
|
||||
"another user inside Matrix isn't possible anyway.\n\n"
|
||||
f"Please visit [the login page]({url}) to log in as "
|
||||
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid}).")
|
||||
return await evt.reply(
|
||||
"This bridge instance does not allow logging in inside Matrix.\n\n"
|
||||
f"Please visit [the login page]({url}) to log in.\n\n"
|
||||
f"{nb}")
|
||||
return await evt.reply(f"[Click here to log in]({url}) as "
|
||||
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid}).")
|
||||
elif allow_matrix_login:
|
||||
return await evt.reply(f"[Click here to log in]({url}). Alternatively, send your phone"
|
||||
f" number (or bot auth token) here to log in.\n\n{nb}")
|
||||
return await evt.reply(f"[Click here to log in]({url}).\n\n{nb}")
|
||||
elif allow_matrix_login:
|
||||
if override_sender:
|
||||
return await evt.reply(
|
||||
"This bridge instance does not allow you to log in outside of Matrix. "
|
||||
"Logging in as another user inside Matrix is not currently possible.")
|
||||
return await evt.reply(
|
||||
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
|
||||
"Please send your phone number or bot auth token here to start the login process.\n\n"
|
||||
f"{nb}")
|
||||
return await evt.reply("Please send your phone number (or bot auth token) here to start "
|
||||
f"the login process.\n\n{nb}")
|
||||
return await evt.reply("This bridge instance has been configured to not allow logging in.")
|
||||
|
||||
|
||||
async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[str, Any]
|
||||
) -> Dict:
|
||||
) -> EventID:
|
||||
ok = False
|
||||
try:
|
||||
await evt.sender.ensure_started(even_if_no_session=True)
|
||||
@@ -188,6 +167,8 @@ async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[
|
||||
except PhoneNumberUnoccupiedError:
|
||||
return await evt.reply("That phone number has not been registered. "
|
||||
"Please register with `$cmdprefix+sp register <phone>`.")
|
||||
except PhoneNumberInvalidError:
|
||||
return await evt.reply("That phone number is not valid.")
|
||||
except Exception:
|
||||
evt.log.exception("Error requesting phone code")
|
||||
return await evt.reply("Unhandled exception while requesting code. "
|
||||
@@ -197,7 +178,7 @@ async def _request_code(evt: CommandEvent, phone_number: str, next_status: Dict[
|
||||
|
||||
|
||||
@command_handler(needs_auth=False)
|
||||
async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def enter_phone_or_token(evt: CommandEvent) -> Optional[EventID]:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`")
|
||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
||||
@@ -221,7 +202,7 @@ async def enter_phone_or_token(evt: CommandEvent) -> Optional[Dict]:
|
||||
|
||||
|
||||
@command_handler(needs_auth=False)
|
||||
async def enter_code(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def enter_code(evt: CommandEvent) -> Optional[EventID]:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
|
||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
||||
@@ -237,7 +218,7 @@ async def enter_code(evt: CommandEvent) -> Optional[Dict]:
|
||||
|
||||
|
||||
@command_handler(needs_auth=False)
|
||||
async def enter_password(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def enter_password(evt: CommandEvent) -> Optional[EventID]:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
|
||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
||||
@@ -256,7 +237,7 @@ async def enter_password(evt: CommandEvent) -> Optional[Dict]:
|
||||
return None
|
||||
|
||||
|
||||
async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
|
||||
async def _sign_in(evt: CommandEvent, **sign_in_info) -> EventID:
|
||||
try:
|
||||
await evt.sender.ensure_started(even_if_no_session=True)
|
||||
user = await evt.sender.client.sign_in(**sign_in_info)
|
||||
@@ -266,7 +247,7 @@ async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
|
||||
await evt.reply(f"[{existing_user.displayname}]"
|
||||
f"(https://matrix.to/#/{existing_user.mxid})"
|
||||
" was logged out from the account.")
|
||||
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
|
||||
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}")
|
||||
@@ -288,7 +269,7 @@ async def _sign_in(evt: CommandEvent, **sign_in_info) -> Dict:
|
||||
@command_handler(needs_auth=True,
|
||||
help_section=SECTION_AUTH,
|
||||
help_text="Log out from Telegram.")
|
||||
async def logout(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def logout(evt: CommandEvent) -> EventID:
|
||||
if await evt.sender.log_out():
|
||||
return await evt.reply("Logged out successfully.")
|
||||
return await evt.reply("Failed to log out.")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,21 +13,24 @@
|
||||
#
|
||||
# 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, List, Optional, Tuple
|
||||
from typing import List, Optional, Tuple, cast
|
||||
import logging
|
||||
import codecs
|
||||
import base64
|
||||
import re
|
||||
|
||||
from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError,
|
||||
UserAlreadyParticipantError)
|
||||
UserAlreadyParticipantError, ChatIdInvalidError)
|
||||
from telethon.tl.patched import Message
|
||||
from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll,
|
||||
TypePeer)
|
||||
TypeInputPeer)
|
||||
from telethon.tl.types.messages import BotCallbackAnswer
|
||||
from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatInviteRequest,
|
||||
GetBotCallbackAnswerRequest, SendVoteRequest)
|
||||
from telethon.tl.functions.channels import JoinChannelRequest
|
||||
|
||||
from mautrix.types import EventID, Format
|
||||
|
||||
from ... import puppet as pu, portal as po
|
||||
from ...abstract_user import AbstractUser
|
||||
from ...db import Message as DBMessage
|
||||
@@ -36,10 +38,26 @@ from ...types import TelegramID
|
||||
from ...commands import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS
|
||||
|
||||
|
||||
@command_handler(needs_auth=False,
|
||||
help_section=SECTION_MISC, help_args="<_caption_>",
|
||||
help_text="Set a caption for the next image you send")
|
||||
async def caption(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp caption <caption>`")
|
||||
|
||||
prefix = f"{evt.command_prefix} caption "
|
||||
if evt.content.format == Format.HTML:
|
||||
evt.content.formatted_body = evt.content.formatted_body.replace(prefix, "", 1)
|
||||
evt.content.body = evt.content.body.replace(prefix, "", 1)
|
||||
evt.sender.command_status = {"caption": evt.content, "action": "Caption"}
|
||||
return await evt.reply("Your next image or file will be sent with that caption. "
|
||||
"Use `$cmdprefix+sp cancel` to cancel the caption.")
|
||||
|
||||
|
||||
@command_handler(help_section=SECTION_MISC,
|
||||
help_args="[_-r|--remote_] <_query_>",
|
||||
help_text="Search your contacts or the Telegram servers for users.")
|
||||
async def search(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def search(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
|
||||
|
||||
@@ -60,7 +78,7 @@ async def search(evt: CommandEvent) -> Optional[Dict]:
|
||||
"Minimum length of remote query is 5 characters.")
|
||||
return await evt.reply("No results 3:")
|
||||
|
||||
reply = [] # type: List[str]
|
||||
reply: List[str] = []
|
||||
if remote:
|
||||
reply += ["**Results from Telegram server:**", ""]
|
||||
else:
|
||||
@@ -74,13 +92,12 @@ async def search(evt: CommandEvent) -> Optional[Dict]:
|
||||
return await evt.reply("\n".join(reply))
|
||||
|
||||
|
||||
@command_handler(help_section=SECTION_CREATING_PORTALS,
|
||||
help_args="<_identifier_>",
|
||||
@command_handler(help_section=SECTION_CREATING_PORTALS, help_args="<_identifier_>",
|
||||
help_text="Open a private chat with the given Telegram user. The identifier is "
|
||||
"either the internal user ID, the username or the phone number. "
|
||||
"**N.B.** The phone numbers you start chats with must already be in "
|
||||
"your contacts.")
|
||||
async def pm(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def pm(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
|
||||
|
||||
@@ -99,7 +116,7 @@ async def pm(evt: CommandEvent) -> Optional[Dict]:
|
||||
f"{pu.Puppet.get_displayname(user, False)}")
|
||||
|
||||
|
||||
async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[Dict]]:
|
||||
async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[EventID]]:
|
||||
if arg.startswith("joinchat/"):
|
||||
invite_hash = arg[len("joinchat/"):]
|
||||
try:
|
||||
@@ -122,7 +139,7 @@ async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Opt
|
||||
@command_handler(help_section=SECTION_CREATING_PORTALS,
|
||||
help_args="<_link_>",
|
||||
help_text="Join a chat with an invite link.")
|
||||
async def join(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def join(evt: CommandEvent) -> Optional[EventID]:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
|
||||
|
||||
@@ -142,7 +159,11 @@ async def join(evt: CommandEvent) -> Optional[Dict]:
|
||||
return await evt.reply(f"Invited you to portal of {portal.title}")
|
||||
else:
|
||||
await evt.reply(f"Creating room for {chat.title}... This might take a while.")
|
||||
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
|
||||
try:
|
||||
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
|
||||
except ChatIdInvalidError as e:
|
||||
logging.getLogger("mau.commands").info(updates.stringify())
|
||||
raise e
|
||||
return await evt.reply(f"Created room for {portal.title}")
|
||||
return None
|
||||
|
||||
@@ -150,7 +171,7 @@ async def join(evt: CommandEvent) -> Optional[Dict]:
|
||||
@command_handler(help_section=SECTION_MISC,
|
||||
help_args="[`chats`|`contacts`|`me`]",
|
||||
help_text="Synchronize your chat portals, contacts and/or own info.")
|
||||
async def sync(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def sync(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) > 0:
|
||||
sync_only = evt.args[0]
|
||||
if sync_only not in ("chats", "contacts", "me"):
|
||||
@@ -177,7 +198,7 @@ class MessageIDError(ValueError):
|
||||
|
||||
|
||||
async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
|
||||
) -> Tuple[TypePeer, Message]:
|
||||
) -> Tuple[TypeInputPeer, Message]:
|
||||
try:
|
||||
enc_id += (4 - len(enc_id) % 4) * "="
|
||||
enc_id = base64.b64decode(enc_id)
|
||||
@@ -191,7 +212,7 @@ async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
|
||||
raise MessageIDError(f"Invalid {type_name} ID (format)") from e
|
||||
|
||||
if peer_type == PEER_TYPE_CHAT:
|
||||
orig_msg = DBMessage.get_by_tgid(msg_id, space)
|
||||
orig_msg = DBMessage.get_one_by_tgid(msg_id, space)
|
||||
if not orig_msg:
|
||||
raise MessageIDError(f"Invalid {type_name} ID (original message not found in db)")
|
||||
new_msg = DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
|
||||
@@ -206,13 +227,13 @@ async def _parse_encoded_msgid(user: AbstractUser, enc_id: str, type_name: str
|
||||
msg = await user.client.get_messages(entity=peer, ids=msg_id)
|
||||
if not msg:
|
||||
raise MessageIDError(f"Invalid {type_name} ID (message not found)")
|
||||
return peer, msg
|
||||
return peer, cast(Message, msg)
|
||||
|
||||
|
||||
@command_handler(help_section=SECTION_MISC,
|
||||
help_args="<_play ID_>",
|
||||
help_text="Play a Telegram game.")
|
||||
async def play(evt: CommandEvent) -> Optional[Dict]:
|
||||
async def play(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) < 1:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp play <play ID>`")
|
||||
elif not await evt.sender.is_logged_in():
|
||||
@@ -228,20 +249,21 @@ async def play(evt: CommandEvent) -> Optional[Dict]:
|
||||
if not isinstance(msg.media, MessageMediaGame):
|
||||
return await evt.reply("Invalid play ID (message doesn't look like a game)")
|
||||
|
||||
game = await evt.sender.client(GetBotCallbackAnswerRequest(peer=peer, msg_id=msg.id, game=True))
|
||||
game = await evt.sender.client(
|
||||
GetBotCallbackAnswerRequest(peer=peer, msg_id=msg.id, game=True))
|
||||
if not isinstance(game, BotCallbackAnswer):
|
||||
return await evt.reply("Game request response invalid")
|
||||
|
||||
await evt.reply(f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
|
||||
f"{msg.media.game.description}")
|
||||
return await evt.reply(f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
|
||||
f"{msg.media.game.description}")
|
||||
|
||||
|
||||
@command_handler(help_section=SECTION_MISC,
|
||||
help_args="<_poll ID_> <_choice ID_>",
|
||||
help_args="<_poll ID_> <_choice number_>",
|
||||
help_text="Vote in a Telegram poll.")
|
||||
async def vote(evt: CommandEvent) -> Optional[Dict]:
|
||||
if len(evt.args) < 2:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice ID>`")
|
||||
async def vote(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) < 1:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice number>`")
|
||||
elif not await evt.sender.is_logged_in():
|
||||
return await evt.reply("You must be logged in with a real account to vote in polls.")
|
||||
elif evt.sender.is_bot:
|
||||
@@ -255,7 +277,25 @@ async def vote(evt: CommandEvent) -> Optional[Dict]:
|
||||
if not isinstance(msg.media, MessageMediaPoll):
|
||||
return await evt.reply("Invalid poll ID (message doesn't look like a poll)")
|
||||
|
||||
options = [base64.b64decode(option + (3 - (len(option) + 3) % 4) * "=")
|
||||
options = []
|
||||
for option in evt.args[1:]:
|
||||
try:
|
||||
if len(option) > 10:
|
||||
raise ValueError("option index too long")
|
||||
option_index = int(option) - 1
|
||||
except ValueError:
|
||||
option_index = None
|
||||
if option_index is None:
|
||||
return await evt.reply(f"Invalid option number \"{option}\"",
|
||||
render_markdown=False, allow_html=False)
|
||||
elif option_index < 0:
|
||||
return await evt.reply(f"Invalid option number {option}. "
|
||||
f"Option numbers must be positive.")
|
||||
elif option_index >= len(msg.media.poll.answers):
|
||||
return await evt.reply(f"Invalid option number {option}. "
|
||||
f"The poll only has {len(msg.media.poll.answers)} options.")
|
||||
options.append(msg.media.poll.answers[option_index].option)
|
||||
options = [msg.media.poll.answers[int(option) - 1].option
|
||||
for option in evt.args[1:]]
|
||||
try:
|
||||
resp = await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
|
||||
|
||||
+85
-163
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,141 +13,39 @@
|
||||
#
|
||||
# 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 Any, Dict, Optional, Tuple
|
||||
from ruamel.yaml import YAML
|
||||
from typing import Any, Dict, List, NamedTuple
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
import random
|
||||
import string
|
||||
import os
|
||||
|
||||
yaml = YAML() # type: YAML
|
||||
yaml.indent(4)
|
||||
from mautrix.types import UserID
|
||||
from mautrix.client import Client
|
||||
from mautrix.bridge.config import (BaseBridgeConfig, ConfigUpdateHelper, ForbiddenKey,
|
||||
ForbiddenDefault)
|
||||
|
||||
Permissions = NamedTuple("Permissions", relaybot=bool, user=bool, puppeting=bool,
|
||||
matrix_puppeting=bool, admin=bool, level=str)
|
||||
|
||||
|
||||
class DictWithRecursion:
|
||||
def __init__(self, data: Optional[CommentedMap] = None) -> None:
|
||||
self._data = data or CommentedMap() # type: CommentedMap
|
||||
|
||||
@staticmethod
|
||||
def _parse_key(key: str) -> Tuple[str, Optional[str]]:
|
||||
if '.' not in key:
|
||||
return key, None
|
||||
key, next_key = key.split('.', 1)
|
||||
if len(key) > 0 and key[0] == "[":
|
||||
end_index = next_key.index("]")
|
||||
key = key[1:] + "." + next_key[:end_index]
|
||||
next_key = next_key[end_index + 2:] if len(next_key) > end_index + 1 else None
|
||||
return key, next_key
|
||||
|
||||
def _recursive_get(self, data: CommentedMap, key: str, default_value: Any) -> Any:
|
||||
key, next_key = self._parse_key(key)
|
||||
if next_key is not None:
|
||||
next_data = data.get(key, CommentedMap())
|
||||
return self._recursive_get(next_data, next_key, default_value)
|
||||
return data.get(key, default_value)
|
||||
|
||||
def get(self, key: str, default_value: Any, allow_recursion: bool = True) -> Any:
|
||||
if allow_recursion and '.' in key:
|
||||
return self._recursive_get(self._data, key, default_value)
|
||||
return self._data.get(key, default_value)
|
||||
|
||||
class Config(BaseBridgeConfig):
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self.get(key, None)
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
return self[key] is not None
|
||||
|
||||
def _recursive_set(self, data: CommentedMap, key: str, value: Any) -> None:
|
||||
key, next_key = self._parse_key(key)
|
||||
if next_key is not None:
|
||||
if key not in data:
|
||||
data[key] = CommentedMap()
|
||||
next_data = data.get(key, CommentedMap())
|
||||
return self._recursive_set(next_data, next_key, value)
|
||||
data[key] = value
|
||||
|
||||
def set(self, key: str, value: Any, allow_recursion: bool = True) -> None:
|
||||
if allow_recursion and '.' in key:
|
||||
self._recursive_set(self._data, key, value)
|
||||
return
|
||||
self._data[key] = value
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
self.set(key, value)
|
||||
|
||||
def _recursive_del(self, data: CommentedMap, key: str) -> None:
|
||||
key, next_key = self._parse_key(key)
|
||||
if next_key is not None:
|
||||
if key not in data:
|
||||
return
|
||||
next_data = data[key]
|
||||
return self._recursive_del(next_data, next_key)
|
||||
try:
|
||||
del data[key]
|
||||
del data.ca.items[key]
|
||||
return os.environ[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
|
||||
except KeyError:
|
||||
pass
|
||||
return super().__getitem__(key)
|
||||
|
||||
def delete(self, key: str, allow_recursion: bool = True) -> None:
|
||||
if allow_recursion and '.' in key:
|
||||
self._recursive_del(self._data, key)
|
||||
return
|
||||
try:
|
||||
del self._data[key]
|
||||
del self._data.ca.items[key]
|
||||
except KeyError:
|
||||
pass
|
||||
@property
|
||||
def forbidden_defaults(self) -> List[ForbiddenDefault]:
|
||||
return [
|
||||
*super().forbidden_defaults,
|
||||
ForbiddenDefault("appservice.public.external", "https://example.com/public",
|
||||
condition="appservice.public.enabled"),
|
||||
ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
|
||||
ForbiddenDefault("telegram.api_id", 12345),
|
||||
ForbiddenDefault("telegram.api_hash", "tjyd5yge35lbodk1xwzw2jstp90k55qz"),
|
||||
]
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
self.delete(key)
|
||||
|
||||
|
||||
class Config(DictWithRecursion):
|
||||
def __init__(self, path: str, registration_path: str, base_path: str) -> None:
|
||||
super().__init__()
|
||||
self.path = path # type: str
|
||||
self.registration_path = registration_path # type: str
|
||||
self.base_path = base_path # type: str
|
||||
self._registration = None # type: Optional[Dict]
|
||||
|
||||
def load(self) -> None:
|
||||
with open(self.path, 'r') as stream:
|
||||
self._data = yaml.load(stream)
|
||||
|
||||
def load_base(self) -> Optional[DictWithRecursion]:
|
||||
try:
|
||||
with open(self.base_path, 'r') as stream:
|
||||
return DictWithRecursion(yaml.load(stream))
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def save(self) -> None:
|
||||
with open(self.path, 'w') as stream:
|
||||
yaml.dump(self._data, stream)
|
||||
if self._registration and self.registration_path:
|
||||
with open(self.registration_path, 'w') as stream:
|
||||
yaml.dump(self._registration, stream)
|
||||
|
||||
@staticmethod
|
||||
def _new_token() -> str:
|
||||
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
|
||||
|
||||
def update(self) -> None:
|
||||
base = self.load_base()
|
||||
if not base:
|
||||
return
|
||||
|
||||
def copy(from_path, to_path=None) -> None:
|
||||
if from_path in self:
|
||||
base[to_path or from_path] = self[from_path]
|
||||
|
||||
def copy_dict(from_path, to_path=None, override_existing_map=True) -> None:
|
||||
if from_path in self:
|
||||
to_path = to_path or from_path
|
||||
if override_existing_map or to_path not in base:
|
||||
base[to_path] = CommentedMap()
|
||||
for key, value in self[from_path].items():
|
||||
base[to_path][key] = value
|
||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
||||
copy, copy_dict, base = helper
|
||||
|
||||
copy("homeserver.address")
|
||||
copy("homeserver.domain")
|
||||
@@ -181,32 +78,49 @@ class Config(DictWithRecursion):
|
||||
copy("appservice.bot_displayname")
|
||||
copy("appservice.bot_avatar")
|
||||
|
||||
copy("appservice.community_id")
|
||||
|
||||
copy("appservice.as_token")
|
||||
copy("appservice.hs_token")
|
||||
|
||||
copy("metrics.enabled")
|
||||
copy("metrics.listen_port")
|
||||
|
||||
copy("manhole.enabled")
|
||||
copy("manhole.path")
|
||||
copy("manhole.whitelist")
|
||||
|
||||
copy("bridge.username_template")
|
||||
copy("bridge.alias_template")
|
||||
copy("bridge.displayname_template")
|
||||
|
||||
copy("bridge.displayname_preference")
|
||||
copy("bridge.displayname_max_length")
|
||||
|
||||
copy("bridge.max_initial_member_sync")
|
||||
copy("bridge.sync_channel_members")
|
||||
copy("bridge.skip_deleted_members")
|
||||
copy("bridge.startup_sync")
|
||||
copy("bridge.sync_dialog_limit")
|
||||
copy("bridge.sync_direct_chats")
|
||||
copy("bridge.max_telegram_delete")
|
||||
copy("bridge.sync_matrix_state")
|
||||
copy("bridge.allow_matrix_login")
|
||||
copy("bridge.plaintext_highlights")
|
||||
copy("bridge.edits_as_replies")
|
||||
copy("bridge.highlight_edits")
|
||||
copy("bridge.public_portals")
|
||||
copy("bridge.catch_up")
|
||||
copy("bridge.sync_with_custom_puppets")
|
||||
copy("bridge.login_shared_secret")
|
||||
copy("bridge.telegram_link_preview")
|
||||
copy("bridge.inline_images")
|
||||
copy("bridge.image_as_file_size")
|
||||
copy("bridge.max_document_size")
|
||||
copy("bridge.parallel_file_transfer")
|
||||
copy("bridge.federate_rooms")
|
||||
copy("bridge.animated_sticker.target")
|
||||
copy("bridge.animated_sticker.args")
|
||||
|
||||
copy("bridge.initial_power_level_overrides.group")
|
||||
copy("bridge.initial_power_level_overrides.user")
|
||||
|
||||
copy("bridge.bot_messages_as_notices")
|
||||
if isinstance(self["bridge.bridge_notices"], bool):
|
||||
@@ -223,6 +137,7 @@ class Config(DictWithRecursion):
|
||||
if "bridge.message_formats.m_text" in self:
|
||||
del self["bridge.message_formats"]
|
||||
copy_dict("bridge.message_formats", override_existing_map=False)
|
||||
copy("bridge.emote_format")
|
||||
|
||||
copy("bridge.state_event_formats.join")
|
||||
copy("bridge.state_event_formats.leave")
|
||||
@@ -249,6 +164,11 @@ class Config(DictWithRecursion):
|
||||
if "bridge.relaybot" not in self:
|
||||
copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals")
|
||||
else:
|
||||
copy("bridge.relaybot.private_chat.invite")
|
||||
copy("bridge.relaybot.private_chat.state_changes")
|
||||
copy("bridge.relaybot.private_chat.message")
|
||||
copy("bridge.relaybot.group_chat_invite")
|
||||
copy("bridge.relaybot.ignore_unbridged_group_chat")
|
||||
copy("bridge.relaybot.authless_portals")
|
||||
copy("bridge.relaybot.whitelist_group_admins")
|
||||
copy("bridge.relaybot.whitelist")
|
||||
@@ -257,10 +177,24 @@ class Config(DictWithRecursion):
|
||||
copy("telegram.api_id")
|
||||
copy("telegram.api_hash")
|
||||
copy("telegram.bot_token")
|
||||
|
||||
copy("telegram.connection.timeout")
|
||||
copy("telegram.connection.retries")
|
||||
copy("telegram.connection.retry_delay")
|
||||
copy("telegram.connection.flood_sleep_threshold")
|
||||
copy("telegram.connection.request_retries")
|
||||
|
||||
copy("telegram.device_info.device_model")
|
||||
copy("telegram.device_info.system_version")
|
||||
copy("telegram.device_info.app_version")
|
||||
copy("telegram.device_info.lang_code")
|
||||
copy("telegram.device_info.system_lang_code")
|
||||
|
||||
copy("telegram.server.enabled")
|
||||
copy("telegram.server.dc")
|
||||
copy("telegram.server.ip")
|
||||
copy("telegram.server.port")
|
||||
|
||||
copy("telegram.proxy.type")
|
||||
copy("telegram.proxy.address")
|
||||
copy("telegram.proxy.port")
|
||||
@@ -276,55 +210,43 @@ class Config(DictWithRecursion):
|
||||
else:
|
||||
copy("logging")
|
||||
|
||||
self._data = base._data
|
||||
self.save()
|
||||
|
||||
def _get_permissions(self, key: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
|
||||
def _get_permissions(self, key: str) -> Permissions:
|
||||
level = self["bridge.permissions"].get(key, "")
|
||||
admin = level == "admin"
|
||||
matrix_puppeting = level == "full" or admin
|
||||
puppeting = level == "puppeting" or matrix_puppeting
|
||||
user = level == "user" or puppeting
|
||||
relaybot = level == "relaybot" or user
|
||||
return relaybot, user, puppeting, matrix_puppeting, admin, level
|
||||
return Permissions(relaybot, user, puppeting, matrix_puppeting, admin, level)
|
||||
|
||||
def get_permissions(self, mxid: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
|
||||
permissions = self["bridge.permissions"] or {}
|
||||
def get_permissions(self, mxid: UserID) -> Permissions:
|
||||
permissions = self["bridge.permissions"]
|
||||
if mxid in permissions:
|
||||
return self._get_permissions(mxid)
|
||||
|
||||
homeserver = mxid[mxid.index(":") + 1:]
|
||||
_, homeserver = Client.parse_user_id(mxid)
|
||||
if homeserver in permissions:
|
||||
return self._get_permissions(homeserver)
|
||||
|
||||
return self._get_permissions("*")
|
||||
|
||||
def generate_registration(self) -> None:
|
||||
@property
|
||||
def namespaces(self) -> Dict[str, List[Dict[str, Any]]]:
|
||||
homeserver = self["homeserver.domain"]
|
||||
|
||||
username_format = self.get("bridge.username_template", "telegram_{userid}") \
|
||||
.format(userid=".+")
|
||||
alias_format = self.get("bridge.alias_template", "telegram_{groupname}") \
|
||||
.format(groupname=".+")
|
||||
username_format = self["bridge.username_template"].format(userid=".+")
|
||||
alias_format = self["bridge.alias_template"].format(groupname=".+")
|
||||
group_id = ({"group_id": self["appservice.community_id"]}
|
||||
if self["appservice.community_id"] else {})
|
||||
|
||||
self.set("appservice.as_token", self._new_token())
|
||||
self.set("appservice.hs_token", self._new_token())
|
||||
|
||||
self._registration = {
|
||||
"id": self["appservice.id"] or "telegram",
|
||||
"as_token": self["appservice.as_token"],
|
||||
"hs_token": self["appservice.hs_token"],
|
||||
"namespaces": {
|
||||
"users": [{
|
||||
"exclusive": True,
|
||||
"regex": f"@{username_format}:{homeserver}"
|
||||
}],
|
||||
"aliases": [{
|
||||
"exclusive": True,
|
||||
"regex": f"#{alias_format}:{homeserver}"
|
||||
}]
|
||||
},
|
||||
"url": self["appservice.address"],
|
||||
"sender_localpart": self["appservice.bot_username"],
|
||||
"rate_limited": False
|
||||
return {
|
||||
"users": [{
|
||||
"exclusive": True,
|
||||
"regex": f"@{username_format}:{homeserver}",
|
||||
**group_id,
|
||||
}],
|
||||
"aliases": [{
|
||||
"exclusive": True,
|
||||
"regex": f"#{alias_format}:{homeserver}",
|
||||
}]
|
||||
}
|
||||
|
||||
+29
-17
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -15,31 +14,44 @@
|
||||
# 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, Tuple, TYPE_CHECKING
|
||||
import asyncio
|
||||
|
||||
from alchemysession import AlchemySessionContainer
|
||||
|
||||
from mautrix.appservice import AppService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncio
|
||||
|
||||
from alchemysession import AlchemySessionContainer
|
||||
from mautrix_appservice import AppService
|
||||
|
||||
from .web import PublicBridgeWebsite, ProvisioningAPI
|
||||
from .config import Config
|
||||
from .bot import Bot
|
||||
from .matrix import MatrixHandler
|
||||
from .__main__ import TelegramBridge
|
||||
|
||||
|
||||
class Context:
|
||||
def __init__(self, az: 'AppService', config: 'Config', loop: 'asyncio.AbstractEventLoop',
|
||||
session_container: 'AlchemySessionContainer', bot: Optional['Bot']) -> None:
|
||||
self.az = az # type: AppService
|
||||
self.config = config # type: Config
|
||||
self.loop = loop # type: asyncio.AbstractEventLoop
|
||||
self.bot = bot # type: Optional[Bot]
|
||||
self.mx = None # type: Optional[MatrixHandler]
|
||||
self.session_container = session_container # type: AlchemySessionContainer
|
||||
self.public_website = None # type: Optional[PublicBridgeWebsite]
|
||||
self.provisioning_api = None # type: Optional[ProvisioningAPI]
|
||||
az: AppService
|
||||
config: 'Config'
|
||||
loop: asyncio.AbstractEventLoop
|
||||
bridge: 'TelegramBridge'
|
||||
bot: Optional['Bot']
|
||||
mx: Optional['MatrixHandler']
|
||||
session_container: AlchemySessionContainer
|
||||
public_website: Optional['PublicBridgeWebsite']
|
||||
provisioning_api: Optional['ProvisioningAPI']
|
||||
|
||||
def __init__(self, az: AppService, config: 'Config', loop: asyncio.AbstractEventLoop,
|
||||
session_container: AlchemySessionContainer, bridge: 'TelegramBridge',
|
||||
bot: Optional['Bot']) -> None:
|
||||
self.az = az
|
||||
self.config = config
|
||||
self.loop = loop
|
||||
self.bridge = bridge
|
||||
self.bot = bot
|
||||
self.mx = None
|
||||
self.session_container = session_container
|
||||
self.public_website = None
|
||||
self.provisioning_api = None
|
||||
|
||||
@property
|
||||
def core(self) -> Tuple['AppService', 'Config', 'asyncio.AbstractEventLoop', Optional['Bot']]:
|
||||
def core(self) -> Tuple[AppService, 'Config', asyncio.AbstractEventLoop, Optional['Bot']]:
|
||||
return self.az, self.config, self.loop, self.bot
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,20 +13,22 @@
|
||||
#
|
||||
# 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 .base import Base
|
||||
from sqlalchemy.engine.base import Engine
|
||||
|
||||
from mautrix.bridge.db import UserProfile, RoomState
|
||||
|
||||
from .bot_chat import BotChat
|
||||
from .message import Message
|
||||
from .portal import Portal
|
||||
from .puppet import Puppet
|
||||
from .room_state import RoomState
|
||||
from .telegram_file import TelegramFile
|
||||
from .user import User, UserPortal, Contact
|
||||
from .user_profile import UserProfile
|
||||
|
||||
|
||||
def init(db_engine) -> 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()
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# 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 abc import abstractmethod
|
||||
|
||||
from sqlalchemy import Table
|
||||
from sqlalchemy.engine.base import Engine
|
||||
from sqlalchemy.engine.result import RowProxy
|
||||
from sqlalchemy.sql.base import ImmutableColumnCollection
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
|
||||
class BaseBase:
|
||||
db = None # type: Engine
|
||||
t = None # type: Table
|
||||
__table__ = None # type: Table
|
||||
c = None # type: ImmutableColumnCollection
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def _one_or_none(cls, rows: RowProxy):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _select_one_or_none(cls, *args):
|
||||
return cls._one_or_none(cls.db.execute(cls.t.select().where(*args)))
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def _edit_identity(self):
|
||||
pass
|
||||
|
||||
def update(self, **values) -> None:
|
||||
with self.db.begin() as conn:
|
||||
conn.execute(self.t.update()
|
||||
.where(self._edit_identity)
|
||||
.values(**values))
|
||||
for key, value in values.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
def delete(self) -> None:
|
||||
with self.db.begin() as conn:
|
||||
conn.execute(self.t.delete().where(self._edit_identity))
|
||||
|
||||
Base = declarative_base(cls=BaseBase)
|
||||
@@ -1,26 +0,0 @@
|
||||
from abc import abstractmethod
|
||||
|
||||
from sqlalchemy import Table
|
||||
from sqlalchemy.engine.base import Engine
|
||||
from sqlalchemy.engine.result import RowProxy
|
||||
from sqlalchemy.sql.base import ImmutableColumnCollection
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
class Base(declarative_base):
|
||||
db: Engine
|
||||
t: Table
|
||||
__table__: Table
|
||||
c: ImmutableColumnCollection
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def _one_or_none(cls, rows: RowProxy): ...
|
||||
|
||||
@classmethod
|
||||
def _select_one_or_none(cls, *args): ...
|
||||
|
||||
def _edit_identity(self): ...
|
||||
|
||||
def update(self, **values) -> None: ...
|
||||
|
||||
def delete(self) -> None: ...
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -18,28 +17,22 @@ from typing import Iterable
|
||||
|
||||
from sqlalchemy import Column, Integer, String
|
||||
|
||||
from mautrix.util.db import Base
|
||||
|
||||
from ..types import TelegramID
|
||||
from .base import Base
|
||||
|
||||
|
||||
# Fucking Telegram not telling bots what chats they are in 3:<
|
||||
class BotChat(Base):
|
||||
__tablename__ = "bot_chat"
|
||||
id = Column(Integer, primary_key=True) # type: TelegramID
|
||||
type = Column(String, nullable=False)
|
||||
id: TelegramID = Column(Integer, primary_key=True)
|
||||
type: str = Column(String, nullable=False)
|
||||
|
||||
@classmethod
|
||||
def delete(cls, chat_id: TelegramID) -> None:
|
||||
def delete_by_id(cls, chat_id: TelegramID) -> None:
|
||||
with cls.db.begin() as conn:
|
||||
conn.execute(cls.t.delete().where(cls.c.id == chat_id))
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> Iterable['BotChat']:
|
||||
rows = cls.db.execute(cls.t.select())
|
||||
for row in rows:
|
||||
chat_id, chat_type = row
|
||||
yield cls(id=chat_id, type=chat_type)
|
||||
|
||||
def insert(self) -> None:
|
||||
with self.db.begin() as conn:
|
||||
conn.execute(self.t.insert().values(id=self.id, type=self.type))
|
||||
return cls._select_all()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,43 +13,46 @@
|
||||
#
|
||||
# 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 sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, select
|
||||
from sqlalchemy.engine.result import RowProxy
|
||||
from typing import Optional, List
|
||||
from typing import Optional, Iterator
|
||||
|
||||
from ..types import MatrixRoomID, MatrixEventID, TelegramID
|
||||
from .base import Base
|
||||
from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, desc, select
|
||||
|
||||
from mautrix.types import RoomID, EventID
|
||||
from mautrix.util.db import Base
|
||||
|
||||
from ..types import TelegramID
|
||||
|
||||
|
||||
class Message(Base):
|
||||
__tablename__ = "message"
|
||||
|
||||
mxid = Column(String) # type: MatrixEventID
|
||||
mx_room = Column(String) # type: MatrixRoomID
|
||||
tgid = Column(Integer, primary_key=True) # type: TelegramID
|
||||
tg_space = Column(Integer, primary_key=True) # type: TelegramID
|
||||
mxid: EventID = Column(String)
|
||||
mx_room: RoomID = Column(String)
|
||||
tgid: TelegramID = Column(Integer, primary_key=True)
|
||||
tg_space: TelegramID = Column(Integer, primary_key=True)
|
||||
edit_index: int = Column(Integer, primary_key=True)
|
||||
|
||||
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
|
||||
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room_2"),)
|
||||
|
||||
@classmethod
|
||||
def _one_or_none(cls, rows: RowProxy) -> Optional['Message']:
|
||||
try:
|
||||
mxid, mx_room, tgid, tg_space = next(rows)
|
||||
return cls(mxid=mxid, mx_room=mx_room, tgid=tgid, tg_space=tg_space)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _all(rows: RowProxy) -> List['Message']:
|
||||
return [Message(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3])
|
||||
for row in rows]
|
||||
def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> Iterator['Message']:
|
||||
return cls._select_all(cls.c.tgid == tgid, cls.c.tg_space == tg_space)
|
||||
|
||||
@classmethod
|
||||
def get_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> Optional['Message']:
|
||||
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space))
|
||||
def get_one_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID, edit_index: int = 0
|
||||
) -> Optional['Message']:
|
||||
if edit_index < 0:
|
||||
return cls._one_or_none(cls.db.execute(
|
||||
cls.t.select()
|
||||
.where(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space))
|
||||
.order_by(desc(cls.c.edit_index))
|
||||
.limit(1).offset(-edit_index - 1)))
|
||||
else:
|
||||
return cls._select_one_or_none(cls.c.tgid == tgid, cls.c.tg_space == tg_space,
|
||||
cls.c.edit_index == edit_index)
|
||||
|
||||
@classmethod
|
||||
def count_spaces_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID) -> int:
|
||||
def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> int:
|
||||
rows = cls.db.execute(select([func.count(cls.c.tg_space)])
|
||||
.where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room)))
|
||||
try:
|
||||
@@ -60,31 +62,23 @@ class Message(Base):
|
||||
return 0
|
||||
|
||||
@classmethod
|
||||
def get_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID, tg_space: TelegramID
|
||||
def get_by_mxid(cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
|
||||
) -> Optional['Message']:
|
||||
return cls._select_one_or_none(and_(cls.c.mxid == mxid,
|
||||
cls.c.mx_room == mx_room,
|
||||
cls.c.tg_space == tg_space))
|
||||
return cls._select_one_or_none(cls.c.mxid == mxid, cls.c.mx_room == mx_room,
|
||||
cls.c.tg_space == tg_space)
|
||||
|
||||
@classmethod
|
||||
def update_by_tgid(cls, s_tgid: TelegramID, s_tg_space: TelegramID, **values) -> None:
|
||||
def update_by_tgid(cls, s_tgid: TelegramID, s_tg_space: TelegramID, s_edit_index: int,
|
||||
**values) -> None:
|
||||
with cls.db.begin() as conn:
|
||||
conn.execute(cls.t.update()
|
||||
.where(and_(cls.c.tgid == s_tgid, cls.c.tg_space == s_tg_space))
|
||||
.where(and_(cls.c.tgid == s_tgid, cls.c.tg_space == s_tg_space,
|
||||
cls.c.edit_index == s_edit_index))
|
||||
.values(**values))
|
||||
|
||||
@classmethod
|
||||
def update_by_mxid(cls, s_mxid: MatrixEventID, s_mx_room: MatrixRoomID, **values) -> None:
|
||||
def update_by_mxid(cls, s_mxid: EventID, s_mx_room: RoomID, **values) -> None:
|
||||
with cls.db.begin() as conn:
|
||||
conn.execute(cls.t.update()
|
||||
.where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room))
|
||||
.values(**values))
|
||||
|
||||
@property
|
||||
def _edit_identity(self):
|
||||
return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space)
|
||||
|
||||
def insert(self) -> None:
|
||||
with self.db.begin() as conn:
|
||||
conn.execute(self.t.insert().values(mxid=self.mxid, mx_room=self.mx_room,
|
||||
tgid=self.tgid, tg_space=self.tg_space))
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,68 +13,44 @@
|
||||
#
|
||||
# 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 sqlalchemy import Column, Integer, String, Boolean, Text, and_
|
||||
from sqlalchemy.engine.result import RowProxy
|
||||
from typing import Optional
|
||||
|
||||
from ..types import MatrixRoomID, TelegramID
|
||||
from .base import Base
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Text, func
|
||||
|
||||
from mautrix.types import RoomID
|
||||
from mautrix.util.db import Base
|
||||
|
||||
from ..types import TelegramID
|
||||
|
||||
|
||||
class Portal(Base):
|
||||
__tablename__ = "portal"
|
||||
|
||||
# Telegram chat information
|
||||
tgid = Column(Integer, primary_key=True) # type: TelegramID
|
||||
tg_receiver = Column(Integer, primary_key=True) # type: TelegramID
|
||||
peer_type = Column(String, nullable=False)
|
||||
megagroup = Column(Boolean)
|
||||
tgid: TelegramID = Column(Integer, primary_key=True)
|
||||
tg_receiver: TelegramID = Column(Integer, primary_key=True)
|
||||
peer_type: str = Column(String, nullable=False)
|
||||
megagroup: bool = Column(Boolean)
|
||||
|
||||
# Matrix portal information
|
||||
mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID]
|
||||
mxid: RoomID = Column(String, unique=True, nullable=True)
|
||||
|
||||
config = Column(Text, nullable=True)
|
||||
config: str = Column(Text, nullable=True)
|
||||
|
||||
# Telegram chat metadata
|
||||
username = Column(String, nullable=True)
|
||||
title = Column(String, nullable=True)
|
||||
about = Column(String, nullable=True)
|
||||
photo_id = Column(String, nullable=True)
|
||||
|
||||
@classmethod
|
||||
def scan(cls, row) -> Optional['Portal']:
|
||||
(tgid, tg_receiver, peer_type, megagroup, mxid, config, username, title, about,
|
||||
photo_id) = row
|
||||
return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup,
|
||||
mxid=mxid, config=config, username=username, title=title, about=about,
|
||||
photo_id=photo_id)
|
||||
|
||||
@classmethod
|
||||
def _one_or_none(cls, rows: RowProxy) -> Optional['Portal']:
|
||||
try:
|
||||
return cls.scan(next(rows))
|
||||
except StopIteration:
|
||||
return None
|
||||
username: str = Column(String, nullable=True)
|
||||
title: str = Column(String, nullable=True)
|
||||
about: str = Column(String, nullable=True)
|
||||
photo_id: str = Column(String, nullable=True)
|
||||
|
||||
@classmethod
|
||||
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']:
|
||||
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver))
|
||||
return cls._select_one_or_none(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver)
|
||||
|
||||
@classmethod
|
||||
def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']:
|
||||
def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
|
||||
return cls._select_one_or_none(cls.c.mxid == mxid)
|
||||
|
||||
@classmethod
|
||||
def get_by_username(cls, username: str) -> Optional['Portal']:
|
||||
return cls._select_one_or_none(cls.c.username == username)
|
||||
|
||||
@property
|
||||
def _edit_identity(self):
|
||||
return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver)
|
||||
|
||||
def insert(self) -> None:
|
||||
with self.db.begin() as conn:
|
||||
conn.execute(self.t.insert().values(
|
||||
tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
|
||||
megagroup=self.megagroup, mxid=self.mxid, config=self.config,
|
||||
username=self.username, title=self.title, about=self.about, photo_id=self.photo_id))
|
||||
return cls._select_one_or_none(func.lower(cls.c.username) == username)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,74 +13,48 @@
|
||||
#
|
||||
# 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 sqlalchemy import Column, Integer, String, Boolean
|
||||
from sqlalchemy.engine.result import RowProxy
|
||||
from sqlalchemy.sql import expression
|
||||
from typing import Optional, Iterable
|
||||
|
||||
from ..types import MatrixUserID, MatrixRoomID, TelegramID
|
||||
from .base import Base
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from sqlalchemy.sql import expression, func
|
||||
|
||||
from mautrix.types import UserID, SyncToken
|
||||
from mautrix.util.db import Base
|
||||
|
||||
from ..types import TelegramID
|
||||
|
||||
|
||||
class Puppet(Base):
|
||||
__tablename__ = "puppet"
|
||||
|
||||
id = Column(Integer, primary_key=True) # type: TelegramID
|
||||
custom_mxid = Column(String, nullable=True) # type: Optional[MatrixUserID]
|
||||
access_token = Column(String, nullable=True)
|
||||
displayname = Column(String, nullable=True)
|
||||
displayname_source = Column(Integer, nullable=True) # type: Optional[TelegramID]
|
||||
username = Column(String, nullable=True)
|
||||
photo_id = Column(String, nullable=True)
|
||||
is_bot = Column(Boolean, nullable=True)
|
||||
matrix_registered = Column(Boolean, nullable=False, server_default=expression.false())
|
||||
|
||||
@classmethod
|
||||
def scan(cls, row) -> Optional['Puppet']:
|
||||
(id, custom_mxid, access_token, displayname, displayname_source, username, photo_id,
|
||||
is_bot, matrix_registered) = row
|
||||
return cls(id=id, custom_mxid=custom_mxid, access_token=access_token,
|
||||
displayname=displayname, displayname_source=displayname_source,
|
||||
username=username, photo_id=photo_id, is_bot=is_bot,
|
||||
matrix_registered=matrix_registered)
|
||||
|
||||
@classmethod
|
||||
def _one_or_none(cls, rows: RowProxy) -> Optional['Puppet']:
|
||||
try:
|
||||
return cls.scan(next(rows))
|
||||
except StopIteration:
|
||||
return None
|
||||
id: TelegramID = Column(Integer, primary_key=True)
|
||||
custom_mxid: UserID = Column(String, nullable=True)
|
||||
access_token: str = Column(String, nullable=True)
|
||||
next_batch: SyncToken = Column(String, nullable=True)
|
||||
displayname: str = Column(String, nullable=True)
|
||||
displayname_source: TelegramID = Column(Integer, nullable=True)
|
||||
username: str = Column(String, nullable=True)
|
||||
photo_id: str = Column(String, nullable=True)
|
||||
is_bot: bool = Column(Boolean, nullable=True)
|
||||
matrix_registered: bool = Column(Boolean, nullable=False, server_default=expression.false())
|
||||
disable_updates: bool = Column(Boolean, nullable=False, server_default=expression.false())
|
||||
|
||||
@classmethod
|
||||
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
|
||||
rows = cls.db.execute(cls.t.select().where(cls.c.custom_mxid != None))
|
||||
for row in rows:
|
||||
yield cls.scan(row)
|
||||
yield from cls._select_all(cls.c.custom_mxid != None)
|
||||
|
||||
@classmethod
|
||||
def get_by_tgid(cls, tgid: TelegramID) -> Optional['Puppet']:
|
||||
return cls._select_one_or_none(cls.c.id == tgid)
|
||||
|
||||
@classmethod
|
||||
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
|
||||
def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
|
||||
return cls._select_one_or_none(cls.c.custom_mxid == mxid)
|
||||
|
||||
@classmethod
|
||||
def get_by_username(cls, username: str) -> Optional['Puppet']:
|
||||
return cls._select_one_or_none(cls.c.username == username)
|
||||
return cls._select_one_or_none(func.lower(cls.c.username) == username)
|
||||
|
||||
@classmethod
|
||||
def get_by_displayname(cls, displayname: str) -> Optional['Puppet']:
|
||||
return cls._select_one_or_none(cls.c.displayname == displayname)
|
||||
|
||||
@property
|
||||
def _edit_identity(self):
|
||||
return self.c.id == self.id
|
||||
|
||||
def insert(self) -> None:
|
||||
with self.db.begin() as conn:
|
||||
conn.execute(self.t.insert().values(
|
||||
id=self.id, custom_mxid=self.custom_mxid, access_token=self.access_token,
|
||||
displayname=self.displayname, displayname_source=self.displayname_source,
|
||||
username=self.username, photo_id=self.photo_id, is_bot=self.is_bot,
|
||||
matrix_registered=self.matrix_registered))
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# 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 sqlalchemy import Column, String, Text
|
||||
from typing import Dict, Optional
|
||||
import json
|
||||
|
||||
from ..types import MatrixRoomID
|
||||
from .base import Base
|
||||
|
||||
|
||||
class RoomState(Base):
|
||||
__tablename__ = "mx_room_state"
|
||||
|
||||
room_id = Column(String, primary_key=True) # type: MatrixRoomID
|
||||
power_levels = Column("power_levels", Text, nullable=True) # type: Optional[Dict]
|
||||
|
||||
@property
|
||||
def _power_levels_text(self) -> Optional[str]:
|
||||
return json.dumps(self.power_levels) if self.power_levels else None
|
||||
|
||||
@property
|
||||
def has_power_levels(self) -> bool:
|
||||
return bool(self.power_levels)
|
||||
|
||||
@classmethod
|
||||
def get(cls, room_id: MatrixRoomID) -> Optional['RoomState']:
|
||||
rows = cls.db.execute(cls.t.select().where(cls.c.room_id == room_id))
|
||||
try:
|
||||
room_id, power_levels_text = next(rows)
|
||||
return cls(room_id=room_id, power_levels=(json.loads(power_levels_text)
|
||||
if power_levels_text else None))
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
def update(self) -> None:
|
||||
with self.db.begin() as conn:
|
||||
conn.execute(self.t.update()
|
||||
.where(self.c.room_id == self.room_id)
|
||||
.values(power_levels=self._power_levels_text))
|
||||
|
||||
@property
|
||||
def _edit_identity(self):
|
||||
return self.c.room_id == self.room_id
|
||||
|
||||
def insert(self) -> None:
|
||||
with self.db.begin() as conn:
|
||||
conn.execute(self.t.insert().values(room_id=self.room_id,
|
||||
power_levels=self._power_levels_text))
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,38 +13,39 @@
|
||||
#
|
||||
# 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 sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
|
||||
from typing import Optional
|
||||
|
||||
from .base import Base
|
||||
from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
|
||||
from sqlalchemy.engine.result import RowProxy
|
||||
|
||||
from mautrix.types import ContentURI
|
||||
from mautrix.util.db import Base
|
||||
|
||||
|
||||
class TelegramFile(Base):
|
||||
__tablename__ = "telegram_file"
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
mxc = Column(String)
|
||||
mime_type = Column(String)
|
||||
was_converted = Column(Boolean)
|
||||
timestamp = Column(BigInteger)
|
||||
size = Column(Integer, nullable=True)
|
||||
width = Column(Integer, nullable=True)
|
||||
height = Column(Integer, nullable=True)
|
||||
thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
|
||||
thumbnail = None # type: Optional[TelegramFile]
|
||||
id: str = Column(String, primary_key=True)
|
||||
mxc: ContentURI = Column(String)
|
||||
mime_type: str = Column(String)
|
||||
was_converted: bool = Column(Boolean)
|
||||
timestamp: int = Column(BigInteger)
|
||||
size: Optional[int] = Column(Integer, nullable=True)
|
||||
width: Optional[int] = Column(Integer, nullable=True)
|
||||
height: Optional[int] = Column(Integer, nullable=True)
|
||||
thumbnail_id: str = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
|
||||
thumbnail: Optional['TelegramFile'] = None
|
||||
|
||||
@classmethod
|
||||
def scan(cls, row: RowProxy) -> 'TelegramFile':
|
||||
telegram_file: TelegramFile = super().scan(row)
|
||||
if isinstance(telegram_file.thumbnail, str):
|
||||
telegram_file.thumbnail = cls.get(telegram_file.thumbnail)
|
||||
return telegram_file
|
||||
|
||||
@classmethod
|
||||
def get(cls, loc_id: str) -> Optional['TelegramFile']:
|
||||
rows = cls.db.execute(cls.t.select().where(cls.c.id == loc_id))
|
||||
try:
|
||||
loc_id, mxc, mime, conv, ts, s, w, h, thumb_id = next(rows)
|
||||
thumb = None
|
||||
if thumb_id:
|
||||
thumb = cls.get(thumb_id)
|
||||
return cls(id=loc_id, mxc=mxc, mime_type=mime, was_converted=conv, timestamp=ts,
|
||||
size=s, width=w, height=h, thumbnail_id=thumb_id, thumbnail=thumb)
|
||||
except StopIteration:
|
||||
return None
|
||||
return cls._select_one_or_none(cls.c.id == loc_id)
|
||||
|
||||
def insert(self) -> None:
|
||||
with self.db.begin() as conn:
|
||||
|
||||
+23
-45
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,61 +13,40 @@
|
||||
#
|
||||
# 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 sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
|
||||
from sqlalchemy.engine.result import RowProxy
|
||||
from typing import Optional, Iterable, Tuple
|
||||
|
||||
from ..types import MatrixUserID, TelegramID
|
||||
from .base import Base
|
||||
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String, func
|
||||
|
||||
from mautrix.types import UserID
|
||||
from mautrix.util.db import Base
|
||||
|
||||
from ..types import TelegramID
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "user"
|
||||
|
||||
mxid = Column(String, primary_key=True) # type: MatrixUserID
|
||||
tgid = Column(Integer, nullable=True, unique=True) # type: Optional[TelegramID]
|
||||
tg_username = Column(String, nullable=True)
|
||||
tg_phone = Column(String, nullable=True)
|
||||
saved_contacts = Column(Integer, default=0, nullable=False)
|
||||
mxid: UserID = Column(String, primary_key=True)
|
||||
tgid: Optional[TelegramID] = Column(Integer, nullable=True, unique=True)
|
||||
tg_username: str = Column(String, nullable=True)
|
||||
tg_phone: str = Column(String, nullable=True)
|
||||
saved_contacts: int = Column(Integer, default=0, nullable=False)
|
||||
|
||||
@classmethod
|
||||
def _one_or_none(cls, rows: RowProxy) -> Optional['User']:
|
||||
try:
|
||||
mxid, tgid, tg_username, tg_phone, saved_contacts = next(rows)
|
||||
return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
|
||||
saved_contacts=saved_contacts)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> Iterable['User']:
|
||||
rows = cls.db.execute(cls.t.select())
|
||||
for row in rows:
|
||||
mxid, tgid, tg_username, tg_phone, saved_contacts = row
|
||||
yield cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
|
||||
saved_contacts=saved_contacts)
|
||||
def all_with_tgid(cls) -> Iterable['User']:
|
||||
return cls._select_all(cls.c.tgid != None)
|
||||
|
||||
@classmethod
|
||||
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
|
||||
return cls._select_one_or_none(cls.c.tgid == tgid)
|
||||
|
||||
@classmethod
|
||||
def get_by_mxid(cls, mxid: MatrixUserID) -> Optional['User']:
|
||||
def get_by_mxid(cls, mxid: UserID) -> Optional['User']:
|
||||
return cls._select_one_or_none(cls.c.mxid == mxid)
|
||||
|
||||
@classmethod
|
||||
def get_by_username(cls, username: str) -> Optional['User']:
|
||||
return cls._select_one_or_none(cls.c.tg_username == username)
|
||||
|
||||
@property
|
||||
def _edit_identity(self):
|
||||
return self.c.mxid == self.mxid
|
||||
|
||||
def insert(self) -> None:
|
||||
with self.db.begin() as conn:
|
||||
conn.execute(self.t.insert().values(
|
||||
mxid=self.mxid, tgid=self.tgid, tg_username=self.tg_username,
|
||||
tg_phone=self.tg_phone, saved_contacts=self.saved_contacts))
|
||||
return cls._select_one_or_none(func.lower(cls.c.tg_username) == username)
|
||||
|
||||
@property
|
||||
def contacts(self) -> Iterable[TelegramID]:
|
||||
@@ -106,17 +84,17 @@ class User(Base):
|
||||
|
||||
def delete(self) -> None:
|
||||
super().delete()
|
||||
self.portals = None
|
||||
self.contacts = None
|
||||
self.portals = []
|
||||
self.contacts = []
|
||||
|
||||
|
||||
class UserPortal(Base):
|
||||
__tablename__ = "user_portal"
|
||||
|
||||
user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"),
|
||||
primary_key=True) # type: TelegramID
|
||||
portal = Column(Integer, primary_key=True) # type: TelegramID
|
||||
portal_receiver = Column(Integer, primary_key=True) # type: TelegramID
|
||||
user: TelegramID = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE",
|
||||
ondelete="CASCADE"), primary_key=True)
|
||||
portal: TelegramID = Column(Integer, primary_key=True)
|
||||
portal_receiver: TelegramID = Column(Integer, primary_key=True)
|
||||
|
||||
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
|
||||
("portal.tgid", "portal.tg_receiver"),
|
||||
@@ -126,5 +104,5 @@ class UserPortal(Base):
|
||||
class Contact(Base):
|
||||
__tablename__ = "contact"
|
||||
|
||||
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) # type: TelegramID
|
||||
contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True) # type: TelegramID
|
||||
user: TelegramID = Column(Integer, ForeignKey("user.tgid"), primary_key=True)
|
||||
contact: TelegramID = Column(Integer, ForeignKey("puppet.id"), primary_key=True)
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# 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 sqlalchemy import Column, String, and_
|
||||
from typing import Dict, Optional
|
||||
|
||||
from ..types import MatrixUserID, MatrixRoomID
|
||||
from .base import Base
|
||||
|
||||
|
||||
class UserProfile(Base):
|
||||
__tablename__ = "mx_user_profile"
|
||||
|
||||
room_id = Column(String, primary_key=True) # type: MatrixRoomID
|
||||
user_id = Column(String, primary_key=True) # type: MatrixUserID
|
||||
membership = Column(String, nullable=False, default="leave")
|
||||
displayname = Column(String, nullable=True)
|
||||
avatar_url = Column(String, nullable=True)
|
||||
|
||||
def dict(self) -> Dict[str, str]:
|
||||
return {
|
||||
"membership": self.membership,
|
||||
"displayname": self.displayname,
|
||||
"avatar_url": self.avatar_url,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get(cls, room_id: MatrixRoomID, user_id: MatrixUserID) -> Optional['UserProfile']:
|
||||
rows = cls.db.execute(
|
||||
cls.t.select().where(and_(cls.c.room_id == room_id, cls.c.user_id == user_id)))
|
||||
try:
|
||||
room_id, user_id, membership, displayname, avatar_url = next(rows)
|
||||
return cls(room_id=room_id, user_id=user_id, membership=membership,
|
||||
displayname=displayname, avatar_url=avatar_url)
|
||||
except StopIteration:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def delete_all(cls, room_id: MatrixRoomID) -> None:
|
||||
with cls.db.begin() as conn:
|
||||
conn.execute(cls.t.delete().where(cls.c.room_id == room_id))
|
||||
|
||||
def update(self) -> None:
|
||||
super().update(membership=self.membership, displayname=self.displayname,
|
||||
avatar_url=self.avatar_url)
|
||||
|
||||
@property
|
||||
def _edit_identity(self):
|
||||
return and_(self.c.room_id == self.room_id, self.c.user_id == self.user_id)
|
||||
|
||||
def insert(self) -> None:
|
||||
with self.db.begin() as conn:
|
||||
conn.execute(self.t.insert().values(room_id=self.room_id, user_id=self.user_id,
|
||||
membership=self.membership,
|
||||
displayname=self.displayname,
|
||||
avatar_url=self.avatar_url))
|
||||
@@ -1,9 +1,8 @@
|
||||
from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram,
|
||||
init_mx)
|
||||
from .from_telegram import (telegram_reply_to_matrix, telegram_to_matrix, init_tg)
|
||||
from .from_telegram import telegram_reply_to_matrix, telegram_to_matrix
|
||||
from .. import context as c
|
||||
|
||||
|
||||
def init(context: c.Context) -> None:
|
||||
init_mx(context)
|
||||
init_tg(context)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,29 +13,30 @@
|
||||
#
|
||||
# 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, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING, Dict, Any
|
||||
from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING
|
||||
import re
|
||||
import logging
|
||||
|
||||
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
|
||||
TypeMessageEntity)
|
||||
from telethon.helpers import add_surrogate, del_surrogate
|
||||
|
||||
from mautrix.types import RoomID, MessageEventContent
|
||||
|
||||
from ... import puppet as pu
|
||||
from ...types import TelegramID, MatrixRoomID
|
||||
from ...types import TelegramID
|
||||
from ...db import Message as DBMessage
|
||||
from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
|
||||
trim_reply_fallback_text)
|
||||
from .parser import ParsedMessage, parse_html
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...context import Context
|
||||
|
||||
log = logging.getLogger("mau.fmt.mx") # type: logging.Logger
|
||||
should_bridge_plaintext_highlights = False # type: bool
|
||||
log: logging.Logger = logging.getLogger("mau.fmt.mx")
|
||||
should_bridge_plaintext_highlights: bool = False
|
||||
|
||||
command_regex = re.compile(r"^!([A-Za-z0-9@]+)") # type: Pattern
|
||||
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)") # type: Pattern
|
||||
plain_mention_regex = None # type: Optional[Pattern]
|
||||
command_regex: Pattern = re.compile(r"^!([A-Za-z0-9@]+)")
|
||||
not_command_regex: Pattern = re.compile(r"^\\(![A-Za-z0-9@]+)")
|
||||
plain_mention_regex: Optional[Pattern] = None
|
||||
|
||||
|
||||
def plain_mention_to_html(match: Match) -> str:
|
||||
@@ -49,17 +49,22 @@ def plain_mention_to_html(match: Match) -> str:
|
||||
return "".join(match.groups())
|
||||
|
||||
|
||||
MAX_LENGTH = 4096
|
||||
CUTOFF_TEXT = " [message cut]"
|
||||
CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT)
|
||||
|
||||
|
||||
def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage:
|
||||
if len(message) > 4096:
|
||||
message = message[0:4082] + " [message cut]"
|
||||
if len(message) > MAX_LENGTH:
|
||||
message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT
|
||||
new_entities = []
|
||||
for entity in entities:
|
||||
if entity.offset > 4082:
|
||||
if entity.offset > CUT_MAX_LENGTH:
|
||||
continue
|
||||
if entity.offset + entity.length > 4082:
|
||||
entity.length = 4082 - entity.offset
|
||||
if entity.offset + entity.length > CUT_MAX_LENGTH:
|
||||
entity.length = CUT_MAX_LENGTH - entity.offset
|
||||
new_entities.append(entity)
|
||||
new_entities.append(MessageEntityItalic(4082, len(" [message cut]")))
|
||||
new_entities.append(MessageEntityItalic(CUT_MAX_LENGTH, len(CUTOFF_TEXT)))
|
||||
entities = new_entities
|
||||
return message, entities
|
||||
|
||||
@@ -76,8 +81,8 @@ def matrix_to_telegram(html: str) -> ParsedMessage:
|
||||
if should_bridge_plaintext_highlights:
|
||||
html = plain_mention_regex.sub(plain_mention_to_html, html)
|
||||
|
||||
text, entities = parse_html(add_surrogates(html))
|
||||
text = remove_surrogates(text.strip())
|
||||
text, entities = parse_html(add_surrogate(html))
|
||||
text = del_surrogate(text.strip())
|
||||
text, entities = cut_long_message(text, entities)
|
||||
|
||||
return text, entities
|
||||
@@ -85,27 +90,16 @@ def matrix_to_telegram(html: str) -> ParsedMessage:
|
||||
raise FormatError(f"Failed to convert Matrix format: {html}") from e
|
||||
|
||||
|
||||
def matrix_reply_to_telegram(content: Dict[str, Any], tg_space: TelegramID,
|
||||
room_id: Optional[MatrixRoomID] = None) -> Optional[TelegramID]:
|
||||
try:
|
||||
reply = content.get("m.relates_to", {}).get("m.in_reply_to", {})
|
||||
if not reply:
|
||||
return None
|
||||
room_id = room_id or reply["room_id"]
|
||||
event_id = reply["event_id"]
|
||||
def matrix_reply_to_telegram(content: MessageEventContent, tg_space: TelegramID,
|
||||
room_id: Optional[RoomID] = None) -> Optional[TelegramID]:
|
||||
event_id = content.get_reply_to()
|
||||
if not event_id:
|
||||
return
|
||||
content.trim_reply_fallback()
|
||||
|
||||
try:
|
||||
if content["format"] == "org.matrix.custom.html":
|
||||
content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"])
|
||||
except KeyError:
|
||||
pass
|
||||
content["body"] = trim_reply_fallback_text(content["body"])
|
||||
|
||||
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
|
||||
if message:
|
||||
return message.tgid
|
||||
except KeyError:
|
||||
pass
|
||||
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
|
||||
if message:
|
||||
return message.tgid
|
||||
return None
|
||||
|
||||
|
||||
@@ -121,10 +115,10 @@ def matrix_text_to_telegram(text: str) -> ParsedMessage:
|
||||
return text, entities
|
||||
|
||||
|
||||
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], str]]:
|
||||
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[Match], str]]:
|
||||
entities = []
|
||||
|
||||
def replacer(match) -> str:
|
||||
def replacer(match: Match) -> str:
|
||||
puppet = pu.Puppet.find_by_displayname(match.group(2))
|
||||
if puppet:
|
||||
offset = match.start()
|
||||
@@ -145,7 +139,7 @@ def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], st
|
||||
def init_mx(context: "Context") -> None:
|
||||
global plain_mention_regex, should_bridge_plaintext_highlights
|
||||
config = context.config
|
||||
dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)")
|
||||
dn_template = config["bridge.displayname_template"]
|
||||
dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+")
|
||||
plain_mention_regex = re.compile(f"^({dn_template})")
|
||||
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] or False
|
||||
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"]
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# 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 typing import Dict, List, Tuple
|
||||
|
||||
from html.parser import HTMLParser
|
||||
|
||||
|
||||
class HTMLNode(list):
|
||||
def __init__(self, tag: str, attrs: List[Tuple[str, str]]):
|
||||
super().__init__()
|
||||
self.tag = tag # type: str
|
||||
self.text = "" # type: str
|
||||
self.tail = "" # type: str
|
||||
self.attrib = dict(attrs) # type: Dict[str, str]
|
||||
|
||||
|
||||
class NodeifyingParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.stack = [HTMLNode("html", [])] # type: List[HTMLNode]
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
node = HTMLNode(tag, attrs)
|
||||
self.stack[-1].append(node)
|
||||
self.stack.append(node)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == self.stack[-1].tag:
|
||||
self.stack.pop()
|
||||
|
||||
def handle_data(self, data):
|
||||
if len(self.stack[-1]) > 0:
|
||||
self.stack[-1][-1].tail += data
|
||||
else:
|
||||
self.stack[-1].text += data
|
||||
|
||||
def error(self, message):
|
||||
pass
|
||||
|
||||
|
||||
def read_html(data: str) -> HTMLNode:
|
||||
parser = NodeifyingParser()
|
||||
parser.feed(data)
|
||||
return parser.stack[0]
|
||||
@@ -1,11 +0,0 @@
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
class HTMLNode(List['HTMLNode']):
|
||||
tag: str
|
||||
text: str
|
||||
tail: str
|
||||
attrib: Dict[str, str]
|
||||
|
||||
|
||||
def read_html(data: str) -> HTMLNode: ...
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,236 +13,77 @@
|
||||
#
|
||||
# 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, Tuple, Pattern
|
||||
import re
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
|
||||
MessageEntityMentionName as MentionName, MessageEntityEmail as Email,
|
||||
MessageEntityUrl as URL, MessageEntityTextUrl as TextURL,
|
||||
MessageEntityBold as Bold, MessageEntityItalic as Italic,
|
||||
MessageEntityCode as Code, MessageEntityPre as Pre,
|
||||
TypeMessageEntity)
|
||||
from telethon.tl.types import TypeMessageEntity
|
||||
|
||||
from mautrix.types import UserID, RoomID
|
||||
from mautrix.util.formatter import MatrixParser as BaseMatrixParser, RecursionContext
|
||||
from mautrix.util.formatter.html_reader_htmlparser import read_html, HTMLNode
|
||||
|
||||
from ... import user as u, puppet as pu, portal as po
|
||||
from ...types import MatrixUserID
|
||||
from ..util import html_to_unicode
|
||||
from .telegram_message import TelegramMessage, Entity, offset_length_multiply
|
||||
from .telegram_message import TelegramMessage, TelegramEntityType
|
||||
|
||||
from .html_reader import HTMLNode, read_html
|
||||
|
||||
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
|
||||
|
||||
|
||||
def parse_html(input_html: str) -> ParsedMessage:
|
||||
return MatrixParser.parse(input_html)
|
||||
msg = MatrixParser.parse(input_html)
|
||||
return msg.text, msg.telegram_entities
|
||||
|
||||
|
||||
class RecursionContext:
|
||||
def __init__(self, strip_linebreaks: bool = True, ul_depth: int = 0):
|
||||
self.strip_linebreaks = strip_linebreaks # type: bool
|
||||
self.ul_depth = ul_depth # type: int
|
||||
self._inited = True # type: bool
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if getattr(self, "_inited", False) is True:
|
||||
raise TypeError("'RecursionContext' object is immutable")
|
||||
super(RecursionContext, self).__setattr__(key, value)
|
||||
|
||||
def enter_list(self) -> 'RecursionContext':
|
||||
return RecursionContext(strip_linebreaks=self.strip_linebreaks, ul_depth=self.ul_depth + 1)
|
||||
|
||||
def enter_code_block(self) -> 'RecursionContext':
|
||||
return RecursionContext(strip_linebreaks=False, ul_depth=self.ul_depth)
|
||||
|
||||
|
||||
class MatrixParser:
|
||||
mention_regex = re.compile("https://matrix.to/#/(@.+:.+)") # type: Pattern
|
||||
room_regex = re.compile("https://matrix.to/#/(#.+:.+)") # type: Pattern
|
||||
block_tags = ("p", "pre", "blockquote",
|
||||
"ol", "ul", "li",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"div", "hr", "table") # type: Tuple[str, ...]
|
||||
list_bullets = ("●", "○", "■", "‣") # type: Tuple[str, ...]
|
||||
class MatrixParser(BaseMatrixParser[TelegramMessage]):
|
||||
e = TelegramEntityType
|
||||
fs = TelegramMessage
|
||||
read_html = read_html
|
||||
|
||||
@classmethod
|
||||
def list_bullet(cls, depth: int) -> str:
|
||||
return cls.list_bullets[(depth - 1) % len(cls.list_bullets)] + " "
|
||||
def custom_node_to_fstring(cls, node: HTMLNode, ctx: RecursionContext
|
||||
) -> Optional[TelegramMessage]:
|
||||
msg = cls.tag_aware_parse_node(node, ctx)
|
||||
if node.tag == "command":
|
||||
msg.format(TelegramEntityType.COMMAND)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def list_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||
ordered = node.tag == "ol"
|
||||
tagged_children = cls.node_to_tagged_tmessages(node, ctx)
|
||||
counter = 1
|
||||
indent_length = 0
|
||||
if ordered:
|
||||
try:
|
||||
counter = int(node.attrib.get("start", "1"))
|
||||
except ValueError:
|
||||
counter = 1
|
||||
|
||||
longest_index = counter - 1 + len(tagged_children)
|
||||
indent_length = len(str(longest_index))
|
||||
indent = (indent_length + 4) * " "
|
||||
children = [] # type: List[TelegramMessage]
|
||||
for child, tag in tagged_children:
|
||||
if tag != "li":
|
||||
continue
|
||||
|
||||
if ordered:
|
||||
prefix = f"{counter}. "
|
||||
counter += 1
|
||||
else:
|
||||
prefix = cls.list_bullet(ctx.ul_depth)
|
||||
child = child.prepend(prefix)
|
||||
parts = child.split("\n")
|
||||
parts = parts[:1] + [part.prepend(indent) for part in parts[1:]]
|
||||
child = TelegramMessage.join(parts, "\n")
|
||||
children.append(child)
|
||||
return TelegramMessage.join(children, "\n")
|
||||
def user_pill_to_fstring(cls, msg: TelegramMessage, user_id: UserID) -> TelegramMessage:
|
||||
user = (pu.Puppet.get_by_mxid(user_id)
|
||||
or u.User.get_by_mxid(user_id, create=False))
|
||||
if not user:
|
||||
return msg
|
||||
if user.username:
|
||||
return TelegramMessage(f"@{user.username}").format(TelegramEntityType.MENTION)
|
||||
elif user.tgid:
|
||||
displayname = user.plain_displayname or msg.text
|
||||
return TelegramMessage(displayname).format(TelegramEntityType.MENTION_NAME,
|
||||
user_id=user.tgid)
|
||||
return msg
|
||||
|
||||
@classmethod
|
||||
def blockquote_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||
def url_to_fstring(cls, msg: TelegramMessage, url: str) -> TelegramMessage:
|
||||
if url == msg.text:
|
||||
return msg.format(cls.e.URL)
|
||||
else:
|
||||
return msg.format(cls.e.INLINE_URL, url=url)
|
||||
|
||||
@classmethod
|
||||
def room_pill_to_fstring(cls, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage:
|
||||
username = po.Portal.get_username_from_mx_alias(room_id)
|
||||
portal = po.Portal.find_by_username(username)
|
||||
if portal and portal.username:
|
||||
return TelegramMessage(f"@{portal.username}").format(TelegramEntityType.MENTION)
|
||||
|
||||
@classmethod
|
||||
def header_to_fstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||
children = cls.node_to_fstrings(node, ctx)
|
||||
length = int(node.tag[1])
|
||||
prefix = "#" * length + " "
|
||||
return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD)
|
||||
|
||||
@classmethod
|
||||
def blockquote_to_fstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||
msg = cls.tag_aware_parse_node(node, ctx)
|
||||
children = msg.trim().split("\n")
|
||||
children = [child.prepend("> ") for child in children]
|
||||
return TelegramMessage.join(children, "\n")
|
||||
|
||||
@classmethod
|
||||
def header_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||
children = cls.node_to_tmessages(node, ctx)
|
||||
length = int(node.tag[1])
|
||||
prefix = "#" * length + " "
|
||||
return TelegramMessage.join(children, "").prepend(prefix).format(Bold)
|
||||
|
||||
@classmethod
|
||||
def basic_format_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||
msg = cls.tag_aware_parse_node(node, ctx)
|
||||
if node.tag in ("b", "strong"):
|
||||
msg.format(Bold)
|
||||
elif node.tag in ("i", "em"):
|
||||
msg.format(Italic)
|
||||
elif node.tag == "command":
|
||||
msg.format(Command)
|
||||
elif node.tag in ("s", "strike", "del"):
|
||||
msg.text = html_to_unicode(msg.text, "\u0336")
|
||||
elif node.tag in ("u", "ins"):
|
||||
msg.text = html_to_unicode(msg.text, "\u0332")
|
||||
|
||||
if node.tag in ("s", "strike", "del", "u", "ins"):
|
||||
msg.entities = Entity.adjust(msg.entities, offset_length_multiply(2))
|
||||
|
||||
return msg
|
||||
|
||||
@classmethod
|
||||
def link_to_tstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||
msg = cls.tag_aware_parse_node(node, ctx)
|
||||
href = node.attrib.get("href", "")
|
||||
if not href:
|
||||
return msg
|
||||
|
||||
if href.startswith("mailto:"):
|
||||
return TelegramMessage(href[len("mailto:"):]).format(Email)
|
||||
|
||||
mention = cls.mention_regex.match(href)
|
||||
if mention:
|
||||
mxid = MatrixUserID(mention.group(1))
|
||||
user = (pu.Puppet.get_by_mxid(mxid)
|
||||
or u.User.get_by_mxid(mxid, create=False))
|
||||
if not user:
|
||||
return msg
|
||||
if user.username:
|
||||
return TelegramMessage(f"@{user.username}").format(Mention)
|
||||
elif user.tgid:
|
||||
displayname = user.plain_displayname or msg.text
|
||||
return TelegramMessage(displayname).format(MentionName, user_id=user.tgid)
|
||||
return msg
|
||||
|
||||
room = cls.room_regex.match(href)
|
||||
if room:
|
||||
username = po.Portal.get_username_from_mx_alias(room.group(1))
|
||||
portal = po.Portal.find_by_username(username)
|
||||
if portal and portal.username:
|
||||
return TelegramMessage(f"@{portal.username}").format(Mention)
|
||||
|
||||
return (msg.format(URL)
|
||||
if msg.text == href
|
||||
else msg.format(TextURL, url=href))
|
||||
|
||||
@classmethod
|
||||
def node_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||
if node.tag == "blockquote":
|
||||
return cls.blockquote_to_tmessage(node, ctx)
|
||||
elif node.tag == "ol":
|
||||
return cls.list_to_tmessage(node, ctx)
|
||||
elif node.tag == "ul":
|
||||
return cls.list_to_tmessage(node, ctx.enter_list())
|
||||
elif node.tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
return cls.header_to_tmessage(node, ctx)
|
||||
elif node.tag == "br":
|
||||
return TelegramMessage("\n")
|
||||
elif node.tag in ("b", "strong", "i", "em", "s", "del", "u", "ins", "command"):
|
||||
return cls.basic_format_to_tmessage(node, ctx)
|
||||
elif node.tag == "a":
|
||||
return cls.link_to_tstring(node, ctx)
|
||||
elif node.tag == "p":
|
||||
return cls.tag_aware_parse_node(node, ctx).append("\n")
|
||||
elif node.tag == "pre":
|
||||
lang = ""
|
||||
try:
|
||||
if node[0].tag == "code":
|
||||
node = node[0]
|
||||
lang = node.attrib["class"][len("language-"):]
|
||||
except (IndexError, KeyError):
|
||||
pass
|
||||
return cls.parse_node(node, ctx.enter_code_block()).format(Pre, language=lang)
|
||||
elif node.tag == "code":
|
||||
return cls.parse_node(node, ctx.enter_code_block()).format(Code)
|
||||
return cls.tag_aware_parse_node(node, ctx)
|
||||
|
||||
@staticmethod
|
||||
def text_to_tmessage(text: str, ctx: RecursionContext) -> TelegramMessage:
|
||||
if ctx.strip_linebreaks:
|
||||
text = text.replace("\n", "")
|
||||
return TelegramMessage(text)
|
||||
|
||||
@classmethod
|
||||
def node_to_tagged_tmessages(cls, node: HTMLNode, ctx: RecursionContext
|
||||
) -> List[Tuple[TelegramMessage, str]]:
|
||||
output = []
|
||||
|
||||
if node.text:
|
||||
output.append((cls.text_to_tmessage(node.text, ctx), "text"))
|
||||
for child in node:
|
||||
output.append((cls.node_to_tmessage(child, ctx), child.tag))
|
||||
if child.tail:
|
||||
output.append((cls.text_to_tmessage(child.tail, ctx), "text"))
|
||||
return output
|
||||
|
||||
@classmethod
|
||||
def node_to_tmessages(cls, node: HTMLNode, ctx: RecursionContext
|
||||
) -> List[TelegramMessage]:
|
||||
return [msg for (msg, tag) in cls.node_to_tagged_tmessages(node, ctx)]
|
||||
|
||||
@classmethod
|
||||
def tag_aware_parse_node(cls, node: HTMLNode, ctx: RecursionContext
|
||||
) -> TelegramMessage:
|
||||
msgs = cls.node_to_tagged_tmessages(node, ctx)
|
||||
output = TelegramMessage()
|
||||
prev_was_block = False
|
||||
for msg, tag in msgs:
|
||||
if tag in cls.block_tags:
|
||||
msg = msg.append("\n")
|
||||
if not prev_was_block:
|
||||
msg = msg.prepend("\n")
|
||||
prev_was_block = True
|
||||
output = output.append(msg)
|
||||
return output.trim()
|
||||
|
||||
@classmethod
|
||||
def parse_node(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
||||
return TelegramMessage.join(cls.node_to_tmessages(node, ctx))
|
||||
|
||||
@classmethod
|
||||
def parse(cls, data: str) -> ParsedMessage:
|
||||
msg = cls.node_to_tmessage(read_html(f"<body>{data}</body>"), RecursionContext())
|
||||
return msg.text, msg.entities
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,145 +13,87 @@
|
||||
#
|
||||
# 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 Callable, List, Optional, Sequence, Type, Union
|
||||
from typing import Optional, Union, Any, List, Type, Dict
|
||||
from enum import Enum
|
||||
|
||||
from telethon.tl.types import (MessageEntityMentionName as MentionName,
|
||||
MessageEntityTextUrl as TextURL, MessageEntityPre as Pre,
|
||||
TypeMessageEntity, InputMessageEntityMentionName as InputMentionName)
|
||||
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
|
||||
MessageEntityMentionName as MentionName, MessageEntityUrl as URL,
|
||||
MessageEntityEmail as Email, MessageEntityTextUrl as TextURL,
|
||||
MessageEntityBold as Bold, MessageEntityItalic as Italic,
|
||||
MessageEntityCode as Code, MessageEntityPre as Pre,
|
||||
MessageEntityStrike as Strike, MessageEntityUnderline as Underline,
|
||||
MessageEntityBlockquote as Blockquote, TypeMessageEntity,
|
||||
InputMessageEntityMentionName as InputMentionName)
|
||||
|
||||
from mautrix.util.formatter import EntityString, SemiAbstractEntity
|
||||
|
||||
|
||||
class Entity:
|
||||
@staticmethod
|
||||
def copy(entity: TypeMessageEntity) -> Optional[TypeMessageEntity]:
|
||||
if not entity:
|
||||
return None
|
||||
kwargs = {
|
||||
"offset": entity.offset,
|
||||
"length": entity.length,
|
||||
}
|
||||
if isinstance(entity, Pre):
|
||||
kwargs["language"] = entity.language
|
||||
elif isinstance(entity, TextURL):
|
||||
kwargs["url"] = entity.url
|
||||
elif isinstance(entity, (MentionName, InputMentionName)):
|
||||
kwargs["user_id"] = entity.user_id
|
||||
return entity.__class__(**kwargs)
|
||||
class TelegramEntityType(Enum):
|
||||
"""EntityType is a Matrix formatting entity type."""
|
||||
BOLD = Bold
|
||||
ITALIC = Italic
|
||||
STRIKETHROUGH = Strike
|
||||
UNDERLINE = Underline
|
||||
URL = URL
|
||||
INLINE_URL = TextURL
|
||||
EMAIL = Email
|
||||
PREFORMATTED = Pre
|
||||
INLINE_CODE = Code
|
||||
BLOCKQUOTE = Blockquote
|
||||
MENTION = Mention
|
||||
MENTION_NAME = MentionName
|
||||
COMMAND = Command
|
||||
|
||||
@classmethod
|
||||
def adjust(cls, entity: Union[TypeMessageEntity, List[TypeMessageEntity]],
|
||||
func: Callable[[TypeMessageEntity], None]
|
||||
) -> Union[Optional[TypeMessageEntity], List[TypeMessageEntity]]:
|
||||
if isinstance(entity, list):
|
||||
return [Entity.adjust(element, func) for element in entity if entity]
|
||||
elif not entity:
|
||||
return None
|
||||
entity = cls.copy(entity)
|
||||
func(entity)
|
||||
if entity.offset < 0:
|
||||
entity.length += entity.offset
|
||||
entity.offset = 0
|
||||
return entity
|
||||
USER_MENTION = 1
|
||||
ROOM_MENTION = 2
|
||||
HEADER = 3
|
||||
|
||||
|
||||
def offset_diff(amount: int) -> Callable[[TypeMessageEntity], None]:
|
||||
def func(entity: TypeMessageEntity) -> None:
|
||||
entity.offset += amount
|
||||
class TelegramEntity(SemiAbstractEntity):
|
||||
internal: TypeMessageEntity
|
||||
|
||||
return func
|
||||
def __init__(self, type: Union[TelegramEntityType, Type[TypeMessageEntity]],
|
||||
offset: int, length: int, extra_info: Dict[str, Any]) -> None:
|
||||
if isinstance(type, TelegramEntityType):
|
||||
if isinstance(type.value, int):
|
||||
raise ValueError(f"Can't create Entity with non-Telegram EntityType {type}")
|
||||
type = type.value
|
||||
self.internal = type(offset=offset, length=length, **extra_info)
|
||||
|
||||
def copy(self) -> Optional['TelegramEntity']:
|
||||
extra_info = {}
|
||||
if isinstance(self.internal, Pre):
|
||||
extra_info["language"] = self.internal.language
|
||||
elif isinstance(self.internal, TextURL):
|
||||
extra_info["url"] = self.internal.url
|
||||
elif isinstance(self.internal, (MentionName, InputMentionName)):
|
||||
extra_info["user_id"] = self.internal.user_id
|
||||
return TelegramEntity(type(self.internal), offset=self.internal.offset,
|
||||
length=self.internal.length, extra_info=extra_info)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self.internal)
|
||||
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
return self.internal.offset
|
||||
|
||||
@offset.setter
|
||||
def offset(self, value: int) -> None:
|
||||
self.internal.offset = value
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
return self.internal.length
|
||||
|
||||
@length.setter
|
||||
def length(self, value: int) -> None:
|
||||
self.internal.length = value
|
||||
|
||||
|
||||
def offset_length_multiply(amount: int) -> Callable[[TypeMessageEntity], None]:
|
||||
def func(entity: TypeMessageEntity) -> None:
|
||||
entity.offset *= amount
|
||||
entity.length *= amount
|
||||
class TelegramMessage(EntityString[TelegramEntity, TelegramEntityType]):
|
||||
entity_class = TelegramEntity
|
||||
|
||||
return func
|
||||
|
||||
|
||||
class TelegramMessage:
|
||||
def __init__(self, text: str = "", entities: Optional[List[TypeMessageEntity]] = None) -> None:
|
||||
self.text = text # type: str
|
||||
self.entities = entities or [] # type: List[TypeMessageEntity]
|
||||
|
||||
def offset_entities(self, offset: int) -> 'TelegramMessage':
|
||||
def apply_offset(entity: TypeMessageEntity, inner_offset: int
|
||||
) -> Optional[TypeMessageEntity]:
|
||||
entity = Entity.copy(entity)
|
||||
entity.offset += inner_offset
|
||||
if entity.offset < 0:
|
||||
entity.offset = 0
|
||||
elif entity.offset > len(self.text):
|
||||
return None
|
||||
elif entity.offset + entity.length > len(self.text):
|
||||
entity.length = len(self.text) - entity.offset
|
||||
return entity
|
||||
|
||||
self.entities = [apply_offset(entity, offset) for entity in self.entities if entity]
|
||||
self.entities = [x for x in self.entities if x is not None]
|
||||
return self
|
||||
|
||||
def append(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
|
||||
for msg in args:
|
||||
if isinstance(msg, str):
|
||||
msg = TelegramMessage(text=msg)
|
||||
self.entities += Entity.adjust(msg.entities, offset_diff(len(self.text)))
|
||||
self.text += msg.text
|
||||
return self
|
||||
|
||||
def prepend(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
|
||||
for msg in args:
|
||||
if isinstance(msg, str):
|
||||
msg = TelegramMessage(text=msg)
|
||||
self.entities = msg.entities + Entity.adjust(self.entities, offset_diff(len(msg.text)))
|
||||
self.text = msg.text + self.text
|
||||
return self
|
||||
|
||||
def format(self, entity_type: Type[TypeMessageEntity], offset: int = None, length: int = None,
|
||||
**kwargs) -> 'TelegramMessage':
|
||||
self.entities.append(entity_type(offset=offset or 0,
|
||||
length=length if length is not None else len(self.text),
|
||||
**kwargs))
|
||||
return self
|
||||
|
||||
def concat(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
|
||||
return TelegramMessage().append(self, *args)
|
||||
|
||||
def trim(self) -> 'TelegramMessage':
|
||||
orig_len = len(self.text)
|
||||
self.text = self.text.lstrip()
|
||||
diff = orig_len - len(self.text)
|
||||
self.text = self.text.rstrip()
|
||||
self.offset_entities(-diff)
|
||||
return self
|
||||
|
||||
def split(self, separator, max_items: int = 0) -> List['TelegramMessage']:
|
||||
text_parts = self.text.split(separator, max_items - 1)
|
||||
output = [] # type: List[TelegramMessage]
|
||||
|
||||
offset = 0
|
||||
for part in text_parts:
|
||||
msg = TelegramMessage(part)
|
||||
for entity in self.entities:
|
||||
start_in_range = len(part) > entity.offset - offset >= 0
|
||||
end_in_range = len(part) >= entity.offset - offset + entity.length > 0
|
||||
if start_in_range and end_in_range:
|
||||
msg.entities.append(Entity.adjust(entity, offset_diff(-offset)))
|
||||
output.append(msg)
|
||||
|
||||
offset += len(part)
|
||||
offset += len(separator)
|
||||
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def join(items: Sequence[Union[str, 'TelegramMessage']],
|
||||
separator: str = " ") -> 'TelegramMessage':
|
||||
main = TelegramMessage()
|
||||
for msg in items:
|
||||
if isinstance(msg, str):
|
||||
msg = TelegramMessage(text=msg)
|
||||
main.entities += Entity.adjust(msg.entities, offset_diff(len(main.text)))
|
||||
main.text += msg.text + separator
|
||||
if len(separator) > 0:
|
||||
main.text = main.text[:-len(separator)]
|
||||
return main
|
||||
@property
|
||||
def telegram_entities(self) -> List[TypeMessageEntity]:
|
||||
return [entity.internal for entity in self.entities]
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,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, List, Optional, Tuple, TYPE_CHECKING
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
from html import escape
|
||||
import logging
|
||||
import re
|
||||
@@ -23,207 +22,161 @@ from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, M
|
||||
MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold,
|
||||
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
|
||||
MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag,
|
||||
MessageEntityPhone, TypeMessageEntity, Message, PeerChannel,
|
||||
MessageFwdHeader, PeerUser)
|
||||
MessageEntityPhone, TypeMessageEntity, PeerChannel,
|
||||
MessageEntityBlockquote, MessageEntityStrike, MessageFwdHeader,
|
||||
MessageEntityUnderline, PeerUser)
|
||||
from telethon.tl.custom import Message
|
||||
from telethon.errors import RPCError
|
||||
from telethon.helpers import add_surrogate, del_surrogate
|
||||
|
||||
from mautrix_appservice import MatrixRequestError
|
||||
from mautrix_appservice.intent_api import IntentAPI
|
||||
from mautrix.errors import MatrixRequestError
|
||||
from mautrix.appservice import IntentAPI
|
||||
from mautrix.types import (TextMessageEventContent, RelatesTo, RelationType, Format, MessageType,
|
||||
MessageEvent)
|
||||
|
||||
from .. import user as u, puppet as pu, portal as po
|
||||
from ..types import TelegramID
|
||||
from ..db import Message as DBMessage
|
||||
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
|
||||
trim_reply_fallback_text, unicode_to_html)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..abstract_user import AbstractUser
|
||||
from ..context import Context
|
||||
|
||||
try:
|
||||
from lxml.html.diff import htmldiff
|
||||
except ImportError:
|
||||
htmldiff = None # type: ignore
|
||||
|
||||
log = logging.getLogger("mau.fmt.tg") # type: logging.Logger
|
||||
should_highlight_edits = False # type: bool
|
||||
log: logging.Logger = logging.getLogger("mau.fmt.tg")
|
||||
|
||||
|
||||
def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Dict:
|
||||
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)
|
||||
else source.tgid)
|
||||
msg = DBMessage.get_by_tgid(evt.reply_to_msg_id, space)
|
||||
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
|
||||
if msg:
|
||||
return {
|
||||
"m.in_reply_to": {
|
||||
"event_id": msg.mxid,
|
||||
"room_id": msg.mx_room,
|
||||
}
|
||||
}
|
||||
return {}
|
||||
return RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
|
||||
return None
|
||||
|
||||
|
||||
async def _add_forward_header(source, text: str, html: Optional[str],
|
||||
fwd_from: MessageFwdHeader) -> Tuple[str, str]:
|
||||
if not html:
|
||||
html = escape(text)
|
||||
async def _add_forward_header(source: 'AbstractUser', content: TextMessageEventContent,
|
||||
fwd_from: MessageFwdHeader) -> None:
|
||||
if not content.formatted_body or content.format != Format.HTML:
|
||||
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 user:
|
||||
fwd_from_text = user.displayname or user.mxid
|
||||
fwd_from_html = f"<a href='https://matrix.to/#/{user.mxid}'>{fwd_from_text}</a>"
|
||||
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)
|
||||
if puppet and puppet.displayname:
|
||||
fwd_from_text = puppet.displayname or puppet.mxid
|
||||
fwd_from_html = f"<a href='https://matrix.to/#/{puppet.mxid}'>{fwd_from_text}</a>"
|
||||
fwd_from_html = (f"<a href='https://matrix.to/#/{puppet.mxid}'>"
|
||||
f"{escape(fwd_from_text)}</a>")
|
||||
|
||||
if not fwd_from_text:
|
||||
user = await source.client.get_entity(PeerUser(fwd_from.from_id))
|
||||
if user:
|
||||
fwd_from_text = pu.Puppet.get_displayname(user, False)
|
||||
fwd_from_html = f"<b>{fwd_from_text}</b>"
|
||||
else:
|
||||
try:
|
||||
user = await source.client.get_entity(PeerUser(fwd_from.from_id))
|
||||
if user:
|
||||
fwd_from_text = pu.Puppet.get_displayname(user, False)
|
||||
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
|
||||
except (ValueError, RPCError):
|
||||
fwd_from_text = fwd_from_html = "unknown user"
|
||||
elif fwd_from.channel_id:
|
||||
portal = po.Portal.get_by_tgid(TelegramID(fwd_from.channel_id))
|
||||
if portal:
|
||||
fwd_from_text = portal.title
|
||||
if portal.alias:
|
||||
fwd_from_html = f"<a href='https://matrix.to/#/{portal.alias}'>{fwd_from_text}</a>"
|
||||
fwd_from_html = (f"<a href='https://matrix.to/#/{portal.alias}'>"
|
||||
f"{escape(fwd_from_text)}</a>")
|
||||
else:
|
||||
fwd_from_html = f"<b>{fwd_from_text}</b>"
|
||||
fwd_from_html = f"channel <b>{escape(fwd_from_text)}</b>"
|
||||
else:
|
||||
channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
|
||||
if channel:
|
||||
fwd_from_text = channel.title
|
||||
fwd_from_html = f"<b>{fwd_from_text}</b>"
|
||||
try:
|
||||
channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
|
||||
if channel:
|
||||
fwd_from_text = f"channel {channel.title}"
|
||||
fwd_from_html = f"channel <b>{escape(channel.title)}</b>"
|
||||
except (ValueError, RPCError):
|
||||
fwd_from_text = fwd_from_html = "unknown channel"
|
||||
elif fwd_from.from_name:
|
||||
fwd_from_text = fwd_from.from_name
|
||||
fwd_from_html = f"<b>{escape(fwd_from.from_name)}</b>"
|
||||
else:
|
||||
fwd_from_text = "unknown source"
|
||||
fwd_from_html = f"unknown source"
|
||||
|
||||
if not fwd_from_text:
|
||||
if fwd_from.from_id:
|
||||
fwd_from_text = "Unknown user"
|
||||
else:
|
||||
fwd_from_text = "Unknown source"
|
||||
fwd_from_html = f"<b>{fwd_from_text}</b>"
|
||||
|
||||
text = "\n".join([f"> {line}" for line in text.split("\n")])
|
||||
text = f"Forwarded from {fwd_from_text}:\n{text}"
|
||||
html = (f"Forwarded message from {fwd_from_html}<br/>"
|
||||
f"<tg-forward><blockquote>{html}</blockquote></tg-forward>")
|
||||
return text, html
|
||||
content.body = "\n".join([f"> {line}" for line in content.body.split("\n")])
|
||||
content.body = f"Forwarded from {fwd_from_text}:\n{content.body}"
|
||||
content.formatted_body = (
|
||||
f"Forwarded message from {fwd_from_html}<br/>"
|
||||
f"<tg-forward><blockquote>{content.formatted_body}</blockquote></tg-forward>")
|
||||
|
||||
|
||||
def highlight_edits(new_html: str, old_html: str) -> str:
|
||||
# Don't include `Edit:` text in diff.
|
||||
if old_html.startswith("<u>Edit:</u> "):
|
||||
old_html = old_html[len("<u>Edit:</u> "):]
|
||||
|
||||
# Generate diff with lxml
|
||||
new_html = htmldiff(old_html, new_html)
|
||||
|
||||
# Replace <ins> with <u> since Riot doesn't allow <ins>
|
||||
new_html = new_html.replace("<ins>", "<u>").replace("</ins>", "</u>")
|
||||
# Remove <del>s since we just want to hide deletions.
|
||||
new_html = re.sub("<del>.+?</del>", "", new_html)
|
||||
return new_html
|
||||
|
||||
|
||||
async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message,
|
||||
relates_to: Dict, main_intent: IntentAPI, is_edit: bool
|
||||
) -> Tuple[str, str]:
|
||||
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)
|
||||
else source.tgid)
|
||||
|
||||
msg = DBMessage.get_by_tgid(evt.reply_to_msg_id, space)
|
||||
msg = DBMessage.get_one_by_tgid(TelegramID(evt.reply_to_msg_id), space)
|
||||
if not msg:
|
||||
return text, html
|
||||
return
|
||||
|
||||
relates_to["m.in_reply_to"] = {
|
||||
"event_id": msg.mxid,
|
||||
"room_id": msg.mx_room,
|
||||
}
|
||||
content.relates_to = RelatesTo(rel_type=RelationType.REFERENCE, event_id=msg.mxid)
|
||||
|
||||
try:
|
||||
event = await main_intent.get_event(msg.mx_room, msg.mxid)
|
||||
|
||||
content = event["content"]
|
||||
r_sender = event["sender"]
|
||||
|
||||
r_text_body = trim_reply_fallback_text(content["body"])
|
||||
r_html_body = trim_reply_fallback_html(content["formatted_body"]
|
||||
if "formatted_body" in content
|
||||
else escape(content["body"]))
|
||||
|
||||
puppet = pu.Puppet.get_by_mxid(r_sender, create=False)
|
||||
r_displayname = puppet.displayname if puppet else r_sender
|
||||
r_sender_link = f"<a href='https://matrix.to/#/{r_sender}'>{r_displayname}</a>"
|
||||
|
||||
if is_edit and should_highlight_edits:
|
||||
html = highlight_edits(html or escape(text), r_html_body)
|
||||
except (ValueError, KeyError, MatrixRequestError):
|
||||
r_sender_link = "unknown user"
|
||||
r_displayname = "unknown user"
|
||||
r_text_body = "Failed to fetch message"
|
||||
r_html_body = "<em>Failed to fetch message</em>"
|
||||
|
||||
if is_edit:
|
||||
html = f"<u>Edit:</u> {html or escape(text)}"
|
||||
text = f"Edit: {text}"
|
||||
|
||||
r_keyword = "In reply to" if not is_edit else "Edit to"
|
||||
r_msg_link = f"<a href='https://matrix.to/#/{msg.mx_room}/{msg.mxid}'>{r_keyword}</a>"
|
||||
html = (
|
||||
f"<mx-reply><blockquote>{r_msg_link} {r_sender_link}\n{r_html_body}</blockquote></mx-reply>"
|
||||
+ (html or escape(text)))
|
||||
|
||||
lines = r_text_body.strip().split("\n")
|
||||
text_with_quote = f"> <{r_displayname}> {lines.pop(0)}"
|
||||
for line in lines:
|
||||
if line:
|
||||
text_with_quote += f"\n> {line}"
|
||||
text_with_quote += "\n\n"
|
||||
text_with_quote += text
|
||||
return text_with_quote, html
|
||||
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)
|
||||
content.set_reply(event, displayname=puppet.displayname if puppet else event.sender)
|
||||
except MatrixRequestError:
|
||||
log.exception("Failed to get event to add reply fallback")
|
||||
|
||||
|
||||
async def telegram_to_matrix(evt: Message, source: "AbstractUser",
|
||||
main_intent: Optional[IntentAPI] = None,
|
||||
is_edit: bool = False, prefix_text: Optional[str] = None,
|
||||
prefix_html: Optional[str] = None, override_text: str = None,
|
||||
prefix_text: Optional[str] = None, prefix_html: Optional[str] = None,
|
||||
override_text: str = None,
|
||||
override_entities: List[TypeMessageEntity] = None,
|
||||
no_reply_fallback: bool = False) -> Tuple[str, str, Dict]:
|
||||
text = add_surrogates(override_text or evt.message)
|
||||
no_reply_fallback: bool = False) -> TextMessageEventContent:
|
||||
content = TextMessageEventContent(
|
||||
msgtype=MessageType.TEXT,
|
||||
body=add_surrogate(override_text or evt.message),
|
||||
)
|
||||
entities = override_entities or evt.entities
|
||||
html = _telegram_entities_to_matrix_catch(text, entities) if entities else None
|
||||
relates_to = {} # type: Dict
|
||||
if entities:
|
||||
content.format = Format.HTML
|
||||
content.formatted_body = _telegram_entities_to_matrix_catch(content.body, entities)
|
||||
|
||||
if prefix_html:
|
||||
html = prefix_html + (html or escape(text))
|
||||
if not content.formatted_body:
|
||||
content.format = Format.HTML
|
||||
content.formatted_body = escape(content.body)
|
||||
content.formatted_body = prefix_html + content.formatted_body
|
||||
if prefix_text:
|
||||
text = prefix_text + text
|
||||
content.body = prefix_text + content.body
|
||||
|
||||
if evt.fwd_from:
|
||||
text, html = await _add_forward_header(source, text, html, evt.fwd_from)
|
||||
await _add_forward_header(source, content, evt.fwd_from)
|
||||
|
||||
if evt.reply_to_msg_id and not no_reply_fallback:
|
||||
text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent,
|
||||
is_edit)
|
||||
await _add_reply_header(source, content, evt, main_intent)
|
||||
|
||||
if isinstance(evt, Message) and evt.post and evt.post_author:
|
||||
if not html:
|
||||
html = escape(text)
|
||||
text += f"\n- {evt.post_author}"
|
||||
html += f"<br/><i>- <u>{evt.post_author}</u></i>"
|
||||
if not content.formatted_body:
|
||||
content.formatted_body = escape(content.body)
|
||||
content.body += f"\n- {evt.post_author}"
|
||||
content.formatted_body += f"<br/><i>- <u>{evt.post_author}</u></i>"
|
||||
|
||||
html = unicode_to_html(text, html, "\u0336", "del")
|
||||
html = unicode_to_html(text, html, "\u0332", "u")
|
||||
content.body = del_surrogate(content.body)
|
||||
|
||||
if html:
|
||||
html = html.replace("\n", "<br/>")
|
||||
if content.formatted_body:
|
||||
content.formatted_body = del_surrogate(content.formatted_body.replace("\n", "<br/>"))
|
||||
|
||||
return remove_surrogates(text), remove_surrogates(html), relates_to
|
||||
return content
|
||||
|
||||
|
||||
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
|
||||
@@ -237,29 +190,43 @@ def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEnti
|
||||
return "[failed conversion in _telegram_entities_to_matrix]"
|
||||
|
||||
|
||||
def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity]) -> str:
|
||||
def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity],
|
||||
offset: int = 0, length: int = None) -> str:
|
||||
if not entities:
|
||||
return text
|
||||
return escape(text)
|
||||
if length is None:
|
||||
length = len(text)
|
||||
html = []
|
||||
last_offset = 0
|
||||
for entity in entities:
|
||||
if entity.offset > last_offset:
|
||||
html.append(escape(text[last_offset:entity.offset]))
|
||||
elif entity.offset < last_offset:
|
||||
for i, entity in enumerate(entities):
|
||||
if entity.offset > offset + length:
|
||||
break
|
||||
relative_offset = entity.offset - offset
|
||||
if relative_offset > last_offset:
|
||||
html.append(escape(text[last_offset:relative_offset]))
|
||||
elif relative_offset < last_offset:
|
||||
continue
|
||||
|
||||
skip_entity = False
|
||||
entity_text = escape(text[entity.offset:entity.offset + entity.length])
|
||||
entity_text = _telegram_entities_to_matrix(
|
||||
text=text[relative_offset:relative_offset + entity.length],
|
||||
entities=entities[i + 1:], offset=entity.offset, length=entity.length)
|
||||
entity_type = type(entity)
|
||||
|
||||
if entity_type == MessageEntityBold:
|
||||
html.append(f"<strong>{entity_text}</strong>")
|
||||
elif entity_type == MessageEntityItalic:
|
||||
html.append(f"<em>{entity_text}</em>")
|
||||
elif entity_type == MessageEntityUnderline:
|
||||
html.append(f"<u>{entity_text}</u>")
|
||||
elif entity_type == MessageEntityStrike:
|
||||
html.append(f"<del>{entity_text}</del>")
|
||||
elif entity_type == MessageEntityBlockquote:
|
||||
html.append(f"<blockquote>{entity_text}</blockquote>")
|
||||
elif entity_type == MessageEntityCode:
|
||||
html.append(("<pre><code>{entity_text}</code></pre>"
|
||||
if "\n" in entity_text
|
||||
else "<code>{entity_text}</code>").format(entity_text=entity_text))
|
||||
html.append(f"<pre><code>{entity_text}</code></pre>"
|
||||
if "\n" in entity_text
|
||||
else f"<code>{entity_text}</code>")
|
||||
elif entity_type == MessageEntityPre:
|
||||
skip_entity = _parse_pre(html, entity_text, entity.language)
|
||||
elif entity_type == MessageEntityMention:
|
||||
@@ -277,8 +244,8 @@ def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity]) -
|
||||
html.append(f"<font color='blue'>{entity_text}</font>")
|
||||
else:
|
||||
skip_entity = True
|
||||
last_offset = entity.offset + (0 if skip_entity else entity.length)
|
||||
html.append(text[last_offset:])
|
||||
last_offset = relative_offset + (0 if skip_entity else entity.length)
|
||||
html.append(escape(text[last_offset:]))
|
||||
|
||||
return "".join(html)
|
||||
|
||||
@@ -324,8 +291,8 @@ def _parse_name_mention(html: List[str], entity_text: str, user_id: TelegramID)
|
||||
return False
|
||||
|
||||
|
||||
message_link_regex = re.compile(
|
||||
r"https?://t(?:elegram)?\.(?:me|dog)/([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})")
|
||||
message_link_regex = re.compile(r"https?://t(?:elegram)?\.(?:me|dog)/"
|
||||
r"([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})")
|
||||
|
||||
|
||||
def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
|
||||
@@ -340,14 +307,9 @@ def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
|
||||
|
||||
portal = po.Portal.find_by_username(group)
|
||||
if portal:
|
||||
message = DBMessage.get_by_tgid(TelegramID(msgid), portal.tgid)
|
||||
message = DBMessage.get_one_by_tgid(TelegramID(msgid), portal.tgid)
|
||||
if message:
|
||||
url = f"https://matrix.to/#/{portal.mxid}/{message.mxid}"
|
||||
|
||||
html.append(f"<a href='{url}'>{entity_text}</a>")
|
||||
return False
|
||||
|
||||
|
||||
def init_tg(context: "Context") -> None:
|
||||
global should_highlight_edits
|
||||
should_highlight_edits = htmldiff and context.config["bridge.highlight_edits"]
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# 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 typing import Optional, Pattern
|
||||
from html import escape
|
||||
import struct
|
||||
import re
|
||||
|
||||
|
||||
def unicode_to_html(text: str, html: str, ctrl: str, tag: str) -> str:
|
||||
if ctrl not in text:
|
||||
return html
|
||||
if not html:
|
||||
html = escape(text)
|
||||
tag_start = f"<{tag}>"
|
||||
tag_end = f"</{tag}>"
|
||||
characters = html.split(ctrl)
|
||||
html = ""
|
||||
in_tag = False
|
||||
for char in characters:
|
||||
if not in_tag:
|
||||
if len(char) > 1:
|
||||
html += char[0:-1]
|
||||
char = char[-1]
|
||||
html += tag_start
|
||||
in_tag = True
|
||||
html += char
|
||||
else:
|
||||
if len(char) > 1:
|
||||
html += tag_end
|
||||
in_tag = False
|
||||
html += char
|
||||
if in_tag:
|
||||
html += tag_end
|
||||
return html
|
||||
|
||||
|
||||
def html_to_unicode(text: str, ctrl: str) -> str:
|
||||
return ctrl.join(text) + ctrl
|
||||
|
||||
|
||||
# add_surrogates and remove_surrogates are unicode surrogate utility functions from Telethon.
|
||||
# Licensed under the MIT license.
|
||||
# https://github.com/LonamiWebs/Telethon/blob/7cce7aa3e4c6c7019a55530391b1761d33e5a04e/telethon/helpers.py
|
||||
def add_surrogates(text: Optional[str]) -> Optional[str]:
|
||||
if text is None:
|
||||
return None
|
||||
return "".join("".join(chr(y) for y in struct.unpack("<HH", x.encode("utf-16-le")))
|
||||
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text)
|
||||
|
||||
|
||||
def remove_surrogates(text: Optional[str]) -> Optional[str]:
|
||||
if text is None:
|
||||
return None
|
||||
return text.encode("utf-16", "surrogatepass").decode("utf-16")
|
||||
|
||||
|
||||
# trim_reply_fallback_text, html_reply_fallback_regex and trim_reply_fallback_html are Matrix
|
||||
# reply fallback utility functions.
|
||||
# You may copy and use them under any OSI-approved license.
|
||||
def trim_reply_fallback_text(text: str) -> str:
|
||||
if not text.startswith("> ") or "\n" not in text:
|
||||
return text
|
||||
lines = text.split("\n")
|
||||
while len(lines) > 0 and lines[0].startswith("> "):
|
||||
lines.pop(0)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
html_reply_fallback_regex = re.compile("^<mx-reply>"
|
||||
r"[\s\S]+?"
|
||||
"</mx-reply>") # type: Pattern
|
||||
|
||||
|
||||
def trim_reply_fallback_html(html: str) -> str:
|
||||
return html_reply_fallback_regex.sub("", html)
|
||||
@@ -0,0 +1,49 @@
|
||||
import subprocess
|
||||
import shutil
|
||||
import os
|
||||
|
||||
from . import __version__
|
||||
|
||||
cmd_env = {
|
||||
"PATH": os.environ["PATH"],
|
||||
"HOME": os.environ["HOME"],
|
||||
"LANG": "C",
|
||||
"LC_ALL": "C",
|
||||
}
|
||||
|
||||
|
||||
def run(cmd):
|
||||
return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env)
|
||||
|
||||
if os.path.exists(".git") and shutil.which("git"):
|
||||
try:
|
||||
git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii")
|
||||
git_revision_url = f"https://github.com/tulir/mautrix-telegram/commit/{git_revision}"
|
||||
git_revision = git_revision[:8]
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
git_revision = "unknown"
|
||||
git_revision_url = None
|
||||
|
||||
try:
|
||||
git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii")
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
git_tag = None
|
||||
else:
|
||||
git_revision = "unknown"
|
||||
git_revision_url = None
|
||||
git_tag = None
|
||||
|
||||
git_tag_url = (f"https://github.com/tulir/mautrix-telegram/releases/tag/{git_tag}"
|
||||
if git_tag else None)
|
||||
|
||||
if git_tag and __version__ == git_tag[1:].replace("-", ""):
|
||||
version = __version__
|
||||
linkified_version = f"[{version}]({git_tag_url})"
|
||||
else:
|
||||
if not __version__.endswith("+dev"):
|
||||
__version__ += "+dev"
|
||||
version = f"{__version__}.{git_revision}"
|
||||
if git_revision_url:
|
||||
linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})"
|
||||
else:
|
||||
linkified_version = version
|
||||
+203
-242
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,48 +13,56 @@
|
||||
#
|
||||
# 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, List, Match, Optional, Set, Tuple, TYPE_CHECKING
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
from typing import Dict, Set, Tuple, Union, Iterable, TYPE_CHECKING
|
||||
|
||||
from mautrix_appservice import MatrixRequestError, IntentError
|
||||
from mautrix.bridge import BaseMatrixHandler
|
||||
from mautrix.types import (Event, EventType, RoomID, UserID, EventID, ReceiptEvent, ReceiptType,
|
||||
ReceiptEventContent, PresenceEvent, PresenceState, TypingEvent,
|
||||
MessageEvent, StateEvent, RedactionEvent, RoomNameStateEventContent,
|
||||
RoomAvatarStateEventContent, RoomTopicStateEventContent,
|
||||
MemberStateEventContent)
|
||||
from mautrix.errors import MatrixError
|
||||
|
||||
from .types import MatrixEvent, MatrixEventID, MatrixRoomID, MatrixUserID
|
||||
from . import user as u, portal as po, puppet as pu, commands as com
|
||||
|
||||
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]
|
||||
|
||||
|
||||
class MatrixHandler:
|
||||
log = logging.getLogger("mau.mx") # type: logging.Logger
|
||||
class MatrixHandler(BaseMatrixHandler):
|
||||
bot: 'Bot'
|
||||
commands: 'com.CommandProcessor'
|
||||
previously_typing: Dict[RoomID, Set[UserID]]
|
||||
|
||||
def __init__(self, context: 'Context') -> None:
|
||||
self.az, self.config, _, self.tgbot = context.core
|
||||
self.commands = com.CommandProcessor(context) # type: com.CommandProcessor
|
||||
self.previously_typing = [] # type: List[MatrixUserID]
|
||||
super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop,
|
||||
command_processor=com.CommandProcessor(context))
|
||||
self.bot = context.bot
|
||||
self.previously_typing = {}
|
||||
|
||||
self.az.matrix_event_handler(self.handle_event)
|
||||
async def get_user(self, user_id: UserID) -> 'u.User':
|
||||
return await u.User.get_by_mxid(user_id).ensure_started()
|
||||
|
||||
async def init_as_bot(self) -> None:
|
||||
displayname = self.config["appservice.bot_displayname"]
|
||||
if displayname:
|
||||
try:
|
||||
await self.az.intent.set_display_name(
|
||||
displayname if displayname != "remove" else "")
|
||||
except asyncio.TimeoutError:
|
||||
self.log.exception("TimeoutError when trying to set displayname")
|
||||
async def get_portal(self, room_id: RoomID) -> 'po.Portal':
|
||||
return po.Portal.get_by_mxid(room_id)
|
||||
|
||||
avatar = self.config["appservice.bot_avatar"]
|
||||
if avatar:
|
||||
try:
|
||||
await self.az.intent.set_avatar(avatar if avatar != "remove" else "")
|
||||
except asyncio.TimeoutError:
|
||||
self.log.exception("TimeoutError when trying to set avatar")
|
||||
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: MatrixRoomID, puppet: pu.Puppet, inviter: u.User
|
||||
) -> None:
|
||||
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}")
|
||||
if not await inviter.is_logged_in():
|
||||
@@ -73,7 +80,7 @@ class MatrixHandler:
|
||||
return
|
||||
try:
|
||||
members = await self.az.intent.get_room_members(room_id)
|
||||
except MatrixRequestError:
|
||||
except MatrixError:
|
||||
members = []
|
||||
if self.az.bot_mxid not in members:
|
||||
if len(members) > 1:
|
||||
@@ -85,18 +92,16 @@ class MatrixHandler:
|
||||
|
||||
await intent.join_room(room_id)
|
||||
portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
|
||||
# TODO: if portal is None:
|
||||
if portal.mxid:
|
||||
try:
|
||||
await intent.invite(portal.mxid, inviter.mxid)
|
||||
await intent.send_notice(room_id, text=None, html=(
|
||||
"You already have a private chat with me: "
|
||||
f"<a href='https://matrix.to/#/{portal.mxid}'>"
|
||||
"Link to room"
|
||||
"</a>"))
|
||||
await intent.invite_user(portal.mxid, inviter.mxid)
|
||||
await intent.send_notice(
|
||||
room_id, text=f"You already have a private chat with me: {portal.mxid}",
|
||||
html=("You already have a private chat with me: "
|
||||
f"<a href='https://matrix.to/#/{portal.mxid}'>Link to room</a>"))
|
||||
await intent.leave_room(room_id)
|
||||
return
|
||||
except MatrixRequestError:
|
||||
except MatrixError:
|
||||
pass
|
||||
portal.mxid = room_id
|
||||
portal.save()
|
||||
@@ -107,48 +112,25 @@ class MatrixHandler:
|
||||
await intent.send_notice(room_id, "This puppet will remain inactive until a "
|
||||
"Telegram chat is created for this room.")
|
||||
|
||||
async def accept_bot_invite(self, room_id: MatrixRoomID, inviter: u.User) -> None:
|
||||
tries = 0
|
||||
while tries < 5:
|
||||
try:
|
||||
await self.az.intent.join_room(room_id)
|
||||
break
|
||||
except (IntentError, MatrixRequestError):
|
||||
tries += 1
|
||||
wait_for_seconds = (tries + 1) * 10
|
||||
if tries < 5:
|
||||
self.log.exception(f"Failed to join room {room_id} with bridge bot, "
|
||||
f"retrying in {wait_for_seconds} seconds...")
|
||||
await asyncio.sleep(wait_for_seconds)
|
||||
else:
|
||||
self.log.exception("Failed to join room {room}, giving up.")
|
||||
return
|
||||
|
||||
if not inviter.whitelisted:
|
||||
await self.az.intent.send_notice(
|
||||
room_id, text="",
|
||||
html="You are not whitelisted to use this bridge.<br/><br/>"
|
||||
"If you are the owner of this bridge, see the "
|
||||
"<code>bridge.permissions</code> section in your config file.")
|
||||
await self.az.intent.leave_room(room_id)
|
||||
|
||||
async def handle_invite(self, room_id: MatrixRoomID, user_id: MatrixUserID,
|
||||
inviter_mxid: MatrixUserID) -> None:
|
||||
self.log.debug(f"{inviter_mxid} invited {user_id} to {room_id}")
|
||||
inviter = u.User.get_by_mxid(inviter_mxid)
|
||||
if inviter is None:
|
||||
self.log.exception("Failed to find user with Matrix ID {inviter_mxid}")
|
||||
await inviter.ensure_started()
|
||||
if user_id == self.az.bot_mxid:
|
||||
return await self.accept_bot_invite(room_id, inviter)
|
||||
elif not inviter.whitelisted:
|
||||
return
|
||||
|
||||
puppet = pu.Puppet.get_by_mxid(user_id)
|
||||
if puppet:
|
||||
await self.handle_puppet_invite(room_id, puppet, inviter)
|
||||
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
|
||||
except MatrixError:
|
||||
# The AS bot is not in the room.
|
||||
return
|
||||
cmd_prefix = self.commands.command_prefix
|
||||
text = html = "Hello, I'm a Telegram bridge bot. "
|
||||
if is_management and inviter.puppet_whitelisted and not await inviter.is_logged_in():
|
||||
text += f"Use `{cmd_prefix} help` for help or `{cmd_prefix} login` to log in."
|
||||
html += (f"Use <code>{cmd_prefix} help</code> for help"
|
||||
f" or <code>{cmd_prefix} login</code> to log in.")
|
||||
else:
|
||||
text += f"Use `{cmd_prefix} help` for help."
|
||||
html += f"Use <code>{cmd_prefix} help</code> for help."
|
||||
await self.az.intent.send_notice(room_id, text=text, html=html)
|
||||
|
||||
async def handle_invite(self, room_id: RoomID, user_id: UserID, inviter: 'u.User',
|
||||
event_id: EventID) -> None:
|
||||
user = u.User.get_by_mxid(user_id, create=False)
|
||||
if not user:
|
||||
return
|
||||
@@ -156,12 +138,8 @@ class MatrixHandler:
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
if user and await user.has_full_access(allow_bot=True) and portal:
|
||||
await portal.invite_telegram(inviter, user)
|
||||
return
|
||||
|
||||
# The rest can probably be ignored
|
||||
|
||||
async def handle_join(self, room_id: MatrixRoomID, user_id: MatrixUserID,
|
||||
event_id: MatrixEventID) -> None:
|
||||
async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
|
||||
user = await u.User.get_by_mxid(user_id).ensure_started()
|
||||
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
@@ -169,35 +147,58 @@ class MatrixHandler:
|
||||
return
|
||||
|
||||
if not user.relaybot_whitelisted:
|
||||
await portal.main_intent.kick(room_id, user.mxid,
|
||||
"You are not whitelisted on this Telegram bridge.")
|
||||
await portal.main_intent.kick_user(room_id, user.mxid,
|
||||
"You are not whitelisted on this Telegram bridge.")
|
||||
return
|
||||
elif not await user.is_logged_in() and not portal.has_bot:
|
||||
await portal.main_intent.kick(room_id, user.mxid,
|
||||
"This chat does not have a bot relaying "
|
||||
"messages for unauthenticated users.")
|
||||
await portal.main_intent.kick_user(room_id, user.mxid,
|
||||
"This chat does not have a bot relaying "
|
||||
"messages for unauthenticated users.")
|
||||
return
|
||||
|
||||
self.log.debug(f"{user} joined {room_id}")
|
||||
if await user.is_logged_in() or portal.has_bot:
|
||||
await portal.join_matrix(user, event_id)
|
||||
|
||||
async def handle_part(self, room_id: MatrixRoomID, user_id: MatrixUserID,
|
||||
sender_mxid: MatrixUserID, event_id: MatrixEventID) -> None:
|
||||
async def get_leave_handle_info(self) -> Tuple[po.Portal, u.User]:
|
||||
pass
|
||||
|
||||
async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
|
||||
self.log.debug(f"{user_id} left {room_id}")
|
||||
|
||||
sender = u.User.get_by_mxid(sender_mxid, create=False)
|
||||
if not sender:
|
||||
return
|
||||
await sender.ensure_started()
|
||||
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
if not portal:
|
||||
return
|
||||
|
||||
user = u.User.get_by_mxid(user_id, create=False)
|
||||
if not user:
|
||||
return
|
||||
await user.ensure_started()
|
||||
await portal.leave_matrix(user, event_id)
|
||||
|
||||
async def handle_kick_ban(self, ban: bool, room_id: RoomID, user_id: UserID, sender: UserID,
|
||||
reason: str, event_id: EventID) -> None:
|
||||
action = "banned" if ban else "kicked"
|
||||
self.log.debug(f"{user_id} was {action} from {room_id} by {sender} for {reason}")
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
if not portal:
|
||||
return
|
||||
|
||||
if user_id == self.az.bot_mxid:
|
||||
# Direct chat portal unbridging is handled in portal.kick_matrix
|
||||
if portal.peer_type != "user":
|
||||
await portal.unbridge()
|
||||
return
|
||||
|
||||
sender = u.User.get_by_mxid(sender, create=False)
|
||||
if not sender:
|
||||
return
|
||||
await sender.ensure_started()
|
||||
|
||||
puppet = pu.Puppet.get_by_mxid(user_id)
|
||||
if puppet:
|
||||
if sender:
|
||||
if ban:
|
||||
await portal.ban_matrix(puppet, sender)
|
||||
else:
|
||||
await portal.kick_matrix(puppet, sender)
|
||||
return
|
||||
|
||||
@@ -205,90 +206,73 @@ class MatrixHandler:
|
||||
if not user:
|
||||
return
|
||||
await user.ensure_started()
|
||||
if await user.is_logged_in() or portal.has_bot:
|
||||
await portal.leave_matrix(user, sender, event_id)
|
||||
if ban:
|
||||
await portal.ban_matrix(user, sender)
|
||||
else:
|
||||
await portal.kick_matrix(user, sender)
|
||||
|
||||
def is_command(self, message: Dict) -> Tuple[bool, str]:
|
||||
text = message.get("body", "")
|
||||
prefix = self.config["bridge.command_prefix"]
|
||||
is_command = text.startswith(prefix)
|
||||
if is_command:
|
||||
text = text[len(prefix) + 1:]
|
||||
return is_command, text
|
||||
async def handle_kick(self, room_id: RoomID, user_id: UserID, kicked_by: UserID, reason: str,
|
||||
event_id: EventID) -> None:
|
||||
await self.handle_kick_ban(False, room_id, user_id, kicked_by, reason, event_id)
|
||||
|
||||
async def handle_message(self, room: MatrixRoomID, sender_id: MatrixUserID, message: Dict,
|
||||
event_id: MatrixEventID) -> None:
|
||||
is_command, text = self.is_command(message)
|
||||
sender = await u.User.get_by_mxid(sender_id).ensure_started()
|
||||
if not sender.relaybot_whitelisted:
|
||||
self.log.debug(f"Ignoring message \"{message}\" from {sender} to {room}:"
|
||||
" User is not whitelisted.")
|
||||
return
|
||||
self.log.debug(f"Received Matrix event \"{message}\" from {sender} in {room}")
|
||||
async def handle_unban(self, room_id: RoomID, user_id: UserID, unbanned_by: UserID,
|
||||
reason: str, event_id: EventID) -> None:
|
||||
# TODO handle unbans properly instead of handling it as a kick
|
||||
await self.handle_kick_ban(False, room_id, user_id, unbanned_by, reason, event_id)
|
||||
|
||||
portal = po.Portal.get_by_mxid(room)
|
||||
if not is_command and portal and (await sender.is_logged_in() or portal.has_bot):
|
||||
await portal.handle_matrix_message(sender, message, event_id)
|
||||
return
|
||||
|
||||
if not sender.whitelisted or message.get("msgtype", "m.unknown") != "m.text":
|
||||
return
|
||||
|
||||
try:
|
||||
is_management = len(await self.az.intent.get_room_members(room)) == 2
|
||||
except MatrixRequestError:
|
||||
# The AS bot is not in the room.
|
||||
return
|
||||
|
||||
if is_command or is_management:
|
||||
try:
|
||||
command, arguments = text.split(" ", 1)
|
||||
args = arguments.split(" ")
|
||||
except ValueError:
|
||||
# Not enough values to unpack, i.e. no arguments
|
||||
command = text
|
||||
args = []
|
||||
await self.commands.handle(room, event_id, sender, command, args, is_management,
|
||||
is_portal=portal is not None)
|
||||
async def handle_ban(self, room_id: RoomID, user_id: UserID, banned_by: UserID, reason: str,
|
||||
event_id: EventID) -> None:
|
||||
await self.handle_kick_ban(True, room_id, user_id, banned_by, reason, event_id)
|
||||
|
||||
@staticmethod
|
||||
async def handle_redaction(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
|
||||
event_id: MatrixEventID) -> None:
|
||||
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
||||
async def allow_message(user: 'u.User') -> bool:
|
||||
return user.relaybot_whitelisted
|
||||
|
||||
@staticmethod
|
||||
async def allow_command(user: 'u.User') -> bool:
|
||||
return user.whitelisted
|
||||
|
||||
@staticmethod
|
||||
async def allow_bridging_message(user: 'u.User', portal: 'po.Portal') -> bool:
|
||||
return await user.is_logged_in() or portal.has_bot
|
||||
|
||||
@staticmethod
|
||||
async def handle_redaction(evt: RedactionEvent) -> None:
|
||||
sender = await u.User.get_by_mxid(evt.sender).ensure_started()
|
||||
if not sender.relaybot_whitelisted:
|
||||
return
|
||||
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
if not portal:
|
||||
return
|
||||
|
||||
await portal.handle_matrix_deletion(sender, event_id)
|
||||
await portal.handle_matrix_deletion(sender, evt.redacts)
|
||||
|
||||
@staticmethod
|
||||
async def handle_power_levels(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
|
||||
new: Dict, old: Dict) -> None:
|
||||
async def handle_power_levels(evt: StateEvent) -> None:
|
||||
portal = po.Portal.get_by_mxid(evt.room_id)
|
||||
sender = await u.User.get_by_mxid(evt.sender).ensure_started()
|
||||
if await sender.has_full_access(allow_bot=True) and portal:
|
||||
await portal.handle_matrix_power_levels(sender, evt.content.users,
|
||||
evt.unsigned.prev_content.users)
|
||||
|
||||
@staticmethod
|
||||
async def handle_room_meta(evt_type: EventType, room_id: RoomID, sender_mxid: UserID,
|
||||
content: RoomMetaStateEventContent) -> None:
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
||||
if await sender.has_full_access(allow_bot=True) and portal:
|
||||
await portal.handle_matrix_power_levels(sender, new["users"], old["users"])
|
||||
|
||||
@staticmethod
|
||||
async def handle_room_meta(evt_type: str, room_id: MatrixRoomID, sender_mxid: MatrixUserID,
|
||||
content: dict) -> None:
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
||||
if await sender.has_full_access(allow_bot=True) and portal:
|
||||
handler, content_key = {
|
||||
"m.room.name": (portal.handle_matrix_title, "name"),
|
||||
"m.room.topic": (portal.handle_matrix_about, "topic"),
|
||||
"m.room.avatar": (portal.handle_matrix_avatar, "url"),
|
||||
handler, content_type, content_key = {
|
||||
EventType.ROOM_NAME: (portal.handle_matrix_title, RoomNameStateEventContent, "name"),
|
||||
EventType.ROOM_TOPIC: (portal.handle_matrix_about, RoomTopicStateEventContent, "topic"),
|
||||
EventType.ROOM_AVATAR: (portal.handle_matrix_avatar, RoomAvatarStateEventContent, "url"),
|
||||
}[evt_type]
|
||||
if content_key not in content:
|
||||
if not isinstance(content, content_type):
|
||||
return
|
||||
await handler(sender, content[content_key])
|
||||
|
||||
@staticmethod
|
||||
async def handle_room_pin(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
|
||||
async def handle_room_pin(room_id: RoomID, sender_mxid: UserID,
|
||||
new_events: Set[str], old_events: Set[str]) -> None:
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
|
||||
@@ -296,62 +280,69 @@ class MatrixHandler:
|
||||
events = new_events - old_events
|
||||
if len(events) > 0:
|
||||
# New event pinned, set that as pinned in Telegram.
|
||||
await portal.handle_matrix_pin(sender, MatrixEventID(events.pop()))
|
||||
await portal.handle_matrix_pin(sender, EventID(events.pop()))
|
||||
elif len(new_events) == 0:
|
||||
# All pinned events removed, remove pinned event in Telegram.
|
||||
await portal.handle_matrix_pin(sender, None)
|
||||
|
||||
@staticmethod
|
||||
async def handle_room_upgrade(room_id: MatrixRoomID, new_room_id: MatrixRoomID) -> None:
|
||||
async def handle_room_upgrade(room_id: RoomID, sender: UserID, new_room_id: RoomID) -> None:
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
if portal:
|
||||
await portal.handle_matrix_upgrade(new_room_id)
|
||||
await portal.handle_matrix_upgrade(sender, new_room_id)
|
||||
|
||||
async def handle_member_info_change(self, room_id: RoomID, user_id: UserID,
|
||||
profile: MemberStateEventContent,
|
||||
prev_profile: MemberStateEventContent,
|
||||
event_id: EventID) -> None:
|
||||
if profile.displayname == prev_profile.displayname:
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
async def handle_name_change(room_id: MatrixRoomID, user_id: MatrixUserID, displayname: str,
|
||||
prev_displayname: str, event_id: MatrixEventID) -> None:
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
if not portal or not portal.has_bot:
|
||||
return
|
||||
|
||||
user = await u.User.get_by_mxid(user_id).ensure_started()
|
||||
if await user.needs_relaybot(portal):
|
||||
await portal.name_change_matrix(user, displayname, prev_displayname, event_id)
|
||||
await portal.name_change_matrix(user, profile.displayname, prev_profile.displayname,
|
||||
event_id)
|
||||
|
||||
@staticmethod
|
||||
def parse_read_receipts(content: Dict) -> Dict[MatrixUserID, MatrixEventID]:
|
||||
return {user_id: event_id
|
||||
def parse_read_receipts(content: ReceiptEventContent) -> Iterable[Tuple[UserID, EventID]]:
|
||||
return ((user_id, event_id)
|
||||
for event_id, receipts in content.items()
|
||||
for user_id in receipts.get("m.read", {})}
|
||||
for user_id in receipts.get(ReceiptType.READ, {}))
|
||||
|
||||
@staticmethod
|
||||
async def handle_read_receipts(room_id: MatrixRoomID,
|
||||
receipts: Dict[MatrixUserID, MatrixEventID]) -> None:
|
||||
async def handle_read_receipts(room_id: RoomID, receipts: Iterable[Tuple[UserID, EventID]]
|
||||
) -> None:
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
if not portal:
|
||||
return
|
||||
|
||||
for user_id, event_id in receipts.items():
|
||||
for user_id, event_id in receipts:
|
||||
user = await u.User.get_by_mxid(user_id).ensure_started()
|
||||
if not await user.is_logged_in():
|
||||
continue
|
||||
await portal.mark_read(user, event_id)
|
||||
|
||||
@staticmethod
|
||||
async def handle_presence(user_id: MatrixUserID, presence: str) -> None:
|
||||
async def handle_presence(user_id: UserID, presence: PresenceState) -> None:
|
||||
user = await u.User.get_by_mxid(user_id).ensure_started()
|
||||
if not await user.is_logged_in():
|
||||
return
|
||||
await user.set_presence(presence == "online")
|
||||
await user.set_presence(presence == PresenceState.ONLINE)
|
||||
|
||||
async def handle_typing(self, room_id: MatrixRoomID, now_typing: List[MatrixUserID]) -> None:
|
||||
async def handle_typing(self, room_id: RoomID, now_typing: Set[UserID]) -> None:
|
||||
portal = po.Portal.get_by_mxid(room_id)
|
||||
if not portal:
|
||||
return
|
||||
|
||||
for user_id in set(self.previously_typing + now_typing):
|
||||
previously_typing = self.previously_typing.get(room_id, set())
|
||||
|
||||
for user_id in set(previously_typing | now_typing):
|
||||
is_typing = user_id in now_typing
|
||||
was_typing = user_id in self.previously_typing
|
||||
was_typing = user_id in previously_typing
|
||||
if is_typing and was_typing:
|
||||
continue
|
||||
|
||||
@@ -361,72 +352,42 @@ class MatrixHandler:
|
||||
|
||||
await portal.set_typing(user, is_typing)
|
||||
|
||||
self.previously_typing = now_typing
|
||||
self.previously_typing[room_id] = now_typing
|
||||
|
||||
def filter_matrix_event(self, event: MatrixEvent) -> bool:
|
||||
sender = event.get("sender", None)
|
||||
if not sender:
|
||||
return False
|
||||
return (sender == self.az.bot_mxid
|
||||
or pu.Puppet.get_id_from_mxid(sender) is not None)
|
||||
def filter_matrix_event(self, evt: Event) -> bool:
|
||||
if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent)):
|
||||
return True
|
||||
return evt.sender and (evt.sender == self.az.bot_mxid
|
||||
or pu.Puppet.get_id_from_mxid(evt.sender) is not None)
|
||||
|
||||
async def try_handle_event(self, evt: MatrixEvent) -> None:
|
||||
try:
|
||||
await self.handle_event(evt)
|
||||
except Exception:
|
||||
self.log.exception("Error handling manually received Matrix event")
|
||||
async def handle_ephemeral_event(self, evt: Union[ReceiptEvent, PresenceEvent, TypingEvent]
|
||||
) -> None:
|
||||
if evt.type == EventType.RECEIPT:
|
||||
await self.handle_read_receipts(evt.room_id, self.parse_read_receipts(evt.content))
|
||||
elif evt.type == EventType.PRESENCE:
|
||||
await self.handle_presence(evt.sender, evt.content.presence)
|
||||
elif evt.type == EventType.TYPING:
|
||||
await self.handle_typing(evt.room_id, set(evt.content.user_ids))
|
||||
|
||||
async def handle_event(self, evt: MatrixEvent) -> None:
|
||||
if self.filter_matrix_event(evt):
|
||||
return
|
||||
self.log.debug("Received event: %s", evt)
|
||||
evt_type = evt.get("type", "m.unknown") # type: str
|
||||
room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID]
|
||||
event_id = evt.get("event_id", None) # type: Optional[MatrixEventID]
|
||||
sender = evt.get("sender", None) # type: Optional[MatrixUserID]
|
||||
content = evt.get("content", {}) # type: Dict
|
||||
if evt_type == "m.room.member":
|
||||
state_key = evt["state_key"] # type: MatrixUserID
|
||||
prev_content = evt.get("unsigned", {}).get("prev_content", {}) # type: Dict
|
||||
membership = content.get("membership", "") # type: str
|
||||
prev_membership = prev_content.get("membership", "leave") # type: str
|
||||
if membership == prev_membership:
|
||||
match = re.compile("@(.+):(.+)").match(state_key) # type: Match
|
||||
mxid = match.group(0) # type: str
|
||||
displayname = content.get("displayname", None) or mxid # type: str
|
||||
prev_displayname = prev_content.get("displayname", None) or mxid # type: str
|
||||
if displayname != prev_displayname:
|
||||
await self.handle_name_change(room_id, state_key, displayname,
|
||||
prev_displayname, event_id)
|
||||
elif membership == "invite":
|
||||
await self.handle_invite(room_id, state_key, sender)
|
||||
elif prev_membership == "join" and membership == "leave":
|
||||
await self.handle_part(room_id, state_key, sender, event_id)
|
||||
elif membership == "join":
|
||||
await self.handle_join(room_id, state_key, event_id)
|
||||
elif evt_type in ("m.room.message", "m.sticker"):
|
||||
if evt_type != "m.room.message":
|
||||
content["msgtype"] = evt_type
|
||||
await self.handle_message(room_id, sender, content, event_id)
|
||||
elif evt_type == "m.room.redaction":
|
||||
await self.handle_redaction(room_id, sender, evt["redacts"])
|
||||
elif evt_type == "m.room.power_levels":
|
||||
prev_content = evt.get("unsigned", {}).get("prev_content", {})
|
||||
await self.handle_power_levels(room_id, sender, evt["content"], prev_content)
|
||||
elif evt_type in ("m.room.name", "m.room.avatar", "m.room.topic"):
|
||||
await self.handle_room_meta(evt_type, room_id, sender, evt["content"])
|
||||
elif evt_type == "m.room.pinned_events":
|
||||
new_events = set(evt["content"]["pinned"])
|
||||
async def handle_event(self, evt: Event) -> None:
|
||||
if evt.type == EventType.ROOM_REDACTION:
|
||||
await self.handle_redaction(evt)
|
||||
|
||||
async def handle_state_event(self, evt: StateEvent) -> None:
|
||||
if evt.type == EventType.ROOM_POWER_LEVELS:
|
||||
await self.handle_power_levels(evt)
|
||||
elif evt.type in (EventType.ROOM_NAME, EventType.ROOM_AVATAR, EventType.ROOM_TOPIC):
|
||||
await self.handle_room_meta(evt.type, evt.room_id, evt.sender, evt.content)
|
||||
elif evt.type == EventType.ROOM_PINNED_EVENTS:
|
||||
new_events = set(evt.content.pinned)
|
||||
try:
|
||||
old_events = set(evt["unsigned"]["prev_content"]["pinned"])
|
||||
except KeyError:
|
||||
old_events = set(evt.unsigned.prev_content.pinned)
|
||||
except (KeyError, ValueError, TypeError, AttributeError):
|
||||
old_events = set()
|
||||
await self.handle_room_pin(room_id, sender, new_events, old_events)
|
||||
elif evt_type == "m.room.tombstone":
|
||||
await self.handle_room_upgrade(room_id, evt["content"]["replacement_room"])
|
||||
elif evt_type == "m.receipt":
|
||||
await self.handle_read_receipts(room_id, self.parse_read_receipts(content))
|
||||
elif evt_type == "m.presence":
|
||||
await self.handle_presence(sender, content.get("presence", "offline"))
|
||||
elif evt_type == "m.typing":
|
||||
await self.handle_typing(room_id, content.get("user_ids", []))
|
||||
await self.handle_room_pin(evt.room_id, evt.sender, new_events, old_events)
|
||||
elif evt.type == EventType.ROOM_TOMBSTONE:
|
||||
await self.handle_room_upgrade(evt.room_id, evt.sender, evt.content.replacement_room)
|
||||
|
||||
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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
from .base import BasePortal, init as init_base
|
||||
from .matrix import PortalMatrix, init as init_matrix
|
||||
from .metadata import PortalMetadata, init as init_metadata
|
||||
from .telegram import PortalTelegram, init as init_telegram
|
||||
from .deduplication import init as init_dedup
|
||||
from ..context import Context
|
||||
|
||||
|
||||
class Portal(PortalMatrix, PortalTelegram, PortalMetadata):
|
||||
pass
|
||||
|
||||
|
||||
def init(context: Context) -> None:
|
||||
init_base(context)
|
||||
init_dedup(context)
|
||||
init_metadata(context)
|
||||
init_telegram(context)
|
||||
init_matrix(context)
|
||||
|
||||
|
||||
__all__ = ["Portal", "init"]
|
||||
@@ -0,0 +1,15 @@
|
||||
from typing import Union
|
||||
from .base import BasePortal
|
||||
from .portal_matrix import PortalMatrix
|
||||
from .portal_metadata import PortalMetadata
|
||||
from .portal_telegram import PortalTelegram
|
||||
from ..context import Context
|
||||
|
||||
Portal = Union[BasePortal, PortalMatrix, PortalMetadata, PortalTelegram]
|
||||
|
||||
|
||||
def init(context: Context) -> None:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = ["Portal", "init"]
|
||||
@@ -0,0 +1,508 @@
|
||||
# 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 typing import Awaitable, Dict, List, Optional, Tuple, Union, Any, TYPE_CHECKING
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
|
||||
from telethon.tl.functions.messages import ExportChatInviteRequest
|
||||
from telethon.tl.types import (Channel, ChannelFull, Chat, ChatFull, ChatInviteEmpty, InputChannel,
|
||||
InputPeerChannel, InputPeerChat, InputPeerUser, InputUser,
|
||||
PeerChannel, PeerChat, PeerUser, TypeChat, TypeInputPeer, TypePeer,
|
||||
TypeUser, TypeUserFull, User, UserFull, TypeInputChannel, Photo,
|
||||
Document, TypePhotoSize, PhotoSize, InputPhotoFileLocation,
|
||||
TypeChatParticipant, TypeChannelParticipant, PhotoEmpty, ChatPhoto,
|
||||
ChatPhotoEmpty)
|
||||
|
||||
from mautrix.errors import MatrixRequestError, IntentError
|
||||
from mautrix.appservice import AppService, IntentAPI
|
||||
from mautrix.types import RoomID, RoomAlias, UserID, EventType, PowerLevelStateEventContent
|
||||
from mautrix.util.simple_template import SimpleTemplate
|
||||
|
||||
from ..types import TelegramID
|
||||
from ..context import Context
|
||||
from ..db import Portal as DBPortal
|
||||
from .. import puppet as p, user as u, util
|
||||
from .deduplication import PortalDedup
|
||||
from .send_lock import PortalSendLock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..bot import Bot
|
||||
from ..abstract_user import AbstractUser
|
||||
from ..config import Config
|
||||
from . import Portal
|
||||
|
||||
TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
|
||||
TypeChatPhoto = Union[ChatPhoto, ChatPhotoEmpty, Photo, PhotoEmpty]
|
||||
InviteList = Union[UserID, List[UserID]]
|
||||
|
||||
config: Optional['Config'] = None
|
||||
|
||||
|
||||
class BasePortal(ABC):
|
||||
base_log: logging.Logger = logging.getLogger("mau.portal")
|
||||
az: AppService = None
|
||||
bot: 'Bot' = None
|
||||
loop: asyncio.AbstractEventLoop = None
|
||||
|
||||
# Config cache
|
||||
filter_mode: str = None
|
||||
filter_list: List[str] = None
|
||||
|
||||
max_initial_member_sync: int = -1
|
||||
sync_channel_members: bool = True
|
||||
sync_matrix_state: bool = True
|
||||
public_portals: bool = False
|
||||
|
||||
alias_template: SimpleTemplate[str]
|
||||
hs_domain: str
|
||||
|
||||
# Instance cache
|
||||
by_mxid: Dict[RoomID, 'Portal'] = {}
|
||||
by_tgid: Dict[Tuple[TelegramID, TelegramID], 'Portal'] = {}
|
||||
|
||||
mxid: Optional[RoomID]
|
||||
tgid: TelegramID
|
||||
tg_receiver: TelegramID
|
||||
peer_type: str
|
||||
username: str
|
||||
megagroup: bool
|
||||
title: Optional[str]
|
||||
about: Optional[str]
|
||||
photo_id: Optional[str]
|
||||
local_config: Dict[str, Any]
|
||||
deleted: bool
|
||||
log: logging.Logger
|
||||
|
||||
alias: Optional[RoomAlias]
|
||||
|
||||
dedup: PortalDedup
|
||||
send_lock: PortalSendLock
|
||||
|
||||
_db_instance: DBPortal
|
||||
_main_intent: Optional[IntentAPI]
|
||||
|
||||
def __init__(self, tgid: TelegramID, peer_type: str, tg_receiver: Optional[TelegramID] = None,
|
||||
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, db_instance: DBPortal = None) -> None:
|
||||
self.mxid = mxid
|
||||
self.tgid = tgid
|
||||
self.tg_receiver = tg_receiver or tgid
|
||||
self.peer_type = peer_type
|
||||
self.username = username
|
||||
self.megagroup = megagroup
|
||||
self.title = title
|
||||
self.about = about
|
||||
self.photo_id = photo_id
|
||||
self.local_config = json.loads(local_config or "{}")
|
||||
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.dedup = PortalDedup(self)
|
||||
self.send_lock = PortalSendLock()
|
||||
|
||||
if tgid:
|
||||
self.by_tgid[self.tgid_full] = self
|
||||
if mxid:
|
||||
self.by_mxid[mxid] = self
|
||||
|
||||
# region Propegrties
|
||||
|
||||
@property
|
||||
def tgid_full(self) -> Tuple[TelegramID, TelegramID]:
|
||||
return self.tgid, self.tg_receiver
|
||||
|
||||
@property
|
||||
def tgid_log(self) -> str:
|
||||
if self.tgid == self.tg_receiver:
|
||||
return str(self.tgid)
|
||||
return f"{self.tg_receiver}<->{self.tgid}"
|
||||
|
||||
@property
|
||||
def alias(self) -> Optional[RoomAlias]:
|
||||
if not self.username:
|
||||
return None
|
||||
return RoomAlias(f"#{self.alias_localpart}:{self.hs_domain}")
|
||||
|
||||
@property
|
||||
def alias_localpart(self) -> Optional[str]:
|
||||
if not self.username:
|
||||
return None
|
||||
return self.alias_template.format(self.username)
|
||||
|
||||
@property
|
||||
def peer(self) -> Union[TypePeer, TypeInputPeer]:
|
||||
if self.peer_type == "user":
|
||||
return PeerUser(user_id=self.tgid)
|
||||
elif self.peer_type == "chat":
|
||||
return PeerChat(chat_id=self.tgid)
|
||||
elif self.peer_type == "channel":
|
||||
return PeerChannel(channel_id=self.tgid)
|
||||
|
||||
@property
|
||||
def has_bot(self) -> bool:
|
||||
return (bool(self.bot)
|
||||
and (self.bot.is_in_chat(self.tgid)
|
||||
or (self.peer_type == "user" and self.tg_receiver == self.bot.tgid)))
|
||||
|
||||
@property
|
||||
def main_intent(self) -> IntentAPI:
|
||||
if not self._main_intent:
|
||||
direct = self.peer_type == "user"
|
||||
puppet = p.Puppet.get(self.tgid) if direct else None
|
||||
self._main_intent = puppet.intent_for(self) if direct else self.az.intent
|
||||
return self._main_intent
|
||||
|
||||
@property
|
||||
def allow_bridging(self) -> bool:
|
||||
if self.peer_type == "user":
|
||||
return True
|
||||
elif self.filter_mode == "whitelist":
|
||||
return self.tgid in self.filter_list
|
||||
elif self.filter_mode == "blacklist":
|
||||
return self.tgid not in self.filter_list
|
||||
return True
|
||||
|
||||
# endregion
|
||||
# region Miscellaneous getters
|
||||
|
||||
def get_config(self, key: str) -> Any:
|
||||
local = util.recursive_get(self.local_config, key)
|
||||
if local is not None:
|
||||
return local
|
||||
return config[f"bridge.{key}"]
|
||||
|
||||
@staticmethod
|
||||
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:
|
||||
return None, None
|
||||
|
||||
largest = max(photo.thumbs if isinstance(photo, Document) else photo.sizes,
|
||||
key=(lambda photo2: (len(photo2.bytes)
|
||||
if not isinstance(photo2, PhotoSize)
|
||||
else photo2.size)))
|
||||
return InputPhotoFileLocation(
|
||||
id=photo.id,
|
||||
access_hash=photo.access_hash,
|
||||
file_reference=photo.file_reference,
|
||||
thumb_size=largest.type,
|
||||
), largest
|
||||
|
||||
async def can_user_perform(self, user: 'u.User', event: str) -> bool:
|
||||
if user.is_admin:
|
||||
return True
|
||||
if not self.mxid:
|
||||
# No room for anybody to perform actions in
|
||||
return False
|
||||
try:
|
||||
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, event=evt_type)
|
||||
|
||||
def get_input_entity(self, user: 'AbstractUser'
|
||||
) -> Awaitable[Union[TypeInputPeer, TypeInputChannel]]:
|
||||
return user.client.get_input_entity(self.peer)
|
||||
|
||||
async def get_entity(self, user: 'AbstractUser') -> TypeChat:
|
||||
try:
|
||||
return await user.client.get_entity(self.peer)
|
||||
except ValueError:
|
||||
if user.is_bot:
|
||||
self.log.warning(f"Could not find entity with bot {user.tgid}. "
|
||||
"Failing...")
|
||||
raise
|
||||
self.log.warning(f"Could not find entity with user {user.tgid}. "
|
||||
"falling back to get_dialogs.")
|
||||
async for dialog in user.client.iter_dialogs():
|
||||
if dialog.entity.id == self.tgid:
|
||||
return dialog.entity
|
||||
raise
|
||||
|
||||
async def get_invite_link(self, user: 'u.User') -> str:
|
||||
if self.peer_type == "user":
|
||||
raise ValueError("You can't invite users to private chats.")
|
||||
if self.username:
|
||||
return f"https://t.me/{self.username}"
|
||||
link = await user.client(ExportChatInviteRequest(peer=await self.get_input_entity(user)))
|
||||
if isinstance(link, ChatInviteEmpty):
|
||||
raise ValueError("Failed to get invite link.")
|
||||
return link.link
|
||||
|
||||
# endregion
|
||||
# region Matrix room cleanup
|
||||
|
||||
async def get_authenticated_matrix_users(self) -> List['u.User']:
|
||||
try:
|
||||
members = await self.main_intent.get_room_members(self.mxid)
|
||||
except MatrixRequestError:
|
||||
return []
|
||||
authenticated: List[u.User] = []
|
||||
has_bot = self.has_bot
|
||||
for member_str in members:
|
||||
member = UserID(member_str)
|
||||
if p.Puppet.get_id_from_mxid(member) or member == self.main_intent.mxid:
|
||||
continue
|
||||
user = await u.User.get_by_mxid(member).ensure_started()
|
||||
authenticated_through_bot = has_bot and user.relaybot_whitelisted
|
||||
if authenticated_through_bot or await user.has_full_access(allow_bot=True):
|
||||
authenticated.append(user)
|
||||
return authenticated
|
||||
|
||||
@staticmethod
|
||||
async def cleanup_room(intent: IntentAPI, room_id: RoomID, message: str,
|
||||
puppets_only: bool = False) -> None:
|
||||
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)
|
||||
if user != intent.mxid and (not puppets_only or puppet):
|
||||
try:
|
||||
if puppet:
|
||||
await puppet.default_mxid_intent.leave_room(room_id)
|
||||
else:
|
||||
await intent.kick_user(room_id, user, message)
|
||||
except (MatrixRequestError, IntentError):
|
||||
pass
|
||||
try:
|
||||
await intent.leave_room(room_id)
|
||||
except (MatrixRequestError, IntentError):
|
||||
self.log.warning("Failed to leave room when cleaning up room", exc_info=True)
|
||||
|
||||
async def cleanup_portal(self, message: str, puppets_only: bool = False) -> None:
|
||||
if self.username:
|
||||
try:
|
||||
await self.main_intent.remove_room_alias(self.alias_localpart)
|
||||
except (MatrixRequestError, IntentError):
|
||||
self.log.warning("Failed to remove alias when cleaning up room", exc_info=True)
|
||||
await self.cleanup_room(self.main_intent, self.mxid, message, puppets_only)
|
||||
|
||||
async def unbridge(self) -> None:
|
||||
await self.cleanup_portal("Room unbridged", puppets_only=True)
|
||||
self.delete()
|
||||
|
||||
async def cleanup_and_delete(self) -> None:
|
||||
await self.cleanup_portal("Portal deleted")
|
||||
self.delete()
|
||||
|
||||
# endregion
|
||||
# region Database conversion
|
||||
|
||||
@property
|
||||
def db_instance(self) -> DBPortal:
|
||||
if not self._db_instance:
|
||||
self._db_instance = self.new_db_instance()
|
||||
return self._db_instance
|
||||
|
||||
def new_db_instance(self) -> DBPortal:
|
||||
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))
|
||||
|
||||
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))
|
||||
|
||||
def delete(self) -> None:
|
||||
try:
|
||||
del self.by_tgid[self.tgid_full]
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
del self.by_mxid[self.mxid]
|
||||
except KeyError:
|
||||
pass
|
||||
if self._db_instance:
|
||||
self._db_instance.delete()
|
||||
self.deleted = True
|
||||
|
||||
@classmethod
|
||||
def from_db(cls, db_portal: DBPortal) -> 'Portal':
|
||||
return cls(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver,
|
||||
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, db_instance=db_portal)
|
||||
|
||||
# endregion
|
||||
# region Class instance lookup
|
||||
|
||||
@classmethod
|
||||
def get_by_mxid(cls, mxid: RoomID) -> Optional['Portal']:
|
||||
try:
|
||||
return cls.by_mxid[mxid]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
portal = DBPortal.get_by_mxid(mxid)
|
||||
if portal:
|
||||
return cls.from_db(portal)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_username_from_mx_alias(cls, alias: str) -> Optional[str]:
|
||||
return cls.alias_template.parse(alias)
|
||||
|
||||
@classmethod
|
||||
def find_by_username(cls, username: str) -> Optional['Portal']:
|
||||
if not username:
|
||||
return None
|
||||
|
||||
username = username.lower()
|
||||
|
||||
for _, portal in cls.by_tgid.items():
|
||||
if portal.username and portal.username.lower() == username:
|
||||
return portal
|
||||
|
||||
dbportal = DBPortal.get_by_username(username)
|
||||
if dbportal:
|
||||
return cls.from_db(dbportal)
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: Optional[TelegramID] = None,
|
||||
peer_type: str = None) -> Optional['Portal']:
|
||||
tg_receiver = tg_receiver or tgid
|
||||
tgid_full = (tgid, tg_receiver)
|
||||
try:
|
||||
return cls.by_tgid[tgid_full]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
db_portal = DBPortal.get_by_tgid(tgid, tg_receiver)
|
||||
if db_portal:
|
||||
return cls.from_db(db_portal)
|
||||
|
||||
if peer_type:
|
||||
cls.log.info(f"Creating portal for {peer_type} {tgid} (receiver {tg_receiver})")
|
||||
# TODO enable this for non-release builds
|
||||
# (or add better wrong peer type error handling)
|
||||
# if peer_type == "chat":
|
||||
# import traceback
|
||||
# cls.log.info("Chat portal stack trace:\n" + "".join(traceback.format_stack()))
|
||||
portal = cls(tgid, peer_type=peer_type, tg_receiver=tg_receiver)
|
||||
portal.db_instance.insert()
|
||||
return portal
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_entity(cls, entity: Union[TypeChat, TypePeer, TypeUser, TypeUserFull,
|
||||
TypeInputPeer],
|
||||
receiver_id: Optional[TelegramID] = None, create: bool = True
|
||||
) -> Optional['Portal']:
|
||||
entity_type = type(entity)
|
||||
if entity_type in (Chat, ChatFull):
|
||||
type_name = "chat"
|
||||
entity_id = entity.id
|
||||
elif entity_type in (PeerChat, InputPeerChat):
|
||||
type_name = "chat"
|
||||
entity_id = entity.chat_id
|
||||
elif entity_type in (Channel, ChannelFull):
|
||||
type_name = "channel"
|
||||
entity_id = entity.id
|
||||
elif entity_type in (PeerChannel, InputPeerChannel, InputChannel):
|
||||
type_name = "channel"
|
||||
entity_id = entity.channel_id
|
||||
elif entity_type in (User, UserFull):
|
||||
type_name = "user"
|
||||
entity_id = entity.id
|
||||
elif entity_type in (PeerUser, InputPeerUser, InputUser):
|
||||
type_name = "user"
|
||||
entity_id = entity.user_id
|
||||
else:
|
||||
raise ValueError(f"Unknown entity type {entity_type.__name__}")
|
||||
return cls.get_by_tgid(TelegramID(entity_id),
|
||||
receiver_id if type_name == "user" else entity_id,
|
||||
type_name if create else None)
|
||||
|
||||
# endregion
|
||||
# region Abstract methods (cross-called in matrix/metadata/telegram classes)
|
||||
|
||||
@abstractmethod
|
||||
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:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None,
|
||||
invites: InviteList = None, update_if_exists: bool = True,
|
||||
synchronous: bool = False) -> Optional[str]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _update_title(self, title: str, sender: Optional['p.Puppet'] = None,
|
||||
save: bool = False) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def _update_avatar(self, user: 'AbstractUser', photo: Union[TypeChatPhoto],
|
||||
sender: Optional['p.Puppet'] = None, save: bool = False) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _migrate_and_save_telegram(self, new_id: TelegramID) -> None:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def handle_matrix_power_levels(self, sender: 'u.User', new_levels: Dict[UserID, int],
|
||||
old_levels: Dict[UserID, int]) -> Awaitable[None]:
|
||||
pass
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
def init(context: Context) -> None:
|
||||
global config
|
||||
BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core
|
||||
BasePortal.max_initial_member_sync = config["bridge.max_initial_member_sync"]
|
||||
BasePortal.sync_channel_members = config["bridge.sync_channel_members"]
|
||||
BasePortal.sync_matrix_state = config["bridge.sync_matrix_state"]
|
||||
BasePortal.public_portals = config["bridge.public_portals"]
|
||||
BasePortal.filter_mode = config["bridge.filter.mode"]
|
||||
BasePortal.filter_list = config["bridge.filter.list"]
|
||||
BasePortal.hs_domain = config["homeserver.domain"]
|
||||
BasePortal.alias_template = SimpleTemplate(config["bridge.alias_template"], "groupname",
|
||||
prefix="#", suffix=f":{BasePortal.hs_domain}")
|
||||
@@ -0,0 +1,133 @@
|
||||
# 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 typing import Optional, Deque, Dict, Tuple, TYPE_CHECKING
|
||||
from collections import deque
|
||||
import hashlib
|
||||
|
||||
from telethon.tl.patched import Message, MessageService
|
||||
from telethon.tl.types import (MessageMediaContact, MessageMediaDocument, MessageMediaGeo,
|
||||
MessageMediaPhoto, TypeMessage, TypeUpdates, UpdateNewMessage,
|
||||
UpdateNewChannelMessage)
|
||||
|
||||
from mautrix.types import EventID
|
||||
|
||||
from ..context import Context
|
||||
from ..types import TelegramID
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import BasePortal
|
||||
|
||||
DedupMXID = Tuple[EventID, TelegramID]
|
||||
|
||||
|
||||
class PortalDedup:
|
||||
pre_db_check: bool = False
|
||||
cache_queue_length: int = 20
|
||||
|
||||
_dedup: Deque[str]
|
||||
_dedup_mxid: Dict[str, DedupMXID]
|
||||
_dedup_action: Deque[str]
|
||||
_portal: 'BasePortal'
|
||||
|
||||
def __init__(self, portal: 'BasePortal') -> None:
|
||||
self._dedup = deque()
|
||||
self._dedup_mxid = {}
|
||||
self._dedup_action = deque()
|
||||
self._portal = portal
|
||||
|
||||
@property
|
||||
def _always_force_hash(self) -> bool:
|
||||
return self._portal.peer_type != 'channel'
|
||||
|
||||
@staticmethod
|
||||
def _hash_event(event: TypeMessage) -> str:
|
||||
# Non-channel messages are unique per-user (wtf telegram), so we have no other choice than
|
||||
# to deduplicate based on a hash of the message content.
|
||||
|
||||
# The timestamp is only accurate to the second, so we can't rely solely on that either.
|
||||
if isinstance(event, MessageService):
|
||||
hash_content = [event.date.timestamp(), event.from_id, event.action]
|
||||
else:
|
||||
hash_content = [event.date.timestamp(), event.message]
|
||||
if event.fwd_from:
|
||||
hash_content += [event.fwd_from.from_id, event.fwd_from.channel_id]
|
||||
elif isinstance(event, Message) and event.media:
|
||||
try:
|
||||
hash_content += {
|
||||
MessageMediaContact: lambda media: [media.user_id],
|
||||
MessageMediaDocument: lambda media: [media.document.id],
|
||||
MessageMediaPhoto: lambda media: [media.photo.id],
|
||||
MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
|
||||
}[type(event.media)](event.media)
|
||||
except KeyError:
|
||||
pass
|
||||
return hashlib.md5("-"
|
||||
.join(str(a) for a in hash_content)
|
||||
.encode("utf-8")
|
||||
).hexdigest()
|
||||
|
||||
def check_action(self, event: TypeMessage) -> bool:
|
||||
evt_hash = self._hash_event(event) if self._always_force_hash else event.id
|
||||
if evt_hash in self._dedup_action:
|
||||
return True
|
||||
|
||||
self._dedup_action.append(evt_hash)
|
||||
|
||||
if len(self._dedup_action) > self.cache_queue_length:
|
||||
self._dedup_action.popleft()
|
||||
return False
|
||||
|
||||
def update(self, event: TypeMessage, mxid: DedupMXID = None,
|
||||
expected_mxid: Optional[DedupMXID] = None, force_hash: bool = False
|
||||
) -> Optional[DedupMXID]:
|
||||
evt_hash = self._hash_event(event) if self._always_force_hash or force_hash else event.id
|
||||
try:
|
||||
found_mxid = self._dedup_mxid[evt_hash]
|
||||
except KeyError:
|
||||
return EventID("None"), TelegramID(0)
|
||||
|
||||
if found_mxid != expected_mxid:
|
||||
return found_mxid
|
||||
self._dedup_mxid[evt_hash] = mxid
|
||||
return None
|
||||
|
||||
def check(self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False
|
||||
) -> Optional[DedupMXID]:
|
||||
evt_hash = (self._hash_event(event)
|
||||
if self._always_force_hash or force_hash
|
||||
else event.id)
|
||||
if evt_hash in self._dedup:
|
||||
return self._dedup_mxid[evt_hash]
|
||||
|
||||
self._dedup_mxid[evt_hash] = mxid
|
||||
self._dedup.append(evt_hash)
|
||||
|
||||
if len(self._dedup) > self.cache_queue_length:
|
||||
del self._dedup_mxid[self._dedup.popleft()]
|
||||
return None
|
||||
|
||||
def register_outgoing_actions(self, response: TypeUpdates) -> None:
|
||||
for update in response.updates:
|
||||
check_dedup = (isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage))
|
||||
and isinstance(update.message, MessageService))
|
||||
if check_dedup:
|
||||
self.check(update.message)
|
||||
|
||||
|
||||
def init(context: Context) -> None:
|
||||
cfg = context.config
|
||||
PortalDedup.dedup_pre_db_check = cfg["bridge.deduplication.pre_db_check"]
|
||||
PortalDedup.dedup_cache_queue_length = cfg["bridge.deduplication.cache_queue_length"]
|
||||
@@ -0,0 +1,537 @@
|
||||
# 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 typing import Awaitable, Dict, List, Optional, Tuple, Union, Any, TYPE_CHECKING
|
||||
from html import escape as escape_html
|
||||
from string import Template
|
||||
from abc import ABC
|
||||
|
||||
import magic
|
||||
|
||||
from telethon.tl.functions.messages import (EditChatPhotoRequest, EditChatTitleRequest,
|
||||
UpdatePinnedMessageRequest, SetTypingRequest,
|
||||
EditChatAboutRequest)
|
||||
from telethon.tl.functions.channels import EditPhotoRequest, EditTitleRequest, JoinChannelRequest
|
||||
from telethon.errors import (ChatNotModifiedError, PhotoExtInvalidError,
|
||||
PhotoInvalidDimensionsError, PhotoSaveFileInvalidError)
|
||||
from telethon.tl.patched import Message, MessageService
|
||||
from telethon.tl.types import (
|
||||
DocumentAttributeFilename, DocumentAttributeImageSize, GeoPoint,
|
||||
InputChatUploadedPhoto, MessageActionChatEditPhoto, MessageMediaGeo,
|
||||
SendMessageCancelAction, SendMessageTypingAction, TypeInputPeer, TypeMessageEntity,
|
||||
UpdateNewMessage, InputMediaUploadedDocument, InputMediaUploadedPhoto)
|
||||
|
||||
from mautrix.types import (EventID, RoomID, UserID, ContentURI, MessageType, MessageEventContent,
|
||||
TextMessageEventContent, MediaMessageEventContent, Format,
|
||||
LocationMessageEventContent)
|
||||
from mautrix.bridge import BasePortal as MautrixBasePortal
|
||||
|
||||
from ..types import TelegramID
|
||||
from ..db import Message as DBMessage
|
||||
from ..util import sane_mimetypes, parallel_transfer_to_telegram
|
||||
from ..context import Context
|
||||
from .. import puppet as p, user as u, formatter, util
|
||||
from .base import BasePortal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..abstract_user import AbstractUser
|
||||
from ..tgclient import MautrixTelegramClient
|
||||
from ..config import Config
|
||||
|
||||
TypeMessage = Union[Message, MessageService]
|
||||
|
||||
config: Optional['Config'] = None
|
||||
|
||||
|
||||
class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
|
||||
async def _get_state_change_message(self, event: str, user: 'u.User', **kwargs: Any
|
||||
) -> Optional[str]:
|
||||
tpl = self.get_config(f"state_event_formats.{event}")
|
||||
if len(tpl) == 0:
|
||||
# Empty format means they don't want the message
|
||||
return None
|
||||
displayname = await self.get_displayname(user)
|
||||
|
||||
tpl_args = {
|
||||
"mxid": user.mxid,
|
||||
"username": user.mxid_localpart,
|
||||
"displayname": escape_html(displayname),
|
||||
**kwargs,
|
||||
}
|
||||
return Template(tpl).safe_substitute(tpl_args)
|
||||
|
||||
async def _send_state_change_message(self, event: str, user: 'u.User', event_id: EventID,
|
||||
**kwargs: Any) -> None:
|
||||
if not self.has_bot:
|
||||
return
|
||||
elif self.peer_type == "user" and not config["bridge.relaybot.private_chat.state_changes"]:
|
||||
return
|
||||
async with self.send_lock(self.bot.tgid):
|
||||
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)
|
||||
space = self.tgid if self.peer_type == "channel" else self.bot.tgid
|
||||
self.dedup.check(response, (event_id, space))
|
||||
|
||||
async def name_change_matrix(self, user: 'u.User', displayname: str, prev_displayname: str,
|
||||
event_id: EventID) -> None:
|
||||
await self._send_state_change_message("name_change", user, event_id,
|
||||
displayname=displayname,
|
||||
prev_displayname=prev_displayname)
|
||||
|
||||
async def get_displayname(self, user: 'u.User') -> str:
|
||||
return await self.main_intent.get_room_displayname(self.mxid, user.mxid) or user.mxid
|
||||
|
||||
def set_typing(self, user: 'u.User', typing: bool = True,
|
||||
action: type = SendMessageTypingAction) -> Awaitable[bool]:
|
||||
return user.client(SetTypingRequest(
|
||||
self.peer, action() if typing else SendMessageCancelAction()))
|
||||
|
||||
async def mark_read(self, user: 'u.User', event_id: EventID) -> None:
|
||||
if user.is_bot:
|
||||
return
|
||||
space = self.tgid if self.peer_type == "channel" else user.tgid
|
||||
message = DBMessage.get_by_mxid(event_id, self.mxid, space)
|
||||
if not message:
|
||||
return
|
||||
await user.client.send_read_acknowledge(self.peer, max_id=message.tgid,
|
||||
clear_mentions=True)
|
||||
|
||||
async def _preproc_kick_ban(self, user: Union['u.User', 'p.Puppet'], source: 'u.User'
|
||||
) -> Optional['AbstractUser']:
|
||||
if user.tgid == source.tgid:
|
||||
return None
|
||||
if self.peer_type == "user" and user.tgid == self.tgid:
|
||||
self.delete()
|
||||
return None
|
||||
if isinstance(user, u.User) and await user.needs_relaybot(self):
|
||||
if not self.bot:
|
||||
return None
|
||||
# TODO kick message
|
||||
return None
|
||||
if await source.needs_relaybot(self):
|
||||
if not self.has_bot:
|
||||
return None
|
||||
return self.bot
|
||||
return source
|
||||
|
||||
async def kick_matrix(self, user: Union['u.User', 'p.Puppet'], source: 'u.User') -> None:
|
||||
source = await self._preproc_kick_ban(user, source)
|
||||
if source is not None:
|
||||
await source.client.kick_participant(self.peer, user.peer)
|
||||
|
||||
async def ban_matrix(self, user: Union['u.User', 'p.Puppet'], source: 'u.User'):
|
||||
source = await self._preproc_kick_ban(user, source)
|
||||
if source is not None:
|
||||
await source.client.edit_permissions(self.peer, user.peer, view_messages=False)
|
||||
|
||||
async def leave_matrix(self, user: 'u.User', event_id: EventID) -> None:
|
||||
if await user.needs_relaybot(self):
|
||||
await self._send_state_change_message("leave", user, event_id)
|
||||
return
|
||||
|
||||
if self.peer_type == "user":
|
||||
await self.main_intent.leave_room(self.mxid)
|
||||
self.delete()
|
||||
try:
|
||||
del self.by_tgid[self.tgid_full]
|
||||
del self.by_mxid[self.mxid]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
await user.client.delete_dialog(self.peer)
|
||||
|
||||
async def join_matrix(self, user: 'u.User', event_id: EventID) -> None:
|
||||
if await user.needs_relaybot(self):
|
||||
await self._send_state_change_message("join", user, event_id)
|
||||
return
|
||||
|
||||
if self.peer_type == "channel" and not user.is_bot:
|
||||
await user.client(JoinChannelRequest(channel=await self.get_input_entity(user)))
|
||||
else:
|
||||
# We'll just assume the user is already in the chat.
|
||||
pass
|
||||
|
||||
async def _apply_msg_format(self, sender: 'u.User', content: MessageEventContent
|
||||
) -> None:
|
||||
if not isinstance(content, TextMessageEventContent) or content.format != Format.HTML:
|
||||
content.format = Format.HTML
|
||||
content.formatted_body = escape_html(content.body).replace("\n", "<br/>")
|
||||
|
||||
tpl = (self.get_config(f"message_formats.[{content.msgtype.value}]")
|
||||
or "<b>$sender_displayname</b>: $message")
|
||||
displayname = await self.get_displayname(sender)
|
||||
tpl_args = dict(sender_mxid=sender.mxid,
|
||||
sender_username=sender.mxid_localpart,
|
||||
sender_displayname=escape_html(displayname),
|
||||
message=content.formatted_body,
|
||||
body=content.body, formatted_body=content.formatted_body)
|
||||
content.formatted_body = Template(tpl).safe_substitute(tpl_args)
|
||||
|
||||
async def _apply_emote_format(self, sender: 'u.User',
|
||||
content: TextMessageEventContent) -> None:
|
||||
if content.format != Format.HTML:
|
||||
content.format = Format.HTML
|
||||
content.formatted_body = escape_html(content.body).replace("\n", "<br/>")
|
||||
|
||||
tpl = self.get_config("emote_format")
|
||||
puppet = p.Puppet.get(sender.tgid)
|
||||
content.formatted_body = Template(tpl).safe_substitute(
|
||||
dict(sender_mxid=sender.mxid,
|
||||
sender_username=sender.mxid_localpart,
|
||||
sender_displayname=escape_html(await self.get_displayname(sender)),
|
||||
mention=f"<a href='https://matrix.to/#/{puppet.mxid}'>{puppet.displayname}</a>",
|
||||
username=sender.username,
|
||||
displayname=puppet.displayname,
|
||||
body=content.body,
|
||||
formatted_body=content.formatted_body))
|
||||
content.msgtype = MessageType.TEXT
|
||||
|
||||
async def _pre_process_matrix_message(self, sender: 'u.User', use_relaybot: bool,
|
||||
content: MessageEventContent) -> None:
|
||||
if use_relaybot:
|
||||
await self._apply_msg_format(sender, content)
|
||||
elif content.msgtype == MessageType.EMOTE:
|
||||
await self._apply_emote_format(sender, content)
|
||||
|
||||
@staticmethod
|
||||
def _matrix_event_to_entities(event: Union[str, MessageEventContent]
|
||||
) -> Tuple[str, Optional[List[TypeMessageEntity]]]:
|
||||
try:
|
||||
if isinstance(event, str):
|
||||
message, entities = formatter.matrix_to_telegram(event)
|
||||
elif isinstance(event, TextMessageEventContent) and event.format == Format.HTML:
|
||||
message, entities = formatter.matrix_to_telegram(event.formatted_body)
|
||||
else:
|
||||
message, entities = formatter.matrix_text_to_telegram(event.body)
|
||||
except KeyError:
|
||||
message, entities = None, None
|
||||
return message, entities
|
||||
|
||||
async def _handle_matrix_text(self, sender_id: TelegramID, event_id: EventID,
|
||||
space: TelegramID, client: 'MautrixTelegramClient',
|
||||
content: TextMessageEventContent, reply_to: TelegramID) -> None:
|
||||
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,
|
||||
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,
|
||||
link_preview=lp)
|
||||
self._add_telegram_message_to_db(event_id, space, 0, response)
|
||||
|
||||
async def _handle_matrix_file(self, sender_id: TelegramID, event_id: EventID,
|
||||
space: TelegramID, client: 'MautrixTelegramClient',
|
||||
content: MediaMessageEventContent, reply_to: TelegramID,
|
||||
caption: TextMessageEventContent = None) -> None:
|
||||
mime = content.info.mimetype
|
||||
w, h = content.info.width, content.info.height
|
||||
file_name = content["net.maunium.telegram.internal.filename"]
|
||||
max_image_size = config["bridge.image_as_file_size"] * 1000 ** 2
|
||||
|
||||
if config["bridge.parallel_file_transfer"]:
|
||||
file_handle, file_size = await parallel_transfer_to_telegram(client, self.main_intent,
|
||||
content.url, sender_id)
|
||||
else:
|
||||
file = await self.main_intent.download_media(content.url)
|
||||
|
||||
if content.msgtype == MessageType.STICKER:
|
||||
if mime != "image/gif":
|
||||
mime, file, w, h = util.convert_image(file, source_mime=mime,
|
||||
target_type="webp")
|
||||
else:
|
||||
# Remove sticker description
|
||||
file_name = "sticker.gif"
|
||||
|
||||
file_handle = await client.upload_file(file)
|
||||
file_size = len(file)
|
||||
|
||||
file_handle.name = file_name
|
||||
|
||||
attributes = [DocumentAttributeFilename(file_name=file_name)]
|
||||
if w and h:
|
||||
attributes.append(DocumentAttributeImageSize(w, h))
|
||||
|
||||
if (mime == "image/png" or mime == "image/jpeg") and file_size < max_image_size:
|
||||
media = InputMediaUploadedPhoto(file_handle)
|
||||
else:
|
||||
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)
|
||||
|
||||
async with self.send_lock(sender_id):
|
||||
if await self._matrix_document_edit(client, content, space, caption, media, event_id):
|
||||
return
|
||||
try:
|
||||
response = await client.send_media(self.peer, media, reply_to=reply_to,
|
||||
caption=caption, entities=entities)
|
||||
except (PhotoInvalidDimensionsError, PhotoSaveFileInvalidError, PhotoExtInvalidError):
|
||||
media = InputMediaUploadedDocument(file=media.file, mime_type=mime,
|
||||
attributes=attributes)
|
||||
response = await client.send_media(self.peer, media, reply_to=reply_to,
|
||||
caption=caption, entities=entities)
|
||||
self._add_telegram_message_to_db(event_id, space, 0, response)
|
||||
|
||||
async def _matrix_document_edit(self, client: 'MautrixTelegramClient',
|
||||
content: MessageEventContent, space: TelegramID,
|
||||
caption: str, media: Any, event_id: EventID) -> bool:
|
||||
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,
|
||||
caption, file=media)
|
||||
self._add_telegram_message_to_db(event_id, space, -1, response)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def _handle_matrix_location(self, sender_id: TelegramID, event_id: EventID,
|
||||
space: TelegramID, client: 'MautrixTelegramClient',
|
||||
content: LocationMessageEventContent, reply_to: TelegramID
|
||||
) -> None:
|
||||
try:
|
||||
lat, long = content.geo_uri[len("geo:"):].split(",")
|
||||
lat, long = float(lat), float(long)
|
||||
except (KeyError, ValueError):
|
||||
self.log.exception("Failed to parse location")
|
||||
return None
|
||||
caption, entities = self._matrix_event_to_entities(content)
|
||||
media = MessageMediaGeo(geo=GeoPoint(lat, long, access_hash=0))
|
||||
|
||||
async with self.send_lock(sender_id):
|
||||
if await self._matrix_document_edit(client, content, space, caption, media, event_id):
|
||||
return
|
||||
response = await client.send_media(self.peer, media, reply_to=reply_to,
|
||||
caption=caption, entities=entities)
|
||||
self._add_telegram_message_to_db(event_id, space, 0, response)
|
||||
|
||||
def _add_telegram_message_to_db(self, event_id: EventID, space: TelegramID,
|
||||
edit_index: int, response: TypeMessage) -> None:
|
||||
self.log.debug("Handled Matrix message: %s", response)
|
||||
self.dedup.check(response, (event_id, space), force_hash=edit_index != 0)
|
||||
if edit_index < 0:
|
||||
prev_edit = DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1)
|
||||
edit_index = prev_edit.edit_index + 1
|
||||
DBMessage(
|
||||
tgid=TelegramID(response.id),
|
||||
tg_space=space,
|
||||
mx_room=self.mxid,
|
||||
mxid=event_id,
|
||||
edit_index=edit_index).insert()
|
||||
|
||||
async def handle_matrix_message(self, sender: 'u.User', content: MessageEventContent,
|
||||
event_id: EventID) -> None:
|
||||
if not content.body or not content.msgtype:
|
||||
self.log.debug(f"Ignoring message {event_id} in {self.mxid} without body or msgtype")
|
||||
return
|
||||
|
||||
puppet = p.Puppet.get_by_custom_mxid(sender.mxid)
|
||||
if puppet and content.get("net.maunium.telegram.puppet", False):
|
||||
self.log.debug("Ignoring puppet-sent message by confirmed puppet user %s", sender.mxid)
|
||||
return
|
||||
|
||||
logged_in = not await sender.needs_relaybot(self)
|
||||
client = sender.client if logged_in else self.bot.client
|
||||
sender_id = sender.tgid if logged_in else self.bot.tgid
|
||||
space = (self.tgid if self.peer_type == "channel" # Channels have their own ID space
|
||||
else (sender.tgid if logged_in else self.bot.tgid))
|
||||
reply_to = formatter.matrix_reply_to_telegram(content, space, room_id=self.mxid)
|
||||
|
||||
media = (MessageType.STICKER, MessageType.IMAGE, MessageType.FILE, MessageType.AUDIO,
|
||||
MessageType.VIDEO)
|
||||
|
||||
if content.msgtype == MessageType.NOTICE:
|
||||
bridge_notices = self.get_config("bridge_notices.default")
|
||||
excepted = sender.mxid in self.get_config("bridge_notices.exceptions")
|
||||
if not bridge_notices and not excepted:
|
||||
return
|
||||
|
||||
if content.msgtype in (MessageType.TEXT, MessageType.NOTICE):
|
||||
await self._pre_process_matrix_message(sender, not logged_in, content)
|
||||
await self._handle_matrix_text(sender_id, event_id, space, client, content, reply_to)
|
||||
elif content.msgtype == MessageType.LOCATION:
|
||||
await self._pre_process_matrix_message(sender, not logged_in, content)
|
||||
await self._handle_matrix_location(sender_id, event_id, space, client, content,
|
||||
reply_to)
|
||||
elif content.msgtype in media:
|
||||
content["net.maunium.telegram.internal.filename"] = content.body
|
||||
try:
|
||||
caption_content: MessageEventContent = sender.command_status["caption"]
|
||||
reply_to = reply_to or formatter.matrix_reply_to_telegram(caption_content, space,
|
||||
room_id=self.mxid)
|
||||
sender.command_status = None
|
||||
except (KeyError, TypeError):
|
||||
caption_content = None if logged_in else TextMessageEventContent(body=content.body)
|
||||
if caption_content:
|
||||
caption_content.msgtype = content.msgtype
|
||||
await self._pre_process_matrix_message(sender, not logged_in, caption_content)
|
||||
await self._handle_matrix_file(sender_id, event_id, space, client, content, reply_to,
|
||||
caption_content)
|
||||
else:
|
||||
self.log.debug(f"Unhandled Matrix event: {content}")
|
||||
|
||||
async def handle_matrix_pin(self, sender: 'u.User',
|
||||
pinned_message: Optional[EventID]) -> None:
|
||||
if self.peer_type != "chat" and self.peer_type != "channel":
|
||||
return
|
||||
try:
|
||||
if not pinned_message:
|
||||
await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=0))
|
||||
else:
|
||||
tg_space = self.tgid if self.peer_type == "channel" else sender.tgid
|
||||
message = DBMessage.get_by_mxid(pinned_message, self.mxid, tg_space)
|
||||
if message is None:
|
||||
self.log.warning(f"Could not find pinned {pinned_message} in {self.mxid}")
|
||||
return
|
||||
await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=message.tgid))
|
||||
except ChatNotModifiedError:
|
||||
pass
|
||||
|
||||
async def handle_matrix_deletion(self, deleter: 'u.User', event_id: EventID) -> None:
|
||||
real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot
|
||||
space = self.tgid if self.peer_type == "channel" else real_deleter.tgid
|
||||
message = DBMessage.get_by_mxid(event_id, self.mxid, space)
|
||||
if not message:
|
||||
return
|
||||
if message.edit_index == 0:
|
||||
await real_deleter.client.delete_messages(self.peer, [message.tgid])
|
||||
else:
|
||||
self.log.debug(f"Ignoring deletion of edit event {message.mxid} in {message.mx_room}")
|
||||
|
||||
async def _update_telegram_power_level(self, sender: 'u.User', user_id: TelegramID,
|
||||
level: int) -> None:
|
||||
moderator = level >= 50
|
||||
admin = level >= 75
|
||||
await sender.client.edit_admin(self.peer, user_id,
|
||||
change_info=moderator, post_messages=moderator,
|
||||
edit_messages=moderator, delete_messages=moderator,
|
||||
ban_users=moderator, invite_users=moderator,
|
||||
pin_messages=moderator, add_admins=admin)
|
||||
|
||||
async def handle_matrix_power_levels(self, sender: 'u.User', new_users: Dict[UserID, int],
|
||||
old_users: Dict[UserID, int]) -> None:
|
||||
# TODO handle all power level changes and bridge exact admin rights to supergroups/channels
|
||||
for user, level in new_users.items():
|
||||
if not user or user == self.main_intent.mxid or user == sender.mxid:
|
||||
continue
|
||||
user_id = p.Puppet.get_id_from_mxid(user)
|
||||
if not user_id:
|
||||
mx_user = u.User.get_by_mxid(user, create=False)
|
||||
if not mx_user or not mx_user.tgid:
|
||||
continue
|
||||
user_id = mx_user.tgid
|
||||
if not user_id or user_id == sender.tgid:
|
||||
continue
|
||||
if user not in old_users or level != old_users[user]:
|
||||
await self._update_telegram_power_level(sender, user_id, level)
|
||||
|
||||
async def handle_matrix_about(self, sender: 'u.User', about: str) -> None:
|
||||
if self.peer_type not in ("chat", "channel"):
|
||||
return
|
||||
peer = await self.get_input_entity(sender)
|
||||
await sender.client(EditChatAboutRequest(peer=peer, about=about))
|
||||
self.about = about
|
||||
self.save()
|
||||
|
||||
async def handle_matrix_title(self, sender: 'u.User', title: str) -> None:
|
||||
if self.peer_type not in ("chat", "channel"):
|
||||
return
|
||||
|
||||
if self.peer_type == "chat":
|
||||
response = await sender.client(EditChatTitleRequest(chat_id=self.tgid, title=title))
|
||||
else:
|
||||
channel = await self.get_input_entity(sender)
|
||||
response = await sender.client(EditTitleRequest(channel=channel, title=title))
|
||||
self.dedup.register_outgoing_actions(response)
|
||||
self.title = title
|
||||
self.save()
|
||||
|
||||
async def handle_matrix_avatar(self, sender: 'u.User', url: ContentURI) -> None:
|
||||
if self.peer_type not in ("chat", "channel"):
|
||||
# Invalid peer type
|
||||
return
|
||||
|
||||
file = await self.main_intent.download_media(url)
|
||||
mime = magic.from_buffer(file, mime=True)
|
||||
ext = sane_mimetypes.guess_extension(mime)
|
||||
uploaded = await sender.client.upload_file(file, file_name=f"avatar{ext}")
|
||||
photo = InputChatUploadedPhoto(file=uploaded)
|
||||
|
||||
if self.peer_type == "chat":
|
||||
response = await sender.client(EditChatPhotoRequest(chat_id=self.tgid, photo=photo))
|
||||
else:
|
||||
channel = await self.get_input_entity(sender)
|
||||
response = await sender.client(EditPhotoRequest(channel=channel, photo=photo))
|
||||
self.dedup.register_outgoing_actions(response)
|
||||
for update in response.updates:
|
||||
is_photo_update = (isinstance(update, UpdateNewMessage)
|
||||
and isinstance(update.message, MessageService)
|
||||
and isinstance(update.message.action, MessageActionChatEditPhoto))
|
||||
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()
|
||||
break
|
||||
|
||||
async def handle_matrix_upgrade(self, sender: UserID, new_room: RoomID) -> None:
|
||||
_, server = self.main_intent.parse_user_id(sender)
|
||||
old_room = self.mxid
|
||||
self.migrate_and_save_matrix(new_room)
|
||||
await self.main_intent.join_room(new_room, servers=[server])
|
||||
entity: Optional[TypeInputPeer] = None
|
||||
user: Optional[AbstractUser] = None
|
||||
if self.bot and self.has_bot:
|
||||
user = self.bot
|
||||
entity = await self.get_input_entity(self.bot)
|
||||
if not entity:
|
||||
user_mxids = await self.main_intent.get_room_members(self.mxid)
|
||||
for user_str in user_mxids:
|
||||
user_id = UserID(user_str)
|
||||
if user_id == self.az.bot_mxid:
|
||||
continue
|
||||
user = u.User.get_by_mxid(user_id, create=False)
|
||||
if user and user.tgid:
|
||||
entity = await self.get_input_entity(user)
|
||||
if entity:
|
||||
break
|
||||
if not entity:
|
||||
self.log.error("Failed to fully migrate to upgraded Matrix room: "
|
||||
"no Telegram user found.")
|
||||
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}")
|
||||
|
||||
def migrate_and_save_matrix(self, new_id: RoomID) -> None:
|
||||
try:
|
||||
del self.by_mxid[self.mxid]
|
||||
except KeyError:
|
||||
pass
|
||||
self.mxid = new_id
|
||||
self.db_instance.edit(mxid=self.mxid)
|
||||
self.by_mxid[self.mxid] = self
|
||||
|
||||
|
||||
def init(context: Context) -> None:
|
||||
global config
|
||||
config = context.config
|
||||
@@ -0,0 +1,708 @@
|
||||
# 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 typing import List, Optional, Tuple, Union, Callable, TYPE_CHECKING
|
||||
from abc import ABC
|
||||
import asyncio
|
||||
|
||||
from telethon.tl.functions.messages import (AddChatUserRequest, CreateChatRequest,
|
||||
GetFullChatRequest, MigrateChatRequest)
|
||||
from telethon.tl.functions.channels import (CreateChannelRequest, GetParticipantsRequest,
|
||||
InviteToChannelRequest, UpdateUsernameRequest)
|
||||
from telethon.errors import ChatAdminRequiredError
|
||||
from telethon.tl.types import (
|
||||
Channel, ChatBannedRights, ChannelParticipantsRecent, ChannelParticipantsSearch, ChatPhoto,
|
||||
PhotoEmpty, InputChannel, InputUser, ChatPhotoEmpty, PeerUser, Photo, TypeChat, TypeInputPeer,
|
||||
TypeUser, User, InputPeerPhotoFileLocation, ChatParticipantAdmin, ChannelParticipantAdmin,
|
||||
ChatParticipantCreator, ChannelParticipantCreator)
|
||||
|
||||
from mautrix.errors import MForbidden
|
||||
from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership, Member,
|
||||
PowerLevelStateEventContent, RoomAlias)
|
||||
from mautrix.appservice import IntentAPI
|
||||
|
||||
from ..types import TelegramID
|
||||
from ..context import Context
|
||||
from .. import puppet as p, user as u, util
|
||||
from .base import BasePortal, InviteList, TypeParticipant, TypeChatPhoto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..abstract_user import AbstractUser
|
||||
from ..config import Config
|
||||
|
||||
config: Optional['Config'] = None
|
||||
|
||||
|
||||
class PortalMetadata(BasePortal, ABC):
|
||||
_room_create_lock: asyncio.Lock
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._room_create_lock = asyncio.Lock()
|
||||
|
||||
# region Matrix -> Telegram
|
||||
|
||||
async def _get_telegram_users_in_matrix_room(self) -> List[Union[InputUser, PeerUser]]:
|
||||
user_tgids = set()
|
||||
user_mxids = await self.main_intent.get_room_members(self.mxid, (Membership.JOIN,
|
||||
Membership.INVITE))
|
||||
for user_str in user_mxids:
|
||||
user = UserID(user_str)
|
||||
if user == self.az.bot_mxid:
|
||||
continue
|
||||
mx_user = u.User.get_by_mxid(user, create=False)
|
||||
if mx_user and mx_user.tgid:
|
||||
user_tgids.add(mx_user.tgid)
|
||||
puppet_id = p.Puppet.get_id_from_mxid(user)
|
||||
if puppet_id:
|
||||
user_tgids.add(puppet_id)
|
||||
return [PeerUser(user_id) for user_id in user_tgids]
|
||||
|
||||
async def upgrade_telegram_chat(self, source: 'u.User') -> None:
|
||||
if self.peer_type != "chat":
|
||||
raise ValueError("Only normal group chats are upgradable to supergroups.")
|
||||
|
||||
response = await source.client(MigrateChatRequest(chat_id=self.tgid))
|
||||
entity = None
|
||||
for chat in response.chats:
|
||||
if isinstance(chat, Channel):
|
||||
entity = chat
|
||||
break
|
||||
if not entity:
|
||||
raise ValueError("Upgrade may have failed: output channel not found.")
|
||||
self.peer_type = "channel"
|
||||
self._migrate_and_save_telegram(TelegramID(entity.id))
|
||||
await self.update_info(source, entity)
|
||||
|
||||
def _migrate_and_save_telegram(self, new_id: TelegramID) -> None:
|
||||
try:
|
||||
del self.by_tgid[self.tgid_full]
|
||||
except KeyError:
|
||||
pass
|
||||
try:
|
||||
existing = self.by_tgid[(new_id, new_id)]
|
||||
existing.delete()
|
||||
except KeyError:
|
||||
pass
|
||||
self.db_instance.edit(tgid=new_id, tg_receiver=new_id, peer_type=self.peer_type)
|
||||
old_id = self.tgid
|
||||
self.tgid = new_id
|
||||
self.tg_receiver = new_id
|
||||
self.by_tgid[self.tgid_full] = self
|
||||
self.log = self.base_log.getChild(self.tgid_log)
|
||||
self.log.info(f"Telegram chat upgraded from {old_id}")
|
||||
|
||||
async def set_telegram_username(self, source: 'u.User', username: str) -> None:
|
||||
if self.peer_type != "channel":
|
||||
raise ValueError("Only channels and supergroups have usernames.")
|
||||
await source.client(
|
||||
UpdateUsernameRequest(await self.get_input_entity(source), username))
|
||||
if await self._update_username(username):
|
||||
self.save()
|
||||
|
||||
async def create_telegram_chat(self, source: 'u.User', supergroup: bool = False) -> None:
|
||||
if not self.mxid:
|
||||
raise ValueError("Can't create Telegram chat for portal without Matrix room.")
|
||||
elif self.tgid:
|
||||
raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.")
|
||||
|
||||
invites = await self._get_telegram_users_in_matrix_room()
|
||||
if len(invites) < 2:
|
||||
if self.bot is not None:
|
||||
info, mxid = await self.bot.get_me()
|
||||
raise ValueError("Not enough Telegram users to create a chat. "
|
||||
"Invite more Telegram ghost users to the room, such as the "
|
||||
f"relaybot ([{info.first_name}](https://matrix.to/#/{mxid})).")
|
||||
raise ValueError("Not enough Telegram users to create a chat. "
|
||||
"Invite more Telegram ghost users to the room.")
|
||||
if self.peer_type == "chat":
|
||||
response = await source.client(CreateChatRequest(title=self.title, users=invites))
|
||||
entity = response.chats[0]
|
||||
elif self.peer_type == "channel":
|
||||
response = await source.client(CreateChannelRequest(title=self.title,
|
||||
about=self.about or "",
|
||||
megagroup=supergroup))
|
||||
entity = response.chats[0]
|
||||
await source.client(InviteToChannelRequest(
|
||||
channel=await source.client.get_input_entity(entity),
|
||||
users=invites))
|
||||
else:
|
||||
raise ValueError("Invalid peer type for Telegram chat creation")
|
||||
|
||||
self.tgid = entity.id
|
||||
self.tg_receiver = self.tgid
|
||||
self.by_tgid[self.tgid_full] = self
|
||||
await self.update_info(source, entity)
|
||||
self.db_instance.insert()
|
||||
self.log = self.base_log.getChild(self.tgid_log)
|
||||
|
||||
if self.bot and self.bot.tgid in invites:
|
||||
self.bot.add_chat(self.tgid, self.peer_type)
|
||||
|
||||
levels = await self.main_intent.get_power_levels(self.mxid)
|
||||
if levels.get_user_level(self.main_intent.mxid) == 100:
|
||||
levels = self._get_base_power_levels(levels, entity)
|
||||
await self.main_intent.set_power_levels(self.mxid, levels)
|
||||
await self.handle_matrix_power_levels(source, levels.users, {})
|
||||
|
||||
async def invite_telegram(self, source: 'u.User',
|
||||
puppet: Union[p.Puppet, 'AbstractUser']) -> None:
|
||||
if self.peer_type == "chat":
|
||||
await source.client(
|
||||
AddChatUserRequest(chat_id=self.tgid, user_id=puppet.tgid, fwd_limit=0))
|
||||
elif self.peer_type == "channel":
|
||||
await source.client(InviteToChannelRequest(channel=self.peer, users=[puppet.tgid]))
|
||||
# We don't care if there are invites for private chat portals with the relaybot.
|
||||
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
|
||||
|
||||
async def invite_to_matrix(self, users: InviteList) -> None:
|
||||
if isinstance(users, list):
|
||||
for user in users:
|
||||
await self.main_intent.invite_user(self.mxid, user, check_cache=True)
|
||||
else:
|
||||
await self.main_intent.invite_user(self.mxid, users, check_cache=True)
|
||||
|
||||
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:
|
||||
if direct is None:
|
||||
direct = self.peer_type == "user"
|
||||
try:
|
||||
await self._update_matrix_room(user, entity, direct, puppet, levels, users,
|
||||
participants)
|
||||
except Exception:
|
||||
self.log.exception("Fatal error updating Matrix room")
|
||||
|
||||
async def _update_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
|
||||
direct: bool, puppet: p.Puppet = None,
|
||||
levels: PowerLevelStateEventContent = None,
|
||||
users: List[User] = None,
|
||||
participants: List[TypeParticipant] = 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)
|
||||
await self._sync_telegram_users(user, users)
|
||||
await self.update_telegram_participants(participants, levels)
|
||||
else:
|
||||
if not puppet:
|
||||
puppet = p.Puppet.get(self.tgid)
|
||||
await puppet.update_info(user, entity)
|
||||
await puppet.intent_for(self).join_room(self.mxid)
|
||||
if self.sync_matrix_state:
|
||||
await self.sync_matrix_members()
|
||||
|
||||
async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None,
|
||||
invites: InviteList = None, update_if_exists: bool = True,
|
||||
synchronous: bool = False) -> Optional[str]:
|
||||
if self.mxid:
|
||||
if update_if_exists:
|
||||
if not entity:
|
||||
try:
|
||||
entity = await self.get_entity(user)
|
||||
except Exception:
|
||||
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)
|
||||
await self.invite_to_matrix(invites or [])
|
||||
return self.mxid
|
||||
async with self._room_create_lock:
|
||||
try:
|
||||
return await self._create_matrix_room(user, entity, invites)
|
||||
except Exception:
|
||||
self.log.exception("Fatal error creating Matrix room")
|
||||
|
||||
async def _create_matrix_room(self, user: 'AbstractUser', entity: TypeChat, invites: InviteList
|
||||
) -> Optional[RoomID]:
|
||||
direct = self.peer_type == "user"
|
||||
|
||||
if self.mxid:
|
||||
return self.mxid
|
||||
|
||||
if not self.allow_bridging:
|
||||
return None
|
||||
|
||||
if not entity:
|
||||
entity = await self.get_entity(user)
|
||||
self.log.debug(f"Fetched data: {entity}")
|
||||
|
||||
self.log.debug("Creating room")
|
||||
|
||||
try:
|
||||
self.title = entity.title
|
||||
except AttributeError:
|
||||
self.title = None
|
||||
|
||||
if direct and self.tgid == user.tgid:
|
||||
self.title = "Telegram Saved Messages"
|
||||
self.about = "Your Telegram cloud storage chat"
|
||||
|
||||
puppet = p.Puppet.get(self.tgid) if direct else None
|
||||
self._main_intent = puppet.intent_for(self) if direct else self.az.intent
|
||||
|
||||
if self.peer_type == "channel":
|
||||
self.megagroup = entity.megagroup
|
||||
|
||||
if self.peer_type == "channel" and entity.username:
|
||||
preset = RoomCreatePreset.PUBLIC
|
||||
self.username = entity.username
|
||||
alias = self.alias_localpart
|
||||
else:
|
||||
preset = RoomCreatePreset.PRIVATE
|
||||
# TODO invite link alias?
|
||||
alias = None
|
||||
|
||||
if alias:
|
||||
# TODO? properly handle existing room aliases
|
||||
await self.main_intent.remove_room_alias(alias)
|
||||
|
||||
power_levels = self._get_base_power_levels(entity=entity)
|
||||
users = participants = None
|
||||
if not direct:
|
||||
users, participants = await self._get_users(user, entity)
|
||||
if self.has_bot:
|
||||
extra_invites = config["bridge.relaybot.group_chat_invite"]
|
||||
invites += extra_invites
|
||||
for invite in extra_invites:
|
||||
power_levels.users.setdefault(invite, 100)
|
||||
self._participants_to_power_levels(participants, power_levels)
|
||||
elif self.bot and self.tg_receiver == self.bot.tgid:
|
||||
invites = config["bridge.relaybot.private_chat.invite"]
|
||||
for invite in invites:
|
||||
power_levels.users.setdefault(invite, 100)
|
||||
self.title = puppet.displayname
|
||||
initial_state = [{
|
||||
"type": EventType.ROOM_POWER_LEVELS.serialize(),
|
||||
"content": power_levels.serialize(),
|
||||
}]
|
||||
if config["appservice.community_id"]:
|
||||
initial_state.append({
|
||||
"type": "m.room.related_groups",
|
||||
"content": {"groups": [config["appservice.community_id"]]},
|
||||
})
|
||||
creation_content = {}
|
||||
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")
|
||||
|
||||
self.mxid = RoomID(room_id)
|
||||
self.by_mxid[self.mxid] = self
|
||||
self.save()
|
||||
self.az.state_store.set_power_levels(self.mxid, power_levels)
|
||||
user.register_portal(self)
|
||||
asyncio.ensure_future(self.update_matrix_room(user, entity, direct, puppet,
|
||||
levels=power_levels, users=users,
|
||||
participants=participants), loop=self.loop)
|
||||
|
||||
return self.mxid
|
||||
|
||||
def _get_base_power_levels(self, levels: PowerLevelStateEventContent = None,
|
||||
entity: TypeChat = None) -> PowerLevelStateEventContent:
|
||||
levels = levels or PowerLevelStateEventContent()
|
||||
if self.peer_type == "user":
|
||||
overrides = config["bridge.initial_power_level_overrides.user"]
|
||||
levels.ban = overrides.get("ban", 100)
|
||||
levels.kick = overrides.get("kick", 100)
|
||||
levels.invite = overrides.get("invite", 100)
|
||||
levels.redact = overrides.get("redact", 0)
|
||||
levels.events[EventType.ROOM_NAME] = 0
|
||||
levels.events[EventType.ROOM_AVATAR] = 0
|
||||
levels.events[EventType.ROOM_TOPIC] = 0
|
||||
levels.state_default = overrides.get("state_default", 0)
|
||||
levels.users_default = overrides.get("users_default", 0)
|
||||
levels.events_default = overrides.get("events_default", 0)
|
||||
else:
|
||||
overrides = config["bridge.initial_power_level_overrides.group"]
|
||||
dbr = entity.default_banned_rights
|
||||
if not dbr:
|
||||
self.log.debug(f"default_banned_rights is None in {entity}")
|
||||
dbr = ChatBannedRights(invite_users=True, change_info=True, pin_messages=True,
|
||||
send_stickers=False, send_messages=False, until_date=None)
|
||||
levels.ban = overrides.get("ban", 50)
|
||||
levels.kick = overrides.get("kick", 50)
|
||||
levels.redact = overrides.get("redact", 50)
|
||||
levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0)
|
||||
levels.events[EventType.ROOM_ENCRYPTED] = 99
|
||||
levels.events[EventType.ROOM_TOMBSTONE] = 99
|
||||
levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0
|
||||
levels.events[EventType.ROOM_AVATAR] = 50 if dbr.change_info else 0
|
||||
levels.events[EventType.ROOM_TOPIC] = 50 if dbr.change_info else 0
|
||||
levels.events[EventType.ROOM_PINNED_EVENTS] = 50 if dbr.pin_messages else 0
|
||||
levels.events[EventType.ROOM_POWER_LEVELS] = 75
|
||||
levels.events[EventType.ROOM_HISTORY_VISIBILITY] = 75
|
||||
levels.events[EventType.STICKER] = 50 if dbr.send_stickers else levels.events_default
|
||||
levels.state_default = overrides.get("state_default", 50)
|
||||
levels.users_default = overrides.get("users_default", 0)
|
||||
levels.events_default = (
|
||||
overrides.get("events_default",
|
||||
50 if (self.peer_type == "channel" and not entity.megagroup
|
||||
or entity.default_banned_rights.send_messages)
|
||||
else 0))
|
||||
for evt_type, value in overrides.get("events", {}).items():
|
||||
levels.events[EventType.find(evt_type)] = value
|
||||
levels.users = overrides.get("users", {})
|
||||
if self.main_intent.mxid not in levels.users:
|
||||
levels.users[self.main_intent.mxid] = 100
|
||||
return levels
|
||||
|
||||
@staticmethod
|
||||
def _get_level_from_participant(participant: TypeParticipant) -> int:
|
||||
# TODO use the power level requirements to get better precision in channels
|
||||
if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
|
||||
return 50
|
||||
elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)):
|
||||
return 95
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _participant_to_power_levels(levels: PowerLevelStateEventContent,
|
||||
user: Union['u.User', p.Puppet], new_level: int,
|
||||
bot_level: int) -> bool:
|
||||
new_level = min(new_level, bot_level)
|
||||
user_level = levels.get_user_level(user.mxid)
|
||||
if user_level != new_level and user_level < bot_level:
|
||||
levels.users[user.mxid] = new_level
|
||||
return True
|
||||
return False
|
||||
|
||||
def _participants_to_power_levels(self, participants: List[TypeParticipant],
|
||||
levels: PowerLevelStateEventContent) -> bool:
|
||||
bot_level = levels.get_user_level(self.main_intent.mxid)
|
||||
if bot_level < levels.get_event_level(EventType.ROOM_POWER_LEVELS):
|
||||
return False
|
||||
changed = False
|
||||
admin_power_level = min(75 if self.peer_type == "channel" else 50, bot_level)
|
||||
if levels.events[EventType.ROOM_POWER_LEVELS] != admin_power_level:
|
||||
changed = True
|
||||
levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
|
||||
|
||||
for participant in participants:
|
||||
puppet = p.Puppet.get(TelegramID(participant.user_id))
|
||||
user = u.User.get_by_tgid(TelegramID(participant.user_id))
|
||||
new_level = self._get_level_from_participant(participant)
|
||||
|
||||
if user:
|
||||
user.register_portal(self)
|
||||
changed = self._participant_to_power_levels(levels, user, new_level,
|
||||
bot_level) or changed
|
||||
|
||||
if puppet:
|
||||
changed = self._participant_to_power_levels(levels, puppet, new_level,
|
||||
bot_level) or changed
|
||||
return changed
|
||||
|
||||
async def update_telegram_participants(self, participants: List[TypeParticipant],
|
||||
levels: PowerLevelStateEventContent = None) -> None:
|
||||
if not levels:
|
||||
levels = await self.main_intent.get_power_levels(self.mxid)
|
||||
if self._participants_to_power_levels(participants, levels):
|
||||
await self.main_intent.set_power_levels(self.mxid, levels)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
allowed_tgids.add(entity.id)
|
||||
await puppet.intent_for(self).ensure_joined(self.mxid)
|
||||
await puppet.update_info(source, entity)
|
||||
|
||||
user = u.User.get_by_tgid(TelegramID(entity.id))
|
||||
if user:
|
||||
await self.invite_to_matrix(user.mxid)
|
||||
|
||||
# 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
|
||||
and (self.megagroup or self.peer_type != "channel"))
|
||||
if trust_member_list:
|
||||
joined_mxids = await self.main_intent.get_room_members(self.mxid)
|
||||
for user_mxid in joined_mxids:
|
||||
if user_mxid == self.az.bot_mxid:
|
||||
continue
|
||||
puppet_id = p.Puppet.get_id_from_mxid(user_mxid)
|
||||
if puppet_id and puppet_id not in allowed_tgids:
|
||||
if self.bot and puppet_id == self.bot.tgid:
|
||||
self.bot.remove_chat(self.tgid)
|
||||
try:
|
||||
await self.main_intent.kick_user(self.mxid, user_mxid,
|
||||
"User had left this Telegram chat.")
|
||||
except MForbidden:
|
||||
pass
|
||||
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)
|
||||
|
||||
if mx_user and not self.has_bot and mx_user.tgid not in allowed_tgids:
|
||||
try:
|
||||
await self.main_intent.kick_user(self.mxid, mx_user.mxid,
|
||||
"You had left this Telegram chat.")
|
||||
except MForbidden:
|
||||
pass
|
||||
continue
|
||||
|
||||
async def _add_telegram_user(self, user_id: TelegramID, source: Optional['AbstractUser'] = None
|
||||
) -> None:
|
||||
puppet = p.Puppet.get(user_id)
|
||||
if source:
|
||||
entity: User = await source.client.get_entity(PeerUser(user_id))
|
||||
await puppet.update_info(source, entity)
|
||||
await puppet.intent_for(self).ensure_joined(self.mxid)
|
||||
|
||||
user = u.User.get_by_tgid(user_id)
|
||||
if user:
|
||||
user.register_portal(self)
|
||||
await self.invite_to_matrix(user.mxid)
|
||||
|
||||
async def _delete_telegram_user(self, user_id: TelegramID, sender: p.Puppet) -> None:
|
||||
puppet = p.Puppet.get(user_id)
|
||||
user = u.User.get_by_tgid(user_id)
|
||||
kick_message = (f"Kicked by {sender.displayname}"
|
||||
if sender and sender.tgid != puppet.tgid
|
||||
else "Left Telegram chat")
|
||||
if sender.tgid != puppet.tgid:
|
||||
try:
|
||||
await sender.intent_for(self).kick_user(self.mxid, puppet.mxid)
|
||||
except MForbidden:
|
||||
await self.main_intent.kick_user(self.mxid, puppet.mxid, kick_message)
|
||||
else:
|
||||
await puppet.intent_for(self).leave_room(self.mxid)
|
||||
if user:
|
||||
user.unregister_portal(self)
|
||||
if sender.tgid != puppet.tgid:
|
||||
try:
|
||||
await sender.intent_for(self).kick_user(self.mxid, puppet.mxid)
|
||||
return
|
||||
except MForbidden:
|
||||
pass
|
||||
try:
|
||||
await self.main_intent.kick_user(self.mxid, user.mxid, kick_message)
|
||||
except MForbidden as e:
|
||||
self.log.warning(f"Failed to kick {user.mxid}: {e}")
|
||||
|
||||
async def update_info(self, user: 'AbstractUser', entity: TypeChat = None) -> None:
|
||||
if self.peer_type == "user":
|
||||
self.log.warning("Called update_info() for direct chat portal")
|
||||
return
|
||||
|
||||
self.log.debug("Updating info")
|
||||
try:
|
||||
if not entity:
|
||||
entity = await self.get_entity(user)
|
||||
self.log.debug(f"Fetched data: {entity}")
|
||||
changed = False
|
||||
|
||||
if self.peer_type == "channel":
|
||||
changed = self.megagroup != entity.megagroup or changed
|
||||
self.megagroup = entity.megagroup
|
||||
changed = await self._update_username(entity.username) or changed
|
||||
|
||||
if hasattr(entity, "about"):
|
||||
changed = self._update_about(entity.about) or changed
|
||||
|
||||
changed = await self._update_title(entity.title) or changed
|
||||
|
||||
if isinstance(entity.photo, ChatPhoto):
|
||||
changed = await self._update_avatar(user, entity.photo) or changed
|
||||
except Exception:
|
||||
self.log.exception(f"Failed to update info from source {user.tgid}")
|
||||
|
||||
if changed:
|
||||
self.save()
|
||||
|
||||
async def _update_username(self, username: str, save: bool = False) -> bool:
|
||||
if self.username == username:
|
||||
return False
|
||||
|
||||
if self.username:
|
||||
await self.main_intent.remove_room_alias(self.alias_localpart)
|
||||
self.username = username or None
|
||||
if self.username:
|
||||
await self.main_intent.add_room_alias(self.mxid, self.alias_localpart, override=True)
|
||||
if self.public_portals:
|
||||
await self.main_intent.set_join_rule(self.mxid, "public")
|
||||
else:
|
||||
await self.main_intent.set_join_rule(self.mxid, "invite")
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
return True
|
||||
|
||||
async def _try_use_intent(self, sender: Optional['p.Puppet'],
|
||||
action: Callable[[IntentAPI], None]) -> None:
|
||||
if sender:
|
||||
try:
|
||||
await action(sender.intent_for(self))
|
||||
except MForbidden:
|
||||
await action(self.main_intent)
|
||||
else:
|
||||
await action(self.main_intent)
|
||||
|
||||
async def _update_about(self, about: str, sender: Optional['p.Puppet'] = None,
|
||||
save: bool = False) -> bool:
|
||||
if self.about == about:
|
||||
return False
|
||||
|
||||
self.about = about
|
||||
await self._try_use_intent(sender,
|
||||
lambda intent: intent.set_room_topic(self.mxid, self.about))
|
||||
if save:
|
||||
self.save()
|
||||
return True
|
||||
|
||||
async def _update_title(self, title: str, sender: Optional['p.Puppet'] = None,
|
||||
save: bool = False) -> bool:
|
||||
if self.title == title:
|
||||
return False
|
||||
|
||||
self.title = title
|
||||
await self._try_use_intent(sender,
|
||||
lambda intent: intent.set_room_name(self.mxid, self.title))
|
||||
if save:
|
||||
self.save()
|
||||
return True
|
||||
|
||||
async def _update_avatar(self, user: 'AbstractUser', photo: TypeChatPhoto,
|
||||
sender: Optional['p.Puppet'] = None, save: bool = False) -> bool:
|
||||
if isinstance(photo, ChatPhoto):
|
||||
loc = InputPeerPhotoFileLocation(
|
||||
peer=await self.get_input_entity(user),
|
||||
local_id=photo.photo_big.local_id,
|
||||
volume_id=photo.photo_big.volume_id,
|
||||
big=True
|
||||
)
|
||||
photo_id = f"{loc.volume_id}-{loc.local_id}"
|
||||
elif isinstance(photo, Photo):
|
||||
loc, largest = self._get_largest_photo_size(photo)
|
||||
photo_id = f"{largest.location.volume_id}-{largest.location.local_id}"
|
||||
elif isinstance(photo, (ChatPhotoEmpty, PhotoEmpty)):
|
||||
photo_id = ""
|
||||
loc = None
|
||||
else:
|
||||
raise ValueError(f"Unknown photo type {type(photo)}")
|
||||
if self.photo_id != photo_id:
|
||||
if not photo_id:
|
||||
await self._try_use_intent(sender,
|
||||
lambda intent: intent.set_room_avatar(self.mxid, None))
|
||||
self.photo_id = ""
|
||||
if save:
|
||||
self.save()
|
||||
return True
|
||||
file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc)
|
||||
if file:
|
||||
await self._try_use_intent(sender, lambda intent: intent.set_room_avatar(self.mxid,
|
||||
file.mxc))
|
||||
self.photo_id = photo_id
|
||||
if save:
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
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
|
||||
if self.peer_type == "chat":
|
||||
chat = await user.client(GetFullChatRequest(chat_id=self.tgid))
|
||||
return chat.users, chat.full_chat.participants.participants
|
||||
elif self.peer_type == "channel":
|
||||
if not self.megagroup and not self.sync_channel_members:
|
||||
return [], []
|
||||
|
||||
limit = self.max_initial_member_sync
|
||||
if limit == 0:
|
||||
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
|
||||
except ChatAdminRequiredError:
|
||||
return [], []
|
||||
elif self.peer_type == "user":
|
||||
return [entity], []
|
||||
return [], []
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
def init(context: Context) -> None:
|
||||
global config
|
||||
config = context.config
|
||||
@@ -0,0 +1,44 @@
|
||||
# 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 typing import Dict
|
||||
from asyncio import Lock
|
||||
|
||||
from ..types import TelegramID
|
||||
|
||||
|
||||
class FakeLock:
|
||||
async def __aenter__(self) -> None:
|
||||
pass
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class PortalSendLock:
|
||||
_send_locks: Dict[int, Lock]
|
||||
_noop_lock: Lock = FakeLock()
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._send_locks = {}
|
||||
|
||||
def __call__(self, user_id: TelegramID, required: bool = True) -> Lock:
|
||||
if user_id is None and required:
|
||||
raise ValueError("Required send lock for none id")
|
||||
try:
|
||||
return self._send_locks[user_id]
|
||||
except KeyError:
|
||||
return (self._send_locks.setdefault(user_id, Lock())
|
||||
if required else self._noop_lock)
|
||||
@@ -0,0 +1,523 @@
|
||||
# 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 typing import Awaitable, Dict, List, Optional, Tuple, Union, NamedTuple, TYPE_CHECKING
|
||||
from html import escape as escape_html
|
||||
from abc import ABC
|
||||
import random
|
||||
import mimetypes
|
||||
import codecs
|
||||
import unicodedata
|
||||
import base64
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from telethon.tl.patched import Message, MessageService
|
||||
from telethon.tl.types import (
|
||||
Poll, DocumentAttributeFilename, DocumentAttributeSticker, DocumentAttributeVideo,
|
||||
MessageMediaPoll, MessageActionChannelCreate, MessageActionChatAddUser,
|
||||
MessageActionChatCreate, MessageActionChatDeletePhoto, MessageActionChatDeleteUser,
|
||||
MessageActionChatEditPhoto, MessageActionChatEditTitle, MessageActionChatJoinedByLink,
|
||||
MessageActionChatMigrateTo, MessageActionPinMessage, MessageActionGameScore,
|
||||
MessageMediaDocument, MessageMediaGeo, MessageMediaPhoto, MessageMediaUnsupported,
|
||||
MessageMediaGame, PeerUser, PhotoCachedSize, TypeChannelParticipant, TypeChatParticipant,
|
||||
TypeDocumentAttribute, TypeMessageAction, TypePhotoSize, PhotoSize, UpdateChatUserTyping,
|
||||
UpdateUserTyping, MessageEntityPre, ChatPhotoEmpty)
|
||||
|
||||
from mautrix.appservice import IntentAPI
|
||||
from mautrix.types import (EventID, UserID, ImageInfo, ThumbnailInfo, RelatesTo, MessageType,
|
||||
EventType, MediaMessageEventContent, TextMessageEventContent,
|
||||
LocationMessageEventContent, Format)
|
||||
|
||||
from ..types import TelegramID
|
||||
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
|
||||
from ..util import sane_mimetypes
|
||||
from ..context import Context
|
||||
from .. import puppet as p, user as u, formatter, util
|
||||
from .base import BasePortal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..abstract_user import AbstractUser
|
||||
from ..config import Config
|
||||
|
||||
InviteList = Union[UserID, List[UserID]]
|
||||
TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
|
||||
DocAttrs = NamedTuple("DocAttrs", name=Optional[str], mime_type=Optional[str], is_sticker=bool,
|
||||
sticker_alt=Optional[str], width=int, height=int)
|
||||
|
||||
config: Optional['Config'] = None
|
||||
|
||||
|
||||
class PortalTelegram(BasePortal, ABC):
|
||||
async def handle_telegram_typing(self, user: p.Puppet,
|
||||
_: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None:
|
||||
await user.intent_for(self).set_typing(self.mxid, is_typing=True)
|
||||
|
||||
def _get_external_url(self, evt: Message) -> Optional[str]:
|
||||
if self.peer_type == "channel" and self.username is not None:
|
||||
return f"https://t.me/{self.username}/{evt.id}"
|
||||
elif self.peer_type != "user":
|
||||
return f"https://t.me/c/{self.tgid}/{evt.id}"
|
||||
return None
|
||||
|
||||
async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
|
||||
relates_to: Dict = None) -> Optional[EventID]:
|
||||
loc, largest_size = self._get_largest_photo_size(evt.media.photo)
|
||||
file = await util.transfer_file_to_matrix(source.client, intent, loc)
|
||||
if not file:
|
||||
return None
|
||||
if self.get_config("inline_images") and (evt.message
|
||||
or evt.fwd_from or evt.reply_to_msg_id):
|
||||
content = await formatter.telegram_to_matrix(
|
||||
evt, source, self.main_intent,
|
||||
prefix_html=f"<img src='{file.mxc}' alt='Inline Telegram photo'/><br/>",
|
||||
prefix_text="Inline image: ")
|
||||
content.external_url = self._get_external_url(evt)
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
return await intent.send_message(self.mxid, content, timestamp=evt.date)
|
||||
info = ImageInfo(
|
||||
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)}"
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
content = MediaMessageEventContent(url=file.mxc, msgtype=MessageType.IMAGE, info=info,
|
||||
body=name, relates_to=relates_to,
|
||||
external_url=self._get_external_url(evt))
|
||||
result = await intent.send_message(self.mxid, content, 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
|
||||
result = await intent.send_message(self.mxid, caption_content, timestamp=evt.date)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _parse_telegram_document_attributes(attributes: List[TypeDocumentAttribute]) -> DocAttrs:
|
||||
name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0
|
||||
for attr in attributes:
|
||||
if isinstance(attr, DocumentAttributeFilename):
|
||||
name = name or attr.file_name
|
||||
mime_type, _ = mimetypes.guess_type(attr.file_name)
|
||||
elif isinstance(attr, DocumentAttributeSticker):
|
||||
is_sticker = True
|
||||
sticker_alt = attr.alt
|
||||
elif isinstance(attr, DocumentAttributeVideo):
|
||||
width, height = attr.w, attr.h
|
||||
return DocAttrs(name, mime_type, is_sticker, sticker_alt, width, height)
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
if attrs.is_sticker:
|
||||
alt = attrs.sticker_alt
|
||||
if len(alt) > 0:
|
||||
try:
|
||||
name = f"{alt} ({unicodedata.name(alt[0]).lower()})"
|
||||
except ValueError:
|
||||
name = alt
|
||||
|
||||
generic_types = ("text/plain", "application/octet-stream")
|
||||
if file.mime_type in generic_types and document.mime_type not in generic_types:
|
||||
mime_type = document.mime_type or file.mime_type
|
||||
else:
|
||||
mime_type = file.mime_type or document.mime_type
|
||||
info = ImageInfo(size=file.size, mimetype=mime_type)
|
||||
|
||||
if attrs.mime_type and not file.was_converted:
|
||||
file.mime_type = attrs.mime_type or file.mime_type
|
||||
if file.width and file.height:
|
||||
info.width, info.height = file.width, file.height
|
||||
elif attrs.width and attrs.height:
|
||||
info.width, info.height = attrs.width, attrs.height
|
||||
|
||||
if file.thumbnail:
|
||||
info.thumbnail_url = file.thumbnail.mxc
|
||||
info.thumbnail_info = ThumbnailInfo(mimetype=file.thumbnail.mime_type,
|
||||
height=file.thumbnail.height or thumb_size.h,
|
||||
width=file.thumbnail.width or thumb_size.w,
|
||||
size=file.thumbnail.size)
|
||||
|
||||
return info, name
|
||||
|
||||
async def handle_telegram_document(self, source: 'AbstractUser', intent: IntentAPI,
|
||||
evt: Message, relates_to: RelatesTo = None
|
||||
) -> Optional[EventID]:
|
||||
document = evt.media.document
|
||||
|
||||
attrs = self._parse_telegram_document_attributes(document.attributes)
|
||||
|
||||
if document.size > config["bridge.max_document_size"] * 1000 ** 2:
|
||||
name = attrs.name or ""
|
||||
caption = f"\n{evt.message}" if evt.message else ""
|
||||
return await intent.send_notice(self.mxid, f"Too large file {name}{caption}")
|
||||
|
||||
thumb_loc, thumb_size = self._get_largest_photo_size(document)
|
||||
if thumb_size and not isinstance(thumb_size, (PhotoSize, PhotoCachedSize)):
|
||||
self.log.debug(f"Unsupported thumbnail type {type(thumb_size)}")
|
||||
thumb_loc = None
|
||||
thumb_size = None
|
||||
parallel_id = source.tgid if config["bridge.parallel_file_transfer"] else None
|
||||
file = await util.transfer_file_to_matrix(source.client, intent, document, thumb_loc,
|
||||
is_sticker=attrs.is_sticker,
|
||||
tgs_convert=config["bridge.animated_sticker"],
|
||||
filename=attrs.name, parallel_id=parallel_id)
|
||||
if not file:
|
||||
return None
|
||||
|
||||
info, name = self._parse_telegram_document_meta(evt, file, attrs, thumb_size)
|
||||
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
|
||||
event_type = EventType.ROOM_MESSAGE
|
||||
# Riot only supports images as stickers, so send animated webm stickers as m.video
|
||||
if attrs.is_sticker and file.mime_type.startswith("image/"):
|
||||
event_type = EventType.STICKER
|
||||
content = MediaMessageEventContent(
|
||||
body=name or "unnamed file", info=info, url=file.mxc, relates_to=relates_to,
|
||||
external_url=self._get_external_url(evt),
|
||||
msgtype={
|
||||
"video/": MessageType.VIDEO,
|
||||
"audio/": MessageType.AUDIO,
|
||||
"image/": MessageType.IMAGE,
|
||||
}.get(info.mimetype[:6], MessageType.FILE))
|
||||
return await intent.send_message_event(self.mxid, event_type, content, timestamp=evt.date)
|
||||
|
||||
def handle_telegram_location(self, _: 'AbstractUser', intent: IntentAPI, evt: Message,
|
||||
relates_to: dict = None) -> Awaitable[EventID]:
|
||||
long = evt.media.geo.long
|
||||
lat = evt.media.geo.lat
|
||||
long_char = "E" if long > 0 else "W"
|
||||
lat_char = "N" if lat > 0 else "S"
|
||||
|
||||
body = f"{round(lat, 5)}° {lat_char}, {round(long, 5)}° {long_char}"
|
||||
url = f"https://maps.google.com/?q={lat},{long}"
|
||||
|
||||
content = LocationMessageEventContent(
|
||||
msgtype=MessageType.LOCATION, geo_uri=f"geo:{lat},{long}",
|
||||
body=f"Location: {body}\n{url}",
|
||||
relates_to=relates_to, external_url=self._get_external_url(evt))
|
||||
content["format"] = str(Format.HTML)
|
||||
content["formatted_body"] = f"Location: <a href='{url}'>{body}</a>"
|
||||
|
||||
return intent.send_message(self.mxid, content, timestamp=evt.date)
|
||||
|
||||
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}")
|
||||
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"):
|
||||
content.msgtype = MessageType.NOTICE
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
return await intent.send_message(self.mxid, content, timestamp=evt.date)
|
||||
|
||||
async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI,
|
||||
evt: Message, relates_to: dict = None) -> EventID:
|
||||
override_text = ("This message is not supported on your version of Mautrix-Telegram. "
|
||||
"Please check https://github.com/tulir/mautrix-telegram or ask your "
|
||||
"bridge administrator about possible updates.")
|
||||
content = await formatter.telegram_to_matrix(
|
||||
evt, source, self.main_intent, override_text=override_text)
|
||||
content.msgtype = MessageType.NOTICE
|
||||
content.external_url = self._get_external_url(evt)
|
||||
content["net.maunium.telegram.unsupported"] = True
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
return await intent.send_message(self.mxid, content, timestamp=evt.date)
|
||||
|
||||
async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
|
||||
relates_to: RelatesTo) -> EventID:
|
||||
poll: Poll = evt.media.poll
|
||||
poll_id = self._encode_msgid(source, evt)
|
||||
|
||||
_n = 0
|
||||
|
||||
def n() -> int:
|
||||
nonlocal _n
|
||||
_n += 1
|
||||
return _n
|
||||
|
||||
text_answers = "\n".join(f"{n()}. {answer.text}" for answer in poll.answers)
|
||||
html_answers = "\n".join(f"<li>{answer.text}</li>" for answer in poll.answers)
|
||||
content = TextMessageEventContent(
|
||||
msgtype=MessageType.TEXT, format=Format.HTML,
|
||||
body=f"Poll: {poll.question}\n{text_answers}\n"
|
||||
f"Vote with !tg vote {poll_id} <choice number>",
|
||||
formatted_body=f"<strong>Poll</strong>: {poll.question}<br/>\n"
|
||||
f"<ol>{html_answers}</ol>\n"
|
||||
f"Vote with <code>!tg vote {poll_id} <choice number></code>",
|
||||
relates_to=relates_to, external_url=self._get_external_url(evt))
|
||||
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
return await intent.send_message(self.mxid, content, timestamp=evt.date)
|
||||
|
||||
@staticmethod
|
||||
def _int_to_bytes(i: int) -> bytes:
|
||||
hex_value = "{0:010x}".format(i)
|
||||
return codecs.decode(hex_value, "hex_codec")
|
||||
|
||||
def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str:
|
||||
if self.peer_type == "channel":
|
||||
play_id = (b"c"
|
||||
+ self._int_to_bytes(self.tgid)
|
||||
+ self._int_to_bytes(evt.id))
|
||||
elif self.peer_type == "chat":
|
||||
play_id = (b"g"
|
||||
+ self._int_to_bytes(self.tgid)
|
||||
+ self._int_to_bytes(evt.id)
|
||||
+ self._int_to_bytes(source.tgid))
|
||||
elif self.peer_type == "user":
|
||||
play_id = (b"u"
|
||||
+ self._int_to_bytes(self.tgid)
|
||||
+ self._int_to_bytes(evt.id))
|
||||
else:
|
||||
raise ValueError("Portal has invalid peer type")
|
||||
return base64.b64encode(play_id).decode("utf-8").rstrip("=")
|
||||
|
||||
async def handle_telegram_game(self, source: 'AbstractUser', intent: IntentAPI,
|
||||
evt: Message, relates_to: RelatesTo = None) -> EventID:
|
||||
game = evt.media.game
|
||||
play_id = self._encode_msgid(source, evt)
|
||||
command = f"!tg play {play_id}"
|
||||
override_text = f"Run {command} in your bridge management room to play {game.title}"
|
||||
override_entities = [
|
||||
MessageEntityPre(offset=len("Run "), length=len(command), language="")]
|
||||
|
||||
content = await formatter.telegram_to_matrix(
|
||||
evt, source, self.main_intent,
|
||||
override_text=override_text, override_entities=override_entities)
|
||||
content.msgtype = MessageType.NOTICE
|
||||
content.external_url = self._get_external_url(evt)
|
||||
content["net.maunium.telegram.game"] = play_id
|
||||
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
return await intent.send_message(self.mxid, content, timestamp=evt.date)
|
||||
|
||||
async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet, evt: Message
|
||||
) -> None:
|
||||
if not self.mxid:
|
||||
return
|
||||
elif hasattr(evt, "media") and isinstance(evt.media, MessageMediaGame):
|
||||
self.log.debug("Ignoring game message edit event")
|
||||
return
|
||||
|
||||
async with self.send_lock(sender.tgid if sender else None, required=False):
|
||||
tg_space = self.tgid if self.peer_type == "channel" else source.tgid
|
||||
|
||||
temporary_identifier = EventID(
|
||||
f"${random.randint(1000000000000, 9999999999999)}TGBRIDGEDITEMP")
|
||||
duplicate_found = self.dedup.check(evt, (temporary_identifier, tg_space),
|
||||
force_hash=True)
|
||||
if duplicate_found:
|
||||
mxid, other_tg_space = duplicate_found
|
||||
if tg_space != other_tg_space:
|
||||
prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1)
|
||||
if not prev_edit_msg:
|
||||
return
|
||||
DBMessage(mxid=mxid, mx_room=self.mxid, tg_space=tg_space,
|
||||
tgid=TelegramID(evt.id), edit_index=prev_edit_msg.edit_index + 1
|
||||
).insert()
|
||||
return
|
||||
|
||||
content = await formatter.telegram_to_matrix(evt, source, self.main_intent,
|
||||
no_reply_fallback=True)
|
||||
editing_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
|
||||
if not editing_msg:
|
||||
self.log.info(f"Didn't find edited message {evt.id}@{tg_space} (src {source.tgid}) "
|
||||
"in database.")
|
||||
return
|
||||
|
||||
content.msgtype = (MessageType.NOTICE if (sender and sender.is_bot
|
||||
and self.get_config("bot_messages_as_notices"))
|
||||
else MessageType.TEXT)
|
||||
content.external_url = self._get_external_url(evt)
|
||||
content.set_edit(editing_msg.mxid)
|
||||
|
||||
intent = sender.intent_for(self) if sender else self.main_intent
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
event_id = await intent.send_message(self.mxid, content)
|
||||
|
||||
prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) or editing_msg
|
||||
DBMessage(mxid=event_id, mx_room=self.mxid, tg_space=tg_space, tgid=TelegramID(evt.id),
|
||||
edit_index=prev_edit_msg.edit_index + 1).insert()
|
||||
DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id)
|
||||
|
||||
async def handle_telegram_message(self, source: 'AbstractUser', sender: p.Puppet,
|
||||
evt: Message) -> None:
|
||||
if not self.mxid:
|
||||
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)):
|
||||
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
|
||||
|
||||
async with self.send_lock(sender.tgid if sender else None, required=False):
|
||||
tg_space = self.tgid if self.peer_type == "channel" else source.tgid
|
||||
|
||||
temporary_identifier = EventID(
|
||||
f"${random.randint(1000000000000, 9999999999999)}TGBRIDGETEMP")
|
||||
duplicate_found = self.dedup.check(evt, (temporary_identifier, tg_space))
|
||||
if duplicate_found:
|
||||
mxid, other_tg_space = duplicate_found
|
||||
self.log.debug(f"Ignoring message {evt.id}@{tg_space} (src {source.tgid}) "
|
||||
f"as it was already handled (in space {other_tg_space})")
|
||||
if tg_space != other_tg_space:
|
||||
DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=mxid,
|
||||
tg_space=tg_space, edit_index=0).insert()
|
||||
return
|
||||
|
||||
if 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"
|
||||
f"handled into {msg.mxid}. This duplicate was catched in the db "
|
||||
"check. If you get this message often, consider increasing"
|
||||
"bridge.deduplication.cache_queue_length in the config.")
|
||||
return
|
||||
|
||||
if sender and not sender.displayname:
|
||||
self.log.debug(f"Telegram user {sender.tgid} sent a message, but doesn't have a "
|
||||
"displayname, updating info...")
|
||||
entity = await source.client.get_entity(PeerUser(sender.tgid))
|
||||
await sender.update_info(source, entity)
|
||||
|
||||
allowed_media = (MessageMediaPhoto, MessageMediaDocument, MessageMediaGeo,
|
||||
MessageMediaGame, MessageMediaPoll, MessageMediaUnsupported)
|
||||
media = evt.media if hasattr(evt, "media") and isinstance(evt.media,
|
||||
allowed_media) else None
|
||||
intent = sender.intent_for(self) if sender else self.main_intent
|
||||
if not media and evt.message:
|
||||
is_bot = sender.is_bot if sender else False
|
||||
event_id = await self.handle_telegram_text(source, intent, is_bot, evt)
|
||||
elif media:
|
||||
event_id = await {
|
||||
MessageMediaPhoto: self.handle_telegram_photo,
|
||||
MessageMediaDocument: self.handle_telegram_document,
|
||||
MessageMediaGeo: self.handle_telegram_location,
|
||||
MessageMediaPoll: self.handle_telegram_poll,
|
||||
MessageMediaUnsupported: self.handle_telegram_unsupported,
|
||||
MessageMediaGame: self.handle_telegram_game,
|
||||
}[type(media)](source, intent, evt,
|
||||
relates_to=formatter.telegram_reply_to_matrix(evt, source))
|
||||
else:
|
||||
self.log.debug("Unhandled Telegram message: %s", evt)
|
||||
return
|
||||
|
||||
if not event_id:
|
||||
return
|
||||
|
||||
prev_id = self.dedup.update(evt, (event_id, tg_space), (temporary_identifier, tg_space))
|
||||
if prev_id:
|
||||
self.log.debug(f"Sent message {evt.id}@{tg_space} to Matrix as {event_id}. "
|
||||
f"Temporary dedup identifier was {temporary_identifier}, "
|
||||
f"but dedup map contained {prev_id[1]} instead! -- "
|
||||
"This was probably a race condition caused by Telegram sending updates"
|
||||
"to other clients before responding to the sender. I'll just redact "
|
||||
"the likely duplicate message now.")
|
||||
await intent.redact(self.mxid, event_id)
|
||||
return
|
||||
|
||||
self.log.debug("Handled Telegram message: %s", evt)
|
||||
try:
|
||||
DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=event_id,
|
||||
tg_space=tg_space, edit_index=0).insert()
|
||||
DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id)
|
||||
except IntegrityError as e:
|
||||
self.log.exception(f"{e.__class__.__name__} while saving message mapping. "
|
||||
"This might mean that an update was handled after it left the "
|
||||
"dedup cache queue. You can try enabling bridge.deduplication."
|
||||
"pre_db_check in the config.")
|
||||
await intent.redact(self.mxid, event_id)
|
||||
|
||||
async def _create_room_on_action(self, source: 'AbstractUser',
|
||||
action: TypeMessageAction) -> bool:
|
||||
if source.is_relaybot and config["bridge.ignore_unbridged_group_chat"]:
|
||||
return False
|
||||
create_and_exit = (MessageActionChatCreate, MessageActionChannelCreate)
|
||||
create_and_continue = (MessageActionChatAddUser, MessageActionChatJoinedByLink)
|
||||
if isinstance(action, create_and_exit) or isinstance(action, create_and_continue):
|
||||
await self.create_matrix_room(source, invites=[source.mxid],
|
||||
update_if_exists=isinstance(action, create_and_exit))
|
||||
if not isinstance(action, create_and_continue):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def handle_telegram_action(self, source: 'AbstractUser', sender: p.Puppet,
|
||||
update: MessageService) -> None:
|
||||
action = update.action
|
||||
should_ignore = ((not self.mxid and not await self._create_room_on_action(source, action))
|
||||
or self.dedup.check_action(update))
|
||||
if should_ignore or not self.mxid:
|
||||
return
|
||||
if isinstance(action, MessageActionChatEditTitle):
|
||||
await self._update_title(action.title, sender=sender, save=True)
|
||||
elif isinstance(action, MessageActionChatEditPhoto):
|
||||
await self._update_avatar(source, action.photo, sender=sender, save=True)
|
||||
elif isinstance(action, MessageActionChatDeletePhoto):
|
||||
await self._update_avatar(source, ChatPhotoEmpty(), sender=sender, save=True)
|
||||
elif isinstance(action, MessageActionChatAddUser):
|
||||
for user_id in action.users:
|
||||
await self._add_telegram_user(TelegramID(user_id), source)
|
||||
elif isinstance(action, MessageActionChatJoinedByLink):
|
||||
await self._add_telegram_user(sender.id, source)
|
||||
elif isinstance(action, MessageActionChatDeleteUser):
|
||||
await self._delete_telegram_user(TelegramID(action.user_id), sender)
|
||||
elif isinstance(action, MessageActionChatMigrateTo):
|
||||
self.peer_type = "channel"
|
||||
self._migrate_and_save_telegram(TelegramID(action.channel_id))
|
||||
await sender.intent_for(self).send_emote(self.mxid,
|
||||
"upgraded this group to a supergroup.")
|
||||
elif isinstance(action, MessageActionGameScore):
|
||||
# TODO handle game score
|
||||
pass
|
||||
else:
|
||||
self.log.debug("Unhandled Telegram action in %s: %s", self.title, action)
|
||||
|
||||
async def set_telegram_admin(self, user_id: TelegramID) -> None:
|
||||
puppet = p.Puppet.get(user_id)
|
||||
user = u.User.get_by_tgid(user_id)
|
||||
|
||||
levels = await self.main_intent.get_power_levels(self.mxid)
|
||||
if user:
|
||||
levels.users[user.mxid] = 50
|
||||
if puppet:
|
||||
levels.users[puppet.mxid] = 50
|
||||
await self.main_intent.set_power_levels(self.mxid, levels)
|
||||
|
||||
async def receive_telegram_pin_id(self, msg_id: TelegramID, receiver: TelegramID) -> None:
|
||||
tg_space = receiver if self.peer_type != "channel" else self.tgid
|
||||
message = DBMessage.get_one_by_tgid(msg_id, tg_space) if msg_id != 0 else None
|
||||
if message:
|
||||
await self.main_intent.set_pinned_messages(self.mxid, [message.mxid])
|
||||
else:
|
||||
await self.main_intent.set_pinned_messages(self.mxid, [])
|
||||
|
||||
async def set_telegram_admins_enabled(self, enabled: bool) -> None:
|
||||
level = 50 if enabled else 10
|
||||
levels = await self.main_intent.get_power_levels(self.mxid)
|
||||
levels.invite = level
|
||||
levels.events[EventType.ROOM_NAME] = level
|
||||
levels.events[EventType.ROOM_AVATAR] = level
|
||||
await self.main_intent.set_power_levels(self.mxid, levels)
|
||||
|
||||
|
||||
def init(context: Context) -> None:
|
||||
global config
|
||||
config = context.config
|
||||
+200
-254
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,20 +13,24 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Awaitable, Any, Dict, List, Iterable, Optional, Pattern, Union, TYPE_CHECKING
|
||||
from typing import Awaitable, Any, Dict, Iterable, Optional, Union, TYPE_CHECKING
|
||||
from difflib import SequenceMatcher
|
||||
from enum import Enum
|
||||
from aiohttp import ServerDisconnectedError
|
||||
import unicodedata
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from telethon.tl.types import UserProfilePhoto, User, FileLocation, UpdateUserName, PeerUser
|
||||
from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError
|
||||
from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer,
|
||||
InputPeerPhotoFileLocation, UserProfilePhotoEmpty, TypeInputUser)
|
||||
|
||||
from .types import MatrixUserID, TelegramID
|
||||
from mautrix.appservice import AppService, IntentAPI
|
||||
from mautrix.errors import MatrixRequestError
|
||||
from mautrix.bridge import CustomPuppetMixin
|
||||
from mautrix.types import UserID, SyncToken
|
||||
from mautrix.util.simple_template import SimpleTemplate
|
||||
|
||||
from .types import TelegramID
|
||||
from .db import Puppet as DBPuppet
|
||||
from . import util
|
||||
from . import util, portal as p
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .matrix import MatrixHandler
|
||||
@@ -35,66 +38,96 @@ if TYPE_CHECKING:
|
||||
from .context import Context
|
||||
from .abstract_user import AbstractUser
|
||||
|
||||
PuppetError = Enum('PuppetError', 'Success OnlyLoginSelf InvalidAccessToken')
|
||||
|
||||
config = None # type: Config
|
||||
config: Optional['Config'] = None
|
||||
|
||||
|
||||
class Puppet:
|
||||
log = logging.getLogger("mau.puppet") # type: logging.Logger
|
||||
az = None # type: AppService
|
||||
mx = None # type: MatrixHandler
|
||||
loop = None # type: asyncio.AbstractEventLoop
|
||||
mxid_regex = None # type: Pattern
|
||||
username_template = None # type: str
|
||||
hs_domain = None # type: str
|
||||
cache = {} # type: Dict[TelegramID, Puppet]
|
||||
by_custom_mxid = {} # type: Dict[str, Puppet]
|
||||
class Puppet(CustomPuppetMixin):
|
||||
log: logging.Logger = logging.getLogger("mau.puppet")
|
||||
az: AppService
|
||||
mx: 'MatrixHandler'
|
||||
loop: asyncio.AbstractEventLoop
|
||||
hs_domain: str
|
||||
mxid_template: SimpleTemplate[TelegramID]
|
||||
displayname_template: SimpleTemplate[str]
|
||||
|
||||
cache: Dict[TelegramID, 'Puppet'] = {}
|
||||
by_custom_mxid: Dict[UserID, 'Puppet'] = {}
|
||||
|
||||
id: TelegramID
|
||||
access_token: Optional[str]
|
||||
custom_mxid: Optional[UserID]
|
||||
_next_batch: Optional[SyncToken]
|
||||
default_mxid: UserID
|
||||
|
||||
username: Optional[str]
|
||||
displayname: Optional[str]
|
||||
displayname_source: Optional[TelegramID]
|
||||
photo_id: Optional[str]
|
||||
is_bot: bool
|
||||
is_registered: bool
|
||||
disable_updates: bool
|
||||
|
||||
default_mxid_intent: IntentAPI
|
||||
intent: IntentAPI
|
||||
|
||||
sync_task: Optional[asyncio.Future]
|
||||
|
||||
_db_instance: Optional[DBPuppet]
|
||||
|
||||
def __init__(self,
|
||||
id: TelegramID,
|
||||
access_token: Optional[str] = None,
|
||||
custom_mxid: Optional[MatrixUserID] = None,
|
||||
custom_mxid: Optional[UserID] = None,
|
||||
next_batch: Optional[SyncToken] = None,
|
||||
username: Optional[str] = None,
|
||||
displayname: Optional[str] = None,
|
||||
displayname_source: Optional[TelegramID] = None,
|
||||
photo_id: Optional[str] = None,
|
||||
is_bot: bool = False,
|
||||
is_registered: bool = False,
|
||||
disable_updates: bool = False,
|
||||
db_instance: Optional[DBPuppet] = None) -> None:
|
||||
self.id = id # type: TelegramID
|
||||
self.access_token = access_token # type: Optional[str]
|
||||
self.custom_mxid = custom_mxid # type: Optional[MatrixUserID]
|
||||
self.default_mxid = self.get_mxid_from_id(self.id) # type: MatrixUserID
|
||||
self.id = id
|
||||
self.access_token = access_token
|
||||
self.custom_mxid = custom_mxid
|
||||
self._next_batch = next_batch
|
||||
self.default_mxid = self.get_mxid_from_id(self.id)
|
||||
|
||||
self.username = username # type: Optional[str]
|
||||
self.displayname = displayname # type: Optional[str]
|
||||
self.displayname_source = displayname_source # type: Optional[TelegramID]
|
||||
self.photo_id = photo_id # type: Optional[str]
|
||||
self.is_bot = is_bot # type: bool
|
||||
self.is_registered = is_registered # type: bool
|
||||
self._db_instance = db_instance # type: Optional[DBPuppet]
|
||||
self.username = username
|
||||
self.displayname = displayname
|
||||
self.displayname_source = displayname_source
|
||||
self.photo_id = photo_id
|
||||
self.is_bot = is_bot
|
||||
self.is_registered = is_registered
|
||||
self.disable_updates = disable_updates
|
||||
self._db_instance = db_instance
|
||||
|
||||
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
|
||||
self.intent = self._fresh_intent() # type: IntentAPI
|
||||
self.sync_task = None # type: Optional[asyncio.Future]
|
||||
self.intent = self._fresh_intent()
|
||||
self.sync_task = None
|
||||
|
||||
self.cache[id] = self
|
||||
if self.custom_mxid:
|
||||
self.by_custom_mxid[self.custom_mxid] = self
|
||||
|
||||
@property
|
||||
def mxid(self) -> MatrixUserID:
|
||||
return self.custom_mxid or self.default_mxid
|
||||
self.log = self.log.getChild(str(self.id))
|
||||
|
||||
@property
|
||||
def tgid(self) -> TelegramID:
|
||||
return self.id
|
||||
|
||||
@property
|
||||
def is_real_user(self) -> bool:
|
||||
""" Is True when the puppet is a real Matrix user. """
|
||||
return bool(self.custom_mxid and self.access_token)
|
||||
def peer(self) -> PeerUser:
|
||||
return PeerUser(user_id=self.tgid)
|
||||
|
||||
@property
|
||||
def next_batch(self) -> SyncToken:
|
||||
return self._next_batch
|
||||
|
||||
@next_batch.setter
|
||||
def next_batch(self, value: SyncToken) -> None:
|
||||
self._next_batch = value
|
||||
self.db_instance.edit(next_batch=self._next_batch)
|
||||
|
||||
@staticmethod
|
||||
async def is_logged_in() -> bool:
|
||||
@@ -103,172 +136,17 @@ class Puppet:
|
||||
|
||||
@property
|
||||
def plain_displayname(self) -> str:
|
||||
tpl = config["bridge.displayname_template"]
|
||||
if tpl == "{displayname}":
|
||||
# Template has no extra stuff, no need to parse.
|
||||
return self.displayname
|
||||
regex = re.compile("^" + re.escape(tpl).replace(re.escape("{displayname}"), "(.+?)") + "$")
|
||||
match = regex.match(self.displayname)
|
||||
return match.group(1) or self.displayname
|
||||
return self.displayname_template.parse(self.displayname) or self.displayname
|
||||
|
||||
# region Custom puppet management
|
||||
def _fresh_intent(self) -> IntentAPI:
|
||||
return (self.az.intent.user(self.custom_mxid, self.access_token)
|
||||
if self.is_real_user else self.default_mxid_intent)
|
||||
def get_input_entity(self, user: 'AbstractUser'
|
||||
) -> Awaitable[Union[TypeInputPeer, TypeInputUser]]:
|
||||
return user.client.get_input_entity(self.peer)
|
||||
|
||||
async def switch_mxid(self, access_token: Optional[str],
|
||||
mxid: Optional[MatrixUserID]) -> PuppetError:
|
||||
prev_mxid = self.custom_mxid
|
||||
self.custom_mxid = mxid
|
||||
self.access_token = access_token
|
||||
self.intent = self._fresh_intent()
|
||||
def intent_for(self, portal: 'p.Portal') -> IntentAPI:
|
||||
if portal.tgid == self.tgid:
|
||||
return self.default_mxid_intent
|
||||
return self.intent
|
||||
|
||||
err = await self.init_custom_mxid()
|
||||
if err != PuppetError.Success:
|
||||
return err
|
||||
|
||||
try:
|
||||
del self.by_custom_mxid[prev_mxid] # type: ignore
|
||||
except KeyError:
|
||||
pass
|
||||
if self.mxid != self.default_mxid:
|
||||
self.by_custom_mxid[self.mxid] = self
|
||||
await self.leave_rooms_with_default_user()
|
||||
self.save()
|
||||
return PuppetError.Success
|
||||
|
||||
async def init_custom_mxid(self) -> PuppetError:
|
||||
if not self.is_real_user:
|
||||
return PuppetError.Success
|
||||
|
||||
mxid = await self.intent.whoami()
|
||||
if not mxid or mxid != self.custom_mxid:
|
||||
self.custom_mxid = None
|
||||
self.access_token = None
|
||||
self.intent = self._fresh_intent()
|
||||
if mxid != self.custom_mxid:
|
||||
return PuppetError.OnlyLoginSelf
|
||||
return PuppetError.InvalidAccessToken
|
||||
if config["bridge.sync_with_custom_puppets"]:
|
||||
self.sync_task = asyncio.ensure_future(self.sync(), loop=self.loop)
|
||||
return PuppetError.Success
|
||||
|
||||
async def leave_rooms_with_default_user(self) -> None:
|
||||
for room_id in await self.default_mxid_intent.get_joined_rooms():
|
||||
try:
|
||||
await self.default_mxid_intent.leave_room(room_id)
|
||||
await self.intent.ensure_joined(room_id)
|
||||
except (IntentError, MatrixRequestError):
|
||||
pass
|
||||
|
||||
def create_sync_filter(self) -> Awaitable[str]:
|
||||
return self.intent.client.create_filter(self.custom_mxid, {
|
||||
"room": {
|
||||
"include_leave": False,
|
||||
"state": {
|
||||
"types": []
|
||||
},
|
||||
"timeline": {
|
||||
"types": [],
|
||||
},
|
||||
"ephemeral": {
|
||||
"types": ["m.typing", "m.receipt"],
|
||||
},
|
||||
"account_data": {
|
||||
"types": []
|
||||
}
|
||||
},
|
||||
"account_data": {
|
||||
"types": [],
|
||||
},
|
||||
"presence": {
|
||||
"types": ["m.presence"],
|
||||
"senders": [self.custom_mxid],
|
||||
},
|
||||
})
|
||||
|
||||
def filter_events(self, events: List[Dict]) -> List:
|
||||
new_events = []
|
||||
for event in events:
|
||||
evt_type = event.get("type", None)
|
||||
event.setdefault("content", {})
|
||||
if evt_type == "m.typing":
|
||||
is_typing = self.custom_mxid in event["content"].get("user_ids", [])
|
||||
event["content"]["user_ids"] = [self.custom_mxid] if is_typing else []
|
||||
elif evt_type == "m.receipt":
|
||||
val = None
|
||||
evt = None
|
||||
for event_id in event["content"]:
|
||||
try:
|
||||
val = event["content"][event_id]["m.read"][self.custom_mxid]
|
||||
evt = event_id
|
||||
break
|
||||
except KeyError:
|
||||
pass
|
||||
if val and evt:
|
||||
event["content"] = {evt: {"m.read": {
|
||||
self.custom_mxid: val
|
||||
}}}
|
||||
else:
|
||||
continue
|
||||
new_events.append(event)
|
||||
return new_events
|
||||
|
||||
def handle_sync(self, presence: List, ephemeral: Dict) -> None:
|
||||
presence_events = [self.mx.try_handle_event(event) for event in presence]
|
||||
|
||||
for room_id, events in ephemeral.items():
|
||||
for event in events:
|
||||
event["room_id"] = room_id
|
||||
|
||||
ephemeral_events = [self.mx.try_handle_event(event)
|
||||
for events in ephemeral.values()
|
||||
for event in self.filter_events(events)]
|
||||
|
||||
events = ephemeral_events + presence_events # List[Callable[[int], Awaitable[None]]]
|
||||
coro = asyncio.gather(*events, loop=self.loop)
|
||||
asyncio.ensure_future(coro, loop=self.loop)
|
||||
|
||||
async def sync(self) -> None:
|
||||
try:
|
||||
await self._sync()
|
||||
except asyncio.CancelledError:
|
||||
self.log.info("Syncing cancelled")
|
||||
except Exception:
|
||||
self.log.exception("Fatal error syncing")
|
||||
|
||||
async def _sync(self) -> None:
|
||||
if not self.is_real_user:
|
||||
self.log.warning("Called sync() for non-custom puppet.")
|
||||
return
|
||||
custom_mxid = self.custom_mxid
|
||||
access_token_at_start = self.access_token
|
||||
errors = 0
|
||||
next_batch = None
|
||||
filter_id = await self.create_sync_filter()
|
||||
self.log.debug(f"Starting syncer for {custom_mxid} with sync filter {filter_id}.")
|
||||
while access_token_at_start == self.access_token:
|
||||
try:
|
||||
sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch,
|
||||
set_presence="offline") # type: Dict
|
||||
errors = 0
|
||||
if next_batch is not None:
|
||||
presence = sync_resp.get("presence", {}).get("events", []) # type: List
|
||||
ephemeral = {room: data.get("ephemeral", {}).get("events", [])
|
||||
for room, data
|
||||
in sync_resp.get("rooms", {}).get("join", {}).items()
|
||||
} # type: Dict
|
||||
self.handle_sync(presence, ephemeral)
|
||||
next_batch = sync_resp.get("next_batch", None)
|
||||
except (MatrixRequestError, ServerDisconnectedError) as e:
|
||||
wait = min(errors, 11) ** 2
|
||||
self.log.warning(f"Syncer for {custom_mxid} errored: {e}. "
|
||||
f"Waiting for {wait} seconds...")
|
||||
errors += 1
|
||||
await asyncio.sleep(wait)
|
||||
self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.")
|
||||
|
||||
# endregion
|
||||
# region DB conversion
|
||||
|
||||
@property
|
||||
@@ -277,27 +155,31 @@ class Puppet:
|
||||
self._db_instance = self.new_db_instance()
|
||||
return self._db_instance
|
||||
|
||||
@property
|
||||
def _fields(self) -> Dict[str, Any]:
|
||||
return dict(access_token=self.access_token, next_batch=self._next_batch,
|
||||
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)
|
||||
|
||||
def new_db_instance(self) -> DBPuppet:
|
||||
return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid,
|
||||
username=self.username, displayname=self.displayname,
|
||||
displayname_source=self.displayname_source, photo_id=self.photo_id,
|
||||
is_bot=self.is_bot, matrix_registered=self.is_registered)
|
||||
return DBPuppet(id=self.id, **self._fields)
|
||||
|
||||
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.username, db_puppet.displayname, db_puppet.displayname_source,
|
||||
db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered,
|
||||
db_puppet.next_batch, db_puppet.username, db_puppet.displayname,
|
||||
db_puppet.displayname_source, db_puppet.photo_id, db_puppet.is_bot,
|
||||
db_puppet.matrix_registered, db_puppet.disable_updates,
|
||||
db_instance=db_puppet)
|
||||
|
||||
def save(self) -> None:
|
||||
self.db_instance.update(access_token=self.access_token, custom_mxid=self.custom_mxid,
|
||||
username=self.username, displayname=self.displayname,
|
||||
displayname_source=self.displayname_source, photo_id=self.photo_id,
|
||||
is_bot=self.is_bot, matrix_registered=self.is_registered)
|
||||
|
||||
# endregion
|
||||
# region Info updating
|
||||
|
||||
def similarity(self, query: str) -> int:
|
||||
username_similarity = (SequenceMatcher(None, self.username, query).ratio()
|
||||
if self.username else 0)
|
||||
@@ -307,14 +189,26 @@ class Puppet:
|
||||
return int(round(similarity * 100))
|
||||
|
||||
@staticmethod
|
||||
def get_displayname(info: User, enable_format: bool = True) -> str:
|
||||
def _filter_name(name: str) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
whitespace = ("\t\n\r\v\f \u00a0\u034f\u180e\u2063\u202f\u205f\u2800\u3000\u3164\ufeff"
|
||||
"\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b"
|
||||
"\u200c\u200d\u200e\u200f\ufe0f")
|
||||
name = "".join(c for c in name.strip(whitespace) if unicodedata.category(c) != 'Cf')
|
||||
return name
|
||||
|
||||
@classmethod
|
||||
def get_displayname(cls, info: User, enable_format: bool = True) -> str:
|
||||
fn = cls._filter_name(info.first_name)
|
||||
ln = cls._filter_name(info.last_name)
|
||||
data = {
|
||||
"phone number": info.phone if hasattr(info, "phone") else None,
|
||||
"username": info.username,
|
||||
"full name": " ".join([info.first_name or "", info.last_name or ""]).strip(),
|
||||
"full name reversed": " ".join([info.first_name or "", info.last_name or ""]).strip(),
|
||||
"first name": info.first_name,
|
||||
"last name": info.last_name,
|
||||
"full name": " ".join([fn, ln]).strip(),
|
||||
"full name reversed": " ".join([ln, fn]).strip(),
|
||||
"first name": fn,
|
||||
"last name": ln,
|
||||
}
|
||||
preferences = config["bridge.displayname_preference"]
|
||||
name = None
|
||||
@@ -326,22 +220,32 @@ class Puppet:
|
||||
if isinstance(info, User) and info.deleted:
|
||||
name = f"Deleted account {info.id}"
|
||||
elif not name:
|
||||
name = info.id
|
||||
name = str(info.id)
|
||||
|
||||
if not enable_format:
|
||||
return name
|
||||
return config["bridge.displayname_template"].format(
|
||||
displayname=name)
|
||||
return cls.displayname_template.format_full(name)
|
||||
|
||||
async def try_update_info(self, source: 'AbstractUser', info: User) -> None:
|
||||
try:
|
||||
await self.update_info(source, info)
|
||||
except Exception:
|
||||
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
|
||||
|
||||
changed = await self.update_displayname(source, info) or changed
|
||||
if isinstance(info.photo, UserProfilePhoto):
|
||||
changed = await self.update_avatar(source, info.photo.photo_big) or changed
|
||||
try:
|
||||
changed = await self.update_displayname(source, info) or changed
|
||||
if isinstance(info.photo, UserProfilePhoto):
|
||||
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
|
||||
|
||||
@@ -350,33 +254,69 @@ class Puppet:
|
||||
|
||||
async def update_displayname(self, source: 'AbstractUser', info: Union[User, UpdateUserName]
|
||||
) -> bool:
|
||||
ignore_source = (not source.is_relaybot
|
||||
and self.displayname_source is not None
|
||||
and self.displayname_source != source.tgid)
|
||||
if ignore_source:
|
||||
if self.disable_updates:
|
||||
return False
|
||||
if isinstance(info, UpdateUserName):
|
||||
allow_source = (source.is_relaybot
|
||||
or self.displayname_source == source.tgid
|
||||
# User is not a contact, so there's no custom name
|
||||
or not info.contact
|
||||
# No displayname source, so just trust anything
|
||||
or self.displayname_source is None)
|
||||
if not allow_source:
|
||||
return False
|
||||
elif isinstance(info, UpdateUserName):
|
||||
info = await source.client.get_entity(PeerUser(self.tgid))
|
||||
|
||||
displayname = self.get_displayname(info)
|
||||
if displayname != self.displayname:
|
||||
await self.default_mxid_intent.set_display_name(displayname)
|
||||
self.displayname = displayname
|
||||
self.displayname_source = source.tgid
|
||||
try:
|
||||
await self.default_mxid_intent.set_displayname(
|
||||
displayname[:config["bridge.displayname_max_length"]])
|
||||
except MatrixRequestError:
|
||||
self.log.exception("Failed to set displayname")
|
||||
self.displayname = ""
|
||||
self.displayname_source = None
|
||||
return True
|
||||
elif source.is_relaybot or self.displayname_source is None:
|
||||
self.displayname_source = source.tgid
|
||||
return True
|
||||
return False
|
||||
|
||||
async def update_avatar(self, source: 'AbstractUser', photo: FileLocation) -> bool:
|
||||
photo_id = f"{photo.volume_id}-{photo.local_id}"
|
||||
async def update_avatar(self, source: 'AbstractUser',
|
||||
photo: Union[UserProfilePhoto, UserProfilePhotoEmpty]) -> bool:
|
||||
if self.disable_updates:
|
||||
return False
|
||||
|
||||
if isinstance(photo, UserProfilePhotoEmpty):
|
||||
photo_id = ""
|
||||
else:
|
||||
photo_id = str(photo.photo_id)
|
||||
if self.photo_id != photo_id:
|
||||
file = await util.transfer_file_to_matrix(source.client, self.default_mxid_intent,
|
||||
photo)
|
||||
if not photo_id:
|
||||
self.photo_id = ""
|
||||
try:
|
||||
await self.default_mxid_intent.set_avatar_url("")
|
||||
except MatrixRequestError:
|
||||
self.log.exception("Failed to set avatar")
|
||||
self.photo_id = ""
|
||||
return True
|
||||
|
||||
loc = InputPeerPhotoFileLocation(
|
||||
peer=await self.get_input_entity(source),
|
||||
local_id=photo.photo_big.local_id,
|
||||
volume_id=photo.photo_big.volume_id,
|
||||
big=True
|
||||
)
|
||||
file = await util.transfer_file_to_matrix(source.client, self.default_mxid_intent, loc)
|
||||
if file:
|
||||
await self.default_mxid_intent.set_avatar(file.mxc)
|
||||
self.photo_id = photo_id
|
||||
try:
|
||||
await self.default_mxid_intent.set_avatar_url(file.mxc)
|
||||
except MatrixRequestError:
|
||||
self.log.exception("Failed to set avatar")
|
||||
self.photo_id = ""
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -402,7 +342,7 @@ class Puppet:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['Puppet']:
|
||||
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['Puppet']:
|
||||
tgid = cls.get_id_from_mxid(mxid)
|
||||
if tgid:
|
||||
return cls.get(tgid, create)
|
||||
@@ -410,7 +350,7 @@ class Puppet:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
|
||||
def get_by_custom_mxid(cls, mxid: UserID) -> Optional['Puppet']:
|
||||
if not mxid:
|
||||
raise ValueError("Matrix ID can't be empty")
|
||||
|
||||
@@ -434,23 +374,22 @@ class Puppet:
|
||||
for puppet in DBPuppet.all_with_custom_mxid())
|
||||
|
||||
@classmethod
|
||||
def get_id_from_mxid(cls, mxid: MatrixUserID) -> Optional[TelegramID]:
|
||||
match = cls.mxid_regex.match(mxid)
|
||||
if match:
|
||||
return TelegramID(int(match.group(1)))
|
||||
return None
|
||||
def get_id_from_mxid(cls, mxid: UserID) -> Optional[TelegramID]:
|
||||
return cls.mxid_template.parse(mxid)
|
||||
|
||||
@classmethod
|
||||
def get_mxid_from_id(cls, tgid: TelegramID) -> MatrixUserID:
|
||||
return MatrixUserID(f"@{cls.username_template.format(userid=tgid)}:{cls.hs_domain}")
|
||||
def get_mxid_from_id(cls, tgid: TelegramID) -> UserID:
|
||||
return UserID(cls.mxid_template.format_full(tgid))
|
||||
|
||||
@classmethod
|
||||
def find_by_username(cls, username: str) -> Optional['Puppet']:
|
||||
if not username:
|
||||
return None
|
||||
|
||||
username = username.lower()
|
||||
|
||||
for _, puppet in cls.cache.items():
|
||||
if puppet.username and puppet.username.lower() == username.lower():
|
||||
if puppet.username and puppet.username.lower() == username:
|
||||
return puppet
|
||||
|
||||
dbpuppet = DBPuppet.get_by_username(username)
|
||||
@@ -476,12 +415,19 @@ class Puppet:
|
||||
# endregion
|
||||
|
||||
|
||||
def init(context: 'Context') -> List[Awaitable[Any]]: # [None, None, PuppetError]
|
||||
def init(context: 'Context') -> Iterable[Awaitable[Any]]:
|
||||
global config
|
||||
Puppet.az, config, Puppet.loop, _ = context.core
|
||||
Puppet.mx = context.mx
|
||||
Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}")
|
||||
Puppet.hs_domain = config["homeserver"]["domain"]
|
||||
Puppet.mxid_regex = re.compile(
|
||||
f"@{Puppet.username_template.format(userid='([0-9]+)')}:{Puppet.hs_domain}")
|
||||
return [puppet.init_custom_mxid() for puppet in Puppet.all_with_custom_mxid()]
|
||||
|
||||
Puppet.mxid_template = SimpleTemplate(config["bridge.username_template"], "userid",
|
||||
prefix="@", suffix=f":{Puppet.hs_domain}", type=int)
|
||||
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.login_device_name = "Telegram Bridge"
|
||||
|
||||
return (puppet.try_start() for puppet in Puppet.all_with_custom_mxid())
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from typing import Union
|
||||
import argparse
|
||||
import sqlalchemy as sql
|
||||
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
import sqlalchemy as sql
|
||||
|
||||
from alchemysession import AlchemySessionContainer
|
||||
|
||||
@@ -22,16 +24,19 @@ def log(message, end="\n"):
|
||||
|
||||
|
||||
def connect(to):
|
||||
import mautrix_telegram.db.base as base
|
||||
base.Base = declarative_base(cls=base.BaseBase)
|
||||
from mautrix_telegram.db import (Portal, Message, UserPortal, User, RoomState, UserProfile,
|
||||
Contact, Puppet, BotChat, TelegramFile)
|
||||
from mautrix.bridge.db import Base, RoomState, UserProfile
|
||||
from mautrix_telegram.db import (Portal, Message, UserPortal, User, Contact, Puppet, BotChat,
|
||||
TelegramFile)
|
||||
|
||||
db_engine = sql.create_engine(to)
|
||||
db_factory = orm.sessionmaker(bind=db_engine)
|
||||
db_session = orm.scoped_session(db_factory) # type: orm.Session
|
||||
base.Base.metadata.bind = db_engine
|
||||
db_session: Union[orm.Session, orm.scoped_session] = orm.scoped_session(db_factory)
|
||||
Base.metadata.bind = db_engine
|
||||
|
||||
new_base = declarative_base()
|
||||
new_base.metadata.bind = db_engine
|
||||
session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
|
||||
table_base=base.Base, table_prefix="telethon_",
|
||||
table_base=new_base, table_prefix="telethon_",
|
||||
manage_tables=False)
|
||||
|
||||
return db_session, {
|
||||
@@ -52,6 +57,7 @@ def connect(to):
|
||||
"TelegramFile": TelegramFile,
|
||||
}
|
||||
|
||||
|
||||
log("Connecting to old database")
|
||||
session, tables = connect(args.from_url)
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -15,11 +14,14 @@
|
||||
# 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
|
||||
from sqlalchemy import orm
|
||||
import sqlalchemy as sql
|
||||
import argparse
|
||||
|
||||
from mautrix_telegram.db import Base, Portal, Message, Puppet, BotChat
|
||||
from sqlalchemy import orm
|
||||
import sqlalchemy as sql
|
||||
|
||||
from mautrix.util.db import Base
|
||||
|
||||
from mautrix_telegram.db import Portal, Message, Puppet, BotChat
|
||||
from mautrix_telegram.config import Config
|
||||
|
||||
from .models import ChatLink, TgUser, MatrixUser, Message as TMMessage, Base as TelematrixBase
|
||||
@@ -38,8 +40,7 @@ args = parser.parse_args()
|
||||
config = Config(args.config, None, None)
|
||||
config.load()
|
||||
|
||||
mxtg_db_engine = sql.create_engine(
|
||||
config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
|
||||
mxtg_db_engine = sql.create_engine(config["appservice.database"])
|
||||
mxtg = orm.sessionmaker(bind=mxtg_db_engine)()
|
||||
Base.metadata.bind = mxtg_db_engine
|
||||
|
||||
@@ -55,18 +56,18 @@ tm_messages = telematrix.query(TMMessage).all()
|
||||
telematrix.close()
|
||||
telematrix_db_engine.dispose()
|
||||
|
||||
portals_by_tgid = {} # type: Dict[int, Portal]
|
||||
portals_by_mxid = {} # type: Dict[str, Portal]
|
||||
chats = {} # type: Dict[int, BotChat]
|
||||
messages = {} # type: Dict[str, Message]
|
||||
puppets = {} # type: Dict[int, Puppet]
|
||||
portals_by_tgid: Dict[int, Portal] = {}
|
||||
portals_by_mxid: Dict[str, Portal] = {}
|
||||
chats: Dict[int, BotChat] = {}
|
||||
messages: Dict[str, Message] = {}
|
||||
puppets: Dict[int, Puppet] = {}
|
||||
|
||||
for chat_link in chat_links:
|
||||
if type(chat_link.tg_room) is str:
|
||||
print("Expected tg_room to be a number, got a string. Ignoring %s" % chat_link.tg_room)
|
||||
print(f"Expected tg_room to be a number, got a string. Ignoring {chat_link.tg_room}")
|
||||
continue
|
||||
if chat_link.tg_room >= 0:
|
||||
print("Unexpected unprefixed telegram chat ID: %s, ignoring..." % chat_link.tg_room)
|
||||
print(f"Unexpected unprefixed telegram chat ID: {chat_link.tg_room}, ignoring...")
|
||||
continue
|
||||
tgid = str(chat_link.tg_room)
|
||||
if tgid.startswith("-100"):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,106 +13,26 @@
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from typing import Dict, Tuple
|
||||
from mautrix.types import UserID
|
||||
from mautrix.bridge.db import SQLStateStore as BaseSQLStateStore
|
||||
|
||||
from mautrix_appservice import StateStore
|
||||
|
||||
from .types import MatrixUserID, MatrixRoomID
|
||||
from . import puppet as pu
|
||||
from .db import RoomState, UserProfile
|
||||
|
||||
|
||||
class SQLStateStore(StateStore):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.profile_cache = {} # type: Dict[Tuple[str, str], UserProfile]
|
||||
self.room_state_cache = {} # type: Dict[str, RoomState]
|
||||
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)
|
||||
|
||||
@staticmethod
|
||||
def is_registered(user: MatrixUserID) -> bool:
|
||||
puppet = pu.Puppet.get_by_mxid(user)
|
||||
return puppet.is_registered if puppet else False
|
||||
|
||||
@staticmethod
|
||||
def registered(user: MatrixUserID) -> None:
|
||||
puppet = pu.Puppet.get_by_mxid(user)
|
||||
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()
|
||||
|
||||
def update_state(self, event: Dict) -> None:
|
||||
event_type = event["type"]
|
||||
if event_type == "m.room.power_levels":
|
||||
self.set_power_levels(event["room_id"], event["content"])
|
||||
elif event_type == "m.room.member":
|
||||
self.set_member(event["room_id"], event["state_key"], event["content"])
|
||||
|
||||
def _get_user_profile(self, room_id: MatrixRoomID, user_id: MatrixUserID, create: bool = True
|
||||
) -> UserProfile:
|
||||
key = (room_id, user_id)
|
||||
try:
|
||||
return self.profile_cache[key]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
profile = UserProfile.get(*key)
|
||||
if profile:
|
||||
self.profile_cache[key] = profile
|
||||
elif create:
|
||||
profile = UserProfile(room_id=room_id, user_id=user_id, membership="leave")
|
||||
profile.insert()
|
||||
self.profile_cache[key] = profile
|
||||
return profile
|
||||
|
||||
def get_member(self, room: MatrixRoomID, user: MatrixUserID) -> Dict:
|
||||
return self._get_user_profile(room, user).dict()
|
||||
|
||||
def set_member(self, room: MatrixRoomID, user: MatrixUserID, member: Dict) -> None:
|
||||
profile = self._get_user_profile(room, user)
|
||||
profile.membership = member.get("membership", profile.membership or "leave")
|
||||
profile.displayname = member.get("displayname", profile.displayname)
|
||||
profile.avatar_url = member.get("avatar_url", profile.avatar_url)
|
||||
profile.update()
|
||||
|
||||
def set_membership(self, room: MatrixRoomID, user: MatrixUserID, membership: str) -> None:
|
||||
self.set_member(room, user, {
|
||||
"membership": membership,
|
||||
})
|
||||
|
||||
def _get_room_state(self, room_id: MatrixRoomID, create: bool = True) -> RoomState:
|
||||
try:
|
||||
return self.room_state_cache[room_id]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
room = RoomState.get(room_id)
|
||||
if room:
|
||||
self.room_state_cache[room_id] = room
|
||||
elif create:
|
||||
room = RoomState(room_id=room_id)
|
||||
room.insert()
|
||||
self.room_state_cache[room_id] = room
|
||||
return room
|
||||
|
||||
def has_power_levels(self, room: MatrixRoomID) -> bool:
|
||||
return bool(self._get_room_state(room).power_levels)
|
||||
|
||||
def get_power_levels(self, room: MatrixRoomID) -> Dict:
|
||||
return self._get_room_state(room).power_levels
|
||||
|
||||
def set_power_level(self, room: MatrixRoomID, user: MatrixUserID, level: int) -> None:
|
||||
room_state = self._get_room_state(room)
|
||||
power_levels = room_state.power_levels
|
||||
if not power_levels:
|
||||
power_levels = {
|
||||
"users": {},
|
||||
"events": {},
|
||||
}
|
||||
power_levels[room]["users"][user] = level
|
||||
room_state.power_levels = power_levels
|
||||
room_state.update()
|
||||
|
||||
def set_power_levels(self, room: MatrixRoomID, content: Dict) -> None:
|
||||
state = self._get_room_state(room)
|
||||
state.power_levels = content
|
||||
state.update()
|
||||
else:
|
||||
super().registered(user_id)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -18,18 +17,21 @@ from typing import List, Union, Optional
|
||||
|
||||
from telethon import TelegramClient, utils
|
||||
from telethon.tl.functions.messages import SendMediaRequest
|
||||
from telethon.tl.types import (
|
||||
InputMediaUploadedDocument, InputMediaUploadedPhoto, TypeDocumentAttribute, TypeInputMedia,
|
||||
TypeInputPeer, TypeMessageEntity, TypeMessageMedia, TypePeer)
|
||||
from telethon.tl.types import (InputMediaUploadedDocument, InputMediaUploadedPhoto,
|
||||
TypeDocumentAttribute, TypeInputMedia, TypeInputPeer,
|
||||
TypeMessageEntity, TypeMessageMedia, TypePeer)
|
||||
from telethon.tl.patched import Message
|
||||
from telethon.sessions.abstract import Session
|
||||
|
||||
|
||||
class MautrixTelegramClient(TelegramClient):
|
||||
session: Session
|
||||
|
||||
async def upload_file_direct(self, file: bytes, mime_type: str = None,
|
||||
attributes: List[TypeDocumentAttribute] = None,
|
||||
file_name: str = None, max_image_size: float = 10 * 1000 ** 2,
|
||||
) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]:
|
||||
file_handle = await super().upload_file(file, file_name=file_name, use_cache=False)
|
||||
file_handle = await super().upload_file(file, file_name=file_name)
|
||||
|
||||
if (mime_type == "image/png" or mime_type == "image/jpeg") and len(file) < max_image_size:
|
||||
return InputMediaUploadedPhoto(file_handle)
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
from typing import Dict, NewType
|
||||
|
||||
MatrixUserID = NewType('MatrixUserID', str)
|
||||
MatrixRoomID = NewType('MatrixRoomID', str)
|
||||
MatrixEventID = NewType('MatrixEventID', str)
|
||||
|
||||
MatrixEvent = NewType('MatrixEvent', Dict)
|
||||
from typing import NewType
|
||||
|
||||
TelegramID = NewType('TelegramID', int)
|
||||
|
||||
+102
-48
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,20 +13,24 @@
|
||||
#
|
||||
# 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, Match, NewType, Optional, Tuple, TYPE_CHECKING
|
||||
from typing import (Awaitable, Dict, List, Iterable, NewType, Optional, Tuple, Any, cast,
|
||||
TYPE_CHECKING)
|
||||
import logging
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from telethon.tl.types import (
|
||||
TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser,
|
||||
UpdateShortChatMessage, UpdateShortMessage, User as TLUser)
|
||||
from telethon.tl.types import (TypeUpdate, UpdateNewMessage, UpdateNewChannelMessage, PeerUser,
|
||||
UpdateShortChatMessage, UpdateShortMessage, User as TLUser, Chat,
|
||||
ChatForbidden)
|
||||
from telethon.tl.types.contacts import ContactsNotModified
|
||||
from telethon.tl.functions.contacts import GetContactsRequest, SearchRequest
|
||||
from telethon.tl.functions.account import UpdateStatusRequest
|
||||
from mautrix_appservice import MatrixRequestError
|
||||
|
||||
from .types import MatrixUserID, TelegramID
|
||||
from mautrix.client import Client
|
||||
from mautrix.errors import MatrixRequestError
|
||||
from mautrix.types import UserID
|
||||
from mautrix.bridge import BaseUser
|
||||
|
||||
from .types import TelegramID
|
||||
from .db import User as DBUser
|
||||
from .abstract_user import AbstractUser
|
||||
from . import portal as po, puppet as pu
|
||||
@@ -36,36 +39,46 @@ if TYPE_CHECKING:
|
||||
from .config import Config
|
||||
from .context import Context
|
||||
|
||||
config = None # type: Config
|
||||
config: Optional['Config'] = None
|
||||
|
||||
SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int])
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
log = logging.getLogger("mau.user") # type: logging.Logger
|
||||
by_mxid = {} # type: Dict[str, User]
|
||||
by_tgid = {} # type: Dict[int, User]
|
||||
class User(AbstractUser, BaseUser):
|
||||
log: logging.Logger = logging.getLogger("mau.user")
|
||||
by_mxid: Dict[str, 'User'] = {}
|
||||
by_tgid: Dict[int, 'User'] = {}
|
||||
|
||||
def __init__(self, mxid: MatrixUserID, tgid: Optional[TelegramID] = None,
|
||||
phone: Optional[str]
|
||||
contacts: List['pu.Puppet']
|
||||
saved_contacts: int
|
||||
portals: Dict[Tuple[TelegramID, TelegramID], 'po.Portal']
|
||||
command_status: Optional[Dict[str, Any]]
|
||||
|
||||
_db_instance: Optional[DBUser]
|
||||
_ensure_started_lock: asyncio.Lock
|
||||
|
||||
def __init__(self, mxid: UserID, tgid: Optional[TelegramID] = None,
|
||||
username: Optional[str] = None, phone: Optional[str] = None,
|
||||
db_contacts: Optional[Iterable[TelegramID]] = None,
|
||||
saved_contacts: int = 0, is_bot: bool = False,
|
||||
db_portals: Optional[Iterable[Tuple[TelegramID, TelegramID]]] = None,
|
||||
db_instance: Optional[DBUser] = None) -> None:
|
||||
super().__init__()
|
||||
self.mxid = mxid # type: MatrixUserID
|
||||
self.tgid = tgid # type: TelegramID
|
||||
self.is_bot = is_bot # type: bool
|
||||
self.username = username # type: str
|
||||
self.phone = phone # type: str
|
||||
self.contacts = [] # type: List[pu.Puppet]
|
||||
self.saved_contacts = saved_contacts # type: int
|
||||
self.mxid = mxid
|
||||
self.tgid = tgid
|
||||
self.is_bot = is_bot
|
||||
self.username = username
|
||||
self.phone = phone
|
||||
self.contacts = []
|
||||
self.saved_contacts = saved_contacts
|
||||
self.db_contacts = db_contacts
|
||||
self.portals = {} # type: Dict[Tuple[TelegramID, TelegramID], po.Portal]
|
||||
self.portals = {}
|
||||
self.db_portals = db_portals or []
|
||||
self._db_instance = db_instance # type: Optional[DBUser]
|
||||
self._db_instance = db_instance
|
||||
self._ensure_started_lock = asyncio.Lock()
|
||||
|
||||
self.command_status = None # type: Optional[Dict]
|
||||
self.command_status = None
|
||||
|
||||
(self.relaybot_whitelisted,
|
||||
self.whitelisted,
|
||||
@@ -78,14 +91,16 @@ class User(AbstractUser):
|
||||
if tgid:
|
||||
self.by_tgid[tgid] = self
|
||||
|
||||
self.log = self.log.getChild(self.mxid)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.mxid
|
||||
|
||||
@property
|
||||
def mxid_localpart(self) -> str:
|
||||
match = re.compile("@(.+):(.+)").match(self.mxid) # type: Match
|
||||
return match.group(1)
|
||||
localpart, server = Client.parse_user_id(self.mxid)
|
||||
return localpart
|
||||
|
||||
@property
|
||||
def human_tg_id(self) -> str:
|
||||
@@ -136,8 +151,8 @@ class User(AbstractUser):
|
||||
saved_contacts=self.saved_contacts, portals=self.db_portals)
|
||||
|
||||
def save(self, contacts: bool = False, portals: bool = False) -> None:
|
||||
self.db_instance.update(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone,
|
||||
saved_contacts=self.saved_contacts)
|
||||
self.db_instance.edit(tgid=self.tgid, tg_username=self.username, tg_phone=self.phone,
|
||||
saved_contacts=self.saved_contacts)
|
||||
if contacts:
|
||||
self.db_instance.contacts = self.db_contacts
|
||||
if portals:
|
||||
@@ -161,8 +176,17 @@ class User(AbstractUser):
|
||||
# endregion
|
||||
# region Telegram connection management
|
||||
|
||||
def ensure_started(self, even_if_no_session=False) -> Awaitable['User']:
|
||||
return super().ensure_started(even_if_no_session)
|
||||
async def try_ensure_started(self) -> None:
|
||||
try:
|
||||
await self.ensure_started()
|
||||
except Exception:
|
||||
self.log.exception("Exception in ensure_started")
|
||||
|
||||
async def ensure_started(self, even_if_no_session=False) -> 'User':
|
||||
if not self.puppet_whitelisted or self.connected:
|
||||
return self
|
||||
async with self._ensure_started_lock:
|
||||
return cast(User, await super().ensure_started(even_if_no_session))
|
||||
|
||||
async def start(self, delete_unless_authenticated: bool = False) -> 'User':
|
||||
await super().start()
|
||||
@@ -175,16 +199,27 @@ class User(AbstractUser):
|
||||
self.client.session.delete()
|
||||
return self
|
||||
|
||||
async def post_login(self, info: TLUser = None) -> None:
|
||||
async def post_login(self, info: TLUser = None, first_login: bool = False) -> None:
|
||||
try:
|
||||
await self.update_info(info)
|
||||
if not self.is_bot and config["bridge.startup_sync"]:
|
||||
except Exception:
|
||||
self.log.exception("Failed to update telegram account info")
|
||||
return
|
||||
|
||||
try:
|
||||
puppet = pu.Puppet.get(self.tgid)
|
||||
if puppet.custom_mxid != self.mxid and puppet.can_auto_login(self.mxid):
|
||||
self.log.info(f"Automatically enabling custom puppet")
|
||||
await puppet.switch_mxid(access_token="auto", mxid=self.mxid)
|
||||
except Exception:
|
||||
self.log.exception("Failed to automatically enable custom puppet")
|
||||
|
||||
if not self.is_bot and config["bridge.startup_sync"]:
|
||||
try:
|
||||
await self.sync_dialogs()
|
||||
await self.sync_contacts()
|
||||
if config["bridge.catch_up"]:
|
||||
await self.client.catch_up()
|
||||
except Exception:
|
||||
self.log.exception("Failed to run post-login functions for %s", self.mxid)
|
||||
except Exception:
|
||||
self.log.exception("Failed to run post-login sync")
|
||||
|
||||
async def update(self, update: TypeUpdate) -> bool:
|
||||
if not self.is_bot:
|
||||
@@ -198,7 +233,7 @@ class User(AbstractUser):
|
||||
else:
|
||||
portal = po.Portal.get_by_entity(message.to_id, receiver_id=self.tgid)
|
||||
elif isinstance(update, UpdateShortChatMessage):
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
||||
elif isinstance(update, UpdateShortMessage):
|
||||
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
|
||||
else:
|
||||
@@ -206,7 +241,9 @@ class User(AbstractUser):
|
||||
|
||||
if portal:
|
||||
self.register_portal(portal)
|
||||
return False
|
||||
|
||||
# Don't bother handling the update
|
||||
return True
|
||||
|
||||
# endregion
|
||||
@@ -229,7 +266,7 @@ class User(AbstractUser):
|
||||
self.phone = info.phone
|
||||
changed = True
|
||||
if self.tgid != info.id:
|
||||
self.tgid = info.id
|
||||
self.tgid = TelegramID(info.id)
|
||||
self.by_tgid[self.tgid] = self
|
||||
if changed:
|
||||
self.save()
|
||||
@@ -242,7 +279,8 @@ class User(AbstractUser):
|
||||
if not portal or portal.deleted or not portal.mxid or portal.has_bot:
|
||||
continue
|
||||
try:
|
||||
await portal.main_intent.kick(portal.mxid, self.mxid, "Logged out of Telegram.")
|
||||
await portal.main_intent.kick_user(portal.mxid, self.mxid,
|
||||
"Logged out of Telegram.")
|
||||
except MatrixRequestError:
|
||||
pass
|
||||
self.portals = {}
|
||||
@@ -263,7 +301,7 @@ class User(AbstractUser):
|
||||
|
||||
def _search_local(self, query: str, max_results: int = 5, min_similarity: int = 45
|
||||
) -> List[SearchResult]:
|
||||
results = [] # type: List[SearchResult]
|
||||
results: List[SearchResult] = []
|
||||
for contact in self.contacts:
|
||||
similarity = contact.similarity(query)
|
||||
if similarity >= min_similarity:
|
||||
@@ -275,7 +313,7 @@ class User(AbstractUser):
|
||||
if len(query) < 5:
|
||||
return []
|
||||
server_results = await self.client(SearchRequest(q=query, limit=max_results))
|
||||
results = [] # type: List[SearchResult]
|
||||
results: List[SearchResult] = []
|
||||
for user in server_results.users:
|
||||
puppet = pu.Puppet.get(user.id)
|
||||
await puppet.update_info(self, user)
|
||||
@@ -295,8 +333,21 @@ class User(AbstractUser):
|
||||
return await self._search_remote(query), True
|
||||
|
||||
async def sync_dialogs(self, synchronous_create: bool = False) -> None:
|
||||
if self.is_bot:
|
||||
return
|
||||
creators = []
|
||||
for entity in await self.get_dialogs(limit=config["bridge.sync_dialog_limit"] or None):
|
||||
limit = config["bridge.sync_dialog_limit"] or None
|
||||
self.log.debug(f"Syncing dialogs (limit={limit}, synchronous_create={synchronous_create})")
|
||||
async for dialog in self.client.iter_dialogs(limit=limit, ignore_migrated=True,
|
||||
archived=False):
|
||||
entity = dialog.entity
|
||||
if isinstance(entity, ChatForbidden):
|
||||
self.log.warning(f"Ignoring forbidden chat {entity} while syncing")
|
||||
elif isinstance(entity, Chat) and (entity.deactivated or entity.left):
|
||||
self.log.warning(f"Ignoring deactivated or left chat {entity} while syncing")
|
||||
continue
|
||||
elif isinstance(entity, TLUser) and not config["bridge.sync_direct_chats"]:
|
||||
continue
|
||||
portal = po.Portal.get_by_entity(entity)
|
||||
self.portals[portal.tgid_full] = portal
|
||||
creators.append(
|
||||
@@ -304,6 +355,7 @@ class User(AbstractUser):
|
||||
synchronous=synchronous_create))
|
||||
self.save(portals=True)
|
||||
await asyncio.gather(*creators, loop=self.loop)
|
||||
self.log.debug("Dialog syncing complete")
|
||||
|
||||
def register_portal(self, portal: po.Portal) -> None:
|
||||
try:
|
||||
@@ -323,7 +375,7 @@ class User(AbstractUser):
|
||||
|
||||
async def needs_relaybot(self, portal: po.Portal) -> bool:
|
||||
return not await self.is_logged_in() or (
|
||||
(portal.has_bot or self.bot) and portal.tgid_full not in self.portals)
|
||||
(portal.has_bot or self.is_bot) and portal.tgid_full not in self.portals)
|
||||
|
||||
def _hash_contacts(self) -> int:
|
||||
acc = 0
|
||||
@@ -348,7 +400,7 @@ class User(AbstractUser):
|
||||
# region Class instance lookup
|
||||
|
||||
@classmethod
|
||||
def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['User']:
|
||||
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Optional['User']:
|
||||
if not mxid:
|
||||
raise ValueError("Matrix ID can't be empty")
|
||||
|
||||
@@ -388,8 +440,10 @@ class User(AbstractUser):
|
||||
if not username:
|
||||
return None
|
||||
|
||||
username = username.lower()
|
||||
|
||||
for _, user in cls.by_tgid.items():
|
||||
if user.username and user.username.lower() == username.lower():
|
||||
if user.username and user.username.lower() == username:
|
||||
return user
|
||||
|
||||
puppet = DBUser.get_by_username(username)
|
||||
@@ -400,9 +454,9 @@ class User(AbstractUser):
|
||||
# endregion
|
||||
|
||||
|
||||
def init(context: 'Context') -> List[Awaitable['User']]:
|
||||
def init(context: 'Context') -> Iterable[Awaitable['User']]:
|
||||
global config
|
||||
config = context.config
|
||||
|
||||
users = [User.from_db(user) for user in DBUser.all()]
|
||||
return [user.ensure_started() for user in users if user.tgid]
|
||||
return (User.from_db(db_user).try_ensure_started()
|
||||
for db_user in DBUser.all_with_tgid())
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
from .file_transfer import transfer_file_to_matrix, convert_image
|
||||
from .parallel_file_transfer import parallel_transfer_to_telegram
|
||||
from .format_duration import format_duration
|
||||
from .signed_token import sign_token, verify_token
|
||||
from .recursive_dict import recursive_del, recursive_set, recursive_get
|
||||
|
||||
|
||||
def ignore_coro(coro):
|
||||
pass
|
||||
from .color_log import ColorFormatter
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 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.util.color_log import ColorFormatter as BaseColorFormatter, PREFIX, MXID_COLOR, RESET
|
||||
|
||||
TELETHON_COLOR = PREFIX + "35;1m" # magenta
|
||||
TELETHON_MODULE_COLOR = PREFIX + "35m"
|
||||
|
||||
|
||||
class ColorFormatter(BaseColorFormatter):
|
||||
def _color_name(self, module: str) -> str:
|
||||
if module.startswith("telethon"):
|
||||
prefix, user_id, module = module.split(".", 2)
|
||||
return (f"{TELETHON_COLOR}{prefix}{RESET}."
|
||||
f"{MXID_COLOR}{user_id}{RESET}."
|
||||
f"{TELETHON_MODULE_COLOR}{module}{RESET}")
|
||||
return super()._color_name(module)
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -23,19 +22,25 @@ import asyncio
|
||||
import magic
|
||||
from sqlalchemy.exc import IntegrityError, InvalidRequestError
|
||||
|
||||
from telethon.tl.types import (Document, FileLocation, InputFileLocation, InputDocumentFileLocation,
|
||||
TypePhotoSize, PhotoSize, PhotoCachedSize)
|
||||
from telethon.tl.types import (Document, InputFileLocation, InputDocumentFileLocation,
|
||||
TypePhotoSize, PhotoSize, PhotoCachedSize, InputPhotoFileLocation,
|
||||
InputPeerPhotoFileLocation)
|
||||
from telethon.errors import (AuthBytesInvalidError, AuthKeyInvalidError, LocationInvalidError,
|
||||
SecurityError)
|
||||
from mautrix_appservice import IntentAPI
|
||||
SecurityError, FileIdInvalidError)
|
||||
|
||||
from mautrix.appservice import IntentAPI
|
||||
|
||||
|
||||
from ..tgclient import MautrixTelegramClient
|
||||
from ..db import TelegramFile as DBTelegramFile
|
||||
from ..util import sane_mimetypes
|
||||
from .parallel_file_transfer import parallel_transfer_to_matrix
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
Image = None
|
||||
|
||||
try:
|
||||
from moviepy.editor import VideoFileClip
|
||||
import random
|
||||
@@ -45,9 +50,12 @@ try:
|
||||
except ImportError:
|
||||
VideoFileClip = random = string = os = mimetypes = None
|
||||
|
||||
log = logging.getLogger("mau.util") # type: logging.Logger
|
||||
from .tgs_converter import convert_tgs_to
|
||||
|
||||
TypeLocation = Union[Document, InputDocumentFileLocation, FileLocation, InputFileLocation]
|
||||
log: logging.Logger = logging.getLogger("mau.util")
|
||||
|
||||
TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation,
|
||||
InputFileLocation, InputPhotoFileLocation]
|
||||
|
||||
|
||||
def convert_image(file: bytes, source_mime: str = "image/webp", target_type: str = "png",
|
||||
@@ -56,7 +64,7 @@ def convert_image(file: bytes, source_mime: str = "image/webp", target_type: str
|
||||
if not Image:
|
||||
return source_mime, file, None, None
|
||||
try:
|
||||
image = Image.open(BytesIO(file)).convert("RGBA") # type: Image.Image
|
||||
image: Image.Image = Image.open(BytesIO(file)).convert("RGBA")
|
||||
if thumbnail_to:
|
||||
image.thumbnail(thumbnail_to, Image.ANTIALIAS)
|
||||
new_file = BytesIO()
|
||||
@@ -99,9 +107,11 @@ def _read_video_thumbnail(data: bytes, video_ext: str = "mp4", frame_ext: str =
|
||||
|
||||
|
||||
def _location_to_id(location: TypeLocation) -> str:
|
||||
if isinstance(location, (Document, InputDocumentFileLocation)):
|
||||
if isinstance(location, Document):
|
||||
return f"{location.id}-{location.access_hash}"
|
||||
elif isinstance(location, (FileLocation, InputFileLocation)):
|
||||
elif isinstance(location, (InputDocumentFileLocation, InputPhotoFileLocation)):
|
||||
return f"{location.id}-{location.access_hash}-{location.thumb_size}"
|
||||
elif isinstance(location, (InputFileLocation, InputPeerPhotoFileLocation)):
|
||||
return f"{location.volume_id}-{location.local_id}"
|
||||
|
||||
|
||||
@@ -119,8 +129,8 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
|
||||
if db_file:
|
||||
return db_file
|
||||
|
||||
video_ext = mimetypes.guess_extension(mime)
|
||||
if VideoFileClip and video_ext:
|
||||
video_ext = sane_mimetypes.guess_extension(mime)
|
||||
if VideoFileClip and video_ext and video:
|
||||
try:
|
||||
file, width, height = _read_video_thumbnail(video, video_ext, frame_ext="png")
|
||||
except OSError:
|
||||
@@ -131,7 +141,7 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
|
||||
width, height = None, None
|
||||
mime_type = magic.from_buffer(file, mime=True)
|
||||
|
||||
content_uri = await intent.upload_file(file, mime_type)
|
||||
content_uri = await intent.upload_media(file, mime_type)
|
||||
|
||||
db_file = DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
|
||||
was_converted=False, timestamp=int(time.time()), size=len(file),
|
||||
@@ -145,14 +155,16 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
|
||||
return db_file
|
||||
|
||||
|
||||
transfer_locks = {} # type: Dict[str, asyncio.Lock]
|
||||
transfer_locks: Dict[str, asyncio.Lock] = {}
|
||||
|
||||
TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]]
|
||||
|
||||
|
||||
async def transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
|
||||
location: TypeLocation, thumbnail: TypeThumbnail = None,
|
||||
is_sticker: bool = False) -> Optional[DBTelegramFile]:
|
||||
is_sticker: bool = False, tgs_convert: Optional[dict] = None,
|
||||
filename: Optional[str] = None, parallel_id: Optional[int] = None
|
||||
) -> Optional[DBTelegramFile]:
|
||||
location_id = _location_to_id(location)
|
||||
if not location_id:
|
||||
return None
|
||||
@@ -168,43 +180,61 @@ async def transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentA
|
||||
transfer_locks[location_id] = lock
|
||||
async with lock:
|
||||
return await _unlocked_transfer_file_to_matrix(client, intent, location_id, location,
|
||||
thumbnail, is_sticker)
|
||||
thumbnail, is_sticker, tgs_convert,
|
||||
filename, parallel_id)
|
||||
|
||||
|
||||
async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
|
||||
loc_id: str, location: TypeLocation,
|
||||
thumbnail: TypeThumbnail, is_sticker: bool
|
||||
thumbnail: TypeThumbnail, is_sticker: bool,
|
||||
tgs_convert: Optional[dict], filename: Optional[str],
|
||||
parallel_id: Optional[int]
|
||||
) -> Optional[DBTelegramFile]:
|
||||
db_file = DBTelegramFile.get(loc_id)
|
||||
if db_file:
|
||||
return db_file
|
||||
|
||||
try:
|
||||
file = await client.download_file(location)
|
||||
except LocationInvalidError:
|
||||
return None
|
||||
except (AuthBytesInvalidError, AuthKeyInvalidError, SecurityError) as e:
|
||||
log.exception(f"{e.__class__.__name__} while downloading a file.")
|
||||
return 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,
|
||||
parallel_id)
|
||||
mime_type = location.mime_type
|
||||
file = None
|
||||
else:
|
||||
try:
|
||||
file = await client.download_file(location)
|
||||
except (LocationInvalidError, FileIdInvalidError):
|
||||
return None
|
||||
except (AuthBytesInvalidError, AuthKeyInvalidError, SecurityError) as e:
|
||||
log.exception(f"{e.__class__.__name__} while downloading a file.")
|
||||
return None
|
||||
|
||||
width, height = None, None
|
||||
mime_type = magic.from_buffer(file, mime=True)
|
||||
width, height = None, None
|
||||
mime_type = magic.from_buffer(file, mime=True)
|
||||
|
||||
image_converted = False
|
||||
if mime_type == "image/webp":
|
||||
new_mime_type, file, width, height = convert_image(
|
||||
file, source_mime="image/webp", target_type="png",
|
||||
thumbnail_to=(256, 256) if is_sticker else None)
|
||||
image_converted = new_mime_type != mime_type
|
||||
mime_type = new_mime_type
|
||||
thumbnail = None
|
||||
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
|
||||
image_converted = mime_type != "application/gzip"
|
||||
|
||||
content_uri = await intent.upload_file(file, mime_type)
|
||||
if mime_type == "image/webp":
|
||||
new_mime_type, file, width, height = convert_image(
|
||||
file, source_mime="image/webp", target_type="png",
|
||||
thumbnail_to=(256, 256) if is_sticker else None)
|
||||
image_converted = new_mime_type != mime_type
|
||||
mime_type = new_mime_type
|
||||
thumbnail = None
|
||||
|
||||
db_file = DBTelegramFile(id=loc_id, mxc=content_uri,
|
||||
mime_type=mime_type, was_converted=image_converted,
|
||||
timestamp=int(time.time()), size=len(file),
|
||||
width=width, height=height)
|
||||
content_uri = await intent.upload_media(file, mime_type)
|
||||
|
||||
db_file = DBTelegramFile(id=loc_id, mxc=content_uri,
|
||||
mime_type=mime_type, was_converted=image_converted,
|
||||
timestamp=int(time.time()), size=len(file),
|
||||
width=width, height=height)
|
||||
if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"):
|
||||
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
|
||||
thumbnail = thumbnail.location
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# 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 typing import Optional, List, AsyncGenerator, Union, Awaitable, DefaultDict, Tuple
|
||||
from collections import defaultdict
|
||||
import hashlib
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
import math
|
||||
|
||||
from aiohttp import ClientResponse
|
||||
|
||||
from telethon.tl.types import (Document, InputFileLocation, InputDocumentFileLocation,
|
||||
InputPhotoFileLocation, InputPeerPhotoFileLocation, TypeInputFile,
|
||||
InputFileBig, InputFile)
|
||||
from telethon.tl.functions.auth import ExportAuthorizationRequest, ImportAuthorizationRequest
|
||||
from telethon.tl.functions.upload import (GetFileRequest, SaveFilePartRequest,
|
||||
SaveBigFilePartRequest)
|
||||
from telethon.network import MTProtoSender
|
||||
from telethon.crypto import AuthKey
|
||||
from telethon import utils, helpers
|
||||
|
||||
from mautrix.appservice import IntentAPI
|
||||
from mautrix.types import ContentURI
|
||||
|
||||
from ..tgclient import MautrixTelegramClient
|
||||
from ..db import TelegramFile as DBTelegramFile
|
||||
|
||||
log: logging.Logger = logging.getLogger("mau.util")
|
||||
|
||||
TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation,
|
||||
InputFileLocation, InputPhotoFileLocation]
|
||||
|
||||
|
||||
class DownloadSender:
|
||||
sender: MTProtoSender
|
||||
request: GetFileRequest
|
||||
remaining: int
|
||||
stride: int
|
||||
|
||||
def __init__(self, sender: MTProtoSender, file: TypeLocation, offset: int, limit: int,
|
||||
stride: int, count: int) -> None:
|
||||
self.sender = sender
|
||||
self.request = GetFileRequest(file, offset=offset, limit=limit)
|
||||
self.stride = stride
|
||||
self.remaining = count
|
||||
|
||||
async def next(self) -> Optional[bytes]:
|
||||
if not self.remaining:
|
||||
return None
|
||||
result = await self.sender.send(self.request)
|
||||
self.remaining -= 1
|
||||
self.request.offset += self.stride
|
||||
return result.bytes
|
||||
|
||||
def disconnect(self) -> Awaitable[None]:
|
||||
return self.sender.disconnect()
|
||||
|
||||
|
||||
class UploadSender:
|
||||
sender: MTProtoSender
|
||||
request: Union[SaveFilePartRequest, SaveBigFilePartRequest]
|
||||
part_count: int
|
||||
stride: int
|
||||
previous: Optional[asyncio.Task]
|
||||
loop: asyncio.AbstractEventLoop
|
||||
|
||||
def __init__(self, sender: MTProtoSender, file_id: int, part_count: int, big: bool, index: int,
|
||||
stride: int, loop: asyncio.AbstractEventLoop) -> None:
|
||||
self.sender = sender
|
||||
self.part_count = part_count
|
||||
if big:
|
||||
self.request = SaveBigFilePartRequest(file_id, index, part_count, b"")
|
||||
else:
|
||||
self.request = SaveFilePartRequest(file_id, index, b"")
|
||||
self.stride = stride
|
||||
self.previous = None
|
||||
self.loop = loop
|
||||
|
||||
async def next(self, data: bytes) -> None:
|
||||
if self.previous:
|
||||
await self.previous
|
||||
self.previous = self.loop.create_task(self._next(data))
|
||||
|
||||
async def _next(self, data: bytes) -> None:
|
||||
self.request.bytes = data
|
||||
log.debug(f"Sending file part {self.request.file_part}/{self.part_count}"
|
||||
f" with {len(data)} bytes")
|
||||
await self.sender.send(self.request)
|
||||
self.request.file_part += self.stride
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if self.previous:
|
||||
await self.previous
|
||||
return await self.sender.disconnect()
|
||||
|
||||
|
||||
class ParallelTransferrer:
|
||||
client: MautrixTelegramClient
|
||||
loop: asyncio.AbstractEventLoop
|
||||
dc_id: int
|
||||
senders: Optional[List[Union[DownloadSender, UploadSender]]]
|
||||
auth_key: AuthKey
|
||||
upload_ticker: int
|
||||
|
||||
def __init__(self, client: MautrixTelegramClient, dc_id: Optional[int] = None) -> None:
|
||||
self.client = client
|
||||
self.loop = self.client.loop
|
||||
self.dc_id = dc_id or self.client.session.dc_id
|
||||
self.auth_key = (None if dc_id and self.client.session.dc_id != dc_id
|
||||
else self.client.session.auth_key)
|
||||
self.senders = None
|
||||
self.upload_ticker = 0
|
||||
|
||||
async def _cleanup(self) -> None:
|
||||
await asyncio.gather(*[sender.disconnect() for sender in self.senders])
|
||||
self.senders = None
|
||||
|
||||
@staticmethod
|
||||
def _get_connection_count(file_size: int, max_count: int = 20,
|
||||
full_size: int = 100 * 1024 * 1024) -> int:
|
||||
if file_size > full_size:
|
||||
return max_count
|
||||
return math.ceil((file_size / full_size) * max_count)
|
||||
|
||||
async def _init_download(self, connections: int, file: TypeLocation, part_count: int,
|
||||
part_size: int) -> None:
|
||||
minimum, remainder = divmod(part_count, connections)
|
||||
|
||||
def get_part_count() -> int:
|
||||
nonlocal remainder
|
||||
if remainder > 0:
|
||||
remainder -= 1
|
||||
return minimum + 1
|
||||
return minimum
|
||||
|
||||
# The first cross-DC sender will export+import the authorization, so we always create it
|
||||
# before creating any other senders.
|
||||
self.senders = [
|
||||
await self._create_download_sender(file, 0, part_size, connections * part_size,
|
||||
get_part_count()),
|
||||
*await asyncio.gather(
|
||||
*[self._create_download_sender(file, i, part_size, connections * part_size,
|
||||
get_part_count())
|
||||
for i in range(1, connections)])
|
||||
]
|
||||
|
||||
async def _create_download_sender(self, file: TypeLocation, index: int, part_size: int,
|
||||
stride: int,
|
||||
part_count: int) -> DownloadSender:
|
||||
return DownloadSender(await self._create_sender(), file, index * part_size, part_size,
|
||||
stride, part_count)
|
||||
|
||||
async def _init_upload(self, connections: int, file_id: int, part_count: int, big: bool
|
||||
) -> None:
|
||||
self.senders = [
|
||||
await self._create_upload_sender(file_id, part_count, big, 0, connections),
|
||||
*await asyncio.gather(
|
||||
*[self._create_upload_sender(file_id, part_count, big, i, connections)
|
||||
for i in range(1, connections)])
|
||||
]
|
||||
|
||||
async def _create_upload_sender(self, file_id: int, part_count: int, big: bool, index: int,
|
||||
stride: int) -> UploadSender:
|
||||
return UploadSender(await self._create_sender(), file_id, part_count, big, index, stride,
|
||||
loop=self.loop)
|
||||
|
||||
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)
|
||||
await sender.connect(self.client._connection(dc.ip_address, dc.port, dc.id,
|
||||
loop=self.loop, loggers=self.client._log,
|
||||
proxy=self.client._proxy))
|
||||
if not self.auth_key:
|
||||
log.debug(f"Exporting auth to DC {self.dc_id}")
|
||||
auth = await self.client(ExportAuthorizationRequest(self.dc_id))
|
||||
req = self.client._init_with(ImportAuthorizationRequest(
|
||||
id=auth.id, bytes=auth.bytes
|
||||
))
|
||||
await sender.send(req)
|
||||
self.auth_key = sender.auth_key
|
||||
return sender
|
||||
|
||||
async def init_upload(self, file_id: int, file_size: int, part_size_kb: Optional[float] = None,
|
||||
connection_count: Optional[int] = None) -> Tuple[int, int, bool]:
|
||||
connection_count = connection_count or self._get_connection_count(file_size)
|
||||
part_size = (part_size_kb or utils.get_appropriated_part_size(file_size)) * 1024
|
||||
part_count = (file_size + part_size - 1) // part_size
|
||||
is_large = file_size > 10 * 1024 * 1024
|
||||
await self._init_upload(connection_count, file_id, part_count, is_large)
|
||||
return part_size, part_count, is_large
|
||||
|
||||
async def upload(self, part: bytes) -> None:
|
||||
await self.senders[self.upload_ticker].next(part)
|
||||
self.upload_ticker = (self.upload_ticker + 1) % len(self.senders)
|
||||
|
||||
async def finish_upload(self) -> None:
|
||||
await self._cleanup()
|
||||
|
||||
async def download(self, file: TypeLocation, file_size: int,
|
||||
part_size_kb: Optional[float] = None,
|
||||
connection_count: Optional[int] = None) -> AsyncGenerator[bytes, None]:
|
||||
connection_count = connection_count or self._get_connection_count(file_size)
|
||||
part_size = (part_size_kb or utils.get_appropriated_part_size(file_size)) * 1024
|
||||
part_count = math.ceil(file_size / part_size)
|
||||
log.debug("Starting parallel download: "
|
||||
f"{connection_count} {part_size} {part_count} {file!s}")
|
||||
await self._init_download(connection_count, file, part_count, part_size)
|
||||
|
||||
part = 0
|
||||
while part < part_count:
|
||||
tasks = []
|
||||
for sender in self.senders:
|
||||
tasks.append(self.loop.create_task(sender.next()))
|
||||
for task in tasks:
|
||||
data = await task
|
||||
if not data:
|
||||
break
|
||||
yield data
|
||||
part += 1
|
||||
log.debug(f"Part {part} downloaded")
|
||||
|
||||
log.debug("Parallel download finished, cleaning up connections")
|
||||
await self._cleanup()
|
||||
|
||||
|
||||
parallel_transfer_locks: DefaultDict[int, asyncio.Lock] = defaultdict(lambda: asyncio.Lock())
|
||||
|
||||
|
||||
async def parallel_transfer_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
|
||||
loc_id: str, location: TypeLocation, filename: str,
|
||||
parallel_id: int) -> DBTelegramFile:
|
||||
size = location.size
|
||||
mime_type = location.mime_type
|
||||
dc_id, location = utils.get_input_location(location)
|
||||
# We lock the transfers because telegram has connection count limits
|
||||
async with parallel_transfer_locks[parallel_id]:
|
||||
downloader = ParallelTransferrer(client, dc_id)
|
||||
content_uri = await intent.upload_media(downloader.download(location, size),
|
||||
mime_type=mime_type, filename=filename, size=size)
|
||||
return DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
|
||||
was_converted=False, timestamp=int(time.time()), size=size,
|
||||
width=None, height=None)
|
||||
|
||||
|
||||
async def _internal_transfer_to_telegram(client: MautrixTelegramClient, response: ClientResponse
|
||||
) -> Tuple[TypeInputFile, int]:
|
||||
file_id = helpers.generate_random_long()
|
||||
file_size = response.content_length
|
||||
|
||||
hash_md5 = hashlib.md5()
|
||||
uploader = ParallelTransferrer(client)
|
||||
part_size, part_count, is_large = await uploader.init_upload(file_id, file_size)
|
||||
buffer = bytearray()
|
||||
async for data in response.content:
|
||||
if not is_large:
|
||||
hash_md5.update(data)
|
||||
if len(buffer) == 0 and len(data) == part_size:
|
||||
await uploader.upload(data)
|
||||
continue
|
||||
new_len = len(buffer) + len(data)
|
||||
if new_len >= part_size:
|
||||
cutoff = part_size - len(buffer)
|
||||
buffer.extend(data[:cutoff])
|
||||
await uploader.upload(bytes(buffer))
|
||||
buffer.clear()
|
||||
buffer.extend(data[cutoff:])
|
||||
else:
|
||||
buffer.extend(data)
|
||||
if len(buffer) > 0:
|
||||
await uploader.upload(bytes(buffer))
|
||||
await uploader.finish_upload()
|
||||
if is_large:
|
||||
return InputFileBig(file_id, part_count, "upload"), file_size
|
||||
else:
|
||||
return InputFile(file_id, part_count, "upload", hash_md5.hexdigest()), file_size
|
||||
|
||||
|
||||
async def parallel_transfer_to_telegram(client: MautrixTelegramClient, intent: IntentAPI,
|
||||
uri: ContentURI, parallel_id: int
|
||||
) -> Tuple[TypeInputFile, int]:
|
||||
url = intent.api.get_download_url(uri)
|
||||
async with parallel_transfer_locks[parallel_id]:
|
||||
async with intent.api.session.get(url) as response:
|
||||
return await _internal_transfer_to_telegram(client, response)
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -15,11 +14,12 @@
|
||||
# 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, Any
|
||||
from ..config import DictWithRecursion
|
||||
|
||||
from mautrix.util.config import RecursiveDict
|
||||
|
||||
|
||||
def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool:
|
||||
key, next_key = DictWithRecursion._parse_key(key)
|
||||
key, next_key = RecursiveDict.parse_key(key)
|
||||
if next_key is not None:
|
||||
if key not in data:
|
||||
data[key] = {}
|
||||
@@ -32,7 +32,7 @@ def recursive_set(data: Dict[str, Any], key: str, value: Any) -> bool:
|
||||
|
||||
|
||||
def recursive_get(data: Dict[str, Any], key: str) -> Any:
|
||||
key, next_key = DictWithRecursion._parse_key(key)
|
||||
key, next_key = RecursiveDict.parse_key(key)
|
||||
if next_key is not None:
|
||||
next_data = data.get(key, None)
|
||||
if not next_data:
|
||||
@@ -42,7 +42,7 @@ def recursive_get(data: Dict[str, Any], key: str) -> Any:
|
||||
|
||||
|
||||
def recursive_del(data: Dict[str, any], key: str) -> bool:
|
||||
key, next_key = DictWithRecursion._parse_key(key)
|
||||
key, next_key = RecursiveDict.parse_key(key)
|
||||
if next_key is not None:
|
||||
if key not in data:
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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/>.
|
||||
import mimetypes
|
||||
|
||||
mimetypes.init()
|
||||
|
||||
sanity_overrides = {
|
||||
"image/jpeg": ".jpeg",
|
||||
"image/tiff": ".tiff",
|
||||
"text/plain": ".txt",
|
||||
"text/html": ".html",
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/ogg": ".ogg",
|
||||
"application/xml": ".xml",
|
||||
"application/octet-stream": "",
|
||||
"application/x-msdos-program": ".exe",
|
||||
}
|
||||
|
||||
|
||||
def guess_extension(mime: str) -> str:
|
||||
try:
|
||||
return sanity_overrides[mime]
|
||||
except KeyError:
|
||||
return mimetypes.guess_extension(mime)
|
||||
@@ -1,53 +0,0 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# 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 typing import Dict, Optional
|
||||
import json
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
|
||||
def _get_checksum(key: str, payload: bytes) -> str:
|
||||
hasher = hashlib.sha256()
|
||||
hasher.update(payload)
|
||||
hasher.update(key.encode("utf-8"))
|
||||
checksum = hasher.hexdigest()
|
||||
return checksum
|
||||
|
||||
|
||||
def sign_token(key: str, payload: Dict) -> str:
|
||||
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
|
||||
checksum = _get_checksum(key, payload_b64)
|
||||
return f"{checksum}:{payload_b64.decode('utf-8')}"
|
||||
|
||||
|
||||
def verify_token(key: str, data: str) -> Optional[Dict]:
|
||||
if not data:
|
||||
return None
|
||||
|
||||
try:
|
||||
checksum, payload = data.split(":", 1)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if checksum != _get_checksum(key, payload.encode("utf-8")):
|
||||
return None
|
||||
|
||||
payload = base64.urlsafe_b64decode(payload).decode("utf-8")
|
||||
try:
|
||||
return json.loads(payload)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
@@ -0,0 +1,112 @@
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Telegram lottie sticker converter
|
||||
# Copyright (C) 2019 Randall Eramde Lawrence
|
||||
#
|
||||
# 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 typing import Dict, Callable, Awaitable, Optional, Tuple, Any
|
||||
import asyncio.subprocess
|
||||
import logging
|
||||
import shutil
|
||||
import os.path
|
||||
import tempfile
|
||||
|
||||
log: logging.Logger = logging.getLogger("mau.util.tgs")
|
||||
converters: Dict[str, Callable[[bytes, int, int, Any], Awaitable[Tuple[str, bytes]]]] = {}
|
||||
|
||||
|
||||
def abswhich(program: Optional[str]) -> Optional[str]:
|
||||
path = shutil.which(program)
|
||||
return os.path.abspath(path) if path else None
|
||||
|
||||
|
||||
lottieconverter = abswhich("lottieconverter")
|
||||
ffmpeg = abswhich("ffmpeg")
|
||||
|
||||
if lottieconverter:
|
||||
async def tgs_to_png(file: bytes, width: int, height: int, **_: Any) -> Tuple[str, bytes]:
|
||||
frame = 1
|
||||
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", "-", "png",
|
||||
f"{width}x{height}", str(frame),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate(file)
|
||||
if proc.returncode == 0:
|
||||
return "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
|
||||
|
||||
|
||||
async def tgs_to_gif(file: bytes, width: int, height: int, background: str = "202020",
|
||||
**_: Any) -> Tuple[str, bytes]:
|
||||
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", "-", "gif",
|
||||
f"{width}x{height}", f"0x{background}",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate(file)
|
||||
if proc.returncode == 0:
|
||||
return "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
|
||||
|
||||
|
||||
converters["png"] = tgs_to_png
|
||||
converters["gif"] = tgs_to_gif
|
||||
|
||||
if lottieconverter and ffmpeg:
|
||||
async def tgs_to_webm(file: bytes, width: int, height: int, fps: int = 30,
|
||||
**_: Any) -> Tuple[str, bytes]:
|
||||
with tempfile.TemporaryDirectory(prefix="tgs_") as tmpdir:
|
||||
file_template = tmpdir + "/out_"
|
||||
proc = await asyncio.create_subprocess_exec(lottieconverter, "-", file_template,
|
||||
"pngs", f"{width}x{height}", str(fps),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.PIPE)
|
||||
_, stderr = await proc.communicate(file)
|
||||
if proc.returncode == 0:
|
||||
proc = await asyncio.create_subprocess_exec(ffmpeg, "-hide_banner", "-loglevel",
|
||||
"error", "-framerate", str(fps),
|
||||
"-pattern_type", "glob", "-i",
|
||||
file_template + "*.png",
|
||||
"-c:v", "libvpx-vp9", "-pix_fmt",
|
||||
"yuva420p", "-f", "webm", "-",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stdin=asyncio.subprocess.PIPE)
|
||||
stdout, stderr = await proc.communicate()
|
||||
if proc.returncode == 0:
|
||||
return "video/webm", stdout
|
||||
else:
|
||||
log.error("ffmpeg error: " + (stderr.decode("utf-8") if stderr is not None
|
||||
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
|
||||
|
||||
|
||||
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]]:
|
||||
if convert_to in converters:
|
||||
converter = converters[convert_to]
|
||||
mime, out = await converter(file, width, height, **kwargs)
|
||||
return mime, out, width, height
|
||||
elif convert_to != "disable":
|
||||
log.warning(f"Unable to convert animated sticker, type {convert_to} not supported")
|
||||
return "application/gzip", file, None, None
|
||||
@@ -0,0 +1 @@
|
||||
from .get_version import git_tag, git_revision, version, linkified_version
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: future_fstrings -*-
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2019 Tulir Asokan
|
||||
#
|
||||
@@ -14,27 +13,30 @@
|
||||
#
|
||||
# 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 abc import abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from abc import abstractmethod
|
||||
import abc
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from telethon.errors import *
|
||||
|
||||
from mautrix.bridge import OnlyLoginSelf, InvalidAccessToken
|
||||
|
||||
from ...commands.telegram.auth import enter_password
|
||||
from ...util import format_duration, ignore_coro
|
||||
from ...puppet import Puppet, PuppetError
|
||||
from ...util import format_duration
|
||||
from ...puppet import Puppet
|
||||
from ...user import User
|
||||
|
||||
|
||||
class AuthAPI(abc.ABC):
|
||||
log = logging.getLogger("mau.web.auth") # type: logging.Logger
|
||||
log: logging.Logger = logging.getLogger("mau.web.auth")
|
||||
loop: asyncio.AbstractEventLoop
|
||||
|
||||
def __init__(self, loop: asyncio.AbstractEventLoop):
|
||||
self.loop = loop # type: asyncio.AbstractEventLoop
|
||||
self.loop = loop
|
||||
|
||||
@abstractmethod
|
||||
def get_login_response(self, status: int = 200, state: str = "", username: str = "",
|
||||
@@ -56,15 +58,14 @@ class AuthAPI(abc.ABC):
|
||||
error="You have already logged in with your Matrix "
|
||||
"account.", errcode="already-logged-in")
|
||||
|
||||
resp = await puppet.switch_mxid(token, user.mxid)
|
||||
if resp == PuppetError.OnlyLoginSelf:
|
||||
try:
|
||||
await puppet.switch_mxid(token.strip(), user.mxid)
|
||||
except OnlyLoginSelf:
|
||||
return self.get_mx_login_response(status=403, errcode="only-login-self",
|
||||
error="You can only log in as your own Matrix user.")
|
||||
elif resp == PuppetError.InvalidAccessToken:
|
||||
except InvalidAccessToken:
|
||||
return self.get_mx_login_response(status=401, errcode="invalid-access-token",
|
||||
error="Failed to verify access token.")
|
||||
assert resp == PuppetError.Success, "Encountered an unhandled PuppetError."
|
||||
|
||||
return self.get_mx_login_response(mxid=user.mxid, status=200, state="logged-in")
|
||||
|
||||
async def post_matrix_password(self, user: User, password: str) -> web.Response:
|
||||
@@ -72,10 +73,15 @@ class AuthAPI(abc.ABC):
|
||||
errcode="not-yet-implemented")
|
||||
|
||||
async def post_login_phone(self, user: User, phone: str) -> web.Response:
|
||||
if not phone or not phone.strip():
|
||||
return self.get_login_response(mxid=user.mxid, state="request", status=400,
|
||||
errcode="phone_number_invalid",
|
||||
error="Phone number not given.")
|
||||
try:
|
||||
await user.client.sign_in(phone or "+123")
|
||||
await user.client.sign_in(phone.strip())
|
||||
return self.get_login_response(mxid=user.mxid, state="code", status=200,
|
||||
message="Code requested successfully.")
|
||||
message="Code requested successfully. Check your SMS "
|
||||
"or Telegram client and enter the code below.")
|
||||
except PhoneNumberInvalidError:
|
||||
return self.get_login_response(mxid=user.mxid, state="request", status=400,
|
||||
errcode="phone_number_invalid",
|
||||
@@ -113,14 +119,13 @@ class AuthAPI(abc.ABC):
|
||||
existing_user = User.get_by_tgid(user_info.id)
|
||||
if existing_user and existing_user != user:
|
||||
await existing_user.log_out()
|
||||
ignore_coro(asyncio.ensure_future(user.post_login(user_info), loop=self.loop))
|
||||
asyncio.ensure_future(user.post_login(user_info, first_login=True), loop=self.loop)
|
||||
if user.command_status and user.command_status["action"] == "Login":
|
||||
user.command_status = None
|
||||
|
||||
|
||||
async def post_login_token(self, user: User, token: str) -> web.Response:
|
||||
try:
|
||||
user_info = await user.client.sign_in(bot_token=token)
|
||||
user_info = await user.client.sign_in(bot_token=token.strip())
|
||||
await self.postprocess_login(user, user_info)
|
||||
return self.get_login_response(mxid=user.mxid, state="logged-in", status=200,
|
||||
username=user_info.username, phone=None,
|
||||
@@ -162,9 +167,13 @@ class AuthAPI(abc.ABC):
|
||||
"next": enter_password,
|
||||
"action": "Login (password entry)",
|
||||
}
|
||||
message = (
|
||||
"Code accepted, but you have 2-factor "
|
||||
"authentication enabled. Please enter your password."
|
||||
)
|
||||
return self.get_login_response(
|
||||
mxid=user.mxid, state="password", status=202,
|
||||
message="Code accepted, but you have 2-factor authentication is enabled.")
|
||||
mxid=user.mxid, state="password", status=202, message=message
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
self.log.exception("Error sending phone code")
|
||||
@@ -174,7 +183,7 @@ class AuthAPI(abc.ABC):
|
||||
|
||||
async def post_login_password(self, user: User, password: str) -> web.Response:
|
||||
try:
|
||||
user_info = await user.client.sign_in(password=password)
|
||||
user_info = await user.client.sign_in(password=password.strip())
|
||||
await self.postprocess_login(user, user_info)
|
||||
human_tg_id = f"@{user_info.username}" if user_info.username else f"+{user_info.phone}"
|
||||
return self.get_login_response(mxid=user.mxid, state="logged-in", status=200,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user