Compare commits

...

55 Commits

Author SHA1 Message Date
Tulir Asokan ff995b2149 Bump version to 0.11.2 2022-02-14 18:19:03 +02:00
Tulir Asokan 2fb08d59c7 Return error if user tries to send empty login code to API 2022-02-09 12:05:16 +02:00
Sumner Evans 7950c5aa61 Merge pull request #754 from mautrix/sumner/bri-1893
link previews: support from Telegram -> Beeper
2022-02-08 11:37:23 -07:00
Sumner Evans bf65824429 link previews: support from Telegram -> Beeper
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2022-02-08 11:35:38 -07:00
Tulir Asokan 4013f822de Remove community_id config option 2022-02-06 17:38:15 +02:00
Tulir Asokan b27519fd88 Add proper error message for syntax errors in !tg login. Fixes #755 2022-02-05 12:27:02 +02:00
Tulir Asokan 22f97756f7 Update CHANGELOG.md 2022-02-03 19:26:11 +02:00
Tulir Asokan da3f4af171 Fix newlines in unformatted channel posts 2022-02-03 17:43:35 +02:00
Tulir Asokan a55d9ae36a Improve profile info syncing 2022-02-01 20:51:55 +02:00
Tulir Asokan ecf3a12bd4 Mark user joined Telegram notice as read if it's backfilled 2022-02-01 17:33:53 +02:00
Tulir Asokan e7248e2418 Fix timestamp of photo has expired messages in backfill 2022-02-01 16:48:51 +02:00
Tulir Asokan fba118f0d9 Send joined telegram message instead of leaving portal empty 2022-02-01 16:44:31 +02:00
Tulir Asokan 100394d161 Add support for relay user distinguishers. Fixes #750 2022-02-01 16:05:56 +02:00
Tulir Asokan a9908781be Add basic support for MSC3488 location descriptions 2022-02-01 15:25:24 +02:00
Tulir Asokan 0f050edcd9 Add proper support for receiving messages sent as a channel. Fixes #740 2022-02-01 15:20:05 +02:00
Tulir Asokan 2182dfc86b Update to Telegram API layer 138 2022-02-01 13:35:27 +02:00
Tulir Asokan 99fa7a57d2 Add config option to set maximum image pixels before sending as document
Fixes #552
2022-01-31 15:57:00 +02:00
Tulir Asokan 6bf3d10e29 Improve handling of disappearing photos and files
Fixes #508
2022-01-31 15:49:39 +02:00
Tulir Asokan ebd2a38e56 Update black and fix version in CI 2022-01-30 12:29:05 +02:00
Tulir Asokan 03b094e4d4 Update mautrix-python 2022-01-30 12:04:21 +02:00
Tulir Asokan 21b509e5a0 Copy animated sticker args explicitly to remove unsupported args 2022-01-29 18:15:54 +02:00
Tulir Asokan 2732a85f9e Update dependencies 2022-01-26 13:41:20 +02:00
Tulir Asokan 033141e435 Add warning for users who don't know what they're doing 2022-01-22 16:31:43 +02:00
Sumner Evans 251458a1d7 Merge pull request #745 from mautrix/pre-commit-config
pre-commit: add configuration
2022-01-21 14:13:44 -07:00
Sumner Evans 7c4f406ac6 ci: add pre-commit-hooks to lint process 2022-01-21 11:15:52 -07:00
Sumner Evans 984c52afc9 dev-requirements: add pre-commit, isort, black 2022-01-21 11:15:21 -07:00
Sumner Evans f664d4ad90 pre-commit: add configuration 2022-01-21 10:07:12 -07:00
Sumner Evans 8f61be76f9 Merge pull request #738 from mautrix/sumner/bri-1583-telegram-has-disconnected-i-woke-up-to
bridge state: use TRANSIENT_DISCONNECT if connection drops and is expected to come back soon
2022-01-13 07:44:34 -07:00
Tulir Asokan 8003b9aa1c Fix bug in !tg create. Fixes #736 2022-01-12 21:52:25 +02:00
Sumner Evans a0fd98b9e2 bridge state: use TRANSIENT_DISCONNECT if connection drops and is expected to come back soon 2022-01-12 08:59:09 -07:00
Scott Weber feac31e841 Very basic support for live location 2022-01-11 13:36:15 +02:00
Tulir Asokan dd83d6278c Add support for t.me/+code invite links 2022-01-10 23:23:16 +02:00
Tulir Asokan 2a6b075ff2 Bump version to 0.11.1 2022-01-10 15:45:30 +02:00
Tulir Asokan e321bc30d0 Update some small things 2022-01-09 00:06:35 +02:00
Tulir Asokan 63fafec1b7 Make telegram blue text more readable on dark themes. Fixes #729 2022-01-08 23:27:57 +02:00
Tulir Asokan 9f48eca5a6 Use min() instead of sorting list 2022-01-05 21:23:58 +02:00
Tulir Asokan 28845b9daf Update dependencies and fix some things in config updater 2022-01-05 21:01:12 +02:00
Tulir Asokan 113f41d1d2 Deduplicate lottieconverter calls in tgs_converter
Also fix finding first frame file

