Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d033042ee1 | |||
| 2270f4fe40 | |||
| 6d208b37a5 | |||
| 55ebaef6e3 | |||
| 215f077cf0 | |||
| 4e4f409f87 | |||
| 4d145f4716 | |||
| b833a41a88 | |||
| 768d51c4ae | |||
| f7db298fda | |||
| 4f2118c7ee | |||
| 4f0770b92d | |||
| 1fb8a7a0a5 | |||
| f79ab283f3 | |||
| 23ec691128 | |||
| 59213ebeae | |||
| 36b2f6af2e | |||
| b2249f7756 | |||
| 212023d296 | |||
| 4b03134620 | |||
| 806eea53eb | |||
| 4ca3ee58ac | |||
| 8b003f1187 | |||
| c06a2b2473 | |||
| f2194c6f33 | |||
| b5c294a558 | |||
| c6b6ec048e | |||
| fb461109c1 | |||
| 0411affc88 | |||
| dfe22800dd | |||
| 7868b05ed3 | |||
| 0474f81044 | |||
| ed471a6623 | |||
| 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 |
@@ -14,6 +14,7 @@ __pycache__
|
|||||||
/registration.yaml
|
/registration.yaml
|
||||||
*.log*
|
*.log*
|
||||||
*.db
|
*.db
|
||||||
|
*.db-*
|
||||||
/*.pickle
|
/*.pickle
|
||||||
*.bak
|
*.bak
|
||||||
/*.session
|
/*.session
|
||||||
|
|||||||
@@ -1,3 +1,68 @@
|
|||||||
|
# v0.14.2 (2023-09-19)
|
||||||
|
|
||||||
|
* **Security:** Updated Pillow to 10.0.1.
|
||||||
|
* Added support for double puppeting with arbitrary `as_token`s.
|
||||||
|
See [docs](https://docs.mau.fi/bridges/general/double-puppeting.html#appservice-method-new) for more info.
|
||||||
|
* Added support for sending webm and tgs files as stickers.
|
||||||
|
* Updated to Telegram API layer 161.
|
||||||
|
* Fixed cached usernames for Telegram users being cleared incorrectly, leading
|
||||||
|
to mentions not being bridged as usernames.
|
||||||
|
* Fixed reaction bridging failing if the server running the bridge was rebooted
|
||||||
|
less than 12 hours ago.
|
||||||
|
|
||||||
|
# v0.14.1 (2023-06-26)
|
||||||
|
|
||||||
|
### Added
|
||||||
|
* Added option to delete megolm sessions that were received before the
|
||||||
|
automatic ratcheting options were introduced.
|
||||||
|
* Added config option to use IPv6 for Telegram connection
|
||||||
|
(thanks to [@exciler] in [#920]).
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
* Dropped support for Python 3.8.
|
||||||
|
* Updated Docker image to Alpine 3.18.
|
||||||
|
* Added timeout for forward backfills to prevent it from getting stuck
|
||||||
|
permanently.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
* Fixed `bridge.filter.users` config option not being read correctly.
|
||||||
|
* Fixed proxy support to use python-socks instead of pysocks.
|
||||||
|
|
||||||
|
[@exciler]: https://github.com/exciler
|
||||||
|
[#920]: https://github.com/mautrix/telegram/pull/920
|
||||||
|
|
||||||
|
# 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)
|
# v0.13.0 (2023-02-26)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+5
-5
@@ -1,8 +1,8 @@
|
|||||||
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.17
|
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.18
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
python3 py3-pip py3-setuptools py3-wheel \
|
python3 py3-pip py3-setuptools py3-wheel \
|
||||||
py3-pillow \
|
#py3-pillow \
|
||||||
py3-aiohttp \
|
py3-aiohttp \
|
||||||
py3-magic \
|
py3-magic \
|
||||||
py3-ruamel.yaml \
|
py3-ruamel.yaml \
|
||||||
@@ -14,8 +14,6 @@ RUN apk add --no-cache \
|
|||||||
py3-idna \
|
py3-idna \
|
||||||
py3-rsa \
|
py3-rsa \
|
||||||
#py3-telethon \ (outdated)
|
#py3-telethon \ (outdated)
|
||||||
# Optional for socks proxies
|
|
||||||
py3-pysocks \
|
|
||||||
py3-pyaes \
|
py3-pyaes \
|
||||||
# cryptg
|
# cryptg
|
||||||
py3-cffi \
|
py3-cffi \
|
||||||
@@ -34,7 +32,9 @@ RUN apk add --no-cache \
|
|||||||
bash \
|
bash \
|
||||||
curl \
|
curl \
|
||||||
jq \
|
jq \
|
||||||
yq
|
yq \
|
||||||
|
# Temporarily install pillow from edge repo to get up-to-date version
|
||||||
|
&& apk add --no-cache py3-pillow --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community
|
||||||
|
|
||||||
COPY requirements.txt /opt/mautrix-telegram/requirements.txt
|
COPY requirements.txt /opt/mautrix-telegram/requirements.txt
|
||||||
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
|
COPY optional-requirements.txt /opt/mautrix-telegram/optional-requirements.txt
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "0.13.0"
|
__version__ = "0.14.2"
|
||||||
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
__author__ = "Tulir Asokan <tulir@maunium.net>"
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ from .abstract_user import AbstractUser # isort: skip
|
|||||||
class TelegramBridge(Bridge):
|
class TelegramBridge(Bridge):
|
||||||
module = "mautrix_telegram"
|
module = "mautrix_telegram"
|
||||||
name = "mautrix-telegram"
|
name = "mautrix-telegram"
|
||||||
|
beeper_service_name = "telegram"
|
||||||
|
beeper_network_name = "telegram"
|
||||||
command = "python -m mautrix-telegram"
|
command = "python -m mautrix-telegram"
|
||||||
description = "A Matrix-Telegram puppeting bridge."
|
description = "A Matrix-Telegram puppeting bridge."
|
||||||
repo_url = "https://github.com/mautrix/telegram"
|
repo_url = "https://github.com/mautrix/telegram"
|
||||||
@@ -50,6 +52,7 @@ class TelegramBridge(Bridge):
|
|||||||
|
|
||||||
config: Config
|
config: Config
|
||||||
bot: Bot | None
|
bot: Bot | None
|
||||||
|
matrix: MatrixHandler
|
||||||
public_website: PublicBridgeWebsite | None
|
public_website: PublicBridgeWebsite | None
|
||||||
provisioning_api: ProvisioningAPI | None
|
provisioning_api: ProvisioningAPI | None
|
||||||
|
|
||||||
@@ -101,8 +104,6 @@ class TelegramBridge(Bridge):
|
|||||||
self.log.info("Finished re-sending bridge info state events")
|
self.log.info("Finished re-sending bridge info state events")
|
||||||
|
|
||||||
def prepare_stop(self) -> None:
|
def prepare_stop(self) -> None:
|
||||||
for puppet in Puppet.by_custom_mxid.values():
|
|
||||||
puppet.stop()
|
|
||||||
self.add_shutdown_actions(user.stop() for user in User.by_tgid.values())
|
self.add_shutdown_actions(user.stop() for user in User.by_tgid.values())
|
||||||
if self.bot:
|
if self.bot:
|
||||||
self.add_shutdown_actions(self.bot.stop())
|
self.add_shutdown_actions(self.bot.stop())
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from telethon.tl.types import (
|
|||||||
PeerChannel,
|
PeerChannel,
|
||||||
PeerChat,
|
PeerChat,
|
||||||
PeerUser,
|
PeerUser,
|
||||||
|
PhoneCallRequested,
|
||||||
TypeUpdate,
|
TypeUpdate,
|
||||||
UpdateChannel,
|
UpdateChannel,
|
||||||
UpdateChannelUserTyping,
|
UpdateChannelUserTyping,
|
||||||
@@ -54,6 +55,7 @@ from telethon.tl.types import (
|
|||||||
UpdateNewChannelMessage,
|
UpdateNewChannelMessage,
|
||||||
UpdateNewMessage,
|
UpdateNewMessage,
|
||||||
UpdateNotifySettings,
|
UpdateNotifySettings,
|
||||||
|
UpdatePhoneCall,
|
||||||
UpdatePinnedChannelMessages,
|
UpdatePinnedChannelMessages,
|
||||||
UpdatePinnedDialogs,
|
UpdatePinnedDialogs,
|
||||||
UpdatePinnedMessages,
|
UpdatePinnedMessages,
|
||||||
@@ -206,6 +208,8 @@ class AbstractUser(ABC):
|
|||||||
sysversion = self.config["telegram.device_info.system_version"]
|
sysversion = self.config["telegram.device_info.system_version"]
|
||||||
appversion = self.config["telegram.device_info.app_version"]
|
appversion = self.config["telegram.device_info.app_version"]
|
||||||
connection, proxy = self._proxy_settings
|
connection, proxy = self._proxy_settings
|
||||||
|
if proxy:
|
||||||
|
self.log.debug(f"Using proxy setting: {proxy}")
|
||||||
|
|
||||||
assert isinstance(session, Session)
|
assert isinstance(session, Session)
|
||||||
|
|
||||||
@@ -233,6 +237,7 @@ class AbstractUser(ABC):
|
|||||||
loop=self.loop,
|
loop=self.loop,
|
||||||
base_logger=base_logger,
|
base_logger=base_logger,
|
||||||
update_error_callback=self._telethon_update_error_callback,
|
update_error_callback=self._telethon_update_error_callback,
|
||||||
|
use_ipv6=self.config["telegram.connection.use_ipv6"],
|
||||||
)
|
)
|
||||||
self.client.add_event_handler(self._update_catch)
|
self.client.add_event_handler(self._update_catch)
|
||||||
|
|
||||||
@@ -343,6 +348,8 @@ class AbstractUser(ABC):
|
|||||||
await self.delete_message(update)
|
await self.delete_message(update)
|
||||||
elif isinstance(update, UpdateDeleteChannelMessages):
|
elif isinstance(update, UpdateDeleteChannelMessages):
|
||||||
await self.delete_channel_message(update)
|
await self.delete_channel_message(update)
|
||||||
|
elif isinstance(update, UpdatePhoneCall):
|
||||||
|
await self.update_phone_call(update)
|
||||||
elif isinstance(update, UpdateMessageReactions):
|
elif isinstance(update, UpdateMessageReactions):
|
||||||
await self.update_reactions(update)
|
await self.update_reactions(update)
|
||||||
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
|
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
|
||||||
@@ -617,6 +624,19 @@ class AbstractUser(ABC):
|
|||||||
return
|
return
|
||||||
await portal.handle_telegram_reactions(self, TelegramID(update.msg_id), update.reactions)
|
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:
|
async def update_channel(self, update: UpdateChannel) -> None:
|
||||||
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
portal = await po.Portal.get_by_tgid(TelegramID(update.channel_id))
|
||||||
if not portal:
|
if not portal:
|
||||||
@@ -659,7 +679,15 @@ class AbstractUser(ABC):
|
|||||||
if not portal:
|
if not portal:
|
||||||
return
|
return
|
||||||
elif portal and not portal.allow_bridging:
|
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
|
return
|
||||||
|
|
||||||
if self.is_relaybot:
|
if self.is_relaybot:
|
||||||
@@ -683,6 +711,22 @@ class AbstractUser(ABC):
|
|||||||
self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
|
self.log.debug("Ignoring relaybot-sent message %s to %s", update.id, portal.tgid_log)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
task = self._call_portal_message_handler(update, original_update, portal, sender)
|
||||||
|
if portal.backfill_lock.locked:
|
||||||
|
self.log.debug(
|
||||||
|
f"{portal.tgid_log} is backfill locked, moving incoming message to async task"
|
||||||
|
)
|
||||||
|
background_task.create(task)
|
||||||
|
else:
|
||||||
|
await task
|
||||||
|
|
||||||
|
async def _call_portal_message_handler(
|
||||||
|
self,
|
||||||
|
update: UpdateMessageContent,
|
||||||
|
original_update: UpdateMessage,
|
||||||
|
portal: po.Portal,
|
||||||
|
sender: pu.Puppet,
|
||||||
|
) -> None:
|
||||||
await portal.backfill_lock.wait(f"update {update.id}")
|
await portal.backfill_lock.wait(f"update {update.id}")
|
||||||
|
|
||||||
if isinstance(update, MessageService):
|
if isinstance(update, MessageService):
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ class Bot(AbstractUser):
|
|||||||
def reply(reply_text: str) -> Awaitable[Message]:
|
def reply(reply_text: str) -> Awaitable[Message]:
|
||||||
return self.client.send_message(message.chat_id, reply_text, reply_to=message.id)
|
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"]
|
pcm = self.config["bridge.relaybot.private_chat.message"]
|
||||||
if pcm:
|
if pcm:
|
||||||
await reply(pcm)
|
await reply(pcm)
|
||||||
|
|||||||
@@ -39,8 +39,6 @@ async def clear_db_cache(evt: CommandEvent) -> EventID:
|
|||||||
await evt.reply("Cleared portal cache")
|
await evt.reply("Cleared portal cache")
|
||||||
elif section == "puppet":
|
elif section == "puppet":
|
||||||
pu.Puppet.by_tgid = {}
|
pu.Puppet.by_tgid = {}
|
||||||
for puppet in pu.Puppet.by_custom_mxid.values():
|
|
||||||
puppet.stop()
|
|
||||||
pu.Puppet.by_custom_mxid = {}
|
pu.Puppet.by_custom_mxid = {}
|
||||||
await asyncio.gather(
|
await asyncio.gather(
|
||||||
*[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()]
|
*[puppet.try_start() async for puppet in pu.Puppet.all_with_custom_mxid()]
|
||||||
@@ -69,8 +67,6 @@ async def reload_user(evt: CommandEvent) -> EventID:
|
|||||||
if not user:
|
if not user:
|
||||||
return await evt.reply("User not found")
|
return await evt.reply("User not found")
|
||||||
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
|
puppet = await pu.Puppet.get_by_custom_mxid(mxid)
|
||||||
if puppet:
|
|
||||||
puppet.stop()
|
|
||||||
await user.stop()
|
await user.stop()
|
||||||
del u.User.by_tgid[user.tgid]
|
del u.User.by_tgid[user.tgid]
|
||||||
del u.User.by_mxid[user.mxid]
|
del u.User.by_mxid[user.mxid]
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ async def bridge(evt: CommandEvent) -> EventID:
|
|||||||
return await evt.reply(f"{that_this} room is already a portal room.")
|
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"):
|
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
|
# The /id bot command provides the prefixed ID, so we assume
|
||||||
tgid_str = evt.args[0]
|
tgid_str = evt.args[0]
|
||||||
|
|||||||
@@ -65,19 +65,11 @@ async def create(evt: CommandEvent) -> EventID:
|
|||||||
about=about,
|
about=about,
|
||||||
encrypted=encrypted,
|
encrypted=encrypted,
|
||||||
)
|
)
|
||||||
invites, errors = await portal.get_telegram_users_in_matrix_room(evt.sender, 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)
|
await warn_missing_power(levels, evt)
|
||||||
|
|
||||||
try:
|
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:
|
except ValueError as e:
|
||||||
await portal.delete()
|
await portal.delete()
|
||||||
return await evt.reply(e.args[0])
|
return await evt.reply(e.args[0])
|
||||||
|
|||||||
@@ -148,7 +148,15 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("bridge.animated_emoji.args.width")
|
copy("bridge.animated_emoji.args.width")
|
||||||
copy("bridge.animated_emoji.args.height")
|
copy("bridge.animated_emoji.args.height")
|
||||||
copy("bridge.animated_emoji.args.fps")
|
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_receipts")
|
||||||
copy("bridge.delivery_error_reports")
|
copy("bridge.delivery_error_reports")
|
||||||
copy("bridge.incoming_bridge_error_reports")
|
copy("bridge.incoming_bridge_error_reports")
|
||||||
@@ -166,8 +174,27 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("bridge.backfill.double_puppet_backfill")
|
copy("bridge.backfill.double_puppet_backfill")
|
||||||
copy("bridge.backfill.normal_groups")
|
copy("bridge.backfill.normal_groups")
|
||||||
copy("bridge.backfill.unread_hours_threshold")
|
copy("bridge.backfill.unread_hours_threshold")
|
||||||
copy("bridge.backfill.forward.initial_limit")
|
if "bridge.backfill.forward" in self:
|
||||||
copy("bridge.backfill.forward.sync_limit")
|
initial_limit = self.get("bridge.backfill.forward.initial_limit", 10)
|
||||||
|
sync_limit = self.get("bridge.backfill.forward.sync_limit", 100)
|
||||||
|
base["bridge.backfill.forward_limits.initial.user"] = initial_limit
|
||||||
|
base["bridge.backfill.forward_limits.initial.normal_group"] = initial_limit
|
||||||
|
base["bridge.backfill.forward_limits.initial.supergroup"] = initial_limit
|
||||||
|
base["bridge.backfill.forward_limits.initial.channel"] = initial_limit
|
||||||
|
base["bridge.backfill.forward_limits.sync.user"] = sync_limit
|
||||||
|
base["bridge.backfill.forward_limits.sync.normal_group"] = sync_limit
|
||||||
|
base["bridge.backfill.forward_limits.sync.supergroup"] = sync_limit
|
||||||
|
base["bridge.backfill.forward_limits.sync.channel"] = sync_limit
|
||||||
|
else:
|
||||||
|
copy("bridge.backfill.forward_limits.initial.user")
|
||||||
|
copy("bridge.backfill.forward_limits.initial.normal_group")
|
||||||
|
copy("bridge.backfill.forward_limits.initial.supergroup")
|
||||||
|
copy("bridge.backfill.forward_limits.initial.channel")
|
||||||
|
copy("bridge.backfill.forward_limits.sync.user")
|
||||||
|
copy("bridge.backfill.forward_limits.sync.normal_group")
|
||||||
|
copy("bridge.backfill.forward_limits.sync.supergroup")
|
||||||
|
copy("bridge.backfill.forward_limits.sync.channel")
|
||||||
|
copy("bridge.backfill.forward_timeout")
|
||||||
copy("bridge.backfill.incremental.messages_per_batch")
|
copy("bridge.backfill.incremental.messages_per_batch")
|
||||||
copy("bridge.backfill.incremental.post_batch_delay")
|
copy("bridge.backfill.incremental.post_batch_delay")
|
||||||
copy("bridge.backfill.incremental.max_batches.user")
|
copy("bridge.backfill.incremental.max_batches.user")
|
||||||
@@ -197,6 +224,7 @@ class Config(BaseBridgeConfig):
|
|||||||
|
|
||||||
copy("bridge.filter.mode")
|
copy("bridge.filter.mode")
|
||||||
copy("bridge.filter.list")
|
copy("bridge.filter.list")
|
||||||
|
copy("bridge.filter.users")
|
||||||
|
|
||||||
copy("bridge.command_prefix")
|
copy("bridge.command_prefix")
|
||||||
|
|
||||||
@@ -241,6 +269,7 @@ class Config(BaseBridgeConfig):
|
|||||||
copy("telegram.connection.retry_delay")
|
copy("telegram.connection.retry_delay")
|
||||||
copy("telegram.connection.flood_sleep_threshold")
|
copy("telegram.connection.flood_sleep_threshold")
|
||||||
copy("telegram.connection.request_retries")
|
copy("telegram.connection.request_retries")
|
||||||
|
copy("telegram.connection.use_ipv6")
|
||||||
|
|
||||||
copy("telegram.device_info.device_model")
|
copy("telegram.device_info.device_model")
|
||||||
copy("telegram.device_info.system_version")
|
copy("telegram.device_info.system_version")
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class Puppet:
|
|||||||
avatar_url: ContentURI | None
|
avatar_url: ContentURI | None
|
||||||
name_set: bool
|
name_set: bool
|
||||||
avatar_set: bool
|
avatar_set: bool
|
||||||
|
contact_info_set: bool
|
||||||
is_bot: bool | None
|
is_bot: bool | None
|
||||||
is_channel: bool
|
is_channel: bool
|
||||||
is_premium: bool
|
is_premium: bool
|
||||||
@@ -68,7 +69,7 @@ class Puppet:
|
|||||||
columns: ClassVar[str] = (
|
columns: ClassVar[str] = (
|
||||||
"id, is_registered, displayname, displayname_source, displayname_contact, "
|
"id, is_registered, displayname, displayname_source, displayname_contact, "
|
||||||
"displayname_quality, disable_updates, username, phone, photo_id, avatar_url, "
|
"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"
|
"custom_mxid, access_token, next_batch, base_url"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ class Puppet:
|
|||||||
self.avatar_url,
|
self.avatar_url,
|
||||||
self.name_set,
|
self.name_set,
|
||||||
self.avatar_set,
|
self.avatar_set,
|
||||||
|
self.contact_info_set,
|
||||||
self.is_bot,
|
self.is_bot,
|
||||||
self.is_channel,
|
self.is_channel,
|
||||||
self.is_premium,
|
self.is_premium,
|
||||||
@@ -122,8 +124,9 @@ class Puppet:
|
|||||||
UPDATE puppet
|
UPDATE puppet
|
||||||
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
|
SET is_registered=$2, displayname=$3, displayname_source=$4, displayname_contact=$5,
|
||||||
displayname_quality=$6, disable_updates=$7, username=$8, phone=$9, photo_id=$10,
|
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,
|
avatar_url=$11, name_set=$12, avatar_set=$13, contact_info_set=$14, is_bot=$15,
|
||||||
is_premium=$16, custom_mxid=$17, access_token=$18, next_batch=$19, base_url=$20
|
is_channel=$16, is_premium=$17, custom_mxid=$18, access_token=$19, next_batch=$20,
|
||||||
|
base_url=$21
|
||||||
WHERE id=$1
|
WHERE id=$1
|
||||||
"""
|
"""
|
||||||
await self.db.execute(q, *self._values)
|
await self.db.execute(q, *self._values)
|
||||||
@@ -133,9 +136,9 @@ class Puppet:
|
|||||||
INSERT INTO puppet (
|
INSERT INTO puppet (
|
||||||
id, is_registered, displayname, displayname_source, displayname_contact,
|
id, is_registered, displayname, displayname_source, displayname_contact,
|
||||||
displayname_quality, disable_updates, username, phone, photo_id, avatar_url, name_set,
|
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,
|
avatar_set, contact_info_set, is_bot, is_channel, is_premium, custom_mxid,
|
||||||
base_url
|
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,
|
) 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)
|
await self.db.execute(q, *self._values)
|
||||||
|
|||||||
@@ -20,4 +20,5 @@ from . import (
|
|||||||
v15_backfill_anchor_id,
|
v15_backfill_anchor_id,
|
||||||
v16_backfill_type,
|
v16_backfill_type,
|
||||||
v17_message_find_recent,
|
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/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from mautrix.util.async_db import Connection, Scheme
|
from mautrix.util.async_db import Connection, Scheme
|
||||||
|
|
||||||
latest_version = 17
|
latest_version = 18
|
||||||
|
|
||||||
|
|
||||||
async def create_latest_tables(conn: Connection, scheme: Scheme) -> int:
|
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,
|
avatar_url TEXT,
|
||||||
name_set BOOLEAN NOT NULL DEFAULT false,
|
name_set BOOLEAN NOT NULL DEFAULT false,
|
||||||
avatar_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_bot BOOLEAN,
|
||||||
is_channel BOOLEAN NOT NULL DEFAULT false,
|
is_channel BOOLEAN NOT NULL DEFAULT false,
|
||||||
is_premium 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"
|
||||||
|
)
|
||||||
@@ -40,7 +40,7 @@ appservice:
|
|||||||
|
|
||||||
# The full URI to the database. SQLite and Postgres are supported.
|
# The full URI to the database. SQLite and Postgres are supported.
|
||||||
# Format examples:
|
# Format examples:
|
||||||
# SQLite: sqlite:///filename.db
|
# SQLite: sqlite:filename.db
|
||||||
# Postgres: postgres://username:password@hostname/dbname
|
# Postgres: postgres://username:password@hostname/dbname
|
||||||
database: postgres://username:password@hostname/dbname
|
database: postgres://username:password@hostname/dbname
|
||||||
# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
|
# Additional arguments for asyncpg.create_pool() or sqlite3.connect()
|
||||||
@@ -274,6 +274,27 @@ bridge:
|
|||||||
# Enable key sharing? If enabled, key requests for rooms where users are in will be fulfilled.
|
# 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.
|
# You must use a client that supports requesting keys from other users to use this feature.
|
||||||
allow_key_sharing: false
|
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
|
||||||
|
# Delete inbound megolm sessions that don't have the received_at field used for
|
||||||
|
# automatic ratcheting and expired session deletion. This is meant as a migration
|
||||||
|
# to delete old keys prior to the bridge update.
|
||||||
|
delete_outdated_inbound: false
|
||||||
# What level of device verification should be required from users?
|
# What level of device verification should be required from users?
|
||||||
#
|
#
|
||||||
# Valid levels:
|
# Valid levels:
|
||||||
@@ -309,9 +330,18 @@ bridge:
|
|||||||
# default.
|
# default.
|
||||||
messages: 100
|
messages: 100
|
||||||
|
|
||||||
# Whether or not to explicitly set the avatar and room name for private
|
# Disable rotating keys when a user's devices change?
|
||||||
# chat portal rooms. This will be implicitly enabled if encryption.default is true.
|
# You should not enable this option unless you understand all the implications.
|
||||||
private_chat_portal_meta: false
|
disable_device_change_key_rotation: 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
|
# Whether or not the bridge should send a read receipt from the bridge bot when a message has
|
||||||
# been sent to Telegram.
|
# been sent to Telegram.
|
||||||
delivery_receipts: false
|
delivery_receipts: false
|
||||||
@@ -360,6 +390,9 @@ bridge:
|
|||||||
# Even without MSC2716, bridging old messages with correct timestamps requires the double
|
# 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
|
# puppets to be in an appservice namespace, or the server to be modified to allow
|
||||||
# overriding timestamps anyway.
|
# 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
|
double_puppet_backfill: false
|
||||||
# Whether or not to enable backfilling in normal groups.
|
# Whether or not to enable backfilling in normal groups.
|
||||||
# Normal groups have numerous technical problems in Telegram, and backfilling normal groups
|
# Normal groups have numerous technical problems in Telegram, and backfilling normal groups
|
||||||
@@ -374,11 +407,21 @@ bridge:
|
|||||||
#
|
#
|
||||||
# Using a negative initial limit is not recommended, as it would try to backfill everything in a single batch.
|
# 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.
|
# 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.
|
# 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.
|
# Number of messages to backfill when syncing chats.
|
||||||
sync_limit: 100
|
sync:
|
||||||
|
user: 100
|
||||||
|
normal_group: 100
|
||||||
|
supergroup: 100
|
||||||
|
channel: 100
|
||||||
|
# Timeout for forward backfills in seconds. If you have a high limit, you'll have to increase this too.
|
||||||
|
forward_timeout: 900
|
||||||
|
|
||||||
# Settings for incremental backfill of history. These only apply when using MSC2716.
|
# Settings for incremental backfill of history. These only apply when using MSC2716.
|
||||||
incremental:
|
incremental:
|
||||||
@@ -458,7 +501,6 @@ bridge:
|
|||||||
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
|
# Filter rooms that can/can't be bridged. Can also be managed using the `filter` and
|
||||||
# `filter-mode` management commands.
|
# `filter-mode` management commands.
|
||||||
#
|
#
|
||||||
# Filters do not affect direct chats.
|
|
||||||
# An empty blacklist will essentially disable the filter.
|
# An empty blacklist will essentially disable the filter.
|
||||||
filter:
|
filter:
|
||||||
# Filter mode to use. Either "blacklist" or "whitelist".
|
# Filter mode to use. Either "blacklist" or "whitelist".
|
||||||
@@ -467,6 +509,11 @@ bridge:
|
|||||||
mode: blacklist
|
mode: blacklist
|
||||||
# The list of group/channel IDs to filter.
|
# The list of group/channel IDs to filter.
|
||||||
list: []
|
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.
|
# The prefix for commands. Only required in non-management rooms.
|
||||||
command_prefix: "!tg"
|
command_prefix: "!tg"
|
||||||
@@ -567,6 +614,8 @@ telegram:
|
|||||||
# is not recommended, since some requests can always trigger a call fail (such as searching
|
# is not recommended, since some requests can always trigger a call fail (such as searching
|
||||||
# for messages).
|
# for messages).
|
||||||
request_retries: 5
|
request_retries: 5
|
||||||
|
# Use IPv6 for Telethon connection
|
||||||
|
use_ipv6: false
|
||||||
|
|
||||||
# Device info sent to Telegram.
|
# Device info sent to Telegram.
|
||||||
device_info:
|
device_info:
|
||||||
|
|||||||
@@ -135,20 +135,8 @@ class MatrixHandler(BaseMatrixHandler):
|
|||||||
levels.users[self.az.bot_mxid] = 100 if invited_by_level >= 100 else invited_by_level
|
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)
|
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:
|
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:
|
except ValueError as e:
|
||||||
await portal.delete()
|
await portal.delete()
|
||||||
await portal.az.intent.send_notice(room_id, e.args[0])
|
await portal.az.intent.send_notice(room_id, e.args[0])
|
||||||
|
|||||||
+296
-64
@@ -1,5 +1,5 @@
|
|||||||
# mautrix-telegram - A Matrix-Telegram puppeting bridge
|
# 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
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
@@ -15,7 +15,17 @@
|
|||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import TYPE_CHECKING, Any, AsyncGenerator, Awaitable, Callable, List, Union, cast
|
from typing import (
|
||||||
|
TYPE_CHECKING,
|
||||||
|
Any,
|
||||||
|
AsyncGenerator,
|
||||||
|
Awaitable,
|
||||||
|
Callable,
|
||||||
|
List,
|
||||||
|
Literal,
|
||||||
|
Union,
|
||||||
|
cast,
|
||||||
|
)
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from html import escape as escape_html
|
from html import escape as escape_html
|
||||||
@@ -23,19 +33,34 @@ from sqlite3 import IntegrityError
|
|||||||
from string import Template
|
from string import Template
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
|
import itertools
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from asyncpg import UniqueViolationError
|
from asyncpg import UniqueViolationError
|
||||||
from telethon.errors import (
|
from telethon.errors import (
|
||||||
|
ChatAdminRequiredError,
|
||||||
ChatNotModifiedError,
|
ChatNotModifiedError,
|
||||||
|
ChatRestrictedError,
|
||||||
|
ChatWriteForbiddenError,
|
||||||
|
EntitiesTooLongError,
|
||||||
|
EntityBoundsInvalidError,
|
||||||
|
EntityMentionUserInvalidError,
|
||||||
|
InputUserDeactivatedError,
|
||||||
|
MessageEmptyError,
|
||||||
MessageIdInvalidError,
|
MessageIdInvalidError,
|
||||||
|
MessageTooLongError,
|
||||||
PhotoExtInvalidError,
|
PhotoExtInvalidError,
|
||||||
PhotoInvalidDimensionsError,
|
PhotoInvalidDimensionsError,
|
||||||
PhotoSaveFileInvalidError,
|
PhotoSaveFileInvalidError,
|
||||||
ReactionInvalidError,
|
ReactionInvalidError,
|
||||||
RPCError,
|
RPCError,
|
||||||
|
SlowModeWaitError,
|
||||||
|
UserBannedInChannelError,
|
||||||
|
UserIsBlockedError,
|
||||||
|
YouBlockedUserError,
|
||||||
)
|
)
|
||||||
|
from telethon.tl.custom import Dialog
|
||||||
from telethon.tl.functions.channels import (
|
from telethon.tl.functions.channels import (
|
||||||
CreateChannelRequest,
|
CreateChannelRequest,
|
||||||
EditPhotoRequest,
|
EditPhotoRequest,
|
||||||
@@ -54,6 +79,7 @@ from telethon.tl.functions.messages import (
|
|||||||
ExportChatInviteRequest,
|
ExportChatInviteRequest,
|
||||||
GetMessageReactionsListRequest,
|
GetMessageReactionsListRequest,
|
||||||
GetMessagesReactionsRequest,
|
GetMessagesReactionsRequest,
|
||||||
|
GetPeerDialogsRequest,
|
||||||
MigrateChatRequest,
|
MigrateChatRequest,
|
||||||
SendReactionRequest,
|
SendReactionRequest,
|
||||||
SetTypingRequest,
|
SetTypingRequest,
|
||||||
@@ -66,22 +92,26 @@ from telethon.tl.types import (
|
|||||||
ChannelFull,
|
ChannelFull,
|
||||||
Chat,
|
Chat,
|
||||||
ChatBannedRights,
|
ChatBannedRights,
|
||||||
|
ChatEmpty,
|
||||||
ChatFull,
|
ChatFull,
|
||||||
ChatPhoto,
|
ChatPhoto,
|
||||||
ChatPhotoEmpty,
|
ChatPhotoEmpty,
|
||||||
DocumentAttributeAudio,
|
DocumentAttributeAudio,
|
||||||
DocumentAttributeFilename,
|
DocumentAttributeFilename,
|
||||||
DocumentAttributeImageSize,
|
DocumentAttributeImageSize,
|
||||||
|
DocumentAttributeSticker,
|
||||||
DocumentAttributeVideo,
|
DocumentAttributeVideo,
|
||||||
GeoPoint,
|
GeoPoint,
|
||||||
InputChannel,
|
InputChannel,
|
||||||
InputChatUploadedPhoto,
|
InputChatUploadedPhoto,
|
||||||
|
InputDialogPeer,
|
||||||
InputMediaUploadedDocument,
|
InputMediaUploadedDocument,
|
||||||
InputMediaUploadedPhoto,
|
InputMediaUploadedPhoto,
|
||||||
InputPeerChannel,
|
InputPeerChannel,
|
||||||
InputPeerChat,
|
InputPeerChat,
|
||||||
InputPeerPhotoFileLocation,
|
InputPeerPhotoFileLocation,
|
||||||
InputPeerUser,
|
InputPeerUser,
|
||||||
|
InputStickerSetEmpty,
|
||||||
InputUser,
|
InputUser,
|
||||||
MessageActionChannelCreate,
|
MessageActionChannelCreate,
|
||||||
MessageActionChatAddUser,
|
MessageActionChatAddUser,
|
||||||
@@ -95,6 +125,9 @@ from telethon.tl.types import (
|
|||||||
MessageActionChatMigrateTo,
|
MessageActionChatMigrateTo,
|
||||||
MessageActionContactSignUp,
|
MessageActionContactSignUp,
|
||||||
MessageActionGameScore,
|
MessageActionGameScore,
|
||||||
|
MessageActionGiftPremium,
|
||||||
|
MessageActionGroupCall,
|
||||||
|
MessageActionPhoneCall,
|
||||||
MessageMediaGame,
|
MessageMediaGame,
|
||||||
MessageMediaGeo,
|
MessageMediaGeo,
|
||||||
MessagePeerReaction,
|
MessagePeerReaction,
|
||||||
@@ -102,6 +135,10 @@ from telethon.tl.types import (
|
|||||||
PeerChannel,
|
PeerChannel,
|
||||||
PeerChat,
|
PeerChat,
|
||||||
PeerUser,
|
PeerUser,
|
||||||
|
PhoneCallDiscardReasonBusy,
|
||||||
|
PhoneCallDiscardReasonDisconnect,
|
||||||
|
PhoneCallDiscardReasonMissed,
|
||||||
|
PhoneCallRequested,
|
||||||
Photo,
|
Photo,
|
||||||
PhotoEmpty,
|
PhotoEmpty,
|
||||||
ReactionCount,
|
ReactionCount,
|
||||||
@@ -126,13 +163,16 @@ from telethon.tl.types import (
|
|||||||
UpdateChatUserTyping,
|
UpdateChatUserTyping,
|
||||||
UpdateMessageReactions,
|
UpdateMessageReactions,
|
||||||
UpdateNewMessage,
|
UpdateNewMessage,
|
||||||
|
UpdatePhoneCall,
|
||||||
UpdateUserTyping,
|
UpdateUserTyping,
|
||||||
User,
|
User,
|
||||||
|
UserEmpty,
|
||||||
UserFull,
|
UserFull,
|
||||||
UserProfilePhoto,
|
UserProfilePhoto,
|
||||||
UserProfilePhotoEmpty,
|
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
|
import attr
|
||||||
|
|
||||||
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY, IntentAPI
|
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY, IntentAPI
|
||||||
@@ -171,7 +211,8 @@ from mautrix.types import (
|
|||||||
UserID,
|
UserID,
|
||||||
VideoInfo,
|
VideoInfo,
|
||||||
)
|
)
|
||||||
from mautrix.util import background_task, magic, variation_selector
|
from mautrix.util import background_task, magic, markdown, variation_selector
|
||||||
|
from mautrix.util.format_duration import format_duration
|
||||||
from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus
|
from mautrix.util.message_send_checkpoint import MessageSendCheckpointStatus
|
||||||
from mautrix.util.simple_lock import SimpleLock
|
from mautrix.util.simple_lock import SimpleLock
|
||||||
from mautrix.util.simple_template import SimpleTemplate
|
from mautrix.util.simple_template import SimpleTemplate
|
||||||
@@ -242,12 +283,13 @@ class Portal(DBPortal, BasePortal):
|
|||||||
# Config cache
|
# Config cache
|
||||||
filter_mode: str
|
filter_mode: str
|
||||||
filter_list: list[int]
|
filter_list: list[int]
|
||||||
|
filter_users: bool | None
|
||||||
|
|
||||||
max_initial_member_sync: int
|
max_initial_member_sync: int
|
||||||
sync_channel_members: bool
|
sync_channel_members: bool
|
||||||
sync_matrix_state: bool
|
sync_matrix_state: bool
|
||||||
public_portals: bool
|
public_portals: bool
|
||||||
private_chat_portal_meta: bool
|
private_chat_portal_meta: Literal["default", "always", "never"]
|
||||||
|
|
||||||
alias_template: SimpleTemplate[str]
|
alias_template: SimpleTemplate[str]
|
||||||
hs_domain: str
|
hs_domain: str
|
||||||
@@ -416,14 +458,22 @@ class Portal(DBPortal, BasePortal):
|
|||||||
def allow_bridging(self) -> bool:
|
def allow_bridging(self) -> bool:
|
||||||
if self._bridging_blocked_at_runtime:
|
if self._bridging_blocked_at_runtime:
|
||||||
return False
|
return False
|
||||||
elif self.peer_type == "user":
|
elif self.peer_type == "user" and self.filter_users is not None:
|
||||||
return True
|
return self.filter_users
|
||||||
elif self.filter_mode == "whitelist":
|
elif self.filter_mode == "whitelist":
|
||||||
return self.tgid in self.filter_list
|
return self.tgid in self.filter_list
|
||||||
elif self.filter_mode == "blacklist":
|
elif self.filter_mode == "blacklist":
|
||||||
return self.tgid not in self.filter_list
|
return self.tgid not in self.filter_list
|
||||||
return True
|
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
|
@classmethod
|
||||||
def init_cls(cls, bridge: "TelegramBridge") -> None:
|
def init_cls(cls, bridge: "TelegramBridge") -> None:
|
||||||
BasePortal.bridge = bridge
|
BasePortal.bridge = bridge
|
||||||
@@ -440,6 +490,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
cls.private_chat_portal_meta = cls.config["bridge.private_chat_portal_meta"]
|
cls.private_chat_portal_meta = cls.config["bridge.private_chat_portal_meta"]
|
||||||
cls.filter_mode = cls.config["bridge.filter.mode"]
|
cls.filter_mode = cls.config["bridge.filter.mode"]
|
||||||
cls.filter_list = cls.config["bridge.filter.list"]
|
cls.filter_list = cls.config["bridge.filter.list"]
|
||||||
|
cls.filter_users = cls.config["bridge.filter.users"]
|
||||||
cls.hs_domain = cls.config["homeserver.domain"]
|
cls.hs_domain = cls.config["homeserver.domain"]
|
||||||
cls.backfill_msc2716 = cls.config["bridge.backfill.msc2716"]
|
cls.backfill_msc2716 = cls.config["bridge.backfill.msc2716"]
|
||||||
cls.backfill_enable = cls.config["bridge.backfill.enable"]
|
cls.backfill_enable = cls.config["bridge.backfill.enable"]
|
||||||
@@ -465,8 +516,9 @@ class Portal(DBPortal, BasePortal):
|
|||||||
|
|
||||||
async def get_telegram_users_in_matrix_room(
|
async def get_telegram_users_in_matrix_room(
|
||||||
self, source: u.User, pre_create: bool = False
|
self, source: u.User, pre_create: bool = False
|
||||||
) -> tuple[list[InputUser], list[UserID]]:
|
) -> tuple[list[InputUser], list[UserID], list[u.User]]:
|
||||||
user_tgids = {}
|
user_tgids = {}
|
||||||
|
users = []
|
||||||
intent = self.az.intent if pre_create else self.main_intent
|
intent = self.az.intent if pre_create else self.main_intent
|
||||||
user_mxids = await intent.get_room_members(self.mxid, (Membership.JOIN, Membership.INVITE))
|
user_mxids = await intent.get_room_members(self.mxid, (Membership.JOIN, Membership.INVITE))
|
||||||
for mxid in user_mxids:
|
for mxid in user_mxids:
|
||||||
@@ -474,6 +526,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
continue
|
continue
|
||||||
mx_user = await u.User.get_by_mxid(mxid, create=False)
|
mx_user = await u.User.get_by_mxid(mxid, create=False)
|
||||||
if mx_user and mx_user.tgid:
|
if mx_user and mx_user.tgid:
|
||||||
|
users.append(mx_user)
|
||||||
user_tgids[mx_user.tgid] = mxid
|
user_tgids[mx_user.tgid] = mxid
|
||||||
puppet_id = p.Puppet.get_id_from_mxid(mxid)
|
puppet_id = p.Puppet.get_id_from_mxid(mxid)
|
||||||
if puppet_id:
|
if puppet_id:
|
||||||
@@ -489,7 +542,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
f"creating a group: {e}"
|
f"creating a group: {e}"
|
||||||
)
|
)
|
||||||
errors.append(mxid)
|
errors.append(mxid)
|
||||||
return input_users, errors
|
return input_users, errors, users
|
||||||
|
|
||||||
async def upgrade_telegram_chat(self, source: u.User) -> None:
|
async def upgrade_telegram_chat(self, source: u.User) -> None:
|
||||||
if self.peer_type != "chat":
|
if self.peer_type != "chat":
|
||||||
@@ -540,11 +593,23 @@ class Portal(DBPortal, BasePortal):
|
|||||||
if await self._update_username(username):
|
if await self._update_username(username):
|
||||||
await self.save()
|
await self.save()
|
||||||
|
|
||||||
async def create_telegram_chat(
|
async def create_telegram_chat(self, source: u.User, supergroup: bool = False) -> None:
|
||||||
self, source: u.User, invites: list[InputUser], supergroup: bool = False
|
|
||||||
) -> None:
|
|
||||||
if not self.mxid:
|
if not self.mxid:
|
||||||
raise ValueError("Can't create Telegram chat for portal without Matrix room.")
|
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:
|
elif self.tgid:
|
||||||
raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.")
|
raise ValueError("Can't create Telegram chat for portal with existing Telegram chat.")
|
||||||
|
|
||||||
@@ -594,6 +659,8 @@ class Portal(DBPortal, BasePortal):
|
|||||||
await self.main_intent.set_power_levels(self.mxid, levels)
|
await self.main_intent.set_power_levels(self.mxid, levels)
|
||||||
await self.handle_matrix_power_levels(source, levels.users, {}, None)
|
await self.handle_matrix_power_levels(source, levels.users, {}, None)
|
||||||
await self.update_bridge_info()
|
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}")
|
await self.main_intent.send_notice(self.mxid, f"Telegram chat created. ID: {self.tgid}")
|
||||||
|
|
||||||
async def handle_matrix_invite(
|
async def handle_matrix_invite(
|
||||||
@@ -698,12 +765,8 @@ class Portal(DBPortal, BasePortal):
|
|||||||
source: au.AbstractUser | None = None,
|
source: au.AbstractUser | None = None,
|
||||||
photo: UserProfilePhoto | None = None,
|
photo: UserProfilePhoto | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not self.encrypted and not self.private_chat_portal_meta:
|
|
||||||
return
|
|
||||||
if puppet is None:
|
if puppet is None:
|
||||||
puppet = await self.get_dm_puppet()
|
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_avatar_from_puppet(puppet, source, photo)
|
||||||
changed = await self._update_title(puppet.displayname) or changed
|
changed = await self._update_title(puppet.displayname) or changed
|
||||||
if changed:
|
if changed:
|
||||||
@@ -716,6 +779,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
entity: TypeChat | User = None,
|
entity: TypeChat | User = None,
|
||||||
invites: InviteList = None,
|
invites: InviteList = None,
|
||||||
update_if_exists: bool = True,
|
update_if_exists: bool = True,
|
||||||
|
from_dialog_sync: bool = False,
|
||||||
client: MautrixTelegramClient | None = None,
|
client: MautrixTelegramClient | None = None,
|
||||||
) -> RoomID | None:
|
) -> RoomID | None:
|
||||||
if self.mxid:
|
if self.mxid:
|
||||||
@@ -732,7 +796,9 @@ class Portal(DBPortal, BasePortal):
|
|||||||
return self.mxid
|
return self.mxid
|
||||||
async with self._room_create_lock:
|
async with self._room_create_lock:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
self.log.exception("Fatal error creating Matrix room")
|
self.log.exception("Fatal error creating Matrix room")
|
||||||
|
|
||||||
@@ -787,6 +853,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
user: au.AbstractUser,
|
user: au.AbstractUser,
|
||||||
entity: TypeChat | User,
|
entity: TypeChat | User,
|
||||||
invites: InviteList,
|
invites: InviteList,
|
||||||
|
from_dialog_sync: bool,
|
||||||
client: MautrixTelegramClient | None = None,
|
client: MautrixTelegramClient | None = None,
|
||||||
) -> RoomID | None:
|
) -> RoomID | None:
|
||||||
if self.mxid:
|
if self.mxid:
|
||||||
@@ -798,6 +865,37 @@ class Portal(DBPortal, BasePortal):
|
|||||||
|
|
||||||
invites = invites or []
|
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:
|
if not entity:
|
||||||
entity = await self.get_entity(user, client)
|
entity = await self.get_entity(user, client)
|
||||||
self.log.trace("Fetched data: %s", entity)
|
self.log.trace("Fetched data: %s", entity)
|
||||||
@@ -900,7 +998,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
)
|
)
|
||||||
if self.is_direct:
|
if self.is_direct:
|
||||||
create_invites.add(self.az.bot_mxid)
|
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
|
assert puppet is not None
|
||||||
self.title = puppet.displayname
|
self.title = puppet.displayname
|
||||||
self.avatar_url = puppet.avatar_url
|
self.avatar_url = puppet.avatar_url
|
||||||
@@ -908,7 +1006,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
creation_content = {}
|
creation_content = {}
|
||||||
if not self.config["bridge.federate_rooms"]:
|
if not self.config["bridge.federate_rooms"]:
|
||||||
creation_content["m.federate"] = False
|
creation_content["m.federate"] = False
|
||||||
if self.avatar_url:
|
if self.avatar_url and self.set_dm_room_metadata:
|
||||||
initial_state.append(
|
initial_state.append(
|
||||||
{
|
{
|
||||||
"type": str(EventType.ROOM_AVATAR),
|
"type": str(EventType.ROOM_AVATAR),
|
||||||
@@ -920,14 +1018,14 @@ class Portal(DBPortal, BasePortal):
|
|||||||
self.log.debug(
|
self.log.debug(
|
||||||
f"Creating room with parameters invite={create_invites}, {autojoin_invites=}, "
|
f"Creating room with parameters invite={create_invites}, {autojoin_invites=}, "
|
||||||
f"{preset=}, {alias=!r}, name={self.title!r}, topic={self.about!r}, "
|
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(
|
room_id = await self.main_intent.create_room(
|
||||||
alias_localpart=alias,
|
alias_localpart=alias,
|
||||||
preset=preset,
|
preset=preset,
|
||||||
is_direct=self.is_direct,
|
is_direct=self.is_direct,
|
||||||
invitees=list(create_invites),
|
invitees=list(create_invites),
|
||||||
name=self.title,
|
name=self.title if self.set_dm_room_metadata else None,
|
||||||
topic=self.about,
|
topic=self.about,
|
||||||
initial_state=initial_state,
|
initial_state=initial_state,
|
||||||
creation_content=creation_content,
|
creation_content=creation_content,
|
||||||
@@ -935,8 +1033,8 @@ class Portal(DBPortal, BasePortal):
|
|||||||
)
|
)
|
||||||
if not room_id:
|
if not room_id:
|
||||||
raise Exception(f"Failed to create room")
|
raise Exception(f"Failed to create room")
|
||||||
self.name_set = bool(self.title)
|
self.name_set = bool(self.title) and self.set_dm_room_metadata
|
||||||
self.avatar_set = bool(self.avatar_url)
|
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:
|
if not autojoin_invites and self.encrypted and self.matrix.e2ee and self.is_direct:
|
||||||
try:
|
try:
|
||||||
@@ -950,6 +1048,10 @@ class Portal(DBPortal, BasePortal):
|
|||||||
self.log.debug(f"Matrix room created: {self.mxid}")
|
self.log.debug(f"Matrix room created: {self.mxid}")
|
||||||
await self.az.state_store.set_power_levels(self.mxid, power_levels)
|
await self.az.state_store.set_power_levels(self.mxid, power_levels)
|
||||||
await user.register_portal(self)
|
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:
|
if not autojoin_invites or not self.is_direct:
|
||||||
await self.invite_to_matrix(invites)
|
await self.invite_to_matrix(invites)
|
||||||
@@ -1246,11 +1348,12 @@ class Portal(DBPortal, BasePortal):
|
|||||||
async def _update_title(
|
async def _update_title(
|
||||||
self, title: str, sender: p.Puppet | None = None, save: bool = False
|
self, title: str, sender: p.Puppet | None = None, save: bool = False
|
||||||
) -> bool:
|
) -> 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
|
return False
|
||||||
|
|
||||||
self.title = title
|
self.title = title
|
||||||
if self.mxid:
|
self.name_set = False
|
||||||
|
if self.mxid and self.set_dm_room_metadata:
|
||||||
try:
|
try:
|
||||||
await self._try_set_state(
|
await self._try_set_state(
|
||||||
sender, EventType.ROOM_NAME, RoomNameStateEventContent(name=self.title)
|
sender, EventType.ROOM_NAME, RoomNameStateEventContent(name=self.title)
|
||||||
@@ -1258,7 +1361,6 @@ class Portal(DBPortal, BasePortal):
|
|||||||
self.name_set = True
|
self.name_set = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(f"Failed to set room name: {e}")
|
self.log.warning(f"Failed to set room name: {e}")
|
||||||
self.name_set = False
|
|
||||||
if save:
|
if save:
|
||||||
await self.save()
|
await self.save()
|
||||||
return True
|
return True
|
||||||
@@ -1266,12 +1368,13 @@ class Portal(DBPortal, BasePortal):
|
|||||||
async def _update_avatar_from_puppet(
|
async def _update_avatar_from_puppet(
|
||||||
self, puppet: p.Puppet, user: au.AbstractUser | None, photo: UserProfilePhoto | None
|
self, puppet: p.Puppet, user: au.AbstractUser | None, photo: UserProfilePhoto | None
|
||||||
) -> bool:
|
) -> 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
|
return False
|
||||||
if puppet.avatar_url:
|
if puppet.avatar_url:
|
||||||
self.photo_id = puppet.photo_id
|
self.photo_id = puppet.photo_id
|
||||||
self.avatar_url = puppet.avatar_url
|
self.avatar_url = puppet.avatar_url
|
||||||
if self.mxid:
|
self.avatar_set = False
|
||||||
|
if self.mxid and self.set_dm_room_metadata:
|
||||||
try:
|
try:
|
||||||
await self._try_set_state(
|
await self._try_set_state(
|
||||||
None,
|
None,
|
||||||
@@ -1281,9 +1384,8 @@ class Portal(DBPortal, BasePortal):
|
|||||||
self.avatar_set = True
|
self.avatar_set = True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.warning(f"Failed to set room avatar: {e}")
|
self.log.warning(f"Failed to set room avatar: {e}")
|
||||||
self.avatar_set = False
|
|
||||||
return True
|
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)
|
return await self._update_avatar(user, photo=photo)
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
@@ -1741,6 +1843,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
max_image_size = self.config["bridge.image_as_file_size"] * 1000**2
|
max_image_size = self.config["bridge.image_as_file_size"] * 1000**2
|
||||||
max_image_pixels = self.config["bridge.image_as_file_pixels"]
|
max_image_pixels = self.config["bridge.image_as_file_pixels"]
|
||||||
|
|
||||||
|
attributes = []
|
||||||
if self.config["bridge.parallel_file_transfer"] and content.url:
|
if self.config["bridge.parallel_file_transfer"] and content.url:
|
||||||
file_handle, file_size = await util.parallel_transfer_to_telegram(
|
file_handle, file_size = await util.parallel_transfer_to_telegram(
|
||||||
client, self.main_intent, content.url, sender_id
|
client, self.main_intent, content.url, sender_id
|
||||||
@@ -1760,25 +1863,31 @@ class Portal(DBPortal, BasePortal):
|
|||||||
file = await self.main_intent.download_media(content.url)
|
file = await self.main_intent.download_media(content.url)
|
||||||
|
|
||||||
if content.msgtype == MessageType.STICKER:
|
if content.msgtype == MessageType.STICKER:
|
||||||
if mime != "image/gif":
|
if mime == "image/gif":
|
||||||
mime, file, w, h = util.convert_image(
|
|
||||||
file, source_mime=mime, target_type="webp"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Remove sticker description
|
# Remove sticker description
|
||||||
file_name = "sticker.gif"
|
file_name = "sticker.gif"
|
||||||
|
else:
|
||||||
|
if mime not in ("video/webm", "application/x-tgsticker"):
|
||||||
|
mime, file, w, h = util.convert_image(
|
||||||
|
file, source_mime=mime, target_type="webp"
|
||||||
|
)
|
||||||
|
attributes.append(
|
||||||
|
DocumentAttributeSticker(
|
||||||
|
alt=content.body, stickerset=InputStickerSetEmpty()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
file_handle = await client.upload_file(file)
|
file_handle = await client.upload_file(file)
|
||||||
file_size = len(file)
|
file_size = len(file)
|
||||||
|
|
||||||
file_handle.name = file_name
|
file_handle.name = file_name
|
||||||
force_document = file_size >= max_image_size
|
force_document = file_size >= max_image_size
|
||||||
|
attributes.append(DocumentAttributeFilename(file_name=file_name))
|
||||||
|
|
||||||
attributes = [DocumentAttributeFilename(file_name=file_name)]
|
|
||||||
if content.msgtype == MessageType.VIDEO:
|
if content.msgtype == MessageType.VIDEO:
|
||||||
attributes.append(
|
attributes.append(
|
||||||
DocumentAttributeVideo(
|
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,
|
w=w or 0,
|
||||||
h=h or 0,
|
h=h or 0,
|
||||||
)
|
)
|
||||||
@@ -1790,7 +1899,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
waveform = [round(part / max(waveform_max / 32, 1)) for part in waveform]
|
waveform = [round(part / max(waveform_max / 32, 1)) for part in waveform]
|
||||||
attributes.append(
|
attributes.append(
|
||||||
DocumentAttributeAudio(
|
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,
|
voice="org.matrix.msc3245.voice" in content,
|
||||||
waveform=encode_waveform(waveform) if waveform else None,
|
waveform=encode_waveform(waveform) if waveform else None,
|
||||||
)
|
)
|
||||||
@@ -1947,7 +2056,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
response: TypeMessage,
|
response: TypeMessage,
|
||||||
msgtype: MessageType | None = None,
|
msgtype: MessageType | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.log.trace("Handled Matrix message: %s", response)
|
self.log.trace("Raw event handling response for %s: %s", event_id, response)
|
||||||
event_hash, _ = self.dedup.check(response, (event_id, space), force_hash=edit_index != 0)
|
event_hash, _ = self.dedup.check(response, (event_id, space), force_hash=edit_index != 0)
|
||||||
if edit_index < 0:
|
if edit_index < 0:
|
||||||
prev_edit = await DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1)
|
prev_edit = await DBMessage.get_one_by_tgid(TelegramID(response.id), space, -1)
|
||||||
@@ -1977,6 +2086,37 @@ class Portal(DBPortal, BasePortal):
|
|||||||
seconds=response.ttl_period,
|
seconds=response.ttl_period,
|
||||||
expires_at=int(response.date.timestamp()) + response.ttl_period,
|
expires_at=int(response.date.timestamp()) + response.ttl_period,
|
||||||
)
|
)
|
||||||
|
self.log.debug(
|
||||||
|
f"Handled Matrix message {event_id} -> {response.id} (edit index {edit_index})"
|
||||||
|
)
|
||||||
|
|
||||||
|
@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:
|
async def _send_message_status(self, event_id: EventID, err: Exception | None) -> None:
|
||||||
if not self.config["bridge.message_status_events"]:
|
if not self.config["bridge.message_status_events"]:
|
||||||
@@ -1995,8 +2135,9 @@ class Portal(DBPortal, BasePortal):
|
|||||||
status.status = MessageStatus.FAIL
|
status.status = MessageStatus.FAIL
|
||||||
elif err:
|
elif err:
|
||||||
status.reason = MessageStatusReason.GENERIC_ERROR
|
status.reason = MessageStatusReason.GENERIC_ERROR
|
||||||
status.error = str(err)
|
status.error = f"{type(err)}: {err}"
|
||||||
status.status = MessageStatus.RETRIABLE
|
status.status = MessageStatus.RETRIABLE
|
||||||
|
status.message = self._error_to_human_message(err)
|
||||||
else:
|
else:
|
||||||
status.status = MessageStatus.SUCCESS
|
status.status = MessageStatus.SUCCESS
|
||||||
|
|
||||||
@@ -2291,6 +2432,10 @@ class Portal(DBPortal, BasePortal):
|
|||||||
await deleter.client(
|
await deleter.client(
|
||||||
SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=new_reactions)
|
SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=new_reactions)
|
||||||
)
|
)
|
||||||
|
self.log.debug(
|
||||||
|
f"Handled Matrix deletion of reaction {event_id} to {msg.tgid} "
|
||||||
|
f"(new reaction count: {len(new_reactions) if new_reactions else 0})"
|
||||||
|
)
|
||||||
|
|
||||||
async def _handle_matrix_deletion(self, deleter: u.User, event_id: EventID) -> None:
|
async def _handle_matrix_deletion(self, deleter: u.User, event_id: EventID) -> None:
|
||||||
real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot
|
real_deleter = deleter if not await deleter.needs_relaybot(self) else self.bot
|
||||||
@@ -2311,6 +2456,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
else:
|
else:
|
||||||
await message.mark_redacted()
|
await message.mark_redacted()
|
||||||
await real_deleter.client.delete_messages(self.peer, [message.tgid])
|
await real_deleter.client.delete_messages(self.peer, [message.tgid])
|
||||||
|
self.log.debug(f"Handled Matrix redaction of {event_id} / {message.tgid}")
|
||||||
|
|
||||||
async def handle_matrix_reaction(
|
async def handle_matrix_reaction(
|
||||||
self, user: u.User, target_event_id: EventID, emoji: str, reaction_event_id: EventID
|
self, user: u.User, target_event_id: EventID, emoji: str, reaction_event_id: EventID
|
||||||
@@ -2417,9 +2563,15 @@ class Portal(DBPortal, BasePortal):
|
|||||||
SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=new_tg_reactions)
|
SendReactionRequest(peer=self.peer, msg_id=msg.tgid, reaction=new_tg_reactions)
|
||||||
)
|
)
|
||||||
puppet = await user.get_puppet()
|
puppet = await user.get_puppet()
|
||||||
|
removed = 0
|
||||||
for db_reaction in reactions_to_remove:
|
for db_reaction in reactions_to_remove:
|
||||||
|
removed += 1
|
||||||
await db_reaction.delete()
|
await db_reaction.delete()
|
||||||
await puppet.intent_for(self).redact(db_reaction.mx_room, db_reaction.mxid)
|
await puppet.intent_for(self).redact(db_reaction.mx_room, db_reaction.mxid)
|
||||||
|
self.log.debug(
|
||||||
|
f"Handled Matrix reaction {reaction_event_id} to {msg.tgid} "
|
||||||
|
f"(new reaction count: {len(new_tg_reactions)}, removed {removed} old reactions)"
|
||||||
|
)
|
||||||
await DBReaction(
|
await DBReaction(
|
||||||
mxid=reaction_event_id,
|
mxid=reaction_event_id,
|
||||||
mx_room=self.mxid,
|
mx_room=self.mxid,
|
||||||
@@ -2671,16 +2823,19 @@ class Portal(DBPortal, BasePortal):
|
|||||||
await DBMessage.replace_temp_mxid(temporary_identifier, self.mxid, event_id)
|
await DBMessage.replace_temp_mxid(temporary_identifier, self.mxid, event_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _default_max_batches(self) -> int:
|
def _backfill_config_type(self) -> str:
|
||||||
if self.peer_type == "user":
|
if self.peer_type == "user":
|
||||||
own_type = "user"
|
return "user"
|
||||||
elif self.peer_type == "chat":
|
elif self.peer_type == "chat":
|
||||||
own_type = "normal_group"
|
return "normal_group"
|
||||||
elif self.megagroup:
|
elif self.megagroup:
|
||||||
own_type = "supergroup"
|
return "supergroup"
|
||||||
else:
|
else:
|
||||||
own_type = "channel"
|
return "channel"
|
||||||
return self.config[f"bridge.backfill.incremental.max_batches.{own_type}"]
|
|
||||||
|
@property
|
||||||
|
def _default_max_batches(self) -> int:
|
||||||
|
return self.config[f"bridge.backfill.incremental.max_batches.{self._backfill_config_type}"]
|
||||||
|
|
||||||
async def enqueue_backfill(
|
async def enqueue_backfill(
|
||||||
self,
|
self,
|
||||||
@@ -2724,13 +2879,21 @@ class Portal(DBPortal, BasePortal):
|
|||||||
if not client:
|
if not client:
|
||||||
client = source.client
|
client = source.client
|
||||||
type = "initial" if initial else "sync"
|
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:
|
if limit == 0:
|
||||||
return "Limit is zero, not backfilling"
|
return "Limit is zero, not backfilling"
|
||||||
|
timeout = self.config["bridge.backfill.forward_timeout"]
|
||||||
with self.backfill_lock:
|
with self.backfill_lock:
|
||||||
output = await self.backfill(
|
task = self.backfill(
|
||||||
source, client, forward=True, forward_limit=limit, last_tgid=last_tgid
|
source, client, forward=True, forward_limit=limit, last_tgid=last_tgid
|
||||||
)
|
)
|
||||||
|
if timeout > 0:
|
||||||
|
output = await asyncio.wait_for(task, timeout=timeout)
|
||||||
|
else:
|
||||||
|
output = await task
|
||||||
self.log.debug(f"Forward backfill complete, status: {output}")
|
self.log.debug(f"Forward backfill complete, status: {output}")
|
||||||
return output
|
return output
|
||||||
|
|
||||||
@@ -2994,9 +3157,16 @@ class Portal(DBPortal, BasePortal):
|
|||||||
anchor_id = 2**31 - 1
|
anchor_id = 2**31 - 1
|
||||||
minmax = {}
|
minmax = {}
|
||||||
self.log.debug(f"Iterating messages through {source.tgid} with {limit=}, {minmax}")
|
self.log.debug(f"Iterating messages through {source.tgid} with {limit=}, {minmax}")
|
||||||
|
delay_warn_handle = self.loop.call_later(
|
||||||
|
5 * 60, lambda: self.log.warning("Iterating messages is taking long")
|
||||||
|
)
|
||||||
# Iterate messages newest to oldest and collect the results
|
# Iterate messages newest to oldest and collect the results
|
||||||
async for msg in client.iter_messages(entity, limit=limit, **minmax):
|
async for msg in client.iter_messages(entity, limit=limit, **minmax):
|
||||||
message_count += 1
|
message_count += 1
|
||||||
|
if message_count == 1:
|
||||||
|
self.log.debug(f"Backfill iter: got first message {msg.id}")
|
||||||
|
elif message_count % 50 == 0:
|
||||||
|
self.log.debug(f"Backfill iter: got {message_count} messages so far (at {msg.id})")
|
||||||
if (forward and msg.id <= anchor_id) or (not forward and msg.id >= anchor_id):
|
if (forward and msg.id <= anchor_id) or (not forward and msg.id >= anchor_id):
|
||||||
continue
|
continue
|
||||||
elif isinstance(msg, MessageService):
|
elif isinstance(msg, MessageService):
|
||||||
@@ -3021,6 +3191,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
events.append(await self._wrap_batch_msg(intent, msg, converted, caption=True))
|
events.append(await self._wrap_batch_msg(intent, msg, converted, caption=True))
|
||||||
intents.append(intent)
|
intents.append(intent)
|
||||||
metas.append(None)
|
metas.append(None)
|
||||||
|
delay_warn_handle.cancel()
|
||||||
if len(events) == 0:
|
if len(events) == 0:
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
f"Didn't get any events to send out of {message_count} messages fetched "
|
f"Didn't get any events to send out of {message_count} messages fetched "
|
||||||
@@ -3079,9 +3250,13 @@ class Portal(DBPortal, BasePortal):
|
|||||||
for item in counts:
|
for item in counts:
|
||||||
if item.count == 2:
|
if item.count == 2:
|
||||||
reactions += [
|
reactions += [
|
||||||
MessagePeerReaction(reaction=item.reaction, peer_id=PeerUser(self.tgid)),
|
|
||||||
MessagePeerReaction(
|
MessagePeerReaction(
|
||||||
reaction=item.reaction, peer_id=PeerUser(self.tg_receiver)
|
reaction=item.reaction, peer_id=PeerUser(self.tgid), date=None
|
||||||
|
),
|
||||||
|
MessagePeerReaction(
|
||||||
|
reaction=item.reaction,
|
||||||
|
peer_id=PeerUser(self.tg_receiver),
|
||||||
|
date=None,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
elif item.count == 1:
|
elif item.count == 1:
|
||||||
@@ -3089,6 +3264,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
MessagePeerReaction(
|
MessagePeerReaction(
|
||||||
reaction=item.reaction,
|
reaction=item.reaction,
|
||||||
peer_id=PeerUser(self.tg_receiver if item.chosen_order else self.tgid),
|
peer_id=PeerUser(self.tg_receiver if item.chosen_order else self.tgid),
|
||||||
|
date=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return reactions
|
return reactions
|
||||||
@@ -3172,17 +3348,18 @@ class Portal(DBPortal, BasePortal):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _reactions_filter(lst: list[TypeReaction], existing: DBReaction) -> bool:
|
def _reactions_filter(lst: list[MessagePeerReaction], existing: DBReaction) -> bool:
|
||||||
if not lst:
|
if not lst:
|
||||||
return False
|
return False
|
||||||
for reaction in lst:
|
for wrapped_reaction in lst:
|
||||||
|
reaction = wrapped_reaction.reaction
|
||||||
if isinstance(reaction, ReactionCustomEmoji) and existing.reaction == str(
|
if isinstance(reaction, ReactionCustomEmoji) and existing.reaction == str(
|
||||||
reaction.document_id
|
reaction.document_id
|
||||||
):
|
):
|
||||||
lst.remove(reaction)
|
lst.remove(wrapped_reaction)
|
||||||
return True
|
return True
|
||||||
elif isinstance(reaction, ReactionEmoji) and existing.reaction == reaction.emoticon:
|
elif isinstance(reaction, ReactionEmoji) and existing.reaction == reaction.emoticon:
|
||||||
lst.remove(reaction)
|
lst.remove(wrapped_reaction)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -3202,15 +3379,14 @@ class Portal(DBPortal, BasePortal):
|
|||||||
total_count: int,
|
total_count: int,
|
||||||
timestamp: datetime | None = None,
|
timestamp: datetime | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
reactions: dict[TelegramID, list[TypeReaction]] = {}
|
reactions: dict[TelegramID, list[MessagePeerReaction]] = {}
|
||||||
custom_emoji_ids: list[int] = []
|
custom_emoji_ids: list[int] = []
|
||||||
for reaction in reaction_list:
|
for reaction in reaction_list:
|
||||||
if isinstance(reaction.peer_id, (PeerUser, PeerChannel)) and isinstance(
|
if isinstance(reaction.peer_id, (PeerUser, PeerChannel)) and isinstance(
|
||||||
reaction.reaction, (ReactionEmoji, ReactionCustomEmoji)
|
reaction.reaction, (ReactionEmoji, ReactionCustomEmoji)
|
||||||
):
|
):
|
||||||
reactions.setdefault(p.Puppet.get_id_from_peer(reaction.peer_id), []).append(
|
sender_user_id = p.Puppet.get_id_from_peer(reaction.peer_id)
|
||||||
reaction.reaction
|
reactions.setdefault(sender_user_id, []).append(reaction)
|
||||||
)
|
|
||||||
if isinstance(reaction.reaction, ReactionCustomEmoji):
|
if isinstance(reaction.reaction, ReactionCustomEmoji):
|
||||||
custom_emoji_ids.append(reaction.reaction.document_id)
|
custom_emoji_ids.append(reaction.reaction.document_id)
|
||||||
is_full = len(reaction_list) == total_count
|
is_full = len(reaction_list) == total_count
|
||||||
@@ -3235,7 +3411,8 @@ class Portal(DBPortal, BasePortal):
|
|||||||
|
|
||||||
new_reaction: TypeReaction
|
new_reaction: TypeReaction
|
||||||
for sender, new_reactions in reactions.items():
|
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):
|
if isinstance(new_reaction, ReactionEmoji):
|
||||||
emoji_id = new_reaction.emoticon
|
emoji_id = new_reaction.emoticon
|
||||||
matrix_reaction = variation_selector.add(new_reaction.emoticon)
|
matrix_reaction = variation_selector.add(new_reaction.emoticon)
|
||||||
@@ -3252,7 +3429,10 @@ class Portal(DBPortal, BasePortal):
|
|||||||
self.log.debug(f"Bridging reaction {emoji_id} by {sender} to {msg.tgid}")
|
self.log.debug(f"Bridging reaction {emoji_id} by {sender} to {msg.tgid}")
|
||||||
puppet: p.Puppet = await p.Puppet.get_by_tgid(sender)
|
puppet: p.Puppet = await p.Puppet.get_by_tgid(sender)
|
||||||
mxid = await puppet.intent_for(self).react(
|
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(
|
await DBReaction(
|
||||||
mxid=mxid,
|
mxid=mxid,
|
||||||
@@ -3476,6 +3656,16 @@ class Portal(DBPortal, BasePortal):
|
|||||||
return False
|
return False
|
||||||
return True
|
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(
|
async def handle_telegram_action(
|
||||||
self, source: au.AbstractUser, sender: p.Puppet | None, update: MessageService
|
self, source: au.AbstractUser, sender: p.Puppet | None, update: MessageService
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -3503,11 +3693,53 @@ class Portal(DBPortal, BasePortal):
|
|||||||
await self.delete_telegram_user(TelegramID(action.user_id), sender)
|
await self.delete_telegram_user(TelegramID(action.user_id), sender)
|
||||||
elif isinstance(action, MessageActionChatMigrateTo):
|
elif isinstance(action, MessageActionChatMigrateTo):
|
||||||
await self._migrate_and_save_telegram(TelegramID(action.channel_id))
|
await self._migrate_and_save_telegram(TelegramID(action.channel_id))
|
||||||
# TODO encrypt
|
await self._send_message(
|
||||||
await sender.intent_for(self).send_emote(
|
sender.intent_for(self),
|
||||||
self.mxid, "upgraded this group to a supergroup."
|
TextMessageEventContent(
|
||||||
|
msgtype=MessageType.EMOTE,
|
||||||
|
body="upgraded this group to a supergroup",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await self.update_bridge_info()
|
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):
|
elif isinstance(action, MessageActionGameScore):
|
||||||
# TODO handle game score
|
# TODO handle game score
|
||||||
pass
|
pass
|
||||||
@@ -3809,7 +4041,7 @@ class Portal(DBPortal, BasePortal):
|
|||||||
return portal
|
return portal
|
||||||
|
|
||||||
if peer_type:
|
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
|
# TODO enable this for non-release builds
|
||||||
# (or add better wrong peer type error handling)
|
# (or add better wrong peer type error handling)
|
||||||
# if peer_type == "chat":
|
# if peer_type == "chat":
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ from telethon.tl.types import (
|
|||||||
DocumentAttributeVideo,
|
DocumentAttributeVideo,
|
||||||
Game,
|
Game,
|
||||||
InputPhotoFileLocation,
|
InputPhotoFileLocation,
|
||||||
|
InputStickerSetID,
|
||||||
|
InputStickerSetShortName,
|
||||||
Message,
|
Message,
|
||||||
MessageEntityPre,
|
MessageEntityPre,
|
||||||
MessageMediaContact,
|
MessageMediaContact,
|
||||||
@@ -42,11 +44,14 @@ from telethon.tl.types import (
|
|||||||
MessageMediaGame,
|
MessageMediaGame,
|
||||||
MessageMediaGeo,
|
MessageMediaGeo,
|
||||||
MessageMediaGeoLive,
|
MessageMediaGeoLive,
|
||||||
|
MessageMediaInvoice,
|
||||||
MessageMediaPhoto,
|
MessageMediaPhoto,
|
||||||
MessageMediaPoll,
|
MessageMediaPoll,
|
||||||
|
MessageMediaStory,
|
||||||
MessageMediaUnsupported,
|
MessageMediaUnsupported,
|
||||||
MessageMediaVenue,
|
MessageMediaVenue,
|
||||||
MessageMediaWebPage,
|
MessageMediaWebPage,
|
||||||
|
MessageReplyStoryHeader,
|
||||||
PeerChannel,
|
PeerChannel,
|
||||||
PeerUser,
|
PeerUser,
|
||||||
Photo,
|
Photo,
|
||||||
@@ -104,6 +109,7 @@ class DocAttrs(NamedTuple):
|
|||||||
mime_type: str | None
|
mime_type: str | None
|
||||||
is_sticker: bool
|
is_sticker: bool
|
||||||
sticker_alt: str | None
|
sticker_alt: str | None
|
||||||
|
sticker_pack_ref: dict | None
|
||||||
width: int
|
width: int
|
||||||
height: int
|
height: int
|
||||||
is_gif: bool
|
is_gif: bool
|
||||||
@@ -142,6 +148,8 @@ class TelegramMessageConverter:
|
|||||||
MessageMediaUnsupported: self._convert_unsupported,
|
MessageMediaUnsupported: self._convert_unsupported,
|
||||||
MessageMediaGame: self._convert_game,
|
MessageMediaGame: self._convert_game,
|
||||||
MessageMediaContact: self._convert_contact,
|
MessageMediaContact: self._convert_contact,
|
||||||
|
MessageMediaStory: self._convert_story,
|
||||||
|
MessageMediaInvoice: self._convert_invoice,
|
||||||
}
|
}
|
||||||
self._allowed_media = tuple(self._media_converters.keys())
|
self._allowed_media = tuple(self._media_converters.keys())
|
||||||
|
|
||||||
@@ -252,6 +260,8 @@ class TelegramMessageConverter:
|
|||||||
) -> None:
|
) -> None:
|
||||||
if not evt.reply_to:
|
if not evt.reply_to:
|
||||||
return
|
return
|
||||||
|
elif isinstance(evt.reply_to, MessageReplyStoryHeader):
|
||||||
|
return
|
||||||
space = (
|
space = (
|
||||||
evt.peer_id.channel_id
|
evt.peer_id.channel_id
|
||||||
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
|
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
|
||||||
@@ -259,6 +269,7 @@ class TelegramMessageConverter:
|
|||||||
)
|
)
|
||||||
reply_to_id = TelegramID(evt.reply_to.reply_to_msg_id)
|
reply_to_id = TelegramID(evt.reply_to.reply_to_msg_id)
|
||||||
msg = await DBMessage.get_one_by_tgid(reply_to_id, space)
|
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 not msg or msg.mx_room != self.portal.mxid:
|
||||||
if deterministic_id:
|
if deterministic_id:
|
||||||
content.set_reply(self.deterministic_event_id(space, reply_to_id))
|
content.set_reply(self.deterministic_event_id(space, reply_to_id))
|
||||||
@@ -496,6 +507,7 @@ class TelegramMessageConverter:
|
|||||||
info["fi.mau.telegram.gif"] = True
|
info["fi.mau.telegram.gif"] = True
|
||||||
else:
|
else:
|
||||||
info["fi.mau.telegram.animated_sticker"] = True
|
info["fi.mau.telegram.animated_sticker"] = True
|
||||||
|
info["fi.mau.gif"] = True
|
||||||
info["fi.mau.loop"] = True
|
info["fi.mau.loop"] = True
|
||||||
info["fi.mau.autoplay"] = True
|
info["fi.mau.autoplay"] = True
|
||||||
info["fi.mau.hide_controls"] = True
|
info["fi.mau.hide_controls"] = True
|
||||||
@@ -698,10 +710,33 @@ class TelegramMessageConverter:
|
|||||||
)
|
)
|
||||||
return ConvertedMessage(content=content)
|
return ConvertedMessage(content=content)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _convert_story(
|
||||||
|
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
|
||||||
|
) -> ConvertedMessage:
|
||||||
|
content = await formatter.telegram_to_matrix(
|
||||||
|
evt, source, client, override_text="Stories are not yet supported"
|
||||||
|
)
|
||||||
|
content.msgtype = MessageType.NOTICE
|
||||||
|
content["fi.mau.telegram.unsupported"] = True
|
||||||
|
return ConvertedMessage(content=content)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _convert_invoice(
|
||||||
|
source: au.AbstractUser, evt: Message, client: MautrixTelegramClient, **_
|
||||||
|
) -> ConvertedMessage:
|
||||||
|
content = await formatter.telegram_to_matrix(
|
||||||
|
evt, source, client, override_text="Invoices are not yet supported"
|
||||||
|
)
|
||||||
|
content.msgtype = MessageType.NOTICE
|
||||||
|
content["fi.mau.telegram.unsupported"] = True
|
||||||
|
return ConvertedMessage(content=content)
|
||||||
|
|
||||||
|
|
||||||
def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAttrs:
|
def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAttrs:
|
||||||
name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0
|
name, mime_type, is_sticker, sticker_alt, width, height = None, None, False, None, 0, 0
|
||||||
is_gif, is_audio, is_voice, duration, waveform = False, False, False, 0, bytes()
|
is_gif, is_audio, is_voice, duration, waveform = False, False, False, 0, bytes()
|
||||||
|
sticker_pack_ref = None
|
||||||
for attr in attributes:
|
for attr in attributes:
|
||||||
if isinstance(attr, DocumentAttributeFilename):
|
if isinstance(attr, DocumentAttributeFilename):
|
||||||
name = name or attr.file_name
|
name = name or attr.file_name
|
||||||
@@ -709,6 +744,13 @@ def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAt
|
|||||||
elif isinstance(attr, DocumentAttributeSticker):
|
elif isinstance(attr, DocumentAttributeSticker):
|
||||||
is_sticker = True
|
is_sticker = True
|
||||||
sticker_alt = attr.alt
|
sticker_alt = attr.alt
|
||||||
|
if isinstance(attr.stickerset, InputStickerSetID):
|
||||||
|
sticker_pack_ref = {
|
||||||
|
"id": str(attr.stickerset.id),
|
||||||
|
"access_hash": str(attr.stickerset.access_hash),
|
||||||
|
}
|
||||||
|
elif isinstance(attr.stickerset, InputStickerSetShortName):
|
||||||
|
sticker_pack_ref = {"short_name": attr.stickerset.short_name}
|
||||||
elif isinstance(attr, DocumentAttributeAnimated):
|
elif isinstance(attr, DocumentAttributeAnimated):
|
||||||
is_gif = True
|
is_gif = True
|
||||||
elif isinstance(attr, DocumentAttributeVideo):
|
elif isinstance(attr, DocumentAttributeVideo):
|
||||||
@@ -726,6 +768,7 @@ def _parse_document_attributes(attributes: list[TypeDocumentAttribute]) -> DocAt
|
|||||||
mime_type,
|
mime_type,
|
||||||
is_sticker,
|
is_sticker,
|
||||||
sticker_alt,
|
sticker_alt,
|
||||||
|
sticker_pack_ref,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
is_gif,
|
is_gif,
|
||||||
@@ -758,6 +801,13 @@ def _parse_document_meta(
|
|||||||
mime_type = file.mime_type or document.mime_type
|
mime_type = file.mime_type or document.mime_type
|
||||||
info = ImageInfo(size=file.size, mimetype=mime_type)
|
info = ImageInfo(size=file.size, mimetype=mime_type)
|
||||||
|
|
||||||
|
if attrs.is_sticker:
|
||||||
|
info["fi.mau.telegram.sticker"] = {
|
||||||
|
"alt": attrs.sticker_alt,
|
||||||
|
"id": str(document.id),
|
||||||
|
"pack": attrs.sticker_pack_ref,
|
||||||
|
}
|
||||||
|
|
||||||
if attrs.mime_type and not file.was_converted:
|
if attrs.mime_type and not file.was_converted:
|
||||||
file.mime_type = attrs.mime_type or file.mime_type
|
file.mime_type = attrs.mime_type or file.mime_type
|
||||||
if file.width and file.height:
|
if file.width and file.height:
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class Puppet(DBPuppet, BasePuppet):
|
class Puppet(DBPuppet, BasePuppet):
|
||||||
|
bridge: TelegramBridge
|
||||||
config: Config
|
config: Config
|
||||||
hs_domain: str
|
hs_domain: str
|
||||||
mxid_template: SimpleTemplate[TelegramID]
|
mxid_template: SimpleTemplate[TelegramID]
|
||||||
@@ -78,6 +79,7 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
avatar_url: ContentURI | None = None,
|
avatar_url: ContentURI | None = None,
|
||||||
name_set: bool = False,
|
name_set: bool = False,
|
||||||
avatar_set: bool = False,
|
avatar_set: bool = False,
|
||||||
|
contact_info_set: bool = False,
|
||||||
is_bot: bool = False,
|
is_bot: bool = False,
|
||||||
is_channel: bool = False,
|
is_channel: bool = False,
|
||||||
is_premium: bool = False,
|
is_premium: bool = False,
|
||||||
@@ -100,6 +102,7 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
avatar_url=avatar_url,
|
avatar_url=avatar_url,
|
||||||
name_set=name_set,
|
name_set=name_set,
|
||||||
avatar_set=avatar_set,
|
avatar_set=avatar_set,
|
||||||
|
contact_info_set=contact_info_set,
|
||||||
is_bot=is_bot,
|
is_bot=is_bot,
|
||||||
is_channel=is_channel,
|
is_channel=is_channel,
|
||||||
is_premium=is_premium,
|
is_premium=is_premium,
|
||||||
@@ -154,6 +157,7 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
|
def init_cls(cls, bridge: "TelegramBridge") -> AsyncIterable[Awaitable[None]]:
|
||||||
|
cls.bridge = bridge
|
||||||
cls.config = bridge.config
|
cls.config = bridge.config
|
||||||
cls.loop = bridge.loop
|
cls.loop = bridge.loop
|
||||||
cls.mx = bridge.matrix
|
cls.mx = bridge.matrix
|
||||||
@@ -265,11 +269,14 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
is_bot != self.is_bot or is_channel != self.is_channel or is_premium != self.is_premium
|
is_bot != self.is_bot or is_channel != self.is_channel or is_premium != self.is_premium
|
||||||
)
|
)
|
||||||
|
|
||||||
self.is_bot = is_bot
|
if is_bot is not None:
|
||||||
|
self.is_bot = is_bot
|
||||||
self.is_channel = is_channel
|
self.is_channel = is_channel
|
||||||
self.is_premium = is_premium
|
if is_premium is not None:
|
||||||
|
self.is_premium = is_premium
|
||||||
|
|
||||||
if self.username != info.username:
|
if self.username != info.username and (info.username or not info.min):
|
||||||
|
self.log.debug(f"Updating username {self.username} -> {info.username}")
|
||||||
self.username = info.username
|
self.username = info.username
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
@@ -279,6 +286,8 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
|
|
||||||
if not self.disable_updates:
|
if not self.disable_updates:
|
||||||
try:
|
try:
|
||||||
|
changed = await self._update_contact_info(force=changed) or changed
|
||||||
|
|
||||||
changed = (
|
changed = (
|
||||||
await self.update_displayname(source, info, client_override=client_override)
|
await self.update_displayname(source, info, client_override=client_override)
|
||||||
or changed
|
or changed
|
||||||
@@ -296,8 +305,37 @@ class Puppet(DBPuppet, BasePuppet):
|
|||||||
await self.update_portals_meta()
|
await self.update_portals_meta()
|
||||||
await self.save()
|
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:
|
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
|
return
|
||||||
async for portal in p.Portal.find_private_chats_with(self.tgid):
|
async for portal in p.Portal.find_private_chats_with(self.tgid):
|
||||||
await portal.update_info_from_puppet(self)
|
await portal.update_info_from_puppet(self)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from telethon.tl.patched import Message
|
|||||||
from telethon.tl.types import (
|
from telethon.tl.types import (
|
||||||
InputMediaUploadedDocument,
|
InputMediaUploadedDocument,
|
||||||
InputMediaUploadedPhoto,
|
InputMediaUploadedPhoto,
|
||||||
|
InputReplyToMessage,
|
||||||
TypeDocumentAttribute,
|
TypeDocumentAttribute,
|
||||||
TypeInputMedia,
|
TypeInputMedia,
|
||||||
TypeInputPeer,
|
TypeInputPeer,
|
||||||
@@ -67,6 +68,10 @@ class MautrixTelegramClient(TelegramClient):
|
|||||||
entity = await self.get_input_entity(entity)
|
entity = await self.get_input_entity(entity)
|
||||||
reply_to = utils.get_message_id(reply_to)
|
reply_to = utils.get_message_id(reply_to)
|
||||||
request = SendMediaRequest(
|
request = SendMediaRequest(
|
||||||
entity, media, message=caption or "", entities=entities or [], reply_to_msg_id=reply_to
|
entity,
|
||||||
|
media,
|
||||||
|
message=caption or "",
|
||||||
|
entities=entities or [],
|
||||||
|
reply_to=InputReplyToMessage(reply_to_msg_id=reply_to) if reply_to else None,
|
||||||
)
|
)
|
||||||
return self._get_response_message(request, await self(request), entity)
|
return self._get_response_message(request, await self(request), entity)
|
||||||
|
|||||||
+69
-20
@@ -39,6 +39,9 @@ from telethon.tl.types import (
|
|||||||
ChatForbidden,
|
ChatForbidden,
|
||||||
InputUserSelf,
|
InputUserSelf,
|
||||||
Message,
|
Message,
|
||||||
|
MessageActionContactSignUp,
|
||||||
|
MessageActionHistoryClear,
|
||||||
|
MessageService,
|
||||||
NotifyPeer,
|
NotifyPeer,
|
||||||
PeerUser,
|
PeerUser,
|
||||||
TypeUpdate,
|
TypeUpdate,
|
||||||
@@ -52,6 +55,7 @@ from telethon.tl.types import (
|
|||||||
User as TLUser,
|
User as TLUser,
|
||||||
)
|
)
|
||||||
from telethon.tl.types.contacts import ContactsNotModified
|
from telethon.tl.types.contacts import ContactsNotModified
|
||||||
|
from telethon.tl.types.help import AppConfig
|
||||||
from telethon.tl.types.messages import AvailableReactions
|
from telethon.tl.types.messages import AvailableReactions
|
||||||
|
|
||||||
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY
|
from mautrix.appservice import DOUBLE_PUPPET_SOURCE_KEY
|
||||||
@@ -106,6 +110,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
_available_emoji_reactions_fetched: float
|
_available_emoji_reactions_fetched: float
|
||||||
_available_emoji_reactions_lock: asyncio.Lock
|
_available_emoji_reactions_lock: asyncio.Lock
|
||||||
_app_config: dict[str, Any] | None
|
_app_config: dict[str, Any] | None
|
||||||
|
_app_config_hash: int
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -143,6 +148,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
self._available_emoji_reactions_fetched = 0
|
self._available_emoji_reactions_fetched = 0
|
||||||
self._available_emoji_reactions_lock = asyncio.Lock()
|
self._available_emoji_reactions_lock = asyncio.Lock()
|
||||||
self._app_config = None
|
self._app_config = None
|
||||||
|
self._app_config_hash = 0
|
||||||
|
|
||||||
(
|
(
|
||||||
self.relaybot_whitelisted,
|
self.relaybot_whitelisted,
|
||||||
@@ -487,13 +493,16 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
self.log.info(f"Creating portal for {portal.tgid_log} as part of backfill loop")
|
self.log.info(f"Creating portal for {portal.tgid_log} as part of backfill loop")
|
||||||
try:
|
try:
|
||||||
await portal.create_matrix_room(
|
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:
|
except Exception:
|
||||||
self.log.exception(f"Error while creating {portal.tgid_log}")
|
self.log.exception(f"Error while creating {portal.tgid_log}")
|
||||||
else:
|
else:
|
||||||
puppet = await pu.Puppet.get_by_custom_mxid(self.mxid)
|
await self.post_sync_dialog(portal, puppet=None, was_created=True, **post_sync_args)
|
||||||
await self._post_sync_dialog(portal, puppet, was_created=True, **post_sync_args)
|
|
||||||
|
|
||||||
async def update(self, update: TypeUpdate) -> bool:
|
async def update(self, update: TypeUpdate) -> bool:
|
||||||
if not self.is_bot:
|
if not self.is_bot:
|
||||||
@@ -535,7 +544,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
await self.stop()
|
await self.stop()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def update_info(self, info: TLUser = None) -> None:
|
async def update_info(self, info: TLUser | None = None) -> None:
|
||||||
if not info:
|
if not info:
|
||||||
info = await self.get_me()
|
info = await self.get_me()
|
||||||
if not info:
|
if not info:
|
||||||
@@ -608,8 +617,11 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
await self.stop()
|
await self.stop()
|
||||||
await sess.delete()
|
await sess.delete()
|
||||||
|
|
||||||
|
# Drop LOGGED_OUT states if the user was already logged out previously
|
||||||
|
# and doesn't have a remote ID anymore
|
||||||
# TODO send a management room notice for non-manual logouts?
|
# TODO send a management room notice for non-manual logouts?
|
||||||
await self.push_bridge_state(state, error=error, message=message)
|
if self.tgid or state != BridgeStateEvent.LOGGED_OUT:
|
||||||
|
await self.push_bridge_state(state, error=error, message=message)
|
||||||
if delete:
|
if delete:
|
||||||
await self.delete()
|
await self.delete()
|
||||||
self.by_mxid.pop(self.mxid, None)
|
self.by_mxid.pop(self.mxid, None)
|
||||||
@@ -743,12 +755,12 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
)
|
)
|
||||||
await self._mute_room(puppet, portal, update.notify_settings.mute_until.timestamp())
|
await self._mute_room(puppet, portal, update.notify_settings.mute_until.timestamp())
|
||||||
|
|
||||||
async def _sync_dialog(
|
@staticmethod
|
||||||
self, portal: po.Portal, dialog: Dialog, should_create: bool, puppet: pu.Puppet | None
|
def dialog_to_sync_args(dialog: Dialog) -> dict:
|
||||||
) -> None:
|
return {
|
||||||
was_created = False
|
"last_message_ts": (
|
||||||
post_sync_args = {
|
cast(datetime, dialog.date).timestamp() if dialog.date else time.time()
|
||||||
"last_message_ts": cast(datetime, dialog.date).timestamp(),
|
),
|
||||||
"unread_count": dialog.unread_count,
|
"unread_count": dialog.unread_count,
|
||||||
"max_read_id": dialog.dialog.read_inbox_max_id,
|
"max_read_id": dialog.dialog.read_inbox_max_id,
|
||||||
"mute_until": (
|
"mute_until": (
|
||||||
@@ -759,6 +771,24 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
"pinned": dialog.pinned,
|
"pinned": dialog.pinned,
|
||||||
"archived": dialog.archived,
|
"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:
|
if portal.mxid:
|
||||||
self.log.debug(f"Backfilling and updating {portal.tgid_log} (dialog sync)")
|
self.log.debug(f"Backfilling and updating {portal.tgid_log} (dialog sync)")
|
||||||
try:
|
try:
|
||||||
@@ -772,7 +802,9 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
elif should_create:
|
elif should_create:
|
||||||
self.log.debug(f"Creating portal for {portal.tgid_log} immediately (dialog sync)")
|
self.log.debug(f"Creating portal for {portal.tgid_log} immediately (dialog sync)")
|
||||||
try:
|
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
|
was_created = True
|
||||||
except Exception:
|
except Exception:
|
||||||
self.log.exception(f"Error while creating {portal.tgid_log}")
|
self.log.exception(f"Error while creating {portal.tgid_log}")
|
||||||
@@ -785,7 +817,7 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
extra_data=post_sync_args,
|
extra_data=post_sync_args,
|
||||||
)
|
)
|
||||||
if portal.mxid and puppet and puppet.is_real_user:
|
if portal.mxid and puppet and puppet.is_real_user:
|
||||||
await self._post_sync_dialog(
|
await self.post_sync_dialog(
|
||||||
portal=portal,
|
portal=portal,
|
||||||
puppet=puppet,
|
puppet=puppet,
|
||||||
was_created=was_created,
|
was_created=was_created,
|
||||||
@@ -793,10 +825,10 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
)
|
)
|
||||||
self.log.debug(f"_sync_dialog finished for {portal.tgid_log}")
|
self.log.debug(f"_sync_dialog finished for {portal.tgid_log}")
|
||||||
|
|
||||||
async def _post_sync_dialog(
|
async def post_sync_dialog(
|
||||||
self,
|
self,
|
||||||
portal: po.Portal,
|
portal: po.Portal,
|
||||||
puppet: pu.Puppet,
|
puppet: pu.Puppet | None,
|
||||||
was_created: bool,
|
was_created: bool,
|
||||||
max_read_id: int,
|
max_read_id: int,
|
||||||
last_message_ts: float,
|
last_message_ts: float,
|
||||||
@@ -805,6 +837,10 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
pinned: bool,
|
pinned: bool,
|
||||||
archived: bool,
|
archived: bool,
|
||||||
) -> None:
|
) -> 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(
|
self.log.debug(
|
||||||
f"Running dialog post-sync for {portal.tgid_log} with args "
|
f"Running dialog post-sync for {portal.tgid_log} with args "
|
||||||
f"{was_created=}, {max_read_id=}, {last_message_ts=}, {unread_count=}, "
|
f"{was_created=}, {max_read_id=}, {last_message_ts=}, {unread_count=}, "
|
||||||
@@ -941,11 +977,18 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
self.log.debug("Contact syncing complete")
|
self.log.debug("Contact syncing complete")
|
||||||
return contacts
|
return contacts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _available_reactions_up_to_date(self) -> bool:
|
||||||
|
return (
|
||||||
|
bool(self._available_emoji_reactions)
|
||||||
|
and self._available_emoji_reactions_fetched + 12 * 60 * 60 > time.monotonic()
|
||||||
|
)
|
||||||
|
|
||||||
async def get_available_reactions(self) -> set[str]:
|
async def get_available_reactions(self) -> set[str]:
|
||||||
if self._available_emoji_reactions_fetched + 12 * 60 * 60 > time.monotonic():
|
if self._available_reactions_up_to_date:
|
||||||
return self._available_emoji_reactions
|
return self._available_emoji_reactions
|
||||||
async with self._available_emoji_reactions_lock:
|
async with self._available_emoji_reactions_lock:
|
||||||
if self._available_emoji_reactions_fetched + 12 * 60 * 60 > time.monotonic():
|
if self._available_reactions_up_to_date:
|
||||||
return self._available_emoji_reactions
|
return self._available_emoji_reactions
|
||||||
self.log.debug("Fetching available emoji reactions")
|
self.log.debug("Fetching available emoji reactions")
|
||||||
available_reactions = await self.client(
|
available_reactions = await self.client(
|
||||||
@@ -955,13 +998,18 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
self._available_emoji_reactions = {
|
self._available_emoji_reactions = {
|
||||||
react.reaction
|
react.reaction
|
||||||
for react in available_reactions.reactions
|
for react in available_reactions.reactions
|
||||||
if self.is_premium or not react.premium
|
if not react.inactive and (self.is_premium or not react.premium)
|
||||||
}
|
}
|
||||||
self._available_emoji_reactions_hash = available_reactions.hash
|
self._available_emoji_reactions_hash = available_reactions.hash
|
||||||
self._available_emoji_reactions_fetched = time.monotonic()
|
self._available_emoji_reactions_fetched = time.monotonic()
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
"Got available emoji reactions: %s", self._available_emoji_reactions
|
"Got available emoji reactions: %s", self._available_emoji_reactions
|
||||||
)
|
)
|
||||||
|
elif self._available_emoji_reactions is None:
|
||||||
|
self.log.warning(
|
||||||
|
f"Got {available_reactions} in response to available reactions request"
|
||||||
|
" even though nothing is cached"
|
||||||
|
)
|
||||||
return self._available_emoji_reactions
|
return self._available_emoji_reactions
|
||||||
|
|
||||||
def tl_to_json(self) -> Any:
|
def tl_to_json(self) -> Any:
|
||||||
@@ -969,8 +1017,9 @@ class User(DBUser, AbstractUser, BaseUser):
|
|||||||
|
|
||||||
async def get_app_config(self) -> dict[str, Any]:
|
async def get_app_config(self) -> dict[str, Any]:
|
||||||
if not self._app_config:
|
if not self._app_config:
|
||||||
cfg = await self.client(GetAppConfigRequest())
|
cfg: AppConfig = await self.client(GetAppConfigRequest(hash=self._app_config_hash))
|
||||||
self._app_config = util.parse_tl_json(cfg)
|
self._app_config = util.parse_tl_json(cfg.config)
|
||||||
|
self._app_config_hash = cfg.hash
|
||||||
return self._app_config
|
return self._app_config
|
||||||
|
|
||||||
async def get_max_reactions(self, is_premium: bool | None = None) -> int:
|
async def get_max_reactions(self, is_premium: bool | None = None) -> int:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from telethon.errors import (
|
|||||||
PhoneNumberInvalidError,
|
PhoneNumberInvalidError,
|
||||||
PhoneNumberUnoccupiedError,
|
PhoneNumberUnoccupiedError,
|
||||||
SessionPasswordNeededError,
|
SessionPasswordNeededError,
|
||||||
|
SessionRevokedError,
|
||||||
)
|
)
|
||||||
|
|
||||||
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
|
from mautrix.bridge import InvalidAccessToken, OnlyLoginSelf
|
||||||
@@ -288,6 +289,17 @@ class AuthAPI(abc.ABC):
|
|||||||
errcode="phone_number_unoccupied",
|
errcode="phone_number_unoccupied",
|
||||||
error="That phone number has not been registered.",
|
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:
|
except SessionPasswordNeededError:
|
||||||
if not password_in_data:
|
if not password_in_data:
|
||||||
if user.command_status and user.command_status["action"] == "Login":
|
if user.command_status and user.command_status["action"] == "Login":
|
||||||
@@ -342,6 +354,28 @@ class AuthAPI(abc.ABC):
|
|||||||
errcode="password_invalid",
|
errcode="password_invalid",
|
||||||
error="Incorrect password.",
|
error="Incorrect password.",
|
||||||
)
|
)
|
||||||
|
except SessionRevokedError:
|
||||||
|
return self.get_login_response(
|
||||||
|
mxid=user.mxid,
|
||||||
|
state="request",
|
||||||
|
status=401,
|
||||||
|
errcode="session_revoked",
|
||||||
|
error=(
|
||||||
|
"Please try again. Login cancelled because your other sessions were "
|
||||||
|
"terminated via the Telegram app."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except FloodWaitError as e:
|
||||||
|
return self.get_login_response(
|
||||||
|
mxid=user.mxid,
|
||||||
|
state="password",
|
||||||
|
status=429,
|
||||||
|
errcode="flood_wait",
|
||||||
|
error=(
|
||||||
|
"You tried to enter your password too many times. "
|
||||||
|
f"Please wait for {format_duration(e.seconds)} before trying again."
|
||||||
|
),
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log.exception("Error sending password")
|
self.log.exception("Error sending password")
|
||||||
if isinstance(e, ValueError) and "You must provide a phone and a code" in str(e):
|
if isinstance(e, ValueError) and "You must provide a phone and a code" in str(e):
|
||||||
@@ -357,5 +391,5 @@ class AuthAPI(abc.ABC):
|
|||||||
state="password",
|
state="password",
|
||||||
status=500,
|
status=500,
|
||||||
errcode="unknown_error",
|
errcode="unknown_error",
|
||||||
error="Internal server error while sending password.",
|
error=f"Internal server error while sending password. {e}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ aiodns
|
|||||||
brotli
|
brotli
|
||||||
|
|
||||||
#/qr_login
|
#/qr_login
|
||||||
pillow>=4,<10
|
pillow>=10.0.1,<11
|
||||||
qrcode>=6,<8
|
qrcode>=6,<8
|
||||||
|
|
||||||
|
|
||||||
#/formattednumbers
|
#/formattednumbers
|
||||||
phonenumbers>=8,<9
|
phonenumbers>=8,<9
|
||||||
|
|
||||||
#/metrics
|
#/metrics
|
||||||
prometheus_client>=0.6,<0.17
|
prometheus_client>=0.6,<0.18
|
||||||
|
|
||||||
#/e2be
|
#/e2be
|
||||||
python-olm>=3,<4
|
python-olm>=3,<4
|
||||||
@@ -23,4 +22,7 @@ pycryptodome>=3,<4
|
|||||||
unpaddedbase64>=1,<3
|
unpaddedbase64>=1,<3
|
||||||
|
|
||||||
#/sqlite
|
#/sqlite
|
||||||
aiosqlite>=0.16,<0.19
|
aiosqlite>=0.16,<0.20
|
||||||
|
|
||||||
|
#/proxy
|
||||||
|
python-socks[asyncio]
|
||||||
|
|||||||
+3
-4
@@ -3,9 +3,8 @@ python-magic>=0.4,<0.5
|
|||||||
commonmark>=0.8,<0.10
|
commonmark>=0.8,<0.10
|
||||||
aiohttp>=3,<4
|
aiohttp>=3,<4
|
||||||
yarl>=1,<2
|
yarl>=1,<2
|
||||||
mautrix>=0.19.4,<0.20
|
mautrix>=0.20.2,<0.21
|
||||||
#telethon>=1.25.4,<1.27
|
tulir-telethon==1.30.0a2
|
||||||
tulir-telethon==1.28.0a3
|
asyncpg>=0.20,<0.29
|
||||||
asyncpg>=0.20,<0.28
|
|
||||||
mako>=1,<2
|
mako>=1,<2
|
||||||
setuptools
|
setuptools
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ setuptools.setup(
|
|||||||
|
|
||||||
install_requires=install_requires,
|
install_requires=install_requires,
|
||||||
extras_require=extras_require,
|
extras_require=extras_require,
|
||||||
python_requires="~=3.8",
|
python_requires="~=3.9",
|
||||||
|
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Development Status :: 4 - Beta",
|
"Development Status :: 4 - Beta",
|
||||||
@@ -60,9 +60,9 @@ setuptools.setup(
|
|||||||
"Framework :: AsyncIO",
|
"Framework :: AsyncIO",
|
||||||
"Programming Language :: Python",
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.8",
|
|
||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
],
|
],
|
||||||
package_data={"mautrix_telegram": [
|
package_data={"mautrix_telegram": [
|
||||||
"web/public/*.mako", "web/public/*.png", "web/public/*.css",
|
"web/public/*.mako", "web/public/*.png", "web/public/*.css",
|
||||||
|
|||||||
Reference in New Issue
Block a user