Compare commits

..

3 Commits

Author SHA1 Message Date
Tulir Asokan 92830c3a64 Bump version to 0.6.0rc1 2019-09-03 00:23:35 +03:00
Tulir Asokan 00faab2213 Ignore Riot web brokenness in edits 2019-09-03 00:22:45 +03:00
Tulir Asokan d6294ebb45 Fix some errors 2019-09-03 00:17:52 +03:00
18147 changed files with 13793 additions and 955099 deletions
+8
View File
@@ -0,0 +1,8 @@
engines:
sonar-python:
enabled: true
checks:
python:S107:
enabled: false
exclude_patterns:
- "alembic/"
+1 -5
View File
@@ -1,8 +1,4 @@
.editorconfig
.codeclimate.yml
*.png
*.md
logs
start
config.yaml
registration.yaml
*.db
+3 -8
View File
@@ -8,13 +8,8 @@ charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
indent_size = 2
indent_style = space
[*.py]
max_line_length = 99
[*.{yaml,yml,sql}]
[*.{yaml,yml,py}]
indent_style = space
[{.gitlab-ci.yml,.pre-commit-config.yaml,provisioning-spec.yaml,.github/workflows/*.yml}]
indent_size = 2
-4
View File
@@ -1,4 +0,0 @@
pkg/connector/humanise/errors.go linguist-generated=true
pkg/gotd/tg/* linguist-generated=true
tl_*_gen.go linguist-generated=true
*.gen.go linguist-generated=true
-17
View File
@@ -1,17 +0,0 @@
---
name: Bug report
about: If something is definitely wrong in the bridge (rather than just a setup issue),
file a bug report. Remember to include relevant logs. Asking in the Matrix room first
is strongly recommended.
type: Bug
---
<!-- Include relevant logs, the bridge version and other important details here -->
### Checklist
<!-- Both items below are mandatory. Issues not following the rules may be closed without comment. -->
* [ ] This is an actual bug, not just a setup issue (see the [troubleshooting docs](https://docs.mau.fi/bridges/general/troubleshooting.html) or ask in the Matrix room for setup help).
* [ ] I am certain that sufficient information is included. Ask in the Matrix room first if not.
-7
View File
@@ -1,7 +0,0 @@
contact_links:
- name: Troubleshooting docs & FAQ
url: https://docs.mau.fi/bridges/general/troubleshooting.html
about: Check this first if you're having problems setting up the bridge.
- name: Support room
url: https://matrix.to/#/#telegram:maunium.net
about: For setup issues not answered by the troubleshooting docs, ask in the Matrix room.
-6
View File
@@ -1,6 +0,0 @@
---
name: Enhancement request
about: Submit a feature request or other suggestion
type: Feature
---
-39
View File
@@ -1,39 +0,0 @@
name: Go
on: [push, pull_request]
env:
GOTOOLCHAIN: local
jobs:
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
go-version: ["1.25", "1.26"]
name: Lint ${{ matrix.go-version == '1.26' && '(latest)' || '(old)' }}
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Install libolm
run: sudo apt-get install libolm-dev libolm3
- name: Install dependencies
run: |
go install golang.org/x/tools/cmd/goimports@latest
go install honnef.co/go/tools/cmd/staticcheck@latest
export PATH="$HOME/go/bin:$PATH"
- name: Install pre-commit
run: pip install pre-commit
- name: Lint
run: pre-commit run -a
-29
View File
@@ -1,29 +0,0 @@
name: 'Lock old issues'
on:
schedule:
- cron: '0 20 * * *'
workflow_dispatch:
permissions:
issues: write
# pull-requests: write
# discussions: write
concurrency:
group: lock-threads
jobs:
lock-stale:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@v6
id: lock
with:
issue-inactive-days: 90
process-only: issues
- name: Log processed threads
run: |
if [ '${{ steps.lock.outputs.issues }}' ]; then
echo "Issues:" && echo '${{ steps.lock.outputs.issues }}' | jq -r '.[] | "https://github.com/\(.owner)/\(.repo)/issues/\(.issue_number)"'
fi
+14 -13
View File
@@ -1,16 +1,17 @@
.idea
.idea/
*.yaml
!.pre-commit-config.yaml
!example-config.yaml
!provisioning-spec.yaml
.venv
env/
pip-selfcheck.json
*.pyc
__pycache__
build
dist
*.egg-info
.eggs
*.json
!pkg/connector/emojis/unicodemojipack.json
*.db*
*.log
config.yaml
registration.yaml
*.log*
*.db
*.bak
/mautrix-telegram
/mautrix-telegramgo
/start
-3
View File
@@ -1,3 +0,0 @@
include:
- project: 'mautrix/ci'
file: '/gov2-as-default.yml'
-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-31
View File
@@ -1,31 +0,0 @@
exclude: pkg/gotd/_fuzz/.*|pkg/gotd/_schema/.*|pkg/gotd/.*\.tmpl
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/tekwizely/pre-commit-golang
rev: v1.0.0-rc.4
hooks:
- id: go-imports
args:
- "-local"
- "go.mau.fi/mautrix-telegram"
- "-w"
- id: go-vet-mod
# Disabled for now until we can find a way to filter out the gotd package
# - id: go-staticcheck-repo-mod
- id: go-mod-tidy
- repo: https://github.com/beeper/pre-commit-go
rev: v0.4.2
hooks:
- id: prevent-literal-http-methods
- id: zerolog-ban-global-log
- id: zerolog-ban-msgf
- id: zerolog-use-stringer
-1071
View File
File diff suppressed because it is too large Load Diff
+38 -14
View File
@@ -1,20 +1,44 @@
FROM golang:1-alpine3.23 AS builder
RUN apk add --no-cache git ca-certificates build-base su-exec olm-dev
COPY . /build
WORKDIR /build
RUN ./build.sh
FROM alpine:3.23
FROM docker.io/alpine:3.10
ENV UID=1337 \
GID=1337
GID=1337 \
FFMPEG_BINARY=/usr/bin/ffmpeg
RUN apk add --no-cache ffmpeg su-exec ca-certificates olm bash jq curl yq-go lottieconverter
COPY . /opt/mautrix-telegram
WORKDIR /opt/mautrix-telegram
RUN apk add --no-cache \
py3-virtualenv \
py3-pillow \
py3-aiohttp \
py3-magic \
py3-sqlalchemy \
py3-markdown \
py3-psycopg2 \
py3-ruamel.yaml \
# Indirect dependencies
#commonmark
py3-future \
#alembic
py3-mako \
py3-dateutil \
py3-markupsafe \
#moviepy
py3-decorator \
#py3-tqdm \
py3-requests \
#imageio
py3-numpy \
#telethon
py3-rsa \
# Other dependencies
python3-dev \
libffi-dev \
build-base \
ffmpeg \
ca-certificates \
su-exec \
&& pip3 install .[all]
COPY --from=builder /build/mautrix-telegram /usr/bin/mautrix-telegram
COPY --from=builder /build/docker-run.sh /docker-run.sh
VOLUME /data
CMD ["/docker-run.sh"]
CMD ["/opt/mautrix-telegram/docker-run.sh"]
-17
View File
@@ -1,17 +0,0 @@
ARG DOCKER_HUB="docker.io"
FROM ${DOCKER_HUB}/alpine:3.23
ENV UID=1337 \
GID=1337
RUN apk add --no-cache ffmpeg su-exec ca-certificates bash jq curl yq-go lottieconverter
ARG EXECUTABLE=./mautrix-telegram
COPY $EXECUTABLE /usr/bin/mautrix-telegram
COPY ./docker-run.sh /docker-run.sh
ENV BRIDGEV2=1
VOLUME /data
WORKDIR /data
CMD ["/docker-run.sh"]
-12
View File
@@ -1,12 +0,0 @@
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.
+6 -19
View File
@@ -1,27 +1,14 @@
# mautrix-telegram
![Languages](https://img.shields.io/github/languages/top/mautrix/telegram.svg)
[![License](https://img.shields.io/github/license/mautrix/telegram.svg)](LICENSE)
[![Release](https://img.shields.io/github/release/mautrix/telegram/all.svg)](https://github.com/mautrix/telegram/releases)
[![GitLab CI](https://mau.dev/mautrix/telegram/badges/main/pipeline.svg)](https://mau.dev/mautrix/telegram/container_registry)
A Matrix-Telegram hybrid puppeting/relaybot bridge.
A Matrix-Telegram puppeting/relaybot bridge.
### [Wiki](https://github.com/tulir/mautrix-telegram/wiki)
## Sponsors
* [Joel Lehtonen / Zouppen](https://github.com/zouppen)
## Documentation
All setup and usage instructions are located on
[docs.mau.fi](https://docs.mau.fi/bridges/go/telegram/index.html).
Some quick links:
* [Bridge setup](https://docs.mau.fi/bridges/go/setup.html?bridge=telegram)
(or [with Docker](https://docs.mau.fi/bridges/general/docker-setup.html?bridge=telegram))
* Basic usage: [Authentication](https://docs.mau.fi/bridges/go/telegram/authentication.html)
### Features & Roadmap
[ROADMAP.md](ROADMAP.md) contains a general overview of what is supported by the bridge.
### [Features & Roadmap](https://github.com/tulir/mautrix-telegram/blob/master/ROADMAP.md)
## Discussion
Matrix room: [`#telegram:maunium.net`](https://matrix.to/#/#telegram:maunium.net)
Telegram chat: [`mautrix_telegram`](https://t.me/mautrix_telegram) (bridged to Matrix room)
## Preview
![Preview](https://raw.githubusercontent.com/tulir/mautrix-telegram/master/preview.png)
+27 -25
View File
@@ -3,37 +3,39 @@
* Matrix → Telegram
* [x] Message content (text, formatting, files, etc..)
* [x] Message redactions
* [x] Message reactions
* [x] Message edits
* [ ] ‡ Message history
* [ ] Presence
* [x] Presence
* [x] Typing notifications
* [x] Read receipts
* [ ] Pinning messages
* [ ] Power level
* [ ] Membership actions (invite/kick/join/leave)
* [ ] Room metadata changes (name, topic, avatar)
* [ ] Initial room metadata
* [x] Pinning messages
* [x] Power level
* [x] Normal chats
* [ ] Non-hardcoded PL requirements
* [x] Supergroups/channels
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
* [x] Membership actions (invite/kick/join/leave)
* [x] Room metadata changes (name, topic, avatar)
* [x] Initial room metadata
* [ ] User metadata
* [ ] Initial displayname/username/avatar at register
* [ ] ‡ Changes to displayname/avatar
* Telegram → Matrix
* [x] Message content (text, formatting, files, etc..)
* [ ] Advanced message content/media
* [x] Custom emojis
* [ ] Polls
* [ ] Games
* [ ] Buttons
* [x] Polls
* [x] Games
* [ ] Buttons
* [x] Message deletions
* [x] Message reactions
* [x] Message edits
* [x] Message history
* [x] Automatically when creating portal
* [x] Automatically for missed messages
* [ ] Message history
* [x] Avatars
* [ ] Presence
* [x] Presence
* [x] Typing notifications
* [x] Read receipts (DMs only)
* [ ] Pinning messages
* [x] Read receipts (private chat only)
* [x] Pinning messages
* [x] Admin/chat creator status
* [x] 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)
* [ ] Chat metadata changes
* [x] Title
@@ -43,16 +45,16 @@
* [x] Initial chat metadata (about text missing)
* [x] User metadata (displayname/avatar)
* [x] Supergroup upgrade
* [x] Topics (spaces)
* Misc
* [x] Automatic portal creation
* [x] At startup
* [x] When receiving invite or message
* [x] Private chat creation by inviting Matrix ghost of Telegram user to new room
* [x] Option to use bot to relay messages for unauthenticated Matrix users (relaybot)
* [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting)
* [ ] ‡ Calls
* [ ] ‡ Secret chats (i.e. end-to-bridge encryption on Telegram)
* [x] Private chat creation by inviting Matrix puppet of Telegram user to new room
* [x] Option to use bot to relay messages for unauthenticated Matrix users
* [x] Option to use own Matrix account for messages sent from other Telegram clients
* [ ] ‡ Calls (hard, not yet supported by Telethon)
* [ ] ‡ Secret chats (not yet supported by Telethon)
* [ ] ‡ E2EE in Matrix rooms (not yet supported
† Information not automatically sent from source, i.e. implementation may not be possible
‡ Maybe, i.e. this feature may or may not be implemented at some point
+71
View File
@@ -0,0 +1,71 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# timezone to use when rendering the date
# within the migration file as well as the filename.
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
#truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; this defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
# version_locations = %(here)s/bar %(here)s/bat alembic/versions
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+95
View File
@@ -0,0 +1,95 @@
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
import sys
from os.path import abspath, dirname
sys.path.insert(0, dirname(dirname(abspath(__file__))))
from mautrix_telegram.db import Base
from mautrix_telegram.config import Config
from alchemysession import AlchemySessionContainer
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
mxtg_config = Config(mxtg_config_path, None, None)
mxtg_config.load()
config.set_main_option("sqlalchemy.url",
mxtg_config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
class FakeDB:
@staticmethod
def query_property():
return None
AlchemySessionContainer.create_table_classes(FakeDB(), "telethon_", Base)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix='sqlalchemy.',
poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}
@@ -0,0 +1,27 @@
"""Add disable_updates field for puppets
Revision ID: 17574c57f3f8
Revises: a9119be92164
Create Date: 2019-05-15 00:24:46.967529
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '17574c57f3f8'
down_revision = 'a9119be92164'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.add_column(sa.Column("disable_updates", sa.Boolean(), nullable=False,
server_default=sa.sql.expression.false()))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column("disable_updates")
@@ -0,0 +1,28 @@
"""Add TelegramFile table
Revision ID: 1b241f7e8530
Revises: 97d2a942bcf8
Create Date: 2018-02-19 23:52:06.605741
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1b241f7e8530'
down_revision = '97d2a942bcf8'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('telegram_file',
sa.Column('id', sa.String(), nullable=False),
sa.Column('mxc', sa.String(), nullable=True),
sa.Column('mime_type', sa.String(), nullable=True),
sa.Column('was_converted', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'))
def downgrade():
op.drop_table('telegram_file')
@@ -0,0 +1,26 @@
"""Add is_bot field to puppets
Revision ID: 1fa46383a9d3
Revises: 30eca60587f1
Create Date: 2018-04-29 23:44:40.102333
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1fa46383a9d3'
down_revision = '30eca60587f1'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.add_column(sa.Column('is_bot', sa.Boolean(), nullable=True))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column('is_bot')
@@ -0,0 +1,41 @@
"""Add cascade rules to UserPortal
Revision ID: 2228d49c383f
Revises: bcfefa1f1299
Create Date: 2018-05-31 11:11:59.482112
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2228d49c383f'
down_revision = 'bcfefa1f1299'
branch_labels = None
depends_on = None
def upgrade():
try:
with op.batch_alter_table("user_portal") as batch_op:
batch_op.drop_constraint("user_portal_user_fkey", type_="foreignkey")
batch_op.drop_constraint("user_portal_portal_fkey", type_="foreignkey")
batch_op.create_foreign_key("user_portal_user_fkey", "user", ["user"], ["tgid"],
onupdate="CASCADE", ondelete="CASCADE")
batch_op.create_foreign_key("user_portal_portal_fkey", "portal",
["portal", "portal_receiver"], ["tgid", "tg_receiver"],
onupdate="CASCADE", ondelete="CASCADE")
except ValueError:
return
def downgrade():
try:
with op.batch_alter_table("user_portal") as batch_op:
batch_op.drop_constraint("user_portal_user_fkey", type_="foreignkey")
batch_op.drop_constraint("user_portal_portal_fkey", type_="foreignkey")
batch_op.create_foreign_key("user_portal_user_fkey", "portal",
["portal", "portal_receiver"], ["tgid", "tg_receiver"])
batch_op.create_foreign_key("user_portal_portal_fkey", "user", ["user"], ["tgid"])
except ValueError:
return
@@ -0,0 +1,25 @@
"""Add megagroup field to portals
Revision ID: 30eca60587f1
Revises: cfc972368e50
Create Date: 2018-04-29 15:51:04.656605
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '30eca60587f1'
down_revision = 'cfc972368e50'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("portal") as batch_op:
batch_op.add_column(sa.Column('megagroup', sa.Boolean()))
def downgrade():
with op.batch_alter_table("portal") as batch_op:
batch_op.drop_column('megagroup')
@@ -0,0 +1,111 @@
"""Move sessions to main database
Revision ID: 501dad2868bc
Revises: 7d47d84380b6
Create Date: 2018-03-02 19:15:53.826985
"""
from alembic import op
import sqlalchemy as sa
import sqlite3
import os
# revision identifiers, used by Alembic.
revision = '501dad2868bc'
down_revision = '7d47d84380b6'
branch_labels = None
depends_on = None
def upgrade():
Session = op.create_table('telethon_sessions',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('dc_id', sa.Integer, nullable=False),
sa.Column('server_address', sa.String, nullable=True),
sa.Column('port', sa.Integer, nullable=True),
sa.Column('auth_key', sa.LargeBinary, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'dc_id'))
SentFile = op.create_table('telethon_sent_files',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('md5_digest', sa.LargeBinary, nullable=False),
sa.Column('file_size', sa.Integer, nullable=False),
sa.Column('type', sa.Integer, nullable=False),
sa.Column('id', sa.BigInteger, nullable=True),
sa.Column('hash', sa.BigInteger, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'md5_digest', 'file_size',
'type'))
Entity = op.create_table('telethon_entities',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('id', sa.Integer, nullable=False),
sa.Column('hash', sa.Integer, nullable=False),
sa.Column('username', sa.String, nullable=True),
sa.Column('phone', sa.Integer, nullable=True),
sa.Column('name', sa.String, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'id'))
Version = op.create_table('telethon_version',
sa.Column('version', sa.Integer, nullable=False),
sa.PrimaryKeyConstraint('version'))
conn = op.get_bind()
sessions = [os.path.basename(f) for f in os.listdir(".") if f.endswith(".session")]
for session in sessions:
session_to_sqlalchemy(conn, session, Session, SentFile, Entity)
def session_to_sqlalchemy(conn, path, Session, SentFile, Entity):
session_conn = sqlite3.connect(path)
session_id = os.path.splitext(path)[0]
c = session_conn.cursor()
auth_data_tuples = c.execute("SELECT * FROM sessions").fetchall()
auth_data_dicts = []
for row in auth_data_tuples:
dc_id, server_address, port, auth_key = row
auth_data_dicts.append({
"session_id": session_id,
"dc_id": dc_id,
"server_address": server_address,
"port": port,
"auth_key": auth_key,
})
if auth_data_dicts:
conn.execute(Session.insert().values(auth_data_dicts))
sent_file_tuples = c.execute("SELECT * FROM sent_files").fetchall()
sent_file_dicts = []
for row in sent_file_tuples:
md5_digest, file_size, type, id, hash = row
sent_file_dicts.append({
"session_id": session_id,
"md5_digest": md5_digest,
"file_size": file_size,
"type": type,
"id": id,
"hash": hash,
})
if sent_file_dicts:
conn.execute(SentFile.insert().values(sent_file_dicts))
entity_tuples = c.execute("SELECT * FROM entities").fetchall()
entity_dicts = []
for row in entity_tuples:
id, hash, username, phone, name = row
entity_dicts.append({
"session_id": session_id,
"id": id,
"hash": hash,
"username": username,
"phone": phone,
"name": name,
})
if entity_dicts:
conn.execute(Entity.insert().values(entity_dicts))
c.close()
session_conn.close()
def downgrade():
op.drop_table('telethon_version')
op.drop_table('telethon_entities')
op.drop_table('telethon_sent_files')
op.drop_table('telethon_sessions')
@@ -0,0 +1,137 @@
"""Move state store to main database
Revision ID: 6ca3d74d51e4
Revises: 2228d49c383f
Create Date: 2018-06-26 21:31:26.911307
"""
from alembic import context, op
import sqlalchemy.orm as orm
import sqlalchemy as sa
import json
import re
from mautrix_telegram.config import Config
from mautrix_telegram.db import Base
# revision identifiers, used by Alembic.
revision = "6ca3d74d51e4"
down_revision = "2228d49c383f"
branch_labels = None
depends_on = None
class RoomState(Base):
query = None
__tablename__ = "mx_room_state"
__table_args__ = {"extend_existing": True}
room_id = sa.Column(sa.String, primary_key=True)
power_levels = sa.Column("power_levels", sa.Text, nullable=True)
class UserProfile(Base):
query = None
__tablename__ = "mx_user_profile"
__table_args__ = {"extend_existing": True}
room_id = sa.Column(sa.String, primary_key=True)
user_id = sa.Column(sa.String, primary_key=True)
membership = sa.Column(sa.String, nullable=False, default="leave")
displayname = sa.Column(sa.String, nullable=True)
avatar_url = sa.Column(sa.String, nullable=True)
class Puppet(Base):
query = None
__tablename__ = "puppet"
__table_args__ = {"extend_existing": True}
id = sa.Column(sa.Integer, primary_key=True)
displayname = sa.Column(sa.String, nullable=True)
displayname_source = sa.Column(sa.Integer, nullable=True)
username = sa.Column(sa.String, nullable=True)
photo_id = sa.Column(sa.String, nullable=True)
is_bot = sa.Column(sa.Boolean, nullable=True)
matrix_registered = sa.Column(sa.Boolean, nullable=False, default=False)
def upgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.add_column(sa.Column("matrix_registered", sa.Boolean(), nullable=False,
server_default=sa.sql.expression.false()))
op.create_table("mx_room_state",
sa.Column("room_id", sa.String(), nullable=False),
sa.Column("power_levels", sa.Text(), nullable=True),
sa.PrimaryKeyConstraint("room_id"))
op.create_table("mx_user_profile",
sa.Column("room_id", sa.String(), nullable=False),
sa.Column("user_id", sa.String(), nullable=False),
sa.Column("membership", sa.String(), nullable=False,
default="leave"),
sa.Column("displayname", sa.String(), nullable=True),
sa.Column("avatar_url", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("room_id", "user_id"))
try:
migrate_state_store()
except Exception as e:
print("Failed to migrate state store:", e)
print("Migrating the state store isn't required, but you can retry by alembic downgrading "
"to revision 2228d49c383f and upgrading again.")
def migrate_state_store():
conn = op.get_bind()
session = orm.sessionmaker(bind=conn)() # type: orm.Session
try:
with open("mx-state.json") as file:
data = json.load(file)
except FileNotFoundError:
return
if not data:
return
registrations = data.get("registrations", [])
mxtg_config_path = context.get_x_argument(as_dictionary=True).get("config", "config.yaml")
mxtg_config = Config(mxtg_config_path, None, None)
mxtg_config.load()
username_template = mxtg_config.get("bridge.username_template", "telegram_{userid}")
hs_domain = mxtg_config["homeserver.domain"]
localpart = username_template.format(userid="(.+)")
mxid_regex = re.compile("@{}:{}".format(localpart, hs_domain))
for user in registrations:
match = mxid_regex.match(user)
if not match:
continue
puppet = session.query(Puppet).get(match.group(1))
if not puppet:
continue
puppet.matrix_registered = True
session.merge(puppet)
session.commit()
user_profiles = [UserProfile(room_id=room, user_id=user,
membership=member.get("membership", "leave"),
displayname=member.get("displayname", None),
avatar_url=member.get("avatar_url", None))
for room, members in data.get("members", {}).items()
for user, member in members.items()]
session.add_all(user_profiles)
session.commit()
room_state = [RoomState(room_id=room, power_levels=json.dumps(levels))
for room, levels in data.get("power_levels", {}).items()]
session.add_all(room_state)
session.commit()
def downgrade():
op.drop_table("mx_user_profile")
op.drop_table("mx_room_state")
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column("matrix_registered")
@@ -0,0 +1,26 @@
"""Add timestamp to TelegramFile
Revision ID: 7d47d84380b6
Revises: 1b241f7e8530
Create Date: 2018-02-19 23:53:18.050871
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7d47d84380b6'
down_revision = '1b241f7e8530'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('telegram_file',
sa.Column('timestamp', sa.BigInteger(), nullable=True, default=0,
server_default="0"))
def downgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.drop_column('timestamp')
@@ -0,0 +1,80 @@
"""initial revision
Revision ID: 97d2a942bcf8
Revises:
Create Date: 2018-02-11 18:40:55.483842
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '97d2a942bcf8'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table('portal',
sa.Column('tgid', sa.Integer),
sa.Column('tg_receiver', sa.Integer),
sa.Column('peer_type', sa.String, nullable=False, default=""),
sa.Column('mxid', sa.String, nullable=True),
sa.Column('username', sa.String, nullable=True),
sa.Column('title', sa.String, nullable=True),
sa.Column('about', sa.String, nullable=True),
sa.Column('photo_id', sa.String, nullable=True),
sa.PrimaryKeyConstraint('tgid', 'tg_receiver'),
sa.UniqueConstraint('mxid'))
op.create_table('user',
sa.Column('mxid', sa.String),
sa.Column('tgid', sa.Integer, nullable=True, unique=True),
sa.Column('tg_username', sa.String, nullable=True),
sa.Column('saved_contacts', sa.Integer, nullable=False, default=0),
sa.PrimaryKeyConstraint('mxid'))
op.create_table('puppet',
sa.Column('id', sa.Integer),
sa.Column('displayname', sa.String, nullable=True),
sa.Column('username', sa.String, nullable=True),
sa.Column('photo_id', sa.String, nullable=True),
sa.PrimaryKeyConstraint('id'))
op.create_table('contact',
sa.Column('user', sa.Integer),
sa.Column('contact', sa.Integer),
sa.ForeignKeyConstraint(("user",), ("user.tgid",)),
sa.ForeignKeyConstraint(("contact",), ("puppet.id",)),
sa.PrimaryKeyConstraint('user', 'contact'))
op.create_table('user_portal',
sa.Column('user', sa.Integer),
sa.Column('portal', sa.Integer),
sa.Column('portal_receiver', sa.Integer),
sa.PrimaryKeyConstraint('user', 'portal', 'portal_receiver'),
sa.ForeignKeyConstraint(("user",), ("user.tgid",),
name="user_portal_user_fkey",
onupdate="CASCADE", ondelete="CASCADE"),
sa.ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"),
name="user_portal_portal_fkey",
onupdate="CASCADE", ondelete="CASCADE"))
op.create_table('message',
sa.Column('mxid', sa.String),
sa.Column('mx_room', sa.String),
sa.Column('tgid', sa.Integer),
sa.Column('tg_space', sa.Integer),
sa.PrimaryKeyConstraint('tgid', 'tg_space'),
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"))
op.create_table('bot_chat',
sa.Column('id', sa.Integer),
sa.Column('type', sa.String, nullable=False, default=""),
sa.PrimaryKeyConstraint('id'))
def downgrade():
op.drop_table('bot_chat')
op.drop_table('message')
op.drop_table('user_portal')
op.drop_table('contact')
op.drop_table('puppet')
op.drop_table('user')
op.drop_table('portal')
@@ -0,0 +1,48 @@
"""Add edit index to messages
Revision ID: 9e9c89b0b877
Revises: 17574c57f3f8
Create Date: 2019-05-29 15:28:23.128377
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9e9c89b0b877'
down_revision = '17574c57f3f8'
branch_labels = None
depends_on = None
def upgrade():
op.create_table('_message_temp',
sa.Column('mxid', sa.String),
sa.Column('mx_room', sa.String),
sa.Column('tgid', sa.Integer),
sa.Column('tg_space', sa.Integer),
sa.Column('edit_index', sa.Integer),
sa.PrimaryKeyConstraint('tgid', 'tg_space', 'edit_index'),
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room_2"))
c = op.get_bind()
c.execute("INSERT INTO _message_temp (mxid, mx_room, tgid, tg_space, edit_index) "
"SELECT message.mxid, message.mx_room, message.tgid, message.tg_space, 0 "
"FROM message")
c.execute("DROP TABLE message")
c.execute("ALTER TABLE _message_temp RENAME TO message")
def downgrade():
op.create_table('_message_temp',
sa.Column('mxid', sa.String),
sa.Column('mx_room', sa.String),
sa.Column('tgid', sa.Integer),
sa.Column('tg_space', sa.Integer),
sa.PrimaryKeyConstraint('tgid', 'tg_space'),
sa.UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"))
c = op.get_bind()
c.execute("INSERT INTO _message_temp (mxid, mx_room, tgid, tg_space) "
"SELECT message.mxid, message.mx_room, message.tgid, message.tg_space "
"FROM message")
c.execute("DROP TABLE message")
c.execute("ALTER TABLE _message_temp RENAME TO message")
@@ -0,0 +1,25 @@
"""Add phone number field to users
Revision ID: a9119be92164
Revises: b54929c22c86
Create Date: 2018-09-28 02:38:40.626282
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a9119be92164"
down_revision = "b54929c22c86"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("user", sa.Column("tg_phone", sa.String(), nullable=True))
def downgrade():
with op.batch_alter_table("user") as batch_op:
batch_op.drop_column("tg_phone")
@@ -0,0 +1,25 @@
"""Add portal-specific config
Revision ID: b54929c22c86
Revises: d5f7b8b4b456
Create Date: 2018-09-24 23:40:33.528710
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "b54929c22c86"
down_revision = "d5f7b8b4b456"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("portal", sa.Column("config", sa.Text(), nullable=True))
def downgrade():
with op.batch_alter_table("portal") as batch_op:
batch_op.drop_column("config")
@@ -0,0 +1,24 @@
"""Add displayname source fields for puppets
Revision ID: bcfefa1f1299
Revises: bdadd173ee02
Create Date: 2018-05-19 17:00:21.078098
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bcfefa1f1299'
down_revision = 'bdadd173ee02'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('puppet', sa.Column('displayname_source', sa.Integer(), nullable=True))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column('displayname_source')
@@ -0,0 +1,43 @@
"""Update telethon update state table
Revision ID: bdadd173ee02
Revises: eeaf0dae87ce
Create Date: 2018-05-13 10:42:59.395597
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bdadd173ee02'
down_revision = 'eeaf0dae87ce'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column("id", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("hash", existing_type=sa.Integer, type_=sa.BigInteger)
with op.batch_alter_table("telethon_update_state") as batch_op:
batch_op.alter_column("entity_id", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("pts", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("qts", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("date", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.alter_column("seq", existing_type=sa.Integer, type_=sa.BigInteger)
batch_op.add_column(sa.Column("unread_count", sa.Integer))
def downgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column("id", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("hash", existing_type=sa.BigInteger, type_=sa.Integer)
with op.batch_alter_table("telethon_update_state") as batch_op:
batch_op.alter_column("entity_id", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("pts", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("qts", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("date", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.alter_column("seq", existing_type=sa.BigInteger, type_=sa.Integer)
batch_op.drop_column("unread_count")
@@ -0,0 +1,35 @@
"""Add metadata to TelegramFile
Revision ID: cfc972368e50
Revises: 501dad2868bc
Create Date: 2018-03-09 16:07:01.236712
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'cfc972368e50'
down_revision = '501dad2868bc'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.add_column(sa.Column('size', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('width', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('height', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('thumbnail', sa.String(), nullable=True))
batch_op.create_foreign_key(constraint_name="fk_file_thumbnail",
referent_table="telegram_file",
local_cols=['thumbnail'],
remote_cols=['id'])
def downgrade():
with op.batch_alter_table("telegram_file") as batch_op:
batch_op.drop_column('size')
batch_op.drop_column('width')
batch_op.drop_column('height')
batch_op.drop_column('thumbnail')
@@ -0,0 +1,26 @@
"""Add access_token and custom_mxid fields for puppets
Revision ID: d5f7b8b4b456
Revises: 6ca3d74d51e4
Create Date: 2018-07-20 12:09:30.277960
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "d5f7b8b4b456"
down_revision = "6ca3d74d51e4"
branch_labels = None
depends_on = None
def upgrade():
op.add_column("puppet", sa.Column("access_token", sa.String(), nullable=True))
op.add_column("puppet", sa.Column("custom_mxid", sa.String(), nullable=True))
def downgrade():
with op.batch_alter_table("puppet") as batch_op:
batch_op.drop_column("custom_mxid")
batch_op.drop_column("access_token")
@@ -0,0 +1,34 @@
"""Add telethon update state table
Revision ID: eeaf0dae87ce
Revises: 1fa46383a9d3
Create Date: 2018-04-30 17:30:59.610885
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'eeaf0dae87ce'
down_revision = '1fa46383a9d3'
branch_labels = None
depends_on = None
def upgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column('phone', existing_type=sa.Integer, type_=sa.BigInteger)
op.create_table('telethon_update_state',
sa.Column('session_id', sa.String, nullable=False),
sa.Column('entity_id', sa.Integer, nullable=False),
sa.Column('pts', sa.Integer, nullable=True),
sa.Column('qts', sa.Integer, nullable=True),
sa.Column('date', sa.Integer, nullable=True),
sa.Column('seq', sa.Integer, nullable=True),
sa.PrimaryKeyConstraint('session_id', 'entity_id'))
def downgrade():
with op.batch_alter_table("telethon_entities") as batch_op:
batch_op.alter_column('phone', existing_type=sa.BigInteger, type_=sa.Integer)
op.drop_table('telethon_update_state')
-2
View File
@@ -1,2 +0,0 @@
#!/bin/sh
BINARY_NAME=mautrix-telegram go tool maubuild "$@"
-102
View File
@@ -1,102 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2025 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/>.
package main
import (
"context"
_ "embed"
"fmt"
"github.com/rs/zerolog"
up "go.mau.fi/util/configupgrade"
"go.mau.fi/util/dbutil"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
)
const legacyMigrateRenameTablesQuery = `
ALTER TABLE backfill_queue RENAME TO backfill_queue_old;
ALTER TABLE bot_chat RENAME TO bot_chat_old;
ALTER TABLE contact RENAME TO contact_old;
ALTER TABLE disappearing_message RENAME TO disappearing_message_old;
ALTER TABLE message RENAME TO message_old;
ALTER TABLE portal RENAME TO portal_old;
ALTER TABLE puppet RENAME TO puppet_old;
ALTER TABLE reaction RENAME TO reaction_old;
ALTER TABLE telegram_file RENAME TO telegram_file_old;
ALTER TABLE telethon_entities RENAME TO telethon_entities_old;
ALTER TABLE telethon_sent_files RENAME TO telethon_sent_files_old;
ALTER TABLE telethon_sessions RENAME TO telethon_sessions_old;
ALTER TABLE telethon_update_state RENAME TO telethon_update_state_old;
ALTER TABLE "user" RENAME TO user_old;
ALTER TABLE user_portal RENAME TO user_portal_old;
DROP INDEX IF EXISTS telegram_file_mxc_idx;
`
func legacyMigrateRenameTables(ctx context.Context, db *dbutil.Database) error {
_, err := db.Exec(ctx, legacyMigrateRenameTablesQuery)
if err != nil {
return err
}
var mxVersion int
err = db.QueryRow(ctx, "SELECT version FROM mx_version").Scan(&mxVersion)
if err != nil {
return fmt.Errorf("failed to get mx_version: %w", err)
} else if mxVersion == 3 {
zerolog.Ctx(ctx).Debug().Msg("mx_version is 3, adding create_event column before running actual migration")
_, err = db.Exec(ctx, `
ALTER TABLE mx_room_state ADD COLUMN create_event TEXT;
UPDATE mx_version SET version=4;
`)
if err != nil {
return fmt.Errorf("failed to add create_event column to mx_room_state: %w", err)
}
}
return nil
}
//go:embed legacymigrate.sql
var legacyMigrateCopyData string
func migrateLegacyConfig(helper up.Helper) {
helper.Set(up.Str, "mautrix.bridge.e2ee", "encryption", "pickle_key")
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"telegram", "api_id"}, []string{"network", "api_id"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "api_hash"}, []string{"network", "api_hash"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "device_model"}, []string{"network", "device_info", "device_model"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "system_version"}, []string{"network", "device_info", "system_version"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "app_version"}, []string{"network", "device_info", "app_version"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "lang_code"}, []string{"network", "device_info", "lang_code"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "device_info", "system_lang_code"}, []string{"network", "device_info", "system_lang_code"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"bridge", "animated_sticker", "target"}, []string{"network", "animated_sticker", "target"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "animated_sticker", "convert_from_webm"}, []string{"network", "animated_sticker", "convert_from_webm"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "animated_sticker", "width"}, []string{"network", "animated_sticker", "width"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "animated_sticker", "height"}, []string{"network", "animated_sticker", "height"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "animated_sticker", "fps"}, []string{"network", "animated_sticker", "fps"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "max_initial_member_sync"}, []string{"network", "member_list", "max_initial_sync"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "sync_channel_members"}, []string{"network", "member_list", "sync_broadcast_channels"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "skip_deleted_members"}, []string{"network", "member_list", "skip_deleted"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "proxy", "type"}, []string{"network", "proxy", "type"})
proxyAddress, _ := helper.Get(up.Str, "telegram", "proxy", "address")
proxyPort, _ := helper.Get(up.Int, "telegram", "proxy", "port")
helper.Set(up.Str, fmt.Sprintf("%s:%s", proxyAddress, proxyPort), "network", "proxy", "address")
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "proxy", "username"}, []string{"network", "proxy", "username"})
bridgeconfig.CopyToOtherLocation(helper, up.Str, []string{"telegram", "proxy", "password"}, []string{"network", "proxy", "password"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "max_member_count"}, []string{"network", "max_member_count"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "sync_update_limit"}, []string{"network", "sync", "update_limit"})
bridgeconfig.CopyToOtherLocation(helper, up.Int, []string{"bridge", "sync_create_limit"}, []string{"network", "sync", "create_limit"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "sync_direct_chats"}, []string{"network", "sync", "direct_chats"})
bridgeconfig.CopyToOtherLocation(helper, up.Bool, []string{"bridge", "always_custom_emoji_reaction"}, []string{"network", "always_custom_emoji_reaction"})
}
-297
View File
@@ -1,297 +0,0 @@
INSERT INTO "user" (bridge_id, mxid)
SELECT '', mxid FROM user_old;
DELETE FROM telethon_sessions_old WHERE auth_key IS NULL;
ALTER TABLE telethon_sessions_old ADD COLUMN json_data jsonb;
UPDATE telethon_sessions_old SET json_data=
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'auth_key', encode(auth_key, 'base64'),
'dc_id', dc_id,
'server_address', server_address,
'port', port
);
INSERT INTO user_login (bridge_id, user_mxid, id, remote_name, remote_profile, space_room, metadata)
SELECT
'', -- bridge_id
mxid, -- user_mxid
CAST(tgid AS TEXT), -- id
COALESCE(tg_username, tg_phone, ''), -- remote_name
'{}', -- remote_profile
'', -- space_room
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'phone', COALESCE('+' || tg_phone, ''),
'session', json((SELECT json_data FROM telethon_sessions_old WHERE session_id=mxid))
) -- metadata
FROM user_old
WHERE tgid IS NOT NULL;
INSERT INTO ghost (
bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc,
name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata
)
SELECT
'', -- bridge_id
CAST(id AS TEXT), -- id
COALESCE(displayname, ''), -- name
COALESCE(photo_id, ''), -- avatar_id
'', -- avatar_hash
COALESCE(avatar_url, ''), -- avatar_mxc
name_set,
avatar_set,
contact_info_set,
COALESCE(is_bot, false),
'[]', -- identifiers
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'is_premium', CASE WHEN is_premium THEN json('true') ELSE json('false') END,
'is_channel', CASE WHEN is_channel THEN json('true') ELSE json('false') END,
'contact_source', displayname_source,
'source_is_contact', CASE WHEN displayname_contact THEN json('true') ELSE json('false') END
) -- metadata
FROM puppet_old;
DELETE FROM user_portal_old WHERE portal IN (SELECT tgid FROM portal_old WHERE peer_type<>'channel');
DELETE FROM backfill_queue_old WHERE portal_tgid IN (SELECT tgid FROM portal_old WHERE peer_type<>'channel');
UPDATE portal_old
SET tg_receiver=COALESCE((SELECT "user" FROM user_portal_old WHERE portal=portal_old.tgid LIMIT 1), tg_receiver)
WHERE peer_type='chat' AND tgid=tg_receiver;
UPDATE portal_old
SET tg_receiver=COALESCE((SELECT tgid FROM user_old WHERE tgid IS NOT NULL LIMIT 1), tg_receiver)
WHERE peer_type='chat' AND tgid=tg_receiver;
DELETE FROM portal_old WHERE peer_type='chat' AND tgid=tg_receiver;
INSERT INTO portal (
bridge_id, id, receiver, mxid, other_user_id, name, topic, avatar_id, avatar_hash, avatar_mxc,
name_set, avatar_set, topic_set, name_is_custom, in_space, room_type, metadata
)
SELECT
'', -- bridge_id
peer_type || ':' || CAST(tgid AS TEXT), -- id
CASE WHEN peer_type='channel' THEN '' ELSE CAST(tg_receiver AS TEXT) END, -- receiver
mxid, -- mxid
CASE WHEN peer_type='user' THEN CAST(tgid AS TEXT) END, -- other_user_id
COALESCE(title, ''), -- name
COALESCE(about, ''), -- topic
COALESCE(photo_id, ''), -- avatar_id
'', -- avatar_hash
COALESCE(avatar_url, ''), -- avatar_mxc
name_set, -- name_set
avatar_set, -- avatar_set
false, -- topic_set
peer_type<>'user', -- name_is_custom
false, -- in_space
CASE WHEN peer_type='user' THEN 'dm' ELSE '' END, -- room_type
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'is_supergroup', CASE WHEN megagroup THEN json('true') ELSE json('false') END
) -- metadata
FROM portal_old;
INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred)
SELECT
'', -- bridge_id
user_old.mxid, -- user_mxid
CAST(user_portal_old.user AS TEXT), -- login_id
portal_old.peer_type || ':' || CAST(user_portal_old.portal AS TEXT), -- portal_id
CASE WHEN peer_type='channel' THEN '' ELSE CAST(user_portal_old.portal_receiver AS TEXT) END, -- portal_receiver
false, -- in_space
false -- preferred
FROM user_portal_old
INNER JOIN user_old ON user_portal_old."user" = user_old.tgid
INNER JOIN portal_old ON user_portal_old.portal = portal_old.tgid and user_portal_old.portal_receiver = portal_old.tg_receiver;
INSERT INTO user_portal (bridge_id, user_mxid, login_id, portal_id, portal_receiver, in_space, preferred)
SELECT
'', -- bridge_id
user_old.mxid, -- user_mxid
CAST(portal_old.tg_receiver AS TEXT), -- login_id
portal_old.peer_type || ':' || CAST(portal_old.tgid AS TEXT), -- portal_id
CAST(portal_old.tg_receiver AS TEXT), -- portal_receiver
false, -- in_space
false -- preferred
FROM portal_old
INNER JOIN user_old ON portal_old.tg_receiver = user_old.tgid
WHERE portal_old.tg_receiver<>portal_old.tgid
ON CONFLICT (bridge_id, user_mxid, login_id, portal_id, portal_receiver) DO NOTHING;
INSERT INTO ghost (bridge_id, id, name, avatar_id, avatar_hash, avatar_mxc, name_set, avatar_set, contact_info_set, is_bot, identifiers, metadata)
VALUES ('', '', '', '', '', '', false, false, false, false, '[]', '{}');
UPDATE message_old SET sender=NULL WHERE sender IS NOT NULL AND NOT EXISTS(SELECT 1 FROM puppet_old WHERE id=sender);
INSERT INTO message (
bridge_id, id, part_id, mxid, room_id, room_receiver, sender_id, sender_mxid, timestamp, edit_count, metadata
)
SELECT
'', -- bridge_id
CASE WHEN tg_space=portal_old.tgid THEN (CAST(tg_space AS TEXT) || '.') ELSE '' END || CAST(message_old.tgid AS TEXT), -- id
'', -- part_id
message_old.mxid, -- mxid
portal_old.peer_type || ':' || CAST(portal_old.tgid AS TEXT), -- room_id
CASE WHEN portal_old.peer_type='channel' THEN '' ELSE CAST(portal_old.tg_receiver AS TEXT) END, -- room_receiver
COALESCE(CAST(sender AS TEXT), ''), -- sender_id
COALESCE(sender_mxid, ''),
0, -- timestamp
0, -- edit_count
-- only: postgres
jsonb_build_object
-- only: sqlite (line commented)
-- json_object
(
'content_hash', CASE WHEN content_hash IS NULL THEN '' ELSE encode(content_hash, 'base64') END
) -- metadata
FROM message_old
INNER JOIN portal_old ON mx_room=portal_old.mxid
WHERE (tg_space=portal_old.tgid OR tg_space=portal_old.tg_receiver) AND edit_index=0;
-- TODO migrate edit_index?
INSERT INTO reaction (
bridge_id, message_id, message_part_id, sender_id, emoji_id, room_id, room_receiver, mxid, timestamp, emoji, metadata
)
SELECT
'', -- bridge_id
message.id, -- message_id
message.part_id, -- message_part_id
CAST(tg_sender AS TEXT), -- sender_id
reaction, -- emoji_id
message.room_id, -- room_id
message.room_receiver, -- room_receiver
reaction_old.mxid, -- mxid
0, -- timestamp
reaction, -- emoji
'{}' -- metadata
FROM reaction_old
INNER JOIN message ON reaction_old.msg_mxid=message.mxid;
INSERT INTO telegram_access_hash (user_id, entity_type, entity_id, access_hash)
SELECT
user_old.tgid,
CASE WHEN id < 0 THEN 'channel' ELSE 'user' END,
CASE WHEN id < 0 THEN -id - 1000000000000 ELSE id END,
hash
FROM telethon_entities_old
LEFT JOIN user_old ON user_old.mxid=session_id
WHERE user_old.tgid IS NOT NULL AND hash<>0;
INSERT INTO telegram_user_state (user_id, pts, qts, date, seq)
SELECT user_old.tgid, pts, qts, date, seq
FROM telethon_update_state_old
LEFT JOIN user_old ON user_old.mxid=session_id
WHERE entity_id=0 AND user_old.tgid IS NOT NULL;
INSERT INTO telegram_channel_state (user_id, channel_id, pts)
SELECT user_old.tgid, entity_id, pts
FROM telethon_update_state_old
LEFT JOIN user_old ON user_old.mxid=session_id
WHERE entity_id<>0 AND user_old.tgid IS NOT NULL;
INSERT INTO telegram_username (username, entity_type, entity_id)
SELECT
username,
CASE WHEN id < 0 THEN 'channel' ELSE 'user' END,
CASE WHEN id < 0 THEN -id - 1000000000000 ELSE id END
FROM telethon_entities_old
WHERE username<>''
ON CONFLICT DO NOTHING;
INSERT INTO telegram_phone_number (phone_number, entity_id)
SELECT phone, id
FROM telethon_entities_old
WHERE phone<>''
ON CONFLICT DO NOTHING;
INSERT INTO telegram_file (id, mxc, mime_type, size, width, height, timestamp)
SELECT id, mxc, mime_type, size, width, height, timestamp
FROM telegram_file_old;
INSERT INTO disappearing_message (bridge_id, mx_room, mxid, type, timer, disappear_at)
SELECT
'', -- bridge_id
room_id,
event_id,
'after_send',
expiration_seconds * 1000000000,
expiration_ts * 1000000
FROM disappearing_message_old
WHERE expiration_ts<9999999999999 AND expiration_seconds<999999
AND room_id IN (SELECT mxid FROM portal WHERE mxid IS NOT NULL);
-- TODO do something with the bot_chat table?
-- Python -> Go mx_ table migration
-- only: postgres until "end only"
ALTER TABLE mx_room_state DROP COLUMN is_encrypted;
ALTER TABLE mx_room_state RENAME COLUMN has_full_member_list TO members_fetched;
UPDATE mx_room_state SET members_fetched=false WHERE members_fetched IS NULL;
ALTER TABLE mx_room_state ADD COLUMN join_rules jsonb;
ALTER TABLE mx_room_state ALTER COLUMN power_levels TYPE jsonb USING power_levels::jsonb;
ALTER TABLE mx_room_state ALTER COLUMN encryption TYPE jsonb USING encryption::jsonb;
ALTER TABLE mx_room_state ALTER COLUMN create_event TYPE jsonb USING create_event::jsonb;
ALTER TABLE mx_room_state ALTER COLUMN members_fetched SET DEFAULT false;
ALTER TABLE mx_room_state ALTER COLUMN members_fetched SET NOT NULL;
-- end only postgres
-- only: sqlite until "end only"
CREATE TABLE new_mx_room_state (
room_id TEXT PRIMARY KEY,
power_levels jsonb,
encryption jsonb,
create_event jsonb,
join_rules jsonb,
members_fetched BOOLEAN NOT NULL DEFAULT false
);
INSERT INTO new_mx_room_state (room_id, encryption, power_levels, create_event, members_fetched)
SELECT room_id, encryption, power_levels, create_event, COALESCE(has_full_member_list, false)
FROM mx_room_state;
-- end only sqlite
ALTER TABLE mx_user_profile ADD COLUMN name_skeleton bytea;
CREATE INDEX mx_user_profile_membership_idx ON mx_user_profile (room_id, membership);
CREATE INDEX mx_user_profile_name_skeleton_idx ON mx_user_profile (room_id, name_skeleton);
UPDATE mx_user_profile SET displayname='' WHERE displayname IS NULL;
UPDATE mx_user_profile SET avatar_url='' WHERE avatar_url IS NULL;
CREATE TABLE mx_registrations (
user_id TEXT PRIMARY KEY
);
UPDATE mx_version SET version=10;
DELETE FROM mx_user_profile WHERE room_id='' OR user_id='';
DELETE FROM mx_room_state WHERE room_id='';
DROP TABLE user_portal_old;
DROP TABLE backfill_queue_old;
DROP TABLE bot_chat_old;
DROP TABLE contact_old;
DROP TABLE disappearing_message_old;
DROP TABLE message_old;
DROP TABLE reaction_old;
DROP TABLE portal_old;
DROP TABLE puppet_old;
DROP TABLE user_old;
-- only: postgres (this is deleted separately for sqlite)
DROP TABLE telegram_file_old;
DROP TABLE telethon_entities_old;
DROP TABLE telethon_sent_files_old;
DROP TABLE telethon_sessions_old;
DROP TABLE telethon_update_state_old;
-427
View File
@@ -1,427 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2024 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/>.
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"github.com/gorilla/websocket"
"github.com/rs/zerolog"
"go.mau.fi/util/exhttp"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/bridgev2"
"maunium.net/go/mautrix/bridgev2/status"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-telegram/pkg/connector"
"go.mau.fi/mautrix-telegram/pkg/connector/ids"
)
type response struct {
Username id.UserID `json:"username,omitempty"`
State string `json:"state,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
ErrCode string `json:"errcode,omitempty"`
}
func (r response) WithState(state string) response {
r.State = state
return r
}
func (r response) WithMessage(message string) response {
r.Message = message
return r
}
func (r response) WithError(errCode, error string) response {
r.ErrCode = errCode
r.Error = error
return r
}
type legacyLogin struct {
Process bridgev2.LoginProcess
NextStep *bridgev2.LoginStep
}
var inflightLegacyLoginsLock sync.RWMutex
var inflightLegacyLogins = map[id.UserID]*legacyLogin{}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
Subprotocols: []string{"net.maunium.telegram.login"},
}
func legacyProvLoginQR(w http.ResponseWriter, r *http.Request) {
log := zerolog.Ctx(r.Context()).With().Str("prov_method", "qr_login").Logger()
ctx := log.WithContext(r.Context())
user := m.Matrix.Provisioning.GetUser(r)
resp := response{Username: user.MXID}
var err error
var loginProcess bridgev2.LoginProcess
var nextStep *bridgev2.LoginStep
if loginProcess, err = c.CreateLogin(ctx, user, connector.LoginFlowIDQR); err != nil {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("create_login_failed", fmt.Sprintf("Failed to create a QR login process: %s", err.Error())))
} else if nextStep, err = loginProcess.Start(ctx); err != nil {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("start_login_failed", fmt.Sprintf("Failed to start login process: %s", err.Error())))
} else if nextStep.StepID != connector.LoginStepIDShowQR {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected first step %s", nextStep.StepID)))
}
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Err(err).Msg("Failed to upgrade connection to websocket")
return
}
defer func() {
err := ws.Close()
if err != nil {
log.Debug().Err(err).Msg("Error closing websocket")
}
}()
go func() {
// Read everything so SetCloseHandler() works
for {
_, _, err = ws.ReadMessage()
if err != nil {
break
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
ws.SetCloseHandler(func(code int, text string) error {
log.Debug().Int("close_code", code).Msg("Login websocket closed, cancelling login")
cancel()
return nil
})
for {
switch nextStep.StepID {
case connector.LoginStepIDShowQR:
ws.WriteJSON(map[string]any{"code": nextStep.DisplayAndWaitParams.Data})
nextStep, err = loginProcess.(bridgev2.LoginProcessDisplayAndWait).Wait(ctx)
if err != nil {
ws.WriteJSON(map[string]any{
"success": false,
"error": "qr_login_failed",
"message": fmt.Sprintf("Failed to login using QR code: %s", err),
})
return
}
case connector.LoginStepIDComplete:
ws.WriteJSON(map[string]any{"success": true})
go handleLoginComplete(ctx, user, nextStep.CompleteParams.UserLogin)
return
case connector.LoginStepIDPassword:
inflightLegacyLoginsLock.Lock()
inflightLegacyLogins[user.MXID] = &legacyLogin{Process: loginProcess, NextStep: nextStep}
inflightLegacyLoginsLock.Unlock()
ws.WriteJSON(map[string]any{"success": false, "error": "password-needed"})
return
default:
ws.WriteJSON(map[string]any{
"success": false,
"error": "unexpected_step",
"message": fmt.Sprintf("Unexpected step in QR code login process %s", nextStep.StepID),
})
return
}
}
}
func legacyProvLoginRequestCode(w http.ResponseWriter, r *http.Request) {
log := zerolog.Ctx(r.Context()).With().Str("prov_step", "request_code").Logger()
ctx := log.WithContext(r.Context())
user := m.Matrix.Provisioning.GetUser(r)
resp := response{Username: user.MXID, State: "request"}
legacyProvRequestCodeReq := map[string]string{}
if err := json.NewDecoder(r.Body).Decode(&legacyProvRequestCodeReq); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_body_invalid", "Request body is invalid"))
} else if phone, ok := legacyProvRequestCodeReq["phone"]; !ok || phone == "" {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("phone_missing", "Phone number missing"))
} else if loginProcess, err := c.CreateLogin(ctx, user, connector.LoginFlowIDPhone); err != nil {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("create_login_failed", fmt.Sprintf("Failed to create a phone number login process: %s", err.Error())))
} else if firstStep, err := loginProcess.Start(ctx); err != nil {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("start_login_failed", fmt.Sprintf("Failed to start login process: %s", err.Error())))
} else if firstStep.StepID != connector.LoginStepIDPhoneNumber {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected first step %s", firstStep.StepID)))
} else if nextStep, err := loginProcess.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{connector.LoginStepIDPhoneNumber: phone}); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_code_failed", fmt.Sprintf("Failed to request code: %s", err.Error())))
} else if nextStep.StepID != connector.LoginStepIDCode {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", nextStep.StepID)))
} else {
inflightLegacyLoginsLock.Lock()
inflightLegacyLogins[user.MXID] = &legacyLogin{Process: loginProcess, NextStep: nextStep}
inflightLegacyLoginsLock.Unlock()
exhttp.WriteJSONResponse(w, http.StatusOK, resp.
WithState("code").
WithMessage("Code requested successfully. Check your SMS or Telegram app and enter the code below."),
)
}
}
func legacyProvLoginSendCode(w http.ResponseWriter, r *http.Request) {
log := zerolog.Ctx(r.Context()).With().Str("prov_step", "send_code").Logger()
ctx := log.WithContext(r.Context())
user := m.Matrix.Provisioning.GetUser(r)
resp := response{Username: user.MXID, State: "code"}
legacyProvSendCodeReq := map[string]string{}
if inflightLogin, ok := inflightLegacyLogins[user.MXID]; !ok {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("no_login", "No login process in progress"))
} else if inflightLogin.NextStep.StepID != connector.LoginStepIDCode {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", inflightLogin.NextStep.StepID)))
} else if err := json.NewDecoder(r.Body).Decode(&legacyProvSendCodeReq); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_body_invalid", "Request body is invalid"))
} else if code, ok := legacyProvSendCodeReq["code"]; !ok || code == "" {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("phone_code_missing", "You must provide the code from your phone."))
} else if nextStep, err := inflightLogin.Process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{connector.LoginStepIDCode: code}); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("send_code_failed", fmt.Sprintf("Failed to send code: %s", err.Error())))
} else if nextStep.StepID == connector.LoginStepIDPassword {
inflightLegacyLoginsLock.Lock()
defer inflightLegacyLoginsLock.Unlock()
inflightLegacyLogins[user.MXID].NextStep = nextStep
exhttp.WriteJSONResponse(w, http.StatusAccepted, resp.
WithState("password").
WithMessage("Code accepted, but you have 2-factor authentication enabled. Please enter your password."),
)
return // Don't delete the inflight login yet, we need to submit the password.
} else if nextStep.StepID == connector.LoginStepIDComplete {
exhttp.WriteJSONResponse(w, http.StatusOK, resp.WithState("logged-in"))
go handleLoginComplete(ctx, user, nextStep.CompleteParams.UserLogin)
} else {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", nextStep.StepID)))
}
// If we got here, then there was an error, or the login is complete.
// Delete the in-flight login.
inflightLegacyLoginsLock.Lock()
delete(inflightLegacyLogins, user.MXID)
inflightLegacyLoginsLock.Unlock()
}
func legacyProvLoginSendPassword(w http.ResponseWriter, r *http.Request) {
log := zerolog.Ctx(r.Context()).With().Str("prov_step", "send_password").Logger()
ctx := log.WithContext(r.Context())
user := m.Matrix.Provisioning.GetUser(r)
resp := response{Username: user.MXID, State: "password"}
legacyProvSendPasswordReq := map[string]string{}
if inflightLogin, ok := inflightLegacyLogins[user.MXID]; !ok {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("no_login", "No login process in progress"))
} else if inflightLogin.NextStep.StepID != connector.LoginStepIDPassword {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", inflightLogin.NextStep.StepID)))
} else if err := json.NewDecoder(r.Body).Decode(&legacyProvSendPasswordReq); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("request_body_invalid", "Request body is invalid"))
} else if password, ok := legacyProvSendPasswordReq["password"]; !ok || password == "" {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("password_missing", "You must provide your password."))
} else if nextStep, err := inflightLogin.Process.(bridgev2.LoginProcessUserInput).SubmitUserInput(ctx, map[string]string{connector.LoginStepIDPassword: password}); err != nil {
exhttp.WriteJSONResponse(w, http.StatusBadRequest, resp.WithError("send_password_failed", fmt.Sprintf("Failed to send password: %s", err.Error())))
} else if nextStep.StepID == connector.LoginStepIDComplete {
exhttp.WriteJSONResponse(w, http.StatusOK, resp.WithState("logged-in").WithMessage(nextStep.Instructions))
go handleLoginComplete(ctx, user, nextStep.CompleteParams.UserLogin)
} else {
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("unexpected_step", fmt.Sprintf("Unexpected step %s", nextStep.StepID)))
}
// If we got here, then there was an error, or the login is complete.
// Delete the in-flight login.
inflightLegacyLoginsLock.Lock()
delete(inflightLegacyLogins, user.MXID)
inflightLegacyLoginsLock.Unlock()
}
func legacyProvLogout(w http.ResponseWriter, r *http.Request) {
user := m.Matrix.Provisioning.GetUser(r)
resp := response{Username: user.MXID}
logins := user.GetUserLogins()
if len(logins) == 0 {
exhttp.WriteJSONResponse(w, http.StatusOK, resp.WithError("not logged in", "You're not logged in"))
return
}
for _, login := range logins {
login.Client.(*connector.TelegramClient).LogoutRemote(r.Context())
}
exhttp.WriteEmptyJSONResponse(w, http.StatusOK)
}
func handleLoginComplete(ctx context.Context, user *bridgev2.User, newLogin *bridgev2.UserLogin) {
allLogins := user.GetUserLogins()
for _, login := range allLogins {
if login.ID != newLogin.ID {
login.Delete(ctx, status.BridgeState{StateEvent: status.StateLoggedOut, Reason: "LOGIN_OVERRIDDEN"}, bridgev2.DeleteOpts{})
}
}
}
func legacyProvContacts(w http.ResponseWriter, r *http.Request) {
log := zerolog.Ctx(r.Context()).With().
Str("prov_method", "contacts").
Logger()
ctx := log.WithContext(r.Context())
var resp response
login := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if login == nil {
exhttp.WriteJSONResponse(w, http.StatusNotFound, resp.WithError(mautrix.MNotFound.ErrCode, "No login found"))
return
}
api := login.Client.(bridgev2.ContactListingNetworkAPI)
contacts, err := api.GetContactList(ctx)
if err != nil {
log.Err(err).Msg("Failed to get contacts")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("M_UNKNOWN", fmt.Sprintf("Failed to get contacts: %v", err)))
return
}
contactsMap := map[int64]*legacyContactInfo{}
for _, contact := range contacts {
peerType, id, err := ids.ParseUserID(contact.UserID)
if err != nil {
log.Err(err).Msg("Failed to parse user id")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("M_UNKNOWN", fmt.Sprintf("Failed to parse user id: %v", err)))
return
} else if peerType != ids.PeerTypeUser {
log.Err(err).Msg("Unexpected peer type")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError, resp.WithError("M_UNKNOWN", fmt.Sprintf("Unexpected peer type: %s", peerType)))
return
}
if contact.UserInfo != nil {
contact.Ghost.UpdateInfo(ctx, contact.UserInfo)
}
contactsMap[id] = legacyContactInfoFromGhost(contact.Ghost)
}
exhttp.WriteJSONResponse(w, http.StatusOK, contactsMap)
}
func legacyProvResolveIdentifier(w http.ResponseWriter, r *http.Request) {
legacyResolveIdentifierOrStartChat(w, r, false)
}
func legacyProvPM(w http.ResponseWriter, r *http.Request) {
legacyResolveIdentifierOrStartChat(w, r, true)
}
type legacyResolveIdentifierResponse struct {
RoomID id.RoomID `json:"room_id,omitempty"`
JustCreated bool `json:"just_created,omitempty"`
ID int `json:"id,omitempty"`
ContactInfo *legacyContactInfo `json:"contact_info,omitempty"`
}
type legacyContactInfo struct {
Name string `json:"name,omitempty"`
Username string `json:"username,omitempty"`
Phone string `json:"phone,omitempty"`
IsBot bool `json:"is_bot,omitempty"`
AvatarURL id.ContentURIString `json:"avatar_url,omitempty"`
}
func legacyContactInfoFromGhost(ghost *bridgev2.Ghost) *legacyContactInfo {
var username, phone string
for _, id := range ghost.Identifiers {
if strings.HasPrefix(id, "telegram:") {
username = strings.TrimPrefix(id, "telegram:")
} else if strings.HasPrefix(id, "tel:") {
phone = strings.TrimPrefix(id, "tel:")
}
}
return &legacyContactInfo{
Name: ghost.Name,
Username: username,
Phone: phone,
IsBot: ghost.IsBot,
AvatarURL: ghost.AvatarMXC,
}
}
func legacyResolveIdentifierOrStartChat(w http.ResponseWriter, r *http.Request, create bool) {
log := zerolog.Ctx(r.Context()).With().
Str("prov_method", "resolve_identifier").
Bool("create", create).
Logger()
ctx := log.WithContext(r.Context())
var resp response
login := m.Matrix.Provisioning.GetLoginForRequest(w, r)
if login == nil {
exhttp.WriteJSONResponse(w, http.StatusNotFound, resp.WithError(mautrix.MNotFound.ErrCode, "No login found"))
return
}
api := login.Client.(bridgev2.IdentifierResolvingNetworkAPI)
identResp, err := api.ResolveIdentifier(ctx, r.PathValue("identifier"), create)
if err != nil {
log.Err(err).Msg("Failed to resolve identifier")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError,
resp.WithError("M_UNKNOWN", fmt.Sprintf("Failed to resolve identifier: %v", err)))
return
} else if identResp == nil {
exhttp.WriteJSONResponse(w, http.StatusNotFound,
resp.WithError(mautrix.MNotFound.ErrCode, "User not found on Telegram"))
return
}
status := http.StatusOK
var apiResp legacyResolveIdentifierResponse
if identResp.Ghost != nil {
if identResp.UserInfo != nil {
identResp.Ghost.UpdateInfo(ctx, identResp.UserInfo)
}
apiResp.ContactInfo = legacyContactInfoFromGhost(identResp.Ghost)
}
if identResp.Chat != nil {
if identResp.Chat.Portal == nil {
identResp.Chat.Portal, err = m.Bridge.GetPortalByKey(ctx, identResp.Chat.PortalKey)
if err != nil {
log.Err(err).Msg("Failed to get portal")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError,
resp.WithError("M_UNKNOWN", "Failed to get portal"))
return
}
}
if create && identResp.Chat.Portal.MXID == "" {
apiResp.JustCreated = true
status = http.StatusCreated
err = identResp.Chat.Portal.CreateMatrixRoom(ctx, login, identResp.Chat.PortalInfo)
if err != nil {
log.Err(err).Msg("Failed to create portal room")
exhttp.WriteJSONResponse(w, http.StatusInternalServerError,
resp.WithError("M_UNKNOWN", "Failed to create portal room"))
return
}
}
apiResp.RoomID = identResp.Chat.Portal.MXID
}
exhttp.WriteJSONResponse(w, status, &apiResp)
}
-127
View File
@@ -1,127 +0,0 @@
// mautrix-telegram - A Matrix-Telegram puppeting bridge.
// Copyright (C) 2024 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/>.
package main
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"strings"
"go.mau.fi/util/dbutil/litestream"
"go.mau.fi/util/exerrors"
"maunium.net/go/mautrix/bridgev2/bridgeconfig"
"maunium.net/go/mautrix/bridgev2/matrix/mxmain"
"maunium.net/go/mautrix/id"
"go.mau.fi/mautrix-telegram/pkg/connector"
"go.mau.fi/mautrix-telegram/pkg/connector/store/upgrades"
)
// Information to find out exactly which commit the bridge was built from.
// These are filled at build time with the -X linker flag.
var (
Tag = "unknown"
Commit = "unknown"
BuildTime = "unknown"
)
var c = &connector.TelegramConnector{}
var m = mxmain.BridgeMain{
Name: "mautrix-telegram",
URL: "https://github.com/mautrix/telegram",
Description: "A Matrix-Telegram puppeting bridge.",
Version: "26.04",
SemCalVer: true,
Connector: c,
}
func init() {
litestream.Functions["encode"] = func(data []byte, encoding string) string {
if encoding == "base64" {
return base64.StdEncoding.EncodeToString(data)
}
panic(fmt.Errorf("unknown encoding %q", encoding))
}
}
func main() {
bridgeconfig.HackyMigrateLegacyNetworkConfig = migrateLegacyConfig
versionWithoutCommit := m.Version
m.PostStart = func() {
if m.Matrix.Provisioning != nil {
m.Matrix.Provisioning.GetAuthFromRequest = func(r *http.Request) string {
if !strings.HasSuffix(r.URL.Path, "/login/qr") {
return ""
}
authParts := strings.Split(r.Header.Get("Sec-WebSocket-Protocol"), ",")
for _, part := range authParts {
part = strings.TrimSpace(part)
if strings.HasPrefix(part, "net.maunium.telegram.auth-") {
return strings.TrimPrefix(part, "net.maunium.telegram.auth-")
}
}
return ""
}
m.Matrix.Provisioning.GetUserIDFromRequest = func(r *http.Request) id.UserID {
return id.UserID(r.PathValue("userID"))
}
m.Matrix.Provisioning.Router.HandleFunc("/v1/user/{userID}/login/qr", legacyProvLoginQR)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/login/request_code", legacyProvLoginRequestCode)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/login/send_code", legacyProvLoginSendCode)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/login/send_password", legacyProvLoginSendPassword)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/logout", legacyProvLogout)
m.Matrix.Provisioning.Router.HandleFunc("GET /v1/user/{userID}/contacts", legacyProvContacts)
m.Matrix.Provisioning.Router.HandleFunc("/v1/user/{userID}/resolve_identifier/{identifier}", legacyProvResolveIdentifier)
m.Matrix.Provisioning.Router.HandleFunc("POST /v1/user/{userID}/pm/{identifier}", legacyProvPM)
}
}
m.PostInit = func() {
if c.Config.DeviceInfo.AppVersion == "auto" {
c.Config.DeviceInfo.AppVersion = versionWithoutCommit
}
if c.Config.DeviceInfo.SystemVersion == "auto" {
c.Config.DeviceInfo.SystemVersion = ""
}
if c.Config.DeviceInfo.DeviceModel == "auto" || c.Config.DeviceInfo.DeviceModel == "" {
c.Config.DeviceInfo.DeviceModel = "mautrix-telegram"
}
m.CheckLegacyDB(
18,
"v0.14.0",
"v26.04",
m.LegacyMigrateWithAnotherUpgrader(
legacyMigrateRenameTables, legacyMigrateCopyData, 27,
upgrades.Table, "telegram_version", 6,
),
true,
)
ctx := context.TODO()
if exists, _ := m.DB.TableExists(ctx, "telegram_file_old"); exists {
exerrors.Must(m.DB.Exec(ctx, `
PRAGMA foreign_keys = 'OFF';
DROP TABLE telegram_file_old;
PRAGMA foreign_key_check;
PRAGMA foreign_keys = 'ON';
`))
}
}
m.InitVersion(Tag, Commit, BuildTime)
m.Run()
}
+21 -19
View File
@@ -1,37 +1,39 @@
#!/bin/sh
if [[ -z "$GID" ]]; then
GID="$UID"
fi
BINARY_NAME=/usr/bin/mautrix-telegram
# Define functions.
function fixperms {
chown -R $UID:$GID /data
# /opt/mautrix-telegram is read-only, so disable file logging if it's pointing there.
if [[ "$(yq e '.logging.writers[1].filename' /data/config.yaml)" == "./logs/mautrix-telegram.log" ]]; then
yq -I4 e -i 'del(.logging.writers[1])' /data/config.yaml
fi
chown -R $UID:$GID /data /opt/mautrix-telegram
}
if [[ ! -f /data/config.yaml ]]; then
$BINARY_NAME -c /data/config.yaml -e
cd /opt/mautrix-telegram
# Replace database path in config.
sed -i "s#sqlite:///mautrix-telegram.db#sqlite:////data/mautrix-telegram.db#" /data/config.yaml
if [ -f /data/mx-state.json ]; then
ln -s /data/mx-state.json
fi
# Check that database is in the right state
alembic -x config=/data/config.yaml upgrade head
if [ ! -f /data/config.yaml ]; then
cp example-config.yaml /data/config.yaml
echo "Didn't find a config file."
echo "Copied default config file to /data/config.yaml"
echo "Modify that config file to your liking."
echo "Start the container again after that to generate the registration file."
fixperms
exit
fi
if [[ ! -f /data/registration.yaml ]]; then
$BINARY_NAME -g -c /data/config.yaml -r /data/registration.yaml
if [ ! -f /data/registration.yaml ]; then
python3 -m mautrix_telegram -g -c /data/config.yaml -r /data/registration.yaml
echo "Didn't find a registration file."
echo "Generated one for you."
echo "See https://docs.mau.fi/bridges/general/registering-appservices.html on how to use it."
echo "Copy that over to synapses app service directory."
fixperms
exit
fi
cd /data
fixperms
exec su-exec $UID:$GID $BINARY_NAME
exec su-exec $UID:$GID python3 -m mautrix_telegram -c /data/config.yaml
+349
View File
@@ -0,0 +1,349 @@
# Homeserver details
homeserver:
# The address that this appservice can use to connect to the homeserver.
address: https://matrix.org
# The domain of the homeserver (for MXIDs, etc).
domain: matrix.org
# Whether or not to verify the SSL certificate of the homeserver.
# Only applies if address starts with https://
verify_ssl: true
# Application service host/registration related details
# Changing these values requires regeneration of the registration.
appservice:
# The address that the homeserver can use to connect to this appservice.
address: http://localhost:8080
# The hostname and port where this appservice should listen.
hostname: 0.0.0.0
port: 8080
# The maximum body size of appservice API requests (from the homeserver) in mebibytes
# Usually 1 is enough, but on high-traffic bridges you might need to increase this to avoid 413s
max_body_size: 1
# The full URI to the database. SQLite and Postgres are fully supported.
# Other DBMSes supported by SQLAlchemy may or may not work.
# Format examples:
# SQLite: sqlite:///filename.db
# Postgres: postgres://username:password@hostname/dbname
database: sqlite:///mautrix-telegram.db
# Public part of web server for out-of-Matrix interaction with the bridge.
# Used for things like login if the user wants to make sure the 2FA password isn't stored in
# the HS database.
public:
# Whether or not the public-facing endpoints should be enabled.
enabled: true
# The prefix to use in the public-facing endpoints.
prefix: /public
# The base URL where the public-facing endpoints are available. The prefix is not added
# implicitly.
external: https://example.com/public
# Provisioning API part of the web server for automated portal creation and fetching information.
# Used by things like Dimension (https://dimension.t2bot.io/).
provisioning:
# Whether or not the provisioning API should be enabled.
enabled: true
# The prefix to use in the provisioning API endpoints.
prefix: /_matrix/provision/v1
# The shared secret to authorize users of the API.
# Set to "generate" to generate and save a new token.
shared_secret: generate
# The unique ID of this appservice.
id: telegram
# Username of the appservice bot.
bot_username: telegrambot
# Display name and avatar for bot. Set to "remove" to remove display name/avatar, leave empty
# to leave display name/avatar as-is.
bot_displayname: Telegram bridge bot
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
# Community ID for bridged users (changes registration file) and rooms.
# Must be created manually.
community_id: false
# Authentication tokens for AS <-> HS communication. Autogenerated; do not modify.
as_token: "This value is generated when generating the registration"
hs_token: "This value is generated when generating the registration"
# Prometheus telemetry config. Requires prometheus-client to be installed.
metrics:
enabled: false
listen_port: 8000
# Bridge config
bridge:
# Localpart template of MXIDs for Telegram users.
# {userid} is replaced with the user ID of the Telegram user.
username_template: "telegram_{userid}"
# Localpart template of room aliases for Telegram portal rooms.
# {groupname} is replaced with the name part of the public channel/group invite link ( https://t.me/{} )
alias_template: "telegram_{groupname}"
# Displayname template for Telegram users.
# {displayname} is replaced with the display name of the Telegram user.
displayname_template: "{displayname} (Telegram)"
# Set the preferred order of user identifiers which to use in the Matrix puppet display name.
# In the (hopefully unlikely) scenario that none of the given keys are found, the numeric user
# ID is used.
#
# If the bridge is working properly, a phone number or an username should always be known, but
# the other one can very well be empty.
#
# Valid keys:
# "full name" (First and/or last name)
# "full name reversed" (Last and/or first name)
# "first name"
# "last name"
# "username"
# "phone number"
displayname_preference:
- full name
- username
- phone number
# Maximum number of members to sync per portal when starting up. Other members will be
# synced when they send messages. The maximum is 10000, after which the Telegram server
# will not send any more members.
# Defaults to no local limit (-> limited to 10000 by server)
max_initial_member_sync: -1
# Whether or not to sync the member list in channels.
# If no channel admins have logged into the bridge, the bridge won't be able to sync the member
# list regardless of this setting.
sync_channel_members: true
# Whether or not to skip deleted members when syncing members.
skip_deleted_members: true
# Whether or not to automatically synchronize contacts and chats of Matrix users logged into
# their Telegram account at startup.
startup_sync: true
# Number of most recently active dialogs to check when syncing chats.
# Dialogs include groups and private chats, but only groups are synced.
# Set to 0 to remove limit.
sync_dialog_limit: 30
# The maximum number of simultaneous Telegram deletions to handle.
# A large number of simultaneous redactions could put strain on your homeserver.
max_telegram_delete: 10
# Whether or not to automatically sync the Matrix room state (mostly unpuppeted displaynames)
# at startup and when creating a bridge.
sync_matrix_state: true
# Allow logging in within Matrix. If false, the only way to log in is using the out-of-Matrix
# login website (see appservice.public config section)
allow_matrix_login: true
# Whether or not to bridge plaintext highlights.
# Only enable this if your displayname_template has some static part that the bridge can use to
# reliably identify what is a plaintext highlight.
plaintext_highlights: false
# Whether or not to make portals of publicly joinable channels/supergroups publicly joinable on Matrix.
public_portals: true
# Whether or not to fetch and handle Telegram updates at startup from the time the bridge was down.
# Currently only works for private chats and normal groups.
# WARNING: This feature seems to be broken in the telegram library.
catch_up: false
# Whether or not to use /sync to get presence, read receipts and typing notifications when using
# your own Matrix account as the Matrix puppet for your Telegram account.
sync_with_custom_puppets: true
# Set to false to disable link previews in messages sent to Telegram.
telegram_link_preview: true
# Use inline images instead of a separate message for the caption.
# N.B. Inline images are not supported on all clients (e.g. Riot iOS).
inline_images: false
# Maximum size of image in megabytes before sending to Telegram as a document.
image_as_file_size: 10
# Maximum size of Telegram documents in megabytes to bridge.
max_document_size: 100
# Whether to bridge Telegram bot messages as m.notices or m.texts.
bot_messages_as_notices: true
bridge_notices:
# Whether or not Matrix bot messages (type m.notice) should be bridged.
default: false
# List of user IDs for whom the previous flag is flipped.
# e.g. if bridge_notices.default is false, notices from other users will not be bridged, but
# notices from users listed here will be bridged.
exceptions:
- "@importantbot:example.com"
# Some config options related to Telegram message deduplication.
# The default values are usually fine, but some debug messages/warnings might recommend you
# change these.
deduplication:
# Whether or not to check the database if the message about to be sent is a duplicate.
pre_db_check: false
# The number of latest events to keep when checking for duplicates.
# You might need to increase this on high-traffic bridge instances.
cache_queue_length: 20
# The formats to use when sending messages to Telegram via the relay bot.
#
# Telegram doesn't have built-in emotes, so the m.emote format is also used for non-relaybot users.
#
# Available variables:
# $sender_displayname - The display name of the sender (e.g. Example User)
# $sender_username - The username (Matrix ID localpart) of the sender (e.g. exampleuser)
# $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
# $message - The message content as HTML
message_formats:
m.text: "<b>$sender_displayname</b>: $message"
m.emote: "* <b>$sender_displayname</b> $message"
m.file: "<b>$sender_displayname</b> sent a file: $message"
m.image: "<b>$sender_displayname</b> sent an image: $message"
m.audio: "<b>$sender_displayname</b> sent an audio file: $message"
m.video: "<b>$sender_displayname</b> sent a video: $message"
m.location: "<b>$sender_displayname</b> sent a location: $message"
# The formats to use when sending state events to Telegram via the relay bot.
#
# Variables from `message_formats` that have the `sender_` prefix are available without the prefix.
# In name_change events, `$prev_displayname` is the previous displayname.
#
# Set format to an empty string to disable the messages for that event.
state_event_formats:
join: "<b>$displayname</b> joined the room."
leave: "<b>$displayname</b> left the room."
name_change: "<b>$prev_displayname</b> changed their name to <b>$displayname</b>"
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
# `filter-mode` management commands.
#
# Filters do not affect direct chats.
# An empty blacklist will essentially disable the filter.
filter:
# Filter mode to use. Either "blacklist" or "whitelist".
# If the mode is "blacklist", the listed chats will never be bridged.
# If the mode is "whitelist", only the listed chats can be bridged.
mode: blacklist
# The list of group/channel IDs to filter.
list: []
# The prefix for commands. Only required in non-management rooms.
command_prefix: "!tg"
# Permissions for using the bridge.
# Permitted values:
# relaybot - Only use the bridge via the relaybot, no access to commands.
# user - Relaybot level + access to commands to create bridges.
# puppeting - User level + logging in with a Telegram account.
# full - Full access to use the bridge, i.e. previous levels + Matrix login.
# admin - Full access to use the bridge and some extra administration commands.
# Permitted keys:
# * - All Matrix users
# domain - All users on that homeserver
# mxid - Specific user
permissions:
"*": "relaybot"
"public.example.com": "user"
"example.com": "full"
"@admin:example.com": "admin"
# Options related to the message relay Telegram bot.
relaybot:
# Whether or not to allow creating portals from Telegram.
authless_portals: true
# Whether or not to allow Telegram group admins to use the bot commands.
whitelist_group_admins: true
# Whether or not to ignore incoming events sent by the relay bot.
ignore_own_incoming_events: true
# List of usernames/user IDs who are also allowed to use the bot commands.
whitelist:
- myusername
- 12345678
# Telegram config
telegram:
# Get your own API keys at https://my.telegram.org/apps
api_id: 12345
api_hash: tjyd5yge35lbodk1xwzw2jstp90k55qz
# (Optional) Create your own bot at https://t.me/BotFather
bot_token: disabled
# Telethon connection options.
connection:
# The timeout in seconds to be used when connecting.
timeout: 120
# How many times the reconnection should retry, either on the initial connection or when
# Telegram disconnects us. May be set to a negative or null value for infinite retries, but
# this is not recommended, since the program can get stuck in an infinite loop.
retries: 5
# The delay in seconds to sleep between automatic reconnections.
retry_delay: 1
# The threshold below which the library should automatically sleep on flood wait errors
# (inclusive). For instance, if a FloodWaitError for 17s occurs and flood_sleep_threshold
# is 20s, the library will sleep automatically. If the error was for 21s, it would raise
# the error instead. Values larger than a day (86400) will be changed to a day.
flood_sleep_threshold: 60
# How many times a request should be retried. Request are retried when Telegram is having
# internal issues, when there is a FloodWaitError less than flood_sleep_threshold, or when
# there's a migrate error. May take a negative or null value for infinite retries, but this
# is not recommended, since some requests can always trigger a call fail (such as searching
# for messages).
request_retries: 5
# Device info sent to Telegram.
device_info:
# "auto" = OS name+version.
device_model: auto
# "auto" = Telethon version.
system_version: auto
# "auto" = mautrix-telegram version.
app_version: auto
lang_code: en
system_lang_code: en
# Custom server to connect to.
server:
# Set to true to use these server settings. If false, will automatically
# use production server assigned by Telegram. Set to false in production.
enabled: false
# The DC ID to connect to.
dc: 2
# The IP to connect to.
ip: 149.154.167.40
# The port to connect to. 443 may not work, 80 is better and both are equally secure.
port: 80
# Telethon proxy configuration.
# You must install PySocks from pip for proxies to work.
proxy:
# Allowed types: disabled, socks4, socks5, http
type: disabled
# Proxy IP address and port.
address: 127.0.0.1
port: 1080
# Whether or not to perform DNS resolving remotely.
rdns: true
# Proxy authentication (optional).
username: ""
password: ""
# Python logging configuration.
#
# See section 16.7.2 of the Python documentation for more info:
# https://docs.python.org/3.6/library/logging.config.html#configuration-dictionary-schema
logging:
version: 1
formatters:
precise:
format: "[%(asctime)s] [%(levelname)s@%(name)s] %(message)s"
handlers:
file:
class: logging.handlers.RotatingFileHandler
formatter: precise
filename: ./mautrix-telegram.log
maxBytes: 10485760
backupCount: 10
console:
class: logging.StreamHandler
formatter: precise
loggers:
mau:
level: DEBUG
telethon:
level: DEBUG
aiohttp:
level: INFO
root:
level: DEBUG
handlers: [file, console]
-81
View File
@@ -1,81 +0,0 @@
module go.mau.fi/mautrix-telegram
go 1.25.0
toolchain go1.26.2
tool (
go.mau.fi/util/cmd/maubuild
golang.org/x/tools/cmd/stringer
)
require (
github.com/cenkalti/backoff/v4 v4.3.0
github.com/coder/websocket v1.8.14
github.com/go-faster/errors v0.7.1
github.com/go-faster/jx v1.2.0
github.com/go-faster/xor v1.0.0
github.com/go-openapi/inflect v0.21.5
github.com/gorilla/websocket v1.5.0
github.com/gotd/getdoc v0.51.0
github.com/gotd/ige v0.2.2
github.com/gotd/neo v0.1.5
github.com/gotd/tl v0.4.0
github.com/k0kubun/pp/v3 v3.5.1
github.com/klauspost/compress v1.18.5
github.com/rogpeppe/go-internal v1.14.1
github.com/rs/zerolog v1.35.0
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.18.0
go.mau.fi/util v0.9.8
go.mau.fi/webp v0.2.0
go.mau.fi/zerozap v0.1.2
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
go.uber.org/atomic v1.11.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.50.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
golang.org/x/image v0.39.0
golang.org/x/net v0.53.0
golang.org/x/sync v0.20.0
golang.org/x/tools v0.44.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.27.0
rsc.io/qr v0.2.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/benbjohnson/clock v1.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/go-faster/sdk v0.28.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/sergi/go-diff v1.1.0 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
go.mau.fi/zeroconfig v0.2.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
maunium.net/go/mauflag v1.0.0 // indirect
)
-242
View File
@@ -1,242 +0,0 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-faster/jx v1.2.0 h1:T2YHJPrFaYu21fJtUxC9GzmluKu8rVIFDwwGBKTDseI=
github.com/go-faster/jx v1.2.0/go.mod h1:UWLOVDmMG597a5tBFPLIWJdUxz5/2emOpfsj9Neg0PE=
github.com/go-faster/sdk v0.28.0 h1:zIu1bt0aeujpUJ/3GxaKy/Yn8Y5K9em4yYNsMHqOl+4=
github.com/go-faster/sdk v0.28.0/go.mod h1:Ts+Rd1B0ltePMxuuCwphkfPVtTIbJhV6jzsV46MVM5w=
github.com/go-faster/xor v0.3.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-faster/xor v1.0.0 h1:2o8vTOgErSGHP3/7XwA5ib1FTtUsNtwCoLLBjl31X38=
github.com/go-faster/xor v1.0.0/go.mod h1:x5CaDY9UKErKzqfRfFZdfu+OSTfoZny3w5Ak7UxcipQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/inflect v0.21.5 h1:M2RCq6PPS3YbIaL7CXosGL3BbzAcmfBAT0nC3YfesZA=
github.com/go-openapi/inflect v0.21.5/go.mod h1:GypUyi6bU880NYurWaEH2CmH84zFDNd+EhhmzroHmB4=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gotd/getdoc v0.51.0 h1:69sHC34TnRCizsL/2fGWkbs88GYmEHbRHmHQxF8+umM=
github.com/gotd/getdoc v0.51.0/go.mod h1:yGFagVr+5jxhUkVTQGOqiBG4Pty0slsLGBqEYhNrGIU=
github.com/gotd/ige v0.2.2 h1:XQ9dJZwBfDnOGSTxKXBGP4gMud3Qku2ekScRjDWWfEk=
github.com/gotd/ige v0.2.2/go.mod h1:tuCRb+Y5Y3eNTo3ypIfNpQ4MFjrnONiL2jN2AKZXmb0=
github.com/gotd/neo v0.1.5 h1:oj0iQfMbGClP8xI59x7fE/uHoTJD7NZH9oV1WNuPukQ=
github.com/gotd/neo v0.1.5/go.mod h1:9A2a4bn9zL6FADufBdt7tZt+WMhvZoc5gWXihOPoiBQ=
github.com/gotd/tl v0.4.0 h1:8k2z0drujiPyhpLDa9PRm/yU1Gwlfn3iUzeInPiXwMA=
github.com/gotd/tl v0.4.0/go.mod h1:CMIcjPWFS4qxxJ+1Ce7U/ilbtPrkoVo/t8uhN5Y/D7c=
github.com/k0kubun/pp/v3 v3.5.1 h1:fS8Xt0MWVVSiKwfXeIdE0WJlktdA87/gt0Hs0+j2R2s=
github.com/k0kubun/pp/v3 v3.5.1/go.mod h1:s7qPOSp65uuilpprLJs2yDi9DNd7JGyWJPtPvDFpG9w=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.35.0 h1:VD0ykx7HMiMJytqINBsKcbLS+BJ4WYjz+05us+LRTdI=
github.com/rs/zerolog v1.35.0/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/sebdah/goldie/v2 v2.5.3 h1:9ES/mNN+HNUbNWpVAlrzuZ7jE+Nrczbj8uFRjM7624Y=
github.com/sebdah/goldie/v2 v2.5.3/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM=
github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mau.fi/util v0.9.8 h1:+/jf8eM2dAT2wx9UidmaneH28r/CSCKCniCyby1qWz8=
go.mau.fi/util v0.9.8/go.mod h1:up/5mbzH2M1pSBNXqRxODn8dg/hEKbLJu92W4/SNAX0=
go.mau.fi/webp v0.2.0 h1:QVMenHw7JDb4vall5sV75JNBQj9Hw4u8AKbi1QetHvg=
go.mau.fi/webp v0.2.0/go.mod h1:VSg9MyODn12Mb5pyG0NIyNFhujrmoFSsZBs8syOZD1Q=
go.mau.fi/zeroconfig v0.2.0 h1:e/OGEERqVRRKlgaro7E6bh8xXiKFSXB3eNNIud7FUjU=
go.mau.fi/zeroconfig v0.2.0/go.mod h1:J0Vn0prHNOm493oZoQ84kq83ZaNCYZnq+noI1b1eN8w=
go.mau.fi/zerozap v0.1.2 h1:ffH+8kPveX1qE0IbzeBu4pJ15vwp7Sz3H13qlZ1myGs=
go.mau.fi/zerozap v0.1.2/go.mod h1:I+w0ErpJijmc7q/63ns98W1jkBqF8iXwAe1krrd1IHU=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.27.0 h1:yfEYwoIluVWkofUgbZl9gP4i5nQTF+QNsxtb+r5bKlM=
maunium.net/go/mautrix v0.27.0/go.mod h1:7QpEQiTy6p4LHkXXaZI+N46tGYy8HMhD0JjzZAFoFWs=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+2
View File
@@ -0,0 +1,2 @@
__version__ = "0.6.1rc1"
__author__ = "Tulir Asokan <tulir@maunium.net>"
+163
View File
@@ -0,0 +1,163 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, List, Any
from time import time
import argparse
import asyncio
import logging.config
import sys
import copy
import signal
import os
import sqlalchemy as sql
from mautrix_appservice import AppService
from alchemysession import AlchemySessionContainer
from .web.provisioning import ProvisioningAPI
from .web.public import PublicBridgeWebsite
from .abstract_user import init as init_abstract_user
from .bot import init as init_bot
from .config import Config
from .context import Context
from .db import Base, init as init_db
from .formatter import init as init_formatter
from .matrix import MatrixHandler
from .portal import init as init_portal
from .puppet import init as init_puppet
from .sqlstatestore import SQLStateStore
from .user import User, init as init_user
from . import __version__
try:
import prometheus_client as prometheus
except ImportError:
prometheus = None
parser = argparse.ArgumentParser(
description="A Matrix-Telegram puppeting bridge.",
prog="python -m mautrix-telegram")
parser.add_argument("-c", "--config", type=str, default="config.yaml",
metavar="<path>", help="the path to your config file")
parser.add_argument("-b", "--base-config", type=str, default="example-config.yaml",
metavar="<path>", help="the path to the example config "
"(for automatic config updates)")
parser.add_argument("-g", "--generate-registration", action="store_true",
help="generate registration and quit")
parser.add_argument("-r", "--registration", type=str, default="registration.yaml",
metavar="<path>", help="the path to save the generated registration to")
args = parser.parse_args()
config = Config(args.config, args.registration, args.base_config, os.environ)
config.load()
config.update()
if args.generate_registration:
config.generate_registration()
config.save()
print(f"Registration generated and saved to {config.registration_path}")
sys.exit(0)
logging.config.dictConfig(copy.deepcopy(config["logging"]))
log = logging.getLogger("mau.init") # type: logging.Logger
log.debug(f"Initializing mautrix-telegram {__version__}")
db_engine = sql.create_engine(config["appservice.database"] or "sqlite:///mautrix-telegram.db")
Base.metadata.bind = db_engine
session_container = AlchemySessionContainer(engine=db_engine, table_base=Base, session=False,
table_prefix="telethon_", manage_tables=False)
session_container.core_mode = True
try:
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
log.debug("Using uvloop for asyncio")
except ImportError:
pass
loop = asyncio.get_event_loop() # type: asyncio.AbstractEventLoop
state_store = SQLStateStore()
mebibyte = 1024 ** 2
appserv = AppService(config["homeserver.address"], config["homeserver.domain"],
config["appservice.as_token"], config["appservice.hs_token"],
config["appservice.bot_username"], log="mau.as", loop=loop,
verify_ssl=config["homeserver.verify_ssl"], state_store=state_store,
real_user_content_key="net.maunium.telegram.puppet",
aiohttp_params={
"client_max_size": config["appservice.max_body_size"] * mebibyte
})
bot = init_bot(config)
context = Context(appserv, config, loop, session_container, bot)
if config["appservice.public.enabled"]:
public_website = PublicBridgeWebsite(loop)
appserv.app.add_subapp(config["appservice.public.prefix"] or "/public", public_website.app)
context.public_website = public_website
if config["appservice.provisioning.enabled"]:
provisioning_api = ProvisioningAPI(context)
appserv.app.add_subapp(config["appservice.provisioning.prefix"] or "/_matrix/provisioning",
provisioning_api.app)
context.provisioning_api = provisioning_api
context.mx = MatrixHandler(context)
if config["metrics.enabled"]:
if prometheus:
prometheus.start_http_server(config["metrics.listen_port"])
else:
log.warn("Metrics are enabled in the config, but prometheus-async is not installed.")
with appserv.run(config["appservice.hostname"], config["appservice.port"]) as start:
start_ts = time()
init_db(db_engine)
init_abstract_user(context)
init_formatter(context)
init_portal(context)
startup_actions = (init_puppet(context) +
init_user(context) +
[start, context.mx.init_as_bot()]) # type: List[Awaitable[Any]]
if context.bot:
startup_actions.append(context.bot.start())
signal.signal(signal.SIGINT, signal.default_int_handler)
signal.signal(signal.SIGTERM, signal.default_int_handler)
end_ts = time()
try:
log.debug(f"Initialization complete in {round(end_ts - start_ts, 2)} seconds,"
" running startup actions")
start_ts = time()
loop.run_until_complete(asyncio.gather(*startup_actions, loop=loop))
end_ts = time()
log.debug(f"Startup actions complete in {round(end_ts - start_ts, 2)} seconds,"
" now running forever")
loop.run_forever()
except KeyboardInterrupt:
log.debug("Interrupt received, stopping clients")
loop.run_until_complete(
asyncio.gather(*[user.stop() for user in User.by_tgid.values()], loop=loop))
log.debug("Clients stopped, shutting down")
sys.exit(0)
except Exception as e:
log.exception("Unexpected error")
sys.exit(1)
+423
View File
@@ -0,0 +1,423 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Tuple, Optional, List, Union, Dict, TYPE_CHECKING
from abc import ABC, abstractmethod
import asyncio
import logging
import platform
import time
from telethon.tl.patched import MessageService, Message
from telethon.tl.types import (
Channel, ChannelForbidden, Chat, ChatForbidden, MessageActionChannelMigrateFrom, PeerUser,
TypeUpdate, UpdateChannelPinnedMessage, UpdateChatPinnedMessage, UpdateChatParticipantAdmin,
UpdateChatParticipants, UpdateChatUserTyping, UpdateDeleteChannelMessages, UpdateDeleteMessages,
UpdateEditChannelMessage, UpdateEditMessage, UpdateNewChannelMessage, UpdateNewMessage,
UpdateReadHistoryOutbox, UpdateShortChatMessage, UpdateShortMessage, UpdateUserName,
UpdateUserPhoto, UpdateUserStatus, UpdateUserTyping, User, UserStatusOffline, UserStatusOnline)
from mautrix_appservice import MatrixRequestError, AppService
from alchemysession import AlchemySessionContainer
from . import portal as po, puppet as pu, __version__
from .db import Message as DBMessage
from .types import TelegramID, MatrixUserID
from .tgclient import MautrixTelegramClient
if TYPE_CHECKING:
from .context import Context
from .config import Config
from .bot import Bot
config = None # type: Config
# Value updated from config in init()
MAX_DELETIONS = 10 # type: int
UpdateMessage = Union[UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage]
UpdateMessageContent = Union[UpdateShortMessage, UpdateShortChatMessage, Message, MessageService]
try:
from prometheus_client import Histogram
UPDATE_TIME = Histogram("telegram_update", "Time spent processing Telegram updates",
["update_type"])
except ImportError:
Histogram = None
UPDATE_TIME = None
class AbstractUser(ABC):
session_container = None # type: AlchemySessionContainer
loop = None # type: asyncio.AbstractEventLoop
log = None # type: logging.Logger
az = None # type: AppService
bot = None # type: Bot
ignore_incoming_bot_events = True # type: bool
def __init__(self) -> None:
self.is_admin = False # type: bool
self.matrix_puppet_whitelisted = False # type: bool
self.puppet_whitelisted = False # type: bool
self.whitelisted = False # type: bool
self.relaybot_whitelisted = False # type: bool
self.client = None # type: MautrixTelegramClient
self.tgid = None # type: TelegramID
self.mxid = None # type: MatrixUserID
self.is_relaybot = False # type: bool
self.is_bot = False # type: bool
self.relaybot = None # type: Optional[Bot]
@property
def connected(self) -> bool:
return self.client and self.client.is_connected()
@property
def _proxy_settings(self) -> Optional[Tuple[int, str, str, str, str, str]]:
proxy_type = config["telegram.proxy.type"].lower()
if proxy_type == "disabled":
return None
elif proxy_type == "socks4":
proxy_type = 1
elif proxy_type == "socks5":
proxy_type = 2
elif proxy_type == "http":
proxy_type = 3
return (proxy_type,
config["telegram.proxy.address"], config["telegram.proxy.port"],
config["telegram.proxy.rdns"],
config["telegram.proxy.username"], config["telegram.proxy.password"])
def _init_client(self) -> None:
self.log.debug(f"Initializing client for {self.name}")
self.session = self.session_container.new_session(self.name)
if config["telegram.server.enabled"]:
self.session.set_dc(config["telegram.server.dc"],
config["telegram.server.ip"],
config["telegram.server.port"])
if self.is_relaybot:
base_logger = logging.getLogger("telethon.relaybot")
else:
base_logger = logging.getLogger(f"telethon.{self.tgid or -hash(self.mxid)}")
device = config["telegram.device_info.device_model"]
sysversion = config["telegram.device_info.system_version"]
appversion = config["telegram.device_info.app_version"]
self.client = MautrixTelegramClient(
session=self.session,
api_id=config["telegram.api_id"],
api_hash=config["telegram.api_hash"],
app_version=__version__ if appversion == "auto" else appversion,
system_version=MautrixTelegramClient.__version__ if sysversion == "auto" else sysversion,
device_model=f"{platform.system()} {platform.release()}" if device == "auto" else device,
timeout=config["telegram.connection.timeout"],
connection_retries=config["telegram.connection.retries"],
retry_delay=config["telegram.connection.retry_delay"],
flood_sleep_threshold=config["telegram.connection.flood_sleep_threshold"],
request_retries=config["telegram.connection.request_retries"],
proxy=self._proxy_settings,
loop=self.loop,
base_logger=base_logger
)
self.client.add_event_handler(self._update_catch)
@abstractmethod
async def update(self, update: TypeUpdate) -> bool:
return False
@abstractmethod
async def post_login(self) -> None:
raise NotImplementedError()
@abstractmethod
def register_portal(self, portal: po.Portal) -> None:
raise NotImplementedError()
@abstractmethod
def unregister_portal(self, portal: po.Portal) -> None:
raise NotImplementedError()
async def _update_catch(self, update: TypeUpdate) -> None:
start_time = time.time()
try:
if not await self.update(update):
await self._update(update)
except Exception:
self.log.exception("Failed to handle Telegram update")
if UPDATE_TIME:
UPDATE_TIME.labels(update_type=type(update).__name__).observe(time.time() - start_time)
async def get_dialogs(self, limit: int = None) -> List[Union[Chat, Channel]]:
if self.is_bot:
return []
dialogs = await self.client.get_dialogs(limit=limit)
return [dialog.entity for dialog in dialogs if (
not isinstance(dialog.entity, (User, ChatForbidden, ChannelForbidden))
and not (isinstance(dialog.entity, Chat)
and (dialog.entity.deactivated or dialog.entity.left)))]
@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:
self._init_client()
await self.client.connect()
self.log.debug("%s connected: %s", self.mxid, self.connected)
return self
async def ensure_started(self, even_if_no_session=False) -> 'AbstractUser':
if not self.puppet_whitelisted or self.connected:
return self
self.log.debug("ensure_started(%s, even_if_no_session=%s)", self.mxid, even_if_no_session)
if even_if_no_session or self.session_container.has_session(self.mxid):
await self.start(delete_unless_authenticated=not even_if_no_session)
return self
async def stop(self) -> None:
await self.client.disconnect()
self.client = None
# region Telegram update handling
async def _update(self, update: TypeUpdate) -> None:
asyncio.ensure_future(self._handle_entity_updates(getattr(update, "_entities", {})),
loop=self.loop)
if isinstance(update, (UpdateShortChatMessage, UpdateShortMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateEditMessage, UpdateEditChannelMessage)):
await self.update_message(update)
elif isinstance(update, UpdateDeleteMessages):
await self.delete_message(update)
elif isinstance(update, UpdateDeleteChannelMessages):
await self.delete_channel_message(update)
elif isinstance(update, (UpdateChatUserTyping, 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, (UpdateChannelPinnedMessage, UpdateChatPinnedMessage)):
await self.update_pinned_messages(update)
elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
await self.update_others_info(update)
elif isinstance(update, UpdateReadHistoryOutbox):
await self.update_read_receipt(update)
else:
self.log.debug("Unhandled update: %s", update)
async def update_pinned_messages(self, update: Union[UpdateChannelPinnedMessage,
UpdateChatPinnedMessage]) -> None:
if isinstance(update, UpdateChatPinnedMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id))
else:
portal = po.Portal.get_by_tgid(TelegramID(update.channel_id))
if portal and portal.mxid:
await portal.receive_telegram_pin_id(update.id, self.tgid)
@staticmethod
async def update_participants(update: UpdateChatParticipants) -> None:
portal = po.Portal.get_by_tgid(TelegramID(update.participants.chat_id))
if portal and portal.mxid:
await portal.update_telegram_participants(update.participants.participants)
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 = po.Portal.get_by_tgid(TelegramID(update.peer.user_id), 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 = DBMessage.get_one_by_tgid(TelegramID(update.max_id), self.tgid, edit_index=-1)
if not message:
return
puppet = pu.Puppet.get(TelegramID(update.peer.user_id))
await puppet.intent.mark_read(portal.mxid, message.mxid)
async def update_admin(self, update: UpdateChatParticipantAdmin) -> None:
# TODO duplication not checked
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
if not portal or not portal.mxid:
return
await portal.set_telegram_admin(TelegramID(update.user_id))
async def update_typing(self, update: Union[UpdateUserTyping, UpdateChatUserTyping]) -> None:
if isinstance(update, UpdateUserTyping):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
else:
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
if not portal or not portal.mxid:
return
sender = pu.Puppet.get(TelegramID(update.user_id))
await portal.handle_telegram_typing(sender, update)
async def _handle_entity_updates(self, entities: Dict[int, Union[User, Chat, Channel]]
) -> None:
try:
users = (entity for entity in entities.values() if isinstance(entity, User))
puppets = ((pu.Puppet.get(TelegramID(user.id)), user) for user in users)
await asyncio.gather(*[puppet.update_info(self, info)
for puppet, info in puppets if puppet])
except Exception:
self.log.exception("Failed to handle entity updates")
async def update_others_info(self, update: Union[UpdateUserName, UpdateUserPhoto]) -> None:
# TODO duplication not checked
puppet = pu.Puppet.get(TelegramID(update.user_id))
if isinstance(update, UpdateUserName):
puppet.username = update.username
if await puppet.update_displayname(self, update):
puppet.save()
elif isinstance(update, UpdateUserPhoto):
if await puppet.update_avatar(self, update.photo):
puppet.save()
else:
self.log.warning("Unexpected other user info update: %s", update)
async def update_status(self, update: UpdateUserStatus) -> None:
puppet = pu.Puppet.get(TelegramID(update.user_id))
if isinstance(update.status, UserStatusOnline):
await puppet.default_mxid_intent.set_presence("online")
elif isinstance(update.status, UserStatusOffline):
await puppet.default_mxid_intent.set_presence("offline")
else:
self.log.warning("Unexpected user status update: %s", update)
return
def get_message_details(self, update: UpdateMessage) -> Tuple[UpdateMessageContent,
Optional[pu.Puppet],
Optional[po.Portal]]:
if isinstance(update, UpdateShortChatMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.chat_id), peer_type="chat")
sender = pu.Puppet.get(TelegramID(update.from_id))
elif isinstance(update, UpdateShortMessage):
portal = po.Portal.get_by_tgid(TelegramID(update.user_id), self.tgid, "user")
sender = pu.Puppet.get(self.tgid if update.out else update.user_id)
elif isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage,
UpdateEditMessage, UpdateEditChannelMessage)):
update = update.message
if isinstance(update.to_id, PeerUser) and not update.out:
portal = po.Portal.get_by_tgid(update.from_id, peer_type="user",
tg_receiver=self.tgid)
else:
portal = po.Portal.get_by_entity(update.to_id, receiver_id=self.tgid)
sender = pu.Puppet.get(update.from_id) if update.from_id else None
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 = po.Portal.get_by_mxid(message.mx_room)
if not portal:
return
try:
await portal.main_intent.redact(message.mx_room, message.mxid)
except MatrixRequestError:
pass
async def delete_message(self, update: UpdateDeleteMessages) -> None:
if len(update.messages) > MAX_DELETIONS:
return
for message_id in update.messages:
messages = DBMessage.get_all_by_tgid(TelegramID(message_id), self.tgid)
for message in messages:
message.delete()
number_left = DBMessage.count_spaces_by_mxid(message.mxid, message.mx_room)
if number_left == 0:
portal = po.Portal.get_by_mxid(message.mx_room)
await self._try_redact(message)
async def delete_channel_message(self, update: UpdateDeleteChannelMessages) -> None:
if len(update.messages) > MAX_DELETIONS:
return
channel_id = TelegramID(update.channel_id)
for message_id in update.messages:
messages = DBMessage.get_all_by_tgid(TelegramID(message_id), channel_id)
for message in messages:
message.delete()
await self._try_redact(message)
async def update_message(self, original_update: UpdateMessage) -> None:
update, sender, portal = self.get_message_details(original_update)
if self.is_bot and not portal.mxid:
self.log.debug(f"Ignoring message received by bot in unbridged chat %s",
portal.tgid_log)
return
if self.ignore_incoming_bot_events and self.bot and sender.id == self.bot.tgid:
self.log.debug(f"Ignoring relaybot-sent message %s to %s", update, portal.tgid_log)
return
if isinstance(update, MessageService):
if isinstance(update.action, MessageActionChannelMigrateFrom):
self.log.debug(f"Ignoring action %s to %s by %d", update.action,
portal.tgid_log,
sender.id)
return
self.log.debug("Handling action %s to %s by %d", update.action, portal.tgid_log,
sender.id)
return await portal.handle_telegram_action(self, sender, update)
user = sender.tgid if sender else "admin"
if isinstance(original_update, (UpdateEditMessage, UpdateEditChannelMessage)):
return await portal.handle_telegram_edit(self, sender, update)
self.log.debug("Handling message %s to %s by %s", update, portal.tgid_log, user)
return await portal.handle_telegram_message(self, sender, update)
# endregion
def init(context: "Context") -> None:
global config, MAX_DELETIONS
AbstractUser.az, config, AbstractUser.loop, AbstractUser.relaybot = context.core
AbstractUser.ignore_incoming_bot_events = config["bridge.relaybot.ignore_own_incoming_events"]
AbstractUser.session_container = context.session_container
MAX_DELETIONS = config.get("bridge.max_telegram_delete", 10)
+298
View File
@@ -0,0 +1,298 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, Callable, Dict, List, Optional, Pattern, Tuple, TYPE_CHECKING
import logging
import re
from telethon.tl.patched import Message, MessageService
from telethon.tl.types import (
ChannelParticipantAdmin, ChannelParticipantCreator, ChatForbidden, ChatParticipantAdmin,
ChatParticipantCreator, InputChannel, InputUser, MessageActionChatAddUser,
MessageActionChatDeleteUser, MessageEntityBotCommand, PeerChannel, PeerChat, TypePeer,
UpdateNewChannelMessage, UpdateNewMessage, MessageActionChatMigrateTo, User)
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
from telethon.errors import ChannelInvalidError, ChannelPrivateError
from .types import MatrixUserID
from .abstract_user import AbstractUser
from .db import BotChat
from .types import TelegramID
from . import puppet as pu, portal as po, user as u
if TYPE_CHECKING:
from .config import Config
from .context import Context
config = None # type: Config
ReplyFunc = Callable[[str], Awaitable[Message]]
class Bot(AbstractUser):
log = logging.getLogger("mau.bot") # type: logging.Logger
mxid_regex = re.compile("@.+:.+") # type: Pattern
def __init__(self, token: str) -> None:
super().__init__()
self.token = token # type: str
self.puppet_whitelisted = True # type: bool
self.whitelisted = True # type: bool
self.relaybot_whitelisted = True # type: bool
self.username = None # type: str
self.is_relaybot = True # type: bool
self.is_bot = True # type: bool
self.chats = {} # type: Dict[int, str]
self.tg_whitelist = [] # type: List[int]
self.whitelist_group_admins = (config["bridge.relaybot.whitelist_group_admins"]
or False) # type: bool
self._me_info = None # type: Optional[User]
self._me_mxid = None # type: Optional[MatrixUserID]
async def get_me(self, use_cache: bool = True) -> Tuple[User, MatrixUserID]:
if not use_cache or not self._me_mxid:
self._me_info = await self.client.get_me()
self._me_mxid = pu.Puppet.get_mxid_from_id(TelegramID(self._me_info.id))
return self._me_info, self._me_mxid
async def init_permissions(self) -> None:
whitelist = config["bridge.relaybot.whitelist"] or []
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 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 post_login(self) -> None:
await self.init_permissions()
info = await self.client.get_me()
self.tgid = info.id
self.username = info.username
self.mxid = pu.Puppet.get_mxid_from_id(self.tgid)
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:
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):
self.remove_chat(TelegramID(channel_id.channel_id))
if config["bridge.catch_up"]:
try:
await self.client.catch_up()
except Exception:
self.log.exception("Failed to run catch_up() for bot")
def register_portal(self, portal: po.Portal) -> None:
self.add_chat(portal.tgid, portal.peer_type)
def unregister_portal(self, portal: po.Portal) -> None:
self.remove_chat(portal.tgid)
def add_chat(self, chat_id: TelegramID, chat_type: str) -> None:
if chat_id not in self.chats:
self.chats[chat_id] = chat_type
BotChat(id=TelegramID(chat_id), type=chat_type).insert()
def remove_chat(self, chat_id: TelegramID) -> None:
try:
del self.chats[chat_id]
except KeyError:
pass
BotChat.delete(chat_id)
async def _can_use_commands(self, chat: TypePeer, tgid: TelegramID) -> bool:
if tgid in self.tg_whitelist:
return True
user = 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:
if isinstance(chat, PeerChannel):
p = await self.client(GetParticipantRequest(chat, tgid))
return isinstance(p, (ChannelParticipantCreator, ChannelParticipantAdmin))
elif isinstance(chat, PeerChat):
chat = await self.client(GetFullChatRequest(chat.chat_id))
participants = chat.full_chat.participants.participants
for p in participants:
if p.user_id == tgid:
return isinstance(p, (ChatParticipantCreator, ChatParticipantAdmin))
return False
async def check_can_use_commands(self, event: Message, reply: ReplyFunc) -> bool:
if not await self._can_use_commands(event.to_id, event.from_id):
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 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.")
async def handle_command_invite(self, portal: po.Portal, reply: ReplyFunc,
mxid_input: MatrixUserID) -> Message:
if len(mxid_input) == 0:
return await reply("Usage: `/invite <mxid>`")
elif not portal.mxid:
return await reply("Portal does not have Matrix room. "
"Create one with /portal first.")
if not self.mxid_regex.match(mxid_input):
return await reply("That doesn't look like a Matrix ID.")
user = await u.User.get_by_mxid(MatrixUserID(mxid_input)).ensure_started()
if not user.relaybot_whitelisted:
return await reply("That user is not whitelisted to use the bridge.")
elif await user.is_logged_in():
displayname = f"@{user.username}" if user.username else user.displayname
return await reply("That user seems to be logged in. "
f"Just invite [{displayname}](tg://user?id={user.tgid})")
else:
await portal.main_intent.invite(portal.mxid, user.mxid)
return await reply(f"Invited `{user.mxid}` to the portal.")
@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}")
return reply(str(-message.to_id.chat_id))
def match_command(self, text: str, command: str) -> bool:
text = text.lower()
command = f"/{command.lower()}"
command_targeted = f"{command}@{self.username.lower()}"
is_plain_command = text == command or text == command_targeted
if is_plain_command:
return True
is_arg_command = text.startswith(command + " ") or text.startswith(command_targeted + " ")
if is_arg_command:
return True
return False
async def handle_command(self, message: Message) -> None:
def reply(reply_text: str) -> Awaitable[Message]:
return self.client.send_message(message.to_id, reply_text, reply_to=message.id)
text = message.message
if self.match_command(text, "id"):
await self.handle_command_id(message, reply)
return
portal = po.Portal.get_by_entity(message.to_id)
if self.match_command(text, "portal"):
if not await self.check_can_use_commands(message, reply):
return
await self.handle_command_portal(portal, reply)
elif self.match_command(text, "invite"):
if not await self.check_can_use_commands(message, reply):
return
try:
mxid = text[text.index(" ") + 1:]
except ValueError:
mxid = ""
await self.handle_command_invite(portal, reply, mxid_input=mxid)
def handle_service_message(self, message: MessageService) -> None:
to_id = message.to_id # type: TelegramID
if isinstance(to_id, PeerChannel):
to_id = to_id.channel_id
chat_type = "channel"
elif isinstance(to_id, PeerChat):
to_id = to_id.chat_id
chat_type = "chat"
else:
return
action = message.action
if isinstance(action, MessageActionChatAddUser) and self.tgid in action.users:
self.add_chat(to_id, chat_type)
elif isinstance(action, MessageActionChatDeleteUser) and action.user_id == self.tgid:
self.remove_chat(to_id)
elif isinstance(action, MessageActionChatMigrateTo):
self.remove_chat(to_id)
self.add_chat(TelegramID(action.channel_id), "channel")
async def update(self, update) -> bool:
if not isinstance(update, (UpdateNewMessage, UpdateNewChannelMessage)):
return False
if isinstance(update.message, MessageService):
self.handle_service_message(update.message)
return False
is_command = (isinstance(update.message, Message)
and update.message.entities and len(update.message.entities) > 0
and isinstance(update.message.entities[0], MessageEntityBotCommand))
if is_command:
await self.handle_command(update.message)
return True
return False
def is_in_chat(self, peer_id) -> bool:
return peer_id in self.chats
@property
def name(self) -> str:
return "bot"
def init(cfg: 'Config') -> Optional[Bot]:
global config
config = cfg
token = config["telegram.bot_token"]
if token and not token.lower().startswith("disable"):
return Bot(token)
return None
+5
View File
@@ -0,0 +1,5 @@
from .handler import (command_handler, command_handlers as _command_handlers,
CommandHandler, CommandProcessor, CommandEvent,
SECTION_GENERAL, SECTION_AUTH, SECTION_CREATING_PORTALS,
SECTION_PORTAL_MANAGEMENT, SECTION_MISC, SECTION_ADMIN)
from . import portal, telegram, clean_rooms, matrix_auth, meta
+178
View File
@@ -0,0 +1,178 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, NewType, Optional, Tuple, Union
from mautrix_appservice import MatrixRequestError, IntentAPI
from ..types import MatrixRoomID, MatrixUserID
from . import command_handler, CommandEvent, SECTION_ADMIN
from .. import puppet as pu, portal as po
ManagementRoom = NewType('ManagementRoom', Tuple[MatrixRoomID, MatrixUserID])
async def _find_rooms(intent: IntentAPI) -> Tuple[List[ManagementRoom], List[MatrixRoomID],
List['po.Portal'], List['po.Portal']]:
management_rooms = [] # type: List[ManagementRoom]
unidentified_rooms = [] # type: List[MatrixRoomID]
portals = [] # type: List[po.Portal]
empty_portals = [] # type: List[po.Portal]
rooms = await intent.get_joined_rooms()
for room_str in rooms:
room = MatrixRoomID(room_str)
portal = po.Portal.get_by_mxid(room)
if not portal:
try:
members = await intent.get_room_members(room)
except MatrixRequestError:
members = []
if len(members) == 2:
other_member = MatrixUserID(members[0] if members[0] != intent.mxid else members[1])
if pu.Puppet.get_id_from_mxid(other_member):
unidentified_rooms.append(room)
else:
management_rooms.append(ManagementRoom((room, other_member)))
else:
unidentified_rooms.append(room)
else:
members = await portal.get_authenticated_matrix_users()
if len(members) == 0:
empty_portals.append(portal)
else:
portals.append(portal)
return management_rooms, unidentified_rooms, portals, empty_portals
@command_handler(needs_admin=True, needs_auth=False, management_only=True, name="clean-rooms",
help_section=SECTION_ADMIN,
help_text="Clean up unused portal/management rooms.")
async def clean_rooms(evt: CommandEvent) -> Optional[Dict]:
management_rooms, unidentified_rooms, portals, empty_portals = await _find_rooms(evt.az.intent)
reply = ["#### Management rooms (M)"]
reply += ([f"{n+1}. [M{n+1}](https://matrix.to/#/{room}) (with {other_member}"
for n, (room, other_member) in enumerate(management_rooms)]
or ["No management rooms found."])
reply.append("#### Active portal rooms (A)")
reply += ([f"{n+1}. [A{n+1}](https://matrix.to/#/{portal.mxid}) "
f"(to Telegram chat \"{portal.title}\")"
for n, portal in enumerate(portals)]
or ["No active portal rooms found."])
reply.append("#### Unidentified rooms (U)")
reply += ([f"{n+1}. [U{n+1}](https://matrix.to/#/{room})"
for n, room in enumerate(unidentified_rooms)]
or ["No unidentified rooms found."])
reply.append("#### Inactive portal rooms (I)")
reply += ([f"{n}. [I{n}](https://matrix.to/#/{portal.mxid}) "
f"(to Telegram chat \"{portal.title}\")"
for n, portal in enumerate(empty_portals)]
or ["No inactive portal rooms found."])
reply += ["#### Usage",
("To clean the recommended set of rooms (unidentified & inactive portals), "
"type `$cmdprefix+sp clean-recommended`"),
"",
("To clean other groups of rooms, type `$cmdprefix+sp clean-groups <letters>` "
"where `letters` are the first letters of the group names (M, A, U, I)"),
"",
("To clean specific rooms, type `$cmdprefix+sp clean-range <range>` "
"where `range` is the range (e.g. `5-21`) prefixed with the first letter of"
"the group name. (e.g. `I2-6`)"),
"",
("Please note that you will have to re-run `$cmdprefix+sp clean-rooms` "
"between each use of the commands above.")]
evt.sender.command_status = {
"next": lambda clean_evt: set_rooms_to_clean(clean_evt, management_rooms,
unidentified_rooms, portals, empty_portals),
"action": "Room cleaning",
}
return await evt.reply("\n".join(reply))
async def set_rooms_to_clean(evt, management_rooms: List[ManagementRoom],
unidentified_rooms: List[MatrixRoomID], portals: List["po.Portal"],
empty_portals: List["po.Portal"]) -> None:
command = evt.args[0]
rooms_to_clean = [] # type: List[Union[po.Portal, MatrixRoomID]]
if command == "clean-recommended":
rooms_to_clean += empty_portals
rooms_to_clean += unidentified_rooms
elif command == "clean-groups":
if len(evt.args) < 2:
return await evt.reply("**Usage:** `$cmdprefix+sp clean-groups [M][A][U][I]")
groups_to_clean = evt.args[1].upper()
if "M" in groups_to_clean:
rooms_to_clean += [room_id for (room_id, user_id) in management_rooms]
if "A" in groups_to_clean:
rooms_to_clean += portals
if "U" in groups_to_clean:
rooms_to_clean += unidentified_rooms
if "I" in groups_to_clean:
rooms_to_clean += empty_portals
elif command == "clean-range":
try:
clean_range = evt.args[1]
group, clean_range = clean_range[0], clean_range[1:]
start, end = clean_range.split("-")
start, end = int(start), int(end)
if group == "M":
group = [room_id for (room_id, user_id) in management_rooms]
elif group == "A":
group = portals
elif group == "U":
group = unidentified_rooms
elif group == "I":
group = empty_portals
else:
raise ValueError("Unknown group")
rooms_to_clean = group[start - 1:end]
except (KeyError, ValueError):
return await evt.reply(
"**Usage:** `$cmdprefix+sp clean-groups <_M|A|U|I_><range>")
else:
return await evt.reply(f"Unknown room cleaning action `{command}`. "
"Use `$cmdprefix+sp cancel` to cancel room "
"cleaning.")
evt.sender.command_status = {
"next": lambda confirm: execute_room_cleanup(confirm, rooms_to_clean),
"action": "Room cleaning",
}
await evt.reply(f"To confirm cleaning up {len(rooms_to_clean)} rooms, type"
"`$cmdprefix+sp confirm-clean`.")
async def execute_room_cleanup(evt, rooms_to_clean: List[Union[po.Portal, MatrixRoomID]]) -> None:
if len(evt.args) > 0 and evt.args[0] == "confirm-clean":
await evt.reply(f"Cleaning {len(rooms_to_clean)} rooms. "
"This might take a while.")
cleaned = 0
for room in rooms_to_clean:
if isinstance(room, po.Portal):
await room.cleanup_and_delete()
cleaned += 1
elif isinstance(room, str): # str is aliased by MatrixRoomID
await po.Portal.cleanup_room(evt.az.intent, room, message="Room deleted")
cleaned += 1
evt.sender.command_status = None
await evt.reply(f"{cleaned} rooms cleaned up successfully.")
else:
await evt.reply("Room cleaning cancelled.")
+365
View File
@@ -0,0 +1,365 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""This module contains classes handling commands issued by Matrix users."""
from typing import Awaitable, Callable, Dict, List, NamedTuple, Optional
import logging
import traceback
import commonmark
from telethon.errors import FloodWaitError
from ..types import MatrixRoomID, MatrixEventID
from ..util import format_duration
from .. import user as u, context as c
command_handlers = {} # type: Dict[str, CommandHandler]
HelpSection = NamedTuple('HelpSection', [('name', str), ('order', int), ('description', str)])
SECTION_GENERAL = HelpSection("General", 0, "")
SECTION_AUTH = HelpSection("Authentication", 10, "")
SECTION_CREATING_PORTALS = HelpSection("Creating portals", 20, "")
SECTION_PORTAL_MANAGEMENT = HelpSection("Portal management", 30, "")
SECTION_MISC = HelpSection("Miscellaneous", 40, "")
SECTION_ADMIN = HelpSection("Administration", 50, "")
class HtmlEscapingRenderer(commonmark.HtmlRenderer):
def __init__(self, allow_html: bool = False):
super().__init__()
self.allow_html = allow_html
def lit(self, s):
if self.allow_html:
return super().lit(s)
return super().lit(s.replace("<", "&lt;").replace(">", "&gt;"))
def image(self, node, entering):
prev = self.allow_html
self.allow_html = True
super().image(node, entering)
self.allow_html = prev
md_parser = commonmark.Parser()
md_renderer = HtmlEscapingRenderer()
def ensure_trailing_newline(s: str) -> str:
"""Returns the passed string, but with a guaranteed trailing newline."""
return s + ("" if s[-1] == "\n" else "\n")
class CommandEvent:
"""Holds information about a command issued in a Matrix room.
When a Matrix command was issued to the bot, CommandEvent will hold
information regarding the event.
Attributes:
room_id: The id of the Matrix room in which the command was issued.
event_id: The id of the matrix event which contained the command.
sender: The user who issued the command.
command: The issued command.
args: Arguments given with the issued command.
is_management: Determines whether the room in which the command wa
issued is a management room.
is_portal: Determines whether the room in which the command was issued
is a portal.
"""
def __init__(self, processor: 'CommandProcessor', room: MatrixRoomID, event: MatrixEventID,
sender: u.User, command: str, args: List[str], is_management: bool,
is_portal: bool) -> None:
self.az = processor.az
self.log = processor.log
self.loop = processor.loop
self.tgbot = processor.tgbot
self.config = processor.config
self.public_website = processor.public_website
self.command_prefix = processor.command_prefix
self.room_id = room
self.event_id = event
self.sender = sender
self.command = command
self.args = args
self.is_management = is_management
self.is_portal = is_portal
def reply(self, message: str, allow_html: bool = False, render_markdown: bool = True
) -> Awaitable[Dict]:
"""Write a reply to the room in which the command was issued.
Replaces occurences of "$cmdprefix" in the message with the command
prefix and replaces occurences of "$cmdprefix+sp " with the command
prefix if the command was not issued in a management room.
If allow_html and render_markdown are both False, the message will not
be rendered to html and sending of html is disabled.
Args:
message: The message to post in the room.
allow_html: Escape html in the message or don't render html at all
if markdown is disabled.
render_markdown: Use markdown formatting to render the passed
message to html.
Returns:
Handler for the message sending function.
"""
message_cmd = self._replace_command_prefix(message)
html = self._render_message(message_cmd, allow_html=allow_html,
render_markdown=render_markdown)
return self.az.intent.send_notice(self.room_id, message_cmd, html=html)
def mark_read(self) -> Awaitable[Dict]:
"""Marks the command as read by the bot."""
return self.az.intent.mark_read(self.room_id, self.event_id)
def _replace_command_prefix(self, message: str) -> str:
"""Returns the string with the proper command prefix entered."""
message = message.replace(
"$cmdprefix+sp ", "" if self.is_management else f"{self.command_prefix} "
)
return message.replace("$cmdprefix", self.command_prefix)
@staticmethod
def _render_message(message: str, allow_html: bool, render_markdown: bool) -> Optional[str]:
"""Renders the message as HTML.
Args:
allow_html: Flag to allow custom HTML in the message.
render_markdown: If true, markdown styling is applied to the message.
Returns:
The message rendered as HTML.
None is returned if no styled output is required.
"""
html = ""
if render_markdown:
md_renderer.allow_html = allow_html
html = md_renderer.render(md_parser.parse(message))
elif allow_html:
html = message
return ensure_trailing_newline(html) if html else None
class CommandHandler:
"""A command which can be executed from a Matrix room.
The command manages its permission and help texts.
When called, it will check the permission of the command event and execute
the command or, in case of error, report back to the user.
Attributes:
needs_auth: Flag indicating if the sender is required to be logged in.
needs_puppeting: Flag indicating if the sender is required to use
Telegram puppeteering for this command.
needs_matrix_puppeting: Flag indicating if the sender is required to use
Matrix pupeteering.
needs_admin: Flag for whether only admin users can issue this command.
management_only: Whether the command can exclusively be issued in a
management room.
name: The name of this command.
help_section: Section of the help in which this command will appear.
"""
def __init__(self, handler: Callable[[CommandEvent], Awaitable[Dict]], needs_auth: bool,
needs_puppeting: bool, needs_matrix_puppeting: bool, needs_admin: bool,
management_only: bool, name: str, help_text: str, help_args: str,
help_section: HelpSection) -> None:
"""
Args:
handler: The function handling the execution of this command.
needs_auth: Flag indicating if the sender is required to be logged in.
needs_puppeting: Flag indicating if the sender is required to use
Telegram puppeteering for this command.
needs_matrix_puppeting: Flag indicating if the sender is required to
use Matrix pupeteering.
needs_admin: Flag for whether only admin users can issue this command.
management_only: Whether the command can exclusively be issued
in a management room.
name: The name of this command.
help_text: The text displayed in the help for this command.
help_args: Help text for the arguments of this command.
help_section: Section of the help in which this command will appear.
"""
self._handler = handler
self.needs_auth = needs_auth
self.needs_puppeting = needs_puppeting
self.needs_matrix_puppeting = needs_matrix_puppeting
self.needs_admin = needs_admin
self.management_only = management_only
self.name = name
self._help_text = help_text
self._help_args = help_args
self.help_section = help_section
async def get_permission_error(self, evt: CommandEvent) -> Optional[str]:
"""Returns the reason why the command could not be issued.
Args:
evt: The event for which to get the error information.
Returns:
A string describing the error or None if there was no error.
"""
if self.management_only and not evt.is_management:
return (f"`{evt.command}` is a restricted command: "
"you may only run it in management rooms.")
elif self.needs_puppeting and not evt.sender.puppet_whitelisted:
return "This command requires puppeting privileges."
elif self.needs_matrix_puppeting and not evt.sender.matrix_puppet_whitelisted:
return "This command requires Matrix puppeting privileges."
elif self.needs_admin and not evt.sender.is_admin:
return "This command requires administrator privileges."
elif self.needs_auth and not await evt.sender.is_logged_in():
return "This command requires you to be logged in."
return None
def has_permission(self, is_management: bool, puppet_whitelisted: bool,
matrix_puppet_whitelisted: bool, is_admin: bool, is_logged_in: bool) -> bool:
"""Checks the permission for this command with the given status.
Args:
is_management: If the room in which the command will be issued is a
management room.
puppet_whitelisted: If the connected Telegram account puppet is
allowed to issue the command.
matrix_puppet_whitelisted: If the connected Matrix account puppet is
allowed to issue the command.
is_admin: If the issuing user is an admin.
is_logged_in: If the issuing user is logged in.
Returns:
True if a user with the given state is allowed to issue the
command.
"""
return ((not self.management_only or is_management) and
(not self.needs_puppeting or puppet_whitelisted) and
(not self.needs_matrix_puppeting or matrix_puppet_whitelisted) and
(not self.needs_admin or is_admin) and
(not self.needs_auth or is_logged_in))
async def __call__(self, evt: CommandEvent) -> Dict:
"""Executes the command if evt was issued with proper rights.
Args:
evt: The CommandEvent for which to check permissions.
Returns:
The result of the command or the error message function.
Raises:
FloodWaitError
"""
error = await self.get_permission_error(evt)
if error is not None:
return await evt.reply(error)
return await self._handler(evt)
@property
def has_help(self) -> bool:
"""Returns true if this command has a help text."""
return bool(self.help_section) and bool(self._help_text)
@property
def help(self) -> str:
"""Returns the help text to this command."""
return f"**{self.name}** {self._help_args} - {self._help_text}"
def command_handler(_func: Optional[Callable[[CommandEvent], Awaitable[Dict]]] = None, *,
needs_auth: bool = True, needs_puppeting: bool = True,
needs_matrix_puppeting: bool = False, needs_admin: bool = False,
management_only: bool = False, name: Optional[str] = None,
help_text: str = "", help_args: str = "", help_section: HelpSection = None
) -> Callable[[Callable[[CommandEvent], Awaitable[Optional[Dict]]]],
CommandHandler]:
def decorator(func: Callable[[CommandEvent], Awaitable[Optional[Dict]]]) -> CommandHandler:
actual_name = name or func.__name__.replace("_", "-")
handler = CommandHandler(func, needs_auth, needs_puppeting, needs_matrix_puppeting,
needs_admin, management_only, actual_name, help_text, help_args,
help_section)
command_handlers[handler.name] = handler
return handler
return decorator if _func is None else decorator(_func)
class CommandProcessor:
"""Handles the raw commands issued by a user to the Matrix bot."""
log = logging.getLogger("mau.commands")
def __init__(self, context: c.Context) -> None:
self.az, self.config, self.loop, self.tgbot = context.core
self.public_website = context.public_website
self.command_prefix = self.config["bridge.command_prefix"]
async def handle(self, room: MatrixRoomID, event_id: MatrixEventID, sender: u.User,
command: str, args: List[str], is_management: bool, is_portal: bool
) -> Optional[Dict]:
"""Handles the raw commands issued by a user to the Matrix bot.
If the command is not known, it might be a followup command and is
delegated to a command handler registered for that purpose in the
senders command_status as "next".
Args:
room: ID of the Matrix room in which the command was issued.
event_id: ID of the event by which the command was issued.
sender: The sender who issued the command.
command: The issued command, case insensitive.
args: Arguments given with the command.
is_management: Whether the room is a management room.
is_portal: Whether the room is a portal.
Returns:
The result of the error message function or None if no error
occured. Unknown and delegated commands do not count as errors.
"""
if not command_handlers or "unknown-command" not in command_handlers:
raise ValueError("command_handlers are not properly initialized.")
evt = CommandEvent(self, room, event_id, sender, command, args, is_management, is_portal)
orig_command = command
command = command.lower()
try:
handler = command_handlers[command]
except KeyError:
if sender.command_status and "next" in sender.command_status:
args.insert(0, orig_command)
evt.command = ""
handler = sender.command_status["next"]
else:
handler = command_handlers["unknown-command"]
try:
await handler(evt)
except FloodWaitError as e:
return await evt.reply(f"Flood error: Please wait {format_duration(e.seconds)}")
except Exception:
self.log.exception("Unhandled error while handling command "
f"{evt.command} {' '.join(args)} from {sender.mxid}")
if evt.sender.is_admin and evt.is_management:
return await evt.reply("Unhandled error while handling command:\n\n"
"```traceback\n"
f"{traceback.format_exc()}"
"```")
return await evt.reply("Unhandled error while handling command. "
"Check logs for more details.")
return None
+101
View File
@@ -0,0 +1,101 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional
from . import command_handler, CommandEvent, SECTION_AUTH
from .. import puppet as pu
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Revert your Telegram account's Matrix puppet to use the default Matrix "
"account.")
async def logout_matrix(evt: CommandEvent) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid)
if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.")
await puppet.switch_mxid(None, None)
return await evt.reply("Reverted your Telegram account's Matrix puppet back to the default.")
@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) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid)
if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first.")
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.")
@command_handler(needs_auth=True, needs_matrix_puppeting=True,
help_section=SECTION_AUTH,
help_text="Pings the server with the stored matrix authentication.")
async def ping_matrix(evt: CommandEvent) -> Optional[Dict]:
puppet = pu.Puppet.get(evt.sender.tgid)
if not puppet.is_real_user:
return await evt.reply("You are not logged in with your Matrix account.")
resp = await puppet.init_custom_mxid()
if resp == pu.PuppetError.InvalidAccessToken:
return await evt.reply("Your access token is invalid.")
elif resp == pu.PuppetError.Success:
return await evt.reply("Your Matrix login is working.")
return await evt.reply(f"Unknown response while checking your Matrix login: {resp}.")
async def enter_matrix_token(evt: CommandEvent) -> Dict:
evt.sender.command_status = None
puppet = pu.Puppet.get(evt.sender.tgid)
if puppet.is_real_user:
return await evt.reply("You have already logged in with your Matrix account. "
"Log out with `$cmdprefix+sp logout-matrix` first.")
resp = await puppet.switch_mxid(" ".join(evt.args), evt.sender.mxid)
if resp == pu.PuppetError.OnlyLoginSelf:
return await evt.reply("You can only log in as your own Matrix user.")
elif resp == pu.PuppetError.InvalidAccessToken:
return await evt.reply("Failed to verify access token.")
assert resp == pu.PuppetError.Success, "Encountered an unhandled PuppetError."
return await evt.reply(
f"Replaced your Telegram account's Matrix puppet with {puppet.custom_mxid}.")
+72
View File
@@ -0,0 +1,72 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple
from . import command_handler, CommandEvent, _command_handlers, SECTION_GENERAL
from .handler import HelpSection
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_GENERAL,
help_text="Cancel an ongoing action (such as login)")
async def cancel(evt: CommandEvent) -> Optional[Dict]:
if evt.sender.command_status:
action = evt.sender.command_status["action"]
evt.sender.command_status = None
return await evt.reply(f"{action} cancelled.")
else:
return await evt.reply("No ongoing command.")
@command_handler(needs_auth=False, needs_puppeting=False)
async def unknown_command(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply("Unknown command. Try `$cmdprefix+sp help` for help.")
help_cache = {} # type: Dict[Tuple[bool, bool, bool, bool, bool], str]
async def _get_help_text(evt: CommandEvent) -> str:
cache_key = (evt.is_management, evt.sender.puppet_whitelisted,
evt.sender.matrix_puppet_whitelisted, evt.sender.is_admin,
await evt.sender.is_logged_in())
if cache_key not in help_cache:
help_sections = {} # type: Dict[HelpSection, List[str]]
for handler in _command_handlers.values():
if handler.has_help and handler.has_permission(*cache_key):
help_sections.setdefault(handler.help_section, [])
help_sections[handler.help_section].append(handler.help + " ")
help_sorted = sorted(help_sections.items(), key=lambda item: item[0].order)
helps = ["#### {}\n{}\n".format(key.name, "\n".join(value)) for key, value in help_sorted]
help_cache[cache_key] = "\n".join(helps)
return help_cache[cache_key]
def _get_management_status(evt: CommandEvent) -> str:
if evt.is_management:
return "This is a management room: prefixing commands with `$cmdprefix` is not required."
elif evt.is_portal:
return ("**This is a portal room**: you must always prefix commands with `$cmdprefix`.\n"
"Management commands will not be sent to Telegram.")
return "**This is not a management room**: you must prefix commands with `$cmdprefix`."
@command_handler(name="help", needs_auth=False, needs_puppeting=False,
help_section=SECTION_GENERAL,
help_text="Show this help message.")
async def help_cmd(evt: CommandEvent) -> Optional[Dict]:
return await evt.reply(_get_management_status(evt) + "\n" + await _get_help_text(evt))
@@ -0,0 +1 @@
from . import admin, bridge, config, create_chat, filter, misc, unbridge
+101
View File
@@ -0,0 +1,101 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
import asyncio
from mautrix_appservice import MatrixRequestError
from ... import portal as po, puppet as pu, user as u
from .. import command_handler, CommandEvent, SECTION_ADMIN
@command_handler(needs_admin=True, needs_auth=False, name="set-pl",
help_section=SECTION_ADMIN,
help_args="<_level_> [_mxid_]",
help_text="Set a temporary power level without affecting Telegram.")
async def set_power_level(evt: CommandEvent) -> Dict:
try:
level = int(evt.args[0])
except KeyError:
return await evt.reply("**Usage:** `$cmdprefix+sp set-pl <level> [mxid]`")
except ValueError:
return await evt.reply("The level must be an integer.")
levels = await evt.az.intent.get_power_levels(evt.room_id)
mxid = evt.args[1] if len(evt.args) > 1 else evt.sender.mxid
levels["users"][mxid] = level
try:
await evt.az.intent.set_power_levels(evt.room_id, levels)
except MatrixRequestError:
evt.log.exception("Failed to set power level.")
return await evt.reply("Failed to set power level.")
return {}
@command_handler(needs_admin=True, needs_auth=False,
help_section=SECTION_ADMIN,
help_args="<`portal`|`puppet`|`user`>",
help_text="Clear internal bridge caches")
async def clear_db_cache(evt: CommandEvent) -> Dict:
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.cache = {}
for puppet in pu.Puppet.by_custom_mxid.values():
puppet.sync_task.cancel()
pu.Puppet.by_custom_mxid = {}
await asyncio.gather(
*[puppet.init_custom_mxid() for puppet in pu.Puppet.all_with_custom_mxid()],
loop=evt.loop)
await 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) -> Dict:
if len(evt.args) > 0:
mxid = evt.args[0]
else:
mxid = evt.sender.mxid
user = u.User.get_by_mxid(mxid, create=False)
if not user:
return await evt.reply("User not found")
puppet = pu.Puppet.get_by_custom_mxid(mxid)
if puppet:
puppet.sync_task.cancel()
await user.stop()
user.delete(delete_db=False)
user = u.User.get_by_mxid(mxid)
await user.ensure_started()
if puppet:
await puppet.init_custom_mxid()
await evt.reply(f"Reloaded and reconnected {user.mxid} (telegram: {user.human_tg_id})")
+187
View File
@@ -0,0 +1,187 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional, Tuple, Coroutine
import asyncio
from telethon.tl.types import ChatForbidden, ChannelForbidden
from ...types import MatrixRoomID, TelegramID
from ...util import ignore_coro
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state
@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) -> Dict:
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 = MatrixRoomID(evt.args[1]) if len(evt.args) > 1 else evt.room_id
that_this = "This" if room_id == evt.room_id else "That"
portal = po.Portal.get_by_mxid(room_id)
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} room.")
# The /id bot command provides the prefixed ID, so we assume
tgid_str = evt.args[0]
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"
else:
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 IDs with `-100` and normal group IDs with `-`.\n\n"
"Bridging private chats to existing rooms is not allowed.")
portal = po.Portal.get_by_tgid(tgid, peer_type=peer_type)
if not portal.allow_bridging():
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.")
if 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": portal.peer_type,
"force_use_bot": force_use_bot,
}
return await evt.reply(f"{has_portal_message}"
"However, you have the permissions to unbridge that room.\n\n"
"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": portal.peer_type,
"force_use_bot": force_use_bot,
}
return await evt.reply("That Telegram chat has no existing portal. To confirm bridging the "
"chat to this room, use `$cmdprefix+sp continue`")
async def cleanup_old_portal_while_bridging(evt: CommandEvent, portal: "po.Portal"
) -> Tuple[bool, Optional[Coroutine[None, None, None]]]:
if not portal.mxid:
await evt.reply("The portal seems to have lost its Matrix room between you"
"calling `$cmdprefix+sp bridge` and this command.\n\n"
"Continuing without touching previous Matrix room...")
return True, None
elif evt.args[0] == "delete-and-continue":
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
message="Portal deleted (moving to another room)")
elif evt.args[0] == "unbridge-and-continue":
return True, portal.cleanup_room(portal.main_intent, portal.mxid,
message="Room unbridged (portal moving to another room)",
puppets_only=True)
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) -> Optional[Dict]:
status = evt.sender.command_status
try:
portal = 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.")
if "mxid" in status:
ok, coro = await cleanup_old_portal_while_bridging(evt, portal)
if not ok:
return None
elif coro:
ignore_coro(asyncio.ensure_future(coro, loop=evt.loop))
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.")
evt.sender.command_status = None
is_logged_in = await evt.sender.is_logged_in() and not status["force_use_bot"]
user = evt.sender if is_logged_in else evt.tgbot
try:
entity = await user.client.get_entity(portal.peer)
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.")
direct = False
portal.mxid = bridge_to_mxid
portal.title, portal.about, levels = await get_initial_state(evt.az.intent, evt.room_id)
portal.photo_id = ""
portal.save()
ignore_coro(asyncio.ensure_future(portal.update_matrix_room(user, entity, direct,
levels=levels),
loop=evt.loop))
return await evt.reply("Bridging complete. Portal synchronization should begin momentarily.")
+132
View File
@@ -0,0 +1,132 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Awaitable
from io import StringIO
from ...config import yaml
from ... import portal as po, util
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
@command_handler(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 = 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
key = evt.args[1] if len(evt.args) > 1 else None
value = yaml.load(" ".join(evt.args[2:])) if len(evt.args) > 2 else None
if cmd == "set":
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
portal.save()
def config_help(evt: CommandEvent) -> Awaitable[Dict]:
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[Dict]:
stream = StringIO()
yaml.dump(portal.local_config, stream)
return evt.reply(f"Room-specific config:\n\n```yaml\n{stream.getvalue()}```")
def config_defaults(evt: CommandEvent) -> Awaitable[Dict]:
stream = StringIO()
yaml.dump({
"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"],
"inline_images": evt.config["bridge.inline_images"],
"message_formats": evt.config["bridge.message_formats"],
"state_event_formats": evt.config["bridge.state_event_formats"],
"telegram_link_preview": evt.config["bridge.telegram_link_preview"],
}, stream)
return evt.reply(f"Bridge instance wide config:\n\n```yaml\n{stream.getvalue()}```")
def config_set(evt: CommandEvent, portal: po.Portal, key: str, value: str) -> Awaitable[Dict]:
if not key or value is None:
return evt.reply(f"**Usage:** `$cmdprefix+sp config set <key> <value>`")
elif util.recursive_set(portal.local_config, key, value):
return evt.reply(f"Successfully set the value of `{key}` to `{value}`.")
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[Dict]:
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[Dict]:
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. "
f"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 `{value}`.")
arr.append(value)
return evt.reply(f"Successfully added `{value}` to the array at `{key}`")
else:
if value not in arr:
return evt.reply(f"The array at `{key}` does not contain `{value}`.")
arr.remove(value)
return evt.reply(f"Successfully removed `{value}` from the array at `{key}`")
@@ -0,0 +1,61 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
from ... import portal as po
from ...types import TelegramID
from .. import command_handler, CommandEvent, SECTION_CREATING_PORTALS
from .util import user_has_power_level, get_initial_state
@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 "
"`group`).")
async def create(evt: CommandEvent) -> Dict:
type = evt.args[0] if len(evt.args) > 0 else "group"
if type not in {"chat", "group", "supergroup", "channel"}:
return await evt.reply(
"**Usage:** `$cmdprefix+sp create ['group'/'supergroup'/'channel']`")
if 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 = 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), peer_type=type,
mxid=evt.room_id, title=title, about=about)
try:
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
except ValueError as e:
portal.delete()
return await evt.reply(e.args[0])
return await evt.reply(f"Telegram chat created. ID: {portal.tgid}")
@@ -0,0 +1,95 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_ADMIN
@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) -> Dict:
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) -> Optional[Dict]:
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}.")
return None
+127
View File
@@ -0,0 +1,127 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
from telethon.errors import (ChatAdminRequiredError, UsernameInvalidError,
UsernameNotModifiedError, UsernameOccupiedError)
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT, SECTION_MISC
from .util import user_has_power_level
@command_handler(needs_admin=False, needs_puppeting=False, needs_auth=False,
help_section=SECTION_MISC,
help_text="Fetch Matrix room state to ensure the bridge has up-to-date info.")
async def sync_state(evt: CommandEvent) -> Dict:
portal = 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.sync_matrix_members()
await evt.reply("Synchronization complete")
@command_handler(name="id", needs_admin=False, needs_puppeting=False, needs_auth=False,
help_section=SECTION_MISC,
help_text="Get the ID of the Telegram chat where this room is bridged.")
async def get_id(evt: CommandEvent) -> Dict:
portal = 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}`.")
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Get a Telegram invite link to the current chat.")
async def invite_link(evt: CommandEvent) -> Dict:
portal = po.Portal.get_by_mxid(evt.room_id)
if not portal:
return await evt.reply("This is not a portal room.")
if portal.peer_type == "user":
return await evt.reply("You can't invite users to private chats.")
try:
link = await portal.get_invite_link(evt.sender)
return await evt.reply(f"Invite link to {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.")
@command_handler(help_section=SECTION_PORTAL_MANAGEMENT,
help_text="Upgrade a normal Telegram group to a supergroup.")
async def upgrade(evt: CommandEvent) -> Dict:
portal = 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) -> Dict:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp group-name <name/->`")
portal = 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")
@@ -0,0 +1,97 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Callable, Optional
from ...types import MatrixRoomID
from ... import portal as po
from .. import command_handler, CommandEvent, SECTION_PORTAL_MANAGEMENT
from .util import user_has_power_level
async def _get_portal_and_check_permission(evt: CommandEvent, permission: str,
action: Optional[str] = None
) -> Optional[po.Portal]:
room_id = MatrixRoomID(evt.args[0]) if len(evt.args) > 0 else evt.room_id
portal = 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 not await user_has_power_level(portal.mxid, evt.az.intent, evt.sender, permission):
action = action or f"{permission.replace('_', ' ')}s"
await evt.reply(f"You do not have the permissions to {action} 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) -> Optional[Dict]:
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) -> Optional[Dict]:
portal = await _get_portal_and_check_permission(evt, "unbridge")
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) -> Optional[Dict]:
portal = await _get_portal_and_check_permission(evt, "unbridge")
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`")
+56
View File
@@ -0,0 +1,56 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Tuple
from mautrix_appservice import MatrixRequestError, IntentAPI
from ... import user as u
async def get_initial_state(intent: IntentAPI, room_id: str) -> Tuple[str, str, Dict]:
state = await intent.get_room_state(room_id)
title = None
about = None
levels = None
for event in state:
try:
if event["type"] == "m.room.name":
title = event["content"]["name"]
elif event["type"] == "m.room.topic":
about = event["content"]["topic"]
elif event["type"] == "m.room.power_levels":
levels = event["content"]
elif event["type"] == "m.room.canonical_alias":
title = title or event["content"]["alias"]
except KeyError:
# Some state event probably has empty content
pass
return title, about, levels
async def user_has_power_level(room: str, intent, sender: u.User, event: str, default: int = 50
) -> bool:
if sender.is_admin:
return True
# Make sure the state store contains the power levels.
try:
await intent.get_power_levels(room)
except MatrixRequestError:
return False
return intent.state_store.has_power_level(room, sender.mxid,
event=f"net.maunium.telegram.{event}",
default=default)
@@ -0,0 +1 @@
from . import account, auth, misc
@@ -0,0 +1,124 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, Optional
from telethon.errors import (UsernameInvalidError, UsernameNotModifiedError, UsernameOccupiedError,
HashInvalidError, AuthKeyError, FirstNameInvalidError)
from telethon.tl.types import Authorization
from telethon.tl.functions.account import (UpdateUsernameRequest, GetAuthorizationsRequest,
ResetAuthorizationRequest, UpdateProfileRequest)
from .. import command_handler, CommandEvent, SECTION_AUTH
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
help_args="<_new username_>",
help_text="Change your Telegram username.")
async def username(evt: CommandEvent) -> Optional[Dict]:
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.username:
await evt.reply("Username removed")
else:
await evt.reply(f"Username changed to {evt.sender.username}")
@command_handler(needs_auth=True, help_section=SECTION_AUTH, help_args="<_new displayname_>",
help_text="Change your Telegram displayname.")
async def displayname(evt: CommandEvent) -> Optional[Dict]:
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()
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) -> Optional[Dict]:
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"
f" **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]`")
+271
View File
@@ -0,0 +1,271 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Any, Dict, Optional
import asyncio
from telethon.errors import (
AccessTokenExpiredError, AccessTokenInvalidError, FirstNameInvalidError, FloodWaitError,
PasswordHashInvalidError, PhoneCodeExpiredError, PhoneCodeInvalidError,
PhoneNumberAppSignupForbiddenError, PhoneNumberBannedError, PhoneNumberFloodError,
PhoneNumberOccupiedError, PhoneNumberUnoccupiedError, SessionPasswordNeededError)
from ... import puppet as pu, user as u
from ...commands import command_handler, CommandEvent, SECTION_AUTH
from ...util import format_duration, ignore_coro
@command_handler(needs_auth=False,
help_section=SECTION_AUTH,
help_text="Check if you're logged into Telegram.")
async def ping(evt: CommandEvent) -> Optional[Dict]:
me = await evt.sender.client.get_me() if await evt.sender.is_logged_in() else None
if me:
human_tg_id = f"@{me.username}" if me.username else f"+{me.phone}"
return await evt.reply(f"You're logged in as {human_tg_id}")
else:
return await evt.reply("You're not logged in.")
@command_handler(needs_auth=False, needs_puppeting=False,
help_section=SECTION_AUTH,
help_text="Get the info of the message relay Telegram bot.")
async def ping_bot(evt: CommandEvent) -> Optional[Dict]:
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_args="<_phone_> <_full name_>",
help_text="Register to Telegram")
async def register(evt: CommandEvent) -> Optional[Dict]:
if await evt.sender.is_logged_in():
return await evt.reply("You are already logged in.")
elif len(evt.args) < 1:
return await evt.reply("**Usage:** `$cmdprefix+sp register <phone> <full name>`")
phone_number = evt.args[0]
if len(evt.args) == 2:
full_name = evt.args[1], ""
else:
full_name = " ".join(evt.args[1:-1]), evt.args[-1]
await _request_code(evt, phone_number, {
"next": enter_code_register,
"action": "Register",
"full_name": full_name,
})
return None
async def enter_code_register(evt: CommandEvent) -> Dict:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
try:
await evt.sender.ensure_started(even_if_no_session=True)
first_name, last_name = evt.sender.command_status["full_name"]
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name)
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
evt.sender.command_status = None
return await evt.reply(f"Successfully registered to Telegram.")
except PhoneNumberOccupiedError:
return await evt.reply("That phone number has already been registered. "
"You can log in with `$cmdprefix+sp login`.")
except FirstNameInvalidError:
return await evt.reply("Invalid name. Please set a Matrix displayname before registering.")
except PhoneCodeExpiredError:
return await evt.reply(
"Phone code expired. Try again with `$cmdprefix+sp register <phone>`.")
except PhoneCodeInvalidError:
return await evt.reply("Invalid phone code.")
except Exception:
evt.log.exception("Error sending phone code")
return await evt.reply("Unhandled exception while sending code. "
"Check console for more details.")
@command_handler(needs_auth=False, management_only=True,
help_section=SECTION_AUTH,
help_text="Get instructions on how to log in.")
async def login(evt: CommandEvent) -> Optional[Dict]:
override_sender = False
if len(evt.args) > 0 and evt.sender.is_admin:
evt.sender = await u.User.get_by_mxid(evt.args[0]).ensure_started()
override_sender = True
if await evt.sender.is_logged_in():
return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.")
allow_matrix_login = evt.config.get("bridge.allow_matrix_login", True)
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]
) -> Dict:
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 {format_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 register with `$cmdprefix+sp register <phone>`.")
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) -> Optional[Dict]:
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) -> Optional[Dict]:
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) -> Optional[Dict]:
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")
try:
await _sign_in(evt, 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, **sign_in_info) -> Dict:
try:
await evt.sender.ensure_started(even_if_no_session=True)
user = await evt.sender.client.sign_in(**sign_in_info)
existing_user = u.User.get_by_tgid(user.id)
if existing_user and existing_user != evt.sender:
await existing_user.log_out()
await evt.reply(f"[{existing_user.displayname}]"
f"(https://matrix.to/#/{existing_user.mxid})"
" was logged out from the account.")
ignore_coro(asyncio.ensure_future(evt.sender.post_login(user), loop=evt.loop))
evt.sender.command_status = None
name = f"@{user.username}" if user.username else f"+{user.phone}"
return await evt.reply(f"Successfully logged in as {name}")
except PhoneCodeExpiredError:
return await evt.reply("Phone code expired. Try again with `$cmdprefix+sp login`.")
except PhoneCodeInvalidError:
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.")
@command_handler(needs_auth=True,
help_section=SECTION_AUTH,
help_text="Log out from Telegram.")
async def logout(evt: CommandEvent) -> Optional[Dict]:
if await evt.sender.log_out():
return await evt.reply("Logged out successfully.")
return await evt.reply("Failed to log out.")
+283
View File
@@ -0,0 +1,283 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple
import codecs
import base64
import re
from telethon.errors import (InviteHashInvalidError, InviteHashExpiredError, OptionsTooMuchError,
UserAlreadyParticipantError)
from telethon.tl.patched import Message
from telethon.tl.types import (User as TLUser, TypeUpdates, MessageMediaGame, MessageMediaPoll,
TypePeer)
from telethon.tl.types.messages import BotCallbackAnswer
from telethon.tl.functions.messages import (ImportChatInviteRequest, CheckChatInviteRequest,
GetBotCallbackAnswerRequest, SendVoteRequest)
from telethon.tl.functions.channels import JoinChannelRequest
from ... import puppet as pu, portal as po
from ...abstract_user import AbstractUser
from ...db import Message as DBMessage
from ...types import TelegramID
from ...commands import command_handler, CommandEvent, SECTION_MISC, SECTION_CREATING_PORTALS
@command_handler(help_section=SECTION_MISC,
help_args="[_-r|--remote_] <_query_>",
help_text="Search your contacts or the Telegram servers for users.")
async def search(evt: CommandEvent) -> Optional[Dict]:
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 = [] # type: 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="<_identifier_>",
help_text="Open a private chat with the given Telegram user. The identifier is "
"either the internal user ID, the username or the phone number. "
"**N.B.** The phone numbers you start chats with must already be in "
"your contacts.")
async def pm(evt: CommandEvent) -> Optional[Dict]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
try:
user = await evt.sender.client.get_entity(evt.args[0])
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 = po.Portal.get_by_entity(user, evt.sender.tgid)
await portal.create_matrix_room(evt.sender, user, [evt.sender.mxid])
return await evt.reply("Created private chat room with "
f"{pu.Puppet.get_displayname(user, False)}")
async def _join(evt: CommandEvent, arg: str) -> Tuple[Optional[TypeUpdates], Optional[Dict]]:
if arg.startswith("joinchat/"):
invite_hash = arg[len("joinchat/"):]
try:
await evt.sender.client(CheckChatInviteRequest(invite_hash))
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(invite_hash))), None
except UserAlreadyParticipantError:
return None, await evt.reply("You are already in that chat.")
else:
channel = await evt.sender.client.get_entity(arg)
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) -> Optional[Dict]:
if len(evt.args) == 0:
return await evt.reply("**Usage:** `$cmdprefix+sp join <invite link>`")
regex = re.compile(r"(?:https?://)?t(?:elegram)?\.(?:dog|me)(?:joinchat/)?/(.+)")
arg = regex.match(evt.args[0])
if not arg:
return await evt.reply("That doesn't look like a Telegram invite link.")
updates, _ = await _join(evt, arg.group(1))
if not updates:
return None
for chat in updates.chats:
portal = 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.")
await portal.create_matrix_room(evt.sender, chat, [evt.sender.mxid])
return await evt.reply(f"Created 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) -> Optional[Dict]:
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.sender.sync_dialogs(synchronous_create=True)
if not sync_only or sync_only == "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[TypePeer, 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 = DBMessage.get_one_by_tgid(msg_id, space)
if not orig_msg:
raise MessageIDError(f"Invalid {type_name} ID (original message not found in db)")
new_msg = DBMessage.get_by_mxid(orig_msg.mxid, orig_msg.mx_room, user.tgid)
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, msg
@command_handler(help_section=SECTION_MISC,
help_args="<_play ID_>",
help_text="Play a Telegram game.")
async def play(evt: CommandEvent) -> Optional[Dict]:
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")
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) -> Optional[Dict]:
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}. "
f"Option numbers must be positive.")
elif option_index >= len(msg.media.poll.answers):
return await evt.reply(f"Invalid option number {option}. "
f"The poll only has {len(msg.media.poll.answers)} options.")
options.append(msg.media.poll.answers[option_index].option)
options = [msg.media.poll.answers[int(option) - 1].option
for option in evt.args[1:]]
try:
resp = await evt.sender.client(SendVoteRequest(peer=peer, msg_id=msg.id, options=options))
except OptionsTooMuchError:
return await evt.reply("You passed too many options.")
# TODO use response
return await evt.mark_read()
+359
View File
@@ -0,0 +1,359 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Any, Dict, Optional, Tuple
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
import random
import string
yaml = YAML() # type: YAML
yaml.indent(4)
class DictWithRecursion:
def __init__(self, data: Optional[CommentedMap] = None) -> None:
self._data = data or CommentedMap() # type: CommentedMap
@staticmethod
def _parse_key(key: str) -> Tuple[str, Optional[str]]:
if '.' not in key:
return key, None
key, next_key = key.split('.', 1)
if len(key) > 0 and key[0] == "[":
end_index = next_key.index("]")
key = key[1:] + "." + next_key[:end_index]
next_key = next_key[end_index + 2:] if len(next_key) > end_index + 1 else None
return key, next_key
def _recursive_get(self, data: CommentedMap, key: str, default_value: Any) -> Any:
key, next_key = self._parse_key(key)
if next_key is not None:
next_data = data.get(key, CommentedMap())
return self._recursive_get(next_data, next_key, default_value)
return data.get(key, default_value)
def get(self, key: str, default_value: Any, allow_recursion: bool = True) -> Any:
if allow_recursion and '.' in key:
return self._recursive_get(self._data, key, default_value)
return self._data.get(key, default_value)
def __getitem__(self, key: str) -> Any:
return self.get(key, None)
def __contains__(self, key: str) -> bool:
return self[key] is not None
def _recursive_set(self, data: CommentedMap, key: str, value: Any) -> None:
key, next_key = self._parse_key(key)
if next_key is not None:
if key not in data:
data[key] = CommentedMap()
next_data = data.get(key, CommentedMap())
return self._recursive_set(next_data, next_key, value)
data[key] = value
def set(self, key: str, value: Any, allow_recursion: bool = True) -> None:
if allow_recursion and '.' in key:
self._recursive_set(self._data, key, value)
return
self._data[key] = value
def __setitem__(self, key: str, value: Any) -> None:
self.set(key, value)
def _recursive_del(self, data: CommentedMap, key: str) -> None:
key, next_key = self._parse_key(key)
if next_key is not None:
if key not in data:
return
next_data = data[key]
return self._recursive_del(next_data, next_key)
try:
del data[key]
del data.ca.items[key]
except KeyError:
pass
def delete(self, key: str, allow_recursion: bool = True) -> None:
if allow_recursion and '.' in key:
self._recursive_del(self._data, key)
return
try:
del self._data[key]
del self._data.ca.items[key]
except KeyError:
pass
def __delitem__(self, key: str) -> None:
self.delete(key)
class Config(DictWithRecursion):
def __init__(self, path: str, registration_path: str, base_path: str,
overrides: Dict[str, Any] = None) -> None:
super().__init__()
self.path = path # type: str
self.registration_path = registration_path # type: str
self.base_path = base_path # type: str
self._registration = None # type: Optional[Dict]
self._overrides = overrides or {} # type: Dict[str, Any]
def __getitem__(self, key: str) -> Any:
try:
return self._overrides[f"MAUTRIX_TELEGRAM_{key.replace('.', '_').upper()}"]
except KeyError:
return super().__getitem__(key)
def load(self) -> None:
with open(self.path, 'r') as stream:
self._data = yaml.load(stream)
def load_base(self) -> Optional[DictWithRecursion]:
try:
with open(self.base_path, 'r') as stream:
return DictWithRecursion(yaml.load(stream))
except OSError:
pass
return None
def save(self) -> None:
with open(self.path, 'w') as stream:
yaml.dump(self._data, stream)
if self._registration and self.registration_path:
with open(self.registration_path, 'w') as stream:
yaml.dump(self._registration, stream)
@staticmethod
def _new_token() -> str:
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64))
def update(self) -> None:
base = self.load_base()
if not base:
return
def copy(from_path, to_path=None) -> None:
if from_path in self:
base[to_path or from_path] = self[from_path]
def copy_dict(from_path, to_path=None, override_existing_map=True) -> None:
if from_path in self:
to_path = to_path or from_path
if override_existing_map or to_path not in base:
base[to_path] = CommentedMap()
for key, value in self[from_path].items():
base[to_path][key] = value
copy("homeserver.address")
copy("homeserver.domain")
copy("homeserver.verify_ssl")
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}"
else:
copy("appservice.address")
copy("appservice.hostname")
copy("appservice.port")
copy("appservice.max_body_size")
copy("appservice.database")
copy("appservice.public.enabled")
copy("appservice.public.prefix")
copy("appservice.public.external")
copy("appservice.provisioning.enabled")
copy("appservice.provisioning.prefix")
copy("appservice.provisioning.shared_secret")
if base["appservice.provisioning.shared_secret"] == "generate":
base["appservice.provisioning.shared_secret"] = self._new_token()
copy("appservice.id")
copy("appservice.bot_username")
copy("appservice.bot_displayname")
copy("appservice.bot_avatar")
copy("appservice.community_id")
copy("appservice.as_token")
copy("appservice.hs_token")
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.max_initial_member_sync")
copy("bridge.sync_channel_members")
copy("bridge.skip_deleted_members")
copy("bridge.startup_sync")
copy("bridge.sync_dialog_limit")
copy("bridge.max_telegram_delete")
copy("bridge.sync_matrix_state")
copy("bridge.allow_matrix_login")
copy("bridge.plaintext_highlights")
copy("bridge.public_portals")
copy("bridge.catch_up")
copy("bridge.sync_with_custom_puppets")
copy("bridge.telegram_link_preview")
copy("bridge.inline_images")
copy("bridge.image_as_file_size")
copy("bridge.max_document_size")
copy("bridge.bot_messages_as_notices")
if isinstance(self["bridge.bridge_notices"], bool):
base["bridge.bridge_notices"] = {
"default": self["bridge.bridge_notices"],
"exceptions": ["@importantbot:example.com"],
}
else:
copy("bridge.bridge_notices")
copy("bridge.deduplication.pre_db_check")
copy("bridge.deduplication.cache_queue_length")
if "bridge.message_formats.m_text" in self:
del self["bridge.message_formats"]
copy_dict("bridge.message_formats", override_existing_map=False)
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.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")
if "bridge.relaybot" not in self:
copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals")
else:
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.connection.timeout")
copy("telegram.connection.retries")
copy("telegram.connection.retry_delay")
copy("telegram.connection.flood_sleep_threshold")
copy("telegram.connection.request_retries")
copy("telegram.device_info.device_model")
copy("telegram.device_info.system_version")
copy("telegram.device_info.app_version")
copy("telegram.device_info.lang_code")
copy("telegram.device_info.system_lang_code")
copy("telegram.server.enabled")
copy("telegram.server.dc")
copy("telegram.server.ip")
copy("telegram.server.port")
copy("telegram.proxy.type")
copy("telegram.proxy.address")
copy("telegram.proxy.port")
copy("telegram.proxy.rdns")
copy("telegram.proxy.username")
copy("telegram.proxy.password")
if "appservice.debug" in self and "logging" not in self:
level = "DEBUG" if self["appservice.debug"] else "INFO"
base["logging.root.level"] = level
base["logging.loggers.mau.level"] = level
base["logging.loggers.telethon.level"] = level
else:
copy("logging")
self._data = base._data
self.save()
def _get_permissions(self, key: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
level = self["bridge.permissions"].get(key, "")
admin = level == "admin"
matrix_puppeting = level == "full" or admin
puppeting = level == "puppeting" or matrix_puppeting
user = level == "user" or puppeting
relaybot = level == "relaybot" or user
return relaybot, user, puppeting, matrix_puppeting, admin, level
def get_permissions(self, mxid: str) -> Tuple[bool, bool, bool, bool, bool, bool]:
permissions = self["bridge.permissions"] or {}
if mxid in permissions:
return self._get_permissions(mxid)
homeserver = mxid[mxid.index(":") + 1:]
if homeserver in permissions:
return self._get_permissions(homeserver)
return self._get_permissions("*")
def generate_registration(self) -> None:
homeserver = self["homeserver.domain"]
username_format = self.get("bridge.username_template", "telegram_{userid}") \
.format(userid=".+")
alias_format = self.get("bridge.alias_template", "telegram_{groupname}") \
.format(groupname=".+")
self.set("appservice.as_token", self._new_token())
self.set("appservice.hs_token", self._new_token())
self._registration = {
"id": self["appservice.id"] or "telegram",
"as_token": self["appservice.as_token"],
"hs_token": self["appservice.hs_token"],
"namespaces": {
"users": [{
"exclusive": True,
"regex": f"@{username_format}:{homeserver}"
}],
"aliases": [{
"exclusive": True,
"regex": f"#{alias_format}:{homeserver}"
}]
},
"url": self["appservice.address"],
"sender_localpart": self["appservice.bot_username"],
"rate_limited": False
}
if self["appservice.community_id"]:
self._registration["namespaces"]["users"][0]["group_id"] \
= self["appservice.community_id"]
+45
View File
@@ -0,0 +1,45 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Tuple, TYPE_CHECKING
if TYPE_CHECKING:
import asyncio
from alchemysession import AlchemySessionContainer
from mautrix_appservice import AppService
from .web import PublicBridgeWebsite, ProvisioningAPI
from .config import Config
from .bot import Bot
from .matrix import MatrixHandler
class Context:
def __init__(self, az: 'AppService', config: 'Config', loop: 'asyncio.AbstractEventLoop',
session_container: 'AlchemySessionContainer', bot: Optional['Bot']) -> None:
self.az = az # type: AppService
self.config = config # type: Config
self.loop = loop # type: asyncio.AbstractEventLoop
self.bot = bot # type: Optional[Bot]
self.mx = None # type: Optional[MatrixHandler]
self.session_container = session_container # type: AlchemySessionContainer
self.public_website = None # type: Optional[PublicBridgeWebsite]
self.provisioning_api = None # type: Optional[ProvisioningAPI]
@property
def core(self) -> Tuple['AppService', 'Config', 'asyncio.AbstractEventLoop', Optional['Bot']]:
return self.az, self.config, self.loop, self.bot
+33
View File
@@ -0,0 +1,33 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from .base import Base
from .bot_chat import BotChat
from .message import Message
from .portal import Portal
from .puppet import Puppet
from .room_state import RoomState
from .telegram_file import TelegramFile
from .user import User, UserPortal, Contact
from .user_profile import UserProfile
def init(db_engine) -> None:
for table in (Portal, Message, User, Contact, UserPortal, Puppet, TelegramFile, UserProfile,
RoomState, BotChat):
table.db = db_engine
table.t = table.__table__
table.c = table.t.c
+58
View File
@@ -0,0 +1,58 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from abc import abstractmethod
from sqlalchemy import Table
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.ext.declarative import declarative_base
class BaseBase:
db = None # type: Engine
t = None # type: Table
__table__ = None # type: Table
c = None # type: ImmutableColumnCollection
@classmethod
@abstractmethod
def _one_or_none(cls, rows: RowProxy):
pass
@classmethod
def _select_one_or_none(cls, *args):
return cls._one_or_none(cls.db.execute(cls.t.select().where(*args)))
@property
@abstractmethod
def _edit_identity(self):
pass
def update(self, **values) -> None:
with self.db.begin() as conn:
conn.execute(self.t.update()
.where(self._edit_identity)
.values(**values))
for key, value in values.items():
setattr(self, key, value)
def delete(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.delete().where(self._edit_identity))
Base = declarative_base(cls=BaseBase)
+26
View File
@@ -0,0 +1,26 @@
from abc import abstractmethod
from sqlalchemy import Table
from sqlalchemy.engine.base import Engine
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql.base import ImmutableColumnCollection
from sqlalchemy.ext.declarative import declarative_base
class Base(declarative_base):
db: Engine
t: Table
__table__: Table
c: ImmutableColumnCollection
@classmethod
@abstractmethod
def _one_or_none(cls, rows: RowProxy): ...
@classmethod
def _select_one_or_none(cls, *args): ...
def _edit_identity(self): ...
def update(self, **values) -> None: ...
def delete(self) -> None: ...
+45
View File
@@ -0,0 +1,45 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Iterable
from sqlalchemy import Column, Integer, String
from ..types import TelegramID
from .base import Base
# Fucking Telegram not telling bots what chats they are in 3:<
class BotChat(Base):
__tablename__ = "bot_chat"
id = Column(Integer, primary_key=True) # type: TelegramID
type = Column(String, nullable=False)
@classmethod
def delete(cls, chat_id: TelegramID) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.id == chat_id))
@classmethod
def all(cls) -> Iterable['BotChat']:
rows = cls.db.execute(cls.t.select())
for row in rows:
chat_id, chat_type = row
yield cls(id=chat_id, type=chat_type)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(id=self.id, type=self.type))
+113
View File
@@ -0,0 +1,113 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, UniqueConstraint, Integer, String, and_, func, desc, select
from sqlalchemy.engine.result import RowProxy
from typing import Optional, List
from ..types import MatrixRoomID, MatrixEventID, TelegramID
from .base import Base
class Message(Base):
__tablename__ = "message"
mxid = Column(String) # type: MatrixEventID
mx_room = Column(String) # type: MatrixRoomID
tgid = Column(Integer, primary_key=True) # type: TelegramID
tg_space = Column(Integer, primary_key=True) # type: TelegramID
edit_index = Column(Integer, primary_key=True) # type: int
__table_args__ = (UniqueConstraint("mxid", "mx_room", "tg_space", name="_mx_id_room"),)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Message']:
try:
mxid, mx_room, tgid, tg_space, edit_index = next(rows)
return cls(mxid=mxid, mx_room=mx_room, tgid=tgid, tg_space=tg_space,
edit_index=edit_index)
except StopIteration:
return None
@staticmethod
def _all(rows: RowProxy) -> List['Message']:
return [Message(mxid=row[0], mx_room=row[1], tgid=row[2], tg_space=row[3],
edit_index=row[4])
for row in rows]
@classmethod
def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> List['Message']:
return cls._all(cls.db.execute(cls.t.select().where(and_(cls.c.tgid == tgid,
cls.c.tg_space == tg_space))))
@classmethod
def get_one_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID, edit_index: int = 0
) -> Optional['Message']:
query = cls.t.select()
if edit_index < 0:
query = (query
.where(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space))
.order_by(desc(cls.c.edit_index))
.limit(1)
.offset(-edit_index - 1))
else:
query = query.where(and_(cls.c.tgid == tgid, cls.c.tg_space == tg_space,
cls.c.edit_index == edit_index))
return cls._one_or_none(cls.db.execute(query))
@classmethod
def count_spaces_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID) -> int:
rows = cls.db.execute(select([func.count(cls.c.tg_space)])
.where(and_(cls.c.mxid == mxid, cls.c.mx_room == mx_room)))
try:
count, = next(rows)
return count
except StopIteration:
return 0
@classmethod
def get_by_mxid(cls, mxid: MatrixEventID, mx_room: MatrixRoomID, tg_space: TelegramID
) -> Optional['Message']:
return cls._select_one_or_none(and_(cls.c.mxid == mxid,
cls.c.mx_room == mx_room,
cls.c.tg_space == tg_space))
@classmethod
def update_by_tgid(cls, s_tgid: TelegramID, s_tg_space: TelegramID, s_edit_index: int,
**values) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.update()
.where(and_(cls.c.tgid == s_tgid, cls.c.tg_space == s_tg_space,
cls.c.edit_index == s_edit_index))
.values(**values))
@classmethod
def update_by_mxid(cls, s_mxid: MatrixEventID, s_mx_room: MatrixRoomID, **values) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.update()
.where(and_(cls.c.mxid == s_mxid, cls.c.mx_room == s_mx_room))
.values(**values))
@property
def _edit_identity(self):
return and_(self.c.tgid == self.tgid, self.c.tg_space == self.tg_space,
self.c.edit_index == self.edit_index)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(mxid=self.mxid, mx_room=self.mx_room,
tgid=self.tgid, tg_space=self.tg_space,
edit_index=self.edit_index))
+81
View File
@@ -0,0 +1,81 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String, Boolean, Text, and_
from sqlalchemy.engine.result import RowProxy
from typing import Optional
from ..types import MatrixRoomID, TelegramID
from .base import Base
class Portal(Base):
__tablename__ = "portal"
# Telegram chat information
tgid = Column(Integer, primary_key=True) # type: TelegramID
tg_receiver = Column(Integer, primary_key=True) # type: TelegramID
peer_type = Column(String, nullable=False)
megagroup = Column(Boolean)
# Matrix portal information
mxid = Column(String, unique=True, nullable=True) # type: Optional[MatrixRoomID]
config = Column(Text, nullable=True)
# Telegram chat metadata
username = Column(String, nullable=True)
title = Column(String, nullable=True)
about = Column(String, nullable=True)
photo_id = Column(String, nullable=True)
@classmethod
def scan(cls, row) -> Optional['Portal']:
(tgid, tg_receiver, peer_type, megagroup, mxid, config, username, title, about,
photo_id) = row
return cls(tgid=tgid, tg_receiver=tg_receiver, peer_type=peer_type, megagroup=megagroup,
mxid=mxid, config=config, username=username, title=title, about=about,
photo_id=photo_id)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Portal']:
try:
return cls.scan(next(rows))
except StopIteration:
return None
@classmethod
def get_by_tgid(cls, tgid: TelegramID, tg_receiver: TelegramID) -> Optional['Portal']:
return cls._select_one_or_none(and_(cls.c.tgid == tgid, cls.c.tg_receiver == tg_receiver))
@classmethod
def get_by_mxid(cls, mxid: MatrixRoomID) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod
def get_by_username(cls, username: str) -> Optional['Portal']:
return cls._select_one_or_none(cls.c.username == username)
@property
def _edit_identity(self):
return and_(self.c.tgid == self.tgid, self.c.tg_receiver == self.tg_receiver)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
tgid=self.tgid, tg_receiver=self.tg_receiver, peer_type=self.peer_type,
megagroup=self.megagroup, mxid=self.mxid, config=self.config,
username=self.username, title=self.title, about=self.about, photo_id=self.photo_id))
+88
View File
@@ -0,0 +1,88 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, Integer, String, Boolean
from sqlalchemy.engine.result import RowProxy
from sqlalchemy.sql import expression
from typing import Optional, Iterable
from ..types import MatrixUserID, MatrixRoomID, TelegramID
from .base import Base
class Puppet(Base):
__tablename__ = "puppet"
id = Column(Integer, primary_key=True) # type: TelegramID
custom_mxid = Column(String, nullable=True) # type: Optional[MatrixUserID]
access_token = Column(String, nullable=True)
displayname = Column(String, nullable=True)
displayname_source = Column(Integer, nullable=True) # type: Optional[TelegramID]
username = Column(String, nullable=True)
photo_id = Column(String, nullable=True)
is_bot = Column(Boolean, nullable=True)
matrix_registered = Column(Boolean, nullable=False, server_default=expression.false())
disable_updates = Column(Boolean, nullable=False, server_default=expression.false())
@classmethod
def scan(cls, row) -> Optional['Puppet']:
(id, custom_mxid, access_token, displayname, displayname_source, username, photo_id,
is_bot, matrix_registered, disable_updates) = row
return cls(id=id, custom_mxid=custom_mxid, access_token=access_token,
displayname=displayname, displayname_source=displayname_source,
username=username, photo_id=photo_id, is_bot=is_bot,
matrix_registered=matrix_registered, disable_updates=disable_updates)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['Puppet']:
try:
return cls.scan(next(rows))
except StopIteration:
return None
@classmethod
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
rows = cls.db.execute(cls.t.select().where(cls.c.custom_mxid != None))
for row in rows:
yield cls.scan(row)
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.id == tgid)
@classmethod
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.custom_mxid == mxid)
@classmethod
def get_by_username(cls, username: str) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.username == username)
@classmethod
def get_by_displayname(cls, displayname: str) -> Optional['Puppet']:
return cls._select_one_or_none(cls.c.displayname == displayname)
@property
def _edit_identity(self):
return self.c.id == self.id
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
id=self.id, custom_mxid=self.custom_mxid, access_token=self.access_token,
displayname=self.displayname, displayname_source=self.displayname_source,
username=self.username, photo_id=self.photo_id, is_bot=self.is_bot,
matrix_registered=self.matrix_registered, disable_updates=self.disable_updates))
+62
View File
@@ -0,0 +1,62 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, String, Text
from typing import Dict, Optional
import json
from ..types import MatrixRoomID
from .base import Base
class RoomState(Base):
__tablename__ = "mx_room_state"
room_id = Column(String, primary_key=True) # type: MatrixRoomID
power_levels = Column("power_levels", Text, nullable=True) # type: Optional[Dict]
@property
def _power_levels_text(self) -> Optional[str]:
return json.dumps(self.power_levels) if self.power_levels else None
@property
def has_power_levels(self) -> bool:
return bool(self.power_levels)
@classmethod
def get(cls, room_id: MatrixRoomID) -> Optional['RoomState']:
rows = cls.db.execute(cls.t.select().where(cls.c.room_id == room_id))
try:
room_id, power_levels_text = next(rows)
return cls(room_id=room_id, power_levels=(json.loads(power_levels_text)
if power_levels_text else None))
except StopIteration:
return None
def update(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.update()
.where(self.c.room_id == self.room_id)
.values(power_levels=self._power_levels_text))
@property
def _edit_identity(self):
return self.c.room_id == self.room_id
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(room_id=self.room_id,
power_levels=self._power_levels_text))
+56
View File
@@ -0,0 +1,56 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, Integer, BigInteger, String, Boolean
from typing import Optional
from .base import Base
class TelegramFile(Base):
__tablename__ = "telegram_file"
id = Column(String, primary_key=True)
mxc = Column(String)
mime_type = Column(String)
was_converted = Column(Boolean)
timestamp = Column(BigInteger)
size = Column(Integer, nullable=True)
width = Column(Integer, nullable=True)
height = Column(Integer, nullable=True)
thumbnail_id = Column("thumbnail", String, ForeignKey("telegram_file.id"), nullable=True)
thumbnail = None # type: Optional[TelegramFile]
@classmethod
def get(cls, loc_id: str) -> Optional['TelegramFile']:
rows = cls.db.execute(cls.t.select().where(cls.c.id == loc_id))
try:
loc_id, mxc, mime, conv, ts, s, w, h, thumb_id = next(rows)
thumb = None
if thumb_id:
thumb = cls.get(thumb_id)
return cls(id=loc_id, mxc=mxc, mime_type=mime, was_converted=conv, timestamp=ts,
size=s, width=w, height=h, thumbnail_id=thumb_id, thumbnail=thumb)
except StopIteration:
return None
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
id=self.id, mxc=self.mxc, mime_type=self.mime_type,
was_converted=self.was_converted, timestamp=self.timestamp, size=self.size,
width=self.width, height=self.height,
thumbnail=self.thumbnail.id if self.thumbnail else self.thumbnail_id))
+130
View File
@@ -0,0 +1,130 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, String
from sqlalchemy.engine.result import RowProxy
from typing import Optional, Iterable, Tuple
from ..types import MatrixUserID, TelegramID
from .base import Base
class User(Base):
__tablename__ = "user"
mxid = Column(String, primary_key=True) # type: MatrixUserID
tgid = Column(Integer, nullable=True, unique=True) # type: Optional[TelegramID]
tg_username = Column(String, nullable=True)
tg_phone = Column(String, nullable=True)
saved_contacts = Column(Integer, default=0, nullable=False)
@classmethod
def _one_or_none(cls, rows: RowProxy) -> Optional['User']:
try:
mxid, tgid, tg_username, tg_phone, saved_contacts = next(rows)
return cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
saved_contacts=saved_contacts)
except StopIteration:
return None
@classmethod
def all(cls) -> Iterable['User']:
rows = cls.db.execute(cls.t.select())
for row in rows:
mxid, tgid, tg_username, tg_phone, saved_contacts = row
yield cls(mxid=mxid, tgid=tgid, tg_username=tg_username, tg_phone=tg_phone,
saved_contacts=saved_contacts)
@classmethod
def get_by_tgid(cls, tgid: TelegramID) -> Optional['User']:
return cls._select_one_or_none(cls.c.tgid == tgid)
@classmethod
def get_by_mxid(cls, mxid: MatrixUserID) -> Optional['User']:
return cls._select_one_or_none(cls.c.mxid == mxid)
@classmethod
def get_by_username(cls, username: str) -> Optional['User']:
return cls._select_one_or_none(cls.c.tg_username == username)
@property
def _edit_identity(self):
return self.c.mxid == self.mxid
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(
mxid=self.mxid, tgid=self.tgid, tg_username=self.tg_username,
tg_phone=self.tg_phone, saved_contacts=self.saved_contacts))
@property
def contacts(self) -> Iterable[TelegramID]:
rows = self.db.execute(Contact.t.select().where(Contact.c.user == self.tgid))
for row in rows:
user, contact = row
yield contact
@contacts.setter
def contacts(self, puppets: Iterable[TelegramID]) -> None:
with self.db.begin() as conn:
conn.execute(Contact.t.delete().where(Contact.c.user == self.tgid))
insert_puppets = [{"user": self.tgid, "contact": tgid} for tgid in puppets]
if insert_puppets:
conn.execute(Contact.t.insert(), insert_puppets)
@property
def portals(self) -> Iterable[Tuple[TelegramID, TelegramID]]:
rows = self.db.execute(UserPortal.t.select().where(UserPortal.c.user == self.tgid))
for row in rows:
user, portal, portal_receiver = row
yield (portal, portal_receiver)
@portals.setter
def portals(self, portals: Iterable[Tuple[TelegramID, TelegramID]]) -> None:
with self.db.begin() as conn:
conn.execute(UserPortal.t.delete().where(UserPortal.c.user == self.tgid))
insert_portals = [{
"user": self.tgid,
"portal": tgid,
"portal_receiver": tg_receiver
} for tgid, tg_receiver in portals]
if insert_portals:
conn.execute(UserPortal.t.insert(), insert_portals)
def delete(self) -> None:
super().delete()
self.portals = []
self.contacts = []
class UserPortal(Base):
__tablename__ = "user_portal"
user = Column(Integer, ForeignKey("user.tgid", onupdate="CASCADE", ondelete="CASCADE"),
primary_key=True) # type: TelegramID
portal = Column(Integer, primary_key=True) # type: TelegramID
portal_receiver = Column(Integer, primary_key=True) # type: TelegramID
__table_args__ = (ForeignKeyConstraint(("portal", "portal_receiver"),
("portal.tgid", "portal.tg_receiver"),
onupdate="CASCADE", ondelete="CASCADE"),)
class Contact(Base):
__tablename__ = "contact"
user = Column(Integer, ForeignKey("user.tgid"), primary_key=True) # type: TelegramID
contact = Column(Integer, ForeignKey("puppet.id"), primary_key=True) # type: TelegramID
+69
View File
@@ -0,0 +1,69 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from sqlalchemy import Column, String, and_
from typing import Dict, Optional
from ..types import MatrixUserID, MatrixRoomID
from .base import Base
class UserProfile(Base):
__tablename__ = "mx_user_profile"
room_id = Column(String, primary_key=True) # type: MatrixRoomID
user_id = Column(String, primary_key=True) # type: MatrixUserID
membership = Column(String, nullable=False, default="leave")
displayname = Column(String, nullable=True)
avatar_url = Column(String, nullable=True)
def dict(self) -> Dict[str, str]:
return {
"membership": self.membership,
"displayname": self.displayname,
"avatar_url": self.avatar_url,
}
@classmethod
def get(cls, room_id: MatrixRoomID, user_id: MatrixUserID) -> Optional['UserProfile']:
rows = cls.db.execute(
cls.t.select().where(and_(cls.c.room_id == room_id, cls.c.user_id == user_id)))
try:
room_id, user_id, membership, displayname, avatar_url = next(rows)
return cls(room_id=room_id, user_id=user_id, membership=membership,
displayname=displayname, avatar_url=avatar_url)
except StopIteration:
return None
@classmethod
def delete_all(cls, room_id: MatrixRoomID) -> None:
with cls.db.begin() as conn:
conn.execute(cls.t.delete().where(cls.c.room_id == room_id))
def update(self) -> None:
super().update(membership=self.membership, displayname=self.displayname,
avatar_url=self.avatar_url)
@property
def _edit_identity(self):
return and_(self.c.room_id == self.room_id, self.c.user_id == self.user_id)
def insert(self) -> None:
with self.db.begin() as conn:
conn.execute(self.t.insert().values(room_id=self.room_id, user_id=self.user_id,
membership=self.membership,
displayname=self.displayname,
avatar_url=self.avatar_url))
+8
View File
@@ -0,0 +1,8 @@
from .from_matrix import (matrix_reply_to_telegram, matrix_to_telegram, matrix_text_to_telegram,
init_mx)
from .from_telegram import telegram_reply_to_matrix, telegram_to_matrix
from .. import context as c
def init(context: c.Context) -> None:
init_mx(context)
@@ -0,0 +1,154 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, List, Tuple, Callable, Pattern, Match, TYPE_CHECKING, Dict, Any
import re
import logging
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityItalic,
TypeMessageEntity)
from ... import puppet as pu
from ...types import TelegramID, MatrixRoomID
from ...db import Message as DBMessage
from ..util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text)
from .parser import ParsedMessage, parse_html
if TYPE_CHECKING:
from ...context import Context
log = logging.getLogger("mau.fmt.mx") # type: logging.Logger
should_bridge_plaintext_highlights = False # type: bool
command_regex = re.compile(r"^!([A-Za-z0-9@]+)") # type: Pattern
not_command_regex = re.compile(r"^\\(![A-Za-z0-9@]+)") # type: Pattern
plain_mention_regex = None # type: Optional[Pattern]
def plain_mention_to_html(match: Match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
return (f"{match.group(1)}"
f"<a href='https://matrix.to/#/{puppet.mxid}'>"
f"{puppet.displayname}"
"</a>")
return "".join(match.groups())
def cut_long_message(message: str, entities: List[TypeMessageEntity]) -> ParsedMessage:
if len(message) > 4096:
message = message[0:4082] + " [message cut]"
new_entities = []
for entity in entities:
if entity.offset > 4082:
continue
if entity.offset + entity.length > 4082:
entity.length = 4082 - entity.offset
new_entities.append(entity)
new_entities.append(MessageEntityItalic(4082, len(" [message cut]")))
entities = new_entities
return message, entities
class FormatError(Exception):
pass
def matrix_to_telegram(html: str) -> ParsedMessage:
try:
html = command_regex.sub(r"<command>\1</command>", html)
html = html.replace("\t", " " * 4)
html = not_command_regex.sub(r"\1", html)
if should_bridge_plaintext_highlights:
html = plain_mention_regex.sub(plain_mention_to_html, html)
text, entities = parse_html(add_surrogates(html))
text = remove_surrogates(text.strip())
text, entities = cut_long_message(text, entities)
return text, entities
except Exception as e:
raise FormatError(f"Failed to convert Matrix format: {html}") from e
def matrix_reply_to_telegram(content: Dict[str, Any], tg_space: TelegramID,
room_id: Optional[MatrixRoomID] = None) -> Optional[TelegramID]:
relates_to = content.get("m.relates_to", None) or {}
if not relates_to:
return None
reply = (relates_to if relates_to.get("rel_type", None) == "m.reference"
else relates_to.get("m.in_reply_to", None) or {})
if not reply:
return None
room_id = room_id or reply.get("room_id", None)
event_id = reply.get("event_id", None)
if not event_id:
return
try:
if content["format"] == "org.matrix.custom.html":
content["formatted_body"] = trim_reply_fallback_html(content["formatted_body"])
except KeyError:
pass
content["body"] = trim_reply_fallback_text(content["body"])
message = DBMessage.get_by_mxid(event_id, room_id, tg_space)
if message:
return message.tgid
return None
def matrix_text_to_telegram(text: str) -> ParsedMessage:
text = command_regex.sub(r"/\1", text)
text = text.replace("\t", " " * 4)
text = not_command_regex.sub(r"\1", text)
if should_bridge_plaintext_highlights:
entities, pmr_replacer = plain_mention_to_text()
text = plain_mention_regex.sub(pmr_replacer, text)
else:
entities = []
return text, entities
def plain_mention_to_text() -> Tuple[List[TypeMessageEntity], Callable[[str], str]]:
entities = []
def replacer(match) -> str:
puppet = pu.Puppet.find_by_displayname(match.group(2))
if puppet:
offset = match.start()
length = match.end() - offset
if puppet.username:
entity = MessageEntityMention(offset, length)
text = f"@{puppet.username}"
else:
entity = MessageEntityMentionName(offset, length, user_id=puppet.tgid)
text = puppet.displayname
entities.append(entity)
return text
return "".join(match.groups())
return entities, replacer
def init_mx(context: "Context") -> None:
global plain_mention_regex, should_bridge_plaintext_highlights
config = context.config
dn_template = config.get("bridge.displayname_template", "{displayname} (Telegram)")
dn_template = re.escape(dn_template).replace(re.escape("{displayname}"), "[^>]+")
plain_mention_regex = re.compile(f"^({dn_template})")
should_bridge_plaintext_highlights = config["bridge.plaintext_highlights"] or False
@@ -0,0 +1,66 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Tuple
from html.parser import HTMLParser
class HTMLNode(list):
def __init__(self, tag: str, attrs: List[Tuple[str, str]]):
super().__init__()
self.tag = tag # type: str
self.text = "" # type: str
self.tail = "" # type: str
self.attrib = dict(attrs) # type: Dict[str, str]
class NodeifyingParser(HTMLParser):
# From https://www.w3.org/TR/html5/syntax.html#writing-html-documents-elements
void_tags = ("area", "base", "br", "col", "command", "embed", "hr", "img", "input", "link",
"meta", "param", "source", "track", "wbr")
def __init__(self):
super().__init__()
self.stack = [HTMLNode("html", [])] # type: List[HTMLNode]
def handle_starttag(self, tag, attrs):
node = HTMLNode(tag, attrs)
self.stack[-1].append(node)
if tag not in self.void_tags:
self.stack.append(node)
def handle_startendtag(self, tag, attrs):
self.stack[-1].append(HTMLNode(tag, attrs))
def handle_endtag(self, tag):
if tag == self.stack[-1].tag:
self.stack.pop()
def handle_data(self, data):
if len(self.stack[-1]) > 0:
self.stack[-1][-1].tail += data
else:
self.stack[-1].text += data
def error(self, message):
pass
def read_html(data: str) -> HTMLNode:
parser = NodeifyingParser()
parser.feed(data)
return parser.stack[0]
@@ -0,0 +1,11 @@
from typing import Dict, List
class HTMLNode(List['HTMLNode']):
tag: str
text: str
tail: str
attrib: Dict[str, str]
def read_html(data: str) -> HTMLNode: ...
@@ -0,0 +1,253 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import List, Tuple, Pattern
import re
from telethon.tl.types import (MessageEntityMention as Mention, MessageEntityBotCommand as Command,
MessageEntityMentionName as MentionName, MessageEntityUrl as URL,
MessageEntityEmail as Email, MessageEntityTextUrl as TextURL,
MessageEntityBold as Bold, MessageEntityItalic as Italic,
MessageEntityCode as Code, MessageEntityPre as Pre,
MessageEntityStrike as Strike, MessageEntityUnderline as Underline,
MessageEntityBlockquote as Blockquote, TypeMessageEntity)
from ... import user as u, puppet as pu, portal as po
from ...types import MatrixUserID
from .telegram_message import TelegramMessage, Entity, offset_length_multiply
from .html_reader import HTMLNode, read_html
ParsedMessage = Tuple[str, List[TypeMessageEntity]]
def parse_html(input_html: str) -> ParsedMessage:
return MatrixParser.parse(input_html)
class RecursionContext:
def __init__(self, strip_linebreaks: bool = True, ul_depth: int = 0):
self.strip_linebreaks = strip_linebreaks # type: bool
self.ul_depth = ul_depth # type: int
self._inited = True # type: bool
def __setattr__(self, key, value):
if getattr(self, "_inited", False) is True:
raise TypeError("'RecursionContext' object is immutable")
super(RecursionContext, self).__setattr__(key, value)
def enter_list(self) -> 'RecursionContext':
return RecursionContext(strip_linebreaks=self.strip_linebreaks, ul_depth=self.ul_depth + 1)
def enter_code_block(self) -> 'RecursionContext':
return RecursionContext(strip_linebreaks=False, ul_depth=self.ul_depth)
class MatrixParser:
mention_regex = re.compile("https://matrix.to/#/(@.+:.+)") # type: Pattern
room_regex = re.compile("https://matrix.to/#/(#.+:.+)") # type: Pattern
block_tags = ("p", "pre", "blockquote",
"ol", "ul", "li",
"h1", "h2", "h3", "h4", "h5", "h6",
"div", "hr", "table") # type: Tuple[str, ...]
list_bullets = ("", "", "", "") # type: Tuple[str, ...]
@classmethod
def list_bullet(cls, depth: int) -> str:
return cls.list_bullets[(depth - 1) % len(cls.list_bullets)] + " "
@classmethod
def list_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
ordered = node.tag == "ol"
tagged_children = cls.node_to_tagged_tmessages(node, ctx)
counter = 1
indent_length = 0
if ordered:
try:
counter = int(node.attrib.get("start", "1"))
except ValueError:
counter = 1
longest_index = counter - 1 + len(tagged_children)
indent_length = len(str(longest_index))
indent = (indent_length + 4) * " "
children = [] # type: List[TelegramMessage]
for child, tag in tagged_children:
if tag != "li":
continue
if ordered:
prefix = f"{counter}. "
counter += 1
else:
prefix = cls.list_bullet(ctx.ul_depth)
child = child.prepend(prefix)
parts = child.split("\n")
parts = parts[:1] + [part.prepend(indent) for part in parts[1:]]
child = TelegramMessage.join(parts, "\n")
children.append(child)
return TelegramMessage.join(children, "\n")
@classmethod
def header_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
children = cls.node_to_tmessages(node, ctx)
length = int(node.tag[1])
prefix = "#" * length + " "
return TelegramMessage.join(children, "").prepend(prefix).format(Bold)
@classmethod
def basic_format_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
if node.tag in ("b", "strong"):
msg.format(Bold)
elif node.tag in ("i", "em"):
msg.format(Italic)
elif node.tag in ("s", "strike", "del"):
msg.format(Strike)
elif node.tag in ("u", "ins"):
msg.format(Underline)
elif node == "blockquote":
msg.format(Blockquote)
elif node.tag == "command":
msg.format(Command)
return msg
@classmethod
def link_to_tstring(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
href = node.attrib.get("href", "")
if not href:
return msg
if href.startswith("mailto:"):
return TelegramMessage(href[len("mailto:"):]).format(Email)
mention = cls.mention_regex.match(href)
if mention:
mxid = MatrixUserID(mention.group(1))
user = (pu.Puppet.get_by_mxid(mxid)
or u.User.get_by_mxid(mxid, create=False))
if not user:
return msg
if user.username:
return TelegramMessage(f"@{user.username}").format(Mention)
elif user.tgid:
displayname = user.plain_displayname or msg.text
return TelegramMessage(displayname).format(MentionName, user_id=user.tgid)
return msg
room = cls.room_regex.match(href)
if room:
username = po.Portal.get_username_from_mx_alias(room.group(1))
portal = po.Portal.find_by_username(username)
if portal and portal.username:
return TelegramMessage(f"@{portal.username}").format(Mention)
return (msg.format(URL)
if msg.text == href
else msg.format(TextURL, url=href))
@classmethod
def blockquote_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
msg = cls.tag_aware_parse_node(node, ctx)
children = msg.trim().split("\n")
children = [child.prepend("> ") for child in children]
return TelegramMessage.join(children, "\n")
@classmethod
def node_to_tmessage(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
if node.tag == "mx-reply":
return TelegramMessage("")
elif node.tag == "ol":
return cls.list_to_tmessage(node, ctx)
elif node.tag == "ul":
return cls.list_to_tmessage(node, ctx.enter_list())
elif node.tag in ("h1", "h2", "h3", "h4", "h5", "h6"):
return cls.header_to_tmessage(node, ctx)
elif node.tag == "br":
return TelegramMessage("\n")
elif node.tag in ("b", "strong", "i", "em", "s", "del", "u", "ins", "command"):
return cls.basic_format_to_tmessage(node, ctx)
elif node.tag == "blockquote":
# Telegram already has blockquote entities in the protocol schema, but it strips them
# server-side and none of the official clients support them.
# TODO once Telegram changes that, use the above if block for blockquotes too.
return cls.blockquote_to_tmessage(node, ctx)
elif node.tag == "a":
return cls.link_to_tstring(node, ctx)
elif node.tag == "p":
return cls.tag_aware_parse_node(node, ctx).append("\n")
elif node.tag == "pre":
lang = ""
try:
if node[0].tag == "code":
node = node[0]
lang = node.attrib["class"][len("language-"):]
except (IndexError, KeyError):
pass
return cls.parse_node(node, ctx.enter_code_block()).format(Pre, language=lang)
elif node.tag == "code":
return cls.parse_node(node, ctx.enter_code_block()).format(Code)
return cls.tag_aware_parse_node(node, ctx)
@staticmethod
def text_to_tmessage(text: str, ctx: RecursionContext) -> TelegramMessage:
if ctx.strip_linebreaks:
text = text.replace("\n", "")
return TelegramMessage(text)
@classmethod
def node_to_tagged_tmessages(cls, node: HTMLNode, ctx: RecursionContext
) -> List[Tuple[TelegramMessage, str]]:
output = []
if node.text:
output.append((cls.text_to_tmessage(node.text, ctx), "text"))
for child in node:
output.append((cls.node_to_tmessage(child, ctx), child.tag))
if child.tail:
output.append((cls.text_to_tmessage(child.tail, ctx), "text"))
return output
@classmethod
def node_to_tmessages(cls, node: HTMLNode, ctx: RecursionContext
) -> List[TelegramMessage]:
return [msg for (msg, tag) in cls.node_to_tagged_tmessages(node, ctx)]
@classmethod
def tag_aware_parse_node(cls, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage:
msgs = cls.node_to_tagged_tmessages(node, ctx)
output = TelegramMessage()
prev_was_block = False
for msg, tag in msgs:
if tag in cls.block_tags:
msg = msg.append("\n")
if not prev_was_block:
msg = msg.prepend("\n")
prev_was_block = True
output = output.append(msg)
return output.trim()
@classmethod
def parse_node(cls, node: HTMLNode, ctx: RecursionContext) -> TelegramMessage:
return TelegramMessage.join(cls.node_to_tmessages(node, ctx))
@classmethod
def parse(cls, data: str) -> ParsedMessage:
msg = cls.node_to_tmessage(read_html(f"<body>{data}</body>"), RecursionContext())
return msg.text, msg.entities
@@ -0,0 +1,158 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Callable, List, Optional, Sequence, Type, Union
from telethon.tl.types import (MessageEntityMentionName as MentionName,
MessageEntityTextUrl as TextURL, MessageEntityPre as Pre,
TypeMessageEntity, InputMessageEntityMentionName as InputMentionName)
class Entity:
@staticmethod
def copy(entity: TypeMessageEntity) -> Optional[TypeMessageEntity]:
if not entity:
return None
kwargs = {
"offset": entity.offset,
"length": entity.length,
}
if isinstance(entity, Pre):
kwargs["language"] = entity.language
elif isinstance(entity, TextURL):
kwargs["url"] = entity.url
elif isinstance(entity, (MentionName, InputMentionName)):
kwargs["user_id"] = entity.user_id
return entity.__class__(**kwargs)
@classmethod
def adjust(cls, entity: Union[TypeMessageEntity, List[TypeMessageEntity]],
func: Callable[[TypeMessageEntity], None]
) -> Union[Optional[TypeMessageEntity], List[TypeMessageEntity]]:
if isinstance(entity, list):
return [Entity.adjust(element, func) for element in entity if entity]
elif not entity:
return None
entity = cls.copy(entity)
func(entity)
if entity.offset < 0:
entity.length += entity.offset
entity.offset = 0
return entity
def offset_diff(amount: int) -> Callable[[TypeMessageEntity], None]:
def func(entity: TypeMessageEntity) -> None:
entity.offset += amount
return func
def offset_length_multiply(amount: int) -> Callable[[TypeMessageEntity], None]:
def func(entity: TypeMessageEntity) -> None:
entity.offset *= amount
entity.length *= amount
return func
class TelegramMessage:
def __init__(self, text: str = "", entities: Optional[List[TypeMessageEntity]] = None) -> None:
self.text = text # type: str
self.entities = entities or [] # type: List[TypeMessageEntity]
def offset_entities(self, offset: int) -> 'TelegramMessage':
def apply_offset(entity: TypeMessageEntity, inner_offset: int
) -> Optional[TypeMessageEntity]:
entity = Entity.copy(entity)
entity.offset += inner_offset
if entity.offset < 0:
entity.offset = 0
elif entity.offset > len(self.text):
return None
elif entity.offset + entity.length > len(self.text):
entity.length = len(self.text) - entity.offset
return entity
self.entities = [apply_offset(entity, offset) for entity in self.entities if entity]
self.entities = [x for x in self.entities if x is not None]
return self
def append(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
for msg in args:
if isinstance(msg, str):
msg = TelegramMessage(text=msg)
self.entities += Entity.adjust(msg.entities, offset_diff(len(self.text)))
self.text += msg.text
return self
def prepend(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
for msg in args:
if isinstance(msg, str):
msg = TelegramMessage(text=msg)
self.entities = msg.entities + Entity.adjust(self.entities, offset_diff(len(msg.text)))
self.text = msg.text + self.text
return self
def format(self, entity_type: Type[TypeMessageEntity], offset: int = None, length: int = None,
**kwargs) -> 'TelegramMessage':
self.entities.append(entity_type(offset=offset or 0,
length=length if length is not None else len(self.text),
**kwargs))
return self
def concat(self, *args: Union[str, 'TelegramMessage']) -> 'TelegramMessage':
return TelegramMessage().append(self, *args)
def trim(self) -> 'TelegramMessage':
orig_len = len(self.text)
self.text = self.text.lstrip()
diff = orig_len - len(self.text)
self.text = self.text.rstrip()
self.offset_entities(-diff)
return self
def split(self, separator, max_items: int = 0) -> List['TelegramMessage']:
text_parts = self.text.split(separator, max_items - 1)
output = [] # type: List[TelegramMessage]
offset = 0
for part in text_parts:
msg = TelegramMessage(part)
for entity in self.entities:
start_in_range = len(part) > entity.offset - offset >= 0
end_in_range = len(part) >= entity.offset - offset + entity.length > 0
if start_in_range and end_in_range:
msg.entities.append(Entity.adjust(entity, offset_diff(-offset)))
output.append(msg)
offset += len(part)
offset += len(separator)
return output
@staticmethod
def join(items: Sequence[Union[str, 'TelegramMessage']],
separator: str = " ") -> 'TelegramMessage':
main = TelegramMessage()
for msg in items:
if isinstance(msg, str):
msg = TelegramMessage(text=msg)
main.entities += Entity.adjust(msg.entities, offset_diff(len(main.text)))
main.text += msg.text + separator
if len(separator) > 0:
main.text = main.text[:-len(separator)]
return main
+337
View File
@@ -0,0 +1,337 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
from html import escape
import logging
import re
from telethon.tl.types import (MessageEntityMention, MessageEntityMentionName, MessageEntityUrl,
MessageEntityEmail, MessageEntityTextUrl, MessageEntityBold,
MessageEntityItalic, MessageEntityCode, MessageEntityPre,
MessageEntityBotCommand, MessageEntityHashtag, MessageEntityCashtag,
MessageEntityPhone, TypeMessageEntity, Message, PeerChannel,
MessageEntityBlockquote, MessageEntityStrike, MessageFwdHeader,
MessageEntityUnderline, PeerUser)
from mautrix_appservice import MatrixRequestError
from mautrix_appservice.intent_api import IntentAPI
from .. import user as u, puppet as pu, portal as po
from ..types import TelegramID
from ..db import Message as DBMessage
from .util import (add_surrogates, remove_surrogates, trim_reply_fallback_html,
trim_reply_fallback_text)
if TYPE_CHECKING:
from ..abstract_user import AbstractUser
log = logging.getLogger("mau.fmt.tg") # type: logging.Logger
def telegram_reply_to_matrix(evt: Message, source: 'AbstractUser') -> Dict:
if evt.reply_to_msg_id:
space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid)
msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space)
if msg:
return {
"m.in_reply_to": {
"event_id": msg.mxid,
"room_id": msg.mx_room,
},
"rel_type": "m.reference",
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
return {}
async def _add_forward_header(source, text: str, html: Optional[str],
fwd_from: MessageFwdHeader) -> Tuple[str, str]:
if not html:
html = escape(text)
fwd_from_html, fwd_from_text = None, None
if fwd_from.from_id:
user = u.User.get_by_tgid(TelegramID(fwd_from.from_id))
if user:
fwd_from_text = user.displayname or user.mxid
fwd_from_html = (f"<a href='https://matrix.to/#/{user.mxid}'>"
f"{escape(fwd_from_text)}</a>")
if not fwd_from_text:
puppet = pu.Puppet.get(TelegramID(fwd_from.from_id), create=False)
if puppet and puppet.displayname:
fwd_from_text = puppet.displayname or puppet.mxid
fwd_from_html = (f"<a href='https://matrix.to/#/{puppet.mxid}'>"
f"{escape(fwd_from_text)}</a>")
if not fwd_from_text:
user = await source.client.get_entity(PeerUser(fwd_from.from_id))
if user:
fwd_from_text = pu.Puppet.get_displayname(user, False)
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
else:
portal = po.Portal.get_by_tgid(TelegramID(fwd_from.channel_id))
if portal:
fwd_from_text = portal.title
if portal.alias:
fwd_from_html = (f"<a href='https://matrix.to/#/{portal.alias}'>"
f"{escape(fwd_from_text)}</a>")
else:
fwd_from_html = f"<b>{escape(fwd_from_text)}</b>"
else:
channel = await source.client.get_entity(PeerChannel(fwd_from.channel_id))
if channel:
fwd_from_text = channel.title
fwd_from_html = f"<b>{fwd_from_text}</b>"
if not fwd_from_text:
if fwd_from.from_id:
fwd_from_text = "Unknown user"
else:
fwd_from_text = "Unknown source"
fwd_from_html = f"<b>{fwd_from_text}</b>"
text = "\n".join([f"> {line}" for line in text.split("\n")])
text = f"Forwarded from {fwd_from_text}:\n{text}"
html = (f"Forwarded message from {fwd_from_html}<br/>"
f"<tg-forward><blockquote>{html}</blockquote></tg-forward>")
return text, html
async def _add_reply_header(source: "AbstractUser", text: str, html: str, evt: Message,
relates_to: Dict, main_intent: IntentAPI) -> Tuple[str, str]:
space = (evt.to_id.channel_id
if isinstance(evt, Message) and isinstance(evt.to_id, PeerChannel)
else source.tgid)
msg = DBMessage.get_one_by_tgid(evt.reply_to_msg_id, space)
if not msg:
return text, html
relates_to["rel_type"] = "m.reference"
relates_to["event_id"] = msg.mxid
relates_to["room_id"] = msg.mx_room
relates_to["m.in_reply_to"] = {
"event_id": msg.mxid,
"room_id": msg.mx_room,
}
try:
event = await main_intent.get_event(msg.mx_room, msg.mxid)
content = event["content"]
r_sender = event["sender"]
r_text_body = trim_reply_fallback_text(content["body"])
r_html_body = trim_reply_fallback_html(content["formatted_body"]
if "formatted_body" in content
else escape(content["body"]))
puppet = pu.Puppet.get_by_mxid(r_sender, create=False)
r_displayname = puppet.displayname if puppet else r_sender
r_sender_link = f"<a href='https://matrix.to/#/{r_sender}'>{escape(r_displayname)}</a>"
except (ValueError, KeyError, MatrixRequestError):
r_sender_link = "unknown user"
r_displayname = "unknown user"
r_text_body = "Failed to fetch message"
r_html_body = "<em>Failed to fetch message</em>"
r_msg_link = f"<a href='https://matrix.to/#/{msg.mx_room}/{msg.mxid}'>In reply to</a>"
html = (
f"<mx-reply><blockquote>{r_msg_link} {r_sender_link}\n{r_html_body}</blockquote></mx-reply>"
+ (html or escape(text)))
lines = r_text_body.strip().split("\n")
text_with_quote = f"> <{r_displayname}> {lines.pop(0)}"
for line in lines:
if line:
text_with_quote += f"\n> {line}"
text_with_quote += "\n\n"
text_with_quote += text
return text_with_quote, html
async def telegram_to_matrix(evt: Message, source: "AbstractUser",
main_intent: Optional[IntentAPI] = None,
prefix_text: Optional[str] = None, prefix_html: Optional[str] = None,
override_text: str = None,
override_entities: List[TypeMessageEntity] = None,
no_reply_fallback: bool = False) -> Tuple[str, str, Dict]:
text = add_surrogates(override_text or evt.message)
entities = override_entities or evt.entities
html = _telegram_entities_to_matrix_catch(text, entities) if entities else None
relates_to = {} # type: Dict
if prefix_html:
html = prefix_html + (html or escape(text))
if prefix_text:
text = prefix_text + text
if evt.fwd_from:
text, html = await _add_forward_header(source, text, html, evt.fwd_from)
if evt.reply_to_msg_id and not no_reply_fallback:
text, html = await _add_reply_header(source, text, html, evt, relates_to, main_intent)
if isinstance(evt, Message) and evt.post and evt.post_author:
if not html:
html = escape(text)
text += f"\n- {evt.post_author}"
html += f"<br/><i>- <u>{evt.post_author}</u></i>"
if html:
html = html.replace("\n", "<br/>")
return remove_surrogates(text), remove_surrogates(html), relates_to
def _telegram_entities_to_matrix_catch(text: str, entities: List[TypeMessageEntity]) -> str:
try:
return _telegram_entities_to_matrix(text, entities)
except Exception:
log.exception("Failed to convert Telegram format:\n"
"message=%s\n"
"entities=%s",
text, entities)
return "[failed conversion in _telegram_entities_to_matrix]"
def _telegram_entities_to_matrix(text: str, entities: List[TypeMessageEntity],
offset: int = 0, length: int = None) -> str:
if not entities:
return escape(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(escape(text[last_offset:relative_offset]))
elif relative_offset < last_offset:
continue
skip_entity = False
entity_text = _telegram_entities_to_matrix(
text=text[relative_offset:relative_offset + entity.length],
entities=entities[i + 1:], offset=entity.offset, length=entity.length)
entity_type = type(entity)
if entity_type == MessageEntityBold:
html.append(f"<strong>{entity_text}</strong>")
elif entity_type == MessageEntityItalic:
html.append(f"<em>{entity_text}</em>")
elif entity_type == MessageEntityUnderline:
html.append(f"<u>{entity_text}</u>")
elif entity_type == MessageEntityStrike:
html.append(f"<del>{entity_text}</del>")
elif entity_type == MessageEntityBlockquote:
html.append(f"<blockquote>{entity_text}</blockquote>")
elif entity_type == MessageEntityCode:
html.append(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 = _parse_mention(html, entity_text)
elif entity_type == MessageEntityMentionName:
skip_entity = _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):
skip_entity = _parse_url(html, entity_text,
entity.url if entity_type == MessageEntityTextUrl else None)
elif entity_type == MessageEntityBotCommand:
html.append(f"<font color='blue'>!{entity_text[1:]}</font>")
elif entity_type in (MessageEntityHashtag, MessageEntityCashtag, MessageEntityPhone):
html.append(f"<font color='blue'>{entity_text}</font>")
else:
skip_entity = True
last_offset = relative_offset + (0 if skip_entity else entity.length)
html.append(escape(text[last_offset:]))
return "".join(html)
def _parse_pre(html: List[str], entity_text: str, language: str) -> bool:
if language:
html.append("<pre>"
f"<code class='language-{language}'>{entity_text}</code>"
"</pre>")
else:
html.append(f"<pre><code>{entity_text}</code></pre>")
return False
def _parse_mention(html: List[str], entity_text: str) -> bool:
username = entity_text[1:]
user = u.User.find_by_username(username) or pu.Puppet.find_by_username(username)
if user:
mxid = user.mxid
else:
portal = po.Portal.find_by_username(username)
mxid = portal.alias or portal.mxid if portal else None
if mxid:
html.append(f"<a href='https://matrix.to/#/{mxid}'>{entity_text}</a>")
else:
return True
return False
def _parse_name_mention(html: List[str], entity_text: str, user_id: TelegramID) -> bool:
user = u.User.get_by_tgid(user_id)
if user:
mxid = user.mxid
else:
puppet = pu.Puppet.get(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)/([A-Za-z][A-Za-z0-9_]{3,}[A-Za-z0-9])/([0-9]{1,50})")
def _parse_url(html: List[str], entity_text: str, url: str) -> bool:
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)
portal = po.Portal.find_by_username(group)
if portal:
message = DBMessage.get_one_by_tgid(TelegramID(msgid), portal.tgid)
if message:
url = f"https://matrix.to/#/{portal.mxid}/{message.mxid}"
html.append(f"<a href='{url}'>{entity_text}</a>")
return False
+57
View File
@@ -0,0 +1,57 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Optional, Pattern
from html import escape
import struct
import re
# add_surrogates and remove_surrogates are unicode surrogate utility functions from Telethon.
# Licensed under the MIT license.
# https://github.com/LonamiWebs/Telethon/blob/7cce7aa3e4c6c7019a55530391b1761d33e5a04e/telethon/helpers.py
def add_surrogates(text: Optional[str]) -> Optional[str]:
if text is None:
return None
return "".join("".join(chr(y) for y in struct.unpack("<HH", x.encode("utf-16-le")))
if (0x10000 <= ord(x) <= 0x10FFFF) else x for x in text)
def remove_surrogates(text: Optional[str]) -> Optional[str]:
if text is None:
return None
return text.encode("utf-16", "surrogatepass").decode("utf-16")
# trim_reply_fallback_text, html_reply_fallback_regex and trim_reply_fallback_html are Matrix
# reply fallback utility functions.
# You may copy and use them under any OSI-approved license.
def trim_reply_fallback_text(text: str) -> str:
if not text.startswith("> ") or "\n" not in text:
return text
lines = text.split("\n")
while len(lines) > 0 and lines[0].startswith("> "):
lines.pop(0)
return "\n".join(lines)
html_reply_fallback_regex = re.compile("^<mx-reply>"
r"[\s\S]+?"
"</mx-reply>") # type: Pattern
def trim_reply_fallback_html(html: str) -> str:
return html_reply_fallback_regex.sub("", html)
+477
View File
@@ -0,0 +1,477 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict, List, Match, Optional, Set, Tuple, TYPE_CHECKING
import logging
import asyncio
import time
import re
from mautrix_appservice import MatrixRequestError, IntentError
from .types import MatrixEvent, MatrixEventID, MatrixRoomID, MatrixUserID
from . import user as u, portal as po, puppet as pu, commands as com
if TYPE_CHECKING:
from .context import Context
try:
from prometheus_client import Histogram
EVENT_TIME = Histogram("matrix_event", "Time spent processing Matrix events",
["event_type"])
except ImportError:
Histogram = None
EVENT_TIME = None
class MatrixHandler:
log = logging.getLogger("mau.mx") # type: logging.Logger
def __init__(self, context: 'Context') -> None:
self.az, self.config, _, self.tgbot = context.core
self.commands = com.CommandProcessor(context) # type: com.CommandProcessor
self.previously_typing = [] # type: List[MatrixUserID]
self.az.matrix_event_handler(self.handle_event)
async def init_as_bot(self) -> None:
displayname = self.config["appservice.bot_displayname"]
if displayname:
try:
await self.az.intent.set_display_name(
displayname if displayname != "remove" else "")
except asyncio.TimeoutError:
self.log.exception("TimeoutError when trying to set displayname")
avatar = self.config["appservice.bot_avatar"]
if avatar:
try:
await self.az.intent.set_avatar(avatar if avatar != "remove" else "")
except asyncio.TimeoutError:
self.log.exception("TimeoutError when trying to set avatar")
async def handle_puppet_invite(self, room_id: MatrixRoomID, puppet: pu.Puppet, inviter: u.User
) -> None:
intent = puppet.default_mxid_intent
self.log.debug(f"{inviter} invited puppet for {puppet.tgid} to {room_id}")
if not await inviter.is_logged_in():
await intent.error_and_leave(
room_id, text="Please log in before inviting Telegram puppets.")
return
portal = po.Portal.get_by_mxid(room_id)
if portal:
if portal.peer_type == "user":
await intent.error_and_leave(
room_id, text="You can not invite additional users to private chats.")
return
await portal.invite_telegram(inviter, puppet)
await intent.join_room(room_id)
return
try:
members = await self.az.intent.get_room_members(room_id)
except MatrixRequestError:
members = []
if self.az.bot_mxid not in members:
if len(members) > 1:
await intent.error_and_leave(room_id, text=None, html=(
f"Please invite "
f"<a href='https://matrix.to/#/{self.az.bot_mxid}'>the bridge bot</a> "
f"first if you want to create a Telegram chat."))
return
await intent.join_room(room_id)
portal = po.Portal.get_by_tgid(puppet.tgid, inviter.tgid, "user")
# TODO: if portal is None:
if portal.mxid:
try:
await intent.invite(portal.mxid, inviter.mxid)
await intent.send_notice(room_id, text=None, html=(
"You already have a private chat with me: "
f"<a href='https://matrix.to/#/{portal.mxid}'>"
"Link to room"
"</a>"))
await intent.leave_room(room_id)
return
except MatrixRequestError:
pass
portal.mxid = room_id
portal.save()
inviter.register_portal(portal)
await intent.send_notice(room_id, "Portal to private chat created.")
else:
await intent.join_room(room_id)
await intent.send_notice(room_id, "This puppet will remain inactive until a "
"Telegram chat is created for this room.")
async def accept_bot_invite(self, room_id: MatrixRoomID, inviter: u.User) -> None:
tries = 0
while tries < 5:
try:
await self.az.intent.join_room(room_id)
break
except (IntentError, MatrixRequestError):
tries += 1
wait_for_seconds = (tries + 1) * 10
if tries < 5:
self.log.exception(f"Failed to join room {room_id} with bridge bot, "
f"retrying in {wait_for_seconds} seconds...")
await asyncio.sleep(wait_for_seconds)
else:
self.log.exception("Failed to join room {room}, giving up.")
return
if not inviter.whitelisted:
await self.az.intent.send_notice(
room_id,
text="You are not whitelisted to use this bridge.\n\n"
"If you are the owner of this bridge, see the "
"`bridge.permissions` section in your config file.",
html="<p>You are not whitelisted to use this bridge.</p>"
"<p>If you are the owner of this bridge, see the "
"<code>bridge.permissions</code> section in your config file.</p>")
await self.az.intent.leave_room(room_id)
try:
is_management = len(await self.az.intent.get_room_members(room_id)) == 2
except MatrixRequestError:
is_management = False
cmd_prefix = self.commands.command_prefix
text = html = "Hello, I'm a Telegram bridge bot. "
if is_management and inviter.puppet_whitelisted and not await inviter.is_logged_in():
text += f"Use `{cmd_prefix} help` for help or `{cmd_prefix} login` to log in."
html += (f"Use <code>{cmd_prefix} help</code> for help"
f" or <code>{cmd_prefix} login</code> to log in.")
pass
else:
text += f"Use `{cmd_prefix} help` for help."
html += f"Use <code>{cmd_prefix} help</code> for help."
await self.az.intent.send_notice(room_id, text=text, html=html)
async def handle_invite(self, room_id: MatrixRoomID, user_id: MatrixUserID,
inviter_mxid: MatrixUserID) -> None:
self.log.debug(f"{inviter_mxid} invited {user_id} to {room_id}")
inviter = u.User.get_by_mxid(inviter_mxid)
if inviter is None:
self.log.exception("Failed to find user with Matrix ID {inviter_mxid}")
await inviter.ensure_started()
if user_id == self.az.bot_mxid:
return await self.accept_bot_invite(room_id, inviter)
elif not inviter.whitelisted:
return
puppet = pu.Puppet.get_by_mxid(user_id)
if puppet:
await self.handle_puppet_invite(room_id, puppet, inviter)
return
user = u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
portal = po.Portal.get_by_mxid(room_id)
if user and await user.has_full_access(allow_bot=True) and portal:
await portal.invite_telegram(inviter, user)
return
# The rest can probably be ignored
async def handle_join(self, room_id: MatrixRoomID, user_id: MatrixUserID,
event_id: MatrixEventID) -> None:
user = await u.User.get_by_mxid(user_id).ensure_started()
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
if not user.relaybot_whitelisted:
await portal.main_intent.kick(room_id, user.mxid,
"You are not whitelisted on this Telegram bridge.")
return
elif not await user.is_logged_in() and not portal.has_bot:
await portal.main_intent.kick(room_id, user.mxid,
"This chat does not have a bot relaying "
"messages for unauthenticated users.")
return
self.log.debug(f"{user} joined {room_id}")
if await user.is_logged_in() or portal.has_bot:
await portal.join_matrix(user, event_id)
async def handle_part(self, room_id: MatrixRoomID, user_id: MatrixUserID,
sender_mxid: MatrixUserID, event_id: MatrixEventID) -> None:
self.log.debug(f"{user_id} left {room_id}")
sender = u.User.get_by_mxid(sender_mxid, create=False)
if not sender:
return
await sender.ensure_started()
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
puppet = pu.Puppet.get_by_mxid(user_id)
if puppet:
if sender:
await portal.kick_matrix(puppet, sender)
return
user = u.User.get_by_mxid(user_id, create=False)
if not user:
return
await user.ensure_started()
if await user.is_logged_in() or portal.has_bot:
await portal.leave_matrix(user, sender, event_id)
def is_command(self, message: Dict) -> Tuple[bool, str]:
text = message.get("body", "")
prefix = self.config["bridge.command_prefix"]
is_command = text.startswith(prefix)
if is_command:
text = text[len(prefix) + 1:].lstrip()
return is_command, text
async def handle_message(self, room: MatrixRoomID, sender_id: MatrixUserID, message: Dict,
event_id: MatrixEventID) -> None:
is_command, text = self.is_command(message)
sender = await u.User.get_by_mxid(sender_id).ensure_started()
if not sender.relaybot_whitelisted:
self.log.debug(f"Ignoring message \"{message}\" from {sender} to {room}:"
" User is not whitelisted.")
return
self.log.debug(f"Received Matrix event \"{message}\" from {sender} in {room}")
portal = po.Portal.get_by_mxid(room)
if not is_command and portal and (await sender.is_logged_in() or portal.has_bot):
await portal.handle_matrix_message(sender, message, event_id)
return
if not sender.whitelisted or message.get("msgtype", "m.unknown") != "m.text":
return
try:
is_management = len(await self.az.intent.get_room_members(room)) == 2
except MatrixRequestError:
# The AS bot is not in the room.
return
if is_command or is_management:
try:
command, arguments = text.split(" ", 1)
args = arguments.split(" ")
except ValueError:
# Not enough values to unpack, i.e. no arguments
command = text
args = []
await self.commands.handle(room, event_id, sender, command, args, is_management,
is_portal=portal is not None)
@staticmethod
async def handle_redaction(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
event_id: MatrixEventID) -> None:
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if not sender.relaybot_whitelisted:
return
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
await portal.handle_matrix_deletion(sender, event_id)
@staticmethod
async def handle_power_levels(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
new: Dict, old: Dict) -> None:
portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal:
await portal.handle_matrix_power_levels(sender, new["users"], old["users"])
@staticmethod
async def handle_room_meta(evt_type: str, room_id: MatrixRoomID, sender_mxid: MatrixUserID,
content: dict) -> None:
portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal:
handler, content_key = {
"m.room.name": (portal.handle_matrix_title, "name"),
"m.room.topic": (portal.handle_matrix_about, "topic"),
"m.room.avatar": (portal.handle_matrix_avatar, "url"),
}[evt_type]
if content_key not in content:
return
await handler(sender, content[content_key])
@staticmethod
async def handle_room_pin(room_id: MatrixRoomID, sender_mxid: MatrixUserID,
new_events: Set[str], old_events: Set[str]) -> None:
portal = po.Portal.get_by_mxid(room_id)
sender = await u.User.get_by_mxid(sender_mxid).ensure_started()
if await sender.has_full_access(allow_bot=True) and portal:
events = new_events - old_events
if len(events) > 0:
# New event pinned, set that as pinned in Telegram.
await portal.handle_matrix_pin(sender, MatrixEventID(events.pop()))
elif len(new_events) == 0:
# All pinned events removed, remove pinned event in Telegram.
await portal.handle_matrix_pin(sender, None)
@staticmethod
async def handle_room_upgrade(room_id: MatrixRoomID, new_room_id: MatrixRoomID) -> None:
portal = po.Portal.get_by_mxid(room_id)
if portal:
await portal.handle_matrix_upgrade(new_room_id)
@staticmethod
async def handle_name_change(room_id: MatrixRoomID, user_id: MatrixUserID, displayname: str,
prev_displayname: str, event_id: MatrixEventID) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal or not portal.has_bot:
return
user = await u.User.get_by_mxid(user_id).ensure_started()
if await user.needs_relaybot(portal):
await portal.name_change_matrix(user, displayname, prev_displayname, event_id)
@staticmethod
def parse_read_receipts(content: Dict) -> Dict[MatrixUserID, MatrixEventID]:
return {user_id: event_id
for event_id, receipts in content.items()
for user_id in receipts.get("m.read", {})}
@staticmethod
async def handle_read_receipts(room_id: MatrixRoomID,
receipts: Dict[MatrixUserID, MatrixEventID]) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
for user_id, event_id in receipts.items():
user = await u.User.get_by_mxid(user_id).ensure_started()
if not await user.is_logged_in():
continue
await portal.mark_read(user, event_id)
@staticmethod
async def handle_presence(user_id: MatrixUserID, presence: str) -> None:
user = await u.User.get_by_mxid(user_id).ensure_started()
if not await user.is_logged_in():
return
await user.set_presence(presence == "online")
async def handle_typing(self, room_id: MatrixRoomID, now_typing: List[MatrixUserID]) -> None:
portal = po.Portal.get_by_mxid(room_id)
if not portal:
return
for user_id in set(self.previously_typing + now_typing):
is_typing = user_id in now_typing
was_typing = user_id in self.previously_typing
if is_typing and was_typing:
continue
user = await u.User.get_by_mxid(user_id).ensure_started()
if not await user.is_logged_in():
continue
await portal.set_typing(user, is_typing)
self.previously_typing = now_typing
def filter_matrix_event(self, event: MatrixEvent) -> bool:
sender = event.get("sender", None)
if not sender:
return False
return (sender == self.az.bot_mxid
or pu.Puppet.get_id_from_mxid(sender) is not None)
async def try_handle_ephemeral_event(self, evt: MatrixEvent) -> None:
try:
await self.handle_ephemeral_event(evt)
except Exception:
self.log.exception("Error handling manually received Matrix event")
async def handle_ephemeral_event(self, evt: MatrixEvent) -> None:
evt_type = evt.get("type", "m.unknown") # type: str
room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID]
sender = evt.get("sender", None) # type: Optional[MatrixUserID]
content = evt.get("content", {}) # type: Dict
if evt_type == "m.receipt":
await self.handle_read_receipts(room_id, self.parse_read_receipts(content))
elif evt_type == "m.presence":
await self.handle_presence(sender, content.get("presence", "offline"))
elif evt_type == "m.typing":
await self.handle_typing(room_id, content.get("user_ids", []))
async def handle_event(self, evt: MatrixEvent) -> None:
if self.filter_matrix_event(evt):
return
start_time = time.time()
self.log.debug("Received event: %s", evt)
evt_type = evt.get("type", "m.unknown") # type: str
room_id = evt.get("room_id", None) # type: Optional[MatrixRoomID]
event_id = evt.get("event_id", None) # type: Optional[MatrixEventID]
sender = evt.get("sender", None) # type: Optional[MatrixUserID]
state_key = evt.get("state_key", None)
content = evt.get("content", {}) # type: Dict
if state_key is not None:
if evt_type == "m.room.member":
prev_content = evt.get("unsigned", {}).get("prev_content", {}) # type: Dict
membership = content.get("membership", "") # type: str
prev_membership = prev_content.get("membership", "leave") # type: str
if membership == prev_membership:
match = re.compile("@(.+):(.+)").match(state_key) # type: Match
mxid = match.group(0) # type: str
displayname = content.get("displayname", None) or mxid # type: str
prev_displayname = prev_content.get("displayname", None) or mxid # type: str
if displayname != prev_displayname:
await self.handle_name_change(room_id, state_key, displayname,
prev_displayname, event_id)
elif membership == "invite":
await self.handle_invite(room_id, state_key, sender)
elif prev_membership == "join" and membership == "leave":
await self.handle_part(room_id, state_key, sender, event_id)
elif membership == "join":
await self.handle_join(room_id, state_key, event_id)
elif evt_type == "m.room.power_levels":
prev_content = evt.get("unsigned", {}).get("prev_content", {})
await self.handle_power_levels(room_id, sender, evt["content"], prev_content)
elif evt_type in ("m.room.name", "m.room.avatar", "m.room.topic"):
await self.handle_room_meta(evt_type, room_id, sender, evt["content"])
elif evt_type == "m.room.pinned_events":
new_events = set(evt["content"]["pinned"])
try:
old_events = set(evt["unsigned"]["prev_content"]["pinned"])
except KeyError:
old_events = set()
await self.handle_room_pin(room_id, sender, new_events, old_events)
elif evt_type == "m.room.tombstone":
await self.handle_room_upgrade(room_id, evt["content"]["replacement_room"])
else:
return
else:
if evt_type in ("m.room.message", "m.sticker"):
if evt_type != "m.room.message":
content["msgtype"] = evt_type
await self.handle_message(room_id, sender, content, event_id)
elif evt_type == "m.room.redaction":
await self.handle_redaction(room_id, sender, evt["redacts"])
else:
return
if EVENT_TIME:
EVENT_TIME.labels(event_type=evt_type).observe(time.time() - start_time)
File diff suppressed because it is too large Load Diff
+533
View File
@@ -0,0 +1,533 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Awaitable, Any, Dict, List, Iterable, Optional, Pattern, Union, TYPE_CHECKING
from difflib import SequenceMatcher
from enum import Enum
from aiohttp import ServerDisconnectedError
import asyncio
import logging
import re
from telethon.tl.types import (UserProfilePhoto, User, UpdateUserName, PeerUser, TypeInputPeer,
InputPeerPhotoFileLocation, UserProfilePhotoEmpty)
from mautrix_appservice import AppService, IntentAPI, IntentError, MatrixRequestError
from .types import MatrixUserID, TelegramID
from .db import Puppet as DBPuppet
from . import util
if TYPE_CHECKING:
from .matrix import MatrixHandler
from .config import Config
from .context import Context
from .abstract_user import AbstractUser
PuppetError = Enum('PuppetError', 'Success OnlyLoginSelf InvalidAccessToken')
config = None # type: Config
class Puppet:
log = logging.getLogger("mau.puppet") # type: logging.Logger
az = None # type: AppService
mx = None # type: MatrixHandler
loop = None # type: asyncio.AbstractEventLoop
mxid_regex = None # type: Pattern
username_template = None # type: str
hs_domain = None # type: str
cache = {} # type: Dict[TelegramID, Puppet]
by_custom_mxid = {} # type: Dict[str, Puppet]
def __init__(self,
id: TelegramID,
access_token: Optional[str] = None,
custom_mxid: Optional[MatrixUserID] = None,
username: Optional[str] = None,
displayname: Optional[str] = None,
displayname_source: Optional[TelegramID] = None,
photo_id: Optional[str] = None,
is_bot: bool = False,
is_registered: bool = False,
disable_updates: bool = False,
db_instance: Optional[DBPuppet] = None) -> None:
self.id = id # type: TelegramID
self.access_token = access_token # type: Optional[str]
self.custom_mxid = custom_mxid # type: Optional[MatrixUserID]
self.default_mxid = self.get_mxid_from_id(self.id) # type: MatrixUserID
self.username = username # type: Optional[str]
self.displayname = displayname # type: Optional[str]
self.displayname_source = displayname_source # type: Optional[TelegramID]
self.photo_id = photo_id # type: Optional[str]
self.is_bot = is_bot # type: bool
self.is_registered = is_registered # type: bool
self.disable_updates = disable_updates # type: bool
self._db_instance = db_instance # type: Optional[DBPuppet]
self.default_mxid_intent = self.az.intent.user(self.default_mxid)
self.intent = self._fresh_intent() # type: IntentAPI
self.sync_task = None # type: Optional[asyncio.Future]
self.cache[id] = self
if self.custom_mxid:
self.by_custom_mxid[self.custom_mxid] = self
@property
def mxid(self) -> MatrixUserID:
return self.custom_mxid or self.default_mxid
@property
def tgid(self) -> TelegramID:
return self.id
@property
def is_real_user(self) -> bool:
""" Is True when the puppet is a real Matrix user. """
return bool(self.custom_mxid and self.access_token)
@staticmethod
async def is_logged_in() -> bool:
""" Is True if the puppet is logged in. """
return True
@property
def plain_displayname(self) -> str:
tpl = config["bridge.displayname_template"]
if tpl == "{displayname}":
# Template has no extra stuff, no need to parse.
return self.displayname
regex = re.compile("^" + re.escape(tpl).replace(re.escape("{displayname}"), "(.+?)") + "$")
match = regex.match(self.displayname)
return match.group(1) or self.displayname
def get_input_entity(self, user: 'AbstractUser') -> Awaitable[TypeInputPeer]:
return user.client.get_input_entity(PeerUser(user_id=self.tgid))
# region Custom puppet management
def _fresh_intent(self) -> IntentAPI:
return (self.az.intent.user(self.custom_mxid, self.access_token)
if self.is_real_user else self.default_mxid_intent)
async def switch_mxid(self, access_token: Optional[str],
mxid: Optional[MatrixUserID]) -> PuppetError:
prev_mxid = self.custom_mxid
self.custom_mxid = mxid
self.access_token = access_token
self.intent = self._fresh_intent()
err = await self.init_custom_mxid()
if err != PuppetError.Success:
return err
try:
del self.by_custom_mxid[prev_mxid] # type: ignore
except KeyError:
pass
if self.mxid != self.default_mxid:
self.by_custom_mxid[self.mxid] = self
await self.leave_rooms_with_default_user()
self.save()
return PuppetError.Success
async def init_custom_mxid(self) -> PuppetError:
if not self.is_real_user:
return PuppetError.Success
mxid = await self.intent.whoami()
if not mxid or mxid != self.custom_mxid:
self.custom_mxid = None
self.access_token = None
self.intent = self._fresh_intent()
if mxid != self.custom_mxid:
return PuppetError.OnlyLoginSelf
return PuppetError.InvalidAccessToken
if config["bridge.sync_with_custom_puppets"]:
self.sync_task = asyncio.ensure_future(self.sync(), loop=self.loop)
return PuppetError.Success
async def leave_rooms_with_default_user(self) -> None:
for room_id in await self.default_mxid_intent.get_joined_rooms():
try:
await self.default_mxid_intent.leave_room(room_id)
await self.intent.ensure_joined(room_id)
except (IntentError, MatrixRequestError):
pass
def create_sync_filter(self) -> Awaitable[str]:
return self.intent.client.create_filter(self.custom_mxid, {
"room": {
"include_leave": False,
"state": {
"types": []
},
"timeline": {
"types": [],
},
"ephemeral": {
"types": ["m.typing", "m.receipt"],
},
"account_data": {
"types": []
}
},
"account_data": {
"types": [],
},
"presence": {
"types": ["m.presence"],
"senders": [self.custom_mxid],
},
})
def filter_events(self, events: List[Dict]) -> List:
new_events = []
for event in events:
evt_type = event.get("type", None)
event.setdefault("content", {})
if evt_type == "m.typing":
is_typing = self.custom_mxid in event["content"].get("user_ids", [])
event["content"]["user_ids"] = [self.custom_mxid] if is_typing else []
elif evt_type == "m.receipt":
val = None
evt = None
for event_id in event["content"]:
try:
val = event["content"][event_id]["m.read"][self.custom_mxid]
evt = event_id
break
except KeyError:
pass
if val and evt:
event["content"] = {evt: {"m.read": {
self.custom_mxid: val
}}}
else:
continue
new_events.append(event)
return new_events
def handle_sync(self, presence: List, ephemeral: Dict) -> None:
presence_events = [self.mx.try_handle_ephemeral_event(event) for event in presence]
for room_id, events in ephemeral.items():
for event in events:
event["room_id"] = room_id
ephemeral_events = [self.mx.try_handle_ephemeral_event(event)
for events in ephemeral.values()
for event in self.filter_events(events)]
events = ephemeral_events + presence_events # List[Callable[[int], Awaitable[None]]]
coro = asyncio.gather(*events, loop=self.loop)
asyncio.ensure_future(coro, loop=self.loop)
async def sync(self) -> None:
try:
await self._sync()
except asyncio.CancelledError:
self.log.info("Syncing cancelled")
except Exception:
self.log.exception("Fatal error syncing")
async def _sync(self) -> None:
if not self.is_real_user:
self.log.warning("Called sync() for non-custom puppet.")
return
custom_mxid = self.custom_mxid
access_token_at_start = self.access_token
errors = 0
next_batch = None
filter_id = await self.create_sync_filter()
self.log.debug(f"Starting syncer for {custom_mxid} with sync filter {filter_id}.")
while access_token_at_start == self.access_token:
try:
sync_resp = await self.intent.client.sync(filter=filter_id, since=next_batch,
set_presence="offline") # type: Dict
errors = 0
if next_batch is not None:
presence = sync_resp.get("presence", {}).get("events", []) # type: List
ephemeral = {room: data.get("ephemeral", {}).get("events", [])
for room, data
in sync_resp.get("rooms", {}).get("join", {}).items()
} # type: Dict
self.handle_sync(presence, ephemeral)
next_batch = sync_resp.get("next_batch", None)
except (MatrixRequestError, ServerDisconnectedError) as e:
wait = min(errors, 11) ** 2
self.log.warning(f"Syncer for {custom_mxid} errored: {e}. "
f"Waiting for {wait} seconds...")
errors += 1
await asyncio.sleep(wait)
self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.")
# endregion
# region DB conversion
@property
def db_instance(self) -> DBPuppet:
if not self._db_instance:
self._db_instance = self.new_db_instance()
return self._db_instance
def new_db_instance(self) -> DBPuppet:
return DBPuppet(id=self.id, access_token=self.access_token, custom_mxid=self.custom_mxid,
username=self.username, displayname=self.displayname,
displayname_source=self.displayname_source, photo_id=self.photo_id,
is_bot=self.is_bot, matrix_registered=self.is_registered,
disable_updates=self.disable_updates)
@classmethod
def from_db(cls, db_puppet: DBPuppet) -> 'Puppet':
return Puppet(db_puppet.id, db_puppet.access_token, db_puppet.custom_mxid,
db_puppet.username, db_puppet.displayname, db_puppet.displayname_source,
db_puppet.photo_id, db_puppet.is_bot, db_puppet.matrix_registered,
db_puppet.disable_updates, db_instance=db_puppet)
def save(self) -> None:
self.db_instance.update(access_token=self.access_token, custom_mxid=self.custom_mxid,
username=self.username, displayname=self.displayname,
displayname_source=self.displayname_source, photo_id=self.photo_id,
is_bot=self.is_bot, matrix_registered=self.is_registered,
disable_updates=self.disable_updates)
# endregion
# 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.displayname, query).ratio()
if self.displayname else 0)
similarity = max(username_similarity, displayname_similarity)
return int(round(similarity * 100))
@staticmethod
def get_displayname(info: User, enable_format: bool = True) -> str:
data = {
"phone number": info.phone if hasattr(info, "phone") else None,
"username": info.username,
"full name": " ".join([info.first_name or "", info.last_name or ""]).strip(),
"full name reversed": " ".join([info.first_name or "", info.last_name or ""]).strip(),
"first name": info.first_name,
"last name": info.last_name,
}
preferences = config["bridge.displayname_preference"]
name = None
for preference in preferences:
name = data[preference]
if name:
break
if isinstance(info, User) and info.deleted:
name = f"Deleted account {info.id}"
elif not name:
name = info.id
if not enable_format:
return name
return config["bridge.displayname_template"].format(
displayname=name)
async def update_info(self, source: 'AbstractUser', info: User) -> None:
if self.disable_updates:
return
changed = False
if self.username != info.username:
self.username = info.username
changed = True
changed = await self.update_displayname(source, info) or changed
if isinstance(info.photo, UserProfilePhoto):
changed = await self.update_avatar(source, info.photo) or changed
self.is_bot = info.bot
if changed:
self.save()
async def update_displayname(self, source: 'AbstractUser', info: Union[User, UpdateUserName]
) -> bool:
if self.disable_updates:
return False
allow_source = (source.is_relaybot
or self.displayname_source == source.tgid
# No displayname source, so just trust anything
or self.displayname_source is None
# No phone -> not in contact list -> can't set custom name
or (isinstance(info, User) and info.phone is None))
if not allow_source:
return False
elif isinstance(info, UpdateUserName):
info = await source.client.get_entity(PeerUser(self.tgid))
displayname = self.get_displayname(info)
if displayname != self.displayname:
self.displayname = displayname
self.displayname_source = source.tgid
try:
await self.default_mxid_intent.set_display_name(displayname[:100])
except MatrixRequestError:
self.log.exception("Failed to set displayname")
self.displayname = ""
self.displayname_source = None
return True
elif source.is_relaybot or self.displayname_source is None:
self.displayname_source = source.tgid
return True
return False
async def update_avatar(self, source: 'AbstractUser',
photo: Union[UserProfilePhoto, UserProfilePhotoEmpty]) -> bool:
if self.disable_updates:
return False
if isinstance(photo, UserProfilePhotoEmpty):
photo_id = ""
else:
photo_id = str(photo.photo_id)
if self.photo_id != photo_id:
if not photo_id:
self.photo_id = ""
try:
await self.default_mxid_intent.set_avatar("")
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
return True
loc = InputPeerPhotoFileLocation(
peer=await self.get_input_entity(source),
local_id=photo.photo_big.local_id,
volume_id=photo.photo_big.volume_id,
big=True
)
file = await util.transfer_file_to_matrix(source.client, self.default_mxid_intent, loc)
if file:
self.photo_id = photo_id
try:
await self.default_mxid_intent.set_avatar(file.mxc)
except MatrixRequestError:
self.log.exception("Failed to set avatar")
self.photo_id = ""
return True
return False
# endregion
# region Getters
@classmethod
def get(cls, tgid: TelegramID, create: bool = True) -> Optional['Puppet']:
try:
return cls.cache[tgid]
except KeyError:
pass
puppet = DBPuppet.get_by_tgid(tgid)
if puppet:
return cls.from_db(puppet)
if create:
puppet = cls(tgid)
puppet.db_instance.insert()
return puppet
return None
@classmethod
def get_by_mxid(cls, mxid: MatrixUserID, create: bool = True) -> Optional['Puppet']:
tgid = cls.get_id_from_mxid(mxid)
if tgid:
return cls.get(tgid, create)
return None
@classmethod
def get_by_custom_mxid(cls, mxid: MatrixUserID) -> Optional['Puppet']:
if not mxid:
raise ValueError("Matrix ID can't be empty")
try:
return cls.by_custom_mxid[mxid]
except KeyError:
pass
puppet = DBPuppet.get_by_custom_mxid(mxid)
if puppet:
puppet = cls.from_db(puppet)
return puppet
return None
@classmethod
def all_with_custom_mxid(cls) -> Iterable['Puppet']:
return (cls.by_custom_mxid[puppet.mxid]
if puppet.custom_mxid in cls.by_custom_mxid
else cls.from_db(puppet)
for puppet in DBPuppet.all_with_custom_mxid())
@classmethod
def get_id_from_mxid(cls, mxid: MatrixUserID) -> Optional[TelegramID]:
match = cls.mxid_regex.match(mxid)
if match:
return TelegramID(int(match.group(1)))
return None
@classmethod
def get_mxid_from_id(cls, tgid: TelegramID) -> MatrixUserID:
return MatrixUserID(f"@{cls.username_template.format(userid=tgid)}:{cls.hs_domain}")
@classmethod
def find_by_username(cls, username: str) -> Optional['Puppet']:
if not username:
return None
for _, puppet in cls.cache.items():
if puppet.username and puppet.username.lower() == username.lower():
return puppet
dbpuppet = DBPuppet.get_by_username(username)
if dbpuppet:
return cls.from_db(dbpuppet)
return None
@classmethod
def find_by_displayname(cls, displayname: str) -> Optional['Puppet']:
if not displayname:
return None
for _, puppet in cls.cache.items():
if puppet.displayname and puppet.displayname == displayname:
return puppet
dbpuppet = DBPuppet.get_by_displayname(displayname)
if dbpuppet:
return cls.from_db(dbpuppet)
return None
# endregion
def init(context: 'Context') -> List[Awaitable[Any]]: # [None, None, PuppetError]
global config
Puppet.az, config, Puppet.loop, _ = context.core
Puppet.mx = context.mx
Puppet.username_template = config.get("bridge.username_template", "telegram_{userid}")
Puppet.hs_domain = config["homeserver"]["domain"]
Puppet.mxid_regex = re.compile(
f"@{Puppet.username_template.format(userid='([0-9]+)')}:{Puppet.hs_domain}")
return [puppet.init_custom_mxid() for puppet in Puppet.all_with_custom_mxid()]
@@ -0,0 +1,81 @@
import argparse
import sqlalchemy as sql
from sqlalchemy import orm
from sqlalchemy.ext.declarative import declarative_base
from alchemysession import AlchemySessionContainer
parser = argparse.ArgumentParser(description="mautrix-telegram dbms migration script",
prog="python -m mautrix_telegram.scripts.dbms_migrate")
parser.add_argument("-f", "--from-url", type=str, required=True, metavar="<url>",
help="the old database path")
parser.add_argument("-t", "--to-url", type=str, required=True, metavar="<url>",
help="the new database path")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logs while migrating")
args = parser.parse_args()
verbose = args.verbose or False
def log(message, end="\n"):
if verbose:
print(message, end=end, flush=True)
def connect(to):
import mautrix_telegram.db.base as base
base.Base = declarative_base(cls=base.BaseBase)
from mautrix_telegram.db import (Portal, Message, UserPortal, User, RoomState, UserProfile,
Contact, Puppet, BotChat, TelegramFile)
db_engine = sql.create_engine(to)
db_factory = orm.sessionmaker(bind=db_engine)
db_session = orm.scoped_session(db_factory) # type: orm.Session
base.Base.metadata.bind = db_engine
session_container = AlchemySessionContainer(engine=db_engine, session=db_session,
table_base=base.Base, table_prefix="telethon_",
manage_tables=False)
return db_session, {
"Version": session_container.Version,
"Session": session_container.Session,
"Entity": session_container.Entity,
"SentFile": session_container.SentFile,
"UpdateState": session_container.UpdateState,
"Portal": Portal,
"Message": Message,
"Puppet": Puppet,
"User": User,
"UserPortal": UserPortal,
"RoomState": RoomState,
"UserProfile": UserProfile,
"Contact": Contact,
"BotChat": BotChat,
"TelegramFile": TelegramFile,
}
log("Connecting to old database")
session, tables = connect(args.from_url)
data = {}
for name, table in tables.items():
log("Reading table {name}...".format(name=name), end=" ")
data[name] = session.query(table).all()
log("Done!")
log("Connecting to new database")
session, tables = connect(args.to_url)
for name, table in tables.items():
log("Writing table {name}".format(name=name), end="")
length = len(data[name])
n = 0
for row in data[name]:
session.merge(row)
n += 5
if n >= length:
log(".", end="")
n = 0
log(" Done!")
log("Committing changes to database...", end=" ")
session.commit()
log("Done!")
@@ -0,0 +1,124 @@
# -*- coding: future_fstrings -*-
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2019 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from typing import Dict
from sqlalchemy import orm
import sqlalchemy as sql
import argparse
from mautrix_telegram.db import Base, Portal, Message, Puppet, BotChat
from mautrix_telegram.config import Config
from .models import ChatLink, TgUser, MatrixUser, Message as TMMessage, Base as TelematrixBase
parser = argparse.ArgumentParser(
description="mautrix-telegram telematrix import script",
prog="python -m mautrix_telegram.scripts.telematrix_import")
parser.add_argument("-c", "--config", type=str, default="config.yaml",
metavar="<path>", help="the path to your mautrix-telegram config file")
parser.add_argument("-b", "--bot-id", type=int, required=True,
metavar="<id>", help="the telegram user ID of your relay bot")
parser.add_argument("-t", "--telematrix-database", type=str, default="sqlite:///database.db",
metavar="<url>", help="your telematrix database URL")
args = parser.parse_args()
config = Config(args.config, None, None)
config.load()
mxtg_db_engine = sql.create_engine(
config.get("appservice.database", "sqlite:///mautrix-telegram.db"))
mxtg = orm.sessionmaker(bind=mxtg_db_engine)()
Base.metadata.bind = mxtg_db_engine
telematrix_db_engine = sql.create_engine(args.telematrix_database)
telematrix = orm.sessionmaker(bind=telematrix_db_engine)()
TelematrixBase.metadata.bind = telematrix_db_engine
chat_links = telematrix.query(ChatLink).all()
tg_users = telematrix.query(TgUser).all()
mx_users = telematrix.query(MatrixUser).all()
tm_messages = telematrix.query(TMMessage).all()
telematrix.close()
telematrix_db_engine.dispose()
portals_by_tgid = {} # type: Dict[int, Portal]
portals_by_mxid = {} # type: Dict[str, Portal]
chats = {} # type: Dict[int, BotChat]
messages = {} # type: Dict[str, Message]
puppets = {} # type: Dict[int, Puppet]
for chat_link in chat_links:
if type(chat_link.tg_room) is str:
print("Expected tg_room to be a number, got a string. Ignoring %s" % chat_link.tg_room)
continue
if chat_link.tg_room >= 0:
print("Unexpected unprefixed telegram chat ID: %s, ignoring..." % chat_link.tg_room)
continue
tgid = str(chat_link.tg_room)
if tgid.startswith("-100"):
tgid = int(tgid[4:])
peer_type = "channel"
megagroup = True
else:
tgid = -chat_link.tg_room
peer_type = "chat"
megagroup = False
portal = Portal(tgid=tgid, tg_receiver=tgid, peer_type=peer_type, megagroup=megagroup,
mxid=chat_link.matrix_room)
chats[tgid] = BotChat(id=tgid, type=peer_type)
if chat_link.tg_room in portals_by_tgid:
print(f"Warning: Ignoring bridge from {portal.tgid} to {portal.mxid} "
f"in favor of {portals_by_tgid[portal.tgid].mxid}")
continue
elif chat_link.matrix_room in portals_by_mxid:
print(f"Warning: Ignoring bridge from {portal.mxid} to {portal.tgid} "
f"in favor of {portals_by_mxid[portal.mxid].tgid}")
continue
portals_by_tgid[portal.tgid] = portal
portals_by_mxid[portal.mxid] = portal
for tm_msg in tm_messages:
try:
portal = portals_by_tgid[tm_msg.tg_group_id]
except KeyError:
print(f"Found message entry {tm_msg.tg_message_id} in unlinked chat {tm_msg.tg_group_id},"
" ignoring...")
continue
if tm_msg.matrix_room_id != portal.mxid:
print(f"Found message entry {tm_msg.tg_message_id} with "
f"mismatching matrix room ID {tm_msg.matrix_room_id} (expected {portal.mxid})")
continue
tg_space = portal.tgid if portal.peer_type == "channel" else args.bot_id
message = Message(mxid=tm_msg.matrix_event_id, mx_room=tm_msg.matrix_room_id,
tgid=tm_msg.tg_message_id, tg_space=tg_space)
messages[tm_msg.matrix_event_id] = message
for user in tg_users:
puppets[user.tg_id] = Puppet(id=user.tg_id, displayname=user.name,
displayname_source=args.bot_id)
for k, v in portals_by_tgid.items():
mxtg.add(v)
for k, v in chats.items():
mxtg.add(v)
for k, v in messages.items():
mxtg.add(v)
for k, v in puppets.items():
mxtg.add(v)
mxtg.commit()

Some files were not shown because too many files have changed in this diff Show More