Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4504973aff | |||
| a5a71edede | |||
| e1c800f3e6 | |||
| 810f86343a | |||
| 5f7d3ac8c1 | |||
| cb5c51cd27 | |||
| 759ccf301c | |||
| 40e4c7e251 | |||
| e12f1784e2 | |||
| 6b8e265f8b | |||
| de33b553be | |||
| ed24a0b89f | |||
| e2697e5a17 | |||
| c4037ccf11 | |||
| 6c6fe134ba | |||
| e3c45f6f27 | |||
| 732258c093 | |||
| 8726fa5d74 | |||
| da61ba96f1 | |||
| 815ce40989 | |||
| 4ff6a62dab | |||
| 918582c967 | |||
| 40c584b121 | |||
| f189dc8c88 | |||
| b291c246f4 | |||
| 59ab7be283 | |||
| 60981386ec | |||
| 436781215f | |||
| 9c4b24475c | |||
| ff8d1fc9ec | |||
| 5f04729ce8 | |||
| 60526f981a | |||
| e39d4972fb | |||
| 233468b37b | |||
| 6eda8bd165 | |||
| 7372e7cbea | |||
| 1fed2201db |
@@ -1,3 +1,35 @@
|
||||
# v0.14.0 (2023-05-26)
|
||||
|
||||
### Added
|
||||
* Added fallback messages for calls and premium gifts.
|
||||
* Added options to automatically ratchet/delete megolm sessions to minimize
|
||||
access to old messages.
|
||||
* Added option to not set room name/avatar even in encrypted rooms.
|
||||
* Implemented appservice pinging using MSC2659.
|
||||
* Added option to disable or filter bridging direct chats
|
||||
(thanks to [@Steffo99] in [#892]).
|
||||
* Added options to specify different limits for forward and catchup backfilling
|
||||
depending on chat type.
|
||||
|
||||
### Improved
|
||||
* Improved handling logouts and certain connection errors.
|
||||
* Changed reaction bridging to preserve timestamps.
|
||||
* Disabled creating portals for DMs that don't have any messages when
|
||||
`sync_direct_chats` is enabled.
|
||||
|
||||
### Fixed
|
||||
* Fixed syncing mute status when portal is created through incoming message
|
||||
rather than in startup sync.
|
||||
* Fixed bridge incorrectly trusting member list and kicking users when
|
||||
supergroup has member list hidden.
|
||||
* Fixed sending messages after creating groups from Matrix using relaybot
|
||||
instead of puppet (thanks to [@maltee1] in [#902]).
|
||||
|
||||
[@Steffo99]: https://github.com/Steffo99
|
||||
[@maltee1]: https://github.com/maltee1
|
||||
[#892]: https://github.com/mautrix/telegram/pull/892
|
||||
[#902]: https://github.com/mautrix/telegram/pull/902
|
||||
|
||||
# v0.13.0 (2023-02-26)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "0.13.0"
|
||||
__version__ = "0.14.0"
|
||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
||||
|
||||
@@ -39,6 +39,8 @@ from .abstract_user import AbstractUser # isort: skip
|
||||
class TelegramBridge(Bridge):
|
||||
module = "mautrix_telegram"
|
||||
name = "mautrix-telegram"
|
||||
beeper_service_name = "telegram"
|
||||
beeper_network_name = "telegram"
|
||||
command = "python -m mautrix-telegram"
|
||||
description = "A Matrix-Telegram puppeting bridge."
|
||||
repo_url = "https://github.com/mautrix/telegram"
|
||||
@@ -50,6 +52,7 @@ class TelegramBridge(Bridge):
|
||||
|
||||
config: Config
|
||||
bot: Bot | None
|
||||
matrix: MatrixHandler
|
||||
public_website: PublicBridgeWebsite | None
|
||||
provisioning_api: ProvisioningAPI | None
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from telethon.tl.types import (
|
||||
PeerChannel,
|
||||
PeerChat,
|
||||
PeerUser,
|
||||
PhoneCallRequested,
|
||||
TypeUpdate,
|
||||
UpdateChannel,
|
||||
UpdateChannelUserTyping,
|
||||
@@ -54,6 +55,7 @@ from telethon.tl.types import (
|
||||
UpdateNewChannelMessage,
|
||||
UpdateNewMessage,
|
||||
UpdateNotifySettings,
|
||||
UpdatePhoneCall,
|
||||
UpdatePinnedChannelMessages,
|
||||
UpdatePinnedDialogs,
|
||||
UpdatePinnedMessages,
|
||||
@@ -343,6 +345,8 @@ class AbstractUser(ABC):
|
||||
await self.delete_message(update)
|
||||
elif isinstance(update, UpdateDeleteChannelMessages):
|
||||
await self.delete_channel_message(update)
|
||||
elif isinstance(update, UpdatePhoneCall):
|
||||
await self.update_phone_call(update)
|
||||
elif isinstance(update, UpdateMessageReactions):
|
||||
await self.update_reactions(update)
|
||||
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
|
||||
@@ -617,6 +621,19 @@ class AbstractUser(ABC):
|
||||
return
|
||||
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions)
|
||||
|
||||
async def update_phone_call(self, update: UpdatePhoneCall) -> None:
|
||||
self.log.debug("Phone call update %s", update)
|
||||
if not isinstance(update.phone_call, PhoneCallRequested):
|
||||
return
|
||||
tgid = TelegramID(update.phone_call.participant_id)
|
||||
if tgid == self.tgid:
|
||||
tgid = update.phone_call.admin_id
|
||||
portal = await po.Portal.get_by_tgid(tgid, tg_receiver=self.tgid, peer_type="user")
|
||||
if not portal or not portal.mxid or not portal.allow_bridging:
|
||||
return
|
||||
sender = await pu.Puppet.get_by_tgid(TelegramID(update.phone_call.admin_id))
|
||||
await portal.handle_telegram_direct_call(self, sender, update)
|
||||
|
||||
async def update_channel(self, update: UpdateChannel) -> None:
|
||||
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
||||
if not portal:
|
||||
@@ -659,7 +676,15 @@ class AbstractUser(ABC):
|
||||
if not portal:
|
||||
return
|
||||
elif portal and not portal.allow_bridging:
|
||||
self.log.debug(f"Ignoring message in portal {portal.tgid_log} (bridging disallowed)")
|
||||
self.log.debug(
|
||||
f"Ignoring message {update.id} in portal {portal.tgid_log} (bridging disallowed)"
|
||||
)
|
||||
return
|
||||
|
||||
if not portal.mxid and getattr(original_update, "mau_left_channel", False):
|
||||
self.log.debug(
|
||||
f"Ignoring message {update.id} in portal {portal.tgid_log} because user isn't in the chat"
|
||||
)
|
||||
return
|
||||
|
||||
if self.is_relaybot:
|
||||
|
||||
@@ -395,7 +395,7 @@ class Bot(AbstractUser):
|
||||
def reply(reply_text: str) -> Awaitable[Message]:
|
||||
return self.client.send_message(message.chat_id, reply_text, reply_to=message.id)
|
||||
|
||||
if command == "start":
|
||||
if command == "start" and message.is_private:
|
||||
pcm = self.config["bridge.relaybot.private_chat.message"]
|
||||
if pcm:
|
||||
await reply(pcm)
|
||||
|
||||
@@ -56,7 +56,9 @@ async def bridge(evt: CommandEvent) -> EventID:
|
||||
return await evt.reply(f"{that_this} room is already a portal room.")
|
||||
|
||||
if not await user_has_power_level(room_id, evt.az.intent, evt.sender, "bridge"):
|
||||
return await evt.reply(f"You do not have the permissions to bridge {that_this} room.")
|
||||
return await evt.reply(
|
||||
f"You do not have the permissions to bridge {that_this.lower()} room."
|
||||
)
|
||||
|
||||
# The /id bot command provides the prefixed ID, so we assume
|
||||
tgid_str = evt.args[0]
|
||||
|
||||
@@ -65,19 +65,11 @@ async def create(evt: CommandEvent) -> EventID:
|
||||
about=about,
|
||||
encrypted=encrypted,
|
||||
)
|
||||
invites, errors = await portal.get_telegram_users_in_matrix_room(evt.sender, pre_create=True)
|
||||
if len(errors) > 0:
|
||||
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
|
||||
await evt.reply(
|
||||
f"Failed to add the following users to the chat:\n\n{error_list}\n\n"
|
||||
"You can try `$cmdprefix+sp search -r <username>` to help the bridge find "
|
||||
"those users."
|
||||
)
|
||||
|
||||
await warn_missing_power(levels, evt)
|
||||
|
||||
try:
|
||||
await portal.create_telegram_chat(evt.sender, invites=invites, supergroup=supergroup)
|
||||
await portal.create_telegram_chat(evt.sender, supergroup=supergroup)
|
||||
except ValueError as e:
|
||||
await portal.delete()
|
||||
return await evt.reply(e.args[0])
|
||||
|
||||
@@ -148,7 +148,15 @@ class Config(BaseBridgeConfig):
|
||||
copy("bridge.animated_emoji.args.width")
|
||||
copy("bridge.animated_emoji.args.height")
|
||||
copy("bridge.animated_emoji.args.fps")
|
||||
copy("bridge.private_chat_portal_meta")
|
||||
if isinstance(self.get("bridge.private_chat_portal_meta", "default"), bool):
|
||||
base["bridge.private_chat_portal_meta"] = (
|
||||
"always" if self["bridge.private_chat_portal_meta"] else "default"
|
||||
)
|
||||
else:
|
||||
copy("bridge.private_chat_portal_meta")
|
||||
if base["bridge.private_chat_portal_meta"] not in ("default", "always", "never"):
|
||||
base["bridge.private_chat_portal_meta"] = "default"
|
||||
copy("bridge.disable_reply_fallbacks")
|
||||
copy("bridge.delivery_receipts")
|
||||
copy("bridge.delivery_error_reports")
|
||||
copy("bridge.incoming_bridge_error_reports")
|
||||
@@ -166,8 +174,26 @@ class Config(BaseBridgeConfig):
|
||||
copy("bridge.backfill.double_puppet_backfill")
|
||||
copy("bridge.backfill.normal_groups")
|
||||
copy("bridge.backfill.unread_hours_threshold")
|
||||
copy("bridge.backfill.forward.initial_limit")
|
||||
copy("bridge.backfill.forward.sync_limit")
|
||||
if "bridge.backfill.forward" in self:
|
||||
initial_limit = self.get("bridge.backfill.forward.initial_limit", 10)
|
||||
sync_limit = self.get("bridge.backfill.forward.sync_limit", 100)
|
||||
base["bridge.backfill.forward_limits.initial.user"] = initial_limit
|
||||
base["bridge.backfill.forward_limits.initial.normal_group"] = initial_limit
|
||||
base["bridge.backfill.forward_limits.initial.supergroup"] = initial_limit
|
||||
base["bridge.backfill.forward_limits.initial.channel"] = initial_limit
|
||||
base["bridge.backfill.forward_limits.sync.user"] = sync_limit
|
||||
base["bridge.backfill.forward_limits.sync.normal_group"] = sync_limit
|
||||
base["bridge.backfill.forward_limits.sync.supergroup"] = sync_limit
|
||||
base["bridge.backfill.forward_limits.sync.channel"] = sync_limit
|
||||
else:
|
||||
copy("bridge.backfill.forward_limits.initial.user")
|
||||
copy("bridge.backfill.forward_limits.initial.normal_group")
|
||||
copy("bridge.backfill.forward_limits.initial.supergroup")
|
||||
copy("bridge.backfill.forward_limits.initial.channel")
|
||||
copy("bridge.backfill.forward_limits.sync.user")
|
||||
copy("bridge.backfill.forward_limits.sync.normal_group")
|
||||
copy("bridge.backfill.forward_limits.sync.supergroup")
|
||||
copy("bridge.backfill.forward_limits.sync.channel")
|
||||
copy("bridge.backfill.incremental.messages_per_batch")
|
||||
copy("bridge.backfill.incremental.post_batch_delay")
|
||||
copy("bridge.backfill.incremental.max_batches.user")
|
||||
@@ -197,6 +223,7 @@ class Config(BaseBridgeConfig):
|
||||
|
||||
copy("bridge.filter.mode")
|
||||
copy("bridge.filter.list")
|
||||
copy("bridge.filter.users")
|
||||
|
||||
copy("bridge.command_prefix")
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class Puppet:
|
||||
avatar_url: ContentURI | None
|
||||
name_set: bool
|
||||
avatar_set: bool
|
||||
contact_info_set: bool
|
||||
is_bot: bool | None
|
||||
is_channel: bool
|
||||
is_premium: bool
|
||||
@@ -68,7 +69,7 @@ class Puppet:
|
||||
columns: ClassVar[str] = (
|
||||
"id, is_registered, displayname, displayname_source, displayname_contact, "
|
||||
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
|
||||
"name_set, avatar_set, is_bot, is_channel, is_premium, "
|
||||
"name_set, avatar_set, contact_info_set, is_bot, is_channel, is_premium, "
|
||||
"custom_mxid, access_token, next_batch, base_url"
|
||||
)
|
||||
|
||||
@@ -108,6 +109,7 @@ class Puppet:
|
||||
self.avatar_url,
|
||||
self.name_set,
|
||||
self.avatar_set,
|
||||
self.contact_info_set,
|
||||
self.is_bot,
|
||||
self.is_channel,
|
||||
self.is_premium,
|
||||
@@ -122,8 +124,9 @@ class Puppet:
|
||||
UPDATE puppet
|
||||
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
|
||||
displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10,
|
||||
avatar_url=$11, name_set=$12, avatar_set=$13, is_bot=$14, is_channel=$15,
|
||||
is_premium=$16, custom_mxid=$17, access_token=$18, next_batch=$19, base_url=$20
|
||||
avatar_url=$11, name_set=$12, avatar_set=$13, contact_info_set=$14, is_bot=$15,
|
||||
is_channel=$16, is_premium=$17, custom_mxid=$18, access_token=$19, next_batch=$20,
|
||||
base_url=$21
|
||||
WHERE id=$1
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
@@ -133,9 +136,9 @@ class Puppet:
|
||||
INSERT INTO puppet (
|
||||
id, is_registered, displayname, displayname_source, displayname_contact,
|
||||
displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
|
||||
avatar_set, is_bot, is_channel, is_premium, custom_mxid, access_token, next_batch,
|
||||
base_url
|
||||
avatar_set, contact_info_set, is_bot, is_channel, is_premium, custom_mxid,
|
||||
access_token, next_batch, base_url
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18,
|
||||
$19, $20)
|
||||
$19, $20, $21)
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
|
||||
@@ -20,4 +20,5 @@ from . import (
|
||||
v15_backfill_anchor_id,
|
||||
v16_backfill_type,
|
||||
v17_message_find_recent,
|
||||
v18_puppet_contact_info_set,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from mautrix.util.async_db import Connection, Scheme
|
||||
|
||||
latest_version = 17
|
||||
latest_version = 18
|
||||
|
||||
|
||||
async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
|
||||
@@ -113,6 +113,7 @@ async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
|
||||
avatar_url TEXT,
|
||||
name_set BOOLEAN NOT NULL DEFAULT false,
|
||||
avatar_set BOOLEAN NOT NULL DEFAULT false,
|
||||
contact_info_set BOOLEAN NOT NULL DEFAULT false,
|
||||
is_bot BOOLEAN,
|
||||
is_channel BOOLEAN NOT NULL DEFAULT false,
|
||||
is_premium BOOLEAN NOT NULL DEFAULT false,
|
||||
|
||||
@@ -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 mautrix.util.async_db import Connection
|
||||
|
||||
from . import upgrade_table
|
||||
|
||||
|
||||
@upgrade_table.register(description="Add contact_info_set column to puppet table")
|
||||
async def upgrade_v18(conn: Connection) -> None:
|
||||
await conn.execute(
|
||||
"ALTER TABLE puppet ADD COLUMN contact_info_set BOOLEAN NOT NULL DEFAULT false"
|
||||
)
|
||||
@@ -274,6 +274,23 @@ bridge:
|
||||
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
|
||||
# You must use a client that supports requesting keys from other users to use this feature.
|
||||
allow_key_sharing: false
|
||||
# Options for deleting megolm sessions from the bridge.
|
||||
delete_keys:
|
||||
# Beeper-specific: delete outbound sessions when hungryserv confirms
|
||||
# that the user has uploaded the key to key backup.
|
||||
delete_outbound_on_ack: false
|
||||
# Don't store outbound sessions in the inbound table.
|
||||
dont_store_outbound: false
|
||||
# Ratchet megolm sessions forward after decrypting messages.
|
||||
ratchet_on_decrypt: false
|
||||
# Delete fully used keys (index >= max_messages) after decrypting messages.
|
||||
delete_fully_used_on_decrypt: false
|
||||
# Delete previous megolm sessions from same device when receiving a new one.
|
||||
delete_prev_on_new_session: false
|
||||
# Delete megolm sessions received from a device when the device is deleted.
|
||||
delete_on_device_delete: false
|
||||
# Periodically delete megolm sessions when 2x max_age has passed since receiving the session.
|
||||
periodically_delete_expired: false
|
||||
# What level of device verification should be required from users?
|
||||
#
|
||||
# Valid levels:
|
||||
@@ -309,9 +326,14 @@ bridge:
|
||||
# default.
|
||||
messages: 100
|
||||
|
||||
# Whether or not to explicitly set the avatar and room name for private
|
||||
# chat portal rooms. This will be implicitly enabled if encryption.default is true.
|
||||
private_chat_portal_meta: false
|
||||
# Whether to explicitly set the avatar and room name for private chat portal rooms.
|
||||
# If set to `default`, this will be enabled in encrypted rooms and disabled in unencrypted rooms.
|
||||
# If set to `always`, all DM rooms will have explicit names and avatars set.
|
||||
# If set to `never`, DM rooms will never have names and avatars set.
|
||||
private_chat_portal_meta: default
|
||||
# Disable generating reply fallbacks? Some extremely bad clients still rely on them,
|
||||
# but they're being phased out and will be completely removed in the future.
|
||||
disable_reply_fallbacks: false
|
||||
# Whether or not the bridge should send a read receipt from the bridge bot when a message has
|
||||
# been sent to Telegram.
|
||||
delivery_receipts: false
|
||||
@@ -360,6 +382,9 @@ bridge:
|
||||
# Even without MSC2716, bridging old messages with correct timestamps requires the double
|
||||
# puppets to be in an appservice namespace, or the server to be modified to allow
|
||||
# overriding timestamps anyway.
|
||||
#
|
||||
# Also note that adding users to the appservice namespace may have unexpected side effects,
|
||||
# as described in https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method
|
||||
double_puppet_backfill: false
|
||||
# Whether or not to enable backfilling in normal groups.
|
||||
# Normal groups have numerous technical problems in Telegram, and backfilling normal groups
|
||||
@@ -374,11 +399,19 @@ bridge:
|
||||
#
|
||||
# Using a negative initial limit is not recommended, as it would try to backfill everything in a single batch.
|
||||
# MSC2716 and the incremental settings are meant for backfilling everything incrementally rather than at once.
|
||||
forward:
|
||||
forward_limits:
|
||||
# Number of messages to backfill immediately after creating a portal.
|
||||
initial_limit: 10
|
||||
initial:
|
||||
user: 50
|
||||
normal_group: 100
|
||||
supergroup: 10
|
||||
channel: 10
|
||||
# Number of messages to backfill when syncing chats.
|
||||
sync_limit: 100
|
||||
sync:
|
||||
user: 100
|
||||
normal_group: 100
|
||||
supergroup: 100
|
||||
channel: 100
|
||||
|
||||
# Settings for incremental backfill of history. These only apply when using MSC2716.
|
||||
incremental:
|
||||
@@ -458,7 +491,6 @@ bridge:
|
||||
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
|
||||
# `filter-mode` management commands.
|
||||
#
|
||||
# Filters do not affect direct chats.
|
||||
# An empty blacklist will essentially disable the filter.
|
||||
filter:
|
||||
# Filter mode to use. Either "blacklist" or "whitelist".
|
||||
@@ -467,6 +499,11 @@ bridge:
|
||||
mode: blacklist
|
||||
# The list of group/channel IDs to filter.
|
||||
list: []
|
||||
# How to handle direct chats:
|
||||
# If users is "null", direct chats will follow the previous settings.
|
||||
# If users is "true", direct chats will always be bridged.
|
||||
# If users is "false", direct chats will never be bridged.
|
||||
users: true
|
||||
|
||||
# The prefix for commands. Only required in non-management rooms.
|
||||
command_prefix: "!tg"
|
||||
|
||||
@@ -135,20 +135,8 @@ class MatrixHandler(BaseMatrixHandler):
|
||||
levels.users[self.az.bot_mxid] = 100 if invited_by_level >= 100 else invited_by_level
|
||||
await double_puppet.intent.set_power_levels(room_id, levels)
|
||||
|
||||
invites, errors = await portal.get_telegram_users_in_matrix_room(
|
||||
invited_by, pre_create=True
|
||||
)
|
||||
if len(errors) > 0:
|
||||
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
|
||||
await portal.az.intent.send_notice(
|
||||
room_id,
|
||||
f"Failed to add the following users to the chat:\n\n{error_list}\n\n"
|
||||
"You can try `$cmdprefix+sp search -r <username>` to help the bridge find "
|
||||
"those users.",
|
||||
)
|
||||
|
||||
try:
|
||||
await portal.create_telegram_chat(invited_by, invites=invites, supergroup=True)
|
||||
await portal.create_telegram_chat(invited_by, supergroup=True)
|
||||
except ValueError as e:
|
||||
await portal.delete()
|
||||
await portal.az.intent.send_notice(room_id, e.args[0])
|
||||
|
||||
+245
-54
@@ -1,5 +1,5 @@
|
||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
||||
# Copyright (C) 2022 Tulir Asokan
|
||||
# Copyright (C) 2023 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
|
||||
@@ -15,7 +15,17 @@
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, List, Union, cast
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AsyncGenerator,
|
||||
Awaitable,
|
||||
Callable,
|
||||
List,
|
||||
Literal,
|
||||
Union,
|
||||
cast,
|
||||
)
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from html import escape as escape_html
|
||||
@@ -23,19 +33,34 @@ from sqlite3 import IntegrityError
|
||||
from string import Template
|
||||
import asyncio
|
||||
import base64
|
||||
import itertools
|
||||
import random
|
||||
import time
|
||||
|
||||
from asyncpg import UniqueViolationError
|
||||
from telethon.errors import (
|
||||
ChatAdminRequiredError,
|
||||
ChatNotModifiedError,
|
||||
ChatRestrictedError,
|
||||
ChatWriteForbiddenError,
|
||||
EntitiesTooLongError,
|
||||
EntityBoundsInvalidError,
|
||||
EntityMentionUserInvalidError,
|
||||
InputUserDeactivatedError,
|
||||
MessageEmptyError,
|
||||
MessageIdInvalidError,
|
||||
MessageTooLongError,
|
||||
PhotoExtInvalidError,
|
||||
PhotoInvalidDimensionsError,
|
||||
PhotoSaveFileInvalidError,
|
||||
ReactionInvalidError,
|
||||
RPCError,
|
||||
SlowModeWaitError,
|
||||
UserBannedInChannelError,
|
||||
UserIsBlockedError,
|
||||
YouBlockedUserError,
|
||||
)
|
||||
from telethon.tl.custom import Dialog
|
||||
from telethon.tl.functions.channels import (
|
||||
CreateChannelRequest,
|
||||
EditPhotoRequest,
|
||||
@@ -54,6 +79,7 @@ from telethon.tl.functions.messages import (
|
||||
ExportChatInviteRequest,
|
||||
GetMessageReactionsListRequest,
|
||||
GetMessagesReactionsRequest,
|
||||
GetPeerDialogsRequest,
|
||||
MigrateChatRequest,
|
||||
SendReactionRequest,
|
||||
SetTypingRequest,
|
||||
@@ -66,6 +92,7 @@ from telethon.tl.types import (
|
||||
ChannelFull,
|
||||
Chat,
|
||||
ChatBannedRights,
|
||||
ChatEmpty,
|
||||
ChatFull,
|
||||
ChatPhoto,
|
||||
ChatPhotoEmpty,
|
||||
@@ -76,6 +103,7 @@ from telethon.tl.types import (
|
||||
GeoPoint,
|
||||
InputChannel,
|
||||
InputChatUploadedPhoto,
|
||||
InputDialogPeer,
|
||||
InputMediaUploadedDocument,
|
||||
InputMediaUploadedPhoto,
|
||||
InputPeerChannel,
|
||||
@@ -95,6 +123,9 @@ from telethon.tl.types import (
|
||||
MessageActionChatMigrateTo,
|
||||
MessageActionContactSignUp,
|
||||
MessageActionGameScore,
|
||||
MessageActionGiftPremium,
|
||||
MessageActionGroupCall,
|
||||
MessageActionPhoneCall,
|
||||
MessageMediaGame,
|
||||
MessageMediaGeo,
|
||||
MessagePeerReaction,
|
||||
@@ -102,6 +133,10 @@ from telethon.tl.types import (
|
||||
PeerChannel,
|
||||
PeerChat,
|
||||
PeerUser,
|
||||
PhoneCallDiscardReasonBusy,
|
||||
PhoneCallDiscardReasonDisconnect,
|
||||
PhoneCallDiscardReasonMissed,
|
||||
PhoneCallRequested,
|
||||
Photo,
|
||||
PhotoEmpty,
|
||||
ReactionCount,
|
||||
@@ -126,13 +161,16 @@ from telethon.tl.types import (
|
||||
UpdateChatUserTyping,
|
||||
UpdateMessageReactions,
|
||||
UpdateNewMessage,
|
||||
UpdatePhoneCall,
|
||||
UpdateUserTyping,
|
||||
User,
|
||||
UserEmpty,
|
||||
UserFull,
|
||||
UserProfilePhoto,
|
||||
UserProfilePhotoEmpty,
|
||||
)
|
||||
from telethon.utils import encode_waveform
|
||||
from telethon.tl.types.messages import PeerDialogs
|
||||
from telethon.utils import encode_waveform, get_peer_id
|
||||
import attr
|
||||
|
||||
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY, IntentAPI
|
||||
@@ -171,7 +209,8 @@ from mautrix.types import (
|
||||
UserID,
|
||||
VideoInfo,
|
||||
)
|
||||
from mautrix.util import background_task, magic, variation_selector
|
||||
from mautrix.util import background_task, magic, markdown, variation_selector
|
||||
from mautrix.util.format_duration import format_duration
|
||||
from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus
|
||||
from mautrix.util.simple_lock import SimpleLock
|
||||
from mautrix.util.simple_template import SimpleTemplate
|
||||
@@ -242,12 +281,13 @@ class Portal(DBPortal, BasePortal):
|
||||
# Config cache
|
||||
filter_mode: str
|
||||
filter_list: list[int]
|
||||
filter_users: bool | None
|
||||
|
||||
max_initial_member_sync: int
|
||||
sync_channel_members: bool
|
||||
sync_matrix_state: bool
|
||||
public_portals: bool
|
||||
private_chat_portal_meta: bool
|
||||
private_chat_portal_meta: Literal["default", "always", "never"]
|
||||
|
||||
alias_template: SimpleTemplate[str]
|
||||
hs_domain: str
|
||||
@@ -416,14 +456,22 @@ class Portal(DBPortal, BasePortal):
|
||||
def allow_bridging(self) -> bool:
|
||||
if self._bridging_blocked_at_runtime:
|
||||
return False
|
||||
elif self.peer_type == "user":
|
||||
return True
|
||||
elif self.peer_type == "user" and self.filter_users is not None:
|
||||
return self.filter_users
|
||||
elif self.filter_mode == "whitelist":
|
||||
return self.tgid in self.filter_list
|
||||
elif self.filter_mode == "blacklist":
|
||||
return self.tgid not in self.filter_list
|
||||
return True
|
||||
|
||||
@property
|
||||
def set_dm_room_metadata(self) -> bool:
|
||||
return (
|
||||
not self.is_direct
|
||||
or self.private_chat_portal_meta == "always"
|
||||
or (self.encrypted and self.private_chat_portal_meta != "never")
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def init_cls(cls, bridge: "TelegramBridge") -> None:
|
||||
BasePortal.bridge = bridge
|
||||
@@ -440,6 +488,7 @@ class Portal(DBPortal, BasePortal):
|
||||
cls.private_chat_portal_meta = cls.config["bridge.private_chat_portal_meta"]
|
||||
cls.filter_mode = cls.config["bridge.filter.mode"]
|
||||
cls.filter_list = cls.config["bridge.filter.list"]
|
||||
cls.filter_users = cls.config["bridge.filter.filter_users"]
|
||||
cls.hs_domain = cls.config["homeserver.domain"]
|
||||
cls.backfill_msc2716 = cls.config["bridge.backfill.msc2716"]
|
||||
cls.backfill_enable = cls.config["bridge.backfill.enable"]
|
||||
@@ -465,8 +514,9 @@ class Portal(DBPortal, BasePortal):
|
||||
|
||||
async def get_telegram_users_in_matrix_room(
|
||||
self, source: u.User, pre_create: bool = False
|
||||
) -> tuple[list[InputUser], list[UserID]]:
|
||||
) -> tuple[list[InputUser], list[UserID], list[u.User]]:
|
||||
user_tgids = {}
|
||||
users = []
|
||||
intent = self.az.intent if pre_create else self.main_intent
|
||||
user_mxids = await intent.get_room_members(self.mxid, (Membership.JOIN, Membership.INVITE))
|
||||
for mxid in user_mxids:
|
||||
@@ -474,6 +524,7 @@ class Portal(DBPortal, BasePortal):
|
||||
continue
|
||||
mx_user = await u.User.get_by_mxid(mxid, create=False)
|
||||
if mx_user and mx_user.tgid:
|
||||
users.append(mx_user)
|
||||
user_tgids[mx_user.tgid] = mxid
|
||||
puppet_id = p.Puppet.get_id_from_mxid(mxid)
|
||||
if puppet_id:
|
||||
@@ -489,7 +540,7 @@ class Portal(DBPortal, BasePortal):
|
||||
f"creating a group: {e}"
|
||||
)
|
||||
errors.append(mxid)
|
||||
return input_users, errors
|
||||
return input_users, errors, users
|
||||
|
||||
async def upgrade_telegram_chat(self, source: u.User) -> None:
|
||||
if self.peer_type != "chat":
|
||||
@@ -540,11 +591,23 @@ class Portal(DBPortal, BasePortal):
|
||||
if await self._update_username(username):
|
||||
await self.save()
|
||||
|
||||
async def create_telegram_chat(
|
||||
self, source: u.User, invites: list[InputUser], supergroup: bool = False
|
||||
) -> None:
|
||||
async def create_telegram_chat(self, source: u.User, supergroup: bool = False) -> None:
|
||||
if not self.mxid:
|
||||
raise ValueError("Can't create Telegram chat for portal without Matrix room.")
|
||||
invites, errors, users = await self.get_telegram_users_in_matrix_room(
|
||||
source, pre_create=True
|
||||
)
|
||||
if len(errors) > 0:
|
||||
error_list = "\n".join(f"* [{mxid}](https://matrix.to/#/{mxid})" for mxid in errors)
|
||||
command_prefix = self.config["bridge.command_prefix"]
|
||||
message = (
|
||||
f"Failed to add the following users to the chat:\n\n{error_list}\n\n"
|
||||
f"You can try `{command_prefix} search -r <username>` to help the bridge find "
|
||||
"those users."
|
||||
)
|
||||
await self.az.intent.send_notice(
|
||||
self.mxid, text=message, html=markdown.render(message)
|
||||
)
|
||||
elif self.tgid:
|
||||
raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.")
|
||||
|
||||
@@ -594,6 +657,8 @@ class Portal(DBPortal, BasePortal):
|
||||
await self.main_intent.set_power_levels(self.mxid, levels)
|
||||
await self.handle_matrix_power_levels(source, levels.users, {}, None)
|
||||
await self.update_bridge_info()
|
||||
for user in users:
|
||||
await user.register_portal(self)
|
||||
await self.main_intent.send_notice(self.mxid, f"Telegram chat created. ID: {self.tgid}")
|
||||
|
||||
async def handle_matrix_invite(
|
||||
@@ -698,12 +763,8 @@ class Portal(DBPortal, BasePortal):
|
||||
source: au.AbstractUser | None = None,
|
||||
photo: UserProfilePhoto | None = None,
|
||||
) -> None:
|
||||
if not self.encrypted and not self.private_chat_portal_meta:
|
||||
return
|
||||
if puppet is None:
|
||||
puppet = await self.get_dm_puppet()
|
||||
# The bridge bot needs to join for e2ee, but that messes up the default name
|
||||
# generation. If/when canonical DMs happen, this might not be necessary anymore.
|
||||
changed = await self._update_avatar_from_puppet(puppet, source, photo)
|
||||
changed = await self._update_title(puppet.displayname) or changed
|
||||
if changed:
|
||||
@@ -716,6 +777,7 @@ class Portal(DBPortal, BasePortal):
|
||||
entity: TypeChat | User = None,
|
||||
invites: InviteList = None,
|
||||
update_if_exists: bool = True,
|
||||
from_dialog_sync: bool = False,
|
||||
client: MautrixTelegramClient | None = None,
|
||||
) -> RoomID | None:
|
||||
if self.mxid:
|
||||
@@ -732,7 +794,9 @@ class Portal(DBPortal, BasePortal):
|
||||
return self.mxid
|
||||
async with self._room_create_lock:
|
||||
try:
|
||||
return await self._create_matrix_room(user, entity, invites, client=client)
|
||||
return await self._create_matrix_room(
|
||||
user, entity, invites, client=client, from_dialog_sync=from_dialog_sync
|
||||
)
|
||||
except Exception:
|
||||
self.log.exception("Fatal error creating Matrix room")
|
||||
|
||||
@@ -787,6 +851,7 @@ class Portal(DBPortal, BasePortal):
|
||||
user: au.AbstractUser,
|
||||
entity: TypeChat | User,
|
||||
invites: InviteList,
|
||||
from_dialog_sync: bool,
|
||||
client: MautrixTelegramClient | None = None,
|
||||
) -> RoomID | None:
|
||||
if self.mxid:
|
||||
@@ -798,6 +863,37 @@ class Portal(DBPortal, BasePortal):
|
||||
|
||||
invites = invites or []
|
||||
|
||||
dialog = None
|
||||
if not from_dialog_sync and not user.is_bot:
|
||||
self.log.debug("Fetching dialog info for new portal")
|
||||
try:
|
||||
dialogs: PeerDialogs | None = await user.client(
|
||||
GetPeerDialogsRequest(
|
||||
peers=[InputDialogPeer(await self.get_input_entity(user))]
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
self.log.warning("Failed to fetch dialog info", exc_info=True)
|
||||
dialogs = None
|
||||
if dialogs and dialogs.chats and dialogs.chats[0].id == self.tgid:
|
||||
entity = dialogs.chats[0]
|
||||
self.log.debug("Got entity info from get dialogs request")
|
||||
elif dialogs and self.is_direct and dialogs.users:
|
||||
for dialog_user in dialogs.users:
|
||||
if dialog_user.id == self.tgid:
|
||||
entity = dialog_user
|
||||
self.log.debug("Got user entity info from get dialogs request")
|
||||
break
|
||||
if dialogs and dialogs.dialogs:
|
||||
entities = {
|
||||
get_peer_id(x): x
|
||||
for x in itertools.chain(dialogs.users, dialogs.chats)
|
||||
if not isinstance(x, (UserEmpty, ChatEmpty))
|
||||
}
|
||||
msg = dialogs.messages[0] if len(dialogs.messages) == 1 else None
|
||||
dialog = Dialog(user.client, dialogs.dialogs[0], entities, msg)
|
||||
self.log.debug("Got dialog info for new portal: %s", dialog)
|
||||
|
||||
if not entity:
|
||||
entity = await self.get_entity(user, client)
|
||||
self.log.trace("Fetched data: %s", entity)
|
||||
@@ -900,7 +996,7 @@ class Portal(DBPortal, BasePortal):
|
||||
)
|
||||
if self.is_direct:
|
||||
create_invites.add(self.az.bot_mxid)
|
||||
if self.is_direct and (self.encrypted or self.private_chat_portal_meta):
|
||||
if self.is_direct:
|
||||
assert puppet is not None
|
||||
self.title = puppet.displayname
|
||||
self.avatar_url = puppet.avatar_url
|
||||
@@ -908,7 +1004,7 @@ class Portal(DBPortal, BasePortal):
|
||||
creation_content = {}
|
||||
if not self.config["bridge.federate_rooms"]:
|
||||
creation_content["m.federate"] = False
|
||||
if self.avatar_url:
|
||||
if self.avatar_url and self.set_dm_room_metadata:
|
||||
initial_state.append(
|
||||
{
|
||||
"type": str(EventType.ROOM_AVATAR),
|
||||
@@ -920,14 +1016,14 @@ class Portal(DBPortal, BasePortal):
|
||||
self.log.debug(
|
||||
f"Creating room with parameters invite={create_invites}, {autojoin_invites=}, "
|
||||
f"{preset=}, {alias=!r}, name={self.title!r}, topic={self.about!r}, "
|
||||
f"{creation_content=}, is_direct={self.is_direct}"
|
||||
f"{creation_content=}, is_direct={self.is_direct}, {self.set_dm_room_metadata=}"
|
||||
)
|
||||
room_id = await self.main_intent.create_room(
|
||||
alias_localpart=alias,
|
||||
preset=preset,
|
||||
is_direct=self.is_direct,
|
||||
invitees=list(create_invites),
|
||||
name=self.title,
|
||||
name=self.title if self.set_dm_room_metadata else None,
|
||||
topic=self.about,
|
||||
initial_state=initial_state,
|
||||
creation_content=creation_content,
|
||||
@@ -935,8 +1031,8 @@ class Portal(DBPortal, BasePortal):
|
||||
)
|
||||
if not room_id:
|
||||
raise Exception(f"Failed to create room")
|
||||
self.name_set = bool(self.title)
|
||||
self.avatar_set = bool(self.avatar_url)
|
||||
self.name_set = bool(self.title) and self.set_dm_room_metadata
|
||||
self.avatar_set = bool(self.avatar_url) and self.set_dm_room_metadata
|
||||
|
||||
if not autojoin_invites and self.encrypted and self.matrix.e2ee and self.is_direct:
|
||||
try:
|
||||
@@ -950,6 +1046,10 @@ class Portal(DBPortal, BasePortal):
|
||||
self.log.debug(f"Matrix room created: {self.mxid}")
|
||||
await self.az.state_store.set_power_levels(self.mxid, power_levels)
|
||||
await user.register_portal(self)
|
||||
if dialog and isinstance(user, u.User):
|
||||
await user.post_sync_dialog(
|
||||
self, puppet=None, was_created=True, **user.dialog_to_sync_args(dialog)
|
||||
)
|
||||
|
||||
if not autojoin_invites or not self.is_direct:
|
||||
await self.invite_to_matrix(invites)
|
||||
@@ -1246,11 +1346,12 @@ class Portal(DBPortal, BasePortal):
|
||||
async def _update_title(
|
||||
self, title: str, sender: p.Puppet | None = None, save: bool = False
|
||||
) -> bool:
|
||||
if self.title == title and self.name_set:
|
||||
if self.title == title and (self.name_set or not self.set_dm_room_metadata):
|
||||
return False
|
||||
|
||||
self.title = title
|
||||
if self.mxid:
|
||||
self.name_set = False
|
||||
if self.mxid and self.set_dm_room_metadata:
|
||||
try:
|
||||
await self._try_set_state(
|
||||
sender, EventType.ROOM_NAME, RoomNameStateEventContent(name=self.title)
|
||||
@@ -1258,7 +1359,6 @@ class Portal(DBPortal, BasePortal):
|
||||
self.name_set = True
|
||||
except Exception as e:
|
||||
self.log.warning(f"Failed to set room name: {e}")
|
||||
self.name_set = False
|
||||
if save:
|
||||
await self.save()
|
||||
return True
|
||||
@@ -1266,12 +1366,13 @@ class Portal(DBPortal, BasePortal):
|
||||
async def _update_avatar_from_puppet(
|
||||
self, puppet: p.Puppet, user: au.AbstractUser | None, photo: UserProfilePhoto | None
|
||||
) -> bool:
|
||||
if self.photo_id == puppet.photo_id and self.avatar_set:
|
||||
if self.photo_id == puppet.photo_id and (self.avatar_set or not self.set_dm_room_metadata):
|
||||
return False
|
||||
if puppet.avatar_url:
|
||||
self.photo_id = puppet.photo_id
|
||||
self.avatar_url = puppet.avatar_url
|
||||
if self.mxid:
|
||||
self.avatar_set = False
|
||||
if self.mxid and self.set_dm_room_metadata:
|
||||
try:
|
||||
await self._try_set_state(
|
||||
None,
|
||||
@@ -1281,9 +1382,8 @@ class Portal(DBPortal, BasePortal):
|
||||
self.avatar_set = True
|
||||
except Exception as e:
|
||||
self.log.warning(f"Failed to set room avatar: {e}")
|
||||
self.avatar_set = False
|
||||
return True
|
||||
elif photo is not None and user is not None:
|
||||
elif photo is not None and user is not None and self.set_dm_room_metadata:
|
||||
return await self._update_avatar(user, photo=photo)
|
||||
else:
|
||||
return False
|
||||
@@ -1778,7 +1878,7 @@ class Portal(DBPortal, BasePortal):
|
||||
if content.msgtype == MessageType.VIDEO:
|
||||
attributes.append(
|
||||
DocumentAttributeVideo(
|
||||
duration=content.info.duration // 1000 if content.info.duration else 0,
|
||||
duration=int(content.info.duration // 1000 if content.info.duration else 0),
|
||||
w=w or 0,
|
||||
h=h or 0,
|
||||
)
|
||||
@@ -1790,7 +1890,7 @@ class Portal(DBPortal, BasePortal):
|
||||
waveform = [round(part / max(waveform_max / 32, 1)) for part in waveform]
|
||||
attributes.append(
|
||||
DocumentAttributeAudio(
|
||||
duration=content.info.duration // 1000 if content.info.duration else 0,
|
||||
duration=int(content.info.duration // 1000 if content.info.duration else 0),
|
||||
voice="org.matrix.msc3245.voice" in content,
|
||||
waveform=encode_waveform(waveform) if waveform else None,
|
||||
)
|
||||
@@ -1978,6 +2078,34 @@ class Portal(DBPortal, BasePortal):
|
||||
expires_at=int(response.date.timestamp()) + response.ttl_period,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _error_to_human_message(err: Exception) -> str | None:
|
||||
if isinstance(err, YouBlockedUserError):
|
||||
return "You blocked this user"
|
||||
elif isinstance(err, UserIsBlockedError):
|
||||
return "You were blocked by this user"
|
||||
elif isinstance(err, UserBannedInChannelError):
|
||||
return "You're banned from sending messages in supergroups/channels"
|
||||
elif isinstance(err, InputUserDeactivatedError):
|
||||
return "This user was deleted"
|
||||
elif isinstance(err, ChatAdminRequiredError):
|
||||
return "Only admins can do that"
|
||||
elif isinstance(err, (ChatRestrictedError, ChatWriteForbiddenError)):
|
||||
return "You can't send messages in this chat"
|
||||
elif isinstance(err, SlowModeWaitError):
|
||||
return f"Slow mode enabled, wait {format_duration(err.seconds)} before sending"
|
||||
elif isinstance(err, MessageEmptyError):
|
||||
return "Message is empty"
|
||||
elif isinstance(err, MessageTooLongError):
|
||||
return "Message is too long"
|
||||
elif isinstance(err, EntitiesTooLongError):
|
||||
return "Message has too many formatting entities"
|
||||
elif isinstance(err, EntityBoundsInvalidError):
|
||||
return "Message formatting entities are malformed"
|
||||
elif isinstance(err, EntityMentionUserInvalidError):
|
||||
return "You mentioned an invalid user"
|
||||
return None
|
||||
|
||||
async def _send_message_status(self, event_id: EventID, err: Exception | None) -> None:
|
||||
if not self.config["bridge.message_status_events"]:
|
||||
return
|
||||
@@ -1995,8 +2123,9 @@ class Portal(DBPortal, BasePortal):
|
||||
status.status = MessageStatus.FAIL
|
||||
elif err:
|
||||
status.reason = MessageStatusReason.GENERIC_ERROR
|
||||
status.error = str(err)
|
||||
status.error = f"{type(err)}: {err}"
|
||||
status.status = MessageStatus.RETRIABLE
|
||||
status.message = self._error_to_human_message(err)
|
||||
else:
|
||||
status.status = MessageStatus.SUCCESS
|
||||
|
||||
@@ -2671,16 +2800,19 @@ class Portal(DBPortal, BasePortal):
|
||||
await DBMessage.replace_temp_mxid(temporary_identifier, self.mxid, event_id)
|
||||
|
||||
@property
|
||||
def _default_max_batches(self) -> int:
|
||||
def _backfill_config_type(self) -> str:
|
||||
if self.peer_type == "user":
|
||||
own_type = "user"
|
||||
return "user"
|
||||
elif self.peer_type == "chat":
|
||||
own_type = "normal_group"
|
||||
return "normal_group"
|
||||
elif self.megagroup:
|
||||
own_type = "supergroup"
|
||||
return "supergroup"
|
||||
else:
|
||||
own_type = "channel"
|
||||
return self.config[f"bridge.backfill.incremental.max_batches.{own_type}"]
|
||||
return "channel"
|
||||
|
||||
@property
|
||||
def _default_max_batches(self) -> int:
|
||||
return self.config[f"bridge.backfill.incremental.max_batches.{self._backfill_config_type}"]
|
||||
|
||||
async def enqueue_backfill(
|
||||
self,
|
||||
@@ -2724,7 +2856,10 @@ class Portal(DBPortal, BasePortal):
|
||||
if not client:
|
||||
client = source.client
|
||||
type = "initial" if initial else "sync"
|
||||
limit = override_limit or self.config[f"bridge.backfill.forward.{type}_limit"]
|
||||
limit = (
|
||||
override_limit
|
||||
or self.config[f"bridge.backfill.forward_limits.{type}.{self._backfill_config_type}"]
|
||||
)
|
||||
if limit == 0:
|
||||
return "Limit is zero, not backfilling"
|
||||
with self.backfill_lock:
|
||||
@@ -3172,17 +3307,18 @@ class Portal(DBPortal, BasePortal):
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _reactions_filter(lst: list[TypeReaction], existing: DBReaction) -> bool:
|
||||
def _reactions_filter(lst: list[MessagePeerReaction], existing: DBReaction) -> bool:
|
||||
if not lst:
|
||||
return False
|
||||
for reaction in lst:
|
||||
for wrapped_reaction in lst:
|
||||
reaction = wrapped_reaction.reaction
|
||||
if isinstance(reaction, ReactionCustomEmoji) and existing.reaction == str(
|
||||
reaction.document_id
|
||||
):
|
||||
lst.remove(reaction)
|
||||
lst.remove(wrapped_reaction)
|
||||
return True
|
||||
elif isinstance(reaction, ReactionEmoji) and existing.reaction == reaction.emoticon:
|
||||
lst.remove(reaction)
|
||||
lst.remove(wrapped_reaction)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -3202,15 +3338,14 @@ class Portal(DBPortal, BasePortal):
|
||||
total_count: int,
|
||||
timestamp: datetime | None = None,
|
||||
) -> None:
|
||||
reactions: dict[TelegramID, list[TypeReaction]] = {}
|
||||
reactions: dict[TelegramID, list[MessagePeerReaction]] = {}
|
||||
custom_emoji_ids: list[int] = []
|
||||
for reaction in reaction_list:
|
||||
if isinstance(reaction.peer_id, (PeerUser, PeerChannel)) and isinstance(
|
||||
reaction.reaction, (ReactionEmoji, ReactionCustomEmoji)
|
||||
):
|
||||
reactions.setdefault(p.Puppet.get_id_from_peer(reaction.peer_id), []).append(
|
||||
reaction.reaction
|
||||
)
|
||||
sender_user_id = p.Puppet.get_id_from_peer(reaction.peer_id)
|
||||
reactions.setdefault(sender_user_id, []).append(reaction)
|
||||
if isinstance(reaction.reaction, ReactionCustomEmoji):
|
||||
custom_emoji_ids.append(reaction.reaction.document_id)
|
||||
is_full = len(reaction_list) == total_count
|
||||
@@ -3235,7 +3370,8 @@ class Portal(DBPortal, BasePortal):
|
||||
|
||||
new_reaction: TypeReaction
|
||||
for sender, new_reactions in reactions.items():
|
||||
for new_reaction in new_reactions:
|
||||
for new_wrapped_reaction in new_reactions:
|
||||
new_reaction = new_wrapped_reaction.reaction
|
||||
if isinstance(new_reaction, ReactionEmoji):
|
||||
emoji_id = new_reaction.emoticon
|
||||
matrix_reaction = variation_selector.add(new_reaction.emoticon)
|
||||
@@ -3252,7 +3388,10 @@ class Portal(DBPortal, BasePortal):
|
||||
self.log.debug(f"Bridging reaction {emoji_id} by {sender} to {msg.tgid}")
|
||||
puppet: p.Puppet = await p.Puppet.get_by_tgid(sender)
|
||||
mxid = await puppet.intent_for(self).react(
|
||||
msg.mx_room, msg.mxid, matrix_reaction, timestamp=timestamp
|
||||
msg.mx_room,
|
||||
msg.mxid,
|
||||
matrix_reaction,
|
||||
timestamp=new_wrapped_reaction.date or timestamp,
|
||||
)
|
||||
await DBReaction(
|
||||
mxid=mxid,
|
||||
@@ -3476,6 +3615,16 @@ class Portal(DBPortal, BasePortal):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def handle_telegram_direct_call(
|
||||
self, source: au.AbstractUser, sender: p.Puppet, update: UpdatePhoneCall
|
||||
) -> None:
|
||||
if isinstance(update.phone_call, PhoneCallRequested):
|
||||
call_type = "video call" if update.phone_call.video else "call"
|
||||
await self._send_message(
|
||||
sender.intent_for(self),
|
||||
TextMessageEventContent(msgtype=MessageType.EMOTE, body=f"started a {call_type}"),
|
||||
)
|
||||
|
||||
async def handle_telegram_action(
|
||||
self, source: au.AbstractUser, sender: p.Puppet | None, update: MessageService
|
||||
) -> None:
|
||||
@@ -3503,11 +3652,53 @@ class Portal(DBPortal, BasePortal):
|
||||
await self.delete_telegram_user(TelegramID(action.user_id), sender)
|
||||
elif isinstance(action, MessageActionChatMigrateTo):
|
||||
await self._migrate_and_save_telegram(TelegramID(action.channel_id))
|
||||
# TODO encrypt
|
||||
await sender.intent_for(self).send_emote(
|
||||
self.mxid, "upgraded this group to a supergroup."
|
||||
await self._send_message(
|
||||
sender.intent_for(self),
|
||||
TextMessageEventContent(
|
||||
msgtype=MessageType.EMOTE,
|
||||
body="upgraded this group to a supergroup",
|
||||
),
|
||||
)
|
||||
await self.update_bridge_info()
|
||||
elif isinstance(action, MessageActionPhoneCall):
|
||||
call_type = "Video call" if action.video else "Call"
|
||||
end_reason = "ended"
|
||||
if isinstance(action.reason, PhoneCallDiscardReasonMissed):
|
||||
end_reason = "cancelled" if sender.tgid == source.tgid else "missed"
|
||||
elif isinstance(action.reason, PhoneCallDiscardReasonBusy):
|
||||
end_reason = "rejected"
|
||||
elif isinstance(action.reason, PhoneCallDiscardReasonDisconnect):
|
||||
end_reason = "disconnected"
|
||||
body = f"{call_type} {end_reason}"
|
||||
if action.duration:
|
||||
body += f" ({format_duration(action.duration)}"
|
||||
await self._send_message(
|
||||
sender.intent_for(self),
|
||||
TextMessageEventContent(msgtype=MessageType.NOTICE, body=body),
|
||||
)
|
||||
elif isinstance(action, MessageActionGroupCall):
|
||||
await self._send_message(
|
||||
sender.intent_for(self),
|
||||
TextMessageEventContent(
|
||||
msgtype=MessageType.EMOTE,
|
||||
body=(
|
||||
"started a video chat"
|
||||
if action.duration is None
|
||||
else f"ended the video chat ({format_duration(action.duration)})"
|
||||
),
|
||||
),
|
||||
)
|
||||
elif isinstance(action, MessageActionGiftPremium):
|
||||
await self._send_message(
|
||||
sender.intent_for(self),
|
||||
TextMessageEventContent(
|
||||
msgtype=MessageType.EMOTE,
|
||||
body=(
|
||||
f"gifted Telegram Premium for {action.months} months "
|
||||
f"({action.amount / 100} {action.currency})"
|
||||
),
|
||||
),
|
||||
)
|
||||
elif isinstance(action, MessageActionGameScore):
|
||||
# TODO handle game score
|
||||
pass
|
||||
@@ -3809,7 +4000,7 @@ class Portal(DBPortal, BasePortal):
|
||||
return portal
|
||||
|
||||
if peer_type:
|
||||
cls.log.info(f"Creating portal for {peer_type} {tgid} (receiver {tg_receiver})")
|
||||
cls.log.info(f"Creating portal object for {peer_type} {tgid} (receiver {tg_receiver})")
|
||||
# TODO enable this for non-release builds
|
||||
# (or add better wrong peer type error handling)
|
||||
# if peer_type == "chat":
|
||||
|
||||
@@ -259,6 +259,7 @@ class TelegramMessageConverter:
|
||||
)
|
||||
reply_to_id = TelegramID(evt.reply_to.reply_to_msg_id)
|
||||
msg = await DBMessage.get_one_by_tgid(reply_to_id, space)
|
||||
no_fallback = no_fallback or self.config["bridge.disable_reply_fallbacks"]
|
||||
if not msg or msg.mx_room != self.portal.mxid:
|
||||
if deterministic_id:
|
||||
content.set_reply(self.deterministic_event_id(space, reply_to_id))
|
||||
@@ -496,6 +497,7 @@ class TelegramMessageConverter:
|
||||
info["fi.mau.telegram.gif"] = True
|
||||
else:
|
||||
info["fi.mau.telegram.animated_sticker"] = True
|
||||
info["fi.mau.gif"] = True
|
||||
info["fi.mau.loop"] = True
|
||||
info["fi.mau.autoplay"] = True
|
||||
info["fi.mau.hide_controls"] = True
|
||||
|
||||
@@ -55,6 +55,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class Puppet(DBPuppet, BasePuppet):
|
||||
bridge: TelegramBridge
|
||||
config: Config
|
||||
hs_domain: str
|
||||
mxid_template: SimpleTemplate[TelegramID]
|
||||
@@ -78,6 +79,7 @@ class Puppet(DBPuppet, BasePuppet):
|
||||
avatar_url: ContentURI | None = None,
|
||||
name_set: bool = False,
|
||||
avatar_set: bool = False,
|
||||
contact_info_set: bool = False,
|
||||
is_bot: bool = False,
|
||||
is_channel: bool = False,
|
||||
is_premium: bool = False,
|
||||
@@ -100,6 +102,7 @@ class Puppet(DBPuppet, BasePuppet):
|
||||
avatar_url=avatar_url,
|
||||
name_set=name_set,
|
||||
avatar_set=avatar_set,
|
||||
contact_info_set=contact_info_set,
|
||||
is_bot=is_bot,
|
||||
is_channel=is_channel,
|
||||
is_premium=is_premium,
|
||||
@@ -154,6 +157,7 @@ class Puppet(DBPuppet, BasePuppet):
|
||||
|
||||
@classmethod
|
||||
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
|
||||
cls.bridge = bridge
|
||||
cls.config = bridge.config
|
||||
cls.loop = bridge.loop
|
||||
cls.mx = bridge.matrix
|
||||
@@ -279,6 +283,8 @@ class Puppet(DBPuppet, BasePuppet):
|
||||
|
||||
if not self.disable_updates:
|
||||
try:
|
||||
changed = await self._update_contact_info(force=changed) or changed
|
||||
|
||||
changed = (
|
||||
await self.update_displayname(source, info, client_override=client_override)
|
||||
or changed
|
||||
@@ -296,8 +302,37 @@ class Puppet(DBPuppet, BasePuppet):
|
||||
await self.update_portals_meta()
|
||||
await self.save()
|
||||
|
||||
async def _update_contact_info(self, force: bool = False) -> bool:
|
||||
if not self.bridge.homeserver_software.is_hungry:
|
||||
return False
|
||||
|
||||
if self.contact_info_set and not force:
|
||||
return False
|
||||
|
||||
try:
|
||||
identifiers = []
|
||||
if self.username:
|
||||
identifiers.append(f"telegram:{self.username}")
|
||||
if self.phone:
|
||||
phone = "+" + self.phone.lstrip("+")
|
||||
identifiers.append(f"tel:{phone}")
|
||||
await self.default_mxid_intent.beeper_update_profile(
|
||||
{
|
||||
"com.beeper.bridge.identifiers": identifiers,
|
||||
"com.beeper.bridge.remote_id": str(self.tgid),
|
||||
"com.beeper.bridge.service": "telegram",
|
||||
"com.beeper.bridge.network": "telegram",
|
||||
"com.beeper.bridge.is_network_bot": self.is_bot,
|
||||
}
|
||||
)
|
||||
self.contact_info_set = True
|
||||
except Exception:
|
||||
self.log.exception("Error updating contact info")
|
||||
self.contact_info_set = False
|
||||
return True
|
||||
|
||||
async def update_portals_meta(self) -> None:
|
||||
if not p.Portal.private_chat_portal_meta and not self.mx.e2ee:
|
||||
if p.Portal.private_chat_portal_meta != "always" and not self.mx.e2ee:
|
||||
return
|
||||
async for portal in p.Portal.find_private_chats_with(self.tgid):
|
||||
await portal.update_info_from_puppet(self)
|
||||
|
||||
+50
-16
@@ -39,6 +39,9 @@ from telethon.tl.types import (
|
||||
ChatForbidden,
|
||||
InputUserSelf,
|
||||
Message,
|
||||
MessageActionContactSignUp,
|
||||
MessageActionHistoryClear,
|
||||
MessageService,
|
||||
NotifyPeer,
|
||||
PeerUser,
|
||||
TypeUpdate,
|
||||
@@ -52,6 +55,7 @@ from telethon.tl.types import (
|
||||
User as TLUser,
|
||||
)
|
||||
from telethon.tl.types.contacts import ContactsNotModified
|
||||
from telethon.tl.types.help import AppConfig
|
||||
from telethon.tl.types.messages import AvailableReactions
|
||||
|
||||
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY
|
||||
@@ -106,6 +110,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
_available_emoji_reactions_fetched: float
|
||||
_available_emoji_reactions_lock: asyncio.Lock
|
||||
_app_config: dict[str, Any] | None
|
||||
_app_config_hash: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -143,6 +148,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
self._available_emoji_reactions_fetched = 0
|
||||
self._available_emoji_reactions_lock = asyncio.Lock()
|
||||
self._app_config = None
|
||||
self._app_config_hash = 0
|
||||
|
||||
(
|
||||
self.relaybot_whitelisted,
|
||||
@@ -487,13 +493,16 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
self.log.info(f"Creating portal for {portal.tgid_log} as part of backfill loop")
|
||||
try:
|
||||
await portal.create_matrix_room(
|
||||
self, client=client, update_if_exists=False, invites=[self.mxid]
|
||||
self,
|
||||
client=client,
|
||||
update_if_exists=False,
|
||||
invites=[self.mxid],
|
||||
from_dialog_sync=True,
|
||||
)
|
||||
except Exception:
|
||||
self.log.exception(f"Error while creating {portal.tgid_log}")
|
||||
else:
|
||||
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
|
||||
await self._post_sync_dialog(portal, puppet, was_created=True, **post_sync_args)
|
||||
await self.post_sync_dialog(portal, puppet=None, was_created=True, **post_sync_args)
|
||||
|
||||
async def update(self, update: TypeUpdate) -> bool:
|
||||
if not self.is_bot:
|
||||
@@ -535,7 +544,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
await self.stop()
|
||||
return None
|
||||
|
||||
async def update_info(self, info: TLUser = None) -> None:
|
||||
async def update_info(self, info: TLUser | None = None) -> None:
|
||||
if not info:
|
||||
info = await self.get_me()
|
||||
if not info:
|
||||
@@ -743,12 +752,12 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
)
|
||||
await self._mute_room(puppet, portal, update.notify_settings.mute_until.timestamp())
|
||||
|
||||
async def _sync_dialog(
|
||||
self, portal: po.Portal, dialog: Dialog, should_create: bool, puppet: pu.Puppet | None
|
||||
) -> None:
|
||||
was_created = False
|
||||
post_sync_args = {
|
||||
"last_message_ts": cast(datetime, dialog.date).timestamp(),
|
||||
@staticmethod
|
||||
def dialog_to_sync_args(dialog: Dialog) -> dict:
|
||||
return {
|
||||
"last_message_ts": (
|
||||
cast(datetime, dialog.date).timestamp() if dialog.date else time.time()
|
||||
),
|
||||
"unread_count": dialog.unread_count,
|
||||
"max_read_id": dialog.dialog.read_inbox_max_id,
|
||||
"mute_until": (
|
||||
@@ -759,6 +768,24 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
"pinned": dialog.pinned,
|
||||
"archived": dialog.archived,
|
||||
}
|
||||
|
||||
async def _sync_dialog(
|
||||
self, portal: po.Portal, dialog: Dialog, should_create: bool, puppet: pu.Puppet | None
|
||||
) -> None:
|
||||
if (
|
||||
not portal.mxid
|
||||
and isinstance(dialog.message, MessageService)
|
||||
and isinstance(
|
||||
dialog.message.action, (MessageActionContactSignUp, MessageActionHistoryClear)
|
||||
)
|
||||
):
|
||||
self.log.debug(
|
||||
f"Not syncing {portal.tgid_log} "
|
||||
f"(last message is a {type(dialog.message.action).__name__})"
|
||||
)
|
||||
return
|
||||
was_created = False
|
||||
post_sync_args = self.dialog_to_sync_args(dialog)
|
||||
if portal.mxid:
|
||||
self.log.debug(f"Backfilling and updating {portal.tgid_log} (dialog sync)")
|
||||
try:
|
||||
@@ -772,7 +799,9 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
elif should_create:
|
||||
self.log.debug(f"Creating portal for {portal.tgid_log} immediately (dialog sync)")
|
||||
try:
|
||||
await portal.create_matrix_room(self, dialog.entity, invites=[self.mxid])
|
||||
await portal.create_matrix_room(
|
||||
self, dialog.entity, invites=[self.mxid], from_dialog_sync=True
|
||||
)
|
||||
was_created = True
|
||||
except Exception:
|
||||
self.log.exception(f"Error while creating {portal.tgid_log}")
|
||||
@@ -785,7 +814,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
extra_data=post_sync_args,
|
||||
)
|
||||
if portal.mxid and puppet and puppet.is_real_user:
|
||||
await self._post_sync_dialog(
|
||||
await self.post_sync_dialog(
|
||||
portal=portal,
|
||||
puppet=puppet,
|
||||
was_created=was_created,
|
||||
@@ -793,10 +822,10 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
)
|
||||
self.log.debug(f"_sync_dialog finished for {portal.tgid_log}")
|
||||
|
||||
async def _post_sync_dialog(
|
||||
async def post_sync_dialog(
|
||||
self,
|
||||
portal: po.Portal,
|
||||
puppet: pu.Puppet,
|
||||
puppet: pu.Puppet | None,
|
||||
was_created: bool,
|
||||
max_read_id: int,
|
||||
last_message_ts: float,
|
||||
@@ -805,6 +834,10 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
pinned: bool,
|
||||
archived: bool,
|
||||
) -> None:
|
||||
if puppet is None:
|
||||
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
|
||||
if not puppet or not puppet.is_real_user:
|
||||
return
|
||||
self.log.debug(
|
||||
f"Running dialog post-sync for {portal.tgid_log} with args "
|
||||
f"{was_created=}, {max_read_id=}, {last_message_ts=}, {unread_count=}, "
|
||||
@@ -969,8 +1002,9 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
|
||||
async def get_app_config(self) -> dict[str, Any]:
|
||||
if not self._app_config:
|
||||
cfg = await self.client(GetAppConfigRequest())
|
||||
self._app_config = util.parse_tl_json(cfg)
|
||||
cfg: AppConfig = await self.client(GetAppConfigRequest(hash=self._app_config_hash))
|
||||
self._app_config = util.parse_tl_json(cfg.config)
|
||||
self._app_config_hash = cfg.hash
|
||||
return self._app_config
|
||||
|
||||
async def get_max_reactions(self, is_premium: bool | None = None) -> int:
|
||||
|
||||
@@ -35,6 +35,7 @@ from telethon.errors import (
|
||||
PhoneNumberInvalidError,
|
||||
PhoneNumberUnoccupiedError,
|
||||
SessionPasswordNeededError,
|
||||
SessionRevokedError,
|
||||
)
|
||||
|
||||
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
|
||||
@@ -288,6 +289,17 @@ class AuthAPI(abc.ABC):
|
||||
errcode="phone_number_unoccupied",
|
||||
error="That phone number has not been registered.",
|
||||
)
|
||||
except FloodWaitError as e:
|
||||
return self.get_login_response(
|
||||
mxid=user.mxid,
|
||||
state="code",
|
||||
status=429,
|
||||
errcode="flood_wait",
|
||||
error=(
|
||||
"You tried to enter your phone code too many times. "
|
||||
f"Please wait for {format_duration(e.seconds)} before trying again."
|
||||
),
|
||||
)
|
||||
except SessionPasswordNeededError:
|
||||
if not password_in_data:
|
||||
if user.command_status and user.command_status["action"] == "Login":
|
||||
@@ -342,6 +354,28 @@ class AuthAPI(abc.ABC):
|
||||
errcode="password_invalid",
|
||||
error="Incorrect password.",
|
||||
)
|
||||
except SessionRevokedError:
|
||||
return self.get_login_response(
|
||||
mxid=user.mxid,
|
||||
state="request",
|
||||
status=401,
|
||||
errcode="session_revoked",
|
||||
error=(
|
||||
"Please try again. Login cancelled because your other sessions were "
|
||||
"terminated via the Telegram app."
|
||||
),
|
||||
)
|
||||
except FloodWaitError as e:
|
||||
return self.get_login_response(
|
||||
mxid=user.mxid,
|
||||
state="password",
|
||||
status=429,
|
||||
errcode="flood_wait",
|
||||
error=(
|
||||
"You tried to enter your password too many times. "
|
||||
f"Please wait for {format_duration(e.seconds)} before trying again."
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
self.log.exception("Error sending password")
|
||||
if isinstance(e, ValueError) and "You must provide a phone and a code" in str(e):
|
||||
@@ -357,5 +391,5 @@ class AuthAPI(abc.ABC):
|
||||
state="password",
|
||||
status=500,
|
||||
errcode="unknown_error",
|
||||
error="Internal server error while sending password.",
|
||||
error=f"Internal server error while sending password. {e}",
|
||||
)
|
||||
|
||||
@@ -10,7 +10,6 @@ brotli
|
||||
pillow>=4,<10
|
||||
qrcode>=6,<8
|
||||
|
||||
|
||||
#/formattednumbers
|
||||
phonenumbers>=8,<9
|
||||
|
||||
@@ -23,4 +22,4 @@ pycryptodome>=3,<4
|
||||
unpaddedbase64>=1,<3
|
||||
|
||||
#/sqlite
|
||||
aiosqlite>=0.16,<0.19
|
||||
aiosqlite>=0.16,<0.20
|
||||
|
||||
+2
-3
@@ -3,9 +3,8 @@ python-magic>=0.4,<0.5
|
||||
commonmark>=0.8,<0.10
|
||||
aiohttp>=3,<4
|
||||
yarl>=1,<2
|
||||
mautrix>=0.19.4,<0.20
|
||||
#telethon>=1.25.4,<1.27
|
||||
tulir-telethon==1.28.0a3
|
||||
mautrix>=0.19.14,<0.20
|
||||
tulir-telethon==1.28.0a9
|
||||
asyncpg>=0.20,<0.28
|
||||
mako>=1,<2
|
||||
setuptools
|
||||
|
||||
Reference in New Issue
Block a user