Fixes #690
Closes #728
2022-01-05 21:00:53 +02:00
Tulir Asokan da3180e290 Delete nulls in message table. Fixes #731 2022-01-05 18:53:10 +02:00
Tulir Asokan 1a62463678 Update changelog 2022-01-05 12:30:38 +02:00
Tulir Asokan e584cf534d Merge branch 'sumner/bri-1517-bridge-voice-messages-telegram-matrix' 2022-01-05 12:09:25 +02:00
Tulir Asokan 4c1267cd32 Merge branch 'maybe-fix-corrupted-db-schema'
Closes #719
2022-01-05 12:09:16 +02:00
Tulir Asokan dc8a3d0c2d Don't use parameters for pg_constraint query 2022-01-05 01:53:57 +02:00
Sumner Evans c481ec850d voice messages: bridge from Telegram to native Matrix
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2022-01-04 14:16:57 -07:00
Tulir Asokan a54dd58de7 Send message checkpoints for Matrix edits too 2022-01-04 21:37:41 +02:00
Tulir Asokan b13da92520 Find constraint names dynamically to work around schemas broken by pgloader 2022-01-03 20:12:55 +02:00
Dominik Fuchß 2b6db85e1a Add missing await to get_input_entity in HTML parser (#724) 2021-12-31 11:19:41 +02:00
Tulir Asokan e7a1216ef7 Don't redact reactions in chats with relaybot
There are usually other Matrix users, so redacting reactions only from
logged-in users would be weird.
2021-12-30 23:34:14 +02:00
Tulir Asokan b1da5c7c2c Don't alter columns to not null on sqlite 2021-12-30 19:59:41 +02:00
Tulir Asokan 3b72de34b3 Fix some things in dedup changes 2021-12-30 19:41:45 +02:00
Tulir Asokan af893554cc Add support for Matrix->Telegram reactions 2021-12-30 18:32:10 +02:00
Tulir Asokan d108ac5d94 Add support for Telegram->Matrix reactions 2021-12-30 17:43:45 +02:00
Tulir Asokan e446121192 Fix order of operations when syncing contacts 2021-12-30 12:20:36 +02:00
Tulir Asokan afb73b1d17 Add support for bridging spoilers 2021-12-29 22:11:11 +02:00
Tulir Asokan aae8f78cb4 Try to drop identity in addition to default and id_seq in puppet/bot_chat tables
Closes #720
Closes #721

Co-authored-by: Carl Ambroselli <git@carl-ambroselli.de>
2021-12-29 12:47:32 +02:00
44 changed files with 1578 additions and 519 deletions
+4 -1
View File
@@ -8,11 +8,14 @@ charset = utf-8
trim_trailing_whitespace = true trim_trailing_whitespace = true
insert_final_newline = true insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[*.py] [*.py]
max_line_length = 99 max_line_length = 99
[*.{yaml,yml,py}] [*.{yaml,yml,py}]
indent_style = space indent_style = space
[.gitlab-ci.yml] [{.gitlab-ci.yml,.pre-commit-config.yaml}]
indent_size = 2 indent_size = 2
+9 -1
View File
@@ -13,6 +13,14 @@ jobs:
- uses: isort/isort-action@master - uses: isort/isort-action@master
with: with:
sortPaths: "./mautrix_telegram" sortPaths: "./mautrix_telegram"
- uses: psf/black@21.12b0 - uses: psf/black@stable
with: with:
src: "./mautrix_telegram" src: "./mautrix_telegram"
version: "22.1.0"
- name: pre-commit
run: |
pip install pre-commit
pre-commit run -av trailing-whitespace
pre-commit run -av end-of-file-fixer
pre-commit run -av check-yaml
pre-commit run -av check-added-large-files
+23
View File
@@ -0,0 +1,23 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.1.0
hooks:
- id: trailing-whitespace
exclude_types: [markdown]
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
# TODO convert to use the upstream psf/black when
# https://github.com/psf/black/issues/2493 gets fixed
- repo: local
hooks:
- id: black
name: black
entry: black --check
language: system
files: ^mautrix_telegram/.*\.py$
- repo: https://github.com/PyCQA/isort
rev: 5.10.1
hooks:
- id: isort
files: ^mautrix_telegram/.*$
+43 -2
View File
@@ -1,8 +1,49 @@
# (unreleased) # v0.11.2 (2022-02-14)
* Added support for message reactions **N.B.** This will be the last release to support Python 3.7. Future versions
will require Python 3.8 or higher. In general, the mautrix bridges will only
support the lowest Python version in the latest Debian or Ubuntu LTS.
### Added
* Added simple fallback message for live location and venue messages from Telegram.
* Added support for `t.me/+code` style invite links in `!tg join`.
* Added support for showing channel profile when users send messages as a channel.
* Added "user joined Telegram" message when Telegram auto-creates a DM chat for
a new user.
### Improved
* Added option for adding a random prefix to relayed user displaynames to help
distinguish them on the Telegram side.
* Improved syncing profile info to room info when using encryption and/or the
`private_chat_profile_meta` config option.
* Removed legacy `community_id` config option.
### Fixed
* Fixed newlines disappearing when bridging channel messages with signatures.
* Fixed login throwing an error if a previous login code expired.
* Fixed bug in v0.11.0 that broke `!tg create`.
# v0.11.1 (2022-01-10)
### Added
* Added support for message reactions.
* Added support for spoiler text. * Added support for spoiler text.
### Improved
* Support for voice messages.
* Changed color of blue text from Telegram to be more readable on dark themes.
### Fixed
* Fixed syncing contacts throwing an error for new accounts.
* Fixed migrating pre-v0.11 legacy databases if the database schema had been
corrupted (e.g. by using 3rd party tools for SQLite -> Postgres migration).
* Fixed converting animated stickers to webm with >33 FPS.
* Fixed a bug in v0.11.0 that broke mentioning users in groups
(thanks to [@dfuchss] in [#724]).
[@dfuchss]: https://github.com/dfuchss
[#724]: https://github.com/mautrix/telegram/pull/724
# v0.11.0 (2021-12-28) # v0.11.0 (2021-12-28)
* Switched from SQLAlchemy to asyncpg/aiosqlite. * Switched from SQLAlchemy to asyncpg/aiosqlite.
+5 -3
View File
@@ -3,6 +3,7 @@
* Matrix → Telegram * Matrix → Telegram
* [x] Message content (text, formatting, files, etc..) * [x] Message content (text, formatting, files, etc..)
* [x] Message redactions * [x] Message redactions
* [x] Message reactions
* [x] Message edits * [x] Message edits
* [ ] ‡ Message history * [ ] ‡ Message history
* [x] Presence * [x] Presence
@@ -12,8 +13,8 @@
* [x] Power level * [x] Power level
* [x] Normal chats * [x] Normal chats
* [ ] Non-hardcoded PL requirements * [ ] Non-hardcoded PL requirements
* [x] Supergroups/channels * [x] Supergroups/channels
* [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..) * [ ] Precise bridging (non-hardcoded PL requirements, bridge specific permissions, etc..)
* [x] Membership actions (invite/kick/join/leave) * [x] Membership actions (invite/kick/join/leave)
* [x] Room metadata changes (name, topic, avatar) * [x] Room metadata changes (name, topic, avatar)
* [x] Initial room metadata * [x] Initial room metadata
@@ -27,6 +28,7 @@
* [x] Games * [x] Games
* [ ] Buttons * [ ] Buttons
* [x] Message deletions * [x] Message deletions
* [x] Message reactions
* [x] Message edits * [x] Message edits
* [x] Message history * [x] Message history
* [x] Manually (`!tg backfill`) * [x] Manually (`!tg backfill`)
@@ -57,7 +59,7 @@
* [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting) * [x] Option to use own Matrix account for messages sent from other Telegram clients (double puppeting)
* [ ] ‡ Calls (hard, not yet supported by Telethon) * [ ] ‡ Calls (hard, not yet supported by Telethon)
* [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram) * [ ] ‡ Secret chats (i.e. End-to-bridge encryption on Telegram)
* [x] End-to-bridge encryption in Matrix rooms (see [wiki](https://github.com/tulir/mautrix-telegram/wiki/End%E2%80%90to%E2%80%90bridge-encryption)) * [x] End-to-bridge encryption in Matrix rooms (see [docs](https://docs.mau.fi/bridges/general/end-to-bridge-encryption.html))
† Information not automatically sent from source, i.e. implementation may not be possible † Information not automatically sent from source, i.e. implementation may not be possible
‡ Maybe, i.e. this feature may or may not be implemented at some point ‡ Maybe, i.e. this feature may or may not be implemented at some point
+3
View File
@@ -0,0 +1,3 @@
pre-commit>=2.10.1,<3
isort>=5.10.1,<6
black==22.1.0
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.11.0" __version__ = "0.11.2"
__author__ = "Tulir Asokan <tulir@maunium.net>" __author__ = "Tulir Asokan <tulir@maunium.net>"
+1
View File
@@ -86,6 +86,7 @@ class TelegramBridge(Bridge):
Portal.init_cls(self) Portal.init_cls(self)
self.add_startup_actions(Puppet.init_cls(self)) self.add_startup_actions(Puppet.init_cls(self))
self.add_startup_actions(User.init_cls(self)) self.add_startup_actions(User.init_cls(self))
self.add_startup_actions(Portal.restart_scheduled_disappearing())
if self.bot: if self.bot:
self.add_startup_actions(self.bot.start()) self.add_startup_actions(self.bot.start())
if self.config["bridge.resend_bridge_info"]: if self.config["bridge.resend_bridge_info"]:
+21 -12
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan # Copyright (C) 2022 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by # it under the terms of the GNU Affero General Public License as published by
@@ -15,7 +15,7 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Any, Type, Union from typing import TYPE_CHECKING, Any, Union
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
import asyncio import asyncio
import logging import logging
@@ -34,6 +34,7 @@ from telethon.tl.types import (
Chat, Chat,
MessageActionChannelMigrateFrom, MessageActionChannelMigrateFrom,
MessageEmpty, MessageEmpty,
PeerChannel,
PeerChat, PeerChat,
PeerUser, PeerUser,
TypeUpdate, TypeUpdate,
@@ -46,6 +47,7 @@ from telethon.tl.types import (
UpdateEditChannelMessage, UpdateEditChannelMessage,
UpdateEditMessage, UpdateEditMessage,
UpdateFolderPeers, UpdateFolderPeers,
UpdateMessageReactions,
UpdateNewChannelMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateNewMessage,
UpdateNotifySettings, UpdateNotifySettings,
@@ -146,7 +148,7 @@ class AbstractUser(ABC):
return self.client and self.client.is_connected() return self.client and self.client.is_connected()
@property @property
def _proxy_settings(self) -> tuple[Type[Connection], tuple[Any, ...] | None]: def _proxy_settings(self) -> tuple[type[Connection], tuple[Any, ...] | None]:
proxy_type = self.config["telegram.proxy.type"].lower() proxy_type = self.config["telegram.proxy.type"].lower()
connection = ConnectionTcpFull connection = ConnectionTcpFull
connection_data = ( connection_data = (
@@ -312,6 +314,8 @@ class AbstractUser(ABC):
await self.delete_message(update) await self.delete_message(update)
elif isinstance(update, UpdateDeleteChannelMessages): elif isinstance(update, UpdateDeleteChannelMessages):
await self.delete_channel_message(update) await self.delete_channel_message(update)
elif isinstance(update, UpdateMessageReactions):
await self.update_reactions(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)): elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
await self.update_typing(update) await self.update_typing(update)
elif isinstance(update, UpdateUserStatus): elif isinstance(update, UpdateUserStatus):
@@ -382,7 +386,7 @@ class AbstractUser(ABC):
if not message: if not message:
return return
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.peer.user_id)) puppet = await pu.Puppet.get_by_peer(update.peer)
await puppet.intent.mark_read(portal.mxid, message.mxid) await puppet.intent.mark_read(portal.mxid, message.mxid)
async def update_own_read_receipt( async def update_own_read_receipt(
@@ -441,10 +445,7 @@ class AbstractUser(ABC):
return return
if isinstance(update, (UpdateChannelUserTyping, UpdateChatUserTyping)): if isinstance(update, (UpdateChannelUserTyping, UpdateChatUserTyping)):
# Can typing notifications come from non-user peers? sender = await pu.Puppet.get_by_peer(update.from_id)
if not update.from_id.user_id:
return
sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id.user_id))
if not sender or not portal or not portal.mxid: if not sender or not portal or not portal.mxid:
return return
@@ -453,8 +454,8 @@ class AbstractUser(ABC):
async def _handle_entity_updates(self, entities: dict[int, User | Chat | Channel]) -> None: async def _handle_entity_updates(self, entities: dict[int, User | Chat | Channel]) -> None:
try: try:
users = (entity for entity in entities.values() if isinstance(entity, User)) users = (entity for entity in entities.values() if isinstance(entity, (User, Channel)))
puppets = ((await pu.Puppet.get_by_tgid(TelegramID(user.id)), user) for user in users) puppets = ((await pu.Puppet.get_by_peer(user), user) for user in users)
await asyncio.gather( await asyncio.gather(
*[puppet.try_update_info(self, info) async for puppet, info in puppets if puppet] *[puppet.try_update_info(self, info) async for puppet, info in puppets if puppet]
) )
@@ -468,9 +469,11 @@ class AbstractUser(ABC):
puppet.username = update.username puppet.username = update.username
if await puppet.update_displayname(self, update): if await puppet.update_displayname(self, update):
await puppet.save() await puppet.save()
await puppet.update_portals_meta()
elif isinstance(update, UpdateUserPhoto): elif isinstance(update, UpdateUserPhoto):
if await puppet.update_avatar(self, update.photo): if await puppet.update_avatar(self, update.photo):
await puppet.save() await puppet.save()
await puppet.update_portals_meta()
else: else:
self.log.warning(f"Unexpected other user info update: {type(update)}") self.log.warning(f"Unexpected other user info update: {type(update)}")
@@ -512,8 +515,8 @@ class AbstractUser(ABC):
portal = await po.Portal.get_by_entity(update.peer_id, tg_receiver=self.tgid) portal = await po.Portal.get_by_entity(update.peer_id, tg_receiver=self.tgid)
if update.out: if update.out:
sender = await pu.Puppet.get_by_tgid(self.tgid) sender = await pu.Puppet.get_by_tgid(self.tgid)
elif isinstance(update.from_id, PeerUser): elif isinstance(update.from_id, (PeerUser, PeerChannel)):
sender = await pu.Puppet.get_by_tgid(TelegramID(update.from_id.user_id)) sender = await pu.Puppet.get_by_peer(update.from_id)
else: else:
sender = None sender = None
else: else:
@@ -559,6 +562,12 @@ class AbstractUser(ABC):
await message.delete() await message.delete()
await self._try_redact(message) await self._try_redact(message)
async def update_reactions(self, update: UpdateMessageReactions) -> None:
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
if not portal or not portal.mxid or not portal.allow_bridging:
return
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions)
async def update_message(self, original_update: UpdateMessage) -> None: async def update_message(self, original_update: UpdateMessage) -> None:
update, sender, portal = await self.get_message_details(original_update) update, sender, portal = await self.get_message_details(original_update)
if not portal: if not portal:
+5 -1
View File
@@ -225,7 +225,11 @@ class Bot(AbstractUser):
elif isinstance(message.to_id, PeerChat): elif isinstance(message.to_id, PeerChat):
return reply(str(-message.to_id.chat_id)) return reply(str(-message.to_id.chat_id))
elif isinstance(message.to_id, PeerUser): elif isinstance(message.to_id, PeerUser):
return reply(f"Your user ID is {message.to_id.user_id}.") return reply(
f"Your user ID is {message.to_id.user_id}.\n\n"
f"If you're trying to bridge a group chat to Matrix, you must run the command in "
f"the group, not here. **The ID above will not work** with `!tg bridge`."
)
else: else:
return reply("Failed to find chat ID.") return reply("Failed to find chat ID.")
@@ -65,7 +65,7 @@ async def create(evt: CommandEvent) -> EventID:
about=about, about=about,
encrypted=encrypted, encrypted=encrypted,
) )
invites, errors = await portal.get_telegram_users_in_matrix_room(evt.sender) invites, errors = await portal.get_telegram_users_in_matrix_room(evt.sender, pre_create=True)
if len(errors) > 0: if len(errors) > 0:
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors) error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
await evt.reply( await evt.reply(
+17 -2
View File
@@ -37,6 +37,7 @@ from telethon.errors import (
) )
from telethon.tl.types import User from telethon.tl.types import User
from mautrix.client import Client
from mautrix.types import ( from mautrix.types import (
EventID, EventID,
ImageInfo, ImageInfo,
@@ -230,9 +231,23 @@ async def login_qr(evt: CommandEvent) -> EventID:
) )
async def login(evt: CommandEvent) -> EventID: async def login(evt: CommandEvent) -> EventID:
override_sender = False override_sender = False
if len(evt.args) > 0 and evt.sender.is_admin: if len(evt.args) > 0 and evt.sender.is_admin and evt.args[0]:
evt.sender = await u.User.get_and_start_by_mxid(UserID(evt.args[0])) override_user_id = UserID(evt.args[0])
try:
Client.parse_user_id(override_user_id)
except ValueError:
return await evt.reply(
f"**Usage:** `$cmdprefix+sp login [override user ID]`\n\n"
f"{override_user_id!r} is not a valid Matrix user ID"
)
orig_user_id = evt.sender.mxid
evt.sender = await u.User.get_and_start_by_mxid(override_user_id)
override_sender = True override_sender = True
if orig_user_id != evt.sender:
await evt.reply(
f"Admin override: logging in as {evt.sender.mxid} instead of {orig_user_id}"
)
if await evt.sender.is_logged_in(): if await evt.sender.is_logged_in():
return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.") return await evt.reply(f"You are already logged in as {evt.sender.human_tg_id}.")
@@ -208,6 +208,9 @@ async def join(evt: CommandEvent) -> EventID | None:
link_type = data["type"] link_type = data["type"]
if link_type: if link_type:
link_type = link_type.lower() link_type = link_type.lower()
elif identifier.startswith("+"):
link_type = "joinchat"
identifier = identifier[1:]
updates, _ = await _join(evt, identifier, link_type) updates, _ = await _join(evt, identifier, link_type)
if not updates: if not updates:
return None return None
+9 -12
View File
@@ -95,8 +95,6 @@ class Config(BaseBridgeConfig):
if "pool_pre_ping" in base["appservice.database_opts"]: if "pool_pre_ping" in base["appservice.database_opts"]:
del base["appservice.database_opts.pool_pre_ping"] del base["appservice.database_opts.pool_pre_ping"]
copy("appservice.community_id")
copy("metrics.enabled") copy("metrics.enabled")
copy("metrics.listen_port") copy("metrics.listen_port")
@@ -138,11 +136,14 @@ class Config(BaseBridgeConfig):
copy("bridge.invite_link_resolve") copy("bridge.invite_link_resolve")
copy("bridge.inline_images") copy("bridge.inline_images")
copy("bridge.image_as_file_size") copy("bridge.image_as_file_size")
copy("bridge.image_as_file_pixels")
copy("bridge.max_document_size") copy("bridge.max_document_size")
copy("bridge.parallel_file_transfer") copy("bridge.parallel_file_transfer")
copy("bridge.federate_rooms") copy("bridge.federate_rooms")
copy("bridge.animated_sticker.target") copy("bridge.animated_sticker.target")
copy("bridge.animated_sticker.args") copy("bridge.animated_sticker.args.width")
copy("bridge.animated_sticker.args.height")
copy("bridge.animated_sticker.args.fps")
copy("bridge.encryption.allow") copy("bridge.encryption.allow")
copy("bridge.encryption.default") copy("bridge.encryption.default")
copy("bridge.encryption.database") copy("bridge.encryption.database")
@@ -171,20 +172,16 @@ class Config(BaseBridgeConfig):
copy("bridge.bot_messages_as_notices") copy("bridge.bot_messages_as_notices")
if isinstance(self["bridge.bridge_notices"], bool): if isinstance(self["bridge.bridge_notices"], bool):
base["bridge.bridge_notices"] = { base["bridge.bridge_notices"]["default"] = self["bridge.bridge_notices"]
"default": self["bridge.bridge_notices"],
"exceptions": ["@importantbot:example.com"],
}
else: else:
copy("bridge.bridge_notices") copy("bridge.bridge_notices.default")
copy("bridge.bridge_notices.exceptions")
copy("bridge.deduplication.pre_db_check")
copy("bridge.deduplication.cache_queue_length")
if "bridge.message_formats.m_text" in self: if "bridge.message_formats.m_text" in self:
del self["bridge.message_formats"] del self["bridge.message_formats"]
copy_dict("bridge.message_formats", override_existing_map=False) copy_dict("bridge.message_formats", override_existing_map=False)
copy("bridge.emote_format") copy("bridge.emote_format")
copy("bridge.relay_user_distinguishers")
copy("bridge.state_event_formats.join") copy("bridge.state_event_formats.join")
copy("bridge.state_event_formats.leave") copy("bridge.state_event_formats.leave")
@@ -208,7 +205,7 @@ class Config(BaseBridgeConfig):
permissions[entry] = "admin" permissions[entry] = "admin"
base["bridge.permissions"] = permissions base["bridge.permissions"] = permissions
else: else:
copy_dict("bridge.permissions") copy_dict("bridge.permissions", override_existing_map=True)
if "bridge.relaybot" not in self: if "bridge.relaybot" not in self:
copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals") copy("bridge.authless_relaybot_portals", "bridge.relaybot.authless_portals")
+15 -1
View File
@@ -16,9 +16,11 @@
from mautrix.util.async_db import Database from mautrix.util.async_db import Database
from .bot_chat import BotChat from .bot_chat import BotChat
from .disappearing_message import DisappearingMessage
from .message import Message from .message import Message
from .portal import Portal from .portal import Portal
from .puppet import Puppet from .puppet import Puppet
from .reaction import Reaction
from .telegram_file import TelegramFile from .telegram_file import TelegramFile
from .telethon_session import PgSession from .telethon_session import PgSession
from .upgrade import upgrade_table from .upgrade import upgrade_table
@@ -26,7 +28,17 @@ from .user import User
def init(db: Database) -> None: def init(db: Database) -> None:
for table in (Portal, Message, User, Puppet, TelegramFile, BotChat, PgSession): for table in (
Portal,
Message,
Reaction,
User,
Puppet,
TelegramFile,
BotChat,
PgSession,
DisappearingMessage,
):
table.db = db table.db = db
@@ -35,9 +47,11 @@ __all__ = [
"init", "init",
"Portal", "Portal",
"Message", "Message",
"Reaction",
"User", "User",
"Puppet", "Puppet",
"TelegramFile", "TelegramFile",
"BotChat", "BotChat",
"PgSession", "PgSession",
"DisappearingMessage",
] ]
@@ -0,0 +1,78 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Sumner Evans
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
import asyncpg
from mautrix.bridge import AbstractDisappearingMessage
from mautrix.types import EventID, RoomID
from mautrix.util.async_db import Database
fake_db = Database.create("") if TYPE_CHECKING else None
class DisappearingMessage(AbstractDisappearingMessage):
db: ClassVar[Database] = fake_db
async def insert(self) -> None:
q = """
INSERT INTO disappearing_message (room_id, event_id, expiration_seconds, expiration_ts)
VALUES ($1, $2, $3, $4)
"""
await self.db.execute(
q, self.room_id, self.event_id, self.expiration_seconds, self.expiration_ts
)
async def update(self) -> None:
q = "UPDATE disappearing_message SET expiration_ts=$3 WHERE room_id=$1 AND event_id=$2"
await self.db.execute(q, self.room_id, self.event_id, self.expiration_ts)
async def delete(self) -> None:
q = "DELETE from disappearing_message WHERE room_id=$1 AND event_id=$2"
await self.db.execute(q, self.room_id, self.event_id)
@classmethod
def _from_row(cls, row: asyncpg.Record) -> DisappearingMessage:
return cls(**row)
@classmethod
async def get(cls, room_id: RoomID, event_id: EventID) -> DisappearingMessage | None:
q = """
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
WHERE room_id=$1 AND mxid=$2
"""
try:
return cls._from_row(await cls.db.fetchrow(q, room_id, event_id))
except Exception:
return None
@classmethod
async def get_all_scheduled(cls) -> list[DisappearingMessage]:
q = """
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
WHERE expiration_ts IS NOT NULL
"""
return [cls._from_row(r) for r in await cls.db.fetch(q)]
@classmethod
async def get_unscheduled_for_room(cls, room_id: RoomID) -> list[DisappearingMessage]:
q = """
SELECT room_id, event_id, expiration_seconds, expiration_ts FROM disappearing_message
WHERE room_id = $1 AND expiration_ts IS NULL
"""
return [cls._from_row(r) for r in await cls.db.fetch(q, room_id)]
+24 -8
View File
@@ -38,6 +38,7 @@ class Message:
tg_space: TelegramID tg_space: TelegramID
edit_index: int edit_index: int
redacted: bool = False redacted: bool = False
content_hash: bytes | None = None
@classmethod @classmethod
def _from_row(cls, row: Record | None) -> Message | None: def _from_row(cls, row: Record | None) -> Message | None:
@@ -45,7 +46,7 @@ class Message:
return None return None
return cls(**row) return cls(**row)
columns: ClassVar[str] = "mxid, mx_room, tgid, tg_space, edit_index, redacted" columns: ClassVar[str] = "mxid, mx_room, tgid, tg_space, edit_index, redacted, content_hash"
@classmethod @classmethod
async def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> list[Message]: async def get_all_by_tgid(cls, tgid: TelegramID, tg_space: TelegramID) -> list[Message]:
@@ -142,14 +143,29 @@ class Message:
q = "UPDATE message SET mxid=$1 WHERE mxid=$2 AND mx_room=$3" q = "UPDATE message SET mxid=$1 WHERE mxid=$2 AND mx_room=$3"
await cls.db.execute(q, real_mxid, temp_mxid, mx_room) await cls.db.execute(q, real_mxid, temp_mxid, mx_room)
@classmethod
async def delete_temp_mxid(cls, temp_mxid: str, mx_room: RoomID) -> None:
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2"
await cls.db.execute(q, temp_mxid, mx_room)
@property
def _values(self):
return (
self.mxid,
self.mx_room,
self.tgid,
self.tg_space,
self.edit_index,
self.redacted,
self.content_hash,
)
async def insert(self) -> None: async def insert(self) -> None:
q = ( q = """
"INSERT INTO message (mxid, mx_room, tgid, tg_space, edit_index, redacted) " INSERT INTO message (mxid, mx_room, tgid, tg_space, edit_index, redacted, content_hash)
"VALUES ($1, $2, $3, $4, $5, $6)" VALUES ($1, $2, $3, $4, $5, $6, $7)
) """
await self.db.execute( await self.db.execute(q, *self._values)
q, self.mxid, self.mx_room, self.tgid, self.tg_space, self.edit_index, self.redacted
)
async def delete(self) -> None: async def delete(self) -> None:
q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3" q = "DELETE FROM message WHERE mxid=$1 AND mx_room=$2 AND tg_space=$3"
+26 -14
View File
@@ -54,6 +54,8 @@ class Portal:
title: str | None title: str | None
about: str | None about: str | None
photo_id: str | None photo_id: str | None
name_set: bool
avatar_set: bool
local_config: dict[str, Any] = attr.ib(factory=lambda: {}) local_config: dict[str, Any] = attr.ib(factory=lambda: {})
@@ -67,7 +69,8 @@ class Portal:
columns: ClassVar[str] = ( columns: ClassVar[str] = (
"tgid, tg_receiver, peer_type, megagroup, mxid, avatar_url, encrypted, sponsored_event_id," "tgid, tg_receiver, peer_type, megagroup, mxid, avatar_url, encrypted, sponsored_event_id,"
"sponsored_event_ts, sponsored_msg_random_id, username, title, about, photo_id, config" "sponsored_event_ts, sponsored_msg_random_id, username, title, about, photo_id, "
"name_set, avatar_set, config"
) )
@classmethod @classmethod
@@ -86,10 +89,15 @@ class Portal:
return cls._from_row(await cls.db.fetchrow(q, username.lower())) return cls._from_row(await cls.db.fetchrow(q, username.lower()))
@classmethod @classmethod
async def find_private_chats(cls, tg_receiver: TelegramID) -> list[Portal]: async def find_private_chats_of(cls, tg_receiver: TelegramID) -> list[Portal]:
q = f"SELECT {cls.columns} FROM portal WHERE tg_receiver=$1 AND peer_type='user'" q = f"SELECT {cls.columns} FROM portal WHERE tg_receiver=$1 AND peer_type='user'"
return [cls._from_row(row) for row in await cls.db.fetch(q, tg_receiver)] return [cls._from_row(row) for row in await cls.db.fetch(q, tg_receiver)]
@classmethod
async def find_private_chats_with(cls, tgid: TelegramID) -> list[Portal]:
q = f"SELECT {cls.columns} FROM portal WHERE tgid=$1 AND peer_type='user'"
return [cls._from_row(row) for row in await cls.db.fetch(q, tgid)]
@classmethod @classmethod
async def all(cls) -> list[Portal]: async def all(cls) -> list[Portal]:
rows = await cls.db.fetch(f"SELECT {cls.columns} FROM portal") rows = await cls.db.fetch(f"SELECT {cls.columns} FROM portal")
@@ -111,17 +119,20 @@ class Portal:
self.title, self.title,
self.about, self.about,
self.photo_id, self.photo_id,
self.name_set,
self.avatar_set,
self.megagroup, self.megagroup,
json.dumps(self.local_config) if self.local_config else None, json.dumps(self.local_config) if self.local_config else None,
) )
async def save(self) -> None: async def save(self) -> None:
q = ( q = """
"UPDATE portal SET mxid=$4, avatar_url=$5, encrypted=$6, sponsored_event_id=$7," UPDATE portal
" sponsored_event_ts=$8, sponsored_msg_random_id=$9, username=$10," SET mxid=$4, avatar_url=$5, encrypted=$6, sponsored_event_id=$7, sponsored_event_ts=$8,
" title=$11, about=$12, photo_id=$13, megagroup=$14, config=$15 " sponsored_msg_random_id=$9, username=$10, title=$11, about=$12, photo_id=$13,
"WHERE tgid=$1 AND tg_receiver=$2 AND (peer_type=$3 OR true)" name_set=$14, avatar_set=$15, megagroup=$16, config=$17
) WHERE tgid=$1 AND tg_receiver=$2 AND (peer_type=$3 OR true)
"""
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
async def update_id(self, id: TelegramID, peer_type: str) -> None: async def update_id(self, id: TelegramID, peer_type: str) -> None:
@@ -135,12 +146,13 @@ class Portal:
self.peer_type = peer_type self.peer_type = peer_type
async def insert(self) -> None: async def insert(self) -> None:
q = ( q = """
"INSERT INTO portal (tgid, tg_receiver, peer_type, mxid, avatar_url, encrypted," INSERT INTO portal (
" sponsored_event_id, sponsored_event_ts, sponsored_msg_random_id," tgid, tg_receiver, peer_type, mxid, avatar_url, encrypted,
" username, title, about, photo_id, megagroup, config) " sponsored_event_id, sponsored_event_ts, sponsored_msg_random_id,
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)" username, title, about, photo_id, name_set, avatar_set, megagroup, config
) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
"""
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
async def delete(self) -> None: async def delete(self) -> None:
+27 -18
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan # Copyright (C) 2022 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by # it under the terms of the GNU Affero General Public License as published by
@@ -21,7 +21,7 @@ from asyncpg import Record
from attr import dataclass from attr import dataclass
from yarl import URL from yarl import URL
from mautrix.types import SyncToken, UserID from mautrix.types import ContentURI, SyncToken, UserID
from mautrix.util.async_db import Database from mautrix.util.async_db import Database
from ..types import TelegramID from ..types import TelegramID
@@ -44,7 +44,11 @@ class Puppet:
disable_updates: bool disable_updates: bool
username: str | None username: str | None
photo_id: str | None photo_id: str | None
avatar_url: ContentURI | None
name_set: bool
avatar_set: bool
is_bot: bool | None is_bot: bool | None
is_channel: bool
custom_mxid: UserID | None custom_mxid: UserID | None
access_token: str | None access_token: str | None
@@ -61,8 +65,8 @@ class Puppet:
columns: ClassVar[str] = ( columns: ClassVar[str] = (
"id, is_registered, displayname, displayname_source, displayname_contact, " "id, is_registered, displayname, displayname_source, displayname_contact, "
"displayname_quality, disable_updates, username, photo_id, is_bot, " "displayname_quality, disable_updates, username, photo_id, avatar_url, "
"custom_mxid, access_token, next_batch, base_url" "name_set, avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url"
) )
@classmethod @classmethod
@@ -102,7 +106,11 @@ class Puppet:
self.disable_updates, self.disable_updates,
self.username, self.username,
self.photo_id, self.photo_id,
self.avatar_url,
self.name_set,
self.avatar_set,
self.is_bot, self.is_bot,
self.is_channel,
self.custom_mxid, self.custom_mxid,
self.access_token, self.access_token,
self.next_batch, self.next_batch,
@@ -110,21 +118,22 @@ class Puppet:
) )
async def save(self) -> None: async def save(self) -> None:
q = ( q = """
"UPDATE puppet " UPDATE puppet
"SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5," SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
" displayname_quality=$6, disable_updates=$7, username=$8, photo_id=$9, is_bot=$10," displayname_quality=$6, disable_updates=$7, username=$8, photo_id=$9,
" custom_mxid=$11, access_token=$12, next_batch=$13, base_url=$14 " avatar_url=$10, name_set=$11, avatar_set=$12, is_bot=$13, is_channel=$14,
"WHERE id=$1" custom_mxid=$15, access_token=$16, next_batch=$17, base_url=$18
) WHERE id=$1
"""
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
async def insert(self) -> None: async def insert(self) -> None:
q = ( q = """
"INSERT INTO puppet (" INSERT INTO puppet (
" id, is_registered, displayname, displayname_source, displayname_contact," id, is_registered, displayname, displayname_source, displayname_contact,
" displayname_quality, disable_updates, username, photo_id, is_bot," displayname_quality, disable_updates, username, photo_id, avatar_url, name_set,
" custom_mxid, access_token, next_batch, base_url" avatar_set, is_bot, is_channel, custom_mxid, access_token, next_batch, base_url
") VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)" ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
) """
await self.db.execute(q, *self._values) await self.db.execute(q, *self._values)
+91
View File
@@ -0,0 +1,91 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar
from asyncpg import Record
from attr import dataclass
from mautrix.types import EventID, RoomID
from mautrix.util.async_db import Database
from ..types import TelegramID
fake_db = Database.create("") if TYPE_CHECKING else None
@dataclass
class Reaction:
db: ClassVar[Database] = fake_db
mxid: EventID
mx_room: RoomID
msg_mxid: EventID
tg_sender: TelegramID
reaction: str
@classmethod
def _from_row(cls, row: Record | None) -> Reaction | None:
if row is None:
return None
return cls(**row)
columns: ClassVar[str] = "mxid, mx_room, msg_mxid, tg_sender, reaction"
@classmethod
async def delete_all(cls, mx_room: RoomID) -> None:
await cls.db.execute("DELETE FROM reaction WHERE mx_room=$1", mx_room)
@classmethod
async def get_by_mxid(cls, mxid: EventID, mx_room: RoomID) -> Reaction | None:
q = f"SELECT {cls.columns} FROM reaction WHERE mxid=$1 AND mx_room=$2"
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room))
@classmethod
async def get_by_sender(
cls, mxid: EventID, mx_room: RoomID, tg_sender: TelegramID
) -> Reaction | None:
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3"
return cls._from_row(await cls.db.fetchrow(q, mxid, mx_room, tg_sender))
@classmethod
async def get_all_by_message(cls, mxid: EventID, mx_room: RoomID) -> list[Reaction]:
q = f"SELECT {cls.columns} FROM reaction WHERE msg_mxid=$1 AND mx_room=$2"
rows = await cls.db.fetch(q, mxid, mx_room)
return [cls._from_row(row) for row in rows]
@property
def _values(self):
return (
self.mxid,
self.mx_room,
self.msg_mxid,
self.tg_sender,
self.reaction,
)
async def save(self) -> None:
q = """
INSERT INTO reaction (mxid, mx_room, msg_mxid, tg_sender, reaction)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT (msg_mxid, mx_room, tg_sender)
DO UPDATE SET mxid=$1, reaction=$5
"""
await self.db.execute(q, *self._values)
async def delete(self) -> None:
q = "DELETE FROM reaction WHERE msg_mxid=$1 AND mx_room=$2 AND tg_sender=$3"
await self.db.execute(q, self.msg_mxid, self.mx_room, self.tg_sender)
+8 -1
View File
@@ -2,4 +2,11 @@ from mautrix.util.async_db import UpgradeTable
upgrade_table = UpgradeTable() upgrade_table = UpgradeTable()
from . import v01_initial_revision, v02_sponsored_events from . import (
v01_initial_revision,
v02_sponsored_events,
v03_reactions,
v04_disappearing_messages,
v05_channel_ghosts,
v06_puppet_avatar_url,
)
@@ -13,6 +13,8 @@
# #
# You should have received a copy of the GNU Affero General Public License # You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations
from asyncpg import Connection from asyncpg import Connection
from . import upgrade_table from . import upgrade_table
@@ -38,6 +40,16 @@ async def upgrade_v1(conn: Connection, scheme: str) -> None:
await create_v1_tables(conn) await create_v1_tables(conn)
async def drop_constraints(conn: Connection, table: str, contype: str) -> None:
q = (
"SELECT conname FROM pg_constraint con INNER JOIN pg_class rel ON rel.oid=con.conrelid "
f"WHERE rel.relname='{table}' AND contype='{contype}'"
)
names = [row["conname"] for row in await conn.fetch(q)]
drops = ", ".join(f"DROP CONSTRAINT {name}" for name in names)
await conn.execute(f"ALTER TABLE {table} {drops}")
async def migrate_legacy_to_v1(conn: Connection, scheme: str) -> None: async def migrate_legacy_to_v1(conn: Connection, scheme: str) -> None:
legacy_version = await conn.fetchval(legacy_version_query) legacy_version = await conn.fetchval(legacy_version_query)
if legacy_version != last_legacy_version: if legacy_version != last_legacy_version:
@@ -46,37 +58,38 @@ async def migrate_legacy_to_v1(conn: Connection, scheme: str) -> None:
"Please upgrade the old database with alembic or drop it completely first." "Please upgrade the old database with alembic or drop it completely first."
) )
if scheme != "sqlite": if scheme != "sqlite":
await drop_constraints(conn, "contact", contype="f")
await conn.execute( await conn.execute(
""" """
ALTER TABLE contact ALTER TABLE contact
DROP CONSTRAINT contact_user_fkey,
DROP CONSTRAINT contact_contact_fkey,
ADD CONSTRAINT contact_user_fkey FOREIGN KEY (contact) REFERENCES puppet(id) ADD CONSTRAINT contact_user_fkey FOREIGN KEY (contact) REFERENCES puppet(id)
ON DELETE CASCADE ON UPDATE CASCADE, ON DELETE CASCADE ON UPDATE CASCADE,
ADD CONSTRAINT contact_contact_fkey FOREIGN KEY ("user") REFERENCES "user"(tgid) ADD CONSTRAINT contact_contact_fkey FOREIGN KEY ("user") REFERENCES "user"(tgid)
ON DELETE CASCADE ON UPDATE CASCADE ON DELETE CASCADE ON UPDATE CASCADE
""" """
) )
await drop_constraints(conn, "telethon_sessions", contype="p")
await conn.execute( await conn.execute(
""" """
ALTER TABLE telethon_sessions ALTER TABLE telethon_sessions
DROP CONSTRAINT telethon_sessions_pkey,
ADD CONSTRAINT telethon_sessions_pkey PRIMARY KEY (session_id) ADD CONSTRAINT telethon_sessions_pkey PRIMARY KEY (session_id)
""" """
) )
await drop_constraints(conn, "telegram_file", contype="f")
await conn.execute( await conn.execute(
""" """
ALTER TABLE telegram_file ALTER TABLE telegram_file
DROP CONSTRAINT fk_file_thumbnail,
ADD CONSTRAINT fk_file_thumbnail ADD CONSTRAINT fk_file_thumbnail
FOREIGN KEY (thumbnail) REFERENCES telegram_file(id) FOREIGN KEY (thumbnail) REFERENCES telegram_file(id)
ON UPDATE CASCADE ON DELETE SET NULL ON UPDATE CASCADE ON DELETE SET NULL
""" """
) )
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP IDENTITY IF EXISTS")
await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP DEFAULT") await conn.execute("ALTER TABLE puppet ALTER COLUMN id DROP DEFAULT")
await conn.execute("DROP SEQUENCE puppet_id_seq") await conn.execute("DROP SEQUENCE IF EXISTS puppet_id_seq")
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP IDENTITY IF EXISTS")
await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP DEFAULT") await conn.execute("ALTER TABLE bot_chat ALTER COLUMN id DROP DEFAULT")
await conn.execute("DROP SEQUENCE bot_chat_id_seq") await conn.execute("DROP SEQUENCE IF EXISTS bot_chat_id_seq")
await conn.execute("ALTER TABLE portal ALTER COLUMN config TYPE jsonb USING config::jsonb") await conn.execute("ALTER TABLE portal ALTER COLUMN config TYPE jsonb USING config::jsonb")
await conn.execute( await conn.execute(
"ALTER TABLE telegram_file ALTER COLUMN decryption_info TYPE jsonb " "ALTER TABLE telegram_file ALTER COLUMN decryption_info TYPE jsonb "
@@ -0,0 +1,39 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from asyncpg import Connection
from . import upgrade_table
@upgrade_table.register(description="Add support for reactions")
async def upgrade_v3(conn: Connection, scheme: str) -> None:
await conn.execute(
"""CREATE TABLE reaction (
mxid TEXT NOT NULL,
mx_room TEXT NOT NULL,
msg_mxid TEXT NOT NULL,
tg_sender BIGINT,
reaction TEXT NOT NULL,
PRIMARY KEY (msg_mxid, mx_room, tg_sender),
UNIQUE (mxid, mx_room)
)"""
)
if scheme != "sqlite":
await conn.execute("DELETE FROM message WHERE mxid IS NULL OR mx_room IS NULL")
await conn.execute("ALTER TABLE message ALTER COLUMN mxid SET NOT NULL")
await conn.execute("ALTER TABLE message ALTER COLUMN mx_room SET NOT NULL")
await conn.execute("ALTER TABLE message ADD COLUMN content_hash bytea")
@@ -0,0 +1,32 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from asyncpg import Connection
from . import upgrade_table
@upgrade_table.register(description="Add support for disappearing messages")
async def upgrade_v4(conn: Connection) -> None:
await conn.execute(
"""CREATE TABLE disappearing_message (
room_id TEXT,
event_id TEXT,
expiration_seconds BIGINT,
expiration_ts BIGINT,
PRIMARY KEY (room_id, event_id)
)"""
)
@@ -0,0 +1,25 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from asyncpg import Connection
from . import upgrade_table
@upgrade_table.register(description="Add separate ghost users for channel senders")
async def upgrade_v5(conn: Connection, scheme: str) -> None:
await conn.execute("ALTER TABLE puppet ADD COLUMN is_channel BOOLEAN NOT NULL DEFAULT false")
if scheme == "postgres":
await conn.execute("ALTER TABLE puppet ALTER COLUMN is_channel DROP DEFAULT")
@@ -0,0 +1,31 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2022 Tulir Asokan
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from asyncpg import Connection
from . import upgrade_table
@upgrade_table.register(description="Store avatar mxc URI in puppet table")
async def upgrade_v6(conn: Connection) -> None:
await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_url TEXT")
await conn.execute("ALTER TABLE puppet ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("ALTER TABLE puppet ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("UPDATE puppet SET name_set=true WHERE displayname<>''")
await conn.execute("UPDATE puppet SET avatar_set=true WHERE photo_id<>''")
await conn.execute("ALTER TABLE portal ADD COLUMN name_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("ALTER TABLE portal ADD COLUMN avatar_set BOOLEAN NOT NULL DEFAULT false")
await conn.execute("UPDATE portal SET name_set=true WHERE title<>'' AND mxid<>''")
await conn.execute("UPDATE portal SET avatar_set=true WHERE photo_id<>'' AND mxid<>''")
+19 -29
View File
@@ -78,12 +78,6 @@ appservice:
bot_displayname: Telegram bridge bot bot_displayname: Telegram bridge bot
bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX bot_avatar: mxc://maunium.net/tJCRmUyJDsgRNgqhOgoiHWbX
# Community ID for bridged users (changes registration file) and rooms.
# Must be created manually.
#
# Example: "+telegram:example.com". Set to false to disable.
community_id: false
# Whether or not to receive ephemeral events via appservice transactions. # Whether or not to receive ephemeral events via appservice transactions.
# Requires MSC2409 support (i.e. Synapse 1.22+). # Requires MSC2409 support (i.e. Synapse 1.22+).
# You should disable bridge -> sync_with_custom_puppets when this is enabled. # You should disable bridge -> sync_with_custom_puppets when this is enabled.
@@ -214,6 +208,8 @@ bridge:
inline_images: false inline_images: false
# Maximum size of image in megabytes before sending to Telegram as a document. # Maximum size of image in megabytes before sending to Telegram as a document.
image_as_file_size: 10 image_as_file_size: 10
# Maximum number of pixels in an image before sending to Telegram as a document. Defaults to 1280x1280 = 1638400.
image_as_file_pixels: 1638400
# Maximum size of Telegram documents in megabytes to bridge. # Maximum size of Telegram documents in megabytes to bridge.
max_document_size: 100 max_document_size: 100
# Enable experimental parallel file transfer, which makes uploads/downloads much faster by # Enable experimental parallel file transfer, which makes uploads/downloads much faster by
@@ -328,19 +324,12 @@ bridge:
# List of user IDs for whom the previous flag is flipped. # 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 # 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. # notices from users listed here will be bridged.
exceptions: 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
# An array of possible values for the $distinguisher variable in message formats.
# Each user gets one of the values here, based on a hash of their user ID.
# If the array is empty, the $distinguisher variable will also be empty.
relay_user_distinguishers: ["🟦", "🟣", "🟩", "⭕️", "🔶", "⬛️", "🔵", "🟢"]
# The formats to use when sending messages to Telegram via the relay bot. # The formats to use when sending messages to Telegram via the relay bot.
# Text msgtypes (m.text, m.notice and m.emote) support HTML, media msgtypes don't. # Text msgtypes (m.text, m.notice and m.emote) support HTML, media msgtypes don't.
# #
@@ -348,16 +337,17 @@ bridge:
# $sender_displayname - The display name of the sender (e.g. Example User) # $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_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) # $sender_mxid - The Matrix ID of the sender (e.g. @exampleuser:example.com)
# $distinguisher - A random string from the options in the relay_user_distinguishers array.
# $message - The message content # $message - The message content
message_formats: message_formats:
m.text: "<b>$sender_displayname</b>: $message" m.text: "$distinguisher <b>$sender_displayname</b>: $message"
m.notice: "<b>$sender_displayname</b>: $message" m.notice: "$distinguisher <b>$sender_displayname</b>: $message"
m.emote: "* <b>$sender_displayname</b> $message" m.emote: "* $distinguisher <b>$sender_displayname</b> $message"
m.file: "<b>$sender_displayname</b> sent a file: $message" m.file: "$distinguisher <b>$sender_displayname</b> sent a file: $message"
m.image: "<b>$sender_displayname</b> sent an image: $message" m.image: "$distinguisher <b>$sender_displayname</b> sent an image: $message"
m.audio: "<b>$sender_displayname</b> sent an audio file: $message" m.audio: "$distinguisher <b>$sender_displayname</b> sent an audio file: $message"
m.video: "<b>$sender_displayname</b> sent a video: $message" m.video: "$distinguisher <b>$sender_displayname</b> sent a video: $message"
m.location: "<b>$sender_displayname</b> sent a location: $message" m.location: "$distinguisher <b>$sender_displayname</b> sent a location: $message"
# Telegram doesn't have built-in emotes, this field specifies how m.emote's from authenticated # Telegram doesn't have built-in emotes, this field specifies how m.emote's from authenticated
# users are sent to telegram. All fields in message_formats are supported. Additionally, the # users are sent to telegram. All fields in message_formats are supported. Additionally, the
# Telegram user info is available in the following variables: # Telegram user info is available in the following variables:
@@ -373,9 +363,9 @@ bridge:
# #
# Set format to an empty string to disable the messages for that event. # Set format to an empty string to disable the messages for that event.
state_event_formats: state_event_formats:
join: "<b>$displayname</b> joined the room." join: "$distinguisher <b>$displayname</b> joined the room."
leave: "<b>$displayname</b> left the room." leave: "$distinguisher <b>$displayname</b> left the room."
name_change: "<b>$prev_displayname</b> changed their name to <b>$displayname</b>" name_change: "$distinguisher <b>$prev_displayname</b> changed their name to $distinguisher <b>$displayname</b>"
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and # Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
# `filter-mode` management commands. # `filter-mode` management commands.
@@ -20,8 +20,7 @@ import logging
from telethon import TelegramClient from telethon import TelegramClient
from mautrix.types import RoomID, UserID from mautrix.types import RoomID, UserID
from mautrix.util.formatter import MatrixParser as BaseMatrixParser, RecursionContext from mautrix.util.formatter import HTMLNode, MatrixParser as BaseMatrixParser, RecursionContext
from mautrix.util.formatter.html_reader_htmlparser import HTMLNode, read_html
from mautrix.util.logging import TraceLogger from mautrix.util.logging import TraceLogger
from ... import portal as po, puppet as pu, user as u from ... import portal as po, puppet as pu, user as u
@@ -33,7 +32,6 @@ log: TraceLogger = logging.getLogger("mau.fmt.mx")
class MatrixParser(BaseMatrixParser[TelegramMessage]): class MatrixParser(BaseMatrixParser[TelegramMessage]):
e = TelegramEntityType e = TelegramEntityType
fs = TelegramMessage fs = TelegramMessage
read_html = staticmethod(read_html)
client: TelegramClient client: TelegramClient
def __init__(self, client: TelegramClient) -> None: def __init__(self, client: TelegramClient) -> None:
@@ -42,8 +40,8 @@ class MatrixParser(BaseMatrixParser[TelegramMessage]):
async def custom_node_to_fstring( async def custom_node_to_fstring(
self, node: HTMLNode, ctx: RecursionContext self, node: HTMLNode, ctx: RecursionContext
) -> TelegramMessage | None: ) -> TelegramMessage | None:
msg = await self.tag_aware_parse_node(node, ctx)
if node.tag == "command": if node.tag == "command":
msg = await self.tag_aware_parse_node(node, ctx)
return msg.prepend("/").format(TelegramEntityType.COMMAND) return msg.prepend("/").format(TelegramEntityType.COMMAND)
return None return None
@@ -59,7 +57,7 @@ class MatrixParser(BaseMatrixParser[TelegramMessage]):
displayname = user.plain_displayname or msg.text displayname = user.plain_displayname or msg.text
msg = TelegramMessage(displayname) msg = TelegramMessage(displayname)
try: try:
input_entity = self.client.get_input_entity(user.tgid) input_entity = await self.client.get_input_entity(user.tgid)
except (ValueError, TypeError) as e: except (ValueError, TypeError) as e:
log.trace(f"Dropping mention of {user.tgid}: {e}") log.trace(f"Dropping mention of {user.tgid}: {e}")
else: else:
@@ -95,5 +93,8 @@ class MatrixParser(BaseMatrixParser[TelegramMessage]):
async def color_to_fstring(self, msg: TelegramMessage, color: str) -> TelegramMessage: async def color_to_fstring(self, msg: TelegramMessage, color: str) -> TelegramMessage:
return msg return msg
async def spoiler_to_fstring(self, msg: TelegramMessage, spoiler: str) -> TelegramMessage: async def spoiler_to_fstring(self, msg: TelegramMessage, reason: str) -> TelegramMessage:
msg = msg.format(self.e.SPOILER)
if reason:
msg = msg.prepend(f"{reason}: ")
return msg return msg
@@ -29,6 +29,7 @@ from telethon.tl.types import (
MessageEntityMention as Mention, MessageEntityMention as Mention,
MessageEntityMentionName as MentionName, MessageEntityMentionName as MentionName,
MessageEntityPre as Pre, MessageEntityPre as Pre,
MessageEntitySpoiler as Spoiler,
MessageEntityStrike as Strike, MessageEntityStrike as Strike,
MessageEntityTextUrl as TextURL, MessageEntityTextUrl as TextURL,
MessageEntityUnderline as Underline, MessageEntityUnderline as Underline,
@@ -55,6 +56,7 @@ class TelegramEntityType(Enum):
MENTION = Mention MENTION = Mention
MENTION_NAME = InputMentionName MENTION_NAME = InputMentionName
COMMAND = Command COMMAND = Command
SPOILER = Spoiler
USER_MENTION = 1 USER_MENTION = 1
ROOM_MENTION = 2 ROOM_MENTION = 2
+12 -8
View File
@@ -35,6 +35,7 @@ from telethon.tl.types import (
MessageEntityMentionName, MessageEntityMentionName,
MessageEntityPhone, MessageEntityPhone,
MessageEntityPre, MessageEntityPre,
MessageEntitySpoiler,
MessageEntityStrike, MessageEntityStrike,
MessageEntityTextUrl, MessageEntityTextUrl,
MessageEntityUnderline, MessageEntityUnderline,
@@ -93,9 +94,7 @@ async def _add_forward_header(
) )
if not fwd_from_text: if not fwd_from_text:
puppet = await pu.Puppet.get_by_tgid( puppet = await pu.Puppet.get_by_peer(fwd_from.from_id, create=False)
TelegramID(fwd_from.from_id.user_id), create=False
)
if puppet and puppet.displayname: if puppet and puppet.displayname:
fwd_from_text = puppet.displayname or puppet.mxid fwd_from_text = puppet.displayname or puppet.mxid
fwd_from_html = ( fwd_from_html = (
@@ -199,7 +198,7 @@ async def telegram_to_matrix(
def force_html(): def force_html():
if not content.formatted_body: if not content.formatted_body:
content.format = Format.HTML content.format = Format.HTML
content.formatted_body = escape(content.body) content.formatted_body = escape(content.body).replace("\n", "<br/>")
if require_html: if require_html:
force_html() force_html()
@@ -289,10 +288,15 @@ async def _telegram_entities_to_matrix(
skip_entity = await _parse_url( skip_entity = await _parse_url(
html, entity_text, entity.url if entity_type == MessageEntityTextUrl else None html, entity_text, entity.url if entity_type == MessageEntityTextUrl else None
) )
elif entity_type == MessageEntityBotCommand: elif entity_type in (
html.append(f"<font color='blue'>{entity_text}</font>") MessageEntityBotCommand,
elif entity_type in (MessageEntityHashtag, MessageEntityCashtag, MessageEntityPhone): MessageEntityHashtag,
html.append(f"<font color='blue'>{entity_text}</font>") MessageEntityCashtag,
MessageEntityPhone,
):
html.append(f"<font color='#3771bb'>{entity_text}</font>")
elif entity_type == MessageEntitySpoiler:
html.append(f"<span data-mx-spoiler>{entity_text}</span>")
else: else:
skip_entity = True skip_entity = True
last_offset = relative_offset + (0 if skip_entity else entity.length) last_offset = relative_offset + (0 if skip_entity else entity.length)
+29 -7
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan # Copyright (C) 2022 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by # it under the terms of the GNU Affero General Public License as published by
@@ -15,9 +15,8 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, Iterable from typing import TYPE_CHECKING
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY
from mautrix.bridge import BaseMatrixHandler from mautrix.bridge import BaseMatrixHandler
from mautrix.errors import MatrixError from mautrix.errors import MatrixError
from mautrix.types import ( from mautrix.types import (
@@ -28,9 +27,8 @@ from mautrix.types import (
MessageType, MessageType,
PresenceEvent, PresenceEvent,
PresenceState, PresenceState,
ReactionEvent,
ReceiptEvent, ReceiptEvent,
ReceiptEventContent,
ReceiptType,
RedactionEvent, RedactionEvent,
RoomAvatarStateEventContent as AvatarContent, RoomAvatarStateEventContent as AvatarContent,
RoomID, RoomID,
@@ -68,11 +66,19 @@ class MatrixHandler(BaseMatrixHandler):
) -> None: ) -> None:
intent = puppet.default_mxid_intent intent = puppet.default_mxid_intent
self.log.debug(f"{inviter.mxid} invited puppet for {puppet.tgid} to {room_id}") self.log.debug(f"{inviter.mxid} invited puppet for {puppet.tgid} to {room_id}")
if puppet.is_channel:
self.log.debug(f"Rejecting invite for {puppet.tgid} to {room_id}: puppet is a channel")
await intent.leave_room(room_id, reason="Channels can't be invited to chats")
return
if not await inviter.is_logged_in(): if not await inviter.is_logged_in():
await intent.error_and_leave( self.log.debug(f"Rejecting invite for {puppet.tgid} to {room_id}: user not logged in")
room_id, text="Please log in before inviting Telegram puppets." await intent.leave_room(
room_id,
reason="Only users who are logged into the bridge can invite Telegram ghosts.",
) )
return return
portal = await po.Portal.get_by_mxid(room_id) portal = await po.Portal.get_by_mxid(room_id)
if portal: if portal:
if portal.peer_type == "user": if portal.peer_type == "user":
@@ -278,6 +284,20 @@ class MatrixHandler(BaseMatrixHandler):
await portal.handle_matrix_deletion(sender, evt.redacts, evt.event_id) await portal.handle_matrix_deletion(sender, evt.redacts, evt.event_id)
@staticmethod
async def handle_reaction(evt: ReactionEvent) -> None:
sender = await u.User.get_and_start_by_mxid(evt.sender)
if not await sender.has_full_access():
return
portal = await po.Portal.get_by_mxid(evt.room_id)
if not portal or not portal.allow_bridging:
return
await portal.handle_matrix_reaction(
sender, evt.content.relates_to.event_id, evt.content.relates_to.key, evt.event_id
)
@staticmethod @staticmethod
async def handle_power_levels(evt: StateEvent) -> None: async def handle_power_levels(evt: StateEvent) -> None:
portal = await po.Portal.get_by_mxid(evt.room_id) portal = await po.Portal.get_by_mxid(evt.room_id)
@@ -400,6 +420,8 @@ class MatrixHandler(BaseMatrixHandler):
async def handle_event(self, evt: Event) -> None: async def handle_event(self, evt: Event) -> None:
if evt.type == EventType.ROOM_REDACTION: if evt.type == EventType.ROOM_REDACTION:
await self.handle_redaction(evt) await self.handle_redaction(evt)
elif evt.type == EventType.REACTION:
await self.handle_reaction(evt)
async def handle_state_event(self, evt: StateEvent) -> None: async def handle_state_event(self, evt: StateEvent) -> None:
if evt.type == EventType.ROOM_POWER_LEVELS: if evt.type == EventType.ROOM_POWER_LEVELS:
+663 -171
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,5 +2,5 @@ from .deduplication import PortalDedup
from .media_fallback import make_contact_event_content, make_dice_event_content from .media_fallback import make_contact_event_content, make_dice_event_content
from .participants import get_users from .participants import get_users
from .power_levels import get_base_power_levels, participants_to_power_levels from .power_levels import get_base_power_levels, participants_to_power_levels
from .send_lock import PortalSendLock from .send_lock import PortalReactionLock, PortalSendLock
from .sponsored_message import get_sponsored_message, make_sponsored_message_content from .sponsored_message import get_sponsored_message, make_sponsored_message_content
+73 -49
View File
@@ -15,20 +15,30 @@
# along with this program. If not, see <https://www.gnu.org/licenses/>. # along with this program. If not, see <https://www.gnu.org/licenses/>.
from __future__ import annotations from __future__ import annotations
from typing import Tuple from typing import Any, Generator, Tuple, Union
from collections import deque from collections import deque
import hashlib import hashlib
from telethon.tl.patched import Message, MessageService from telethon.tl.patched import Message, MessageService
from telethon.tl.types import ( from telethon.tl.types import (
Message,
MessageMediaContact, MessageMediaContact,
MessageMediaDice,
MessageMediaDocument, MessageMediaDocument,
MessageMediaGame,
MessageMediaGeo, MessageMediaGeo,
MessageMediaPhoto, MessageMediaPhoto,
TypeMessage, MessageMediaPoll,
MessageMediaUnsupported,
MessageService,
PeerChannel,
PeerChat,
PeerUser,
TypeUpdates, TypeUpdates,
UpdateNewChannelMessage, UpdateNewChannelMessage,
UpdateNewMessage, UpdateNewMessage,
UpdateShortChatMessage,
UpdateShortMessage,
) )
from mautrix.types import EventID from mautrix.types import EventID
@@ -37,60 +47,67 @@ from .. import portal as po
from ..types import TelegramID from ..types import TelegramID
DedupMXID = Tuple[EventID, TelegramID] DedupMXID = Tuple[EventID, TelegramID]
TypeMessage = Union[Message, MessageService, UpdateShortMessage, UpdateShortChatMessage]
media_content_table = {
MessageMediaContact: lambda media: [media.user_id],
MessageMediaDocument: lambda media: [media.document.id],
MessageMediaPhoto: lambda media: [media.photo.id if media.photo else 0],
MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat],
MessageMediaGame: lambda media: [media.game.id],
MessageMediaPoll: lambda media: [media.poll.id],
MessageMediaDice: lambda media: [media.value, media.emoticon],
MessageMediaUnsupported: lambda media: ["unsupported media"],
}
class PortalDedup: class PortalDedup:
pre_db_check: bool = False pre_db_check: bool = False
cache_queue_length: int = 20 cache_queue_length: int = 256
_dedup: deque[str] _dedup: deque[bytes | int]
_dedup_mxid: dict[str, DedupMXID] _dedup_mxid: dict[bytes | int, DedupMXID]
_dedup_action: deque[str] _dedup_action: deque[bytes | int]
_portal: po.Portal _portal: po.Portal
def __init__(self, portal: po.Portal) -> None: def __init__(self, portal: po.Portal) -> None:
self._dedup = deque() self._dedup = deque()
self._dedup_mxid = {} self._dedup_mxid = {}
self._dedup_action = deque() self._dedup_action = deque(maxlen=self.cache_queue_length)
self._portal = portal self._portal = portal
@property @property
def _always_force_hash(self) -> bool: def _always_force_hash(self) -> bool:
return self._portal.peer_type == "chat" return self._portal.peer_type == "chat"
@staticmethod def _hash_content(self, event: TypeMessage) -> Generator[Any, None, None]:
def _hash_event(event: TypeMessage) -> str: if not self._always_force_hash:
# Non-channel messages are unique per-user (wtf telegram), so we have no other choice than yield event.id
# to deduplicate based on a hash of the message content. yield int(event.date.timestamp())
# The timestamp is only accurate to the second, so we can't rely solely on that either.
if isinstance(event, MessageService): if isinstance(event, MessageService):
hash_content = [event.date.timestamp(), event.from_id, event.action] yield event.from_id
yield event.action
else: else:
hash_content = [event.date.timestamp(), event.message.strip()] yield event.message.strip()
if event.fwd_from: if event.fwd_from:
hash_content += [event.fwd_from.from_id] yield event.fwd_from.from_id
elif isinstance(event, Message) and event.media: if isinstance(event, Message) and event.media:
try: media_hash_func = media_content_table.get(type(event.media)) or (
hash_content += { lambda media: ["unknown media"]
MessageMediaContact: lambda media: [media.user_id], )
MessageMediaDocument: lambda media: [media.document.id], yield media_hash_func(event.media)
MessageMediaPhoto: lambda media: [media.photo.id if media.photo else 0],
MessageMediaGeo: lambda media: [media.geo.long, media.geo.lat], def _hash_event(self, event: TypeMessage) -> bytes:
}[type(event.media)](event.media) return hashlib.sha256(
except KeyError: "-".join(str(a) for a in self._hash_content(event)).encode("utf-8")
pass ).digest()
return hashlib.md5("-".join(str(a) for a in hash_content).encode("utf-8")).hexdigest()
def check_action(self, event: TypeMessage) -> bool: def check_action(self, event: TypeMessage) -> bool:
evt_hash = self._hash_event(event) if self._always_force_hash else event.id dedup_id = self._hash_event(event) if self._always_force_hash else event.id
if evt_hash in self._dedup_action: if dedup_id in self._dedup_action:
return True return True
self._dedup_action.append(evt_hash) self._dedup_action.appendleft(dedup_id)
if len(self._dedup_action) > self.cache_queue_length:
self._dedup_action.popleft()
return False return False
def update( def update(
@@ -99,31 +116,38 @@ class PortalDedup:
mxid: DedupMXID = None, mxid: DedupMXID = None,
expected_mxid: DedupMXID | None = None, expected_mxid: DedupMXID | None = None,
force_hash: bool = False, force_hash: bool = False,
) -> DedupMXID | None: ) -> tuple[bytes, DedupMXID | None]:
evt_hash = self._hash_event(event) if self._always_force_hash or force_hash else event.id evt_hash = self._hash_event(event)
dedup_id = evt_hash if self._always_force_hash or force_hash else event.id
try: try:
found_mxid = self._dedup_mxid[evt_hash] found_mxid = self._dedup_mxid[dedup_id]
except KeyError: except KeyError:
return EventID("None"), TelegramID(0) return evt_hash, None
if found_mxid != expected_mxid: if found_mxid != expected_mxid:
return found_mxid return evt_hash, found_mxid
self._dedup_mxid[evt_hash] = mxid self._dedup_mxid[dedup_id] = mxid
return None if evt_hash != dedup_id:
self._dedup_mxid[evt_hash] = mxid
return evt_hash, None
def check( def check(
self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False self, event: TypeMessage, mxid: DedupMXID = None, force_hash: bool = False
) -> DedupMXID | None: ) -> tuple[bytes, DedupMXID | None]:
evt_hash = self._hash_event(event) if self._always_force_hash or force_hash else event.id evt_hash = self._hash_event(event)
if evt_hash in self._dedup: dedup_id = evt_hash if self._always_force_hash or force_hash else event.id
return self._dedup_mxid[evt_hash] if dedup_id in self._dedup:
return evt_hash, self._dedup_mxid[dedup_id]
self._dedup_mxid[evt_hash] = mxid self._dedup_mxid[dedup_id] = mxid
self._dedup.append(evt_hash) self._dedup.appendleft(dedup_id)
if evt_hash != dedup_id:
self._dedup_mxid[evt_hash] = mxid
self._dedup.appendleft(evt_hash)
if len(self._dedup) > self.cache_queue_length: while len(self._dedup) > self.cache_queue_length:
del self._dedup_mxid[self._dedup.popleft()] del self._dedup_mxid[self._dedup.pop()]
return None return evt_hash, None
def register_outgoing_actions(self, response: TypeUpdates) -> None: def register_outgoing_actions(self, response: TypeUpdates) -> None:
for update in response.updates: for update in response.updates:
+13
View File
@@ -16,6 +16,9 @@
from __future__ import annotations from __future__ import annotations
from asyncio import Lock from asyncio import Lock
from collections import defaultdict
from mautrix.types import EventID
from ..types import TelegramID from ..types import TelegramID
@@ -42,3 +45,13 @@ class PortalSendLock:
return self._send_locks[user_id] return self._send_locks[user_id]
except KeyError: except KeyError:
return self._send_locks.setdefault(user_id, Lock()) if required else self._noop_lock return self._send_locks.setdefault(user_id, Lock()) if required else self._noop_lock
class PortalReactionLock:
_reaction_locks: dict[EventID, Lock]
def __init__(self) -> None:
self._reaction_locks = defaultdict(lambda: Lock())
def __call__(self, mxid: EventID) -> Lock:
return self._reaction_locks[mxid]
+117 -49
View File
@@ -1,5 +1,5 @@
# mautrix-telegram - A Matrix-Telegram puppeting bridge # mautrix-telegram - A Matrix-Telegram puppeting bridge
# Copyright (C) 2021 Tulir Asokan # Copyright (C) 2022 Tulir Asokan
# #
# This program is free software: you can redistribute it and/or modify # This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by # it under the terms of the GNU Affero General Public License as published by
@@ -20,10 +20,18 @@ from difflib import SequenceMatcher
import unicodedata import unicodedata
from telethon.tl.types import ( from telethon.tl.types import (
Channel,
ChatPhoto,
ChatPhotoEmpty,
InputPeerPhotoFileLocation, InputPeerPhotoFileLocation,
PeerChannel,
PeerChat,
PeerUser, PeerUser,
TypeChatPhoto,
TypeInputPeer, TypeInputPeer,
TypeInputUser, TypeInputUser,
TypePeer,
TypeUserProfilePhoto,
UpdateUserName, UpdateUserName,
User, User,
UserProfilePhoto, UserProfilePhoto,
@@ -33,7 +41,6 @@ from yarl import URL
from mautrix.appservice import IntentAPI from mautrix.appservice import IntentAPI
from mautrix.bridge import BasePuppet, async_getter_lock from mautrix.bridge import BasePuppet, async_getter_lock
from mautrix.errors import MatrixError
from mautrix.types import ContentURI, RoomID, SyncToken, UserID from mautrix.types import ContentURI, RoomID, SyncToken, UserID
from mautrix.util.simple_template import SimpleTemplate from mautrix.util.simple_template import SimpleTemplate
@@ -66,7 +73,11 @@ class Puppet(DBPuppet, BasePuppet):
disable_updates: bool = False, disable_updates: bool = False,
username: str | None = None, username: str | None = None,
photo_id: str | None = None, photo_id: str | None = None,
avatar_url: ContentURI | None = None,
name_set: bool = False,
avatar_set: bool = False,
is_bot: bool = False, is_bot: bool = False,
is_channel: bool = False,
custom_mxid: UserID | None = None, custom_mxid: UserID | None = None,
access_token: str | None = None, access_token: str | None = None,
next_batch: SyncToken | None = None, next_batch: SyncToken | None = None,
@@ -82,7 +93,11 @@ class Puppet(DBPuppet, BasePuppet):
disable_updates=disable_updates, disable_updates=disable_updates,
username=username, username=username,
photo_id=photo_id, photo_id=photo_id,
avatar_url=avatar_url,
name_set=name_set,
avatar_set=avatar_set,
is_bot=is_bot, is_bot=is_bot,
is_channel=is_channel,
custom_mxid=custom_mxid, custom_mxid=custom_mxid,
access_token=access_token, access_token=access_token,
next_batch=next_batch, next_batch=next_batch,
@@ -109,7 +124,9 @@ class Puppet(DBPuppet, BasePuppet):
@property @property
def peer(self) -> PeerUser: def peer(self) -> PeerUser:
return PeerUser(user_id=self.tgid) return (
PeerChannel(channel_id=self.tgid) if self.is_channel else PeerUser(user_id=self.tgid)
)
@property @property
def plain_displayname(self) -> str: def plain_displayname(self) -> str:
@@ -185,9 +202,12 @@ class Puppet(DBPuppet, BasePuppet):
return name return name
@classmethod @classmethod
def get_displayname(cls, info: User, enable_format: bool = True) -> tuple[str, int]: def get_displayname(cls, info: User | Channel, enable_format: bool = True) -> tuple[str, int]:
fn = cls._filter_name(info.first_name) if isinstance(info, Channel):
ln = cls._filter_name(info.last_name) fn, ln = cls._filter_name(info.title), ""
else:
fn = cls._filter_name(info.first_name)
ln = cls._filter_name(info.last_name)
data = { data = {
"phone number": info.phone if hasattr(info, "phone") else None, "phone number": info.phone if hasattr(info, "phone") else None,
"username": info.username, "username": info.username,
@@ -214,14 +234,20 @@ class Puppet(DBPuppet, BasePuppet):
return (cls.displayname_template.format_full(name) if enable_format else name), quality return (cls.displayname_template.format_full(name) if enable_format else name), quality
async def try_update_info(self, source: au.AbstractUser, info: User) -> None: async def try_update_info(self, source: au.AbstractUser, info: User | Channel) -> None:
try: try:
await self.update_info(source, info) await self.update_info(source, info)
except Exception: except Exception:
source.log.exception(f"Failed to update info of {self.tgid}") source.log.exception(f"Failed to update info of {self.tgid}")
async def update_info(self, source: au.AbstractUser, info: User) -> None: async def update_info(self, source: au.AbstractUser, info: User | Channel) -> None:
changed = False is_bot = False if isinstance(info, Channel) else info.bot
is_channel = isinstance(info, Channel)
changed = is_bot != self.is_bot or is_channel != self.is_channel
self.is_bot = is_bot
self.is_channel = is_channel
if self.username != info.username: if self.username != info.username:
self.username = info.username self.username = info.username
changed = True changed = True
@@ -233,32 +259,46 @@ class Puppet(DBPuppet, BasePuppet):
except Exception: except Exception:
self.log.exception(f"Failed to update info from source {source.tgid}") self.log.exception(f"Failed to update info from source {source.tgid}")
self.is_bot = info.bot
if changed: if changed:
await self.update_portals_meta()
await self.save() await self.save()
async def update_portals_meta(self) -> None:
if not p.Portal.private_chat_portal_meta and not self.mx.e2ee:
return
async for portal in p.Portal.find_private_chats_with(self.tgid):
await portal.update_info_from_puppet(self)
async def update_displayname( async def update_displayname(
self, source: au.AbstractUser, info: User | UpdateUserName self, source: au.AbstractUser, info: User | Channel | UpdateUserName
) -> bool: ) -> bool:
if self.disable_updates: if self.disable_updates:
return False return False
if source.is_relaybot or source.is_bot: if (
allow_because = "user is bot" self.displayname
and self.displayname.startswith("Deleted user ")
and not getattr(info, "deleted", False)
):
allow_because = "target user was previously deleted"
self.displayname_quality = 0
elif source.is_relaybot or source.is_bot:
allow_because = "source user is a bot"
elif self.displayname_source == source.tgid: elif self.displayname_source == source.tgid:
allow_because = "user is the primary source" allow_because = "source user is the primary source"
elif isinstance(info, Channel):
allow_because = "target user is a channel"
elif not isinstance(info, UpdateUserName) and not info.contact: elif not isinstance(info, UpdateUserName) and not info.contact:
allow_because = "user is not a contact" allow_because = "target user is not a contact"
elif not self.displayname_source: elif not self.displayname_source:
allow_because = "no primary source set" allow_because = "no primary source set"
elif not self.displayname: elif not self.displayname:
allow_because = "user has no name" allow_because = "target user has no name"
else: else:
return False return False
if isinstance(info, UpdateUserName): if isinstance(info, UpdateUserName):
info = await source.client.get_entity(PeerUser(self.tgid)) info = await source.client.get_entity(self.peer)
if not info.contact: if isinstance(info, Channel) or not info.contact:
self.displayname_contact = False self.displayname_contact = False
elif not self.displayname_contact: elif not self.displayname_contact:
if not self.displayname: if not self.displayname:
@@ -267,7 +307,9 @@ class Puppet(DBPuppet, BasePuppet):
return False return False
displayname, quality = self.get_displayname(info) displayname, quality = self.get_displayname(info)
if displayname != self.displayname and quality >= self.displayname_quality: needs_reset = displayname != self.displayname or not self.name_set
is_high_quality = quality >= self.displayname_quality
if needs_reset and is_high_quality:
allow_because = f"{allow_because} and quality {quality} >= {self.displayname_quality}" allow_because = f"{allow_because} and quality {quality} >= {self.displayname_quality}"
self.log.debug( self.log.debug(
f"Updating displayname of {self.id} (src: {source.tgid}, allowed " f"Updating displayname of {self.id} (src: {source.tgid}, allowed "
@@ -281,11 +323,10 @@ class Puppet(DBPuppet, BasePuppet):
await self.default_mxid_intent.set_displayname( await self.default_mxid_intent.set_displayname(
displayname[: self.config["bridge.displayname_max_length"]] displayname[: self.config["bridge.displayname_max_length"]]
) )
except MatrixError: self.name_set = True
self.log.exception("Failed to set displayname") except Exception as e:
self.displayname = "" self.log.warning(f"Failed to set displayname: {e}")
self.displayname_source = None self.name_set = False
self.displayname_quality = 0
return True return True
elif source.is_relaybot or self.displayname_source is None: elif source.is_relaybot or self.displayname_source is None:
self.displayname_source = source.tgid self.displayname_source = source.tgid
@@ -293,42 +334,43 @@ class Puppet(DBPuppet, BasePuppet):
return False return False
async def update_avatar( async def update_avatar(
self, source: au.AbstractUser, photo: UserProfilePhoto | UserProfilePhotoEmpty self, source: au.AbstractUser, photo: TypeUserProfilePhoto | TypeChatPhoto
) -> bool: ) -> bool:
if self.disable_updates: if self.disable_updates:
return False return False
if photo is None or isinstance(photo, UserProfilePhotoEmpty): if photo is None or isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty)):
photo_id = "" photo_id = ""
elif isinstance(photo, UserProfilePhoto): elif isinstance(photo, (UserProfilePhoto, ChatPhoto)):
photo_id = str(photo.photo_id) photo_id = str(photo.photo_id)
else: else:
self.log.warning(f"Unknown user profile photo type: {type(photo)}") self.log.warning(f"Unknown user profile photo type: {type(photo)}")
return False return False
if not photo_id and not self.config["bridge.allow_avatar_remove"]: if not photo_id and not self.config["bridge.allow_avatar_remove"]:
return False return False
if self.photo_id != photo_id: if self.photo_id != photo_id or not self.avatar_set:
if not photo_id: if not photo_id:
self.photo_id = "" self.photo_id = ""
try: self.avatar_url = None
await self.default_mxid_intent.set_avatar_url(ContentURI("")) elif self.photo_id != photo_id or not self.avatar_url:
except MatrixError: file = await util.transfer_file_to_matrix(
self.log.exception("Failed to set avatar") client=source.client,
self.photo_id = "" intent=self.default_mxid_intent,
return True location=InputPeerPhotoFileLocation(
peer=await self.get_input_entity(source), photo_id=photo.photo_id, big=True
loc = InputPeerPhotoFileLocation( ),
peer=await self.get_input_entity(source), photo_id=photo.photo_id, big=True )
) if not file:
file = await util.transfer_file_to_matrix(source.client, self.default_mxid_intent, loc) return False
if file:
self.photo_id = photo_id self.photo_id = photo_id
try: self.avatar_url = file.mxc
await self.default_mxid_intent.set_avatar_url(file.mxc) try:
except MatrixError: await self.default_mxid_intent.set_avatar_url(self.avatar_url or "")
self.log.exception("Failed to set avatar") self.avatar_set = True
self.photo_id = "" except Exception as e:
return True self.log.warning(f"Failed to set avatar: {e}")
self.avatar_set = False
return True
return False return False
async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool: async def default_puppet_should_leave_room(self, room_id: RoomID) -> bool:
@@ -345,7 +387,9 @@ class Puppet(DBPuppet, BasePuppet):
@classmethod @classmethod
@async_getter_lock @async_getter_lock
async def get_by_tgid(cls, tgid: TelegramID, *, create: bool = True) -> Puppet | None: async def get_by_tgid(
cls, tgid: TelegramID, *, create: bool = True, is_channel: bool = False
) -> Puppet | None:
if tgid is None: if tgid is None:
return None return None
@@ -360,13 +404,37 @@ class Puppet(DBPuppet, BasePuppet):
return puppet return puppet
if create: if create:
puppet = cls(tgid) puppet = cls(tgid, is_channel=is_channel)
await puppet.insert() await puppet.insert()
puppet._add_to_cache() puppet._add_to_cache()
return puppet return puppet
return None return None
@staticmethod
def get_id_from_peer(peer: TypePeer | User | Channel) -> TelegramID:
if isinstance(peer, PeerUser):
return TelegramID(peer.user_id)
elif isinstance(peer, PeerChannel):
return TelegramID(peer.channel_id)
elif isinstance(peer, PeerChat):
return TelegramID(peer.chat_id)
elif isinstance(peer, (User, Channel)):
return TelegramID(peer.id)
raise TypeError(f"invalid type {type(peer).__name__!r} in _id_from_peer()")
@classmethod
async def get_by_peer(
cls, peer: TypePeer | User | Channel, *, create: bool = True
) -> Puppet | None:
if isinstance(peer, PeerChat):
return None
return await cls.get_by_tgid(
cls.get_id_from_peer(peer),
create=create,
is_channel=isinstance(peer, (PeerChannel, Channel)),
)
@classmethod @classmethod
def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Awaitable[Puppet | None]: def get_by_mxid(cls, mxid: UserID, create: bool = True) -> Awaitable[Puppet | None]:
return cls.get_by_tgid(cls.get_id_from_mxid(mxid), create=create) return cls.get_by_tgid(cls.get_id_from_mxid(mxid), create=create)
+1 -1
View File
@@ -40,7 +40,7 @@ class MautrixTelegramClient(TelegramClient):
mime_type: str = None, mime_type: str = None,
attributes: List[TypeDocumentAttribute] = None, attributes: List[TypeDocumentAttribute] = None,
file_name: str = None, file_name: str = None,
max_image_size: float = 10 * 1000 ** 2, max_image_size: float = 10 * 1000**2,
) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]: ) -> Union[InputMediaUploadedDocument, InputMediaUploadedPhoto]:
file_handle = await super().upload_file(file, file_name=file_name) file_handle = await super().upload_file(file, file_name=file_name)
+5 -4
View File
@@ -195,7 +195,8 @@ class User(DBUser, AbstractUser, BaseUser):
if self.tgid: if self.tgid:
await self.push_bridge_state(BridgeStateEvent.UNKNOWN_ERROR, message=str(e)) await self.push_bridge_state(BridgeStateEvent.UNKNOWN_ERROR, message=str(e))
except UnauthorizedError as e: except UnauthorizedError as e:
self.log.error(f"Authorization error in start(): {type(e)}: {e}") if delete_unless_authenticated or self.tgid:
self.log.error(f"Authorization error in start(): {type(e)}: {e}")
if self.tgid: if self.tgid:
await self.push_bridge_state( await self.push_bridge_state(
BridgeStateEvent.BAD_CREDENTIALS, BridgeStateEvent.BAD_CREDENTIALS,
@@ -240,7 +241,7 @@ class User(DBUser, AbstractUser, BaseUser):
) )
else: else:
await self.push_bridge_state( await self.push_bridge_state(
BridgeStateEvent.UNKNOWN_ERROR, ttl=240, error="tg-not-connected" BridgeStateEvent.TRANSIENT_DISCONNECT, ttl=240, error="tg-not-connected"
) )
async def fill_bridge_state(self, state: BridgeState) -> None: async def fill_bridge_state(self, state: BridgeState) -> None:
@@ -448,7 +449,7 @@ class User(DBUser, AbstractUser, BaseUser):
async def get_direct_chats(self) -> dict[UserID, list[RoomID]]: async def get_direct_chats(self) -> dict[UserID, list[RoomID]]:
return { return {
pu.Puppet.get_mxid_from_id(portal.tgid): [portal.mxid] pu.Puppet.get_mxid_from_id(portal.tgid): [portal.mxid]
async for portal in po.Portal.find_private_chats(self.tgid) async for portal in po.Portal.find_private_chats_of(self.tgid)
if portal.mxid if portal.mxid
} }
@@ -655,10 +656,10 @@ class User(DBUser, AbstractUser, BaseUser):
if self.saved_contacts != response.saved_count: if self.saved_contacts != response.saved_count:
self.saved_contacts = response.saved_count self.saved_contacts = response.saved_count
await self.save() await self.save()
await self.set_contacts(user.id for user in response.users)
for user in response.users: for user in response.users:
puppet = await pu.Puppet.get_by_tgid(user.id) puppet = await pu.Puppet.get_by_tgid(user.id)
await puppet.update_info(self, user) await puppet.update_info(self, user)
await self.set_contacts(user.id for user in response.users)
# endregion # endregion
# region Class instance lookup # region Class instance lookup
+50 -97
View File
@@ -19,12 +19,15 @@ from __future__ import annotations
from typing import Any, Awaitable, Callable from typing import Any, Awaitable, Callable
import asyncio.subprocess import asyncio.subprocess
import logging import logging
import os
import os.path import os.path
import shutil import shutil
import tempfile import tempfile
from attr import dataclass from attr import dataclass
from mautrix.util import ffmpeg
log: logging.Logger = logging.getLogger("mau.util.tgs") log: logging.Logger = logging.getLogger("mau.util.tgs")
@@ -48,61 +51,49 @@ def abswhich(program: str | None) -> str | None:
lottieconverter = abswhich("lottieconverter") lottieconverter = abswhich("lottieconverter")
ffmpeg = abswhich("ffmpeg")
async def _run_lottieconverter(args: tuple[str, ...], input_data: bytes) -> bytes:
proc = await asyncio.create_subprocess_exec(
lottieconverter,
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate(input_data)
if proc.returncode == 0:
return stdout
else:
err_text = stderr.decode("utf-8") if stderr else f"unknown ({proc.returncode})"
raise ffmpeg.ConverterError(f"lottieconverter error: {err_text}")
if lottieconverter: if lottieconverter:
async def tgs_to_png(file: bytes, width: int, height: int, **_: Any) -> ConvertedSticker: async def tgs_to_png(file: bytes, width: int, height: int, **_: Any) -> ConvertedSticker:
frame = 1 frame = 1
proc = await asyncio.create_subprocess_exec( try:
lottieconverter, converted_png = await _run_lottieconverter(
"-", args=("-", "-", "png", f"{width}x{height}", str(frame)),
"-", input_data=file,
"png",
f"{width}x{height}",
str(frame),
stdout=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate(file)
if proc.returncode == 0:
return ConvertedSticker("image/png", stdout)
else:
log.error(
"lottieconverter error: "
+ (
stderr.decode("utf-8")
if stderr is not None
else f"unknown ({proc.returncode})"
)
) )
return ConvertedSticker("image/png", converted_png)
except ffmpeg.ConverterError as e:
log.error(str(e))
return ConvertedSticker("application/gzip", file) return ConvertedSticker("application/gzip", file)
async def tgs_to_gif( async def tgs_to_gif(
file: bytes, width: int, height: int, fps: int = 25, **_: Any file: bytes, width: int, height: int, fps: int = 25, **_: Any
) -> ConvertedSticker: ) -> ConvertedSticker:
proc = await asyncio.create_subprocess_exec( try:
lottieconverter, converted_gif = await _run_lottieconverter(
"-", args=("-", "-", "gif", f"{width}x{height}", str(fps)),
"-", input_data=file,
"gif",
f"{width}x{height}",
str(fps),
stdout=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate(file)
if proc.returncode == 0:
return ConvertedSticker("image/gif", stdout)
else:
log.error(
"lottieconverter error: "
+ (
stderr.decode("utf-8")
if stderr is not None
else f"unknown ({proc.returncode})"
)
) )
return ConvertedSticker("image/gif", converted_gif)
except ffmpeg.ConverterError as e:
log.error(str(e))
return ConvertedSticker("application/gzip", file) return ConvertedSticker("application/gzip", file)
converters["png"] = tgs_to_png converters["png"] = tgs_to_png
@@ -115,62 +106,24 @@ if lottieconverter and ffmpeg:
) -> ConvertedSticker: ) -> ConvertedSticker:
with tempfile.TemporaryDirectory(prefix="tgs_") as tmpdir: with tempfile.TemporaryDirectory(prefix="tgs_") as tmpdir:
file_template = tmpdir + "/out_" file_template = tmpdir + "/out_"
proc = await asyncio.create_subprocess_exec( try:
lottieconverter, await _run_lottieconverter(
"-", args=("-", file_template, "pngs", f"{width}x{height}", str(fps)),
file_template, input_data=file,
"pngs", )
f"{width}x{height}", first_frame_name = min(os.listdir(tmpdir))
str(fps), with open(f"{tmpdir}/{first_frame_name}", "rb") as first_frame_file:
stdout=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate(file)
if proc.returncode == 0:
with open(f"{file_template}00.png", "rb") as first_frame_file:
first_frame_data = first_frame_file.read() first_frame_data = first_frame_file.read()
proc = await asyncio.create_subprocess_exec( webm_data = await ffmpeg.convert_path(
ffmpeg, input_args=("-framerate", str(fps), "-pattern_type", "glob"),
"-hide_banner", input_file=f"{file_template}*.png",
"-loglevel", output_args=("-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-f", "webm"),
"error", output_path_override="-",
"-framerate", output_extension=None,
str(fps),
"-pattern_type",
"glob",
"-i",
file_template + "*.png",
"-c:v",
"libvpx-vp9",
"-pix_fmt",
"yuva420p",
"-f",
"webm",
"-",
stdout=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode == 0:
return ConvertedSticker("video/webm", stdout, "image/png", first_frame_data)
else:
log.error(
"ffmpeg error: "
+ (
stderr.decode("utf-8")
if stderr is not None
else f"unknown ({proc.returncode})"
)
)
else:
log.error(
"lottieconverter error: "
+ (
stderr.decode("utf-8")
if stderr is not None
else f"unknown ({proc.returncode})"
)
) )
return ConvertedSticker("video/webm", webm_data, "image/png", first_frame_data)
except ffmpeg.ConverterError as e:
log.error(str(e))
return ConvertedSticker("application/gzip", file) return ConvertedSticker("application/gzip", file)
converters["webm"] = tgs_to_webm converters["webm"] = tgs_to_webm
+20 -6
View File
@@ -126,8 +126,10 @@ class AuthAPI(abc.ABC):
mxid=user.mxid, mxid=user.mxid,
state="code", state="code",
status=200, status=200,
message="Code requested successfully. Check your SMS " message=(
"or Telegram client and enter the code below.", "Code requested successfully. Check your SMS "
"or Telegram client and enter the code below."
),
) )
except PhoneNumberInvalidError: except PhoneNumberInvalidError:
return self.get_login_response( return self.get_login_response(
@@ -167,8 +169,10 @@ class AuthAPI(abc.ABC):
state="request", state="request",
status=429, status=429,
errcode="phone_number_flood", errcode="phone_number_flood",
error="Your phone number has been temporarily blocked for flooding. " error=(
"The ban is usually applied for around a day.", "Your phone number has been temporarily blocked for flooding. "
"The ban is usually applied for around a day."
),
) )
except FloodWaitError as e: except FloodWaitError as e:
return self.get_login_response( return self.get_login_response(
@@ -176,8 +180,10 @@ class AuthAPI(abc.ABC):
state="request", state="request",
status=429, status=429,
errcode="flood_wait", errcode="flood_wait",
error="Your phone number has been temporarily blocked for flooding. " error=(
f"Please wait for {format_duration(e.seconds)} before trying again.", "Your phone number has been temporarily blocked for flooding. "
f"Please wait for {format_duration(e.seconds)} before trying again."
),
) )
except Exception: except Exception:
self.log.exception("Error requesting phone code") self.log.exception("Error requesting phone code")
@@ -237,6 +243,14 @@ class AuthAPI(abc.ABC):
async def post_login_code( async def post_login_code(
self, user: User, code: int, password_in_data: bool self, user: User, code: int, password_in_data: bool
) -> web.Response | None: ) -> web.Response | None:
if not code:
return self.get_login_response(
mxid=user.mxid,
state="code",
status=400,
errcode="phone_code_missing",
error="You must provide the code from your phone.",
)
try: try:
user_info = await user.client.sign_in(code=code) user_info = await user.client.sign_in(code=code)
await self.postprocess_login(user, user_info) await self.postprocess_login(user, user_info)
+2 -2
View File
@@ -8,7 +8,7 @@ aiodns
brotli brotli
#/qr_login #/qr_login
pillow>=4,<9 pillow>=4,<10
qrcode>=6,<8 qrcode>=6,<8
#/hq_thumbnails #/hq_thumbnails
@@ -18,7 +18,7 @@ moviepy>=1,<2
phonenumbers>=8,<9 phonenumbers>=8,<9
#/metrics #/metrics
prometheus_client>=0.6,<0.13 prometheus_client>=0.6,<0.14
#/e2be #/e2be
python-olm>=3,<4 python-olm>=3,<4
+1 -1
View File
@@ -9,4 +9,4 @@ line_length = 99
[tool.black] [tool.black]
line-length = 99 line-length = 99
target-version = ["py38"] target-version = ["py38"]
required-version = "21.12b0" required-version = "22.1.0"
+3 -3
View File
@@ -3,10 +3,10 @@ python-magic>=0.4,<0.5
commonmark>=0.8,<0.10 commonmark>=0.8,<0.10
aiohttp>=3,<4 aiohttp>=3,<4
yarl>=1,<2 yarl>=1,<2
mautrix>=0.14.0,<0.15 mautrix>=0.14.9,<0.15
#telethon>=1.24,<1.25 #telethon>=1.24,<1.25
# Fork to make session storage async # Fork to make session storage async and update to layer 138
tulir-telethon==1.25.0a1 tulir-telethon==1.25.0a5
asyncpg>=0.20,<0.26 asyncpg>=0.20,<0.26
mako>=1,<2 mako>=1,<2
setuptools setuptools
-1
View File
@@ -1,5 +1,4 @@
import setuptools import setuptools
import glob
from mautrix_telegram.get_version import git_tag, git_revision, version, linkified_version from mautrix_telegram.get_version import git_tag, git_revision, version, linkified_version