Compare commits

...

110 Commits

Author SHA1 Message Date
Tulir Asokan 4b03cddde8 Bump version to 0.8.2 2020-07-27 13:55:09 +03:00
Tulir Asokan 6eec096501 Bump minimum mautrix-python version to v0.5.8 2020-07-27 13:55:09 +03:00
Tulir Asokan 78cdb43c65 Fix using edge repos in docker image
Backported from 482a52cb5e
2020-07-27 13:54:54 +03:00
Tulir Asokan 4b57be3917 Bump version to 0.8.1 2020-06-08 17:45:19 +03:00
Tulir Asokan 9383e5eed2 Allow any 0.5.x version of mautrix-python
Fixes #479
2020-06-08 12:36:18 +03:00
Tulir Asokan a3b4a5e30e Update Docker image to Alpine 3.12 2020-06-06 20:10:14 +03:00
Tulir Asokan 72a45d7d80 Bump version to 0.8.0 2020-06-03 15:37:07 +03:00
Tulir Asokan bcf464428a Bump version to 0.8.0rc5 2020-05-30 13:18:58 +03:00
Tulir Asokan f3b9f4bf73 Bump maximum Telethon version 2020-05-29 15:28:53 +03:00
Tulir Asokan 10e54ed789 Add option to send delivery error notices 2020-05-29 15:28:41 +03:00
Tulir Asokan 35da8df526 Add option to disable removing avatars from Telegram ghosts
There's no way to determine whether an avatar is removed or just hidden
from some users, so avatars are not removed by default.
2020-05-29 15:27:38 +03:00
Tulir Asokan fb1ab220ff Update ROADMAP.md 2020-05-28 12:56:56 +03:00
Tulir Asokan 2dd39fddf0 Try to prevent infinite loop of state changes with double puppeting
Fixes #464
2020-05-27 12:36:51 +03:00
Tulir Asokan 7f69e9f329 Bump mautrix-python version 2020-05-25 14:11:03 +03:00
Tulir Asokan 3f6a4237ad Add option to send read receipt on confirmed delivery to Telegram 2020-05-25 13:25:37 +03:00
Tulir Asokan ee04e8c17f Bump mautrix-python req to rc1 2020-05-22 22:19:36 +03:00
Tulir Asokan 7d75c15027 Actually fix branch/tag condition in CI 2020-05-22 22:19:27 +03:00
Tulir Asokan 312a44d361 Add sponsors section to README.md 2020-05-22 22:00:23 +03:00
Tulir Asokan 85d38e3db6 Bump version to 0.8.0rc3 2020-05-22 20:49:47 +03:00
Tulir Asokan 3a25ee2c93 Merge pull request #468 from davidmehren/fix-peerchannel-admin
Fix admin detection in _can_use_commands
2020-05-22 20:02:53 +03:00
Tulir Asokan a4d49a41e0 Maybe fix branch condition in CI 2020-05-21 19:35:04 +03:00
David Mehren 7ba9e10f0f Fix admin detection in _can_use_commands 2020-05-21 09:44:27 +02:00
Tulir Asokan 05e966011e Fix error syncing private chat portals with no avatar 2020-05-20 23:29:36 +03:00
Tulir Asokan 9081f6bce4 Bump mautrix-python requirement 2020-05-20 23:17:42 +03:00
Tulir Asokan c126e8b615 Actually ignore ChatForbidden when syncing. Fixes #446 2020-05-20 22:45:22 +03:00
Tulir Asokan f454803ef7 Move private information to trace log level. Fixes #321 2020-05-20 22:40:20 +03:00
Tulir Asokan 40beb8f752 Add private_chat_portal_meta option and fix bugs
* The new option is implicitly enabled when encryption is default
* Private chat metadata is now updated after creating the room too
* The puppet metadata is updated before creating the room, to make sure their
  name is available locally
