mirror of
https://github.com/mautrix/telegram.git
synced 2026-05-16 23:15:45 +03:00
Remove everything and add stub Go module
This commit is contained in:
@@ -1,9 +1,7 @@
|
|||||||
.editorconfig
|
.editorconfig
|
||||||
.codeclimate.yml
|
|
||||||
*.png
|
*.png
|
||||||
*.md
|
*.md
|
||||||
logs
|
logs
|
||||||
.venv
|
|
||||||
start
|
start
|
||||||
config.yaml
|
config.yaml
|
||||||
registration.yaml
|
registration.yaml
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ insert_final_newline = true
|
|||||||
|
|
||||||
[*.md]
|
[*.md]
|
||||||
trim_trailing_whitespace = false
|
trim_trailing_whitespace = false
|
||||||
|
indent_size = 2
|
||||||
[*.py]
|
|
||||||
max_line_length = 99
|
|
||||||
|
|
||||||
[*.{yaml,yml,py}]
|
|
||||||
indent_style = space
|
indent_style = space
|
||||||
|
|
||||||
[{.gitlab-ci.yml,.pre-commit-config.yaml,mautrix_telegram/web/provisioning/spec.yaml}]
|
[*.{yaml,yml,sql}]
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
[{.gitlab-ci.yml,.pre-commit-config.yaml,provisioning-spec.yaml,.github/workflows/*.yml}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|||||||
35
.github/workflows/go.yml
vendored
Normal file
35
.github/workflows/go.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Go
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
go-version: ["1.21", "1.22"]
|
||||||
|
name: Lint ${{ matrix.go-version == '1.22' && '(latest)' || '(old)' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ matrix.go-version }}
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Install libolm
|
||||||
|
run: sudo apt-get install libolm-dev libolm3
|
||||||
|
|
||||||
|
- name: Install goimports
|
||||||
|
run: |
|
||||||
|
go install golang.org/x/tools/cmd/goimports@latest
|
||||||
|
export PATH="$HOME/go/bin:$PATH"
|
||||||
|
|
||||||
|
- name: Install pre-commit
|
||||||
|
run: pip install pre-commit
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pre-commit run -a
|
||||||
26
.github/workflows/python-lint.yml
vendored
26
.github/workflows/python-lint.yml
vendored
@@ -1,26 +0,0 @@
|
|||||||
name: Python lint
|
|
||||||
|
|
||||||
on: [push, pull_request]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
- uses: isort/isort-action@master
|
|
||||||
with:
|
|
||||||
sortPaths: "./mautrix_telegram"
|
|
||||||
- uses: psf/black@stable
|
|
||||||
with:
|
|
||||||
src: "./mautrix_telegram"
|
|
||||||
version: "24.1.1"
|
|
||||||
- name: pre-commit
|
|
||||||
run: |
|
|
||||||
pip install pre-commit
|
|
||||||
pre-commit run -av trailing-whitespace
|
|
||||||
pre-commit run -av end-of-file-fixer
|
|
||||||
pre-commit run -av check-yaml
|
|
||||||
pre-commit run -av check-added-large-files
|
|
||||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -1,28 +1,15 @@
|
|||||||
/.idea/
|
.idea
|
||||||
|
|
||||||
/.venv
|
|
||||||
/env/
|
|
||||||
pip-selfcheck.json
|
|
||||||
*.pyc
|
|
||||||
__pycache__
|
|
||||||
/build
|
|
||||||
/dist
|
|
||||||
/*.egg-info
|
|
||||||
/.eggs
|
|
||||||
|
|
||||||
*.yaml
|
*.yaml
|
||||||
!.pre-commit-config.yaml
|
!.pre-commit-config.yaml
|
||||||
!example-config.yaml
|
!example-config.yaml
|
||||||
!/mautrix_telegram/web/provisioning/spec.yaml
|
!provisioning-spec.yaml
|
||||||
!/.github/workflows/*.yaml
|
|
||||||
|
|
||||||
/start
|
*.json
|
||||||
/mautrix
|
|
||||||
/telethon
|
|
||||||
|
|
||||||
*.log*
|
|
||||||
*.db
|
*.db
|
||||||
*.db-*
|
*.log
|
||||||
/*.pickle
|
|
||||||
*.bak
|
*.bak
|
||||||
/*.json
|
|
||||||
|
/mautrix-telegram
|
||||||
|
/mautrix-telegramgo
|
||||||
|
/start
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
include:
|
include:
|
||||||
- project: 'mautrix/ci'
|
- project: 'mautrix/ci'
|
||||||
file: '/python.yml'
|
file: '/go.yml'
|
||||||
|
|
||||||
|
variables:
|
||||||
|
BINARY_NAME: mautrix-telegram
|
||||||
|
|||||||
1
.idea/icon.svg
generated
Normal file
1
.idea/icon.svg
generated
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg id="Livello_1" data-name="Livello 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 240 240"><defs><linearGradient id="linear-gradient" x1="120" y1="240" x2="120" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#1d93d2"/><stop offset="1" stop-color="#38b0e3"/></linearGradient></defs><title>Telegram_logo</title><circle cx="120" cy="120" r="120" fill="url(#linear-gradient)"/><path d="M81.229,128.772l14.237,39.406s1.78,3.687,3.686,3.687,30.255-29.492,30.255-29.492l31.525-60.89L81.737,118.6Z" fill="#c8daea"/><path d="M100.106,138.878l-2.733,29.046s-1.144,8.9,7.754,0,17.415-15.763,17.415-15.763" fill="#a9c6d8"/><path d="M81.486,130.178,52.2,120.636s-3.5-1.42-2.373-4.64c.232-.664.7-1.229,2.1-2.2,6.489-4.523,120.106-45.36,120.106-45.36s3.208-1.081,5.1-.362a2.766,2.766,0,0,1,1.885,2.055,9.357,9.357,0,0,1,.254,2.585c-.009.752-.1,1.449-.169,2.542-.692,11.165-21.4,94.493-21.4,94.493s-1.239,4.876-5.678,5.043A8.13,8.13,0,0,1,146.1,172.5c-8.711-7.493-38.819-27.727-45.472-32.177a1.27,1.27,0,0,1-.546-.9c-.093-.469.417-1.05.417-1.05s52.426-46.6,53.821-51.492c.108-.379-.3-.566-.848-.4-3.482,1.281-63.844,39.4-70.506,43.607A3.21,3.21,0,0,1,81.486,130.178Z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,20 +1,24 @@
|
|||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v4.5.0
|
rev: v4.6.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
exclude_types: [markdown]
|
exclude_types: [markdown]
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
- id: check-added-large-files
|
- id: check-added-large-files
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 24.1.1
|
- repo: https://github.com/tekwizely/pre-commit-golang
|
||||||
|
rev: v1.0.0-rc.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: go-imports
|
||||||
language_version: python3
|
exclude: "pb\\.go$"
|
||||||
files: ^mautrix_telegram/.*\.pyi?$
|
- id: go-vet-mod
|
||||||
- repo: https://github.com/PyCQA/isort
|
#- id: go-staticcheck-repo-mod
|
||||||
rev: 5.13.2
|
# TODO: reenable this and fix all the problems
|
||||||
|
|
||||||
|
- repo: https://github.com/beeper/pre-commit-go
|
||||||
|
rev: v0.3.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: zerolog-ban-msgf
|
||||||
files: ^mautrix_telegram/.*\.pyi?$
|
- id: zerolog-use-stringer
|
||||||
|
|||||||
68
Dockerfile
68
Dockerfile
@@ -1,59 +1,21 @@
|
|||||||
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.19
|
FROM golang:1-alpine3.19 AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
|
||||||
python3 py3-pip py3-setuptools py3-wheel \
|
|
||||||
py3-pillow \
|
|
||||||
py3-aiohttp \
|
|
||||||
py3-asyncpg \
|
|
||||||
py3-aiosqlite \
|
|
||||||
py3-magic \
|
|
||||||
py3-ruamel.yaml \
|
|
||||||
py3-commonmark \
|
|
||||||
py3-phonenumbers \
|
|
||||||
py3-mako \
|
|
||||||
#py3-prometheus-client \ (pulls in twisted unnecessarily)
|
|
||||||
# Indirect dependencies
|
|
||||||
py3-idna \
|
|
||||||
py3-rsa \
|
|
||||||
#py3-telethon \ (outdated)
|
|
||||||
py3-pyaes \
|
|
||||||
py3-aiodns \
|
|
||||||
py3-python-socks \
|
|
||||||
# cryptg
|
|
||||||
py3-cffi \
|
|
||||||
py3-qrcode \
|
|
||||||
py3-brotli \
|
|
||||||
# Other dependencies
|
|
||||||
ffmpeg \
|
|
||||||
ca-certificates \
|
|
||||||
su-exec \
|
|
||||||
netcat-openbsd \
|
|
||||||
# encryption
|
|
||||||
py3-olm \
|
|
||||||
py3-pycryptodome \
|
|
||||||
py3-unpaddedbase64 \
|
|
||||||
py3-future \
|
|
||||||
bash \
|
|
||||||
curl \
|
|
||||||
jq \
|
|
||||||
yq
|
|
||||||
|
|
||||||
COPY requirements.txt /opt/mautrix-telegram/requirements.txt
|
COPY . /build
|
||||||
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
|
WORKDIR /build
|
||||||
WORKDIR /opt/mautrix-telegram
|
RUN go build -o /usr/bin/mautrix-telegram
|
||||||
RUN apk add --virtual .build-deps python3-dev libffi-dev build-base \
|
|
||||||
&& pip3 install --break-system-packages /cryptg-*.whl \
|
|
||||||
&& pip3 install --break-system-packages --no-cache-dir -r requirements.txt -r optional-requirements.txt \
|
|
||||||
&& apk del .build-deps \
|
|
||||||
&& rm -f /cryptg-*.whl
|
|
||||||
|
|
||||||
COPY . /opt/mautrix-telegram
|
FROM alpine:3.19
|
||||||
RUN apk add git && pip3 install --break-system-packages --no-cache-dir .[all] && 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 .git build
|
|
||||||
|
|
||||||
|
ENV UID=1337 \
|
||||||
|
GID=1337
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq yq curl
|
||||||
|
|
||||||
|
COPY --from=builder /usr/bin/mautrix-telegram /usr/bin/mautrix-telegram
|
||||||
|
COPY --from=builder /build/example-config.yaml /opt/mautrix-telegram/example-config.yaml
|
||||||
|
COPY --from=builder /build/docker-run.sh /docker-run.sh
|
||||||
VOLUME /data
|
VOLUME /data
|
||||||
ENV UID=1337 GID=1337 \
|
|
||||||
FFMPEG_BINARY=/usr/bin/ffmpeg
|
|
||||||
|
|
||||||
CMD ["/opt/mautrix-telegram/docker-run.sh"]
|
CMD ["/docker-run.sh"]
|
||||||
|
|||||||
14
Dockerfile.ci
Normal file
14
Dockerfile.ci
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
ENV UID=1337 \
|
||||||
|
GID=1337
|
||||||
|
|
||||||
|
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq
|
||||||
|
|
||||||
|
ARG EXECUTABLE=./mautrix-telegram
|
||||||
|
COPY $EXECUTABLE /usr/bin/mautrix-telegram
|
||||||
|
COPY ./example-config.yaml /opt/mautrix-telegram/example-config.yaml
|
||||||
|
COPY ./docker-run.sh /docker-run.sh
|
||||||
|
VOLUME /data
|
||||||
|
|
||||||
|
CMD ["/docker-run.sh"]
|
||||||
12
LICENSE.exceptions
Normal file
12
LICENSE.exceptions
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
The mautrix-telegram developers grant the following special exceptions:
|
||||||
|
|
||||||
|
* to Beeper the right to embed the program in the Beeper clients and servers,
|
||||||
|
and use and distribute the collective work without applying the license to
|
||||||
|
the whole.
|
||||||
|
* to Element the right to distribute compiled binaries of the program as a part
|
||||||
|
of the Element Server Suite and other server bundles without applying the
|
||||||
|
license.
|
||||||
|
|
||||||
|
All exceptions are only valid under the condition that any modifications to
|
||||||
|
the source code of mautrix-telegram remain publicly available under the terms
|
||||||
|
of the GNU AGPL version 3 or later.
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
include README.md
|
|
||||||
include CHANGELOG.md
|
|
||||||
include LICENSE
|
|
||||||
include requirements.txt
|
|
||||||
include optional-requirements.txt
|
|
||||||
29
README.md
29
README.md
@@ -1,10 +1,8 @@
|
|||||||
# mautrix-telegram
|
# mautrix-telegram
|
||||||

|

|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/mautrix/telegram/releases)
|
[](https://github.com/mautrix/telegramgo/releases)
|
||||||
[](https://mau.dev/mautrix/telegram/container_registry)
|
[](https://mau.dev/mautrix/telegramgo/container_registry)
|
||||||
[](https://github.com/psf/black)
|
|
||||||
[](https://pycqa.github.io/isort/)
|
|
||||||
|
|
||||||
A Matrix-Telegram hybrid puppeting/relaybot bridge.
|
A Matrix-Telegram hybrid puppeting/relaybot bridge.
|
||||||
|
|
||||||
@@ -12,19 +10,22 @@ A Matrix-Telegram hybrid puppeting/relaybot bridge.
|
|||||||
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
|
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
The rewrite doesn't exist yet, so there's no documentation.
|
||||||
|
|
||||||
|
<!--
|
||||||
All setup and usage instructions are located on
|
All setup and usage instructions are located on
|
||||||
[docs.mau.fi](https://docs.mau.fi/bridges/python/telegram/index.html).
|
[docs.mau.fi](https://docs.mau.fi/bridges/go/telegram/index.html).
|
||||||
Some quick links:
|
Some quick links:
|
||||||
|
|
||||||
* [Bridge setup](https://docs.mau.fi/bridges/python/setup.html?bridge=telegram)
|
* [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=telegramgo)
|
||||||
(or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=telegram))
|
(or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=telegramgo))
|
||||||
* Basic usage: [Authentication](https://docs.mau.fi/bridges/python/telegram/authentication.html),
|
* Basic usage: [Authentication](https://docs.mau.fi/bridges/go/telegram/authentication.html),
|
||||||
[Creating chats](https://docs.mau.fi/bridges/python/telegram/creating-and-managing-chats.html),
|
[Creating chats](https://docs.mau.fi/bridges/go/telegram/creating-and-managing-chats.html),
|
||||||
[Relaybot setup](https://docs.mau.fi/bridges/python/telegram/relay-bot.html)
|
[Relaybot setup](https://docs.mau.fi/bridges/go/telegram/relay-bot.html)
|
||||||
|
-->
|
||||||
|
|
||||||
### Features & Roadmap
|
### Features & Roadmap
|
||||||
[ROADMAP.md](https://github.com/mautrix/telegram/blob/master/ROADMAP.md)
|
[ROADMAP.md](ROADMAP.md) contains a general overview of what is supported by the bridge.
|
||||||
contains a general overview of what is supported by the bridge.
|
|
||||||
|
|
||||||
## Discussion
|
## Discussion
|
||||||
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
|
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
|
||||||
|
|||||||
94
ROADMAP.md
94
ROADMAP.md
@@ -1,66 +1,66 @@
|
|||||||
# Features & roadmap
|
# Features & roadmap
|
||||||
|
|
||||||
* Matrix → Telegram
|
* Matrix → Telegram
|
||||||
* [x] Message content (text, formatting, files, etc..)
|
* [ ] Message content (text, formatting, files, etc..)
|
||||||
* [x] Message redactions
|
* [ ] Message redactions
|
||||||
* [x] Message reactions
|
* [ ] Message reactions
|
||||||
* [x] Message edits
|
* [ ] Message edits
|
||||||
* [ ] ‡ Message history
|
* [ ] ‡ Message history
|
||||||
* [x] Presence
|
* [ ] Presence
|
||||||
* [x] Typing notifications
|
* [ ] Typing notifications
|
||||||
* [x] Read receipts
|
* [ ] Read receipts
|
||||||
* [x] Pinning messages
|
* [ ] Pinning messages
|
||||||
* [x] Power level
|
* [ ] Power level
|
||||||
* [x] Normal chats
|
* [ ] Normal chats
|
||||||
* [ ] Non-hardcoded PL requirements
|
* [ ] Non-hardcoded PL requirements
|
||||||
* [x] Supergroups/channels
|
* [ ] Supergroups/channels
|
||||||
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
|
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
|
||||||
* [x] Membership actions (invite/kick/join/leave)
|
* [ ] Membership actions (invite/kick/join/leave)
|
||||||
* [x] Room metadata changes (name, topic, avatar)
|
* [ ] Room metadata changes (name, topic, avatar)
|
||||||
* [x] Initial room metadata
|
* [ ] Initial room metadata
|
||||||
* [ ] User metadata
|
* [ ] User metadata
|
||||||
* [ ] Initial displayname/username/avatar at register
|
* [ ] Initial displayname/username/avatar at register
|
||||||
* [ ] ‡ Changes to displayname/avatar
|
* [ ] ‡ Changes to displayname/avatar
|
||||||
* Telegram → Matrix
|
* Telegram → Matrix
|
||||||
* [x] Message content (text, formatting, files, etc..)
|
* [ ] Message content (text, formatting, files, etc..)
|
||||||
* [ ] Advanced message content/media
|
* [ ] Advanced message content/media
|
||||||
* [x] Custom emojis
|
* [ ] Custom emojis
|
||||||
* [x] Polls
|
* [ ] Polls
|
||||||
* [x] Games
|
* [ ] Games
|
||||||
* [ ] Buttons
|
* [ ] Buttons
|
||||||
* [x] Message deletions
|
* [ ] Message deletions
|
||||||
* [x] Message reactions
|
* [ ] Message reactions
|
||||||
* [x] Message edits
|
* [ ] Message edits
|
||||||
* [x] Message history
|
* [ ] Message history
|
||||||
* [x] Manually (`!tg backfill`)
|
* [ ] Manually (`!tg backfill`)
|
||||||
* [x] Automatically when creating portal
|
* [ ] Automatically when creating portal
|
||||||
* [x] Automatically for missed messages
|
* [ ] Automatically for missed messages
|
||||||
* [x] Avatars
|
* [ ] Avatars
|
||||||
* [x] Presence
|
* [ ] Presence
|
||||||
* [x] Typing notifications
|
* [ ] Typing notifications
|
||||||
* [x] Read receipts (private chat only)
|
* [ ] Read receipts (private chat only)
|
||||||
* [x] Pinning messages
|
* [ ] Pinning messages
|
||||||
* [x] Admin/chat creator status
|
* [ ] Admin/chat creator status
|
||||||
* [ ] Supergroup/channel permissions (precise per-user permissions not supported in Matrix)
|
* [ ] Supergroup/channel permissions (precise per-user permissions not supported in Matrix)
|
||||||
* [x] Membership actions (invite/kick/join/leave)
|
* [ ] Membership actions (invite/kick/join/leave)
|
||||||
* [ ] Chat metadata changes
|
* [ ] Chat metadata changes
|
||||||
* [x] Title
|
* [ ] Title
|
||||||
* [x] Avatar
|
* [ ] Avatar
|
||||||
* [ ] † About text
|
* [ ] † About text
|
||||||
* [ ] † Public channel username
|
* [ ] † Public channel username
|
||||||
* [x] Initial chat metadata (about text missing)
|
* [ ] Initial chat metadata (about text missing)
|
||||||
* [x] User metadata (displayname/avatar)
|
* [ ] User metadata (displayname/avatar)
|
||||||
* [x] Supergroup upgrade
|
* [ ] Supergroup upgrade
|
||||||
* Misc
|
* Misc
|
||||||
* [x] Automatic portal creation
|
* [ ] Automatic portal creation
|
||||||
* [x] At startup
|
* [ ] At startup
|
||||||
* [x] When receiving invite or message
|
* [ ] When receiving invite or message
|
||||||
* [x] Portal creation by inviting Matrix puppet of Telegram user to new room
|
* [ ] Portal creation by inviting Matrix puppet of Telegram user to new room
|
||||||
* [x] Option to use bot to relay messages for unauthenticated Matrix users (relaybot)
|
* [ ] Option to use bot to relay messages for unauthenticated Matrix users (relaybot)
|
||||||
* [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting)
|
* [ ] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting)
|
||||||
* [ ] ‡ Calls (hard, not yet supported by Telethon)
|
* [ ] ‡ Calls
|
||||||
* [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram)
|
* [ ] ‡ Secret chats (i.e. end-to-bridge encryption on Telegram)
|
||||||
* [x] End-to-bridge encryption in Matrix rooms (see [docs](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html))
|
* [ ] End-to-bridge encryption in Matrix rooms (see [docs](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html))
|
||||||
|
|
||||||
† 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
|
||||||
|
|||||||
4
build.sh
Executable file
4
build.sh
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
export MAUTRIX_VERSION=$(cat go.mod | grep 'maunium.net/go/mautrix ' | awk '{ print $2 }')
|
||||||
|
export GO_LDFLAGS="-s -w -X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`' -X 'maunium.net/go/mautrix.GoModVersion=$MAUTRIX_VERSION'"
|
||||||
|
go build -ldflags "$GO_LDFLAGS" -o mautrix-telegram "$@"
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
pre-commit>=2.10.1,<3
|
|
||||||
isort>=5.10.1,<6
|
|
||||||
black>=24,<25
|
|
||||||
@@ -1,17 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
if [ ! -z "$MAUTRIX_DIRECT_STARTUP" ]; then
|
|
||||||
if [ $(id -u) == 0 ]; then
|
if [[ -z "$GID" ]]; then
|
||||||
echo "|------------------------------------------|"
|
GID="$UID"
|
||||||
echo "| Warning: running bridge unsafely as root |"
|
|
||||||
echo "|------------------------------------------|"
|
|
||||||
fi
|
|
||||||
exec python3 -m mautrix_telegram -c /data/config.yaml
|
|
||||||
elif [ $(id -u) != 0 ]; then
|
|
||||||
echo "The startup script must run as root. It will use su-exec to drop permissions before running the bridge."
|
|
||||||
echo "To bypass the startup script, either set the `MAUTRIX_DIRECT_STARTUP` environment variable,"
|
|
||||||
echo "or just use `python3 -m mautrix_telegram -c /data/config.yaml` as the run command."
|
|
||||||
echo "Note that the config and registration will not be auto-generated when bypassing the startup script."
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Define functions.
|
# Define functions.
|
||||||
@@ -19,32 +9,28 @@ function fixperms {
|
|||||||
chown -R $UID:$GID /data
|
chown -R $UID:$GID /data
|
||||||
|
|
||||||
# /opt/mautrix-telegram is read-only, so disable file logging if it's pointing there.
|
# /opt/mautrix-telegram is read-only, so disable file logging if it's pointing there.
|
||||||
if [[ "$(yq e '.logging.handlers.file.filename' /data/config.yaml)" == "./mautrix-telegram.log" ]]; then
|
if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/mautrix-telegram.log" ]]; then
|
||||||
yq -I4 e -i 'del(.logging.root.handlers[] | select(. == "file"))' /data/config.yaml
|
yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml
|
||||||
yq -I4 e -i 'del(.logging.handlers.file)' /data/config.yaml
|
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
cd /opt/mautrix-telegram
|
if [[ ! -f /data/config.yaml ]]; then
|
||||||
|
cp /opt/mautrix-telegram/example-config.yaml /data/config.yaml
|
||||||
if [ ! -f /data/config.yaml ]; then
|
|
||||||
cp example-config.yaml /data/config.yaml
|
|
||||||
echo "Didn't find a config file."
|
echo "Didn't find a config file."
|
||||||
echo "Copied default config file to /data/config.yaml"
|
echo "Copied default config file to /data/config.yaml"
|
||||||
echo "Modify that config file to your liking."
|
echo "Modify that config file to your liking."
|
||||||
echo "Start the container again after that to generate the registration file."
|
echo "Start the container again after that to generate the registration file."
|
||||||
fixperms
|
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f /data/registration.yaml ]; then
|
if [[ ! -f /data/registration.yaml ]]; then
|
||||||
python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml || exit $?
|
/usr/bin/mautrix-telegram -g -c /data/config.yaml -r /data/registration.yaml || exit $?
|
||||||
echo "Didn't find a registration file."
|
echo "Didn't find a registration file."
|
||||||
echo "Generated one for you."
|
echo "Generated one for you."
|
||||||
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
|
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
|
||||||
fixperms
|
|
||||||
exit
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
cd /data
|
||||||
fixperms
|
fixperms
|
||||||
exec su-exec $UID:$GID python3 -m mautrix_telegram -c /data/config.yaml
|
exec su-exec $UID:$GID /usr/bin/mautrix-telegram
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
__version__ = "0.15.1"
|
|
||||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from telethon import __version__ as __telethon_version__
|
|
||||||
|
|
||||||
from mautrix.bridge import Bridge
|
|
||||||
from mautrix.types import RoomID, UserID
|
|
||||||
|
|
||||||
from .bot import Bot
|
|
||||||
from .config import Config
|
|
||||||
from .db import init as init_db, upgrade_table
|
|
||||||
from .matrix import MatrixHandler
|
|
||||||
from .portal import Portal
|
|
||||||
from .puppet import Puppet
|
|
||||||
from .user import User
|
|
||||||
from .version import linkified_version, version
|
|
||||||
from .web.provisioning import ProvisioningAPI
|
|
||||||
from .web.public import PublicBridgeWebsite
|
|
||||||
|
|
||||||
from .abstract_user import AbstractUser # isort: skip
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramBridge(Bridge):
|
|
||||||
module = "mautrix_telegram"
|
|
||||||
name = "mautrix-telegram"
|
|
||||||
beeper_service_name = "telegram"
|
|
||||||
beeper_network_name = "telegram"
|
|
||||||
command = "python -m mautrix-telegram"
|
|
||||||
description = "A Matrix-Telegram puppeting bridge."
|
|
||||||
repo_url = "https://github.com/mautrix/telegram"
|
|
||||||
version = version
|
|
||||||
markdown_version = linkified_version
|
|
||||||
config_class = Config
|
|
||||||
matrix_class = MatrixHandler
|
|
||||||
upgrade_table = upgrade_table
|
|
||||||
|
|
||||||
config: Config
|
|
||||||
bot: Bot | None
|
|
||||||
matrix: MatrixHandler
|
|
||||||
public_website: PublicBridgeWebsite | None
|
|
||||||
provisioning_api: ProvisioningAPI | None
|
|
||||||
|
|
||||||
def prepare_db(self) -> None:
|
|
||||||
super().prepare_db()
|
|
||||||
init_db(self.db)
|
|
||||||
|
|
||||||
def _prepare_website(self) -> None:
|
|
||||||
if self.config["appservice.provisioning.enabled"]:
|
|
||||||
self.provisioning_api = ProvisioningAPI(self)
|
|
||||||
self.az.app.add_subapp(
|
|
||||||
self.config["appservice.provisioning.prefix"], self.provisioning_api.app
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.provisioning_api = None
|
|
||||||
|
|
||||||
if self.config["appservice.public.enabled"]:
|
|
||||||
self.public_website = PublicBridgeWebsite(self.loop)
|
|
||||||
self.az.app.add_subapp(
|
|
||||||
self.config["appservice.public.prefix"], self.public_website.app
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.public_website = None
|
|
||||||
|
|
||||||
def prepare_bridge(self) -> None:
|
|
||||||
self._prepare_website()
|
|
||||||
AbstractUser.init_cls(self)
|
|
||||||
bot_token: str = self.config["telegram.bot_token"]
|
|
||||||
if bot_token and not bot_token.lower().startswith("disable"):
|
|
||||||
self.bot = AbstractUser.relaybot = Bot(bot_token)
|
|
||||||
else:
|
|
||||||
self.bot = AbstractUser.relaybot = None
|
|
||||||
self.matrix = MatrixHandler(self)
|
|
||||||
Portal.init_cls(self)
|
|
||||||
self.add_startup_actions(Puppet.init_cls(self))
|
|
||||||
self.add_startup_actions(User.init_cls(self))
|
|
||||||
self.add_startup_actions(Portal.restart_scheduled_disappearing())
|
|
||||||
if self.bot:
|
|
||||||
self.add_startup_actions(self.bot.start())
|
|
||||||
if self.config["bridge.resend_bridge_info"]:
|
|
||||||
self.add_startup_actions(self.resend_bridge_info())
|
|
||||||
|
|
||||||
async def resend_bridge_info(self) -> None:
|
|
||||||
self.config["bridge.resend_bridge_info"] = False
|
|
||||||
self.config.save()
|
|
||||||
self.log.info("Re-sending bridge info state event to all portals")
|
|
||||||
async for portal in Portal.all():
|
|
||||||
await portal.update_bridge_info()
|
|
||||||
self.log.info("Finished re-sending bridge info state events")
|
|
||||||
|
|
||||||
def prepare_stop(self) -> None:
|
|
||||||
self.add_shutdown_actions(user.stop() for user in User.by_tgid.values())
|
|
||||||
if self.bot:
|
|
||||||
self.add_shutdown_actions(self.bot.stop())
|
|
||||||
|
|
||||||
async def get_user(self, user_id: UserID, create: bool = True) -> User | None:
|
|
||||||
user = await User.get_by_mxid(user_id, create=create)
|
|
||||||
if user:
|
|
||||||
await user.ensure_started()
|
|
||||||
return user
|
|
||||||
|
|
||||||
async def get_portal(self, room_id: RoomID) -> Portal | None:
|
|
||||||
return await Portal.get_by_mxid(room_id)
|
|
||||||
|
|
||||||
async def get_puppet(self, user_id: UserID, create: bool = False) -> Puppet | None:
|
|
||||||
return await Puppet.get_by_mxid(user_id, create=create)
|
|
||||||
|
|
||||||
async def get_double_puppet(self, user_id: UserID) -> Puppet | None:
|
|
||||||
return await Puppet.get_by_custom_mxid(user_id)
|
|
||||||
|
|
||||||
def is_bridge_ghost(self, user_id: UserID) -> bool:
|
|
||||||
return bool(Puppet.get_id_from_mxid(user_id))
|
|
||||||
|
|
||||||
async def count_logged_in_users(self) -> int:
|
|
||||||
return len([user for user in User.by_tgid.values() if user.tgid])
|
|
||||||
|
|
||||||
async def manhole_global_namespace(self, user_id: UserID) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
**await super().manhole_global_namespace(user_id),
|
|
||||||
"User": User,
|
|
||||||
"Portal": Portal,
|
|
||||||
"Puppet": Puppet,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def manhole_banner_program_version(self) -> str:
|
|
||||||
return f"{super().manhole_banner_program_version} and Telethon {__telethon_version__}"
|
|
||||||
|
|
||||||
|
|
||||||
TelegramBridge().run()
|
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, Union
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import platform
|
|
||||||
import time
|
|
||||||
|
|
||||||
from telethon.errors import AuthKeyError, UnauthorizedError
|
|
||||||
from telethon.network import (
|
|
||||||
Connection,
|
|
||||||
ConnectionTcpFull,
|
|
||||||
ConnectionTcpMTProxyRandomizedIntermediate,
|
|
||||||
)
|
|
||||||
from telethon.sessions import Session
|
|
||||||
from telethon.tl.patched import Message, MessageService
|
|
||||||
from telethon.tl.types import (
|
|
||||||
Channel,
|
|
||||||
Chat,
|
|
||||||
MessageActionChannelMigrateFrom,
|
|
||||||
MessageEmpty,
|
|
||||||
PeerChannel,
|
|
||||||
PeerChat,
|
|
||||||
PeerUser,
|
|
||||||
PhoneCallRequested,
|
|
||||||
TypeUpdate,
|
|
||||||
UpdateBotMessageReaction,
|
|
||||||
UpdateChannel,
|
|
||||||
UpdateChannelUserTyping,
|
|
||||||
UpdateChatDefaultBannedRights,
|
|
||||||
UpdateChatParticipantAdmin,
|
|
||||||
UpdateChatParticipants,
|
|
||||||
UpdateChatUserTyping,
|
|
||||||
UpdateDeleteChannelMessages,
|
|
||||||
UpdateDeleteMessages,
|
|
||||||
UpdateEditChannelMessage,
|
|
||||||
UpdateEditMessage,
|
|
||||||
UpdateFolderPeers,
|
|
||||||
UpdateMessageReactions,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage,
|
|
||||||
UpdateNotifySettings,
|
|
||||||
UpdatePhoneCall,
|
|
||||||
UpdatePinnedChannelMessages,
|
|
||||||
UpdatePinnedDialogs,
|
|
||||||
UpdatePinnedMessages,
|
|
||||||
UpdateReadChannelInbox,
|
|
||||||
UpdateReadHistoryInbox,
|
|
||||||
UpdateReadHistoryOutbox,
|
|
||||||
UpdateShort,
|
|
||||||
UpdateShortChatMessage,
|
|
||||||
UpdateShortMessage,
|
|
||||||
UpdateUser,
|
|
||||||
UpdateUserName,
|
|
||||||
UpdateUserStatus,
|
|
||||||
UpdateUserTyping,
|
|
||||||
User,
|
|
||||||
UserStatusOffline,
|
|
||||||
UserStatusOnline,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.appservice import AppService
|
|
||||||
from mautrix.errors import MatrixError
|
|
||||||
from mautrix.types import PresenceState, UserID
|
|
||||||
from mautrix.util import background_task
|
|
||||||
from mautrix.util.logging import TraceLogger
|
|
||||||
from mautrix.util.opt_prometheus import Counter, Histogram
|
|
||||||
|
|
||||||
from . import __version__, portal as po, puppet as pu
|
|
||||||
from .config import Config
|
|
||||||
from .db import Message as DBMessage, PgSession
|
|
||||||
from .tgclient import MautrixTelegramClient
|
|
||||||
from .types import TelegramID
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .__main__ import TelegramBridge
|
|
||||||
from .bot import Bot
|
|
||||||
|
|
||||||
UpdateMessage = Union[
|
|
||||||
UpdateShortChatMessage,
|
|
||||||
UpdateShortMessage,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage,
|
|
||||||
UpdateEditMessage,
|
|
||||||
UpdateEditChannelMessage,
|
|
||||||
]
|
|
||||||
UpdateMessageContent = Union[
|
|
||||||
UpdateShortMessage, UpdateShortChatMessage, Message, MessageService, MessageEmpty
|
|
||||||
]
|
|
||||||
|
|
||||||
UPDATE_TIME = Histogram(
|
|
||||||
name="bridge_telegram_update",
|
|
||||||
documentation="Time spent processing Telegram updates",
|
|
||||||
labelnames=("update_type",),
|
|
||||||
)
|
|
||||||
UPDATE_ERRORS = Counter(
|
|
||||||
name="bridge_telegram_update_error",
|
|
||||||
documentation="Number of fatal errors while handling Telegram updates",
|
|
||||||
labelnames=("update_type",),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AbstractUser(ABC):
|
|
||||||
loop: asyncio.AbstractEventLoop = None
|
|
||||||
log: TraceLogger
|
|
||||||
az: AppService
|
|
||||||
bridge: "TelegramBridge"
|
|
||||||
config: Config
|
|
||||||
relaybot: "Bot"
|
|
||||||
ignore_incoming_bot_events: bool = True
|
|
||||||
max_deletions: int = 10
|
|
||||||
|
|
||||||
client: MautrixTelegramClient | None
|
|
||||||
mxid: UserID | None
|
|
||||||
|
|
||||||
tgid: TelegramID | None
|
|
||||||
username: str | None
|
|
||||||
is_bot: bool
|
|
||||||
|
|
||||||
is_relaybot: bool
|
|
||||||
|
|
||||||
puppet_whitelisted: bool
|
|
||||||
whitelisted: bool
|
|
||||||
relaybot_whitelisted: bool
|
|
||||||
matrix_puppet_whitelisted: bool
|
|
||||||
is_admin: bool
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.is_admin = False
|
|
||||||
self.matrix_puppet_whitelisted = False
|
|
||||||
self.puppet_whitelisted = False
|
|
||||||
self.whitelisted = False
|
|
||||||
self.relaybot_whitelisted = False
|
|
||||||
self.client = None
|
|
||||||
self.is_relaybot = False
|
|
||||||
self.is_bot = False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def connected(self) -> bool:
|
|
||||||
return self.client and self.client.is_connected()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _proxy_settings(self) -> tuple[type[Connection], tuple[Any, ...] | None]:
|
|
||||||
proxy_type = self.config["telegram.proxy.type"].lower()
|
|
||||||
connection = ConnectionTcpFull
|
|
||||||
connection_data = (
|
|
||||||
self.config["telegram.proxy.address"],
|
|
||||||
self.config["telegram.proxy.port"],
|
|
||||||
self.config["telegram.proxy.rdns"],
|
|
||||||
self.config["telegram.proxy.username"],
|
|
||||||
self.config["telegram.proxy.password"],
|
|
||||||
)
|
|
||||||
if proxy_type == "disabled":
|
|
||||||
connection_data = None
|
|
||||||
elif proxy_type == "socks4":
|
|
||||||
connection_data = (1,) + connection_data
|
|
||||||
elif proxy_type == "socks5":
|
|
||||||
connection_data = (2,) + connection_data
|
|
||||||
elif proxy_type == "http":
|
|
||||||
connection_data = (3,) + connection_data
|
|
||||||
elif proxy_type == "mtproxy":
|
|
||||||
connection = ConnectionTcpMTProxyRandomizedIntermediate
|
|
||||||
connection_data = (connection_data[0], connection_data[1], connection_data[4])
|
|
||||||
|
|
||||||
return connection, connection_data
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def init_cls(cls, bridge: "TelegramBridge") -> None:
|
|
||||||
cls.bridge = bridge
|
|
||||||
cls.config = bridge.config
|
|
||||||
cls.loop = bridge.loop
|
|
||||||
cls.az = bridge.az
|
|
||||||
cls.ignore_incoming_bot_events = cls.config["bridge.relaybot.ignore_own_incoming_events"]
|
|
||||||
cls.max_deletions = cls.config["bridge.max_telegram_delete"]
|
|
||||||
|
|
||||||
async def _init_client(self) -> None:
|
|
||||||
self.log.debug(f"Initializing client for {self.name}")
|
|
||||||
|
|
||||||
session = await PgSession.get(self.name)
|
|
||||||
if self.config["telegram.server.enabled"]:
|
|
||||||
session.set_dc(
|
|
||||||
self.config["telegram.server.dc"],
|
|
||||||
self.config["telegram.server.ip"],
|
|
||||||
self.config["telegram.server.port"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.is_relaybot:
|
|
||||||
base_logger = logging.getLogger("telethon.relaybot")
|
|
||||||
else:
|
|
||||||
base_logger = logging.getLogger(f"telethon.{self.tgid or -hash(self.mxid)}")
|
|
||||||
|
|
||||||
device = self.config["telegram.device_info.device_model"]
|
|
||||||
sysversion = self.config["telegram.device_info.system_version"]
|
|
||||||
appversion = self.config["telegram.device_info.app_version"]
|
|
||||||
connection, proxy = self._proxy_settings
|
|
||||||
if proxy:
|
|
||||||
self.log.debug(f"Using proxy setting: {proxy}")
|
|
||||||
|
|
||||||
assert isinstance(session, Session)
|
|
||||||
|
|
||||||
self.client = MautrixTelegramClient(
|
|
||||||
session=session,
|
|
||||||
api_id=self.config["telegram.api_id"],
|
|
||||||
api_hash=self.config["telegram.api_hash"],
|
|
||||||
app_version=__version__ if appversion == "auto" else appversion,
|
|
||||||
system_version=(
|
|
||||||
MautrixTelegramClient.__version__ if sysversion == "auto" else sysversion
|
|
||||||
),
|
|
||||||
device_model=(
|
|
||||||
f"{platform.system()} {platform.release()}" if device == "auto" else device
|
|
||||||
),
|
|
||||||
timeout=self.config["telegram.connection.timeout"],
|
|
||||||
connection_retries=self.config["telegram.connection.retries"],
|
|
||||||
retry_delay=self.config["telegram.connection.retry_delay"],
|
|
||||||
flood_sleep_threshold=self.config["telegram.connection.flood_sleep_threshold"],
|
|
||||||
request_retries=self.config["telegram.connection.request_retries"],
|
|
||||||
connection=connection,
|
|
||||||
proxy=proxy,
|
|
||||||
raise_last_call_error=True,
|
|
||||||
catch_up=self.config["telegram.catch_up"],
|
|
||||||
sequential_updates=self.config["telegram.sequential_updates"],
|
|
||||||
loop=self.loop,
|
|
||||||
base_logger=base_logger,
|
|
||||||
update_error_callback=self._telethon_update_error_callback,
|
|
||||||
use_ipv6=self.config["telegram.connection.use_ipv6"],
|
|
||||||
)
|
|
||||||
self.client.add_event_handler(self._update_catch)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def _telethon_update_error_callback(self, err: Exception) -> None:
|
|
||||||
if isinstance(err, (UnauthorizedError, AuthKeyError)):
|
|
||||||
background_task.create(self.on_signed_out(err))
|
|
||||||
return
|
|
||||||
if self.config["telegram.exit_on_update_error"]:
|
|
||||||
self.log.critical(f"Stopping due to update handling error {type(err).__name__}")
|
|
||||||
self.bridge.manual_stop(50)
|
|
||||||
else:
|
|
||||||
self.log.info("Recreating Telethon connection in 60 seconds")
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
self.log.debug("Now recreating Telethon connection")
|
|
||||||
await self.stop()
|
|
||||||
await self.start()
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def update(self, update: TypeUpdate) -> bool:
|
|
||||||
return False
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def post_login(self) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def register_portal(self, portal: po.Portal) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def unregister_portal(self, tgid: int, tg_receiver: int) -> None:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
async def _update_catch(self, update: TypeUpdate) -> None:
|
|
||||||
start_time = time.time()
|
|
||||||
update_type = type(update).__name__
|
|
||||||
try:
|
|
||||||
if not await self.update(update):
|
|
||||||
await self._update(update)
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Failed to handle Telegram update")
|
|
||||||
UPDATE_ERRORS.labels(update_type=update_type).inc()
|
|
||||||
UPDATE_TIME.labels(update_type=update_type).observe(time.time() - start_time)
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def name(self) -> str:
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
async def is_logged_in(self) -> bool:
|
|
||||||
return (
|
|
||||||
self.client and self.client.is_connected() and await self.client.is_user_authorized()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def has_full_access(self, allow_bot: bool = False) -> bool:
|
|
||||||
return (
|
|
||||||
self.puppet_whitelisted
|
|
||||||
and (not self.is_bot or allow_bot)
|
|
||||||
and await self.is_logged_in()
|
|
||||||
)
|
|
||||||
|
|
||||||
async def start(self, delete_unless_authenticated: bool = False) -> AbstractUser:
|
|
||||||
if not self.client:
|
|
||||||
await self._init_client()
|
|
||||||
attempts = 1
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
await self.client.connect()
|
|
||||||
except Exception:
|
|
||||||
attempts += 1
|
|
||||||
if attempts > 10:
|
|
||||||
raise
|
|
||||||
self.log.exception("Exception connecting to Telegram, retrying in 5s...")
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
self.log.debug(f"{'Bot' if self.is_relaybot else self.mxid} connected: {self.connected}")
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def ensure_started(self, even_if_no_session=False) -> AbstractUser:
|
|
||||||
if self.connected:
|
|
||||||
return self
|
|
||||||
session_exists = await PgSession.has(self.mxid)
|
|
||||||
if even_if_no_session or session_exists:
|
|
||||||
self.log.debug(
|
|
||||||
f"Starting client due to ensure_started({even_if_no_session=}, {session_exists=})"
|
|
||||||
)
|
|
||||||
await self.start(delete_unless_authenticated=not even_if_no_session)
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
if self.client:
|
|
||||||
await self.client.disconnect()
|
|
||||||
self.client = None
|
|
||||||
|
|
||||||
# region Telegram update handling
|
|
||||||
|
|
||||||
async def _update(self, update: TypeUpdate) -> None:
|
|
||||||
if isinstance(update, UpdateShort):
|
|
||||||
update = update.update
|
|
||||||
background_task.create(self._handle_entity_updates(getattr(update, "_entities", {})))
|
|
||||||
if isinstance(
|
|
||||||
update,
|
|
||||||
(
|
|
||||||
UpdateShortChatMessage,
|
|
||||||
UpdateShortMessage,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage,
|
|
||||||
UpdateEditMessage,
|
|
||||||
UpdateEditChannelMessage,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
await self.update_message(update)
|
|
||||||
elif isinstance(update, UpdateDeleteMessages):
|
|
||||||
await self.delete_message(update)
|
|
||||||
elif isinstance(update, UpdateDeleteChannelMessages):
|
|
||||||
await self.delete_channel_message(update)
|
|
||||||
elif isinstance(update, UpdatePhoneCall):
|
|
||||||
await self.update_phone_call(update)
|
|
||||||
elif isinstance(update, UpdateMessageReactions):
|
|
||||||
await self.update_reactions(update)
|
|
||||||
elif isinstance(update, UpdateBotMessageReaction):
|
|
||||||
await self.update_bot_reactions(update)
|
|
||||||
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
|
|
||||||
await self.update_typing(update)
|
|
||||||
elif isinstance(update, UpdateUserStatus):
|
|
||||||
await self.update_status(update)
|
|
||||||
elif isinstance(update, UpdateChatParticipantAdmin):
|
|
||||||
await self.update_admin(update)
|
|
||||||
elif isinstance(update, UpdateChatParticipants):
|
|
||||||
await self.update_participants(update)
|
|
||||||
elif isinstance(update, UpdateChatDefaultBannedRights):
|
|
||||||
await self.update_default_banned_rights(update)
|
|
||||||
elif isinstance(update, (UpdatePinnedMessages, UpdatePinnedChannelMessages)):
|
|
||||||
await self.update_pinned_messages(update)
|
|
||||||
elif isinstance(update, (UpdateUserName, UpdateUser)):
|
|
||||||
await self.update_others_info(update)
|
|
||||||
elif isinstance(update, UpdateReadHistoryOutbox):
|
|
||||||
await self.update_read_receipt(update)
|
|
||||||
elif isinstance(update, (UpdateReadHistoryInbox, UpdateReadChannelInbox)):
|
|
||||||
await self.update_own_read_receipt(update)
|
|
||||||
elif isinstance(update, UpdateFolderPeers):
|
|
||||||
await self.update_folder_peers(update)
|
|
||||||
elif isinstance(update, UpdatePinnedDialogs):
|
|
||||||
await self.update_pinned_dialogs(update)
|
|
||||||
elif isinstance(update, UpdateNotifySettings):
|
|
||||||
await self.update_notify_settings(update)
|
|
||||||
elif isinstance(update, UpdateChannel):
|
|
||||||
await self.update_channel(update)
|
|
||||||
else:
|
|
||||||
self.log.trace("Unhandled update: %s", update)
|
|
||||||
|
|
||||||
async def update_folder_peers(self, update: UpdateFolderPeers) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def update_pinned_dialogs(self, update: UpdatePinnedDialogs) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def update_notify_settings(self, update: UpdateNotifySettings) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def update_pinned_messages(
|
|
||||||
self, update: UpdatePinnedMessages | UpdatePinnedChannelMessages
|
|
||||||
) -> None:
|
|
||||||
if isinstance(update, UpdatePinnedMessages):
|
|
||||||
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
|
|
||||||
else:
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
|
||||||
if portal and portal.mxid:
|
|
||||||
await portal.receive_telegram_pin_ids(
|
|
||||||
update.messages, self.tgid, remove=not update.pinned
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_participants(update: UpdateChatParticipants) -> None:
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
|
|
||||||
if portal and portal.mxid:
|
|
||||||
await portal.update_power_levels(update.participants.participants)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def update_default_banned_rights(update: UpdateChatDefaultBannedRights) -> None:
|
|
||||||
portal = await po.Portal.get_by_entity(update.peer)
|
|
||||||
if portal and portal.mxid:
|
|
||||||
await portal.update_default_banned_rights(update.default_banned_rights)
|
|
||||||
|
|
||||||
async def update_read_receipt(self, update: UpdateReadHistoryOutbox) -> None:
|
|
||||||
if not isinstance(update.peer, PeerUser):
|
|
||||||
self.log.debug("Unexpected read receipt peer: %s", update.peer)
|
|
||||||
return
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_tgid(
|
|
||||||
TelegramID(update.peer.user_id), tg_receiver=self.tgid
|
|
||||||
)
|
|
||||||
if not portal or not portal.mxid:
|
|
||||||
return
|
|
||||||
|
|
||||||
# We check that these are user read receipts, so tg_space is always the user ID.
|
|
||||||
message = await DBMessage.get_one_by_tgid(
|
|
||||||
TelegramID(update.max_id), self.tgid, edit_index=-1
|
|
||||||
)
|
|
||||||
if not message:
|
|
||||||
return
|
|
||||||
|
|
||||||
puppet = await pu.Puppet.get_by_peer(update.peer)
|
|
||||||
await puppet.intent.mark_read(portal.mxid, message.mxid)
|
|
||||||
|
|
||||||
async def update_own_read_receipt(
|
|
||||||
self, update: UpdateReadHistoryInbox | UpdateReadChannelInbox
|
|
||||||
) -> None:
|
|
||||||
puppet = await pu.Puppet.get_by_tgid(self.tgid)
|
|
||||||
if not puppet.is_real_user:
|
|
||||||
return
|
|
||||||
|
|
||||||
self.log.debug("Handling own read receipt: %s", update)
|
|
||||||
if isinstance(update, UpdateReadChannelInbox):
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
|
||||||
elif isinstance(update.peer, PeerChat):
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.peer.chat_id))
|
|
||||||
elif isinstance(update.peer, PeerUser):
|
|
||||||
portal = await po.Portal.get_by_tgid(
|
|
||||||
TelegramID(update.peer.user_id), tg_receiver=self.tgid
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.log.debug("Unexpected own read receipt peer: %s", update.peer)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not portal or not portal.mxid:
|
|
||||||
# TODO This explodes on channels because the field is channel_id
|
|
||||||
self.log.debug(f"Dropping own read receipt in unknown chat ({update.peer})")
|
|
||||||
return
|
|
||||||
|
|
||||||
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
|
|
||||||
message = await DBMessage.get_one_by_tgid(
|
|
||||||
TelegramID(update.max_id), tg_space, edit_index=-1
|
|
||||||
)
|
|
||||||
if not message:
|
|
||||||
self.log.debug(
|
|
||||||
f"Dropping own read receipt: unknown message {update.max_id}@{tg_space}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
await puppet.intent.mark_read(portal.mxid, message.mxid)
|
|
||||||
|
|
||||||
async def update_admin(self, update: UpdateChatParticipantAdmin) -> None:
|
|
||||||
# TODO duplication not checked
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
|
||||||
if not portal or not portal.mxid:
|
|
||||||
return
|
|
||||||
|
|
||||||
await portal.set_telegram_admin(TelegramID(update.user_id))
|
|
||||||
|
|
||||||
async def update_typing(
|
|
||||||
self, update: UpdateUserTyping | UpdateChatUserTyping | UpdateChannelUserTyping
|
|
||||||
) -> None:
|
|
||||||
sender = None
|
|
||||||
if isinstance(update, UpdateUserTyping):
|
|
||||||
portal = await po.Portal.get_by_tgid(
|
|
||||||
TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user"
|
|
||||||
)
|
|
||||||
sender = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
|
|
||||||
elif isinstance(update, UpdateChannelUserTyping):
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
|
||||||
elif isinstance(update, UpdateChatUserTyping):
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id))
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(update, (UpdateChannelUserTyping, UpdateChatUserTyping)):
|
|
||||||
sender = await pu.Puppet.get_by_peer(update.from_id)
|
|
||||||
|
|
||||||
if not sender or not portal or not portal.mxid:
|
|
||||||
return
|
|
||||||
|
|
||||||
await portal.handle_telegram_typing(sender, update)
|
|
||||||
|
|
||||||
async def _handle_entity_updates(self, entities: dict[int, User | Chat | Channel]) -> None:
|
|
||||||
try:
|
|
||||||
users = (entity for entity in entities.values() if isinstance(entity, (User, Channel)))
|
|
||||||
puppets = ((await pu.Puppet.get_by_peer(user), user) for user in users)
|
|
||||||
await asyncio.gather(
|
|
||||||
*[puppet.try_update_info(self, info) async for puppet, info in puppets if puppet]
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Failed to handle entity updates")
|
|
||||||
|
|
||||||
async def update_others_info(self, update: UpdateUserName | UpdateUser) -> None:
|
|
||||||
# TODO duplication not checked
|
|
||||||
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
|
|
||||||
if isinstance(update, UpdateUserName):
|
|
||||||
if len(update.usernames) > 1:
|
|
||||||
self.log.warning(
|
|
||||||
"Got update with multiple usernames (%s) for %s, only saving first one",
|
|
||||||
update.usernames,
|
|
||||||
update.user_id,
|
|
||||||
)
|
|
||||||
puppet.username = update.usernames[0].username if update.usernames else None
|
|
||||||
if await puppet.update_displayname(self, update):
|
|
||||||
await puppet.save()
|
|
||||||
await puppet.update_portals_meta()
|
|
||||||
elif isinstance(update, UpdateUser):
|
|
||||||
info = await self.client.get_entity(puppet.peer)
|
|
||||||
await puppet.update_info(self, info)
|
|
||||||
else:
|
|
||||||
self.log.warning(f"Unexpected other user info update: {type(update)}")
|
|
||||||
|
|
||||||
async def update_status(self, update: UpdateUserStatus) -> None:
|
|
||||||
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
|
|
||||||
if isinstance(update.status, UserStatusOnline):
|
|
||||||
await puppet.default_mxid_intent.set_presence(PresenceState.ONLINE)
|
|
||||||
elif isinstance(update.status, UserStatusOffline):
|
|
||||||
await puppet.default_mxid_intent.set_presence(PresenceState.OFFLINE)
|
|
||||||
else:
|
|
||||||
self.log.warning(f"Unexpected user status update: type({update})")
|
|
||||||
return
|
|
||||||
|
|
||||||
async def get_message_details(
|
|
||||||
self, update: UpdateMessage
|
|
||||||
) -> tuple[UpdateMessageContent, pu.Puppet | None, po.Portal | None]:
|
|
||||||
if isinstance(update, UpdateShortChatMessage):
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
|
|
||||||
sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id))
|
|
||||||
elif isinstance(update, UpdateShortMessage):
|
|
||||||
portal = await po.Portal.get_by_tgid(
|
|
||||||
TelegramID(update.user_id), tg_receiver=self.tgid, peer_type="user"
|
|
||||||
)
|
|
||||||
sender = await pu.Puppet.get_by_tgid(self.tgid if update.out else update.user_id)
|
|
||||||
elif isinstance(
|
|
||||||
update,
|
|
||||||
(
|
|
||||||
UpdateNewMessage,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateEditMessage,
|
|
||||||
UpdateEditChannelMessage,
|
|
||||||
),
|
|
||||||
):
|
|
||||||
update = update.message
|
|
||||||
if isinstance(update, MessageEmpty):
|
|
||||||
return update, None, None
|
|
||||||
portal = await po.Portal.get_by_entity(update.peer_id, tg_receiver=self.tgid)
|
|
||||||
if update.out:
|
|
||||||
sender = await pu.Puppet.get_by_tgid(self.tgid)
|
|
||||||
elif isinstance(update.from_id, (PeerUser, PeerChannel)):
|
|
||||||
sender = await pu.Puppet.get_by_peer(update.from_id)
|
|
||||||
elif isinstance(update.peer_id, PeerUser):
|
|
||||||
sender = await pu.Puppet.get_by_peer(update.peer_id)
|
|
||||||
else:
|
|
||||||
sender = None
|
|
||||||
else:
|
|
||||||
self.log.warning(
|
|
||||||
f"Unexpected message type in User#get_message_details: {type(update)}"
|
|
||||||
)
|
|
||||||
return update, None, None
|
|
||||||
return update, sender, portal
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _try_redact(message: DBMessage) -> None:
|
|
||||||
portal = await po.Portal.get_by_mxid(message.mx_room)
|
|
||||||
if not portal:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await portal.main_intent.redact(message.mx_room, message.mxid)
|
|
||||||
except MatrixError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def delete_message(self, update: UpdateDeleteMessages) -> None:
|
|
||||||
if len(update.messages) > self.max_deletions:
|
|
||||||
return
|
|
||||||
|
|
||||||
for message_id in update.messages:
|
|
||||||
for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid):
|
|
||||||
if message.redacted:
|
|
||||||
continue
|
|
||||||
await message.delete()
|
|
||||||
number_left = await DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
|
|
||||||
if number_left == 0:
|
|
||||||
await self._try_redact(message)
|
|
||||||
|
|
||||||
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
|
|
||||||
if len(update.messages) > self.max_deletions:
|
|
||||||
return
|
|
||||||
|
|
||||||
channel_id = TelegramID(update.channel_id)
|
|
||||||
|
|
||||||
for message_id in update.messages:
|
|
||||||
for message in await DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id):
|
|
||||||
if message.redacted:
|
|
||||||
continue
|
|
||||||
await message.delete()
|
|
||||||
await self._try_redact(message)
|
|
||||||
|
|
||||||
async def update_reactions(self, update: UpdateMessageReactions) -> None:
|
|
||||||
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
|
|
||||||
if not portal or not portal.mxid or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions)
|
|
||||||
|
|
||||||
async def update_bot_reactions(self, update: UpdateBotMessageReaction) -> None:
|
|
||||||
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
|
|
||||||
if not portal or not portal.mxid or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
await portal.handle_telegram_bot_reactions(self, update)
|
|
||||||
|
|
||||||
async def update_phone_call(self, update: UpdatePhoneCall) -> None:
|
|
||||||
self.log.debug("Phone call update %s", update)
|
|
||||||
if not isinstance(update.phone_call, PhoneCallRequested):
|
|
||||||
return
|
|
||||||
tgid = TelegramID(update.phone_call.participant_id)
|
|
||||||
if tgid == self.tgid:
|
|
||||||
tgid = update.phone_call.admin_id
|
|
||||||
portal = await po.Portal.get_by_tgid(tgid, tg_receiver=self.tgid, peer_type="user")
|
|
||||||
if not portal or not portal.mxid or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
sender = await pu.Puppet.get_by_tgid(TelegramID(update.phone_call.admin_id))
|
|
||||||
await portal.handle_telegram_direct_call(self, sender, update)
|
|
||||||
|
|
||||||
async def update_channel(self, update: UpdateChannel) -> None:
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
|
||||||
if not portal:
|
|
||||||
return
|
|
||||||
if getattr(update, "mau_telethon_is_leave", False):
|
|
||||||
self.log.debug("UpdateChannel has mau_telethon_is_leave, leaving portal")
|
|
||||||
await portal.delete_telegram_user(self.tgid, sender=None)
|
|
||||||
elif chan := getattr(update, "mau_channel", None):
|
|
||||||
if not portal.mxid:
|
|
||||||
if (
|
|
||||||
not self.is_relaybot
|
|
||||||
or not self.config["bridge.relaybot.ignore_unbridged_group_chat"]
|
|
||||||
):
|
|
||||||
background_task.create(self._delayed_create_channel(chan))
|
|
||||||
else:
|
|
||||||
self.log.debug("Updating channel info with data fetched by Telethon")
|
|
||||||
await portal.update_info(self, chan)
|
|
||||||
await portal.invite_to_matrix(self.mxid)
|
|
||||||
|
|
||||||
async def _delayed_create_channel(self, chan: Channel) -> None:
|
|
||||||
self.log.debug(
|
|
||||||
f"Waiting 5 seconds before handling UpdateChannel for non-existent portal {chan.id}"
|
|
||||||
)
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(chan.id))
|
|
||||||
if portal.mxid:
|
|
||||||
self.log.debug(
|
|
||||||
"Portal started existing after waiting 5 seconds, "
|
|
||||||
f"dropping UpdateChannel for {portal.tgid}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
self.log.info(
|
|
||||||
f"Creating Matrix room for {portal.tgid}"
|
|
||||||
" with data fetched by Telethon due to UpdateChannel"
|
|
||||||
)
|
|
||||||
await portal.create_matrix_room(self, chan, invites=[self.mxid])
|
|
||||||
|
|
||||||
async def _check_server_notice_edit(self, message: Message) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def update_message(self, original_update: UpdateMessage) -> None:
|
|
||||||
update, sender, portal = await self.get_message_details(original_update)
|
|
||||||
if not portal:
|
|
||||||
return
|
|
||||||
elif portal and not portal.allow_bridging:
|
|
||||||
self.log.debug(
|
|
||||||
f"Ignoring message {update.id} in portal {portal.tgid_log} (bridging disallowed)"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not portal.mxid and getattr(original_update, "mau_left_channel", False):
|
|
||||||
self.log.debug(
|
|
||||||
f"Ignoring message {update.id} in portal {portal.tgid_log} because user isn't in the chat"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.is_relaybot:
|
|
||||||
if update.is_private:
|
|
||||||
if not self.config["bridge.relaybot.private_chat.invite"]:
|
|
||||||
if sender:
|
|
||||||
self.log.debug(f"Ignoring private message to bot from {sender.id}")
|
|
||||||
return
|
|
||||||
elif not portal.mxid and self.config["bridge.relaybot.ignore_unbridged_group_chat"]:
|
|
||||||
self.log.debug(
|
|
||||||
f"Ignoring message received by bot in unbridged chat {portal.tgid_log}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if (
|
|
||||||
self.ignore_incoming_bot_events
|
|
||||||
and self.relaybot
|
|
||||||
and sender
|
|
||||||
and sender.id == self.relaybot.tgid
|
|
||||||
):
|
|
||||||
self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
|
|
||||||
return
|
|
||||||
|
|
||||||
task = self._call_portal_message_handler(update, original_update, portal, sender)
|
|
||||||
if portal.backfill_lock.locked:
|
|
||||||
self.log.debug(
|
|
||||||
f"{portal.tgid_log} is backfill locked, moving incoming message to async task"
|
|
||||||
)
|
|
||||||
background_task.create(task)
|
|
||||||
else:
|
|
||||||
await task
|
|
||||||
|
|
||||||
async def _call_portal_message_handler(
|
|
||||||
self,
|
|
||||||
update: UpdateMessageContent,
|
|
||||||
original_update: UpdateMessage,
|
|
||||||
portal: po.Portal,
|
|
||||||
sender: pu.Puppet,
|
|
||||||
) -> None:
|
|
||||||
await portal.backfill_lock.wait(f"update {update.id}")
|
|
||||||
|
|
||||||
if isinstance(update, MessageService):
|
|
||||||
if isinstance(update.action, MessageActionChannelMigrateFrom):
|
|
||||||
self.log.debug(
|
|
||||||
"Received %s in %s by %d, unregistering portal...",
|
|
||||||
update.action,
|
|
||||||
portal.tgid_log,
|
|
||||||
sender.id,
|
|
||||||
)
|
|
||||||
await self.unregister_portal(update.action.chat_id, update.action.chat_id)
|
|
||||||
await self.register_portal(portal)
|
|
||||||
return
|
|
||||||
self.log.debug(
|
|
||||||
"Handling action %s to %s by %d",
|
|
||||||
update.action,
|
|
||||||
portal.tgid_log,
|
|
||||||
(sender.id if sender else 0),
|
|
||||||
)
|
|
||||||
return await portal.handle_telegram_action(self, sender, update)
|
|
||||||
|
|
||||||
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
|
|
||||||
if sender and sender.tgid == 777000:
|
|
||||||
await self._check_server_notice_edit(update)
|
|
||||||
return await portal.handle_telegram_edit(self, sender, update)
|
|
||||||
return await portal.handle_telegram_message(self, sender, update)
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Awaitable, Callable, Literal
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
|
|
||||||
from telethon.errors import (
|
|
||||||
AuthKeyError,
|
|
||||||
ChannelInvalidError,
|
|
||||||
ChannelPrivateError,
|
|
||||||
UnauthorizedError,
|
|
||||||
)
|
|
||||||
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
|
|
||||||
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
|
|
||||||
from telethon.tl.patched import Message, MessageService
|
|
||||||
from telethon.tl.types import (
|
|
||||||
ChannelParticipantAdmin,
|
|
||||||
ChannelParticipantCreator,
|
|
||||||
ChatForbidden,
|
|
||||||
ChatParticipantAdmin,
|
|
||||||
ChatParticipantCreator,
|
|
||||||
ChatParticipantsForbidden,
|
|
||||||
InputChannel,
|
|
||||||
InputUser,
|
|
||||||
MessageActionChatAddUser,
|
|
||||||
MessageActionChatDeleteUser,
|
|
||||||
MessageActionChatMigrateTo,
|
|
||||||
MessageEntityBotCommand,
|
|
||||||
PeerChannel,
|
|
||||||
PeerChat,
|
|
||||||
PeerUser,
|
|
||||||
TypeChannelParticipant,
|
|
||||||
TypeChatParticipant,
|
|
||||||
TypeInputPeer,
|
|
||||||
TypePeer,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from telethon.utils import add_surrogate, del_surrogate
|
|
||||||
|
|
||||||
from mautrix.errors import MBadState, MForbidden
|
|
||||||
from mautrix.types import RoomID, UserID
|
|
||||||
|
|
||||||
from . import portal as po, puppet as pu, user as u
|
|
||||||
from .abstract_user import AbstractUser
|
|
||||||
from .db import BotChat, Message as DBMessage
|
|
||||||
from .types import TelegramID
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from asyncio import Future
|
|
||||||
|
|
||||||
ReplyFunc = Callable[[str], Awaitable[Message]]
|
|
||||||
BanFunc = Callable[[RoomID, UserID, str], Awaitable[None]]
|
|
||||||
TelegramAdminPermission = Literal[
|
|
||||||
"change_info",
|
|
||||||
"post_messages",
|
|
||||||
"edit_messages",
|
|
||||||
"delete_messages",
|
|
||||||
"ban_users",
|
|
||||||
"invite_users",
|
|
||||||
"pin_messages",
|
|
||||||
"add_admins",
|
|
||||||
"anonymous",
|
|
||||||
"manage_call",
|
|
||||||
"other",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class Bot(AbstractUser):
|
|
||||||
log: logging.Logger = logging.getLogger("mau.user.bot")
|
|
||||||
|
|
||||||
token: str
|
|
||||||
chats: dict[int, str]
|
|
||||||
tg_whitelist: list[int]
|
|
||||||
whitelist_group_admins: bool
|
|
||||||
_me_info: User | None
|
|
||||||
_me_mxid: UserID | None
|
|
||||||
_admin_cache: dict[
|
|
||||||
tuple[int, int],
|
|
||||||
tuple[ChatParticipantAdmin | ChatParticipantCreator | None, float],
|
|
||||||
]
|
|
||||||
_login_wait_fut: Future | None
|
|
||||||
required_permissions: dict[str, TelegramAdminPermission] = {
|
|
||||||
"portal": None,
|
|
||||||
"invite": "invite_users",
|
|
||||||
"mxban": "ban_users",
|
|
||||||
"mxkick": "ban_users",
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(self, token: str) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.token = token
|
|
||||||
self.tgid = None
|
|
||||||
self.mxid = None
|
|
||||||
self.puppet_whitelisted = True
|
|
||||||
self.whitelisted = True
|
|
||||||
self.relaybot_whitelisted = True
|
|
||||||
self.tg_username = None
|
|
||||||
self.is_relaybot = True
|
|
||||||
self.is_bot = True
|
|
||||||
self.chats = {}
|
|
||||||
self._admin_cache = {}
|
|
||||||
self.tg_whitelist = []
|
|
||||||
self.whitelist_group_admins = (
|
|
||||||
self.config["bridge.relaybot.whitelist_group_admins"] or False
|
|
||||||
)
|
|
||||||
self._me_info = None
|
|
||||||
self._me_mxid = None
|
|
||||||
self._login_wait_fut = self.loop.create_future()
|
|
||||||
|
|
||||||
async def get_me(self, use_cache: bool = True) -> tuple[User, UserID]:
|
|
||||||
if not use_cache or not self._me_mxid:
|
|
||||||
self._me_info = await self.client.get_me()
|
|
||||||
self._me_mxid = pu.Puppet.get_mxid_from_id(TelegramID(self._me_info.id))
|
|
||||||
return self._me_info, self._me_mxid
|
|
||||||
|
|
||||||
async def init_permissions(self) -> None:
|
|
||||||
whitelist = self.config["bridge.relaybot.whitelist"] or []
|
|
||||||
for user_id in whitelist:
|
|
||||||
if isinstance(user_id, str):
|
|
||||||
entity = await self.client.get_input_entity(user_id)
|
|
||||||
if isinstance(entity, InputUser):
|
|
||||||
user_id = entity.user_id
|
|
||||||
else:
|
|
||||||
user_id = None
|
|
||||||
if isinstance(user_id, int):
|
|
||||||
self.tg_whitelist.append(user_id)
|
|
||||||
|
|
||||||
async def start(self, delete_unless_authenticated: bool = False) -> Bot:
|
|
||||||
self.chats = {chat.id: chat.type for chat in await BotChat.all()}
|
|
||||||
await super().start(delete_unless_authenticated)
|
|
||||||
if not await self.is_logged_in():
|
|
||||||
await self.client.sign_in(bot_token=self.token)
|
|
||||||
await self.post_login()
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
|
|
||||||
self.log.fatal("Relay bot got signed out, crashing bridge", exc_info=err)
|
|
||||||
self.bridge.manual_stop(51)
|
|
||||||
|
|
||||||
async def post_login(self) -> None:
|
|
||||||
await self.init_permissions()
|
|
||||||
info = await self.client.get_me()
|
|
||||||
self.tgid = TelegramID(info.id)
|
|
||||||
self.tg_username = info.username
|
|
||||||
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
|
|
||||||
if self._login_wait_fut:
|
|
||||||
self._login_wait_fut.set_result(None)
|
|
||||||
self._login_wait_fut = None
|
|
||||||
|
|
||||||
chat_ids = [chat_id for chat_id, chat_type in self.chats.items() if chat_type == "chat"]
|
|
||||||
response = await self.client(GetChatsRequest(chat_ids))
|
|
||||||
for chat in response.chats:
|
|
||||||
if isinstance(chat, ChatForbidden) or chat.left or chat.deactivated:
|
|
||||||
await self.remove_chat(TelegramID(chat.id))
|
|
||||||
|
|
||||||
channel_ids = [
|
|
||||||
InputChannel(chat_id, 0)
|
|
||||||
for chat_id, chat_type in self.chats.items()
|
|
||||||
if chat_type == "channel"
|
|
||||||
]
|
|
||||||
for channel_id in channel_ids:
|
|
||||||
try:
|
|
||||||
await self.client(GetChannelsRequest([channel_id]))
|
|
||||||
except (ChannelPrivateError, ChannelInvalidError):
|
|
||||||
await self.remove_chat(TelegramID(channel_id.channel_id))
|
|
||||||
|
|
||||||
async def register_portal(self, portal: po.Portal) -> None:
|
|
||||||
await self.add_chat(portal.tgid, portal.peer_type)
|
|
||||||
|
|
||||||
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
|
|
||||||
await self.remove_chat(tgid)
|
|
||||||
|
|
||||||
async def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
|
|
||||||
if chat_id not in self.chats:
|
|
||||||
self.chats[chat_id] = chat_type
|
|
||||||
await BotChat(id=chat_id, type=chat_type).insert()
|
|
||||||
|
|
||||||
async def remove_chat(self, chat_id: TelegramID) -> None:
|
|
||||||
try:
|
|
||||||
del self.chats[chat_id]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
await BotChat.delete_by_id(chat_id)
|
|
||||||
|
|
||||||
async def _get_admin_participant(
|
|
||||||
self, chat: TypePeer | TypeInputPeer, tgid: TelegramID
|
|
||||||
) -> TypeChatParticipant | TypeChannelParticipant | None:
|
|
||||||
chan_id = chat.channel_id if isinstance(chat, PeerChannel) else chat.chat_id
|
|
||||||
try:
|
|
||||||
cached, created = self._admin_cache[chan_id, tgid]
|
|
||||||
if created + 60 < time.time():
|
|
||||||
return cached
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
if isinstance(chat, PeerChannel):
|
|
||||||
p = await self.client(GetParticipantRequest(chat, tgid))
|
|
||||||
pcp = p.participant
|
|
||||||
self._admin_cache[chat.channel_id, tgid] = (pcp, time.time())
|
|
||||||
return pcp
|
|
||||||
elif isinstance(chat, PeerChat):
|
|
||||||
chat = await self.client(GetFullChatRequest(chat.chat_id))
|
|
||||||
if isinstance(chat.full_chat.participants, ChatParticipantsForbidden):
|
|
||||||
return None
|
|
||||||
participants = chat.full_chat.participants.participants
|
|
||||||
for p in participants:
|
|
||||||
self._admin_cache[chat.channel_id, tgid] = (p, time.time())
|
|
||||||
if p.user_id == tgid:
|
|
||||||
return p
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _has_participant_permission(
|
|
||||||
pcp: TypeChatParticipant | TypeChannelParticipant | None,
|
|
||||||
permission: TelegramAdminPermission | None,
|
|
||||||
) -> bool:
|
|
||||||
if isinstance(pcp, (ChannelParticipantCreator, ChannelParticipantAdmin)):
|
|
||||||
return permission is None or getattr(pcp.admin_rights, permission, False)
|
|
||||||
elif isinstance(pcp, (ChatParticipantCreator, ChatParticipantAdmin)):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _can_use_commands(
|
|
||||||
self, chat: TypePeer, tgid: TelegramID, permission: TelegramAdminPermission | None = None
|
|
||||||
) -> bool:
|
|
||||||
if tgid in self.tg_whitelist:
|
|
||||||
return True
|
|
||||||
|
|
||||||
user = await u.User.get_by_tgid(tgid)
|
|
||||||
if user and user.is_admin:
|
|
||||||
self.tg_whitelist.append(user.tgid)
|
|
||||||
return True
|
|
||||||
|
|
||||||
if self.whitelist_group_admins:
|
|
||||||
pcp = await self._get_admin_participant(chat, tgid)
|
|
||||||
return self._has_participant_permission(pcp, permission)
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def check_can_use_command(self, event: Message, reply: ReplyFunc, command: str) -> bool:
|
|
||||||
if command not in self.required_permissions:
|
|
||||||
# Unknown command
|
|
||||||
return False
|
|
||||||
elif not isinstance(event.from_id, PeerUser):
|
|
||||||
await reply("Channels can't use commands")
|
|
||||||
return False
|
|
||||||
elif not await self._can_use_commands(
|
|
||||||
event.to_id, TelegramID(event.from_id.user_id), self.required_permissions[command]
|
|
||||||
):
|
|
||||||
await reply("You do not have the permission to use that command.")
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def handle_command_portal(self, portal: po.Portal, reply: ReplyFunc) -> Message:
|
|
||||||
if not self.config["bridge.relaybot.authless_portals"]:
|
|
||||||
return await reply("This bridge doesn't allow portal creation from Telegram.")
|
|
||||||
|
|
||||||
if not portal.allow_bridging:
|
|
||||||
return await reply("This bridge doesn't allow bridging this chat.")
|
|
||||||
|
|
||||||
await portal.create_matrix_room(self)
|
|
||||||
if portal.mxid:
|
|
||||||
if portal.username:
|
|
||||||
return await reply(
|
|
||||||
f"Portal is public: [{portal.alias}](https://matrix.to/#/{portal.alias})"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return await reply("Portal is not public. Use `/invite <mxid>` to get an invite.")
|
|
||||||
else:
|
|
||||||
return await reply("Couldn't create portal room")
|
|
||||||
|
|
||||||
async def handle_command_invite(
|
|
||||||
self, portal: po.Portal, reply: ReplyFunc, mxid_input: UserID
|
|
||||||
) -> Message:
|
|
||||||
if len(mxid_input) == 0:
|
|
||||||
return await reply("Usage: `/invite <mxid>`")
|
|
||||||
elif not portal.mxid:
|
|
||||||
return await reply("Portal does not have Matrix room. Create one with /portal first.")
|
|
||||||
if mxid_input[0] != "@" or mxid_input.find(":") < 2:
|
|
||||||
return await reply("That doesn't look like a Matrix ID.")
|
|
||||||
user = await u.User.get_and_start_by_mxid(mxid_input)
|
|
||||||
if not user.relaybot_whitelisted:
|
|
||||||
return await reply("That user is not whitelisted to use the bridge.")
|
|
||||||
elif await user.is_logged_in():
|
|
||||||
displayname = f"@{user.tg_username}" if user.tg_username else user.displayname
|
|
||||||
return await reply(
|
|
||||||
"That user seems to be logged in. "
|
|
||||||
f"Just invite [{displayname}](tg://user?id={user.tgid})"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
await portal.invite_to_matrix(user.mxid)
|
|
||||||
except MBadState:
|
|
||||||
try:
|
|
||||||
await portal.main_intent.unban_user(
|
|
||||||
portal.mxid, user.mxid, reason="Invited from Telegram"
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return await reply(f"Failed to unban `{user.mxid}` from the portal.")
|
|
||||||
await portal.invite_to_matrix(user.mxid)
|
|
||||||
return await reply(f"Unbanned and invited `{user.mxid}` to the portal.")
|
|
||||||
return await reply(f"Invited `{user.mxid}` to the portal.")
|
|
||||||
|
|
||||||
async def handle_command_ban(
|
|
||||||
self,
|
|
||||||
message: Message,
|
|
||||||
portal: po.Portal,
|
|
||||||
reply: ReplyFunc,
|
|
||||||
reason: str,
|
|
||||||
action: Literal["kick", "ban"] = "ban",
|
|
||||||
) -> Message:
|
|
||||||
if not message.reply_to:
|
|
||||||
return await reply("You must reply to a relaybot message when using that command")
|
|
||||||
reply_to_id = TelegramID(message.reply_to.reply_to_msg_id)
|
|
||||||
tg_space = portal.tgid if portal.peer_type == "channel" else self.tgid
|
|
||||||
msg = await DBMessage.get_one_by_tgid(reply_to_id, tg_space)
|
|
||||||
if not msg or msg.sender != self.tgid or not msg.sender_mxid:
|
|
||||||
return await reply("Target message is not a relayed message")
|
|
||||||
puppet = await pu.Puppet.get_by_peer(message.from_id)
|
|
||||||
actioned = "Banned" if action == "ban" else "Kicked"
|
|
||||||
try:
|
|
||||||
intent = puppet.intent_for(portal)
|
|
||||||
func: BanFunc = intent.ban_user if action == "ban" else intent.kick_user
|
|
||||||
await func(portal.mxid, msg.sender_mxid, reason)
|
|
||||||
except MForbidden as e:
|
|
||||||
self.log.warning(
|
|
||||||
f"Failed to {action} {msg.sender_mxid} from {portal.mxid} as {puppet.mxid}: {e}, "
|
|
||||||
f"falling back to bridge bot"
|
|
||||||
)
|
|
||||||
reason_prefix = f"{actioned} by {puppet.displayname or puppet.tgid}"
|
|
||||||
reason = f"{reason_prefix}: {reason}" if reason else reason_prefix
|
|
||||||
try:
|
|
||||||
func: BanFunc = (
|
|
||||||
self.az.intent.ban_user if action == "ban" else self.az.intent.kick_user
|
|
||||||
)
|
|
||||||
await func(portal.mxid, msg.sender_mxid, reason)
|
|
||||||
except MForbidden as e:
|
|
||||||
self.log.warning(
|
|
||||||
f"Failed to {action} {msg.sender_mxid} from {portal.mxid} as bridge bot: {e}"
|
|
||||||
)
|
|
||||||
return await reply(f"Failed to {action} `{msg.sender_mxid}`")
|
|
||||||
return await reply(f"Successfully {actioned.lower()} `{msg.sender_mxid}`")
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def handle_command_id(message: Message, reply: ReplyFunc) -> Awaitable[Message]:
|
|
||||||
# Provide the prefixed ID to the user so that the user wouldn't need to specify whether the
|
|
||||||
# chat is a normal group or a supergroup/channel when using the ID.
|
|
||||||
if isinstance(message.to_id, PeerChannel):
|
|
||||||
return reply(f"-100{message.to_id.channel_id}")
|
|
||||||
elif isinstance(message.to_id, PeerChat):
|
|
||||||
return reply(str(-message.to_id.chat_id))
|
|
||||||
elif isinstance(message.to_id, PeerUser):
|
|
||||||
return reply(
|
|
||||||
f"Your user ID is {message.to_id.user_id}.\n\n"
|
|
||||||
f"If you're trying to bridge a group chat to Matrix, you must run the command in "
|
|
||||||
f"the group, not here. **The ID above will not work** with `!tg bridge`."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return reply("Failed to find chat ID.")
|
|
||||||
|
|
||||||
def parse_command(self, message: Message) -> tuple[str | None, str | None]:
|
|
||||||
if not message.entities or len(message.entities) < 1 or not message.message:
|
|
||||||
return None, None
|
|
||||||
cmd_entity = message.entities[0]
|
|
||||||
if not isinstance(cmd_entity, MessageEntityBotCommand) or cmd_entity.offset != 0:
|
|
||||||
return None, None
|
|
||||||
surrogated_text = add_surrogate(message.message)
|
|
||||||
command: str = del_surrogate(surrogated_text[: cmd_entity.length]).lower()
|
|
||||||
rest_of_message: str = ""
|
|
||||||
if len(surrogated_text) > cmd_entity.length + 1:
|
|
||||||
rest_of_message: str = del_surrogate(surrogated_text[cmd_entity.length + 1 :])
|
|
||||||
command, *target = command.split("@", 1)
|
|
||||||
if not command.startswith("/"):
|
|
||||||
return None, None
|
|
||||||
elif target and target[0] != self.tg_username.lower():
|
|
||||||
return None, None
|
|
||||||
return command[1:], rest_of_message
|
|
||||||
|
|
||||||
async def handle_command(self, message: Message, command: str, args: str) -> None:
|
|
||||||
def reply(reply_text: str) -> Awaitable[Message]:
|
|
||||||
return self.client.send_message(message.chat_id, reply_text, reply_to=message.id)
|
|
||||||
|
|
||||||
if command == "start" and message.is_private:
|
|
||||||
pcm = self.config["bridge.relaybot.private_chat.message"]
|
|
||||||
if pcm:
|
|
||||||
await reply(pcm)
|
|
||||||
elif command == "id":
|
|
||||||
await self.handle_command_id(message, reply)
|
|
||||||
elif not message.is_private:
|
|
||||||
if not await self.check_can_use_command(message, reply, command):
|
|
||||||
return
|
|
||||||
portal = await po.Portal.get_by_entity(message.to_id)
|
|
||||||
if command == "portal":
|
|
||||||
await self.handle_command_portal(portal, reply)
|
|
||||||
elif command == "invite":
|
|
||||||
await self.handle_command_invite(portal, reply, mxid_input=UserID(args))
|
|
||||||
elif command == "mxban":
|
|
||||||
await self.handle_command_ban(message, portal, reply, reason=args)
|
|
||||||
elif command == "mxkick":
|
|
||||||
await self.handle_command_ban(message, portal, reply, reason=args, action="kick")
|
|
||||||
|
|
||||||
async def handle_service_message(self, message: MessageService) -> None:
|
|
||||||
to_peer = message.to_id
|
|
||||||
if isinstance(to_peer, PeerChannel):
|
|
||||||
to_id = TelegramID(to_peer.channel_id)
|
|
||||||
chat_type = "channel"
|
|
||||||
elif isinstance(to_peer, PeerChat):
|
|
||||||
to_id = TelegramID(to_peer.chat_id)
|
|
||||||
chat_type = "chat"
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
|
|
||||||
action = message.action
|
|
||||||
if isinstance(action, MessageActionChatAddUser) and self.tgid in action.users:
|
|
||||||
await self.add_chat(to_id, chat_type)
|
|
||||||
elif isinstance(action, MessageActionChatDeleteUser) and action.user_id == self.tgid:
|
|
||||||
await self.remove_chat(to_id)
|
|
||||||
elif isinstance(action, MessageActionChatMigrateTo):
|
|
||||||
await self.remove_chat(to_id)
|
|
||||||
await self.add_chat(TelegramID(action.channel_id), "channel")
|
|
||||||
|
|
||||||
async def update(self, update) -> bool:
|
|
||||||
if self._login_wait_fut:
|
|
||||||
await self._login_wait_fut
|
|
||||||
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
|
|
||||||
return False
|
|
||||||
if isinstance(update.message, MessageService):
|
|
||||||
await self.handle_service_message(update.message)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if isinstance(update.message, Message):
|
|
||||||
command, args = self.parse_command(update.message)
|
|
||||||
if command:
|
|
||||||
await self.handle_command(update.message, command, args)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_in_chat(self, peer_id) -> bool:
|
|
||||||
return peer_id in self.chats
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return "bot"
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
from .handler import (
|
|
||||||
SECTION_ADMIN,
|
|
||||||
SECTION_AUTH,
|
|
||||||
SECTION_CREATING_PORTALS,
|
|
||||||
SECTION_MISC,
|
|
||||||
SECTION_PORTAL_MANAGEMENT,
|
|
||||||
CommandEvent,
|
|
||||||
CommandHandler,
|
|
||||||
CommandProcessor,
|
|
||||||
command_handler,
|
|
||||||
)
|
|
||||||
|
|
||||||
# This has to happen after the handler imports
|
|
||||||
from . import matrix_auth, portal, telegram # isort: skip
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"command_handler",
|
|
||||||
"CommandHandler",
|
|
||||||
"CommandProcessor",
|
|
||||||
"CommandEvent",
|
|
||||||
"SECTION_AUTH",
|
|
||||||
"SECTION_MISC",
|
|
||||||
"SECTION_ADMIN",
|
|
||||||
"SECTION_CREATING_PORTALS",
|
|
||||||
"SECTION_PORTAL_MANAGEMENT",
|
|
||||||
]
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, NamedTuple
|
|
||||||
|
|
||||||
from telethon.errors import FloodWaitError
|
|
||||||
|
|
||||||
from mautrix.bridge.commands import (
|
|
||||||
CommandEvent as BaseCommandEvent,
|
|
||||||
CommandHandler as BaseCommandHandler,
|
|
||||||
CommandHandlerFunc,
|
|
||||||
CommandProcessor as BaseCommandProcessor,
|
|
||||||
HelpSection,
|
|
||||||
command_handler as base_command_handler,
|
|
||||||
)
|
|
||||||
from mautrix.types import EventID, MessageEventContent, RoomID
|
|
||||||
from mautrix.util.format_duration import format_duration
|
|
||||||
|
|
||||||
from .. import portal as po, user as u
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from ..__main__ import TelegramBridge
|
|
||||||
|
|
||||||
|
|
||||||
class HelpCacheKey(NamedTuple):
|
|
||||||
is_management: bool
|
|
||||||
is_portal: bool
|
|
||||||
puppet_whitelisted: bool
|
|
||||||
matrix_puppet_whitelisted: bool
|
|
||||||
is_admin: bool
|
|
||||||
is_logged_in: bool
|
|
||||||
|
|
||||||
|
|
||||||
SECTION_AUTH = HelpSection("Authentication", 10, "")
|
|
||||||
SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "")
|
|
||||||
SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "")
|
|
||||||
SECTION_MISC = HelpSection("Miscellaneous", 40, "")
|
|
||||||
SECTION_ADMIN = HelpSection("Administration", 50, "")
|
|
||||||
|
|
||||||
|
|
||||||
class CommandEvent(BaseCommandEvent):
|
|
||||||
sender: u.User
|
|
||||||
portal: po.Portal
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
processor: CommandProcessor,
|
|
||||||
room_id: RoomID,
|
|
||||||
event_id: EventID,
|
|
||||||
sender: u.User,
|
|
||||||
command: str,
|
|
||||||
args: list[str],
|
|
||||||
content: MessageEventContent,
|
|
||||||
portal: po.Portal | None,
|
|
||||||
is_management: bool,
|
|
||||||
has_bridge_bot: bool,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
processor,
|
|
||||||
room_id,
|
|
||||||
event_id,
|
|
||||||
sender,
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
content,
|
|
||||||
portal,
|
|
||||||
is_management,
|
|
||||||
has_bridge_bot,
|
|
||||||
)
|
|
||||||
self.bridge = processor.bridge
|
|
||||||
self.tgbot = processor.tgbot
|
|
||||||
self.config = processor.config
|
|
||||||
self.public_website = processor.public_website
|
|
||||||
|
|
||||||
@property
|
|
||||||
def print_error_traceback(self) -> bool:
|
|
||||||
return self.sender.is_admin
|
|
||||||
|
|
||||||
async def get_help_key(self) -> HelpCacheKey:
|
|
||||||
return HelpCacheKey(
|
|
||||||
self.is_management,
|
|
||||||
self.portal is not None,
|
|
||||||
self.sender.puppet_whitelisted,
|
|
||||||
self.sender.matrix_puppet_whitelisted,
|
|
||||||
self.sender.is_admin,
|
|
||||||
await self.sender.is_logged_in(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CommandHandler(BaseCommandHandler):
|
|
||||||
name: str
|
|
||||||
|
|
||||||
needs_puppeting: bool
|
|
||||||
needs_matrix_puppeting: bool
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
handler: Callable[[CommandEvent], Awaitable[EventID]],
|
|
||||||
management_only: bool,
|
|
||||||
name: str,
|
|
||||||
help_text: str,
|
|
||||||
help_args: str,
|
|
||||||
help_section: HelpSection,
|
|
||||||
needs_auth: bool,
|
|
||||||
needs_puppeting: bool,
|
|
||||||
needs_matrix_puppeting: bool,
|
|
||||||
needs_admin: bool,
|
|
||||||
**kwargs,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
handler,
|
|
||||||
management_only,
|
|
||||||
name,
|
|
||||||
help_text,
|
|
||||||
help_args,
|
|
||||||
help_section,
|
|
||||||
needs_auth=needs_auth,
|
|
||||||
needs_puppeting=needs_puppeting,
|
|
||||||
needs_matrix_puppeting=needs_matrix_puppeting,
|
|
||||||
needs_admin=needs_admin,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_permission_error(self, evt: CommandEvent) -> str | None:
|
|
||||||
if self.needs_puppeting and not evt.sender.puppet_whitelisted:
|
|
||||||
return "That command is limited to users with puppeting privileges."
|
|
||||||
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
|
|
||||||
return "That command is limited to users with full puppeting privileges."
|
|
||||||
return await super().get_permission_error(evt)
|
|
||||||
|
|
||||||
def has_permission(self, key: HelpCacheKey) -> bool:
|
|
||||||
return (
|
|
||||||
super().has_permission(key)
|
|
||||||
and (not self.needs_puppeting or key.puppet_whitelisted)
|
|
||||||
and (not self.needs_matrix_puppeting or key.matrix_puppet_whitelisted)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def command_handler(
|
|
||||||
_func: CommandHandlerFunc | None = None,
|
|
||||||
*,
|
|
||||||
needs_auth: bool = True,
|
|
||||||
needs_puppeting: bool = True,
|
|
||||||
needs_matrix_puppeting: bool = False,
|
|
||||||
needs_admin: bool = False,
|
|
||||||
management_only: bool = False,
|
|
||||||
name: str | None = None,
|
|
||||||
aliases: list[str] | None = None,
|
|
||||||
help_text: str = "",
|
|
||||||
help_args: str = "",
|
|
||||||
help_section: HelpSection = None,
|
|
||||||
) -> Callable[[CommandHandlerFunc], CommandHandler]:
|
|
||||||
return base_command_handler(
|
|
||||||
_func,
|
|
||||||
_handler_class=CommandHandler,
|
|
||||||
name=name,
|
|
||||||
aliases=aliases,
|
|
||||||
help_text=help_text,
|
|
||||||
help_args=help_args,
|
|
||||||
help_section=help_section,
|
|
||||||
management_only=management_only,
|
|
||||||
needs_auth=needs_auth,
|
|
||||||
needs_admin=needs_admin,
|
|
||||||
needs_puppeting=needs_puppeting,
|
|
||||||
needs_matrix_puppeting=needs_matrix_puppeting,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CommandProcessor(BaseCommandProcessor):
|
|
||||||
def __init__(self, bridge: "TelegramBridge") -> None:
|
|
||||||
super().__init__(event_class=CommandEvent, bridge=bridge)
|
|
||||||
self.tgbot = bridge.bot
|
|
||||||
self.public_website = bridge.public_website
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _run_handler(
|
|
||||||
handler: Callable[[CommandEvent], Awaitable[Any]], evt: CommandEvent
|
|
||||||
) -> Any:
|
|
||||||
try:
|
|
||||||
return await handler(evt)
|
|
||||||
except FloodWaitError as e:
|
|
||||||
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from .. import puppet as pu
|
|
||||||
from . import SECTION_AUTH, CommandEvent, command_handler
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=True,
|
|
||||||
management_only=True,
|
|
||||||
needs_matrix_puppeting=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Replace your Telegram account's Matrix puppet with your own Matrix account.",
|
|
||||||
)
|
|
||||||
async def login_matrix(evt: CommandEvent) -> EventID:
|
|
||||||
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
|
|
||||||
if puppet.is_real_user:
|
|
||||||
return await evt.reply(
|
|
||||||
"You have already logged in with your Matrix account. "
|
|
||||||
"Log out with `$cmdprefix+sp logout-matrix` first."
|
|
||||||
)
|
|
||||||
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
|
|
||||||
if allow_matrix_login:
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": enter_matrix_token,
|
|
||||||
"action": "Matrix login",
|
|
||||||
}
|
|
||||||
if evt.config["appservice.public.enabled"]:
|
|
||||||
prefix = evt.config["appservice.public.external"]
|
|
||||||
token = evt.public_website.make_token(evt.sender.mxid, "/matrix-login")
|
|
||||||
url = f"{prefix}/matrix-login?token={token}"
|
|
||||||
if allow_matrix_login:
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance allows you to log in inside or outside Matrix.\n\n"
|
|
||||||
"If you would like to log in within Matrix, please send your Matrix access token "
|
|
||||||
"here.\n"
|
|
||||||
f"If you would like to log in outside of Matrix, [click here]({url}).\n\n"
|
|
||||||
"Logging in outside of Matrix is recommended, because in-Matrix login would save "
|
|
||||||
"your access token in the message history."
|
|
||||||
)
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance does not allow logging in inside Matrix.\n\n"
|
|
||||||
f"Please visit [the login page]({url}) to log in."
|
|
||||||
)
|
|
||||||
elif allow_matrix_login:
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance does not allow you to log in outside of Matrix.\n\n"
|
|
||||||
"Please send your Matrix access token here to log in."
|
|
||||||
)
|
|
||||||
return await evt.reply("This bridge instance has been configured to not allow logging in.")
|
|
||||||
|
|
||||||
|
|
||||||
async def enter_matrix_token(evt: CommandEvent) -> EventID:
|
|
||||||
evt.sender.command_status = None
|
|
||||||
|
|
||||||
puppet = await pu.Puppet.get_by_tgid(evt.sender.tgid)
|
|
||||||
if puppet.is_real_user:
|
|
||||||
return await evt.reply(
|
|
||||||
"You have already logged in with your Matrix account. "
|
|
||||||
"Log out with `$cmdprefix+sp logout-matrix` first."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
|
|
||||||
except OnlyLoginSelf:
|
|
||||||
return await evt.reply("You can only log in as your own Matrix user.")
|
|
||||||
except InvalidAccessToken:
|
|
||||||
return await evt.reply("Failed to verify access token.")
|
|
||||||
return await evt.reply(
|
|
||||||
f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}."
|
|
||||||
)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
from . import admin, bridge, config, create_chat, filter, misc, unbridge
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from ... import portal as po, puppet as pu, user as u
|
|
||||||
from .. import SECTION_ADMIN, CommandEvent, command_handler
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_admin=True,
|
|
||||||
needs_auth=False,
|
|
||||||
help_section=SECTION_ADMIN,
|
|
||||||
help_args="<`portal`|`puppet`|`user`>",
|
|
||||||
help_text="Clear internal bridge caches",
|
|
||||||
)
|
|
||||||
async def clear_db_cache(evt: CommandEvent) -> EventID:
|
|
||||||
try:
|
|
||||||
section = evt.args[0].lower()
|
|
||||||
except IndexError:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache <section>`")
|
|
||||||
if section == "portal":
|
|
||||||
po.Portal.by_tgid = {}
|
|
||||||
po.Portal.by_mxid = {}
|
|
||||||
await evt.reply("Cleared portal cache")
|
|
||||||
elif section == "puppet":
|
|
||||||
pu.Puppet.by_tgid = {}
|
|
||||||
pu.Puppet.by_custom_mxid = {}
|
|
||||||
await asyncio.gather(
|
|
||||||
*[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()]
|
|
||||||
)
|
|
||||||
await evt.reply("Cleared puppet cache and restarted custom puppet syncers")
|
|
||||||
elif section == "user":
|
|
||||||
u.User.by_mxid = {user.mxid: user for user in u.User.by_tgid.values()}
|
|
||||||
await evt.reply("Cleared non-logged-in user cache")
|
|
||||||
else:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp clear-db-cache <section>`")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_admin=True,
|
|
||||||
needs_auth=False,
|
|
||||||
help_section=SECTION_ADMIN,
|
|
||||||
help_args="[_mxid_]",
|
|
||||||
help_text="Reload and reconnect a user",
|
|
||||||
)
|
|
||||||
async def reload_user(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) > 0:
|
|
||||||
mxid = evt.args[0]
|
|
||||||
else:
|
|
||||||
mxid = evt.sender.mxid
|
|
||||||
user = await u.User.get_by_mxid(mxid, create=False)
|
|
||||||
if not user:
|
|
||||||
return await evt.reply("User not found")
|
|
||||||
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
|
|
||||||
await user.stop()
|
|
||||||
del u.User.by_tgid[user.tgid]
|
|
||||||
del u.User.by_mxid[user.mxid]
|
|
||||||
user = await u.User.get_by_mxid(mxid)
|
|
||||||
await user.ensure_started()
|
|
||||||
if puppet:
|
|
||||||
await puppet.start()
|
|
||||||
return await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Awaitable
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from telethon.tl.types import ChannelForbidden, ChatForbidden
|
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID
|
|
||||||
from mautrix.util import background_task
|
|
||||||
|
|
||||||
from ... import portal as po
|
|
||||||
from ...types import TelegramID
|
|
||||||
from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler
|
|
||||||
from .util import get_initial_state, user_has_power_level, warn_missing_power
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False,
|
|
||||||
needs_puppeting=False,
|
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
|
||||||
help_args="[_id_]",
|
|
||||||
help_text=(
|
|
||||||
"Bridge the current Matrix room to the Telegram chat with the given ID. The ID must be "
|
|
||||||
"the prefixed version that you get with the `/id` command of the Telegram-side bot."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def bridge(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply(
|
|
||||||
"**Usage:** `$cmdprefix+sp bridge <Telegram chat ID> [Matrix room ID]`"
|
|
||||||
)
|
|
||||||
force_use_bot = False
|
|
||||||
if evt.args[0] == "--usebot" and evt.sender.is_admin:
|
|
||||||
force_use_bot = True
|
|
||||||
evt.args = evt.args[1:]
|
|
||||||
room_id = RoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
|
|
||||||
that_this = "This" if room_id == evt.room_id else "That"
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if portal:
|
|
||||||
return await evt.reply(f"{that_this} room is already a portal room.")
|
|
||||||
|
|
||||||
if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
|
|
||||||
return await evt.reply(
|
|
||||||
f"You do not have the permissions to bridge {that_this.lower()} room."
|
|
||||||
)
|
|
||||||
|
|
||||||
# The /id bot command provides the prefixed ID, so we assume
|
|
||||||
tgid_str = evt.args[0]
|
|
||||||
tgid = None
|
|
||||||
try:
|
|
||||||
if tgid_str.startswith("-100"):
|
|
||||||
tgid = TelegramID(int(tgid_str[4:]))
|
|
||||||
peer_type = "channel"
|
|
||||||
elif tgid_str.startswith("-"):
|
|
||||||
tgid = TelegramID(-int(tgid_str))
|
|
||||||
peer_type = "chat"
|
|
||||||
except ValueError:
|
|
||||||
# Invalid integer
|
|
||||||
pass
|
|
||||||
if not tgid:
|
|
||||||
return await evt.reply(
|
|
||||||
"That doesn't seem like a prefixed Telegram chat ID.\n\n"
|
|
||||||
"If you did not get the ID using the `/id` bot command, please prefix"
|
|
||||||
"channel/supergroup IDs with `-100` and non-super group IDs with `-`.\n\n"
|
|
||||||
"Bridging private chats to existing rooms is not allowed."
|
|
||||||
)
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_tgid(tgid, peer_type=peer_type)
|
|
||||||
if not portal.allow_bridging:
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge doesn't allow bridging that Telegram chat.\n"
|
|
||||||
"If you're the bridge admin, try "
|
|
||||||
"`$cmdprefix+sp filter whitelist <Telegram chat ID>` first."
|
|
||||||
)
|
|
||||||
elif portal.mxid:
|
|
||||||
has_portal_message = (
|
|
||||||
"That Telegram chat already has a portal at "
|
|
||||||
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}). "
|
|
||||||
)
|
|
||||||
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
|
|
||||||
return await evt.reply(
|
|
||||||
f"{has_portal_message}"
|
|
||||||
"Additionally, you do not have the permissions to unbridge that room."
|
|
||||||
)
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": confirm_bridge,
|
|
||||||
"action": "Room bridging",
|
|
||||||
"mxid": portal.mxid,
|
|
||||||
"bridge_to_mxid": room_id,
|
|
||||||
"tgid": portal.tgid,
|
|
||||||
"peer_type": peer_type,
|
|
||||||
"force_use_bot": force_use_bot,
|
|
||||||
}
|
|
||||||
return await evt.reply(
|
|
||||||
f"{has_portal_message}"
|
|
||||||
"However, you have the permissions to unbridge that room.\n\n"
|
|
||||||
"To delete that portal completely and continue bridging, use "
|
|
||||||
"`$cmdprefix+sp delete-and-continue`. To unbridge the portal "
|
|
||||||
"without kicking Matrix users, use `$cmdprefix+sp unbridge-and-"
|
|
||||||
"continue`. To cancel, use `$cmdprefix+sp cancel`"
|
|
||||||
)
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": confirm_bridge,
|
|
||||||
"action": "Room bridging",
|
|
||||||
"bridge_to_mxid": room_id,
|
|
||||||
"tgid": portal.tgid,
|
|
||||||
"peer_type": peer_type,
|
|
||||||
"force_use_bot": force_use_bot,
|
|
||||||
}
|
|
||||||
return await evt.reply(
|
|
||||||
"That Telegram chat has no existing portal. To confirm bridging the "
|
|
||||||
"chat to this room, use `$cmdprefix+sp continue`"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def cleanup_old_portal_while_bridging(
|
|
||||||
evt: CommandEvent, portal: po.Portal
|
|
||||||
) -> tuple[bool, Awaitable[None] | None]:
|
|
||||||
if not portal.mxid:
|
|
||||||
await evt.reply(
|
|
||||||
"The portal seems to have lost its Matrix room between you"
|
|
||||||
"calling `$cmdprefix+sp bridge` and this command.\n\n"
|
|
||||||
"Continuing without touching previous Matrix room..."
|
|
||||||
)
|
|
||||||
return True, None
|
|
||||||
elif evt.args[0] == "delete-and-continue":
|
|
||||||
return True, portal.cleanup_portal("Portal deleted (moving to another room)", delete=False)
|
|
||||||
elif evt.args[0] == "unbridge-and-continue":
|
|
||||||
return True, portal.cleanup_portal(
|
|
||||||
"Room unbridged (portal moving to another room)", puppets_only=True, delete=False
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await evt.reply(
|
|
||||||
"The chat you were trying to bridge already has a Matrix portal room.\n\n"
|
|
||||||
"Please use `$cmdprefix+sp delete-and-continue` or `$cmdprefix+sp unbridge-and-"
|
|
||||||
"continue` to either delete or unbridge the existing room (respectively) and "
|
|
||||||
"continue with the bridging.\n\n"
|
|
||||||
"If you changed your mind, use `$cmdprefix+sp cancel` to cancel."
|
|
||||||
)
|
|
||||||
return False, None
|
|
||||||
|
|
||||||
|
|
||||||
async def confirm_bridge(evt: CommandEvent) -> EventID | None:
|
|
||||||
status = evt.sender.command_status
|
|
||||||
try:
|
|
||||||
portal = await po.Portal.get_by_tgid(status["tgid"], peer_type=status["peer_type"])
|
|
||||||
bridge_to_mxid = status["bridge_to_mxid"]
|
|
||||||
except KeyError:
|
|
||||||
evt.sender.command_status = None
|
|
||||||
return await evt.reply(
|
|
||||||
"Fatal error: tgid or peer_type missing from command_status. "
|
|
||||||
"This shouldn't happen unless you're messing with the command handler code."
|
|
||||||
)
|
|
||||||
|
|
||||||
is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"]
|
|
||||||
|
|
||||||
if "mxid" in status:
|
|
||||||
if portal.peer_type != status["peer_type"]:
|
|
||||||
evt.log.warning(
|
|
||||||
"Portal %d in database has mismatching peer type %s (expected %s),"
|
|
||||||
" trusting database as a room already existed",
|
|
||||||
portal.tgid,
|
|
||||||
portal.peer_type,
|
|
||||||
status["peer_type"],
|
|
||||||
)
|
|
||||||
await evt.reply(
|
|
||||||
"Mismatching peer type in command and portal table, "
|
|
||||||
"trusting portal as room already existed"
|
|
||||||
)
|
|
||||||
ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
|
|
||||||
if not ok:
|
|
||||||
return None
|
|
||||||
elif coro:
|
|
||||||
background_task.create(coro)
|
|
||||||
await evt.reply("Cleaning up previous portal room...")
|
|
||||||
elif portal.mxid:
|
|
||||||
evt.sender.command_status = None
|
|
||||||
return await evt.reply(
|
|
||||||
"The portal seems to have created a Matrix room between you "
|
|
||||||
"calling `$cmdprefix+sp bridge` and this command.\n\n"
|
|
||||||
"Please start over by calling the bridge command again."
|
|
||||||
)
|
|
||||||
elif evt.args[0] != "continue":
|
|
||||||
return await evt.reply(
|
|
||||||
"Please use `$cmdprefix+sp continue` to confirm the bridging or "
|
|
||||||
"`$cmdprefix+sp cancel` to cancel."
|
|
||||||
)
|
|
||||||
elif portal.peer_type != status["peer_type"]:
|
|
||||||
evt.log.warning(
|
|
||||||
"Portal %d in database has mismatching peer type %s (expected %s),"
|
|
||||||
" trusting new peer type as there's no existing room",
|
|
||||||
portal.tgid,
|
|
||||||
portal.peer_type,
|
|
||||||
status["peer_type"],
|
|
||||||
)
|
|
||||||
await evt.reply(
|
|
||||||
"Mismatching peer type in command and portal table, "
|
|
||||||
"trusting you as portal room doesn't exist"
|
|
||||||
)
|
|
||||||
portal.peer_type = status["peer_type"]
|
|
||||||
|
|
||||||
evt.sender.command_status = None
|
|
||||||
async with portal._room_create_lock:
|
|
||||||
await _locked_confirm_bridge(
|
|
||||||
evt, portal=portal, room_id=bridge_to_mxid, is_logged_in=is_logged_in
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _locked_confirm_bridge(
|
|
||||||
evt: CommandEvent, portal: po.Portal, room_id: RoomID, is_logged_in: bool
|
|
||||||
) -> EventID | None:
|
|
||||||
user = evt.sender if is_logged_in else evt.tgbot
|
|
||||||
try:
|
|
||||||
entity = await user.client.get_entity(portal.peer)
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Failed to get_entity(%s) for manual bridging.", portal.peer)
|
|
||||||
if is_logged_in:
|
|
||||||
return await evt.reply(
|
|
||||||
"Failed to get info of telegram chat. You are logged in, are you in that chat?"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return await evt.reply(
|
|
||||||
"Failed to get info of telegram chat. "
|
|
||||||
"You're not logged in, is the relay bot in the chat?"
|
|
||||||
)
|
|
||||||
if isinstance(entity, (ChatForbidden, ChannelForbidden)):
|
|
||||||
if is_logged_in:
|
|
||||||
return await evt.reply("You don't seem to be in that chat.")
|
|
||||||
else:
|
|
||||||
return await evt.reply("The bot doesn't seem to be in that chat.")
|
|
||||||
|
|
||||||
portal.mxid = room_id
|
|
||||||
portal.by_mxid[portal.mxid] = portal
|
|
||||||
(portal.title, portal.about, levels, portal.encrypted) = await get_initial_state(
|
|
||||||
evt.az.intent, evt.room_id
|
|
||||||
)
|
|
||||||
portal.photo_id = ""
|
|
||||||
await portal.save()
|
|
||||||
await portal.update_bridge_info()
|
|
||||||
|
|
||||||
background_task.create(portal.update_matrix_room(user, entity, levels=levels))
|
|
||||||
|
|
||||||
await warn_missing_power(levels, evt)
|
|
||||||
|
|
||||||
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Awaitable
|
|
||||||
from io import StringIO
|
|
||||||
|
|
||||||
from ruamel.yaml import YAMLError
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
from mautrix.util.config import yaml
|
|
||||||
|
|
||||||
from ... import portal as po, util
|
|
||||||
from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False,
|
|
||||||
needs_puppeting=False,
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_text="View or change per-portal settings.",
|
|
||||||
help_args="<`help`|_subcommand_> [...]",
|
|
||||||
)
|
|
||||||
async def config(evt: CommandEvent) -> None:
|
|
||||||
cmd = evt.args[0].lower() if len(evt.args) > 0 else "help"
|
|
||||||
if cmd not in ("view", "defaults", "set", "unset", "add", "del"):
|
|
||||||
await config_help(evt)
|
|
||||||
return
|
|
||||||
elif cmd == "defaults":
|
|
||||||
await config_defaults(evt)
|
|
||||||
return
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
|
||||||
await evt.reply("This is not a portal room.")
|
|
||||||
return
|
|
||||||
elif cmd == "view":
|
|
||||||
await config_view(evt, portal)
|
|
||||||
return
|
|
||||||
|
|
||||||
if not await portal.can_user_perform(evt.sender, "config"):
|
|
||||||
await evt.reply("You do not have the permissions to configure this room.")
|
|
||||||
return
|
|
||||||
|
|
||||||
key = evt.args[1] if len(evt.args) > 1 else None
|
|
||||||
try:
|
|
||||||
value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None
|
|
||||||
except YAMLError as e:
|
|
||||||
await evt.reply(f"Invalid value provided. Values must be valid YAML.\n{e}")
|
|
||||||
return
|
|
||||||
if cmd == "set":
|
|
||||||
await config_set(evt, portal, key, value)
|
|
||||||
elif cmd == "unset":
|
|
||||||
await config_unset(evt, portal, key)
|
|
||||||
elif cmd == "add" or cmd == "del":
|
|
||||||
await config_add_del(evt, portal, key, value, cmd)
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
await portal.save()
|
|
||||||
|
|
||||||
|
|
||||||
def config_help(evt: CommandEvent) -> Awaitable[EventID]:
|
|
||||||
return evt.reply(
|
|
||||||
"""**Usage:** `$cmdprefix config <subcommand> [...]`. Subcommands:
|
|
||||||
|
|
||||||
* **help** - View this help text.
|
|
||||||
* **view** - View the current config data.
|
|
||||||
* **defaults** - View the default config values.
|
|
||||||
* **set** <_key_> <_value_> - Set a config value.
|
|
||||||
* **unset** <_key_> - Remove a config value.
|
|
||||||
* **add** <_key_> <_value_> - Add a value to an array.
|
|
||||||
* **del** <_key_> <_value_> - Remove a value from an array.
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def config_view(evt: CommandEvent, portal: po.Portal) -> Awaitable[EventID]:
|
|
||||||
return evt.reply(f"Room-specific config:\n{_str_value(portal.local_config).rstrip()}")
|
|
||||||
|
|
||||||
|
|
||||||
def config_defaults(evt: CommandEvent) -> Awaitable[EventID]:
|
|
||||||
value = _str_value(
|
|
||||||
{
|
|
||||||
"bridge_notices": {
|
|
||||||
"default": evt.config["bridge.bridge_notices.default"],
|
|
||||||
"exceptions": evt.config["bridge.bridge_notices.exceptions"],
|
|
||||||
},
|
|
||||||
"bot_messages_as_notices": evt.config["bridge.bot_messages_as_notices"],
|
|
||||||
"caption_in_message": evt.config["bridge.caption_in_message"],
|
|
||||||
"message_formats": evt.config["bridge.message_formats"],
|
|
||||||
"emote_format": evt.config["bridge.emote_format"],
|
|
||||||
"state_event_formats": evt.config["bridge.state_event_formats"],
|
|
||||||
"telegram_link_preview": evt.config["bridge.telegram_link_preview"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return evt.reply(f"Bridge instance wide config:\n{value.rstrip()}")
|
|
||||||
|
|
||||||
|
|
||||||
def _str_value(value: Any) -> str:
|
|
||||||
stream = StringIO()
|
|
||||||
yaml.dump(value, stream)
|
|
||||||
value_str = stream.getvalue()
|
|
||||||
if "\n" in value_str:
|
|
||||||
return f"\n```yaml\n{value_str}\n```\n"
|
|
||||||
else:
|
|
||||||
return f"`{value_str}`"
|
|
||||||
|
|
||||||
|
|
||||||
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: Any) -> Awaitable[EventID]:
|
|
||||||
if not key or value is None:
|
|
||||||
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
|
|
||||||
elif util.recursive_set(portal.local_config, key, value):
|
|
||||||
return evt.reply(f"Successfully set the value of `{key}` to {_str_value(value)}".rstrip())
|
|
||||||
else:
|
|
||||||
return evt.reply(f"Failed to set value of `{key}`. Does the path contain non-map types?")
|
|
||||||
|
|
||||||
|
|
||||||
def config_unset(evt: CommandEvent, portal: po.Portal, key: str) -> Awaitable[EventID]:
|
|
||||||
if not key:
|
|
||||||
return evt.reply(f"**Usage:** `$cmdprefix+sp config unset <key>`")
|
|
||||||
elif util.recursive_del(portal.local_config, key):
|
|
||||||
return evt.reply(f"Successfully deleted `{key}` from config.")
|
|
||||||
else:
|
|
||||||
return evt.reply(f"`{key}` not found in config.")
|
|
||||||
|
|
||||||
|
|
||||||
def config_add_del(
|
|
||||||
evt: CommandEvent, portal: po.Portal, key: str, value: str, cmd: str
|
|
||||||
) -> Awaitable[EventID]:
|
|
||||||
if not key or value is None:
|
|
||||||
return evt.reply(f"**Usage:** `$cmdprefix+sp config {cmd} <key> <value>`")
|
|
||||||
|
|
||||||
arr = util.recursive_get(portal.local_config, key)
|
|
||||||
if not arr:
|
|
||||||
return evt.reply(
|
|
||||||
f"`{key}` not found in config. Maybe do `$cmdprefix+sp config set {key} []` first?"
|
|
||||||
)
|
|
||||||
elif not isinstance(arr, list):
|
|
||||||
return evt.reply("`{key}` does not seem to be an array.")
|
|
||||||
elif cmd == "add":
|
|
||||||
if value in arr:
|
|
||||||
return evt.reply(f"The array at `{key}` already contains {_str_value(value)}".rstrip())
|
|
||||||
arr.append(value)
|
|
||||||
return evt.reply(f"Successfully added {_str_value(value)} to the array at `{key}`")
|
|
||||||
else:
|
|
||||||
if value not in arr:
|
|
||||||
return evt.reply(f"The array at `{key}` does not contain {_str_value(value)}")
|
|
||||||
arr.remove(value)
|
|
||||||
return evt.reply(f"Successfully removed {_str_value(value)} from the array at `{key}`")
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from ... import portal as po
|
|
||||||
from ...types import TelegramID
|
|
||||||
from .. import SECTION_CREATING_PORTALS, CommandEvent, command_handler
|
|
||||||
from .util import get_initial_state, user_has_power_level, warn_missing_power
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
|
||||||
help_args="[_type_]",
|
|
||||||
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 `supergroup`)."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def create(evt: CommandEvent) -> EventID:
|
|
||||||
type = evt.args[0] if len(evt.args) > 0 else "supergroup"
|
|
||||||
if type not in ("chat", "group", "supergroup", "channel"):
|
|
||||||
return await evt.reply(
|
|
||||||
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`"
|
|
||||||
)
|
|
||||||
|
|
||||||
if await po.Portal.get_by_mxid(evt.room_id):
|
|
||||||
return await evt.reply("This is already a portal room.")
|
|
||||||
|
|
||||||
if not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
|
|
||||||
return await evt.reply("You do not have the permissions to bridge this room.")
|
|
||||||
|
|
||||||
title, about, levels, encrypted = await get_initial_state(evt.az.intent, evt.room_id)
|
|
||||||
if not title:
|
|
||||||
return await evt.reply("Please set a title before creating a Telegram chat.")
|
|
||||||
|
|
||||||
supergroup = type == "supergroup"
|
|
||||||
type = {
|
|
||||||
"supergroup": "channel",
|
|
||||||
"channel": "channel",
|
|
||||||
"chat": "chat",
|
|
||||||
"group": "chat",
|
|
||||||
}[type]
|
|
||||||
|
|
||||||
portal = po.Portal(
|
|
||||||
tgid=TelegramID(0),
|
|
||||||
tg_receiver=TelegramID(0),
|
|
||||||
peer_type=type,
|
|
||||||
mxid=evt.room_id,
|
|
||||||
title=title,
|
|
||||||
about=about,
|
|
||||||
encrypted=encrypted,
|
|
||||||
)
|
|
||||||
|
|
||||||
await warn_missing_power(levels, evt)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
|
|
||||||
except ValueError as e:
|
|
||||||
await portal.delete()
|
|
||||||
return await evt.reply(e.args[0])
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from ... import portal as po
|
|
||||||
from .. import SECTION_ADMIN, CommandEvent, command_handler
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_admin=True,
|
|
||||||
help_section=SECTION_ADMIN,
|
|
||||||
help_args="<`whitelist`|`blacklist`>",
|
|
||||||
help_text="Change whether the bridge will allow or disallow bridging rooms by default.",
|
|
||||||
)
|
|
||||||
async def filter_mode(evt: CommandEvent) -> EventID:
|
|
||||||
try:
|
|
||||||
mode = evt.args[0]
|
|
||||||
if mode not in ("whitelist", "blacklist"):
|
|
||||||
raise ValueError()
|
|
||||||
except (IndexError, ValueError):
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp filter-mode <whitelist/blacklist>`")
|
|
||||||
|
|
||||||
evt.config["bridge.filter.mode"] = mode
|
|
||||||
evt.config.save()
|
|
||||||
po.Portal.filter_mode = mode
|
|
||||||
if mode == "whitelist":
|
|
||||||
return await evt.reply(
|
|
||||||
"The bridge will now disallow bridging chats by default.\n"
|
|
||||||
"To allow bridging a specific chat, use"
|
|
||||||
"`!filter whitelist <chat ID>`."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return await evt.reply(
|
|
||||||
"The bridge will now allow bridging chats by default.\n"
|
|
||||||
"To disallow bridging a specific chat, use"
|
|
||||||
"`!filter blacklist <chat ID>`."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
name="filter",
|
|
||||||
needs_admin=True,
|
|
||||||
help_section=SECTION_ADMIN,
|
|
||||||
help_args="<`whitelist`|`blacklist`> <_chat ID_>",
|
|
||||||
help_text="Allow or disallow bridging a specific chat.",
|
|
||||||
)
|
|
||||||
async def edit_filter(evt: CommandEvent) -> EventID:
|
|
||||||
try:
|
|
||||||
action = evt.args[0]
|
|
||||||
if action not in ("whitelist", "blacklist", "add", "remove"):
|
|
||||||
raise ValueError()
|
|
||||||
|
|
||||||
id_str = evt.args[1]
|
|
||||||
if id_str.startswith("-100"):
|
|
||||||
filter_id = int(id_str[4:])
|
|
||||||
elif id_str.startswith("-"):
|
|
||||||
filter_id = int(id_str[1:])
|
|
||||||
else:
|
|
||||||
filter_id = int(id_str)
|
|
||||||
except (IndexError, ValueError):
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
|
|
||||||
|
|
||||||
mode = evt.config["bridge.filter.mode"]
|
|
||||||
if mode not in ("blacklist", "whitelist"):
|
|
||||||
return await evt.reply(f'Unknown filter mode "{mode}". Please fix the bridge config.')
|
|
||||||
|
|
||||||
filter_id_list = evt.config["bridge.filter.list"]
|
|
||||||
|
|
||||||
if action in ("blacklist", "whitelist"):
|
|
||||||
action = "add" if mode == action else "remove"
|
|
||||||
|
|
||||||
def save() -> None:
|
|
||||||
evt.config["bridge.filter.list"] = filter_id_list
|
|
||||||
evt.config.save()
|
|
||||||
po.Portal.filter_list = filter_id_list
|
|
||||||
|
|
||||||
if action == "add":
|
|
||||||
if filter_id in filter_id_list:
|
|
||||||
return await evt.reply(f"That chat is already {mode}ed.")
|
|
||||||
filter_id_list.append(filter_id)
|
|
||||||
save()
|
|
||||||
return await evt.reply(f"Chat ID added to {mode}.")
|
|
||||||
elif action == "remove":
|
|
||||||
if filter_id not in filter_id_list:
|
|
||||||
return await evt.reply(f"That chat is not {mode}ed.")
|
|
||||||
filter_id_list.remove(filter_id)
|
|
||||||
save()
|
|
||||||
return await evt.reply(f"Chat ID removed from {mode}.")
|
|
||||||
else:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp filter <whitelist/blacklist> <chat ID>`")
|
|
||||||
@@ -1,347 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
import re
|
|
||||||
|
|
||||||
from telethon.errors import (
|
|
||||||
ChatAdminRequiredError,
|
|
||||||
RPCError,
|
|
||||||
UsernameInvalidError,
|
|
||||||
UsernameNotModifiedError,
|
|
||||||
UsernameOccupiedError,
|
|
||||||
)
|
|
||||||
from telethon.helpers import add_surrogate
|
|
||||||
from telethon.tl.functions.channels import GetFullChannelRequest
|
|
||||||
from telethon.tl.functions.messages import GetExportedChatInvitesRequest, GetFullChatRequest
|
|
||||||
from telethon.tl.types import (
|
|
||||||
ChatInviteExported,
|
|
||||||
InputMessageEntityMentionName,
|
|
||||||
InputUserSelf,
|
|
||||||
MessageEntityMention,
|
|
||||||
TypeInputPeer,
|
|
||||||
TypeInputUser,
|
|
||||||
)
|
|
||||||
from telethon.tl.types.messages import ExportedChatInvites
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from ... import formatter as fmt, portal as po, puppet as pu
|
|
||||||
from .. import SECTION_MISC, SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
|
|
||||||
from .util import user_has_power_level
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_admin=False,
|
|
||||||
needs_puppeting=False,
|
|
||||||
needs_auth=False,
|
|
||||||
help_section=SECTION_MISC,
|
|
||||||
help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.",
|
|
||||||
)
|
|
||||||
async def sync_state(evt: CommandEvent) -> EventID:
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
|
||||||
return await evt.reply("This is not a portal room.")
|
|
||||||
elif not await user_has_power_level(evt.room_id, evt.az.intent, evt.sender, "bridge"):
|
|
||||||
return await evt.reply(f"You do not have the permissions to synchronize this room.")
|
|
||||||
|
|
||||||
await portal.main_intent.get_joined_members(portal.mxid)
|
|
||||||
await evt.reply("Synchronization complete")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_admin=False, needs_puppeting=False, needs_auth=False, help_section=SECTION_MISC
|
|
||||||
)
|
|
||||||
async def sync_full(evt: CommandEvent) -> EventID:
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
|
||||||
return await evt.reply("This is not a portal room.")
|
|
||||||
|
|
||||||
if len(evt.args) > 0 and evt.args[0] == "--usebot" and evt.sender.is_admin:
|
|
||||||
src = evt.tgbot
|
|
||||||
else:
|
|
||||||
src = evt.tgbot if await evt.sender.needs_relaybot(portal) else evt.sender
|
|
||||||
|
|
||||||
try:
|
|
||||||
if portal.peer_type == "channel":
|
|
||||||
res = await src.client(GetFullChannelRequest(portal.peer))
|
|
||||||
elif portal.peer_type == "chat":
|
|
||||||
res = await src.client(GetFullChatRequest(portal.tgid))
|
|
||||||
else:
|
|
||||||
return await evt.reply("This is not a channel or chat portal.")
|
|
||||||
except (ValueError, RPCError):
|
|
||||||
return await evt.reply("Failed to get portal info from Telegram.")
|
|
||||||
|
|
||||||
await portal.update_matrix_room(src, res.full_chat)
|
|
||||||
return await evt.reply("Portal synced successfully.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
name="id",
|
|
||||||
needs_admin=False,
|
|
||||||
needs_puppeting=False,
|
|
||||||
needs_auth=False,
|
|
||||||
help_section=SECTION_MISC,
|
|
||||||
help_text="Get the ID of the Telegram chat where this room is bridged.",
|
|
||||||
)
|
|
||||||
async def get_id(evt: CommandEvent) -> EventID:
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
|
||||||
return await evt.reply("This is not a portal room.")
|
|
||||||
tgid = portal.tgid
|
|
||||||
if portal.peer_type == "chat":
|
|
||||||
tgid = -tgid
|
|
||||||
elif portal.peer_type == "channel":
|
|
||||||
tgid = f"-100{tgid}"
|
|
||||||
await evt.reply(f"This room is bridged to Telegram chat ID `{tgid}`.")
|
|
||||||
|
|
||||||
|
|
||||||
invite_link_usage = (
|
|
||||||
"**Usage:** `$cmdprefix+sp invite-link "
|
|
||||||
"[--uses=<amount>] [--expire=<delta>] [--request-needed] -- [title]`"
|
|
||||||
"\n\n"
|
|
||||||
"* `--uses`: the number of times the invite link can be used."
|
|
||||||
" Defaults to unlimited.\n"
|
|
||||||
"* `--expire`: the duration after which the link will expire."
|
|
||||||
" A number suffixed with d(ay), h(our), m(inute) or s(econd)\n"
|
|
||||||
"* `--request-needed`: should the link require admins to approve joins?\n"
|
|
||||||
"* `title`: a description of the link (only shown to admins)."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_flag(args: list[str]) -> tuple[str, str]:
|
|
||||||
arg = args.pop(0).lower()
|
|
||||||
if arg == "--":
|
|
||||||
return "", ""
|
|
||||||
value = ""
|
|
||||||
if arg.startswith("--"):
|
|
||||||
value_start = arg.find("=")
|
|
||||||
if value_start > 0:
|
|
||||||
flag = arg[2:value_start]
|
|
||||||
value = arg[value_start + 1 :]
|
|
||||||
else:
|
|
||||||
flag = arg[2:]
|
|
||||||
if arg not in ("request", "request-needed"):
|
|
||||||
value = args.pop(0).lower()
|
|
||||||
elif arg.startswith("-"):
|
|
||||||
flag = arg[1]
|
|
||||||
if len(arg) > 3 and arg[2] == "=":
|
|
||||||
value = arg[3:]
|
|
||||||
elif arg != "r":
|
|
||||||
value = args.pop(0).lower()
|
|
||||||
else:
|
|
||||||
raise ValueError("invalid flag")
|
|
||||||
return flag, value
|
|
||||||
|
|
||||||
|
|
||||||
delta_regex = re.compile(
|
|
||||||
"([0-9]+)(w(?:eek)?|d(?:ay)?|h(?:our)?|m(?:in(?:ute)?)?|s(?:ec(?:ond)?)?)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_delta(value: str) -> timedelta | None:
|
|
||||||
match = delta_regex.fullmatch(value)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
number = int(match.group(1))
|
|
||||||
unit = match.group(2)[0]
|
|
||||||
if unit == "w":
|
|
||||||
return timedelta(weeks=number)
|
|
||||||
elif unit == "d":
|
|
||||||
return timedelta(days=number)
|
|
||||||
elif unit == "h":
|
|
||||||
return timedelta(hours=number)
|
|
||||||
elif unit == "m":
|
|
||||||
return timedelta(minutes=number)
|
|
||||||
elif unit == "s":
|
|
||||||
return timedelta(seconds=number)
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_text="Get a Telegram invite link to the current chat.",
|
|
||||||
help_args="[--uses=<amount>] [--expire=<time delta, e.g. 1d>] [--request-needed] -- [title]",
|
|
||||||
)
|
|
||||||
async def invite_link(evt: CommandEvent) -> EventID:
|
|
||||||
if not evt.is_portal:
|
|
||||||
return await evt.reply("This is not a portal room.")
|
|
||||||
|
|
||||||
# TODO once we switch to Python 3.9 minimum, use argparse with exit_on_error=False
|
|
||||||
uses = None
|
|
||||||
expire = None
|
|
||||||
request_needed = False
|
|
||||||
while evt.args:
|
|
||||||
try:
|
|
||||||
flag, value = _parse_flag(evt.args)
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
return await evt.reply(invite_link_usage)
|
|
||||||
if not flag:
|
|
||||||
break
|
|
||||||
elif flag in ("uses", "u"):
|
|
||||||
try:
|
|
||||||
uses = int(value)
|
|
||||||
except ValueError:
|
|
||||||
await evt.reply("The number of uses must be an integer")
|
|
||||||
elif flag in ("expire", "e"):
|
|
||||||
expire_delta = _parse_delta(value)
|
|
||||||
if not expire_delta:
|
|
||||||
await evt.reply("Invalid format for expiry time delta")
|
|
||||||
expire = datetime.now() + expire_delta
|
|
||||||
elif flag in ("request", "request-needed", "r"):
|
|
||||||
request_needed = True
|
|
||||||
title = " ".join(evt.args)
|
|
||||||
|
|
||||||
if evt.portal.peer_type == "user":
|
|
||||||
return await evt.reply("You can't invite users to private chats.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
link = await evt.portal.get_invite_link(
|
|
||||||
evt.sender, uses=uses, expire=expire, request_needed=request_needed, title=title
|
|
||||||
)
|
|
||||||
return await evt.reply(f"Invite link to {evt.portal.title}: {link}")
|
|
||||||
except ValueError as e:
|
|
||||||
return await evt.reply(e.args[0])
|
|
||||||
except ChatAdminRequiredError:
|
|
||||||
return await evt.reply("You don't have the permission to create an invite link.")
|
|
||||||
|
|
||||||
|
|
||||||
async def _format_invite_link(link: ChatInviteExported) -> str:
|
|
||||||
desc = f"* {link.link}"
|
|
||||||
if link.title:
|
|
||||||
desc += f" - {link.title}"
|
|
||||||
if link.expire_date:
|
|
||||||
desc += f" \n Expires at {link.expire_date.isoformat()}"
|
|
||||||
if link.usage_limit:
|
|
||||||
desc += f" \n Used {link.usage or 0} out of {link.usage_limit} times"
|
|
||||||
elif link.usage:
|
|
||||||
desc += f" \n Used {link.usage} times"
|
|
||||||
else:
|
|
||||||
desc += " \n Never used"
|
|
||||||
if link.request_needed:
|
|
||||||
desc += " \n Join requests enabled - using link requires admin approval"
|
|
||||||
return desc
|
|
||||||
|
|
||||||
|
|
||||||
async def _hacky_find_mention(evt: CommandEvent) -> TypeInputUser | TypeInputPeer | None:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return None
|
|
||||||
text, entities = await fmt.matrix_to_telegram(
|
|
||||||
evt.sender.client, text=evt.content.body, html=evt.content.formatted_body
|
|
||||||
)
|
|
||||||
for entity in entities:
|
|
||||||
if isinstance(entity, MessageEntityMention):
|
|
||||||
admin_username = add_surrogate(text)[entity.offset + 1 : entity.offset + entity.length]
|
|
||||||
return await evt.sender.client.get_input_entity(admin_username)
|
|
||||||
elif isinstance(entity, InputMessageEntityMentionName):
|
|
||||||
return entity.user_id
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_text="List existing Telegram invite links to the current chat.",
|
|
||||||
help_args="[creator]",
|
|
||||||
)
|
|
||||||
async def list_invite_links(evt: CommandEvent) -> EventID:
|
|
||||||
admin_id = InputUserSelf()
|
|
||||||
try:
|
|
||||||
admin_id = await _hacky_find_mention(evt) or InputUserSelf()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
resp: ExportedChatInvites = await evt.sender.client(
|
|
||||||
GetExportedChatInvitesRequest(
|
|
||||||
peer=await evt.portal.get_input_entity(evt.sender),
|
|
||||||
admin_id=admin_id,
|
|
||||||
limit=100,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if resp.count == 0:
|
|
||||||
if isinstance(admin_id, InputUserSelf):
|
|
||||||
return await evt.reply("You haven't created any invite links to the current chat")
|
|
||||||
else:
|
|
||||||
return await evt.reply("That user hasn't created any invite links to the current chat")
|
|
||||||
formatted_links = "\n".join([await _format_invite_link(link) for link in resp.invites])
|
|
||||||
if isinstance(admin_id, InputUserSelf):
|
|
||||||
await evt.reply(f"Your links to this chat:\n\n{formatted_links}")
|
|
||||||
else:
|
|
||||||
puppet = await pu.Puppet.get_by_peer(admin_id)
|
|
||||||
await evt.reply(
|
|
||||||
f"[{puppet.displayname}](https://matrix.to/#/{puppet.mxid})'s links to this chat:\n\n"
|
|
||||||
f"{formatted_links}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_text="Upgrade a normal Telegram group to a supergroup.",
|
|
||||||
)
|
|
||||||
async def upgrade(evt: CommandEvent) -> EventID:
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
|
||||||
return await evt.reply("This is not a portal room.")
|
|
||||||
elif portal.peer_type == "channel":
|
|
||||||
return await evt.reply("This is already a supergroup or a channel.")
|
|
||||||
elif portal.peer_type == "user":
|
|
||||||
return await evt.reply("You can't upgrade private chats.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await portal.upgrade_telegram_chat(evt.sender)
|
|
||||||
return await evt.reply(f"Group upgraded to supergroup. New ID: -100{portal.tgid}")
|
|
||||||
except ChatAdminRequiredError:
|
|
||||||
return await evt.reply("You don't have the permission to upgrade this group.")
|
|
||||||
except ValueError as e:
|
|
||||||
return await evt.reply(e.args[0])
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_args="<_name_|`-`>",
|
|
||||||
help_text=(
|
|
||||||
"Change the username of a supergroup/channel. To disable, use a dash (`-`) as the name."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def group_name(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal:
|
|
||||||
return await evt.reply("This is not a portal room.")
|
|
||||||
elif portal.peer_type != "channel":
|
|
||||||
return await evt.reply("Only channels and supergroups have usernames.")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await portal.set_telegram_username(evt.sender, evt.args[0] if evt.args[0] != "-" else "")
|
|
||||||
if portal.username:
|
|
||||||
return await evt.reply(f"Username of channel changed to {portal.username}.")
|
|
||||||
else:
|
|
||||||
return await evt.reply(f"Channel is now private.")
|
|
||||||
except ChatAdminRequiredError:
|
|
||||||
return await evt.reply(
|
|
||||||
"You don't have the permission to set the username of this channel."
|
|
||||||
)
|
|
||||||
except UsernameNotModifiedError:
|
|
||||||
if portal.username:
|
|
||||||
return await evt.reply("That is already the username of this channel.")
|
|
||||||
else:
|
|
||||||
return await evt.reply("This channel is already private")
|
|
||||||
except UsernameOccupiedError:
|
|
||||||
return await evt.reply("That username is already in use.")
|
|
||||||
except UsernameInvalidError:
|
|
||||||
return await evt.reply("Invalid username")
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID
|
|
||||||
|
|
||||||
from ... import portal as po
|
|
||||||
from .. import SECTION_PORTAL_MANAGEMENT, CommandEvent, command_handler
|
|
||||||
from .util import user_has_power_level
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_portal_and_check_permission(evt: CommandEvent) -> po.Portal | None:
|
|
||||||
room_id = RoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal:
|
|
||||||
that_this = "This" if room_id == evt.room_id else "That"
|
|
||||||
await evt.reply(f"{that_this} is not a portal room.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
if portal.peer_type == "user":
|
|
||||||
if portal.tg_receiver != evt.sender.tgid:
|
|
||||||
await evt.reply("You do not have the permissions to unbridge that portal.")
|
|
||||||
return None
|
|
||||||
return portal
|
|
||||||
|
|
||||||
if not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, "unbridge"):
|
|
||||||
await evt.reply("You do not have the permissions to unbridge that portal.")
|
|
||||||
return None
|
|
||||||
return portal
|
|
||||||
|
|
||||||
|
|
||||||
def _get_portal_murder_function(
|
|
||||||
action: str, room_id: str, function: Callable, command: str, completed_message: str
|
|
||||||
) -> dict:
|
|
||||||
async def post_confirm(confirm) -> EventID | None:
|
|
||||||
confirm.sender.command_status = None
|
|
||||||
if len(confirm.args) > 0 and confirm.args[0] == f"confirm-{command}":
|
|
||||||
await function()
|
|
||||||
if confirm.room_id != room_id:
|
|
||||||
return await confirm.reply(completed_message)
|
|
||||||
else:
|
|
||||||
return await confirm.reply(f"{action} cancelled.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"next": post_confirm,
|
|
||||||
"action": action,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False,
|
|
||||||
needs_puppeting=False,
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_text=(
|
|
||||||
"Remove all users from the current portal room and forget the portal. "
|
|
||||||
"Only works for group chats; to delete a private chat portal, simply leave the room."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def delete_portal(evt: CommandEvent) -> EventID | None:
|
|
||||||
portal = await _get_portal_and_check_permission(evt)
|
|
||||||
if not portal:
|
|
||||||
return None
|
|
||||||
|
|
||||||
evt.sender.command_status = _get_portal_murder_function(
|
|
||||||
"Portal deletion",
|
|
||||||
portal.mxid,
|
|
||||||
portal.cleanup_and_delete,
|
|
||||||
"delete",
|
|
||||||
"Portal successfully deleted.",
|
|
||||||
)
|
|
||||||
return await evt.reply(
|
|
||||||
"Please confirm deletion of portal "
|
|
||||||
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
|
|
||||||
f'to Telegram chat "{portal.title}" '
|
|
||||||
"by typing `$cmdprefix+sp confirm-delete`"
|
|
||||||
"\n\n"
|
|
||||||
"**WARNING:** If the bridge bot has the power level to do so, **this "
|
|
||||||
"will kick ALL users** in the room. If you just want to remove the "
|
|
||||||
"bridge, use `$cmdprefix+sp unbridge` instead."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False,
|
|
||||||
needs_puppeting=False,
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_text="Remove puppets from the current portal room and forget the portal.",
|
|
||||||
)
|
|
||||||
async def unbridge(evt: CommandEvent) -> EventID | None:
|
|
||||||
portal = await _get_portal_and_check_permission(evt)
|
|
||||||
if not portal:
|
|
||||||
return None
|
|
||||||
|
|
||||||
evt.sender.command_status = _get_portal_murder_function(
|
|
||||||
"Room unbridging", portal.mxid, portal.unbridge, "unbridge", "Room successfully unbridged."
|
|
||||||
)
|
|
||||||
return await evt.reply(
|
|
||||||
f'Please confirm unbridging chat "{portal.title}" from room '
|
|
||||||
f"[{portal.alias or portal.mxid}](https://matrix.to/#/{portal.mxid}) "
|
|
||||||
"by typing `$cmdprefix+sp confirm-unbridge`"
|
|
||||||
)
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from mautrix.appservice import IntentAPI
|
|
||||||
from mautrix.errors import MatrixRequestError
|
|
||||||
from mautrix.types import EventType, PowerLevelStateEventContent, RoomID
|
|
||||||
|
|
||||||
from ... import user as u
|
|
||||||
from .. import CommandEvent
|
|
||||||
|
|
||||||
|
|
||||||
async def get_initial_state(
|
|
||||||
intent: IntentAPI, room_id: RoomID
|
|
||||||
) -> tuple[str | None, str | None, PowerLevelStateEventContent | None, bool]:
|
|
||||||
state = await intent.get_state(room_id)
|
|
||||||
title: str | None = None
|
|
||||||
about: str | None = None
|
|
||||||
levels: PowerLevelStateEventContent | None = None
|
|
||||||
encrypted: bool = False
|
|
||||||
for event in state:
|
|
||||||
try:
|
|
||||||
if event.type == EventType.ROOM_NAME:
|
|
||||||
title = event.content.name
|
|
||||||
elif event.type == EventType.ROOM_TOPIC:
|
|
||||||
about = event.content.topic
|
|
||||||
elif event.type == EventType.ROOM_POWER_LEVELS:
|
|
||||||
levels = event.content
|
|
||||||
elif event.type == EventType.ROOM_CANONICAL_ALIAS:
|
|
||||||
title = title or event.content.canonical_alias
|
|
||||||
elif event.type == EventType.ROOM_ENCRYPTION:
|
|
||||||
encrypted = True
|
|
||||||
except KeyError:
|
|
||||||
# Some state event probably has empty content
|
|
||||||
pass
|
|
||||||
return title, about, levels, encrypted
|
|
||||||
|
|
||||||
|
|
||||||
async def warn_missing_power(levels: PowerLevelStateEventContent, evt: CommandEvent) -> None:
|
|
||||||
if levels.get_user_level(evt.az.bot_mxid) < levels.redact:
|
|
||||||
await evt.reply(
|
|
||||||
"Warning: The bot does not have privileges to redact messages on Matrix. "
|
|
||||||
"Message deletions from Telegram will not be bridged unless you give "
|
|
||||||
f"redaction permissions to [{evt.az.bot_mxid}](https://matrix.to/#/{evt.az.bot_mxid})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def user_has_power_level(
|
|
||||||
room_id: RoomID, intent: IntentAPI, sender: u.User, event: str
|
|
||||||
) -> bool:
|
|
||||||
if sender.is_admin:
|
|
||||||
return True
|
|
||||||
# Make sure the state store contains the power levels.
|
|
||||||
try:
|
|
||||||
await intent.get_power_levels(room_id)
|
|
||||||
except MatrixRequestError:
|
|
||||||
return False
|
|
||||||
event_type = EventType.find(f"fi.mau.telegram.{event}", t_class=EventType.Class.STATE)
|
|
||||||
return await intent.state_store.has_power_level(room_id, sender.mxid, event_type)
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
from . import account, auth, misc
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from telethon.errors import (
|
|
||||||
AboutTooLongError,
|
|
||||||
AuthKeyError,
|
|
||||||
FirstNameInvalidError,
|
|
||||||
HashInvalidError,
|
|
||||||
UsernameInvalidError,
|
|
||||||
UsernameNotModifiedError,
|
|
||||||
UsernameOccupiedError,
|
|
||||||
)
|
|
||||||
from telethon.tl.functions.account import (
|
|
||||||
GetAuthorizationsRequest,
|
|
||||||
ResetAuthorizationRequest,
|
|
||||||
UpdateProfileRequest,
|
|
||||||
UpdateUsernameRequest,
|
|
||||||
)
|
|
||||||
from telethon.tl.types import Authorization
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from .. import SECTION_AUTH, CommandEvent, command_handler
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_args="<_new username_>",
|
|
||||||
help_text="Change your Telegram username.",
|
|
||||||
)
|
|
||||||
async def username(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp username <new username>`")
|
|
||||||
if evt.sender.is_bot:
|
|
||||||
return await evt.reply("Bots can't set their own username.")
|
|
||||||
new_name = evt.args[0]
|
|
||||||
if new_name == "-":
|
|
||||||
new_name = ""
|
|
||||||
try:
|
|
||||||
await evt.sender.client(UpdateUsernameRequest(username=new_name))
|
|
||||||
except UsernameInvalidError:
|
|
||||||
return await evt.reply(
|
|
||||||
"Invalid username. Usernames must be between 5 and 30 alphanumeric characters."
|
|
||||||
)
|
|
||||||
except UsernameNotModifiedError:
|
|
||||||
return await evt.reply("That is your current username.")
|
|
||||||
except UsernameOccupiedError:
|
|
||||||
return await evt.reply("That username is already in use.")
|
|
||||||
await evt.sender.update_info()
|
|
||||||
if not evt.sender.tg_username:
|
|
||||||
await evt.reply("Username removed")
|
|
||||||
else:
|
|
||||||
await evt.reply(f"Username changed to {evt.sender.tg_username}")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_args="<_new about_>",
|
|
||||||
help_text="Change your Telegram about section.",
|
|
||||||
)
|
|
||||||
async def about(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp about <new about>`")
|
|
||||||
if evt.sender.is_bot:
|
|
||||||
return await evt.reply("Bots can't set their own about section.")
|
|
||||||
new_about = " ".join(evt.args)
|
|
||||||
if new_about == "-":
|
|
||||||
new_about = ""
|
|
||||||
try:
|
|
||||||
await evt.sender.client(UpdateProfileRequest(about=new_about))
|
|
||||||
except AboutTooLongError:
|
|
||||||
return await evt.reply("The provided about section is too long")
|
|
||||||
return await evt.reply("About section updated")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_args="<_new displayname_>",
|
|
||||||
help_text="Change your Telegram displayname.",
|
|
||||||
)
|
|
||||||
async def displayname(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp displayname <new displayname>`")
|
|
||||||
if evt.sender.is_bot:
|
|
||||||
return await evt.reply("Bots can't set their own displayname.")
|
|
||||||
|
|
||||||
first_name, last_name = (
|
|
||||||
(evt.args[0], "") if len(evt.args) == 1 else (" ".join(evt.args[:-1]), evt.args[-1])
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await evt.sender.client(UpdateProfileRequest(first_name=first_name, last_name=last_name))
|
|
||||||
except FirstNameInvalidError:
|
|
||||||
return await evt.reply("Invalid first name")
|
|
||||||
await evt.sender.update_info()
|
|
||||||
return await evt.reply("Displayname updated")
|
|
||||||
|
|
||||||
|
|
||||||
def _format_session(sess: Authorization) -> str:
|
|
||||||
return (
|
|
||||||
f"**{sess.app_name} {sess.app_version}** \n"
|
|
||||||
f" **Platform:** {sess.device_model} {sess.platform} {sess.system_version} \n"
|
|
||||||
f" **Active:** {sess.date_active} (created {sess.date_created}) \n"
|
|
||||||
f" **From:** {sess.ip} - {sess.region}, {sess.country}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_args="<`list`|`terminate`> [_hash_]",
|
|
||||||
help_text="View or delete other Telegram sessions.",
|
|
||||||
)
|
|
||||||
async def session(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
|
|
||||||
elif evt.sender.is_bot:
|
|
||||||
return await evt.reply("Bots can't manage their sessions")
|
|
||||||
cmd = evt.args[0].lower()
|
|
||||||
if cmd == "list":
|
|
||||||
res = await evt.sender.client(GetAuthorizationsRequest())
|
|
||||||
session_list = res.authorizations
|
|
||||||
current = [s for s in session_list if s.current][0]
|
|
||||||
current_text = _format_session(current)
|
|
||||||
other_text = "\n".join(
|
|
||||||
f"* {_format_session(sess)} \n **Hash:** {sess.hash}"
|
|
||||||
for sess in session_list
|
|
||||||
if not sess.current
|
|
||||||
)
|
|
||||||
return await evt.reply(
|
|
||||||
f"### Current session\n"
|
|
||||||
f"{current_text}\n"
|
|
||||||
f"\n"
|
|
||||||
f"### Other active sessions\n"
|
|
||||||
f"{other_text}"
|
|
||||||
)
|
|
||||||
elif cmd == "terminate" and len(evt.args) > 1:
|
|
||||||
try:
|
|
||||||
session_hash = int(evt.args[1])
|
|
||||||
except ValueError:
|
|
||||||
return await evt.reply("Hash must be an integer")
|
|
||||||
try:
|
|
||||||
ok = await evt.sender.client(ResetAuthorizationRequest(hash=session_hash))
|
|
||||||
except HashInvalidError:
|
|
||||||
return await evt.reply("Invalid session hash.")
|
|
||||||
except AuthKeyError as e:
|
|
||||||
if e.message == "FRESH_RESET_AUTHORISATION_FORBIDDEN":
|
|
||||||
return await evt.reply(
|
|
||||||
"New sessions can't terminate other sessions. Please wait a while."
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
if ok:
|
|
||||||
return await evt.reply("Session terminated successfully.")
|
|
||||||
else:
|
|
||||||
return await evt.reply("Session not found.")
|
|
||||||
else:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp session <list|terminate> [hash]`")
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
import asyncio
|
|
||||||
import io
|
|
||||||
|
|
||||||
from telethon.errors import (
|
|
||||||
AccessTokenExpiredError,
|
|
||||||
AccessTokenInvalidError,
|
|
||||||
FloodWaitError,
|
|
||||||
PasswordHashInvalidError,
|
|
||||||
PhoneCodeExpiredError,
|
|
||||||
PhoneCodeInvalidError,
|
|
||||||
PhoneNumberAppSignupForbiddenError,
|
|
||||||
PhoneNumberBannedError,
|
|
||||||
PhoneNumberFloodError,
|
|
||||||
PhoneNumberInvalidError,
|
|
||||||
PhoneNumberUnoccupiedError,
|
|
||||||
SessionPasswordNeededError,
|
|
||||||
)
|
|
||||||
from telethon.tl.types import User
|
|
||||||
|
|
||||||
from mautrix.client import Client
|
|
||||||
from mautrix.types import (
|
|
||||||
EventID,
|
|
||||||
ImageInfo,
|
|
||||||
MediaMessageEventContent,
|
|
||||||
MessageType,
|
|
||||||
TextMessageEventContent,
|
|
||||||
UserID,
|
|
||||||
)
|
|
||||||
from mautrix.util import background_task
|
|
||||||
from mautrix.util.format_duration import format_duration as fmt_duration
|
|
||||||
|
|
||||||
from ... import user as u
|
|
||||||
from ...commands import SECTION_AUTH, CommandEvent, command_handler
|
|
||||||
from ...types import TelegramID
|
|
||||||
|
|
||||||
try:
|
|
||||||
from telethon.tl.custom import QRLogin
|
|
||||||
import PIL as _
|
|
||||||
import qrcode
|
|
||||||
except ImportError:
|
|
||||||
qrcode = None
|
|
||||||
QRLogin = None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False, help_section=SECTION_AUTH, help_text="Check if you're logged into Telegram."
|
|
||||||
)
|
|
||||||
async def ping(evt: CommandEvent) -> EventID:
|
|
||||||
if await evt.sender.is_logged_in():
|
|
||||||
me = await evt.sender.get_me()
|
|
||||||
if me:
|
|
||||||
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
|
|
||||||
return await evt.reply(f"You're logged in as {human_tg_id}")
|
|
||||||
else:
|
|
||||||
return await evt.reply("You were logged in, but there appears to have been an error.")
|
|
||||||
else:
|
|
||||||
return await evt.reply("You're not logged in.")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False,
|
|
||||||
needs_puppeting=False,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Get the info of the message relay Telegram bot.",
|
|
||||||
)
|
|
||||||
async def ping_bot(evt: CommandEvent) -> EventID:
|
|
||||||
if not evt.tgbot:
|
|
||||||
return await evt.reply("Telegram message relay bot not configured.")
|
|
||||||
info, mxid = await evt.tgbot.get_me(use_cache=False)
|
|
||||||
return await evt.reply(
|
|
||||||
"Telegram message relay bot is active: "
|
|
||||||
f"[{info.first_name}](https://matrix.to/#/{mxid}) (ID {info.id})\n\n"
|
|
||||||
"To use the bot, simply invite it to a portal room."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False,
|
|
||||||
management_only=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Log in by scanning a QR code.",
|
|
||||||
)
|
|
||||||
async def login_qr(evt: CommandEvent) -> EventID:
|
|
||||||
login_as = evt.sender
|
|
||||||
if len(evt.args) > 0 and evt.sender.is_admin:
|
|
||||||
login_as = await u.User.get_by_mxid(UserID(evt.args[0]))
|
|
||||||
if not qrcode or not QRLogin:
|
|
||||||
return await evt.reply("This bridge instance does not support logging in with a QR code.")
|
|
||||||
if await login_as.is_logged_in():
|
|
||||||
return await evt.reply(f"You are already logged in as {login_as.human_tg_id}.")
|
|
||||||
|
|
||||||
await login_as.ensure_started(even_if_no_session=True)
|
|
||||||
qr_login = QRLogin(login_as.client, ignored_ids=[])
|
|
||||||
qr_event_id: EventID | None = None
|
|
||||||
|
|
||||||
async def upload_qr() -> None:
|
|
||||||
nonlocal qr_event_id
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
image = qrcode.make(qr_login.url)
|
|
||||||
size = image.pixel_size
|
|
||||||
image.save(buffer, "PNG")
|
|
||||||
qr = buffer.getvalue()
|
|
||||||
mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr))
|
|
||||||
content = MediaMessageEventContent(
|
|
||||||
body=qr_login.url,
|
|
||||||
url=mxc,
|
|
||||||
msgtype=MessageType.IMAGE,
|
|
||||||
info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size),
|
|
||||||
)
|
|
||||||
if qr_event_id:
|
|
||||||
content.set_edit(qr_event_id)
|
|
||||||
await evt.az.intent.send_message(evt.room_id, content)
|
|
||||||
else:
|
|
||||||
content.set_reply(evt.event_id)
|
|
||||||
qr_event_id = await evt.az.intent.send_message(evt.room_id, content)
|
|
||||||
|
|
||||||
retries = 4
|
|
||||||
while retries > 0:
|
|
||||||
await qr_login.recreate()
|
|
||||||
await upload_qr()
|
|
||||||
try:
|
|
||||||
user = await qr_login.wait()
|
|
||||||
break
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
retries -= 1
|
|
||||||
except SessionPasswordNeededError:
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": enter_password,
|
|
||||||
"login_as": login_as if login_as != evt.sender else None,
|
|
||||||
"action": "Login (password entry)",
|
|
||||||
}
|
|
||||||
return await evt.reply(
|
|
||||||
"Your account has two-factor authentication. Please send your password here."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await evt.main_intent.redact(evt.room_id, qr_event_id, reason="QR code scanned")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
timeout = TextMessageEventContent(body="Login timed out", msgtype=MessageType.TEXT)
|
|
||||||
timeout.set_edit(qr_event_id)
|
|
||||||
return await evt.az.intent.send_message(evt.room_id, timeout)
|
|
||||||
|
|
||||||
return await _finish_sign_in(evt, user, login_as=login_as)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False,
|
|
||||||
management_only=True,
|
|
||||||
help_section=SECTION_AUTH,
|
|
||||||
help_text="Get instructions on how to log in.",
|
|
||||||
)
|
|
||||||
async def login(evt: CommandEvent) -> EventID:
|
|
||||||
override_sender = False
|
|
||||||
if len(evt.args) > 0 and evt.sender.is_admin and evt.args[0]:
|
|
||||||
override_user_id = UserID(evt.args[0])
|
|
||||||
try:
|
|
||||||
Client.parse_user_id(override_user_id)
|
|
||||||
except ValueError:
|
|
||||||
return await evt.reply(
|
|
||||||
f"**Usage:** `$cmdprefix+sp login [override user ID]`\n\n"
|
|
||||||
f"{override_user_id!r} is not a valid Matrix user ID"
|
|
||||||
)
|
|
||||||
orig_user_id = evt.sender.mxid
|
|
||||||
evt.sender = await u.User.get_and_start_by_mxid(override_user_id)
|
|
||||||
override_sender = True
|
|
||||||
if orig_user_id != evt.sender:
|
|
||||||
await evt.reply(
|
|
||||||
f"Admin override: logging in as {evt.sender.mxid} instead of {orig_user_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if await evt.sender.is_logged_in():
|
|
||||||
return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.")
|
|
||||||
|
|
||||||
allow_matrix_login = evt.config["bridge.allow_matrix_login"]
|
|
||||||
if allow_matrix_login and not override_sender:
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": enter_phone_or_token,
|
|
||||||
"action": "Login",
|
|
||||||
}
|
|
||||||
|
|
||||||
nb = "**N.B. Logging in grants the bridge full access to your Telegram account.**"
|
|
||||||
if evt.config["appservice.public.enabled"]:
|
|
||||||
prefix = evt.config["appservice.public.external"]
|
|
||||||
url = f"{prefix}/login?token={evt.public_website.make_token(evt.sender.mxid, '/login')}"
|
|
||||||
if override_sender:
|
|
||||||
return await evt.reply(
|
|
||||||
f"[Click here to log in]({url}) as "
|
|
||||||
f"[{evt.sender.mxid}](https://matrix.to/#/{evt.sender.mxid})."
|
|
||||||
)
|
|
||||||
elif allow_matrix_login:
|
|
||||||
return await evt.reply(
|
|
||||||
f"[Click here to log in]({url}). Alternatively, send your phone"
|
|
||||||
f" number (or bot auth token) here to log in.\n\n{nb}"
|
|
||||||
)
|
|
||||||
return await evt.reply(f"[Click here to log in]({url}).\n\n{nb}")
|
|
||||||
elif allow_matrix_login:
|
|
||||||
if override_sender:
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance does not allow you to log in outside of Matrix. "
|
|
||||||
"Logging in as another user inside Matrix is not currently possible."
|
|
||||||
)
|
|
||||||
return await evt.reply(
|
|
||||||
"Please send your phone number (or bot auth token) here to start "
|
|
||||||
f"the login process.\n\n{nb}"
|
|
||||||
)
|
|
||||||
return await evt.reply("This bridge instance has been configured to not allow logging in.")
|
|
||||||
|
|
||||||
|
|
||||||
async def _request_code(
|
|
||||||
evt: CommandEvent, phone_number: str, next_status: dict[str, Any]
|
|
||||||
) -> EventID:
|
|
||||||
ok = False
|
|
||||||
try:
|
|
||||||
await evt.sender.ensure_started(even_if_no_session=True)
|
|
||||||
await evt.sender.client.sign_in(phone_number)
|
|
||||||
ok = True
|
|
||||||
return await evt.reply(f"Login code sent to {phone_number}. Please send the code here.")
|
|
||||||
except PhoneNumberAppSignupForbiddenError:
|
|
||||||
return await evt.reply("Your phone number does not allow 3rd party apps to sign in.")
|
|
||||||
except PhoneNumberFloodError:
|
|
||||||
return await evt.reply(
|
|
||||||
"Your phone number has been temporarily blocked for flooding. "
|
|
||||||
"The ban is usually applied for around a day."
|
|
||||||
)
|
|
||||||
except FloodWaitError as e:
|
|
||||||
return await evt.reply(
|
|
||||||
"Your phone number has been temporarily blocked for flooding. "
|
|
||||||
f"Please wait for {fmt_duration(e.seconds)} before trying again."
|
|
||||||
)
|
|
||||||
except PhoneNumberBannedError:
|
|
||||||
return await evt.reply("Your phone number has been banned from Telegram.")
|
|
||||||
except PhoneNumberUnoccupiedError:
|
|
||||||
return await evt.reply(
|
|
||||||
"That phone number has not been registered. "
|
|
||||||
"Please sign up to Telegram using an official mobile client first."
|
|
||||||
)
|
|
||||||
except PhoneNumberInvalidError:
|
|
||||||
return await evt.reply("That phone number is not valid.")
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Error requesting phone code")
|
|
||||||
return await evt.reply(
|
|
||||||
"Unhandled exception while requesting code. Check console for more details."
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
evt.sender.command_status = next_status if ok else None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False)
|
|
||||||
async def enter_phone_or_token(evt: CommandEvent) -> EventID | None:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-phone-or-token <phone-or-token>`")
|
|
||||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance does not allow in-Matrix login. "
|
|
||||||
"Please use `$cmdprefix+sp login` to get login instructions"
|
|
||||||
)
|
|
||||||
|
|
||||||
# phone numbers don't contain colons but telegram bot auth tokens do
|
|
||||||
if evt.args[0].find(":") > 0:
|
|
||||||
try:
|
|
||||||
await _sign_in(evt, bot_token=evt.args[0])
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Error sending auth token")
|
|
||||||
return await evt.reply(
|
|
||||||
"Unhandled exception while sending auth token. Check console for more details."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await _request_code(evt, evt.args[0], {"next": enter_code, "action": "Login"})
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False)
|
|
||||||
async def enter_code(evt: CommandEvent) -> EventID | None:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-code <code>`")
|
|
||||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance does not allow in-Matrix login. "
|
|
||||||
"Please use `$cmdprefix+sp login` to get login instructions"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await _sign_in(evt, code=evt.args[0])
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Error sending phone code")
|
|
||||||
return await evt.reply(
|
|
||||||
"Unhandled exception while sending code. Check console for more details."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False)
|
|
||||||
async def enter_password(evt: CommandEvent) -> EventID | None:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp enter-password <password>`")
|
|
||||||
elif not evt.config.get("bridge.allow_matrix_login", True):
|
|
||||||
return await evt.reply(
|
|
||||||
"This bridge instance does not allow in-Matrix login. "
|
|
||||||
"Please use `$cmdprefix+sp login` to get login instructions"
|
|
||||||
)
|
|
||||||
await evt.redact()
|
|
||||||
try:
|
|
||||||
await _sign_in(
|
|
||||||
evt,
|
|
||||||
login_as=evt.sender.command_status.get("login_as", None),
|
|
||||||
password=" ".join(evt.args),
|
|
||||||
)
|
|
||||||
except AccessTokenInvalidError:
|
|
||||||
return await evt.reply("That bot token is not valid.")
|
|
||||||
except AccessTokenExpiredError:
|
|
||||||
return await evt.reply("That bot token has expired.")
|
|
||||||
except Exception:
|
|
||||||
evt.log.exception("Error sending password")
|
|
||||||
return await evt.reply(
|
|
||||||
"Unhandled exception while sending password. Check console for more details."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _sign_in(evt: CommandEvent, login_as: u.User = None, **sign_in_info) -> EventID:
|
|
||||||
login_as = login_as or evt.sender
|
|
||||||
try:
|
|
||||||
await login_as.ensure_started(even_if_no_session=True)
|
|
||||||
user = await login_as.client.sign_in(**sign_in_info)
|
|
||||||
await _finish_sign_in(evt, user)
|
|
||||||
except PhoneCodeExpiredError:
|
|
||||||
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
|
|
||||||
except PhoneCodeInvalidError:
|
|
||||||
return await evt.reply("Invalid phone code.")
|
|
||||||
except PasswordHashInvalidError:
|
|
||||||
return await evt.reply("Incorrect password.")
|
|
||||||
except SessionPasswordNeededError:
|
|
||||||
evt.sender.command_status = {
|
|
||||||
"next": enter_password,
|
|
||||||
"action": "Login (password entry)",
|
|
||||||
}
|
|
||||||
return await evt.reply(
|
|
||||||
"Your account has two-factor authentication. Please send your password here."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _finish_sign_in(evt: CommandEvent, user: User, login_as: u.User = None) -> EventID:
|
|
||||||
login_as = login_as or evt.sender
|
|
||||||
existing_user = await u.User.get_by_tgid(TelegramID(user.id))
|
|
||||||
if existing_user and existing_user != login_as:
|
|
||||||
await existing_user.log_out()
|
|
||||||
await evt.reply(
|
|
||||||
f"[{existing_user.displayname}] (https://matrix.to/#/{existing_user.mxid})"
|
|
||||||
" was logged out from the account."
|
|
||||||
)
|
|
||||||
background_task.create(login_as.post_login(user, first_login=True))
|
|
||||||
evt.sender.command_status = None
|
|
||||||
name = f"@{user.username}" if user.username else f"+{user.phone}"
|
|
||||||
if login_as != evt.sender:
|
|
||||||
msg = (
|
|
||||||
f"Successfully logged in [{login_as.mxid}](https://matrix.to/#/{login_as.mxid})"
|
|
||||||
f" as {name}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
msg = f"Successfully logged in as {name}"
|
|
||||||
return await evt.reply(msg)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(needs_auth=False, help_section=SECTION_AUTH, help_text="Log out from Telegram.")
|
|
||||||
async def logout(evt: CommandEvent) -> EventID:
|
|
||||||
if not evt.sender.tgid:
|
|
||||||
return await evt.reply("You're not logged in")
|
|
||||||
if await evt.sender.log_out():
|
|
||||||
return await evt.reply("Logged out successfully.")
|
|
||||||
return await evt.reply("Failed to log out.")
|
|
||||||
@@ -1,516 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import cast
|
|
||||||
import base64
|
|
||||||
import codecs
|
|
||||||
import re
|
|
||||||
import shlex
|
|
||||||
|
|
||||||
from aiohttp import ClientSession, InvalidURL
|
|
||||||
from telethon.errors import (
|
|
||||||
ChatIdInvalidError,
|
|
||||||
EmoticonInvalidError,
|
|
||||||
InviteHashExpiredError,
|
|
||||||
InviteHashInvalidError,
|
|
||||||
InviteRequestSentError,
|
|
||||||
OptionsTooMuchError,
|
|
||||||
UserAlreadyParticipantError,
|
|
||||||
)
|
|
||||||
from telethon.tl.functions.channels import JoinChannelRequest
|
|
||||||
from telethon.tl.functions.contacts import DeleteByPhonesRequest, ImportContactsRequest
|
|
||||||
from telethon.tl.functions.messages import (
|
|
||||||
CheckChatInviteRequest,
|
|
||||||
GetBotCallbackAnswerRequest,
|
|
||||||
ImportChatInviteRequest,
|
|
||||||
SendVoteRequest,
|
|
||||||
)
|
|
||||||
from telethon.tl.patched import Message
|
|
||||||
from telethon.tl.types import (
|
|
||||||
InputMediaDice,
|
|
||||||
InputPhoneContact,
|
|
||||||
MessageMediaGame,
|
|
||||||
MessageMediaPoll,
|
|
||||||
TypeInputPeer,
|
|
||||||
TypeUpdates,
|
|
||||||
User as TLUser,
|
|
||||||
)
|
|
||||||
from telethon.tl.types.contacts import ImportedContacts
|
|
||||||
from telethon.tl.types.messages import BotCallbackAnswer
|
|
||||||
|
|
||||||
from mautrix.types import EventID, Format
|
|
||||||
|
|
||||||
from ... import portal as po, puppet as pu
|
|
||||||
from ...abstract_user import AbstractUser
|
|
||||||
from ...commands import (
|
|
||||||
SECTION_CREATING_PORTALS,
|
|
||||||
SECTION_MISC,
|
|
||||||
SECTION_PORTAL_MANAGEMENT,
|
|
||||||
CommandEvent,
|
|
||||||
command_handler,
|
|
||||||
)
|
|
||||||
from ...db import Message as DBMessage
|
|
||||||
from ...types import TelegramID
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
needs_auth=False,
|
|
||||||
needs_puppeting=False,
|
|
||||||
help_section=SECTION_MISC,
|
|
||||||
help_args="<_caption_>",
|
|
||||||
help_text="Set a caption for the next image you send",
|
|
||||||
)
|
|
||||||
async def caption(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp caption <caption>`")
|
|
||||||
|
|
||||||
prefix = f"{evt.command_prefix} caption "
|
|
||||||
if evt.content.format == Format.HTML:
|
|
||||||
evt.content.formatted_body = evt.content.formatted_body.replace(prefix, "", 1)
|
|
||||||
evt.content.body = evt.content.body.replace(prefix, "", 1)
|
|
||||||
evt.sender.command_status = {"caption": evt.content, "action": "Caption"}
|
|
||||||
return await evt.reply(
|
|
||||||
"Your next image or file will be sent with that caption. "
|
|
||||||
"Use `$cmdprefix+sp cancel` to cancel the caption."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_MISC,
|
|
||||||
help_args="[_-r|--remote_] <_query_>",
|
|
||||||
help_text="Search your contacts or the Telegram servers for users.",
|
|
||||||
)
|
|
||||||
async def search(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp search [-r|--remote] <query>`")
|
|
||||||
|
|
||||||
force_remote = False
|
|
||||||
if evt.args[0] in {"-r", "--remote"}:
|
|
||||||
force_remote = True
|
|
||||||
evt.args.pop(0)
|
|
||||||
|
|
||||||
query = " ".join(evt.args)
|
|
||||||
if force_remote and len(query) < 5:
|
|
||||||
return await evt.reply("Minimum length of query for remote search is 5 characters.")
|
|
||||||
|
|
||||||
results, remote = await evt.sender.search(query, force_remote)
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
if len(query) < 5 and remote:
|
|
||||||
return await evt.reply(
|
|
||||||
"No local results. Minimum length of remote query is 5 characters."
|
|
||||||
)
|
|
||||||
return await evt.reply("No results 3:")
|
|
||||||
|
|
||||||
reply: list[str] = []
|
|
||||||
if remote:
|
|
||||||
reply += ["**Results from Telegram server:**", ""]
|
|
||||||
else:
|
|
||||||
reply += ["**Results in contacts:**", ""]
|
|
||||||
reply += [
|
|
||||||
(
|
|
||||||
f"* [{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): "
|
|
||||||
f"{puppet.id} ({similarity}% match)"
|
|
||||||
)
|
|
||||||
for puppet, similarity in results
|
|
||||||
]
|
|
||||||
|
|
||||||
# TODO somehow show remote channel results when joining by alias is possible?
|
|
||||||
|
|
||||||
return await evt.reply("\n".join(reply))
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
|
||||||
help_args="<_username_>",
|
|
||||||
help_text=(
|
|
||||||
"Open a private chat with the given Telegram user. You can also use a "
|
|
||||||
"phone number instead of username, but you must have the number in "
|
|
||||||
"your Telegram contacts for that to work."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
async def pm(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp pm <username>`")
|
|
||||||
|
|
||||||
try:
|
|
||||||
id = "".join(evt.args).translate({ord(c): None for c in "+()- "})
|
|
||||||
user = await evt.sender.client.get_entity(id)
|
|
||||||
except ValueError:
|
|
||||||
return await evt.reply("Invalid user identifier or user not found.")
|
|
||||||
|
|
||||||
if not user:
|
|
||||||
return await evt.reply("User not found.")
|
|
||||||
elif not isinstance(user, TLUser):
|
|
||||||
return await evt.reply("That doesn't seem to be a user.")
|
|
||||||
portal = await po.Portal.get_by_entity(user, tg_receiver=evt.sender.tgid)
|
|
||||||
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
|
|
||||||
displayname, _ = pu.Puppet.get_displayname(user, False)
|
|
||||||
return await evt.reply(f"Created private chat room with {displayname}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle_contact(source: AbstractUser, user: TLUser) -> str:
|
|
||||||
puppet: pu.Puppet = await pu.Puppet.get_by_tgid(user.id)
|
|
||||||
await puppet.update_info(source, user)
|
|
||||||
|
|
||||||
params = []
|
|
||||||
if user.username:
|
|
||||||
params.append(f"[@{user.username}](https://t.me/{user.username})")
|
|
||||||
if user.phone:
|
|
||||||
params.append(f"+{user.phone}")
|
|
||||||
params.append(f"ID `{user.id}`")
|
|
||||||
params_str = " / ".join(params)
|
|
||||||
return f"[{puppet.displayname}](https://matrix.to/#/{puppet.mxid}): {params_str}"
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
|
||||||
help_args="<_phone_> <_first name_> <_last name_>",
|
|
||||||
help_text="Add a phone number to your contacts on Telegram",
|
|
||||||
)
|
|
||||||
async def add_contact(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) < 3:
|
|
||||||
return await evt.reply(
|
|
||||||
"**Usage:** `$cmdprefix+sp add-contact <phone> <first name> <last name>`"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
names = shlex.split(" ".join(evt.args[1:]))
|
|
||||||
except ValueError as e:
|
|
||||||
return await evt.reply(
|
|
||||||
f"Failed to parse names (use shell quoting for names with spaces): {e}"
|
|
||||||
)
|
|
||||||
if len(names) != 2:
|
|
||||||
return await evt.reply(
|
|
||||||
"Wrong number of names, must have first and last name "
|
|
||||||
"(use shell quoting for names with spaces)"
|
|
||||||
)
|
|
||||||
res: ImportedContacts = await evt.sender.client(
|
|
||||||
ImportContactsRequest(
|
|
||||||
contacts=[
|
|
||||||
InputPhoneContact(
|
|
||||||
client_id=1, phone=evt.args[0], first_name=names[0], last_name=names[1]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if res.retry_contacts:
|
|
||||||
return await evt.reply("Failed to import contacts")
|
|
||||||
elif not res.users:
|
|
||||||
return await evt.reply("Contact imported, but user not found on Telegram")
|
|
||||||
imported_str = "\n".join(
|
|
||||||
[f"* {await _handle_contact(evt.sender, user)}" for user in res.users]
|
|
||||||
)
|
|
||||||
return await evt.reply(f"Imported contacts:\n\n{imported_str}")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
|
||||||
help_args="<_phones..._>",
|
|
||||||
help_text="Remove phone numbers from your contacts on Telegram.",
|
|
||||||
aliases=["remove-contact", "delete-contacts", "remove-contacts"],
|
|
||||||
)
|
|
||||||
async def delete_contact(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp delete-contact <phones...>`")
|
|
||||||
ok = await evt.sender.client(DeleteByPhonesRequest(phones=evt.args))
|
|
||||||
if ok:
|
|
||||||
return await evt.reply("Contacts deleted")
|
|
||||||
else:
|
|
||||||
return await evt.reply("Contacts not deleted?")
|
|
||||||
|
|
||||||
|
|
||||||
async def _join(
|
|
||||||
evt: CommandEvent, identifier: str, link_type: str
|
|
||||||
) -> tuple[TypeUpdates | None, EventID | None]:
|
|
||||||
if link_type == "joinchat":
|
|
||||||
try:
|
|
||||||
await evt.sender.client(CheckChatInviteRequest(identifier))
|
|
||||||
except InviteHashInvalidError:
|
|
||||||
return None, await evt.reply("Invalid invite link.")
|
|
||||||
except InviteHashExpiredError:
|
|
||||||
return None, await evt.reply("Invite link expired.")
|
|
||||||
try:
|
|
||||||
return (await evt.sender.client(ImportChatInviteRequest(identifier))), None
|
|
||||||
except UserAlreadyParticipantError:
|
|
||||||
return None, await evt.reply("You are already in that chat.")
|
|
||||||
except InviteRequestSentError:
|
|
||||||
return None, await evt.reply("Invite request sent successfully.")
|
|
||||||
else:
|
|
||||||
channel = await evt.sender.client.get_entity(identifier)
|
|
||||||
if not channel:
|
|
||||||
return None, await evt.reply("Channel/supergroup not found.")
|
|
||||||
return await evt.sender.client(JoinChannelRequest(channel)), None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_CREATING_PORTALS,
|
|
||||||
help_args="<_link_>",
|
|
||||||
help_text="Join a chat with an invite link.",
|
|
||||||
)
|
|
||||||
async def join(evt: CommandEvent) -> EventID | None:
|
|
||||||
if len(evt.args) == 0:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
|
|
||||||
|
|
||||||
url = evt.args[0]
|
|
||||||
if evt.config["bridge.invite_link_resolve"]:
|
|
||||||
try:
|
|
||||||
async with ClientSession() as sess, sess.get(url) as resp:
|
|
||||||
url = str(resp.url)
|
|
||||||
except InvalidURL:
|
|
||||||
return await evt.reply("That doesn't look like a Telegram invite link.")
|
|
||||||
|
|
||||||
regex = re.compile(
|
|
||||||
r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:/(?P<type>joinchat|s))?/(?P<id>[^/]+)/?",
|
|
||||||
flags=re.IGNORECASE,
|
|
||||||
)
|
|
||||||
arg = regex.match(url)
|
|
||||||
if not arg:
|
|
||||||
return await evt.reply("That doesn't look like a Telegram invite link.")
|
|
||||||
|
|
||||||
data = arg.groupdict()
|
|
||||||
identifier = data["id"]
|
|
||||||
link_type = data["type"]
|
|
||||||
if link_type:
|
|
||||||
link_type = link_type.lower()
|
|
||||||
elif identifier.startswith("+"):
|
|
||||||
link_type = "joinchat"
|
|
||||||
identifier = identifier[1:]
|
|
||||||
updates, _ = await _join(evt, identifier, link_type)
|
|
||||||
if not updates:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for chat in updates.chats:
|
|
||||||
portal = await po.Portal.get_by_entity(chat)
|
|
||||||
if portal.mxid:
|
|
||||||
await portal.invite_to_matrix([evt.sender.mxid])
|
|
||||||
return await evt.reply(f"Invited you to portal of {portal.title}")
|
|
||||||
else:
|
|
||||||
await evt.reply(f"Creating room for {chat.title}... This might take a while.")
|
|
||||||
try:
|
|
||||||
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
|
|
||||||
except ChatIdInvalidError as e:
|
|
||||||
evt.log.trace(
|
|
||||||
"ChatIdInvalidError while creating portal from !tg join command: %s",
|
|
||||||
updates.stringify(),
|
|
||||||
)
|
|
||||||
raise e
|
|
||||||
if portal.mxid:
|
|
||||||
return await evt.reply(f"Created room for {portal.title}")
|
|
||||||
else:
|
|
||||||
return await evt.reply(f"Couldn't create room for {portal.title}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_MISC,
|
|
||||||
help_args="[`chats`|`contacts`|`me`]",
|
|
||||||
help_text="Synchronize your chat portals, contacts and/or own info.",
|
|
||||||
)
|
|
||||||
async def sync(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) > 0:
|
|
||||||
sync_only = evt.args[0]
|
|
||||||
if sync_only not in ("chats", "contacts", "me"):
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp sync [chats|contacts|me]`")
|
|
||||||
else:
|
|
||||||
sync_only = None
|
|
||||||
|
|
||||||
if not sync_only or sync_only == "chats":
|
|
||||||
await evt.reply("Synchronizing chats...")
|
|
||||||
await evt.sender.sync_dialogs()
|
|
||||||
if not sync_only or sync_only == "contacts":
|
|
||||||
await evt.reply("Synchronizing contacts...")
|
|
||||||
await evt.sender.sync_contacts()
|
|
||||||
if not sync_only or sync_only == "me":
|
|
||||||
await evt.sender.update_info()
|
|
||||||
return await evt.reply("Synchronization complete.")
|
|
||||||
|
|
||||||
|
|
||||||
PEER_TYPE_CHAT = b"g"
|
|
||||||
|
|
||||||
|
|
||||||
class MessageIDError(ValueError):
|
|
||||||
def __init__(self, message: str) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.message = message
|
|
||||||
|
|
||||||
|
|
||||||
async def _parse_encoded_msgid(
|
|
||||||
user: AbstractUser, enc_id: str, type_name: str
|
|
||||||
) -> tuple[TypeInputPeer, Message]:
|
|
||||||
try:
|
|
||||||
enc_id += (4 - len(enc_id) % 4) * "="
|
|
||||||
enc_id = base64.b64decode(enc_id)
|
|
||||||
peer_type, enc_id = bytes([enc_id[0]]), enc_id[1:]
|
|
||||||
tgid = TelegramID(int(codecs.encode(enc_id[0:5], "hex_codec"), 16))
|
|
||||||
msg_id = TelegramID(int(codecs.encode(enc_id[5:10], "hex_codec"), 16))
|
|
||||||
space = None
|
|
||||||
if peer_type == PEER_TYPE_CHAT:
|
|
||||||
space = TelegramID(int(codecs.encode(enc_id[10:15], "hex_codec"), 16))
|
|
||||||
except ValueError as e:
|
|
||||||
raise MessageIDError(f"Invalid {type_name} ID (format)") from e
|
|
||||||
|
|
||||||
if peer_type == PEER_TYPE_CHAT:
|
|
||||||
orig_msg = await DBMessage.get_one_by_tgid(msg_id, space)
|
|
||||||
if not orig_msg:
|
|
||||||
raise MessageIDError(f"Invalid {type_name} ID (original message not found in db)")
|
|
||||||
new_msg = await DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
|
|
||||||
if not new_msg:
|
|
||||||
raise MessageIDError(f"Invalid {type_name} ID (your copy of message not found in db)")
|
|
||||||
msg_id = new_msg.tgid
|
|
||||||
try:
|
|
||||||
peer = await user.client.get_input_entity(tgid)
|
|
||||||
except ValueError as e:
|
|
||||||
raise MessageIDError(f"Invalid {type_name} ID (chat not found)") from e
|
|
||||||
|
|
||||||
msg = await user.client.get_messages(entity=peer, ids=msg_id)
|
|
||||||
if not msg:
|
|
||||||
raise MessageIDError(f"Invalid {type_name} ID (message not found)")
|
|
||||||
return peer, cast(Message, msg)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_MISC, help_args="<_play ID_>", help_text="Play a Telegram game."
|
|
||||||
)
|
|
||||||
async def play(evt: CommandEvent) -> EventID:
|
|
||||||
if len(evt.args) < 1:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp play <play ID>`")
|
|
||||||
elif not await evt.sender.is_logged_in():
|
|
||||||
return await evt.reply("You must be logged in with a real account to play games.")
|
|
||||||
elif evt.sender.is_bot:
|
|
||||||
return await evt.reply("Bots can't play games :(")
|
|
||||||
|
|
||||||
try:
|
|
||||||
peer, msg = await _parse_encoded_msgid(evt.sender, evt.args[0], type_name="play")
|
|
||||||
except MessageIDError as e:
|
|
||||||
return await evt.reply(e.message)
|
|
||||||
|
|
||||||
if not isinstance(msg.media, MessageMediaGame):
|
|
||||||
return await evt.reply("Invalid play ID (message doesn't look like a game)")
|
|
||||||
|
|
||||||
game = await evt.sender.client(
|
|
||||||
GetBotCallbackAnswerRequest(peer=peer, msg_id=msg.id, game=True)
|
|
||||||
)
|
|
||||||
if not isinstance(game, BotCallbackAnswer):
|
|
||||||
return await evt.reply("Game request response invalid")
|
|
||||||
|
|
||||||
return await evt.reply(
|
|
||||||
f"Click [here]({game.url}) to play {msg.media.game.title}:\n\n"
|
|
||||||
f"{msg.media.game.description}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_MISC,
|
|
||||||
help_args="<_poll ID_> <_choice number_>",
|
|
||||||
help_text="Vote in a Telegram poll.",
|
|
||||||
)
|
|
||||||
async def vote(evt: CommandEvent) -> EventID | None:
|
|
||||||
if len(evt.args) < 1:
|
|
||||||
return await evt.reply("**Usage:** `$cmdprefix+sp vote <poll ID> <choice number>`")
|
|
||||||
elif not await evt.sender.is_logged_in():
|
|
||||||
return await evt.reply("You must be logged in with a real account to vote in polls.")
|
|
||||||
elif evt.sender.is_bot:
|
|
||||||
return await evt.reply("Bots can't vote in polls :(")
|
|
||||||
|
|
||||||
try:
|
|
||||||
peer, msg = await _parse_encoded_msgid(evt.sender, evt.args[0], type_name="poll")
|
|
||||||
except MessageIDError as e:
|
|
||||||
return await evt.reply(e.message)
|
|
||||||
|
|
||||||
if not isinstance(msg.media, MessageMediaPoll):
|
|
||||||
return await evt.reply("Invalid poll ID (message doesn't look like a poll)")
|
|
||||||
|
|
||||||
options = []
|
|
||||||
for option in evt.args[1:]:
|
|
||||||
try:
|
|
||||||
if len(option) > 10:
|
|
||||||
raise ValueError("option index too long")
|
|
||||||
option_index = int(option) - 1
|
|
||||||
except ValueError:
|
|
||||||
option_index = None
|
|
||||||
if option_index is None:
|
|
||||||
return await evt.reply(
|
|
||||||
f'Invalid option number "{option}"', render_markdown=False, allow_html=False
|
|
||||||
)
|
|
||||||
elif option_index < 0:
|
|
||||||
return await evt.reply(
|
|
||||||
f"Invalid option number {option}. Option numbers must be positive."
|
|
||||||
)
|
|
||||||
elif option_index >= len(msg.media.poll.answers):
|
|
||||||
return await evt.reply(
|
|
||||||
f"Invalid option number {option}. "
|
|
||||||
f"The poll only has {len(msg.media.poll.answers)} options."
|
|
||||||
)
|
|
||||||
options.append(msg.media.poll.answers[option_index].option)
|
|
||||||
options = [msg.media.poll.answers[int(option) - 1].option for option in evt.args[1:]]
|
|
||||||
try:
|
|
||||||
await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
|
|
||||||
except OptionsTooMuchError:
|
|
||||||
return await evt.reply("You passed too many options.")
|
|
||||||
# TODO use response
|
|
||||||
return await evt.mark_read()
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_MISC,
|
|
||||||
help_args="<_emoji_>",
|
|
||||||
help_text="Roll a dice (\U0001F3B2), kick a football (\u26BD\uFE0F) or throw a "
|
|
||||||
"dart (\U0001F3AF) or basketball (\U0001F3C0) on the Telegram servers.",
|
|
||||||
)
|
|
||||||
async def random(evt: CommandEvent) -> EventID:
|
|
||||||
if not evt.is_portal:
|
|
||||||
return await evt.reply("You can only randomize values in portal rooms")
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
arg = evt.args[0] if len(evt.args) > 0 else "dice"
|
|
||||||
emoticon = {
|
|
||||||
"dart": "\U0001F3AF",
|
|
||||||
"dice": "\U0001F3B2",
|
|
||||||
"ball": "\U0001F3C0",
|
|
||||||
"basketball": "\U0001F3C0",
|
|
||||||
"football": "\u26BD",
|
|
||||||
"soccer": "\u26BD",
|
|
||||||
}.get(arg, arg)
|
|
||||||
try:
|
|
||||||
await evt.sender.client.send_media(
|
|
||||||
await portal.get_input_entity(evt.sender), InputMediaDice(emoticon)
|
|
||||||
)
|
|
||||||
except EmoticonInvalidError:
|
|
||||||
return await evt.reply("Invalid emoji for randomization")
|
|
||||||
|
|
||||||
|
|
||||||
@command_handler(
|
|
||||||
help_section=SECTION_PORTAL_MANAGEMENT,
|
|
||||||
help_args="[_limit_]",
|
|
||||||
help_text="Backfill messages from Telegram history.",
|
|
||||||
)
|
|
||||||
async def backfill(evt: CommandEvent) -> None:
|
|
||||||
if not evt.is_portal:
|
|
||||||
await evt.reply("You can only use backfill in portal rooms")
|
|
||||||
return
|
|
||||||
elif not evt.config["bridge.backfill.enable"]:
|
|
||||||
await evt.reply("Backfilling is disabled in the bridge config")
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
limit = int(evt.args[0])
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
limit = -1
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not evt.config["bridge.backfill.normal_groups"] and portal.peer_type == "chat":
|
|
||||||
await evt.reply("Backfilling normal groups is disabled in the bridge config")
|
|
||||||
return
|
|
||||||
output = await portal.forward_backfill(evt.sender, initial=False, override_limit=limit)
|
|
||||||
await evt.reply(output)
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Any, List, NamedTuple
|
|
||||||
import os
|
|
||||||
|
|
||||||
from ruamel.yaml.comments import CommentedMap
|
|
||||||
|
|
||||||
from mautrix.bridge.config import BaseBridgeConfig
|
|
||||||
from mautrix.client import Client
|
|
||||||
from mautrix.types import UserID
|
|
||||||
from mautrix.util.config import ConfigUpdateHelper, ForbiddenDefault, ForbiddenKey
|
|
||||||
|
|
||||||
Permissions = NamedTuple(
|
|
||||||
"Permissions",
|
|
||||||
relaybot=bool,
|
|
||||||
user=bool,
|
|
||||||
puppeting=bool,
|
|
||||||
matrix_puppeting=bool,
|
|
||||||
admin=bool,
|
|
||||||
level=str,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Config(BaseBridgeConfig):
|
|
||||||
@property
|
|
||||||
def forbidden_defaults(self) -> List[ForbiddenDefault]:
|
|
||||||
return [
|
|
||||||
*super().forbidden_defaults,
|
|
||||||
ForbiddenDefault(
|
|
||||||
"appservice.database",
|
|
||||||
"postgres://username:password@hostname/dbname",
|
|
||||||
),
|
|
||||||
ForbiddenDefault(
|
|
||||||
"appservice.public.external",
|
|
||||||
"https://example.com/public",
|
|
||||||
condition="appservice.public.enabled",
|
|
||||||
),
|
|
||||||
ForbiddenDefault("bridge.permissions", ForbiddenKey("example.com")),
|
|
||||||
ForbiddenDefault("telegram.api_id", 12345),
|
|
||||||
ForbiddenDefault("telegram.api_hash", "tjyd5yge35lbodk1xwzw2jstp90k55qz"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def do_update(self, helper: ConfigUpdateHelper) -> None:
|
|
||||||
super().do_update(helper)
|
|
||||||
copy, copy_dict, base = helper
|
|
||||||
|
|
||||||
if "appservice.protocol" in self and "appservice.address" not in self:
|
|
||||||
protocol, hostname, port = (
|
|
||||||
self["appservice.protocol"],
|
|
||||||
self["appservice.hostname"],
|
|
||||||
self["appservice.port"],
|
|
||||||
)
|
|
||||||
base["appservice.address"] = f"{protocol}://{hostname}:{port}"
|
|
||||||
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
|
|
||||||
|
|
||||||
copy("appservice.public.enabled")
|
|
||||||
copy("appservice.public.prefix")
|
|
||||||
copy("appservice.public.external")
|
|
||||||
|
|
||||||
copy("appservice.provisioning.enabled")
|
|
||||||
copy("appservice.provisioning.prefix")
|
|
||||||
if base["appservice.provisioning.prefix"].endswith("/v1"):
|
|
||||||
base["appservice.provisioning.prefix"] = base["appservice.provisioning.prefix"][
|
|
||||||
: -len("/v1")
|
|
||||||
]
|
|
||||||
copy("appservice.provisioning.shared_secret")
|
|
||||||
if base["appservice.provisioning.shared_secret"] == "generate":
|
|
||||||
base["appservice.provisioning.shared_secret"] = self._new_token()
|
|
||||||
|
|
||||||
if "pool_size" in base["appservice.database_opts"]:
|
|
||||||
pool_size = base["appservice.database_opts"].pop("pool_size")
|
|
||||||
base["appservice.database_opts.min_size"] = pool_size
|
|
||||||
base["appservice.database_opts.max_size"] = pool_size
|
|
||||||
if "pool_pre_ping" in base["appservice.database_opts"]:
|
|
||||||
del base["appservice.database_opts.pool_pre_ping"]
|
|
||||||
|
|
||||||
copy("metrics.enabled")
|
|
||||||
copy("metrics.listen_port")
|
|
||||||
|
|
||||||
copy("bridge.username_template")
|
|
||||||
copy("bridge.alias_template")
|
|
||||||
copy("bridge.displayname_template")
|
|
||||||
|
|
||||||
copy("bridge.displayname_preference")
|
|
||||||
copy("bridge.displayname_max_length")
|
|
||||||
copy("bridge.allow_avatar_remove")
|
|
||||||
copy("bridge.allow_contact_info")
|
|
||||||
|
|
||||||
copy("bridge.max_initial_member_sync")
|
|
||||||
copy("bridge.max_member_count")
|
|
||||||
copy("bridge.sync_channel_members")
|
|
||||||
copy("bridge.skip_deleted_members")
|
|
||||||
copy("bridge.startup_sync")
|
|
||||||
if "bridge.sync_dialog_limit" in self:
|
|
||||||
base["bridge.sync_create_limit"] = self["bridge.sync_dialog_limit"]
|
|
||||||
base["bridge.sync_update_limit"] = self["bridge.sync_dialog_limit"]
|
|
||||||
else:
|
|
||||||
copy("bridge.sync_update_limit")
|
|
||||||
copy("bridge.sync_create_limit")
|
|
||||||
copy("bridge.sync_deferred_create_all")
|
|
||||||
copy("bridge.sync_direct_chats")
|
|
||||||
copy("bridge.max_telegram_delete")
|
|
||||||
copy("bridge.sync_matrix_state")
|
|
||||||
copy("bridge.allow_matrix_login")
|
|
||||||
copy("bridge.public_portals")
|
|
||||||
copy("bridge.sync_with_custom_puppets")
|
|
||||||
copy("bridge.sync_direct_chat_list")
|
|
||||||
copy("bridge.double_puppet_server_map")
|
|
||||||
copy("bridge.double_puppet_allow_discovery")
|
|
||||||
copy("bridge.create_group_on_invite")
|
|
||||||
if "bridge.login_shared_secret" in self:
|
|
||||||
base["bridge.login_shared_secret_map"] = {
|
|
||||||
base["homeserver.domain"]: self["bridge.login_shared_secret"]
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
copy("bridge.login_shared_secret_map")
|
|
||||||
copy("bridge.telegram_link_preview")
|
|
||||||
copy("bridge.invite_link_resolve")
|
|
||||||
copy("bridge.caption_in_message")
|
|
||||||
copy("bridge.image_as_file_size")
|
|
||||||
copy("bridge.image_as_file_pixels")
|
|
||||||
copy("bridge.document_as_link_size.bot")
|
|
||||||
copy("bridge.document_as_link_size.channel")
|
|
||||||
copy("bridge.parallel_file_transfer")
|
|
||||||
copy("bridge.federate_rooms")
|
|
||||||
copy("bridge.always_custom_emoji_reaction")
|
|
||||||
copy("bridge.animated_sticker.target")
|
|
||||||
copy("bridge.animated_sticker.convert_from_webm")
|
|
||||||
copy("bridge.animated_sticker.args.width")
|
|
||||||
copy("bridge.animated_sticker.args.height")
|
|
||||||
copy("bridge.animated_sticker.args.fps")
|
|
||||||
copy("bridge.animated_emoji.target")
|
|
||||||
copy("bridge.animated_emoji.args.width")
|
|
||||||
copy("bridge.animated_emoji.args.height")
|
|
||||||
copy("bridge.animated_emoji.args.fps")
|
|
||||||
if isinstance(self.get("bridge.private_chat_portal_meta", "default"), bool):
|
|
||||||
base["bridge.private_chat_portal_meta"] = (
|
|
||||||
"always" if self["bridge.private_chat_portal_meta"] else "default"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
copy("bridge.private_chat_portal_meta")
|
|
||||||
if base["bridge.private_chat_portal_meta"] not in ("default", "always", "never"):
|
|
||||||
base["bridge.private_chat_portal_meta"] = "default"
|
|
||||||
copy("bridge.disable_reply_fallbacks")
|
|
||||||
copy("bridge.cross_room_replies")
|
|
||||||
copy("bridge.delivery_receipts")
|
|
||||||
copy("bridge.delivery_error_reports")
|
|
||||||
copy("bridge.incoming_bridge_error_reports")
|
|
||||||
copy("bridge.message_status_events")
|
|
||||||
copy("bridge.resend_bridge_info")
|
|
||||||
copy("bridge.mute_bridging")
|
|
||||||
copy("bridge.pinned_tag")
|
|
||||||
copy("bridge.archive_tag")
|
|
||||||
copy("bridge.tag_only_on_create")
|
|
||||||
copy("bridge.bridge_matrix_leave")
|
|
||||||
copy("bridge.kick_on_logout")
|
|
||||||
copy("bridge.always_read_joined_telegram_notice")
|
|
||||||
copy("bridge.backfill.enable")
|
|
||||||
copy("bridge.backfill.normal_groups")
|
|
||||||
copy("bridge.backfill.unread_hours_threshold")
|
|
||||||
if "bridge.backfill.forward" in self:
|
|
||||||
initial_limit = self.get("bridge.backfill.forward.initial_limit", 10)
|
|
||||||
sync_limit = self.get("bridge.backfill.forward.sync_limit", 100)
|
|
||||||
base["bridge.backfill.forward_limits.initial.user"] = initial_limit
|
|
||||||
base["bridge.backfill.forward_limits.initial.normal_group"] = initial_limit
|
|
||||||
base["bridge.backfill.forward_limits.initial.supergroup"] = initial_limit
|
|
||||||
base["bridge.backfill.forward_limits.initial.channel"] = initial_limit
|
|
||||||
base["bridge.backfill.forward_limits.sync.user"] = sync_limit
|
|
||||||
base["bridge.backfill.forward_limits.sync.normal_group"] = sync_limit
|
|
||||||
base["bridge.backfill.forward_limits.sync.supergroup"] = sync_limit
|
|
||||||
base["bridge.backfill.forward_limits.sync.channel"] = sync_limit
|
|
||||||
else:
|
|
||||||
copy("bridge.backfill.forward_limits.initial.user")
|
|
||||||
copy("bridge.backfill.forward_limits.initial.normal_group")
|
|
||||||
copy("bridge.backfill.forward_limits.initial.supergroup")
|
|
||||||
copy("bridge.backfill.forward_limits.initial.channel")
|
|
||||||
copy("bridge.backfill.forward_limits.sync.user")
|
|
||||||
copy("bridge.backfill.forward_limits.sync.normal_group")
|
|
||||||
copy("bridge.backfill.forward_limits.sync.supergroup")
|
|
||||||
copy("bridge.backfill.forward_limits.sync.channel")
|
|
||||||
copy("bridge.backfill.forward_timeout")
|
|
||||||
copy("bridge.backfill.incremental.messages_per_batch")
|
|
||||||
copy("bridge.backfill.incremental.post_batch_delay")
|
|
||||||
copy("bridge.backfill.incremental.max_batches.user")
|
|
||||||
copy("bridge.backfill.incremental.max_batches.normal_group")
|
|
||||||
copy("bridge.backfill.incremental.max_batches.supergroup")
|
|
||||||
copy("bridge.backfill.incremental.max_batches.channel")
|
|
||||||
|
|
||||||
copy("bridge.initial_power_level_overrides.group")
|
|
||||||
copy("bridge.initial_power_level_overrides.user")
|
|
||||||
|
|
||||||
copy("bridge.bot_messages_as_notices")
|
|
||||||
if isinstance(self["bridge.bridge_notices"], bool):
|
|
||||||
base["bridge.bridge_notices"]["default"] = self["bridge.bridge_notices"]
|
|
||||||
else:
|
|
||||||
copy("bridge.bridge_notices.default")
|
|
||||||
copy("bridge.bridge_notices.exceptions")
|
|
||||||
|
|
||||||
if "bridge.message_formats.m_text" in self:
|
|
||||||
del self["bridge.message_formats"]
|
|
||||||
copy_dict("bridge.message_formats", override_existing_map=False)
|
|
||||||
copy("bridge.emote_format")
|
|
||||||
copy("bridge.relay_user_distinguishers")
|
|
||||||
|
|
||||||
copy("bridge.state_event_formats.join")
|
|
||||||
copy("bridge.state_event_formats.leave")
|
|
||||||
copy("bridge.state_event_formats.name_change")
|
|
||||||
|
|
||||||
copy("bridge.filter.mode")
|
|
||||||
copy("bridge.filter.list")
|
|
||||||
copy("bridge.filter.users")
|
|
||||||
|
|
||||||
copy("bridge.command_prefix")
|
|
||||||
|
|
||||||
migrate_permissions = (
|
|
||||||
"bridge.permissions" not in self
|
|
||||||
or "bridge.whitelist" in self
|
|
||||||
or "bridge.admins" in self
|
|
||||||
)
|
|
||||||
if migrate_permissions:
|
|
||||||
permissions = self["bridge.permissions"] or CommentedMap()
|
|
||||||
for entry in self["bridge.whitelist"] or []:
|
|
||||||
permissions[entry] = "full"
|
|
||||||
for entry in self["bridge.admins"] or []:
|
|
||||||
permissions[entry] = "admin"
|
|
||||||
base["bridge.permissions"] = permissions
|
|
||||||
else:
|
|
||||||
copy_dict("bridge.permissions", override_existing_map=True)
|
|
||||||
|
|
||||||
if "bridge.relaybot" not in self:
|
|
||||||
copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals")
|
|
||||||
else:
|
|
||||||
copy("bridge.relaybot.private_chat.invite")
|
|
||||||
copy("bridge.relaybot.private_chat.state_changes")
|
|
||||||
copy("bridge.relaybot.private_chat.message")
|
|
||||||
copy("bridge.relaybot.group_chat_invite")
|
|
||||||
copy("bridge.relaybot.ignore_unbridged_group_chat")
|
|
||||||
copy("bridge.relaybot.authless_portals")
|
|
||||||
copy("bridge.relaybot.whitelist_group_admins")
|
|
||||||
copy("bridge.relaybot.whitelist")
|
|
||||||
copy("bridge.relaybot.ignore_own_incoming_events")
|
|
||||||
|
|
||||||
copy("telegram.api_id")
|
|
||||||
copy("telegram.api_hash")
|
|
||||||
copy("telegram.bot_token")
|
|
||||||
|
|
||||||
copy("telegram.catch_up")
|
|
||||||
copy("telegram.sequential_updates")
|
|
||||||
copy("telegram.exit_on_update_error")
|
|
||||||
|
|
||||||
copy("telegram.connection.timeout")
|
|
||||||
copy("telegram.connection.retries")
|
|
||||||
copy("telegram.connection.retry_delay")
|
|
||||||
copy("telegram.connection.flood_sleep_threshold")
|
|
||||||
copy("telegram.connection.request_retries")
|
|
||||||
copy("telegram.connection.use_ipv6")
|
|
||||||
|
|
||||||
copy("telegram.device_info.device_model")
|
|
||||||
copy("telegram.device_info.system_version")
|
|
||||||
copy("telegram.device_info.app_version")
|
|
||||||
copy("telegram.device_info.lang_code")
|
|
||||||
copy("telegram.device_info.system_lang_code")
|
|
||||||
|
|
||||||
copy("telegram.server.enabled")
|
|
||||||
copy("telegram.server.dc")
|
|
||||||
copy("telegram.server.ip")
|
|
||||||
copy("telegram.server.port")
|
|
||||||
|
|
||||||
copy("telegram.proxy.type")
|
|
||||||
copy("telegram.proxy.address")
|
|
||||||
copy("telegram.proxy.port")
|
|
||||||
copy("telegram.proxy.rdns")
|
|
||||||
copy("telegram.proxy.username")
|
|
||||||
copy("telegram.proxy.password")
|
|
||||||
|
|
||||||
def _get_permissions(self, key: str) -> Permissions:
|
|
||||||
level = self["bridge.permissions"].get(key, "")
|
|
||||||
admin = level == "admin"
|
|
||||||
matrix_puppeting = level == "full" or admin
|
|
||||||
puppeting = level == "puppeting" or matrix_puppeting
|
|
||||||
user = level == "user" or puppeting
|
|
||||||
relaybot = level == "relaybot" or user
|
|
||||||
return Permissions(relaybot, user, puppeting, matrix_puppeting, admin, level)
|
|
||||||
|
|
||||||
def get_permissions(self, mxid: UserID) -> Permissions:
|
|
||||||
permissions = self["bridge.permissions"]
|
|
||||||
if mxid in permissions:
|
|
||||||
return self._get_permissions(mxid)
|
|
||||||
|
|
||||||
_, homeserver = Client.parse_user_id(mxid)
|
|
||||||
if homeserver in permissions:
|
|
||||||
return self._get_permissions(homeserver)
|
|
||||||
|
|
||||||
return self._get_permissions("*")
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from .backfill_queue import Backfill, BackfillType
|
|
||||||
from .bot_chat import BotChat
|
|
||||||
from .disappearing_message import DisappearingMessage
|
|
||||||
from .message import Message
|
|
||||||
from .portal import Portal
|
|
||||||
from .puppet import Puppet
|
|
||||||
from .reaction import Reaction
|
|
||||||
from .telegram_file import TelegramFile
|
|
||||||
from .telethon_session import PgSession
|
|
||||||
from .upgrade import upgrade_table
|
|
||||||
from .user import User
|
|
||||||
|
|
||||||
|
|
||||||
def init(db: Database) -> None:
|
|
||||||
for table in (
|
|
||||||
Portal,
|
|
||||||
Message,
|
|
||||||
Reaction,
|
|
||||||
User,
|
|
||||||
Puppet,
|
|
||||||
TelegramFile,
|
|
||||||
BotChat,
|
|
||||||
PgSession,
|
|
||||||
DisappearingMessage,
|
|
||||||
Backfill,
|
|
||||||
):
|
|
||||||
table.db = db
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"upgrade_table",
|
|
||||||
"init",
|
|
||||||
"Portal",
|
|
||||||
"Message",
|
|
||||||
"Reaction",
|
|
||||||
"User",
|
|
||||||
"Puppet",
|
|
||||||
"TelegramFile",
|
|
||||||
"BotChat",
|
|
||||||
"PgSession",
|
|
||||||
"DisappearingMessage",
|
|
||||||
"Backfill",
|
|
||||||
]
|
|
||||||
@@ -1,235 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan, Sumner Evans
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from enum import Enum
|
|
||||||
import json
|
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.types import UserID
|
|
||||||
from mautrix.util.async_db import Connection, Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
class BackfillType(Enum):
|
|
||||||
HISTORICAL = "historical"
|
|
||||||
SYNC_DIALOG = "sync_dialog"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Backfill:
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
queue_id: int | None
|
|
||||||
user_mxid: UserID
|
|
||||||
priority: int
|
|
||||||
type: BackfillType
|
|
||||||
portal_tgid: TelegramID
|
|
||||||
portal_tg_receiver: TelegramID
|
|
||||||
anchor_msg_id: TelegramID | None
|
|
||||||
extra_data: dict[str, Any]
|
|
||||||
messages_per_batch: int
|
|
||||||
post_batch_delay: int
|
|
||||||
max_batches: int
|
|
||||||
dispatch_time: datetime | None
|
|
||||||
completed_at: datetime | None
|
|
||||||
cooldown_timeout: datetime | None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def new(
|
|
||||||
user_mxid: UserID,
|
|
||||||
priority: int,
|
|
||||||
type: BackfillType,
|
|
||||||
portal_tgid: TelegramID,
|
|
||||||
portal_tg_receiver: TelegramID,
|
|
||||||
messages_per_batch: int,
|
|
||||||
anchor_msg_id: TelegramID | None = None,
|
|
||||||
extra_data: dict[str, Any] | None = None,
|
|
||||||
post_batch_delay: int = 0,
|
|
||||||
max_batches: int = -1,
|
|
||||||
) -> "Backfill":
|
|
||||||
return Backfill(
|
|
||||||
queue_id=None,
|
|
||||||
user_mxid=user_mxid,
|
|
||||||
priority=priority,
|
|
||||||
type=type,
|
|
||||||
portal_tgid=portal_tgid,
|
|
||||||
portal_tg_receiver=portal_tg_receiver,
|
|
||||||
anchor_msg_id=anchor_msg_id,
|
|
||||||
extra_data=extra_data or {},
|
|
||||||
messages_per_batch=messages_per_batch,
|
|
||||||
post_batch_delay=post_batch_delay,
|
|
||||||
max_batches=max_batches,
|
|
||||||
dispatch_time=None,
|
|
||||||
completed_at=None,
|
|
||||||
cooldown_timeout=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: Record | None) -> Backfill | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
data = {**row}
|
|
||||||
type = BackfillType(data.pop("type"))
|
|
||||||
extra_data = json.loads(data.pop("extra_data", None) or "{}")
|
|
||||||
return cls(**data, type=type, extra_data=extra_data)
|
|
||||||
|
|
||||||
columns = [
|
|
||||||
"user_mxid",
|
|
||||||
"priority",
|
|
||||||
"type",
|
|
||||||
"portal_tgid",
|
|
||||||
"portal_tg_receiver",
|
|
||||||
"anchor_msg_id",
|
|
||||||
"extra_data",
|
|
||||||
"messages_per_batch",
|
|
||||||
"post_batch_delay",
|
|
||||||
"max_batches",
|
|
||||||
"dispatch_time",
|
|
||||||
"completed_at",
|
|
||||||
"cooldown_timeout",
|
|
||||||
]
|
|
||||||
columns_str = ",".join(columns)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_next(cls, user_mxid: UserID) -> Backfill | None:
|
|
||||||
q = f"""
|
|
||||||
SELECT queue_id, {cls.columns_str}
|
|
||||||
FROM backfill_queue
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
AND (
|
|
||||||
dispatch_time IS NULL
|
|
||||||
OR (
|
|
||||||
dispatch_time < $2
|
|
||||||
AND completed_at IS NULL
|
|
||||||
)
|
|
||||||
)
|
|
||||||
AND (
|
|
||||||
cooldown_timeout IS NULL
|
|
||||||
OR cooldown_timeout < current_timestamp
|
|
||||||
)
|
|
||||||
ORDER BY priority, queue_id
|
|
||||||
LIMIT 1
|
|
||||||
"""
|
|
||||||
return cls._from_row(
|
|
||||||
await cls.db.fetchrow(q, user_mxid, datetime.now() - timedelta(minutes=15))
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def delete_existing(
|
|
||||||
cls,
|
|
||||||
user_mxid: UserID,
|
|
||||||
portal_tgid: int,
|
|
||||||
portal_tg_receiver: int,
|
|
||||||
type: BackfillType,
|
|
||||||
) -> Backfill | None:
|
|
||||||
q = f"""
|
|
||||||
WITH deleted_entries AS (
|
|
||||||
DELETE FROM backfill_queue
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
AND portal_tgid=$2
|
|
||||||
AND portal_tg_receiver=$3
|
|
||||||
AND type=$4
|
|
||||||
AND dispatch_time IS NULL
|
|
||||||
AND completed_at IS NULL
|
|
||||||
RETURNING 1
|
|
||||||
)
|
|
||||||
WITH dispatched_entries AS (
|
|
||||||
SELECT 1 FROM backfill_queue
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
AND portal_tgid=$2
|
|
||||||
AND portal_tg_receiver=$3
|
|
||||||
AND type=$4
|
|
||||||
AND dispatch_time IS NOT NULL
|
|
||||||
AND completed_at IS NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
return cls._from_row(
|
|
||||||
await cls.db.fetchrow(q, user_mxid, portal_tgid, portal_tg_receiver, type.value)
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def delete_all(cls, user_mxid: UserID, conn: Connection | None = None) -> None:
|
|
||||||
await (conn or cls.db).execute("DELETE FROM backfill_queue WHERE user_mxid=$1", user_mxid)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def delete_for_portal(cls, tgid: int, tg_receiver: int) -> None:
|
|
||||||
q = "DELETE FROM backfill_queue WHERE portal_tgid=$1 AND portal_tg_receiver=$2"
|
|
||||||
await cls.db.execute(q, tgid, tg_receiver)
|
|
||||||
|
|
||||||
async def insert(self) -> list[Backfill]:
|
|
||||||
delete_q = f"""
|
|
||||||
DELETE FROM backfill_queue
|
|
||||||
WHERE user_mxid=$1
|
|
||||||
AND portal_tgid=$2
|
|
||||||
AND portal_tg_receiver=$3
|
|
||||||
AND type=$4
|
|
||||||
AND dispatch_time IS NULL
|
|
||||||
AND completed_at IS NULL
|
|
||||||
RETURNING queue_id, {self.columns_str}
|
|
||||||
"""
|
|
||||||
q = f"""
|
|
||||||
INSERT INTO backfill_queue ({self.columns_str})
|
|
||||||
VALUES ({','.join(f'${i+1}' for i in range(len(self.columns)))})
|
|
||||||
RETURNING queue_id
|
|
||||||
"""
|
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
|
||||||
deleted_rows = await conn.fetch(
|
|
||||||
delete_q,
|
|
||||||
self.user_mxid,
|
|
||||||
self.portal_tgid,
|
|
||||||
self.portal_tg_receiver,
|
|
||||||
self.type.value,
|
|
||||||
)
|
|
||||||
self.queue_id = await conn.fetchval(
|
|
||||||
q,
|
|
||||||
self.user_mxid,
|
|
||||||
self.priority,
|
|
||||||
self.type.value,
|
|
||||||
self.portal_tgid,
|
|
||||||
self.portal_tg_receiver,
|
|
||||||
self.anchor_msg_id,
|
|
||||||
json.dumps(self.extra_data) if self.extra_data else None,
|
|
||||||
self.messages_per_batch,
|
|
||||||
self.post_batch_delay,
|
|
||||||
self.max_batches,
|
|
||||||
self.dispatch_time,
|
|
||||||
self.completed_at,
|
|
||||||
self.cooldown_timeout,
|
|
||||||
)
|
|
||||||
return [self._from_row(row) for row in deleted_rows]
|
|
||||||
|
|
||||||
async def mark_dispatched(self) -> None:
|
|
||||||
q = "UPDATE backfill_queue SET dispatch_time=$1 WHERE queue_id=$2"
|
|
||||||
await self.db.execute(q, datetime.now(), self.queue_id)
|
|
||||||
|
|
||||||
async def mark_done(self) -> None:
|
|
||||||
q = "UPDATE backfill_queue SET completed_at=$1 WHERE queue_id=$2"
|
|
||||||
await self.db.execute(q, datetime.now(), self.queue_id)
|
|
||||||
|
|
||||||
async def set_cooldown_timeout(self, timeout: int) -> None:
|
|
||||||
"""
|
|
||||||
Set the backfill request to cooldown for ``timeout`` seconds.
|
|
||||||
"""
|
|
||||||
q = "UPDATE backfill_queue SET cooldown_timeout=$1 WHERE queue_id=$2"
|
|
||||||
await self.db.execute(q, datetime.now() + timedelta(seconds=timeout), self.queue_id)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
# Fucking Telegram not telling bots what chats they are in 3:<
|
|
||||||
@dataclass
|
|
||||||
class BotChat:
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
id: TelegramID
|
|
||||||
type: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: Record | None) -> BotChat | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def delete_by_id(cls, chat_id: TelegramID) -> None:
|
|
||||||
await cls.db.execute("DELETE FROM bot_chat WHERE id=$1", chat_id)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def all(cls) -> list[BotChat]:
|
|
||||||
rows = await cls.db.fetch("SELECT id, type FROM bot_chat")
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = "INSERT INTO bot_chat (id, type) VALUES ($1, $2)"
|
|
||||||
await self.db.execute(q, self.id, self.type)
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Sumner Evans
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
|
||||||
|
|
||||||
import asyncpg
|
|
||||||
|
|
||||||
from mautrix.bridge import AbstractDisappearingMessage
|
|
||||||
from mautrix.types import EventID, RoomID
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
class DisappearingMessage(AbstractDisappearingMessage):
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = """
|
|
||||||
INSERT INTO disappearing_message (room_id, event_id, expiration_seconds, expiration_ts)
|
|
||||||
VALUES ($1, $2, $3, $4)
|
|
||||||
"""
|
|
||||||
await self.db.execute(
|
|
||||||
q, self.room_id, self.event_id, self.expiration_seconds, self.expiration_ts
|
|
||||||
)
|
|
||||||
|
|
||||||
async def update(self) -> None:
|
|
||||||
q = "UPDATE disappearing_message SET expiration_ts=$3 WHERE room_id=$1 AND event_id=$2"
|
|
||||||
await self.db.execute(q, self.room_id, self.event_id, self.expiration_ts)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
q = "DELETE from disappearing_message WHERE room_id=$1 AND event_id=$2"
|
|
||||||
await self.db.execute(q, self.room_id, self.event_id)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: asyncpg.Record) -> DisappearingMessage:
|
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get(cls, room_id: RoomID, event_id: EventID) -> DisappearingMessage | None:
|
|
||||||
q = """
|
|
||||||
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
|
|
||||||
WHERE room_id=$1 AND mxid=$2
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, room_id, event_id))
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_all_scheduled(cls) -> list[DisappearingMessage]:
|
|
||||||
q = """
|
|
||||||
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
|
|
||||||
WHERE expiration_ts IS NOT NULL
|
|
||||||
"""
|
|
||||||
return [cls._from_row(r) for r in await cls.db.fetch(q)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_unscheduled_for_room(cls, room_id: RoomID) -> list[DisappearingMessage]:
|
|
||||||
q = """
|
|
||||||
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
|
|
||||||
WHERE room_id = $1 AND expiration_ts IS NULL
|
|
||||||
"""
|
|
||||||
return [cls._from_row(r) for r in await cls.db.fetch(q, room_id)]
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
import attr
|
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID, UserID
|
|
||||||
from mautrix.util.async_db import Database, Scheme
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Message:
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
mxid: EventID
|
|
||||||
mx_room: RoomID
|
|
||||||
tgid: TelegramID
|
|
||||||
tg_space: TelegramID
|
|
||||||
edit_index: int
|
|
||||||
redacted: bool = False
|
|
||||||
content_hash: bytes | None = None
|
|
||||||
sender_mxid: UserID | None = None
|
|
||||||
sender: TelegramID | None = None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: Record | None) -> Message | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
columns: ClassVar[str] = ", ".join(
|
|
||||||
(
|
|
||||||
"mxid",
|
|
||||||
"mx_room",
|
|
||||||
"tgid",
|
|
||||||
"tg_space",
|
|
||||||
"edit_index",
|
|
||||||
"redacted",
|
|
||||||
"content_hash",
|
|
||||||
"sender_mxid",
|
|
||||||
"sender",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> list[Message]:
|
|
||||||
q = f"SELECT {cls.columns} FROM message WHERE tgid=$1 AND tg_space=$2"
|
|
||||||
rows = await cls.db.fetch(q, tgid, tg_space)
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_one_by_tgid(
|
|
||||||
cls, tgid: TelegramID, tg_space: TelegramID, edit_index: int = 0
|
|
||||||
) -> Message | None:
|
|
||||||
if edit_index < 0:
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message WHERE tgid=$1 AND tg_space=$2 "
|
|
||||||
f"ORDER BY edit_index DESC LIMIT 1 OFFSET {-edit_index - 1}"
|
|
||||||
)
|
|
||||||
row = await cls.db.fetchrow(q, tgid, tg_space)
|
|
||||||
else:
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message"
|
|
||||||
" WHERE tgid=$1 AND tg_space=$2 AND edit_index=$3"
|
|
||||||
)
|
|
||||||
row = await cls.db.fetchrow(q, tgid, tg_space, edit_index)
|
|
||||||
return cls._from_row(row)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_first_by_tgids(
|
|
||||||
cls, tgids: list[TelegramID], tg_space: TelegramID
|
|
||||||
) -> list[Message]:
|
|
||||||
if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message"
|
|
||||||
" WHERE tgid=ANY($1) AND tg_space=$2 AND edit_index=0"
|
|
||||||
)
|
|
||||||
rows = await cls.db.fetch(q, tgids, tg_space)
|
|
||||||
else:
|
|
||||||
tgid_placeholders = ("?," * len(tgids)).rstrip(",")
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message "
|
|
||||||
f"WHERE tg_space=? AND edit_index=0 AND tgid IN ({tgid_placeholders})"
|
|
||||||
)
|
|
||||||
rows = await cls.db.fetch(q, tg_space, *tgids)
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def count_spaces_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> int:
|
|
||||||
return (
|
|
||||||
await cls.db.fetchval(
|
|
||||||
"SELECT COUNT(tg_space) FROM message WHERE mxid=$1 AND mx_room=$2", mxid, mx_room
|
|
||||||
)
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_last(cls, mx_room: RoomID, tg_space: TelegramID) -> Message | None:
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message WHERE mx_room=$1 AND tg_space=$2 "
|
|
||||||
f"ORDER BY tgid DESC LIMIT 1"
|
|
||||||
)
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mx_room, tg_space))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_first(cls, mx_room: RoomID, tg_space: TelegramID) -> Message | None:
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message WHERE mx_room=$1 AND tg_space=$2 "
|
|
||||||
f"ORDER BY tgid ASC LIMIT 1"
|
|
||||||
)
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mx_room, tg_space))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def delete_all(cls, mx_room: RoomID) -> None:
|
|
||||||
await cls.db.execute("DELETE FROM message WHERE mx_room=$1", mx_room)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_mxid(
|
|
||||||
cls, mxid: EventID, mx_room: RoomID, tg_space: TelegramID
|
|
||||||
) -> Message | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room, tg_space))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_mxids(
|
|
||||||
cls, mxids: list[EventID], mx_room: RoomID, tg_space: TelegramID
|
|
||||||
) -> list[Message]:
|
|
||||||
if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message"
|
|
||||||
" WHERE mxid=ANY($1) AND mx_room=$2 AND tg_space=$3"
|
|
||||||
)
|
|
||||||
rows = await cls.db.fetch(q, mxids, mx_room, tg_space)
|
|
||||||
else:
|
|
||||||
mxid_placeholders = ("?," * len(mxids)).rstrip(",")
|
|
||||||
q = (
|
|
||||||
f"SELECT {cls.columns} FROM message "
|
|
||||||
f"WHERE mx_room=? AND tg_space=? AND mxid IN ({mxid_placeholders})"
|
|
||||||
)
|
|
||||||
rows = await cls.db.fetch(q, mx_room, tg_space, *mxids)
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_recent(
|
|
||||||
cls, mx_room: RoomID, not_sender: TelegramID, limit: int = 20
|
|
||||||
) -> list[Message]:
|
|
||||||
q = f"""
|
|
||||||
SELECT {cls.columns} FROM message
|
|
||||||
WHERE mx_room=$1 AND sender<>$2
|
|
||||||
ORDER BY tgid DESC LIMIT $3
|
|
||||||
"""
|
|
||||||
return [cls._from_row(row) for row in await cls.db.fetch(q, mx_room, not_sender, limit)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def replace_temp_mxid(cls, temp_mxid: str, mx_room: RoomID, real_mxid: EventID) -> None:
|
|
||||||
q = "UPDATE message SET mxid=$1 WHERE mxid=$2 AND mx_room=$3"
|
|
||||||
await cls.db.execute(q, real_mxid, temp_mxid, mx_room)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def delete_temp_mxid(cls, temp_mxid: str, mx_room: RoomID) -> None:
|
|
||||||
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2"
|
|
||||||
await cls.db.execute(q, temp_mxid, mx_room)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def bulk_insert(cls, messages: list[Message]) -> None:
|
|
||||||
columns = cls.columns.split(", ")
|
|
||||||
records = [attr.astuple(message) for message in messages]
|
|
||||||
async with cls.db.acquire() as conn, conn.transaction():
|
|
||||||
if cls.db.scheme == Scheme.POSTGRES:
|
|
||||||
await conn.copy_records_to_table("message", records=records, columns=columns)
|
|
||||||
else:
|
|
||||||
await conn.executemany(cls._insert_query, records)
|
|
||||||
|
|
||||||
_insert_query: ClassVar[
|
|
||||||
str
|
|
||||||
] = """
|
|
||||||
INSERT INTO message (mxid, mx_room, tgid, tg_space, edit_index, redacted, content_hash, sender_mxid, sender)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
"""
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _values(self):
|
|
||||||
return (
|
|
||||||
self.mxid,
|
|
||||||
self.mx_room,
|
|
||||||
self.tgid,
|
|
||||||
self.tg_space,
|
|
||||||
self.edit_index,
|
|
||||||
self.redacted,
|
|
||||||
self.content_hash,
|
|
||||||
self.sender_mxid,
|
|
||||||
self.sender,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
await self.db.execute(self._insert_query, *self._values)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
|
|
||||||
await self.db.execute(q, self.mxid, self.mx_room, self.tg_space)
|
|
||||||
|
|
||||||
async def mark_redacted(self) -> None:
|
|
||||||
self.redacted = True
|
|
||||||
q = "UPDATE message SET redacted=true WHERE mxid=$1 AND mx_room=$2"
|
|
||||||
await self.db.execute(q, self.mxid, self.mx_room)
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, ClassVar
|
|
||||||
import json
|
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
import attr
|
|
||||||
|
|
||||||
from mautrix.types import BatchID, ContentURI, EventID, RoomID
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Portal:
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
# Telegram chat information
|
|
||||||
tgid: TelegramID
|
|
||||||
tg_receiver: TelegramID
|
|
||||||
peer_type: str
|
|
||||||
megagroup: bool
|
|
||||||
|
|
||||||
# Matrix portal information
|
|
||||||
mxid: RoomID | None
|
|
||||||
avatar_url: ContentURI | None
|
|
||||||
encrypted: bool
|
|
||||||
first_event_id: EventID | None
|
|
||||||
next_batch_id: BatchID | None
|
|
||||||
base_insertion_id: EventID | None
|
|
||||||
|
|
||||||
sponsored_event_id: EventID | None
|
|
||||||
sponsored_event_ts: int | None
|
|
||||||
sponsored_msg_random_id: bytes | None
|
|
||||||
|
|
||||||
# Telegram chat metadata
|
|
||||||
username: str | None
|
|
||||||
title: str | None
|
|
||||||
about: str | None
|
|
||||||
photo_id: str | None
|
|
||||||
name_set: bool
|
|
||||||
avatar_set: bool
|
|
||||||
|
|
||||||
local_config: dict[str, Any] = attr.ib(factory=lambda: {})
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: Record | None) -> Portal | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
data = {**row}
|
|
||||||
data["local_config"] = json.loads(data.pop("config", None) or "{}")
|
|
||||||
return cls(**data)
|
|
||||||
|
|
||||||
columns: ClassVar[str] = ", ".join(
|
|
||||||
(
|
|
||||||
"tgid",
|
|
||||||
"tg_receiver",
|
|
||||||
"peer_type",
|
|
||||||
"megagroup",
|
|
||||||
"mxid",
|
|
||||||
"avatar_url",
|
|
||||||
"encrypted",
|
|
||||||
"first_event_id",
|
|
||||||
"next_batch_id",
|
|
||||||
"base_insertion_id",
|
|
||||||
"sponsored_event_id",
|
|
||||||
"sponsored_event_ts",
|
|
||||||
"sponsored_msg_random_id",
|
|
||||||
"username",
|
|
||||||
"title",
|
|
||||||
"about",
|
|
||||||
"photo_id",
|
|
||||||
"name_set",
|
|
||||||
"avatar_set",
|
|
||||||
"config",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Portal | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND tg_receiver=$2"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, tgid, tg_receiver))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_mxid(cls, mxid: RoomID) -> Portal | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM portal WHERE mxid=$1"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_by_username(cls, username: str) -> Portal | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM portal WHERE lower(username)=$1"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_private_chats_of(cls, tg_receiver: TelegramID) -> list[Portal]:
|
|
||||||
q = f"SELECT {cls.columns} FROM portal WHERE tg_receiver=$1 AND peer_type='user'"
|
|
||||||
return [cls._from_row(row) for row in await cls.db.fetch(q, tg_receiver)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_private_chats_with(cls, tgid: TelegramID) -> list[Portal]:
|
|
||||||
q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND peer_type='user'"
|
|
||||||
return [cls._from_row(row) for row in await cls.db.fetch(q, tgid)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def all(cls) -> list[Portal]:
|
|
||||||
rows = await cls.db.fetch(f"SELECT {cls.columns} FROM portal")
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _values(self):
|
|
||||||
return (
|
|
||||||
self.tgid,
|
|
||||||
self.tg_receiver,
|
|
||||||
self.peer_type,
|
|
||||||
self.mxid,
|
|
||||||
self.avatar_url,
|
|
||||||
self.encrypted,
|
|
||||||
self.first_event_id,
|
|
||||||
self.next_batch_id,
|
|
||||||
self.base_insertion_id,
|
|
||||||
self.sponsored_event_id,
|
|
||||||
self.sponsored_event_ts,
|
|
||||||
self.sponsored_msg_random_id,
|
|
||||||
self.username,
|
|
||||||
self.title,
|
|
||||||
self.about,
|
|
||||||
self.photo_id,
|
|
||||||
self.name_set,
|
|
||||||
self.avatar_set,
|
|
||||||
self.megagroup,
|
|
||||||
json.dumps(self.local_config) if self.local_config else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def save(self) -> None:
|
|
||||||
q = """
|
|
||||||
UPDATE portal
|
|
||||||
SET mxid=$4, avatar_url=$5, encrypted=$6,
|
|
||||||
first_event_id=$7, next_batch_id=$8, base_insertion_id=$9,
|
|
||||||
sponsored_event_id=$10, sponsored_event_ts=$11, sponsored_msg_random_id=$12,
|
|
||||||
username=$13, title=$14, about=$15, photo_id=$16, name_set=$17, avatar_set=$18,
|
|
||||||
megagroup=$19, config=$20
|
|
||||||
WHERE tgid=$1 AND tg_receiver=$2 AND (peer_type=$3 OR true)
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def update_id(self, id: TelegramID, peer_type: str) -> None:
|
|
||||||
q = (
|
|
||||||
"UPDATE portal SET tgid=$1, tg_receiver=$1, peer_type=$2 "
|
|
||||||
"WHERE tgid=$3 AND tg_receiver=$3"
|
|
||||||
)
|
|
||||||
clear_queue = "DELETE FROM backfill_queue WHERE portal_tgid=$1 AND portal_tg_receiver=$2"
|
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
|
||||||
await conn.execute(clear_queue, self.tgid, self.tg_receiver)
|
|
||||||
await conn.execute(q, id, peer_type, self.tgid)
|
|
||||||
self.tgid = id
|
|
||||||
self.tg_receiver = id
|
|
||||||
self.peer_type = peer_type
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = """
|
|
||||||
INSERT INTO portal (
|
|
||||||
tgid, tg_receiver, peer_type, mxid, avatar_url, encrypted,
|
|
||||||
first_event_id, base_insertion_id, next_batch_id,
|
|
||||||
sponsored_event_id, sponsored_event_ts, sponsored_msg_random_id,
|
|
||||||
username, title, about, photo_id, name_set, avatar_set, megagroup, config
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
|
|
||||||
$19, $20)
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
q = "DELETE FROM portal WHERE tgid=$1 AND tg_receiver=$2"
|
|
||||||
await self.db.execute(q, self.tgid, self.tg_receiver)
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
from yarl import URL
|
|
||||||
|
|
||||||
from mautrix.types import ContentURI, SyncToken, UserID
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Puppet:
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
id: TelegramID
|
|
||||||
|
|
||||||
is_registered: bool
|
|
||||||
|
|
||||||
displayname: str | None
|
|
||||||
displayname_source: TelegramID | None
|
|
||||||
displayname_contact: bool
|
|
||||||
displayname_quality: int
|
|
||||||
disable_updates: bool
|
|
||||||
username: str | None
|
|
||||||
phone: str | None
|
|
||||||
photo_id: str | None
|
|
||||||
avatar_url: ContentURI | None
|
|
||||||
name_set: bool
|
|
||||||
avatar_set: bool
|
|
||||||
contact_info_set: bool
|
|
||||||
is_bot: bool | None
|
|
||||||
is_channel: bool
|
|
||||||
is_premium: bool
|
|
||||||
|
|
||||||
custom_mxid: UserID | None
|
|
||||||
access_token: str | None
|
|
||||||
next_batch: SyncToken | None
|
|
||||||
base_url: URL | None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: Record | None) -> Puppet | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
data = {**row}
|
|
||||||
base_url = data.pop("base_url", None)
|
|
||||||
return cls(**data, base_url=URL(base_url) if base_url else None)
|
|
||||||
|
|
||||||
columns: ClassVar[str] = (
|
|
||||||
"id, is_registered, displayname, displayname_source, displayname_contact, "
|
|
||||||
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
|
|
||||||
"name_set, avatar_set, contact_info_set, is_bot, is_channel, is_premium, "
|
|
||||||
"custom_mxid, access_token, next_batch, base_url"
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def all_with_custom_mxid(cls) -> list[Puppet]:
|
|
||||||
q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid<>''"
|
|
||||||
return [cls._from_row(row) for row in await cls.db.fetch(q)]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_tgid(cls, tgid: TelegramID) -> Puppet | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM puppet WHERE id=$1"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, tgid))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_custom_mxid(cls, mxid: UserID) -> Puppet | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM puppet WHERE custom_mxid=$1"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_by_username(cls, username: str) -> Puppet | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM puppet WHERE lower(username)=$1"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _values(self):
|
|
||||||
return (
|
|
||||||
self.id,
|
|
||||||
self.is_registered,
|
|
||||||
self.displayname,
|
|
||||||
self.displayname_source,
|
|
||||||
self.displayname_contact,
|
|
||||||
self.displayname_quality,
|
|
||||||
self.disable_updates,
|
|
||||||
self.username,
|
|
||||||
self.phone,
|
|
||||||
self.photo_id,
|
|
||||||
self.avatar_url,
|
|
||||||
self.name_set,
|
|
||||||
self.avatar_set,
|
|
||||||
self.contact_info_set,
|
|
||||||
self.is_bot,
|
|
||||||
self.is_channel,
|
|
||||||
self.is_premium,
|
|
||||||
self.custom_mxid,
|
|
||||||
self.access_token,
|
|
||||||
self.next_batch,
|
|
||||||
str(self.base_url) if self.base_url else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def save(self) -> None:
|
|
||||||
q = """
|
|
||||||
UPDATE puppet
|
|
||||||
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
|
|
||||||
displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10,
|
|
||||||
avatar_url=$11, name_set=$12, avatar_set=$13, contact_info_set=$14, is_bot=$15,
|
|
||||||
is_channel=$16, is_premium=$17, custom_mxid=$18, access_token=$19, next_batch=$20,
|
|
||||||
base_url=$21
|
|
||||||
WHERE id=$1
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = """
|
|
||||||
INSERT INTO puppet (
|
|
||||||
id, is_registered, displayname, displayname_source, displayname_contact,
|
|
||||||
displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
|
|
||||||
avatar_set, contact_info_set, is_bot, is_channel, is_premium, custom_mxid,
|
|
||||||
access_token, next_batch, base_url
|
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
|
|
||||||
$19, $20, $21)
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
from telethon.tl.types import ReactionCustomEmoji, ReactionEmoji, TypeReaction
|
|
||||||
|
|
||||||
from mautrix.types import EventID, RoomID
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Reaction:
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
mxid: EventID
|
|
||||||
mx_room: RoomID
|
|
||||||
msg_mxid: EventID
|
|
||||||
tg_sender: TelegramID
|
|
||||||
reaction: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: Record | None) -> Reaction | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
columns: ClassVar[str] = "mxid, mx_room, msg_mxid, tg_sender, reaction"
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def delete_all(cls, mx_room: RoomID) -> None:
|
|
||||||
await cls.db.execute("DELETE FROM reaction WHERE mx_room=$1", mx_room)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM reaction WHERE mxid=$1 AND mx_room=$2"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_sender(
|
|
||||||
cls, mxid: EventID, mx_room: RoomID, tg_sender: TelegramID
|
|
||||||
) -> list[Reaction]:
|
|
||||||
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3"
|
|
||||||
rows = await cls.db.fetch(q, mxid, mx_room, tg_sender)
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_all_by_message(cls, mxid: EventID, mx_room: RoomID) -> list[Reaction]:
|
|
||||||
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2"
|
|
||||||
rows = await cls.db.fetch(q, mxid, mx_room)
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@property
|
|
||||||
def telegram(self) -> TypeReaction:
|
|
||||||
if self.reaction.isdecimal():
|
|
||||||
return ReactionCustomEmoji(document_id=int(self.reaction))
|
|
||||||
else:
|
|
||||||
return ReactionEmoji(emoticon=self.reaction)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _values(self):
|
|
||||||
return (
|
|
||||||
self.mxid,
|
|
||||||
self.mx_room,
|
|
||||||
self.msg_mxid,
|
|
||||||
self.tg_sender,
|
|
||||||
self.reaction,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def save(self) -> None:
|
|
||||||
q = """
|
|
||||||
INSERT INTO reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
|
|
||||||
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (msg_mxid, mx_room, tg_sender, reaction)
|
|
||||||
DO UPDATE SET mxid=excluded.mxid
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
q = "DELETE FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3 AND reaction=$4"
|
|
||||||
await self.db.execute(q, self.msg_mxid, self.mx_room, self.tg_sender, self.reaction)
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar
|
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.types import ContentURI, EncryptedFile
|
|
||||||
from mautrix.util.async_db import Database, Scheme
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TelegramFile:
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
id: str
|
|
||||||
mxc: ContentURI
|
|
||||||
mime_type: str
|
|
||||||
was_converted: bool
|
|
||||||
timestamp: int
|
|
||||||
size: int | None
|
|
||||||
width: int | None
|
|
||||||
height: int | None
|
|
||||||
decryption_info: EncryptedFile | None
|
|
||||||
thumbnail: TelegramFile | None = None
|
|
||||||
|
|
||||||
columns: ClassVar[str] = (
|
|
||||||
"id, mxc, mime_type, was_converted, timestamp, size, width, height, thumbnail, "
|
|
||||||
"decryption_info"
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: Record | None) -> TelegramFile | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
data = {**row}
|
|
||||||
data.pop("thumbnail", None)
|
|
||||||
decryption_info = data.pop("decryption_info", None)
|
|
||||||
return cls(
|
|
||||||
**data,
|
|
||||||
thumbnail=None,
|
|
||||||
decryption_info=EncryptedFile.parse_json(decryption_info) if decryption_info else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_many(cls, loc_ids: list[str]) -> list[TelegramFile]:
|
|
||||||
if cls.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
|
|
||||||
q = f"SELECT {cls.columns} FROM telegram_file WHERE id=ANY($1)"
|
|
||||||
rows = await cls.db.fetch(q, loc_ids)
|
|
||||||
else:
|
|
||||||
tgid_placeholders = ("?," * len(loc_ids)).rstrip(",")
|
|
||||||
q = f"SELECT {cls.columns} FROM telegram_file WHERE id IN ({tgid_placeholders})"
|
|
||||||
rows = await cls.db.fetch(q, *loc_ids)
|
|
||||||
return [cls._from_row(row) for row in rows]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get(cls, loc_id: str, *, _thumbnail: bool = False) -> TelegramFile | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM telegram_file WHERE id=$1"
|
|
||||||
row = await cls.db.fetchrow(q, loc_id)
|
|
||||||
file = cls._from_row(row)
|
|
||||||
if file is None:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
thumbnail_id = row["thumbnail"]
|
|
||||||
except KeyError:
|
|
||||||
thumbnail_id = None
|
|
||||||
if thumbnail_id and not _thumbnail:
|
|
||||||
file.thumbnail = await cls.get(thumbnail_id, _thumbnail=True)
|
|
||||||
return file
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_by_mxc(cls, mxc: ContentURI) -> TelegramFile | None:
|
|
||||||
q = f"SELECT {cls.columns} FROM telegram_file WHERE mxc=$1"
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxc))
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = (
|
|
||||||
"INSERT INTO telegram_file (id, mxc, mime_type, was_converted, timestamp,"
|
|
||||||
" size, width, height, thumbnail, decryption_info) "
|
|
||||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"
|
|
||||||
)
|
|
||||||
await self.db.execute(
|
|
||||||
q,
|
|
||||||
self.id,
|
|
||||||
self.mxc,
|
|
||||||
self.mime_type,
|
|
||||||
self.was_converted,
|
|
||||||
self.timestamp,
|
|
||||||
self.size,
|
|
||||||
self.width,
|
|
||||||
self.height,
|
|
||||||
self.thumbnail.id if self.thumbnail else None,
|
|
||||||
self.decryption_info.json() if self.decryption_info else None,
|
|
||||||
)
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar, Iterable
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from telethon import utils
|
|
||||||
from telethon.crypto import AuthKey
|
|
||||||
from telethon.sessions import MemorySession
|
|
||||||
from telethon.tl.types import PeerChannel, PeerChat, PeerUser, updates
|
|
||||||
|
|
||||||
from mautrix.util.async_db import Database, Scheme
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
class PgSession(MemorySession):
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
session_id: str
|
|
||||||
_dc_id: int
|
|
||||||
_server_address: str | None
|
|
||||||
_port: int | None
|
|
||||||
_auth_key: AuthKey | None
|
|
||||||
_takeout_id: int | None
|
|
||||||
_process_entities_lock: asyncio.Lock
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session_id: str,
|
|
||||||
dc_id: int = 0,
|
|
||||||
server_address: str | None = None,
|
|
||||||
port: int | None = None,
|
|
||||||
auth_key: AuthKey | None = None,
|
|
||||||
takeout_id: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.session_id = session_id
|
|
||||||
self._dc_id = dc_id
|
|
||||||
self._server_address = server_address
|
|
||||||
self._port = port
|
|
||||||
self._auth_key = auth_key
|
|
||||||
self._takeout_id = takeout_id
|
|
||||||
self._process_entities_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
def clone(self, to_instance=None) -> MemorySession:
|
|
||||||
# We don't want to store data of clones
|
|
||||||
# (which are used for temporarily connecting to different DCs)
|
|
||||||
return super().clone(MemorySession())
|
|
||||||
|
|
||||||
@property
|
|
||||||
def auth_key_bytes(self) -> bytes | None:
|
|
||||||
return self._auth_key.key if self._auth_key else None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get(cls, session_id: str) -> PgSession:
|
|
||||||
q = (
|
|
||||||
"SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions "
|
|
||||||
"WHERE session_id=$1"
|
|
||||||
)
|
|
||||||
row = await cls.db.fetchrow(q, session_id)
|
|
||||||
if row is None:
|
|
||||||
return cls(session_id)
|
|
||||||
data = {**row}
|
|
||||||
auth_key = AuthKey(data.pop("auth_key", None))
|
|
||||||
return cls(**data, auth_key=auth_key)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def has(cls, session_id: str) -> bool:
|
|
||||||
q = "SELECT COUNT(*) FROM telethon_sessions WHERE session_id=$1"
|
|
||||||
count = await cls.db.fetchval(q, session_id)
|
|
||||||
return count > 0
|
|
||||||
|
|
||||||
async def save(self) -> None:
|
|
||||||
q = (
|
|
||||||
"INSERT INTO telethon_sessions (session_id, dc_id, server_address, port, auth_key) "
|
|
||||||
"VALUES ($1, $2, $3, $4, $5) ON CONFLICT (session_id) "
|
|
||||||
"DO UPDATE SET dc_id=$2, server_address=$3, port=$4, auth_key=$5"
|
|
||||||
)
|
|
||||||
await self.db.execute(
|
|
||||||
q, self.session_id, self.dc_id, self.server_address, self.port, self.auth_key_bytes
|
|
||||||
)
|
|
||||||
|
|
||||||
_tables: ClassVar[tuple[str, ...]] = (
|
|
||||||
"telethon_sessions",
|
|
||||||
"telethon_entities",
|
|
||||||
"telethon_sent_files",
|
|
||||||
"telethon_update_state",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
|
||||||
for table in self._tables:
|
|
||||||
await conn.execute(f"DELETE FROM {table} WHERE session_id=$1", self.session_id)
|
|
||||||
|
|
||||||
async def close(self) -> None:
|
|
||||||
# Nothing to do here, DB connection is global
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def get_update_state(self, entity_id: int) -> updates.State | None:
|
|
||||||
q = (
|
|
||||||
"SELECT pts, qts, date, seq, unread_count FROM telethon_update_state "
|
|
||||||
"WHERE session_id=$1 AND entity_id=$2"
|
|
||||||
)
|
|
||||||
row = await self.db.fetchrow(q, self.session_id, entity_id)
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
date = datetime.datetime.utcfromtimestamp(row["date"])
|
|
||||||
return updates.State(row["pts"], row["qts"], date, row["seq"], row["unread_count"])
|
|
||||||
|
|
||||||
_set_update_state_q = """
|
|
||||||
INSERT INTO telethon_update_state (session_id, entity_id, pts, qts, date, seq, unread_count)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
ON CONFLICT (session_id, entity_id) DO UPDATE SET
|
|
||||||
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
|
|
||||||
unread_count=excluded.unread_count
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def set_update_state(self, entity_id: int, row: updates.State) -> None:
|
|
||||||
q = self._set_update_state_q
|
|
||||||
ts = row.date.timestamp()
|
|
||||||
await self.db.execute(
|
|
||||||
q, self.session_id, entity_id, row.pts, row.qts, ts, row.seq, row.unread_count
|
|
||||||
)
|
|
||||||
|
|
||||||
async def set_update_states(self, rows: list[tuple[int, updates.State]]) -> None:
|
|
||||||
rows = [
|
|
||||||
(
|
|
||||||
self.session_id,
|
|
||||||
entity_id,
|
|
||||||
row.pts,
|
|
||||||
row.qts,
|
|
||||||
row.date.timestamp(),
|
|
||||||
row.seq,
|
|
||||||
row.unread_count,
|
|
||||||
)
|
|
||||||
for entity_id, row in rows
|
|
||||||
]
|
|
||||||
if self.db.scheme == Scheme.POSTGRES:
|
|
||||||
q = """
|
|
||||||
INSERT INTO telethon_update_state (
|
|
||||||
session_id, entity_id, pts, qts, date, seq, unread_count
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
$1,
|
|
||||||
unnest($2::bigint[]), unnest($3::bigint[]), unnest($4::bigint[]),
|
|
||||||
unnest($5::bigint[]), unnest($6::bigint[]), unnest($7::integer[])
|
|
||||||
)
|
|
||||||
ON CONFLICT (session_id, entity_id) DO UPDATE SET
|
|
||||||
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
|
|
||||||
unread_count=excluded.unread_count
|
|
||||||
"""
|
|
||||||
_, entity_ids, ptses, qtses, timestamps, seqs, unread_counts = zip(*rows)
|
|
||||||
await self.db.execute(
|
|
||||||
q, self.session_id, entity_ids, ptses, qtses, timestamps, seqs, unread_counts
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await self.db.executemany(self._set_update_state_q, rows)
|
|
||||||
|
|
||||||
async def delete_update_state(self, entity_id: int) -> None:
|
|
||||||
q = "DELETE FROM telethon_update_state WHERE session_id=$1 AND entity_id=$2"
|
|
||||||
await self.db.execute(q, self.session_id, entity_id)
|
|
||||||
|
|
||||||
async def get_update_states(self) -> Iterable[tuple[int, updates.State], ...]:
|
|
||||||
q = (
|
|
||||||
"SELECT entity_id, pts, qts, date, seq, unread_count FROM telethon_update_state "
|
|
||||||
"WHERE session_id=$1"
|
|
||||||
)
|
|
||||||
rows = await self.db.fetch(q, self.session_id)
|
|
||||||
return (
|
|
||||||
(
|
|
||||||
row["entity_id"],
|
|
||||||
updates.State(
|
|
||||||
row["pts"],
|
|
||||||
row["qts"],
|
|
||||||
datetime.datetime.utcfromtimestamp(row["date"]),
|
|
||||||
row["seq"],
|
|
||||||
row["unread_count"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for row in rows
|
|
||||||
)
|
|
||||||
|
|
||||||
def _entity_values_to_row(
|
|
||||||
self, id: int, hash: int, username: str | None, phone: str | int | None, name: str | None
|
|
||||||
) -> tuple[str, int, int, str | None, str | None, str | None]:
|
|
||||||
return self.session_id, id, hash, username, str(phone) if phone else None, name
|
|
||||||
|
|
||||||
async def process_entities(self, tlo) -> None:
|
|
||||||
# Postgres likes to deadlock on simultaneous upserts, so just lock the whole thing here
|
|
||||||
# TODO: make sure postgres doesn't deadlock on upserts when session_id is different
|
|
||||||
async with self._process_entities_lock:
|
|
||||||
await self._locked_process_entities(tlo)
|
|
||||||
|
|
||||||
async def _locked_process_entities(self, tlo) -> None:
|
|
||||||
rows: list[tuple[str, int, int, str | None, str | None, str | None]] = (
|
|
||||||
self._entities_to_rows(tlo)
|
|
||||||
)
|
|
||||||
if not rows:
|
|
||||||
return
|
|
||||||
if self.db.scheme == Scheme.POSTGRES:
|
|
||||||
q = (
|
|
||||||
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
|
|
||||||
"VALUES ($1, unnest($2::bigint[]), unnest($3::bigint[]), "
|
|
||||||
" unnest($4::text[]), unnest($5::text[]), unnest($6::text[])) "
|
|
||||||
"ON CONFLICT (session_id, id) DO UPDATE"
|
|
||||||
" SET hash=excluded.hash, username=excluded.username,"
|
|
||||||
" phone=excluded.phone, name=excluded.name"
|
|
||||||
)
|
|
||||||
_, ids, hashes, usernames, phones, names = zip(*rows)
|
|
||||||
await self.db.execute(q, self.session_id, ids, hashes, usernames, phones, names)
|
|
||||||
else:
|
|
||||||
q = (
|
|
||||||
"INSERT INTO telethon_entities (session_id, id, hash, username, phone, name) "
|
|
||||||
"VALUES ($1, $2, $3, $4, $5, $6) "
|
|
||||||
"ON CONFLICT (session_id, id) DO UPDATE "
|
|
||||||
" SET hash=$3, username=$4, phone=$5, name=$6"
|
|
||||||
)
|
|
||||||
await self.db.executemany(q, rows)
|
|
||||||
|
|
||||||
async def _select_entity(
|
|
||||||
self, constraint: str, *args: str | int | tuple[int, ...]
|
|
||||||
) -> tuple[int, int] | None:
|
|
||||||
q = f"SELECT id, hash FROM telethon_entities WHERE session_id=$1 AND {constraint}"
|
|
||||||
row = await self.db.fetchrow(q, self.session_id, *args)
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return row["id"], row["hash"]
|
|
||||||
|
|
||||||
async def get_entity_rows_by_phone(self, key: str | int) -> tuple[int, int] | None:
|
|
||||||
return await self._select_entity("phone=$2", str(key))
|
|
||||||
|
|
||||||
async def get_entity_rows_by_username(self, key: str) -> tuple[int, int] | None:
|
|
||||||
return await self._select_entity("username=$2", key)
|
|
||||||
|
|
||||||
async def get_entity_rows_by_name(self, key: str) -> tuple[int, int] | None:
|
|
||||||
return await self._select_entity("name=$2", key)
|
|
||||||
|
|
||||||
async def get_entity_rows_by_id(self, key: int, exact: bool = True) -> tuple[int, int] | None:
|
|
||||||
if exact:
|
|
||||||
return await self._select_entity("id=$2", key)
|
|
||||||
|
|
||||||
ids = (
|
|
||||||
utils.get_peer_id(PeerUser(key)),
|
|
||||||
utils.get_peer_id(PeerChat(key)),
|
|
||||||
utils.get_peer_id(PeerChannel(key)),
|
|
||||||
)
|
|
||||||
if self.db.scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
|
|
||||||
return await self._select_entity("id=ANY($2)", ids)
|
|
||||||
else:
|
|
||||||
return await self._select_entity(f"id IN ($2, $3, $4)", *ids)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
from mautrix.util.async_db import UpgradeTable
|
|
||||||
|
|
||||||
upgrade_table = UpgradeTable()
|
|
||||||
|
|
||||||
from . import (
|
|
||||||
v01_initial_revision,
|
|
||||||
v02_sponsored_events,
|
|
||||||
v03_reactions,
|
|
||||||
v04_disappearing_messages,
|
|
||||||
v05_channel_ghosts,
|
|
||||||
v06_puppet_avatar_url,
|
|
||||||
v07_puppet_phone_number,
|
|
||||||
v08_portal_first_event,
|
|
||||||
v09_puppet_username_index,
|
|
||||||
v10_more_backfill_fields,
|
|
||||||
v11_backfill_queue,
|
|
||||||
v12_message_sender,
|
|
||||||
v13_multiple_reactions,
|
|
||||||
v14_puppet_custom_mxid_index,
|
|
||||||
v15_backfill_anchor_id,
|
|
||||||
v16_backfill_type,
|
|
||||||
v17_message_find_recent,
|
|
||||||
v18_puppet_contact_info_set,
|
|
||||||
)
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection, Scheme
|
|
||||||
|
|
||||||
latest_version = 18
|
|
||||||
|
|
||||||
|
|
||||||
async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE "user" (
|
|
||||||
mxid TEXT PRIMARY KEY,
|
|
||||||
tgid BIGINT UNIQUE,
|
|
||||||
tg_username TEXT,
|
|
||||||
tg_phone TEXT,
|
|
||||||
is_bot BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
is_premium BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
saved_contacts INTEGER NOT NULL DEFAULT 0
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE portal (
|
|
||||||
tgid BIGINT,
|
|
||||||
tg_receiver BIGINT,
|
|
||||||
peer_type TEXT NOT NULL,
|
|
||||||
mxid TEXT UNIQUE,
|
|
||||||
avatar_url TEXT,
|
|
||||||
encrypted BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
username TEXT,
|
|
||||||
title TEXT,
|
|
||||||
about TEXT,
|
|
||||||
photo_id TEXT,
|
|
||||||
name_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
avatar_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
megagroup BOOLEAN,
|
|
||||||
config jsonb,
|
|
||||||
|
|
||||||
first_event_id TEXT,
|
|
||||||
next_batch_id TEXT,
|
|
||||||
base_insertion_id TEXT,
|
|
||||||
|
|
||||||
sponsored_event_id TEXT,
|
|
||||||
sponsored_event_ts BIGINT,
|
|
||||||
sponsored_msg_random_id bytea,
|
|
||||||
|
|
||||||
PRIMARY KEY (tgid, tg_receiver)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE message (
|
|
||||||
mxid TEXT NOT NULL,
|
|
||||||
mx_room TEXT NOT NULL,
|
|
||||||
tgid BIGINT,
|
|
||||||
tg_space BIGINT,
|
|
||||||
edit_index INTEGER,
|
|
||||||
redacted BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
content_hash bytea,
|
|
||||||
sender_mxid TEXT,
|
|
||||||
sender BIGINT,
|
|
||||||
PRIMARY KEY (tgid, tg_space, edit_index),
|
|
||||||
UNIQUE (mxid, mx_room, tg_space)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute("CREATE INDEX message_mx_room_and_tgid_idx ON message(mx_room, tgid DESC)")
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE reaction (
|
|
||||||
mxid TEXT NOT NULL,
|
|
||||||
mx_room TEXT NOT NULL,
|
|
||||||
msg_mxid TEXT NOT NULL,
|
|
||||||
tg_sender BIGINT,
|
|
||||||
reaction TEXT NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction),
|
|
||||||
UNIQUE (mxid, mx_room)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE disappearing_message (
|
|
||||||
room_id TEXT,
|
|
||||||
event_id TEXT,
|
|
||||||
expiration_seconds BIGINT,
|
|
||||||
expiration_ts BIGINT,
|
|
||||||
|
|
||||||
PRIMARY KEY (room_id, event_id)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE puppet (
|
|
||||||
id BIGINT PRIMARY KEY,
|
|
||||||
|
|
||||||
is_registered BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
displayname TEXT,
|
|
||||||
displayname_source BIGINT,
|
|
||||||
displayname_contact BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
displayname_quality INTEGER NOT NULL DEFAULT 0,
|
|
||||||
disable_updates BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
username TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
photo_id TEXT,
|
|
||||||
avatar_url TEXT,
|
|
||||||
name_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
avatar_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
contact_info_set BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
is_bot BOOLEAN,
|
|
||||||
is_channel BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
is_premium BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
|
|
||||||
access_token TEXT,
|
|
||||||
custom_mxid TEXT,
|
|
||||||
next_batch TEXT,
|
|
||||||
base_url TEXT
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute("CREATE INDEX puppet_username_idx ON puppet(LOWER(username))")
|
|
||||||
await conn.execute("CREATE INDEX puppet_custom_mxid_idx ON puppet(custom_mxid)")
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telegram_file (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
mxc TEXT NOT NULL,
|
|
||||||
mime_type TEXT,
|
|
||||||
was_converted BOOLEAN NOT NULL DEFAULT false,
|
|
||||||
timestamp BIGINT NOT NULL DEFAULT 0,
|
|
||||||
size BIGINT,
|
|
||||||
width INTEGER,
|
|
||||||
height INTEGER,
|
|
||||||
thumbnail TEXT,
|
|
||||||
decryption_info jsonb,
|
|
||||||
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE SET NULL
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute("CREATE INDEX telegram_file_mxc_idx ON telegram_file(mxc)")
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE bot_chat (
|
|
||||||
id BIGINT PRIMARY KEY,
|
|
||||||
type TEXT NOT NULL
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE user_portal (
|
|
||||||
"user" BIGINT,
|
|
||||||
portal BIGINT,
|
|
||||||
portal_receiver BIGINT,
|
|
||||||
PRIMARY KEY ("user", portal, portal_receiver),
|
|
||||||
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (portal, portal_receiver) REFERENCES portal(tgid, tg_receiver)
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE contact (
|
|
||||||
"user" BIGINT,
|
|
||||||
contact BIGINT,
|
|
||||||
PRIMARY KEY ("user", contact),
|
|
||||||
FOREIGN KEY ("user") REFERENCES "user"(tgid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (contact) REFERENCES puppet(id) ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telethon_sessions (
|
|
||||||
session_id TEXT PRIMARY KEY,
|
|
||||||
dc_id INTEGER,
|
|
||||||
server_address TEXT,
|
|
||||||
port INTEGER,
|
|
||||||
auth_key bytea
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telethon_entities (
|
|
||||||
session_id TEXT,
|
|
||||||
id BIGINT,
|
|
||||||
hash BIGINT NOT NULL,
|
|
||||||
username TEXT,
|
|
||||||
phone TEXT,
|
|
||||||
name TEXT,
|
|
||||||
PRIMARY KEY (session_id, id)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telethon_sent_files (
|
|
||||||
session_id TEXT,
|
|
||||||
md5_digest bytea,
|
|
||||||
file_size INTEGER,
|
|
||||||
type INTEGER,
|
|
||||||
id BIGINT,
|
|
||||||
hash BIGINT,
|
|
||||||
PRIMARY KEY (session_id, md5_digest, file_size, type)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telethon_update_state (
|
|
||||||
session_id TEXT,
|
|
||||||
entity_id BIGINT,
|
|
||||||
pts BIGINT,
|
|
||||||
qts BIGINT,
|
|
||||||
date BIGINT,
|
|
||||||
seq BIGINT,
|
|
||||||
unread_count INTEGER,
|
|
||||||
PRIMARY KEY (session_id, entity_id)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
gen = ""
|
|
||||||
if scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
|
|
||||||
gen = "GENERATED ALWAYS AS IDENTITY"
|
|
||||||
await conn.execute(
|
|
||||||
f"""
|
|
||||||
CREATE TABLE backfill_queue (
|
|
||||||
queue_id INTEGER PRIMARY KEY {gen},
|
|
||||||
user_mxid TEXT,
|
|
||||||
priority INTEGER NOT NULL,
|
|
||||||
type TEXT NOT NULL,
|
|
||||||
portal_tgid BIGINT,
|
|
||||||
portal_tg_receiver BIGINT,
|
|
||||||
anchor_msg_id BIGINT,
|
|
||||||
extra_data jsonb,
|
|
||||||
messages_per_batch INTEGER NOT NULL,
|
|
||||||
post_batch_delay INTEGER NOT NULL,
|
|
||||||
max_batches INTEGER NOT NULL,
|
|
||||||
dispatch_time TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
cooldown_timeout TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (portal_tgid, portal_tg_receiver)
|
|
||||||
REFERENCES portal(tgid, tg_receiver) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
return latest_version
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from mautrix.util.async_db import Connection, Scheme
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
from .v00_latest_revision import create_latest_tables, latest_version
|
|
||||||
|
|
||||||
legacy_version_query = "SELECT version_num FROM alembic_version"
|
|
||||||
last_legacy_version = "bfc0a39bfe02"
|
|
||||||
|
|
||||||
|
|
||||||
async def first_upgrade_target(conn: Connection, scheme: Scheme) -> int:
|
|
||||||
is_legacy = await conn.table_exists("alembic_version")
|
|
||||||
# If it's a legacy db, the upgrade process will go to v1 and run each migration up to latest.
|
|
||||||
# If it's a new db, we'll create the latest tables directly (see create_latest_tables call).
|
|
||||||
return 1 if is_legacy else latest_version
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Initial asyncpg revision", upgrades_to=first_upgrade_target)
|
|
||||||
async def upgrade_v1(conn: Connection, scheme: Scheme) -> int:
|
|
||||||
is_legacy = await conn.table_exists("alembic_version")
|
|
||||||
if is_legacy:
|
|
||||||
await migrate_legacy_to_v1(conn, scheme)
|
|
||||||
return 1
|
|
||||||
else:
|
|
||||||
return await create_latest_tables(conn, scheme)
|
|
||||||
|
|
||||||
|
|
||||||
async def drop_constraints(conn: Connection, table: str, contype: str) -> None:
|
|
||||||
q = (
|
|
||||||
"SELECT conname FROM pg_constraint con INNER JOIN pg_class rel ON rel.oid=con.conrelid "
|
|
||||||
f"WHERE rel.relname='{table}' AND contype='{contype}'"
|
|
||||||
)
|
|
||||||
names = [row["conname"] for row in await conn.fetch(q)]
|
|
||||||
drops = ", ".join(f"DROP CONSTRAINT {name}" for name in names)
|
|
||||||
await conn.execute(f"ALTER TABLE {table} {drops}")
|
|
||||||
|
|
||||||
|
|
||||||
async def migrate_legacy_to_v1(conn: Connection, scheme: Scheme) -> None:
|
|
||||||
legacy_version = await conn.fetchval(legacy_version_query)
|
|
||||||
if legacy_version != last_legacy_version:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Legacy database is not on last version. "
|
|
||||||
"Please upgrade the old database with alembic or drop it completely first."
|
|
||||||
)
|
|
||||||
if scheme != Scheme.SQLITE:
|
|
||||||
await drop_constraints(conn, "contact", contype="f")
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE contact
|
|
||||||
ADD CONSTRAINT contact_user_fkey FOREIGN KEY (contact) REFERENCES puppet(id)
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
ADD CONSTRAINT contact_contact_fkey FOREIGN KEY ("user") REFERENCES "user"(tgid)
|
|
||||||
ON DELETE CASCADE ON UPDATE CASCADE
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
await drop_constraints(conn, "telethon_sessions", contype="p")
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE telethon_sessions
|
|
||||||
ADD CONSTRAINT telethon_sessions_pkey PRIMARY KEY (session_id)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
await drop_constraints(conn, "telegram_file", contype="f")
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE telegram_file
|
|
||||||
ADD CONSTRAINT fk_file_thumbnail
|
|
||||||
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
|
|
||||||
ON UPDATE CASCADE ON DELETE SET NULL
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP IDENTITY IF EXISTS")
|
|
||||||
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP DEFAULT")
|
|
||||||
await conn.execute("DROP SEQUENCE IF EXISTS puppet_id_seq")
|
|
||||||
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP IDENTITY IF EXISTS")
|
|
||||||
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP DEFAULT")
|
|
||||||
await conn.execute("DROP SEQUENCE IF EXISTS bot_chat_id_seq")
|
|
||||||
await conn.execute("ALTER TABLE portal ALTER COLUMN config TYPE jsonb USING config::jsonb")
|
|
||||||
await conn.execute(
|
|
||||||
"ALTER TABLE telegram_file ALTER COLUMN decryption_info TYPE jsonb "
|
|
||||||
"USING decryption_info::jsonb"
|
|
||||||
)
|
|
||||||
await varchar_to_text(conn)
|
|
||||||
else:
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE telethon_sessions_new (
|
|
||||||
session_id TEXT PRIMARY KEY,
|
|
||||||
dc_id INTEGER,
|
|
||||||
server_address TEXT,
|
|
||||||
port INTEGER,
|
|
||||||
auth_key bytea
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO telethon_sessions_new (session_id, dc_id, server_address, port, auth_key)
|
|
||||||
SELECT session_id, dc_id, server_address, port, auth_key FROM telethon_sessions
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
await conn.execute("DROP TABLE telethon_sessions")
|
|
||||||
await conn.execute("ALTER TABLE telethon_sessions_new RENAME TO telethon_sessions")
|
|
||||||
|
|
||||||
await update_state_store(conn, scheme)
|
|
||||||
await conn.execute('ALTER TABLE "user" ADD COLUMN is_bot BOOLEAN NOT NULL DEFAULT false')
|
|
||||||
await conn.execute("ALTER TABLE puppet RENAME COLUMN matrix_registered TO is_registered")
|
|
||||||
await conn.execute("DROP TABLE telethon_version")
|
|
||||||
await conn.execute("DROP TABLE alembic_version")
|
|
||||||
|
|
||||||
|
|
||||||
async def update_state_store(conn: Connection, scheme: Scheme) -> None:
|
|
||||||
# The Matrix state store already has more or less the correct schema, so set the version
|
|
||||||
await conn.execute("CREATE TABLE mx_version (version INTEGER PRIMARY KEY)")
|
|
||||||
await conn.execute("INSERT INTO mx_version (version) VALUES (2)")
|
|
||||||
await conn.execute("UPDATE mx_user_profile SET membership='LEAVE' WHERE membership='LEFT'")
|
|
||||||
if scheme != Scheme.SQLITE:
|
|
||||||
# Also add the membership type on postgres
|
|
||||||
await conn.execute(
|
|
||||||
"CREATE TYPE membership AS ENUM ('join', 'leave', 'invite', 'ban', 'knock')"
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"ALTER TABLE mx_user_profile ALTER COLUMN membership TYPE membership "
|
|
||||||
"USING LOWER(membership)::membership"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# On SQLite there's no custom type, but we still want to lowercase everything
|
|
||||||
await conn.execute("UPDATE mx_user_profile SET membership=LOWER(membership)")
|
|
||||||
|
|
||||||
|
|
||||||
async def varchar_to_text(conn: Connection) -> None:
|
|
||||||
columns_to_adjust = {
|
|
||||||
"user": ("mxid", "tg_username", "tg_phone"),
|
|
||||||
"portal": (
|
|
||||||
"peer_type",
|
|
||||||
"mxid",
|
|
||||||
"username",
|
|
||||||
"title",
|
|
||||||
"about",
|
|
||||||
"photo_id",
|
|
||||||
"avatar_url",
|
|
||||||
"config",
|
|
||||||
),
|
|
||||||
"message": ("mxid", "mx_room"),
|
|
||||||
"puppet": (
|
|
||||||
"displayname",
|
|
||||||
"username",
|
|
||||||
"photo_id",
|
|
||||||
"access_token",
|
|
||||||
"custom_mxid",
|
|
||||||
"next_batch",
|
|
||||||
"base_url",
|
|
||||||
),
|
|
||||||
"bot_chat": ("type",),
|
|
||||||
"telegram_file": ("id", "mxc", "mime_type", "thumbnail"),
|
|
||||||
# Phone is a bigint in the old schema, which is safe, but we don't do math on it,
|
|
||||||
# so let's change it to a string
|
|
||||||
"telethon_entities": ("session_id", "username", "name", "phone"),
|
|
||||||
"telethon_sent_files": ("session_id",),
|
|
||||||
"telethon_sessions": ("session_id", "server_address"),
|
|
||||||
"telethon_update_state": ("session_id",),
|
|
||||||
"mx_room_state": ("room_id",),
|
|
||||||
"mx_user_profile": ("room_id", "user_id", "displayname", "avatar_url"),
|
|
||||||
}
|
|
||||||
for table, columns in columns_to_adjust.items():
|
|
||||||
for column in columns:
|
|
||||||
await conn.execute(f'ALTER TABLE "{table}" ALTER COLUMN {column} TYPE TEXT')
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add column to store sponsored message event ID in channels")
|
|
||||||
async def upgrade_v2(conn: Connection) -> None:
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_event_id TEXT")
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_event_ts BIGINT")
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN sponsored_msg_random_id bytea")
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add support for reactions")
|
|
||||||
async def upgrade_v3(conn: Connection, scheme: str) -> None:
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE reaction (
|
|
||||||
mxid TEXT NOT NULL,
|
|
||||||
mx_room TEXT NOT NULL,
|
|
||||||
msg_mxid TEXT NOT NULL,
|
|
||||||
tg_sender BIGINT,
|
|
||||||
reaction TEXT NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (msg_mxid, mx_room, tg_sender),
|
|
||||||
UNIQUE (mxid, mx_room)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
if scheme != "sqlite":
|
|
||||||
await conn.execute("DELETE FROM message WHERE mxid IS NULL OR mx_room IS NULL")
|
|
||||||
await conn.execute("ALTER TABLE message ALTER COLUMN mxid SET NOT NULL")
|
|
||||||
await conn.execute("ALTER TABLE message ALTER COLUMN mx_room SET NOT NULL")
|
|
||||||
await conn.execute("ALTER TABLE message ADD COLUMN content_hash bytea")
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add support for disappearing messages")
|
|
||||||
async def upgrade_v4(conn: Connection) -> None:
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE disappearing_message (
|
|
||||||
room_id TEXT,
|
|
||||||
event_id TEXT,
|
|
||||||
expiration_seconds BIGINT,
|
|
||||||
expiration_ts BIGINT,
|
|
||||||
|
|
||||||
PRIMARY KEY (room_id, event_id)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection, Scheme
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add separate ghost users for channel senders")
|
|
||||||
async def upgrade_v5(conn: Connection, scheme: str) -> None:
|
|
||||||
await conn.execute("ALTER TABLE puppet ADD COLUMN is_channel BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
if scheme == Scheme.POSTGRES:
|
|
||||||
await conn.execute("ALTER TABLE puppet ALTER COLUMN is_channel DROP DEFAULT")
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Store avatar mxc URI in puppet table")
|
|
||||||
async def upgrade_v6(conn: Connection) -> None:
|
|
||||||
await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_url TEXT")
|
|
||||||
await conn.execute("ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
await conn.execute("UPDATE puppet SET name_set=true WHERE displayname<>''")
|
|
||||||
await conn.execute("UPDATE puppet SET avatar_set=true WHERE photo_id<>''")
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
await conn.execute("UPDATE portal SET name_set=true WHERE title<>'' AND mxid<>''")
|
|
||||||
await conn.execute("UPDATE portal SET avatar_set=true WHERE photo_id<>'' AND mxid<>''")
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Store phone number in puppet table")
|
|
||||||
async def upgrade_v7(conn: Connection) -> None:
|
|
||||||
await conn.execute("ALTER TABLE puppet ADD COLUMN phone TEXT")
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Track first event ID in portals for infinite backfilling")
|
|
||||||
async def upgrade_v8(conn: Connection) -> None:
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN first_event_id TEXT")
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN next_batch_id TEXT")
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add index to puppet username column")
|
|
||||||
async def upgrade_v9(conn: Connection) -> None:
|
|
||||||
await conn.execute("CREATE INDEX puppet_username_idx ON puppet(LOWER(username))")
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add more portal columns related to infinite backfill")
|
|
||||||
async def upgrade_v10(conn: Connection) -> None:
|
|
||||||
await conn.execute("ALTER TABLE portal ADD COLUMN base_insertion_id TEXT")
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan, Sumner Evans
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection, Scheme
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add the backfill queue table")
|
|
||||||
async def upgrade_v11(conn: Connection, scheme: Scheme) -> None:
|
|
||||||
gen = ""
|
|
||||||
if scheme in (Scheme.POSTGRES, Scheme.COCKROACH):
|
|
||||||
gen = "GENERATED ALWAYS AS IDENTITY"
|
|
||||||
await conn.execute(
|
|
||||||
f"""
|
|
||||||
CREATE TABLE backfill_queue (
|
|
||||||
queue_id INTEGER PRIMARY KEY {gen},
|
|
||||||
user_mxid TEXT,
|
|
||||||
priority INTEGER NOT NULL,
|
|
||||||
portal_tgid BIGINT,
|
|
||||||
portal_tg_receiver BIGINT,
|
|
||||||
messages_per_batch INTEGER NOT NULL,
|
|
||||||
post_batch_delay INTEGER NOT NULL,
|
|
||||||
max_batches INTEGER NOT NULL,
|
|
||||||
dispatch_time TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP,
|
|
||||||
cooldown_timeout TIMESTAMP,
|
|
||||||
FOREIGN KEY (user_mxid) REFERENCES "user"(mxid) ON DELETE CASCADE ON UPDATE CASCADE,
|
|
||||||
FOREIGN KEY (portal_tgid, portal_tg_receiver)
|
|
||||||
REFERENCES portal(tgid, tg_receiver) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Store sender in message table")
|
|
||||||
async def upgrade_v12(conn: Connection) -> None:
|
|
||||||
await conn.execute("ALTER TABLE message ADD COLUMN sender_mxid TEXT")
|
|
||||||
await conn.execute("ALTER TABLE message ADD COLUMN sender BIGINT")
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection, Scheme
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Allow multiple reactions from the same user")
|
|
||||||
async def upgrade_v13(conn: Connection, scheme: Scheme) -> None:
|
|
||||||
await conn.execute("CREATE INDEX telegram_file_mxc_idx ON telegram_file(mxc)")
|
|
||||||
await conn.execute('ALTER TABLE "user" ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false')
|
|
||||||
await conn.execute("ALTER TABLE puppet ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false")
|
|
||||||
if scheme == Scheme.POSTGRES:
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
ALTER TABLE reaction
|
|
||||||
DROP CONSTRAINT reaction_pkey,
|
|
||||||
ADD CONSTRAINT reaction_pkey PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE new_reaction (
|
|
||||||
mxid TEXT NOT NULL,
|
|
||||||
mx_room TEXT NOT NULL,
|
|
||||||
msg_mxid TEXT NOT NULL,
|
|
||||||
tg_sender BIGINT,
|
|
||||||
reaction TEXT NOT NULL,
|
|
||||||
|
|
||||||
PRIMARY KEY (msg_mxid, mx_room, tg_sender, reaction),
|
|
||||||
UNIQUE (mxid, mx_room)
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
await conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO new_reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
|
|
||||||
SELECT mxid, mx_room, msg_mxid, tg_sender, reaction FROM reaction
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
await conn.execute("DROP TABLE reaction")
|
|
||||||
await conn.execute("ALTER TABLE new_reaction RENAME TO reaction")
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add index to puppet custom_mxid column")
|
|
||||||
async def upgrade_v14(conn: Connection) -> None:
|
|
||||||
await conn.execute("CREATE INDEX IF NOT EXISTS puppet_custom_mxid_idx ON puppet(custom_mxid)")
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Store lowest message ID in backfill queue")
|
|
||||||
async def upgrade_v15(conn: Connection) -> None:
|
|
||||||
await conn.execute("ALTER TABLE backfill_queue ADD COLUMN anchor_msg_id BIGINT")
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection, Scheme
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add type for backfill queue items")
|
|
||||||
async def upgrade_v16(conn: Connection, scheme: Scheme) -> None:
|
|
||||||
await conn.execute(
|
|
||||||
"ALTER TABLE backfill_queue ADD COLUMN type TEXT NOT NULL DEFAULT 'historical'"
|
|
||||||
)
|
|
||||||
await conn.execute("ALTER TABLE backfill_queue ADD COLUMN extra_data jsonb")
|
|
||||||
if scheme != Scheme.SQLITE:
|
|
||||||
await conn.execute("ALTER TABLE backfill_queue ALTER COLUMN type DROP DEFAULT")
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add index for Message.find_recent")
|
|
||||||
async def upgrade_v17(conn: Connection) -> None:
|
|
||||||
await conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS message_mx_room_and_tgid_idx ON message(mx_room, tgid DESC)"
|
|
||||||
)
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.async_db import Connection
|
|
||||||
|
|
||||||
from . import upgrade_table
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Add contact_info_set column to puppet table")
|
|
||||||
async def upgrade_v18(conn: Connection) -> None:
|
|
||||||
await conn.execute(
|
|
||||||
"ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false"
|
|
||||||
)
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, ClassVar, Iterable
|
|
||||||
|
|
||||||
from asyncpg import Record
|
|
||||||
from attr import dataclass
|
|
||||||
|
|
||||||
from mautrix.types import UserID
|
|
||||||
from mautrix.util.async_db import Connection, Database, Scheme
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
from .backfill_queue import Backfill
|
|
||||||
|
|
||||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class User:
|
|
||||||
db: ClassVar[Database] = fake_db
|
|
||||||
|
|
||||||
mxid: UserID
|
|
||||||
tgid: TelegramID | None
|
|
||||||
tg_username: str | None
|
|
||||||
tg_phone: str | None
|
|
||||||
is_bot: bool
|
|
||||||
is_premium: bool
|
|
||||||
saved_contacts: int
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _from_row(cls, row: Record | None) -> User | None:
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
return cls(**row)
|
|
||||||
|
|
||||||
columns: ClassVar[str] = ", ".join(
|
|
||||||
("mxid", "tgid", "tg_username", "tg_phone", "is_bot", "is_premium", "saved_contacts")
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_tgid(cls, tgid: TelegramID) -> User | None:
|
|
||||||
q = f'SELECT {cls.columns} FROM "user" WHERE tgid=$1'
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, tgid))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_mxid(cls, mxid: UserID) -> User | None:
|
|
||||||
q = f'SELECT {cls.columns} FROM "user" WHERE mxid=$1'
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, mxid))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_by_username(cls, username: str) -> User | None:
|
|
||||||
q = f'SELECT {cls.columns} FROM "user" WHERE lower(tg_username)=$1'
|
|
||||||
return cls._from_row(await cls.db.fetchrow(q, username.lower()))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def all_with_tgid(cls) -> list[User]:
|
|
||||||
q = f'SELECT {cls.columns} FROM "user" WHERE tgid IS NOT NULL'
|
|
||||||
return [cls._from_row(row) for row in await cls.db.fetch(q)]
|
|
||||||
|
|
||||||
async def delete(self) -> None:
|
|
||||||
await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid)
|
|
||||||
|
|
||||||
async def remove_tgid(self) -> None:
|
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
|
||||||
if self.tgid:
|
|
||||||
await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid)
|
|
||||||
await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid)
|
|
||||||
await Backfill.delete_all(self.mxid, conn=conn)
|
|
||||||
self.tgid = None
|
|
||||||
self.tg_username = None
|
|
||||||
self.tg_phone = None
|
|
||||||
self.is_bot = False
|
|
||||||
self.is_premium = False
|
|
||||||
self.saved_contacts = 0
|
|
||||||
await self.save(conn=conn)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _values(self):
|
|
||||||
return (
|
|
||||||
self.mxid,
|
|
||||||
self.tgid,
|
|
||||||
self.tg_username,
|
|
||||||
self.tg_phone,
|
|
||||||
self.is_bot,
|
|
||||||
self.is_premium,
|
|
||||||
self.saved_contacts,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def save(self, conn: Connection | None = None) -> None:
|
|
||||||
q = """
|
|
||||||
UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, is_premium=$6,
|
|
||||||
saved_contacts=$7
|
|
||||||
WHERE mxid=$1
|
|
||||||
"""
|
|
||||||
await (conn or self.db).execute(q, *self._values)
|
|
||||||
|
|
||||||
async def insert(self) -> None:
|
|
||||||
q = """
|
|
||||||
INSERT INTO "user" (mxid, tgid, tg_username, tg_phone, is_bot, is_premium, saved_contacts)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
"""
|
|
||||||
await self.db.execute(q, *self._values)
|
|
||||||
|
|
||||||
async def get_contacts(self) -> list[TelegramID]:
|
|
||||||
rows = await self.db.fetch('SELECT contact FROM contact WHERE "user"=$1', self.tgid)
|
|
||||||
return [TelegramID(row["contact"]) for row in rows]
|
|
||||||
|
|
||||||
async def set_contacts(self, puppets: Iterable[TelegramID]) -> None:
|
|
||||||
columns = ["user", "contact"]
|
|
||||||
records = [(self.tgid, puppet_id) for puppet_id in puppets]
|
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
|
||||||
await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid)
|
|
||||||
if self.db.scheme == Scheme.POSTGRES:
|
|
||||||
await conn.copy_records_to_table("contact", records=records, columns=columns)
|
|
||||||
else:
|
|
||||||
q = 'INSERT INTO contact ("user", contact) VALUES ($1, $2)'
|
|
||||||
await conn.executemany(q, records)
|
|
||||||
|
|
||||||
async def get_portals(self) -> list[tuple[TelegramID, TelegramID]]:
|
|
||||||
q = 'SELECT portal, portal_receiver FROM user_portal WHERE "user"=$1'
|
|
||||||
rows = await self.db.fetch(q, self.tgid)
|
|
||||||
return [(TelegramID(row["portal"]), TelegramID(row["portal_receiver"])) for row in rows]
|
|
||||||
|
|
||||||
async def set_portals(self, portals: Iterable[tuple[TelegramID, TelegramID]]) -> None:
|
|
||||||
columns = ["user", "portal", "portal_receiver"]
|
|
||||||
records = [(self.tgid, tgid, tg_receiver) for tgid, tg_receiver in portals]
|
|
||||||
async with self.db.acquire() as conn, conn.transaction():
|
|
||||||
await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid)
|
|
||||||
if self.db.scheme == Scheme.POSTGRES:
|
|
||||||
await conn.copy_records_to_table("user_portal", records=records, columns=columns)
|
|
||||||
else:
|
|
||||||
q = 'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3)'
|
|
||||||
await conn.executemany(q, records)
|
|
||||||
|
|
||||||
async def register_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
|
|
||||||
q = (
|
|
||||||
'INSERT INTO user_portal ("user", portal, portal_receiver) VALUES ($1, $2, $3) '
|
|
||||||
'ON CONFLICT ("user", portal, portal_receiver) DO NOTHING'
|
|
||||||
)
|
|
||||||
await self.db.execute(q, self.tgid, tgid, tg_receiver)
|
|
||||||
|
|
||||||
async def unregister_portal(self, tgid: TelegramID, tg_receiver: TelegramID) -> None:
|
|
||||||
q = 'DELETE FROM user_portal WHERE "user"=$1 AND portal=$2 AND portal_receiver=$3'
|
|
||||||
await self.db.execute(q, self.tgid, tgid, tg_receiver)
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
from .from_matrix import matrix_reply_to_telegram, matrix_to_telegram
|
|
||||||
from .from_telegram import telegram_text_to_matrix_html, telegram_to_matrix
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from telethon import TelegramClient
|
|
||||||
from telethon.helpers import add_surrogate, del_surrogate, strip_text
|
|
||||||
from telethon.tl.types import MessageEntityItalic, TypeMessageEntity
|
|
||||||
|
|
||||||
from mautrix.types import MessageEventContent, RoomID
|
|
||||||
|
|
||||||
from ...db import Message as DBMessage
|
|
||||||
from ...types import TelegramID
|
|
||||||
from .parser import MatrixParser
|
|
||||||
|
|
||||||
command_regex = re.compile(r"^!([A-Za-z0-9@]+)")
|
|
||||||
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)")
|
|
||||||
|
|
||||||
MAX_LENGTH = 4096
|
|
||||||
CUTOFF_TEXT = " [message cut]"
|
|
||||||
CUT_MAX_LENGTH = MAX_LENGTH - len(CUTOFF_TEXT)
|
|
||||||
|
|
||||||
|
|
||||||
class FormatError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
async def matrix_reply_to_telegram(
|
|
||||||
content: MessageEventContent, tg_space: TelegramID, room_id: RoomID | None = None
|
|
||||||
) -> TelegramID | None:
|
|
||||||
event_id = content.get_reply_to()
|
|
||||||
if not event_id:
|
|
||||||
return
|
|
||||||
content.trim_reply_fallback()
|
|
||||||
|
|
||||||
message = await DBMessage.get_by_mxid(event_id, room_id, tg_space)
|
|
||||||
if message:
|
|
||||||
return message.tgid
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def matrix_to_telegram(
|
|
||||||
client: TelegramClient, *, text: str | None = None, html: str | None = None
|
|
||||||
) -> tuple[str, list[TypeMessageEntity]]:
|
|
||||||
if html is not None:
|
|
||||||
return await _matrix_html_to_telegram(client, html)
|
|
||||||
elif text is not None:
|
|
||||||
return _matrix_text_to_telegram(text)
|
|
||||||
else:
|
|
||||||
raise ValueError("text or html must be provided to convert formatting")
|
|
||||||
|
|
||||||
|
|
||||||
async def _matrix_html_to_telegram(
|
|
||||||
client: TelegramClient, html: str
|
|
||||||
) -> tuple[str, list[TypeMessageEntity]]:
|
|
||||||
try:
|
|
||||||
html = command_regex.sub(r"<command>\1</command>", html)
|
|
||||||
html = html.replace("\t", " " * 4)
|
|
||||||
html = not_command_regex.sub(r"\1", html)
|
|
||||||
|
|
||||||
parsed = await MatrixParser(client).parse(add_surrogate(html))
|
|
||||||
text, entities = _cut_long_message(parsed.text, parsed.telegram_entities)
|
|
||||||
text = del_surrogate(strip_text(text, entities))
|
|
||||||
|
|
||||||
return text, entities
|
|
||||||
except Exception as e:
|
|
||||||
raise FormatError(f"Failed to convert Matrix format: {html}") from e
|
|
||||||
|
|
||||||
|
|
||||||
def _cut_long_message(
|
|
||||||
message: str, entities: list[TypeMessageEntity]
|
|
||||||
) -> tuple[str, list[TypeMessageEntity]]:
|
|
||||||
if len(message) > MAX_LENGTH:
|
|
||||||
message = message[0:CUT_MAX_LENGTH] + CUTOFF_TEXT
|
|
||||||
new_entities = []
|
|
||||||
for entity in entities:
|
|
||||||
if entity.offset > CUT_MAX_LENGTH:
|
|
||||||
continue
|
|
||||||
if entity.offset + entity.length > CUT_MAX_LENGTH:
|
|
||||||
entity.length = CUT_MAX_LENGTH - entity.offset
|
|
||||||
new_entities.append(entity)
|
|
||||||
new_entities.append(MessageEntityItalic(CUT_MAX_LENGTH, len(CUTOFF_TEXT)))
|
|
||||||
entities = new_entities
|
|
||||||
return message, entities
|
|
||||||
|
|
||||||
|
|
||||||
def _matrix_text_to_telegram(text: str) -> tuple[str, list[TypeMessageEntity]]:
|
|
||||||
text = command_regex.sub(r"/\1", text)
|
|
||||||
text = text.replace("\t", " " * 4)
|
|
||||||
text = not_command_regex.sub(r"\1", text)
|
|
||||||
entities = []
|
|
||||||
surrogated_text = add_surrogate(text)
|
|
||||||
if len(surrogated_text) > MAX_LENGTH:
|
|
||||||
surrogated_text, entities = _cut_long_message(surrogated_text, entities)
|
|
||||||
text = del_surrogate(surrogated_text)
|
|
||||||
return text, entities
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from telethon import TelegramClient
|
|
||||||
|
|
||||||
from mautrix.types import RoomID, UserID
|
|
||||||
from mautrix.util.formatter import HTMLNode, MatrixParser as BaseMatrixParser, RecursionContext
|
|
||||||
from mautrix.util.logging import TraceLogger
|
|
||||||
|
|
||||||
from ... import portal as po, puppet as pu, user as u
|
|
||||||
from .telegram_message import TelegramEntityType, TelegramMessage
|
|
||||||
|
|
||||||
log: TraceLogger = logging.getLogger("mau.fmt.mx")
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixParser(BaseMatrixParser[TelegramMessage]):
|
|
||||||
e = TelegramEntityType
|
|
||||||
fs = TelegramMessage
|
|
||||||
client: TelegramClient
|
|
||||||
|
|
||||||
def __init__(self, client: TelegramClient) -> None:
|
|
||||||
self.client = client
|
|
||||||
|
|
||||||
async def custom_node_to_fstring(
|
|
||||||
self, node: HTMLNode, ctx: RecursionContext
|
|
||||||
) -> TelegramMessage | None:
|
|
||||||
if node.tag == "command":
|
|
||||||
msg = await self.tag_aware_parse_node(node, ctx)
|
|
||||||
return msg.prepend("/").format(TelegramEntityType.COMMAND)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def user_pill_to_fstring(self, msg: TelegramMessage, user_id: UserID) -> TelegramMessage:
|
|
||||||
user = await pu.Puppet.get_by_mxid(user_id) or await u.User.get_by_mxid(
|
|
||||||
user_id, create=False
|
|
||||||
)
|
|
||||||
if not user:
|
|
||||||
return msg
|
|
||||||
if user.tg_username:
|
|
||||||
return TelegramMessage(f"@{user.tg_username}").format(TelegramEntityType.MENTION)
|
|
||||||
elif user.tgid:
|
|
||||||
displayname = user.plain_displayname or msg.text
|
|
||||||
msg = TelegramMessage(displayname)
|
|
||||||
try:
|
|
||||||
input_entity = await self.client.get_input_entity(user.tgid)
|
|
||||||
except (ValueError, TypeError) as e:
|
|
||||||
log.trace(f"Dropping mention of {user.tgid}: {e}")
|
|
||||||
else:
|
|
||||||
msg = msg.format(TelegramEntityType.MENTION_NAME, user_id=input_entity)
|
|
||||||
return msg
|
|
||||||
|
|
||||||
async def url_to_fstring(self, msg: TelegramMessage, url: str) -> TelegramMessage:
|
|
||||||
if url == msg.text:
|
|
||||||
return msg.format(self.e.URL)
|
|
||||||
else:
|
|
||||||
return msg.format(self.e.INLINE_URL, url=url)
|
|
||||||
|
|
||||||
async def room_pill_to_fstring(self, msg: TelegramMessage, room_id: RoomID) -> TelegramMessage:
|
|
||||||
username = po.Portal.get_username_from_mx_alias(room_id)
|
|
||||||
portal = await po.Portal.find_by_username(username)
|
|
||||||
if portal and portal.username:
|
|
||||||
return TelegramMessage(f"@{portal.username}").format(TelegramEntityType.MENTION)
|
|
||||||
|
|
||||||
async def header_to_fstring(self, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
|
|
||||||
children = await self.node_to_fstrings(node, ctx)
|
|
||||||
length = int(node.tag[1])
|
|
||||||
prefix = "#" * length + " "
|
|
||||||
return TelegramMessage.join(children, "").prepend(prefix).format(TelegramEntityType.BOLD)
|
|
||||||
|
|
||||||
async def color_to_fstring(self, msg: TelegramMessage, color: str) -> TelegramMessage:
|
|
||||||
return msg
|
|
||||||
|
|
||||||
async def spoiler_to_fstring(self, msg: TelegramMessage, reason: str) -> TelegramMessage:
|
|
||||||
msg = msg.format(self.e.SPOILER)
|
|
||||||
if reason:
|
|
||||||
msg = msg.prepend(f"{reason}: ")
|
|
||||||
return msg
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Type
|
|
||||||
from enum import Enum
|
|
||||||
|
|
||||||
from telethon.tl.types import (
|
|
||||||
InputMessageEntityMentionName as InputMentionName,
|
|
||||||
MessageEntityBlockquote as Blockquote,
|
|
||||||
MessageEntityBold as Bold,
|
|
||||||
MessageEntityBotCommand as Command,
|
|
||||||
MessageEntityCode as Code,
|
|
||||||
MessageEntityEmail as Email,
|
|
||||||
MessageEntityItalic as Italic,
|
|
||||||
MessageEntityMention as Mention,
|
|
||||||
MessageEntityMentionName as MentionName,
|
|
||||||
MessageEntityPre as Pre,
|
|
||||||
MessageEntitySpoiler as Spoiler,
|
|
||||||
MessageEntityStrike as Strike,
|
|
||||||
MessageEntityTextUrl as TextURL,
|
|
||||||
MessageEntityUnderline as Underline,
|
|
||||||
MessageEntityUrl as URL,
|
|
||||||
TypeMessageEntity,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.util.formatter import EntityString, SemiAbstractEntity
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramEntityType(Enum):
|
|
||||||
"""EntityType is a Matrix formatting entity type."""
|
|
||||||
|
|
||||||
BOLD = Bold
|
|
||||||
ITALIC = Italic
|
|
||||||
STRIKETHROUGH = Strike
|
|
||||||
UNDERLINE = Underline
|
|
||||||
URL = URL
|
|
||||||
INLINE_URL = TextURL
|
|
||||||
EMAIL = Email
|
|
||||||
PREFORMATTED = Pre
|
|
||||||
INLINE_CODE = Code
|
|
||||||
BLOCKQUOTE = Blockquote
|
|
||||||
MENTION = Mention
|
|
||||||
MENTION_NAME = InputMentionName
|
|
||||||
COMMAND = Command
|
|
||||||
SPOILER = Spoiler
|
|
||||||
|
|
||||||
USER_MENTION = 1
|
|
||||||
ROOM_MENTION = 2
|
|
||||||
HEADER = 3
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramEntity(SemiAbstractEntity):
|
|
||||||
internal: TypeMessageEntity
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
type: TelegramEntityType | Type[TypeMessageEntity],
|
|
||||||
offset: int,
|
|
||||||
length: int,
|
|
||||||
extra_info: dict[str, Any],
|
|
||||||
) -> None:
|
|
||||||
if isinstance(type, TelegramEntityType):
|
|
||||||
if isinstance(type.value, int):
|
|
||||||
raise ValueError(f"Can't create Entity with non-Telegram EntityType {type}")
|
|
||||||
type = type.value
|
|
||||||
self.internal = type(offset=offset, length=length, **extra_info)
|
|
||||||
|
|
||||||
def copy(self) -> TelegramEntity:
|
|
||||||
extra_info = {}
|
|
||||||
if isinstance(self.internal, Pre):
|
|
||||||
extra_info["language"] = self.internal.language
|
|
||||||
elif isinstance(self.internal, TextURL):
|
|
||||||
extra_info["url"] = self.internal.url
|
|
||||||
elif isinstance(self.internal, (MentionName, InputMentionName)):
|
|
||||||
extra_info["user_id"] = self.internal.user_id
|
|
||||||
return TelegramEntity(
|
|
||||||
type(self.internal),
|
|
||||||
offset=self.internal.offset,
|
|
||||||
length=self.internal.length,
|
|
||||||
extra_info=extra_info,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
return str(self.internal)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def offset(self) -> int:
|
|
||||||
return self.internal.offset
|
|
||||||
|
|
||||||
@offset.setter
|
|
||||||
def offset(self, value: int) -> None:
|
|
||||||
self.internal.offset = value
|
|
||||||
|
|
||||||
@property
|
|
||||||
def length(self) -> int:
|
|
||||||
return self.internal.length
|
|
||||||
|
|
||||||
@length.setter
|
|
||||||
def length(self, value: int) -> None:
|
|
||||||
self.internal.length = value
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramMessage(EntityString[TelegramEntity, TelegramEntityType]):
|
|
||||||
entity_class = TelegramEntity
|
|
||||||
|
|
||||||
@property
|
|
||||||
def telegram_entities(self) -> list[TypeMessageEntity]:
|
|
||||||
return [entity.internal for entity in self.entities]
|
|
||||||
@@ -1,437 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from html import escape
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
|
|
||||||
from telethon.errors import RPCError
|
|
||||||
from telethon.helpers import add_surrogate, del_surrogate
|
|
||||||
from telethon.tl.custom import Message
|
|
||||||
from telethon.tl.types import (
|
|
||||||
Channel,
|
|
||||||
InputPeerChannelFromMessage,
|
|
||||||
InputPeerUserFromMessage,
|
|
||||||
MessageEntityBlockquote,
|
|
||||||
MessageEntityBold,
|
|
||||||
MessageEntityBotCommand,
|
|
||||||
MessageEntityCashtag,
|
|
||||||
MessageEntityCode,
|
|
||||||
MessageEntityCustomEmoji,
|
|
||||||
MessageEntityEmail,
|
|
||||||
MessageEntityHashtag,
|
|
||||||
MessageEntityItalic,
|
|
||||||
MessageEntityMention,
|
|
||||||
MessageEntityMentionName,
|
|
||||||
MessageEntityPhone,
|
|
||||||
MessageEntityPre,
|
|
||||||
MessageEntitySpoiler,
|
|
||||||
MessageEntityStrike,
|
|
||||||
MessageEntityTextUrl,
|
|
||||||
MessageEntityUnderline,
|
|
||||||
MessageEntityUrl,
|
|
||||||
MessageFwdHeader,
|
|
||||||
PeerChannel,
|
|
||||||
PeerChat,
|
|
||||||
PeerUser,
|
|
||||||
SponsoredMessage,
|
|
||||||
TypeMessageEntity,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.types import Format, MessageType, TextMessageEventContent
|
|
||||||
|
|
||||||
from .. import abstract_user as au, portal as po, puppet as pu, user as u
|
|
||||||
from ..db import Message as DBMessage, TelegramFile as DBTelegramFile
|
|
||||||
from ..tgclient import MautrixTelegramClient
|
|
||||||
from ..types import TelegramID
|
|
||||||
from ..util.file_transfer import UnicodeCustomEmoji, transfer_custom_emojis_to_matrix
|
|
||||||
|
|
||||||
log: logging.Logger = logging.getLogger("mau.fmt.tg")
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_fwd_entity(client: MautrixTelegramClient, evt: Message) -> Channel | User | None:
|
|
||||||
try:
|
|
||||||
return await client.get_entity(evt.fwd_from.from_id)
|
|
||||||
except (ValueError, RPCError) as e:
|
|
||||||
try:
|
|
||||||
input_peer = await client.get_input_entity(evt.peer_id)
|
|
||||||
if isinstance(evt.fwd_from.from_id, PeerUser):
|
|
||||||
return await client.get_entity(
|
|
||||||
InputPeerUserFromMessage(
|
|
||||||
peer=input_peer, msg_id=evt.id, user_id=evt.fwd_from.from_id.user_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif isinstance(evt.fwd_from.from_id, PeerChannel):
|
|
||||||
return await client.get_entity(
|
|
||||||
InputPeerChannelFromMessage(
|
|
||||||
peer=input_peer, msg_id=evt.id, channel_id=evt.fwd_from.from_id.channel_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except (ValueError, RPCError) as e:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _add_forward_header(
|
|
||||||
client: MautrixTelegramClient, content: TextMessageEventContent, evt: Message
|
|
||||||
) -> None:
|
|
||||||
fwd_from = evt.fwd_from
|
|
||||||
fwd_from_html, fwd_from_text = None, None
|
|
||||||
if isinstance(fwd_from.from_id, PeerUser):
|
|
||||||
user = await u.User.get_by_tgid(TelegramID(fwd_from.from_id.user_id))
|
|
||||||
if user:
|
|
||||||
fwd_from_text = user.displayname or user.mxid
|
|
||||||
fwd_from_html = (
|
|
||||||
f"<a href='https://matrix.to/#/{user.mxid}'>{escape(fwd_from_text)}</a>"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not fwd_from_text:
|
|
||||||
puppet = await pu.Puppet.get_by_peer(fwd_from.from_id, create=False)
|
|
||||||
if puppet and puppet.displayname:
|
|
||||||
fwd_from_text = puppet.displayname or puppet.mxid
|
|
||||||
fwd_from_html = (
|
|
||||||
f"<a href='https://matrix.to/#/{puppet.mxid}'>{escape(fwd_from_text)}</a>"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not fwd_from_text:
|
|
||||||
user = await _get_fwd_entity(client, evt)
|
|
||||||
if user:
|
|
||||||
fwd_from_text, _ = pu.Puppet.get_displayname(user, False)
|
|
||||||
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
|
|
||||||
else:
|
|
||||||
fwd_from_text = fwd_from_html = "unknown user"
|
|
||||||
elif isinstance(fwd_from.from_id, (PeerChannel, PeerChat)):
|
|
||||||
from_id = (
|
|
||||||
fwd_from.from_id.chat_id
|
|
||||||
if isinstance(fwd_from.from_id, PeerChat)
|
|
||||||
else fwd_from.from_id.channel_id
|
|
||||||
)
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(from_id))
|
|
||||||
if portal and portal.title:
|
|
||||||
fwd_from_text = portal.title
|
|
||||||
if portal.alias:
|
|
||||||
fwd_from_html = (
|
|
||||||
f"<a href='https://matrix.to/#/{portal.alias}'>{escape(fwd_from_text)}</a>"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
fwd_from_html = f"channel <b>{escape(fwd_from_text)}</b>"
|
|
||||||
else:
|
|
||||||
channel = await _get_fwd_entity(client, evt)
|
|
||||||
if channel:
|
|
||||||
fwd_from_text = f"channel {channel.title}"
|
|
||||||
fwd_from_html = f"channel <b>{escape(channel.title)}</b>"
|
|
||||||
else:
|
|
||||||
fwd_from_text = fwd_from_html = "unknown channel"
|
|
||||||
elif fwd_from.from_name:
|
|
||||||
fwd_from_text = fwd_from.from_name
|
|
||||||
fwd_from_html = f"<b>{escape(fwd_from.from_name)}</b>"
|
|
||||||
else:
|
|
||||||
fwd_from_text = "unknown source"
|
|
||||||
fwd_from_html = f"unknown source"
|
|
||||||
|
|
||||||
content.ensure_has_html()
|
|
||||||
content.body = "\n".join([f"> {line}" for line in content.body.split("\n")])
|
|
||||||
content.body = f"Forwarded from {fwd_from_text}:\n{content.body}"
|
|
||||||
content.formatted_body = (
|
|
||||||
f"Forwarded message from {fwd_from_html}<br/>"
|
|
||||||
f"<tg-forward><blockquote>{content.formatted_body}</blockquote></tg-forward>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ReuploadedCustomEmoji(MessageEntityCustomEmoji):
|
|
||||||
file: DBTelegramFile
|
|
||||||
|
|
||||||
def __init__(self, parent: MessageEntityCustomEmoji, file: DBTelegramFile) -> None:
|
|
||||||
super().__init__(parent.offset, parent.length, parent.document_id)
|
|
||||||
self.file = file
|
|
||||||
|
|
||||||
|
|
||||||
async def _convert_custom_emoji(
|
|
||||||
source: au.AbstractUser,
|
|
||||||
entities: list[TypeMessageEntity],
|
|
||||||
client: MautrixTelegramClient | None = None,
|
|
||||||
) -> None:
|
|
||||||
emoji_ids = [
|
|
||||||
entity.document_id for entity in entities if isinstance(entity, MessageEntityCustomEmoji)
|
|
||||||
]
|
|
||||||
custom_emojis = await transfer_custom_emojis_to_matrix(source, emoji_ids, client=client)
|
|
||||||
if len(custom_emojis) > 0:
|
|
||||||
for i, entity in enumerate(entities):
|
|
||||||
if isinstance(entity, MessageEntityCustomEmoji):
|
|
||||||
entities[i] = ReuploadedCustomEmoji(entity, custom_emojis[entity.document_id])
|
|
||||||
|
|
||||||
|
|
||||||
async def telegram_text_to_matrix_html(
|
|
||||||
source: au.AbstractUser,
|
|
||||||
text: str,
|
|
||||||
entities: list[TypeMessageEntity],
|
|
||||||
client: MautrixTelegramClient | None = None,
|
|
||||||
) -> str:
|
|
||||||
if not entities:
|
|
||||||
return escape(text).replace("\n", "<br/>")
|
|
||||||
await _convert_custom_emoji(source, entities, client=client)
|
|
||||||
text = add_surrogate(text)
|
|
||||||
html = await _telegram_entities_to_matrix_catch(text, entities)
|
|
||||||
html = del_surrogate(html)
|
|
||||||
return html
|
|
||||||
|
|
||||||
|
|
||||||
async def telegram_to_matrix(
|
|
||||||
evt: Message | SponsoredMessage,
|
|
||||||
source: au.AbstractUser,
|
|
||||||
client: MautrixTelegramClient | None = None,
|
|
||||||
override_text: str = None,
|
|
||||||
override_entities: list[TypeMessageEntity] = None,
|
|
||||||
require_html: bool = False,
|
|
||||||
) -> TextMessageEventContent:
|
|
||||||
if not client:
|
|
||||||
client = source.client
|
|
||||||
content = TextMessageEventContent(
|
|
||||||
msgtype=MessageType.TEXT,
|
|
||||||
body=override_text or evt.message,
|
|
||||||
)
|
|
||||||
entities = override_entities or evt.entities
|
|
||||||
if entities:
|
|
||||||
content.format = Format.HTML
|
|
||||||
content.formatted_body = await telegram_text_to_matrix_html(
|
|
||||||
source, content.body, entities, client=client
|
|
||||||
)
|
|
||||||
|
|
||||||
if require_html:
|
|
||||||
content.ensure_has_html()
|
|
||||||
|
|
||||||
if getattr(evt, "fwd_from", None):
|
|
||||||
await _add_forward_header(client, content, evt)
|
|
||||||
|
|
||||||
if isinstance(evt, Message) and evt.post and evt.post_author:
|
|
||||||
content.ensure_has_html()
|
|
||||||
content.body += f"\n- {evt.post_author}"
|
|
||||||
content.formatted_body += f"<br/><i>- <u>{evt.post_author}</u></i>"
|
|
||||||
|
|
||||||
return content
|
|
||||||
|
|
||||||
|
|
||||||
async def _telegram_entities_to_matrix_catch(text: str, entities: list[TypeMessageEntity]) -> str:
|
|
||||||
try:
|
|
||||||
return await _telegram_entities_to_matrix(text, entities)
|
|
||||||
except Exception:
|
|
||||||
log.exception(
|
|
||||||
"Failed to convert Telegram format:\nmessage=%s\nentities=%s", text, entities
|
|
||||||
)
|
|
||||||
return "[failed conversion in _telegram_entities_to_matrix]"
|
|
||||||
|
|
||||||
|
|
||||||
def within_surrogate(text, index):
|
|
||||||
"""
|
|
||||||
`True` if ``index`` is within a surrogate (before and after it, not at!).
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
1 < index < len(text) # in bounds
|
|
||||||
and "\ud800" <= text[index - 1] <= "\udbff" # current is low surrogate
|
|
||||||
and "\udc00" <= text[index] <= "\udfff" # previous is high surrogate
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _telegram_entities_to_matrix(
|
|
||||||
text: str,
|
|
||||||
entities: list[TypeMessageEntity | ReuploadedCustomEmoji],
|
|
||||||
offset: int = 0,
|
|
||||||
length: int = None,
|
|
||||||
in_codeblock: bool = False,
|
|
||||||
) -> str:
|
|
||||||
def text_to_html(
|
|
||||||
val: str, _in_codeblock: bool = in_codeblock, escape_html: bool = True
|
|
||||||
) -> str:
|
|
||||||
if escape_html:
|
|
||||||
val = escape(val)
|
|
||||||
if not _in_codeblock:
|
|
||||||
val = val.replace("\n", "<br/>")
|
|
||||||
return val
|
|
||||||
|
|
||||||
if not entities:
|
|
||||||
return text_to_html(text)
|
|
||||||
if length is None:
|
|
||||||
length = len(text)
|
|
||||||
html = []
|
|
||||||
last_offset = 0
|
|
||||||
for i, entity in enumerate(entities):
|
|
||||||
if entity.offset >= offset + length:
|
|
||||||
break
|
|
||||||
relative_offset = entity.offset - offset
|
|
||||||
if relative_offset > last_offset:
|
|
||||||
html.append(text_to_html(text[last_offset:relative_offset]))
|
|
||||||
elif relative_offset < last_offset:
|
|
||||||
continue
|
|
||||||
|
|
||||||
while within_surrogate(text, relative_offset):
|
|
||||||
relative_offset += 1
|
|
||||||
while within_surrogate(text, relative_offset + entity.length):
|
|
||||||
entity.length += 1
|
|
||||||
|
|
||||||
skip_entity = False
|
|
||||||
is_code_entity = isinstance(entity, (MessageEntityCode, MessageEntityPre))
|
|
||||||
entity_text = await _telegram_entities_to_matrix(
|
|
||||||
text=text[relative_offset : relative_offset + entity.length],
|
|
||||||
entities=entities[i + 1 :],
|
|
||||||
offset=entity.offset,
|
|
||||||
length=entity.length,
|
|
||||||
in_codeblock=is_code_entity,
|
|
||||||
)
|
|
||||||
entity_text = text_to_html(entity_text, is_code_entity, escape_html=False)
|
|
||||||
entity_type = type(entity)
|
|
||||||
|
|
||||||
if entity_type == MessageEntityBold:
|
|
||||||
html.append(f"<strong>{entity_text}</strong>")
|
|
||||||
elif entity_type == MessageEntityItalic:
|
|
||||||
html.append(f"<em>{entity_text}</em>")
|
|
||||||
elif entity_type == MessageEntityUnderline:
|
|
||||||
html.append(f"<u>{entity_text}</u>")
|
|
||||||
elif entity_type == MessageEntityStrike:
|
|
||||||
html.append(f"<del>{entity_text}</del>")
|
|
||||||
elif entity_type == MessageEntityBlockquote:
|
|
||||||
html.append(f"<blockquote>{entity_text}</blockquote>")
|
|
||||||
elif entity_type == MessageEntityCode:
|
|
||||||
html.append(
|
|
||||||
f"<pre><code>{entity_text}</code></pre>"
|
|
||||||
if "\n" in entity_text
|
|
||||||
else f"<code>{entity_text}</code>"
|
|
||||||
)
|
|
||||||
elif entity_type == MessageEntityPre:
|
|
||||||
skip_entity = _parse_pre(html, entity_text, entity.language)
|
|
||||||
elif entity_type == MessageEntityMention:
|
|
||||||
skip_entity = await _parse_mention(html, entity_text)
|
|
||||||
elif entity_type == MessageEntityMentionName:
|
|
||||||
skip_entity = await _parse_name_mention(html, entity_text, TelegramID(entity.user_id))
|
|
||||||
elif entity_type == MessageEntityEmail:
|
|
||||||
html.append(f"<a href='mailto:{entity_text}'>{entity_text}</a>")
|
|
||||||
elif entity_type in (MessageEntityTextUrl, MessageEntityUrl):
|
|
||||||
await _parse_url(
|
|
||||||
html, entity_text, entity.url if entity_type == MessageEntityTextUrl else None
|
|
||||||
)
|
|
||||||
elif entity_type == MessageEntityCustomEmoji:
|
|
||||||
html.append(entity_text)
|
|
||||||
elif entity_type == ReuploadedCustomEmoji:
|
|
||||||
if isinstance(entity.file, UnicodeCustomEmoji):
|
|
||||||
html.append(entity.file.emoji)
|
|
||||||
else:
|
|
||||||
html.append(
|
|
||||||
f"<img data-mx-emoticon data-mau-animated-emoji"
|
|
||||||
f' src="{escape(entity.file.mxc)}" height="32" width="32"'
|
|
||||||
f' alt="{entity_text}" title="{entity_text}"/>'
|
|
||||||
)
|
|
||||||
elif entity_type in (
|
|
||||||
MessageEntityBotCommand,
|
|
||||||
MessageEntityHashtag,
|
|
||||||
MessageEntityCashtag,
|
|
||||||
MessageEntityPhone,
|
|
||||||
):
|
|
||||||
html.append(f"<font color='#3771bb'>{entity_text}</font>")
|
|
||||||
elif entity_type == MessageEntitySpoiler:
|
|
||||||
html.append(f"<span data-mx-spoiler>{entity_text}</span>")
|
|
||||||
else:
|
|
||||||
skip_entity = True
|
|
||||||
last_offset = relative_offset + (0 if skip_entity else entity.length)
|
|
||||||
html.append(text_to_html(text[last_offset:]))
|
|
||||||
|
|
||||||
html_string = "".join(html)
|
|
||||||
# Remove redundant <br>'s after block tags
|
|
||||||
html_string = html_string.replace("</blockquote><br/>", "</blockquote>")
|
|
||||||
html_string = html_string.replace("</pre><br/>", "</pre>")
|
|
||||||
return html_string
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_pre(html: list[str], entity_text: str, language: str) -> bool:
|
|
||||||
if language:
|
|
||||||
html.append(f"<pre><code class='language-{language}'>{entity_text}</code></pre>")
|
|
||||||
else:
|
|
||||||
html.append(f"<pre><code>{entity_text}</code></pre>")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _parse_mention(html: list[str], entity_text: str) -> bool:
|
|
||||||
username = entity_text[1:]
|
|
||||||
|
|
||||||
mxid = None
|
|
||||||
portal = None
|
|
||||||
# This is a bit complicated because public channels have both Puppet and Portal instances.
|
|
||||||
# Basically the currently intended output is:
|
|
||||||
# User/bot mention (bridge user) -> real user mention
|
|
||||||
# User/bot mention (normal Telegram user) -> ghost user mention
|
|
||||||
# Public channel with existing portal -> room mention
|
|
||||||
# Public channel without portal -> ghost user mention
|
|
||||||
# Other chat -> room mention
|
|
||||||
user = await u.User.find_by_username(username) or await pu.Puppet.find_by_username(username)
|
|
||||||
if user:
|
|
||||||
if isinstance(user, pu.Puppet) and user.is_channel:
|
|
||||||
portal = await po.Portal.get_by_tgid(user.tgid)
|
|
||||||
mxid = user.mxid
|
|
||||||
else:
|
|
||||||
portal = await po.Portal.find_by_username(username)
|
|
||||||
if portal and (portal.mxid or not user):
|
|
||||||
mxid = portal.alias or portal.mxid
|
|
||||||
|
|
||||||
if mxid:
|
|
||||||
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def _parse_name_mention(html: list[str], entity_text: str, user_id: TelegramID) -> bool:
|
|
||||||
user = await u.User.get_by_tgid(user_id)
|
|
||||||
if user:
|
|
||||||
mxid = user.mxid
|
|
||||||
else:
|
|
||||||
puppet = await pu.Puppet.get_by_tgid(user_id, create=False)
|
|
||||||
mxid = puppet.mxid if puppet else None
|
|
||||||
if mxid:
|
|
||||||
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
message_link_regex = re.compile(
|
|
||||||
r"https?://t(?:elegram)?\.(?:me|dog)"
|
|
||||||
# /username or /c/id
|
|
||||||
r"/([A-Za-z][A-Za-z0-9_]{3,31}[A-Za-z0-9]|[Cc]/[0-9]{1,20})"
|
|
||||||
# /messageid
|
|
||||||
r"/([0-9]{1,20})"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _parse_url(html: list[str], entity_text: str, url: str) -> None:
|
|
||||||
url = escape(url) if url else entity_text
|
|
||||||
if not url.startswith(("https://", "http://", "ftp://", "magnet://")):
|
|
||||||
url = "http://" + url
|
|
||||||
|
|
||||||
message_link_match = message_link_regex.match(url)
|
|
||||||
if message_link_match:
|
|
||||||
group, msgid_str = message_link_match.groups()
|
|
||||||
msgid = int(msgid_str)
|
|
||||||
|
|
||||||
if group.lower().startswith("c/"):
|
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(int(group[2:])))
|
|
||||||
else:
|
|
||||||
portal = await po.Portal.find_by_username(group)
|
|
||||||
if portal:
|
|
||||||
message = await DBMessage.get_one_by_tgid(TelegramID(msgid), portal.tgid)
|
|
||||||
if message:
|
|
||||||
url = f"https://matrix.to/#/{portal.mxid}/{message.mxid}"
|
|
||||||
|
|
||||||
html.append(f"<a href='{url}'>{entity_text}</a>")
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from . import __version__
|
|
||||||
|
|
||||||
cmd_env = {
|
|
||||||
"PATH": os.environ["PATH"],
|
|
||||||
"HOME": os.environ["HOME"],
|
|
||||||
"LANG": "C",
|
|
||||||
"LC_ALL": "C",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def run(cmd):
|
|
||||||
return subprocess.check_output(cmd, stderr=subprocess.DEVNULL, env=cmd_env)
|
|
||||||
|
|
||||||
|
|
||||||
if os.path.exists(".git") and shutil.which("git"):
|
|
||||||
try:
|
|
||||||
git_revision = run(["git", "rev-parse", "HEAD"]).strip().decode("ascii")
|
|
||||||
git_revision_url = f"https://github.com/mautrix/telegram/commit/{git_revision}"
|
|
||||||
git_revision = git_revision[:8]
|
|
||||||
except (subprocess.SubprocessError, OSError):
|
|
||||||
git_revision = "unknown"
|
|
||||||
git_revision_url = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
git_tag = run(["git", "describe", "--exact-match", "--tags"]).strip().decode("ascii")
|
|
||||||
except (subprocess.SubprocessError, OSError):
|
|
||||||
git_tag = None
|
|
||||||
else:
|
|
||||||
git_revision = "unknown"
|
|
||||||
git_revision_url = None
|
|
||||||
git_tag = None
|
|
||||||
|
|
||||||
git_tag_url = f"https://github.com/mautrix/telegram/releases/tag/{git_tag}" if git_tag else None
|
|
||||||
|
|
||||||
if git_tag and __version__ == git_tag[1:].replace("-", ""):
|
|
||||||
version = __version__
|
|
||||||
linkified_version = f"[{version}]({git_tag_url})"
|
|
||||||
else:
|
|
||||||
if not __version__.endswith("+dev"):
|
|
||||||
__version__ += "+dev"
|
|
||||||
version = f"{__version__}.{git_revision}"
|
|
||||||
if git_revision_url:
|
|
||||||
linkified_version = f"{__version__}.[{git_revision}]({git_revision_url})"
|
|
||||||
else:
|
|
||||||
linkified_version = version
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from mautrix.bridge import BaseMatrixHandler
|
|
||||||
from mautrix.types import (
|
|
||||||
Event,
|
|
||||||
EventID,
|
|
||||||
EventType,
|
|
||||||
MemberStateEventContent,
|
|
||||||
PresenceEvent,
|
|
||||||
PresenceState,
|
|
||||||
ReactionEvent,
|
|
||||||
ReceiptEvent,
|
|
||||||
RedactionEvent,
|
|
||||||
RoomAvatarStateEventContent as AvatarContent,
|
|
||||||
RoomID,
|
|
||||||
RoomNameStateEventContent as NameContent,
|
|
||||||
RoomTopicStateEventContent as TopicContent,
|
|
||||||
SingleReceiptEventContent,
|
|
||||||
StateEvent,
|
|
||||||
TypingEvent,
|
|
||||||
UserID,
|
|
||||||
)
|
|
||||||
|
|
||||||
from . import commands as com, portal as po, puppet as pu, user as u
|
|
||||||
from .commands.portal.util import get_initial_state, user_has_power_level, warn_missing_power
|
|
||||||
from .types import TelegramID
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .__main__ import TelegramBridge
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixHandler(BaseMatrixHandler):
|
|
||||||
commands: com.CommandProcessor
|
|
||||||
_previously_typing: dict[RoomID, set[UserID]]
|
|
||||||
|
|
||||||
def __init__(self, bridge: "TelegramBridge") -> None:
|
|
||||||
prefix, suffix = bridge.config["bridge.username_template"].format(userid=":").split(":")
|
|
||||||
homeserver = bridge.config["homeserver.domain"]
|
|
||||||
self.user_id_prefix = f"@{prefix}"
|
|
||||||
self.user_id_suffix = f"{suffix}:{homeserver}"
|
|
||||||
|
|
||||||
super().__init__(command_processor=com.CommandProcessor(bridge), bridge=bridge)
|
|
||||||
|
|
||||||
self._previously_typing = {}
|
|
||||||
|
|
||||||
async def handle_puppet_group_invite(
|
|
||||||
self,
|
|
||||||
room_id: RoomID,
|
|
||||||
puppet: pu.Puppet,
|
|
||||||
invited_by: u.User,
|
|
||||||
evt: StateEvent,
|
|
||||||
members: list[UserID],
|
|
||||||
) -> None:
|
|
||||||
double_puppet = await pu.Puppet.get_by_custom_mxid(invited_by.mxid)
|
|
||||||
if (
|
|
||||||
not double_puppet
|
|
||||||
or self.az.bot_mxid in members
|
|
||||||
or not self.config["bridge.create_group_on_invite"]
|
|
||||||
):
|
|
||||||
if self.az.bot_mxid not in members:
|
|
||||||
await puppet.default_mxid_intent.leave_room(
|
|
||||||
room_id,
|
|
||||||
reason="This ghost does not join multi-user rooms without the bridge bot.",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await puppet.default_mxid_intent.send_notice(
|
|
||||||
room_id,
|
|
||||||
"This ghost will remain inactive "
|
|
||||||
"until a Telegram chat is created for this room.",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
elif not await user_has_power_level(
|
|
||||||
evt.room_id, double_puppet.intent, invited_by, "bridge"
|
|
||||||
):
|
|
||||||
await puppet.default_mxid_intent.leave_room(
|
|
||||||
room_id, reason="You do not have the permissions to bridge this room."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
await double_puppet.intent.invite_user(room_id, self.az.bot_mxid)
|
|
||||||
|
|
||||||
title, about, levels, encrypted = await get_initial_state(double_puppet.intent, room_id)
|
|
||||||
if not title:
|
|
||||||
await puppet.default_mxid_intent.leave_room(
|
|
||||||
room_id, reason="Please set a title before inviting Telegram ghosts."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
portal = po.Portal(
|
|
||||||
tgid=TelegramID(0),
|
|
||||||
tg_receiver=TelegramID(0),
|
|
||||||
peer_type="channel",
|
|
||||||
mxid=evt.room_id,
|
|
||||||
title=title,
|
|
||||||
about=about,
|
|
||||||
encrypted=encrypted,
|
|
||||||
)
|
|
||||||
await portal.az.intent.ensure_joined(room_id)
|
|
||||||
levels = await portal.az.intent.get_power_levels(room_id)
|
|
||||||
invited_by_level = levels.get_user_level(invited_by.mxid)
|
|
||||||
if invited_by_level > levels.get_user_level(self.az.bot_mxid):
|
|
||||||
levels.users[self.az.bot_mxid] = 100 if invited_by_level >= 100 else invited_by_level
|
|
||||||
await double_puppet.intent.set_power_levels(room_id, levels)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await portal.create_telegram_chat(invited_by, supergroup=True)
|
|
||||||
except ValueError as e:
|
|
||||||
await portal.delete()
|
|
||||||
await portal.az.intent.send_notice(room_id, e.args[0])
|
|
||||||
return
|
|
||||||
|
|
||||||
async def handle_invite(
|
|
||||||
self, room_id: RoomID, user_id: UserID, inviter: u.User, event_id: EventID
|
|
||||||
) -> None:
|
|
||||||
user = await u.User.get_by_mxid(user_id, create=False)
|
|
||||||
if not user:
|
|
||||||
return
|
|
||||||
await user.ensure_started()
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if (
|
|
||||||
user
|
|
||||||
and portal
|
|
||||||
and await user.has_full_access(allow_bot=True)
|
|
||||||
and portal.allow_bridging
|
|
||||||
):
|
|
||||||
await portal.handle_matrix_invite(inviter, user)
|
|
||||||
|
|
||||||
async def handle_join(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
|
|
||||||
user = await u.User.get_and_start_by_mxid(user_id)
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not user.relaybot_whitelisted:
|
|
||||||
await portal.main_intent.kick_user(
|
|
||||||
room_id, user.mxid, "You are not whitelisted on this Telegram bridge."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
elif not await user.is_logged_in() and not portal.has_bot:
|
|
||||||
await portal.main_intent.kick_user(
|
|
||||||
room_id,
|
|
||||||
user.mxid,
|
|
||||||
"This chat does not have a bot on the Telegram side for relaying messages sent by"
|
|
||||||
" unauthenticated Matrix users.",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
self.log.debug(f"{user.mxid} joined {room_id}")
|
|
||||||
if await user.is_logged_in() or portal.has_bot:
|
|
||||||
await portal.join_matrix(user, event_id)
|
|
||||||
|
|
||||||
async def handle_leave(self, room_id: RoomID, user_id: UserID, event_id: EventID) -> None:
|
|
||||||
self.log.debug(f"{user_id} left {room_id}")
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
user = await u.User.get_by_mxid(user_id, create=False)
|
|
||||||
if not user:
|
|
||||||
return
|
|
||||||
await user.ensure_started()
|
|
||||||
await portal.leave_matrix(user, event_id)
|
|
||||||
|
|
||||||
async def handle_kick_ban(
|
|
||||||
self,
|
|
||||||
ban: bool,
|
|
||||||
room_id: RoomID,
|
|
||||||
user_id: UserID,
|
|
||||||
sender: UserID,
|
|
||||||
reason: str,
|
|
||||||
event_id: EventID,
|
|
||||||
) -> None:
|
|
||||||
action = "banned" if ban else "kicked"
|
|
||||||
self.log.debug(f"{user_id} was {action} from {room_id} by {sender} for {reason}")
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
if user_id == self.az.bot_mxid:
|
|
||||||
# Direct chat portal unbridging is handled in portal.kick_matrix
|
|
||||||
if portal.peer_type != "user":
|
|
||||||
await portal.unbridge()
|
|
||||||
return
|
|
||||||
|
|
||||||
sender = await u.User.get_by_mxid(sender, create=False)
|
|
||||||
if not sender:
|
|
||||||
return
|
|
||||||
await sender.ensure_started()
|
|
||||||
|
|
||||||
puppet = await pu.Puppet.get_by_mxid(user_id)
|
|
||||||
if puppet:
|
|
||||||
if ban:
|
|
||||||
await portal.ban_matrix(puppet, sender)
|
|
||||||
else:
|
|
||||||
await portal.kick_matrix(puppet, sender)
|
|
||||||
return
|
|
||||||
|
|
||||||
user = await u.User.get_by_mxid(user_id, create=False)
|
|
||||||
if not user:
|
|
||||||
return
|
|
||||||
await user.ensure_started()
|
|
||||||
if ban:
|
|
||||||
await portal.ban_matrix(user, sender)
|
|
||||||
else:
|
|
||||||
await portal.kick_matrix(user, sender)
|
|
||||||
|
|
||||||
async def handle_kick(
|
|
||||||
self, room_id: RoomID, user_id: UserID, kicked_by: UserID, reason: str, event_id: EventID
|
|
||||||
) -> None:
|
|
||||||
await self.handle_kick_ban(False, room_id, user_id, kicked_by, reason, event_id)
|
|
||||||
|
|
||||||
async def handle_unban(
|
|
||||||
self, room_id: RoomID, user_id: UserID, unbanned_by: UserID, reason: str, event_id: EventID
|
|
||||||
) -> None:
|
|
||||||
# TODO handle unbans properly instead of handling it as a kick
|
|
||||||
await self.handle_kick_ban(False, room_id, user_id, unbanned_by, reason, event_id)
|
|
||||||
|
|
||||||
async def handle_ban(
|
|
||||||
self, room_id: RoomID, user_id: UserID, banned_by: UserID, reason: str, event_id: EventID
|
|
||||||
) -> None:
|
|
||||||
await self.handle_kick_ban(True, room_id, user_id, banned_by, reason, event_id)
|
|
||||||
|
|
||||||
async def allow_message(self, user: u.User) -> bool:
|
|
||||||
return user.relaybot_whitelisted
|
|
||||||
|
|
||||||
async def allow_command(self, user: u.User) -> bool:
|
|
||||||
return user.whitelisted
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def allow_bridging_message(user: u.User, portal: po.Portal) -> bool:
|
|
||||||
return await user.is_logged_in() or portal.has_bot
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_redaction(evt: RedactionEvent) -> None:
|
|
||||||
sender = await u.User.get_and_start_by_mxid(evt.sender)
|
|
||||||
if not sender.relaybot_whitelisted:
|
|
||||||
return
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
await portal.handle_matrix_deletion(sender, evt.redacts, evt.event_id)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_reaction(evt: ReactionEvent) -> None:
|
|
||||||
sender = await u.User.get_and_start_by_mxid(evt.sender)
|
|
||||||
if not await sender.has_full_access():
|
|
||||||
return
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
if not portal or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
await portal.handle_matrix_reaction(
|
|
||||||
sender, evt.content.relates_to.event_id, evt.content.relates_to.key, evt.event_id
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_power_levels(evt: StateEvent) -> None:
|
|
||||||
portal = await po.Portal.get_by_mxid(evt.room_id)
|
|
||||||
sender = await u.User.get_and_start_by_mxid(evt.sender)
|
|
||||||
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
|
|
||||||
await portal.handle_matrix_power_levels(
|
|
||||||
sender, evt.content.users, evt.unsigned.prev_content.users, evt.event_id
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_room_meta(
|
|
||||||
evt_type: EventType,
|
|
||||||
room_id: RoomID,
|
|
||||||
sender_mxid: UserID,
|
|
||||||
content: NameContent | AvatarContent | TopicContent,
|
|
||||||
event_id: EventID,
|
|
||||||
) -> None:
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
sender = await u.User.get_and_start_by_mxid(sender_mxid)
|
|
||||||
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
|
|
||||||
handler, content_type, content_key = {
|
|
||||||
EventType.ROOM_NAME: (portal.handle_matrix_title, NameContent, "name"),
|
|
||||||
EventType.ROOM_TOPIC: (portal.handle_matrix_about, TopicContent, "topic"),
|
|
||||||
EventType.ROOM_AVATAR: (portal.handle_matrix_avatar, AvatarContent, "url"),
|
|
||||||
}[evt_type]
|
|
||||||
if not isinstance(content, content_type):
|
|
||||||
return
|
|
||||||
await handler(sender, content[content_key], event_id)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_room_pin(
|
|
||||||
room_id: RoomID,
|
|
||||||
sender_mxid: UserID,
|
|
||||||
new_events: set[str],
|
|
||||||
old_events: set[str],
|
|
||||||
event_id: EventID,
|
|
||||||
) -> None:
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
sender = await u.User.get_and_start_by_mxid(sender_mxid)
|
|
||||||
if await sender.has_full_access(allow_bot=True) and portal and portal.allow_bridging:
|
|
||||||
if not new_events:
|
|
||||||
await portal.handle_matrix_unpin_all(sender, event_id)
|
|
||||||
else:
|
|
||||||
changes = {
|
|
||||||
event_id: event_id in new_events for event_id in new_events ^ old_events
|
|
||||||
}
|
|
||||||
await portal.handle_matrix_pin(sender, changes, event_id)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_room_upgrade(
|
|
||||||
room_id: RoomID, sender: UserID, new_room_id: RoomID, event_id: EventID
|
|
||||||
) -> None:
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if portal and portal.allow_bridging:
|
|
||||||
await portal.handle_matrix_upgrade(sender, new_room_id, event_id)
|
|
||||||
|
|
||||||
async def handle_member_info_change(
|
|
||||||
self,
|
|
||||||
room_id: RoomID,
|
|
||||||
user_id: UserID,
|
|
||||||
profile: MemberStateEventContent,
|
|
||||||
prev_profile: MemberStateEventContent,
|
|
||||||
event_id: EventID,
|
|
||||||
) -> None:
|
|
||||||
if profile.displayname == prev_profile.displayname:
|
|
||||||
return
|
|
||||||
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal or not portal.has_bot or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
user = await u.User.get_and_start_by_mxid(user_id)
|
|
||||||
if await user.needs_relaybot(portal):
|
|
||||||
await portal.name_change_matrix(
|
|
||||||
user, profile.displayname, prev_profile.displayname, event_id
|
|
||||||
)
|
|
||||||
|
|
||||||
async def handle_read_receipt(
|
|
||||||
self, user: u.User, portal: po.Portal, event_id: EventID, data: SingleReceiptEventContent
|
|
||||||
) -> None:
|
|
||||||
if not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
await portal.mark_read(user, event_id, data.get("ts", 0))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def handle_presence(user_id: UserID, presence: PresenceState) -> None:
|
|
||||||
user = await u.User.get_by_mxid(user_id, check_db=False, create=False)
|
|
||||||
if user and await user.is_logged_in():
|
|
||||||
await user.set_presence(presence == PresenceState.ONLINE)
|
|
||||||
|
|
||||||
async def handle_typing(self, room_id: RoomID, now_typing: set[UserID]) -> None:
|
|
||||||
portal = await po.Portal.get_by_mxid(room_id)
|
|
||||||
if not portal or not portal.allow_bridging:
|
|
||||||
return
|
|
||||||
|
|
||||||
previously_typing = self._previously_typing.get(room_id, set())
|
|
||||||
|
|
||||||
for user_id in set(previously_typing | now_typing):
|
|
||||||
is_typing = user_id in now_typing
|
|
||||||
was_typing = user_id in previously_typing
|
|
||||||
if is_typing and was_typing:
|
|
||||||
continue
|
|
||||||
|
|
||||||
user = await u.User.get_by_mxid(user_id, check_db=False, create=False)
|
|
||||||
if user and await user.is_logged_in():
|
|
||||||
await portal.set_typing(user, is_typing)
|
|
||||||
|
|
||||||
self._previously_typing[room_id] = now_typing
|
|
||||||
|
|
||||||
async def handle_ephemeral_event(
|
|
||||||
self, evt: ReceiptEvent | PresenceEvent | TypingEvent
|
|
||||||
) -> None:
|
|
||||||
if evt.type == EventType.RECEIPT:
|
|
||||||
await self.handle_receipt(evt)
|
|
||||||
elif evt.type == EventType.PRESENCE:
|
|
||||||
await self.handle_presence(evt.sender, evt.content.presence)
|
|
||||||
elif evt.type == EventType.TYPING:
|
|
||||||
await self.handle_typing(evt.room_id, set(evt.content.user_ids))
|
|
||||||
|
|
||||||
async def handle_event(self, evt: Event) -> None:
|
|
||||||
if evt.type == EventType.ROOM_REDACTION:
|
|
||||||
await self.handle_redaction(evt)
|
|
||||||
elif evt.type == EventType.REACTION:
|
|
||||||
await self.handle_reaction(evt)
|
|
||||||
|
|
||||||
async def handle_state_event(self, evt: StateEvent) -> None:
|
|
||||||
if evt.type == EventType.ROOM_POWER_LEVELS:
|
|
||||||
await self.handle_power_levels(evt)
|
|
||||||
elif evt.type in (EventType.ROOM_NAME, EventType.ROOM_AVATAR, EventType.ROOM_TOPIC):
|
|
||||||
await self.handle_room_meta(
|
|
||||||
evt.type, evt.room_id, evt.sender, evt.content, evt.event_id
|
|
||||||
)
|
|
||||||
elif evt.type == EventType.ROOM_PINNED_EVENTS:
|
|
||||||
new_events = set(evt.content.pinned)
|
|
||||||
try:
|
|
||||||
old_events = set(evt.unsigned.prev_content.pinned)
|
|
||||||
except (KeyError, ValueError, TypeError, AttributeError):
|
|
||||||
old_events = set()
|
|
||||||
await self.handle_room_pin(
|
|
||||||
evt.room_id, evt.sender, new_events, old_events, evt.event_id
|
|
||||||
)
|
|
||||||
elif evt.type == EventType.ROOM_TOMBSTONE:
|
|
||||||
await self.handle_room_upgrade(
|
|
||||||
evt.room_id, evt.sender, evt.content.replacement_room, evt.event_id
|
|
||||||
)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
from .deduplication import PortalDedup
|
|
||||||
from .message_convert import ConvertedMessage, TelegramMessageConverter
|
|
||||||
from .participants import get_users
|
|
||||||
from .power_levels import get_base_power_levels, participants_to_power_levels
|
|
||||||
from .send_lock import PortalReactionLock, PortalSendLock
|
|
||||||
from .sponsored_message import get_sponsored_message, make_sponsored_message_content
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any, Generator, Tuple, Union
|
|
||||||
from collections import deque
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from telethon.tl.patched import Message, MessageService
|
|
||||||
from telethon.tl.types import (
|
|
||||||
Message,
|
|
||||||
MessageMediaContact,
|
|
||||||
MessageMediaDice,
|
|
||||||
MessageMediaDocument,
|
|
||||||
MessageMediaGame,
|
|
||||||
MessageMediaGeo,
|
|
||||||
MessageMediaPhoto,
|
|
||||||
MessageMediaPoll,
|
|
||||||
MessageMediaUnsupported,
|
|
||||||
MessageService,
|
|
||||||
PeerChannel,
|
|
||||||
PeerChat,
|
|
||||||
PeerUser,
|
|
||||||
TypeUpdates,
|
|
||||||
UpdateNewChannelMessage,
|
|
||||||
UpdateNewMessage,
|
|
||||||
UpdateShortChatMessage,
|
|
||||||
UpdateShortMessage,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from .. import portal as po
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
DedupMXID = Tuple[EventID, TelegramID]
|
|
||||||
TypeMessage = Union[Message, MessageService, UpdateShortMessage, UpdateShortChatMessage]
|
|
||||||
|
|
||||||
media_content_table = {
|
|
||||||
MessageMediaContact: lambda media: [media.user_id],
|
|
||||||
MessageMediaDocument: lambda media: [media.document.id],
|
|
||||||
MessageMediaPhoto: lambda media: [media.photo.id if media.photo else 0],
|
|
||||||
MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
|
|
||||||
MessageMediaGame: lambda media: [media.game.id],
|
|
||||||
MessageMediaPoll: lambda media: [media.poll.id],
|
|
||||||
MessageMediaDice: lambda media: [media.value, media.emoticon],
|
|
||||||
MessageMediaUnsupported: lambda media: ["unsupported media"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PortalDedup:
|
|
||||||
cache_queue_length: int = 256
|
|
||||||
|
|
||||||
_dedup: deque[bytes | int]
|
|
||||||
_dedup_mxid: dict[bytes | int, DedupMXID]
|
|
||||||
_dedup_action: deque[bytes | int]
|
|
||||||
_portal: po.Portal
|
|
||||||
|
|
||||||
def __init__(self, portal: po.Portal) -> None:
|
|
||||||
self._dedup = deque()
|
|
||||||
self._dedup_mxid = {}
|
|
||||||
self._dedup_action = deque(maxlen=self.cache_queue_length)
|
|
||||||
self._portal = portal
|
|
||||||
|
|
||||||
@property
|
|
||||||
def _always_force_hash(self) -> bool:
|
|
||||||
return self._portal.peer_type == "chat"
|
|
||||||
|
|
||||||
def _hash_content(self, event: TypeMessage) -> Generator[Any, None, None]:
|
|
||||||
if not self._always_force_hash:
|
|
||||||
yield event.id
|
|
||||||
yield int(event.date.timestamp())
|
|
||||||
if isinstance(event, MessageService):
|
|
||||||
yield event.from_id
|
|
||||||
yield event.action
|
|
||||||
else:
|
|
||||||
yield event.message.strip()
|
|
||||||
if event.fwd_from:
|
|
||||||
yield event.fwd_from.from_id
|
|
||||||
if isinstance(event, Message) and event.media:
|
|
||||||
media_hash_func = media_content_table.get(type(event.media)) or (
|
|
||||||
lambda media: ["unknown media"]
|
|
||||||
)
|
|
||||||
yield media_hash_func(event.media)
|
|
||||||
|
|
||||||
def hash_event(self, event: TypeMessage) -> bytes:
|
|
||||||
return hashlib.sha256(
|
|
||||||
"-".join(str(a) for a in self._hash_content(event)).encode("utf-8")
|
|
||||||
).digest()
|
|
||||||
|
|
||||||
def check_action(self, event: TypeMessage) -> bool:
|
|
||||||
dedup_id = self.hash_event(event) if self._always_force_hash else event.id
|
|
||||||
if dedup_id in self._dedup_action:
|
|
||||||
return True
|
|
||||||
|
|
||||||
self._dedup_action.appendleft(dedup_id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def update(
|
|
||||||
self,
|
|
||||||
event: TypeMessage,
|
|
||||||
mxid: DedupMXID = None,
|
|
||||||
expected_mxid: DedupMXID | None = None,
|
|
||||||
force_hash: bool = False,
|
|
||||||
) -> tuple[bytes, DedupMXID | None]:
|
|
||||||
evt_hash = self.hash_event(event)
|
|
||||||
dedup_id = evt_hash if self._always_force_hash or force_hash else event.id
|
|
||||||
try:
|
|
||||||
found_mxid = self._dedup_mxid[dedup_id]
|
|
||||||
except KeyError:
|
|
||||||
return evt_hash, None
|
|
||||||
|
|
||||||
if found_mxid != expected_mxid:
|
|
||||||
return evt_hash, found_mxid
|
|
||||||
self._dedup_mxid[dedup_id] = mxid
|
|
||||||
if evt_hash != dedup_id:
|
|
||||||
self._dedup_mxid[evt_hash] = mxid
|
|
||||||
return evt_hash, None
|
|
||||||
|
|
||||||
def check(
|
|
||||||
self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False
|
|
||||||
) -> tuple[bytes, DedupMXID | None]:
|
|
||||||
evt_hash = self.hash_event(event)
|
|
||||||
dedup_id = evt_hash if self._always_force_hash or force_hash else event.id
|
|
||||||
if dedup_id in self._dedup:
|
|
||||||
return evt_hash, self._dedup_mxid[dedup_id]
|
|
||||||
|
|
||||||
self._dedup_mxid[dedup_id] = mxid
|
|
||||||
self._dedup.appendleft(dedup_id)
|
|
||||||
if evt_hash != dedup_id:
|
|
||||||
self._dedup_mxid[evt_hash] = mxid
|
|
||||||
self._dedup.appendleft(evt_hash)
|
|
||||||
|
|
||||||
while len(self._dedup) > self.cache_queue_length:
|
|
||||||
del self._dedup_mxid[self._dedup.pop()]
|
|
||||||
return evt_hash, None
|
|
||||||
|
|
||||||
def register_outgoing_actions(self, response: TypeUpdates) -> None:
|
|
||||||
for update in response.updates:
|
|
||||||
check_dedup = isinstance(
|
|
||||||
update, (UpdateNewMessage, UpdateNewChannelMessage)
|
|
||||||
) and isinstance(update.message, MessageService)
|
|
||||||
if check_dedup:
|
|
||||||
self.check(update.message)
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,109 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from telethon.errors import ChatAdminRequiredError
|
|
||||||
from telethon.tl.functions.channels import GetParticipantsRequest
|
|
||||||
from telethon.tl.functions.messages import GetFullChatRequest
|
|
||||||
from telethon.tl.types import (
|
|
||||||
ChannelParticipantBanned,
|
|
||||||
ChannelParticipantsRecent,
|
|
||||||
ChannelParticipantsSearch,
|
|
||||||
ChatParticipantsForbidden,
|
|
||||||
InputChannel,
|
|
||||||
InputUser,
|
|
||||||
TypeChannelParticipant,
|
|
||||||
TypeChat,
|
|
||||||
TypeChatParticipant,
|
|
||||||
TypeInputPeer,
|
|
||||||
TypeUser,
|
|
||||||
)
|
|
||||||
|
|
||||||
from ..tgclient import MautrixTelegramClient
|
|
||||||
|
|
||||||
|
|
||||||
def _filter_participants(
|
|
||||||
users: list[TypeUser], participants: list[TypeChatParticipant | TypeChannelParticipant]
|
|
||||||
) -> Iterable[TypeUser]:
|
|
||||||
participant_map = {
|
|
||||||
part.user_id: part
|
|
||||||
for part in participants
|
|
||||||
if not isinstance(part, ChannelParticipantBanned)
|
|
||||||
}
|
|
||||||
for user in users:
|
|
||||||
try:
|
|
||||||
user.participant = participant_map[user.id]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
yield user
|
|
||||||
|
|
||||||
|
|
||||||
async def _get_channel_users(
|
|
||||||
client: MautrixTelegramClient, entity: InputChannel, limit: int
|
|
||||||
) -> list[TypeUser]:
|
|
||||||
if 0 < limit <= 200:
|
|
||||||
response = await client(
|
|
||||||
GetParticipantsRequest(
|
|
||||||
entity, ChannelParticipantsRecent(), offset=0, limit=limit, hash=0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return list(_filter_participants(response.users, response.participants))
|
|
||||||
elif limit > 200 or limit == -1:
|
|
||||||
users: list[TypeUser] = []
|
|
||||||
offset = 0
|
|
||||||
remaining_quota = limit if limit > 0 else 1000000
|
|
||||||
query = ChannelParticipantsSearch("") if limit == -1 else ChannelParticipantsRecent()
|
|
||||||
while True:
|
|
||||||
if remaining_quota <= 0:
|
|
||||||
break
|
|
||||||
response = await client(
|
|
||||||
GetParticipantsRequest(
|
|
||||||
entity, query, offset=offset, limit=min(remaining_quota, 200), hash=0
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not response.users:
|
|
||||||
break
|
|
||||||
users += _filter_participants(response.users, response.participants)
|
|
||||||
offset += len(response.participants)
|
|
||||||
remaining_quota -= len(response.participants)
|
|
||||||
return users
|
|
||||||
|
|
||||||
|
|
||||||
async def get_users(
|
|
||||||
client: MautrixTelegramClient,
|
|
||||||
tgid: int,
|
|
||||||
entity: TypeInputPeer | InputUser | TypeChat | TypeUser | InputChannel,
|
|
||||||
limit: int,
|
|
||||||
peer_type: str,
|
|
||||||
) -> list[TypeUser]:
|
|
||||||
if peer_type == "chat":
|
|
||||||
chat = await client(GetFullChatRequest(chat_id=tgid))
|
|
||||||
if isinstance(chat.full_chat.participants, ChatParticipantsForbidden):
|
|
||||||
return []
|
|
||||||
users = list(_filter_participants(chat.users, chat.full_chat.participants.participants))
|
|
||||||
return users[:limit] if limit > 0 else users
|
|
||||||
elif peer_type == "channel":
|
|
||||||
try:
|
|
||||||
return await _get_channel_users(client, entity, limit)
|
|
||||||
except ChatAdminRequiredError:
|
|
||||||
return []
|
|
||||||
elif peer_type == "user":
|
|
||||||
return [entity]
|
|
||||||
else:
|
|
||||||
raise RuntimeError(f"Unexpected peer type {peer_type}")
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from telethon.tl.types import (
|
|
||||||
ChannelParticipantAdmin,
|
|
||||||
ChannelParticipantCreator,
|
|
||||||
ChatBannedRights,
|
|
||||||
ChatParticipantAdmin,
|
|
||||||
ChatParticipantCreator,
|
|
||||||
TypeChannelParticipant,
|
|
||||||
TypeChat,
|
|
||||||
TypeChatParticipant,
|
|
||||||
TypeUser,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.types import EventType, PowerLevelStateEventContent as PowerLevelContent, UserID
|
|
||||||
|
|
||||||
from .. import portal as po, puppet as pu, user as u
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
|
|
||||||
def get_base_power_levels(
|
|
||||||
portal: po.Portal,
|
|
||||||
levels: PowerLevelContent = None,
|
|
||||||
entity: TypeChat | None = None,
|
|
||||||
dbr: ChatBannedRights | None = None,
|
|
||||||
) -> PowerLevelContent:
|
|
||||||
is_initial = not levels
|
|
||||||
levels = levels or PowerLevelContent()
|
|
||||||
if portal.peer_type == "user":
|
|
||||||
overrides = portal.config["bridge.initial_power_level_overrides.user"]
|
|
||||||
levels.ban = overrides.get("ban", 100)
|
|
||||||
levels.kick = overrides.get("kick", 100)
|
|
||||||
levels.invite = overrides.get("invite", 100)
|
|
||||||
levels.redact = overrides.get("redact", 0)
|
|
||||||
levels.events[EventType.ROOM_NAME] = 0
|
|
||||||
levels.events[EventType.ROOM_AVATAR] = 0
|
|
||||||
levels.events[EventType.ROOM_TOPIC] = 0
|
|
||||||
levels.state_default = overrides.get("state_default", 0)
|
|
||||||
levels.users_default = overrides.get("users_default", 0)
|
|
||||||
levels.events_default = overrides.get("events_default", 0)
|
|
||||||
else:
|
|
||||||
overrides = portal.config["bridge.initial_power_level_overrides.group"]
|
|
||||||
dbr = dbr or entity.default_banned_rights
|
|
||||||
if not dbr:
|
|
||||||
portal.log.debug(f"default_banned_rights is None in {entity}")
|
|
||||||
dbr = ChatBannedRights(
|
|
||||||
invite_users=True,
|
|
||||||
change_info=True,
|
|
||||||
pin_messages=True,
|
|
||||||
send_stickers=False,
|
|
||||||
send_messages=False,
|
|
||||||
until_date=None,
|
|
||||||
)
|
|
||||||
levels.ban = overrides.get("ban", 50)
|
|
||||||
levels.kick = overrides.get("kick", 50)
|
|
||||||
levels.redact = overrides.get("redact", 50)
|
|
||||||
levels.invite = overrides.get("invite", 50 if dbr.invite_users else 0)
|
|
||||||
levels.events[EventType.ROOM_ENCRYPTION] = 50 if portal.matrix.e2ee else 99
|
|
||||||
levels.events[EventType.ROOM_TOMBSTONE] = 99
|
|
||||||
levels.events[EventType.ROOM_NAME] = 50 if dbr.change_info else 0
|
|
||||||
levels.events[EventType.ROOM_AVATAR] = 50 if dbr.change_info else 0
|
|
||||||
levels.events[EventType.ROOM_TOPIC] = 50 if dbr.change_info else 0
|
|
||||||
levels.events[EventType.ROOM_PINNED_EVENTS] = 50 if dbr.pin_messages else 0
|
|
||||||
levels.events[EventType.ROOM_POWER_LEVELS] = 75
|
|
||||||
levels.events[EventType.ROOM_HISTORY_VISIBILITY] = 75
|
|
||||||
levels.events[EventType.STICKER] = 50 if dbr.send_stickers else levels.events_default
|
|
||||||
levels.state_default = overrides.get("state_default", 50)
|
|
||||||
levels.users_default = overrides.get("users_default", 0)
|
|
||||||
levels.events_default = overrides.get(
|
|
||||||
"events_default",
|
|
||||||
(
|
|
||||||
50
|
|
||||||
if portal.peer_type == "channel" and not portal.megagroup or dbr.send_messages
|
|
||||||
else 0
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for evt_type, value in overrides.get("events", {}).items():
|
|
||||||
levels.events[EventType.find(evt_type)] = value
|
|
||||||
userlevel_overrides = overrides.get("users", {})
|
|
||||||
if is_initial:
|
|
||||||
levels.users.update(userlevel_overrides)
|
|
||||||
if portal.main_intent.mxid not in levels.users:
|
|
||||||
levels.users[portal.main_intent.mxid] = 100
|
|
||||||
return levels
|
|
||||||
|
|
||||||
|
|
||||||
async def participants_to_power_levels(
|
|
||||||
portal: po.Portal,
|
|
||||||
users: list[TypeUser | TypeChatParticipant | TypeChannelParticipant],
|
|
||||||
levels: PowerLevelContent,
|
|
||||||
) -> bool:
|
|
||||||
bot_level = levels.get_user_level(portal.main_intent.mxid)
|
|
||||||
if bot_level < levels.get_event_level(EventType.ROOM_POWER_LEVELS):
|
|
||||||
return False
|
|
||||||
changed = False
|
|
||||||
admin_power_level = min(75 if portal.peer_type == "channel" else 50, bot_level)
|
|
||||||
if levels.get_event_level(EventType.ROOM_POWER_LEVELS) != admin_power_level:
|
|
||||||
changed = True
|
|
||||||
levels.events[EventType.ROOM_POWER_LEVELS] = admin_power_level
|
|
||||||
|
|
||||||
for user in users:
|
|
||||||
# The User objects we get from TelegramClient.get_participants have a custom
|
|
||||||
# participant property
|
|
||||||
participant = getattr(user, "participant", user)
|
|
||||||
|
|
||||||
puppet = await pu.Puppet.get_by_tgid(TelegramID(participant.user_id))
|
|
||||||
user = await u.User.get_by_tgid(TelegramID(participant.user_id))
|
|
||||||
new_level = _get_level_from_participant(portal.az.bot_mxid, participant, levels)
|
|
||||||
|
|
||||||
if user:
|
|
||||||
await user.register_portal(portal)
|
|
||||||
changed = _participant_to_power_levels(levels, user, new_level, bot_level) or changed
|
|
||||||
|
|
||||||
if puppet:
|
|
||||||
changed = _participant_to_power_levels(levels, puppet, new_level, bot_level) or changed
|
|
||||||
return changed
|
|
||||||
|
|
||||||
|
|
||||||
def _get_level_from_participant(
|
|
||||||
bot_mxid: UserID,
|
|
||||||
participant: TypeUser | TypeChatParticipant | TypeChannelParticipant,
|
|
||||||
levels: PowerLevelContent,
|
|
||||||
) -> int:
|
|
||||||
# TODO use the power level requirements to get better precision in channels
|
|
||||||
if isinstance(participant, (ChatParticipantAdmin, ChannelParticipantAdmin)):
|
|
||||||
return levels.state_default or 50
|
|
||||||
elif isinstance(participant, (ChatParticipantCreator, ChannelParticipantCreator)):
|
|
||||||
return levels.get_user_level(bot_mxid) - 5
|
|
||||||
return levels.users_default or 0
|
|
||||||
|
|
||||||
|
|
||||||
def _participant_to_power_levels(
|
|
||||||
levels: PowerLevelContent,
|
|
||||||
user: u.User | pu.Puppet,
|
|
||||||
new_level: int,
|
|
||||||
bot_level: int,
|
|
||||||
) -> bool:
|
|
||||||
new_level = min(new_level, bot_level)
|
|
||||||
user_level = levels.get_user_level(user.mxid)
|
|
||||||
if user_level != new_level and user_level < bot_level:
|
|
||||||
levels.users[user.mxid] = new_level
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from asyncio import Lock
|
|
||||||
from collections import defaultdict
|
|
||||||
|
|
||||||
from mautrix.types import EventID
|
|
||||||
|
|
||||||
from ..types import TelegramID
|
|
||||||
|
|
||||||
|
|
||||||
class FakeLock:
|
|
||||||
async def __aenter__(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class PortalSendLock:
|
|
||||||
_send_locks: dict[int, Lock]
|
|
||||||
_noop_lock: Lock = FakeLock()
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._send_locks = {}
|
|
||||||
|
|
||||||
def __call__(self, user_id: TelegramID, required: bool = True) -> Lock:
|
|
||||||
if user_id is None and required:
|
|
||||||
raise ValueError("Required send lock for none id")
|
|
||||||
try:
|
|
||||||
return self._send_locks[user_id]
|
|
||||||
except KeyError:
|
|
||||||
return self._send_locks.setdefault(user_id, Lock()) if required else self._noop_lock
|
|
||||||
|
|
||||||
|
|
||||||
class PortalReactionLock:
|
|
||||||
_reaction_locks: dict[EventID, Lock]
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self._reaction_locks = defaultdict(lambda: Lock())
|
|
||||||
|
|
||||||
def __call__(self, mxid: EventID) -> Lock:
|
|
||||||
return self._reaction_locks[mxid]
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import html
|
|
||||||
|
|
||||||
from telethon.tl.functions.channels import GetSponsoredMessagesRequest
|
|
||||||
from telethon.tl.types import Channel, InputChannel, PeerChannel, PeerUser, SponsoredMessage, User
|
|
||||||
from telethon.tl.types.messages import SponsoredMessages, SponsoredMessagesEmpty
|
|
||||||
|
|
||||||
from mautrix.types import MessageType, TextMessageEventContent
|
|
||||||
|
|
||||||
from .. import user as u
|
|
||||||
from ..formatter import telegram_to_matrix
|
|
||||||
|
|
||||||
|
|
||||||
async def get_sponsored_message(
|
|
||||||
user: u.User,
|
|
||||||
entity: InputChannel,
|
|
||||||
) -> tuple[SponsoredMessage | None, int | None, Channel | User | None]:
|
|
||||||
resp = await user.client(GetSponsoredMessagesRequest(entity))
|
|
||||||
if isinstance(resp, SponsoredMessagesEmpty):
|
|
||||||
return None, None, None
|
|
||||||
assert isinstance(resp, SponsoredMessages)
|
|
||||||
msg = resp.messages[0]
|
|
||||||
if isinstance(msg.from_id, PeerUser):
|
|
||||||
entities = resp.users
|
|
||||||
target_id = msg.from_id.user_id
|
|
||||||
else:
|
|
||||||
entities = resp.chats
|
|
||||||
target_id = msg.from_id.channel_id
|
|
||||||
try:
|
|
||||||
entity = next(ent for ent in entities if ent.id == target_id)
|
|
||||||
except StopIteration:
|
|
||||||
entity = None
|
|
||||||
return msg, target_id, entity
|
|
||||||
|
|
||||||
|
|
||||||
async def make_sponsored_message_content(
|
|
||||||
source: u.User, msg: SponsoredMessage, entity: Channel | User
|
|
||||||
) -> TextMessageEventContent | None:
|
|
||||||
content = await telegram_to_matrix(msg, source, require_html=True)
|
|
||||||
content.external_url = f"https://t.me/{entity.username}"
|
|
||||||
content.msgtype = MessageType.NOTICE
|
|
||||||
sponsored_meta = {
|
|
||||||
"random_id": base64.b64encode(msg.random_id).decode("utf-8"),
|
|
||||||
}
|
|
||||||
if isinstance(msg.from_id, PeerChannel):
|
|
||||||
sponsored_meta["channel_id"] = msg.from_id.channel_id
|
|
||||||
if getattr(msg, "channel_post", None) is not None:
|
|
||||||
sponsored_meta["channel_post"] = msg.channel_post
|
|
||||||
content.external_url += f"/{msg.channel_post}"
|
|
||||||
action = "View Post"
|
|
||||||
else:
|
|
||||||
action = "View Channel"
|
|
||||||
elif isinstance(msg.from_id, PeerUser):
|
|
||||||
sponsored_meta["bot_id"] = msg.from_id.user_id
|
|
||||||
if msg.start_param:
|
|
||||||
content.external_url += f"?start={msg.start_param}"
|
|
||||||
action = "View Bot"
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(entity, User):
|
|
||||||
name_parts = [entity.first_name, entity.last_name]
|
|
||||||
sponsor_name = " ".join(x for x in name_parts if x)
|
|
||||||
sponsor_name_html = f"<strong>{html.escape(sponsor_name)}</strong>"
|
|
||||||
elif isinstance(entity, Channel):
|
|
||||||
sponsor_name = entity.title
|
|
||||||
sponsor_name_html = f"<strong>{html.escape(sponsor_name)}</strong>"
|
|
||||||
else:
|
|
||||||
sponsor_name = sponsor_name_html = "unknown entity"
|
|
||||||
|
|
||||||
content["fi.mau.telegram.sponsored"] = sponsored_meta
|
|
||||||
content.formatted_body += (
|
|
||||||
f"<br/><br/>Sponsored message from {sponsor_name_html} "
|
|
||||||
f"- <a href='{content.external_url}'>{action}</a>"
|
|
||||||
)
|
|
||||||
content.body += (
|
|
||||||
f"\n\nSponsored message from {sponsor_name} - {action} at {content.external_url}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return content
|
|
||||||
@@ -1,601 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, AsyncGenerator, AsyncIterable, Awaitable, cast
|
|
||||||
from difflib import SequenceMatcher
|
|
||||||
import unicodedata
|
|
||||||
|
|
||||||
from telethon import utils
|
|
||||||
from telethon.tl.types import (
|
|
||||||
Channel,
|
|
||||||
ChatPhoto,
|
|
||||||
ChatPhotoEmpty,
|
|
||||||
InputPeerPhotoFileLocation,
|
|
||||||
InputPeerUser,
|
|
||||||
PeerChannel,
|
|
||||||
PeerChat,
|
|
||||||
PeerUser,
|
|
||||||
TypeChatPhoto,
|
|
||||||
TypePeer,
|
|
||||||
TypeUserProfilePhoto,
|
|
||||||
UpdateUserName,
|
|
||||||
User,
|
|
||||||
UserProfilePhoto,
|
|
||||||
UserProfilePhotoEmpty,
|
|
||||||
)
|
|
||||||
from yarl import URL
|
|
||||||
|
|
||||||
from mautrix.appservice import IntentAPI
|
|
||||||
from mautrix.bridge import BasePuppet, async_getter_lock
|
|
||||||
from mautrix.types import ContentURI, RoomID, SyncToken, UserID
|
|
||||||
from mautrix.util.simple_template import SimpleTemplate
|
|
||||||
|
|
||||||
from . import abstract_user as au, portal as p, util
|
|
||||||
from .config import Config
|
|
||||||
from .db import Puppet as DBPuppet
|
|
||||||
from .tgclient import MautrixTelegramClient
|
|
||||||
from .types import TelegramID
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .__main__ import TelegramBridge
|
|
||||||
|
|
||||||
|
|
||||||
class Puppet(DBPuppet, BasePuppet):
|
|
||||||
bridge: TelegramBridge
|
|
||||||
config: Config
|
|
||||||
hs_domain: str
|
|
||||||
mxid_template: SimpleTemplate[TelegramID]
|
|
||||||
displayname_template: SimpleTemplate[str]
|
|
||||||
|
|
||||||
by_tgid: dict[TelegramID, Puppet] = {}
|
|
||||||
by_custom_mxid: dict[UserID, Puppet] = {}
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
id: TelegramID,
|
|
||||||
is_registered: bool = False,
|
|
||||||
displayname: str | None = None,
|
|
||||||
displayname_source: TelegramID | None = None,
|
|
||||||
displayname_contact: bool = True,
|
|
||||||
displayname_quality: int = 0,
|
|
||||||
disable_updates: bool = False,
|
|
||||||
username: str | None = None,
|
|
||||||
phone: str | None = None,
|
|
||||||
photo_id: str | None = None,
|
|
||||||
avatar_url: ContentURI | None = None,
|
|
||||||
name_set: bool = False,
|
|
||||||
avatar_set: bool = False,
|
|
||||||
contact_info_set: bool = False,
|
|
||||||
is_bot: bool = False,
|
|
||||||
is_channel: bool = False,
|
|
||||||
is_premium: bool = False,
|
|
||||||
custom_mxid: UserID | None = None,
|
|
||||||
access_token: str | None = None,
|
|
||||||
next_batch: SyncToken | None = None,
|
|
||||||
base_url: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
id=id,
|
|
||||||
is_registered=is_registered,
|
|
||||||
displayname=displayname,
|
|
||||||
displayname_source=displayname_source,
|
|
||||||
displayname_contact=displayname_contact,
|
|
||||||
displayname_quality=displayname_quality,
|
|
||||||
disable_updates=disable_updates,
|
|
||||||
username=username,
|
|
||||||
phone=phone,
|
|
||||||
photo_id=photo_id,
|
|
||||||
avatar_url=avatar_url,
|
|
||||||
name_set=name_set,
|
|
||||||
avatar_set=avatar_set,
|
|
||||||
contact_info_set=contact_info_set,
|
|
||||||
is_bot=is_bot,
|
|
||||||
is_channel=is_channel,
|
|
||||||
is_premium=is_premium,
|
|
||||||
custom_mxid=custom_mxid,
|
|
||||||
access_token=access_token,
|
|
||||||
next_batch=next_batch,
|
|
||||||
base_url=base_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.default_mxid = self.get_mxid_from_id(self.id)
|
|
||||||
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
|
|
||||||
self.intent = self._fresh_intent()
|
|
||||||
|
|
||||||
self.by_tgid[id] = self
|
|
||||||
if self.custom_mxid:
|
|
||||||
self.by_custom_mxid[self.custom_mxid] = self
|
|
||||||
|
|
||||||
self.log = self.log.getChild(str(self.id))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tgid(self) -> TelegramID:
|
|
||||||
return self.id
|
|
||||||
|
|
||||||
@property
|
|
||||||
def tg_username(self) -> str | None:
|
|
||||||
return self.username
|
|
||||||
|
|
||||||
@property
|
|
||||||
def peer(self) -> PeerUser:
|
|
||||||
return (
|
|
||||||
PeerChannel(channel_id=self.tgid) if self.is_channel else PeerUser(user_id=self.tgid)
|
|
||||||
)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def contact_info(self) -> dict:
|
|
||||||
return {
|
|
||||||
"name": self.displayname,
|
|
||||||
"username": self.username,
|
|
||||||
"phone": f"+{self.phone.lstrip('+')}" if self.phone else None,
|
|
||||||
"is_bot": self.is_bot,
|
|
||||||
"avatar_url": self.avatar_url,
|
|
||||||
}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def plain_displayname(self) -> str:
|
|
||||||
return self.displayname_template.parse(self.displayname) or self.displayname
|
|
||||||
|
|
||||||
def intent_for(self, portal: p.Portal) -> IntentAPI:
|
|
||||||
if portal.tgid == self.tgid:
|
|
||||||
return self.default_mxid_intent
|
|
||||||
return self.intent
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
|
|
||||||
cls.bridge = bridge
|
|
||||||
cls.config = bridge.config
|
|
||||||
cls.loop = bridge.loop
|
|
||||||
cls.mx = bridge.matrix
|
|
||||||
cls.az = bridge.az
|
|
||||||
cls.hs_domain = cls.config["homeserver.domain"]
|
|
||||||
mxid_tpl = SimpleTemplate(
|
|
||||||
cls.config["bridge.username_template"],
|
|
||||||
"userid",
|
|
||||||
prefix="@",
|
|
||||||
suffix=f":{Puppet.hs_domain}",
|
|
||||||
type=int,
|
|
||||||
)
|
|
||||||
cls.mxid_template = cast(SimpleTemplate[TelegramID], mxid_tpl)
|
|
||||||
cls.displayname_template = SimpleTemplate(
|
|
||||||
cls.config["bridge.displayname_template"], "displayname"
|
|
||||||
)
|
|
||||||
cls.sync_with_custom_puppets = cls.config["bridge.sync_with_custom_puppets"]
|
|
||||||
cls.homeserver_url_map = {
|
|
||||||
server: URL(url)
|
|
||||||
for server, url in cls.config["bridge.double_puppet_server_map"].items()
|
|
||||||
}
|
|
||||||
cls.allow_discover_url = cls.config["bridge.double_puppet_allow_discovery"]
|
|
||||||
cls.login_shared_secret_map = {
|
|
||||||
server: secret.encode("utf-8")
|
|
||||||
for server, secret in cls.config["bridge.login_shared_secret_map"].items()
|
|
||||||
}
|
|
||||||
cls.login_device_name = "Telegram Bridge"
|
|
||||||
|
|
||||||
return (puppet.try_start() async for puppet in cls.all_with_custom_mxid())
|
|
||||||
|
|
||||||
# region Info updating
|
|
||||||
|
|
||||||
def similarity(self, query: str) -> int:
|
|
||||||
username_similarity = (
|
|
||||||
SequenceMatcher(None, self.username, query).ratio() if self.username else 0
|
|
||||||
)
|
|
||||||
displayname_similarity = (
|
|
||||||
SequenceMatcher(None, self.plain_displayname, query).ratio() if self.displayname else 0
|
|
||||||
)
|
|
||||||
similarity = max(username_similarity, displayname_similarity)
|
|
||||||
return int(round(similarity * 100))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _filter_name(name: str) -> str:
|
|
||||||
if not name:
|
|
||||||
return ""
|
|
||||||
whitespace = (
|
|
||||||
"\t\n\r\v\f \u00a0\u034f\u180e\u2063\u202f\u205f\u2800\u3000\u3164\ufeff\u2000\u2001"
|
|
||||||
"\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u200b\u200c\u200d\u200e\u200f"
|
|
||||||
"\ufe0f"
|
|
||||||
)
|
|
||||||
allowed_other_format = ("\u200d", "\u200c")
|
|
||||||
name = "".join(
|
|
||||||
c
|
|
||||||
for c in name.strip(whitespace)
|
|
||||||
if unicodedata.category(c) != "Cf" or c in allowed_other_format
|
|
||||||
)
|
|
||||||
return name
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_displayname(cls, info: User | Channel, enable_format: bool = True) -> tuple[str, int]:
|
|
||||||
if isinstance(info, Channel):
|
|
||||||
fn, ln = cls._filter_name(info.title), ""
|
|
||||||
else:
|
|
||||||
fn = cls._filter_name(info.first_name)
|
|
||||||
ln = cls._filter_name(info.last_name)
|
|
||||||
data = {
|
|
||||||
"phone number": info.phone if hasattr(info, "phone") else None,
|
|
||||||
"username": info.username,
|
|
||||||
"full name": " ".join([fn, ln]).strip(),
|
|
||||||
"full name reversed": " ".join([ln, fn]).strip(),
|
|
||||||
"first name": fn,
|
|
||||||
"last name": ln,
|
|
||||||
}
|
|
||||||
preferences = cls.config["bridge.displayname_preference"]
|
|
||||||
name = None
|
|
||||||
quality = 99
|
|
||||||
for preference in preferences:
|
|
||||||
name = data[preference]
|
|
||||||
if name:
|
|
||||||
break
|
|
||||||
quality -= 1
|
|
||||||
|
|
||||||
if isinstance(info, User) and info.deleted:
|
|
||||||
name = f"Deleted account {info.id}"
|
|
||||||
quality = 99
|
|
||||||
elif not name:
|
|
||||||
name = str(info.id)
|
|
||||||
quality = 0
|
|
||||||
|
|
||||||
return (cls.displayname_template.format_full(name) if enable_format else name), quality
|
|
||||||
|
|
||||||
async def try_update_info(self, source: au.AbstractUser, info: User | Channel) -> None:
|
|
||||||
try:
|
|
||||||
await self.update_info(source, info)
|
|
||||||
except Exception:
|
|
||||||
source.log.exception(f"Failed to update info of {self.tgid}")
|
|
||||||
|
|
||||||
async def update_info(
|
|
||||||
self,
|
|
||||||
source: au.AbstractUser,
|
|
||||||
info: User | Channel,
|
|
||||||
client_override: MautrixTelegramClient | None = None,
|
|
||||||
) -> None:
|
|
||||||
is_bot = False if isinstance(info, Channel) else info.bot
|
|
||||||
is_premium = False if isinstance(info, Channel) else info.premium
|
|
||||||
is_channel = isinstance(info, Channel)
|
|
||||||
changed = (
|
|
||||||
is_bot != self.is_bot or is_channel != self.is_channel or is_premium != self.is_premium
|
|
||||||
)
|
|
||||||
|
|
||||||
if is_bot is not None:
|
|
||||||
self.is_bot = is_bot
|
|
||||||
self.is_channel = is_channel
|
|
||||||
if is_premium is not None:
|
|
||||||
self.is_premium = is_premium
|
|
||||||
|
|
||||||
if self.username != info.username and (info.username or not info.min):
|
|
||||||
self.log.debug(f"Updating username {self.username} -> {info.username}")
|
|
||||||
self.username = info.username
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if getattr(info, "phone", None) and self.phone != info.phone:
|
|
||||||
self.phone = info.phone
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if not self.disable_updates:
|
|
||||||
try:
|
|
||||||
changed = await self._update_contact_info(force=changed) or changed
|
|
||||||
|
|
||||||
changed = (
|
|
||||||
await self.update_displayname(source, info, client_override=client_override)
|
|
||||||
or changed
|
|
||||||
)
|
|
||||||
changed = (
|
|
||||||
await self.update_avatar(
|
|
||||||
source, info.photo, entity=info, client_override=client_override
|
|
||||||
)
|
|
||||||
or changed
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self.log.exception(f"Failed to update info from source {source.tgid}")
|
|
||||||
|
|
||||||
if changed:
|
|
||||||
await self.update_portals_meta()
|
|
||||||
await self.save()
|
|
||||||
|
|
||||||
async def _update_contact_info(self, force: bool = False) -> bool:
|
|
||||||
if not self.bridge.homeserver_software.is_hungry:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if self.contact_info_set and not force:
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
identifiers = []
|
|
||||||
if self.username:
|
|
||||||
identifiers.append(f"telegram:{self.username}")
|
|
||||||
if self.phone:
|
|
||||||
phone = "+" + self.phone.lstrip("+")
|
|
||||||
identifiers.append(f"tel:{phone}")
|
|
||||||
await self.default_mxid_intent.beeper_update_profile(
|
|
||||||
{
|
|
||||||
"com.beeper.bridge.identifiers": identifiers,
|
|
||||||
"com.beeper.bridge.remote_id": str(self.tgid),
|
|
||||||
"com.beeper.bridge.service": "telegram",
|
|
||||||
"com.beeper.bridge.network": "telegram",
|
|
||||||
"com.beeper.bridge.is_network_bot": self.is_bot,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
self.contact_info_set = True
|
|
||||||
except Exception:
|
|
||||||
self.log.exception("Error updating contact info")
|
|
||||||
self.contact_info_set = False
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def update_portals_meta(self) -> None:
|
|
||||||
if p.Portal.private_chat_portal_meta != "always" and not self.mx.e2ee:
|
|
||||||
return
|
|
||||||
async for portal in p.Portal.find_private_chats_with(self.tgid):
|
|
||||||
await portal.update_info_from_puppet(self)
|
|
||||||
|
|
||||||
async def update_displayname(
|
|
||||||
self,
|
|
||||||
source: au.AbstractUser,
|
|
||||||
info: User | Channel | UpdateUserName,
|
|
||||||
client_override: MautrixTelegramClient | None = None,
|
|
||||||
) -> bool:
|
|
||||||
if self.disable_updates:
|
|
||||||
return False
|
|
||||||
if (
|
|
||||||
self.displayname
|
|
||||||
and self.displayname.startswith("Deleted user ")
|
|
||||||
and not getattr(info, "deleted", False)
|
|
||||||
):
|
|
||||||
allow_because = "target user was previously deleted"
|
|
||||||
self.displayname_quality = 0
|
|
||||||
elif source.is_relaybot or source.is_bot:
|
|
||||||
allow_because = "source user is a bot"
|
|
||||||
elif self.displayname_source == source.tgid:
|
|
||||||
allow_because = "source user is the primary source"
|
|
||||||
elif isinstance(info, Channel):
|
|
||||||
allow_because = "target user is a channel"
|
|
||||||
elif not isinstance(info, UpdateUserName) and not info.contact:
|
|
||||||
allow_because = "target user is not a contact"
|
|
||||||
elif not self.displayname_source:
|
|
||||||
allow_because = "no primary source set"
|
|
||||||
elif not self.displayname:
|
|
||||||
allow_because = "target user has no name"
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if isinstance(info, UpdateUserName):
|
|
||||||
info = await (client_override or source.client).get_entity(self.peer)
|
|
||||||
is_contact_name = not isinstance(info, Channel) and info.contact
|
|
||||||
# Reject name change if the contact status is moving in an unwanted direction,
|
|
||||||
# and we already have a name for the ghost.
|
|
||||||
if (
|
|
||||||
is_contact_name != self.displayname_contact
|
|
||||||
and is_contact_name != self.config["bridge.allow_contact_info"]
|
|
||||||
and self.displayname
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
|
|
||||||
displayname, quality = self.get_displayname(info)
|
|
||||||
needs_reset = displayname != self.displayname or not self.name_set
|
|
||||||
is_high_quality = quality >= self.displayname_quality
|
|
||||||
if needs_reset and is_high_quality:
|
|
||||||
allow_because = f"{allow_because} and quality {quality} >= {self.displayname_quality}"
|
|
||||||
self.log.debug(
|
|
||||||
f"Updating displayname of {self.id} (src: {source.tgid}, "
|
|
||||||
f"contact: {is_contact_name}, allowed because {allow_because}) "
|
|
||||||
f"from {self.displayname} to {displayname}"
|
|
||||||
)
|
|
||||||
self.log.trace("Displayname source data: %s", info)
|
|
||||||
self.displayname = displayname
|
|
||||||
self.displayname_source = source.tgid
|
|
||||||
self.displayname_contact = is_contact_name
|
|
||||||
self.displayname_quality = quality
|
|
||||||
try:
|
|
||||||
await self.default_mxid_intent.set_displayname(
|
|
||||||
displayname[: self.config["bridge.displayname_max_length"]]
|
|
||||||
)
|
|
||||||
self.name_set = True
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to set displayname: {e}")
|
|
||||||
self.name_set = False
|
|
||||||
return True
|
|
||||||
elif source.is_relaybot or self.displayname_source is None:
|
|
||||||
self.displayname_source = source.tgid
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def update_avatar(
|
|
||||||
self,
|
|
||||||
source: au.AbstractUser,
|
|
||||||
photo: TypeUserProfilePhoto | TypeChatPhoto,
|
|
||||||
entity: User | None = None,
|
|
||||||
client_override: MautrixTelegramClient | None = None,
|
|
||||||
) -> bool:
|
|
||||||
if self.disable_updates:
|
|
||||||
return False
|
|
||||||
if (
|
|
||||||
isinstance(photo, UserProfilePhoto)
|
|
||||||
and photo.personal
|
|
||||||
and not self.config["bridge.allow_contact_info"]
|
|
||||||
):
|
|
||||||
self.log.trace(
|
|
||||||
"Dropping user avatar as it's personal "
|
|
||||||
"and contact info is disabled in bridge config"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
if photo is None or isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty)):
|
|
||||||
photo_id = ""
|
|
||||||
elif isinstance(photo, (UserProfilePhoto, ChatPhoto)):
|
|
||||||
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 self.config["bridge.allow_avatar_remove"]:
|
|
||||||
return False
|
|
||||||
if self.photo_id != photo_id or not self.avatar_set:
|
|
||||||
if not photo_id:
|
|
||||||
self.photo_id = ""
|
|
||||||
self.avatar_url = None
|
|
||||||
elif self.photo_id != photo_id or not self.avatar_url:
|
|
||||||
client = client_override or source.client
|
|
||||||
try:
|
|
||||||
peer = await client.get_input_entity(entity or self.peer)
|
|
||||||
except ValueError:
|
|
||||||
if entity:
|
|
||||||
peer = utils.get_input_peer(entity, check_hash=False)
|
|
||||||
else:
|
|
||||||
self.log.warning(f"Couldn't get input entity to update avatar")
|
|
||||||
return False
|
|
||||||
file = await util.transfer_file_to_matrix(
|
|
||||||
client=client,
|
|
||||||
intent=self.default_mxid_intent,
|
|
||||||
location=InputPeerPhotoFileLocation(
|
|
||||||
peer=peer,
|
|
||||||
photo_id=photo.photo_id,
|
|
||||||
big=True,
|
|
||||||
),
|
|
||||||
async_upload=self.config["homeserver.async_media"],
|
|
||||||
)
|
|
||||||
if not file:
|
|
||||||
return False
|
|
||||||
self.photo_id = photo_id
|
|
||||||
self.avatar_url = file.mxc
|
|
||||||
try:
|
|
||||||
await self.default_mxid_intent.set_avatar_url(self.avatar_url or "")
|
|
||||||
self.avatar_set = True
|
|
||||||
except Exception as e:
|
|
||||||
self.log.warning(f"Failed to set avatar: {e}")
|
|
||||||
self.avatar_set = False
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
|
|
||||||
portal: p.Portal = await p.Portal.get_by_mxid(room_id)
|
|
||||||
return portal and portal.peer_type != "user"
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
# region Getters
|
|
||||||
|
|
||||||
def _add_to_cache(self) -> None:
|
|
||||||
self.by_tgid[self.id] = self
|
|
||||||
if self.custom_mxid:
|
|
||||||
self.by_custom_mxid[self.custom_mxid] = self
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@async_getter_lock
|
|
||||||
async def get_by_tgid(
|
|
||||||
cls, tgid: TelegramID, /, *, create: bool = True, is_channel: bool = False
|
|
||||||
) -> Puppet | None:
|
|
||||||
if tgid is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
return cls.by_tgid[tgid]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
puppet = cast(cls, await super().get_by_tgid(tgid))
|
|
||||||
if puppet:
|
|
||||||
puppet._add_to_cache()
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
if create:
|
|
||||||
puppet = cls(tgid, is_channel=is_channel)
|
|
||||||
await puppet.insert()
|
|
||||||
puppet._add_to_cache()
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_id_from_peer(peer: TypePeer | User | Channel) -> TelegramID:
|
|
||||||
if isinstance(peer, (PeerUser, InputPeerUser)):
|
|
||||||
return TelegramID(peer.user_id)
|
|
||||||
elif isinstance(peer, PeerChannel):
|
|
||||||
return TelegramID(peer.channel_id)
|
|
||||||
elif isinstance(peer, PeerChat):
|
|
||||||
return TelegramID(peer.chat_id)
|
|
||||||
elif isinstance(peer, (User, Channel)):
|
|
||||||
return TelegramID(peer.id)
|
|
||||||
raise TypeError(f"invalid type {type(peer).__name__!r} in _id_from_peer()")
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def get_by_peer(
|
|
||||||
cls, peer: TypePeer | User | Channel, *, create: bool = True
|
|
||||||
) -> Puppet | None:
|
|
||||||
if isinstance(peer, PeerChat):
|
|
||||||
return None
|
|
||||||
return await cls.get_by_tgid(
|
|
||||||
cls.get_id_from_peer(peer),
|
|
||||||
create=create,
|
|
||||||
is_channel=isinstance(peer, (PeerChannel, Channel)),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Awaitable[Puppet | None]:
|
|
||||||
return cls.get_by_tgid(cls.get_id_from_mxid(mxid), create=create)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@async_getter_lock
|
|
||||||
async def get_by_custom_mxid(cls, mxid: UserID, /) -> Puppet | None:
|
|
||||||
try:
|
|
||||||
return cls.by_custom_mxid[mxid]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
puppet = cast(cls, await super().get_by_custom_mxid(mxid))
|
|
||||||
if puppet:
|
|
||||||
puppet._add_to_cache()
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def all_with_custom_mxid(cls) -> AsyncGenerator[Puppet, None]:
|
|
||||||
puppets = await super().all_with_custom_mxid()
|
|
||||||
puppet: cls
|
|
||||||
for puppet in puppets:
|
|
||||||
try:
|
|
||||||
yield cls.by_tgid[puppet.tgid]
|
|
||||||
except KeyError:
|
|
||||||
puppet._add_to_cache()
|
|
||||||
yield puppet
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_id_from_mxid(cls, mxid: UserID) -> TelegramID | None:
|
|
||||||
return cls.mxid_template.parse(mxid)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_mxid_from_id(cls, tgid: TelegramID) -> UserID:
|
|
||||||
return UserID(cls.mxid_template.format_full(tgid))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def find_by_username(cls, username: str) -> Puppet | None:
|
|
||||||
if not username:
|
|
||||||
return None
|
|
||||||
|
|
||||||
username = username.lower()
|
|
||||||
|
|
||||||
for _, puppet in cls.by_tgid.items():
|
|
||||||
if puppet.username and puppet.username.lower() == username:
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
puppet = cast(cls, await super().find_by_username(username))
|
|
||||||
if puppet:
|
|
||||||
try:
|
|
||||||
return cls.by_tgid[puppet.tgid]
|
|
||||||
except KeyError:
|
|
||||||
puppet._add_to_cache()
|
|
||||||
return puppet
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
# endregion
|
|
||||||
@@ -1,397 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2022 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import Any, Literal, TypedDict
|
|
||||||
from pathlib import Path
|
|
||||||
import argparse
|
|
||||||
import asyncio
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import mimetypes
|
|
||||||
import pickle
|
|
||||||
import random
|
|
||||||
import string
|
|
||||||
|
|
||||||
from lottie.exporters import export_tgs
|
|
||||||
from lottie.exporters.cairo import export_png
|
|
||||||
from lottie.exporters.tgs_validator import Severity, TgsValidator
|
|
||||||
from lottie.importers.svg import import_svg
|
|
||||||
from lottie.objects import Animation
|
|
||||||
from lottie.utils.stripper import float_strip
|
|
||||||
from PIL import Image
|
|
||||||
from telethon import TelegramClient
|
|
||||||
from telethon.custom import Conversation, Message
|
|
||||||
from telethon.tl.functions.messages import GetStickerSetRequest
|
|
||||||
from telethon.tl.types import (
|
|
||||||
Document,
|
|
||||||
DocumentAttributeCustomEmoji,
|
|
||||||
DocumentAttributeFilename,
|
|
||||||
DocumentAttributeImageSize,
|
|
||||||
InputMediaUploadedDocument,
|
|
||||||
InputStickerSetShortName,
|
|
||||||
)
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
mimetypes.add_type("image/webp", ".webp")
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(description="mautrix-telegram unicode emoji packer")
|
|
||||||
parser.add_argument(
|
|
||||||
"-i", "--api-id", type=int, required=True, metavar="<api id>", help="Telegram API ID"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-a", "--api-hash", type=str, required=True, metavar="<api hash>", help="Telegram API hash"
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-s",
|
|
||||||
"--session",
|
|
||||||
type=str,
|
|
||||||
default="unicodemojipacker.session",
|
|
||||||
metavar="<file name>",
|
|
||||||
help="Telethon session name",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-o",
|
|
||||||
"--output",
|
|
||||||
type=str,
|
|
||||||
default="mautrix_telegram/unicodemojipack.json",
|
|
||||||
metavar="<file name>",
|
|
||||||
help="Path to save created emoji pack document IDs",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-f",
|
|
||||||
"--font-directory",
|
|
||||||
type=Path,
|
|
||||||
required=True,
|
|
||||||
metavar="<directory path>",
|
|
||||||
help="Path to the Noto color emoji files",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-m",
|
|
||||||
"--media-directory",
|
|
||||||
type=Path,
|
|
||||||
required=True,
|
|
||||||
metavar="<directory path>",
|
|
||||||
help="Path to save converted tgs and webp emoji files",
|
|
||||||
)
|
|
||||||
args = parser.parse_args()
|
|
||||||
font_dir: Path = args.font_directory
|
|
||||||
media_dir: Path = args.media_directory
|
|
||||||
|
|
||||||
EMOJI_DATA_URL = "https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji.json"
|
|
||||||
|
|
||||||
|
|
||||||
def unified_to_unicode(unified: str) -> str:
|
|
||||||
return (
|
|
||||||
"".join(rf"\U{chunk:0>8}" for chunk in unified.split("-"))
|
|
||||||
.encode("ascii")
|
|
||||||
.decode("unicode_escape")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def tag_to_str(unified: str) -> str:
|
|
||||||
return "".join(chr(int(x.removeprefix("E00"), 16)) for x in unified.split("-"))
|
|
||||||
|
|
||||||
|
|
||||||
EmojiType = Literal["webp", "tgs"]
|
|
||||||
PackType = Literal["Animated emoji", "Static emoji"]
|
|
||||||
|
|
||||||
|
|
||||||
class Emoji(TypedDict):
|
|
||||||
hex: str
|
|
||||||
emoji: str
|
|
||||||
type: EmojiType
|
|
||||||
filename: str
|
|
||||||
|
|
||||||
|
|
||||||
class EmojiData(TypedDict):
|
|
||||||
tgs: list[Emoji]
|
|
||||||
webp: list[Emoji]
|
|
||||||
|
|
||||||
|
|
||||||
def parse_emoji_data(tone: dict[str, Any], emoji: dict[str, Any]) -> Emoji:
|
|
||||||
hex = (tone["non_qualified"] or tone["unified"]).replace("-FE0F", "")
|
|
||||||
filename_hex = hex.replace("-", "_").lower()
|
|
||||||
filename = f"svg/emoji_u{filename_hex}.svg"
|
|
||||||
if emoji["category"] == "Flags" and emoji["subcategory"] in (
|
|
||||||
"country-flag",
|
|
||||||
"subdivision-flag",
|
|
||||||
):
|
|
||||||
filename = f"third_party/region-flags/waved-svg/emoji_u{filename_hex}.svg"
|
|
||||||
|
|
||||||
with (font_dir / filename).open() as f:
|
|
||||||
lot: Animation = import_svg(f)
|
|
||||||
float_strip(lot)
|
|
||||||
lot.tgs_sanitize()
|
|
||||||
|
|
||||||
output = io.BytesIO()
|
|
||||||
export_tgs(lot, output)
|
|
||||||
|
|
||||||
validator = TgsValidator()
|
|
||||||
validator(lot)
|
|
||||||
validator.check_size(len(output.getvalue()))
|
|
||||||
errors = [err for err in validator.errors if err.severity != Severity.Note]
|
|
||||||
if errors or ("region-flags" in filename and len(output.getvalue()) > 32768):
|
|
||||||
lot.scale(100, 100)
|
|
||||||
|
|
||||||
png_out = io.BytesIO()
|
|
||||||
export_png(lot, png_out)
|
|
||||||
img = Image.open(png_out)
|
|
||||||
output = io.BytesIO()
|
|
||||||
output.name = "image.webp"
|
|
||||||
img.save(output, "webp")
|
|
||||||
|
|
||||||
media_type: EmojiType = "webp"
|
|
||||||
else:
|
|
||||||
media_type: EmojiType = "tgs"
|
|
||||||
path = media_dir / f"{filename_hex}.{media_type}"
|
|
||||||
with path.open("wb") as f:
|
|
||||||
f.write(output.getvalue())
|
|
||||||
print(
|
|
||||||
"Converted", filename, "->", path.name, "//" if errors else "", "\n".join(map(str, errors))
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"hex": hex,
|
|
||||||
"emoji": unified_to_unicode(tone["unified"]),
|
|
||||||
"type": media_type,
|
|
||||||
"filename": path.name,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def load_emoji_data() -> EmojiData:
|
|
||||||
cache_path = media_dir / "conversion-cache.json"
|
|
||||||
try:
|
|
||||||
with cache_path.open() as f:
|
|
||||||
return json.load(f)
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
async with aiohttp.ClientSession() as sess, sess.get(EMOJI_DATA_URL) as resp:
|
|
||||||
raw_emoji_data = sorted(
|
|
||||||
await resp.json(content_type=None),
|
|
||||||
key=lambda dat: dat["sort_order"],
|
|
||||||
)
|
|
||||||
tgs_emoji = []
|
|
||||||
webp_emoji = []
|
|
||||||
for emoji in raw_emoji_data:
|
|
||||||
for tone in (emoji, *emoji.get("skin_variations", {}).values()):
|
|
||||||
parsed_emoji = parse_emoji_data(tone, emoji)
|
|
||||||
if parsed_emoji["type"] == "tgs":
|
|
||||||
tgs_emoji.append(parsed_emoji)
|
|
||||||
else:
|
|
||||||
webp_emoji.append(parsed_emoji)
|
|
||||||
full_data = {"tgs": tgs_emoji, "webp": webp_emoji}
|
|
||||||
with cache_path.open("w") as f:
|
|
||||||
json.dump(full_data, f, ensure_ascii=False)
|
|
||||||
return full_data
|
|
||||||
|
|
||||||
|
|
||||||
async def create_pack(conv: Conversation, name: str, pack_type: str) -> None:
|
|
||||||
await conv.send_message("/newemojipack")
|
|
||||||
resp: Message = await conv.get_response()
|
|
||||||
assert "A new set of custom emoji" in resp.raw_text
|
|
||||||
assert "Please choose the type" in resp.raw_text
|
|
||||||
await conv.send_message(pack_type)
|
|
||||||
resp = await conv.get_response()
|
|
||||||
if pack_type == "Animated emoji":
|
|
||||||
assert "When ready to upload, tell me the name of your set." in resp.raw_text
|
|
||||||
else:
|
|
||||||
assert "Now choose a name for your set." in resp.raw_text
|
|
||||||
await conv.send_message(name)
|
|
||||||
resp = await conv.get_response()
|
|
||||||
if pack_type == "Animated emoji":
|
|
||||||
assert "Now send me the first animated emoji" in resp.raw_text
|
|
||||||
else:
|
|
||||||
assert "Now send me the custom emoji" in resp.raw_text
|
|
||||||
|
|
||||||
|
|
||||||
async def publish_pack(conv: Conversation, shortname: str) -> None:
|
|
||||||
await conv.send_message("/publish")
|
|
||||||
|
|
||||||
resp: Message = await conv.get_response()
|
|
||||||
assert "You can send me a custom emoji from your emoji set" in resp.raw_text
|
|
||||||
await conv.send_message("/skip")
|
|
||||||
|
|
||||||
resp = await conv.get_response()
|
|
||||||
assert "Please provide a short name for your emoji set" in resp.raw_text
|
|
||||||
await conv.send_message(shortname)
|
|
||||||
|
|
||||||
resp = await conv.get_response()
|
|
||||||
assert "I've just published your emoji set" in resp.raw_text
|
|
||||||
|
|
||||||
|
|
||||||
async def send_emoji(
|
|
||||||
conv: Conversation, file: bytes | Path | InputMediaUploadedDocument, emoji: str
|
|
||||||
) -> None:
|
|
||||||
await conv.send_file(file)
|
|
||||||
resp: Message = await conv.get_response()
|
|
||||||
assert "Send me a replacement emoji that corresponds to your custom emoji" in resp.raw_text
|
|
||||||
await conv.send_message(emoji)
|
|
||||||
resp = await conv.get_response()
|
|
||||||
if "Sorry, too many attempts" in resp.raw_text:
|
|
||||||
print(resp.raw_text)
|
|
||||||
input("Press enter to continue")
|
|
||||||
await conv.send_message(emoji)
|
|
||||||
resp = await conv.get_response()
|
|
||||||
while "Please send an emoji that best describes your custom emoji." in resp.raw_text:
|
|
||||||
emoji = input(f"{emoji} was rejected, provide replacement: ")
|
|
||||||
await conv.send_message(emoji)
|
|
||||||
resp = await conv.get_response()
|
|
||||||
assert "Congratulations" in resp.raw_text
|
|
||||||
|
|
||||||
|
|
||||||
class CachedPack(TypedDict):
|
|
||||||
name: str
|
|
||||||
short_name: str
|
|
||||||
part: int
|
|
||||||
type: PackType
|
|
||||||
published: bool
|
|
||||||
collected: bool
|
|
||||||
emojis: list[Emoji]
|
|
||||||
|
|
||||||
|
|
||||||
class CachedData(TypedDict):
|
|
||||||
packs: list[CachedPack]
|
|
||||||
|
|
||||||
|
|
||||||
def _split_packs_int(
|
|
||||||
emoji_list: list[Emoji], pack_type: PackType, current_part: int, total_parts: int
|
|
||||||
) -> tuple[list[CachedPack], int]:
|
|
||||||
packs = []
|
|
||||||
current_pack: CachedPack | None = None
|
|
||||||
for i, emoji in enumerate(emoji_list):
|
|
||||||
if i % 200 == 0:
|
|
||||||
current_part += 1
|
|
||||||
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
|
||||||
short_name = f"mxtg_unicodemoji_{random_id}"
|
|
||||||
name = f"mautrix-telegram unicodemoji ({current_part}/{total_parts})"
|
|
||||||
current_pack = {
|
|
||||||
"type": pack_type,
|
|
||||||
"short_name": short_name,
|
|
||||||
"part": current_part,
|
|
||||||
"name": name,
|
|
||||||
"published": False,
|
|
||||||
"collected": False,
|
|
||||||
"emojis": [],
|
|
||||||
}
|
|
||||||
packs.append(current_pack)
|
|
||||||
current_pack["emojis"].append(emoji)
|
|
||||||
return packs, current_part
|
|
||||||
|
|
||||||
|
|
||||||
def split_packs(emoji_data: EmojiData) -> list[CachedPack]:
|
|
||||||
total_parts = math.ceil(len(emoji_data["tgs"]) / 200) + math.ceil(
|
|
||||||
len(emoji_data["webp"]) / 200
|
|
||||||
)
|
|
||||||
current_part = 0
|
|
||||||
animated_packs, current_part = _split_packs_int(
|
|
||||||
emoji_data["tgs"], "Animated emoji", current_part, total_parts
|
|
||||||
)
|
|
||||||
static_packs, current_part = _split_packs_int(
|
|
||||||
emoji_data["webp"], "Static emoji", current_part, total_parts
|
|
||||||
)
|
|
||||||
return animated_packs + static_packs
|
|
||||||
|
|
||||||
|
|
||||||
async def create_and_fill_pack(
|
|
||||||
client: TelegramClient, conv: Conversation, pack: CachedPack
|
|
||||||
) -> None:
|
|
||||||
if pack["short_name"] == "mxtg_unicodemoji_xvzs6743":
|
|
||||||
print("Continuing pack", pack["name"])
|
|
||||||
else:
|
|
||||||
print("Creating pack", pack["name"])
|
|
||||||
await create_pack(conv, pack["name"], pack["type"])
|
|
||||||
total = len(pack["emojis"])
|
|
||||||
for i, emoji in enumerate(pack["emojis"]):
|
|
||||||
if pack["short_name"] == "mxtg_unicodemoji_xvzs6743" and i < 87:
|
|
||||||
continue
|
|
||||||
print(f"Adding emoji {i+1}/{total}", emoji["hex"], emoji["emoji"])
|
|
||||||
emoji_file = media_dir / emoji["filename"]
|
|
||||||
if emoji["type"] == "webp":
|
|
||||||
attrs = [
|
|
||||||
DocumentAttributeImageSize(w=100, h=100),
|
|
||||||
DocumentAttributeFilename(file_name="image.webp"),
|
|
||||||
]
|
|
||||||
with emoji_file.open("rb") as f:
|
|
||||||
file_handle = await client.upload_file(f, file_name="emoji.webp")
|
|
||||||
emoji_file = InputMediaUploadedDocument(
|
|
||||||
file_handle, mime_type="image/webp", attributes=attrs
|
|
||||||
)
|
|
||||||
await send_emoji(conv, emoji_file, emoji["emoji"])
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
print("Publishing pack", pack["short_name"])
|
|
||||||
await publish_pack(conv, pack["short_name"])
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
|
|
||||||
emoji_data = await load_emoji_data()
|
|
||||||
|
|
||||||
split_cache = media_dir / "split-cache.json"
|
|
||||||
try:
|
|
||||||
with split_cache.open() as f:
|
|
||||||
packs: list[CachedPack] = json.load(f)
|
|
||||||
except FileNotFoundError:
|
|
||||||
packs = split_packs(emoji_data)
|
|
||||||
with split_cache.open("w") as f:
|
|
||||||
json.dump(packs, f)
|
|
||||||
|
|
||||||
doc_id_file = Path(args.output)
|
|
||||||
try:
|
|
||||||
with doc_id_file.open() as f:
|
|
||||||
doc_ids = json.load(f)
|
|
||||||
except FileNotFoundError:
|
|
||||||
doc_ids = {}
|
|
||||||
|
|
||||||
client = TelegramClient(args.session, args.api_id, args.api_hash, flood_sleep_threshold=3600)
|
|
||||||
await client.start()
|
|
||||||
async with client.conversation("Stickers", max_messages=20000) as conv:
|
|
||||||
for pack in packs:
|
|
||||||
if not pack["published"]:
|
|
||||||
await create_and_fill_pack(client, conv, pack)
|
|
||||||
pack["published"] = True
|
|
||||||
with split_cache.open("w") as f:
|
|
||||||
json.dump(packs, f, ensure_ascii=False)
|
|
||||||
if not pack["collected"] or True:
|
|
||||||
print("Collecting document IDs from pack", pack["short_name"])
|
|
||||||
stickers = await client(
|
|
||||||
GetStickerSetRequest(InputStickerSetShortName(pack["short_name"]), 0)
|
|
||||||
)
|
|
||||||
doc: Document
|
|
||||||
for i, doc in enumerate(stickers.documents):
|
|
||||||
attr = next(
|
|
||||||
attr
|
|
||||||
for attr in doc.attributes
|
|
||||||
if isinstance(attr, DocumentAttributeCustomEmoji)
|
|
||||||
)
|
|
||||||
base_emoji = attr.alt.replace("\ufe0f", "")
|
|
||||||
emoji = pack["emojis"][i]["emoji"].replace("\ufe0f", "")
|
|
||||||
doc_ids[emoji] = doc.id
|
|
||||||
print(f"Mapped {emoji} (fallback: {base_emoji}) -> {doc_ids[emoji]}")
|
|
||||||
pack["collected"] = True
|
|
||||||
with split_cache.open("w") as f:
|
|
||||||
json.dump(packs, f, ensure_ascii=False)
|
|
||||||
with doc_id_file.open("w") as f:
|
|
||||||
json.dump(doc_ids, f, ensure_ascii=False)
|
|
||||||
print("Pack completed")
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
with open(args.output.replace(".json", ".pickle"), "wb") as f:
|
|
||||||
pickle.dump(doc_ids, f)
|
|
||||||
print("Wrote pickle")
|
|
||||||
|
|
||||||
|
|
||||||
asyncio.run(main())
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from typing import List, Optional, Union
|
|
||||||
|
|
||||||
from telethon import TelegramClient, utils
|
|
||||||
from telethon.sessions.abstract import Session
|
|
||||||
from telethon.tl.functions.messages import SendMediaRequest
|
|
||||||
from telethon.tl.patched import Message
|
|
||||||
from telethon.tl.types import (
|
|
||||||
InputMediaUploadedDocument,
|
|
||||||
InputMediaUploadedPhoto,
|
|
||||||
InputReplyToMessage,
|
|
||||||
TypeDocumentAttribute,
|
|
||||||
TypeInputMedia,
|
|
||||||
TypeInputPeer,
|
|
||||||
TypeMessageEntity,
|
|
||||||
TypeMessageMedia,
|
|
||||||
TypePeer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MautrixTelegramClient(TelegramClient):
|
|
||||||
session: Session
|
|
||||||
|
|
||||||
async def upload_file_direct(
|
|
||||||
self,
|
|
||||||
file: bytes,
|
|
||||||
mime_type: str = None,
|
|
||||||
attributes: List[TypeDocumentAttribute] = None,
|
|
||||||
file_name: str = None,
|
|
||||||
max_image_size: float = 10 * 1000**2,
|
|
||||||
) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]:
|
|
||||||
file_handle = await super().upload_file(file, file_name=file_name)
|
|
||||||
|
|
||||||
if (mime_type == "image/png" or mime_type == "image/jpeg") and len(file) < max_image_size:
|
|
||||||
return InputMediaUploadedPhoto(file_handle)
|
|
||||||
else:
|
|
||||||
attributes = attributes or []
|
|
||||||
attr_dict = {type(attr): attr for attr in attributes}
|
|
||||||
|
|
||||||
return InputMediaUploadedDocument(
|
|
||||||
file=file_handle,
|
|
||||||
mime_type=mime_type or "application/octet-stream",
|
|
||||||
attributes=list(attr_dict.values()),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_media(
|
|
||||||
self,
|
|
||||||
entity: Union[TypeInputPeer, TypePeer],
|
|
||||||
media: Union[TypeInputMedia, TypeMessageMedia],
|
|
||||||
caption: str = None,
|
|
||||||
entities: List[TypeMessageEntity] = None,
|
|
||||||
reply_to: int = None,
|
|
||||||
) -> Optional[Message]:
|
|
||||||
entity = await self.get_input_entity(entity)
|
|
||||||
reply_to = utils.get_message_id(reply_to)
|
|
||||||
request = SendMediaRequest(
|
|
||||||
entity,
|
|
||||||
media,
|
|
||||||
message=caption or "",
|
|
||||||
entities=entities or [],
|
|
||||||
reply_to=InputReplyToMessage(reply_to_msg_id=reply_to) if reply_to else None,
|
|
||||||
)
|
|
||||||
return self._get_response_message(request, await self(request), entity)
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
from typing import NewType
|
|
||||||
|
|
||||||
TelegramID = NewType("TelegramID", int)
|
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,12 +0,0 @@
|
|||||||
from .color_log import ColorFormatter
|
|
||||||
from .file_transfer import (
|
|
||||||
UnicodeCustomEmoji,
|
|
||||||
convert_image,
|
|
||||||
transfer_custom_emojis_to_matrix,
|
|
||||||
transfer_file_to_matrix,
|
|
||||||
transfer_thumbnail_to_matrix,
|
|
||||||
unicode_custom_emoji_map,
|
|
||||||
)
|
|
||||||
from .parallel_file_transfer import parallel_transfer_to_telegram
|
|
||||||
from .recursive_dict import recursive_del, recursive_get, recursive_set
|
|
||||||
from .tl_json import parse_tl_json
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from mautrix.util.logging.color import (
|
|
||||||
MXID_COLOR,
|
|
||||||
PREFIX,
|
|
||||||
RESET,
|
|
||||||
ColorFormatter as BaseColorFormatter,
|
|
||||||
)
|
|
||||||
|
|
||||||
TELETHON_COLOR = PREFIX + "35;1m" # magenta
|
|
||||||
TELETHON_MODULE_COLOR = PREFIX + "35m"
|
|
||||||
|
|
||||||
|
|
||||||
class ColorFormatter(BaseColorFormatter):
|
|
||||||
def _color_name(self, module: str) -> str:
|
|
||||||
if module.startswith("telethon"):
|
|
||||||
prefix, user_id, module = module.split(".", 2)
|
|
||||||
return (
|
|
||||||
f"{TELETHON_COLOR}{prefix}{RESET}."
|
|
||||||
f"{MXID_COLOR}{user_id}{RESET}."
|
|
||||||
f"{TELETHON_MODULE_COLOR}{module}{RESET}"
|
|
||||||
)
|
|
||||||
return super()._color_name(module)
|
|
||||||
@@ -1,427 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2021 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import NamedTuple, Optional, Union
|
|
||||||
from io import BytesIO
|
|
||||||
from sqlite3 import IntegrityError
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import pickle
|
|
||||||
import pkgutil
|
|
||||||
import time
|
|
||||||
|
|
||||||
from asyncpg import UniqueViolationError
|
|
||||||
from telethon.errors import (
|
|
||||||
AuthBytesInvalidError,
|
|
||||||
AuthKeyInvalidError,
|
|
||||||
FileIdInvalidError,
|
|
||||||
LocationInvalidError,
|
|
||||||
SecurityError,
|
|
||||||
)
|
|
||||||
from telethon.tl.functions.messages import GetCustomEmojiDocumentsRequest
|
|
||||||
from telethon.tl.types import (
|
|
||||||
Document,
|
|
||||||
InputDocumentFileLocation,
|
|
||||||
InputFileLocation,
|
|
||||||
InputPeerPhotoFileLocation,
|
|
||||||
InputPhotoFileLocation,
|
|
||||||
PhotoCachedSize,
|
|
||||||
PhotoSize,
|
|
||||||
TypePhotoSize,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.appservice import IntentAPI
|
|
||||||
from mautrix.util import ffmpeg, magic, variation_selector
|
|
||||||
|
|
||||||
from .. import abstract_user as au
|
|
||||||
from ..db import TelegramFile as DBTelegramFile
|
|
||||||
from ..tgclient import MautrixTelegramClient
|
|
||||||
from ..util import sane_mimetypes
|
|
||||||
from .parallel_file_transfer import parallel_transfer_to_matrix
|
|
||||||
from .tgs_converter import convert_tgs_to
|
|
||||||
from .webm_converter import convert_webm_to
|
|
||||||
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
except ImportError:
|
|
||||||
Image = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
from mautrix.crypto.attachments import encrypt_attachment
|
|
||||||
except ImportError:
|
|
||||||
encrypt_attachment = None
|
|
||||||
|
|
||||||
log: logging.Logger = logging.getLogger("mau.util")
|
|
||||||
|
|
||||||
TypeLocation = Union[
|
|
||||||
Document,
|
|
||||||
InputDocumentFileLocation,
|
|
||||||
InputPeerPhotoFileLocation,
|
|
||||||
InputFileLocation,
|
|
||||||
InputPhotoFileLocation,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def convert_image(
|
|
||||||
file: bytes,
|
|
||||||
source_mime: str = "image/webp",
|
|
||||||
target_type: str = "png",
|
|
||||||
thumbnail_to: tuple[int, int] | None = None,
|
|
||||||
) -> tuple[str, bytes, int | None, int | None]:
|
|
||||||
if not Image:
|
|
||||||
return source_mime, file, None, None
|
|
||||||
try:
|
|
||||||
image: Image.Image = Image.open(BytesIO(file)).convert("RGBA")
|
|
||||||
if thumbnail_to:
|
|
||||||
image.thumbnail(thumbnail_to, Image.ANTIALIAS)
|
|
||||||
new_file = BytesIO()
|
|
||||||
image.save(new_file, target_type)
|
|
||||||
w, h = image.size
|
|
||||||
return f"image/{target_type}", new_file.getvalue(), w, h
|
|
||||||
except Exception:
|
|
||||||
log.exception(f"Failed to convert {source_mime} to {target_type}")
|
|
||||||
return source_mime, file, None, None
|
|
||||||
|
|
||||||
|
|
||||||
async def _read_video_thumbnail(data: bytes, mime_type: str) -> tuple[bytes, int, int]:
|
|
||||||
first_frame = await ffmpeg.convert_bytes(
|
|
||||||
data,
|
|
||||||
output_extension=".png",
|
|
||||||
output_args=("-update", "1", "-frames:v", "1"),
|
|
||||||
input_mime=mime_type,
|
|
||||||
logger=log,
|
|
||||||
)
|
|
||||||
width, height = Image.open(BytesIO(first_frame)).size
|
|
||||||
return first_frame, width, height
|
|
||||||
|
|
||||||
|
|
||||||
def _location_to_id(location: TypeLocation) -> str:
|
|
||||||
if isinstance(location, Document):
|
|
||||||
return str(location.id)
|
|
||||||
elif isinstance(location, (InputDocumentFileLocation, InputPhotoFileLocation)):
|
|
||||||
return f"{location.id}-{location.thumb_size}"
|
|
||||||
elif isinstance(location, InputFileLocation):
|
|
||||||
return f"{location.volume_id}-{location.local_id}"
|
|
||||||
elif isinstance(location, InputPeerPhotoFileLocation):
|
|
||||||
return str(location.photo_id)
|
|
||||||
|
|
||||||
|
|
||||||
async def transfer_thumbnail_to_matrix(
|
|
||||||
client: MautrixTelegramClient,
|
|
||||||
intent: IntentAPI,
|
|
||||||
thumbnail_loc: TypeLocation,
|
|
||||||
mime_type: str,
|
|
||||||
encrypt: bool,
|
|
||||||
video: bytes | None,
|
|
||||||
custom_data: bytes | None = None,
|
|
||||||
width: int | None = None,
|
|
||||||
height: int | None = None,
|
|
||||||
async_upload: bool = False,
|
|
||||||
) -> DBTelegramFile | None:
|
|
||||||
if not Image or not ffmpeg.ffmpeg_path:
|
|
||||||
return None
|
|
||||||
|
|
||||||
loc_id = _location_to_id(thumbnail_loc)
|
|
||||||
if not loc_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if custom_data:
|
|
||||||
loc_id += "-mau_custom_thumbnail"
|
|
||||||
if encrypt:
|
|
||||||
loc_id += "-encrypted"
|
|
||||||
|
|
||||||
db_file = await DBTelegramFile.get(loc_id)
|
|
||||||
if db_file:
|
|
||||||
return db_file
|
|
||||||
|
|
||||||
video_ext = sane_mimetypes.guess_extension(mime_type)
|
|
||||||
if custom_data:
|
|
||||||
file = custom_data
|
|
||||||
elif video_ext and video:
|
|
||||||
log.debug(f"Generating thumbnail for video {loc_id} with ffmpeg")
|
|
||||||
try:
|
|
||||||
file, width, height = await _read_video_thumbnail(video, mime_type=mime_type)
|
|
||||||
except Exception:
|
|
||||||
log.warning(f"Failed to generate thumbnail for {loc_id}", exc_info=True)
|
|
||||||
return None
|
|
||||||
mime_type = "image/png"
|
|
||||||
else:
|
|
||||||
file = await client.download_file(thumbnail_loc)
|
|
||||||
width, height = None, None
|
|
||||||
mime_type = magic.mimetype(file)
|
|
||||||
|
|
||||||
decryption_info = None
|
|
||||||
upload_mime_type = mime_type
|
|
||||||
if encrypt:
|
|
||||||
file, decryption_info = encrypt_attachment(file)
|
|
||||||
upload_mime_type = "application/octet-stream"
|
|
||||||
content_uri = await intent.upload_media(file, upload_mime_type, async_upload=async_upload)
|
|
||||||
if decryption_info:
|
|
||||||
decryption_info.url = content_uri
|
|
||||||
|
|
||||||
db_file = DBTelegramFile(
|
|
||||||
id=loc_id,
|
|
||||||
mxc=content_uri,
|
|
||||||
mime_type=mime_type,
|
|
||||||
was_converted=False,
|
|
||||||
timestamp=int(time.time()),
|
|
||||||
size=len(file),
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
decryption_info=decryption_info,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await db_file.insert()
|
|
||||||
except (UniqueViolationError, IntegrityError) as e:
|
|
||||||
log.exception(
|
|
||||||
f"{e.__class__.__name__} while saving transferred file thumbnail data. "
|
|
||||||
"This was probably caused by two simultaneous transfers of the same file, "
|
|
||||||
"and might (but probably won't) cause problems with thumbnails or something."
|
|
||||||
)
|
|
||||||
return db_file
|
|
||||||
|
|
||||||
|
|
||||||
transfer_locks: dict[str, asyncio.Lock] = {}
|
|
||||||
|
|
||||||
unicode_custom_emoji_map = pickle.loads(
|
|
||||||
pkgutil.get_data("mautrix_telegram", "unicodemojipack.pickle")
|
|
||||||
)
|
|
||||||
reverse_unicode_custom_emoji_map = {
|
|
||||||
doc_id: emoji for emoji, doc_id in unicode_custom_emoji_map.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
TypeThumbnail = Optional[Union[TypeLocation, TypePhotoSize]]
|
|
||||||
|
|
||||||
|
|
||||||
class UnicodeCustomEmoji(NamedTuple):
|
|
||||||
emoji: str
|
|
||||||
|
|
||||||
|
|
||||||
async def transfer_custom_emojis_to_matrix(
|
|
||||||
source: au.AbstractUser, emoji_ids: list[int], client: MautrixTelegramClient | None = None
|
|
||||||
) -> dict[int, DBTelegramFile | UnicodeCustomEmoji]:
|
|
||||||
if not client:
|
|
||||||
client = source.client
|
|
||||||
emoji_ids = set(emoji_ids)
|
|
||||||
existing_unicode = {}
|
|
||||||
for emoji_id in emoji_ids:
|
|
||||||
try:
|
|
||||||
existing_unicode[emoji_id] = UnicodeCustomEmoji(
|
|
||||||
variation_selector.add(reverse_unicode_custom_emoji_map[emoji_id])
|
|
||||||
)
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
emoji_ids -= existing_unicode.keys()
|
|
||||||
if not emoji_ids:
|
|
||||||
return existing_unicode
|
|
||||||
existing = await DBTelegramFile.get_many([str(id) for id in emoji_ids])
|
|
||||||
file_map = {int(file.id): file for file in existing} | existing_unicode
|
|
||||||
not_existing_ids = list(emoji_ids - file_map.keys())
|
|
||||||
if not_existing_ids:
|
|
||||||
log.debug(f"Transferring custom emojis through {source.mxid}: {not_existing_ids}")
|
|
||||||
|
|
||||||
documents: list[Document] = await client(
|
|
||||||
GetCustomEmojiDocumentsRequest(document_id=not_existing_ids)
|
|
||||||
)
|
|
||||||
|
|
||||||
tgs_args = source.config["bridge.animated_emoji"]
|
|
||||||
webm_convert = tgs_args["target"]
|
|
||||||
|
|
||||||
transfer_sema = asyncio.Semaphore(5)
|
|
||||||
|
|
||||||
async def transfer(document: Document) -> None:
|
|
||||||
async with transfer_sema:
|
|
||||||
file_map[document.id] = await transfer_file_to_matrix(
|
|
||||||
client,
|
|
||||||
source.bridge.az.intent,
|
|
||||||
document,
|
|
||||||
is_sticker=True,
|
|
||||||
tgs_convert=tgs_args,
|
|
||||||
webm_convert=webm_convert,
|
|
||||||
filename=f"emoji-{document.id}",
|
|
||||||
# Emojis are used as inline images and can't be encrypted
|
|
||||||
encrypt=False,
|
|
||||||
async_upload=source.config["homeserver.async_media"],
|
|
||||||
)
|
|
||||||
|
|
||||||
await asyncio.gather(*[transfer(doc) for doc in documents])
|
|
||||||
return file_map
|
|
||||||
|
|
||||||
|
|
||||||
async def transfer_file_to_matrix(
|
|
||||||
client: MautrixTelegramClient,
|
|
||||||
intent: IntentAPI,
|
|
||||||
location: TypeLocation,
|
|
||||||
thumbnail: TypeThumbnail = None,
|
|
||||||
*,
|
|
||||||
is_sticker: bool = False,
|
|
||||||
tgs_convert: dict | None = None,
|
|
||||||
webm_convert: str | None = None,
|
|
||||||
filename: str | None = None,
|
|
||||||
encrypt: bool = False,
|
|
||||||
parallel_id: int | None = None,
|
|
||||||
async_upload: bool = False,
|
|
||||||
) -> DBTelegramFile | None:
|
|
||||||
location_id = _location_to_id(location)
|
|
||||||
if not location_id:
|
|
||||||
return None
|
|
||||||
if encrypt:
|
|
||||||
location_id += "-encrypted"
|
|
||||||
|
|
||||||
db_file = await DBTelegramFile.get(location_id)
|
|
||||||
if db_file:
|
|
||||||
return db_file
|
|
||||||
|
|
||||||
try:
|
|
||||||
lock = transfer_locks[location_id]
|
|
||||||
except KeyError:
|
|
||||||
lock = asyncio.Lock()
|
|
||||||
transfer_locks[location_id] = lock
|
|
||||||
async with lock:
|
|
||||||
return await _unlocked_transfer_file_to_matrix(
|
|
||||||
client,
|
|
||||||
intent,
|
|
||||||
location_id,
|
|
||||||
location,
|
|
||||||
thumbnail,
|
|
||||||
is_sticker,
|
|
||||||
tgs_convert,
|
|
||||||
webm_convert,
|
|
||||||
filename,
|
|
||||||
encrypt,
|
|
||||||
parallel_id,
|
|
||||||
async_upload=async_upload,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _unlocked_transfer_file_to_matrix(
|
|
||||||
client: MautrixTelegramClient,
|
|
||||||
intent: IntentAPI,
|
|
||||||
loc_id: str,
|
|
||||||
location: TypeLocation,
|
|
||||||
thumbnail: TypeThumbnail,
|
|
||||||
is_sticker: bool,
|
|
||||||
tgs_convert: dict | None,
|
|
||||||
webm_convert: str | None,
|
|
||||||
filename: str | None,
|
|
||||||
encrypt: bool,
|
|
||||||
parallel_id: int | None,
|
|
||||||
async_upload: bool = False,
|
|
||||||
) -> DBTelegramFile | None:
|
|
||||||
db_file = await DBTelegramFile.get(loc_id)
|
|
||||||
if db_file:
|
|
||||||
return db_file
|
|
||||||
|
|
||||||
converted_anim = None
|
|
||||||
|
|
||||||
if parallel_id and isinstance(location, Document) and (not is_sticker or not tgs_convert):
|
|
||||||
db_file = await parallel_transfer_to_matrix(
|
|
||||||
client, intent, loc_id, location, filename, encrypt, parallel_id
|
|
||||||
)
|
|
||||||
mime_type = location.mime_type
|
|
||||||
unencrypted_file = None
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
unencrypted_file = file = await client.download_file(location)
|
|
||||||
except (LocationInvalidError, FileIdInvalidError):
|
|
||||||
return None
|
|
||||||
except (AuthBytesInvalidError, AuthKeyInvalidError, SecurityError) as e:
|
|
||||||
log.exception(f"{e.__class__.__name__} while downloading a file.")
|
|
||||||
return None
|
|
||||||
|
|
||||||
width, height = None, None
|
|
||||||
mime_type = magic.mimetype(file)
|
|
||||||
|
|
||||||
image_converted = False
|
|
||||||
is_tgs = mime_type == "application/gzip"
|
|
||||||
if is_sticker and tgs_convert and is_tgs:
|
|
||||||
converted_anim = await convert_tgs_to(
|
|
||||||
file, tgs_convert["target"], **tgs_convert["args"]
|
|
||||||
)
|
|
||||||
mime_type = converted_anim.mime
|
|
||||||
file = converted_anim.data
|
|
||||||
width, height = converted_anim.width, converted_anim.height
|
|
||||||
image_converted = mime_type != "application/gzip"
|
|
||||||
thumbnail = None
|
|
||||||
elif is_sticker and webm_convert and webm_convert != "webm" and mime_type == "video/webm":
|
|
||||||
converted_anim = await convert_webm_to(file, webm_convert)
|
|
||||||
mime_type = converted_anim.mime
|
|
||||||
file = converted_anim.data
|
|
||||||
image_converted = mime_type != "video/webm"
|
|
||||||
thumbnail = None
|
|
||||||
|
|
||||||
decryption_info = None
|
|
||||||
upload_mime_type = mime_type
|
|
||||||
if encrypt and encrypt_attachment:
|
|
||||||
file, decryption_info = encrypt_attachment(file)
|
|
||||||
upload_mime_type = "application/octet-stream"
|
|
||||||
content_uri = await intent.upload_media(file, upload_mime_type, async_upload=async_upload)
|
|
||||||
if decryption_info:
|
|
||||||
decryption_info.url = content_uri
|
|
||||||
|
|
||||||
db_file = DBTelegramFile(
|
|
||||||
id=loc_id,
|
|
||||||
mxc=content_uri,
|
|
||||||
decryption_info=decryption_info,
|
|
||||||
mime_type=mime_type,
|
|
||||||
was_converted=image_converted,
|
|
||||||
timestamp=int(time.time()),
|
|
||||||
size=len(file),
|
|
||||||
width=width,
|
|
||||||
height=height,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"):
|
|
||||||
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
|
|
||||||
thumbnail = thumbnail.location
|
|
||||||
try:
|
|
||||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(
|
|
||||||
client,
|
|
||||||
intent,
|
|
||||||
thumbnail,
|
|
||||||
video=unencrypted_file,
|
|
||||||
mime_type=mime_type,
|
|
||||||
encrypt=encrypt,
|
|
||||||
async_upload=async_upload,
|
|
||||||
)
|
|
||||||
except FileIdInvalidError:
|
|
||||||
log.warning(f"Failed to transfer thumbnail {thumbnail!s}", exc_info=True)
|
|
||||||
elif converted_anim and converted_anim.thumbnail_data:
|
|
||||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(
|
|
||||||
client,
|
|
||||||
intent,
|
|
||||||
location,
|
|
||||||
video=None,
|
|
||||||
encrypt=encrypt,
|
|
||||||
custom_data=converted_anim.thumbnail_data,
|
|
||||||
mime_type=converted_anim.thumbnail_mime,
|
|
||||||
width=converted_anim.width,
|
|
||||||
height=converted_anim.height,
|
|
||||||
async_upload=async_upload,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
log.exception(f"Failed to transfer thumbnail for {loc_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
await db_file.insert()
|
|
||||||
except (UniqueViolationError, IntegrityError) as e:
|
|
||||||
log.exception(
|
|
||||||
f"{e.__class__.__name__} while saving transferred file data. "
|
|
||||||
"This was probably caused by two simultaneous transfers of the same file, "
|
|
||||||
"and should not cause any problems."
|
|
||||||
)
|
|
||||||
return db_file
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
|
||||||
# Copyright (C) 2019 Tulir Asokan
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import AsyncGenerator, Awaitable, Union, cast
|
|
||||||
from collections import defaultdict
|
|
||||||
import asyncio
|
|
||||||
import hashlib
|
|
||||||
import logging
|
|
||||||
import math
|
|
||||||
import time
|
|
||||||
|
|
||||||
from aiohttp import ClientResponse
|
|
||||||
from telethon import helpers, utils
|
|
||||||
from telethon.crypto import AuthKey
|
|
||||||
from telethon.network import MTProtoSender
|
|
||||||
from telethon.tl.alltlobjects import LAYER
|
|
||||||
from telethon.tl.functions import InvokeWithLayerRequest
|
|
||||||
from telethon.tl.functions.auth import ExportAuthorizationRequest, ImportAuthorizationRequest
|
|
||||||
from telethon.tl.functions.upload import (
|
|
||||||
GetFileRequest,
|
|
||||||
SaveBigFilePartRequest,
|
|
||||||
SaveFilePartRequest,
|
|
||||||
)
|
|
||||||
from telethon.tl.types import (
|
|
||||||
Document,
|
|
||||||
InputDocumentFileLocation,
|
|
||||||
InputFile,
|
|
||||||
InputFileBig,
|
|
||||||
InputFileLocation,
|
|
||||||
InputPeerPhotoFileLocation,
|
|
||||||
InputPhotoFileLocation,
|
|
||||||
TypeInputFile,
|
|
||||||
)
|
|
||||||
|
|
||||||
from mautrix.appservice import IntentAPI
|
|
||||||
from mautrix.types import ContentURI, EncryptedFile
|
|
||||||
from mautrix.util.logging import TraceLogger
|
|
||||||
|
|
||||||
from ..db import TelegramFile as DBTelegramFile
|
|
||||||
from ..tgclient import MautrixTelegramClient
|
|
||||||
|
|
||||||
try:
|
|
||||||
from mautrix.crypto.attachments import async_encrypt_attachment
|
|
||||||
except ImportError:
|
|
||||||
async_encrypt_attachment = None
|
|
||||||
|
|
||||||
log: TraceLogger = cast(TraceLogger, logging.getLogger("mau.util"))
|
|
||||||
|
|
||||||
TypeLocation = Union[
|
|
||||||
Document,
|
|
||||||
InputDocumentFileLocation,
|
|
||||||
InputPeerPhotoFileLocation,
|
|
||||||
InputFileLocation,
|
|
||||||
InputPhotoFileLocation,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class DownloadSender:
|
|
||||||
sender: MTProtoSender
|
|
||||||
request: GetFileRequest
|
|
||||||
remaining: int
|
|
||||||
stride: int
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
sender: MTProtoSender,
|
|
||||||
file: TypeLocation,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
stride: int,
|
|
||||||
count: int,
|
|
||||||
) -> None:
|
|
||||||
self.sender = sender
|
|
||||||
self.request = GetFileRequest(file, offset=offset, limit=limit)
|
|
||||||
self.stride = stride
|
|
||||||
self.remaining = count
|
|
||||||
|
|
||||||
async def next(self) -> bytes | None:
|
|
||||||
if not self.remaining:
|
|
||||||
return None
|
|
||||||
result = await self.sender.send(self.request)
|
|
||||||
self.remaining -= 1
|
|
||||||
self.request.offset += self.stride
|
|
||||||
return result.bytes
|
|
||||||
|
|
||||||
def disconnect(self) -> Awaitable[None]:
|
|
||||||
return self.sender.disconnect()
|
|
||||||
|
|
||||||
|
|
||||||
class UploadSender:
|
|
||||||
sender: MTProtoSender
|
|
||||||
request: SaveFilePartRequest < SaveBigFilePartRequest
|
|
||||||
part_count: int
|
|
||||||
stride: int
|
|
||||||
previous: asyncio.Task | None
|
|
||||||
loop: asyncio.AbstractEventLoop
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
sender: MTProtoSender,
|
|
||||||
file_id: int,
|
|
||||||
part_count: int,
|
|
||||||
big: bool,
|
|
||||||
index: int,
|
|
||||||
stride: int,
|
|
||||||
loop: asyncio.AbstractEventLoop,
|
|
||||||
) -> None:
|
|
||||||
self.sender = sender
|
|
||||||
self.part_count = part_count
|
|
||||||
if big:
|
|
||||||
self.request = SaveBigFilePartRequest(file_id, index, part_count, b"")
|
|
||||||
else:
|
|
||||||
self.request = SaveFilePartRequest(file_id, index, b"")
|
|
||||||
self.stride = stride
|
|
||||||
self.previous = None
|
|
||||||
self.loop = loop
|
|
||||||
|
|
||||||
async def next(self, data: bytes) -> None:
|
|
||||||
if self.previous:
|
|
||||||
await self.previous
|
|
||||||
self.previous = asyncio.create_task(self._next(data))
|
|
||||||
|
|
||||||
async def _next(self, data: bytes) -> None:
|
|
||||||
self.request.bytes = data
|
|
||||||
log.trace(
|
|
||||||
f"Sending file part {self.request.file_part}/{self.part_count}"
|
|
||||||
f" with {len(data)} bytes"
|
|
||||||
)
|
|
||||||
await self.sender.send(self.request)
|
|
||||||
self.request.file_part += self.stride
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
|
||||||
if self.previous:
|
|
||||||
await self.previous
|
|
||||||
return await self.sender.disconnect()
|
|
||||||
|
|
||||||
|
|
||||||
class ParallelTransferrer:
|
|
||||||
client: MautrixTelegramClient
|
|
||||||
loop: asyncio.AbstractEventLoop
|
|
||||||
dc_id: int
|
|
||||||
senders: list[DownloadSender | UploadSender] | None
|
|
||||||
auth_key: AuthKey
|
|
||||||
upload_ticker: int
|
|
||||||
|
|
||||||
def __init__(self, client: MautrixTelegramClient, dc_id: int | None = None) -> None:
|
|
||||||
self.client = client
|
|
||||||
self.loop = self.client.loop
|
|
||||||
self.dc_id = dc_id or self.client.session.dc_id
|
|
||||||
self.auth_key = (
|
|
||||||
None if dc_id and self.client.session.dc_id != dc_id else self.client.session.auth_key
|
|
||||||
)
|
|
||||||
self.senders = None
|
|
||||||
self.upload_ticker = 0
|
|
||||||
|
|
||||||
async def _cleanup(self) -> None:
|
|
||||||
await asyncio.gather(*(sender.disconnect() for sender in self.senders))
|
|
||||||
self.senders = None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_connection_count(
|
|
||||||
file_size: int, max_count: int = 20, full_size: int = 100 * 1024 * 1024
|
|
||||||
) -> int:
|
|
||||||
if file_size > full_size:
|
|
||||||
return max_count
|
|
||||||
return math.ceil((file_size / full_size) * max_count)
|
|
||||||
|
|
||||||
async def _init_download(
|
|
||||||
self, connections: int, file: TypeLocation, part_count: int, part_size: int
|
|
||||||
) -> None:
|
|
||||||
minimum, remainder = divmod(part_count, connections)
|
|
||||||
|
|
||||||
def get_part_count() -> int:
|
|
||||||
nonlocal remainder
|
|
||||||
if remainder > 0:
|
|
||||||
remainder -= 1
|
|
||||||
return minimum + 1
|
|
||||||
return minimum
|
|
||||||
|
|
||||||
# The first cross-DC sender will export+import the authorization, so we always create it
|
|
||||||
# before creating any other senders.
|
|
||||||
self.senders = [
|
|
||||||
await self._create_download_sender(
|
|
||||||
file, 0, part_size, connections * part_size, get_part_count()
|
|
||||||
),
|
|
||||||
*await asyncio.gather(
|
|
||||||
*(
|
|
||||||
self._create_download_sender(
|
|
||||||
file, i, part_size, connections * part_size, get_part_count()
|
|
||||||
)
|
|
||||||
for i in range(1, connections)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
async def _create_download_sender(
|
|
||||||
self, file: TypeLocation, index: int, part_size: int, stride: int, part_count: int
|
|
||||||
) -> DownloadSender:
|
|
||||||
return DownloadSender(
|
|
||||||
await self._create_sender(), file, index * part_size, part_size, stride, part_count
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _init_upload(
|
|
||||||
self, connections: int, file_id: int, part_count: int, big: bool
|
|
||||||
) -> None:
|
|
||||||
self.senders = [
|
|
||||||
await self._create_upload_sender(file_id, part_count, big, 0, connections),
|
|
||||||
*await asyncio.gather(
|
|
||||||
*(
|
|
||||||
self._create_upload_sender(file_id, part_count, big, i, connections)
|
|
||||||
for i in range(1, connections)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
async def _create_upload_sender(
|
|
||||||
self, file_id: int, part_count: int, big: bool, index: int, stride: int
|
|
||||||
) -> UploadSender:
|
|
||||||
return UploadSender(
|
|
||||||
await self._create_sender(), file_id, part_count, big, index, stride, loop=self.loop
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _create_sender(self) -> MTProtoSender:
|
|
||||||
dc = await self.client._get_dc(self.dc_id)
|
|
||||||
sender = MTProtoSender(self.auth_key, loggers=self.client._log)
|
|
||||||
await sender.connect(
|
|
||||||
self.client._connection(
|
|
||||||
dc.ip_address, dc.port, dc.id, loggers=self.client._log, proxy=self.client._proxy
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not self.auth_key:
|
|
||||||
log.debug(f"Exporting auth to DC {self.dc_id}")
|
|
||||||
auth = await self.client(ExportAuthorizationRequest(self.dc_id))
|
|
||||||
self.client._init_request.query = ImportAuthorizationRequest(
|
|
||||||
id=auth.id, bytes=auth.bytes
|
|
||||||
)
|
|
||||||
req = InvokeWithLayerRequest(LAYER, self.client._init_request)
|
|
||||||
await sender.send(req)
|
|
||||||
self.auth_key = sender.auth_key
|
|
||||||
return sender
|
|
||||||
|
|
||||||
async def init_upload(
|
|
||||||
self,
|
|
||||||
file_id: int,
|
|
||||||
file_size: int,
|
|
||||||
part_size_kb: float | None = None,
|
|
||||||
connection_count: int | None = None,
|
|
||||||
) -> tuple[int, int, bool]:
|
|
||||||
connection_count = connection_count or self._get_connection_count(file_size)
|
|
||||||
part_size = (part_size_kb or utils.get_appropriated_part_size(file_size)) * 1024
|
|
||||||
part_count = (file_size + part_size - 1) // part_size
|
|
||||||
is_large = file_size > 10 * 1024 * 1024
|
|
||||||
await self._init_upload(connection_count, file_id, part_count, is_large)
|
|
||||||
return part_size, part_count, is_large
|
|
||||||
|
|
||||||
async def upload(self, part: bytes) -> None:
|
|
||||||
await self.senders[self.upload_ticker].next(part)
|
|
||||||
self.upload_ticker = (self.upload_ticker + 1) % len(self.senders)
|
|
||||||
|
|
||||||
async def finish_upload(self) -> None:
|
|
||||||
await self._cleanup()
|
|
||||||
|
|
||||||
async def download(
|
|
||||||
self,
|
|
||||||
file: TypeLocation,
|
|
||||||
file_size: int,
|
|
||||||
part_size_kb: float | None = None,
|
|
||||||
connection_count: int | None = None,
|
|
||||||
) -> AsyncGenerator[bytes, None]:
|
|
||||||
connection_count = connection_count or self._get_connection_count(file_size)
|
|
||||||
part_size = (part_size_kb or utils.get_appropriated_part_size(file_size)) * 1024
|
|
||||||
part_count = math.ceil(file_size / part_size)
|
|
||||||
log.debug(
|
|
||||||
f"Starting parallel download: {connection_count} {part_size} {part_count} {file!s}"
|
|
||||||
)
|
|
||||||
await self._init_download(connection_count, file, part_count, part_size)
|
|
||||||
|
|
||||||
part = 0
|
|
||||||
while part < part_count:
|
|
||||||
tasks = []
|
|
||||||
for sender in self.senders:
|
|
||||||
tasks.append(asyncio.create_task(sender.next()))
|
|
||||||
for task in tasks:
|
|
||||||
data = await task
|
|
||||||
if not data:
|
|
||||||
break
|
|
||||||
yield data
|
|
||||||
part += 1
|
|
||||||
log.trace(f"Part {part} downloaded")
|
|
||||||
|
|
||||||
log.debug("Parallel download finished, cleaning up connections")
|
|
||||||
await self._cleanup()
|
|
||||||
|
|
||||||
|
|
||||||
parallel_transfer_locks: defaultdict[int, asyncio.Lock] = defaultdict(lambda: asyncio.Lock())
|
|
||||||
|
|
||||||
|
|
||||||
async def parallel_transfer_to_matrix(
|
|
||||||
client: MautrixTelegramClient,
|
|
||||||
intent: IntentAPI,
|
|
||||||
loc_id: str,
|
|
||||||
location: TypeLocation,
|
|
||||||
filename: str,
|
|
||||||
encrypt: bool,
|
|
||||||
parallel_id: int,
|
|
||||||
) -> DBTelegramFile:
|
|
||||||
size = location.size
|
|
||||||
mime_type = location.mime_type
|
|
||||||
dc_id, location = utils.get_input_location(location)
|
|
||||||
# We lock the transfers because telegram has connection count limits
|
|
||||||
async with parallel_transfer_locks[parallel_id]:
|
|
||||||
downloader = ParallelTransferrer(client, dc_id)
|
|
||||||
data = downloader.download(location, 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, EncryptedFile):
|
|
||||||
decryption_info = 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,
|
|
||||||
was_converted=False,
|
|
||||||
timestamp=int(time.time()),
|
|
||||||
size=size,
|
|
||||||
width=None,
|
|
||||||
height=None,
|
|
||||||
decryption_info=decryption_info,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _internal_transfer_to_telegram(
|
|
||||||
client: MautrixTelegramClient, response: ClientResponse
|
|
||||||
) -> tuple[TypeInputFile, int]:
|
|
||||||
file_id = helpers.generate_random_long()
|
|
||||||
file_size = response.content_length
|
|
||||||
|
|
||||||
hash_md5 = hashlib.md5()
|
|
||||||
uploader = ParallelTransferrer(client)
|
|
||||||
part_size, part_count, is_large = await uploader.init_upload(file_id, file_size)
|
|
||||||
buffer = bytearray()
|
|
||||||
async for data in response.content:
|
|
||||||
if not is_large:
|
|
||||||
hash_md5.update(data)
|
|
||||||
if len(buffer) == 0 and len(data) == part_size:
|
|
||||||
await uploader.upload(data)
|
|
||||||
continue
|
|
||||||
new_len = len(buffer) + len(data)
|
|
||||||
if new_len >= part_size:
|
|
||||||
cutoff = part_size - len(buffer)
|
|
||||||
buffer.extend(data[:cutoff])
|
|
||||||
await uploader.upload(bytes(buffer))
|
|
||||||
buffer.clear()
|
|
||||||
buffer.extend(data[cutoff:])
|
|
||||||
else:
|
|
||||||
buffer.extend(data)
|
|
||||||
if len(buffer) > 0:
|
|
||||||
await uploader.upload(bytes(buffer))
|
|
||||||
await uploader.finish_upload()
|
|
||||||
if is_large:
|
|
||||||
return InputFileBig(file_id, part_count, "upload"), file_size
|
|
||||||
else:
|
|
||||||
return InputFile(file_id, part_count, "upload", hash_md5.hexdigest()), file_size
|
|
||||||
|
|
||||||
|
|
||||||
async def parallel_transfer_to_telegram(
|
|
||||||
client: MautrixTelegramClient, intent: IntentAPI, uri: ContentURI, parallel_id: int
|
|
||||||
) -> tuple[TypeInputFile, int]:
|
|
||||||
url = intent.api.get_download_url(uri)
|
|
||||||
async with parallel_transfer_locks[parallel_id]:
|
|
||||||
async with intent.api.session.get(url) as response:
|
|
||||||
return await _internal_transfer_to_telegram(client, response)
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user