Compare commits

..

46 Commits

Author SHA1 Message Date
Tulir Asokan e8114ff5ad Pin setuptools version 2026-02-11 00:08:59 +02:00
Tulir Asokan 280c74e9cd Add config option to self-sign bot device 2025-09-24 00:08:57 +03:00
Tulir Asokan 4f1482e7b0 Update mautrix-python 2025-08-17 14:11:11 +03:00
Tulir Asokan 4641215e97 Update issue templates 2025-08-12 16:21:05 +03:00
Tulir Asokan 2f34ebfed9 Disable kicking unauthenticated joiners too 2025-08-12 16:20:45 +03:00
Tulir Asokan b65a1cc60a Update Docker image to Alpine 3.22 2025-07-16 23:50:21 +03:00
Tulir Asokan 53bf278f1e Bump version to 0.15.3 2025-07-16 11:50:47 +03:00
Tulir Asokan 35f137ccc1 Hardcode v11 for new rooms
Upcoming breaking changes in room v12 prevent safely using the default
room version and security embargoes prevent fixing them ahead of time.
2025-07-15 14:20:19 +03:00
Tulir Asokan 31846e7a98 Update changelog 2025-06-16 13:33:52 +03:00
Tulir Asokan 9ab2ee2970 Disable reply fallbacks by default 2025-06-16 13:24:17 +03:00
Tulir Asokan c7dd08ecd1 Update dependencies 2025-06-16 13:20:07 +03:00
Tulir Asokan 8fbd723bfa Enable captions by default 2025-05-07 13:40:39 +03:00
Tulir Asokan 530bd9e52e Update telethon 2025-04-19 15:32:39 +03:00
Tulir Asokan 6480e7925e Fix login QR filename 2025-03-19 20:47:08 +02:00
Tulir Asokan c70ab2a12b Update Telethon 2025-03-19 20:47:08 +02:00
Tulir Asokan 070bfd4f55 Update dependencies 2025-03-09 13:10:39 +02:00
Tulir Asokan 88c3a93526 Fix text in poll bridging 2025-03-09 13:07:29 +02:00
Tulir Asokan caefda582b Disable kicking unauthenticated users 2025-01-19 20:38:39 +02:00
Tulir Asokan e1b181ed55 Update mautrix-python to support MSC4190 2025-01-15 18:54:54 +02:00
Tulir Asokan cc6a915ef4 Update dependencies 2025-01-15 17:54:33 +02:00
Tulir Asokan de4df57278 Ignore partial quotes on sticker messages 2025-01-15 17:49:18 +02:00
Tulir Asokan 0068341185 Bump version to 0.15.2 2024-07-16 11:53:19 +03:00
Tulir Asokan efcf1535ff Update mautrix-python 2024-07-12 20:25:57 +03:00
Tulir Asokan 99f633e98d Update telethon and changelog 2024-07-09 12:15:41 +03:00
Tulir Asokan 0137bfcbf6 Update mautrix-python 2024-07-09 12:15:41 +03:00
Javier Cuevas f6cb26f7f5 Merge pull request #964 from mautrix/feature/periodic-refresh
Add periodic connection refresh
2024-05-24 10:19:43 +02:00
Javier Cuevas 6418202118 Update mautrix_telegram/abstract_user.py
Co-authored-by: Tulir Asokan <tulir@maunium.net>
2024-05-24 09:39:20 +02:00
Javier Cuevas 4b25e855e0 Add force_refresh_interval_seconds to config.py 2024-05-24 09:36:09 +02:00
Javier Cuevas a35f6abfd1 Change default for force_refresh_interval_seconds (disabled by default) 2024-05-24 09:36:03 +02:00
Javier Cuevas 716222a671 Format to pass linting 2024-05-23 17:18:06 +02:00
Javier Cuevas 31801a436c Add periodic connection refresh 2024-05-23 17:06:04 +02:00
Tulir Asokan 8bd5a4e367 Update changelog 2024-05-03 11:47:48 +02:00
Tulir Asokan 43d17a335b Fix call end message 2024-04-08 17:47:44 +03:00
Nick Mills-Barrett 84a3fde1ca Implement bot/channel file size limit 2024-03-25 14:36:29 +00:00
Nick Mills-Barrett 05d05e671b Add config to limit size of documents from bots/channels copied to Matrix 2024-03-25 14:36:29 +00:00
Nick Mills-Barrett ab6a6654f7 Pass through is channel to msg conversion 2024-03-25 14:36:29 +00:00
Tulir Asokan dbfbf12862 Fix error handling replies in some cases 2024-03-19 12:02:58 +02:00
Tulir Asokan 6166173376 Fix message in MSS events 2024-03-14 13:08:53 +02:00
Tulir Asokan 2232d9898e Avoid logging RPCErrors twice 2024-03-14 13:07:22 +02:00
Tulir Asokan 3cf279718f Don't send notices for some errors 2024-03-14 13:05:55 +02:00
Tulir Asokan 65ec4491e2 Merge branch 'tulir/bot-reactions' 2024-03-13 15:21:33 +02:00
Tulir Asokan ce43607c56 Update dependencies 2024-03-13 15:20:41 +02:00
Nick Mills-Barrett 150bf5e338 Return if no document contained in media document event 2024-02-14 09:58:24 +00:00
Tulir Asokan 77cbbebfb2 Update Black to 2024 style and Python 3.10 target 2024-01-29 18:52:10 +02:00
Tulir Asokan 511043a720 Add support for bot-specific reaction update 2024-01-13 22:42:42 +02:00
Tulir Asokan 19a4b4374d Update dependencies and drop Python 3.9 support 2024-01-08 17:35:37 +02:00
25 changed files with 354 additions and 86 deletions
+11 -2
View File
@@ -1,7 +1,16 @@
--- ---
name: Bug report name: Bug report
about: If something is definitely wrong in the bridge (rather than just a setup issue), about: If something is definitely wrong in the bridge (rather than just a setup issue),
file a bug report. Remember to include relevant logs. file a bug report. Remember to include relevant logs. Asking in the Matrix room first
labels: bug is strongly recommended.
type: Bug
--- ---
<!--
Remember to include relevant logs, the bridge version and any other details.
It's always best to ask in the Matrix room first, especially if you aren't sure
what details are needed. Issues with insufficient detail will likely just be
ignored or closed immediately.
-->
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: Enhancement request name: Enhancement request
about: Submit a feature request or other suggestion about: Submit a feature request or other suggestion
labels: enhancement type: Feature
--- ---
+4 -4
View File
@@ -6,17 +6,17 @@ jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v4
- uses: actions/setup-python@v2 - uses: actions/setup-python@v5
with: with:
python-version: "3.11" python-version: "3.12"
- uses: isort/isort-action@master - uses: isort/isort-action@master
with: with:
sortPaths: "./mautrix_telegram" sortPaths: "./mautrix_telegram"
- uses: psf/black@stable - uses: psf/black@stable
with: with:
src: "./mautrix_telegram" src: "./mautrix_telegram"
version: "23.1.0" version: "24.1.1"
- name: pre-commit - name: pre-commit
run: | run: |
pip install pre-commit pip install pre-commit
+3 -3
View File
@@ -1,6 +1,6 @@
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0 rev: v4.5.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
exclude_types: [markdown] exclude_types: [markdown]
@@ -8,13 +8,13 @@ repos:
- id: check-yaml - id: check-yaml
- id: check-added-large-files - id: check-added-large-files
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 23.1.0 rev: 24.1.1
hooks: hooks:
- id: black - id: black
language_version: python3 language_version: python3
files: ^mautrix_telegram/.*\.pyi?$ files: ^mautrix_telegram/.*\.pyi?$
- repo: https://github.com/PyCQA/isort - repo: https://github.com/PyCQA/isort
rev: 5.12.0 rev: 5.13.2
hooks: hooks:
- id: isort - id: isort
files: ^mautrix_telegram/.*\.pyi?$ files: ^mautrix_telegram/.*\.pyi?$
+21
View File
@@ -1,3 +1,24 @@
# v0.15.3 (2025-07-16)
* Updated Telegram API to layer 204.
* Added support for MSC4190.
* Enabled captions by default, as they are now supported by most clients.
* Existing configs will still need to enable `caption_in_message` manually.
* Changed new room creation to hardcode room v11 to avoid v12 rooms being
created before proper support for them can be added.
* Fixed bridging sticker messages with partial quote replies from Telegram.
* Fixed text in poll bridging.
* Disabled kicking unauthenticated users from portals.
# v0.15.2 (2024-07-16)
* Dropped support for Python 3.9.
* Updated Telegram API to layer 183.
* Added support for authenticated media downloads.
* Added support for receiving reactions when using a bot account.
* Added option to limit file size by chat type.
* Fixed reply bridging breaking in some cases.
# v0.15.1 (2023-12-26) # v0.15.1 (2023-12-26)
* Updated Telegram API to layer 169. * Updated Telegram API to layer 169.
+1 -1
View File
@@ -1,4 +1,4 @@
FROM dock.mau.dev/tulir/lottieconverter:alpine-3.19 FROM dock.mau.dev/tulir/lottieconverter:alpine-3.22
RUN apk add --no-cache \ RUN apk add --no-cache \
python3 py3-pip py3-setuptools py3-wheel \ python3 py3-pip py3-setuptools py3-wheel \
+1 -1
View File
@@ -1,3 +1,3 @@
pre-commit>=2.10.1,<3 pre-commit>=2.10.1,<3
isort>=5.10.1,<6 isort>=5.10.1,<6
black>=23,<24 black>=24,<25
+1 -1
View File
@@ -1,2 +1,2 @@
__version__ = "0.15.1" __version__ = "0.15.3"
__author__ = "Tulir Asokan <tulir@maunium.net>" __author__ = "Tulir Asokan <tulir@maunium.net>"
+27
View File
@@ -40,6 +40,7 @@ from telethon.tl.types import (
PeerUser, PeerUser,
PhoneCallRequested, PhoneCallRequested,
TypeUpdate, TypeUpdate,
UpdateBotMessageReaction,
UpdateChannel, UpdateChannel,
UpdateChannelUserTyping, UpdateChannelUserTyping,
UpdateChatDefaultBannedRights, UpdateChatDefaultBannedRights,
@@ -240,6 +241,24 @@ class AbstractUser(ABC):
use_ipv6=self.config["telegram.connection.use_ipv6"], use_ipv6=self.config["telegram.connection.use_ipv6"],
) )
self.client.add_event_handler(self._update_catch) self.client.add_event_handler(self._update_catch)
self._schedule_reconnect()
def _schedule_reconnect(self) -> None:
reconnect_interval = self.config["telegram.force_refresh_interval_seconds"]
if not reconnect_interval or reconnect_interval == 0:
return
refresh_time = time.time() + reconnect_interval
self.log.info(
"Scheduling forced reconnect in %d seconds. Connection will be refreshed at %s",
reconnect_interval,
time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(refresh_time)),
)
self.loop.call_later(reconnect_interval, lambda: background_task.create(self._reconnect()))
async def _reconnect(self) -> None:
self.log.info("Reconnecting to Telegram...")
await self.stop()
await self.start()
@abstractmethod @abstractmethod
async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None: async def on_signed_out(self, err: UnauthorizedError | AuthKeyError) -> None:
@@ -363,6 +382,8 @@ class AbstractUser(ABC):
await self.update_phone_call(update) 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, UpdateBotMessageReaction):
await self.update_bot_reactions(update)
elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)): elif isinstance(update, (UpdateChatUserTyping, UpdateChannelUserTyping, UpdateUserTyping)):
await self.update_typing(update) await self.update_typing(update)
elif isinstance(update, UpdateUserStatus): elif isinstance(update, UpdateUserStatus):
@@ -636,6 +657,12 @@ 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_bot_reactions(self, update: UpdateBotMessageReaction) -> None:
portal = await po.Portal.get_by_entity(update.peer, tg_receiver=self.tgid)
if not portal or not portal.mxid or not portal.allow_bridging:
return
await portal.handle_telegram_bot_reactions(self, update)
async def update_phone_call(self, update: UpdatePhoneCall) -> None: async def update_phone_call(self, update: UpdatePhoneCall) -> None:
self.log.debug("Phone call update %s", update) self.log.debug("Phone call update %s", update)
if not isinstance(update.phone_call, PhoneCallRequested): if not isinstance(update.phone_call, PhoneCallRequested):
@@ -121,6 +121,7 @@ async def login_qr(evt: CommandEvent) -> EventID:
mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr)) mxc = await evt.az.intent.upload_media(qr, "image/png", "login-qr.png", len(qr))
content = MediaMessageEventContent( content = MediaMessageEventContent(
body=qr_login.url, body=qr_login.url,
filename="login-qr.png",
url=mxc, url=mxc,
msgtype=MessageType.IMAGE, msgtype=MessageType.IMAGE,
info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size), info=ImageInfo(mimetype="image/png", size=len(qr), width=size, height=size),
+3
View File
@@ -136,6 +136,8 @@ class Config(BaseBridgeConfig):
copy("bridge.caption_in_message") copy("bridge.caption_in_message")
copy("bridge.image_as_file_size") copy("bridge.image_as_file_size")
copy("bridge.image_as_file_pixels") copy("bridge.image_as_file_pixels")
copy("bridge.document_as_link_size.bot")
copy("bridge.document_as_link_size.channel")
copy("bridge.parallel_file_transfer") copy("bridge.parallel_file_transfer")
copy("bridge.federate_rooms") copy("bridge.federate_rooms")
copy("bridge.always_custom_emoji_reaction") copy("bridge.always_custom_emoji_reaction")
@@ -262,6 +264,7 @@ class Config(BaseBridgeConfig):
copy("telegram.catch_up") copy("telegram.catch_up")
copy("telegram.sequential_updates") copy("telegram.sequential_updates")
copy("telegram.exit_on_update_error") copy("telegram.exit_on_update_error")
copy("telegram.force_refresh_interval_seconds")
copy("telegram.connection.timeout") copy("telegram.connection.timeout")
copy("telegram.connection.retries") copy("telegram.connection.retries")
+3 -3
View File
@@ -208,9 +208,9 @@ class PgSession(MemorySession):
await self._locked_process_entities(tlo) await self._locked_process_entities(tlo)
async def _locked_process_entities(self, tlo) -> None: async def _locked_process_entities(self, tlo) -> None:
rows: list[ rows: list[tuple[str, int, int, str | None, str | None, str | None]] = (
tuple[str, int, int, str | None, str | None, str | None] self._entities_to_rows(tlo)
] = self._entities_to_rows(tlo) )
if not rows: if not rows:
return return
if self.db.scheme == Scheme.POSTGRES: if self.db.scheme == Scheme.POSTGRES:
+21 -4
View File
@@ -216,12 +216,16 @@ bridge:
# to resolve redirects in invite links. # to resolve redirects in invite links.
invite_link_resolve: false invite_link_resolve: false
# Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552. # Send captions in the same message as images. This will send data compatible with both MSC2530 and MSC3552.
# This is currently not supported in most clients. caption_in_message: true
caption_in_message: false
# Maximum size of image in megabytes before sending to Telegram as a document. # Maximum size of image in megabytes before sending to Telegram as a document.
image_as_file_size: 10 image_as_file_size: 10
# Maximum number of pixels in an image before sending to Telegram as a document. Defaults to 4096x4096 = 16777216. # Maximum number of pixels in an image before sending to Telegram as a document. Defaults to 4096x4096 = 16777216.
image_as_file_pixels: 16777216 image_as_file_pixels: 16777216
# Maximum size of Telegram documents before linking to Telegrm instead of bridge
# to Matrix media.
document_as_link_size:
channel:
bot:
# Enable experimental parallel file transfer, which makes uploads/downloads much faster by # Enable experimental parallel file transfer, which makes uploads/downloads much faster by
# streaming from/to Matrix and using many connections for Telegram. # streaming from/to Matrix and using many connections for Telegram.
# Note that generating HQ thumbnails for videos is not possible with streamed transfers. # Note that generating HQ thumbnails for videos is not possible with streamed transfers.
@@ -267,8 +271,19 @@ bridge:
# Default to encryption, force-enable encryption in all portals the bridge creates # Default to encryption, force-enable encryption in all portals the bridge creates
# This will cause the bridge bot to be in private chats for the encryption to work properly. # This will cause the bridge bot to be in private chats for the encryption to work properly.
default: false default: false
# Whether to use MSC2409/MSC3202 instead of /sync long polling for receiving encryption-related data. # Whether to use MSC3202/MSC4203 instead of /sync long polling for receiving encryption-related data.
# This option is not yet compatible with standard Matrix servers like Synapse and should not be used.
# Changing this option requires updating the appservice registration file.
appservice: false appservice: false
# Whether to use MSC4190 instead of appservice login to create the bridge bot device.
# Requires the homeserver to support MSC4190 and the device masquerading parts of MSC3202.
# Only relevant when using end-to-bridge encryption, required when using encryption with next-gen auth (MSC3861).
# Changing this option requires updating the appservice registration file.
msc4190: false
# Should the bridge bot generate a recovery key and cross-signing keys and verify itself?
# Note that without the latest version of MSC4190, this will fail if you reset the bridge database.
# The generated recovery key will be printed in logs.
self_sign: false
# Require encryption, drop any unencrypted messages. # Require encryption, drop any unencrypted messages.
require: false require: false
# 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.
@@ -341,7 +356,7 @@ bridge:
private_chat_portal_meta: default private_chat_portal_meta: default
# Disable generating reply fallbacks? Some extremely bad clients still rely on them, # 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. # but they're being phased out and will be completely removed in the future.
disable_reply_fallbacks: false disable_reply_fallbacks: true
# Should cross-chat replies from Telegram be bridged? Most servers and clients don't support this. # Should cross-chat replies from Telegram be bridged? Most servers and clients don't support this.
cross_room_replies: false cross_room_replies: 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
@@ -576,6 +591,8 @@ telegram:
# Should incoming updates be handled sequentially to make sure order is preserved on Matrix? # Should incoming updates be handled sequentially to make sure order is preserved on Matrix?
sequential_updates: true sequential_updates: true
exit_on_update_error: false exit_on_update_error: false
# Interval to force refresh the connection (full reconnect). 0 disables it.
force_refresh_interval_seconds: 0
# Telethon connection options. # Telethon connection options.
connection: connection:
-8
View File
@@ -155,14 +155,6 @@ class MatrixHandler(BaseMatrixHandler):
room_id, user.mxid, "You are not whitelisted on this Telegram bridge." room_id, user.mxid, "You are not whitelisted on this Telegram bridge."
) )
return return
elif not await user.is_logged_in() and not portal.has_bot:
await portal.main_intent.kick_user(
room_id,
user.mxid,
"This chat does not have a bot on the Telegram side for relaying messages sent by"
" unauthenticated Matrix users.",
)
return
self.log.debug(f"{user.mxid} joined {room_id}") self.log.debug(f"{user.mxid} joined {room_id}")
if await user.is_logged_in() or portal.has_bot: if await user.is_logged_in() or portal.has_bot:
+96 -21
View File
@@ -23,6 +23,7 @@ from typing import (
Callable, Callable,
List, List,
Literal, Literal,
NamedTuple,
Union, Union,
cast, cast,
) )
@@ -49,6 +50,7 @@ from telethon.errors import (
InputUserDeactivatedError, InputUserDeactivatedError,
MessageEmptyError, MessageEmptyError,
MessageIdInvalidError, MessageIdInvalidError,
MessageNotModifiedError,
MessageTooLongError, MessageTooLongError,
PhotoExtInvalidError, PhotoExtInvalidError,
PhotoInvalidDimensionsError, PhotoInvalidDimensionsError,
@@ -68,7 +70,6 @@ from telethon.tl.functions.channels import (
InviteToChannelRequest, InviteToChannelRequest,
JoinChannelRequest, JoinChannelRequest,
UpdateUsernameRequest, UpdateUsernameRequest,
ViewSponsoredMessageRequest,
) )
from telethon.tl.functions.messages import ( from telethon.tl.functions.messages import (
AddChatUserRequest, AddChatUserRequest,
@@ -85,6 +86,7 @@ from telethon.tl.functions.messages import (
SetTypingRequest, SetTypingRequest,
UnpinAllMessagesRequest, UnpinAllMessagesRequest,
UpdatePinnedMessageRequest, UpdatePinnedMessageRequest,
ViewSponsoredMessageRequest,
) )
from telethon.tl.patched import Message, MessageService from telethon.tl.patched import Message, MessageService
from telethon.tl.types import ( from telethon.tl.types import (
@@ -113,6 +115,7 @@ from telethon.tl.types import (
InputPeerUser, InputPeerUser,
InputStickerSetEmpty, InputStickerSetEmpty,
InputUser, InputUser,
MessageActionBoostApply,
MessageActionChannelCreate, MessageActionChannelCreate,
MessageActionChatAddUser, MessageActionChatAddUser,
MessageActionChatCreate, MessageActionChatCreate,
@@ -159,6 +162,7 @@ from telethon.tl.types import (
TypeUser, TypeUser,
TypeUserFull, TypeUserFull,
TypeUserProfilePhoto, TypeUserProfilePhoto,
UpdateBotMessageReaction,
UpdateChannelUserTyping, UpdateChannelUserTyping,
UpdateChatUserTyping, UpdateChatUserTyping,
UpdateMessageReactions, UpdateMessageReactions,
@@ -269,6 +273,11 @@ class IgnoredMessageError(Exception):
pass pass
class WrappedReaction(NamedTuple):
reaction: ReactionEmoji | ReactionCustomEmoji
date: datetime | None
class Portal(DBPortal, BasePortal): class Portal(DBPortal, BasePortal):
bot: "Bot" bot: "Bot"
config: Config config: Config
@@ -435,6 +444,10 @@ class Portal(DBPortal, BasePortal):
def is_direct(self) -> bool: def is_direct(self) -> bool:
return self.peer_type == "user" return self.peer_type == "user"
@property
def is_channel(self) -> bool:
return self.peer_type == "channel"
@property @property
def has_bot(self) -> bool: def has_bot(self) -> bool:
return bool(self.bot) and ( return bool(self.bot) and (
@@ -1025,6 +1038,7 @@ class Portal(DBPortal, BasePortal):
initial_state=initial_state, initial_state=initial_state,
creation_content=creation_content, creation_content=creation_content,
beeper_auto_join_invites=autojoin_invites, beeper_auto_join_invites=autojoin_invites,
room_version="11",
) )
if not room_id: if not room_id:
raise Exception(f"Failed to create room") raise Exception(f"Failed to create room")
@@ -1187,7 +1201,7 @@ class Portal(DBPortal, BasePortal):
continue continue
if mx_user.is_bot: if mx_user.is_bot:
await mx_user.unregister_portal(*self.tgid_full) await mx_user.unregister_portal(*self.tgid_full)
if not self.has_bot: if not self.has_bot and mx_user.tgid:
try: try:
await self.main_intent.kick_user( await self.main_intent.kick_user(
self.mxid, mx_user.mxid, "You had left this Telegram chat." self.mxid, mx_user.mxid, "You had left this Telegram chat."
@@ -2133,7 +2147,7 @@ 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 = f"{type(err)}: {err}" status.error = f"{type(err).__name__}: {err}"
status.status = MessageStatus.RETRIABLE status.status = MessageStatus.RETRIABLE
status.message = self._error_to_human_message(err) status.message = self._error_to_human_message(err)
else: else:
@@ -2164,6 +2178,7 @@ class Portal(DBPortal, BasePortal):
) )
if msg and self.config["bridge.delivery_error_reports"]: if msg and self.config["bridge.delivery_error_reports"]:
if not isinstance(err, MessageNotModifiedError):
await self._send_message( await self._send_message(
self.main_intent, TextMessageEventContent(msgtype=MessageType.NOTICE, body=msg) self.main_intent, TextMessageEventContent(msgtype=MessageType.NOTICE, body=msg)
) )
@@ -2184,7 +2199,6 @@ class Portal(DBPortal, BasePortal):
message_type=content.msgtype, message_type=content.msgtype,
msg=f"\u26a0 Your message may not have been bridged: {e}", msg=f"\u26a0 Your message may not have been bridged: {e}",
) )
raise
except Exception as e: except Exception as e:
if isinstance(e, IgnoredMessageError): if isinstance(e, IgnoredMessageError):
self.log.debug(f"Ignored {event_id}: {e}") self.log.debug(f"Ignored {event_id}: {e}")
@@ -2323,20 +2337,17 @@ class Portal(DBPortal, BasePortal):
sender.command_status = None sender.command_status = None
except (KeyError, TypeError): except (KeyError, TypeError):
if not logged_in or ( if not logged_in or (
"filename" in content and content["filename"] != content.body content.filename is not None and content.filename != content.body
): ):
if "filename" in content: if content.filename:
file_name = content["filename"] file_name = content.filename
caption_content = TextMessageEventContent( caption_content = TextMessageEventContent(
msgtype=MessageType.TEXT, msgtype=MessageType.TEXT,
body=content.body, body=content.body,
) )
if ( if content.formatted_body and content.format == Format.HTML:
"formatted_body" in content caption_content.formatted_body = content.formatted_body
and str(content.get("format")) == Format.HTML.value caption_content.format = Format.HTML
):
caption_content["formatted_body"] = content["formatted_body"]
caption_content["format"] = Format.HTML
else: else:
caption_content = None caption_content = None
if caption_content: if caption_content:
@@ -2800,7 +2811,7 @@ class Portal(DBPortal, BasePortal):
intent = sender.intent_for(self) if sender else self.main_intent intent = sender.intent_for(self) if sender else self.main_intent
is_bot = sender.is_bot if sender else False is_bot = sender.is_bot if sender else False
converted = await self._msg_conv.convert( converted = await self._msg_conv.convert(
source, intent, is_bot, evt, no_reply_fallback=True source, intent, is_bot, self.is_channel, evt, no_reply_fallback=True
) )
converted.content.set_edit(editing_msg.mxid) converted.content.set_edit(editing_msg.mxid)
await intent.set_typing(self.mxid, timeout=0) await intent.set_typing(self.mxid, timeout=0)
@@ -3016,6 +3027,7 @@ class Portal(DBPortal, BasePortal):
source, source,
intent, intent,
is_bot, is_bot,
self.is_channel,
msg, msg,
client=client, client=client,
deterministic_reply_id=self.bridge.homeserver_software.is_hungry, deterministic_reply_id=self.bridge.homeserver_software.is_hungry,
@@ -3250,12 +3262,40 @@ class Portal(DBPortal, BasePortal):
recent_reactions = resp.reactions recent_reactions = resp.reactions
async with self.reaction_lock(dbm.mxid): async with self.reaction_lock(dbm.mxid):
await self._handle_telegram_reactions_locked( await self._handle_telegram_user_reactions_locked(
source, dbm, recent_reactions, total_count, timestamp=timestamp source, dbm, recent_reactions, total_count, timestamp=timestamp
) )
async def handle_telegram_bot_reactions(
self, source: au.AbstractUser, update: UpdateBotMessageReaction
) -> None:
tg_space = self.tgid if self.peer_type == "channel" else source.tgid
dbm = await DBMessage.get_one_by_tgid(TelegramID(update.msg_id), tg_space)
if dbm is None:
return
reactions: dict[TelegramID, list[WrappedReaction]] = {}
custom_emoji_ids: list[int] = []
if isinstance(update.actor, PeerUser):
user_id = TelegramID(update.actor.user_id)
elif isinstance(update.actor, PeerChannel):
user_id = TelegramID(update.actor.channel_id)
else:
return
for reaction in update.new_reactions:
reactions.setdefault(user_id, []).append(WrappedReaction(reaction=reaction, date=None))
async with self.reaction_lock(dbm.mxid):
await self._handle_telegram_parsed_reactions_locked(
source,
dbm,
reactions,
custom_emoji_ids,
is_full=True,
only_user_id=user_id,
timestamp=update.date,
)
@staticmethod @staticmethod
def _reactions_filter(lst: list[MessagePeerReaction], existing: DBReaction) -> bool: def _reactions_filter(lst: list[WrappedReaction], existing: DBReaction) -> bool:
if not lst: if not lst:
return False return False
for wrapped_reaction in lst: for wrapped_reaction in lst:
@@ -3278,7 +3318,7 @@ class Portal(DBPortal, BasePortal):
return await source.get_max_reactions(is_premium) return await source.get_max_reactions(is_premium)
return 3 if is_premium else 1 return 3 if is_premium else 1
async def _handle_telegram_reactions_locked( async def _handle_telegram_user_reactions_locked(
self, self,
source: au.AbstractUser, source: au.AbstractUser,
msg: DBMessage, msg: DBMessage,
@@ -3286,17 +3326,38 @@ class Portal(DBPortal, BasePortal):
total_count: int, total_count: int,
timestamp: datetime | None = None, timestamp: datetime | None = None,
) -> None: ) -> None:
reactions: dict[TelegramID, list[MessagePeerReaction]] = {} reactions: dict[TelegramID, list[WrappedReaction]] = {}
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)
): ):
sender_user_id = p.Puppet.get_id_from_peer(reaction.peer_id) sender_user_id = p.Puppet.get_id_from_peer(reaction.peer_id)
reactions.setdefault(sender_user_id, []).append(reaction) reactions.setdefault(sender_user_id, []).append(
WrappedReaction(reaction.reaction, reaction.date)
)
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
await self._handle_telegram_parsed_reactions_locked(
source,
msg,
reactions,
custom_emoji_ids,
is_full=is_full,
timestamp=timestamp,
)
async def _handle_telegram_parsed_reactions_locked(
self,
source: au.AbstractUser,
msg: DBMessage,
reactions: dict[TelegramID, list[WrappedReaction]],
custom_emoji_ids: list[int],
is_full: bool,
only_user_id: TelegramID | None = None,
timestamp: datetime | None = None,
) -> None:
custom_emojis = await util.transfer_custom_emojis_to_matrix(source, custom_emoji_ids) custom_emojis = await util.transfer_custom_emojis_to_matrix(source, custom_emoji_ids)
existing_reactions = await DBReaction.get_all_by_message(msg.mxid, msg.mx_room) existing_reactions = await DBReaction.get_all_by_message(msg.mxid, msg.mx_room)
@@ -3304,6 +3365,8 @@ class Portal(DBPortal, BasePortal):
removed: list[DBReaction] = [] removed: list[DBReaction] = []
for existing_reaction in existing_reactions: for existing_reaction in existing_reactions:
sender_id = existing_reaction.tg_sender sender_id = existing_reaction.tg_sender
if only_user_id is not None and sender_id != only_user_id:
continue
new_reactions = reactions.get(sender_id) new_reactions = reactions.get(sender_id)
if self._reactions_filter(new_reactions, existing_reaction): if self._reactions_filter(new_reactions, existing_reaction):
if new_reactions is not None and len(new_reactions) == 0: if new_reactions is not None and len(new_reactions) == 0:
@@ -3469,7 +3532,7 @@ class Portal(DBPortal, BasePortal):
else: else:
intent = self.main_intent intent = self.main_intent
is_bot = sender.is_bot if sender else False is_bot = sender.is_bot if sender else False
converted = await self._msg_conv.convert(source, intent, is_bot, evt) converted = await self._msg_conv.convert(source, intent, is_bot, self.is_channel, evt)
if not converted: if not converted:
return return
await intent.set_typing(self.mxid, timeout=0) await intent.set_typing(self.mxid, timeout=0)
@@ -3623,7 +3686,7 @@ class Portal(DBPortal, BasePortal):
end_reason = "disconnected" end_reason = "disconnected"
body = f"{call_type} {end_reason}" body = f"{call_type} {end_reason}"
if action.duration: if action.duration:
body += f" ({format_duration(action.duration)}" body += f" ({format_duration(action.duration)})"
await self._send_message( await self._send_message(
sender.intent_for(self), sender.intent_for(self),
TextMessageEventContent(msgtype=MessageType.NOTICE, body=body), TextMessageEventContent(msgtype=MessageType.NOTICE, body=body),
@@ -3651,6 +3714,18 @@ class Portal(DBPortal, BasePortal):
), ),
), ),
) )
elif isinstance(action, MessageActionBoostApply):
await self._send_message(
sender.intent_for(self),
TextMessageEventContent(
msgtype=MessageType.EMOTE,
body=(
"boosted the group"
if action.boosts == 1
else f"boosted the group {action.boosts} times"
),
),
)
elif isinstance(action, MessageActionGameScore): elif isinstance(action, MessageActionGameScore):
# TODO handle game score # TODO handle game score
pass pass
+122 -7
View File
@@ -53,6 +53,7 @@ from telethon.tl.types import (
MessageMediaWebPage, MessageMediaWebPage,
MessageReplyStoryHeader, MessageReplyStoryHeader,
PeerChannel, PeerChannel,
PeerChat,
PeerUser, PeerUser,
Photo, Photo,
PhotoCachedSize, PhotoCachedSize,
@@ -63,6 +64,8 @@ from telethon.tl.types import (
Poll, Poll,
TypeDocumentAttribute, TypeDocumentAttribute,
TypePhotoSize, TypePhotoSize,
UpdateShortChatMessage,
UpdateShortMessage,
WebPage, WebPage,
) )
from telethon.utils import decode_waveform from telethon.utils import decode_waveform
@@ -158,6 +161,7 @@ class TelegramMessageConverter:
source: au.AbstractUser, source: au.AbstractUser,
intent: IntentAPI, intent: IntentAPI,
is_bot: bool, is_bot: bool,
is_channel: bool,
evt: Message, evt: Message,
no_reply_fallback: bool = False, no_reply_fallback: bool = False,
deterministic_reply_id: bool = False, deterministic_reply_id: bool = False,
@@ -166,8 +170,13 @@ class TelegramMessageConverter:
if not client: if not client:
client = source.client client = source.client
if hasattr(evt, "media") and isinstance(evt.media, self._allowed_media): if hasattr(evt, "media") and isinstance(evt.media, self._allowed_media):
if self._should_convert_full_document(evt.media, is_bot, is_channel):
convert_media = self._media_converters[type(evt.media)] convert_media = self._media_converters[type(evt.media)]
converted = await convert_media(source=source, intent=intent, evt=evt, client=client) converted = await convert_media(
source=source, intent=intent, evt=evt, client=client
)
else:
converted = await self._convert_document_thumb_only(source, intent, evt, client)
elif evt.message: elif evt.message:
converted = await self._convert_text(source, intent, is_bot, evt, client) converted = await self._convert_text(source, intent, is_bot, evt, client)
else: else:
@@ -200,6 +209,16 @@ class TelegramMessageConverter:
) )
return converted return converted
def _should_convert_full_document(self, media, is_bot: bool, is_channel: bool) -> bool:
if not isinstance(media, MessageMediaDocument):
return True
size = media.document.size
if is_bot and self.config["bridge.document_as_link_size.bot"]:
return size < self.config["bridge.document_as_link_size.bot"] * 1000**2
if is_channel and self.config["bridge.document_as_link_size.channel"]:
return size < self.config["bridge.document_as_link_size.channel"] * 1000**2
return True
@staticmethod @staticmethod
def _caption_to_message(converted: ConvertedMessage) -> None: def _caption_to_message(converted: ConvertedMessage) -> None:
content, caption = converted.content, converted.caption content, caption = converted.content, converted.caption
@@ -263,7 +282,7 @@ class TelegramMessageConverter:
elif isinstance(evt.reply_to, MessageReplyStoryHeader): elif isinstance(evt.reply_to, MessageReplyStoryHeader):
return return
if evt.reply_to.quote and content.msgtype.is_text: if evt.reply_to.quote and content.msgtype and content.msgtype.is_text:
content.ensure_has_html() content.ensure_has_html()
quote_html = await formatter.telegram_text_to_matrix_html( quote_html = await formatter.telegram_text_to_matrix_html(
source, evt.reply_to.quote_text, evt.reply_to.quote_entities source, evt.reply_to.quote_text, evt.reply_to.quote_entities
@@ -278,7 +297,15 @@ class TelegramMessageConverter:
if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel) if isinstance(evt, Message) and isinstance(evt.peer_id, PeerChannel)
else source.tgid else source.tgid
) )
if evt.reply_to.reply_to_peer_id and evt.reply_to.reply_to_peer_id != evt.peer_id: if isinstance(evt, Message):
evt_peer_id = evt.peer_id
elif isinstance(evt, UpdateShortMessage):
evt_peer_id = PeerUser(evt.user_id)
elif isinstance(evt, UpdateShortChatMessage):
evt_peer_id = PeerChat(evt.chat_id)
else:
evt_peer_id = None
if evt.reply_to.reply_to_peer_id and evt.reply_to.reply_to_peer_id != evt_peer_id:
if not self.config["bridge.cross_room_replies"]: if not self.config["bridge.cross_room_replies"]:
return return
space = ( space = (
@@ -481,6 +508,91 @@ class TelegramMessageConverter:
# but we can only count it from read receipt. # but we can only count it from read receipt.
return ttl * 5 return ttl * 5
async def _convert_document_thumb_only(
self,
source: au.AbstractUser,
intent: IntentAPI,
evt: Message,
client: MautrixTelegramClient,
) -> ConvertedMessage | None:
document = evt.media.document
if not document:
return None
external_link_content = "Unsupported file, please access directly on Telegram"
external_url = self._get_external_url(evt)
# We don't generate external URLs for bot users so only set if known
if external_url is not None:
external_link_content = (
f"Unsupported file, please access directly on Telegram here: {external_url}"
)
attrs = _parse_document_attributes(document.attributes)
file = None
thumb_loc, thumb_size = self.get_largest_photo_size(document)
if thumb_size and not isinstance(thumb_size, (PhotoSize, PhotoCachedSize)):
self.log.debug(f"Unsupported thumbnail type {type(thumb_size)}")
thumb_loc = None
thumb_size = None
if thumb_loc:
try:
file = await util.transfer_thumbnail_to_matrix(
client,
intent,
thumb_loc,
video=None,
mime_type=document.mime_type,
encrypt=self.portal.encrypted,
async_upload=self.config["homeserver.async_media"],
)
except Exception:
self.log.exception("Failed to transfer thumbnail")
if not file:
name = attrs.name or ""
caption = f"\n{evt.message}" if evt.message else ""
return ConvertedMessage(
content=TextMessageEventContent(
msgtype=MessageType.NOTICE,
body=f"{name}{caption}\n{external_link_content}",
)
)
info, name = _parse_document_meta(evt, file, attrs, thumb_size)
event_type = EventType.ROOM_MESSAGE
if not name:
ext = sane_mimetypes.guess_extension(file.mime_type) or ""
name = "unnamed_file" + ext
content = MediaMessageEventContent(
body=name,
info=info,
msgtype={
"video/": MessageType.VIDEO,
"audio/": MessageType.AUDIO,
"image/": MessageType.IMAGE,
}.get(info.mimetype[:6], MessageType.FILE),
)
if file.decryption_info:
content.file = file.decryption_info
else:
content.url = file.mxc
caption_content = (
await formatter.telegram_to_matrix(evt, source, client) if evt.message else None
)
caption_content = f"{caption_content}\n{external_link_content}"
return ConvertedMessage(
type=event_type,
content=content,
caption=caption_content,
disappear_seconds=self._adjust_ttl(evt.media.ttl_seconds),
)
async def _convert_document( async def _convert_document(
self, self,
source: au.AbstractUser, source: au.AbstractUser,
@@ -490,6 +602,9 @@ class TelegramMessageConverter:
) -> ConvertedMessage | None: ) -> ConvertedMessage | None:
document = evt.media.document document = evt.media.document
if not document:
return None
attrs = _parse_document_attributes(document.attributes) attrs = _parse_document_attributes(document.attributes)
if document.size > self.matrix.media_config.upload_size: if document.size > self.matrix.media_config.upload_size:
@@ -648,18 +763,18 @@ class TelegramMessageConverter:
_n += 1 _n += 1
return _n return _n
text_answers = "\n".join(f"{n()}. {answer.text}" for answer in poll.answers) text_answers = "\n".join(f"{n()}. {answer.text.text}" for answer in poll.answers)
html_answers = "\n".join(f"<li>{answer.text}</li>" for answer in poll.answers) html_answers = "\n".join(f"<li>{answer.text.text}</li>" for answer in poll.answers)
vote_command = f"{self.command_prefix} vote {poll_id}" vote_command = f"{self.command_prefix} vote {poll_id}"
content = TextMessageEventContent( content = TextMessageEventContent(
msgtype=MessageType.TEXT, msgtype=MessageType.TEXT,
format=Format.HTML, format=Format.HTML,
body=( body=(
f"Poll: {poll.question}\n{text_answers}\n" f"Poll: {poll.question.text}\n{text_answers}\n"
f"Vote with {vote_command} <choice number>" f"Vote with {vote_command} <choice number>"
), ),
formatted_body=( formatted_body=(
f"<strong>Poll</strong>: {poll.question}<br/>\n" f"<strong>Poll</strong>: {poll.question.text}<br/>\n"
f"<ol>{html_answers}</ol>\n" f"<ol>{html_answers}</ol>\n"
f"Vote with <code>{vote_command} &lt;choice number&gt;</code>" f"Vote with <code>{vote_command} &lt;choice number&gt;</code>"
), ),
+3 -1
View File
@@ -83,9 +83,11 @@ def get_base_power_levels(
levels.users_default = overrides.get("users_default", 0) levels.users_default = overrides.get("users_default", 0)
levels.events_default = overrides.get( levels.events_default = overrides.get(
"events_default", "events_default",
(
50 50
if portal.peer_type == "channel" and not portal.megagroup or dbr.send_messages if portal.peer_type == "channel" and not portal.megagroup or dbr.send_messages
else 0, else 0
),
) )
for evt_type, value in overrides.get("events", {}).items(): for evt_type, value in overrides.get("events", {}).items():
levels.events[EventType.find(evt_type)] = value levels.events[EventType.find(evt_type)] = value
@@ -18,7 +18,7 @@ from __future__ import annotations
import base64 import base64
import html import html
from telethon.tl.functions.channels import GetSponsoredMessagesRequest from telethon.tl.functions.messages import GetSponsoredMessagesRequest
from telethon.tl.types import Channel, InputChannel, PeerChannel, PeerUser, SponsoredMessage, User from telethon.tl.types import Channel, InputChannel, PeerChannel, PeerUser, SponsoredMessage, User
from telethon.tl.types.messages import SponsoredMessages, SponsoredMessagesEmpty from telethon.tl.types.messages import SponsoredMessages, SponsoredMessagesEmpty
+3 -1
View File
@@ -298,9 +298,11 @@ class User(DBUser, AbstractUser, BaseUser):
self._track_metric(METRIC_CONNECTED, connected) self._track_metric(METRIC_CONNECTED, connected)
if connected: if connected:
await self.push_bridge_state( await self.push_bridge_state(
(
BridgeStateEvent.BACKFILLING BridgeStateEvent.BACKFILLING
if self._is_backfilling if self._is_backfilling
else BridgeStateEvent.CONNECTED, else BridgeStateEvent.CONNECTED
),
info=self._bridge_state_info, info=self._bridge_state_info,
) )
else: else:
+1
View File
@@ -4,6 +4,7 @@ from .file_transfer import (
convert_image, convert_image,
transfer_custom_emojis_to_matrix, transfer_custom_emojis_to_matrix,
transfer_file_to_matrix, transfer_file_to_matrix,
transfer_thumbnail_to_matrix,
unicode_custom_emoji_map, unicode_custom_emoji_map,
) )
from .parallel_file_transfer import parallel_transfer_to_telegram from .parallel_file_transfer import parallel_transfer_to_telegram
@@ -130,9 +130,9 @@ class ProvisioningAPI(AuthAPI):
"about": portal.about, "about": portal.about,
"username": portal.username, "username": portal.username,
"megagroup": portal.megagroup, "megagroup": portal.megagroup,
"can_unbridge": (await portal.can_user_perform(user, "unbridge")) "can_unbridge": (
if user (await portal.can_user_perform(user, "unbridge")) if user else False
else False, ),
} }
) )
@@ -188,9 +188,11 @@ class ProvisioningAPI(AuthAPI):
if force in ("delete", "unbridge"): if force in ("delete", "unbridge"):
delete = force == "delete" delete = force == "delete"
await portal.cleanup_portal( await portal.cleanup_portal(
(
"Portal deleted (moving to another room)" "Portal deleted (moving to another room)"
if delete if delete
else "Room unbridged (portal moving to another room)", else "Room unbridged (portal moving to another room)"
),
puppets_only=not delete, puppets_only=not delete,
) )
else: else:
+7 -6
View File
@@ -2,27 +2,28 @@
# Uncommented lines after the group definition insert things into that group. # Uncommented lines after the group definition insert things into that group.
#/speedups #/speedups
cryptg>=0.1,<0.5 cryptg>=0.1,<0.6
aiodns aiodns
brotli brotli
#/qr_login #/qr_login
pillow>=10.0.1,<11 pillow>=10.0.1,<12
qrcode>=6,<8 qrcode>=6,<9
#/formattednumbers #/formattednumbers
phonenumbers>=8,<9 phonenumbers>=8,<10
#/metrics #/metrics
prometheus_client>=0.6,<0.20 prometheus_client>=0.6,<0.23
#/e2be #/e2be
python-olm>=3,<4 python-olm>=3,<4
pycryptodome>=3,<4 pycryptodome>=3,<4
unpaddedbase64>=1,<3 unpaddedbase64>=1,<3
base58>=2,<3
#/sqlite #/sqlite
aiosqlite>=0.16,<0.20 aiosqlite>=0.16,<0.22
#/proxy #/proxy
python-socks[asyncio] python-socks[asyncio]
+1 -1
View File
@@ -9,4 +9,4 @@ line_length = 99
[tool.black] [tool.black]
line-length = 99 line-length = 99
target-version = ["py38"] target-version = ["py310"]
+4 -4
View File
@@ -3,8 +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.20.3,<0.21 mautrix>=0.21.0b5,<0.22
tulir-telethon==1.34.0a2 tulir-telethon==1.99.0a6
asyncpg>=0.20,<0.30 asyncpg>=0.20,<1
mako>=1,<2 mako>=1,<2
setuptools setuptools<82
+2 -2
View File
@@ -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.9", python_requires="~=3.10",
classifiers=[ classifiers=[
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
@@ -60,10 +60,10 @@ setuptools.setup(
"Framework :: AsyncIO", "Framework :: AsyncIO",
"Programming Language :: Python", "Programming Language :: Python",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
], ],
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",