2020-05-20 21:19:42 +03:00
Tulir Asokan 4d8d332732 Bump version to 0.8.0rc2 2020-05-20 19:13:54 +03:00
Tulir Asokan 7fb771b992 Fix copying example config on first run of docker image 2020-05-20 19:13:45 +03:00
Tulir Asokan d0900a95a7 Send uk.half-shot.bridge in addition to m.bridge 2020-05-19 11:37:17 +03:00
Tulir Asokan 8552d463a1 Add missing receiver_id when syncing direct chat dialogs (ref #425) 2020-05-19 11:30:45 +03:00
Tulir Asokan 74d130644c Fix tempfile usage 2020-05-17 15:01:03 +03:00
Tulir Asokan 976e0dd2b7 Fix !tg version command for non-release versions in docker 2020-05-13 23:34:43 +03:00
Tulir Asokan 340c25ba0b Use stdlib tempfile for video thumbnail temp files 2020-05-13 23:33:24 +03:00
Tulir Asokan 7e8d4bc9a8 Include readme, license and requirements.txt in PyPI tarballs 2020-05-13 23:33:08 +03:00
Tulir Asokan 429544373a Bump mautrix-python and send m.bridge events 2020-05-05 21:40:57 +03:00
Tulir Asokan 80dd6fa9e1 Fix typo in unbridge permission error 2020-04-27 13:21:49 +03:00
Tulir Asokan 45ac120407 Add error message if backfill is ran in non-portal room 2020-04-25 23:24:39 +03:00
Tulir Asokan 2c100ca1e5 Fix minor mistake in logging 2020-04-25 19:31:12 +03:00
Tulir Asokan c54bd9e1ce Log the source and reason of user displayname changes 2020-04-25 19:29:12 +03:00
Tulir Asokan a2a35e481a Bump version to 0.8.0rc1 2020-04-25 18:34:10 +03:00
Tulir Asokan 84ff0c777d Allow !tg random command with text names instead of emojis 2020-04-25 18:33:34 +03:00
Tulir Asokan 37ecd57a9b Update telethon and add support for darts. Fixes #457 2020-04-25 18:25:00 +03:00
Tulir Asokan 8578a9bd01 Merge pull request #455 from davidmehren/fix-create-matrix-room
Do not crash in _create_matrix_room if `invites` is `None`
2020-04-25 15:26:34 +03:00
Tulir Asokan 6b64f38fa3 Merge pull request #452 from jevolk/master
TLS listener configuration related
2020-04-25 15:25:37 +03:00
Tulir Asokan ea9206f56b Add support for sending and receiving dice 2020-04-21 10:01:33 +03:00
David Mehren 467c0989e1 Do not crash in _create_matrix_room if invites is None 2020-04-17 18:19:44 +02:00
Jason Volk 2a0d44acc5 Ensure config.yaml update order preservation by including tls items in example-conf.yaml 2020-04-08 00:58:53 -07:00
Jason Volk a9b28b54d5 Fix missing config update copy() for tls items. 2020-04-08 00:56:35 -07:00
Tulir Asokan c296a5d4a4 Merge pull request #449 from halkeye/run-db-migration-after-configs
Run migrations after config file is in place, so it can be properly generated
2020-04-06 10:19:54 +03:00
Tulir Asokan 10926a1240 Use chat.id instead of get_peer_id(chat) for Dialog. Fixes #450 2020-04-06 10:17:13 +03:00
Tulir Asokan 992e962df7 Fix async for typo. Fixes #448 2020-04-06 10:06:12 +03:00
Gavin Mogan 7726925771 Run migrations after config file is in place, so it can be properly generated 2020-04-05 23:50:41 -07:00
Tulir Asokan a53b0e9837 Fix potential KeyError in power level syncing 2020-04-04 22:01:59 +03:00
Tulir Asokan 26eb2d4e54 Remove extra COPY statements in dockerfile 2020-04-04 21:48:53 +03:00
Tulir Asokan b53b27cf2d Use separately built image for lottieconverter to improve caching 2020-04-04 21:38:21 +03:00
Tulir Asokan cecda22ec3 Adjust editorconfig for .gitlab-ci.yml 2020-04-04 21:37:58 +03:00
Tulir Asokan dc5fe62e3a Merge branch 'e2be' into master 2020-04-04 20:39:08 +03:00
Tulir Asokan c957989abb Merge branch 'master' into e2be 2020-04-03 22:18:28 +03:00
Tulir Asokan 708fec6886 Add missing check 2020-04-03 22:18:07 +03:00
Tulir Asokan 32db2355a2 Add pysocks to dockerfile
Closes #445
2020-04-03 22:13:02 +03:00
Tulir Asokan c1d4e8e482 Update mautrix-python to use SQLAlchemy for matrix-nio state storage 2020-03-31 22:19:43 +03:00
Tulir Asokan a00c58e521 Decrypt encrypted media from Matrix 2020-03-30 21:47:41 +03:00
Tulir Asokan 698b56afcf Encrypt media being sent to Matrix in encrypted rooms 2020-03-30 21:47:13 +03:00
Tulir Asokan af285c5ffe Allow matrix-nio 0.10 2020-03-30 01:10:13 +03:00
Tulir Asokan 37917c497e Fix encrypting outgoing Matrix events after restart 2020-03-30 01:04:12 +03:00
Tulir Asokan 50ec2551f8 Remove all automatic matrix-nio state receiving
All state is now fed to nio from the appservice state event stream instead of
/sync. This should remove all race conditions of trying to encrypt messages
before nio is synced.
2020-03-29 14:28:22 +03:00
Tulir Asokan 4519c88230 Bump mautrix-python version 2020-03-29 02:12:40 +02:00
Tulir Asokan d84724b8b0 Fix copying example config in docker 2020-03-29 01:58:38 +02:00
Tulir Asokan 56d21bdf59 Add support for enabling encryption by default 2020-03-29 01:37:00 +02:00
Tulir Asokan 260c1612a6 Install matrix-nio dependencies from alpine packages when available 2020-03-28 23:09:08 +02:00
Tulir Asokan 6ab3106b38 Add libolm to docker image 2020-03-28 22:43:28 +02:00
Tulir Asokan c79d442158 Add initial Matrix end-to-bridge encryption support 2020-03-28 22:01:23 +02:00
Tulir Asokan 7a6de144ce Merge pull request #438 from anoadragon453/anoa/group_id_example
Provide an example of the community ID format in the example config
2020-03-25 12:19:27 +02:00
Andrew Morgan 5240999f56 Merge branch 'master' of https://github.com/tulir/mautrix-telegram into anoa/group_id_example
* 'master' of https://github.com/tulir/mautrix-telegram:
  Add hack for Riot iOS being dumb about thumbnails
  Update to mautrix-python 0.5.0
  Optimize dockerfile a bit
  Move dependency versions to requirements.txt
2020-03-25 10:17:56 +00:00
Tulir Asokan 0a94e60e22 Add hack for Riot iOS being dumb about thumbnails 2020-03-24 14:05:54 +02:00
Tulir Asokan c83fdab502 Update to mautrix-python 0.5.0 2020-03-22 00:51:10 +02:00
Andrew Morgan ca0c2fd9e6 Example group id format 2020-03-06 23:11:13 +00:00
Tulir Asokan a0c842acb6 Optimize dockerfile a bit 2020-03-04 23:57:15 +02:00
Tulir Asokan ba17246755 Move dependency versions to requirements.txt 2020-03-04 23:32:14 +02:00
Tulir Asokan af766449d2 Switch default create group type to supergroup 2020-02-29 17:07:06 +02:00
Tulir Asokan 30052b4d74 Fix typo in Puppet.all_with_custom_mxid 2020-02-28 23:00:09 +02:00
Tulir Asokan 9f02b6edb0 Move enabling experimental docker features to before_script 2020-02-25 22:19:14 +02:00
Tulir Asokan 22e24e6e6c Combine amd64 and arm64 docker images into one manifest 2020-02-25 22:00:29 +02:00
Tulir Asokan 48bc1995bb Merge branch 'arm-ci' 2020-02-25 21:28:10 +02:00
Tulir Asokan 854e289bba Merge pull request #420 from n0emis/n0emis-ogg-mimetype
add workaround for application/ogg
2020-02-19 12:14:18 +02:00
Tulir Asokan db9d55a5cc Default to info logs for telethon 2020-02-13 18:49:21 +02:00
n0emis cca0efbd8d add workaround for application/ogg 2020-02-11 00:02:36 +01:00
Serhat Seyren 596446d14b Fix formatted phone number issue for pm command
(cherry picked from commit 5612330e3b)

Fixes #395
Closes #416
2020-02-08 13:18:45 +02:00
Tulir Asokan 578bc7cd5a Only leave group chat portals with default puppet. Fixes #418 2020-02-08 12:50:17 +02:00
Tulir Asokan d58eb52944 Fix ignore_incoming_bot_events check in channels
Fixes #417
2020-02-07 17:36:43 +02:00
Tulir Asokan 906d8322e3 Set version to 0.8.0+dev 2020-02-07 17:36:23 +02:00
Tulir Asokan c2be26adb2 Fix incorrect initial value for Portal.backfilling. Fixes #414 2020-02-05 21:00:28 +02:00
Tulir Asokan cf88823e6f Add support for backfilling private chats 2020-02-04 22:50:58 +02:00
Tulir Asokan 2fbee75453 Add command to backfill room history from Telegram
Currently supports backfilling one room at a time and backfills
everything after the last bridged message.
2020-02-04 22:41:51 +02:00
Tulir Asokan 07edcc4867 Bump version to 0.7.1 2020-02-04 22:31:09 +02:00
Tulir Asokan 65d7934c21 Add missing response to logout provisioning API endpoint 2020-01-28 22:49:48 +02:00
Tulir Asokan 842d98dc1c Bump version to 0.7.1rc2 2020-01-25 23:37:18 +02:00
Tulir Asokan b7e69ddc61 Fix relaybot messages being allowed through with ignore_own_incoming_events set 2020-01-25 23:36:17 +02:00
Tulir Asokan 2dc6041bd7 Add architecture tags 2020-01-20 22:25:20 +02:00
Tulir Asokan b007646d4b Fix syntax 2020-01-20 22:22:47 +02:00
Tulir Asokan 5580f3dc81 Build arm64 docker image and remove separate push step 2020-01-20 22:19:14 +02:00
Tulir Asokan 82f7905367 Add note to Matrix->Telegram EDU bridging 2020-01-13 20:46:00 +02:00
Tulir Asokan 1d8699054c Merge pull request #409 from cubesky/master
Fix mautrix-python import error.
2020-01-12 23:21:18 +02:00
天空/立音 32c521cb79 Fix mautrix-python import error.
Because of mautrix-python library [API Changes](https://github.com/tulir/mautrix-python/commit/04d2ae4c3d4db5f8798f4f844caafb5d00606507). Database migration script is broken.
2020-01-13 02:46:26 +08:00
Tulir Asokan b4cf8cd451 Bump version to 0.7.1rc1 2020-01-11 20:08:47 +02:00
Tulir Asokan 80ff9d0f66 Precalculate list of channel IDs to get info for to fix #393 2020-01-11 20:07:21 +02:00
Tulir Asokan b0e60e60e4 Fix parameter name error in has_power_level call 2020-01-11 19:58:08 +02:00
Tulir Asokan c4b9a76931 Merge pull request #406 from Ma27/fix-tests
Fix several broken tests that were missing some required positional arguments
2019-12-28 14:56:21 +02:00
Maximilian Bosch fe52f0ad10 Fix several broken tests that were missing some required positional arguments 2019-12-28 13:00:39 +01:00
40 changed files with 949 additions and 370 deletions
+3
View File
@@ -13,3 +13,6 @@ max_line_length = 99
[*.{yaml,yml,py}] [*.{yaml,yml,py}]
indent_style = space indent_style = space
[.gitlab-ci.yml]
indent_size = 2
+25 -22
View File
@@ -2,37 +2,40 @@ image: docker:stable
stages: stages:
- build - build
- push - manifest
default: default:
before_script: before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
build: build amd64:
stage: build stage: build
tags:
- amd64
script: script:
- docker pull $CI_REGISTRY_IMAGE:latest || true - docker pull $CI_REGISTRY_IMAGE:latest || true
- docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA . - docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
push latest: build arm64:
stage: push stage: build
only: tags:
- master - arm64
variables:
GIT_STRATEGY: none
script: script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - docker pull $CI_REGISTRY_IMAGE:latest || true
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:latest - docker build --pull --cache-from $CI_REGISTRY_IMAGE:latest --tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 .
- docker push $CI_REGISTRY_IMAGE:latest - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
push tag: manifest:
stage: push stage: manifest
variables: before_script:
GIT_STRATEGY: none - "mkdir -p $HOME/.docker && echo '{\"experimental\": \"enabled\"}' > $HOME/.docker/config.json"
except: - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- master
script: script:
- docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64
- docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME - docker pull $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME - if [ "$CI_COMMIT_BRANCH" = "master" ]; then docker manifest create $CI_REGISTRY_IMAGE:latest $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 && docker manifest push $CI_REGISTRY_IMAGE:latest; fi
- if [ "$CI_COMMIT_BRANCH" != "master" ]; then docker manifest create $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64 && docker manifest push $CI_REGISTRY_IMAGE:$CI_COMMIT_REF_NAME; fi
- docker rmi $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-amd64 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA-arm64
+45 -48
View File
@@ -1,77 +1,74 @@
FROM docker.io/alpine:3.10 AS lottieconverter FROM dock.mau.dev/tulir/lottieconverter:alpine-3.12
WORKDIR /build RUN echo $'\
@edge http://dl-cdn.alpinelinux.org/alpine/edge/main\n\
@edge http://dl-cdn.alpinelinux.org/alpine/edge/testing\n\
@edge http://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories
RUN apk add --no-cache git build-base cmake \ RUN apk add --no-cache \
&& git clone https://github.com/Samsung/rlottie.git \ python3 py3-pip py3-setuptools py3-wheel \
&& 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 --virtual .build-deps \
python3-dev \
libffi-dev \
build-base \
git \
&& apk add --no-cache \
py3-virtualenv \ py3-virtualenv \
py3-pillow \ py3-pillow \
py3-aiohttp \ py3-aiohttp \
py3-magic \ py3-magic \
py3-sqlalchemy \ py3-sqlalchemy \
py3-telethon-session-sqlalchemy@edge \
py3-alembic@edge \
py3-psycopg2 \ py3-psycopg2 \
py3-ruamel.yaml \ py3-ruamel.yaml \
py3-commonmark@edge \
# Indirect dependencies # Indirect dependencies
py3-idna \ py3-idna \
#commonmark
py3-future \
#alembic
py3-mako \
py3-dateutil \
py3-markupsafe \
#moviepy #moviepy
py3-decorator \ py3-decorator \
py3-tqdm \ py3-tqdm \
py3-requests \ py3-requests \
#imageio #imageio
py3-numpy \ py3-numpy \
#telethon py3-telethon@edge \
py3-rsa \ # Optional for socks proxies
py3-pysocks \
# cryptg # cryptg
py3-cffi \ py3-cffi \
py3-brotli \
# Other dependencies # Other dependencies
ffmpeg \ ffmpeg \
ca-certificates \ ca-certificates \
su-exec \ su-exec \
netcat-openbsd \ netcat-openbsd \
# lottieconverter # olm
zlib libpng \ olm-dev \
&& pip3 install .[speedups,hq_thumbnails,metrics] \ # matrix-nio?
# pip installs the sources to /usr/lib/python3.8/site-packages, so we don't need them here py3-future \
&& rm -rf /opt/mautrix-telegram/mautrix_telegram \ py3-atomicwrites \
py3-pycryptodome \
py3-peewee \
py3-pyrsistent \
py3-jsonschema \
#py3-aiofiles \ # (too new)
py3-cachetools \
py3-unpaddedbase64 \
py3-h2@edge \
py3-logbook@edge
COPY requirements.txt /opt/mautrix-telegram/requirements.txt
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
WORKDIR /opt/mautrix-telegram
RUN apk add --virtual .build-deps \
python3-dev \
libffi-dev \
build-base \
&& sed -Ei 's/psycopg2-binary.+//' optional-requirements.txt \
&& pip3 install -r requirements.txt -r optional-requirements.txt \
&& apk del .build-deps && apk del .build-deps
COPY . /opt/mautrix-telegram
RUN apk add git && pip3 install .[speedups,hq_thumbnails,metrics,e2be] && apk del git \
# This doesn't make the image smaller, but it's needed so that the `version` command works properly
&& cp mautrix_telegram/example-config.yaml . && rm -rf mautrix_telegram
VOLUME /data VOLUME /data
ENV UID=1337 GID=1337 \
FFMPEG_BINARY=/usr/bin/ffmpeg
CMD ["/opt/mautrix-telegram/docker-run.sh"] CMD ["/opt/mautrix-telegram/docker-run.sh"]
+4
View File
@@ -0,0 +1,4 @@
include README.md
include LICENSE
include requirements.txt
include optional-requirements.txt
+3
View File
@@ -7,6 +7,9 @@
A Matrix-Telegram hybrid puppeting/relaybot bridge. A Matrix-Telegram hybrid puppeting/relaybot bridge.
## Sponsors
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
### [Wiki](https://github.com/tulir/mautrix-telegram/wiki) ### [Wiki](https://github.com/tulir/mautrix-telegram/wiki)
### [Features & Roadmap](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md) ### [Features & Roadmap](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md)
+7 -3
View File
@@ -6,9 +6,9 @@
* [x] Message edits * [x] Message edits
* [ ] ‡ Message history * [ ] ‡ Message history
* [x] Presence * [x] Presence
* [x] Typing notifications * [x] Typing notifications*
* [x] Read receipts * [x] Read receipts*
* [x] Pinning messages * [x] Pinning messages*
* [x] Power level * [x] Power level
* [x] Normal chats * [x] Normal chats
* [ ] Non-hardcoded PL requirements * [ ] Non-hardcoded PL requirements
@@ -29,6 +29,9 @@
* [x] Message deletions * [x] Message deletions
* [x] Message edits * [x] Message edits
* [ ] Message history * [ ] Message history
* [x] Manually (`!tg backfill`)
* [ ] Automatically when creating portal
* [ ] Automatically for missed messages
* [x] Avatars * [x] Avatars
* [x] Presence * [x] Presence
* [x] Typing notifications * [x] Typing notifications
@@ -56,5 +59,6 @@
* [ ] ‡ Secret chats (not yet supported by Telethon) * [ ] ‡ Secret chats (not yet supported by Telethon)
* [ ] ‡ E2EE in Matrix rooms (not yet supported * [ ] ‡ E2EE in Matrix rooms (not yet supported
\* Requires [double puppeting](https://github.com/tulir/mautrix-telegram/wiki/Authentication#replacing-telegram-accounts-matrix-puppet-with-matrix-account) to be enabled
† Information not automatically sent from source, i.e. implementation may not be possible † Information not automatically sent from source, i.e. implementation may not be possible
‡ Maybe, i.e. this feature may or may not be implemented at some point ‡ Maybe, i.e. this feature may or may not be implemented at some point
@@ -0,0 +1,27 @@
"""Add encrypted field for portals
Revision ID: 24f31fc8a72b
Revises: a7c04a56041b
Create Date: 2020-03-28 20:14:29.046699
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "24f31fc8a72b"
down_revision = "a7c04a56041b"
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("portal") as batch_op:
batch_op.add_column(sa.Column("encrypted", sa.Boolean(), nullable=False,
server_default=sa.sql.expression.false()))
def downgrade():
with op.batch_alter_table("portal") as batch_op:
batch_op.drop_column("encrypted")
@@ -0,0 +1,26 @@
"""Add decryption info field for reuploaded telegram files
Revision ID: d3c922a6acd2
Revises: 24f31fc8a72b
Create Date: 2020-03-30 20:07:17.340346
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd3c922a6acd2'
down_revision = '24f31fc8a72b'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.add_column(sa.Column("decryption_info", sa.Text(), nullable=True))
def downgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.drop_column("decryption_info")
@@ -0,0 +1,71 @@
"""Add matrix-nio state store to main db
Revision ID: dff56c93da8d
Revises: d3c922a6acd2
Create Date: 2020-03-31 22:04:04.014048
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dff56c93da8d'
down_revision = 'd3c922a6acd2'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('nio_account',
sa.Column('user_id', sa.String(length=255), nullable=False),
sa.Column('device_id', sa.String(length=255), nullable=False),
sa.Column('shared', sa.Boolean(), nullable=False),
sa.Column('sync_token', sa.Text(), nullable=False),
sa.Column('account', sa.LargeBinary(), nullable=False),
sa.PrimaryKeyConstraint('user_id', 'device_id')
)
op.create_table('nio_device_key',
sa.Column('user_id', sa.String(length=255), nullable=False),
sa.Column('device_id', sa.String(length=255), nullable=False),
sa.Column('display_name', sa.String(length=255), nullable=False),
sa.Column('deleted', sa.Boolean(), nullable=False),
sa.Column('keys', sa.PickleType(), nullable=False),
sa.PrimaryKeyConstraint('user_id', 'device_id')
)
op.create_table('nio_megolm_inbound_session',
sa.Column('session_id', sa.String(length=255), nullable=False),
sa.Column('sender_key', sa.String(length=255), nullable=False),
sa.Column('fp_key', sa.String(length=255), nullable=False),
sa.Column('room_id', sa.String(length=255), nullable=False),
sa.Column('session', sa.LargeBinary(), nullable=False),
sa.Column('forwarded_chains', sa.PickleType(), nullable=False),
sa.PrimaryKeyConstraint('session_id')
)
op.create_table('nio_olm_session',
sa.Column('session_id', sa.String(length=255), nullable=False),
sa.Column('sender_key', sa.String(length=255), nullable=False),
sa.Column('session', sa.LargeBinary(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('last_used', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('session_id')
)
op.create_table('nio_outgoing_key_request',
sa.Column('request_id', sa.String(length=255), nullable=False),
sa.Column('session_id', sa.String(length=255), nullable=False),
sa.Column('room_id', sa.String(length=255), nullable=False),
sa.Column('algorithm', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('request_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('nio_outgoing_key_request')
op.drop_table('nio_olm_session')
op.drop_table('nio_megolm_inbound_session')
op.drop_table('nio_device_key')
op.drop_table('nio_account')
# ### end Alembic commands ###
+3 -2
View File
@@ -13,8 +13,6 @@ sed -i "s#sqlite:///mautrix-telegram.db#sqlite:////data/mautrix-telegram.db#" /d
if [ -f /data/mx-state.json ]; then if [ -f /data/mx-state.json ]; then
ln -s /data/mx-state.json ln -s /data/mx-state.json
fi fi
# Check that database is in the right state
alembic -x config=/data/config.yaml upgrade head
if [ ! -f /data/config.yaml ]; then if [ ! -f /data/config.yaml ]; then
cp example-config.yaml /data/config.yaml cp example-config.yaml /data/config.yaml
@@ -35,5 +33,8 @@ if [ ! -f /data/registration.yaml ]; then
exit exit
fi fi
# Check that database is in the right state
alembic -x config=/data/config.yaml upgrade head
fixperms fixperms
exec su-exec $UID:$GID python3 -m mautrix_telegram -c /data/config.yaml exec su-exec $UID:$GID python3 -m mautrix_telegram -c /data/config.yaml
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.7.0" __version__ = "0.8.2"
__author__ = "Tulir Asokan <tulir@maunium.net>" __author__ = "Tulir Asokan <tulir@maunium.net>"
+1
View File
@@ -44,6 +44,7 @@ except ImportError:
class TelegramBridge(Bridge): class TelegramBridge(Bridge):
module = "mautrix_telegram"
name = "mautrix-telegram" name = "mautrix-telegram"
command = "python -m mautrix-telegram" command = "python -m mautrix-telegram"
description = "A Matrix-Telegram puppeting bridge." description = "A Matrix-Telegram puppeting bridge."
+11 -12
View File
@@ -35,6 +35,7 @@ from telethon.tl.types import (
from mautrix.types import UserID, PresenceState from mautrix.types import UserID, PresenceState
from mautrix.errors import MatrixError from mautrix.errors import MatrixError
from mautrix.appservice import AppService from mautrix.appservice import AppService
from mautrix.util.logging import TraceLogger
from alchemysession import AlchemySessionContainer from alchemysession import AlchemySessionContainer
from . import portal as po, puppet as pu, __version__ from . import portal as po, puppet as pu, __version__
@@ -68,7 +69,7 @@ except ImportError:
class AbstractUser(ABC): class AbstractUser(ABC):
session_container: AlchemySessionContainer = None session_container: AlchemySessionContainer = None
loop: asyncio.AbstractEventLoop = None loop: asyncio.AbstractEventLoop = None
log: logging.Logger log: TraceLogger
az: AppService az: AppService
relaybot: Optional['Bot'] relaybot: Optional['Bot']
ignore_incoming_bot_events: bool = True ignore_incoming_bot_events: bool = True
@@ -97,7 +98,6 @@ class AbstractUser(ABC):
self.client = None self.client = None
self.is_relaybot = False self.is_relaybot = False
self.is_bot = False self.is_bot = False
self.relaybot = None
@property @property
def connected(self) -> bool: def connected(self) -> bool:
@@ -259,7 +259,7 @@ class AbstractUser(ABC):
elif isinstance(update, UpdateReadHistoryOutbox): elif isinstance(update, UpdateReadHistoryOutbox):
await self.update_read_receipt(update) await self.update_read_receipt(update)
else: else:
self.log.debug("Unhandled update: %s", update) self.log.trace("Unhandled update: %s", update)
async def update_pinned_messages(self, update: Union[UpdateChannelPinnedMessage, async def update_pinned_messages(self, update: Union[UpdateChannelPinnedMessage,
UpdateChatPinnedMessage]) -> None: UpdateChatPinnedMessage]) -> None:
@@ -334,7 +334,7 @@ class AbstractUser(ABC):
if await puppet.update_avatar(self, update.photo): if await puppet.update_avatar(self, update.photo):
puppet.save() puppet.save()
else: else:
self.log.warning("Unexpected other user info update: %s", update) self.log.warning(f"Unexpected other user info update: {type(update)}")
async def update_status(self, update: UpdateUserStatus) -> None: async def update_status(self, update: UpdateUserStatus) -> None:
puppet = pu.Puppet.get(TelegramID(update.user_id)) puppet = pu.Puppet.get(TelegramID(update.user_id))
@@ -343,7 +343,7 @@ class AbstractUser(ABC):
elif isinstance(update.status, UserStatusOffline): elif isinstance(update.status, UserStatusOffline):
await puppet.default_mxid_intent.set_presence(PresenceState.OFFLINE) await puppet.default_mxid_intent.set_presence(PresenceState.OFFLINE)
else: else:
self.log.warning("Unexpected user status update: %s", update) self.log.warning(f"Unexpected user status update: type({update})")
return return
def get_message_details(self, update: UpdateMessage) -> Tuple[UpdateMessageContent, def get_message_details(self, update: UpdateMessage) -> Tuple[UpdateMessageContent,
@@ -367,8 +367,7 @@ class AbstractUser(ABC):
portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid) portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
sender = pu.Puppet.get(update.from_id) if update.from_id else None sender = pu.Puppet.get(update.from_id) if update.from_id else None
else: else:
self.log.warning( self.log.warning(f"Unexpected message type in User#get_message_details: {type(update)}")
f"Unexpected message type in User#get_message_details: {type(update)}")
return update, None, None return update, None, None
return update, sender, portal return update, sender, portal
@@ -422,17 +421,17 @@ class AbstractUser(ABC):
f" in unbridged chat {portal.tgid_log}") f" in unbridged chat {portal.tgid_log}")
return return
if self.ignore_incoming_bot_events and self.relaybot and sender.id == self.relaybot.tgid: if ((self.ignore_incoming_bot_events and self.relaybot
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update, portal.tgid_log) and sender and sender.id == self.relaybot.tgid)):
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
return return
if isinstance(update, MessageService): if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom): if isinstance(update.action, MessageActionChannelMigrateFrom):
self.log.debug(f"Ignoring action %s to %s by %d", update.action, self.log.trace(f"Ignoring action %s to %s by %d", update.action, portal.tgid_log,
portal.tgid_log,
sender.id) sender.id)
return return
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log, self.log.trace("Handling action %s to %s by %d", update.action, portal.tgid_log,
sender.id) sender.id)
return await portal.handle_telegram_action(self, sender, update) return await portal.handle_telegram_action(self, sender, update)
+3 -3
View File
@@ -108,9 +108,9 @@ class Bot(AbstractUser):
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated: if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
self.remove_chat(TelegramID(chat.id)) 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() for chat_id, chat_type in self.chats.items()
if chat_type == "channel") if chat_type == "channel"]
for channel_id in channel_ids: for channel_id in channel_ids:
try: try:
await self.client(GetChannelsRequest([channel_id])) await self.client(GetChannelsRequest([channel_id]))
@@ -147,7 +147,7 @@ class Bot(AbstractUser):
if self.whitelist_group_admins: if self.whitelist_group_admins:
if isinstance(chat, PeerChannel): if isinstance(chat, PeerChannel):
p = await self.client(GetParticipantRequest(chat, tgid)) p = await self.client(GetParticipantRequest(chat, tgid))
return isinstance(p, (ChannelParticipantCreator, ChannelParticipantAdmin)) return isinstance(p.participant, (ChannelParticipantCreator, ChannelParticipantAdmin))
elif isinstance(chat, PeerChat): elif isinstance(chat, PeerChat):
chat = await self.client(GetFullChatRequest(chat.chat_id)) chat = await self.client(GetFullChatRequest(chat.chat_id))
participants = chat.full_chat.participants.participants participants = chat.full_chat.participants.participants
@@ -25,10 +25,10 @@ from .util import user_has_power_level, get_initial_state
help_args="[_type_]", help_args="[_type_]",
help_text="Create a Telegram chat of the given type for the current Matrix room. " 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 " "The type is either `group`, `supergroup` or `channel` (defaults to "
"`group`).") "`supergroup`).")
async def create(evt: CommandEvent) -> EventID: async def create(evt: CommandEvent) -> EventID:
type = evt.args[0] if len(evt.args) > 0 else "group" type = evt.args[0] if len(evt.args) > 0 else "supergroup"
if type not in {"chat", "group", "supergroup", "channel"}: if type not in ("chat", "group", "supergroup", "channel"):
return await evt.reply( return await evt.reply(
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`") "**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
+5 -8
View File
@@ -22,9 +22,7 @@ from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
from .util import user_has_power_level from .util import user_has_power_level
async def _get_portal_and_check_permission(evt: CommandEvent, permission: str, async def _get_portal_and_check_permission(evt: CommandEvent) -> Optional[po.Portal]:
action: Optional[str] = None
) -> Optional[po.Portal]:
room_id = RoomID(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) portal = po.Portal.get_by_mxid(room_id)
@@ -33,9 +31,8 @@ async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
await evt.reply(f"{that_this} is not a portal room.") await evt.reply(f"{that_this} is not a portal room.")
return None return None
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission): if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
action = action or f"{permission.replace('_', ' ')}s" await evt.reply("You do not have the permissions to unbridge that portal.")
await evt.reply(f"You do not have the permissions to {action} that portal.")
return None return None
return portal return portal
@@ -64,7 +61,7 @@ def _get_portal_murder_function(action: str, room_id: str, function: Callable, c
"Only works for group chats; to delete a private chat portal, simply " "Only works for group chats; to delete a private chat portal, simply "
"leave the room.") "leave the room.")
async def delete_portal(evt: CommandEvent) -> Optional[EventID]: async def delete_portal(evt: CommandEvent) -> Optional[EventID]:
portal = await _get_portal_and_check_permission(evt, "unbridge") portal = await _get_portal_and_check_permission(evt)
if not portal: if not portal:
return None return None
@@ -85,7 +82,7 @@ async def delete_portal(evt: CommandEvent) -> Optional[EventID]:
help_section=SECTION_PORTAL_MANAGEMENT, help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Remove puppets from the current portal room and forget the portal.") help_text="Remove puppets from the current portal room and forget the portal.")
async def unbridge(evt: CommandEvent) -> Optional[EventID]: async def unbridge(evt: CommandEvent) -> Optional[EventID]:
portal = await _get_portal_and_check_permission(evt, "unbridge") portal = await _get_portal_and_check_permission(evt)
if not portal: if not portal:
return None return None
+48 -5
View File
@@ -20,10 +20,11 @@ import base64
import re import re
from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError, from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError,
UserAlreadyParticipantError, ChatIdInvalidError) UserAlreadyParticipantError, ChatIdInvalidError,
TakeoutInitDelayError, EmoticonInvalidError)
from telethon.tl.patched import Message from telethon.tl.patched import Message
from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll, from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll,
TypeInputPeer) TypeInputPeer, InputMediaDice)
from telethon.tl.types.messages import BotCallbackAnswer from telethon.tl.types.messages import BotCallbackAnswer
from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatInviteRequest, from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatInviteRequest,
GetBotCallbackAnswerRequest, SendVoteRequest) GetBotCallbackAnswerRequest, SendVoteRequest)
@@ -35,7 +36,8 @@ from ... import puppet as pu, portal as po
from ...abstract_user import AbstractUser from ...abstract_user import AbstractUser
from ...db import Message as DBMessage from ...db import Message as DBMessage
from ...types import TelegramID from ...types import TelegramID
from ...commands import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS from ...commands import (command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS,
SECTION_PORTAL_MANAGEMENT)
@command_handler(needs_auth=False, @command_handler(needs_auth=False,
@@ -102,7 +104,8 @@ async def pm(evt: CommandEvent) -> EventID:
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`") return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
try: try:
user = await evt.sender.client.get_entity(evt.args[0]) id = "".join(evt.args).translate({ord(c): None for c in "+()- "})
user = await evt.sender.client.get_entity(id)
except ValueError: except ValueError:
return await evt.reply("Invalid user identifier or user not found.") return await evt.reply("Invalid user identifier or user not found.")
@@ -162,7 +165,9 @@ async def join(evt: CommandEvent) -> Optional[EventID]:
try: try:
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid]) await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
except ChatIdInvalidError as e: except ChatIdInvalidError as e:
logging.getLogger("mau.commands").info(updates.stringify()) logging.getLogger("mau.commands").trace("ChatIdInvalidError while creating portal "
"from !tg join command: %s",
updates.stringify())
raise e raise e
return await evt.reply(f"Created room for {portal.title}") return await evt.reply(f"Created room for {portal.title}")
return None return None
@@ -303,3 +308,41 @@ async def vote(evt: CommandEvent) -> EventID:
return await evt.reply("You passed too many options.") return await evt.reply("You passed too many options.")
# TODO use response # TODO use response
return await evt.mark_read() return await evt.mark_read()
@command_handler(help_section=SECTION_MISC, help_args="<_emoji_>",
help_text="Roll a dice (\U0001F3B2) or throw a dart (\U0001F3AF) "
"on the Telegram servers.")
async def random(evt: CommandEvent) -> EventID:
if not evt.is_portal:
return await evt.reply("You can only roll dice in portal rooms")
portal = po.Portal.get_by_mxid(evt.room_id)
arg = evt.args[0] if len(evt.args) > 0 else "dice"
emoticon = {
"dart": "\U0001F3AF",
"dice": "\U0001F3B2",
}.get(arg, arg)
try:
await evt.sender.client.send_media(await portal.get_input_entity(evt.sender),
InputMediaDice(emoticon))
except EmoticonInvalidError:
return await evt.reply("Invalid emoji for randomization")
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Backfill messages from Telegram history.")
async def backfill(evt: CommandEvent) -> None:
if not evt.is_portal:
await evt.reply("You can only use backfill in portal rooms")
return
portal = po.Portal.get_by_mxid(evt.room_id)
try:
await portal.backfill(evt.sender)
except TakeoutInitDelayError:
msg = ("Please accept the data export request from a mobile device, "
"then re-run the backfill command.")
if portal.peer_type == "user":
from mautrix.appservice import IntentAPI
await portal.main_intent.send_notice(evt.room_id, msg)
else:
await evt.reply(msg)
+12 -27
View File
@@ -45,23 +45,18 @@ class Config(BaseBridgeConfig):
] ]
def do_update(self, helper: ConfigUpdateHelper) -> None: def do_update(self, helper: ConfigUpdateHelper) -> None:
super().do_update(helper)
copy, copy_dict, base = helper copy, copy_dict, base = helper
copy("homeserver.address")
copy("homeserver.domain")
copy("homeserver.verify_ssl")
if "appservice.protocol" in self and "appservice.address" not in self: if "appservice.protocol" in self and "appservice.address" not in self:
protocol, hostname, port = (self["appservice.protocol"], self["appservice.hostname"], protocol, hostname, port = (self["appservice.protocol"], self["appservice.hostname"],
self["appservice.port"]) self["appservice.port"])
base["appservice.address"] = f"{protocol}://{hostname}:{port}" base["appservice.address"] = f"{protocol}://{hostname}:{port}"
else: if "appservice.debug" in self and "logging" not in self:
copy("appservice.address") level = "DEBUG" if self["appservice.debug"] else "INFO"
copy("appservice.hostname") base["logging.root.level"] = level
copy("appservice.port") base["logging.loggers.mau.level"] = level
copy("appservice.max_body_size") base["logging.loggers.telethon.level"] = level
copy("appservice.database")
copy("appservice.public.enabled") copy("appservice.public.enabled")
copy("appservice.public.prefix") copy("appservice.public.prefix")
@@ -73,16 +68,8 @@ class Config(BaseBridgeConfig):
if base["appservice.provisioning.shared_secret"] == "generate": if base["appservice.provisioning.shared_secret"] == "generate":
base["appservice.provisioning.shared_secret"] = self._new_token() base["appservice.provisioning.shared_secret"] = self._new_token()
copy("appservice.id")
copy("appservice.bot_username")
copy("appservice.bot_displayname")
copy("appservice.bot_avatar")
copy("appservice.community_id") copy("appservice.community_id")
copy("appservice.as_token")
copy("appservice.hs_token")
copy("metrics.enabled") copy("metrics.enabled")
copy("metrics.listen_port") copy("metrics.listen_port")
@@ -96,6 +83,7 @@ class Config(BaseBridgeConfig):
copy("bridge.displayname_preference") copy("bridge.displayname_preference")
copy("bridge.displayname_max_length") copy("bridge.displayname_max_length")
copy("bridge.allow_avatar_remove")
copy("bridge.max_initial_member_sync") copy("bridge.max_initial_member_sync")
copy("bridge.sync_channel_members") copy("bridge.sync_channel_members")
@@ -118,6 +106,11 @@ class Config(BaseBridgeConfig):
copy("bridge.federate_rooms") copy("bridge.federate_rooms")
copy("bridge.animated_sticker.target") copy("bridge.animated_sticker.target")
copy("bridge.animated_sticker.args") copy("bridge.animated_sticker.args")
copy("bridge.encryption.allow")
copy("bridge.encryption.default")
copy("bridge.private_chat_portal_meta")
copy("bridge.delivery_receipts")
copy("bridge.delivery_error_reports")
copy("bridge.initial_power_level_overrides.group") copy("bridge.initial_power_level_overrides.group")
copy("bridge.initial_power_level_overrides.user") copy("bridge.initial_power_level_overrides.user")
@@ -202,14 +195,6 @@ class Config(BaseBridgeConfig):
copy("telegram.proxy.username") copy("telegram.proxy.username")
copy("telegram.proxy.password") copy("telegram.proxy.password")
if "appservice.debug" in self and "logging" not in self:
level = "DEBUG" if self["appservice.debug"] else "INFO"
base["logging.root.level"] = level
base["logging.loggers.mau.level"] = level
base["logging.loggers.telethon.level"] = level
else:
copy("logging")
def _get_permissions(self, key: str) -> Permissions: def _get_permissions(self, key: str) -> Permissions:
level = self["bridge.permissions"].get(key, "") level = self["bridge.permissions"].get(key, "")
admin = level == "admin" admin = level == "admin"
+7
View File
@@ -24,6 +24,11 @@ from .puppet import Puppet
from .telegram_file import TelegramFile from .telegram_file import TelegramFile
from .user import User, UserPortal, Contact from .user import User, UserPortal, Contact
try:
from mautrix.bridge.db.nio_state_store import init as init_nio_db
except ImportError:
init_nio_db = None
def init(db_engine: Engine) -> None: def init(db_engine: Engine) -> None:
for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile, for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile,
@@ -32,3 +37,5 @@ def init(db_engine: Engine) -> None:
table.t = table.__table__ table.t = table.__table__
table.c = table.t.c table.c = table.t.c
table.column_names = table.c.keys() table.column_names = table.c.keys()
if init_nio_db:
init_nio_db(db_engine)
+10
View File
@@ -61,6 +61,16 @@ class Message(Base):
except StopIteration: except StopIteration:
return 0 return 0
@classmethod
def find_last(cls, mx_room: RoomID, tg_space: TelegramID) -> Optional['Message']:
return cls._one_or_none(cls.db.execute(
cls._make_simple_select(cls.c.mx_room == mx_room, cls.c.tg_space == tg_space)
.order_by(desc(cls.c.tgid)).limit(1)))
@classmethod
def delete_all(cls, mx_room: RoomID) -> None:
cls.db.execute(cls.t.delete().where(cls.c.mx_room == mx_room))
@classmethod @classmethod
def get_by_mxid(cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID def get_by_mxid(cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
) -> Optional['Message']: ) -> Optional['Message']:
+2 -1
View File
@@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional from typing import Optional
from sqlalchemy import Column, Integer, String, Boolean, Text, func from sqlalchemy import Column, Integer, String, Boolean, Text, func, sql
from mautrix.types import RoomID from mautrix.types import RoomID
from mautrix.util.db import Base from mautrix.util.db import Base
@@ -34,6 +34,7 @@ class Portal(Base):
# Matrix portal information # Matrix portal information
mxid: RoomID = Column(String, unique=True, nullable=True) mxid: RoomID = Column(String, unique=True, nullable=True)
encrypted: bool = Column(Boolean, nullable=False, server_default=sql.expression.false())
config: str = Column(Text, nullable=True) config: str = Column(Text, nullable=True)
+28 -5
View File
@@ -13,15 +13,37 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional from typing import Optional, cast, Dict, Any
from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean from sqlalchemy import (Column, ForeignKey, Integer, BigInteger, String, Boolean, Text,
TypeDecorator)
from sqlalchemy.engine.result import RowProxy from sqlalchemy.engine.result import RowProxy
from mautrix.types import ContentURI from mautrix.types import ContentURI, EncryptedFile
from mautrix.util.db import Base from mautrix.util.db import Base
class DBEncryptedFile(TypeDecorator):
impl = Text
@property
def python_type(self):
return EncryptedFile
def process_bind_param(self, value: EncryptedFile, dialect) -> Optional[str]:
if value is not None:
return value.json()
return None
def process_result_value(self, value: str, dialect) -> Optional[EncryptedFile]:
if value is not None:
return EncryptedFile.parse_json(value)
return None
def process_literal_param(self, value, dialect):
return value
class TelegramFile(Base): class TelegramFile(Base):
__tablename__ = "telegram_file" __tablename__ = "telegram_file"
@@ -33,12 +55,13 @@ class TelegramFile(Base):
size: Optional[int] = Column(Integer, nullable=True) size: Optional[int] = Column(Integer, nullable=True)
width: Optional[int] = Column(Integer, nullable=True) width: Optional[int] = Column(Integer, nullable=True)
height: Optional[int] = Column(Integer, nullable=True) height: Optional[int] = Column(Integer, nullable=True)
decryption_info: Optional[Dict[str, Any]] = Column(DBEncryptedFile, nullable=True)
thumbnail_id: str = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True) thumbnail_id: str = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
thumbnail: Optional['TelegramFile'] = None thumbnail: Optional['TelegramFile'] = None
@classmethod @classmethod
def scan(cls, row: RowProxy) -> 'TelegramFile': def scan(cls, row: RowProxy) -> 'TelegramFile':
telegram_file: TelegramFile = super().scan(row) telegram_file = cast(TelegramFile, super().scan(row))
if isinstance(telegram_file.thumbnail, str): if isinstance(telegram_file.thumbnail, str):
telegram_file.thumbnail = cls.get(telegram_file.thumbnail) telegram_file.thumbnail = cls.get(telegram_file.thumbnail)
return telegram_file return telegram_file
@@ -52,5 +75,5 @@ class TelegramFile(Base):
conn.execute(self.t.insert().values( conn.execute(self.t.insert().values(
id=self.id, mxc=self.mxc, mime_type=self.mime_type, id=self.id, mxc=self.mxc, mime_type=self.mime_type,
was_converted=self.was_converted, timestamp=self.timestamp, size=self.size, was_converted=self.was_converted, timestamp=self.timestamp, size=self.size,
width=self.width, height=self.height, width=self.width, height=self.height, decryption_info=self.decryption_info,
thumbnail=self.thumbnail.id if self.thumbnail else self.thumbnail_id)) thumbnail=self.thumbnail.id if self.thumbnail else self.thumbnail_id))
@@ -13,6 +13,9 @@ homeserver:
appservice: appservice:
# The address that the homeserver can use to connect to this appservice. # The address that the homeserver can use to connect to this appservice.
address: http://localhost:29317 address: http://localhost:29317
# When using https:// the TLS certificate and key files for the address.
tls_cert: false
tls_key: false
# The hostname and port where this appservice should listen. # The hostname and port where this appservice should listen.
hostname: 0.0.0.0 hostname: 0.0.0.0
@@ -62,6 +65,8 @@ appservice:
# Community ID for bridged users (changes registration file) and rooms. # Community ID for bridged users (changes registration file) and rooms.
# Must be created manually. # Must be created manually.
#
# Example: "+telegram:example.com". Set to false to disable.
community_id: false community_id: false
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify. # Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
@@ -116,6 +121,10 @@ bridge:
- phone number - phone number
# Maximum length of displayname # Maximum length of displayname
displayname_max_length: 100 displayname_max_length: 100
# Remove avatars from Telegram ghost users when removed on Telegram. This is disabled by default
# as there's no way to determine whether an avatar is removed or just hidden from some users. If
# you're on a single-user instance, this should be safe to enable.
allow_avatar_remove: false
# Maximum number of members to sync per portal when starting up. Other members will be # 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 # synced when they send messages. The maximum is 10000, after which the Telegram server
@@ -191,6 +200,25 @@ bridge:
height: 256 height: 256
background: "020202" # only for gif background: "020202" # only for gif
fps: 30 # only for webm fps: 30 # only for webm
# End-to-bridge encryption support options. These require matrix-nio to be installed with pip
# and login_shared_secret to be configured in order to get a device for the bridge bot.
#
# Additionally, https://github.com/matrix-org/synapse/pull/5758 is required if using a normal
# application service.
encryption:
# Allow encryption, work in group chat rooms with e2ee enabled
allow: false
# Default to encryption, force-enable encryption in all portals the bridge creates
# This will cause the bridge bot to be in private chats for the encryption to work properly.
default: false
# Whether or not to explicitly set the avatar and room name for private
# chat portal rooms. This will be implicitly enabled if encryption.default is true.
private_chat_portal_meta: false
# Whether or not the bridge should send a read receipt from the bridge bot when a message has
# been sent to Telegram.
delivery_receipts: false
# Whether or not delivery errors should be reported as messages in the Matrix room.
delivery_error_reports: false
# Overrides for base power levels. # Overrides for base power levels.
initial_power_level_overrides: initial_power_level_overrides:
@@ -409,7 +437,7 @@ logging:
mau: mau:
level: DEBUG level: DEBUG
telethon: telethon:
level: DEBUG level: INFO
aiohttp: aiohttp:
level: INFO level: INFO
root: root:
+66 -18
View File
@@ -13,14 +13,15 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Set, Tuple, Union, Iterable, TYPE_CHECKING from typing import Dict, Set, Tuple, Union, Iterable, List, TYPE_CHECKING
from mautrix.bridge import BaseMatrixHandler from mautrix.bridge import BaseMatrixHandler
from mautrix.types import (Event, EventType, RoomID, UserID, EventID, ReceiptEvent, ReceiptType, from mautrix.types import (Event, EventType, RoomID, UserID, EventID, ReceiptEvent, ReceiptType,
ReceiptEventContent, PresenceEvent, PresenceState, TypingEvent, ReceiptEventContent, PresenceEvent, PresenceState, TypingEvent,
MessageEvent, StateEvent, RedactionEvent, RoomNameStateEventContent, MessageEvent, StateEvent, RedactionEvent, RoomNameStateEventContent,
RoomAvatarStateEventContent, RoomTopicStateEventContent, RoomAvatarStateEventContent, RoomTopicStateEventContent,
MemberStateEventContent) MemberStateEventContent, EncryptedEvent, TextMessageEventContent,
MessageType)
from mautrix.errors import MatrixError from mautrix.errors import MatrixError
from . import user as u, portal as po, puppet as pu, commands as com from . import user as u, portal as po, puppet as pu, commands as com
@@ -47,8 +48,15 @@ class MatrixHandler(BaseMatrixHandler):
previously_typing: Dict[RoomID, Set[UserID]] previously_typing: Dict[RoomID, Set[UserID]]
def __init__(self, context: 'Context') -> None: def __init__(self, context: 'Context') -> None:
prefix, suffix = context.config["bridge.username_template"].format(userid=":").split(":")
homeserver = context.config["homeserver.domain"]
self.user_id_prefix = f"@{prefix}"
self.user_id_suffix = f"{suffix}:{homeserver}"
super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop, super(MatrixHandler, self).__init__(context.az, context.config, loop=context.loop,
command_processor=com.CommandProcessor(context)) command_processor=com.CommandProcessor(context),
bridge=context.bridge)
self.bot = context.bot self.bot = context.bot
self.previously_typing = {} self.previously_typing = {}
@@ -104,14 +112,38 @@ class MatrixHandler(BaseMatrixHandler):
except MatrixError: except MatrixError:
pass pass
portal.mxid = room_id portal.mxid = room_id
e2be_ok = None
if self.config["bridge.encryption.default"] and self.e2ee:
e2be_ok = await self.enable_dm_encryption(portal, members=members)
portal.save() portal.save()
inviter.register_portal(portal) inviter.register_portal(portal)
await intent.send_notice(room_id, "Portal to private chat created.") if e2be_ok is True:
evt_type, content = await self.e2ee.encrypt(
room_id, EventType.ROOM_MESSAGE,
TextMessageEventContent(msgtype=MessageType.NOTICE,
body="Portal to private chat created and end-to-bridge"
" encryption enabled."))
await intent.send_message_event(room_id, evt_type, content)
else:
message = "Portal to private chat created."
if e2be_ok is False:
message += "\n\nWarning: Failed to enable end-to-bridge encryption"
await intent.send_notice(room_id, message)
else: else:
await intent.join_room(room_id) await intent.join_room(room_id)
await intent.send_notice(room_id, "This puppet will remain inactive until a " await intent.send_notice(room_id, "This puppet will remain inactive until a "
"Telegram chat is created for this room.") "Telegram chat is created for this room.")
async def enable_dm_encryption(self, portal: po.Portal, members: List[UserID]) -> bool:
ok = await super().enable_dm_encryption(portal, members)
if ok:
try:
puppet = pu.Puppet.get(portal.tgid)
await portal.main_intent.set_room_name(portal.mxid, puppet.displayname)
except Exception:
self.log.warning(f"Failed to set room name for {portal.mxid}", exc_info=True)
return ok
async def send_welcome_message(self, room_id: RoomID, inviter: 'u.User') -> None: async def send_welcome_message(self, room_id: RoomID, inviter: 'u.User') -> None:
try: try:
is_management = len(await self.az.intent.get_room_members(room_id)) == 2 is_management = len(await self.az.intent.get_room_members(room_id)) == 2
@@ -156,7 +188,7 @@ class MatrixHandler(BaseMatrixHandler):
"messages for unauthenticated users.") "messages for unauthenticated users.")
return return
self.log.debug(f"{user} joined {room_id}") self.log.debug(f"{user.mxid} joined {room_id}")
if await user.is_logged_in() or portal.has_bot: if await user.is_logged_in() or portal.has_bot:
await portal.join_matrix(user, event_id) await portal.join_matrix(user, event_id)
@@ -246,7 +278,7 @@ class MatrixHandler(BaseMatrixHandler):
if not portal: if not portal:
return return
await portal.handle_matrix_deletion(sender, evt.redacts) await portal.handle_matrix_deletion(sender, evt.redacts, evt.event_id)
@staticmethod @staticmethod
async def handle_power_levels(evt: StateEvent) -> None: async def handle_power_levels(evt: StateEvent) -> None:
@@ -254,11 +286,12 @@ class MatrixHandler(BaseMatrixHandler):
sender = await u.User.get_by_mxid(evt.sender).ensure_started() sender = await u.User.get_by_mxid(evt.sender).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal: if await sender.has_full_access(allow_bot=True) and portal:
await portal.handle_matrix_power_levels(sender, evt.content.users, await portal.handle_matrix_power_levels(sender, evt.content.users,
evt.unsigned.prev_content.users) evt.unsigned.prev_content.users,
evt.event_id)
@staticmethod @staticmethod
async def handle_room_meta(evt_type: EventType, room_id: RoomID, sender_mxid: UserID, async def handle_room_meta(evt_type: EventType, room_id: RoomID, sender_mxid: UserID,
content: RoomMetaStateEventContent) -> None: content: RoomMetaStateEventContent, event_id: EventID) -> None:
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started() sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal: if await sender.has_full_access(allow_bot=True) and portal:
@@ -269,27 +302,29 @@ class MatrixHandler(BaseMatrixHandler):
}[evt_type] }[evt_type]
if not isinstance(content, content_type): if not isinstance(content, content_type):
return return
await handler(sender, content[content_key]) await handler(sender, content[content_key], event_id)
@staticmethod @staticmethod
async def handle_room_pin(room_id: RoomID, sender_mxid: UserID, async def handle_room_pin(room_id: RoomID, sender_mxid: UserID,
new_events: Set[str], old_events: Set[str]) -> None: new_events: Set[str], old_events: Set[str],
event_id: EventID) -> None:
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started() sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal: if await sender.has_full_access(allow_bot=True) and portal:
events = new_events - old_events events = new_events - old_events
if len(events) > 0: if len(events) > 0:
# New event pinned, set that as pinned in Telegram. # New event pinned, set that as pinned in Telegram.
await portal.handle_matrix_pin(sender, EventID(events.pop())) await portal.handle_matrix_pin(sender, EventID(events.pop()), event_id)
elif len(new_events) == 0: elif len(new_events) == 0:
# All pinned events removed, remove pinned event in Telegram. # All pinned events removed, remove pinned event in Telegram.
await portal.handle_matrix_pin(sender, None) await portal.handle_matrix_pin(sender, None, event_id)
@staticmethod @staticmethod
async def handle_room_upgrade(room_id: RoomID, sender: UserID, new_room_id: RoomID) -> None: async def handle_room_upgrade(room_id: RoomID, sender: UserID, new_room_id: RoomID,
event_id: EventID) -> None:
portal = po.Portal.get_by_mxid(room_id) portal = po.Portal.get_by_mxid(room_id)
if portal: if portal:
await portal.handle_matrix_upgrade(sender, new_room_id) await portal.handle_matrix_upgrade(sender, new_room_id, event_id)
async def handle_member_info_change(self, room_id: RoomID, user_id: UserID, async def handle_member_info_change(self, room_id: RoomID, user_id: UserID,
profile: MemberStateEventContent, profile: MemberStateEventContent,
@@ -355,8 +390,13 @@ class MatrixHandler(BaseMatrixHandler):
self.previously_typing[room_id] = now_typing self.previously_typing[room_id] = now_typing
def filter_matrix_event(self, evt: Event) -> bool: def filter_matrix_event(self, evt: Event) -> bool:
if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent)): if not isinstance(evt, (RedactionEvent, MessageEvent, StateEvent, EncryptedEvent)):
return True return True
if evt.content.get("net.maunium.telegram.puppet", False):
puppet = pu.Puppet.get_by_custom_mxid(evt.sender)
if puppet:
self.log.debug("Ignoring puppet-sent event %s", evt.event_id)
return True
return evt.sender and (evt.sender == self.az.bot_mxid return evt.sender and (evt.sender == self.az.bot_mxid
or pu.Puppet.get_id_from_mxid(evt.sender) is not None) or pu.Puppet.get_id_from_mxid(evt.sender) is not None)
@@ -377,16 +417,24 @@ class MatrixHandler(BaseMatrixHandler):
if evt.type == EventType.ROOM_POWER_LEVELS: if evt.type == EventType.ROOM_POWER_LEVELS:
await self.handle_power_levels(evt) await self.handle_power_levels(evt)
elif evt.type in (EventType.ROOM_NAME, EventType.ROOM_AVATAR, EventType.ROOM_TOPIC): 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) await self.handle_room_meta(evt.type, evt.room_id, evt.sender, evt.content,
evt.event_id)
elif evt.type == EventType.ROOM_PINNED_EVENTS: elif evt.type == EventType.ROOM_PINNED_EVENTS:
new_events = set(evt.content.pinned) new_events = set(evt.content.pinned)
try: try:
old_events = set(evt.unsigned.prev_content.pinned) old_events = set(evt.unsigned.prev_content.pinned)
except (KeyError, ValueError, TypeError, AttributeError): except (KeyError, ValueError, TypeError, AttributeError):
old_events = set() old_events = set()
await self.handle_room_pin(evt.room_id, evt.sender, new_events, old_events) await self.handle_room_pin(evt.room_id, evt.sender, new_events, old_events,
evt.event_id)
elif evt.type == EventType.ROOM_TOMBSTONE: elif evt.type == EventType.ROOM_TOMBSTONE:
await self.handle_room_upgrade(evt.room_id, evt.sender, evt.content.replacement_room) await self.handle_room_upgrade(evt.room_id, evt.sender, evt.content.replacement_room,
evt.event_id)
elif evt.type == EventType.ROOM_ENCRYPTION:
portal = po.Portal.get_by_mxid(evt.room_id)
if portal:
portal.encrypted = True
portal.save()
async def log_event_handle_duration(self, evt: Event, duration: float) -> None: async def log_event_handle_duration(self, evt: Event, duration: float) -> None:
if EVENT_TIME: if EVENT_TIME:
+3 -3
View File
@@ -1,8 +1,8 @@
from typing import Union from typing import Union
from .base import BasePortal from .base import BasePortal
from .portal_matrix import PortalMatrix from .matrix import PortalMatrix
from .portal_metadata import PortalMetadata from .metadata import PortalMetadata
from .portal_telegram import PortalTelegram from .telegram import PortalTelegram
from ..context import Context from ..context import Context
Portal = Union[BasePortal, PortalMatrix, PortalMetadata, PortalTelegram] Portal = Union[BasePortal, PortalMatrix, PortalMetadata, PortalTelegram]
+51 -21
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2020 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # 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 typing import Awaitable, Dict, List, Optional, Tuple, Union, Any, Set, TYPE_CHECKING
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import logging import logging
@@ -30,12 +30,14 @@ from telethon.tl.types import (Channel, ChannelFull, Chat, ChatFull, ChatInviteE
from mautrix.errors import MatrixRequestError, IntentError from mautrix.errors import MatrixRequestError, IntentError
from mautrix.appservice import AppService, IntentAPI from mautrix.appservice import AppService, IntentAPI
from mautrix.types import RoomID, RoomAlias, UserID, EventType, PowerLevelStateEventContent from mautrix.types import (RoomID, RoomAlias, UserID, EventID, EventType, MessageEventContent,
PowerLevelStateEventContent)
from mautrix.util.simple_template import SimpleTemplate from mautrix.util.simple_template import SimpleTemplate
from mautrix.util.logging import TraceLogger
from ..types import TelegramID from ..types import TelegramID
from ..context import Context from ..context import Context
from ..db import Portal as DBPortal from ..db import Portal as DBPortal, Message as DBMessage
from .. import puppet as p, user as u, util from .. import puppet as p, user as u, util
from .deduplication import PortalDedup from .deduplication import PortalDedup
from .send_lock import PortalSendLock from .send_lock import PortalSendLock
@@ -44,6 +46,7 @@ if TYPE_CHECKING:
from ..bot import Bot from ..bot import Bot
from ..abstract_user import AbstractUser from ..abstract_user import AbstractUser
from ..config import Config from ..config import Config
from ..matrix import MatrixHandler
from . import Portal from . import Portal
TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant] TypeParticipant = Union[TypeChatParticipant, TypeChannelParticipant]
@@ -54,10 +57,11 @@ config: Optional['Config'] = None
class BasePortal(ABC): class BasePortal(ABC):
base_log: logging.Logger = logging.getLogger("mau.portal") base_log: TraceLogger = logging.getLogger("mau.portal")
az: AppService = None az: AppService = None
bot: 'Bot' = None bot: 'Bot' = None
loop: asyncio.AbstractEventLoop = None loop: asyncio.AbstractEventLoop = None
matrix: 'MatrixHandler' = None
# Config cache # Config cache
filter_mode: str = None filter_mode: str = None
@@ -67,6 +71,7 @@ class BasePortal(ABC):
sync_channel_members: bool = True sync_channel_members: bool = True
sync_matrix_state: bool = True sync_matrix_state: bool = True
public_portals: bool = False public_portals: bool = False
private_chat_portal_meta: bool = False
alias_template: SimpleTemplate[str] alias_template: SimpleTemplate[str]
hs_domain: str hs_domain: str
@@ -85,8 +90,11 @@ class BasePortal(ABC):
about: Optional[str] about: Optional[str]
photo_id: Optional[str] photo_id: Optional[str]
local_config: Dict[str, Any] local_config: Dict[str, Any]
encrypted: bool
deleted: bool deleted: bool
log: logging.Logger backfilling: bool
backfill_leave: Optional[Set[IntentAPI]]
log: TraceLogger
alias: Optional[RoomAlias] alias: Optional[RoomAlias]
@@ -100,7 +108,8 @@ class BasePortal(ABC):
mxid: Optional[RoomID] = None, username: Optional[str] = None, mxid: Optional[RoomID] = None, username: Optional[str] = None,
megagroup: Optional[bool] = False, title: Optional[str] = None, megagroup: Optional[bool] = False, title: Optional[str] = None,
about: Optional[str] = None, photo_id: Optional[str] = None, about: Optional[str] = None, photo_id: Optional[str] = None,
local_config: Optional[str] = None, db_instance: DBPortal = None) -> None: local_config: Optional[str] = None, encrypted: Optional[bool] = False,
db_instance: DBPortal = None) -> None:
self.mxid = mxid self.mxid = mxid
self.tgid = tgid self.tgid = tgid
self.tg_receiver = tg_receiver or tgid self.tg_receiver = tg_receiver or tgid
@@ -111,10 +120,13 @@ class BasePortal(ABC):
self.about = about self.about = about
self.photo_id = photo_id self.photo_id = photo_id
self.local_config = json.loads(local_config or "{}") self.local_config = json.loads(local_config or "{}")
self.encrypted = encrypted
self._db_instance = db_instance self._db_instance = db_instance
self._main_intent = None self._main_intent = None
self.deleted = False self.deleted = False
self.log = self.base_log.getChild(self.tgid_log if self.tgid else self.mxid) self.log = self.base_log.getChild(self.tgid_log if self.tgid else self.mxid)
self.backfilling = False
self.backfill_leave = None
self.dedup = PortalDedup(self) self.dedup = PortalDedup(self)
self.send_lock = PortalSendLock() self.send_lock = PortalSendLock()
@@ -124,7 +136,7 @@ class BasePortal(ABC):
if mxid: if mxid:
self.by_mxid[mxid] = self self.by_mxid[mxid] = self
# region Propegrties # region Properties
@property @property
def tgid_full(self) -> Tuple[TelegramID, TelegramID]: def tgid_full(self) -> Tuple[TelegramID, TelegramID]:
@@ -222,7 +234,7 @@ class BasePortal(ABC):
return False return False
evt_type = EventType.find(f"net.maunium.telegram.{event}") evt_type = EventType.find(f"net.maunium.telegram.{event}")
evt_type.t_class = EventType.Class.STATE evt_type.t_class = EventType.Class.STATE
return self.main_intent.state_store.has_power_level(self.mxid, user.mxid, event=evt_type) return self.main_intent.state_store.has_power_level(self.mxid, user.mxid, evt_type)
def get_input_entity(self, user: 'AbstractUser' def get_input_entity(self, user: 'AbstractUser'
) -> Awaitable[Union[TypeInputPeer, TypeInputChannel]]: ) -> Awaitable[Union[TypeInputPeer, TypeInputChannel]]:
@@ -233,8 +245,7 @@ class BasePortal(ABC):
return await user.client.get_entity(self.peer) return await user.client.get_entity(self.peer)
except ValueError: except ValueError:
if user.is_bot: if user.is_bot:
self.log.warning(f"Could not find entity with bot {user.tgid}. " self.log.warning(f"Could not find entity with bot {user.tgid}. Failing...")
"Failing...")
raise raise
self.log.warning(f"Could not find entity with user {user.tgid}. " self.log.warning(f"Could not find entity with user {user.tgid}. "
"falling back to get_dialogs.") "falling back to get_dialogs.")
@@ -273,8 +284,8 @@ class BasePortal(ABC):
authenticated.append(user) authenticated.append(user)
return authenticated return authenticated
@staticmethod @classmethod
async def cleanup_room(intent: IntentAPI, room_id: RoomID, message: str, async def cleanup_room(cls, intent: IntentAPI, room_id: RoomID, message: str,
puppets_only: bool = False) -> None: puppets_only: bool = False) -> None:
try: try:
members = await intent.get_room_members(room_id) members = await intent.get_room_members(room_id)
@@ -293,7 +304,7 @@ class BasePortal(ABC):
try: try:
await intent.leave_room(room_id) await intent.leave_room(room_id)
except (MatrixRequestError, IntentError): except (MatrixRequestError, IntentError):
self.log.warning("Failed to leave room when cleaning up room", exc_info=True) cls.log.warning(f"Failed to leave room {room_id} when cleaning up room", exc_info=True)
async def cleanup_portal(self, message: str, puppets_only: bool = False) -> None: async def cleanup_portal(self, message: str, puppets_only: bool = False) -> None:
if self.username: if self.username:
@@ -324,12 +335,12 @@ class BasePortal(ABC):
return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type, return DBPortal(tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
mxid=self.mxid, username=self.username, megagroup=self.megagroup, mxid=self.mxid, username=self.username, megagroup=self.megagroup,
title=self.title, about=self.about, photo_id=self.photo_id, title=self.title, about=self.about, photo_id=self.photo_id,
config=json.dumps(self.local_config)) config=json.dumps(self.local_config), encrypted=self.encrypted)
def save(self) -> None: def save(self) -> None:
self.db_instance.edit(mxid=self.mxid, username=self.username, title=self.title, self.db_instance.edit(mxid=self.mxid, username=self.username, title=self.title,
about=self.about, photo_id=self.photo_id, megagroup=self.megagroup, about=self.about, photo_id=self.photo_id, megagroup=self.megagroup,
config=json.dumps(self.local_config)) config=json.dumps(self.local_config), encrypted=self.encrypted)
def delete(self) -> None: def delete(self) -> None:
try: try:
@@ -342,15 +353,16 @@ class BasePortal(ABC):
pass pass
if self._db_instance: if self._db_instance:
self._db_instance.delete() self._db_instance.delete()
DBMessage.delete_all(self.mxid)
self.deleted = True self.deleted = True
@classmethod @classmethod
def from_db(cls, db_portal: DBPortal) -> 'Portal': def from_db(cls, db_portal: DBPortal) -> 'Portal':
return cls(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver, return cls(tgid=db_portal.tgid, tg_receiver=db_portal.tg_receiver,
peer_type=db_portal.peer_type, mxid=db_portal.mxid, peer_type=db_portal.peer_type, mxid=db_portal.mxid, username=db_portal.username,
username=db_portal.username, megagroup=db_portal.megagroup, megagroup=db_portal.megagroup, title=db_portal.title, about=db_portal.about,
title=db_portal.title, about=db_portal.about, photo_id=db_portal.photo_id, photo_id=db_portal.photo_id, local_config=db_portal.config,
local_config=db_portal.config, db_instance=db_portal) encrypted=db_portal.encrypted, db_instance=db_portal)
# endregion # endregion
# region Class instance lookup # region Class instance lookup
@@ -392,6 +404,8 @@ class BasePortal(ABC):
@classmethod @classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: Optional[TelegramID] = None, def get_by_tgid(cls, tgid: TelegramID, tg_receiver: Optional[TelegramID] = None,
peer_type: str = None) -> Optional['Portal']: peer_type: str = None) -> Optional['Portal']:
if peer_type == "user" and tg_receiver is None:
raise ValueError("tg_receiver is required when peer_type is \"user\"")
tg_receiver = tg_receiver or tgid tg_receiver = tg_receiver or tgid
tgid_full = (tgid, tg_receiver) tgid_full = (tgid, tg_receiver)
try: try:
@@ -447,6 +461,15 @@ class BasePortal(ABC):
type_name if create else None) type_name if create else None)
# endregion # endregion
async def _send_message(self, intent: IntentAPI, content: MessageEventContent,
event_type: EventType = EventType.ROOM_MESSAGE, **kwargs) -> EventID:
if self.encrypted and self.matrix.e2ee:
if intent.api.is_real_user:
content[intent.api.real_user_content_key] = True
event_type, content = await self.matrix.e2ee.encrypt(self.mxid, event_type, content)
return await intent.send_message_event(self.mxid, event_type, content, **kwargs)
# region Abstract methods (cross-called in matrix/metadata/telegram classes) # region Abstract methods (cross-called in matrix/metadata/telegram classes)
@abstractmethod @abstractmethod
@@ -488,7 +511,12 @@ class BasePortal(ABC):
@abstractmethod @abstractmethod
def handle_matrix_power_levels(self, sender: 'u.User', new_levels: Dict[UserID, int], def handle_matrix_power_levels(self, sender: 'u.User', new_levels: Dict[UserID, int],
old_levels: Dict[UserID, int]) -> Awaitable[None]: old_levels: Dict[UserID, int], event_id: Optional[EventID]
) -> Awaitable[None]:
pass
@abstractmethod
def backfill(self, source: 'AbstractUser') -> Awaitable[None]:
pass pass
# endregion # endregion
@@ -497,10 +525,12 @@ class BasePortal(ABC):
def init(context: Context) -> None: def init(context: Context) -> None:
global config global config
BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core BasePortal.az, config, BasePortal.loop, BasePortal.bot = context.core
BasePortal.matrix = context.mx
BasePortal.max_initial_member_sync = config["bridge.max_initial_member_sync"] BasePortal.max_initial_member_sync = config["bridge.max_initial_member_sync"]
BasePortal.sync_channel_members = config["bridge.sync_channel_members"] BasePortal.sync_channel_members = config["bridge.sync_channel_members"]
BasePortal.sync_matrix_state = config["bridge.sync_matrix_state"] BasePortal.sync_matrix_state = config["bridge.sync_matrix_state"]
BasePortal.public_portals = config["bridge.public_portals"] BasePortal.public_portals = config["bridge.public_portals"]
BasePortal.private_chat_portal_meta = config["bridge.private_chat_portal_meta"]
BasePortal.filter_mode = config["bridge.filter.mode"] BasePortal.filter_mode = config["bridge.filter.mode"]
BasePortal.filter_list = config["bridge.filter.list"] BasePortal.filter_list = config["bridge.filter.list"]
BasePortal.hs_domain = config["homeserver.domain"] BasePortal.hs_domain = config["homeserver.domain"]
+64 -19
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2020 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@@ -25,7 +25,8 @@ from telethon.tl.functions.messages import (EditChatPhotoRequest, EditChatTitleR
EditChatAboutRequest) EditChatAboutRequest)
from telethon.tl.functions.channels import EditPhotoRequest, EditTitleRequest, JoinChannelRequest from telethon.tl.functions.channels import EditPhotoRequest, EditTitleRequest, JoinChannelRequest
from telethon.errors import (ChatNotModifiedError, PhotoExtInvalidError, from telethon.errors import (ChatNotModifiedError, PhotoExtInvalidError,
PhotoInvalidDimensionsError, PhotoSaveFileInvalidError) PhotoInvalidDimensionsError, PhotoSaveFileInvalidError,
RPCError)
from telethon.tl.patched import Message, MessageService from telethon.tl.patched import Message, MessageService
from telethon.tl.types import ( from telethon.tl.types import (
DocumentAttributeFilename, DocumentAttributeImageSize, GeoPoint, DocumentAttributeFilename, DocumentAttributeImageSize, GeoPoint,
@@ -50,6 +51,11 @@ if TYPE_CHECKING:
from ..tgclient import MautrixTelegramClient from ..tgclient import MautrixTelegramClient
from ..config import Config from ..config import Config
try:
from nio.crypto import decrypt_attachment
except ImportError:
decrypt_attachment = None
TypeMessage = Union[Message, MessageService] TypeMessage = Union[Message, MessageService]
config: Optional['Config'] = None config: Optional['Config'] = None
@@ -223,6 +229,13 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
message, entities = None, None message, entities = None, None
return message, entities return message, entities
async def _send_delivery_receipt(self, event_id: EventID) -> None:
if event_id and config["bridge.delivery_receipts"]:
try:
await self.az.intent.mark_read(self.mxid, event_id)
except Exception:
self.log.exception("Failed to send delivery receipt for %s", event_id)
async def _handle_matrix_text(self, sender_id: TelegramID, event_id: EventID, async def _handle_matrix_text(self, sender_id: TelegramID, event_id: EventID,
space: TelegramID, client: 'MautrixTelegramClient', space: TelegramID, client: 'MautrixTelegramClient',
content: TextMessageEventContent, reply_to: TelegramID) -> None: content: TextMessageEventContent, reply_to: TelegramID) -> None:
@@ -240,6 +253,7 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
parse_mode=self._matrix_event_to_entities, parse_mode=self._matrix_event_to_entities,
link_preview=lp) link_preview=lp)
self._add_telegram_message_to_db(event_id, space, 0, response) self._add_telegram_message_to_db(event_id, space, 0, response)
await self._send_delivery_receipt(event_id)
async def _handle_matrix_file(self, sender_id: TelegramID, event_id: EventID, async def _handle_matrix_file(self, sender_id: TelegramID, event_id: EventID,
space: TelegramID, client: 'MautrixTelegramClient', space: TelegramID, client: 'MautrixTelegramClient',
@@ -250,11 +264,20 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
file_name = content["net.maunium.telegram.internal.filename"] file_name = content["net.maunium.telegram.internal.filename"]
max_image_size = config["bridge.image_as_file_size"] * 1000 ** 2 max_image_size = config["bridge.image_as_file_size"] * 1000 ** 2
if config["bridge.parallel_file_transfer"]: if config["bridge.parallel_file_transfer"] and content.url:
file_handle, file_size = await parallel_transfer_to_telegram(client, self.main_intent, file_handle, file_size = await parallel_transfer_to_telegram(client, self.main_intent,
content.url, sender_id) content.url, sender_id)
else: else:
file = await self.main_intent.download_media(content.url) if content.file:
if not decrypt_attachment:
self.log.warning(f"Can't bridge encrypted media event {event_id}:"
" matrix-nio not installed")
return
file = await self.main_intent.download_media(content.file.url)
file = decrypt_attachment(file, content.file.key.key,
content.file.hashes.get("sha256"), content.file.iv)
else:
file = await self.main_intent.download_media(content.url)
if content.msgtype == MessageType.STICKER: if content.msgtype == MessageType.STICKER:
if mime != "image/gif": if mime != "image/gif":
@@ -293,6 +316,7 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
response = await client.send_media(self.peer, media, reply_to=reply_to, response = await client.send_media(self.peer, media, reply_to=reply_to,
caption=caption, entities=entities) caption=caption, entities=entities)
self._add_telegram_message_to_db(event_id, space, 0, response) self._add_telegram_message_to_db(event_id, space, 0, response)
await self._send_delivery_receipt(event_id)
async def _matrix_document_edit(self, client: 'MautrixTelegramClient', async def _matrix_document_edit(self, client: 'MautrixTelegramClient',
content: MessageEventContent, space: TelegramID, content: MessageEventContent, space: TelegramID,
@@ -303,6 +327,7 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
response = await client.edit_message(self.peer, orig_msg.tgid, response = await client.edit_message(self.peer, orig_msg.tgid,
caption, file=media) caption, file=media)
self._add_telegram_message_to_db(event_id, space, -1, response) self._add_telegram_message_to_db(event_id, space, -1, response)
await self._send_delivery_receipt(event_id)
return True return True
return False return False
@@ -325,10 +350,11 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
response = await client.send_media(self.peer, media, reply_to=reply_to, response = await client.send_media(self.peer, media, reply_to=reply_to,
caption=caption, entities=entities) caption=caption, entities=entities)
self._add_telegram_message_to_db(event_id, space, 0, response) self._add_telegram_message_to_db(event_id, space, 0, response)
await self._send_delivery_receipt(event_id)
def _add_telegram_message_to_db(self, event_id: EventID, space: TelegramID, def _add_telegram_message_to_db(self, event_id: EventID, space: TelegramID,
edit_index: int, response: TypeMessage) -> None: edit_index: int, response: TypeMessage) -> None:
self.log.debug("Handled Matrix message: %s", response) self.log.trace("Handled Matrix message: %s", response)
self.dedup.check(response, (event_id, space), force_hash=edit_index != 0) self.dedup.check(response, (event_id, space), force_hash=edit_index != 0)
if edit_index < 0: if edit_index < 0:
prev_edit = DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1) prev_edit = DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1)
@@ -340,17 +366,26 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
mxid=event_id, mxid=event_id,
edit_index=edit_index).insert() edit_index=edit_index).insert()
async def _send_bridge_error(self, msg: str) -> None:
if config["bridge.delivery_error_reports"]:
await self._send_message(self.main_intent,
TextMessageEventContent(msgtype=MessageType.NOTICE, body=msg))
async def handle_matrix_message(self, sender: 'u.User', content: MessageEventContent, async def handle_matrix_message(self, sender: 'u.User', content: MessageEventContent,
event_id: EventID) -> None: event_id: EventID) -> None:
try:
await self._handle_matrix_message(sender, content, event_id)
except RPCError as e:
if config["bridge.delivery_error_reports"]:
await self._send_bridge_error(f"\u26a0 Your message may not have been bridged: {e}")
raise
async def _handle_matrix_message(self, sender: 'u.User', content: MessageEventContent,
event_id: EventID) -> None:
if not content.body or not content.msgtype: if not content.body or not content.msgtype:
self.log.debug(f"Ignoring message {event_id} in {self.mxid} without body or msgtype") self.log.debug(f"Ignoring message {event_id} in {self.mxid} without body or msgtype")
return 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) logged_in = not await sender.needs_relaybot(self)
client = sender.client if logged_in else self.bot.client client = sender.client if logged_in else self.bot.client
sender_id = sender.tgid if logged_in else self.bot.tgid sender_id = sender.tgid if logged_in else self.bot.tgid
@@ -389,10 +424,10 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
await self._handle_matrix_file(sender_id, event_id, space, client, content, reply_to, await self._handle_matrix_file(sender_id, event_id, space, client, content, reply_to,
caption_content) caption_content)
else: else:
self.log.debug(f"Unhandled Matrix event: {content}") self.log.trace("Unhandled Matrix event: %s", content)
async def handle_matrix_pin(self, sender: 'u.User', async def handle_matrix_pin(self, sender: 'u.User', pinned_message: Optional[EventID],
pinned_message: Optional[EventID]) -> None: pin_event_id: EventID) -> None:
if self.peer_type != "chat" and self.peer_type != "channel": if self.peer_type != "chat" and self.peer_type != "channel":
return return
try: try:
@@ -405,10 +440,12 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
self.log.warning(f"Could not find pinned {pinned_message} in {self.mxid}") self.log.warning(f"Could not find pinned {pinned_message} in {self.mxid}")
return return
await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=message.tgid)) await sender.client(UpdatePinnedMessageRequest(peer=self.peer, id=message.tgid))
await self._send_delivery_receipt(pin_event_id)
except ChatNotModifiedError: except ChatNotModifiedError:
pass pass
async def handle_matrix_deletion(self, deleter: 'u.User', event_id: EventID) -> None: async def handle_matrix_deletion(self, deleter: 'u.User', event_id: EventID,
redaction_event_id: EventID) -> None:
real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot 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 space = self.tgid if self.peer_type == "channel" else real_deleter.tgid
message = DBMessage.get_by_mxid(event_id, self.mxid, space) message = DBMessage.get_by_mxid(event_id, self.mxid, space)
@@ -416,6 +453,7 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
return return
if message.edit_index == 0: if message.edit_index == 0:
await real_deleter.client.delete_messages(self.peer, [message.tgid]) await real_deleter.client.delete_messages(self.peer, [message.tgid])
await self._send_delivery_receipt(redaction_event_id)
else: else:
self.log.debug(f"Ignoring deletion of edit event {message.mxid} in {message.mx_room}") self.log.debug(f"Ignoring deletion of edit event {message.mxid} in {message.mx_room}")
@@ -430,7 +468,8 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
pin_messages=moderator, add_admins=admin) pin_messages=moderator, add_admins=admin)
async def handle_matrix_power_levels(self, sender: 'u.User', new_users: Dict[UserID, int], async def handle_matrix_power_levels(self, sender: 'u.User', new_users: Dict[UserID, int],
old_users: Dict[UserID, int]) -> None: old_users: Dict[UserID, int], event_id: Optional[EventID]
) -> None:
# TODO handle all power level changes and bridge exact admin rights to supergroups/channels # TODO handle all power level changes and bridge exact admin rights to supergroups/channels
for user, level in new_users.items(): for user, level in new_users.items():
if not user or user == self.main_intent.mxid or user == sender.mxid: if not user or user == self.main_intent.mxid or user == sender.mxid:
@@ -446,15 +485,16 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
if user not in old_users or level != old_users[user]: if user not in old_users or level != old_users[user]:
await self._update_telegram_power_level(sender, user_id, level) await self._update_telegram_power_level(sender, user_id, level)
async def handle_matrix_about(self, sender: 'u.User', about: str) -> None: async def handle_matrix_about(self, sender: 'u.User', about: str, event_id: EventID) -> None:
if self.peer_type not in ("chat", "channel"): if self.peer_type not in ("chat", "channel"):
return return
peer = await self.get_input_entity(sender) peer = await self.get_input_entity(sender)
await sender.client(EditChatAboutRequest(peer=peer, about=about)) await sender.client(EditChatAboutRequest(peer=peer, about=about))
self.about = about self.about = about
self.save() self.save()
await self._send_delivery_receipt(event_id)
async def handle_matrix_title(self, sender: 'u.User', title: str) -> None: async def handle_matrix_title(self, sender: 'u.User', title: str, event_id: EventID) -> None:
if self.peer_type not in ("chat", "channel"): if self.peer_type not in ("chat", "channel"):
return return
@@ -466,8 +506,10 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
self.dedup.register_outgoing_actions(response) self.dedup.register_outgoing_actions(response)
self.title = title self.title = title
self.save() self.save()
await self._send_delivery_receipt(event_id)
async def handle_matrix_avatar(self, sender: 'u.User', url: ContentURI) -> None: async def handle_matrix_avatar(self, sender: 'u.User', url: ContentURI, event_id: EventID
) -> None:
if self.peer_type not in ("chat", "channel"): if self.peer_type not in ("chat", "channel"):
# Invalid peer type # Invalid peer type
return return
@@ -493,8 +535,10 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
self.photo_id = f"{size.location.volume_id}-{size.location.local_id}" self.photo_id = f"{size.location.volume_id}-{size.location.local_id}"
self.save() self.save()
break break
await self._send_delivery_receipt(event_id)
async def handle_matrix_upgrade(self, sender: UserID, new_room: RoomID) -> None: async def handle_matrix_upgrade(self, sender: UserID, new_room: RoomID, event_id: EventID
) -> None:
_, server = self.main_intent.parse_user_id(sender) _, server = self.main_intent.parse_user_id(sender)
old_room = self.mxid old_room = self.mxid
self.migrate_and_save_matrix(new_room) self.migrate_and_save_matrix(new_room)
@@ -521,6 +565,7 @@ class PortalMatrix(BasePortal, MautrixBasePortal, ABC):
return return
await self.update_matrix_room(user, entity, direct=self.peer_type == "user") 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}") self.log.info(f"{sender} upgraded room from {old_room} to {self.mxid}")
await self._send_delivery_receipt(event_id)
def migrate_and_save_matrix(self, new_id: RoomID) -> None: def migrate_and_save_matrix(self, new_id: RoomID) -> None:
try: try:
+90 -30
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2020 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import List, Optional, Tuple, Union, Callable, TYPE_CHECKING from typing import List, Optional, Tuple, Union, Callable, Awaitable, TYPE_CHECKING
from abc import ABC from abc import ABC
import asyncio import asyncio
@@ -26,12 +26,13 @@ from telethon.tl.types import (
Channel, ChatBannedRights, ChannelParticipantsRecent, ChannelParticipantsSearch, ChatPhoto, Channel, ChatBannedRights, ChannelParticipantsRecent, ChannelParticipantsSearch, ChatPhoto,
PhotoEmpty, InputChannel, InputUser, ChatPhotoEmpty, PeerUser, Photo, TypeChat, TypeInputPeer, PhotoEmpty, InputChannel, InputUser, ChatPhotoEmpty, PeerUser, Photo, TypeChat, TypeInputPeer,
TypeUser, User, InputPeerPhotoFileLocation, ChatParticipantAdmin, ChannelParticipantAdmin, TypeUser, User, InputPeerPhotoFileLocation, ChatParticipantAdmin, ChannelParticipantAdmin,
ChatParticipantCreator, ChannelParticipantCreator) ChatParticipantCreator, ChannelParticipantCreator, UserProfilePhoto, UserProfilePhotoEmpty)
from mautrix.errors import MForbidden from mautrix.errors import MForbidden
from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership, Member, from mautrix.types import (RoomID, UserID, RoomCreatePreset, EventType, Membership, Member,
PowerLevelStateEventContent, RoomAlias) PowerLevelStateEventContent, RoomTopicStateEventContent,
from mautrix.appservice import IntentAPI RoomNameStateEventContent, RoomAvatarStateEventContent,
StateEventContent)
from ..types import TelegramID from ..types import TelegramID
from ..context import Context from ..context import Context
@@ -155,7 +156,7 @@ class PortalMetadata(BasePortal, ABC):
if levels.get_user_level(self.main_intent.mxid) == 100: if levels.get_user_level(self.main_intent.mxid) == 100:
levels = self._get_base_power_levels(levels, entity) levels = self._get_base_power_levels(levels, entity)
await self.main_intent.set_power_levels(self.mxid, levels) await self.main_intent.set_power_levels(self.mxid, levels)
await self.handle_matrix_power_levels(source, levels.users, {}) await self.handle_matrix_power_levels(source, levels.users, {}, None)
async def invite_telegram(self, source: 'u.User', async def invite_telegram(self, source: 'u.User',
puppet: Union[p.Puppet, 'AbstractUser']) -> None: puppet: Union[p.Puppet, 'AbstractUser']) -> None:
@@ -218,10 +219,17 @@ class PortalMetadata(BasePortal, ABC):
puppet = p.Puppet.get(self.tgid) puppet = p.Puppet.get(self.tgid)
await puppet.update_info(user, entity) await puppet.update_info(user, entity)
await puppet.intent_for(self).join_room(self.mxid) await puppet.intent_for(self).join_room(self.mxid)
if self.encrypted or self.private_chat_portal_meta:
# The bridge bot needs to join for e2ee, but that messes up the default name
# generation. If/when canonical DMs happen, this might not be necessary anymore.
changed = await self._update_title(puppet.displayname)
changed = await self._update_avatar(user, entity.photo) or changed
if changed:
self.save()
if self.sync_matrix_state: if self.sync_matrix_state:
await self.sync_matrix_members() await self.sync_matrix_members()
async def create_matrix_room(self, user: 'AbstractUser', entity: TypeChat = None, async def create_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User] = None,
invites: InviteList = None, update_if_exists: bool = True, invites: InviteList = None, update_if_exists: bool = True,
synchronous: bool = False) -> Optional[str]: synchronous: bool = False) -> Optional[str]:
if self.mxid: if self.mxid:
@@ -245,10 +253,13 @@ class PortalMetadata(BasePortal, ABC):
except Exception: except Exception:
self.log.exception("Fatal error creating Matrix room") self.log.exception("Fatal error creating Matrix room")
async def _create_matrix_room(self, user: 'AbstractUser', entity: TypeChat, invites: InviteList async def _create_matrix_room(self, user: 'AbstractUser', entity: Union[TypeChat, User],
) -> Optional[RoomID]: invites: InviteList) -> Optional[RoomID]:
direct = self.peer_type == "user" direct = self.peer_type == "user"
if invites is None:
invites = []
if self.mxid: if self.mxid:
return self.mxid return self.mxid
@@ -257,7 +268,7 @@ class PortalMetadata(BasePortal, ABC):
if not entity: if not entity:
entity = await self.get_entity(user) entity = await self.get_entity(user)
self.log.debug(f"Fetched data: {entity}") self.log.trace("Fetched data: %s", entity)
self.log.debug("Creating room") self.log.debug("Creating room")
@@ -271,6 +282,8 @@ class PortalMetadata(BasePortal, ABC):
self.about = "Your Telegram cloud storage chat" self.about = "Your Telegram cloud storage chat"
puppet = p.Puppet.get(self.tgid) if direct else None puppet = p.Puppet.get(self.tgid) if direct else None
if puppet:
await puppet.update_info(user, entity)
self._main_intent = puppet.intent_for(self) if direct else self.az.intent self._main_intent = puppet.intent_for(self) if direct else self.az.intent
if self.peer_type == "channel": if self.peer_type == "channel":
@@ -304,10 +317,41 @@ class PortalMetadata(BasePortal, ABC):
for invite in invites: for invite in invites:
power_levels.users.setdefault(invite, 100) power_levels.users.setdefault(invite, 100)
self.title = puppet.displayname self.title = puppet.displayname
bridge_info = {
"bridgebot": self.az.bot_mxid,
"creator": self.main_intent.mxid,
"protocol": {
"id": "telegram",
"displayname": "Telegram",
"avatar_url": config["appservice.bot_avatar"],
},
"channel": {
"id": self.tgid
}
}
initial_state = [{ initial_state = [{
"type": EventType.ROOM_POWER_LEVELS.serialize(), "type": EventType.ROOM_POWER_LEVELS.serialize(),
"content": power_levels.serialize(), "content": power_levels.serialize(),
}, {
"type": "m.bridge",
"state_key": f"net.maunium.telegram://telegram/{self.tgid}",
"content": bridge_info
}, {
# TODO remove this once https://github.com/matrix-org/matrix-doc/pull/2346 is in spec
"type": "uk.half-shot.bridge",
"state_key": f"net.maunium.telegram://telegram/{self.tgid}",
"content": bridge_info
}] }]
if config["bridge.encryption.default"] and self.matrix.e2ee:
self.encrypted = True
initial_state.append({
"type": "m.room.encryption",
"content": {"algorithm": "m.megolm.v1.aes-sha2"},
})
if direct:
invites.append(self.az.bot_mxid)
if direct and (self.encrypted or self.private_chat_portal_meta):
self.title = puppet.displayname
if config["appservice.community_id"]: if config["appservice.community_id"]:
initial_state.append({ initial_state.append({
"type": "m.room.related_groups", "type": "m.room.related_groups",
@@ -325,6 +369,16 @@ class PortalMetadata(BasePortal, ABC):
if not room_id: if not room_id:
raise Exception(f"Failed to create room") raise Exception(f"Failed to create room")
if self.encrypted and self.matrix.e2ee:
members = [self.main_intent.mxid]
if direct:
try:
await self.az.intent.join_room_by_id(room_id)
members += [self.az.intent.mxid]
except Exception:
self.log.warning(f"Failed to add bridge bot to new private chat {room_id}")
await self.matrix.e2ee.add_room(room_id, members=members, encrypted=True)
self.mxid = RoomID(room_id) self.mxid = RoomID(room_id)
self.by_mxid[self.mxid] = self self.by_mxid[self.mxid] = self
self.save() self.save()
@@ -362,7 +416,7 @@ class PortalMetadata(BasePortal, ABC):
levels.kick = overrides.get("kick", 50) levels.kick = overrides.get("kick", 50)
levels.redact = overrides.get("redact", 50) levels.redact = overrides.get("redact", 50)
levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0) levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0)
levels.events[EventType.ROOM_ENCRYPTED] = 99 levels.events[EventType.ROOM_ENCRYPTION] = 99
levels.events[EventType.ROOM_TOMBSTONE] = 99 levels.events[EventType.ROOM_TOMBSTONE] = 99
levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0 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_AVATAR] = 50 if dbr.change_info else 0
@@ -412,7 +466,7 @@ class PortalMetadata(BasePortal, ABC):
return False return False
changed = False changed = False
admin_power_level = min(75 if self.peer_type == "channel" else 50, bot_level) admin_power_level = min(75 if self.peer_type == "channel" else 50, bot_level)
if levels.events[EventType.ROOM_POWER_LEVELS] != admin_power_level: if levels.get_event_level(EventType.ROOM_POWER_LEVELS) != admin_power_level:
changed = True changed = True
levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
@@ -542,12 +596,12 @@ class PortalMetadata(BasePortal, ABC):
self.log.warning("Called update_info() for direct chat portal") self.log.warning("Called update_info() for direct chat portal")
return return
changed = False
self.log.debug("Updating info") self.log.debug("Updating info")
try: try:
if not entity: if not entity:
entity = await self.get_entity(user) entity = await self.get_entity(user)
self.log.debug(f"Fetched data: {entity}") self.log.trace("Fetched data: %s", entity)
changed = False
if self.peer_type == "channel": if self.peer_type == "channel":
changed = self.megagroup != entity.megagroup or changed changed = self.megagroup != entity.megagroup or changed
@@ -585,15 +639,18 @@ class PortalMetadata(BasePortal, ABC):
self.save() self.save()
return True return True
async def _try_use_intent(self, sender: Optional['p.Puppet'], async def _try_set_state(self, sender: Optional['p.Puppet'], evt_type: EventType,
action: Callable[[IntentAPI], None]) -> None: content: StateEventContent) -> None:
if sender: if sender:
try: try:
await action(sender.intent_for(self)) intent = sender.intent_for(self)
if sender.is_real_user:
content[self.az.real_user_content_key] = True
await intent.send_state_event(self.mxid, evt_type, content)
except MForbidden: except MForbidden:
await action(self.main_intent) await self.main_intent.send_state_event(self.mxid, evt_type, content)
else: else:
await action(self.main_intent) await self.main_intent.send_state_event(self.mxid, evt_type, content)
async def _update_about(self, about: str, sender: Optional['p.Puppet'] = None, async def _update_about(self, about: str, sender: Optional['p.Puppet'] = None,
save: bool = False) -> bool: save: bool = False) -> bool:
@@ -601,8 +658,8 @@ class PortalMetadata(BasePortal, ABC):
return False return False
self.about = about self.about = about
await self._try_use_intent(sender, await self._try_set_state(sender, EventType.ROOM_TOPIC,
lambda intent: intent.set_room_topic(self.mxid, self.about)) RoomTopicStateEventContent(topic=self.about))
if save: if save:
self.save() self.save()
return True return True
@@ -613,42 +670,45 @@ class PortalMetadata(BasePortal, ABC):
return False return False
self.title = title self.title = title
await self._try_use_intent(sender, await self._try_set_state(sender, EventType.ROOM_NAME,
lambda intent: intent.set_room_name(self.mxid, self.title)) RoomNameStateEventContent(name=self.title))
if save: if save:
self.save() self.save()
return True return True
async def _update_avatar(self, user: 'AbstractUser', photo: TypeChatPhoto, async def _update_avatar(self, user: 'AbstractUser', photo: TypeChatPhoto,
sender: Optional['p.Puppet'] = None, save: bool = False) -> bool: sender: Optional['p.Puppet'] = None, save: bool = False) -> bool:
if isinstance(photo, ChatPhoto): if isinstance(photo, (ChatPhoto, UserProfilePhoto)):
loc = InputPeerPhotoFileLocation( loc = InputPeerPhotoFileLocation(
peer=await self.get_input_entity(user), peer=await self.get_input_entity(user),
local_id=photo.photo_big.local_id, local_id=photo.photo_big.local_id,
volume_id=photo.photo_big.volume_id, volume_id=photo.photo_big.volume_id,
big=True big=True
) )
photo_id = f"{loc.volume_id}-{loc.local_id}" photo_id = (f"{loc.volume_id}-{loc.local_id}" if isinstance(photo, ChatPhoto)
else photo.photo_id)
elif isinstance(photo, Photo): elif isinstance(photo, Photo):
loc, largest = self._get_largest_photo_size(photo) loc, largest = self._get_largest_photo_size(photo)
photo_id = f"{largest.location.volume_id}-{largest.location.local_id}" photo_id = f"{largest.location.volume_id}-{largest.location.local_id}"
elif isinstance(photo, (ChatPhotoEmpty, PhotoEmpty)): elif isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty, PhotoEmpty, type(None))):
photo_id = "" photo_id = ""
loc = None loc = None
else: else:
raise ValueError(f"Unknown photo type {type(photo)}") raise ValueError(f"Unknown photo type {type(photo)}")
if self.peer_type == "user" and not photo_id and not config["bridge.allow_avatar_remove"]:
return False
if self.photo_id != photo_id: if self.photo_id != photo_id:
if not photo_id: if not photo_id:
await self._try_use_intent(sender, await self._try_set_state(sender, EventType.ROOM_AVATAR,
lambda intent: intent.set_room_avatar(self.mxid, None)) RoomAvatarStateEventContent(url=None))
self.photo_id = "" self.photo_id = ""
if save: if save:
self.save() self.save()
return True return True
file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc) file = await util.transfer_file_to_matrix(user.client, self.main_intent, loc)
if file: if file:
await self._try_use_intent(sender, lambda intent: intent.set_room_avatar(self.mxid, await self._try_set_state(sender, EventType.ROOM_AVATAR,
file.mxc)) RoomAvatarStateEventContent(url=file.mxc))
self.photo_id = photo_id self.photo_id = photo_id
if save: if save:
self.save() self.save()
+122 -35
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2020 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@@ -14,7 +14,6 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # 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 typing import Awaitable, Dict, List, Optional, Tuple, Union, NamedTuple, TYPE_CHECKING
from html import escape as escape_html
from abc import ABC from abc import ABC
import random import random
import mimetypes import mimetypes
@@ -30,16 +29,16 @@ from telethon.tl.types import (
MessageMediaPoll, MessageActionChannelCreate, MessageActionChatAddUser, MessageMediaPoll, MessageActionChannelCreate, MessageActionChatAddUser,
MessageActionChatCreate, MessageActionChatDeletePhoto, MessageActionChatDeleteUser, MessageActionChatCreate, MessageActionChatDeletePhoto, MessageActionChatDeleteUser,
MessageActionChatEditPhoto, MessageActionChatEditTitle, MessageActionChatJoinedByLink, MessageActionChatEditPhoto, MessageActionChatEditTitle, MessageActionChatJoinedByLink,
MessageActionChatMigrateTo, MessageActionPinMessage, MessageActionGameScore, MessageActionChatMigrateTo, MessageActionGameScore, MessageMediaDocument, MessageMediaGeo,
MessageMediaDocument, MessageMediaGeo, MessageMediaPhoto, MessageMediaUnsupported, MessageMediaPhoto, MessageMediaDice, MessageMediaGame, MessageMediaUnsupported, PeerUser,
MessageMediaGame, PeerUser, PhotoCachedSize, TypeChannelParticipant, TypeChatParticipant, PhotoCachedSize, TypeChannelParticipant, TypeChatParticipant, TypeDocumentAttribute,
TypeDocumentAttribute, TypeMessageAction, TypePhotoSize, PhotoSize, UpdateChatUserTyping, TypeMessageAction, TypePhotoSize, PhotoSize, UpdateChatUserTyping, UpdateUserTyping,
UpdateUserTyping, MessageEntityPre, ChatPhotoEmpty) MessageEntityPre, ChatPhotoEmpty)
from mautrix.appservice import IntentAPI from mautrix.appservice import IntentAPI
from mautrix.types import (EventID, UserID, ImageInfo, ThumbnailInfo, RelatesTo, MessageType, from mautrix.types import (EventID, UserID, ImageInfo, ThumbnailInfo, RelatesTo, MessageType,
EventType, MediaMessageEventContent, TextMessageEventContent, EventType, MediaMessageEventContent, TextMessageEventContent,
LocationMessageEventContent, Format) LocationMessageEventContent, Format, MessageEventContent)
from ..types import TelegramID from ..types import TelegramID
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
@@ -73,9 +72,10 @@ class PortalTelegram(BasePortal, ABC):
return None return None
async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message, async def handle_telegram_photo(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: Dict = None) -> Optional[EventID]: relates_to: RelatesTo = None) -> Optional[EventID]:
loc, largest_size = self._get_largest_photo_size(evt.media.photo) loc, largest_size = self._get_largest_photo_size(evt.media.photo)
file = await util.transfer_file_to_matrix(source.client, intent, loc) file = await util.transfer_file_to_matrix(source.client, intent, loc,
encrypt=self.encrypted)
if not file: if not file:
return None return None
if self.get_config("inline_images") and (evt.message if self.get_config("inline_images") and (evt.message
@@ -86,22 +86,26 @@ class PortalTelegram(BasePortal, ABC):
prefix_text="Inline image: ") prefix_text="Inline image: ")
content.external_url = self._get_external_url(evt) content.external_url = self._get_external_url(evt)
await intent.set_typing(self.mxid, is_typing=False) await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_message(self.mxid, content, timestamp=evt.date) return await self._send_message(intent, content, timestamp=evt.date)
info = ImageInfo( info = ImageInfo(
height=largest_size.h, width=largest_size.w, orientation=0, mimetype=file.mime_type, height=largest_size.h, width=largest_size.w, orientation=0, mimetype=file.mime_type,
size=(len(largest_size.bytes) if (isinstance(largest_size, PhotoCachedSize)) size=(len(largest_size.bytes) if (isinstance(largest_size, PhotoCachedSize))
else largest_size.size)) else largest_size.size))
name = f"image{sane_mimetypes.guess_extension(file.mime_type)}" name = f"image{sane_mimetypes.guess_extension(file.mime_type)}"
await intent.set_typing(self.mxid, is_typing=False) await intent.set_typing(self.mxid, is_typing=False)
content = MediaMessageEventContent(url=file.mxc, msgtype=MessageType.IMAGE, info=info, content = MediaMessageEventContent(msgtype=MessageType.IMAGE, info=info,
body=name, relates_to=relates_to, body=name, relates_to=relates_to,
external_url=self._get_external_url(evt)) external_url=self._get_external_url(evt))
result = await intent.send_message(self.mxid, content, timestamp=evt.date) if file.decryption_info:
content.file = file.decryption_info
else:
content.url = file.mxc
result = await self._send_message(intent, content, timestamp=evt.date)
if evt.message: if evt.message:
caption_content = await formatter.telegram_to_matrix(evt, source, self.main_intent, caption_content = await formatter.telegram_to_matrix(evt, source, self.main_intent,
no_reply_fallback=True) no_reply_fallback=True)
caption_content.external_url = content.external_url caption_content.external_url = content.external_url
result = await intent.send_message(self.mxid, caption_content, timestamp=evt.date) result = await self._send_message(intent, caption_content, timestamp=evt.date)
return result return result
@staticmethod @staticmethod
@@ -134,6 +138,8 @@ class PortalTelegram(BasePortal, ABC):
generic_types = ("text/plain", "application/octet-stream") generic_types = ("text/plain", "application/octet-stream")
if file.mime_type in generic_types and document.mime_type not in generic_types: if file.mime_type in generic_types and document.mime_type not in generic_types:
mime_type = document.mime_type or file.mime_type mime_type = document.mime_type or file.mime_type
elif file.mime_type == 'application/ogg':
mime_type = 'audio/ogg'
else: else:
mime_type = file.mime_type or document.mime_type mime_type = file.mime_type or document.mime_type
info = ImageInfo(size=file.size, mimetype=mime_type) info = ImageInfo(size=file.size, mimetype=mime_type)
@@ -146,11 +152,21 @@ class PortalTelegram(BasePortal, ABC):
info.width, info.height = attrs.width, attrs.height info.width, info.height = attrs.width, attrs.height
if file.thumbnail: if file.thumbnail:
info.thumbnail_url = file.thumbnail.mxc if file.thumbnail.decryption_info:
info.thumbnail_file = file.thumbnail.decryption_info
else:
info.thumbnail_url = file.thumbnail.mxc
info.thumbnail_info = ThumbnailInfo(mimetype=file.thumbnail.mime_type, info.thumbnail_info = ThumbnailInfo(mimetype=file.thumbnail.mime_type,
height=file.thumbnail.height or thumb_size.h, height=file.thumbnail.height or thumb_size.h,
width=file.thumbnail.width or thumb_size.w, width=file.thumbnail.width or thumb_size.w,
size=file.thumbnail.size) size=file.thumbnail.size)
else:
# This is a hack for bad clients like Riot iOS that require a thumbnail
if file.decryption_info:
info.thumbnail_file = file.decryption_info
else:
info.thumbnail_url = file.mxc
info.thumbnail_info = ImageInfo.deserialize(info.serialize())
return info, name return info, name
@@ -164,6 +180,7 @@ class PortalTelegram(BasePortal, ABC):
if document.size > config["bridge.max_document_size"] * 1000 ** 2: if document.size > config["bridge.max_document_size"] * 1000 ** 2:
name = attrs.name or "" name = attrs.name or ""
caption = f"\n{evt.message}" if evt.message else "" caption = f"\n{evt.message}" if evt.message else ""
# TODO encrypt
return await intent.send_notice(self.mxid, f"Too large file {name}{caption}") return await intent.send_notice(self.mxid, f"Too large file {name}{caption}")
thumb_loc, thumb_size = self._get_largest_photo_size(document) thumb_loc, thumb_size = self._get_largest_photo_size(document)
@@ -175,7 +192,8 @@ class PortalTelegram(BasePortal, ABC):
file = await util.transfer_file_to_matrix(source.client, intent, document, thumb_loc, file = await util.transfer_file_to_matrix(source.client, intent, document, thumb_loc,
is_sticker=attrs.is_sticker, is_sticker=attrs.is_sticker,
tgs_convert=config["bridge.animated_sticker"], tgs_convert=config["bridge.animated_sticker"],
filename=attrs.name, parallel_id=parallel_id) filename=attrs.name, parallel_id=parallel_id,
encrypt=self.encrypted)
if not file: if not file:
return None return None
@@ -188,17 +206,21 @@ class PortalTelegram(BasePortal, ABC):
if attrs.is_sticker and file.mime_type.startswith("image/"): if attrs.is_sticker and file.mime_type.startswith("image/"):
event_type = EventType.STICKER event_type = EventType.STICKER
content = MediaMessageEventContent( content = MediaMessageEventContent(
body=name or "unnamed file", info=info, url=file.mxc, relates_to=relates_to, body=name or "unnamed file", info=info, relates_to=relates_to,
external_url=self._get_external_url(evt), external_url=self._get_external_url(evt),
msgtype={ msgtype={
"video/": MessageType.VIDEO, "video/": MessageType.VIDEO,
"audio/": MessageType.AUDIO, "audio/": MessageType.AUDIO,
"image/": MessageType.IMAGE, "image/": MessageType.IMAGE,
}.get(info.mimetype[:6], MessageType.FILE)) }.get(info.mimetype[:6], MessageType.FILE))
return await intent.send_message_event(self.mxid, event_type, content, timestamp=evt.date) if file.decryption_info:
content.file = file.decryption_info
else:
content.url = file.mxc
return await self._send_message(intent, content, event_type=event_type, timestamp=evt.date)
def handle_telegram_location(self, _: 'AbstractUser', intent: IntentAPI, evt: Message, def handle_telegram_location(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: dict = None) -> Awaitable[EventID]: relates_to: RelatesTo = None) -> Awaitable[EventID]:
long = evt.media.geo.long long = evt.media.geo.long
lat = evt.media.geo.lat lat = evt.media.geo.lat
long_char = "E" if long > 0 else "W" long_char = "E" if long > 0 else "W"
@@ -214,7 +236,7 @@ class PortalTelegram(BasePortal, ABC):
content["format"] = str(Format.HTML) content["format"] = str(Format.HTML)
content["formatted_body"] = f"Location: <a href='{url}'>{body}</a>" content["formatted_body"] = f"Location: <a href='{url}'>{body}</a>"
return intent.send_message(self.mxid, content, timestamp=evt.date) return self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool, async def handle_telegram_text(self, source: 'AbstractUser', intent: IntentAPI, is_bot: bool,
evt: Message) -> EventID: evt: Message) -> EventID:
@@ -224,10 +246,10 @@ class PortalTelegram(BasePortal, ABC):
if is_bot and self.get_config("bot_messages_as_notices"): if is_bot and self.get_config("bot_messages_as_notices"):
content.msgtype = MessageType.NOTICE content.msgtype = MessageType.NOTICE
await intent.set_typing(self.mxid, is_typing=False) await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_message(self.mxid, content, timestamp=evt.date) return await self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI, async def handle_telegram_unsupported(self, source: 'AbstractUser', intent: IntentAPI,
evt: Message, relates_to: dict = None) -> EventID: evt: Message, relates_to: RelatesTo = None) -> EventID:
override_text = ("This message is not supported on your version of Mautrix-Telegram. " override_text = ("This message is not supported on your version of Mautrix-Telegram. "
"Please check https://github.com/tulir/mautrix-telegram or ask your " "Please check https://github.com/tulir/mautrix-telegram or ask your "
"bridge administrator about possible updates.") "bridge administrator about possible updates.")
@@ -237,7 +259,7 @@ class PortalTelegram(BasePortal, ABC):
content.external_url = self._get_external_url(evt) content.external_url = self._get_external_url(evt)
content["net.maunium.telegram.unsupported"] = True content["net.maunium.telegram.unsupported"] = True
await intent.set_typing(self.mxid, is_typing=False) await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_message(self.mxid, content, timestamp=evt.date) return await self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message, async def handle_telegram_poll(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: RelatesTo) -> EventID: relates_to: RelatesTo) -> EventID:
@@ -263,11 +285,26 @@ class PortalTelegram(BasePortal, ABC):
relates_to=relates_to, external_url=self._get_external_url(evt)) relates_to=relates_to, external_url=self._get_external_url(evt))
await intent.set_typing(self.mxid, is_typing=False) await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_message(self.mxid, content, timestamp=evt.date) return await self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_dice(self, source: 'AbstractUser', intent: IntentAPI, evt: Message,
relates_to: RelatesTo) -> EventID:
emoji_text = {
"\U0001F3AF": " Dart throw",
"\U0001F3B2": " Dice roll",
}
roll: MessageMediaDice = evt.media
text = f"{roll.emoticon}{emoji_text.get(roll.emoticon, '')} result: {roll.value}"
content = TextMessageEventContent(msgtype=MessageType.TEXT, format=Format.HTML, body=text,
formatted_body=f"<h4>{text}</h4>", relates_to=relates_to,
external_url=self._get_external_url(evt))
content["net.maunium.telegram.dice"] = {"emoticon": roll.emoticon, "value": roll.value}
await intent.set_typing(self.mxid, is_typing=False)
return await self._send_message(intent, content, timestamp=evt.date)
@staticmethod @staticmethod
def _int_to_bytes(i: int) -> bytes: def _int_to_bytes(i: int) -> bytes:
hex_value = "{0:010x}".format(i) hex_value = "{0:010x}".format(i).encode("utf-8")
return codecs.decode(hex_value, "hex_codec") return codecs.decode(hex_value, "hex_codec")
def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str: def _encode_msgid(self, source: 'AbstractUser', evt: Message) -> str:
@@ -305,11 +342,12 @@ class PortalTelegram(BasePortal, ABC):
content["net.maunium.telegram.game"] = play_id content["net.maunium.telegram.game"] = play_id
await intent.set_typing(self.mxid, is_typing=False) await intent.set_typing(self.mxid, is_typing=False)
return await intent.send_message(self.mxid, content, timestamp=evt.date) return await self._send_message(intent, content, timestamp=evt.date)
async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet, evt: Message async def handle_telegram_edit(self, source: 'AbstractUser', sender: p.Puppet, evt: Message
) -> None: ) -> None:
if not self.mxid: if not self.mxid:
self.log.trace("Ignoring edit to %d as chat has no Matrix room", evt.id)
return return
elif hasattr(evt, "media") and isinstance(evt.media, MessageMediaGame): elif hasattr(evt, "media") and isinstance(evt.media, MessageMediaGame):
self.log.debug("Ignoring game message edit event") self.log.debug("Ignoring game message edit event")
@@ -349,16 +387,54 @@ class PortalTelegram(BasePortal, ABC):
intent = sender.intent_for(self) if sender else self.main_intent intent = sender.intent_for(self) if sender else self.main_intent
await intent.set_typing(self.mxid, is_typing=False) await intent.set_typing(self.mxid, is_typing=False)
event_id = await intent.send_message(self.mxid, content) event_id = await self._send_message(intent, content)
prev_edit_msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space, -1) or editing_msg 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), 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() edit_index=prev_edit_msg.edit_index + 1).insert()
DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id) DBMessage.update_by_mxid(temporary_identifier, self.mxid, mxid=event_id)
async def backfill(self, source: 'AbstractUser') -> None:
self.log.debug("Backfilling history through %s", source.mxid)
last = DBMessage.find_last(self.mxid, (source.tgid if self.peer_type != "channel"
else self.tgid))
min_id = last.tgid if last else 0
self.backfilling = True
self.backfill_leave = set()
if self.peer_type == "user":
self.log.debug("Adding %s's default puppet to room for backfilling", source.mxid)
sender = p.Puppet.get(source.tgid)
await self.main_intent.invite_user(self.mxid, sender.default_mxid)
await sender.default_mxid_intent.join_room_by_id(self.mxid)
self.backfill_leave.add(sender.default_mxid_intent)
max_file_size = min(config["bridge.max_document_size"], 1500) * 1024 * 1024
self.log.trace("Opening takeout client for %d, message ID %d->", source.tgid, min_id)
count = 0
async with source.client.takeout(files=True, megagroups=self.megagroup,
chats=self.peer_type == "chat",
users=self.peer_type == "user",
channels=(self.peer_type == "channel"
and not self.megagroup),
max_file_size=max_file_size
) as takeout_client:
async for message in takeout_client.iter_messages(await self.get_input_entity(source),
reverse=True, min_id=min_id):
sender = p.Puppet.get(message.sender_id)
# if isinstance(message, MessageService):
# await self.handle_telegram_action(source, sender, message)
await self.handle_telegram_message(source, sender, message)
count += 1
for intent in self.backfill_leave:
self.log.trace("Leaving room with %s post-backfill", intent.mxid)
await intent.leave_room(self.mxid)
self.backfilling = False
self.backfill_leave = None
self.log.info("Backfilled %d messages through %s", count, source.mxid)
async def handle_telegram_message(self, source: 'AbstractUser', sender: p.Puppet, async def handle_telegram_message(self, source: 'AbstractUser', sender: p.Puppet,
evt: Message) -> None: evt: Message) -> None:
if not self.mxid: if not self.mxid:
self.log.trace("Got telegram message %d, but no room exists, creating...", evt.id)
await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False) await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
if (self.peer_type == "user" and sender.tgid == self.tg_receiver if (self.peer_type == "user" and sender.tgid == self.tg_receiver
@@ -383,7 +459,7 @@ class PortalTelegram(BasePortal, ABC):
tg_space=tg_space, edit_index=0).insert() tg_space=tg_space, edit_index=0).insert()
return return
if self.dedup.pre_db_check and self.peer_type == "channel": if self.backfilling or (self.dedup.pre_db_check and self.peer_type == "channel"):
msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space) msg = DBMessage.get_one_by_tgid(TelegramID(evt.id), tg_space)
if msg: if msg:
self.log.debug(f"Ignoring message {evt.id} (src {source.tgid}) as it was already" self.log.debug(f"Ignoring message {evt.id} (src {source.tgid}) as it was already"
@@ -392,6 +468,8 @@ class PortalTelegram(BasePortal, ABC):
"bridge.deduplication.cache_queue_length in the config.") "bridge.deduplication.cache_queue_length in the config.")
return return
self.log.trace("Handling Telegram message %s", evt)
if sender and not sender.displayname: if sender and not sender.displayname:
self.log.debug(f"Telegram user {sender.tgid} sent a message, but doesn't have a " self.log.debug(f"Telegram user {sender.tgid} sent a message, but doesn't have a "
"displayname, updating info...") "displayname, updating info...")
@@ -399,10 +477,17 @@ class PortalTelegram(BasePortal, ABC):
await sender.update_info(source, entity) await sender.update_info(source, entity)
allowed_media = (MessageMediaPhoto, MessageMediaDocument, MessageMediaGeo, allowed_media = (MessageMediaPhoto, MessageMediaDocument, MessageMediaGeo,
MessageMediaGame, MessageMediaPoll, MessageMediaUnsupported) MessageMediaGame, MessageMediaDice, MessageMediaPoll,
MessageMediaUnsupported)
media = evt.media if hasattr(evt, "media") and isinstance(evt.media, media = evt.media if hasattr(evt, "media") and isinstance(evt.media,
allowed_media) else None allowed_media) else None
intent = sender.intent_for(self) if sender else self.main_intent if sender:
intent = sender.intent_for(self)
if self.backfilling and intent != sender.default_mxid_intent:
intent = sender.default_mxid_intent
self.backfill_leave.add(intent)
else:
intent = self.main_intent
if not media and evt.message: if not media and evt.message:
is_bot = sender.is_bot if sender else False is_bot = sender.is_bot if sender else False
event_id = await self.handle_telegram_text(source, intent, is_bot, evt) event_id = await self.handle_telegram_text(source, intent, is_bot, evt)
@@ -412,12 +497,13 @@ class PortalTelegram(BasePortal, ABC):
MessageMediaDocument: self.handle_telegram_document, MessageMediaDocument: self.handle_telegram_document,
MessageMediaGeo: self.handle_telegram_location, MessageMediaGeo: self.handle_telegram_location,
MessageMediaPoll: self.handle_telegram_poll, MessageMediaPoll: self.handle_telegram_poll,
MessageMediaDice: self.handle_telegram_dice,
MessageMediaUnsupported: self.handle_telegram_unsupported, MessageMediaUnsupported: self.handle_telegram_unsupported,
MessageMediaGame: self.handle_telegram_game, MessageMediaGame: self.handle_telegram_game,
}[type(media)](source, intent, evt, }[type(media)](source, intent, evt,
relates_to=formatter.telegram_reply_to_matrix(evt, source)) relates_to=formatter.telegram_reply_to_matrix(evt, source))
else: else:
self.log.debug("Unhandled Telegram message: %s", evt) self.log.debug("Unhandled Telegram message %d", evt.id)
return return
if not event_id: if not event_id:
@@ -434,7 +520,7 @@ class PortalTelegram(BasePortal, ABC):
await intent.redact(self.mxid, event_id) await intent.redact(self.mxid, event_id)
return return
self.log.debug("Handled Telegram message: %s", evt) self.log.debug("Handled telegram message %d -> %s", evt.id, event_id)
try: try:
DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=event_id, DBMessage(tgid=TelegramID(evt.id), mx_room=self.mxid, mxid=event_id,
tg_space=tg_space, edit_index=0).insert() tg_space=tg_space, edit_index=0).insert()
@@ -482,13 +568,14 @@ class PortalTelegram(BasePortal, ABC):
elif isinstance(action, MessageActionChatMigrateTo): elif isinstance(action, MessageActionChatMigrateTo):
self.peer_type = "channel" self.peer_type = "channel"
self._migrate_and_save_telegram(TelegramID(action.channel_id)) self._migrate_and_save_telegram(TelegramID(action.channel_id))
# TODO encrypt
await sender.intent_for(self).send_emote(self.mxid, await sender.intent_for(self).send_emote(self.mxid,
"upgraded this group to a supergroup.") "upgraded this group to a supergroup.")
elif isinstance(action, MessageActionGameScore): elif isinstance(action, MessageActionGameScore):
# TODO handle game score # TODO handle game score
pass pass
else: else:
self.log.debug("Unhandled Telegram action in %s: %s", self.title, action) self.log.trace("Unhandled Telegram action in %s: %s", self.title, action)
async def set_telegram_admin(self, user_id: TelegramID) -> None: async def set_telegram_admin(self, user_id: TelegramID) -> None:
puppet = p.Puppet.get(user_id) puppet = p.Puppet.get(user_id)
@@ -502,7 +589,7 @@ class PortalTelegram(BasePortal, ABC):
await self.main_intent.set_power_levels(self.mxid, levels) await self.main_intent.set_power_levels(self.mxid, levels)
async def receive_telegram_pin_id(self, msg_id: TelegramID, receiver: TelegramID) -> None: async def receive_telegram_pin_id(self, msg_id: TelegramID, receiver: TelegramID) -> None:
tg_space = receiver if self.peer_type != "channel" else self.tgid 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 message = DBMessage.get_one_by_tgid(msg_id, tg_space) if msg_id != 0 else None
if message: if message:
await self.main_intent.set_pinned_messages(self.mxid, [message.mxid]) await self.main_intent.set_pinned_messages(self.mxid, [message.mxid])
+28 -15
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan # Copyright (C) 2020 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@@ -25,7 +25,7 @@ from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser,
from mautrix.appservice import AppService, IntentAPI from mautrix.appservice import AppService, IntentAPI
from mautrix.errors import MatrixRequestError from mautrix.errors import MatrixRequestError
from mautrix.bridge import CustomPuppetMixin from mautrix.bridge import CustomPuppetMixin
from mautrix.types import UserID, SyncToken from mautrix.types import UserID, SyncToken, RoomID
from mautrix.util.simple_template import SimpleTemplate from mautrix.util.simple_template import SimpleTemplate
from .types import TelegramID from .types import TelegramID
@@ -242,8 +242,7 @@ class Puppet(CustomPuppetMixin):
try: try:
changed = await self.update_displayname(source, info) or changed changed = await self.update_displayname(source, info) or changed
if isinstance(info.photo, UserProfilePhoto): changed = await self.update_avatar(source, info.photo) or changed
changed = await self.update_avatar(source, info.photo) or changed
except Exception: except Exception:
self.log.exception(f"Failed to update info from source {source.tgid}") self.log.exception(f"Failed to update info from source {source.tgid}")
@@ -256,19 +255,24 @@ class Puppet(CustomPuppetMixin):
) -> bool: ) -> bool:
if self.disable_updates: if self.disable_updates:
return False return False
allow_source = (source.is_relaybot if source.is_relaybot or source.is_bot:
or self.displayname_source == source.tgid allow_because = "user is bot"
# User is not a contact, so there's no custom name elif self.displayname_source == source.tgid:
or not info.contact allow_because = "user is the primary source"
# No displayname source, so just trust anything elif not info.contact:
or self.displayname_source is None) allow_because = "user is not a contact"
if not allow_source: elif self.displayname_source is None:
allow_because = "no primary source set"
else:
return False return False
elif isinstance(info, UpdateUserName):
if isinstance(info, UpdateUserName):
info = await source.client.get_entity(PeerUser(self.tgid)) info = await source.client.get_entity(PeerUser(self.tgid))
displayname = self.get_displayname(info) displayname = self.get_displayname(info)
if displayname != self.displayname: if displayname != self.displayname:
self.log.debug(f"Updating displayname of {self.id} (src: {source.tgid}, allowed "
f"because {allow_because}) from {self.displayname} to {displayname}")
self.displayname = displayname self.displayname = displayname
self.displayname_source = source.tgid self.displayname_source = source.tgid
try: try:
@@ -289,10 +293,15 @@ class Puppet(CustomPuppetMixin):
if self.disable_updates: if self.disable_updates:
return False return False
if isinstance(photo, UserProfilePhotoEmpty): if photo is None or isinstance(photo, UserProfilePhotoEmpty):
photo_id = "" photo_id = ""
else: elif isinstance(photo, UserProfilePhoto):
photo_id = str(photo.photo_id) photo_id = str(photo.photo_id)
else:
self.log.warning(f"Unknown user profile photo type: {type(photo)}")
return False
if not photo_id and not config["bridge.allow_avatar_remove"]:
return False
if self.photo_id != photo_id: if self.photo_id != photo_id:
if not photo_id: if not photo_id:
self.photo_id = "" self.photo_id = ""
@@ -320,6 +329,10 @@ class Puppet(CustomPuppetMixin):
return True return True
return False return False
def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
portal: p.Portal = p.Portal.get_by_mxid(room_id)
return portal and not portal.backfilling and portal.peer_type != "user"
# endregion # endregion
# region Getters # region Getters
@@ -368,7 +381,7 @@ class Puppet(CustomPuppetMixin):
@classmethod @classmethod
def all_with_custom_mxid(cls) -> Iterable['Puppet']: def all_with_custom_mxid(cls) -> Iterable['Puppet']:
return (cls.by_custom_mxid[puppet.mxid] return (cls.by_custom_mxid[puppet.custom_mxid]
if puppet.custom_mxid in cls.by_custom_mxid if puppet.custom_mxid in cls.by_custom_mxid
else cls.from_db(puppet) else cls.from_db(puppet)
for puppet in DBPuppet.all_with_custom_mxid()) for puppet in DBPuppet.all_with_custom_mxid())
@@ -24,7 +24,8 @@ def log(message, end="\n"):
def connect(to): def connect(to):
from mautrix.bridge.db import Base, RoomState, UserProfile from mautrix.util.db import Base
from mautrix.bridge.db import RoomState, UserProfile
from mautrix_telegram.db import (Portal, Message, UserPortal, User, Contact, Puppet, BotChat, from mautrix_telegram.db import (Portal, Message, UserPortal, User, Contact, Puppet, BotChat,
TelegramFile) TelegramFile)
+5 -2
View File
@@ -29,6 +29,7 @@ from mautrix.client import Client
from mautrix.errors import MatrixRequestError from mautrix.errors import MatrixRequestError
from mautrix.types import UserID from mautrix.types import UserID
from mautrix.bridge import BaseUser from mautrix.bridge import BaseUser
from mautrix.util.logging import TraceLogger
from .types import TelegramID from .types import TelegramID
from .db import User as DBUser from .db import User as DBUser
@@ -45,7 +46,7 @@ SearchResult = NewType('SearchResult', Tuple['pu.Puppet', int])
class User(AbstractUser, BaseUser): class User(AbstractUser, BaseUser):
log: logging.Logger = logging.getLogger("mau.user") log: TraceLogger = logging.getLogger("mau.user")
by_mxid: Dict[str, 'User'] = {} by_mxid: Dict[str, 'User'] = {}
by_tgid: Dict[int, 'User'] = {} by_tgid: Dict[int, 'User'] = {}
@@ -343,12 +344,14 @@ class User(AbstractUser, BaseUser):
entity = dialog.entity entity = dialog.entity
if isinstance(entity, ChatForbidden): if isinstance(entity, ChatForbidden):
self.log.warning(f"Ignoring forbidden chat {entity} while syncing") self.log.warning(f"Ignoring forbidden chat {entity} while syncing")
continue
elif isinstance(entity, Chat) and (entity.deactivated or entity.left): elif isinstance(entity, Chat) and (entity.deactivated or entity.left):
self.log.warning(f"Ignoring deactivated or left chat {entity} while syncing") self.log.warning(f"Ignoring deactivated or left chat {entity} while syncing")
continue continue
elif isinstance(entity, TLUser) and not config["bridge.sync_direct_chats"]: elif isinstance(entity, TLUser) and not config["bridge.sync_direct_chats"]:
self.log.trace(f"Ignoring user {entity.id} while syncing")
continue continue
portal = po.Portal.get_by_entity(entity) portal = po.Portal.get_by_entity(entity, receiver_id=self.tgid)
self.portals[portal.tgid_full] = portal self.portals[portal.tgid_full] = portal
creators.append( creators.append(
portal.create_matrix_room(self, entity, invites=[self.mxid], portal.create_matrix_room(self, entity, invites=[self.mxid],
+2 -1
View File
@@ -13,7 +13,8 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # 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 from mautrix.util.logging.color import (ColorFormatter as BaseColorFormatter,
PREFIX, MXID_COLOR, RESET)
TELETHON_COLOR = PREFIX + "35;1m" # magenta TELETHON_COLOR = PREFIX + "35;1m" # magenta
TELETHON_MODULE_COLOR = PREFIX + "35m" TELETHON_MODULE_COLOR = PREFIX + "35m"
+48 -37
View File
@@ -18,6 +18,7 @@ from io import BytesIO
import time import time
import logging import logging
import asyncio import asyncio
import tempfile
import magic import magic
from sqlalchemy.exc import IntegrityError, InvalidRequestError from sqlalchemy.exc import IntegrityError, InvalidRequestError
@@ -29,12 +30,13 @@ from telethon.errors import (AuthBytesInvalidError, AuthKeyInvalidError, Locatio
SecurityError, FileIdInvalidError) SecurityError, FileIdInvalidError)
from mautrix.appservice import IntentAPI from mautrix.appservice import IntentAPI
from mautrix.types import EncryptedFile
from ..tgclient import MautrixTelegramClient from ..tgclient import MautrixTelegramClient
from ..db import TelegramFile as DBTelegramFile from ..db import TelegramFile as DBTelegramFile
from ..util import sane_mimetypes from ..util import sane_mimetypes
from .parallel_file_transfer import parallel_transfer_to_matrix from .parallel_file_transfer import parallel_transfer_to_matrix
from .tgs_converter import convert_tgs_to
try: try:
from PIL import Image from PIL import Image
@@ -43,14 +45,13 @@ except ImportError:
try: try:
from moviepy.editor import VideoFileClip from moviepy.editor import VideoFileClip
import random
import string
import os
import mimetypes
except ImportError: except ImportError:
VideoFileClip = random = string = os = mimetypes = None VideoFileClip = None
from .tgs_converter import convert_tgs_to try:
from nio.crypto import encrypt_attachment
except ImportError:
encrypt_attachment = None
log: logging.Logger = logging.getLogger("mau.util") log: logging.Logger = logging.getLogger("mau.util")
@@ -76,32 +77,23 @@ def convert_image(file: bytes, source_mime: str = "image/webp", target_type: str
return source_mime, file, None, None return source_mime, file, None, None
def _temp_file_name(ext: str) -> str:
return ("/tmp/mxtg-video-"
+ "".join(random.choice(string.ascii_uppercase + string.digits) for _ in range(10))
+ ext)
def _read_video_thumbnail(data: bytes, video_ext: str = "mp4", frame_ext: str = "png", def _read_video_thumbnail(data: bytes, video_ext: str = "mp4", frame_ext: str = "png",
max_size: Tuple[int, int] = (1024, 720)) -> Tuple[bytes, int, int]: max_size: Tuple[int, int] = (1024, 720)) -> Tuple[bytes, int, int]:
# We don't have any way to read the video from memory, so save it to disk. with tempfile.NamedTemporaryFile(prefix="mxtg_video_", suffix=f".{video_ext}") as file:
temp_file = _temp_file_name(video_ext) # We don't have any way to read the video from memory, so save it to disk.
with open(temp_file, "wb") as file:
file.write(data) file.write(data)
# Read temp file and get frame # Read temp file and get frame
clip = VideoFileClip(temp_file) frame = VideoFileClip(file.name).get_frame(0)
frame = clip.get_frame(0)
# Convert to png and save to BytesIO # Convert to png and save to BytesIO
image = Image.fromarray(frame).convert("RGBA") image = Image.fromarray(frame).convert("RGBA")
thumbnail_file = BytesIO() thumbnail_file = BytesIO()
if max_size: if max_size:
image.thumbnail(max_size, Image.ANTIALIAS) image.thumbnail(max_size, Image.ANTIALIAS)
image.save(thumbnail_file, frame_ext) image.save(thumbnail_file, frame_ext)
os.remove(temp_file)
w, h = image.size w, h = image.size
return thumbnail_file.getvalue(), w, h return thumbnail_file.getvalue(), w, h
@@ -116,8 +108,8 @@ def _location_to_id(location: TypeLocation) -> str:
async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: IntentAPI, async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
thumbnail_loc: TypeLocation, video: bytes, thumbnail_loc: TypeLocation, video: bytes, mime: str,
mime: str) -> Optional[DBTelegramFile]: encrypt: bool) -> Optional[DBTelegramFile]:
if not Image or not VideoFileClip: if not Image or not VideoFileClip:
return None return None
@@ -141,11 +133,19 @@ async def transfer_thumbnail_to_matrix(client: MautrixTelegramClient, intent: In
width, height = None, None width, height = None, None
mime_type = magic.from_buffer(file, mime=True) mime_type = magic.from_buffer(file, mime=True)
content_uri = await intent.upload_media(file, mime_type) decryption_info = None
upload_mime_type = mime_type
if encrypt:
file, decryption_info_dict = encrypt_attachment(file)
decryption_info = EncryptedFile.deserialize(decryption_info_dict)
upload_mime_type = "application/octet-stream"
content_uri = await intent.upload_media(file, upload_mime_type)
if decryption_info:
decryption_info.url = content_uri
db_file = DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=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), was_converted=False, timestamp=int(time.time()), size=len(file),
width=width, height=height) width=width, height=height, decryption_info=decryption_info)
try: try:
db_file.insert() db_file.insert()
except (IntegrityError, InvalidRequestError) as e: except (IntegrityError, InvalidRequestError) as e:
@@ -161,10 +161,10 @@ TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]]
async def transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI, async def transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
location: TypeLocation, thumbnail: TypeThumbnail = None, location: TypeLocation, thumbnail: TypeThumbnail = None, *,
is_sticker: bool = False, tgs_convert: Optional[dict] = None, is_sticker: bool = False, tgs_convert: Optional[dict] = None,
filename: Optional[str] = None, parallel_id: Optional[int] = None filename: Optional[str] = None, encrypt: bool = False,
) -> Optional[DBTelegramFile]: parallel_id: Optional[int] = None) -> Optional[DBTelegramFile]:
location_id = _location_to_id(location) location_id = _location_to_id(location)
if not location_id: if not location_id:
return None return None
@@ -181,14 +181,14 @@ async def transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentA
async with lock: async with lock:
return await _unlocked_transfer_file_to_matrix(client, intent, location_id, location, return await _unlocked_transfer_file_to_matrix(client, intent, location_id, location,
thumbnail, is_sticker, tgs_convert, thumbnail, is_sticker, tgs_convert,
filename, parallel_id) filename, encrypt, parallel_id)
async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI, async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
loc_id: str, location: TypeLocation, loc_id: str, location: TypeLocation,
thumbnail: TypeThumbnail, is_sticker: bool, thumbnail: TypeThumbnail, is_sticker: bool,
tgs_convert: Optional[dict], filename: Optional[str], tgs_convert: Optional[dict], filename: Optional[str],
parallel_id: Optional[int] encrypt: bool, parallel_id: Optional[int]
) -> Optional[DBTelegramFile]: ) -> Optional[DBTelegramFile]:
db_file = DBTelegramFile.get(loc_id) db_file = DBTelegramFile.get(loc_id)
if db_file: if db_file:
@@ -196,7 +196,7 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
if parallel_id and isinstance(location, Document) and (not is_sticker or not tgs_convert): 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, db_file = await parallel_transfer_to_matrix(client, intent, loc_id, location, filename,
parallel_id) encrypt, parallel_id)
mime_type = location.mime_type mime_type = location.mime_type
file = None file = None
else: else:
@@ -214,8 +214,8 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
image_converted = False image_converted = False
# A weird bug in alpine/magic makes it return application/octet-stream for gzips... # 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 ( if is_sticker and tgs_convert and (mime_type == "application/gzip" or (
mime_type == "application/octet-stream" mime_type == "application/octet-stream"
and magic.from_buffer(file).startswith("gzip"))): and magic.from_buffer(file).startswith("gzip"))):
mime_type, file, width, height = await convert_tgs_to( mime_type, file, width, height = await convert_tgs_to(
file, tgs_convert["target"], **tgs_convert["args"]) file, tgs_convert["target"], **tgs_convert["args"])
thumbnail = None thumbnail = None
@@ -229,17 +229,28 @@ async def _unlocked_transfer_file_to_matrix(client: MautrixTelegramClient, inten
mime_type = new_mime_type mime_type = new_mime_type
thumbnail = None thumbnail = None
content_uri = await intent.upload_media(file, mime_type) decryption_info = None
upload_mime_type = mime_type
if encrypt and encrypt_attachment:
file, decryption_info_dict = encrypt_attachment(file)
decryption_info = EncryptedFile.deserialize(decryption_info_dict)
upload_mime_type = "application/octet-stream"
content_uri = await intent.upload_media(file, upload_mime_type)
if decryption_info:
decryption_info.url = content_uri
db_file = DBTelegramFile(id=loc_id, mxc=content_uri, db_file = DBTelegramFile(id=loc_id, mxc=content_uri, decryption_info=decryption_info,
mime_type=mime_type, was_converted=image_converted, mime_type=mime_type, was_converted=image_converted,
timestamp=int(time.time()), size=len(file), timestamp=int(time.time()), size=len(file),
width=width, height=height) width=width, height=height)
if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"): if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"):
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)): if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
thumbnail = thumbnail.location thumbnail = thumbnail.location
db_file.thumbnail = await transfer_thumbnail_to_matrix(client, intent, thumbnail, file, try:
mime_type) db_file.thumbnail = await transfer_thumbnail_to_matrix(client, intent, thumbnail, file,
mime_type, encrypt)
except FileIdInvalidError:
log.warning(f"Failed to transfer thumbnail for {thumbnail!s}", exc_info=True)
try: try:
db_file.insert() db_file.insert()
@@ -13,7 +13,7 @@
# #
# You should have received a copy of the GNU Affero General Public License # 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/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, List, AsyncGenerator, Union, Awaitable, DefaultDict, Tuple from typing import Optional, List, AsyncGenerator, Union, Awaitable, DefaultDict, Tuple, cast
from collections import defaultdict from collections import defaultdict
import hashlib import hashlib
import asyncio import asyncio
@@ -34,12 +34,18 @@ from telethon.crypto import AuthKey
from telethon import utils, helpers from telethon import utils, helpers
from mautrix.appservice import IntentAPI from mautrix.appservice import IntentAPI
from mautrix.types import ContentURI from mautrix.types import ContentURI, EncryptedFile
from mautrix.util.logging import TraceLogger
from ..tgclient import MautrixTelegramClient from ..tgclient import MautrixTelegramClient
from ..db import TelegramFile as DBTelegramFile from ..db import TelegramFile as DBTelegramFile
log: logging.Logger = logging.getLogger("mau.util") try:
from nio.crypto import async_encrypt_attachment
except ImportError:
async_encrypt_attachment = None
log: TraceLogger = cast(TraceLogger, logging.getLogger("mau.util"))
TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation, TypeLocation = Union[Document, InputDocumentFileLocation, InputPeerPhotoFileLocation,
InputFileLocation, InputPhotoFileLocation] InputFileLocation, InputPhotoFileLocation]
@@ -97,7 +103,7 @@ class UploadSender:
async def _next(self, data: bytes) -> None: async def _next(self, data: bytes) -> None:
self.request.bytes = data self.request.bytes = data
log.debug(f"Sending file part {self.request.file_part}/{self.part_count}" log.trace(f"Sending file part {self.request.file_part}/{self.part_count}"
f" with {len(data)} bytes") f" with {len(data)} bytes")
await self.sender.send(self.request) await self.sender.send(self.request)
self.request.file_part += self.stride self.request.file_part += self.stride
@@ -231,7 +237,7 @@ class ParallelTransferrer:
break break
yield data yield data
part += 1 part += 1
log.debug(f"Part {part} downloaded") log.trace(f"Part {part} downloaded")
log.debug("Parallel download finished, cleaning up connections") log.debug("Parallel download finished, cleaning up connections")
await self._cleanup() await self._cleanup()
@@ -242,18 +248,34 @@ parallel_transfer_locks: DefaultDict[int, asyncio.Lock] = defaultdict(lambda: as
async def parallel_transfer_to_matrix(client: MautrixTelegramClient, intent: IntentAPI, async def parallel_transfer_to_matrix(client: MautrixTelegramClient, intent: IntentAPI,
loc_id: str, location: TypeLocation, filename: str, loc_id: str, location: TypeLocation, filename: str,
parallel_id: int) -> DBTelegramFile: encrypt: bool, parallel_id: int) -> DBTelegramFile:
size = location.size size = location.size
mime_type = location.mime_type mime_type = location.mime_type
dc_id, location = utils.get_input_location(location) dc_id, location = utils.get_input_location(location)
# We lock the transfers because telegram has connection count limits # We lock the transfers because telegram has connection count limits
async with parallel_transfer_locks[parallel_id]: async with parallel_transfer_locks[parallel_id]:
downloader = ParallelTransferrer(client, dc_id) downloader = ParallelTransferrer(client, dc_id)
content_uri = await intent.upload_media(downloader.download(location, size), data = downloader.download(location, size)
mime_type=mime_type, filename=filename, size=size) decryption_info = None
up_mime_type = mime_type
if encrypt and async_encrypt_attachment:
async def encrypted(stream):
nonlocal decryption_info
async for chunk in async_encrypt_attachment(stream):
if isinstance(chunk, dict):
decryption_info = EncryptedFile.deserialize(chunk)
else:
yield chunk
data = encrypted(data)
up_mime_type = "application/octet-stream"
content_uri = await intent.upload_media(data, mime_type=up_mime_type, filename=filename,
size=size if not encrypt else None)
if decryption_info:
decryption_info.url = content_uri
return DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type, return DBTelegramFile(id=loc_id, mxc=content_uri, mime_type=mime_type,
was_converted=False, timestamp=int(time.time()), size=size, was_converted=False, timestamp=int(time.time()), size=size,
width=None, height=None) width=None, height=None, decryption_info=decryption_info)
async def _internal_transfer_to_telegram(client: MautrixTelegramClient, response: ClientResponse async def _internal_transfer_to_telegram(client: MautrixTelegramClient, response: ClientResponse
@@ -315,9 +315,9 @@ class ProvisioningAPI(AuthAPI):
if not user.is_bot: if not user.is_bot:
return web.json_response([{ return web.json_response([{
"id": get_peer_id(chat), "id": chat.id,
"title": chat.title, "title": chat.title,
} async for chat in user.client.get_dialogs(ignore_migrated=True, archived=False)]) } async for chat in user.client.iter_dialogs(ignore_migrated=True, archived=False)])
else: else:
return web.json_response([{ return web.json_response([{
"id": get_peer_id(chat.peer), "id": get_peer_id(chat.peer),
@@ -355,6 +355,7 @@ class ProvisioningAPI(AuthAPI):
if err is not None: if err is not None:
return err return err
await user.log_out() await user.log_out()
return web.json_response({}, status=200)
async def bridge_info(self, request: web.Request) -> web.Response: async def bridge_info(self, request: web.Request) -> web.Response:
return web.json_response({ return web.json_response({
+23 -5
View File
@@ -1,5 +1,23 @@
cryptg # Format: #/name defines a new extras_require group called name
Pillow # Uncommented lines after the group definition insert things into that group.
moviepy
prometheus_client #/speedups
psycopg2-binary cryptg>=0.1,<0.3
cchardet
aiodns
brotli
#/webp_convert
pillow>=4.3,<8
#/hq_thumbnails
moviepy>=1,<2
#/metrics
prometheus_client>=0.6,<0.9
#/postgres
psycopg2-binary>=2,<3
#/e2be
matrix-nio[e2e]>=0.9,<0.13
+9 -9
View File
@@ -1,9 +1,9 @@
aiohttp SQLAlchemy>=1.2,<2
mautrix alembic>=1,<2
ruamel.yaml ruamel.yaml>=0.15.35,<0.17
python-magic python-magic>=0.4,<0.5
SQLAlchemy commonmark>=0.8,<0.10
alembic aiohttp>=3,<4
commonmark mautrix>=0.5.8,<0.6
telethon telethon>=1.13,<1.15
telethon-session-sqlalchemy telethon-session-sqlalchemy>=0.2.14,<0.3
+19 -21
View File
@@ -3,14 +3,21 @@ import glob
from mautrix_telegram.get_version import git_tag, git_revision, version, linkified_version from mautrix_telegram.get_version import git_tag, git_revision, version, linkified_version
extras = { with open("requirements.txt") as reqs:
"speedups": ["cryptg>=0.1,<0.3", "cchardet", "aiodns", "Brotli"], install_requires = reqs.read().splitlines()
"webp_convert": ["Pillow>=4.3.0,<7"],
"hq_thumbnails": ["moviepy>=1.0,<2.0"], with open("optional-requirements.txt") as reqs:
"metrics": ["prometheus_client>=0.6.0,<0.8.0"], extras_require = {}
"postgres": ["psycopg2-binary>=2,<3"], current = []
} for line in reqs.read().splitlines():
extras["all"] = list({dep for deps in extras.values() for dep in deps}) if line.startswith("#/"):
extras_require[line[2:]] = current = []
elif not line or line.startswith("#"):
continue
else:
current.append(line)
extras_require["all"] = list({dep for deps in extras_require.values() for dep in deps})
try: try:
long_desc = open("README.md").read() long_desc = open("README.md").read()
@@ -40,18 +47,8 @@ setuptools.setup(
packages=setuptools.find_packages(), packages=setuptools.find_packages(),
install_requires=[ install_requires=install_requires,
"aiohttp>=3.0.1,<4", extras_require=extras_require,
"mautrix>=0.4.0,<0.5",
"SQLAlchemy>=1.2.3,<2",
"alembic>=1.0.0,<2",
"commonmark>=0.8.1,<0.10",
"ruamel.yaml>=0.15.35,<0.17",
"python-magic>=0.4.15,<0.5",
"telethon>=1.10,<1.11",
"telethon-session-sqlalchemy>=0.2.14,<0.3",
],
extras_require=extras,
python_requires="~=3.6", python_requires="~=3.6",
setup_requires=["pytest-runner"], setup_requires=["pytest-runner"],
@@ -74,9 +71,10 @@ setuptools.setup(
""", """,
package_data={"mautrix_telegram": [ package_data={"mautrix_telegram": [
"web/public/*.mako", "web/public/*.png", "web/public/*.css", "web/public/*.mako", "web/public/*.png", "web/public/*.css",
"example-config.yaml",
]}, ]},
data_files=[ data_files=[
(".", ["example-config.yaml", "alembic.ini"]), (".", ["alembic.ini"]),
("alembic", ["alembic/env.py"]), ("alembic", ["alembic/env.py"]),
("alembic/versions", glob.glob("alembic/versions/*.py")) ("alembic/versions", glob.glob("alembic/versions/*.py"))
], ],
+9 -1
View File
@@ -26,7 +26,7 @@ def context(request: FixtureRequest) -> Context:
""" """
# Config(path, registration_path, base_path) # Config(path, registration_path, base_path)
config = getattr(request.cls, 'config', Config("", "", "")) config = getattr(request.cls, 'config', Config("", "", ""))
return Context(az=Mock(), config=config, loop=Mock(), session_container=Mock(), bot=Mock()) return Context(az=Mock(), config=config, loop=Mock(), session_container=Mock(), bridge=Mock(), bot=Mock())
@pytest.fixture @pytest.fixture
@@ -52,6 +52,7 @@ class TestCommandEvent:
sender=u.User(UserID("@sender:example.org")), sender=u.User(UserID("@sender:example.org")),
command="help", command="help",
args=[], args=[],
content=Mock(),
is_management=True, is_management=True,
is_portal=False, is_portal=False,
) )
@@ -107,6 +108,7 @@ class TestCommandEvent:
sender=u.User(UserID("@sender:example.org")), sender=u.User(UserID("@sender:example.org")),
command="help", command="help",
args=[], args=[],
content=Mock(),
is_management=False, is_management=False,
is_portal=False, is_portal=False,
) )
@@ -133,6 +135,7 @@ class TestCommandEvent:
sender=u.User(UserID("@sender:example.org")), sender=u.User(UserID("@sender:example.org")),
command="help", command="help",
args=[], args=[],
content=Mock(),
is_management=True, is_management=True,
is_portal=False, is_portal=False,
) )
@@ -209,6 +212,7 @@ class TestCommandHandler:
sender=sender, sender=sender,
command=command, command=command,
args=[], args=[],
content=Mock(),
is_management=False, is_management=False,
is_portal=boolean, is_portal=boolean,
) )
@@ -271,6 +275,7 @@ class TestCommandHandler:
sender=sender, sender=sender,
command=command, command=command,
args=[], args=[],
content=Mock(),
is_management=is_management, is_management=is_management,
is_portal=boolean, is_portal=boolean,
) )
@@ -307,6 +312,7 @@ class TestCommandProcessor:
sender=sender, sender=sender,
command="hElp", command="hElp",
args=[], args=[],
content=Mock(),
is_management=boolean2[0], is_management=boolean2[0],
is_portal=boolean2[1]) is_portal=boolean2[1])
@@ -333,6 +339,7 @@ class TestCommandProcessor:
sender=sender, sender=sender,
command="foo", command="foo",
args=[], args=[],
content=Mock(),
is_management=boolean2[0], is_management=boolean2[0],
is_portal=boolean2[1], is_portal=boolean2[1],
) )
@@ -361,6 +368,7 @@ class TestCommandProcessor:
sender=sender, # u.User sender=sender, # u.User
command="foo", command="foo",
args=[], args=[],
content=Mock(),
is_management=boolean2[0], is_management=boolean2[0],
is_portal=boolean2[1] is_portal=boolean2[1]
) )