Compare commits
83 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 | |||
| 60b1573386 | |||
| f4695d8395 | |||
| f63c679d3e | |||
| 4e5305c91b | |||
| f30c03a727 | |||
| 354b49d9e5 | |||
| 7b60ee1337 | |||
| ab1d9b246e | |||
| f7b694c9e4 | |||
| be6f6bbfac | |||
| a32f797b0b | |||
| f12abbe038 | |||
| ad2b49928a | |||
| 67f75796fa | |||
| c235ced030 | |||
| d53764fd84 | |||
| 529d8ae3ba | |||
| f864f66e62 | |||
| b1b633bcf9 | |||
| e655e0a882 | |||
| db88fbb694 | |||
| ace3e42281 | |||
| a40000e6b7 | |||
| 21d2d7dfea | |||
| a61731a289 | |||
| c250076032 | |||
| c6d35b103a | |||
| 596c9a5055 | |||
| 9fae4f14d2 | |||
| f1f0b86696 | |||
| e3d2a1fcef | |||
| 2303622475 | |||
| 732277be5e | |||
| 28f205057f | |||
| 9e32ec3e39 | |||
| 1fa86cbb52 | |||
| 9d8a4d4269 | |||
| cb22615bb5 | |||
| 989dc32481 | |||
| 02dd44ad63 | |||
| d6517959d8 | |||
| d9d539c4b8 | |||
| 5b18ffb7ec | |||
| cf70efb6a2 | |||
| a42699e1fb | |||
| 597e82a33b |
@@ -9,14 +9,14 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.11"
|
||||
- uses: isort/isort-action@master
|
||||
with:
|
||||
sortPaths: "./mautrix_telegram"
|
||||
- uses: psf/black@stable
|
||||
with:
|
||||
src: "./mautrix_telegram"
|
||||
version: "22.3.0"
|
||||
version: "23.1.0"
|
||||
- name: pre-commit
|
||||
run: |
|
||||
pip install pre-commit
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.1.0
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude_types: [markdown]
|
||||
@@ -8,13 +8,13 @@ repos:
|
||||
- id: check-yaml
|
||||
- id: check-added-large-files
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.3.0
|
||||
rev: 23.1.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python3
|
||||
files: ^mautrix_telegram/.*\.pyi?$
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.10.1
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
files: ^mautrix_telegram/.*\.pyi?$
|
||||
|
||||
@@ -1,3 +1,63 @@
|
||||
# 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
|
||||
* Added `allow_contact_info` config option to specify whether personal names
|
||||
and avatars for other users should be bridged.
|
||||
* The option is only safe to enable on single-user instances, using it
|
||||
anywhere else will cause ghost user profiles to flip back and forth between
|
||||
personal and default ones.
|
||||
* Added config option to notify Matrix room if bridging an incoming message
|
||||
fails.
|
||||
|
||||
### Improved
|
||||
* Updated Docker image to Alpine 3.17.
|
||||
* Updated to Telegram API layer 152.
|
||||
* Improved handling users getting logged out.
|
||||
* Removed support for creating accounts, as Telegram only allows requesting SMS
|
||||
login codes on the official mobile clients now.
|
||||
* Replaced moviepy with calling ffmpeg directly for generating video thumbnails.
|
||||
|
||||
### Fixed
|
||||
* Fixed handling Telegram chat upgrades when backfilling is enabled.
|
||||
* Fixed file transfers failing if transfering the thumbnail fails.
|
||||
* Fixed bridging unnamed files with unrecognized mime types.
|
||||
* Fixed enqueueing more backfill.
|
||||
* Fixed timestamps not being saved in `telegram_file` table.
|
||||
* Fixed issues with old events being replayed if the bridge was shut down
|
||||
uncleanly.
|
||||
|
||||
# v0.12.2 (2022-11-26)
|
||||
|
||||
### Added
|
||||
|
||||
+1
-8
@@ -1,4 +1,4 @@
|
||||
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.16
|
||||
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17
|
||||
|
||||
RUN apk add --no-cache \
|
||||
python3 py3-pip py3-setuptools py3-wheel \
|
||||
@@ -13,13 +13,6 @@ RUN apk add --no-cache \
|
||||
# Indirect dependencies
|
||||
py3-idna \
|
||||
py3-rsa \
|
||||
#moviepy
|
||||
py3-decorator \
|
||||
py3-tqdm \
|
||||
py3-requests \
|
||||
#py3-proglog \
|
||||
#imageio
|
||||
py3-numpy \
|
||||
#py3-telethon \ (outdated)
|
||||
# Optional for socks proxies
|
||||
py3-pysocks \
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
pre-commit>=2.10.1,<3
|
||||
isort>=5.10.1,<6
|
||||
black>=22.3,<23
|
||||
black>=23,<24
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
__version__ = "0.12.2"
|
||||
__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
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import logging
|
||||
import platform
|
||||
import time
|
||||
|
||||
from telethon.errors import UnauthorizedError
|
||||
from telethon.errors import AuthKeyError, UnauthorizedError
|
||||
from telethon.network import (
|
||||
Connection,
|
||||
ConnectionTcpFull,
|
||||
@@ -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,
|
||||
@@ -63,8 +65,8 @@ from telethon.tl.types import (
|
||||
UpdateShort,
|
||||
UpdateShortChatMessage,
|
||||
UpdateShortMessage,
|
||||
UpdateUser,
|
||||
UpdateUserName,
|
||||
UpdateUserPhoto,
|
||||
UpdateUserStatus,
|
||||
UpdateUserTyping,
|
||||
User,
|
||||
@@ -75,6 +77,7 @@ from telethon.tl.types import (
|
||||
from mautrix.appservice import AppService
|
||||
from mautrix.errors import MatrixError
|
||||
from mautrix.types import PresenceState, UserID
|
||||
from mautrix.util import background_task
|
||||
from mautrix.util.logging import TraceLogger
|
||||
from mautrix.util.opt_prometheus import Counter, Histogram
|
||||
|
||||
@@ -235,18 +238,23 @@ class AbstractUser(ABC):
|
||||
)
|
||||
self.client.add_event_handler(self._update_catch)
|
||||
|
||||
@abstractmethod
|
||||
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
|
||||
pass
|
||||
|
||||
async def _telethon_update_error_callback(self, err: Exception) -> None:
|
||||
if isinstance(err, (UnauthorizedError, AuthKeyError)):
|
||||
background_task.create(self.on_signed_out(err))
|
||||
return
|
||||
if self.config["telegram.exit_on_update_error"]:
|
||||
self.log.critical(f"Stopping due to update handling error {type(err).__name__}")
|
||||
self.bridge.manual_stop(50)
|
||||
else:
|
||||
if isinstance(err, UnauthorizedError):
|
||||
self.log.warning("Not recreating Telethon update loop")
|
||||
return
|
||||
self.log.info("Recreating Telethon update loop in 60 seconds")
|
||||
self.log.info("Recreating Telethon connection in 60 seconds")
|
||||
await asyncio.sleep(60)
|
||||
self.log.debug("Now recreating Telethon update loop")
|
||||
self.client._updates_handle = self.loop.create_task(self.client._update_loop())
|
||||
self.log.debug("Now recreating Telethon connection")
|
||||
await self.stop()
|
||||
await self.start()
|
||||
|
||||
@abstractmethod
|
||||
async def update(self, update: TypeUpdate) -> bool:
|
||||
@@ -320,7 +328,7 @@ class AbstractUser(ABC):
|
||||
async def _update(self, update: TypeUpdate) -> None:
|
||||
if isinstance(update, UpdateShort):
|
||||
update = update.update
|
||||
asyncio.create_task(self._handle_entity_updates(getattr(update, "_entities", {})))
|
||||
background_task.create(self._handle_entity_updates(getattr(update, "_entities", {})))
|
||||
if isinstance(
|
||||
update,
|
||||
(
|
||||
@@ -337,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)):
|
||||
@@ -351,7 +361,7 @@ class AbstractUser(ABC):
|
||||
await self.update_default_banned_rights(update)
|
||||
elif isinstance(update, (UpdatePinnedMessages, UpdatePinnedChannelMessages)):
|
||||
await self.update_pinned_messages(update)
|
||||
elif isinstance(update, (UpdateUserName, UpdateUserPhoto)):
|
||||
elif isinstance(update, (UpdateUserName, UpdateUser)):
|
||||
await self.update_others_info(update)
|
||||
elif isinstance(update, UpdateReadHistoryOutbox):
|
||||
await self.update_read_receipt(update)
|
||||
@@ -500,18 +510,23 @@ class AbstractUser(ABC):
|
||||
except Exception:
|
||||
self.log.exception("Failed to handle entity updates")
|
||||
|
||||
async def update_others_info(self, update: UpdateUserName | UpdateUserPhoto) -> None:
|
||||
async def update_others_info(self, update: UpdateUserName | UpdateUser) -> None:
|
||||
# TODO duplication not checked
|
||||
puppet = await pu.Puppet.get_by_tgid(TelegramID(update.user_id))
|
||||
if isinstance(update, UpdateUserName):
|
||||
puppet.username = update.username
|
||||
if len(update.usernames) > 1:
|
||||
self.log.warning(
|
||||
"Got update with multiple usernames (%s) for %s, only saving first one",
|
||||
update.usernames,
|
||||
update.user_id,
|
||||
)
|
||||
puppet.username = update.usernames[0].username if update.usernames else None
|
||||
if await puppet.update_displayname(self, update):
|
||||
await puppet.save()
|
||||
await puppet.update_portals_meta()
|
||||
elif isinstance(update, UpdateUserPhoto):
|
||||
if await puppet.update_avatar(self, update.photo):
|
||||
await puppet.save()
|
||||
await puppet.update_portals_meta()
|
||||
elif isinstance(update, UpdateUser):
|
||||
info = await self.client.get_entity(puppet.peer)
|
||||
await puppet.update_info(self, info)
|
||||
else:
|
||||
self.log.warning(f"Unexpected other user info update: {type(update)}")
|
||||
|
||||
@@ -606,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:
|
||||
@@ -615,24 +643,28 @@ class AbstractUser(ABC):
|
||||
await portal.delete_telegram_user(self.tgid, sender=None)
|
||||
elif chan := getattr(update, "mau_channel", None):
|
||||
if not portal.mxid:
|
||||
asyncio.create_task(self._delayed_create_channel(chan))
|
||||
background_task.create(self._delayed_create_channel(chan))
|
||||
else:
|
||||
self.log.debug("Updating channel info with data fetched by Telethon")
|
||||
await portal.update_info(self, chan)
|
||||
await portal.invite_to_matrix(self.mxid)
|
||||
|
||||
async def _delayed_create_channel(self, chan: Channel) -> None:
|
||||
self.log.debug("Waiting 5 seconds before handling UpdateChannel for non-existent portal")
|
||||
self.log.debug(
|
||||
f"Waiting 5 seconds before handling UpdateChannel for non-existent portal {chan.id}"
|
||||
)
|
||||
await asyncio.sleep(5)
|
||||
portal = await po.Portal.get_by_tgid(TelegramID(chan.id))
|
||||
if portal.mxid:
|
||||
self.log.debug(
|
||||
"Portal started existing after waiting 5 seconds, dropping UpdateChannel"
|
||||
"Portal started existing after waiting 5 seconds, "
|
||||
f"dropping UpdateChannel for {portal.tgid}"
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.log.info(
|
||||
"Creating Matrix room with data fetched by Telethon due to UpdateChannel"
|
||||
f"Creating Matrix room for {portal.tgid}"
|
||||
" with data fetched by Telethon due to UpdateChannel"
|
||||
)
|
||||
await portal.create_matrix_room(self, chan, invites=[self.mxid])
|
||||
|
||||
@@ -644,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:
|
||||
@@ -681,7 +721,7 @@ class AbstractUser(ABC):
|
||||
await self.unregister_portal(update.action.chat_id, update.action.chat_id)
|
||||
await self.register_portal(portal)
|
||||
return
|
||||
self.log.trace(
|
||||
self.log.debug(
|
||||
"Handling action %s to %s by %d",
|
||||
update.action,
|
||||
portal.tgid_log,
|
||||
|
||||
+11
-2
@@ -19,7 +19,12 @@ from typing import TYPE_CHECKING, Awaitable, Callable, Literal
|
||||
import logging
|
||||
import time
|
||||
|
||||
from telethon.errors import ChannelInvalidError, ChannelPrivateError
|
||||
from telethon.errors import (
|
||||
AuthKeyError,
|
||||
ChannelInvalidError,
|
||||
ChannelPrivateError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from telethon.tl.functions.channels import GetChannelsRequest, GetParticipantRequest
|
||||
from telethon.tl.functions.messages import GetChatsRequest, GetFullChatRequest
|
||||
from telethon.tl.patched import Message, MessageService
|
||||
@@ -145,6 +150,10 @@ class Bot(AbstractUser):
|
||||
await self.post_login()
|
||||
return self
|
||||
|
||||
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
|
||||
self.log.fatal("Relay bot got signed out, crashing bridge", exc_info=err)
|
||||
self.bridge.manual_stop(51)
|
||||
|
||||
async def post_login(self) -> None:
|
||||
await self.init_permissions()
|
||||
info = await self.client.get_me()
|
||||
@@ -386,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)
|
||||
|
||||
@@ -21,6 +21,7 @@ import asyncio
|
||||
from telethon.tl.types import ChannelForbidden, ChatForbidden
|
||||
|
||||
from mautrix.types import EventID, RoomID
|
||||
from mautrix.util import background_task
|
||||
|
||||
from ... import portal as po
|
||||
from ...types import TelegramID
|
||||
@@ -55,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]
|
||||
@@ -184,7 +187,7 @@ async def confirm_bridge(evt: CommandEvent) -> EventID | None:
|
||||
if not ok:
|
||||
return None
|
||||
elif coro:
|
||||
asyncio.create_task(coro)
|
||||
background_task.create(coro)
|
||||
await evt.reply("Cleaning up previous portal room...")
|
||||
elif portal.mxid:
|
||||
evt.sender.command_status = None
|
||||
@@ -251,7 +254,7 @@ async def _locked_confirm_bridge(
|
||||
await portal.save()
|
||||
await portal.update_bridge_info()
|
||||
|
||||
asyncio.create_task(portal.update_matrix_room(user, entity, levels=levels))
|
||||
background_task.create(portal.update_matrix_room(user, entity, levels=levels))
|
||||
|
||||
await warn_missing_power(levels, evt)
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -22,7 +22,6 @@ import io
|
||||
from telethon.errors import (
|
||||
AccessTokenExpiredError,
|
||||
AccessTokenInvalidError,
|
||||
FirstNameInvalidError,
|
||||
FloodWaitError,
|
||||
PasswordHashInvalidError,
|
||||
PhoneCodeExpiredError,
|
||||
@@ -31,14 +30,12 @@ from telethon.errors import (
|
||||
PhoneNumberBannedError,
|
||||
PhoneNumberFloodError,
|
||||
PhoneNumberInvalidError,
|
||||
PhoneNumberOccupiedError,
|
||||
PhoneNumberUnoccupiedError,
|
||||
SessionPasswordNeededError,
|
||||
)
|
||||
from telethon.tl.types import User
|
||||
|
||||
from mautrix.client import Client
|
||||
from mautrix.errors import MForbidden
|
||||
from mautrix.types import (
|
||||
EventID,
|
||||
ImageInfo,
|
||||
@@ -47,6 +44,7 @@ from mautrix.types import (
|
||||
TextMessageEventContent,
|
||||
UserID,
|
||||
)
|
||||
from mautrix.util import background_task
|
||||
from mautrix.util.format_duration import format_duration as fmt_duration
|
||||
|
||||
from ... import user as u
|
||||
@@ -94,70 +92,6 @@ async def ping_bot(evt: CommandEvent) -> EventID:
|
||||
)
|
||||
|
||||
|
||||
@command_handler(
|
||||
needs_auth=False,
|
||||
management_only=True,
|
||||
help_section=SECTION_AUTH,
|
||||
help_args="<_phone_> <_full name_>",
|
||||
help_text="Register to Telegram",
|
||||
)
|
||||
async def register(evt: CommandEvent) -> EventID:
|
||||
if await evt.sender.is_logged_in():
|
||||
return await evt.reply("You are already logged in.")
|
||||
elif len(evt.args) < 1:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp register <phone> <full name>`")
|
||||
|
||||
phone_number = evt.args[0]
|
||||
if len(evt.args) == 2:
|
||||
full_name = evt.args[1], ""
|
||||
else:
|
||||
full_name = " ".join(evt.args[1:-1]), evt.args[-1]
|
||||
|
||||
await _request_code(
|
||||
evt,
|
||||
phone_number,
|
||||
{
|
||||
"next": enter_code_register,
|
||||
"action": "Register",
|
||||
"full_name": full_name,
|
||||
},
|
||||
)
|
||||
return await evt.reply(
|
||||
"By signing up for Telegram, you agree to "
|
||||
"the terms of service: https://telegram.org/tos"
|
||||
)
|
||||
|
||||
|
||||
async def enter_code_register(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp <code>`")
|
||||
try:
|
||||
await evt.sender.ensure_started(even_if_no_session=True)
|
||||
first_name, last_name = evt.sender.command_status["full_name"]
|
||||
user = await evt.sender.client.sign_up(evt.args[0], first_name, last_name)
|
||||
asyncio.create_task(evt.sender.post_login(user, first_login=True))
|
||||
evt.sender.command_status = None
|
||||
return await evt.reply(f"Successfully registered to Telegram.")
|
||||
except PhoneNumberOccupiedError:
|
||||
return await evt.reply(
|
||||
"That phone number has already been registered. "
|
||||
"You can log in with `$cmdprefix+sp login`."
|
||||
)
|
||||
except FirstNameInvalidError:
|
||||
return await evt.reply("Invalid name. Please set a Matrix displayname before registering.")
|
||||
except PhoneCodeExpiredError:
|
||||
return await evt.reply(
|
||||
"Phone code expired. Try again with `$cmdprefix+sp register <phone>`."
|
||||
)
|
||||
except PhoneCodeInvalidError:
|
||||
return await evt.reply("Invalid phone code.")
|
||||
except Exception:
|
||||
evt.log.exception("Error sending phone code")
|
||||
return await evt.reply(
|
||||
"Unhandled exception while sending code. Check console for more details."
|
||||
)
|
||||
|
||||
|
||||
@command_handler(
|
||||
needs_auth=False,
|
||||
management_only=True,
|
||||
@@ -317,7 +251,7 @@ async def _request_code(
|
||||
except PhoneNumberUnoccupiedError:
|
||||
return await evt.reply(
|
||||
"That phone number has not been registered. "
|
||||
"Please register with `$cmdprefix+sp register <phone>`."
|
||||
"Please sign up to Telegram using an official mobile client first."
|
||||
)
|
||||
except PhoneNumberInvalidError:
|
||||
return await evt.reply("That phone number is not valid.")
|
||||
@@ -432,7 +366,7 @@ async def _finish_sign_in(evt: CommandEvent, user: User, login_as: u.User = None
|
||||
f"[{existing_user.displayname}] (https://matrix.to/#/{existing_user.mxid})"
|
||||
" was logged out from the account."
|
||||
)
|
||||
asyncio.create_task(login_as.post_login(user, first_login=True))
|
||||
background_task.create(login_as.post_login(user, first_login=True))
|
||||
evt.sender.command_status = None
|
||||
name = f"@{user.username}" if user.username else f"+{user.phone}"
|
||||
if login_as != evt.sender:
|
||||
|
||||
@@ -134,15 +134,16 @@ async def search(evt: CommandEvent) -> EventID:
|
||||
|
||||
@command_handler(
|
||||
help_section=SECTION_CREATING_PORTALS,
|
||||
help_args="<_identifier_>",
|
||||
help_text="Open a private chat with the given Telegram user. The identifier is "
|
||||
"either the internal user ID, the username or the phone number. "
|
||||
"**N.B.** The phone numbers you start chats with must already be in "
|
||||
"your contacts.",
|
||||
help_args="<_username_>",
|
||||
help_text=(
|
||||
"Open a private chat with the given Telegram user. You can also use a "
|
||||
"phone number instead of username, but you must have the number in "
|
||||
"your Telegram contacts for that to work."
|
||||
),
|
||||
)
|
||||
async def pm(evt: CommandEvent) -> EventID:
|
||||
if len(evt.args) == 0:
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp pm <user identifier>`")
|
||||
return await evt.reply("**Usage:** `$cmdprefix+sp pm <username>`")
|
||||
|
||||
try:
|
||||
id = "".join(evt.args).translate({ord(c): None for c in "+()- "})
|
||||
|
||||
@@ -101,6 +101,7 @@ class Config(BaseBridgeConfig):
|
||||
copy("bridge.displayname_preference")
|
||||
copy("bridge.displayname_max_length")
|
||||
copy("bridge.allow_avatar_remove")
|
||||
copy("bridge.allow_contact_info")
|
||||
|
||||
copy("bridge.max_initial_member_sync")
|
||||
copy("bridge.max_member_count")
|
||||
@@ -147,9 +148,18 @@ 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")
|
||||
copy("bridge.message_status_events")
|
||||
copy("bridge.resend_bridge_info")
|
||||
copy("bridge.mute_bridging")
|
||||
@@ -164,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")
|
||||
@@ -195,6 +223,7 @@ class Config(BaseBridgeConfig):
|
||||
|
||||
copy("bridge.filter.mode")
|
||||
copy("bridge.filter.list")
|
||||
copy("bridge.filter.users")
|
||||
|
||||
copy("bridge.command_prefix")
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from asyncpg import Record
|
||||
from attr import dataclass
|
||||
|
||||
from mautrix.types import UserID
|
||||
from mautrix.util.async_db import Database
|
||||
from mautrix.util.async_db import Connection, Database
|
||||
|
||||
from ..types import TelegramID
|
||||
|
||||
@@ -169,8 +169,8 @@ class Backfill:
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def delete_all(cls, user_mxid: UserID) -> None:
|
||||
await cls.db.execute("DELETE FROM backfill_queue WHERE user_mxid=$1", user_mxid)
|
||||
async def delete_all(cls, user_mxid: UserID, conn: Connection | None = None) -> None:
|
||||
await (conn or cls.db).execute("DELETE FROM backfill_queue WHERE user_mxid=$1", user_mxid)
|
||||
|
||||
@classmethod
|
||||
async def delete_for_portal(cls, tgid: int, tg_receiver: int) -> None:
|
||||
@@ -186,7 +186,7 @@ class Backfill:
|
||||
AND type=$4
|
||||
AND dispatch_time IS NULL
|
||||
AND completed_at IS NULL
|
||||
RETURNING {self.columns_str}
|
||||
RETURNING queue_id, {self.columns_str}
|
||||
"""
|
||||
q = f"""
|
||||
INSERT INTO backfill_queue ({self.columns_str})
|
||||
|
||||
@@ -167,7 +167,10 @@ class Portal:
|
||||
"UPDATE portal SET tgid=$1, tg_receiver=$1, peer_type=$2 "
|
||||
"WHERE tgid=$3 AND tg_receiver=$3"
|
||||
)
|
||||
await self.db.execute(q, id, peer_type, self.tgid)
|
||||
clear_queue = "DELETE FROM backfill_queue WHERE portal_tgid=$1 AND portal_tg_receiver=$2"
|
||||
async with self.db.acquire() as conn, conn.transaction():
|
||||
await conn.execute(clear_queue, self.tgid, self.tg_receiver)
|
||||
await conn.execute(q, id, peer_type, self.tgid)
|
||||
self.tgid = id
|
||||
self.tg_receiver = id
|
||||
self.peer_type = peer_type
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -92,9 +92,9 @@ class TelegramFile:
|
||||
|
||||
async def insert(self) -> None:
|
||||
q = (
|
||||
"INSERT INTO telegram_file (id, mxc, mime_type, was_converted, size, width, height, "
|
||||
" thumbnail, decryption_info) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"
|
||||
"INSERT INTO telegram_file (id, mxc, mime_type, was_converted, timestamp,"
|
||||
" size, width, height, thumbnail, decryption_info) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"
|
||||
)
|
||||
await self.db.execute(
|
||||
q,
|
||||
@@ -102,6 +102,7 @@ class TelegramFile:
|
||||
self.mxc,
|
||||
self.mime_type,
|
||||
self.was_converted,
|
||||
self.timestamp,
|
||||
self.size,
|
||||
self.width,
|
||||
self.height,
|
||||
|
||||
@@ -123,19 +123,55 @@ class PgSession(MemorySession):
|
||||
date = datetime.datetime.utcfromtimestamp(row["date"])
|
||||
return updates.State(row["pts"], row["qts"], date, row["seq"], row["unread_count"])
|
||||
|
||||
_set_update_state_q = """
|
||||
INSERT INTO telethon_update_state (session_id, entity_id, pts, qts, date, seq, unread_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (session_id, entity_id) DO UPDATE SET
|
||||
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
|
||||
unread_count=excluded.unread_count
|
||||
"""
|
||||
|
||||
async def set_update_state(self, entity_id: int, row: updates.State) -> None:
|
||||
q = """
|
||||
INSERT INTO telethon_update_state(session_id, entity_id, pts, qts, date, seq, unread_count)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (session_id, entity_id) DO UPDATE SET
|
||||
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
|
||||
unread_count=excluded.unread_count
|
||||
"""
|
||||
q = self._set_update_state_q
|
||||
ts = row.date.timestamp()
|
||||
await self.db.execute(
|
||||
q, self.session_id, entity_id, row.pts, row.qts, ts, row.seq, row.unread_count
|
||||
)
|
||||
|
||||
async def set_update_states(self, rows: list[tuple[int, updates.State]]) -> None:
|
||||
rows = [
|
||||
(
|
||||
self.session_id,
|
||||
entity_id,
|
||||
row.pts,
|
||||
row.qts,
|
||||
row.date.timestamp(),
|
||||
row.seq,
|
||||
row.unread_count,
|
||||
)
|
||||
for entity_id, row in rows
|
||||
]
|
||||
if self.db.scheme == Scheme.POSTGRES:
|
||||
q = """
|
||||
INSERT INTO telethon_update_state (
|
||||
session_id, entity_id, pts, qts, date, seq, unread_count
|
||||
)
|
||||
VALUES (
|
||||
$1,
|
||||
unnest($2::bigint[]), unnest($3::bigint[]), unnest($4::bigint[]),
|
||||
unnest($5::bigint[]), unnest($6::bigint[]), unnest($7::integer[])
|
||||
)
|
||||
ON CONFLICT (session_id, entity_id) DO UPDATE SET
|
||||
pts=excluded.pts, qts=excluded.qts, date=excluded.date, seq=excluded.seq,
|
||||
unread_count=excluded.unread_count
|
||||
"""
|
||||
_, entity_ids, ptses, qtses, timestamps, seqs, unread_counts = zip(*rows)
|
||||
await self.db.execute(
|
||||
q, self.session_id, entity_ids, ptses, qtses, timestamps, seqs, unread_counts
|
||||
)
|
||||
else:
|
||||
await self.db.executemany(self._set_update_state_q, rows)
|
||||
|
||||
async def delete_update_state(self, entity_id: int) -> None:
|
||||
q = "DELETE FROM telethon_update_state WHERE session_id=$1 AND entity_id=$2"
|
||||
await self.db.execute(q, self.session_id, entity_id)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -21,9 +21,10 @@ from asyncpg import Record
|
||||
from attr import dataclass
|
||||
|
||||
from mautrix.types import UserID
|
||||
from mautrix.util.async_db import Database, Scheme
|
||||
from mautrix.util.async_db import Connection, Database, Scheme
|
||||
|
||||
from ..types import TelegramID
|
||||
from .backfill_queue import Backfill
|
||||
|
||||
fake_db = Database.create("") if TYPE_CHECKING else None
|
||||
|
||||
@@ -73,6 +74,20 @@ class User:
|
||||
async def delete(self) -> None:
|
||||
await self.db.execute('DELETE FROM "user" WHERE mxid=$1', self.mxid)
|
||||
|
||||
async def remove_tgid(self) -> None:
|
||||
async with self.db.acquire() as conn, conn.transaction():
|
||||
if self.tgid:
|
||||
await conn.execute('DELETE FROM contact WHERE "user"=$1', self.tgid)
|
||||
await conn.execute('DELETE FROM user_portal WHERE "user"=$1', self.tgid)
|
||||
await Backfill.delete_all(self.mxid, conn=conn)
|
||||
self.tgid = None
|
||||
self.tg_username = None
|
||||
self.tg_phone = None
|
||||
self.is_bot = False
|
||||
self.is_premium = False
|
||||
self.saved_contacts = 0
|
||||
await self.save(conn=conn)
|
||||
|
||||
@property
|
||||
def _values(self):
|
||||
return (
|
||||
@@ -85,13 +100,13 @@ class User:
|
||||
self.saved_contacts,
|
||||
)
|
||||
|
||||
async def save(self) -> None:
|
||||
async def save(self, conn: Connection | None = None) -> None:
|
||||
q = """
|
||||
UPDATE "user" SET tgid=$2, tg_username=$3, tg_phone=$4, is_bot=$5, is_premium=$6,
|
||||
saved_contacts=$7
|
||||
WHERE mxid=$1
|
||||
"""
|
||||
await self.db.execute(q, *self._values)
|
||||
await (conn or self.db).execute(q, *self._values)
|
||||
|
||||
async def insert(self) -> None:
|
||||
q = """
|
||||
|
||||
@@ -145,6 +145,9 @@ bridge:
|
||||
# as there's no way to determine whether an avatar is removed or just hidden from some users. If
|
||||
# you're on a single-user instance, this should be safe to enable.
|
||||
allow_avatar_remove: false
|
||||
# Should contact names and profile pictures be allowed?
|
||||
# This is only safe to enable on single-user instances.
|
||||
allow_contact_info: false
|
||||
|
||||
# Maximum number of members to sync per portal when starting up. Other members will be
|
||||
# synced when they send messages. The maximum is 10000, after which the Telegram server
|
||||
@@ -271,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:
|
||||
@@ -306,14 +326,21 @@ 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
|
||||
# Whether or not delivery errors should be reported as messages in the Matrix room.
|
||||
delivery_error_reports: false
|
||||
# Should errors in incoming message handling send a message to the Matrix room?
|
||||
incoming_bridge_error_reports: false
|
||||
# Whether the bridge should send the message status as a custom com.beeper.message_send_status event.
|
||||
message_status_events: false
|
||||
# Set this to true to tell the bridge to re-send m.bridge events to all rooms on the next run.
|
||||
@@ -355,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
|
||||
@@ -369,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:
|
||||
@@ -453,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".
|
||||
@@ -462,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])
|
||||
|
||||
+304
-76
@@ -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 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:
|
||||
@@ -727,12 +789,14 @@ class Portal(DBPortal, BasePortal):
|
||||
self.log.exception(f"Failed to get entity through {user.tgid} for update")
|
||||
return self.mxid
|
||||
update = self.update_matrix_room(user, entity)
|
||||
asyncio.create_task(update)
|
||||
background_task.create(update)
|
||||
await self.invite_to_matrix(invites or [])
|
||||
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)
|
||||
@@ -805,6 +901,12 @@ class Portal(DBPortal, BasePortal):
|
||||
participants_count = 2
|
||||
if isinstance(entity, Chat):
|
||||
participants_count = entity.participants_count
|
||||
if entity.deactivated or entity.migrated_to:
|
||||
self.log.error(
|
||||
"Throwing error for attempted portal creation "
|
||||
f"({entity.deactivated=}, {entity.migrated_to=})"
|
||||
)
|
||||
raise RuntimeError("Tried to create portal for deactivated chat")
|
||||
elif isinstance(entity, Channel) and not entity.broadcast:
|
||||
participants_count = entity.participants_count
|
||||
if participants_count is None and self.config["bridge.max_member_count"] > 0:
|
||||
@@ -894,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
|
||||
@@ -902,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),
|
||||
@@ -914,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,
|
||||
@@ -929,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:
|
||||
@@ -944,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)
|
||||
@@ -1240,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)
|
||||
@@ -1252,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
|
||||
@@ -1260,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,
|
||||
@@ -1275,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
|
||||
@@ -1525,11 +1631,11 @@ class Portal(DBPortal, BasePortal):
|
||||
)
|
||||
if self.peer_type == "channel":
|
||||
if not self.megagroup:
|
||||
asyncio.create_task(
|
||||
background_task.create(
|
||||
self._try_handle_read_for_sponsored_msg(user, event_id, timestamp)
|
||||
)
|
||||
else:
|
||||
asyncio.create_task(self._poll_telegram_reactions(user))
|
||||
background_task.create(self._poll_telegram_reactions(user))
|
||||
|
||||
async def _preproc_kick_ban(
|
||||
self, user: u.User | p.Puppet, source: u.User
|
||||
@@ -1772,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,
|
||||
)
|
||||
@@ -1784,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,
|
||||
)
|
||||
@@ -1964,7 +2070,7 @@ class Portal(DBPortal, BasePortal):
|
||||
message_type=msgtype,
|
||||
)
|
||||
await self._send_delivery_receipt(event_id)
|
||||
asyncio.create_task(self._send_message_status(event_id, err=None))
|
||||
background_task.create(self._send_message_status(event_id, err=None))
|
||||
if response.ttl_period:
|
||||
await self._mark_disappearing(
|
||||
event_id=event_id,
|
||||
@@ -1972,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
|
||||
@@ -1989,11 +2123,11 @@ 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
|
||||
status.fill_legacy_booleans()
|
||||
|
||||
await intent.send_message_event(
|
||||
room_id=self.mxid,
|
||||
@@ -2258,7 +2392,7 @@ class Portal(DBPortal, BasePortal):
|
||||
EventType.ROOM_REDACTION,
|
||||
)
|
||||
await self._send_delivery_receipt(redaction_event_id)
|
||||
asyncio.create_task(self._send_message_status(redaction_event_id, err=None))
|
||||
background_task.create(self._send_message_status(redaction_event_id, err=None))
|
||||
|
||||
async def _handle_matrix_reaction_deletion(
|
||||
self, deleter: u.User, event_id: EventID, tg_space: TelegramID
|
||||
@@ -2371,7 +2505,7 @@ class Portal(DBPortal, BasePortal):
|
||||
EventType.REACTION,
|
||||
)
|
||||
await self._send_delivery_receipt(reaction_event_id)
|
||||
asyncio.create_task(self._send_message_status(reaction_event_id, err=None))
|
||||
background_task.create(self._send_message_status(reaction_event_id, err=None))
|
||||
|
||||
async def _handle_matrix_reaction(
|
||||
self,
|
||||
@@ -2574,7 +2708,7 @@ class Portal(DBPortal, BasePortal):
|
||||
# Ignore typing notifications from double puppeted users to avoid echoing
|
||||
return
|
||||
is_typing = isinstance(update.action, SendMessageTypingAction)
|
||||
await user.default_mxid_intent.set_typing(self.mxid, is_typing=is_typing)
|
||||
await user.default_mxid_intent.set_typing(self.mxid, timeout=5000 if is_typing else 0)
|
||||
|
||||
async def handle_telegram_edit(
|
||||
self, source: au.AbstractUser, sender: p.Puppet | None, evt: Message
|
||||
@@ -2587,7 +2721,7 @@ class Portal(DBPortal, BasePortal):
|
||||
return
|
||||
|
||||
if self.peer_type != "channel" and isinstance(evt, Message) and evt.reactions is not None:
|
||||
asyncio.create_task(
|
||||
background_task.create(
|
||||
self.try_handle_telegram_reactions(source, TelegramID(evt.id), evt.reactions)
|
||||
)
|
||||
sender_id = sender.tgid if sender else self.tgid
|
||||
@@ -2648,7 +2782,7 @@ class Portal(DBPortal, BasePortal):
|
||||
source, intent, is_bot, evt, no_reply_fallback=True
|
||||
)
|
||||
converted.content.set_edit(editing_msg.mxid)
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
await intent.set_typing(self.mxid, timeout=0)
|
||||
timestamp = evt.edit_date if evt.edit_date != evt.date else None
|
||||
event_id = await self._send_message(
|
||||
intent, converted.content, timestamp=timestamp, event_type=converted.type
|
||||
@@ -2666,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,
|
||||
@@ -2719,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:
|
||||
@@ -2805,8 +2945,11 @@ class Portal(DBPortal, BasePortal):
|
||||
elif not insertion_id:
|
||||
insertion_id = self.base_insertion_id
|
||||
await self.save()
|
||||
# TODO this should probably check actual event count instead of message count
|
||||
if event_count > 0 and self.backfill_msc2716:
|
||||
if (
|
||||
event_count > 0
|
||||
and self.backfill_msc2716
|
||||
and (not forward or not self.bridge.homeserver_software.is_hungry)
|
||||
):
|
||||
await self.main_intent.send_state_event(
|
||||
self.mxid,
|
||||
StateMarker,
|
||||
@@ -3164,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
|
||||
|
||||
@@ -3194,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
|
||||
@@ -3227,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)
|
||||
@@ -3244,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,
|
||||
@@ -3264,9 +3411,29 @@ class Portal(DBPortal, BasePortal):
|
||||
|
||||
async def handle_telegram_message(
|
||||
self, source: au.AbstractUser, sender: p.Puppet | None, evt: Message
|
||||
) -> None:
|
||||
try:
|
||||
await self._handle_telegram_message(source, sender, evt)
|
||||
except Exception:
|
||||
sender_id = sender.tgid if sender else None
|
||||
self.log.exception(
|
||||
f"Failed to handle Telegram message {evt.id} from {sender_id} via {source.tgid}"
|
||||
)
|
||||
if self.config["bridge.incoming_bridge_error_reports"]:
|
||||
intent = sender.intent_for(self) if sender else self.main_intent
|
||||
await self._send_message(
|
||||
intent,
|
||||
TextMessageEventContent(
|
||||
msgtype=MessageType.NOTICE,
|
||||
body="Error processing message from Telegram",
|
||||
),
|
||||
)
|
||||
|
||||
async def _handle_telegram_message(
|
||||
self, source: au.AbstractUser, sender: p.Puppet | None, evt: Message
|
||||
) -> None:
|
||||
if not self.mxid:
|
||||
self.log.trace("Got telegram message %d, but no room exists, creating...", evt.id)
|
||||
self.log.debug("Got telegram message %d, but no room exists, creating...", evt.id)
|
||||
await self.create_matrix_room(source, invites=[source.mxid], update_if_exists=False)
|
||||
if not self.mxid:
|
||||
self.log.warning("Room doesn't exist even after creating, dropping %d", evt.id)
|
||||
@@ -3333,12 +3500,17 @@ class Portal(DBPortal, BasePortal):
|
||||
f"Telegram user {sender.tgid} sent a message, but doesn't have a displayname,"
|
||||
" updating info..."
|
||||
)
|
||||
entity = await source.client.get_entity(sender.peer)
|
||||
await sender.update_info(source, entity)
|
||||
if not sender.displayname:
|
||||
self.log.debug(
|
||||
f"Telegram user {sender.tgid} doesn't have a displayname even after"
|
||||
f" updating with data {entity!s}"
|
||||
try:
|
||||
entity = await source.client.get_entity(sender.peer)
|
||||
await sender.update_info(source, entity)
|
||||
if not sender.displayname:
|
||||
self.log.debug(
|
||||
f"Telegram user {sender.tgid} doesn't have a displayname even after"
|
||||
f" updating with data {entity!s}"
|
||||
)
|
||||
except ValueError as e:
|
||||
self.log.warning(
|
||||
f"Couldn't find entity to update profile of {sender.tgid}", exc_info=True
|
||||
)
|
||||
|
||||
if sender:
|
||||
@@ -3350,7 +3522,7 @@ class Portal(DBPortal, BasePortal):
|
||||
converted = await self._msg_conv.convert(source, intent, is_bot, evt)
|
||||
if not converted:
|
||||
return
|
||||
await intent.set_typing(self.mxid, is_typing=False)
|
||||
await intent.set_typing(self.mxid, timeout=0)
|
||||
event_id = await self._send_message(
|
||||
intent, converted.content, timestamp=evt.date, event_type=converted.type
|
||||
)
|
||||
@@ -3397,7 +3569,7 @@ class Portal(DBPortal, BasePortal):
|
||||
await intent.redact(self.mxid, event_id)
|
||||
return
|
||||
if isinstance(evt, Message) and evt.reactions:
|
||||
asyncio.create_task(
|
||||
background_task.create(
|
||||
self.try_handle_telegram_reactions(
|
||||
source, dbm.tgid, evt.reactions, dbm=dbm, timestamp=evt.date
|
||||
)
|
||||
@@ -3418,7 +3590,7 @@ class Portal(DBPortal, BasePortal):
|
||||
dm = DisappearingMessage(self.mxid, event_id, seconds, expiration_ts=expires_at * 1000)
|
||||
await dm.insert()
|
||||
if expires_at:
|
||||
asyncio.create_task(self._disappear_event(dm))
|
||||
background_task.create(self._disappear_event(dm))
|
||||
|
||||
async def _create_room_on_action(
|
||||
self, source: au.AbstractUser, action: TypeMessageAction
|
||||
@@ -3432,6 +3604,10 @@ class Portal(DBPortal, BasePortal):
|
||||
MessageActionChatJoinedByRequest,
|
||||
)
|
||||
if isinstance(action, create_and_exit) or isinstance(action, create_and_continue):
|
||||
self.log.debug(
|
||||
f"Got telegram action of type {type(action).__name__},"
|
||||
" but no room exists, creating..."
|
||||
)
|
||||
await self.create_matrix_room(
|
||||
source, invites=[source.mxid], update_if_exists=isinstance(action, create_and_exit)
|
||||
)
|
||||
@@ -3439,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:
|
||||
@@ -3466,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
|
||||
@@ -3772,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))
|
||||
@@ -409,6 +410,8 @@ class TelegramMessageConverter:
|
||||
mimetype=file.mime_type,
|
||||
size=self._photo_size_key(largest_size),
|
||||
)
|
||||
if media.spoiler:
|
||||
info["fi.mau.telegram.spoiler"] = True
|
||||
ext = sane_mimetypes.guess_extension(file.mime_type)
|
||||
name = f"disappearing_image{ext}" if media.ttl_seconds else f"image{ext}"
|
||||
content = MediaMessageEventContent(
|
||||
@@ -494,12 +497,15 @@ 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
|
||||
info["fi.mau.no_audio"] = True
|
||||
if evt.media.spoiler:
|
||||
info["fi.mau.telegram.spoiler"] = True
|
||||
if not name:
|
||||
ext = sane_mimetypes.guess_extension(file.mime_type)
|
||||
ext = sane_mimetypes.guess_extension(file.mime_type) or ""
|
||||
name = "unnamed_file" + ext
|
||||
|
||||
content = MediaMessageEventContent(
|
||||
|
||||
+59
-10
@@ -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)
|
||||
@@ -334,13 +369,15 @@ class Puppet(DBPuppet, BasePuppet):
|
||||
|
||||
if isinstance(info, UpdateUserName):
|
||||
info = await (client_override or source.client).get_entity(self.peer)
|
||||
if isinstance(info, Channel) or not info.contact:
|
||||
self.displayname_contact = False
|
||||
elif not self.displayname_contact:
|
||||
if not self.displayname:
|
||||
self.displayname_contact = True
|
||||
else:
|
||||
return False
|
||||
is_contact_name = not isinstance(info, Channel) and info.contact
|
||||
# Reject name change if the contact status is moving in an unwanted direction,
|
||||
# and we already have a name for the ghost.
|
||||
if (
|
||||
is_contact_name != self.displayname_contact
|
||||
and is_contact_name != self.config["bridge.allow_contact_info"]
|
||||
and self.displayname
|
||||
):
|
||||
return False
|
||||
|
||||
displayname, quality = self.get_displayname(info)
|
||||
needs_reset = displayname != self.displayname or not self.name_set
|
||||
@@ -348,12 +385,14 @@ class Puppet(DBPuppet, BasePuppet):
|
||||
if needs_reset and is_high_quality:
|
||||
allow_because = f"{allow_because} and quality {quality} >= {self.displayname_quality}"
|
||||
self.log.debug(
|
||||
f"Updating displayname of {self.id} (src: {source.tgid}, allowed "
|
||||
f"because {allow_because}) from {self.displayname} to {displayname}"
|
||||
f"Updating displayname of {self.id} (src: {source.tgid}, "
|
||||
f"contact: {is_contact_name}, allowed because {allow_because}) "
|
||||
f"from {self.displayname} to {displayname}"
|
||||
)
|
||||
self.log.trace("Displayname source data: %s", info)
|
||||
self.displayname = displayname
|
||||
self.displayname_source = source.tgid
|
||||
self.displayname_contact = is_contact_name
|
||||
self.displayname_quality = quality
|
||||
try:
|
||||
await self.default_mxid_intent.set_displayname(
|
||||
@@ -378,6 +417,16 @@ class Puppet(DBPuppet, BasePuppet):
|
||||
) -> bool:
|
||||
if self.disable_updates:
|
||||
return False
|
||||
if (
|
||||
isinstance(photo, UserProfilePhoto)
|
||||
and photo.personal
|
||||
and not self.config["bridge.allow_contact_info"]
|
||||
):
|
||||
self.log.trace(
|
||||
"Dropping user avatar as it's personal "
|
||||
"and contact info is disabled in bridge config"
|
||||
)
|
||||
return False
|
||||
|
||||
if photo is None or isinstance(photo, (UserProfilePhotoEmpty, ChatPhotoEmpty)):
|
||||
photo_id = ""
|
||||
|
||||
+108
-45
@@ -22,6 +22,7 @@ import time
|
||||
|
||||
from telethon.errors import (
|
||||
AuthKeyDuplicatedError,
|
||||
AuthKeyError,
|
||||
RPCError,
|
||||
TakeoutInitDelayError,
|
||||
UnauthorizedError,
|
||||
@@ -38,6 +39,9 @@ from telethon.tl.types import (
|
||||
ChatForbidden,
|
||||
InputUserSelf,
|
||||
Message,
|
||||
MessageActionContactSignUp,
|
||||
MessageActionHistoryClear,
|
||||
MessageService,
|
||||
NotifyPeer,
|
||||
PeerUser,
|
||||
TypeUpdate,
|
||||
@@ -51,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
|
||||
@@ -58,6 +63,7 @@ from mautrix.bridge import BaseUser, async_getter_lock
|
||||
from mautrix.client import Client
|
||||
from mautrix.errors import MatrixRequestError, MNotFound
|
||||
from mautrix.types import PushActionType, PushRuleKind, PushRuleScope, RoomID, RoomTagInfo, UserID
|
||||
from mautrix.util import background_task
|
||||
from mautrix.util.bridge_state import BridgeState, BridgeStateEvent
|
||||
from mautrix.util.opt_prometheus import Gauge
|
||||
|
||||
@@ -104,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,
|
||||
@@ -141,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,
|
||||
@@ -207,17 +215,30 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
async with self._ensure_started_lock:
|
||||
return cast(User, await super().ensure_started(even_if_no_session))
|
||||
|
||||
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
|
||||
error_code = "tg-auth-error"
|
||||
if isinstance(err, AuthKeyDuplicatedError):
|
||||
error_code = "tg-auth-key-duplicated"
|
||||
message = None
|
||||
else:
|
||||
message = str(err)
|
||||
self.log.warning(f"User got signed out with {err}, deleting data...")
|
||||
try:
|
||||
await self.log_out(
|
||||
state=BridgeStateEvent.BAD_CREDENTIALS,
|
||||
error=error_code,
|
||||
message=message,
|
||||
delete=False,
|
||||
)
|
||||
except Exception:
|
||||
self.log.exception("Error handling external logout")
|
||||
|
||||
async def start(self, delete_unless_authenticated: bool = False) -> User:
|
||||
try:
|
||||
await super().start()
|
||||
except AuthKeyDuplicatedError:
|
||||
except AuthKeyDuplicatedError as e:
|
||||
self.log.warning("Got AuthKeyDuplicatedError in start()")
|
||||
await self.push_bridge_state(
|
||||
BridgeStateEvent.BAD_CREDENTIALS, error="tg-auth-key-duplicated"
|
||||
)
|
||||
await self.client.disconnect()
|
||||
await self.client.session.delete()
|
||||
self.client = None
|
||||
await self.on_signed_out(e)
|
||||
if not delete_unless_authenticated:
|
||||
# The caller wants the client to be connected, so restart the connection.
|
||||
await super().start()
|
||||
@@ -237,12 +258,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
if delete_unless_authenticated or self.tgid:
|
||||
self.log.error(f"Authorization error in start(): {type(e)}: {e}")
|
||||
if self.tgid:
|
||||
await self.push_bridge_state(
|
||||
BridgeStateEvent.BAD_CREDENTIALS,
|
||||
error="tg-auth-error",
|
||||
message=str(e),
|
||||
ttl=3600,
|
||||
)
|
||||
await self.on_signed_out(e)
|
||||
except RPCError as e:
|
||||
self.log.error(f"Unknown RPC error in start(): {type(e)}: {e}")
|
||||
if self.tgid:
|
||||
@@ -250,10 +266,10 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
else:
|
||||
# Authenticated, run post login
|
||||
self.log.debug(f"Ensuring post_login() for {self.name}")
|
||||
asyncio.create_task(self.post_login())
|
||||
background_task.create(self.post_login())
|
||||
return self
|
||||
# Not authenticated, delete data if necessary
|
||||
if delete_unless_authenticated:
|
||||
if delete_unless_authenticated and self.client is not None:
|
||||
self.log.debug(f"Unauthenticated user {self.name} start()ed, deleting session...")
|
||||
await self.client.disconnect()
|
||||
await self.client.session.delete()
|
||||
@@ -284,18 +300,18 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
BridgeStateEvent.BACKFILLING
|
||||
if self._is_backfilling
|
||||
else BridgeStateEvent.CONNECTED,
|
||||
ttl=3600,
|
||||
info=self._bridge_state_info,
|
||||
)
|
||||
else:
|
||||
await self.push_bridge_state(
|
||||
BridgeStateEvent.TRANSIENT_DISCONNECT, ttl=240, error="tg-not-connected"
|
||||
BridgeStateEvent.TRANSIENT_DISCONNECT, error="tg-not-connected"
|
||||
)
|
||||
|
||||
async def fill_bridge_state(self, state: BridgeState) -> None:
|
||||
await super().fill_bridge_state(state)
|
||||
state.remote_id = str(self.tgid)
|
||||
state.remote_name = self.human_tg_id
|
||||
if self.tgid:
|
||||
state.remote_id = str(self.tgid)
|
||||
state.remote_name = self.human_tg_id
|
||||
|
||||
async def get_bridge_states(self) -> list[BridgeState]:
|
||||
if not self.tgid:
|
||||
@@ -477,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:
|
||||
@@ -525,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:
|
||||
@@ -567,27 +586,46 @@ class User(DBUser, AbstractUser, BaseUser):
|
||||
except MatrixRequestError:
|
||||
pass
|
||||
|
||||
async def log_out(self) -> bool:
|
||||
async def log_out(
|
||||
self,
|
||||
delete: bool = True,
|
||||
do_logout: bool = True,
|
||||
state: BridgeStateEvent = BridgeStateEvent.LOGGED_OUT,
|
||||
error: str | None = None,
|
||||
message: str | None = None,
|
||||
) -> bool:
|
||||
puppet = await pu.Puppet.get_by_tgid(self.tgid)
|
||||
if puppet.is_real_user:
|
||||
if puppet is not None and puppet.is_real_user:
|
||||
await puppet.switch_mxid(None, None)
|
||||
try:
|
||||
await self.kick_from_portals()
|
||||
except Exception:
|
||||
self.log.exception("Failed to kick user from portals on logout")
|
||||
await self.push_bridge_state(BridgeStateEvent.LOGGED_OUT)
|
||||
if self.tgid:
|
||||
try:
|
||||
del self.by_tgid[self.tgid]
|
||||
except KeyError:
|
||||
pass
|
||||
self.tgid = None
|
||||
ok = await self.client.log_out()
|
||||
sess = self.client.session
|
||||
await self.stop()
|
||||
await sess.delete()
|
||||
await self.delete()
|
||||
self.by_mxid.pop(self.mxid, None)
|
||||
ok = False
|
||||
if self.client is not None:
|
||||
sess = self.client.session
|
||||
# Try to send a logout request. If it succeeds, this also disconnects the client and
|
||||
# deletes the session, but we do those again later just to be safe.
|
||||
if do_logout:
|
||||
ok = await self.client.log_out()
|
||||
# Force-disconnect the client and set it to None
|
||||
await self.stop()
|
||||
await sess.delete()
|
||||
|
||||
# TODO send a management room notice for non-manual logouts?
|
||||
await self.push_bridge_state(state, error=error, message=message)
|
||||
if delete:
|
||||
await self.delete()
|
||||
self.by_mxid.pop(self.mxid, None)
|
||||
self.log.info("User deleted")
|
||||
else:
|
||||
await self.remove_tgid()
|
||||
self.log.info("User telegram ID cleared")
|
||||
self._track_metric(METRIC_LOGGED_IN, False)
|
||||
return ok
|
||||
|
||||
@@ -714,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": (
|
||||
@@ -730,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:
|
||||
@@ -743,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}")
|
||||
@@ -756,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,
|
||||
@@ -764,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,
|
||||
@@ -776,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=}, "
|
||||
@@ -940,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:
|
||||
|
||||
@@ -22,7 +22,6 @@ import asyncio
|
||||
import logging
|
||||
import pickle
|
||||
import pkgutil
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from asyncpg import UniqueViolationError
|
||||
@@ -46,7 +45,7 @@ from telethon.tl.types import (
|
||||
)
|
||||
|
||||
from mautrix.appservice import IntentAPI
|
||||
from mautrix.util import magic, variation_selector
|
||||
from mautrix.util import ffmpeg, magic, variation_selector
|
||||
|
||||
from .. import abstract_user as au
|
||||
from ..db import TelegramFile as DBTelegramFile
|
||||
@@ -61,11 +60,6 @@ try:
|
||||
except ImportError:
|
||||
Image = None
|
||||
|
||||
try:
|
||||
from moviepy.editor import VideoFileClip
|
||||
except ImportError:
|
||||
VideoFileClip = None
|
||||
|
||||
try:
|
||||
from mautrix.crypto.attachments import encrypt_attachment
|
||||
except ImportError:
|
||||
@@ -103,29 +97,16 @@ def convert_image(
|
||||
return source_mime, file, None, None
|
||||
|
||||
|
||||
def _read_video_thumbnail(
|
||||
data: bytes,
|
||||
video_ext: str = "mp4",
|
||||
frame_ext: str = "png",
|
||||
max_size: tuple[int, int] = (1024, 720),
|
||||
) -> tuple[bytes, int, int]:
|
||||
with tempfile.NamedTemporaryFile(prefix="mxtg_video_", suffix=f".{video_ext}") as file:
|
||||
# We don't have any way to read the video from memory, so save it to disk.
|
||||
file.write(data)
|
||||
|
||||
# Read temp file and get frame
|
||||
frame = VideoFileClip(file.name).get_frame(0)
|
||||
|
||||
# Convert to png and save to BytesIO
|
||||
image = Image.fromarray(frame).convert("RGBA")
|
||||
|
||||
thumbnail_file = BytesIO()
|
||||
if max_size:
|
||||
image.thumbnail(max_size, Image.ANTIALIAS)
|
||||
image.save(thumbnail_file, frame_ext)
|
||||
|
||||
w, h = image.size
|
||||
return thumbnail_file.getvalue(), w, h
|
||||
async def _read_video_thumbnail(data: bytes, mime_type: str) -> tuple[bytes, int, int]:
|
||||
first_frame = await ffmpeg.convert_bytes(
|
||||
data,
|
||||
output_extension=".png",
|
||||
output_args=("-update", "1", "-frames:v", "1"),
|
||||
input_mime=mime_type,
|
||||
logger=log,
|
||||
)
|
||||
width, height = Image.open(BytesIO(first_frame)).size
|
||||
return first_frame, width, height
|
||||
|
||||
|
||||
def _location_to_id(location: TypeLocation) -> str:
|
||||
@@ -151,7 +132,7 @@ async def transfer_thumbnail_to_matrix(
|
||||
height: int | None = None,
|
||||
async_upload: bool = False,
|
||||
) -> DBTelegramFile | None:
|
||||
if not Image or not VideoFileClip:
|
||||
if not Image or not ffmpeg.ffmpeg_path:
|
||||
return None
|
||||
|
||||
loc_id = _location_to_id(thumbnail_loc)
|
||||
@@ -170,10 +151,12 @@ async def transfer_thumbnail_to_matrix(
|
||||
video_ext = sane_mimetypes.guess_extension(mime_type)
|
||||
if custom_data:
|
||||
file = custom_data
|
||||
elif VideoFileClip and video_ext and video:
|
||||
elif video_ext and video:
|
||||
log.debug(f"Generating thumbnail for video {loc_id} with ffmpeg")
|
||||
try:
|
||||
file, width, height = _read_video_thumbnail(video, video_ext, frame_ext="png")
|
||||
except OSError:
|
||||
file, width, height = await _read_video_thumbnail(video, mime_type=mime_type)
|
||||
except Exception:
|
||||
log.warning(f"Failed to generate thumbnail for {loc_id}", exc_info=True)
|
||||
return None
|
||||
mime_type = "image/png"
|
||||
else:
|
||||
@@ -350,10 +333,10 @@ async def _unlocked_transfer_file_to_matrix(
|
||||
client, intent, loc_id, location, filename, encrypt, parallel_id
|
||||
)
|
||||
mime_type = location.mime_type
|
||||
file = None
|
||||
unencrypted_file = None
|
||||
else:
|
||||
try:
|
||||
file = await client.download_file(location)
|
||||
unencrypted_file = file = await client.download_file(location)
|
||||
except (LocationInvalidError, FileIdInvalidError):
|
||||
return None
|
||||
except (AuthBytesInvalidError, AuthKeyInvalidError, SecurityError) as e:
|
||||
@@ -401,34 +384,37 @@ async def _unlocked_transfer_file_to_matrix(
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"):
|
||||
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
|
||||
thumbnail = thumbnail.location
|
||||
try:
|
||||
try:
|
||||
if thumbnail and (mime_type.startswith("video/") or mime_type == "image/gif"):
|
||||
if isinstance(thumbnail, (PhotoSize, PhotoCachedSize)):
|
||||
thumbnail = thumbnail.location
|
||||
try:
|
||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(
|
||||
client,
|
||||
intent,
|
||||
thumbnail,
|
||||
video=unencrypted_file,
|
||||
mime_type=mime_type,
|
||||
encrypt=encrypt,
|
||||
async_upload=async_upload,
|
||||
)
|
||||
except FileIdInvalidError:
|
||||
log.warning(f"Failed to transfer thumbnail {thumbnail!s}", exc_info=True)
|
||||
elif converted_anim and converted_anim.thumbnail_data:
|
||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(
|
||||
client,
|
||||
intent,
|
||||
thumbnail,
|
||||
video=file,
|
||||
mime_type=mime_type,
|
||||
location,
|
||||
video=None,
|
||||
encrypt=encrypt,
|
||||
custom_data=converted_anim.thumbnail_data,
|
||||
mime_type=converted_anim.thumbnail_mime,
|
||||
width=converted_anim.width,
|
||||
height=converted_anim.height,
|
||||
async_upload=async_upload,
|
||||
)
|
||||
except FileIdInvalidError:
|
||||
log.warning(f"Failed to transfer thumbnail for {thumbnail!s}", exc_info=True)
|
||||
elif converted_anim and converted_anim.thumbnail_data:
|
||||
db_file.thumbnail = await transfer_thumbnail_to_matrix(
|
||||
client,
|
||||
intent,
|
||||
location,
|
||||
video=None,
|
||||
encrypt=encrypt,
|
||||
custom_data=converted_anim.thumbnail_data,
|
||||
mime_type=converted_anim.thumbnail_mime,
|
||||
width=converted_anim.width,
|
||||
height=converted_anim.height,
|
||||
async_upload=async_upload,
|
||||
)
|
||||
except Exception:
|
||||
log.exception(f"Failed to transfer thumbnail for {loc_id}")
|
||||
|
||||
try:
|
||||
await db_file.insert()
|
||||
|
||||
@@ -35,9 +35,11 @@ from telethon.errors import (
|
||||
PhoneNumberInvalidError,
|
||||
PhoneNumberUnoccupiedError,
|
||||
SessionPasswordNeededError,
|
||||
SessionRevokedError,
|
||||
)
|
||||
|
||||
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
|
||||
from mautrix.util import background_task
|
||||
from mautrix.util.format_duration import format_duration
|
||||
|
||||
from ...commands.telegram.auth import enter_password
|
||||
@@ -199,7 +201,7 @@ class AuthAPI(abc.ABC):
|
||||
existing_user = await User.get_by_tgid(user_info.id)
|
||||
if existing_user and existing_user != user:
|
||||
await existing_user.log_out()
|
||||
asyncio.create_task(user.post_login(user_info, first_login=True))
|
||||
background_task.create(user.post_login(user_info, first_login=True))
|
||||
if user.command_status and user.command_status["action"] == "Login":
|
||||
user.command_status = None
|
||||
|
||||
@@ -287,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":
|
||||
@@ -341,12 +354,42 @@ class AuthAPI(abc.ABC):
|
||||
errcode="password_invalid",
|
||||
error="Incorrect password.",
|
||||
)
|
||||
except Exception:
|
||||
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):
|
||||
return self.get_login_response(
|
||||
mxid=user.mxid,
|
||||
state="request",
|
||||
status=400,
|
||||
errcode="phone_code_not_entered",
|
||||
error="Please request a new phone code and enter it first.",
|
||||
)
|
||||
return self.get_login_response(
|
||||
mxid=user.mxid,
|
||||
state="password",
|
||||
status=500,
|
||||
errcode="unknown_error",
|
||||
error="Internal server error while sending password.",
|
||||
error=f"Internal server error while sending password. {e}",
|
||||
)
|
||||
|
||||
@@ -17,10 +17,14 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
from telethon.errors import SessionPasswordNeededError
|
||||
from telethon.tl.custom import QRLogin
|
||||
from telethon.tl.functions.messages import GetAllStickersRequest
|
||||
from telethon.tl.types import ChannelForbidden, ChatForbidden, TypeChat, User as TLUser
|
||||
from telethon.utils import get_peer_id, resolve_id
|
||||
|
||||
@@ -28,6 +32,7 @@ from mautrix.appservice import AppService
|
||||
from mautrix.client import Client
|
||||
from mautrix.errors import IntentError, MatrixRequestError
|
||||
from mautrix.types import UserID
|
||||
from mautrix.util import background_task
|
||||
|
||||
from ...commands.portal.util import get_initial_state, user_has_power_level
|
||||
from ...portal import Portal
|
||||
@@ -72,9 +77,12 @@ class ProvisioningAPI(AuthAPI):
|
||||
)
|
||||
self.app.router.add_route("POST", f"{user_prefix}/pm/{{identifier}}", self.start_dm)
|
||||
|
||||
self.app.router.add_route("GET", f"{user_prefix}/stickersets", self.get_stickersets)
|
||||
|
||||
self.app.router.add_route("POST", f"{user_prefix}/retry_takeout", self.retry_takeout)
|
||||
|
||||
self.app.router.add_route("POST", f"{user_prefix}/logout", self.logout)
|
||||
self.app.router.add_route("GET", f"{user_prefix}/login/qr", self.login_qr)
|
||||
self.app.router.add_route("POST", f"{user_prefix}/login/bot_token", self.send_bot_token)
|
||||
self.app.router.add_route("POST", f"{user_prefix}/login/request_code", self.request_code)
|
||||
self.app.router.add_route("POST", f"{user_prefix}/login/send_code", self.send_code)
|
||||
@@ -220,7 +228,7 @@ class ProvisioningAPI(AuthAPI):
|
||||
portal.photo_id = ""
|
||||
await portal.save()
|
||||
|
||||
asyncio.create_task(portal.update_matrix_room(user, entity, levels=levels))
|
||||
background_task.create(portal.update_matrix_room(user, entity, levels=levels))
|
||||
|
||||
return web.Response(status=202, body="{}")
|
||||
|
||||
@@ -341,7 +349,7 @@ class ProvisioningAPI(AuthAPI):
|
||||
self.log.exception("Failed to disconnect chat")
|
||||
return self.get_error_response(500, "exception", "Failed to disconnect chat")
|
||||
else:
|
||||
asyncio.create_task(coro)
|
||||
background_task.create(coro)
|
||||
return web.json_response({}, status=200 if sync else 202)
|
||||
|
||||
async def get_user_info(self, request: web.Request) -> web.Response:
|
||||
@@ -497,6 +505,18 @@ class ProvisioningAPI(AuthAPI):
|
||||
status=201 if just_created else 200,
|
||||
)
|
||||
|
||||
async def get_stickersets(self, request: web.Request) -> web.Response:
|
||||
_, user, err = await self.get_user_request_info(
|
||||
request, expect_logged_in=True, want_data=False
|
||||
)
|
||||
if err is not None:
|
||||
return err
|
||||
result = await user.client(GetAllStickersRequest(0))
|
||||
resp = []
|
||||
for stickerset in result.sets:
|
||||
resp.append(stickerset.short_name)
|
||||
return web.json_response(resp, status=200)
|
||||
|
||||
async def retry_takeout(self, request: web.Request) -> web.Response:
|
||||
data, user, err = await self.get_user_request_info(
|
||||
request, expect_logged_in=True, want_data=False
|
||||
@@ -513,6 +533,50 @@ class ProvisioningAPI(AuthAPI):
|
||||
user.takeout_retry_immediate.set()
|
||||
return web.json_response({}, status=200)
|
||||
|
||||
async def login_qr(self, request: web.Request) -> web.Response:
|
||||
_, user, err = await self.get_user_request_info(request, websocket=True)
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
await user.ensure_started(even_if_no_session=True)
|
||||
qr_login = QRLogin(user.client, ignored_ids=[])
|
||||
|
||||
ws = web.WebSocketResponse(protocols=["net.maunium.telegram.login"])
|
||||
await ws.prepare(request)
|
||||
|
||||
retries = 0
|
||||
user_info = None
|
||||
while retries < 4:
|
||||
try:
|
||||
await qr_login.recreate()
|
||||
await ws.send_json(
|
||||
{
|
||||
"code": qr_login.url,
|
||||
"timeout": int(
|
||||
(
|
||||
qr_login.expires - datetime.datetime.now(tz=datetime.timezone.utc)
|
||||
).total_seconds()
|
||||
),
|
||||
}
|
||||
)
|
||||
user_info = await qr_login.wait()
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
retries += 1
|
||||
except SessionPasswordNeededError:
|
||||
await ws.send_json({"success": False, "error": "password-needed"})
|
||||
await ws.close()
|
||||
return ws
|
||||
else:
|
||||
await ws.send_json({"success": False, "error": "timeout"})
|
||||
await ws.close()
|
||||
return ws
|
||||
|
||||
await self.postprocess_login(user, user_info)
|
||||
await ws.send_json({"success": True})
|
||||
await ws.close()
|
||||
return ws
|
||||
|
||||
async def send_bot_token(self, request: web.Request) -> web.Response:
|
||||
data, user, err = await self.get_user_request_info(request)
|
||||
if err is not None:
|
||||
@@ -638,6 +702,15 @@ class ProvisioningAPI(AuthAPI):
|
||||
)
|
||||
return None
|
||||
|
||||
def check_websocket_authorization(self, request: web.Request) -> web.Response | None:
|
||||
auth_parts = request.headers.get("Sec-WebSocket-Protocol").split(",")
|
||||
for part in auth_parts:
|
||||
if part.strip() == f"net.maunium.telegram.auth-{self.secret}":
|
||||
return None
|
||||
return self.get_error_response(
|
||||
error="Shared secret is not valid.", errcode="shared_secret_invalid", status=401
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_data(request: web.Request) -> dict | None:
|
||||
try:
|
||||
@@ -692,8 +765,12 @@ class ProvisioningAPI(AuthAPI):
|
||||
expect_logged_in: bool | None = False,
|
||||
require_puppeting: bool = False,
|
||||
want_data: bool = True,
|
||||
websocket: bool = False,
|
||||
) -> tuple[dict | None, User | None, web.Response | None]:
|
||||
err = self.check_authorization(request)
|
||||
if not websocket:
|
||||
err = self.check_authorization(request)
|
||||
else:
|
||||
err = self.check_websocket_authorization(request)
|
||||
if err is not None:
|
||||
return None, None, err
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Uncommented lines after the group definition insert things into that group.
|
||||
|
||||
#/speedups
|
||||
cryptg>=0.1,<0.4
|
||||
cryptg>=0.1,<0.5
|
||||
aiodns
|
||||
brotli
|
||||
|
||||
@@ -10,14 +10,11 @@ brotli
|
||||
pillow>=4,<10
|
||||
qrcode>=6,<8
|
||||
|
||||
#/hq_thumbnails
|
||||
moviepy>=1,<2
|
||||
|
||||
#/formattednumbers
|
||||
phonenumbers>=8,<9
|
||||
|
||||
#/metrics
|
||||
prometheus_client>=0.6,<0.16
|
||||
prometheus_client>=0.6,<0.17
|
||||
|
||||
#/e2be
|
||||
python-olm>=3,<4
|
||||
@@ -25,4 +22,4 @@ pycryptodome>=3,<4
|
||||
unpaddedbase64>=1,<3
|
||||
|
||||
#/sqlite
|
||||
aiosqlite>=0.16,<0.18
|
||||
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.18.8,<0.19
|
||||
#telethon>=1.25.4,<1.27
|
||||
tulir-telethon==1.27.0a1
|
||||
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